diff --git a/CHANGELOG.md b/CHANGELOG.md index ee674ae..2ef4289 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 f954dfe..7aa7989 100644 --- a/src/core/git.ts +++ b/src/core/git.ts @@ -295,9 +295,61 @@ 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. + */ +const workingTreeGitDiffInputCache = new Map(); + +function isWorkingTreeGitDiffInput( + input: GitCommandInput, + { + cwd = process.cwd(), + gitExecutable = "git", + repoRoot, + }: Pick & { repoRoot?: string } = {}, +) { + if (input.staged) { + return false; + } + + if (!input.range) { + 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], + cwd, + gitExecutable, + }) + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + + const positiveRevs = revs.filter((line) => !line.startsWith("^")); + const negativeRevs = revs.filter((line) => line.startsWith("^")); + const includesWorkingTree = 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) { - return !input.staged && !input.range && input.options.excludeUntracked !== true; +function shouldIncludeUntrackedFiles( + input: GitCommandInput, + options: Pick & { repoRoot?: string } = {}, +) { + return input.options.excludeUntracked !== true && isWorkingTreeGitDiffInput(input, options); } /** Parse porcelain status output down to repo-root-relative untracked file paths. */ @@ -348,7 +400,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 6e2ddd0..30dfaee 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 adfcfc5..6e3f1e8 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); + }); });