Skip to content

Commit fdbec32

Browse files
committed
feat(core): resolve exact source sides for expansion
Attach per-file source fetchers from explicit Git refs, index, worktree, file-pair, and untracked endpoints so expanded context never guesses from the wrong revision.
1 parent 9ada110 commit fdbec32

7 files changed

Lines changed: 1178 additions & 6 deletions

File tree

src/core/fileSource.test.ts

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
import { afterEach, describe, expect, test } from "bun:test";
2+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import { createFileSourceFetcher } from "./fileSource";
6+
7+
const tempDirs: string[] = [];
8+
9+
function createTempDir(prefix: string) {
10+
const dir = mkdtempSync(join(tmpdir(), prefix));
11+
tempDirs.push(dir);
12+
return dir;
13+
}
14+
15+
function git(cwd: string, ...cmd: string[]) {
16+
const proc = Bun.spawnSync(["git", ...cmd], {
17+
cwd,
18+
stdout: "pipe",
19+
stderr: "pipe",
20+
stdin: "ignore",
21+
});
22+
23+
if (proc.exitCode !== 0) {
24+
const stderr = Buffer.from(proc.stderr).toString("utf8");
25+
throw new Error(stderr.trim() || `git ${cmd.join(" ")} failed`);
26+
}
27+
28+
return Buffer.from(proc.stdout).toString("utf8");
29+
}
30+
31+
function createTempRepo(prefix: string) {
32+
const dir = createTempDir(prefix);
33+
git(dir, "init");
34+
git(dir, "config", "user.name", "Test User");
35+
git(dir, "config", "user.email", "test@example.com");
36+
git(dir, "config", "commit.gpgSign", "false");
37+
return dir;
38+
}
39+
40+
/** Capture console.error calls while exercising diagnostic paths. */
41+
async function captureConsoleErrors(fn: () => Promise<void>) {
42+
const originalConsoleError = console.error;
43+
const loggedErrors: unknown[][] = [];
44+
console.error = (...args: unknown[]) => {
45+
loggedErrors.push(args);
46+
};
47+
48+
try {
49+
await fn();
50+
} finally {
51+
console.error = originalConsoleError;
52+
}
53+
54+
return loggedErrors;
55+
}
56+
57+
afterEach(() => {
58+
while (tempDirs.length > 0) {
59+
const dir = tempDirs.pop();
60+
if (dir) {
61+
rmSync(dir, { recursive: true, force: true });
62+
}
63+
}
64+
});
65+
66+
describe("createFileSourceFetcher", () => {
67+
test("reads fs paths for old and new sides", async () => {
68+
const dir = createTempDir("hunk-source-fs-");
69+
const left = join(dir, "before.txt");
70+
const right = join(dir, "after.txt");
71+
writeFileSync(left, "old contents\n");
72+
writeFileSync(right, "new contents\n");
73+
74+
const fetcher = createFileSourceFetcher({
75+
old: { kind: "fs", absolutePath: left },
76+
new: { kind: "fs", absolutePath: right },
77+
});
78+
79+
expect(await fetcher.getFullText("old")).toBe("old contents\n");
80+
expect(await fetcher.getFullText("new")).toBe("new contents\n");
81+
});
82+
83+
test("returns null for `none` specs", async () => {
84+
const fetcher = createFileSourceFetcher({
85+
old: { kind: "none" },
86+
new: { kind: "none" },
87+
});
88+
89+
expect(await fetcher.getFullText("old")).toBeNull();
90+
expect(await fetcher.getFullText("new")).toBeNull();
91+
});
92+
93+
test("returns null when an fs path cannot be read", async () => {
94+
const dir = createTempDir("hunk-source-fs-missing-");
95+
const fetcher = createFileSourceFetcher({
96+
old: { kind: "fs", absolutePath: join(dir, "missing.txt") },
97+
new: { kind: "none" },
98+
});
99+
100+
expect(await fetcher.getFullText("old")).toBeNull();
101+
});
102+
103+
test("reads git blob contents for both sides via `git show`", async () => {
104+
const repoRoot = createTempRepo("hunk-source-git-");
105+
const filePath = "note.txt";
106+
107+
writeFileSync(join(repoRoot, filePath), "first revision\n");
108+
git(repoRoot, "add", ".");
109+
git(repoRoot, "commit", "-m", "first");
110+
writeFileSync(join(repoRoot, filePath), "second revision\n");
111+
git(repoRoot, "add", ".");
112+
git(repoRoot, "commit", "-m", "second");
113+
114+
const fetcher = createFileSourceFetcher({
115+
old: { kind: "git-blob", repoRoot, ref: "HEAD~1", path: filePath },
116+
new: { kind: "git-blob", repoRoot, ref: "HEAD", path: filePath },
117+
});
118+
119+
expect(await fetcher.getFullText("old")).toBe("first revision\n");
120+
expect(await fetcher.getFullText("new")).toBe("second revision\n");
121+
});
122+
123+
test("reads git index contents through an explicit index spec", async () => {
124+
const repoRoot = createTempRepo("hunk-source-git-index-");
125+
const filePath = "note.txt";
126+
127+
writeFileSync(join(repoRoot, filePath), "committed\n");
128+
git(repoRoot, "add", ".");
129+
git(repoRoot, "commit", "-m", "first");
130+
writeFileSync(join(repoRoot, filePath), "staged\n");
131+
git(repoRoot, "add", filePath);
132+
writeFileSync(join(repoRoot, filePath), "working tree\n");
133+
134+
const fetcher = createFileSourceFetcher({
135+
old: { kind: "git-index", repoRoot, path: filePath },
136+
new: { kind: "fs", absolutePath: join(repoRoot, filePath) },
137+
});
138+
139+
expect(await fetcher.getFullText("old")).toBe("staged\n");
140+
expect(await fetcher.getFullText("new")).toBe("working tree\n");
141+
});
142+
143+
test("returns null when a git blob cannot be resolved", async () => {
144+
const repoRoot = createTempRepo("hunk-source-git-missing-");
145+
writeFileSync(join(repoRoot, "tracked.txt"), "x\n");
146+
git(repoRoot, "add", ".");
147+
git(repoRoot, "commit", "-m", "first");
148+
149+
const fetcher = createFileSourceFetcher({
150+
old: { kind: "git-blob", repoRoot, ref: "HEAD", path: "missing-from-history.txt" },
151+
new: { kind: "none" },
152+
});
153+
154+
const loggedErrors = await captureConsoleErrors(async () => {
155+
expect(await fetcher.getFullText("old")).toBeNull();
156+
});
157+
expect(loggedErrors).toHaveLength(0);
158+
});
159+
160+
test("logs unexpected git source failures with object context", async () => {
161+
const repoRoot = createTempDir("hunk-source-git-not-repo-");
162+
const fetcher = createFileSourceFetcher({
163+
old: { kind: "git-blob", repoRoot, ref: "HEAD", path: "note.txt" },
164+
new: { kind: "none" },
165+
});
166+
167+
const loggedErrors = await captureConsoleErrors(async () => {
168+
expect(await fetcher.getFullText("old")).toBeNull();
169+
});
170+
171+
expect(loggedErrors).toHaveLength(1);
172+
expect(String(loggedErrors[0]?.[0])).toContain("HEAD:note.txt");
173+
expect(String(loggedErrors[0]?.[0])).toContain(repoRoot);
174+
});
175+
176+
test("caches resolved text per side", async () => {
177+
const dir = createTempDir("hunk-source-cache-");
178+
const target = join(dir, "value.txt");
179+
writeFileSync(target, "first\n");
180+
181+
const fetcher = createFileSourceFetcher({
182+
old: { kind: "none" },
183+
new: { kind: "fs", absolutePath: target },
184+
});
185+
186+
const initial = await fetcher.getFullText("new");
187+
writeFileSync(target, "rewritten\n");
188+
const cached = await fetcher.getFullText("new");
189+
190+
expect(initial).toBe("first\n");
191+
expect(cached).toBe("first\n");
192+
});
193+
});

