Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/lucky-moles-color.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": minor
---

Add `hunk --color-only` to color a unified diff piped on stdin without the interactive UI, for `git interactive.diffFilter` (`git add -p`) and fzf-style diff previews.
66 changes: 66 additions & 0 deletions src/app/startup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,72 @@ describe("startup planning", () => {
expect(loaded).toBe(false);
});

test("routes color-only pager stdin through the filter plan", async () => {
let loaded = false;
const patchText = "diff --git a/a.ts b/a.ts\n@@ -1 +1 @@\n-old\n+new\n";
const customThemes = [{ id: "custom", base: "github-light-default", text: "#123456" }];

const plan = await prepareStartupPlan(["bun", "hunk", "--color-only"], {
parseCliImpl: async () => ({ kind: "pager", colorOnly: true, options: { theme: "custom" } }),
readStdinText: async () => patchText,
looksLikePatchInputImpl: () => true,
stdoutIsTTY: false,
env: { TERM: "xterm-256color" },
resolveRuntimeCliInputImpl: (input) => input,
resolveConfiguredCliInputImpl: (input) =>
createTestConfigResolution(
{
...input,
options: { ...input.options, theme: "custom" },
},
{ customThemes },
),
loadAppBootstrapImpl: async () => {
loaded = true;
throw new Error("unreachable");
},
});

expect(plan).toEqual({
kind: "color-only",
text: patchText,
options: { theme: "custom", pager: true },
customThemes,
});
expect(loaded).toBe(false);
});

test("keeps non-diff stdin on the color-only plan so the filter can pass it through", async () => {
let loaded = false;
const text = "* main\n feature/demo\n";

const plan = await prepareStartupPlan(["bun", "hunk", "--color-only"], {
parseCliImpl: async () => ({ kind: "pager", colorOnly: true, options: {} }),
readStdinText: async () => text,
looksLikePatchInputImpl: () => false,
stdoutIsTTY: false,
env: { TERM: "xterm-256color" },
resolveRuntimeCliInputImpl: (input) => input,
resolveConfiguredCliInputImpl: (input) => createTestConfigResolution(input),
loadAppBootstrapImpl: async () => {
loaded = true;
throw new Error("unreachable");
},
});

expect(plan.kind).toBe("color-only");
expect(loaded).toBe(false);
});

test("rejects color-only filter mode when stdin is an interactive terminal", async () => {
await expect(
prepareStartupPlan(["bun", "hunk", "--color-only"], {
parseCliImpl: async () => ({ kind: "pager", colorOnly: true, options: {} }),
stdinIsTTY: true,
}),
).rejects.toThrow("reads a unified diff from standard input");
});

