Skip to content

Commit 8ed7c8d

Browse files
authored
fix(diff): skip huge file rendering (#266)
1 parent 0f2dec3 commit 8ed7c8d

11 files changed

Lines changed: 459 additions & 16 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ All notable user-visible changes to Hunk are documented in this file.
1010

1111
### Fixed
1212

13+
- Fixed large tracked and untracked file handling so very large diffs render as skipped placeholders instead of slowing startup or overflowing the JavaScript call stack.
14+
1315
## [0.11.0] - 2026-05-09
1416

1517
### Added
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { testRender } from "@opentui/react/test-utils";
2+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import { act } from "react";
6+
import { loadAppBootstrap } from "../src/core/loaders";
7+
import { AppHost } from "../src/ui/AppHost";
8+
9+
function runGit(cwd: string, ...args: string[]) {
10+
const proc = Bun.spawnSync(["git", ...args], {
11+
cwd,
12+
stderr: "pipe",
13+
stdin: "ignore",
14+
stdout: "pipe",
15+
});
16+
17+
if (proc.exitCode !== 0) {
18+
throw new Error(Buffer.from(proc.stderr).toString("utf8").trim());
19+
}
20+
}
21+
22+
/** Generate a large untracked file that stays small on disk for manual render checks. */
23+
function createLargeFileBody(lineCount: number) {
24+
return (
25+
Array.from({ length: lineCount }, (_, index) => {
26+
if (index === 0) {
27+
return "visible-first-line";
28+
}
29+
if (index === lineCount - 1) {
30+
return "the widest generated line";
31+
}
32+
return "x";
33+
}).join("\n") + "\n"
34+
);
35+
}
36+
37+
const lineCount = Number.parseInt(process.argv[2] ?? "100000", 10);
38+
const fixtureKind = process.argv[3] === "tracked" ? "tracked" : "untracked";
39+
if (!Number.isFinite(lineCount) || lineCount <= 0) {
40+
throw new Error("Usage: bun run scripts/test-large-untracked-render.tsx [line-count] [tracked]");
41+
}
42+
43+
const repo = mkdtempSync(join(tmpdir(), "hunk-large-untracked-render-"));
44+
try {
45+
runGit(repo, "init", "--initial-branch", "main");
46+
runGit(repo, "config", "user.name", "Test User");
47+
runGit(repo, "config", "user.email", "test@example.com");
48+
const largePath = fixtureKind === "tracked" ? "large-tracked.txt" : "large-untracked.txt";
49+
writeFileSync(join(repo, "tracked.txt"), "tracked\n");
50+
if (fixtureKind === "tracked") {
51+
writeFileSync(join(repo, largePath), "original\n");
52+
}
53+
runGit(repo, "add", ".");
54+
runGit(repo, "commit", "-m", "initial");
55+
writeFileSync(join(repo, largePath), createLargeFileBody(lineCount));
56+
57+
const bootstrap = await loadAppBootstrap(
58+
{ kind: "vcs", staged: false, options: { mode: "stack" } },
59+
{ cwd: repo },
60+
);
61+
const setup = await testRender(<AppHost bootstrap={bootstrap} />, { width: 120, height: 30 });
62+
63+
try {
64+
await act(async () => {
65+
await setup.renderOnce();
66+
});
67+
68+
const frame = setup.captureCharFrame();
69+
console.log(
70+
JSON.stringify(
71+
{
72+
containsHeader: frame.includes(`@@ -0,0 +1,${lineCount} @@`),
73+
containsPath: frame.includes(largePath),
74+
containsSkippedLargeMessage: frame.includes("File too large to render"),
75+
containsVisibleLine: frame.includes("visible-first-line"),
76+
fileCount: bootstrap.changeset.files.length,
77+
firstFileStats: bootstrap.changeset.files[0]?.stats,
78+
firstFileStatsTruncated: bootstrap.changeset.files[0]?.statsTruncated,
79+
fixtureKind,
80+
lineCount,
81+
renderedFrameBytes: Buffer.byteLength(frame),
82+
},
83+
null,
84+
2,
85+
),
86+
);
87+
} finally {
88+
await act(async () => {
89+
setup.renderer.destroy();
90+
});
91+
}
92+
} finally {
93+
rmSync(repo, { force: true, recursive: true });
94+
}

src/core/git.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ function withNormalizedDiffPrefixes(args: string[]) {
4949
}
5050

5151
/** Build the exact `git diff` arguments used for the shared working-tree and range review path. */
52-
export function buildGitDiffArgs(input: VcsCommandInput) {
52+
export function buildGitDiffArgs(input: VcsCommandInput, excludedPathspecs: string[] = []) {
5353
const args = ["diff", "--no-ext-diff", "--find-renames", "--no-color"];
5454

5555
if (input.staged) {
@@ -60,6 +60,31 @@ export function buildGitDiffArgs(input: VcsCommandInput) {
6060
args.push(input.range);
6161
}
6262

63+
if (excludedPathspecs.length > 0) {
64+
args.push(
65+
"--",
66+
...(input.pathspecs ?? []),
67+
...excludedPathspecs.map((path) => `:(exclude)${path}`),
68+
);
69+
} else {
70+
appendGitPathspecs(args, input.pathspecs);
71+
}
72+
73+
return withNormalizedDiffPrefixes(args);
74+
}
75+
76+
/** Build the cheap tracked-file stats query used to skip huge file diffs before patch output. */
77+
export function buildGitDiffNumstatArgs(input: VcsCommandInput) {
78+
const args = ["diff", "--no-ext-diff", "--find-renames", "--no-color", "--numstat", "-z"];
79+
80+
if (input.staged) {
81+
args.push("--staged");
82+
}
83+
84+
if (input.range) {
85+
args.push(input.range);
86+
}
87+
6388
appendGitPathspecs(args, input.pathspecs);
6489
return withNormalizedDiffPrefixes(args);
6590
}

src/core/loaders.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,71 @@ describe("loadAppBootstrap", () => {
297297
expect(bootstrap.changeset.files[1]?.patch).toContain("new file mode");
298298
});
299299

300+
test("keeps generated large tracked diffs as skipped placeholders", async () => {
301+
const dir = createTempRepo("hunk-git-large-tracked-");
302+
303+
writeFileSync(join(dir, "large.txt"), "original\n");
304+
git(dir, "add", "large.txt");
305+
git(dir, "commit", "-m", "initial");
306+
writeFileSync(join(dir, "large.txt"), `${"x\n".repeat(100_000)}widest generated line\n`);
307+
308+
const bootstrap = await loadFromRepo(dir, {
309+
kind: "vcs",
310+
staged: false,
311+
options: { mode: "auto" },
312+
});
313+
314+
expect(bootstrap.changeset.files).toHaveLength(1);
315+
expect(bootstrap.changeset.files[0]?.path).toBe("large.txt");
316+
expect(bootstrap.changeset.files[0]?.isTooLarge).toBe(true);
317+
expect(bootstrap.changeset.files[0]?.stats).toEqual({ additions: 100_001, deletions: 1 });
318+
expect(bootstrap.changeset.files[0]?.metadata.hunks).toHaveLength(0);
319+
});
320+
321+
test("keeps generated large untracked files as skipped placeholders", async () => {
322+
const dir = createTempRepo("hunk-git-large-untracked-");
323+
324+
writeFileSync(join(dir, "tracked.ts"), "export const value = 1;\n");
325+
git(dir, "add", "tracked.ts");
326+
git(dir, "commit", "-m", "initial");
327+
writeFileSync(join(dir, "large.txt"), `${"x\n".repeat(100_000)}widest generated line\n`);
328+
329+
const bootstrap = await loadFromRepo(dir, {
330+
kind: "vcs",
331+
staged: false,
332+
options: { mode: "auto" },
333+
});
334+
335+
expect(bootstrap.changeset.files).toHaveLength(1);
336+
expect(bootstrap.changeset.files[0]?.path).toBe("large.txt");
337+
expect(bootstrap.changeset.files[0]?.isTooLarge).toBe(true);
338+
expect(bootstrap.changeset.files[0]?.stats).toEqual({ additions: 100_001, deletions: 0 });
339+
expect(bootstrap.changeset.files[0]?.statsTruncated).toBe(false);
340+
expect(bootstrap.changeset.files[0]?.metadata.hunks).toHaveLength(0);
341+
});
342+
343+
test("caps skipped untracked-file stats when byte-size detection would require a full huge read", async () => {
344+
const dir = createTempRepo("hunk-git-byte-large-untracked-");
345+
346+
writeFileSync(join(dir, "tracked.ts"), "export const value = 1;\n");
347+
git(dir, "add", "tracked.ts");
348+
git(dir, "commit", "-m", "initial");
349+
writeFileSync(join(dir, "large-single-line.txt"), "x".repeat(1_000_001));
350+
351+
const bootstrap = await loadFromRepo(dir, {
352+
kind: "vcs",
353+
staged: false,
354+
options: { mode: "auto" },
355+
});
356+
357+
expect(bootstrap.changeset.files).toHaveLength(1);
358+
expect(bootstrap.changeset.files[0]?.path).toBe("large-single-line.txt");
359+
expect(bootstrap.changeset.files[0]?.isTooLarge).toBe(true);
360+
expect(bootstrap.changeset.files[0]?.stats).toEqual({ additions: 1, deletions: 0 });
361+
expect(bootstrap.changeset.files[0]?.statsTruncated).toBe(true);
362+
expect(bootstrap.changeset.files[0]?.metadata.hunks).toHaveLength(0);
363+
});
364+
300365
test("skips untracked symlinks to directories while loading the rest of the review", async () => {
301366
const dir = createTempRepo("hunk-git-untracked-dir-symlink-");
302367

0 commit comments

Comments
 (0)