From 50ec7922c97a2118610a41e71817b6bc1758372c Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sat, 18 Apr 2026 21:39:47 -0400 Subject: [PATCH 1/2] fix(git): include untracked files in one-ref diffs --- CHANGELOG.md | 1 + src/core/git.ts | 47 ++++++++++++++++++++++-- src/core/loaders.test.ts | 77 ++++++++++++++++++++++++++++++++++++++++ src/core/watch.test.ts | 40 ++++++++++++++++++--- 4 files changed, 158 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee674ae4..2ef42898 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ All notable user-visible changes to Hunk are documented in this file. ### Fixed +- Included untracked files when `hunk diff ` still compares against the live working tree, while keeping explicit revset diffs commit-to-commit only. - Balanced Pierre word-level highlights so split-view inline changes stay visible without overpowering the surrounding diff row. - Smoothed mouse-wheel review scrolling so small diffs stay precise while sustained wheel gestures still speed up. - Fixed Shift+mouse-wheel horizontal scrolling so it no longer leaks a one-line vertical scroll in some terminals. diff --git a/src/core/git.ts b/src/core/git.ts index f954dfe4..c31071f5 100644 --- a/src/core/git.ts +++ b/src/core/git.ts @@ -295,9 +295,50 @@ export function runGitText(options: RunGitTextOptions) { return runGitCommand(options).stdout; } +/** + * Return whether one `hunk diff` input still compares against the live working tree. + * + * Plain `hunk diff ` keeps the working tree on one side, so untracked files should still + * appear. Explicit revision-set expressions like `a..b`, `a...b`, or `rev^!` expand into positive + * and negative revisions and should stay commit-to-commit only. + */ +function isWorkingTreeGitDiffInput( + input: GitCommandInput, + { + cwd = process.cwd(), + gitExecutable = "git", + }: Pick = {}, +) { + if (input.staged) { + return false; + } + + if (!input.range) { + return true; + } + + const revs = runGitText({ + input, + args: ["rev-parse", "--revs-only", input.range], + cwd, + gitExecutable, + }) + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + + const positiveRevs = revs.filter((line) => !line.startsWith("^")); + const negativeRevs = revs.filter((line) => line.startsWith("^")); + + return positiveRevs.length === 1 && negativeRevs.length === 0; +} + /** Return whether working-tree review should synthesize untracked files into the patch stream. */ -function shouldIncludeUntrackedFiles(input: GitCommandInput) { - return !input.staged && !input.range && input.options.excludeUntracked !== true; +function shouldIncludeUntrackedFiles( + input: GitCommandInput, + options: Pick = {}, +) { + return input.options.excludeUntracked !== true && isWorkingTreeGitDiffInput(input, options); } /** Parse porcelain status output down to repo-root-relative untracked file paths. */ @@ -348,7 +389,7 @@ export function listGitUntrackedFiles( gitExecutable = "git", }: Omit & { repoRoot?: string } = {}, ) { - if (!shouldIncludeUntrackedFiles(input)) { + if (!shouldIncludeUntrackedFiles(input, { cwd, gitExecutable })) { return []; } diff --git a/src/core/loaders.test.ts b/src/core/loaders.test.ts index 6e2ddd0c..30dfaee8 100644 --- a/src/core/loaders.test.ts +++ b/src/core/loaders.test.ts @@ -283,6 +283,83 @@ describe("loadAppBootstrap", () => { expect(bootstrap.changeset.files.map((file) => file.path)).toEqual(["example.ts"]); }); + test("includes untracked files when diff compares the working tree against one ref", async () => { + const dir = createTempRepo("hunk-git-ref-untracked-"); + + writeFileSync(join(dir, "tracked.ts"), "export const tracked = 1;\n"); + git(dir, "add", "tracked.ts"); + git(dir, "commit", "-m", "initial"); + git(dir, "branch", "main"); + + writeFileSync(join(dir, "tracked.ts"), "export const tracked = 2;\n"); + git(dir, "add", "tracked.ts"); + git(dir, "commit", "-m", "second"); + + writeFileSync(join(dir, "tracked.ts"), "export const tracked = 3;\n"); + writeFileSync(join(dir, "new-file.ts"), "export const added = true;\n"); + + const bootstrap = await loadFromRepo(dir, { + kind: "git", + range: "main", + staged: false, + options: { mode: "auto" }, + }); + + expect(bootstrap.changeset.files.map((file) => file.path)).toEqual([ + "tracked.ts", + "new-file.ts", + ]); + }); + + test("excludes untracked files for explicit git ranges that do not include the working tree", async () => { + const dir = createTempRepo("hunk-git-range-no-untracked-"); + + writeFileSync(join(dir, "tracked.ts"), "export const tracked = 1;\n"); + git(dir, "add", "tracked.ts"); + git(dir, "commit", "-m", "initial"); + git(dir, "branch", "main"); + + writeFileSync(join(dir, "tracked.ts"), "export const tracked = 2;\n"); + git(dir, "add", "tracked.ts"); + git(dir, "commit", "-m", "second"); + + writeFileSync(join(dir, "tracked.ts"), "export const tracked = 3;\n"); + writeFileSync(join(dir, "new-file.ts"), "export const added = true;\n"); + + const bootstrap = await loadFromRepo(dir, { + kind: "git", + range: "main..HEAD", + staged: false, + options: { mode: "auto" }, + }); + + expect(bootstrap.changeset.files.map((file) => file.path)).toEqual(["tracked.ts"]); + }); + + test("excludes untracked files for revset diffs like HEAD^! that do not include the working tree", async () => { + const dir = createTempRepo("hunk-git-revset-no-untracked-"); + + writeFileSync(join(dir, "tracked.ts"), "export const tracked = 1;\n"); + git(dir, "add", "tracked.ts"); + git(dir, "commit", "-m", "initial"); + + writeFileSync(join(dir, "tracked.ts"), "export const tracked = 2;\n"); + git(dir, "add", "tracked.ts"); + git(dir, "commit", "-m", "second"); + + writeFileSync(join(dir, "tracked.ts"), "export const tracked = 3;\n"); + writeFileSync(join(dir, "new-file.ts"), "export const added = true;\n"); + + const bootstrap = await loadFromRepo(dir, { + kind: "git", + range: "HEAD^!", + staged: false, + options: { mode: "auto" }, + }); + + expect(bootstrap.changeset.files.map((file) => file.path)).toEqual(["tracked.ts"]); + }); + test("loads untracked files whose names need parser-safe diff headers", async () => { const dir = createTempRepo("hunk-git-quoted-untracked-"); diff --git a/src/core/watch.test.ts b/src/core/watch.test.ts index adfcfc5a..6e3f1e8e 100644 --- a/src/core/watch.test.ts +++ b/src/core/watch.test.ts @@ -54,13 +54,19 @@ function withCwd(cwd: string, callback: () => T) { } } -function createGitInput(overrides: Partial["options"]> = {}) { +function createGitInput({ + options, + ...overrides +}: { + options?: Partial["options"]>; +} & Partial, "kind" | "options">> = {}) { return { kind: "git", staged: false, + ...overrides, options: { mode: "auto", - ...overrides, + ...options, }, } satisfies Extract; } @@ -101,13 +107,39 @@ describe("computeWatchSignature", () => { writeFileSync(untrackedPath, "first\n"); const initialSignature = withCwd(dir, () => - computeWatchSignature(createGitInput({ excludeUntracked: true })), + computeWatchSignature(createGitInput({ options: { excludeUntracked: true } })), ); writeFileSync(untrackedPath, "second\n"); const changedSignature = withCwd(dir, () => - computeWatchSignature(createGitInput({ excludeUntracked: true })), + computeWatchSignature(createGitInput({ options: { excludeUntracked: true } })), ); expect(changedSignature).toEqual(initialSignature); }); + + test("tracks untracked file changes when diff compares the working tree against one ref", () => { + const dir = createTempRepo("hunk-watch-ref-untracked-"); + + writeFileSync(join(dir, "tracked.ts"), "export const tracked = 1;\n"); + git(dir, "add", "tracked.ts"); + git(dir, "commit", "-m", "initial"); + git(dir, "branch", "main"); + + writeFileSync(join(dir, "tracked.ts"), "export const tracked = 2;\n"); + git(dir, "add", "tracked.ts"); + git(dir, "commit", "-m", "second"); + + const untrackedPath = join(dir, "note.txt"); + writeFileSync(untrackedPath, "first\n"); + + const initialSignature = withCwd(dir, () => + computeWatchSignature(createGitInput({ range: "main" })), + ); + writeFileSync(untrackedPath, "second\n"); + const changedSignature = withCwd(dir, () => + computeWatchSignature(createGitInput({ range: "main" })), + ); + + expect(changedSignature).not.toEqual(initialSignature); + }); }); From b92a9779269f214b5075d5709993f2484eed92c4 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sat, 18 Apr 2026 22:20:16 -0400 Subject: [PATCH 2/2] perf(git): cache one-ref working tree checks --- src/core/git.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/core/git.ts b/src/core/git.ts index c31071f5..7aa79891 100644 --- a/src/core/git.ts +++ b/src/core/git.ts @@ -302,12 +302,15 @@ export function runGitText(options: RunGitTextOptions) { * appear. Explicit revision-set expressions like `a..b`, `a...b`, or `rev^!` expand into positive * and negative revisions and should stay commit-to-commit only. */ +const workingTreeGitDiffInputCache = new Map(); + function isWorkingTreeGitDiffInput( input: GitCommandInput, { cwd = process.cwd(), gitExecutable = "git", - }: Pick = {}, + repoRoot, + }: Pick & { repoRoot?: string } = {}, ) { if (input.staged) { return false; @@ -317,6 +320,12 @@ function isWorkingTreeGitDiffInput( return true; } + const cacheKey = `${gitExecutable}\0${repoRoot ?? cwd}\0${input.range}`; + const cached = workingTreeGitDiffInputCache.get(cacheKey); + if (cached !== undefined) { + return cached; + } + const revs = runGitText({ input, args: ["rev-parse", "--revs-only", input.range], @@ -329,14 +338,16 @@ function isWorkingTreeGitDiffInput( const positiveRevs = revs.filter((line) => !line.startsWith("^")); const negativeRevs = revs.filter((line) => line.startsWith("^")); + const includesWorkingTree = positiveRevs.length === 1 && negativeRevs.length === 0; - return positiveRevs.length === 1 && negativeRevs.length === 0; + workingTreeGitDiffInputCache.set(cacheKey, includesWorkingTree); + return includesWorkingTree; } /** Return whether working-tree review should synthesize untracked files into the patch stream. */ function shouldIncludeUntrackedFiles( input: GitCommandInput, - options: Pick = {}, + options: Pick & { repoRoot?: string } = {}, ) { return input.options.excludeUntracked !== true && isWorkingTreeGitDiffInput(input, options); }