Skip to content

Commit 0807139

Browse files
authored
feat: add watch mode for reloadable reviews (#91)
1 parent 1cc2102 commit 0807139

13 files changed

Lines changed: 310 additions & 66 deletions

File tree

README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ Hunk is a review-first terminal diff viewer for agent-authored changesets, built
99
- multi-file review stream with sidebar navigation
1010
- inline AI and agent annotations beside the code
1111
- split, stack, and responsive auto layouts
12+
- watch mode for auto-reloading file and Git-backed reviews
1213
- keyboard, mouse, pager, and Git difftool support
1314

1415
<table>
@@ -52,15 +53,17 @@ Hunk mirrors Git's diff-style commands, but opens the changeset in a review UI i
5253
```bash
5354
hunk diff # review current repo changes
5455
hunk diff --staged
56+
hunk diff --watch # auto-reload as the working tree changes
5557
hunk show # review the latest commit
5658
hunk show HEAD~1 # review an earlier commit
5759
```
5860

5961
### Working with raw files and patches
6062

6163
```bash
62-
hunk diff before.ts after.ts # compare two files directly
63-
git diff --no-color | hunk patch - # review a patch from stdin
64+
hunk diff before.ts after.ts # compare two files directly
65+
hunk diff before.ts after.ts --watch # auto-reload when either file changes
66+
git diff --no-color | hunk patch - # review a patch from stdin
6467
```
6568

6669
### Working with agents

src/core/cli.ts

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ function buildCommonOptions(
5454
theme?: string;
5555
agentContext?: string;
5656
pager?: boolean;
57+
watch?: boolean;
5758
},
5859
argv: string[],
5960
): CommonOptions {
@@ -62,14 +63,15 @@ function buildCommonOptions(
6263
theme: options.theme,
6364
agentContext: options.agentContext,
6465
pager: options.pager ? true : undefined,
66+
watch: options.watch ? true : undefined,
6567
lineNumbers: resolveBooleanFlag(argv, "--line-numbers", "--no-line-numbers"),
6668
wrapLines: resolveBooleanFlag(argv, "--wrap", "--no-wrap"),
6769
hunkHeaders: resolveBooleanFlag(argv, "--hunk-headers", "--no-hunk-headers"),
6870
agentNotes: resolveBooleanFlag(argv, "--agent-notes", "--no-agent-notes"),
6971
};
7072
}
7173

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