src/core/fileSource.ts

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
/**
2+
* Resolve full file contents for one diff file across input modes.
3+
*
4+
* Each `DiffFile` may carry a `FileSourceFetcher` that knows how to read the
5+
* file's "old" and "new" sides without re-running the original diff. Returns
6+
* `null` when source content is unreachable.
7+
*/
8+
9+
export type FileSourceSpec =
10+
| { kind: "none" }
11+
| { kind: "fs"; absolutePath: string }
12+
| { kind: "git-blob"; repoRoot: string; ref: string; path: string }
13+
| { kind: "git-index"; repoRoot: string; path: string };
14+
15+
export type FileSourceSide = "old" | "new";
16+
17+
export interface FileSourceFetcher {
18+
/**
19+
* Returns the file's full source text on the requested side, or `null` when
20+
* the side is not reachable (deleted side, missing path, git error). Built-in
21+
* fetchers resolve `null` instead of rejecting, but UI callers still handle
22+
* custom fetcher rejection defensively.
23+
*/
24+
getFullText(side: FileSourceSide): Promise<string | null>;
25+
}
26+
27+
interface ResolvedSpecs {
28+
old: FileSourceSpec;
29+
new: FileSourceSpec;
30+
}
31+
32+
/** Return the first useful diagnostic line from a failed source read. */
33+
function firstDiagnosticLine(text: string) {
34+
return text
35+
.split("\n")
36+
.map((line) => line.trim())
37+
.find(Boolean);
38+
}
39+
40+
/** Keep source-load diagnostics terse enough to be useful in logs. */
41+
function logSourceDiagnostic(message: string, detail?: unknown) {
42+
if (detail instanceof Error) {
43+
console.error(`hunk: ${message}: ${detail.message}`, detail);
44+
return;
45+
}
46+
47+
const detailText = typeof detail === "string" ? firstDiagnosticLine(detail) : undefined;
48+
console.error(detailText ? `hunk: ${message}: ${detailText}` : `hunk: ${message}`);
49+
}
50+
51+
/** Return whether a Git failure is an expected missing source side/path. */
52+
function isExpectedMissingGitSource(stderr: string) {
53+
const normalized = stderr.toLowerCase();
54+
return [
55+
"exists on disk, but not in",
56+
"does not exist in",
57+
"invalid object name",
58+
"needed a single revision",
59+
"unknown revision or path not in the working tree",
60+
].some((fragment) => normalized.includes(fragment));
61+
}
62+
63+
async function readFsSpec(spec: Extract<FileSourceSpec, { kind: "fs" }>): Promise<string | null> {
64+
try {
65+
const file = Bun.file(spec.absolutePath);
66+
if (!(await file.exists())) {
67+
return null;
68+
}
69+
70+
return await file.text();
71+
} catch (error) {
72+
logSourceDiagnostic(`failed to read source file ${spec.absolutePath}`, error);
73+
return null;
74+
}
75+
}
76+
77+
function readGitBlobSpec(
78+
spec: Extract<FileSourceSpec, { kind: "git-blob" }>,
79+
gitExecutable = "git",
80+
): string | null {
81+
return readGitObjectSpec(spec.repoRoot, `${spec.ref}:${spec.path}`, gitExecutable);
82+
}
83+
84+
function readGitIndexSpec(
85+
spec: Extract<FileSourceSpec, { kind: "git-index" }>,
86+
gitExecutable = "git",
87+
): string | null {
88+
return readGitObjectSpec(spec.repoRoot, `:${spec.path}`, gitExecutable);
89+
}
90+
91+
/** Read a blob-like Git object spec such as `HEAD:path` or `:path`. */
92+
function readGitObjectSpec(
93+
repoRoot: string,
94+
objectName: string,
95+
gitExecutable = "git",
96+
): string | null {
97+
let proc: ReturnType<typeof Bun.spawnSync>;
98+
99+
try {
100+
proc = Bun.spawnSync([gitExecutable, "show", objectName], {
101+
cwd: repoRoot,
102+
stdin: "ignore",
103+
stdout: "pipe",
104+
stderr: "pipe",
105+
});
106+
} catch (error) {
107+
logSourceDiagnostic(`failed to run Git while reading source ${objectName}`, error);
108+
return null;
109+
}
110+
111+
if (proc.exitCode !== 0) {
112+
const stderr = Buffer.from(proc.stderr ?? []).toString("utf8");
113+
if (!isExpectedMissingGitSource(stderr)) {
114+
logSourceDiagnostic(`failed to read Git source ${objectName} in ${repoRoot}`, stderr);
115+
}
116+
return null;
117+
}
118+
119+
return Buffer.from(proc.stdout ?? []).toString("utf8");
120+
}
121+
122+
async function readSpec(spec: FileSourceSpec): Promise<string | null> {
123+
if (spec.kind === "none") {
124+
return null;
125+
}
126+
127+
if (spec.kind === "fs") {
128+
return readFsSpec(spec);
129+
}
130+
131+
if (spec.kind === "git-index") {
132+
return readGitIndexSpec(spec);
133+
}
134+
135+
return readGitBlobSpec(spec);
136+
}
137+
138+
/** Build a per-file source fetcher that caches each side's resolved text. */
139+
export function createFileSourceFetcher(specs: ResolvedSpecs): FileSourceFetcher {
140+
const cache = new Map<FileSourceSide, string | null>();
141+
142+
return {
143+
async getFullText(side) {
144+
if (cache.has(side)) {
145+
return cache.get(side) ?? null;
146+
}
147+
148+
const text = await readSpec(specs[side]);
149+
cache.set(side, text);
150+
return text;
151+
},
152+
};
153+
}

0 commit comments

Comments
 (0)