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
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Hunk is a review-first terminal diff viewer for agent-authored changesets, built
- multi-file review stream with sidebar navigation
- inline AI and agent annotations beside the code
- split, stack, and responsive auto layouts
- watch mode for auto-reloading file and Git-backed reviews
- keyboard, mouse, pager, and Git difftool support

<table>
Expand Down Expand Up @@ -52,15 +53,17 @@ Hunk mirrors Git's diff-style commands, but opens the changeset in a review UI i
```bash
hunk diff # review current repo changes
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
```

### Working with raw files and patches

```bash
hunk diff before.ts after.ts # compare two files directly
git diff --no-color | hunk patch - # review a patch from stdin
hunk diff before.ts after.ts # compare two files directly
hunk diff before.ts after.ts --watch # auto-reload when either file changes
git diff --no-color | hunk patch - # review a patch from stdin
```

### Working with agents
Expand Down
29 changes: 19 additions & 10 deletions src/core/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ function buildCommonOptions(
theme?: string;
agentContext?: string;
pager?: boolean;
watch?: boolean;
},
argv: string[],
): CommonOptions {
Expand All @@ -62,14 +63,15 @@ function buildCommonOptions(
theme: options.theme,
agentContext: options.agentContext,
pager: options.pager ? true : undefined,
watch: options.watch ? true : undefined,
lineNumbers: resolveBooleanFlag(argv, "--line-numbers", "--no-line-numbers"),
wrapLines: resolveBooleanFlag(argv, "--wrap", "--no-wrap"),
hunkHeaders: resolveBooleanFlag(argv, "--hunk-headers", "--no-hunk-headers"),
agentNotes: resolveBooleanFlag(argv, "--agent-notes", "--no-agent-notes"),
};
}