91+
/** Attach auto-refresh support to review commands that can reopen their source input. */
92+
function applyWatchOption(command: Command) {
93+
return command.option("--watch", "auto-reload when the current diff input changes");
94+
}
95+
8996
/** Resolve the CLI version from the nearest shipped package manifest. */
9097
function resolveCliVersion() {
9198
const candidatePaths = [
@@ -223,7 +230,9 @@ function resolveExplicitSessionSelector(
223230
/** Parse the overloaded `hunk diff` command. */
224231
async function parseDiffCommand(tokens: string[], argv: string[]): Promise<ParsedCliInput> {
225232
const { commandTokens, pathspecs } = splitPathspecArgs(tokens);
226-
const command = createCommand("diff", "review Git diffs or compare two concrete files")
233+
const command = applyWatchOption(
234+
createCommand("diff", "review Git diffs or compare two concrete files"),
235+
)
227236
.option("--staged", "show staged changes instead of the working tree")
228237
.option("--cached", "alias for --staged")
229238
.argument("[targets...]");
@@ -287,7 +296,9 @@ async function parseDiffCommand(tokens: string[], argv: string[]): Promise<Parse
287296
/** Parse the Git-style `hunk show` command. */
288297
async function parseShowCommand(tokens: string[], argv: string[]): Promise<ParsedCliInput> {
289298
const { commandTokens, pathspecs } = splitPathspecArgs(tokens);
290-
const command = createCommand("show", "review the last commit or a given ref").argument("[ref]");
299+
const command = applyWatchOption(
300+
createCommand("show", "review the last commit or a given ref"),
301+
).argument("[ref]");
291302

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

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

321331
let parsedFile: string | undefined;
@@ -365,7 +375,7 @@ async function parsePagerCommand(
365375

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

915-
const command = createCommand(
916-
"stash show",
917-
"review a stash entry as a full Hunk changeset",
925+
const command = applyWatchOption(
926+
createCommand("stash show", "review a stash entry as a full Hunk changeset"),
918927
).argument("[ref]");
919928

920929
let parsedRef: string | undefined;

src/core/config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ function mergeOptions(base: CommonOptions, overrides: CommonOptions): CommonOpti
6060
theme: overrides.theme ?? base.theme,
6161
agentContext: overrides.agentContext ?? base.agentContext,
6262
pager: overrides.pager ?? base.pager,
63+
watch: overrides.watch ?? base.watch,
6364
lineNumbers: overrides.lineNumbers ?? base.lineNumbers,
6465
wrapLines: overrides.wrapLines ?? base.wrapLines,
6566
hunkHeaders: overrides.hunkHeaders ?? base.hunkHeaders,
@@ -143,6 +144,7 @@ export function resolveConfiguredCliInput(
143144
theme: undefined,
144145
agentContext: input.options.agentContext,
145146
pager: input.options.pager ?? false,
147+
watch: input.options.watch ?? false,
146148
lineNumbers: DEFAULT_VIEW_PREFERENCES.showLineNumbers,
147149
wrapLines: DEFAULT_VIEW_PREFERENCES.wrapLines,
148150
hunkHeaders: DEFAULT_VIEW_PREFERENCES.showHunkHeaders,
@@ -168,6 +170,7 @@ export function resolveConfiguredCliInput(
168170
...resolvedOptions,
169171
agentContext: input.options.agentContext,
170172
pager: input.options.pager ?? false,
173+
watch: input.options.watch ?? false,
171174
mode: resolvedOptions.mode ?? DEFAULT_VIEW_PREFERENCES.mode,
172175
lineNumbers: resolvedOptions.lineNumbers ?? DEFAULT_VIEW_PREFERENCES.showLineNumbers,
173176
wrapLines: resolvedOptions.wrapLines ?? DEFAULT_VIEW_PREFERENCES.wrapLines,

src/core/git.ts

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

13+
/** Append Git pathspec arguments only when the caller requested them. */
14+
export function appendGitPathspecs(args: string[], pathspecs?: string[]) {
15+
if (!pathspecs || pathspecs.length === 0) {
16+
return;
17+
}
18+
19+
args.push("--", ...pathspecs);
20+
}
21+
22+
/** Build the exact `git diff` arguments used for the shared working-tree and range review path. */
23+
export function buildGitDiffArgs(input: GitCommandInput) {
24+
const args = ["diff", "--no-ext-diff", "--find-renames", "--no-color"];
25+
26+
if (input.staged) {
27+
args.push("--staged");
28+
}
29+
30+
if (input.range) {
31+
args.push(input.range);
32+
}
33+
34+
appendGitPathspecs(args, input.pathspecs);
35+
return args;
36+
}
37+
38+
/** Build the exact `git show` arguments used for commit review. */
39+
export function buildGitShowArgs(input: ShowCommandInput) {
40+
const args = ["show", "--format=", "--no-ext-diff", "--find-renames", "--no-color"];
41+
42+
if (input.ref) {
43+
args.push(input.ref);
44+
}
45+
46+
appendGitPathspecs(args, input.pathspecs);
47+
return args;
48+
}
49+
50+
/** Build the exact `git stash show -p` arguments used for stash review. */
51+
export function buildGitStashShowArgs(input: StashShowCommandInput) {
52+
const args = ["stash", "show", "-p", "--find-renames", "--no-color"];
53+
54+
if (input.ref) {
55+
args.push(input.ref);
56+
}
57+
58+
return args;
59+
}
60+
1361
export function formatGitCommandLabel(input: GitBackedInput) {
1462
switch (input.kind) {
1563
case "git":

src/core/loaders.ts

Lines changed: 10 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,13 @@ import {
77
} from "@pierre/diffs";
88
import { createTwoFilesPatch } from "diff";
99
import { findAgentFileContext, loadAgentContext } from "./agent";
10-
import { resolveGitRepoRoot, runGitText } from "./git";
10+
import {
11+
buildGitDiffArgs,
12+
buildGitShowArgs,
13+
buildGitStashShowArgs,
14+
resolveGitRepoRoot,
15+
runGitText,
16+
} from "./git";
1117
import type {
1218
AppBootstrap,
1319
AgentContext,
@@ -266,32 +272,11 @@ async function loadFileDiffChangeset(
266272
} satisfies Changeset;
267273
}
268274

269-
/** Append Git pathspec arguments only when the caller requested them. */
270-
function appendPathspecs(args: string[], pathspecs?: string[]) {
271-
if (!pathspecs || pathspecs.length === 0) {
272-
return;
273-
}
274-
275-
args.push("--", ...pathspecs);
276-
}
277-
278275
/** Build a changeset from the current repository working tree or a git range. */
279276
async function loadGitChangeset(input: GitCommandInput, agentContext: AgentContext | null) {
280277
const repoRoot = resolveGitRepoRoot(input);
281278
const repoName = basename(repoRoot);
282-
const args = ["git", "diff", "--no-ext-diff", "--find-renames", "--no-color"];
283-
284-
if (input.staged) {
285-
args.push("--staged");
286-
}
287-
288-
if (input.range) {
289-
args.push(input.range);
290-
}
291-
292-
appendPathspecs(args, input.pathspecs);
293-
294-
const patchText = runGitText({ input, args: args.slice(1) });
279+
const patchText = runGitText({ input, args: buildGitDiffArgs(input) });
295280
const title = input.staged
296281
? `${repoName} staged changes`
297282
: input.range
@@ -305,16 +290,9 @@ async function loadGitChangeset(input: GitCommandInput, agentContext: AgentConte
305290
async function loadShowChangeset(input: ShowCommandInput, agentContext: AgentContext | null) {
306291
const repoRoot = resolveGitRepoRoot(input);
307292
const repoName = basename(repoRoot);
308-
const args = ["git", "show", "--format=", "--no-ext-diff", "--find-renames", "--no-color"];
309-
310-
if (input.ref) {
311-
args.push(input.ref);
312-
}
313-
314-
appendPathspecs(args, input.pathspecs);
315293

316294
return normalizePatchChangeset(
317-
runGitText({ input, args: args.slice(1) }),
295+
runGitText({ input, args: buildGitShowArgs(input) }),
318296
input.ref ? `${repoName} show ${input.ref}` : `${repoName} show HEAD`,
319297
repoRoot,
320298
agentContext,
@@ -328,14 +306,9 @@ async function loadStashShowChangeset(
328306
) {
329307
const repoRoot = resolveGitRepoRoot(input);
330308
const repoName = basename(repoRoot);
331-
const args = ["git", "stash", "show", "-p", "--find-renames", "--no-color"];
332-
333-
if (input.ref) {
334-
args.push(input.ref);
335-
}
336309

337310
return normalizePatchChangeset(
338-
runGitText({ input, args: args.slice(1) }),
311+
runGitText({ input, args: buildGitStashShowArgs(input) }),
339312
input.ref ? `${repoName} stash ${input.ref}` : `${repoName} stash`,
340313
repoRoot,
341314
agentContext,

src/core/startup.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { resolveConfiguredCliInput } from "./config";
2+
import { HunkUserError } from "./errors";
23
import { loadAppBootstrap } from "./loaders";
34
import { looksLikePatchInput } from "./pager";
45
import {
@@ -8,6 +9,7 @@ import {
89
type ControllingTerminal,
910
} from "./terminal";
1011
import type { AppBootstrap, CliInput, ParsedCliInput, SessionCommandInput } from "./types";
12+
import { canReloadInput } from "./watch";
1113
import { parseCli } from "./cli";
1214

1315
export type StartupPlan =
@@ -105,6 +107,16 @@ export async function prepareStartupPlan(
105107
const runtimeCliInput = resolveRuntimeCliInputImpl(parsedCliInput);
106108
const configured = resolveConfiguredCliInputImpl(runtimeCliInput);
107109
const cliInput = configured.input;
110+
111+
if (cliInput.options.watch && !canReloadInput(cliInput)) {
112+
throw new HunkUserError(
113+
"`--watch` requires a file- or Git-backed input that Hunk can reopen.",
114+
[
115+
"Use a patch file path instead of stdin, and avoid `--agent-context -` for watched sessions.",
116+
],
117+
);
118+
}
119+
108120
const bootstrap = await loadAppBootstrapImpl(cliInput);
109121
const controllingTerminal = usesPipedPatchInputImpl(cliInput)
110122
? openControllingTerminalImpl()

src/core/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ export interface CommonOptions {
5555
theme?: string;
5656
agentContext?: string;
5757
pager?: boolean;
58+
watch?: boolean;
5859
lineNumbers?: boolean;
5960
wrapLines?: boolean;
6061
hunkHeaders?: boolean;

src/core/watch.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import fs from "node:fs";
2+
import { buildGitDiffArgs, buildGitShowArgs, buildGitStashShowArgs, runGitText } from "./git";
3+
import type { CliInput } from "./types";
4+
5+
/** Return whether the current input can be rebuilt from files or Git state without rereading stdin. */
6+
export function canReloadInput(input: CliInput) {
7+
if (input.options.agentContext === "-") {
8+
return false;
9+
}
10+
11+
return input.kind !== "patch" || Boolean(input.file && input.file !== "-");
12+
}
13+
14+
/** Format one file stat into a stable signature fragment, or mark the path missing. */
15+
function statSignature(path: string) {
16+
if (!fs.existsSync(path)) {
17+
return `${path}:missing`;
18+
}
19+
20+
const stat = fs.statSync(path);
21+
return `${path}:${stat.size}:${stat.mtimeMs}:${stat.ino}`;
22+
}
23+
24+
/** Build one exact patch signature for Git-backed review inputs. */
25+
function gitPatchSignature(input: Extract<CliInput, { kind: "git" | "show" | "stash-show" }>) {
26+
switch (input.kind) {
27+
case "git":
28+
return runGitText({ input, args: buildGitDiffArgs(input) });
29+
case "show":
30+
return runGitText({ input, args: buildGitShowArgs(input) });
31+
case "stash-show":
32+
return runGitText({ input, args: buildGitStashShowArgs(input) });
33+
}
34+
}
35+
36+
/** Compute a change-detection signature for one watchable input. */
37+
export function computeWatchSignature(input: CliInput) {
38+
const parts: string[] = [input.kind];
39+
40+
switch (input.kind) {
41+
case "git":
42+
case "show":
43+
case "stash-show":
44+
parts.push(gitPatchSignature(input));
45+
break;
46+
case "diff":
47+
case "difftool":
48+
parts.push(statSignature(input.left), statSignature(input.right));
49+
break;
50+
case "patch":
51+
if (!input.file || input.file === "-") {
52+
throw new Error("Watch mode requires a patch file path instead of stdin.");
53+
}
54+
parts.push(statSignature(input.file));
55+
break;
56+
}
57+
58+
if (input.options.agentContext && input.options.agentContext !== "-") {
59+
parts.push(`agent:${statSignature(input.options.agentContext)}`);
60+
}
61+
62+
return parts.join("\n---\n");
63+
}

0 commit comments

Comments
 (0)