Skip to content

Commit 952f1d8

Browse files
committed
fix: harden source expansion loading
1 parent 1638d0e commit 952f1d8

10 files changed

Lines changed: 417 additions & 56 deletions

src/core/fileSource.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,48 @@ describe("createFileSourceFetcher", () => {
140140
expect(await fetcher.getFullText("new")).toBe("working tree\n");
141141
});
142142

143+
test("passes custom git executable through async git source reads", async () => {
144+
const originalSpawn = Bun.spawn;
145+
const mutableBun = Bun as unknown as { spawn: typeof Bun.spawn };
146+
const spawnCalls: string[][] = [];
147+
148+
mutableBun.spawn = ((cmds: string[]) => {
149+
spawnCalls.push(cmds);
150+
return originalSpawn(
151+
[
152+
process.execPath,
153+
"--eval",
154+
`process.stdout.write(${JSON.stringify(`read:${cmds[2]}\n`)})`,
155+
],
156+
{
157+
stdin: "ignore",
158+
stdout: "pipe",
159+
stderr: "pipe",
160+
},
161+
);
162+
}) as typeof Bun.spawn;
163+
164+
try {
165+
const fetcher = createFileSourceFetcher(
166+
{
167+
old: { kind: "git-blob", repoRoot: process.cwd(), ref: "HEAD", path: "note.txt" },
168+
new: { kind: "git-index", repoRoot: process.cwd(), path: "note.txt" },
169+
},
170+
{ gitExecutable: "custom-git" },
171+
);
172+
173+
expect(await fetcher.getFullText("old")).toBe("read:HEAD:note.txt\n");
174+
expect(await fetcher.getFullText("new")).toBe("read::note.txt\n");
175+
} finally {
176+
mutableBun.spawn = originalSpawn;
177+
}
178+
179+
expect(spawnCalls).toEqual([
180+
["custom-git", "show", "HEAD:note.txt"],
181+
["custom-git", "show", ":note.txt"],
182+
]);
183+
});
184+
143185
test("returns null when a git blob cannot be resolved", async () => {
144186
const repoRoot = createTempRepo("hunk-source-git-missing-");
145187
writeFileSync(join(repoRoot, "tracked.txt"), "x\n");

src/core/fileSource.ts

Lines changed: 37 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ export interface FileSourceFetcher {
2424
getFullText(side: FileSourceSide): Promise<string | null>;
2525
}
2626

27+
export interface FileSourceFetcherOptions {
28+
gitExecutable?: string;
29+
}
30+
2731
interface ResolvedSpecs {
2832
old: FileSourceSpec;
2933
new: FileSourceSpec;
@@ -77,27 +81,27 @@ async function readFsSpec(spec: Extract<FileSourceSpec, { kind: "fs" }>): Promis
7781
function readGitBlobSpec(
7882
spec: Extract<FileSourceSpec, { kind: "git-blob" }>,
7983
gitExecutable = "git",
80-
): string | null {
84+
): Promise<string | null> {
8185
return readGitObjectSpec(spec.repoRoot, `${spec.ref}:${spec.path}`, gitExecutable);
8286
}
8387

8488
function readGitIndexSpec(
8589
spec: Extract<FileSourceSpec, { kind: "git-index" }>,
8690
gitExecutable = "git",
87-
): string | null {
91+
): Promise<string | null> {
8892
return readGitObjectSpec(spec.repoRoot, `:${spec.path}`, gitExecutable);
8993
}
9094

9195
/** Read a blob-like Git object spec such as `HEAD:path` or `:path`. */
92-
function readGitObjectSpec(
96+
async function readGitObjectSpec(
9397
repoRoot: string,
9498
objectName: string,
9599
gitExecutable = "git",
96-
): string | null {
97-
let proc: ReturnType<typeof Bun.spawnSync>;
100+
): Promise<string | null> {
101+
let proc: Bun.ReadableSubprocess;
98102

99103
try {
100-
proc = Bun.spawnSync([gitExecutable, "show", objectName], {
104+
proc = Bun.spawn([gitExecutable, "show", objectName], {
101105
cwd: repoRoot,
102106
stdin: "ignore",
103107
stdout: "pipe",
@@ -108,18 +112,34 @@ function readGitObjectSpec(
108112
return null;
109113
}
110114

111-
if (proc.exitCode !== 0) {
112-
const stderr = Buffer.from(proc.stderr ?? []).toString("utf8");
115+
let output: [number, string, string];
116+
try {
117+
output = await Promise.all([
118+
proc.exited,
119+
new Response(proc.stdout).text(),
120+
new Response(proc.stderr).text(),
121+
]);
122+
} catch (error) {
123+
logSourceDiagnostic(`failed to collect Git source ${objectName}`, error);
124+
return null;
125+
}
126+
127+
const [exitCode, stdout, stderr] = output;
128+
129+
if (exitCode !== 0) {
113130
if (!isExpectedMissingGitSource(stderr)) {
114131
logSourceDiagnostic(`failed to read Git source ${objectName} in ${repoRoot}`, stderr);
115132
}
116133
return null;
117134
}
118135

119-
return Buffer.from(proc.stdout ?? []).toString("utf8");
136+
return stdout;
120137
}
121138

122-
async function readSpec(spec: FileSourceSpec): Promise<string | null> {
139+
async function readSpec(
140+
spec: FileSourceSpec,
141+
{ gitExecutable = "git" }: FileSourceFetcherOptions = {},
142+
): Promise<string | null> {
123143
if (spec.kind === "none") {
124144
return null;
125145
}
@@ -129,14 +149,17 @@ async function readSpec(spec: FileSourceSpec): Promise<string | null> {
129149
}
130150

131151
if (spec.kind === "git-index") {
132-
return readGitIndexSpec(spec);
152+
return readGitIndexSpec(spec, gitExecutable);
133153
}
134154

135-
return readGitBlobSpec(spec);
155+
return readGitBlobSpec(spec, gitExecutable);
136156
}
137157

138158
/** Build a per-file source fetcher that caches each side's resolved text. */
139-
export function createFileSourceFetcher(specs: ResolvedSpecs): FileSourceFetcher {
159+
export function createFileSourceFetcher(
160+
specs: ResolvedSpecs,
161+
{ gitExecutable = "git" }: Readonly<FileSourceFetcherOptions> = {},
162+
): FileSourceFetcher {
140163
const cache = new Map<FileSourceSide, string | null>();
141164

142165
return {
@@ -145,7 +168,7 @@ export function createFileSourceFetcher(specs: ResolvedSpecs): FileSourceFetcher
145168
return cache.get(side) ?? null;
146169
}
147170

148-
const text = await readSpec(specs[side]);
171+
const text = await readSpec(specs[side], { gitExecutable });
149172
cache.set(side, text);
150173
return text;
151174
},

src/core/loaders.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1396,6 +1396,62 @@ describe("loadAppBootstrap source fetcher attachment", () => {
13961396
expect(await file?.sourceFetcher?.getFullText("old")).toBe("first\n");
13971397
});
13981398

1399+
test("git source fetchers use the custom git executable from bootstrap loading", async () => {
1400+
const dir = createTempRepo("hunk-source-custom-git-");
1401+
writeFileSync(join(dir, "value.txt"), "first\n");
1402+
git(dir, "add", "value.txt");
1403+
git(dir, "commit", "-m", "initial");
1404+
writeFileSync(join(dir, "value.txt"), "second\n");
1405+
1406+
const gitExecutable = "hunk-custom-git";
1407+
const syncCalls: string[][] = [];
1408+
const asyncCalls: string[][] = [];
1409+
const originalSpawnSync = Bun.spawnSync;
1410+
const originalSpawn = Bun.spawn;
1411+
const mutableBun = Bun as unknown as {
1412+
spawnSync: typeof Bun.spawnSync;
1413+
spawn: typeof Bun.spawn;
1414+
};
1415+
1416+
mutableBun.spawnSync = ((cmds: string[], options?: Parameters<typeof Bun.spawnSync>[1]) => {
1417+
if (cmds[0] === gitExecutable) {
1418+
syncCalls.push(cmds);
1419+
return originalSpawnSync(["git", ...cmds.slice(1)], options);
1420+
}
1421+
1422+
return originalSpawnSync(cmds, options);
1423+
}) as typeof Bun.spawnSync;
1424+
mutableBun.spawn = ((cmds: string[], options?: Parameters<typeof Bun.spawn>[1]) => {
1425+
if (cmds[0] === gitExecutable) {
1426+
asyncCalls.push(cmds);
1427+
return originalSpawn(["git", ...cmds.slice(1)], options);
1428+
}
1429+
1430+
return originalSpawn(cmds, options);
1431+
}) as typeof Bun.spawn;
1432+
1433+
try {
1434+
const bootstrap = await loadAppBootstrap(
1435+
{
1436+
kind: "vcs",
1437+
staged: false,
1438+
options: { mode: "auto" },
1439+
},
1440+
{ cwd: dir, gitExecutable },
1441+
);
1442+
1443+
const file = bootstrap.changeset.files[0];
1444+
expect(await file?.sourceFetcher?.getFullText("old")).toBe("first\n");
1445+
} finally {
1446+
mutableBun.spawnSync = originalSpawnSync;
1447+
mutableBun.spawn = originalSpawn;
1448+
}
1449+
1450+
expect(syncCalls.some((call) => call.includes("rev-parse"))).toBe(true);
1451+
expect(syncCalls.some((call) => call.includes("diff"))).toBe(true);
1452+
expect(asyncCalls).toContainEqual([gitExecutable, "show", ":value.txt"]);
1453+
});
1454+
13991455
test("unstaged working-tree diffs read old source from the index when it differs from HEAD", async () => {
14001456
const dir = createTempRepo("hunk-source-git-wt-index-");
14011457
writeFileSync(join(dir, "value.txt"), "committed\n");

0 commit comments

Comments
 (0)