diff --git a/e2e-tests/snapshots/smart_context_deep.spec.ts_smart-context-deep---read-write-read-1.txt b/e2e-tests/snapshots/smart_context_deep.spec.ts_smart-context-deep---read-write-read-1.txt index cdfa01b..bcac720 100644 --- a/e2e-tests/snapshots/smart_context_deep.spec.ts_smart-context-deep---read-write-read-1.txt +++ b/e2e-tests/snapshots/smart_context_deep.spec.ts_smart-context-deep---read-write-read-1.txt @@ -525,7 +525,8 @@ "src/pages/Index.tsx": "63e4529ce654d0667a5204e3ec99ea29aac4ad8967682eb9158506beda92b4eb" }, "7": {} - } + }, + "hasExternalChanges": false }, "enable_lazy_edits": true, "enable_smart_files_context": true, diff --git a/src/__tests__/versioned_codebase_context.test.ts b/src/__tests__/versioned_codebase_context.test.ts index c02084f..d668f1c 100644 --- a/src/__tests__/versioned_codebase_context.test.ts +++ b/src/__tests__/versioned_codebase_context.test.ts @@ -10,6 +10,8 @@ import crypto from "node:crypto"; // Mock git_utils vi.mock("@/ipc/utils/git_utils", () => ({ getFileAtCommit: vi.fn(), + getCurrentCommitHash: vi.fn().mockResolvedValue("mock-current-commit-hash"), + isGitStatusClean: vi.fn().mockResolvedValue(true), })); // Mock electron-log @@ -973,4 +975,147 @@ src/file2.ts }); }); }); + + describe("hasExternalChanges", () => { + it("should default to true when no assistant message has commitHash", async () => { + const { getCurrentCommitHash, isGitStatusClean } = await import( + "@/ipc/utils/git_utils" + ); + const mockGetCurrentCommitHash = vi.mocked(getCurrentCommitHash); + const mockIsGitStatusClean = vi.mocked(isGitStatusClean); + + const files: CodebaseFile[] = []; + const chatMessages: ModelMessage[] = [ + { + role: "assistant", + content: "No commit hash here", + providerOptions: { + "dyad-engine": { + sourceCommitHash: "abc123", + commitHash: null, + }, + }, + }, + ]; + const appPath = "/test/app"; + + const result = await processChatMessagesWithVersionedFiles({ + files, + chatMessages, + appPath, + }); + + expect(result.hasExternalChanges).toBe(true); + expect(mockGetCurrentCommitHash).not.toHaveBeenCalled(); + expect(mockIsGitStatusClean).not.toHaveBeenCalled(); + }); + + it("should be false when latest assistant commit matches current and git status is clean", async () => { + const { getCurrentCommitHash, isGitStatusClean } = await import( + "@/ipc/utils/git_utils" + ); + const mockGetCurrentCommitHash = vi.mocked(getCurrentCommitHash); + const mockIsGitStatusClean = vi.mocked(isGitStatusClean); + + mockGetCurrentCommitHash.mockResolvedValue("commit-123"); + mockIsGitStatusClean.mockResolvedValue(true); + + const files: CodebaseFile[] = []; + const chatMessages: ModelMessage[] = [ + { + role: "assistant", + content: "Assistant message with commit hash", + providerOptions: { + "dyad-engine": { + sourceCommitHash: "ignored-for-this-test", + commitHash: "commit-123", + }, + }, + }, + ]; + const appPath = "/test/app"; + + const result = await processChatMessagesWithVersionedFiles({ + files, + chatMessages, + appPath, + }); + + expect(result.hasExternalChanges).toBe(false); + expect(mockGetCurrentCommitHash).toHaveBeenCalledWith({ path: appPath }); + expect(mockIsGitStatusClean).toHaveBeenCalledWith({ path: appPath }); + }); + + it("should be true when latest assistant commit differs from current", async () => { + const { getCurrentCommitHash, isGitStatusClean } = await import( + "@/ipc/utils/git_utils" + ); + const mockGetCurrentCommitHash = vi.mocked(getCurrentCommitHash); + const mockIsGitStatusClean = vi.mocked(isGitStatusClean); + + mockGetCurrentCommitHash.mockResolvedValue("current-commit"); + mockIsGitStatusClean.mockResolvedValue(true); + + const files: CodebaseFile[] = []; + const chatMessages: ModelMessage[] = [ + { + role: "assistant", + content: "Assistant message with different commit hash", + providerOptions: { + "dyad-engine": { + sourceCommitHash: "ignored-for-this-test", + commitHash: "older-commit", + }, + }, + }, + ]; + const appPath = "/test/app"; + + const result = await processChatMessagesWithVersionedFiles({ + files, + chatMessages, + appPath, + }); + + expect(result.hasExternalChanges).toBe(true); + expect(mockGetCurrentCommitHash).toHaveBeenCalledWith({ path: appPath }); + expect(mockIsGitStatusClean).toHaveBeenCalledWith({ path: appPath }); + }); + + it("should be true when git status is dirty even if commits match", async () => { + const { getCurrentCommitHash, isGitStatusClean } = await import( + "@/ipc/utils/git_utils" + ); + const mockGetCurrentCommitHash = vi.mocked(getCurrentCommitHash); + const mockIsGitStatusClean = vi.mocked(isGitStatusClean); + + mockGetCurrentCommitHash.mockResolvedValue("same-commit"); + mockIsGitStatusClean.mockResolvedValue(false); + + const files: CodebaseFile[] = []; + const chatMessages: ModelMessage[] = [ + { + role: "assistant", + content: "Assistant message with matching commit but dirty status", + providerOptions: { + "dyad-engine": { + sourceCommitHash: "ignored-for-this-test", + commitHash: "same-commit", + }, + }, + }, + ]; + const appPath = "/test/app"; + + const result = await processChatMessagesWithVersionedFiles({ + files, + chatMessages, + appPath, + }); + + expect(result.hasExternalChanges).toBe(true); + expect(mockGetCurrentCommitHash).toHaveBeenCalledWith({ path: appPath }); + expect(mockIsGitStatusClean).toHaveBeenCalledWith({ path: appPath }); + }); + }); }); diff --git a/src/ipc/handlers/chat_stream_handlers.ts b/src/ipc/handlers/chat_stream_handlers.ts index 384eb83..90845a1 100644 --- a/src/ipc/handlers/chat_stream_handlers.ts +++ b/src/ipc/handlers/chat_stream_handlers.ts @@ -553,6 +553,7 @@ ${componentSnippet} role: message.role as "user" | "assistant" | "system", content: message.content, sourceCommitHash: message.sourceCommitHash, + commitHash: message.commitHash, })); // For Dyad Pro + Deep Context, we set to 200 chat turns (+1) @@ -752,6 +753,7 @@ This conversation includes one or more image attachments. When the user uploads providerOptions: { "dyad-engine": { sourceCommitHash: msg.sourceCommitHash, + commitHash: msg.commitHash, }, }, })); diff --git a/src/ipc/utils/git_utils.ts b/src/ipc/utils/git_utils.ts index 6a4f73e..729b038 100644 --- a/src/ipc/utils/git_utils.ts +++ b/src/ipc/utils/git_utils.ts @@ -39,6 +39,23 @@ export async function getCurrentCommitHash({ }); } +export async function isGitStatusClean({ + path, +}: { + path: string; +}): Promise { + const settings = readSettings(); + if (settings.enableNativeGit) { + const { stdout } = await execAsync(`git -C "${path}" status --porcelain`); + return stdout.trim() === ""; + } else { + const statusMatrix = await git.statusMatrix({ fs, dir: path }); + return statusMatrix.every( + (row) => row[1] === 1 && row[2] === 1 && row[3] === 1, + ); + } +} + export async function gitCommit({ path, message, diff --git a/src/ipc/utils/versioned_codebase_context.ts b/src/ipc/utils/versioned_codebase_context.ts index 8db3ed2..339648b 100644 --- a/src/ipc/utils/versioned_codebase_context.ts +++ b/src/ipc/utils/versioned_codebase_context.ts @@ -2,7 +2,11 @@ import { CodebaseFile, CodebaseFileReference } from "@/utils/codebase"; import { ModelMessage } from "@ai-sdk/provider-utils"; import crypto from "node:crypto"; import log from "electron-log"; -import { getFileAtCommit } from "./git_utils"; +import { + getCurrentCommitHash, + getFileAtCommit, + isGitStatusClean, +} from "./git_utils"; import { normalizePath } from "../../../shared/normalizePath"; const logger = log.scope("versioned_codebase_context"); @@ -11,10 +15,13 @@ export interface VersionedFiles { fileIdToContent: Record; fileReferences: CodebaseFileReference[]; messageIndexToFilePathToFileId: Record>; + /** True if there are changes outside of files from the latest chat message (different commit or dirty git status) */ + hasExternalChanges: boolean; } interface DyadEngineProviderOptions { - sourceCommitHash: string; + sourceCommitHash: string | null; + commitHash: string | null; } /** @@ -211,9 +218,47 @@ export async function processChatMessagesWithVersionedFiles({ } } + // Determine hasExternalChanges: + // Find the latest assistant message's commitHash + let latestCommitHash: string | undefined; + for (let i = chatMessages.length - 1; i >= 0; i--) { + const message = chatMessages[i]; + if (message.role === "assistant") { + const engineOptions = message.providerOptions?.[ + "dyad-engine" + ] as unknown as DyadEngineProviderOptions; + if (engineOptions?.commitHash) { + latestCommitHash = engineOptions.commitHash; + break; + } + } + } + + let hasExternalChanges = true; // Default to true if we can't determine + + if (latestCommitHash) { + try { + // Get current commit hash + const currentCommitHash = await getCurrentCommitHash({ path: appPath }); + + // Check if git status is clean + const isClean = await isGitStatusClean({ path: appPath }); + + // hasExternalChanges is false only if commits match AND status is clean + hasExternalChanges = !(latestCommitHash === currentCommitHash && isClean); + logger.info( + `detected hasExternalChanges: ${hasExternalChanges} because latestCommitHash: ${latestCommitHash} and currentCommitHash: ${currentCommitHash} and isClean: ${isClean}`, + ); + } catch (error) { + logger.warn("Failed to determine hasExternalChanges:", error); + // Keep default of true + } + } + return { fileIdToContent, fileReferences, messageIndexToFilePathToFileId, + hasExternalChanges, }; }