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:
@@ -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,
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user