diff --git a/AGENTS.md b/AGENTS.md index fea88081..a66bf8ec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,7 +64,7 @@ CLI input - Keep note behavior explicit. If the UI intentionally prioritizes one note, one selection, or one active target, encode that as a named policy rather than scattering array-index assumptions through the codebase. - If you choose to use a local sidecar for temporary review context, keep it concise and review-oriented: one changeset summary, file summaries in narrative order, and a few hunk-level annotations with real rationale. - If a local sidecar is present, its file order is intentional, but the visible note UI should stay hunk-note driven rather than showing generic file or changeset explainer cards. -- If newly created files should appear in `hunk diff` before commit, use `git add -N ` so they show up in the review stream without staging content. +- `hunk diff` working-tree reviews include untracked files by default. Use `--exclude-untracked` if you explicitly want tracked changes only. ## commands diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 94bc9dca..3b8b8c59 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -116,7 +116,7 @@ Key rules: - Keep scope tight and explain user-visible behavior changes clearly. - Update docs and examples when behavior or workflows change. - If you want temporary local review notes, you can use `.hunk/latest.json`, but do not commit it. -- If newly created files should appear in `hunk diff` before commit, use `git add -N `. +- `hunk diff` includes untracked working-tree files by default. Use `--exclude-untracked` if you want to review tracked changes only. ## Release notes diff --git a/README.md b/README.md index e7147795..1a25833e 100644 --- a/README.md +++ b/README.md @@ -51,11 +51,12 @@ hunk --version # print the installed version Hunk mirrors Git's diff-style commands, but opens the changeset in a review UI instead of plain text. ```bash -hunk diff # review current repo changes +hunk diff # review current repo changes, including untracked files +hunk diff --exclude-untracked # limit working tree review to tracked files only hunk diff --staged -hunk diff --watch # auto-reload as the working tree changes -hunk show # review the latest commit -hunk show HEAD~1 # review an earlier commit +hunk diff --watch # auto-reload as the working tree changes +hunk show # review the latest commit +hunk show HEAD~1 # review an earlier commit ``` ### Working with raw files and patches @@ -100,17 +101,23 @@ You can persist preferences to a config file: Example: ```toml -theme = "graphite" # graphite, midnight, paper, ember -mode = "auto" # auto, split, stack +theme = "graphite" # graphite, midnight, paper, ember +mode = "auto" # auto, split, stack +exclude_untracked = false line_numbers = true wrap_lines = false agent_notes = false ``` +`exclude_untracked` affects working-tree `hunk diff` sessions only. + ### Git integration Set Hunk as your Git pager so `git diff` and `git show` open in Hunk automatically: +> [!NOTE] +> Untracked files are auto-included only for Hunk's own `hunk diff` working-tree loader. If you open `git diff` through `hunk pager`, Git still decides the patch contents, so untracked files will not appear there. + ```bash git config --global core.pager "hunk pager" ``` diff --git a/skills/hunk-review/SKILL.md b/skills/hunk-review/SKILL.md index 1c259660..29afa7ef 100644 --- a/skills/hunk-review/SKILL.md +++ b/skills/hunk-review/SKILL.md @@ -74,11 +74,10 @@ hunk session comment clear --repo . --yes [--file README.md] ## New files in working-tree reviews -Newly created files won't appear in `hunk diff` until Git knows about them: +`hunk diff` includes untracked files by default. If the user wants tracked changes only, reload with `--exclude-untracked`: ```bash -git add -N src/new-file.ts -hunk session reload --repo . -- diff +hunk session reload --repo . -- diff --exclude-untracked ``` ## Guiding a review diff --git a/src/core/cli.ts b/src/core/cli.ts index 26d80952..e086e854 100644 --- a/src/core/cli.ts +++ b/src/core/cli.ts @@ -1,6 +1,6 @@ import { existsSync, readFileSync, statSync } from "node:fs"; import { dirname, resolve } from "node:path"; -import { Command } from "commander"; +import { Command, Option } from "commander"; import type { CliInput, CommonOptions, @@ -64,6 +64,7 @@ function buildCommonOptions( agentContext: options.agentContext, pager: options.pager ? true : undefined, watch: options.watch ? true : undefined, + excludeUntracked: resolveBooleanFlag(argv, "--exclude-untracked", "--no-exclude-untracked"), lineNumbers: resolveBooleanFlag(argv, "--line-numbers", "--no-line-numbers"), wrapLines: resolveBooleanFlag(argv, "--wrap", "--no-wrap"), hunkHeaders: resolveBooleanFlag(argv, "--hunk-headers", "--no-hunk-headers"), @@ -235,6 +236,13 @@ async function parseDiffCommand(tokens: string[], argv: string[]): Promise): CommonOptions { return { mode: normalizeLayoutMode(source.mode), theme: normalizeString(source.theme), + excludeUntracked: normalizeBoolean(source.exclude_untracked), lineNumbers: normalizeBoolean(source.line_numbers), wrapLines: normalizeBoolean(source.wrap_lines), hunkHeaders: normalizeBoolean(source.hunk_headers), @@ -61,6 +62,7 @@ function mergeOptions(base: CommonOptions, overrides: CommonOptions): CommonOpti agentContext: overrides.agentContext ?? base.agentContext, pager: overrides.pager ?? base.pager, watch: overrides.watch ?? base.watch, + excludeUntracked: overrides.excludeUntracked ?? base.excludeUntracked, lineNumbers: overrides.lineNumbers ?? base.lineNumbers, wrapLines: overrides.wrapLines ?? base.wrapLines, hunkHeaders: overrides.hunkHeaders ?? base.hunkHeaders, @@ -145,6 +147,7 @@ export function resolveConfiguredCliInput( agentContext: input.options.agentContext, pager: input.options.pager ?? false, watch: input.options.watch ?? false, + excludeUntracked: false, lineNumbers: DEFAULT_VIEW_PREFERENCES.showLineNumbers, wrapLines: DEFAULT_VIEW_PREFERENCES.wrapLines, hunkHeaders: DEFAULT_VIEW_PREFERENCES.showHunkHeaders, @@ -171,6 +174,7 @@ export function resolveConfiguredCliInput( agentContext: input.options.agentContext, pager: input.options.pager ?? false, watch: input.options.watch ?? false, + excludeUntracked: resolvedOptions.excludeUntracked ?? false, mode: resolvedOptions.mode ?? DEFAULT_VIEW_PREFERENCES.mode, lineNumbers: resolvedOptions.lineNumbers ?? DEFAULT_VIEW_PREFERENCES.showLineNumbers, wrapLines: resolvedOptions.wrapLines ?? DEFAULT_VIEW_PREFERENCES.wrapLines, diff --git a/src/core/git.ts b/src/core/git.ts index e4280426..4ef2cb36 100644 --- a/src/core/git.ts +++ b/src/core/git.ts @@ -10,6 +10,15 @@ export interface RunGitTextOptions { gitExecutable?: string; } +interface RunGitCommandResult { + stdout: string; + exitCode: number; +} + +interface RunGitCommandOptions extends RunGitTextOptions { + acceptedExitCodes?: number[]; +} + /** Append Git pathspec arguments only when the caller requested them. */ export function appendGitPathspecs(args: string[], pathspecs?: string[]) { if (!pathspecs || pathspecs.length === 0) { @@ -53,6 +62,26 @@ export function buildGitDiffArgs(input: GitCommandInput) { return withNormalizedDiffPrefixes(args); } +/** Build the porcelain status query used to discover untracked files for working-tree review. */ +function buildGitStatusArgs(input: GitCommandInput) { + const args = ["status", "--porcelain=v1", "-z", "--untracked-files=all"]; + + appendGitPathspecs(args, input.pathspecs); + return args; +} + +/** Build the synthetic patch used to render one untracked file as a new-file diff. */ +function buildGitNewFileDiffArgs(filePath: string) { + return withNormalizedDiffPrefixes([ + "diff", + "--no-index", + "--no-color", + "--", + "/dev/null", + filePath, + ]); +} + /** Build the exact `git show` arguments used for commit review. */ export function buildGitShowArgs(input: ShowCommandInput) { const args = ["show", "--format=", "--no-ext-diff", "--find-renames", "--no-color"]; @@ -222,13 +251,14 @@ function translateGitExitFailure(input: GitBackedInput, stderr: string) { return createGenericGitError(input, stderr); } -/** Run a git command and translate common failures into user-facing Hunk errors. */ -export function runGitText({ +/** Spawn one Git command and accept only the exit codes the caller declared as non-errors. */ +function runGitCommand({ input, args, cwd = process.cwd(), gitExecutable = "git", -}: RunGitTextOptions) { + acceptedExitCodes = [0], +}: RunGitCommandOptions): RunGitCommandResult { let proc: ReturnType; try { @@ -245,14 +275,75 @@ export function runGitText({ const stdout = Buffer.from(proc.stdout ?? []).toString("utf8"); const stderr = Buffer.from(proc.stderr ?? []).toString("utf8"); - if (proc.exitCode !== 0) { + if (!acceptedExitCodes.includes(proc.exitCode)) { throw translateGitExitFailure( input, stderr.trim() || `Command failed: ${gitExecutable} ${args.join(" ")}`, ); } - return stdout; + return { + stdout, + exitCode: proc.exitCode, + }; +} + +/** Run a git command and translate common failures into user-facing Hunk errors. */ +export function runGitText(options: RunGitTextOptions) { + return runGitCommand(options).stdout; +} + +/** 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; +} + +/** Parse porcelain status output down to repo-root-relative untracked file paths. */ +function parseUntrackedFilePaths(statusText: string) { + return statusText + .split("\0") + .filter(Boolean) + .flatMap((entry) => (entry.startsWith("?? ") ? [entry.slice(3)] : [])); +} + +/** Return the repo-root-relative untracked files for a working-tree review input. */ +export function listGitUntrackedFiles( + input: GitCommandInput, + { cwd = process.cwd(), gitExecutable = "git" }: Omit = {}, +) { + if (!shouldIncludeUntrackedFiles(input)) { + return []; + } + + const statusText = runGitText({ + input, + args: buildGitStatusArgs(input), + cwd, + gitExecutable, + }); + + return parseUntrackedFilePaths(statusText); +} + +/** Return the raw Git patch text for one untracked file using `git diff --no-index`. */ +export function runGitUntrackedFileDiffText( + input: GitCommandInput, + filePath: string, + { + cwd = process.cwd(), + repoRoot, + gitExecutable = "git", + }: Omit & { repoRoot?: string } = {}, +) { + const normalizedRepoRoot = repoRoot ?? resolveGitRepoRoot(input, { cwd, gitExecutable }); + + return runGitCommand({ + input, + args: buildGitNewFileDiffArgs(filePath), + cwd: normalizedRepoRoot, + gitExecutable, + acceptedExitCodes: [0, 1], + }).stdout; } export function resolveGitRepoRoot( diff --git a/src/core/loaders.ts b/src/core/loaders.ts index d041cd12..967904e2 100644 --- a/src/core/loaders.ts +++ b/src/core/loaders.ts @@ -11,8 +11,10 @@ import { buildGitDiffArgs, buildGitShowArgs, buildGitStashShowArgs, + listGitUntrackedFiles, resolveGitRepoRoot, runGitText, + runGitUntrackedFileDiffText, } from "./git"; import type { AppBootstrap, @@ -146,6 +148,91 @@ function buildDiffFile( }; } +/** Escape only the filename characters that break unified-diff header parsing. */ +function escapeUntrackedPatchPath(path: string) { + return path + .replaceAll("\\", "\\\\") + .replaceAll("\t", "\\t") + .replaceAll("\n", "\\n") + .replaceAll("\r", "\\r"); +} + +/** Rewrite Git's quoted untracked-file headers into parser-friendly paths. */ +function normalizeUntrackedPatchHeaders(patchText: string, filePath: string) { + const safePath = escapeUntrackedPatchPath(filePath); + + return patchText + .replaceAll("\r\n", "\n") + .split("\n") + .map((line) => { + if (line.startsWith("diff --git ")) { + return `diff --git a/${safePath} b/${safePath}`; + } + + if (line.startsWith("+++ ")) { + return `+++ b/${safePath}`; + } + + if (line.startsWith("Binary files /dev/null and ")) { + return `Binary files /dev/null and b/${safePath} differ`; + } + + return line; + }) + .join("\n"); +} + +/** Parse one synthetic untracked-file patch and reattach the real path after header normalization. */ +function parseUntrackedPatchFile(patchText: string, filePath: string) { + let parsedPatches: ReturnType; + + try { + parsedPatches = parsePatchFiles(patchText, "patch", true); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `Failed to parse untracked file patch for ${JSON.stringify(filePath)}: ${message}`, + ); + } + + const metadataFiles = parsedPatches.flatMap((entry) => entry.files); + if (metadataFiles.length !== 1) { + throw new Error( + `Expected one parsed file for untracked patch ${JSON.stringify(filePath)}, got ${metadataFiles.length}.`, + ); + } + + const metadata = metadataFiles[0]!; + return { + ...metadata, + name: filePath, + prevName: undefined, + } satisfies FileDiffMetadata; +} + +/** Build one reviewable diff file for an untracked working-tree file. */ +function buildUntrackedDiffFile( + input: GitCommandInput, + filePath: string, + index: number, + repoRoot: string, + sourcePrefix: string, + agentContext: AgentContext | null, +) { + const patch = normalizeUntrackedPatchHeaders( + runGitUntrackedFileDiffText(input, filePath, { repoRoot }), + filePath, + ); + + return buildDiffFile( + parseUntrackedPatchFile(patch, filePath), + patch, + index, + sourcePrefix, + agentContext, + ); +} + /** Reorder files to follow agent-context narrative order when a sidecar provides one. */ function orderDiffFiles(files: DiffFile[], agentContext: AgentContext | null) { if (!agentContext || agentContext.files.length === 0) { @@ -276,14 +363,40 @@ async function loadFileDiffChangeset( async function loadGitChangeset(input: GitCommandInput, agentContext: AgentContext | null) { const repoRoot = resolveGitRepoRoot(input); const repoName = basename(repoRoot); - const patchText = runGitText({ input, args: buildGitDiffArgs(input) }); const title = input.staged ? `${repoName} staged changes` : input.range ? `${repoName} ${input.range}` : `${repoName} working tree`; + const trackedChangeset = normalizePatchChangeset( + runGitText({ input, args: buildGitDiffArgs(input) }), + title, + repoRoot, + agentContext, + ); + const trackedFiles = trackedChangeset.files; + const untrackedFiles = listGitUntrackedFiles(input); - return normalizePatchChangeset(patchText, title, repoRoot, agentContext); + if (untrackedFiles.length === 0) { + return trackedChangeset; + } + + return { + ...trackedChangeset, + files: [ + ...trackedFiles, + ...untrackedFiles.map((filePath, index) => + buildUntrackedDiffFile( + input, + filePath, + trackedFiles.length + index, + repoRoot, + repoRoot, + agentContext, + ), + ), + ], + } satisfies Changeset; } /** Build a changeset from `git show`, suppressing commit-message chrome so only the patch feeds the UI. */ diff --git a/src/core/types.ts b/src/core/types.ts index 5175c460..545d974a 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -56,6 +56,7 @@ export interface CommonOptions { agentContext?: string; pager?: boolean; watch?: boolean; + excludeUntracked?: boolean; lineNumbers?: boolean; wrapLines?: boolean; hunkHeaders?: boolean; diff --git a/src/core/watch.ts b/src/core/watch.ts index 14d90b84..edfc1882 100644 --- a/src/core/watch.ts +++ b/src/core/watch.ts @@ -1,5 +1,13 @@ import fs from "node:fs"; -import { buildGitDiffArgs, buildGitShowArgs, buildGitStashShowArgs, runGitText } from "./git"; +import { join } from "node:path"; +import { + buildGitDiffArgs, + buildGitShowArgs, + buildGitStashShowArgs, + listGitUntrackedFiles, + resolveGitRepoRoot, + runGitText, +} from "./git"; import type { CliInput } from "./types"; /** Return whether the current input can be rebuilt from files or Git state without rereading stdin. */ @@ -21,11 +29,22 @@ function statSignature(path: string) { return `${path}:${stat.size}:${stat.mtimeMs}:${stat.ino}`; } +/** Build the cheaper watch signature for working-tree git diff inputs without rendering full untracked patches. */ +function gitWorkingTreeWatchSignature(input: Extract) { + const trackedPatch = runGitText({ input, args: buildGitDiffArgs(input) }); + const repoRoot = resolveGitRepoRoot(input); + const untrackedSignatures = listGitUntrackedFiles(input).map( + (filePath) => `untracked:${statSignature(join(repoRoot, filePath))}`, + ); + + return [trackedPatch, ...untrackedSignatures].join("\n---\n"); +} + /** Build one exact patch signature for Git-backed review inputs. */ function gitPatchSignature(input: Extract) { switch (input.kind) { case "git": - return runGitText({ input, args: buildGitDiffArgs(input) }); + return gitWorkingTreeWatchSignature(input); case "show": return runGitText({ input, args: buildGitShowArgs(input) }); case "stash-show": diff --git a/test/cli.test.ts b/test/cli.test.ts index e5ec9abf..df1e6fe9 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -96,6 +96,26 @@ describe("parseCli", () => { expect(cached).toMatchObject({ kind: "git", staged: true }); }); + test("parses untracked file toggles for git diff", async () => { + const excluded = await parseCli(["bun", "hunk", "diff", "--exclude-untracked"]); + const included = await parseCli(["bun", "hunk", "diff", "--no-exclude-untracked"]); + + expect(excluded).toMatchObject({ + kind: "git", + staged: false, + options: { + excludeUntracked: true, + }, + }); + expect(included).toMatchObject({ + kind: "git", + staged: false, + options: { + excludeUntracked: false, + }, + }); + }); + test("keeps two concrete file paths as file-pair diff mode", async () => { const dir = createTempDir("hunk-cli-files-"); const left = join(dir, "before.ts"); diff --git a/test/config.test.ts b/test/config.test.ts index 3621a375..6dc0892d 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -119,6 +119,43 @@ describe("config resolution", () => { expect(resolved.input.options.lineNumbers).toBe(false); }); + test("defaults git diff to include untracked files and honors config plus CLI overrides", () => { + const home = createTempDir("hunk-config-home-"); + mkdirSync(join(home, ".config", "hunk"), { recursive: true }); + writeFileSync(join(home, ".config", "hunk", "config.toml"), "exclude_untracked = true\n"); + + const cwd = createTempDir("hunk-config-cwd-"); + const defaultResolved = resolveConfiguredCliInput( + { + kind: "git", + staged: false, + options: {}, + }, + { cwd, env: { HOME: home } }, + ); + const overriddenResolved = resolveConfiguredCliInput( + { + kind: "git", + staged: false, + options: { excludeUntracked: false }, + }, + { cwd, env: { HOME: home } }, + ); + const noConfigHome = createTempDir("hunk-config-home-"); + const fallbackResolved = resolveConfiguredCliInput( + { + kind: "git", + staged: false, + options: {}, + }, + { cwd, env: { HOME: noConfigHome } }, + ); + + expect(defaultResolved.input.options.excludeUntracked).toBe(true); + expect(overriddenResolved.input.options.excludeUntracked).toBe(false); + expect(fallbackResolved.input.options.excludeUntracked).toBe(false); + }); + test("loadAppBootstrap exposes resolved initial preferences to the UI", async () => { const home = createTempDir("hunk-config-home-"); const repo = createTempDir("hunk-config-repo-"); diff --git a/test/loaders.test.ts b/test/loaders.test.ts index 1ec92275..79f7ef4a 100644 --- a/test/loaders.test.ts +++ b/test/loaders.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { loadAppBootstrap } from "../src/core/loaders"; @@ -43,9 +43,9 @@ function createTempRepo(prefix: string) { return dir; } -async function loadFromRepo(dir: string, input: CliInput) { +async function loadFromCwd(cwd: string, input: CliInput) { const previousCwd = process.cwd(); - process.chdir(dir); + process.chdir(cwd); try { return await loadAppBootstrap(input); @@ -54,6 +54,10 @@ async function loadFromRepo(dir: string, input: CliInput) { } } +async function loadFromRepo(dir: string, input: CliInput) { + return loadFromCwd(dir, input); +} + afterEach(() => { cleanupTempDirs(); }); @@ -119,6 +123,118 @@ describe("loadAppBootstrap", () => { expect(bootstrap.changeset.files[0]?.stats.additions).toBeGreaterThan(0); }); + test("includes untracked files in working tree reviews by default", async () => { + const dir = createTempRepo("hunk-git-untracked-"); + + writeFileSync(join(dir, "example.ts"), "export const value = 1;\n"); + git(dir, "add", "example.ts"); + git(dir, "commit", "-m", "initial"); + + writeFileSync(join(dir, "example.ts"), "export const value = 2;\n"); + writeFileSync(join(dir, "new-file.ts"), "export const added = true;\n"); + + const bootstrap = await loadFromRepo(dir, { + kind: "git", + staged: false, + options: { mode: "auto" }, + }); + + expect(bootstrap.changeset.files.map((file) => file.path)).toEqual([ + "example.ts", + "new-file.ts", + ]); + expect(bootstrap.changeset.files[1]?.patch).toContain("new file mode"); + }); + + test("can exclude untracked files from working tree reviews", async () => { + const dir = createTempRepo("hunk-git-no-untracked-"); + + writeFileSync(join(dir, "example.ts"), "export const value = 1;\n"); + git(dir, "add", "example.ts"); + git(dir, "commit", "-m", "initial"); + + writeFileSync(join(dir, "example.ts"), "export const value = 2;\n"); + writeFileSync(join(dir, "new-file.ts"), "export const added = true;\n"); + + const bootstrap = await loadFromRepo(dir, { + kind: "git", + staged: false, + options: { mode: "auto", excludeUntracked: true }, + }); + + expect(bootstrap.changeset.files.map((file) => file.path)).toEqual(["example.ts"]); + }); + + test("loads untracked files whose names need parser-safe diff headers", async () => { + const dir = createTempRepo("hunk-git-quoted-untracked-"); + + writeFileSync(join(dir, "tracked.ts"), "export const tracked = 1;\n"); + git(dir, "add", "tracked.ts"); + git(dir, "commit", "-m", "initial"); + + const quoteFile = 'quote"name.txt'; + const tabFile = "tab\tname.txt"; + const backslashFile = "back\\slash.txt"; + writeFileSync(join(dir, quoteFile), "quote\n"); + writeFileSync(join(dir, tabFile), "tab\n"); + writeFileSync(join(dir, backslashFile), "backslash\n"); + + const bootstrap = await loadFromRepo(dir, { + kind: "git", + staged: false, + options: { mode: "auto" }, + }); + const paths = bootstrap.changeset.files.map((file) => file.path); + + expect(paths).toContain(quoteFile); + expect(paths).toContain(tabFile); + expect(paths).toContain(backslashFile); + expect(paths).toHaveLength(3); + }); + + test("still shows an untracked agent sidecar when it lives inside the repo", async () => { + const dir = createTempRepo("hunk-git-agent-sidecar-"); + + writeFileSync(join(dir, "example.ts"), "export const value = 1;\n"); + git(dir, "add", "example.ts"); + git(dir, "commit", "-m", "initial"); + + writeFileSync(join(dir, "example.ts"), "export const value = 2;\n"); + const agent = join(dir, "agent.json"); + writeFileSync(agent, JSON.stringify({ version: 1, files: [] })); + + const bootstrap = await loadFromRepo(dir, { + kind: "git", + staged: false, + options: { mode: "auto", agentContext: agent }, + }); + + expect(bootstrap.changeset.files.map((file) => file.path)).toEqual([ + "example.ts", + "agent.json", + ]); + }); + + test("includes repo-wide untracked files even when launched from a subdirectory", async () => { + const dir = createTempRepo("hunk-git-subdir-untracked-"); + + writeFileSync(join(dir, "tracked.ts"), "export const tracked = 1;\n"); + git(dir, "add", "tracked.ts"); + git(dir, "commit", "-m", "initial"); + + writeFileSync(join(dir, "new-root.ts"), "export const root = true;\n"); + const subdir = join(dir, "nested"); + mkdirSync(subdir, { recursive: true }); + + const bootstrap = await loadFromCwd(subdir, { + kind: "git", + staged: false, + options: { mode: "auto" }, + }); + + expect(bootstrap.changeset.files.map((file) => file.path)).toContain("new-root.ts"); + }); + test("loads git working tree changes when diff.noprefix is enabled", async () => { const dir = createTempRepo("hunk-git-noprefix-"); @@ -128,6 +244,7 @@ describe("loadAppBootstrap", () => { git(dir, "config", "--local", "diff.noprefix", "true"); writeFileSync(join(dir, "example.ts"), "export const value = 2;\nexport const extra = true;\n"); + writeFileSync(join(dir, "new-file.ts"), "export const added = true;\n"); const bootstrap = await loadFromRepo(dir, { kind: "git", @@ -135,8 +252,10 @@ describe("loadAppBootstrap", () => { options: { mode: "auto" }, }); - expect(bootstrap.changeset.files).toHaveLength(1); - expect(bootstrap.changeset.files[0]?.path).toBe("example.ts"); + expect(bootstrap.changeset.files.map((file) => file.path)).toEqual([ + "example.ts", + "new-file.ts", + ]); }); test("loads git working tree changes when diff.mnemonicPrefix is enabled", async () => { @@ -148,6 +267,7 @@ describe("loadAppBootstrap", () => { git(dir, "config", "--local", "diff.mnemonicPrefix", "true"); writeFileSync(join(dir, "example.ts"), "export const value = 2;\nexport const extra = true;\n"); + writeFileSync(join(dir, "new-file.ts"), "export const added = true;\n"); const bootstrap = await loadFromRepo(dir, { kind: "git", @@ -155,8 +275,10 @@ describe("loadAppBootstrap", () => { options: { mode: "auto" }, }); - expect(bootstrap.changeset.files).toHaveLength(1); - expect(bootstrap.changeset.files[0]?.path).toBe("example.ts"); + expect(bootstrap.changeset.files.map((file) => file.path)).toEqual([ + "example.ts", + "new-file.ts", + ]); }); test("reports a friendly error when git review runs outside a repository", async () => { @@ -200,7 +322,9 @@ describe("loadAppBootstrap", () => { writeFileSync(join(dir, "alpha.ts"), "export const alpha = 2;\n"); writeFileSync(join(dir, "beta.ts"), "export const beta = 2;\n"); - const agent = join(dir, "agent.json"); + const agentDir = mkdtempSync(join(tmpdir(), "hunk-agent-")); + tempDirs.push(agentDir); + const agent = join(agentDir, "agent.json"); writeFileSync( agent, JSON.stringify({ @@ -297,6 +421,26 @@ describe("loadAppBootstrap", () => { expect(bootstrap.changeset.files.map((file) => file.path)).toEqual(["beta.ts"]); }); + test("applies pathspec filtering to untracked files in working tree reviews", async () => { + const dir = createTempRepo("hunk-git-untracked-pathspec-"); + + writeFileSync(join(dir, "tracked.ts"), "export const tracked = 1;\n"); + git(dir, "add", "tracked.ts"); + git(dir, "commit", "-m", "initial"); + + writeFileSync(join(dir, "alpha.ts"), "export const alpha = true;\n"); + writeFileSync(join(dir, "beta.ts"), "export const beta = true;\n"); + + const bootstrap = await loadFromRepo(dir, { + kind: "git", + staged: false, + pathspecs: ["beta.ts"], + options: { mode: "auto" }, + }); + + expect(bootstrap.changeset.files.map((file) => file.path)).toEqual(["beta.ts"]); + }); + test("loads show output for the latest commit and an explicit ref", async () => { const dir = createTempRepo("hunk-show-"); diff --git a/test/watch.test.ts b/test/watch.test.ts new file mode 100644 index 00000000..b5df8e1f --- /dev/null +++ b/test/watch.test.ts @@ -0,0 +1,113 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { computeWatchSignature } from "../src/core/watch"; +import type { CliInput } from "../src/core/types"; + +const tempDirs: string[] = []; + +function cleanupTempDirs() { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + rmSync(dir, { recursive: true, force: true }); + } + } +} + +function git(cwd: string, ...cmd: string[]) { + const proc = Bun.spawnSync(["git", ...cmd], { + cwd, + stdout: "pipe", + stderr: "pipe", + stdin: "ignore", + }); + + if (proc.exitCode !== 0) { + const stderr = Buffer.from(proc.stderr).toString("utf8"); + throw new Error(stderr.trim() || `git ${cmd.join(" ")} failed`); + } + + return Buffer.from(proc.stdout).toString("utf8"); +} + +function createTempRepo(prefix: string) { + const dir = mkdtempSync(join(tmpdir(), prefix)); + tempDirs.push(dir); + + git(dir, "init"); + git(dir, "config", "user.name", "Test User"); + git(dir, "config", "user.email", "test@example.com"); + + return dir; +} + +function withCwd(cwd: string, callback: () => T) { + const previousCwd = process.cwd(); + process.chdir(cwd); + + try { + return callback(); + } finally { + process.chdir(previousCwd); + } +} + +function createGitInput(overrides: Partial["options"]> = {}) { + return { + kind: "git", + staged: false, + options: { + mode: "auto", + ...overrides, + }, + } satisfies Extract; +} + +afterEach(() => { + cleanupTempDirs(); +}); + +describe("computeWatchSignature", () => { + test("does not embed full untracked file contents in git watch signatures", () => { + const dir = createTempRepo("hunk-watch-untracked-"); + + writeFileSync(join(dir, "tracked.ts"), "export const tracked = 1;\n"); + git(dir, "add", "tracked.ts"); + git(dir, "commit", "-m", "initial"); + + const largeMarker = "UNTRACKED-CONTENT-".repeat(1024); + const untrackedPath = join(dir, "large-untracked.txt"); + writeFileSync(untrackedPath, largeMarker); + + const initialSignature = withCwd(dir, () => computeWatchSignature(createGitInput())); + writeFileSync(untrackedPath, `${largeMarker}changed`); + const changedSignature = withCwd(dir, () => computeWatchSignature(createGitInput())); + + expect(initialSignature).not.toContain(largeMarker); + expect(changedSignature).not.toContain(largeMarker); + expect(changedSignature).not.toEqual(initialSignature); + }); + + test("ignores untracked file changes when the git input excludes them", () => { + const dir = createTempRepo("hunk-watch-exclude-untracked-"); + + writeFileSync(join(dir, "tracked.ts"), "export const tracked = 1;\n"); + git(dir, "add", "tracked.ts"); + git(dir, "commit", "-m", "initial"); + + const untrackedPath = join(dir, "note.txt"); + writeFileSync(untrackedPath, "first\n"); + + const initialSignature = withCwd(dir, () => + computeWatchSignature(createGitInput({ excludeUntracked: true })), + ); + writeFileSync(untrackedPath, "second\n"); + const changedSignature = withCwd(dir, () => + computeWatchSignature(createGitInput({ excludeUntracked: true })), + ); + + expect(changedSignature).toEqual(initialSignature); + }); +});