Skip to content

Commit 0676315

Browse files
committed
fix: support git pager stdin patches
1 parent 39d6a81 commit 0676315

7 files changed

Lines changed: 218 additions & 5 deletions

File tree

README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,13 +161,21 @@ Interpretation:
161161

162162
## Git integration
163163

164-
Use Hunk as the viewer for `git diff` and `git show`:
164+
Use Hunk as the default Git pager:
165+
166+
```bash
167+
git config --global core.pager 'hunk patch -'
168+
```
169+
170+
Or scope it just to `git diff` and `git show`:
165171

166172
```bash
167173
git config --global pager.diff 'hunk patch -'
168174
git config --global pager.show 'hunk patch -'
169175
```
170176

177+
When Hunk reads a patch from stdin, it automatically switches to pager-style chrome, strips Git's color escape sequences before parsing, and binds keyboard input to the controlling terminal so it works correctly as a Git pager.
178+
171179
Then:
172180

173181
```bash

src/core/loaders.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,15 @@ function stripPrefixes(path: string) {
4848
return path.replace(/^[ab]\//, "");
4949
}
5050

51+
/** Remove terminal escape sequences so Git-colored pager input still parses as plain patch text. */
52+
function stripTerminalControl(text: string) {
53+
return text
54+
.replace(/\x1bP[\s\S]*?\x1b\\/g, "")
55+
.replace(/\x1b\][\s\S]*?(?:\x07|\x1b\\)/g, "")
56+
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
57+
.replace(/\x1b[@-_]/g, "");
58+
}
59+
5160
/** Split a multi-file patch into per-file chunks so each diff file keeps its original patch text. */
5261
function splitPatchIntoFileChunks(rawPatch: string) {
5362
const patch = rawPatch.replaceAll("\r\n", "\n");
@@ -188,9 +197,10 @@ function normalizePatchChangeset(
188197
sourceLabel: string,
189198
agentContext: AgentContext | null,
190199
): Changeset {
191-
const parsedPatches = parsePatchFiles(patchText, "patch", true);
200+
const normalizedPatchText = stripTerminalControl(patchText.replaceAll("\r\n", "\n"));
201+
const parsedPatches = parsePatchFiles(normalizedPatchText, "patch", true);
192202
const metadataFiles = parsedPatches.flatMap((entry) => entry.files);
193-
const chunks = splitPatchIntoFileChunks(patchText);
203+
const chunks = splitPatchIntoFileChunks(normalizedPatchText);
194204

195205
return {
196206
id: `changeset:${Date.now()}`,

src/core/terminal.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import fs from "node:fs";
2+
import tty from "node:tty";
3+
import type { CliInput } from "./types";
4+
5+
/** Detect the stdin-pipe patch workflow used by `git diff` pagers. */
6+
export function usesPipedPatchInput(input: CliInput, stdinIsTTY = Boolean(process.stdin.isTTY)) {
7+
return input.kind === "patch" && (!input.file || input.file === "-") && !stdinIsTTY;
8+
}
9+
10+
/** Enable pager-style chrome automatically when Hunk is consuming a piped patch. */
11+
export function shouldUsePagerMode(input: CliInput, stdinIsTTY = Boolean(process.stdin.isTTY)) {
12+
return Boolean(input.options.pager) || usesPipedPatchInput(input, stdinIsTTY);
13+
}
14+
15+
/** Apply runtime CLI defaults that depend on whether stdin is an interactive terminal. */
16+
export function resolveRuntimeCliInput(input: CliInput, stdinIsTTY = Boolean(process.stdin.isTTY)): CliInput {
17+
return {
18+
...input,
19+
options: {
20+
...input.options,
21+
pager: shouldUsePagerMode(input, stdinIsTTY),
22+
},
23+
} as CliInput;
24+
}
25+
26+
export interface ControllingTerminal {
27+
stdin: tty.ReadStream;
28+
stdout: tty.WriteStream;
29+
close: () => void;
30+
}
31+
32+
/** Open the controlling terminal so the UI can stay interactive while stdin carries patch data. */
33+
export function openControllingTerminal(): ControllingTerminal | null {
34+
try {
35+
const stdinFd = fs.openSync("/dev/tty", "r");
36+
const stdoutFd = fs.openSync("/dev/tty", "w");
37+
const stdin = new tty.ReadStream(stdinFd);
38+
const stdout = new tty.WriteStream(stdoutFd);
39+
40+
return {
41+
stdin,
42+
stdout,
43+
close: () => {
44+
stdin.destroy();
45+
stdout.destroy();
46+
},
47+
};
48+
} catch {
49+
return null;
50+
}
51+
}

src/main.tsx

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

10-
const cliInput = await parseCli(process.argv);
11+
const cliInput = resolveRuntimeCliInput(await parseCli(process.argv));
1112
const bootstrap = await loadAppBootstrap(cliInput);
13+
const controllingTerminal = usesPipedPatchInput(cliInput) ? openControllingTerminal() : null;
1214

1315
const renderer = await createCliRenderer({
16+
stdin: controllingTerminal?.stdin,
17+
stdout: controllingTerminal?.stdout,
1418
useMouse: !cliInput.options.pager,
1519
useAlternateScreen: true,
1620
exitOnCtrlC: true,
1721
openConsoleOnError: true,
22+
onDestroy: () => controllingTerminal?.close(),
1823
});
1924

2025
const root = createRoot(renderer);

test/loaders.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,4 +161,40 @@ describe("loadAppBootstrap", () => {
161161
process.chdir(previousCwd);
162162
}
163163
});
164+
165+
test("loads colorized git patch files like the real pager stdin stream", async () => {
166+
const dir = mkdtempSync(join(tmpdir(), "hunk-patch-"));
167+
tempDirs.push(dir);
168+
169+
const before = join(dir, "before.ts");
170+
const after = join(dir, "after.ts");
171+
const patch = join(dir, "input.patch");
172+
173+
writeFileSync(before, "export const answer = 41;\n");
174+
writeFileSync(after, "export const answer = 42;\nexport const added = true;\n");
175+
176+
const diffProc = Bun.spawnSync(["git", "diff", "--no-index", "--color=always", "--", before, after], {
177+
cwd: dir,
178+
stdin: "ignore",
179+
stdout: "pipe",
180+
stderr: "pipe",
181+
});
182+
183+
if (diffProc.exitCode !== 0 && diffProc.exitCode !== 1) {
184+
const stderr = Buffer.from(diffProc.stderr).toString("utf8");
185+
throw new Error(stderr.trim() || `git diff --color=always failed`);
186+
}
187+
188+
writeFileSync(patch, Buffer.from(diffProc.stdout).toString("utf8"));
189+
190+
const bootstrap = await loadAppBootstrap({
191+
kind: "patch",
192+
file: patch,
193+
options: { mode: "auto" },
194+
});
195+
196+
expect(bootstrap.changeset.files).toHaveLength(1);
197+
expect(bootstrap.changeset.files[0]?.path.endsWith("after.ts")).toBe(true);
198+
expect(bootstrap.changeset.files[0]?.stats.additions).toBeGreaterThan(0);
199+
});
164200
});

test/terminal.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { describe, expect, test } from "bun:test";
2+
import type { CliInput } from "../src/core/types";
3+
import { resolveRuntimeCliInput, shouldUsePagerMode, usesPipedPatchInput } from "../src/core/terminal";
4+
5+
function createPatchInput(file?: string, pager = false): CliInput {
6+
return {
7+
kind: "patch",
8+
file,
9+
options: {
10+
mode: "auto",
11+
pager,
12+
},
13+
};
14+
}
15+
16+
describe("terminal runtime defaults", () => {
17+
test("treats stdin patch mode as pager-style when stdin is piped", () => {
18+
const input = createPatchInput("-", false);
19+
20+
expect(usesPipedPatchInput(input, false)).toBe(true);
21+
expect(shouldUsePagerMode(input, false)).toBe(true);
22+
expect(resolveRuntimeCliInput(input, false).options.pager).toBe(true);
23+
});
24+
25+
test("does not force pager mode for patch files or interactive stdin", () => {
26+
expect(usesPipedPatchInput(createPatchInput("changes.patch"), false)).toBe(false);
27+
expect(shouldUsePagerMode(createPatchInput("changes.patch"), false)).toBe(false);
28+
expect(shouldUsePagerMode(createPatchInput("-"), true)).toBe(false);
29+
});
30+
31+
test("keeps explicit pager mode enabled", () => {
32+
const input = createPatchInput(undefined, true);
33+
34+
expect(shouldUsePagerMode(input, true)).toBe(true);
35+
expect(resolveRuntimeCliInput(input, true).options.pager).toBe(true);
36+
});
37+
});

test/tty-render-smoke.test.ts

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ function createFixtureFiles() {
4040
const before = join(dir, "before.ts");
4141
const after = join(dir, "after.ts");
4242
const agent = join(dir, "agent.json");
43+
const patch = join(dir, "input.patch");
44+
const coloredPatch = join(dir, "input-colored.patch");
4345

4446
writeFileSync(before, "export const answer = 41;\n");
4547
writeFileSync(after, "export const answer = 42;\nexport const added = true;\n");
@@ -56,7 +58,33 @@ function createFixtureFiles() {
5658
}),
5759
);
5860

59-
return { dir, before, after, agent };
61+
const patchProc = Bun.spawnSync(["git", "diff", "--no-index", "--no-color", "--", before, after], {
62+
cwd: dir,
63+
stdin: "ignore",
64+
stdout: "pipe",
65+
stderr: "pipe",
66+
});
67+
const coloredPatchProc = Bun.spawnSync(["git", "diff", "--no-index", "--color=always", "--", before, after], {
68+
cwd: dir,
69+
stdin: "ignore",
70+
stdout: "pipe",
71+
stderr: "pipe",
72+
});
73+
74+
if (patchProc.exitCode !== 0 && patchProc.exitCode !== 1) {
75+
const stderr = Buffer.from(patchProc.stderr).toString("utf8");
76+
throw new Error(stderr.trim() || `failed to build fixture patch: ${patchProc.exitCode}`);
77+
}
78+
79+
if (coloredPatchProc.exitCode !== 0 && coloredPatchProc.exitCode !== 1) {
80+
const stderr = Buffer.from(coloredPatchProc.stderr).toString("utf8");
81+
throw new Error(stderr.trim() || `failed to build colored fixture patch: ${coloredPatchProc.exitCode}`);
82+
}
83+
84+
writeFileSync(patch, Buffer.from(patchProc.stdout).toString("utf8"));
85+
writeFileSync(coloredPatch, Buffer.from(coloredPatchProc.stdout).toString("utf8"));
86+
87+
return { dir, before, after, agent, patch, coloredPatch };
6088
}
6189

6290
async function runTtySmoke(options: { mode?: "split" | "stack"; pager?: boolean; agentContext?: boolean }) {
@@ -96,6 +124,30 @@ async function runTtySmoke(options: { mode?: "split" | "stack"; pager?: boolean;
96124
return stripTerminalControl(await Bun.file(transcript).text());
97125
}
98126

127+
async function runStdinPagerSmoke() {
128+
const fixture = createFixtureFiles();
129+
const transcript = join(fixture.dir, "stdin-pager-transcript.txt");
130+
const patchCommand = `cat ${shellQuote(fixture.coloredPatch)} | bun run src/main.tsx patch -`;
131+
const scriptCommand = `timeout 5 script -q -f -e -c ${shellQuote(patchCommand)} ${shellQuote(transcript)}`;
132+
const proc = Bun.spawnSync(["bash", "-lc", `(sleep 1; printf q) | ${scriptCommand}`], {
133+
cwd: process.cwd(),
134+
stdin: "ignore",
135+
stdout: "pipe",
136+
stderr: "pipe",
137+
env: {
138+
...process.env,
139+
TERM: "xterm-256color",
140+
},
141+
});
142+
143+
if (proc.exitCode !== 0) {
144+
const stderr = Buffer.from(proc.stderr).toString("utf8");
145+
throw new Error(stderr.trim() || `stdin pager smoke command failed with exit ${proc.exitCode}`);
146+
}
147+
148+
return stripTerminalControl(await Bun.file(transcript).text());
149+
}
150+
99151
afterEach(() => {
100152
cleanupTempDirs();
101153
});
@@ -141,4 +193,18 @@ describe("TTY render smoke", () => {
141193
expect(output).toContain("before.ts -> after.ts");
142194
expect(output).toContain("export const answer = 42;");
143195
});
196+
197+
test("stdin patch mode auto-enters pager mode and can quit from terminal input", async () => {
198+
if (!ttyToolsAvailable) {
199+
return;
200+
}
201+
202+
const output = await runStdinPagerSmoke();
203+
204+
expect(output).not.toContain("View Navigate Theme Agent Help");
205+
expect(output).not.toContain("F10 menu");
206+
expect(output).toContain("after.ts");
207+
expect(output).toContain("@@ -1 +1,2 @@");
208+
expect(output).toContain("export const answer = 42;");
209+
});
144210
});

0 commit comments

Comments
 (0)