Skip to content

Commit f177429

Browse files
committed
feat: implement checkpoints store only explicit Write/Edit file paths
1 parent 27b9b7f commit f177429

2 files changed

Lines changed: 215 additions & 87 deletions

File tree

src/common/file-history.ts

Lines changed: 160 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,26 @@
11
import * as childProcess from "child_process";
2+
import * as crypto from "crypto";
23
import * as fs from "fs";
34
import * as path from "path";
45

56
const FILE_HISTORY_AUTHOR_NAME = "DeepCode Checkpoint";
67
const FILE_HISTORY_AUTHOR_EMAIL = "deepcode-checkpoint@localhost";
8+
const MANIFEST_PATH = ".deepcode-file-history.json";
9+
10+
type FileHistoryEntry = {
11+
path: string;
12+
blob: string;
13+
mode: "100644";
14+
};
15+
16+
type FileHistoryManifest = {
17+
version: 1;
18+
files: Record<string, FileHistoryEntry>;
19+
};
720

821
export class GitFileHistory {
922
constructor(
10-
private readonly projectRoot: string,
23+
_projectRoot: string,
1124
private readonly gitDir: string
1225
) {}
1326

@@ -20,17 +33,17 @@ export class GitFileHistory {
2033
try {
2134
if (!fs.existsSync(this.gitDir)) {
2235
fs.mkdirSync(path.dirname(this.gitDir), { recursive: true });
23-
this.runGit(["init"], { includeWorkTree: true });
36+
this.runGit(["init"]);
2437
}
2538

2639
const current = this.getCurrentCheckpointHash(sessionId);
2740
if (current) {
2841
return current;
2942
}
3043

31-
const emptyTree = this.runGit(["mktree"], { includeWorkTree: false, input: "" }).trim();
32-
const commitHash = this.createCommit(emptyTree, null, "Initial checkpoint");
33-
this.runGit(["update-ref", branchRef, commitHash], { includeWorkTree: false });
44+
const treeHash = this.createTree(emptyManifest());
45+
const commitHash = this.createCommit(treeHash, null, "Initial checkpoint");
46+
this.runGit(["update-ref", branchRef, commitHash]);
3447
return commitHash;
3548
} catch {
3649
return undefined;
@@ -44,9 +57,7 @@ export class GitFileHistory {
4457
}
4558

4659
try {
47-
const hash = this.runGit(["rev-parse", "--verify", `${branchRef}^{commit}`], {
48-
includeWorkTree: false,
49-
}).trim();
60+
const hash = this.runGit(["rev-parse", "--verify", `${branchRef}^{commit}`]).trim();
5061
return isCommitHash(hash) ? hash : undefined;
5162
} catch {
5263
return undefined;
@@ -59,10 +70,8 @@ export class GitFileHistory {
5970
return undefined;
6071
}
6172

62-
const relativePaths = filePaths
63-
.map((filePath) => this.toProjectRelativeGitPath(filePath))
64-
.filter((filePath): filePath is string => Boolean(filePath));
65-
if (relativePaths.length === 0) {
73+
const absolutePaths = uniqueAbsolutePaths(filePaths);
74+
if (absolutePaths.length === 0) {
6675
return this.getCurrentCheckpointHash(sessionId);
6776
}
6877

@@ -71,18 +80,30 @@ export class GitFileHistory {
7180
if (!parentHash) {
7281
return undefined;
7382
}
74-
this.runGit(["read-tree", "--reset", branchRef], { includeWorkTree: true });
75-
this.runGit(["add", "-f", "-A", "--", ...relativePaths], { includeWorkTree: true });
76-
const treeHash = this.runGit(["write-tree"], { includeWorkTree: false }).trim();
77-
const parentTreeHash = this.runGit(["rev-parse", `${parentHash}^{tree}`], {
78-
includeWorkTree: false,
79-
}).trim();
83+
84+
const manifest = this.readManifest(parentHash);
85+
for (const filePath of absolutePaths) {
86+
const key = this.getFileKey(filePath);
87+
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
88+
delete manifest.files[key];
89+
continue;
90+
}
91+
92+
manifest.files[key] = {
93+
path: filePath,
94+
blob: this.hashFile(filePath),
95+
mode: "100644",
96+
};
97+
}
98+
99+
const treeHash = this.createTree(manifest);
100+
const parentTreeHash = this.runGit(["rev-parse", `${parentHash}^{tree}`]).trim();
80101
if (treeHash === parentTreeHash) {
81102
return parentHash;
82103
}
83104

84105
const commitHash = this.createCommit(treeHash, parentHash, message);
85-
this.runGit(["update-ref", branchRef, commitHash, parentHash], { includeWorkTree: false });
106+
this.runGit(["update-ref", branchRef, commitHash, parentHash]);
86107
return commitHash;
87108
} catch {
88109
return undefined;
@@ -101,7 +122,8 @@ export class GitFileHistory {
101122
}
102123

103124
try {
104-
this.runGit(["cat-file", "-e", `${checkpointHash}^{commit}`], { includeWorkTree: false });
125+
this.runGit(["cat-file", "-e", `${checkpointHash}^{commit}`]);
126+
this.readManifest(checkpointHash);
105127
return true;
106128
} catch {
107129
return false;
@@ -116,16 +138,24 @@ export class GitFileHistory {
116138
if (!branchRef || !fs.existsSync(this.gitDir)) {
117139
throw new Error("File history Git repository was not found for this project.");
118140
}
119-
this.runGit(["cat-file", "-e", `${checkpointHash}^{commit}`], { includeWorkTree: false });
141+
this.runGit(["cat-file", "-e", `${checkpointHash}^{commit}`]);
120142

121-
try {
122-
this.runGit(["read-tree", "--reset", branchRef], { includeWorkTree: true });
123-
} catch {
124-
// If the session branch is missing, fall back to the target tree only.
125-
// The target checkpoint has already been validated above.
143+
const currentHash = this.getCurrentCheckpointHash(sessionId);
144+
const currentManifest = currentHash ? this.readManifest(currentHash) : emptyManifest();
145+
const targetManifest = this.readManifest(checkpointHash);
146+
147+
for (const [key, entry] of Object.entries(currentManifest.files)) {
148+
if (!targetManifest.files[key]) {
149+
removeTrackedFile(entry.path);
150+
}
126151
}
127-
this.runGit(["read-tree", "--reset", "-u", checkpointHash], { includeWorkTree: true });
128-
this.runGit(["update-ref", branchRef, checkpointHash], { includeWorkTree: false });
152+
153+
for (const entry of Object.values(targetManifest.files)) {
154+
fs.mkdirSync(path.dirname(entry.path), { recursive: true });
155+
fs.writeFileSync(entry.path, this.readBlob(entry.blob));
156+
}
157+
158+
this.runGit(["update-ref", branchRef, checkpointHash]);
129159
}
130160

131161
private getSessionBranchRef(sessionId: string): string | null {
@@ -142,41 +172,125 @@ export class GitFileHistory {
142172
}
143173
args.push("-m", message);
144174
return this.runGit(args, {
145-
includeWorkTree: false,
146175
env: getFileHistoryGitEnv(),
147176
}).trim();
148177
}
149178

150-
private toProjectRelativeGitPath(filePath: string): string | null {
151-
const absolutePath = path.resolve(filePath);
152-
const relativePath = path.relative(this.projectRoot, absolutePath);
153-
if (!relativePath || relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
154-
return null;
179+
private createTree(manifest: FileHistoryManifest): string {
180+
const normalizedManifest = normalizeManifest(manifest);
181+
const manifestBlob = this.hashContent(`${JSON.stringify(normalizedManifest, null, 2)}\n`);
182+
const entries: string[] = [`100644 blob ${manifestBlob}\t${MANIFEST_PATH}\0`];
183+
184+
for (const [key, entry] of Object.entries(normalizedManifest.files)) {
185+
entries.push(`${entry.mode} blob ${entry.blob}\t${key}\0`);
155186
}
156-
return relativePath.split(path.sep).join("/");
187+
188+
return this.runGit(["mktree", "-z"], { input: entries.join("") }).trim();
157189
}
158190

159-
private runGit(
160-
args: string[],
161-
options: { includeWorkTree: boolean; input?: string; env?: NodeJS.ProcessEnv }
162-
): string {
163-
const gitArgs = ["-c", "core.autocrlf=false", "-c", "core.eol=lf", `--git-dir=${this.gitDir}`];
164-
if (options.includeWorkTree) {
165-
gitArgs.push(`--work-tree=${this.projectRoot}`);
191+
private readManifest(commitHash: string): FileHistoryManifest {
192+
const buffer = this.runGitBuffer(["cat-file", "blob", `${commitHash}:${MANIFEST_PATH}`]);
193+
const parsed = JSON.parse(buffer.toString("utf8")) as FileHistoryManifest;
194+
if (!parsed || parsed.version !== 1 || !parsed.files || typeof parsed.files !== "object") {
195+
throw new Error("Invalid file history manifest.");
196+
}
197+
return normalizeManifest(parsed);
198+
}
199+
200+
private readBlob(blobHash: string): Buffer {
201+
if (!isCommitHash(blobHash)) {
202+
throw new Error("Invalid file history blob hash.");
203+
}
204+
return this.runGitBuffer(["cat-file", "blob", blobHash]);
205+
}
206+
207+
private hashFile(filePath: string): string {
208+
const blobHash = this.runGit(["hash-object", "-w", "--", filePath]).trim();
209+
if (!isCommitHash(blobHash)) {
210+
throw new Error("Invalid file history blob hash.");
166211
}
167-
gitArgs.push(...args);
212+
return blobHash;
213+
}
214+
215+
private hashContent(content: string): string {
216+
const blobHash = this.runGit(["hash-object", "-w", "--stdin"], { input: content }).trim();
217+
if (!isCommitHash(blobHash)) {
218+
throw new Error("Invalid file history blob hash.");
219+
}
220+
return blobHash;
221+
}
222+
223+
private getFileKey(filePath: string): string {
224+
const hash = crypto.createHash("sha256").update(filePath).digest("hex");
225+
return `files-${hash}`;
226+
}
227+
228+
private runGit(args: string[], options: { input?: string | Buffer; env?: NodeJS.ProcessEnv } = {}): string {
229+
return this.spawnGit(args, options, "utf8") as string;
230+
}
231+
232+
private runGitBuffer(args: string[], options: { input?: string | Buffer; env?: NodeJS.ProcessEnv } = {}): Buffer {
233+
return this.spawnGit(args, options, "buffer") as Buffer;
234+
}
235+
236+
private spawnGit(
237+
args: string[],
238+
options: { input?: string | Buffer; env?: NodeJS.ProcessEnv },
239+
encoding: BufferEncoding | "buffer"
240+
): string | Buffer {
241+
const gitArgs = ["-c", "core.autocrlf=false", "-c", "core.eol=lf", `--git-dir=${this.gitDir}`, ...args];
168242
const result = childProcess.spawnSync("git", gitArgs, {
169-
encoding: "utf8",
243+
encoding,
170244
input: options.input,
171245
env: options.env,
172246
stdio: ["pipe", "pipe", "pipe"],
173247
});
174248
if (result.status !== 0) {
175-
const detail = (result.stderr || result.stdout || "").trim();
249+
const stderr = Buffer.isBuffer(result.stderr) ? result.stderr.toString("utf8") : result.stderr;
250+
const stdout = Buffer.isBuffer(result.stdout) ? result.stdout.toString("utf8") : result.stdout;
251+
const detail = (stderr || stdout || "").trim();
176252
throw new Error(detail || `git ${args.join(" ")} failed`);
177253
}
178-
return result.stdout ?? "";
254+
return result.stdout ?? (encoding === "buffer" ? Buffer.alloc(0) : "");
255+
}
256+
}
257+
258+
function emptyManifest(): FileHistoryManifest {
259+
return { version: 1, files: {} };
260+
}
261+
262+
function normalizeManifest(manifest: FileHistoryManifest): FileHistoryManifest {
263+
const files: Record<string, FileHistoryEntry> = {};
264+
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)) {
266+
throw new Error("Invalid file history manifest.");
267+
}
268+
files[key] = {
269+
path: path.resolve(entry.path),
270+
blob: entry.blob,
271+
mode: "100644",
272+
};
273+
}
274+
return { version: 1, files };
275+
}
276+
277+
function uniqueAbsolutePaths(filePaths: string[]): string[] {
278+
return Array.from(new Set(filePaths.map((filePath) => path.resolve(filePath))));
279+
}
280+
281+
function isValidStoredPath(value: string): boolean {
282+
return /^files-[0-9a-f]{64}$/.test(value);
283+
}
284+
285+
function removeTrackedFile(filePath: string): void {
286+
if (!fs.existsSync(filePath)) {
287+
return;
288+
}
289+
const stat = fs.lstatSync(filePath);
290+
if (stat.isDirectory()) {
291+
return;
179292
}
293+
fs.unlinkSync(filePath);
180294
}
181295

182296
function getFileHistoryGitEnv(): NodeJS.ProcessEnv {

0 commit comments

Comments
 (0)