Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <paths>` 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

Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <paths>`.
- `hunk diff` includes untracked working-tree files by default. Use `--exclude-untracked` if you want to review tracked changes only.

## Release notes

Expand Down
19 changes: 13 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
```
Expand Down
5 changes: 2 additions & 3 deletions skills/hunk-review/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion src/core/cli.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -235,6 +236,13 @@ async function parseDiffCommand(tokens: string[], argv: string[]): Promise<Parse
)
.option("--staged", "show staged changes instead of the working tree")
.option("--cached", "alias for --staged")
.option("--exclude-untracked", "exclude untracked files from working tree reviews")
.addOption(
new Option(
"--no-exclude-untracked",
"include untracked files in working tree reviews",
).hideHelp(),
)
.argument("[targets...]");

let parsedTargets: string[] = [];
Expand Down
4 changes: 4 additions & 0 deletions src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ function readConfigPreferences(source: Record<string, unknown>): 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),
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
101 changes: 96 additions & 5 deletions src/core/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
]);
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

/** 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"];
Expand Down Expand Up @@ -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<typeof Bun.spawnSync>;

try {
Expand All @@ -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<RunGitTextOptions, "input" | "args"> = {},
) {
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<RunGitTextOptions, "input" | "args"> & { 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(
Expand Down
Loading
Loading