test("routes diff-like pager stdin to static output when no controlling terminal is available", async () => {
let loaded = false;
const patchText = "diff --git a/a.ts b/a.ts\n@@ -1 +1 @@\n-old\n+new\n";
Expand Down
62 changes: 43 additions & 19 deletions src/app/startup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,12 @@ export type StartupPlan =
options: CliInput["options"];
customThemes?: AppBootstrap["customThemes"];
}
| {
kind: "color-only";
text: string;
options: CliInput["options"];
customThemes?: AppBootstrap["customThemes"];
}
| {
kind: "markup-render";
input: MarkupRenderCommandInput;
Expand Down Expand Up @@ -176,37 +182,55 @@ export async function prepareStartupPlan(
}

if (parsedCliInput.kind === "pager") {
const wantsColorOnly = parsedCliInput.colorOnly === true;
if (wantsColorOnly && stdinIsTTY) {
// Reading an interactive stdin would hang the filter; Git always pipes the diff.
throw new HunkUserError("`hunk --color-only` reads a unified diff from standard input.", [
"Use it as a Git diff filter: `git config interactive.diffFilter 'hunk --color-only'`.",
]);
}

const stdinText = await readStdinText();
const pagerOptions = parsedCliInput.options;
const capturedPagerHost = isCapturedPagerHost(env);
const staticPagerPlan = () => {
const staticPatchInput: CliInput = {
kind: "patch",
file: "-",
text: stdinText,
options: {
...pagerOptions,
pager: true,
},
};
const configuredStatic = resolveConfiguredCliInputImpl(
resolveRuntimeCliInputImpl(staticPatchInput),
// Both non-interactive pager plans resolve the piped patch through the same config layers,
// so option defaults and custom themes apply identically. Extensions never load on these
// paths, so config themes are the whole theme set here.
const resolvePipedPatchPlan = (kind: "static-diff-pager" | "color-only") => {
const configured = resolveConfiguredCliInputImpl(
resolveRuntimeCliInputImpl({
kind: "patch",
file: "-",
text: stdinText,
options: {
...pagerOptions,
pager: true,
},
}),
{
vcsCatalog: baseVcsCatalog,
},
);
const staticPlan = {
kind: "static-diff-pager" as const,
const plan = {
kind,
text: stdinText,
options: configuredStatic.input.options,
options: configured.input.options,
};

// Extensions never load on the static pager path, so config themes are the whole set here.
return configuredStatic.customThemes.length > 0
? { ...staticPlan, customThemes: configuredStatic.customThemes }
: staticPlan;
return configured.customThemes.length > 0
? { ...plan, customThemes: configured.customThemes }
: plan;
};

const staticPagerPlan = () => resolvePipedPatchPlan("static-diff-pager");

// Filter mode: color whatever stdin holds without touching the interactive pager paths.
// The renderer itself passes non-diff input through unchanged, as `interactive.diffFilter`
// requires, so both diff-like and plain stdin share this one plan.
if (wantsColorOnly) {
return resolvePipedPatchPlan("color-only");
}

// Captured hosts render Hunk's stdout in their own panel, so passed-through text keeps
// the color Git already put in it.
const passthroughPlan = {
Expand Down
41 changes: 41 additions & 0 deletions src/core/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,42 @@ describe("parseCli", () => {
});
});

test("parses bare --color-only as pager filter mode", async () => {
const parsed = await parseCli(["bun", "hunk", "--color-only"]);

expect(parsed).toMatchObject({
kind: "pager",
colorOnly: true,
});
});

test("parses --color-only with theme options", async () => {
const parsed = await parseCli([
"bun",
"hunk",
"--color-only",
"--theme",
"github-light-default",
]);

expect(parsed).toMatchObject({
kind: "pager",
colorOnly: true,
options: {
theme: "github-light-default",
},
});
});

test("parses pager --color-only explicitly", async () => {
const parsed = await parseCli(["bun", "hunk", "pager", "--color-only"]);

expect(parsed).toMatchObject({
kind: "pager",
colorOnly: true,
});
});

test("prints the bundled skill path for hunk skill path", async () => {
const parsed = await parseCli(["bun", "hunk", "skill", "path"]);

Expand Down Expand Up @@ -1116,6 +1152,11 @@ describe("parseCli command help text", () => {
expect(await expectHelp(["difftool", "--help"])).toContain("review Git difftool file pairs");
});

test("documents the --color-only filter mode in help output", async () => {
expect(await expectHelp([])).toContain("hunk --color-only");
expect(await expectHelp(["pager", "--help"])).toContain("--color-only");
});

test("renders the stash command overview and the stash show command help", async () => {
const overview = await expectHelp(["stash"]);
expect(overview).toContain("Usage: hunk stash show [ref] [options]");
Expand Down
10 changes: 9 additions & 1 deletion src/core/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,7 @@ function renderCliHelp() {
" hunk stash show [ref] review a stash entry (git only)",
" hunk patch [file] review a patch file or stdin",
" hunk pager general Git pager wrapper with diff detection",
" hunk --color-only color a unified diff piped on stdin (git interactive.diffFilter)",
" hunk difftool <left> <right> [path] review Git difftool file pairs",
" hunk session <subcommand> inspect or control a live Hunk session",
" hunk markup render (<file> | -) preview experimental STML note markup",
Expand Down Expand Up @@ -782,7 +783,10 @@ async function parsePagerCommand(
tokens: string[],
argv: string[],
): Promise<PagerCommandInput | HelpCommandInput> {
const command = createCliReferenceCommand("pager");
const command = createCliReferenceCommand("pager").option(
"--color-only",
"color the piped diff without reformatting it (for git interactive.diffFilter)",
);
let parsedOptions: Record<string, unknown> = {};

command.action((options: Record<string, unknown>) => {
Expand All @@ -797,6 +801,7 @@ async function parsePagerCommand(

return {
kind: "pager",
colorOnly: parsedOptions.colorOnly ? true : undefined,
options: buildCommonOptions(parsedOptions, argv),
};
}
Expand Down Expand Up @@ -1638,6 +1643,9 @@ export async function parseCli(argv: string[]): Promise<ParsedCliInput> {
return parsePatchCommand(rest, argv);
case "pager":
return parsePagerCommand(rest, argv);
// Bare `hunk --color-only` is the form Git stores in `interactive.diffFilter`.
case "--color-only":
return parsePagerCommand(["--color-only", ...rest], argv);
case "difftool":
return parseDifftoolCommand(rest, argv);
case "stash":
Expand Down
2 changes: 2 additions & 0 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,8 @@ export interface HelpCommandInput {
export interface PagerCommandInput {
kind: "pager";
options: CommonOptions;
/** Filter mode: color stdin without reformatting it, for `git interactive.diffFilter`. */
colorOnly?: boolean;
}

export interface DaemonServeCommandInput {
Expand Down
11 changes: 11 additions & 0 deletions src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,17 @@ async function main() {
process.exit(0);
}

if (startupPlan.kind === "color-only") {
const { renderColorOnlyDiff } = await import("./ui/colorOnlyFilter");
process.stdout.write(
await renderColorOnlyDiff(startupPlan.text, startupPlan.options, {
customThemes: startupPlan.customThemes,
stderr: process.stderr,
}),
);
process.exit(0);
}

if (startupPlan.kind !== "app") {
throw new Error("Unreachable startup plan.");
}
Expand Down
Loading
Loading