Skip to content

Commit ecdace9

Browse files
authored
Include untracked files in working-tree diff reviews (#123)
1 parent a9af393 commit ecdace9

14 files changed

Lines changed: 585 additions & 29 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ CLI input
6464
- 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.
6565
- 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.
6666
- 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.
67-
- If newly created files should appear in `hunk diff` before commit, use `git add -N <paths>` so they show up in the review stream without staging content.
67+
- `hunk diff` working-tree reviews include untracked files by default. Use `--exclude-untracked` if you explicitly want tracked changes only.
6868

6969
## commands
7070

CONTRIBUTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ Key rules:
116116
- Keep scope tight and explain user-visible behavior changes clearly.
117117
- Update docs and examples when behavior or workflows change.
118118
- If you want temporary local review notes, you can use `.hunk/latest.json`, but do not commit it.
119-
- If newly created files should appear in `hunk diff` before commit, use `git add -N <paths>`.
119+
- `hunk diff` includes untracked working-tree files by default. Use `--exclude-untracked` if you want to review tracked changes only.
120120

121121
## Release notes
122122

README.md

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,12 @@ hunk --version # print the installed version
5151
Hunk mirrors Git's diff-style commands, but opens the changeset in a review UI instead of plain text.
5252

5353
```bash
54-
hunk diff # review current repo changes
54+
hunk diff # review current repo changes, including untracked files
55+
hunk diff --exclude-untracked # limit working tree review to tracked files only
5556
hunk diff --staged
56-
hunk diff --watch # auto-reload as the working tree changes
57-
hunk show # review the latest commit
58-
hunk show HEAD~1 # review an earlier commit
57+
hunk diff --watch # auto-reload as the working tree changes
58+
hunk show # review the latest commit
59+
hunk show HEAD~1 # review an earlier commit
5960
```
6061

6162
### Working with raw files and patches
@@ -100,17 +101,23 @@ You can persist preferences to a config file:
100101
Example:
101102

102103
```toml
103-
theme = "graphite" # graphite, midnight, paper, ember
104-
mode = "auto" # auto, split, stack
104+
theme = "graphite" # graphite, midnight, paper, ember
105+
mode = "auto" # auto, split, stack
106+
exclude_untracked = false
105107
line_numbers = true
106108
wrap_lines = false
107109
agent_notes = false
108110
```
109111

112+
`exclude_untracked` affects working-tree `hunk diff` sessions only.
113+
110114
### Git integration
111115

112116
Set Hunk as your Git pager so `git diff` and `git show` open in Hunk automatically:
113117

118+
> [!NOTE]
119+
> 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.
120+
114121
```bash
115122
git config --global core.pager "hunk pager"
116123
```

skills/hunk-review/SKILL.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,11 +74,10 @@ hunk session comment clear --repo . --yes [--file README.md]
7474

7575
## New files in working-tree reviews
7676

77-
Newly created files won't appear in `hunk diff` until Git knows about them:
77+
`hunk diff` includes untracked files by default. If the user wants tracked changes only, reload with `--exclude-untracked`:
7878

7979
```bash
80-
git add -N src/new-file.ts
81-
hunk session reload --repo . -- diff
80+
hunk session reload --repo . -- diff --exclude-untracked
8281
```
8382

8483
## Guiding a review

src/core/cli.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { existsSync, readFileSync, statSync } from "node:fs";
22
import { dirname, resolve } from "node:path";
3-
import { Command } from "commander";
3+
import { Command, Option } from "commander";
44
import type {
55
CliInput,
66
CommonOptions,
@@ -64,6 +64,7 @@ function buildCommonOptions(
6464
agentContext: options.agentContext,
6565
pager: options.pager ? true : undefined,
6666
watch: options.watch ? true : undefined,
67+
excludeUntracked: resolveBooleanFlag(argv, "--exclude-untracked", "--no-exclude-untracked"),
6768
lineNumbers: resolveBooleanFlag(argv, "--line-numbers", "--no-line-numbers"),
6869
wrapLines: resolveBooleanFlag(argv, "--wrap", "--no-wrap"),
6970
hunkHeaders: resolveBooleanFlag(argv, "--hunk-headers", "--no-hunk-headers"),
@@ -235,6 +236,13 @@ async function parseDiffCommand(tokens: string[], argv: string[]): Promise<Parse
235236
)
236237
.option("--staged", "show staged changes instead of the working tree")
237238
.option("--cached", "alias for --staged")
239+
.option("--exclude-untracked", "exclude untracked files from working tree reviews")
240+
.addOption(
241+
new Option(
242+
"--no-exclude-untracked",
243+
"include untracked files in working tree reviews",
244+
).hideHelp(),
245+
)
238246
.argument("[targets...]");
239247

240248
let parsedTargets: string[] = [];

src/core/config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ function readConfigPreferences(source: Record<string, unknown>): CommonOptions {
4545
return {
4646
mode: normalizeLayoutMode(source.mode),
4747
theme: normalizeString(source.theme),
48+
excludeUntracked: normalizeBoolean(source.exclude_untracked),
4849
lineNumbers: normalizeBoolean(source.line_numbers),
4950
wrapLines: normalizeBoolean(source.wrap_lines),
5051
hunkHeaders: normalizeBoolean(source.hunk_headers),
@@ -61,6 +62,7 @@ function mergeOptions(base: CommonOptions, overrides: CommonOptions): CommonOpti
6162
agentContext: overrides.agentContext ?? base.agentContext,
6263
pager: overrides.pager ?? base.pager,
6364
watch: overrides.watch ?? base.watch,
65+
excludeUntracked: overrides.excludeUntracked ?? base.excludeUntracked,
6466
lineNumbers: overrides.lineNumbers ?? base.lineNumbers,
6567
wrapLines: overrides.wrapLines ?? base.wrapLines,
6668
hunkHeaders: overrides.hunkHeaders ?? base.hunkHeaders,
@@ -145,6 +147,7 @@ export function resolveConfiguredCliInput(
145147
agentContext: input.options.agentContext,
146148
pager: input.options.pager ?? false,
147149
watch: input.options.watch ?? false,
150+
excludeUntracked: false,
148151
lineNumbers: DEFAULT_VIEW_PREFERENCES.showLineNumbers,
149152
wrapLines: DEFAULT_VIEW_PREFERENCES.wrapLines,
150153
hunkHeaders: DEFAULT_VIEW_PREFERENCES.showHunkHeaders,
@@ -171,6 +174,7 @@ export function resolveConfiguredCliInput(
171174
agentContext: input.options.agentContext,
172175
pager: input.options.pager ?? false,
173176
watch: input.options.watch ?? false,
177+
excludeUntracked: resolvedOptions.excludeUntracked ?? false,
174178
mode: resolvedOptions.mode ?? DEFAULT_VIEW_PREFERENCES.mode,
175179
lineNumbers: resolvedOptions.lineNumbers ?? DEFAULT_VIEW_PREFERENCES.showLineNumbers,
176180
wrapLines: resolvedOptions.wrapLines ?? DEFAULT_VIEW_PREFERENCES.wrapLines,

src/core/git.ts

Lines changed: 96 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,15 @@ export interface RunGitTextOptions {
1010
gitExecutable?: string;
1111
}
1212

13+
interface RunGitCommandResult {
14+
stdout: string;
15+
exitCode: number;
16+
}
17+
18+
interface RunGitCommandOptions extends RunGitTextOptions {
19+
acceptedExitCodes?: number[];
20+
}
21+
1322
/** Append Git pathspec arguments only when the caller requested them. */
1423
export function appendGitPathspecs(args: string[], pathspecs?: string[]) {
1524
if (!pathspecs || pathspecs.length === 0) {
@@ -53,6 +62,26 @@ export function buildGitDiffArgs(input: GitCommandInput) {
5362
return withNormalizedDiffPrefixes(args);
5463
}
5564

65+
/** Build the porcelain status query used to discover untracked files for working-tree review. */
66+
function buildGitStatusArgs(input: GitCommandInput) {
67+
const args = ["status", "--porcelain=v1", "-z", "--untracked-files=all"];
68+
69+
appendGitPathspecs(args, input.pathspecs);
70+
return args;
71+
}
72+
73+
/** Build the synthetic patch used to render one untracked file as a new-file diff. */
74+
function buildGitNewFileDiffArgs(filePath: string) {
75+
return withNormalizedDiffPrefixes([
76+
"diff",
77+
"--no-index",
78+
"--no-color",
79+
"--",
80+
"/dev/null",
81+
filePath,
82+
]);
83+
}
84+
5685
/** Build the exact `git show` arguments used for commit review. */
5786
export function buildGitShowArgs(input: ShowCommandInput) {
5887
const args = ["show", "--format=", "--no-ext-diff", "--find-renames", "--no-color"];
@@ -222,13 +251,14 @@ function translateGitExitFailure(input: GitBackedInput, stderr: string) {
222251
return createGenericGitError(input, stderr);
223252
}
224253

225-
/** Run a git command and translate common failures into user-facing Hunk errors. */
226-
export function runGitText({
254+
/** Spawn one Git command and accept only the exit codes the caller declared as non-errors. */
255+
function runGitCommand({
227256
input,
228257
args,
229258
cwd = process.cwd(),
230259
gitExecutable = "git",
231-
}: RunGitTextOptions) {
260+
acceptedExitCodes = [0],
261+
}: RunGitCommandOptions): RunGitCommandResult {
232262
let proc: ReturnType<typeof Bun.spawnSync>;
233263

234264
try {
@@ -245,14 +275,75 @@ export function runGitText({
245275
const stdout = Buffer.from(proc.stdout ?? []).toString("utf8");
246276
const stderr = Buffer.from(proc.stderr ?? []).toString("utf8");
247277

248-
if (proc.exitCode !== 0) {
278+
if (!acceptedExitCodes.includes(proc.exitCode)) {
249279
throw translateGitExitFailure(
250280
input,
251281
stderr.trim() || `Command failed: ${gitExecutable} ${args.join(" ")}`,
252282
);
253283
}
254284

255-
return stdout;
285+
return {
286+
stdout,
287+
exitCode: proc.exitCode,
288+
};
289+
}
290+
291+
/** Run a git command and translate common failures into user-facing Hunk errors. */
292+
export function runGitText(options: RunGitTextOptions) {
293+
return runGitCommand(options).stdout;
294+
}
295+
296+
/** Return whether working-tree review should synthesize untracked files into the patch stream. */
297+
function shouldIncludeUntrackedFiles(input: GitCommandInput) {
298+
return !input.staged && !input.range && input.options.excludeUntracked !== true;
299+
}
300+
301+
/** Parse porcelain status output down to repo-root-relative untracked file paths. */
302+
function parseUntrackedFilePaths(statusText: string) {
303+
return statusText
304+
.split("\0")
305+
.filter(Boolean)
306+
.flatMap((entry) => (entry.startsWith("?? ") ? [entry.slice(3)] : []));
307+
}
308+
309+
/** Return the repo-root-relative untracked files for a working-tree review input. */
310+
export function listGitUntrackedFiles(
311+
input: GitCommandInput,
312+
{ cwd = process.cwd(), gitExecutable = "git" }: Omit<RunGitTextOptions, "input" | "args"> = {},
313+
) {
314+
if (!shouldIncludeUntrackedFiles(input)) {
315+
return [];
316+
}
317+
318+
const statusText = runGitText({
319+
input,
320+
args: buildGitStatusArgs(input),
321+
cwd,
322+
gitExecutable,
323+
});
324+
325+
return parseUntrackedFilePaths(statusText);
326+
}
327+
328+
/** Return the raw Git patch text for one untracked file using `git diff --no-index`. */
329+
export function runGitUntrackedFileDiffText(
330+
input: GitCommandInput,
331+
filePath: string,
332+
{
333+
cwd = process.cwd(),
334+
repoRoot,
335+
gitExecutable = "git",
336+
}: Omit<RunGitTextOptions, "input" | "args"> & { repoRoot?: string } = {},
337+
) {
338+
const normalizedRepoRoot = repoRoot ?? resolveGitRepoRoot(input, { cwd, gitExecutable });
339+
340+
return runGitCommand({
341+
input,
342+
args: buildGitNewFileDiffArgs(filePath),
343+
cwd: normalizedRepoRoot,
344+
gitExecutable,
345+
acceptedExitCodes: [0, 1],
346+
}).stdout;
256347
}
257348

258349
export function resolveGitRepoRoot(

0 commit comments

Comments
 (0)