Skip to content

Commit 084c84e

Browse files
committed
feat: enhance file history management and add user prompt checkpointing
1 parent 98c503c commit 084c84e

3 files changed

Lines changed: 241 additions & 8 deletions

File tree

src/common/file-history.ts

Lines changed: 74 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,12 @@ const MANIFEST_PATH = ".deepcode-file-history.json";
99

1010
type FileHistoryEntry = {
1111
path: string;
12-
blob: string;
12+
blob: string | null;
1313
mode: "100644";
1414
};
1515

1616
type FileHistoryManifest = {
17-
version: 1;
17+
version: 1 | 2;
1818
files: Record<string, FileHistoryEntry>;
1919
};
2020

@@ -85,7 +85,11 @@ export class GitFileHistory {
8585
for (const filePath of absolutePaths) {
8686
const key = this.getFileKey(filePath);
8787
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
88-
delete manifest.files[key];
88+
manifest.files[key] = {
89+
path: filePath,
90+
blob: null,
91+
mode: "100644",
92+
};
8993
continue;
9094
}
9195

@@ -110,6 +114,24 @@ export class GitFileHistory {
110114
}
111115
}
112116

117+
recordTrackedFilesCheckpoint(sessionId: string, message: string): string | undefined {
118+
const currentHash = this.ensureSession(sessionId);
119+
if (!currentHash) {
120+
return undefined;
121+
}
122+
123+
try {
124+
const manifest = this.readManifest(currentHash);
125+
const trackedPaths = Object.values(manifest.files).map((entry) => entry.path);
126+
if (trackedPaths.length === 0) {
127+
return currentHash;
128+
}
129+
return this.recordCheckpoint(sessionId, trackedPaths, message);
130+
} catch {
131+
return undefined;
132+
}
133+
}
134+
113135
canRestore(sessionId: string, checkpointHash: string): boolean {
114136
if (!isCommitHash(checkpointHash)) {
115137
return false;
@@ -146,18 +168,49 @@ export class GitFileHistory {
146168

147169
for (const [key, entry] of Object.entries(currentManifest.files)) {
148170
if (!targetManifest.files[key]) {
149-
removeTrackedFile(entry.path);
171+
this.restoreFirstKnownEntry(currentHash, key, entry.path);
150172
}
151173
}
152174

153175
for (const entry of Object.values(targetManifest.files)) {
176+
if (!entry.blob) {
177+
removeTrackedFile(entry.path);
178+
continue;
179+
}
154180
fs.mkdirSync(path.dirname(entry.path), { recursive: true });
155181
fs.writeFileSync(entry.path, this.readBlob(entry.blob));
156182
}
157183

158184
this.runGit(["update-ref", branchRef, checkpointHash]);
159185
}
160186

187+
private restoreFirstKnownEntry(currentHash: string | undefined, key: string, fallbackPath: string): void {
188+
const firstEntry = currentHash ? this.findFirstKnownEntry(currentHash, key) : undefined;
189+
const entry = firstEntry ?? { path: fallbackPath, blob: null, mode: "100644" as const };
190+
if (!entry.blob) {
191+
removeTrackedFile(entry.path);
192+
return;
193+
}
194+
195+
fs.mkdirSync(path.dirname(entry.path), { recursive: true });
196+
fs.writeFileSync(entry.path, this.readBlob(entry.blob));
197+
}
198+
199+
private findFirstKnownEntry(currentHash: string, key: string): FileHistoryEntry | undefined {
200+
const commitHashes = this.runGit(["rev-list", "--reverse", currentHash])
201+
.split(/\r?\n/)
202+
.map((line) => line.trim())
203+
.filter(isCommitHash);
204+
205+
for (const commitHash of commitHashes) {
206+
const entry = this.readManifest(commitHash).files[key];
207+
if (entry) {
208+
return entry;
209+
}
210+
}
211+
return undefined;
212+
}
213+
161214
private getSessionBranchRef(sessionId: string): string | null {
162215
if (!/^[A-Za-z0-9._-]+$/.test(sessionId)) {
163216
return null;
@@ -182,6 +235,9 @@ export class GitFileHistory {
182235
const entries: string[] = [`100644 blob ${manifestBlob}\t${MANIFEST_PATH}\0`];
183236

184237
for (const [key, entry] of Object.entries(normalizedManifest.files)) {
238+
if (!entry.blob) {
239+
continue;
240+
}
185241
entries.push(`${entry.mode} blob ${entry.blob}\t${key}\0`);
186242
}
187243

@@ -191,7 +247,12 @@ export class GitFileHistory {
191247
private readManifest(commitHash: string): FileHistoryManifest {
192248
const buffer = this.runGitBuffer(["cat-file", "blob", `${commitHash}:${MANIFEST_PATH}`]);
193249
const parsed = JSON.parse(buffer.toString("utf8")) as FileHistoryManifest;
194-
if (!parsed || parsed.version !== 1 || !parsed.files || typeof parsed.files !== "object") {
250+
if (
251+
!parsed ||
252+
(parsed.version !== 1 && parsed.version !== 2) ||
253+
!parsed.files ||
254+
typeof parsed.files !== "object"
255+
) {
195256
throw new Error("Invalid file history manifest.");
196257
}
197258
return normalizeManifest(parsed);
@@ -256,13 +317,18 @@ export class GitFileHistory {
256317
}
257318

258319
function emptyManifest(): FileHistoryManifest {
259-
return { version: 1, files: {} };
320+
return { version: 2, files: {} };
260321
}
261322

262323
function normalizeManifest(manifest: FileHistoryManifest): FileHistoryManifest {
263324
const files: Record<string, FileHistoryEntry> = {};
264325
for (const [key, entry] of Object.entries(manifest.files).sort(([left], [right]) => left.localeCompare(right))) {
265-
if (!isValidStoredPath(key) || !entry || entry.mode !== "100644" || !isCommitHash(entry.blob)) {
326+
if (
327+
!isValidStoredPath(key) ||
328+
!entry ||
329+
entry.mode !== "100644" ||
330+
(entry.blob !== null && !isCommitHash(entry.blob))
331+
) {
266332
throw new Error("Invalid file history manifest.");
267333
}
268334
files[key] = {
@@ -271,7 +337,7 @@ function normalizeManifest(manifest: FileHistoryManifest): FileHistoryManifest {
271337
mode: "100644",
272338
};
273339
}
274-
return { version: 1, files };
340+
return { version: 2, files };
275341
}
276342

277343
function uniqueAbsolutePaths(filePaths: string[]): string[] {

src/session.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1013,6 +1013,7 @@ The candidate skills are as follows:\n\n`;
10131013
this.appendSessionMessage(sessionId, instructionsMessage);
10141014
}
10151015

1016+
this.recordUserPromptCheckpoint(sessionId);
10161017
const userMessage = this.buildUserMessage(sessionId, userPrompt);
10171018
this.appendSessionMessage(sessionId, userMessage);
10181019

@@ -1087,6 +1088,7 @@ ${skillMd}
10871088
this.reportNewPrompt();
10881089

10891090
this.ensureFileHistorySession(sessionId);
1091+
this.recordUserPromptCheckpoint(sessionId);
10901092
const userMessage = this.buildUserMessage(sessionId, userPrompt);
10911093
this.appendSessionMessage(sessionId, userMessage);
10921094

@@ -1752,6 +1754,10 @@ ${skillMd}
17521754
return this.getFileHistory().getCurrentCheckpointHash(sessionId);
17531755
}
17541756

1757+
private recordUserPromptCheckpoint(sessionId: string): string | undefined {
1758+
return this.getFileHistory().recordTrackedFilesCheckpoint(sessionId, "User prompt checkpoint");
1759+
}
1760+
17551761
private prepareFileMutationCheckpoint(sessionId: string, filePath: string): void {
17561762
const fileHistory = this.getFileHistory();
17571763
const previousHash = fileHistory.ensureSession(sessionId);

src/tests/session.test.ts

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -999,6 +999,68 @@ test("createSession initializes file-history repo and session branch", async (t)
999999
);
10001000
});
10011001

1002+
test("createSession initializes an empty file-history manifest without scanning existing files", async (t) => {
1003+
if (!hasGit()) {
1004+
t.skip("git is not available");
1005+
return;
1006+
}
1007+
1008+
const workspace = createTempDir("deepcode-file-history-empty-init-workspace-");
1009+
const home = createTempDir("deepcode-file-history-empty-init-home-");
1010+
setHomeDir(home);
1011+
fs.writeFileSync(path.join(workspace, "unrelated.txt"), "keep me\n", "utf8");
1012+
fs.mkdirSync(path.join(workspace, "nested"));
1013+
fs.writeFileSync(path.join(workspace, "nested", "another.txt"), "also keep me\n", "utf8");
1014+
1015+
const manager = createSessionManager(workspace, "machine-id-file-history-empty-init");
1016+
(manager as any).activateSession = async () => {};
1017+
1018+
const sessionId = await manager.createSession({ text: "first prompt" });
1019+
const userMessage = manager.listSessionMessages(sessionId).find((message) => message.role === "user");
1020+
assert.ok(userMessage?.checkpointHash);
1021+
1022+
const manifest = readFileHistoryManifest(home, workspace, userMessage.checkpointHash);
1023+
assert.deepEqual(manifest.files, {});
1024+
});
1025+
1026+
test("replySession snapshots manual edits to tracked files before appending the user prompt", async (t) => {
1027+
if (!hasGit()) {
1028+
t.skip("git is not available");
1029+
return;
1030+
}
1031+
1032+
const workspace = createTempDir("deepcode-prompt-checkpoint-manual-edit-workspace-");
1033+
const home = createTempDir("deepcode-prompt-checkpoint-manual-edit-home-");
1034+
setHomeDir(home);
1035+
1036+
const filePath = path.join(workspace, "hello_world.py");
1037+
const manager = createSessionManager(workspace, "machine-id-prompt-checkpoint-manual-edit");
1038+
(manager as any).activateSession = async () => {};
1039+
1040+
const sessionId = await manager.createSession({ text: "create hello world" });
1041+
const gitDir = getFileHistoryGitDir(home, workspace);
1042+
const fileHistory = new GitFileHistory(workspace, gitDir);
1043+
1044+
fs.writeFileSync(filePath, 'print("Hello, World!")\n', "utf8");
1045+
assert.ok(fileHistory.recordCheckpoint(sessionId, [filePath], "created hello world"));
1046+
1047+
const manualEdit = 'if name == main:\n print("Hello, World!")\n';
1048+
fs.writeFileSync(filePath, manualEdit, "utf8");
1049+
await manager.replySession(sessionId, { text: "I manually edited @hello_world.py, note it" });
1050+
const manualEditUserMessage = manager
1051+
.listSessionMessages(sessionId)
1052+
.filter((message) => message.role === "user")
1053+
.at(-1);
1054+
assert.ok(manualEditUserMessage?.checkpointHash);
1055+
1056+
fs.writeFileSync(filePath, 'if __name__ == "__main__":\n print("Hello, World!")\n', "utf8");
1057+
assert.ok(fileHistory.recordCheckpoint(sessionId, [filePath], "fixed hello world"));
1058+
1059+
manager.restoreSessionCode(sessionId, manualEditUserMessage.id);
1060+
1061+
assert.equal(fs.readFileSync(filePath, "utf8"), manualEdit);
1062+
});
1063+
10021064
test("Write tool advances file-history while preserving the user prompt checkpoint", async (t) => {
10031065
if (!hasGit()) {
10041066
t.skip("git is not available");
@@ -1191,6 +1253,8 @@ test("restoreSessionCode restores project files from the recorded Git checkpoint
11911253
const manager = createSessionManager(workspace, "machine-id-undo-code");
11921254
const sessionId = "session-code-restore";
11931255
const checkpointHash = createFileHistoryCommit(home, workspace, sessionId, { "tracked.txt": "before\n" });
1256+
const fileHistory = new GitFileHistory(workspace, getFileHistoryGitDir(home, workspace));
1257+
assert.ok(fileHistory.recordCheckpoint(sessionId, [path.join(workspace, "new.txt")], "pre-create new.txt"));
11941258
createFileHistoryCommit(home, workspace, sessionId, { "tracked.txt": "after\n", "new.txt": "remove me\n" });
11951259
fs.writeFileSync(path.join(workspace, "tracked.txt"), "after\n", "utf8");
11961260
fs.writeFileSync(path.join(workspace, "new.txt"), "remove me\n", "utf8");
@@ -1206,6 +1270,91 @@ test("restoreSessionCode restores project files from the recorded Git checkpoint
12061270
assert.equal(fs.existsSync(path.join(workspace, "new.txt")), false);
12071271
});
12081272

1273+
test("restoreSessionCode preserves files that predate their first tracked mutation", async (t) => {
1274+
if (!hasGit()) {
1275+
t.skip("git is not available");
1276+
return;
1277+
}
1278+
1279+
const workspace = createTempDir("deepcode-undo-preexisting-files-workspace-");
1280+
const home = createTempDir("deepcode-undo-preexisting-files-home-");
1281+
setHomeDir(home);
1282+
1283+
const readmePath = path.join(workspace, "README.md");
1284+
const readmeEnPath = path.join(workspace, "README-en.md");
1285+
const readmeZhPath = path.join(workspace, "README-zh_CN.md");
1286+
fs.writeFileSync(readmePath, "这是一个hello world演示项目\n", "utf8");
1287+
fs.writeFileSync(readmeEnPath, "This is a hello world demo project.\n", "utf8");
1288+
fs.writeFileSync(readmeZhPath, "", "utf8");
1289+
1290+
const manager = createSessionManager(workspace, "machine-id-undo-preexisting-files");
1291+
const sessionId = "session-undo-preexisting-files";
1292+
const gitDir = getFileHistoryGitDir(home, workspace);
1293+
const fileHistory = new GitFileHistory(workspace, gitDir);
1294+
fileHistory.ensureSession(sessionId);
1295+
1296+
const targetCheckpoint = fileHistory.recordCheckpoint(
1297+
sessionId,
1298+
[readmePath, readmeEnPath],
1299+
"checkpoint before syncing all readmes"
1300+
);
1301+
assert.ok(targetCheckpoint);
1302+
1303+
assert.ok(fileHistory.recordCheckpoint(sessionId, [readmeZhPath], "pre-sync zh readme"));
1304+
fs.writeFileSync(readmePath, "Synced readme\n", "utf8");
1305+
fs.writeFileSync(readmeEnPath, "Synced readme\n", "utf8");
1306+
fs.writeFileSync(readmeZhPath, "Synced readme\n", "utf8");
1307+
assert.ok(fileHistory.recordCheckpoint(sessionId, [readmePath, readmeEnPath, readmeZhPath], "synced readmes"));
1308+
1309+
(manager as any).appendSessionMessage(sessionId, {
1310+
...buildTestMessage("user-with-readme-checkpoint", sessionId, "user", "sync README*.md"),
1311+
checkpointHash: targetCheckpoint,
1312+
});
1313+
1314+
manager.restoreSessionCode(sessionId, "user-with-readme-checkpoint");
1315+
1316+
assert.equal(fs.readFileSync(readmePath, "utf8"), "这是一个hello world演示项目\n");
1317+
assert.equal(fs.readFileSync(readmeEnPath, "utf8"), "This is a hello world demo project.\n");
1318+
assert.equal(fs.readFileSync(readmeZhPath, "utf8"), "");
1319+
});
1320+
1321+
test("restoreSessionCode restores deleted tracked files and leaves unrelated files alone", async (t) => {
1322+
if (!hasGit()) {
1323+
t.skip("git is not available");
1324+
return;
1325+
}
1326+
1327+
const workspace = createTempDir("deepcode-undo-deleted-files-workspace-");
1328+
const home = createTempDir("deepcode-undo-deleted-files-home-");
1329+
setHomeDir(home);
1330+
1331+
const trackedPath = path.join(workspace, "tracked.txt");
1332+
const unrelatedPath = path.join(workspace, "unrelated.txt");
1333+
fs.writeFileSync(trackedPath, "before delete\n", "utf8");
1334+
fs.writeFileSync(unrelatedPath, "do not touch\n", "utf8");
1335+
1336+
const manager = createSessionManager(workspace, "machine-id-undo-deleted-files");
1337+
const sessionId = "session-undo-deleted-files";
1338+
const gitDir = getFileHistoryGitDir(home, workspace);
1339+
const fileHistory = new GitFileHistory(workspace, gitDir);
1340+
fileHistory.ensureSession(sessionId);
1341+
const targetCheckpoint = fileHistory.recordCheckpoint(sessionId, [trackedPath], "before delete");
1342+
assert.ok(targetCheckpoint);
1343+
1344+
fs.unlinkSync(trackedPath);
1345+
assert.ok(fileHistory.recordCheckpoint(sessionId, [trackedPath], "after delete"));
1346+
1347+
(manager as any).appendSessionMessage(sessionId, {
1348+
...buildTestMessage("user-before-delete", sessionId, "user", "restore deleted file"),
1349+
checkpointHash: targetCheckpoint,
1350+
});
1351+
1352+
manager.restoreSessionCode(sessionId, "user-before-delete");
1353+
1354+
assert.equal(fs.readFileSync(trackedPath, "utf8"), "before delete\n");
1355+
assert.equal(fs.readFileSync(unrelatedPath, "utf8"), "do not touch\n");
1356+
});
1357+
12091358
test("replySession /continue runs trailing pending tool calls before requesting another response", async () => {
12101359
const workspace = createTempDir("deepcode-continue-tool-workspace-");
12111360
const home = createTempDir("deepcode-continue-tool-home-");
@@ -2617,6 +2766,18 @@ function createFileHistoryCommit(
26172766
return commitHash;
26182767
}
26192768

2769+
function getFileHistoryGitDir(home: string, workspace: string): string {
2770+
const projectCode = workspace.replace(/[\\/]/g, "-").replace(/:/g, "");
2771+
return path.join(home, ".deepcode", "projects", projectCode, "file-history", ".git");
2772+
}
2773+
2774+
function readFileHistoryManifest(home: string, workspace: string, checkpointHash: string): any {
2775+
const gitDir = getFileHistoryGitDir(home, workspace);
2776+
return JSON.parse(
2777+
runFileHistoryGit(gitDir, workspace, ["cat-file", "blob", `${checkpointHash}:.deepcode-file-history.json`])
2778+
);
2779+
}
2780+
26202781
function runFileHistoryGit(
26212782
gitDir: string,
26222783
workspace: string,

0 commit comments

Comments
 (0)