From e0ecf6f305df92710e608b28a02070145450cb92 Mon Sep 17 00:00:00 2001 From: Amir Date: Tue, 19 May 2026 11:03:58 +0200 Subject: [PATCH] feat(diff): default `dunk diff` to staged + unstaged, add --unstaged MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `dunk diff` compared the working tree against the index, so anything already `git add`ed silently dropped out of the review. Default it to working tree vs `HEAD` (range "HEAD") so one `dunk diff` shows everything since the last commit. `--staged`/`--cached` keeps index-vs-`HEAD`; new `--unstaged` scopes to working-tree-vs-index. Working-tree scope stays encoded purely as the comparison base — no new input field. `classifyVcsScope` is the single source for title/label so the mapping can't drift. A repo with no commits has an unborn HEAD where `git diff HEAD` would fail, so the loader and the watch-signature path resolve the base through `resolveWorktreeBaseRef`, which falls back to git's empty tree (still surfaces staged-but-uncommitted files). The fallback is loader-local; the session keeps the original HEAD base so a later reload re-evaluates it. HEAD existence is cached per repo so the common already-committed path adds no extra git spawn after the first. Co-Authored-By: Claude Opus 4.7 (1M context) --- AGENTS.md | 2 +- CHANGELOG.md | 4 ++ src/core/cli.test.ts | 27 ++++++++ src/core/cli.ts | 39 +++++++++--- src/core/git.test.ts | 16 ++++- src/core/git.ts | 122 ++++++++++++++++++++++++++++++++---- src/core/loaders.test.ts | 115 ++++++++++++++++++++++++++++++++- src/core/loaders.ts | 33 +++++++--- src/core/watch.ts | 14 ++++- test/cli/entrypoint.test.ts | 53 ++++++++++++++++ 10 files changed, 389 insertions(+), 36 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6abd1c9..e33f443 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,7 +98,7 @@ CLI input - Comments belong beside the code, not hidden in a separate mode or workflow. - Comments are hunk-specific: render them in the diff flow near the annotated row, and keep a clear spatial relationship to the code they explain. - Drifted comments (file removed, line out of range, or anchor mismatch) pin to the top of the diff so they stay visible until resolved or dismissed. -- `dunk diff` working-tree reviews include untracked files by default. Use `--exclude-untracked` if you explicitly want tracked changes only. +- `dunk diff` reviews staged + unstaged changes together (working tree vs `HEAD`) and includes untracked files by default. `--staged`/`--cached` scopes to index-vs-`HEAD`, `--unstaged` to working-tree-vs-index. Use `--exclude-untracked` if you explicitly want tracked changes only. Working-tree scope is encoded purely as the comparison base (`range`); there is no separate scope field. - The agent skill at `skills/dunk-review/SKILL.md` describes how a coding agent should read, fix, and prune `.dunk/comments.json`. Don't run interactive TUI commands from agents — they should drive through the file. ## commands diff --git a/CHANGELOG.md b/CHANGELOG.md index 42c912c..3e18122 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and ## [Unreleased] +### Changed + +- `dunk diff` now reviews staged **and** unstaged changes together (working tree vs `HEAD`), so a quick `dunk diff` shows everything you've touched since the last commit, including changes you've already `git add`ed. Untracked files are still included. Use `dunk diff --staged` (or `--cached`) for index-vs-`HEAD` only, and the new `dunk diff --unstaged` for working-tree-vs-index only. In a repo with no commits, `dunk diff` falls back to a plain working-tree diff. + ## [0.14.0] - 2026-05-16 ### Added diff --git a/src/core/cli.test.ts b/src/core/cli.test.ts index c876829..bfe0cf2 100644 --- a/src/core/cli.test.ts +++ b/src/core/cli.test.ts @@ -107,6 +107,33 @@ describe("parseCli", () => { expect(cached).toMatchObject({ kind: "vcs", staged: true }); }); + test("defaults bare `dunk diff` to a staged + unstaged HEAD review", async () => { + const parsed = await parseCli(["bun", "dunk", "diff"]); + + expect(parsed).toMatchObject({ kind: "vcs", staged: false, range: "HEAD" }); + }); + + test("parses --unstaged as an index-vs-working-tree review", async () => { + const parsed = await parseCli(["bun", "dunk", "diff", "--unstaged"]); + + expect(parsed).toMatchObject({ kind: "vcs", staged: false }); + if (parsed.kind === "vcs") { + expect(parsed.range).toBeUndefined(); + } + }); + + test("rejects --staged combined with --unstaged", async () => { + await expect(parseCli(["bun", "dunk", "diff", "--staged", "--unstaged"])).rejects.toThrow( + /--staged.*--unstaged/, + ); + }); + + test("rejects --branch combined with --unstaged", async () => { + await expect(parseCli(["bun", "dunk", "diff", "--branch", "--unstaged"])).rejects.toThrow( + /--branch.*--staged.*--unstaged/, + ); + }); + test("parses untracked file toggles for git diff", async () => { const excluded = await parseCli(["bun", "dunk", "diff", "--exclude-untracked"]); const included = await parseCli(["bun", "dunk", "diff", "--no-exclude-untracked"]); diff --git a/src/core/cli.ts b/src/core/cli.ts index 0b031ad..b03ab03 100644 --- a/src/core/cli.ts +++ b/src/core/cli.ts @@ -14,6 +14,7 @@ import { runCommentsShow, } from "./cliComments"; import { DunkUserError } from "./errors"; +import { WORKTREE_BASE_REF } from "./git"; import { resolveBundledDunkReviewSkillPath } from "./paths"; import { resolveCliVersion } from "./version"; import type { BranchReviewRequest } from "./types"; @@ -115,8 +116,9 @@ function renderCliHelp() { "Review diffs in a TUI, leave inline comments, and let a coding agent resolve them through .dunk/comments.json.", "", "Commands:", - " dunk diff [target] [-- ] review working tree changes or compare against a target", - " dunk diff --staged [-- ] review staged changes", + " dunk diff [target] [-- ] review staged + unstaged changes or compare against a target", + " dunk diff --staged [-- ] review only staged changes", + " dunk diff --unstaged [-- ] review only unstaged changes", " dunk diff --branch[=base] review everything on the current branch vs its base", " dunk diff compare two concrete files", " dunk show [target] [-- ] review the last commit or a given target", @@ -141,7 +143,8 @@ function renderCliHelp() { " --theme named theme override", "", "Git diff options:", - " --staged, --cached review staged changes", + " --staged, --cached review only staged changes", + " --unstaged review only unstaged changes", " --exclude-untracked hide untracked files in working tree reviews", " --branch [base] review the whole branch vs base (origin/HEAD by default)", "", @@ -201,8 +204,9 @@ async function parseDiffCommand(tokens: string[], _argv: string[]): Promise 0) { throw new DunkUserError("`dunk diff --branch` does not take positional revision arguments.", [ @@ -260,14 +271,26 @@ async function parseDiffCommand(tokens: string[], _argv: string[]): Promise index vs HEAD (staged: true, no range) + // --unstaged -> working tree vs index (plain `git diff`, no range) + // default -> working tree vs HEAD (range "HEAD" => staged + unstaged combined) return { kind: "vcs", staged, + range: staged || unstaged ? undefined : WORKTREE_BASE_REF, pathspecs: normalizedPathspecs, options, }; } + if (unstaged) { + throw new DunkUserError("`dunk diff --unstaged` does not take positional revision arguments.", [ + "`--unstaged` only scopes a working-tree review (working tree vs index).", + "Drop the revision, or use `dunk diff ` to compare against it.", + ]); + } + if (parsedTargets.length === 1) { return { kind: "vcs", diff --git a/src/core/git.test.ts b/src/core/git.test.ts index 3c65246..8155d70 100644 --- a/src/core/git.test.ts +++ b/src/core/git.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { buildGitStashShowArgs, runGitText } from "./git"; +import { WORKTREE_BASE_REF, buildGitStashShowArgs, formatGitCommandLabel, runGitText } from "./git"; describe("git command helpers", () => { test("disables external diff tools for stash patches", () => { @@ -17,6 +17,7 @@ describe("git command helpers", () => { input: { kind: "vcs", staged: false, + range: WORKTREE_BASE_REF, options: { mode: "auto" }, }, args: ["status"], @@ -26,4 +27,17 @@ describe("git command helpers", () => { "Git is required for `dunk diff`, but `definitely-not-a-real-git-binary` was not found in PATH.", ); }); + + test("labels working-tree review scope from the comparison base", () => { + const common = { kind: "vcs", options: { mode: "auto" } } as const; + + expect(formatGitCommandLabel({ ...common, staged: false, range: WORKTREE_BASE_REF })).toBe( + "dunk diff", + ); + expect(formatGitCommandLabel({ ...common, staged: true })).toBe("dunk diff --staged"); + expect(formatGitCommandLabel({ ...common, staged: false })).toBe("dunk diff --unstaged"); + expect(formatGitCommandLabel({ ...common, staged: false, range: "main..feature" })).toBe( + "dunk diff main..feature", + ); + }); }); diff --git a/src/core/git.ts b/src/core/git.ts index 9fb2b27..94a6b55 100644 --- a/src/core/git.ts +++ b/src/core/git.ts @@ -5,6 +5,15 @@ import type { VcsCommandInput, ShowCommandInput, StashShowCommandInput } from ". export type GitBackedInput = VcsCommandInput | ShowCommandInput | StashShowCommandInput; +/** + * Comparison base for a default working-tree review. + * + * Diffing the working tree against HEAD surfaces staged and unstaged edits in + * one stream. `--staged` (index vs HEAD) and `--unstaged` (working tree vs + * index) carry no range and narrow the scope to one side. + */ +export const WORKTREE_BASE_REF = "HEAD"; + export interface RunGitTextOptions { input: GitBackedInput; args: string[]; @@ -156,20 +165,48 @@ export function buildGitStashShowArgs(input: StashShowCommandInput) { return withNormalizedDiffPrefixes(args); } +/** + * The single classification of a `dunk diff` review's scope. Title, command + * label, and untracked-inclusion all derive from this so the default-vs-staged + * vs-unstaged mapping cannot drift between consumers. + */ +export type VcsScope = "staged" | "branch" | "unstaged" | "all" | "range"; + +/** Classify a vcs input's review scope from its comparison base. */ +export function classifyVcsScope(input: VcsCommandInput): VcsScope { + if (input.staged) { + return "staged"; + } + if (input.branchReview) { + return "branch"; + } + if (!input.range) { + return "unstaged"; + } + return input.range === WORKTREE_BASE_REF ? "all" : "range"; +} + +function formatVcsCommandLabel(input: VcsCommandInput) { + switch (classifyVcsScope(input)) { + case "staged": + return "dunk diff --staged"; + case "branch": + return input.branchReview?.explicitBase + ? `dunk diff --branch=${input.branchReview.explicitBase}` + : "dunk diff --branch"; + case "unstaged": + return "dunk diff --unstaged"; + case "all": + return "dunk diff"; + case "range": + return `dunk diff ${input.range}`; + } +} + export function formatGitCommandLabel(input: GitBackedInput) { switch (input.kind) { case "vcs": - if (input.staged) { - return "dunk diff --staged"; - } - - if (input.branchReview) { - return input.branchReview.explicitBase - ? `dunk diff --branch=${input.branchReview.explicitBase}` - : "dunk diff --branch"; - } - - return input.range ? `dunk diff ${input.range}` : "dunk diff"; + return formatVcsCommandLabel(input); case "show": return input.ref ? `dunk show ${input.ref}` : "dunk show"; case "stash-show": @@ -371,7 +408,10 @@ function isWorkingTreeGitDiffInput( return false; } - if (!input.range) { + // No range is `--unstaged` (working tree vs index); the injected HEAD base is + // the default staged+unstaged review. Both keep the working tree on one side, + // so untracked files still belong in the stream. + if (!input.range || input.range === WORKTREE_BASE_REF) { return true; } @@ -498,6 +538,64 @@ export function runGitUntrackedFileDiffText( }).stdout; } +// The empty-tree id is immutable per repo (it only depends on the object +// format), so it is safe to cache. HEAD existence is intentionally *not* +// cached: it is cheap relative to the diff/status spawns and a long-lived +// session can switch to an orphan branch, making a cached "exists" stale. +const gitEmptyTreeCache = new Map(); + +function gitWorktreeCacheKey(gitExecutable: string, cwd: string) { + return `${gitExecutable}\0${cwd}`; +} + +/** Resolve git's empty tree object id (SHA-1 or SHA-256, per repo). */ +function resolveGitEmptyTreeId(input: GitBackedInput, cwd: string, gitExecutable: string) { + const key = gitWorktreeCacheKey(gitExecutable, cwd); + const cached = gitEmptyTreeCache.get(key); + if (cached !== undefined) { + return cached; + } + + // `--stdin` with the inherited empty stdin hashes a zero-entry tree. + const emptyTreeId = runGitText({ + input, + args: ["hash-object", "-t", "tree", "--stdin"], + cwd, + gitExecutable, + }).trim(); + gitEmptyTreeCache.set(key, emptyTreeId); + return emptyTreeId; +} + +/** + * Resolve the effective comparison base for a working-tree review. + * + * The default `dunk diff` carries `range: "HEAD"` so it merges staged and + * unstaged work. A repo with no commits has an unborn HEAD where `git diff + * HEAD` would hard-fail, so fall back to git's empty tree — that still surfaces + * staged-but-uncommitted files as additions, preserving the default scope. + * `--staged`, `--unstaged`, and explicit ranges pass through untouched. + */ +export function resolveWorktreeBaseRef( + input: VcsCommandInput, + { cwd = process.cwd(), gitExecutable = "git" }: Omit = {}, +) { + if (input.range !== WORKTREE_BASE_REF) { + return input.range; + } + + const headExists = + runGitCommand({ + input, + args: ["rev-parse", "--verify", "--quiet", "HEAD"], + acceptedExitCodes: [0, 1], + cwd, + gitExecutable, + }).exitCode === 0; + + return headExists ? WORKTREE_BASE_REF : resolveGitEmptyTreeId(input, cwd, gitExecutable); +} + export function resolveGitRepoRoot( input: GitBackedInput, options: Omit = {}, diff --git a/src/core/loaders.test.ts b/src/core/loaders.test.ts index 1be5537..b556d25 100644 --- a/src/core/loaders.test.ts +++ b/src/core/loaders.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { platform, tmpdir } from "node:os"; import { join } from "node:path"; +import { WORKTREE_BASE_REF } from "./git"; import { loadAppBootstrap } from "./loaders"; import type { CliInput } from "./types"; @@ -38,10 +39,10 @@ function git(cwd: string, ...cmd: string[]) { return Buffer.from(proc.stdout).toString("utf8"); } -function createTempRepo(prefix: string) { +function createTempRepo(prefix: string, ...initArgs: string[]) { const dir = createTempDir(prefix); - git(dir, "init", "--initial-branch", "master"); + git(dir, "init", "--initial-branch", "master", ...initArgs); git(dir, "config", "user.name", "Test User"); git(dir, "config", "user.email", "test@example.com"); git(dir, "config", "commit.gpgsign", "false"); @@ -49,6 +50,22 @@ function createTempRepo(prefix: string) { return dir; } +function gitSupportsSha256() { + const probeDir = mkdtempSync(join(tmpdir(), "hunk-sha256-probe-")); + try { + const probe = Bun.spawnSync(["git", "init", "--object-format=sha256", probeDir], { + stdout: "pipe", + stderr: "pipe", + stdin: "ignore", + }); + return probe.exitCode === 0; + } finally { + rmSync(probeDir, { recursive: true, force: true }); + } +} + +const sha256Supported = gitSupportsSha256(); + async function loadFromCwd(cwd: string, input: CliInput) { const previousCwd = process.cwd(); process.chdir(cwd); @@ -137,6 +154,99 @@ describe("loadAppBootstrap", () => { expect(bootstrap.changeset.files[0]?.stats.additions).toBeGreaterThan(0); }); + test("default review merges staged and unstaged changes", async () => { + const dir = createTempRepo("hunk-git-default-scope-"); + + writeFileSync(join(dir, "staged.ts"), "export const a = 1;\n"); + writeFileSync(join(dir, "unstaged.ts"), "export const b = 1;\n"); + git(dir, "add", "staged.ts", "unstaged.ts"); + git(dir, "commit", "-m", "initial"); + + writeFileSync(join(dir, "staged.ts"), "export const a = 2;\n"); + git(dir, "add", "staged.ts"); + writeFileSync(join(dir, "unstaged.ts"), "export const b = 2;\n"); + writeFileSync(join(dir, "extra.txt"), "loose\n"); + + const all = await loadFromRepo(dir, { + kind: "vcs", + staged: false, + range: WORKTREE_BASE_REF, + options: { mode: "auto" }, + }); + // Default merges staged + unstaged and still includes untracked files. + expect(all.changeset.files.map((file) => file.path).sort()).toEqual([ + "extra.txt", + "staged.ts", + "unstaged.ts", + ]); + + const stagedOnly = await loadFromRepo(dir, { + kind: "vcs", + staged: true, + options: { mode: "auto" }, + }); + expect(stagedOnly.changeset.files.map((file) => file.path)).toEqual(["staged.ts"]); + + const unstagedOnly = await loadFromRepo(dir, { + kind: "vcs", + staged: false, + options: { mode: "auto" }, + }); + expect(unstagedOnly.changeset.files.map((file) => file.path).sort()).toEqual([ + "extra.txt", + "unstaged.ts", + ]); + }); + + test("default review still shows staged files in a repo with no commits", async () => { + const dir = createTempRepo("hunk-git-unborn-head-"); + + writeFileSync(join(dir, "staged.ts"), "export const value = 1;\n"); + git(dir, "add", "staged.ts"); + writeFileSync(join(dir, "loose.ts"), "export const loose = true;\n"); + + const bootstrap = await loadFromRepo(dir, { + kind: "vcs", + staged: false, + range: WORKTREE_BASE_REF, + options: { mode: "auto" }, + }); + + // Unborn HEAD falls back to git's empty tree, so staged-but-uncommitted + // work still appears instead of `git diff HEAD` hard-failing. + expect(bootstrap.changeset.files.map((file) => file.path).sort()).toEqual([ + "loose.ts", + "staged.ts", + ]); + // The fallback stays loader-local: the session keeps the original HEAD base + // so a later reload re-evaluates it once the first commit exists. + expect(bootstrap.input.kind).toBe("vcs"); + if (bootstrap.input.kind === "vcs") { + expect(bootstrap.input.range).toBe(WORKTREE_BASE_REF); + } + }); + + test.skipIf(!sha256Supported)( + "computes the empty-tree base per object format in a sha256 unborn repo", + async () => { + const dir = createTempRepo("hunk-git-sha256-", "--object-format=sha256"); + + writeFileSync(join(dir, "staged.ts"), "export const value = 1;\n"); + git(dir, "add", "staged.ts"); + + const bootstrap = await loadFromRepo(dir, { + kind: "vcs", + staged: false, + range: WORKTREE_BASE_REF, + options: { mode: "auto" }, + }); + + // A SHA-1 empty-tree constant would not exist in a sha256 repo, so the + // staged file appearing proves the empty tree is resolved per repo. + expect(bootstrap.changeset.files.map((file) => file.path)).toEqual(["staged.ts"]); + }, + ); + test("includes untracked files in working tree reviews by default", async () => { const dir = createTempRepo("hunk-git-untracked-"); @@ -470,6 +580,7 @@ describe("loadAppBootstrap", () => { loadFromRepo(dir, { kind: "vcs", staged: false, + range: WORKTREE_BASE_REF, options: { mode: "auto" }, }), ).rejects.toThrow("`dunk diff` must be run inside a Git repository."); diff --git a/src/core/loaders.ts b/src/core/loaders.ts index 99f3533..451cbcc 100644 --- a/src/core/loaders.ts +++ b/src/core/loaders.ts @@ -25,8 +25,10 @@ import { buildGitDiffNumstatArgs, buildGitShowArgs, buildGitStashShowArgs, + classifyVcsScope, listGitUntrackedFiles, resolveGitRepoRoot, + resolveWorktreeBaseRef, runGitText, runGitUntrackedFileDiffText, } from "./git"; @@ -897,15 +899,23 @@ async function loadGitChangeset( const repoRoot = resolveGitRepoRoot(input, { cwd }); const repoName = basename(repoRoot); + // Title reflects what the user asked for, so classify the original input + // before branch resolution / the unborn-HEAD fallback rewrite the base. + const scope = classifyVcsScope(input); + // Branch review: resolve ...HEAD to a concrete merge-base SHA, then route through the // existing "git diff " path so untracked files, large-file skips, and watch reload // keep working without a parallel code path. // // `gitDiffInput` is local to this loader by design — do not leak it past here. The original // `input` (with `branchReview` still set) is what user-facing helpers like - // `formatGitCommandLabel` need, while `gitDiffInput` carries the resolved SHA in `range` for - // the git arg builders below. - let gitDiffInput = input; + // `formatGitCommandLabel` need, while `gitDiffInput` carries the resolved base in `range` for + // the git arg builders below. The unborn-HEAD fallback (empty tree) is resolved the same way + // so it stays loader-local and reload re-evaluates HEAD instead of sticking to a stale base. + let gitDiffInput: VcsCommandInput = { + ...input, + range: resolveWorktreeBaseRef(input, { cwd }), + }; let branchDisplayBase: string | undefined; if (input.branchReview && !input.staged) { const resolved = resolveGitBranchBase(input, { cwd }); @@ -917,13 +927,16 @@ async function loadGitChangeset( }; } - const title = gitDiffInput.staged - ? `${repoName} staged changes` - : branchDisplayBase - ? `${repoName} branch vs ${branchDisplayBase}` - : gitDiffInput.range - ? `${repoName} ${gitDiffInput.range}` - : `${repoName} working tree`; + const title = + scope === "staged" + ? `${repoName} staged changes` + : branchDisplayBase + ? `${repoName} branch vs ${branchDisplayBase}` + : scope === "unstaged" + ? `${repoName} unstaged changes` + : scope === "all" + ? `${repoName} working tree` + : `${repoName} ${input.range}`; const largeTrackedFiles = parseGitNumstat( runGitText({ input: gitDiffInput, args: buildGitDiffNumstatArgs(gitDiffInput), cwd }), ).filter((file) => shouldSkipLargeTrackedDiff(file, repoRoot)); diff --git a/src/core/watch.ts b/src/core/watch.ts index 50ba62d..8936842 100644 --- a/src/core/watch.ts +++ b/src/core/watch.ts @@ -2,7 +2,13 @@ import fs from "node:fs"; import { join } from "node:path"; import { findRepoRoot } from "./config"; import { DUNK_COMMENTS_RELATIVE_PATH } from "./dunkPaths"; -import { buildGitDiffRawArgs, listGitUntrackedFiles, resolveGitRepoRoot, runGitText } from "./git"; +import { + buildGitDiffRawArgs, + listGitUntrackedFiles, + resolveGitRepoRoot, + resolveWorktreeBaseRef, + runGitText, +} from "./git"; import type { CliInput } from "./types"; /** Return whether the current input can be rebuilt from files or VCS state without rereading stdin. */ @@ -42,7 +48,11 @@ function statSignature(path: string) { * actually need the bytes. */ function gitWorkingTreeWatchSignature(input: Extract) { - const raw = runGitText({ input, args: buildGitDiffRawArgs(input) }); + // Resolve the same base the loader uses (incl. the unborn-HEAD empty-tree + // fallback) so the signature probe doesn't crash on `git diff --raw HEAD` in + // a repo with no commits. Untracked detection keeps the original input. + const baseInput = { ...input, range: resolveWorktreeBaseRef(input) }; + const raw = runGitText({ input, args: buildGitDiffRawArgs(baseInput) }); const repoRoot = resolveGitRepoRoot(input); const untrackedSignatures = listGitUntrackedFiles(input, { repoRoot }).map( (filePath) => `untracked:${statSignature(join(repoRoot, filePath))}`, diff --git a/test/cli/entrypoint.test.ts b/test/cli/entrypoint.test.ts index d5c8ff0..dc60912 100644 --- a/test/cli/entrypoint.test.ts +++ b/test/cli/entrypoint.test.ts @@ -163,6 +163,59 @@ describe("CLI entrypoint contracts", () => { expect(stdout).not.toContain("\u001b[?1049h"); }); + test("documents the staged + unstaged default and the --unstaged scope flag", () => { + const proc = Bun.spawnSync([bunExecutable, "run", "src/main.tsx"], { + cwd: process.cwd(), + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + + const stdout = Buffer.from(proc.stdout).toString("utf8"); + + expect(proc.exitCode).toBe(0); + expect(stdout).toContain("review staged + unstaged changes or compare against a target"); + expect(stdout).toContain("dunk diff --unstaged"); + expect(stdout).toContain( + "--unstaged review only unstaged changes", + ); + }); + + test("rejects --staged combined with --unstaged without a Bun stack trace", () => { + const repoDir = mkdtempSync(join(tmpdir(), "hunk-scope-conflict-")); + git(repoDir, "init"); + + try { + const proc = Bun.spawnSync( + [ + bunExecutable, + "run", + join(process.cwd(), "src/main.tsx"), + "diff", + "--staged", + "--unstaged", + ], + { + cwd: repoDir, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + env: process.env, + }, + ); + + const stdout = Buffer.from(proc.stdout).toString("utf8"); + const stderr = Buffer.from(proc.stderr).toString("utf8"); + + expect(proc.exitCode).toBe(1); + expect(stdout).toBe(""); + expect(stderr).toContain("`dunk diff --staged` cannot be combined with `--unstaged`."); + expect(stderr).not.toContain("Bun v"); + } finally { + rmSync(repoDir, { recursive: true, force: true }); + } + }); + test("prints a friendly git-repo error without a Bun stack trace", () => { const nonRepoDir = mkdtempSync(join(tmpdir(), "hunk-nonrepo-")); const sourceEntrypoint = join(process.cwd(), "src/main.tsx");