Skip to content

Commit 95bd096

Browse files
committed
feat: add general Git pager wrapper
1 parent 8912059 commit 95bd096

10 files changed

Lines changed: 194 additions & 10 deletions

File tree

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ If you want a different install location, set `HUNK_INSTALL_DIR` before running
5050
- `hunk stash show [ref]` — review a stash entry in the full Hunk UI
5151
- `hunk diff <left> <right>` — compare two concrete files directly
5252
- `hunk patch [file|-]` — review a patch file or stdin, including pager mode
53+
- `hunk pager` — act as a general Git pager wrapper, opening Hunk for diff-like stdin and falling back to normal text paging otherwise
5354
- `hunk difftool <left> <right> [path]` — integrate with Git difftool
5455
- `hunk git [range]` — legacy alias for the original Git-style diff entrypoint
5556

@@ -262,3 +263,9 @@ If you want Git to launch Hunk as a difftool for file-to-file comparisons:
262263
git config --global diff.tool hunk
263264
git config --global difftool.hunk.cmd 'hunk difftool "$LOCAL" "$REMOTE" "$MERGED"'
264265
```
266+
e comparisons:
267+
268+
```bash
269+
git config --global diff.tool hunk
270+
git config --global difftool.hunk.cmd 'hunk difftool "$LOCAL" "$REMOTE" "$MERGED"'
271+
```

src/core/cli.ts

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { existsSync, statSync } from "node:fs";
22
import { Command } from "commander";
3-
import type { CliInput, CommonOptions, LayoutMode, ParsedCliInput } from "./types";
3+
import type { CommonOptions, HelpCommandInput, LayoutMode, PagerCommandInput, ParsedCliInput } from "./types";
44

55
/** Validate one requested layout mode from CLI input. */
66
function parseLayoutMode(value: string): LayoutMode {
@@ -82,6 +82,7 @@ function renderCliHelp() {
8282
" hunk show [ref] [-- <pathspec...>] review the last commit or a given ref",
8383
" hunk stash show [ref] review a stash entry",
8484
" hunk patch [file] review a patch file or stdin",
85+
" hunk pager general Git pager wrapper with diff detection",
8586
" hunk difftool <left> <right> [path] review Git difftool file pairs",
8687
" hunk git [range] legacy alias for git diff-style review",
8788
"",
@@ -97,6 +98,7 @@ function renderCliHelp() {
9798
" hunk show HEAD~1",
9899
" hunk show abc123 -- README.md",
99100
" hunk patch -",
101+
" hunk pager",
100102
"",
101103
].join("\n");
102104
}
@@ -120,15 +122,14 @@ function areExistingFiles(left: string, right: string) {
120122
}
121123

122124
/** Parse one standalone command while letting us capture `--help` as plain text. */
123-
async function parseStandaloneCommand<T>(command: Command, tokens: string[]): Promise<T | null> {
125+
async function parseStandaloneCommand(command: Command, tokens: string[]) {
124126
command.exitOverride();
125127

126128
try {
127129
await command.parseAsync(["bun", "hunk", ...tokens]);
128-
return null;
129130
} catch (error) {
130131
if (error && typeof error === "object" && "code" in error && error.code === "commander.helpDisplayed") {
131-
return null;
132+
return;
132133
}
133134

134135
throw error;
@@ -282,6 +283,27 @@ async function parsePatchCommand(tokens: string[], argv: string[]): Promise<Pars
282283
};
283284
}
284285

286+
/** Parse the general pager wrapper command used from Git `core.pager`. */
287+
async function parsePagerCommand(tokens: string[], argv: string[]): Promise<PagerCommandInput | HelpCommandInput> {
288+
const command = createCommand("pager", "general Git pager wrapper with diff detection");
289+
let parsedOptions: Record<string, unknown> = {};
290+
291+
command.action((options: Record<string, unknown>) => {
292+
parsedOptions = options;
293+
});
294+
295+
if (tokens.includes("--help") || tokens.includes("-h")) {
296+
return { kind: "help", text: `${command.helpInformation().trimEnd()}\n` };
297+
}
298+
299+
await parseStandaloneCommand(command, tokens);
300+
301+
return {
302+
kind: "pager",
303+
options: buildCommonOptions(parsedOptions, argv),
304+
};
305+
}
306+
285307
/** Parse Git difftool-style two-file review commands. */
286308
async function parseDifftoolCommand(tokens: string[], argv: string[]): Promise<ParsedCliInput> {
287309
const command = createCommand("difftool", "review Git difftool file pairs")
@@ -379,6 +401,8 @@ export async function parseCli(argv: string[]): Promise<ParsedCliInput> {
379401
return parseGitCommand(rest, argv);
380402
case "patch":
381403
return parsePatchCommand(rest, argv);
404+
case "pager":
405+
return parsePagerCommand(rest, argv);
382406
case "difftool":
383407
return parseDifftoolCommand(rest, argv);
384408
case "stash":

src/core/loaders.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -329,9 +329,10 @@ async function loadStashShowChangeset(input: StashShowCommandInput, agentContext
329329
/** Build a changeset from patch text supplied by file or stdin. */
330330
async function loadPatchChangeset(input: PatchCommandInput, agentContext: AgentContext | null) {
331331
const patchText =
332-
!input.file || input.file === "-"
332+
input.text ??
333+
(!input.file || input.file === "-"
333334
? await new Response(Bun.stdin.stream()).text()
334-
: await Bun.file(input.file).text();
335+
: await Bun.file(input.file).text());
335336

336337
const label = input.file && input.file !== "-" ? input.file : "stdin patch";
337338
return normalizePatchChangeset(patchText, `Patch review: ${basename(label)}`, label, agentContext);

src/core/pager.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { spawn } from "node:child_process";
2+
import { once } from "node:events";
3+
4+
/** Remove terminal escape sequences before deciding whether stdin looks like a patch. */
5+
function stripTerminalControl(text: string) {
6+
return text
7+
.replace(/\x1bP[\s\S]*?\x1b\\/g, "")
8+
.replace(/\x1b\][\s\S]*?(?:\x07|\x1b\\)/g, "")
9+
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
10+
.replace(/\x1b[@-_]/g, "");
11+
}
12+
13+
/** Detect whether generic pager stdin looks like a diff/patch that Hunk should review. */
14+
export function looksLikePatchInput(text: string) {
15+
const normalized = stripTerminalControl(text.replaceAll("\r\n", "\n"));
16+
17+
return (
18+
/^diff --git /m.test(normalized) ||
19+
(/^--- /m.test(normalized) && /^\+\+\+ /m.test(normalized)) ||
20+
/^@@ /m.test(normalized)
21+
);
22+
}
23+
24+
/** Choose a plain-text pager command while avoiding recursive `hunk pager` launches. */
25+
export function resolveTextPagerCommand(env: NodeJS.ProcessEnv = process.env) {
26+
const candidate = env.HUNK_TEXT_PAGER ?? env.PAGER;
27+
28+
if (!candidate || /(^|\s)hunk(\s|$)/.test(candidate)) {
29+
return "less -R";
30+
}
31+
32+
return candidate;
33+
}
34+
35+
/** Stream plain text through a normal pager, or write directly when not attached to a terminal. */
36+
export async function pagePlainText(text: string, env: NodeJS.ProcessEnv = process.env) {
37+
if (!process.stdout.isTTY) {
38+
process.stdout.write(text);
39+
return;
40+
}
41+
42+
const pagerCommand = resolveTextPagerCommand(env);
43+
const pager = spawn(pagerCommand, {
44+
shell: true,
45+
stdio: ["pipe", "inherit", "inherit"],
46+
env,
47+
});
48+
49+
pager.stdin?.end(text);
50+
const [, code] = await once(pager, "close");
51+
52+
if (typeof code === "number" && code !== 0) {
53+
throw new Error(`Pager command failed: ${pagerCommand}`);
54+
}
55+
}

src/core/types.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,11 @@ export interface HelpCommandInput {
7171
text: string;
7272
}
7373

74+
export interface PagerCommandInput {
75+
kind: "pager";
76+
options: CommonOptions;
77+
}
78+
7479
export interface GitCommandInput {
7580
kind: "git";
7681
range?: string;
@@ -102,6 +107,7 @@ export interface FileCommandInput {
102107
export interface PatchCommandInput {
103108
kind: "patch";
104109
file?: string;
110+
text?: string;
105111
options: CommonOptions;
106112
}
107113

@@ -121,7 +127,7 @@ export type CliInput =
121127
| PatchCommandInput
122128
| DiffToolCommandInput;
123129

124-
export type ParsedCliInput = CliInput | HelpCommandInput;
130+
export type ParsedCliInput = CliInput | HelpCommandInput | PagerCommandInput;
125131

126132
export interface AppBootstrap {
127133
input: CliInput;

src/main.tsx

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,37 @@ import { createRoot } from "@opentui/react";
55
import { parseCli } from "./core/cli";
66
import { persistViewPreferences, resolveConfiguredCliInput } from "./core/config";
77
import { loadAppBootstrap } from "./core/loaders";
8+
import { looksLikePatchInput, pagePlainText } from "./core/pager";
89
import { shutdownSession } from "./core/shutdown";
910
import { openControllingTerminal, resolveRuntimeCliInput, usesPipedPatchInput } from "./core/terminal";
1011
import { App } from "./ui/App";
1112

12-
const parsedCliInput = await parseCli(process.argv);
13+
let parsedCliInput = await parseCli(process.argv);
1314

1415
if (parsedCliInput.kind === "help") {
1516
process.stdout.write(parsedCliInput.text);
1617
process.exit(0);
1718
}
1819

20+
if (parsedCliInput.kind === "pager") {
21+
const stdinText = await new Response(Bun.stdin.stream()).text();
22+
23+
if (!looksLikePatchInput(stdinText)) {
24+
await pagePlainText(stdinText);
25+
process.exit(0);
26+
}
27+
28+
parsedCliInput = {
29+
kind: "patch",
30+
file: "-",
31+
text: stdinText,
32+
options: {
33+
...parsedCliInput.options,
34+
pager: true,
35+
},
36+
};
37+
}
38+
1939
const runtimeCliInput = resolveRuntimeCliInput(parsedCliInput);
2040
const configured = resolveConfiguredCliInput(runtimeCliInput);
2141
const cliInput = configured.input;

test/cli.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,17 @@ describe("parseCli", () => {
124124
});
125125
});
126126

127+
test("parses general pager mode", async () => {
128+
const parsed = await parseCli(["bun", "hunk", "pager", "--theme", "paper"]);
129+
130+
expect(parsed).toMatchObject({
131+
kind: "pager",
132+
options: {
133+
theme: "paper",
134+
},
135+
});
136+
});
137+
127138
test("parses stash show mode", async () => {
128139
const parsed = await parseCli(["bun", "hunk", "stash", "show", "stash@{1}"]);
129140

test/help-output.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,27 @@ describe("CLI help output", () => {
1717
expect(stdout).toContain("Usage:");
1818
expect(stdout).toContain("hunk diff");
1919
expect(stdout).toContain("hunk show");
20+
expect(stdout).toContain("hunk pager");
21+
expect(stdout).not.toContain("\u001b[?1049h");
22+
});
23+
24+
test("general pager mode falls back to plain text for non-diff stdin", () => {
25+
const proc = Bun.spawnSync(["bash", "-lc", "printf '* main\\n feature/demo\\n' | HUNK_TEXT_PAGER=cat bun run src/main.tsx pager"], {
26+
cwd: process.cwd(),
27+
stdin: "ignore",
28+
stdout: "pipe",
29+
stderr: "pipe",
30+
env: process.env,
31+
});
32+
33+
const stdout = Buffer.from(proc.stdout).toString("utf8");
34+
const stderr = Buffer.from(proc.stderr).toString("utf8");
35+
36+
expect(proc.exitCode).toBe(0);
37+
expect(stderr).toBe("");
38+
expect(stdout).toContain("* main");
39+
expect(stdout).toContain("feature/demo");
40+
expect(stdout).not.toContain("View Navigate Theme Agent Help");
2041
expect(stdout).not.toContain("\u001b[?1049h");
2142
});
2243
});

test/pager.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { looksLikePatchInput } from "../src/core/pager";
3+
4+
describe("general pager detection", () => {
5+
test("detects git-style patch input even when ANSI-colored", () => {
6+
const patch = [
7+
"\u001b[1mdiff --git a/src/example.ts b/src/example.ts\u001b[m",
8+
"index 1111111..2222222 100644",
9+
"--- a/src/example.ts",
10+
"+++ b/src/example.ts",
11+
"@@ -1 +1,2 @@",
12+
"-export const value = 1;",
13+
"+export const value = 2;",
14+
"+export const extra = true;",
15+
].join("\n");
16+
17+
expect(looksLikePatchInput(patch)).toBe(true);
18+
});
19+
20+
test("does not misclassify plain git pager text as a patch", () => {
21+
const branchOutput = ["* main", " feat/persist-view-config", " release/0.1.0"].join("\n");
22+
23+
expect(looksLikePatchInput(branchOutput)).toBe(false);
24+
});
25+
});

test/tty-render-smoke.test.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,10 +137,11 @@ async function runTtySmoke(options: { mode?: "split" | "stack"; pager?: boolean;
137137
return stripTerminalControl(await Bun.file(transcript).text());
138138
}
139139

140-
async function runStdinPagerSmoke(options?: { input?: string; inputCommand?: string; lines?: number }) {
140+
async function runStdinPagerSmoke(options?: { input?: string; inputCommand?: string; lines?: number; command?: "patch" | "pager" }) {
141141
const fixture = createFixtureFiles(options?.lines ?? 1);
142142
const transcript = join(fixture.dir, "stdin-pager-transcript.txt");
143-
const patchCommand = `cat ${shellQuote(fixture.coloredPatch)} | bun run src/main.tsx patch -`;
143+
const subcommand = options?.command === "pager" ? "pager" : "patch -";
144+
const patchCommand = `cat ${shellQuote(fixture.coloredPatch)} | bun run src/main.tsx ${subcommand}`;
144145
const scriptCommand = `timeout 5 script -q -f -e -c ${shellQuote(patchCommand)} ${shellQuote(transcript)}`;
145146
const inputCommand = options?.inputCommand ?? `(sleep 1; printf ${shellQuote(options?.input ?? "q")})`;
146147
const proc = Bun.spawnSync(["bash", "-lc", `${inputCommand} | ${scriptCommand}`], {
@@ -235,4 +236,17 @@ describe("TTY render smoke", () => {
235236
expect(output).toContain("before_23");
236237
expect(output).toContain("after_06");
237238
});
239+
240+
test("general pager mode opens Hunk pager UI for diff-like stdin", async () => {
241+
if (!ttyToolsAvailable) {
242+
return;
243+
}
244+
245+
const output = await runStdinPagerSmoke({ command: "pager" });
246+
247+
expect(output).not.toContain("View Navigate Theme Agent Help");
248+
expect(output).toContain("after.ts");
249+
expect(output).toContain("@@ -1 +1,2 @@");
250+
expect(output).toContain("export const answer = 42;");
251+
});
238252
});

0 commit comments

Comments
 (0)