Skip to content

Commit c98251f

Browse files
committed
fix: close incremental git -z gaps and add regression tests
Use diff --name-status -z for commit deltas, lowercase ext in candidate filter, and cover mixed delete+change incremental runs plus comma-safe impact walks.
1 parent c150127 commit c98251f

4 files changed

Lines changed: 197 additions & 12 deletions

File tree

src/application/get-changed-files.test.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,4 +105,89 @@ describe("getChangedFiles", () => {
105105
closeDb(db);
106106
}
107107
});
108+
109+
it("detects committed changes to paths with spaces (diff --name-status -z)", () => {
110+
mkdirSync(join(projectRoot, "src"), { recursive: true });
111+
writeFileSync(
112+
join(projectRoot, "src/my module.ts"),
113+
"export const x = 1;\n",
114+
);
115+
writeFileSync(join(projectRoot, "src/other.ts"), "export const o = 1;\n");
116+
const base = commitAll("add spaced and other");
117+
118+
writeFileSync(
119+
join(projectRoot, "src/my module.ts"),
120+
"export const x = 2;\n",
121+
);
122+
commitAll("commit spaced change");
123+
124+
const db = openDb();
125+
try {
126+
createTables(db);
127+
db.run(
128+
"INSERT INTO files (path, content_hash, size, line_count, language, last_modified, indexed_at) VALUES ('src/my module.ts', 'old', 1, 1, 'typescript', 1, 1)",
129+
);
130+
db.run(
131+
"INSERT INTO files (path, content_hash, size, line_count, language, last_modified, indexed_at) VALUES ('src/other.ts', 'old', 1, 1, 'typescript', 1, 1)",
132+
);
133+
setMeta(db, "last_indexed_commit", base);
134+
135+
const delta = getChangedFiles(db);
136+
expect(delta).not.toBeNull();
137+
expect(delta!.changed).toContain("src/my module.ts");
138+
expect(delta!.deleted).not.toContain("src/my module.ts");
139+
} finally {
140+
closeDb(db);
141+
}
142+
});
143+
144+
it("includes uppercase extensions in incremental candidates", () => {
145+
mkdirSync(join(projectRoot, "src"), { recursive: true });
146+
writeFileSync(join(projectRoot, "src/File.TS"), "export const x = 1;\n");
147+
const base = commitAll("add uppercase ext");
148+
149+
writeFileSync(join(projectRoot, "src/File.TS"), "export const x = 2;\n");
150+
151+
const db = openDb();
152+
try {
153+
createTables(db);
154+
setMeta(db, "last_indexed_commit", base);
155+
156+
const delta = getChangedFiles(db);
157+
expect(delta).not.toBeNull();
158+
expect(delta!.changed).toContain("src/File.TS");
159+
} finally {
160+
closeDb(db);
161+
}
162+
});
163+
164+
it("returns deleted and changed paths in the same delta", () => {
165+
mkdirSync(join(projectRoot, "src"), { recursive: true });
166+
writeFileSync(join(projectRoot, "src/a.ts"), "export const a = 1;\n");
167+
writeFileSync(join(projectRoot, "src/b.ts"), "export const b = 1;\n");
168+
const base = commitAll("add a and b");
169+
170+
git(["rm", "src/a.ts"]);
171+
commitAll("delete a");
172+
writeFileSync(join(projectRoot, "src/b.ts"), "export const b = 2;\n");
173+
174+
const db = openDb();
175+
try {
176+
createTables(db);
177+
db.run(
178+
"INSERT INTO files (path, content_hash, size, line_count, language, last_modified, indexed_at) VALUES ('src/a.ts', 'old', 1, 1, 'typescript', 1, 1)",
179+
);
180+
db.run(
181+
"INSERT INTO files (path, content_hash, size, line_count, language, last_modified, indexed_at) VALUES ('src/b.ts', 'old', 1, 1, 'typescript', 1, 1)",
182+
);
183+
setMeta(db, "last_indexed_commit", base);
184+
185+
const delta = getChangedFiles(db);
186+
expect(delta).not.toBeNull();
187+
expect(delta!.deleted).toContain("src/a.ts");
188+
expect(delta!.changed).toContain("src/b.ts");
189+
} finally {
190+
closeDb(db);
191+
}
192+
});
108193
});

