Detect external changes with deep context (#1888)

<!-- CURSOR_SUMMARY -->
> [!NOTE]
> Adds commit-aware deep context by computing hasExternalChanges (via
latest assistant commit vs current repo + dirty check) and propagating
commitHash through messages/provider options.
> 
> - **Deep Smart Context**:
> - Add `hasExternalChanges` to `VersionedFiles`; compute by comparing
latest assistant `commitHash` with `getCurrentCommitHash` and checking
`isGitStatusClean`.
> - Make `sourceCommitHash` nullable; add `commitHash` in
`DyadEngineProviderOptions` and use it when scanning history.
> - **Chat Handling**:
> - Include `commitHash` in `messageHistory` and pass through
`providerOptions['dyad-engine']`.
> - **Git Utilities**:
> - New `isGitStatusClean(path)` supporting native git and
isomorphic-git.
> - **Tests/Snapshots**:
> - Mock `getCurrentCommitHash` and `isGitStatusClean`; update snapshot
to include `hasExternalChanges`.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
ad92d9dd5ead941de822e8da59c8819e4db8b775. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->







<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Detects external code changes in deep context by comparing the latest
assistant commit with the current repo state. Exposes a
hasExternalChanges flag so the engine can adapt responses when the
workspace diverges.

- **New Features**
  - Added hasExternalChanges to VersionedFiles.
- Computes by comparing the latest assistant commitHash with
getCurrentCommitHash and checking isGitStatusClean.
- Passes commitHash through chat messages and dyad-engine
providerOptions; sourceCommitHash is now nullable.
  - Defaults to true if detection fails (with a warning).

<sup>Written for commit 6ebb0b125c9a3421b4e5673870b204c9cb279265.
Summary will update automatically on new commits.</sup>

<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Will Chen
2025-12-04 15:40:58 -08:00
committed by GitHub
parent 3701886505
commit 538745d546
5 changed files with 213 additions and 3 deletions

View File

@@ -525,7 +525,8 @@
"src/pages/Index.tsx": "63e4529ce654d0667a5204e3ec99ea29aac4ad8967682eb9158506beda92b4eb" "src/pages/Index.tsx": "63e4529ce654d0667a5204e3ec99ea29aac4ad8967682eb9158506beda92b4eb"
}, },
"7": {} "7": {}
} },
"hasExternalChanges": false
}, },
"enable_lazy_edits": true, "enable_lazy_edits": true,
"enable_smart_files_context": true, "enable_smart_files_context": true,

View File

@@ -10,6 +10,8 @@ import crypto from "node:crypto";
// Mock git_utils // Mock git_utils
vi.mock("@/ipc/utils/git_utils", () => ({ vi.mock("@/ipc/utils/git_utils", () => ({
getFileAtCommit: vi.fn(), getFileAtCommit: vi.fn(),
getCurrentCommitHash: vi.fn().mockResolvedValue("mock-current-commit-hash"),
isGitStatusClean: vi.fn().mockResolvedValue(true),
})); }));
// Mock electron-log // 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 });
});
});
}); });

View File

@@ -553,6 +553,7 @@ ${componentSnippet}
role: message.role as "user" | "assistant" | "system", role: message.role as "user" | "assistant" | "system",
content: message.content, content: message.content,
sourceCommitHash: message.sourceCommitHash, sourceCommitHash: message.sourceCommitHash,
commitHash: message.commitHash,
})); }));
// For Dyad Pro + Deep Context, we set to 200 chat turns (+1) // 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: { providerOptions: {
"dyad-engine": { "dyad-engine": {
sourceCommitHash: msg.sourceCommitHash, sourceCommitHash: msg.sourceCommitHash,
commitHash: msg.commitHash,
}, },
}, },
})); }));

View File

@@ -39,6 +39,23 @@ export async function getCurrentCommitHash({
}); });
} }
export async function isGitStatusClean({
path,
}: {
path: string;
}): Promise<boolean> {
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({ export async function gitCommit({
path, path,
message, message,

View File

@@ -2,7 +2,11 @@ import { CodebaseFile, CodebaseFileReference } from "@/utils/codebase";
import { ModelMessage } from "@ai-sdk/provider-utils"; import { ModelMessage } from "@ai-sdk/provider-utils";
import crypto from "node:crypto"; import crypto from "node:crypto";
import log from "electron-log"; import log from "electron-log";
import { getFileAtCommit } from "./git_utils"; import {
getCurrentCommitHash,
getFileAtCommit,
isGitStatusClean,
} from "./git_utils";
import { normalizePath } from "../../../shared/normalizePath"; import { normalizePath } from "../../../shared/normalizePath";
const logger = log.scope("versioned_codebase_context"); const logger = log.scope("versioned_codebase_context");
@@ -11,10 +15,13 @@ export interface VersionedFiles {
fileIdToContent: Record<string, string>; fileIdToContent: Record<string, string>;
fileReferences: CodebaseFileReference[]; fileReferences: CodebaseFileReference[];
messageIndexToFilePathToFileId: Record<number, Record<string, string>>; messageIndexToFilePathToFileId: Record<number, Record<string, string>>;
/** True if there are changes outside of files from the latest chat message (different commit or dirty git status) */
hasExternalChanges: boolean;
} }
interface DyadEngineProviderOptions { 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 { return {
fileIdToContent, fileIdToContent,
fileReferences, fileReferences,
messageIndexToFilePathToFileId, messageIndexToFilePathToFileId,
hasExternalChanges,
}; };
} }