/** Attach the shared mode/theme/agent-context flags to a subcommand parser. */
/** Attach the shared view flags to a subcommand parser. */
function applyCommonOptions(command: Command) {
return command
.option("--mode <mode>", "layout mode: auto, split, stack", parseLayoutMode)
Expand All @@ -86,6 +88,11 @@ function applyCommonOptions(command: Command) {
.option("--no-agent-notes", "hide agent notes by default");
}

/** Attach auto-refresh support to review commands that can reopen their source input. */
function applyWatchOption(command: Command) {
return command.option("--watch", "auto-reload when the current diff input changes");
}

/** Resolve the CLI version from the nearest shipped package manifest. */
function resolveCliVersion() {
const candidatePaths = [
Expand Down Expand Up @@ -223,7 +230,9 @@ function resolveExplicitSessionSelector(
/** Parse the overloaded `hunk diff` command. */
async function parseDiffCommand(tokens: string[], argv: string[]): Promise<ParsedCliInput> {
const { commandTokens, pathspecs } = splitPathspecArgs(tokens);
const command = createCommand("diff", "review Git diffs or compare two concrete files")
const command = applyWatchOption(
createCommand("diff", "review Git diffs or compare two concrete files"),
)
.option("--staged", "show staged changes instead of the working tree")
.option("--cached", "alias for --staged")
.argument("[targets...]");
Expand Down Expand Up @@ -287,7 +296,9 @@ async function parseDiffCommand(tokens: string[], argv: string[]): Promise<Parse
/** Parse the Git-style `hunk show` command. */
async function parseShowCommand(tokens: string[], argv: string[]): Promise<ParsedCliInput> {
const { commandTokens, pathspecs } = splitPathspecArgs(tokens);
const command = createCommand("show", "review the last commit or a given ref").argument("[ref]");
const command = applyWatchOption(
createCommand("show", "review the last commit or a given ref"),
).argument("[ref]");

let parsedRef: string | undefined;
let parsedOptions: Record<string, unknown> = {};
Expand All @@ -313,9 +324,8 @@ async function parseShowCommand(tokens: string[], argv: string[]): Promise<Parse

/** Parse the patch-file / stdin patch entrypoint. */
async function parsePatchCommand(tokens: string[], argv: string[]): Promise<ParsedCliInput> {
const command = createCommand(
"patch",
"review a patch file, or read a patch from stdin",
const command = applyWatchOption(
createCommand("patch", "review a patch file, or read a patch from stdin"),
).argument("[file]");

let parsedFile: string | undefined;
Expand Down Expand Up @@ -365,7 +375,7 @@ async function parsePagerCommand(

/** Parse Git difftool-style two-file review commands. */
async function parseDifftoolCommand(tokens: string[], argv: string[]): Promise<ParsedCliInput> {
const command = createCommand("difftool", "review Git difftool file pairs")
const command = applyWatchOption(createCommand("difftool", "review Git difftool file pairs"))
.argument("<left>")
.argument("<right>")
.argument("[path]");
Expand Down Expand Up @@ -912,9 +922,8 @@ async function parseStashCommand(tokens: string[], argv: string[]): Promise<Pars
throw new Error("Only `hunk stash show` is supported.");
}

const command = createCommand(
"stash show",
"review a stash entry as a full Hunk changeset",
const command = applyWatchOption(
createCommand("stash show", "review a stash entry as a full Hunk changeset"),
).argument("[ref]");

let parsedRef: string | undefined;
Expand Down
3 changes: 3 additions & 0 deletions src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ function mergeOptions(base: CommonOptions, overrides: CommonOptions): CommonOpti
theme: overrides.theme ?? base.theme,
agentContext: overrides.agentContext ?? base.agentContext,
pager: overrides.pager ?? base.pager,
watch: overrides.watch ?? base.watch,
lineNumbers: overrides.lineNumbers ?? base.lineNumbers,
wrapLines: overrides.wrapLines ?? base.wrapLines,
hunkHeaders: overrides.hunkHeaders ?? base.hunkHeaders,
Expand Down Expand Up @@ -143,6 +144,7 @@ export function resolveConfiguredCliInput(
theme: undefined,
agentContext: input.options.agentContext,
pager: input.options.pager ?? false,
watch: input.options.watch ?? false,
lineNumbers: DEFAULT_VIEW_PREFERENCES.showLineNumbers,
wrapLines: DEFAULT_VIEW_PREFERENCES.wrapLines,
hunkHeaders: DEFAULT_VIEW_PREFERENCES.showHunkHeaders,
Expand All @@ -168,6 +170,7 @@ export function resolveConfiguredCliInput(
...resolvedOptions,
agentContext: input.options.agentContext,
pager: input.options.pager ?? false,
watch: input.options.watch ?? false,
mode: resolvedOptions.mode ?? DEFAULT_VIEW_PREFERENCES.mode,
lineNumbers: resolvedOptions.lineNumbers ?? DEFAULT_VIEW_PREFERENCES.showLineNumbers,
wrapLines: resolvedOptions.wrapLines ?? DEFAULT_VIEW_PREFERENCES.wrapLines,
Expand Down
48 changes: 48 additions & 0 deletions src/core/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,54 @@ export interface RunGitTextOptions {
gitExecutable?: string;
}

/** Append Git pathspec arguments only when the caller requested them. */
export function appendGitPathspecs(args: string[], pathspecs?: string[]) {
if (!pathspecs || pathspecs.length === 0) {
return;
}

args.push("--", ...pathspecs);
}

/** Build the exact `git diff` arguments used for the shared working-tree and range review path. */
export function buildGitDiffArgs(input: GitCommandInput) {
const args = ["diff", "--no-ext-diff", "--find-renames", "--no-color"];

if (input.staged) {
args.push("--staged");
}

if (input.range) {
args.push(input.range);
}

appendGitPathspecs(args, input.pathspecs);
return args;
}

/** 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"];

if (input.ref) {
args.push(input.ref);
}

appendGitPathspecs(args, input.pathspecs);
return args;
}

/** Build the exact `git stash show -p` arguments used for stash review. */
export function buildGitStashShowArgs(input: StashShowCommandInput) {
const args = ["stash", "show", "-p", "--find-renames", "--no-color"];

if (input.ref) {
args.push(input.ref);
}

return args;
}

export function formatGitCommandLabel(input: GitBackedInput) {
switch (input.kind) {
case "git":
Expand Down
47 changes: 10 additions & 37 deletions src/core/loaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@ import {
} from "@pierre/diffs";
import { createTwoFilesPatch } from "diff";
import { findAgentFileContext, loadAgentContext } from "./agent";
import { resolveGitRepoRoot, runGitText } from "./git";
import {
buildGitDiffArgs,
buildGitShowArgs,
buildGitStashShowArgs,
resolveGitRepoRoot,
runGitText,
} from "./git";
import type {
AppBootstrap,
AgentContext,
Expand Down Expand Up @@ -266,32 +272,11 @@ async function loadFileDiffChangeset(
} satisfies Changeset;
}

/** Append Git pathspec arguments only when the caller requested them. */
function appendPathspecs(args: string[], pathspecs?: string[]) {
if (!pathspecs || pathspecs.length === 0) {
return;
}

args.push("--", ...pathspecs);
}

/** Build a changeset from the current repository working tree or a git range. */
async function loadGitChangeset(input: GitCommandInput, agentContext: AgentContext | null) {
const repoRoot = resolveGitRepoRoot(input);
const repoName = basename(repoRoot);
const args = ["git", "diff", "--no-ext-diff", "--find-renames", "--no-color"];

if (input.staged) {
args.push("--staged");
}

if (input.range) {
args.push(input.range);
}

appendPathspecs(args, input.pathspecs);

const patchText = runGitText({ input, args: args.slice(1) });
const patchText = runGitText({ input, args: buildGitDiffArgs(input) });
const title = input.staged
? `${repoName} staged changes`
: input.range
Expand All @@ -305,16 +290,9 @@ async function loadGitChangeset(input: GitCommandInput, agentContext: AgentConte
async function loadShowChangeset(input: ShowCommandInput, agentContext: AgentContext | null) {
const repoRoot = resolveGitRepoRoot(input);
const repoName = basename(repoRoot);
const args = ["git", "show", "--format=", "--no-ext-diff", "--find-renames", "--no-color"];

if (input.ref) {
args.push(input.ref);
}

appendPathspecs(args, input.pathspecs);

return normalizePatchChangeset(
runGitText({ input, args: args.slice(1) }),
runGitText({ input, args: buildGitShowArgs(input) }),
input.ref ? `${repoName} show ${input.ref}` : `${repoName} show HEAD`,
repoRoot,
agentContext,
Expand All @@ -328,14 +306,9 @@ async function loadStashShowChangeset(
) {
const repoRoot = resolveGitRepoRoot(input);
const repoName = basename(repoRoot);
const args = ["git", "stash", "show", "-p", "--find-renames", "--no-color"];

if (input.ref) {
args.push(input.ref);
}

return normalizePatchChangeset(
runGitText({ input, args: args.slice(1) }),
runGitText({ input, args: buildGitStashShowArgs(input) }),
input.ref ? `${repoName} stash ${input.ref}` : `${repoName} stash`,
repoRoot,
agentContext,
Expand Down
12 changes: 12 additions & 0 deletions src/core/startup.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { resolveConfiguredCliInput } from "./config";
import { HunkUserError } from "./errors";
import { loadAppBootstrap } from "./loaders";
import { looksLikePatchInput } from "./pager";
import {
Expand All @@ -8,6 +9,7 @@ import {
type ControllingTerminal,
} from "./terminal";
import type { AppBootstrap, CliInput, ParsedCliInput, SessionCommandInput } from "./types";
import { canReloadInput } from "./watch";
import { parseCli } from "./cli";

export type StartupPlan =
Expand Down Expand Up @@ -105,6 +107,16 @@ export async function prepareStartupPlan(
const runtimeCliInput = resolveRuntimeCliInputImpl(parsedCliInput);
const configured = resolveConfiguredCliInputImpl(runtimeCliInput);
const cliInput = configured.input;

if (cliInput.options.watch && !canReloadInput(cliInput)) {
throw new HunkUserError(
"`--watch` requires a file- or Git-backed input that Hunk can reopen.",
[
"Use a patch file path instead of stdin, and avoid `--agent-context -` for watched sessions.",
],
);
}

const bootstrap = await loadAppBootstrapImpl(cliInput);
const controllingTerminal = usesPipedPatchInputImpl(cliInput)
? openControllingTerminalImpl()
Expand Down
1 change: 1 addition & 0 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export interface CommonOptions {
theme?: string;
agentContext?: string;
pager?: boolean;
watch?: boolean;
lineNumbers?: boolean;
wrapLines?: boolean;
hunkHeaders?: boolean;
Expand Down
63 changes: 63 additions & 0 deletions src/core/watch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import fs from "node:fs";
import { buildGitDiffArgs, buildGitShowArgs, buildGitStashShowArgs, runGitText } from "./git";
import type { CliInput } from "./types";

/** Return whether the current input can be rebuilt from files or Git state without rereading stdin. */
export function canReloadInput(input: CliInput) {
if (input.options.agentContext === "-") {
return false;
}

return input.kind !== "patch" || Boolean(input.file && input.file !== "-");
}

/** Format one file stat into a stable signature fragment, or mark the path missing. */
function statSignature(path: string) {
if (!fs.existsSync(path)) {
return `${path}:missing`;
}

const stat = fs.statSync(path);
return `${path}:${stat.size}:${stat.mtimeMs}:${stat.ino}`;
}

/** Build one exact patch signature for Git-backed review inputs. */
function gitPatchSignature(input: Extract<CliInput, { kind: "git" | "show" | "stash-show" }>) {
switch (input.kind) {
case "git":
return runGitText({ input, args: buildGitDiffArgs(input) });
case "show":
return runGitText({ input, args: buildGitShowArgs(input) });
case "stash-show":
return runGitText({ input, args: buildGitStashShowArgs(input) });
}
}

/** Compute a change-detection signature for one watchable input. */
export function computeWatchSignature(input: CliInput) {
const parts: string[] = [input.kind];

switch (input.kind) {
case "git":
case "show":
case "stash-show":
parts.push(gitPatchSignature(input));
break;
case "diff":
case "difftool":
parts.push(statSignature(input.left), statSignature(input.right));
break;
case "patch":
if (!input.file || input.file === "-") {
throw new Error("Watch mode requires a patch file path instead of stdin.");
}
parts.push(statSignature(input.file));
break;
}

if (input.options.agentContext && input.options.agentContext !== "-") {
parts.push(`agent:${statSignature(input.options.agentContext)}`);
}

return parts.join("\n---\n");
}
Loading
Loading