src/application/impact-engine.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,20 @@ describe("findImpact — cycle detection", () => {
188188
const names = r.matches.map((m) => m.name).sort();
189189
expect(names).toEqual(["b", "c"]);
190190
});
191+
192+
it("does not treat comma-containing symbol names as prefix matches", () => {
193+
seedFile("src/a.ts");
194+
seedFile("src/ab.ts");
195+
seedFile("src/c.ts");
196+
seedSymbol("a", "src/a.ts");
197+
seedSymbol("a,b", "src/ab.ts");
198+
seedSymbol("c", "src/c.ts");
199+
seedCall("src/ab.ts", "a,b", "c");
200+
seedCall("src/a.ts", "a", "c");
201+
const r = findImpact(db, { target: "c", direction: "up", depth: 3 });
202+
const names = r.matches.map((m) => m.name).sort();
203+
expect(names).toEqual(["a", "a,b"]);
204+
});
191205
});
192206

193207
describe("findImpact — limit termination", () => {

src/application/index-engine.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ export function getChangedFiles(db: CodemapDatabase): {
190190

191191
const diffResult = spawnSync(
192192
"git",
193-
["diff", "--name-status", "--no-renames", `${lastCommit}..HEAD`],
193+
["diff", "--name-status", "-z", "--no-renames", `${lastCommit}..HEAD`],
194194
{
195195
cwd: root,
196196
},
@@ -205,15 +205,15 @@ export function getChangedFiles(db: CodemapDatabase): {
205205

206206
const diffDeletedFromCommit: string[] = [];
207207
const diffFiles: string[] = [];
208-
for (const line of diffResult.stdout
208+
// --name-status -z records are STATUS NUL path NUL pairs (paths unquoted).
209+
const diffRecords = diffResult.stdout
209210
.toString()
210-
.trim()
211-
.split("\n")
212-
.filter(Boolean)) {
213-
const tab = line.indexOf("\t");
214-
if (tab === -1) continue;
215-
const status = line.slice(0, tab);
216-
const path = line.slice(tab + 1);
211+
.split("\0")
212+
.filter(Boolean);
213+
for (let i = 0; i < diffRecords.length; i += 2) {
214+
const status = diffRecords[i];
215+
const path = diffRecords[i + 1];
216+
if (path === undefined) continue;
217217
if (status === "D") diffDeletedFromCommit.push(path);
218218
else diffFiles.push(path);
219219
}
@@ -227,7 +227,7 @@ export function getChangedFiles(db: CodemapDatabase): {
227227
const existingHashes = getAllFileHashes(db);
228228
const allCandidates = [...new Set([...diffFiles, ...statusFiles])].filter(
229229
(f) => {
230-
const ext = extname(f);
230+
const ext = extname(f).toLowerCase();
231231
return ext in LANG_MAP || existingHashes.has(f);
232232
},
233233
);

src/application/run-index.test.ts

Lines changed: 88 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,44 @@
1-
import { describe, expect, test } from "bun:test";
2-
import { mkdtempSync, writeFileSync } from "node:fs";
1+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2+
import { spawnSync } from "node:child_process";
3+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
34
import { tmpdir } from "node:os";
45
import { join } from "node:path";
56

67
import { resolveCodemapConfig } from "../config";
8+
import { closeDb, createTables, openDb, setMeta } from "../db";
9+
import { hashContent } from "../hash";
710
import { configureResolver } from "../resolver";
811
import { initCodemap } from "../runtime";
912
import { openCodemapDatabase } from "../sqlite-db";
1013
import { runCodemapIndex } from "./run-index";
1114

15+
let projectRoot: string;
16+
17+
function fixtureEnv(): NodeJS.ProcessEnv {
18+
const e: NodeJS.ProcessEnv = {};
19+
for (const [k, v] of Object.entries(process.env)) {
20+
if (k.startsWith("GIT_") || k.startsWith("HUSKY")) continue;
21+
e[k] = v;
22+
}
23+
e.GIT_AUTHOR_DATE = "2026-01-01T00:00:00Z";
24+
e.GIT_COMMITTER_DATE = "2026-01-01T00:00:00Z";
25+
return e;
26+
}
27+
28+
function git(args: string[]): string {
29+
const r = spawnSync("git", args, { cwd: projectRoot, env: fixtureEnv() });
30+
if (r.status !== 0) {
31+
throw new Error(`git ${args.join(" ")}: ${r.stderr.toString().trim()}`);
32+
}
33+
return r.stdout.toString().trim();
34+
}
35+
36+
function commitAll(message: string): string {
37+
git(["add", "."]);
38+
git(["commit", "-m", message, "--no-gpg-sign"]);
39+
return git(["rev-parse", "HEAD"]);
40+
}
41+
1242
describe("runCodemapIndex", () => {
1343
test("incremental on empty DB creates schema first (no missing meta table)", async () => {
1444
const root = mkdtempSync(join(tmpdir(), "codemap-run-index-"));
@@ -25,4 +55,60 @@ describe("runCodemapIndex", () => {
2555
db.close();
2656
}
2757
});
58+
59+
describe("incremental git repo", () => {
60+
beforeEach(() => {
61+
projectRoot = mkdtempSync(join(tmpdir(), "codemap-run-index-git-"));
62+
git(["init", "-q", "-b", "main"]);
63+
git(["config", "user.email", "t@example.com"]);
64+
git(["config", "user.name", "T"]);
65+
git(["config", "commit.gpgsign", "false"]);
66+
initCodemap(resolveCodemapConfig(projectRoot, undefined));
67+
configureResolver(projectRoot, null);
68+
});
69+
70+
afterEach(() => {
71+
rmSync(projectRoot, { recursive: true, force: true });
72+
});
73+
74+
test("deletes and re-indexes in one incremental run when both change", async () => {
75+
mkdirSync(join(projectRoot, "src"), { recursive: true });
76+
writeFileSync(join(projectRoot, "src/a.ts"), "export const a = 1;\n");
77+
writeFileSync(join(projectRoot, "src/b.ts"), "export const b = 1;\n");
78+
const base = commitAll("add a and b");
79+
80+
git(["rm", "src/a.ts"]);
81+
commitAll("delete a");
82+
const bSource = "export const b = 2;\n";
83+
writeFileSync(join(projectRoot, "src/b.ts"), bSource);
84+
85+
const db = openDb();
86+
try {
87+
createTables(db);
88+
db.run(
89+
"INSERT INTO files (path, content_hash, size, line_count, language, last_modified, indexed_at) VALUES ('src/a.ts', 'old', 1, 1, 'typescript', 1, 1)",
90+
);
91+
db.run(
92+
"INSERT INTO files (path, content_hash, size, line_count, language, last_modified, indexed_at) VALUES ('src/b.ts', 'old', 1, 1, 'typescript', 1, 1)",
93+
);
94+
setMeta(db, "last_indexed_commit", base);
95+
96+
await runCodemapIndex(db, { mode: "incremental", quiet: true });
97+
98+
const aRow = db
99+
.query<{ path: string }>("SELECT path FROM files WHERE path = ?")
100+
.get("src/a.ts");
101+
expect(aRow).toBeFalsy();
102+
103+
const bRow = db
104+
.query<{ content_hash: string }>(
105+
"SELECT content_hash FROM files WHERE path = ?",
106+
)
107+
.get("src/b.ts");
108+
expect(bRow?.content_hash).toBe(hashContent(bSource));
109+
} finally {
110+
closeDb(db);
111+
}
112+
});
113+
});
28114
});

0 commit comments

Comments
 (0)