From f38c6f88917508be82138fb3fd1cec899ac23476 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Thu, 3 Sep 2026 21:54:02 -0400 Subject: [PATCH 1/7] feat: add git history browser --- .changeset/pretty-git-history.md | 5 + README.md | 9 + docs/extensions.md | 29 ++ docs/keybindings.md | 6 + src/app/cli.test.ts | 70 ++++ src/app/cli.ts | 117 +++++- src/app/historyBootstrap.ts | 148 ++++++++ src/app/startup.ts | 33 ++ src/core/history/lanePlanner.test.ts | 64 ++++ src/core/history/lanePlanner.ts | 86 +++++ src/core/history/types.ts | 32 ++ src/core/process/pager.test.ts | 38 ++ src/core/process/pager.ts | 91 +++-- src/core/process/relaunch.test.ts | 26 ++ src/core/process/relaunch.ts | 31 ++ src/core/run/cliCommandNames.ts | 1 + src/core/run/commandInputs.ts | 26 ++ src/core/vcs/index.ts | 21 ++ src/core/vcs/types.ts | 18 +- src/extension-api/index.ts | 6 + src/extension-api/types.ts | 58 ++- .../default/vcs/git/history.test.ts | 103 ++++++ src/extensions/default/vcs/git/history.ts | 342 ++++++++++++++++++ src/extensions/default/vcs/git/index.ts | 6 + src/extensions/runExtension.test.ts | 122 ++++++- src/extensions/runExtension.ts | 268 +++++++++++++- src/extensions/types.ts | 6 + src/main.tsx | 12 + src/session/broker/brokerLauncher.ts | 50 +-- src/ui/App.tsx | 10 +- src/ui/history/runInteractiveHistory.ts | 304 ++++++++++++++++ src/ui/history/runStaticHistory.test.ts | 158 ++++++++ src/ui/history/runStaticHistory.ts | 138 +++++++ src/ui/history/staticProjection.test.ts | 96 +++++ src/ui/history/staticProjection.ts | 244 +++++++++++++ src/ui/history/terminalInput.test.ts | 36 ++ src/ui/history/terminalInput.ts | 167 +++++++++ src/ui/history/types.ts | 15 + test/cli/log.test.ts | 151 ++++++++ test/pty/log-integration.test.ts | 95 +++++ website/astro.config.mjs | 1 + .../src/content/docs/docs/reference/cli.md | 88 +++-- .../docs/docs/workflows/git-history.md | 65 ++++ .../docs/workflows/git-pager-and-difftool.md | 2 +- 44 files changed, 3276 insertions(+), 118 deletions(-) create mode 100644 .changeset/pretty-git-history.md create mode 100644 src/app/historyBootstrap.ts create mode 100644 src/core/history/lanePlanner.test.ts create mode 100644 src/core/history/lanePlanner.ts create mode 100644 src/core/history/types.ts create mode 100644 src/core/process/relaunch.test.ts create mode 100644 src/core/process/relaunch.ts create mode 100644 src/extensions/default/vcs/git/history.test.ts create mode 100644 src/extensions/default/vcs/git/history.ts create mode 100644 src/ui/history/runInteractiveHistory.ts create mode 100644 src/ui/history/runStaticHistory.test.ts create mode 100644 src/ui/history/runStaticHistory.ts create mode 100644 src/ui/history/staticProjection.test.ts create mode 100644 src/ui/history/staticProjection.ts create mode 100644 src/ui/history/terminalInput.test.ts create mode 100644 src/ui/history/terminalInput.ts create mode 100644 src/ui/history/types.ts create mode 100644 test/cli/log.test.ts create mode 100644 test/pty/log-integration.test.ts create mode 100644 website/src/content/docs/docs/workflows/git-history.md diff --git a/.changeset/pretty-git-history.md b/.changeset/pretty-git-history.md new file mode 100644 index 000000000..8823d8345 --- /dev/null +++ b/.changeset/pretty-git-history.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": minor +--- + +Add a themed, static-first `hunk log` with familiar commit metadata and decorations, compact output, and an explicit minimal browser that opens selected commits in Hunk. diff --git a/README.md b/README.md index d327d7a3f..ee4cbd835 100644 --- a/README.md +++ b/README.md @@ -87,8 +87,17 @@ hunk --fast # experimentally offload eligible syntax highligh hunk diff --watch # auto-reload as the working tree changes hunk show # review the latest commit hunk show HEAD~1 # review an earlier commit +hunk log # print a portable, attractive Git history +hunk log --interactive # browse history; Enter opens a commit in Hunk ``` +`hunk log` is a static-first, read-only alternative to `git log`, not a repository manager or a +parser for every formatting option. Its default output keeps full commit, author, date, message, +branch, remote, and tag details; `--oneline` provides compact rows, and `--theme` uses the same +palette as Hunk review. Static output remains safe for pipes, redirects, and normal terminal +scrollback. The explicit interactive mode stays a single history list; after opening a commit, +quit its normal Hunk review to return to the same selection. + ### Working with Jujutsu and Sapling Hunk auto-detects Jujutsu and Sapling checkouts, so `hunk diff [revset]` and `hunk show [revset]` use native revsets inside jj or Sapling workspaces. To override VCS detection, set `vcs = "git"` or `vcs = "jj"` or `vcs = "sl"` in [config](#config). diff --git a/docs/extensions.md b/docs/extensions.md index 179eb1dbc..55c849e17 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -500,6 +500,35 @@ reuses one is skipped with a notice. map off entirely — produces a clear "not supported" error for that command instead of a crash. +API version 17 also adds the optional, read-only `history` capability used by `hunk log`: + +```ts +hunk.registerVcsAdapter({ + id: "hg-history", + name: "Mercurial history", + detect: () => null, + history: { + async open() { + return { + async read({ signal }) { + signal?.throwIfAborted(); + return { commits: [], done: true }; + }, + close() {}, + }; + }, + }, +}); +``` + +History is deliberately separate from patch-producing `operations`. Commits must carry an +immutable full `revisionId`, display id, ordered parent ids, subject, optional message body, +author (and optional email), ISO authored time, and structured ref decorations. Reads may return at most the requested limit and must distinguish +a page boundary from repository end with `done`. Hunk copies and validates every page, strips +terminal controls from display metadata, rejects duplicate revisions, forwards cancellation, and +closes the source at EOF or failure. Git implements this capability today; the bundled jj and +Sapling adapters currently report it as unsupported. + A `load` result is patch text plus how to label it. Everything else on it is optional, and each optional field buys one thing: diff --git a/docs/keybindings.md b/docs/keybindings.md index 356b8f1e3..12606d245 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -45,6 +45,12 @@ deleted until its replies are removed. The built-in commands and the keys they ship with: +`hunk log --interactive` is a separate, fixed read-only history entry point rather than part of +the configurable review command table. It uses `Up`/`Down` or `j`/`k` to move, `PageUp`/`PageDown`, +`g`/`G` or `Home`/`End` to jump, `/` to search, `n`/`N` for matches, `y` to copy the full commit id, +`Enter` to open the commit in normal Hunk review, and `q` to quit. Quitting the opened review +returns to the retained history selection and viewport. + | Command id | Does | Default keys | | ---------------------------------------------- | ---------------------------------------------- | ---------------------------- | | `hunk.app.openAgentSkill` | Show agent skill | _(none)_ | diff --git a/src/app/cli.test.ts b/src/app/cli.test.ts index dd9333691..69f6f6f02 100644 --- a/src/app/cli.test.ts +++ b/src/app/cli.test.ts @@ -58,6 +58,7 @@ describe("parseCli", () => { expect(parsed.text).toContain("Usage:"); expect(parsed.text).toContain("hunk diff"); expect(parsed.text).toContain("hunk show"); + expect(parsed.text).toContain("hunk log"); expect(parsed.text).toContain("hunk skill path"); expect(parsed.text).toContain("Global options:"); expect(parsed.text).toContain("Common review options:"); @@ -488,6 +489,75 @@ describe("parseCli", () => { }); }); + test("parses static and interactive log options without review flags", async () => { + expect( + await parseCli([ + "bun", + "hunk", + "log", + "main..feature", + "--first-parent", + "-n", + "25", + "--author", + "Ada", + "--color", + "never", + "--ascii", + "--interactive", + "--", + "src/app.ts", + ]), + ).toEqual({ + kind: "history", + revision: "main..feature", + firstParent: true, + maxCount: 25, + author: "Ada", + pathspecs: ["src/app.ts"], + color: "never", + format: "medium", + ascii: true, + interactive: true, + extensionsEnabled: true, + extensionPaths: [], + }); + }); + + test("parses compact aliases, themes, and command-local extension disabling", async () => { + expect( + await parseCli(["bun", "hunk", "log", "--oneline", "--theme", "nord", "--no-extensions"]), + ).toMatchObject({ + kind: "history", + format: "compact", + theme: "nord", + extensionsEnabled: false, + }); + expect(await parseCli(["bun", "hunk", "--no-extensions", "log"])).toMatchObject({ + kind: "history", + extensionsEnabled: false, + }); + }); + + test("rejects unsupported log flags and conflicting traversal starts", async () => { + await expect(parseCli(["bun", "hunk", "log", "--pretty=raw"])).rejects.toThrow( + "unknown option", + ); + await expect(parseCli(["bun", "hunk", "log", "HEAD", "--all"])).rejects.toThrow( + "either a revision/range or --all", + ); + await expect(parseCli(["bun", "hunk", "--fast", "log"])).rejects.toThrow("review command"); + }); + + test("accepts the internal VCS override used by history-to-review transitions", async () => { + expect( + await parseCli(["bun", "hunk", "diff", "parent", "commit", "--vcs", "demo"]), + ).toMatchObject({ + kind: "vcs", + options: { vcs: "demo" }, + }); + }); + test("parses show mode with optional ref and pathspecs", async () => { const parsed = await parseCli(["bun", "hunk", "show", "HEAD~1", "--", "src/app.ts"]); diff --git a/src/app/cli.ts b/src/app/cli.ts index 4c6d4483b..4c976a489 100644 --- a/src/app/cli.ts +++ b/src/app/cli.ts @@ -64,6 +64,7 @@ export interface CliReferenceOption { | "layout" | "cursorLine" | "positiveInt" + | "nonNegativeInt" | "tabWidth" | "fileGap" | "hunkGap" @@ -96,6 +97,11 @@ export const COMMON_REVIEW_OPTIONS = [ parse: "cursorLine", }, { flag: "--theme ", description: "named theme override" }, + { + flag: "--vcs ", + description: "select a VCS provider", + hidden: true, + }, AUXILIARY_AGENT_OPTIONS.agentContext, { flag: "--pager", description: "use pager-style chrome" }, AUXILIARY_AGENT_OPTIONS.experimental, @@ -188,6 +194,49 @@ export const CLI_REFERENCE_COMMANDS = { commonReviewOptions: true, watch: true, }, + log: { + path: "log", + summary: "print an attractive Git commit history", + synopsis: ["hunk log [revision-or-range] [-- ]"], + details: [ + "Static output is the default. Use --interactive for the experimental history browser.", + "This is an opinionated Git log subset, not a parser for arbitrary git-log options.", + ], + options: [ + { flag: "--all", description: "include commits reachable from every ref" }, + { flag: "--first-parent", description: "follow only the first parent of merge commits" }, + { + flag: "-n, --max-count ", + description: "stop after this many commits", + parse: "nonNegativeInt", + }, + { flag: "--author ", description: "limit commits by author" }, + { flag: "--grep ", description: "limit commits by subject or message" }, + { flag: "--since ", description: "show commits newer than a Git date" }, + { flag: "--until ", description: "show commits older than a Git date" }, + { + flag: "--color ", + description: "color output: auto, always, never", + commanderDefault: "auto", + }, + { + flag: "--format ", + description: "record format: medium or compact", + commanderDefault: "medium", + }, + { flag: "--oneline", description: "alias for --format compact" }, + { flag: "--theme ", description: "use the same theme as Hunk review" }, + { flag: "--ascii", description: "use an ASCII graph" }, + { flag: "--interactive", description: "browse history and open commits in Hunk" }, + { flag: "--vcs ", description: "select a VCS history provider" }, + { + flag: "--extension ", + description: "load an extension entry file or directory (repeatable)", + parse: "collect", + }, + { flag: "--no-extensions", description: "disable user extensions for this run" }, + ], + }, "stash-show": { path: "stash show", summary: "review a stash entry as a full Hunk changeset", @@ -379,6 +428,7 @@ function buildCommonOptions( mode?: LayoutMode; cursorLine?: CursorLine; theme?: string; + vcs?: string; agentContext?: string; pager?: boolean; watch?: boolean; @@ -396,6 +446,7 @@ function buildCommonOptions( mode: options.mode, cursorLine: options.cursorLine, theme: options.theme, + vcs: options.vcs, agentContext: options.agentContext, pager: options.pager ? true : undefined, watch: options.watch ? true : undefined, @@ -435,6 +486,8 @@ function applyReferenceOption(command: Command, option: CliReferenceOption) { commanderOption.argParser(parseCursorLine); } else if (option.parse === "positiveInt") { commanderOption.argParser(parsePositiveInt); + } else if (option.parse === "nonNegativeInt") { + commanderOption.argParser(parseNonNegativeInt); } else if (option.parse === "tabWidth") { commanderOption.argParser(parseTabWidth); } else if (option.parse === "fileGap") { @@ -512,6 +565,7 @@ function renderCliHelp() { " hunk diff --staged [-- ] review staged changes", " hunk diff --files compare two concrete files", " hunk show [target] [-- ] review the last commit or a given target", + " hunk log [target] [-- ] print an attractive Git commit history", " 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", @@ -869,6 +923,63 @@ async function parseShowCommand(tokens: string[], argv: string[]): Promise { + const { commandTokens, pathspecs } = splitPathspecArgs(tokens); + const command = createCliReferenceCommand("log").argument("[revision]"); + let revision: string | undefined; + let options: Record = {}; + + command.action((parsedRevision: string | undefined, parsedOptions: Record) => { + revision = parsedRevision; + options = parsedOptions; + }); + if (commandTokens.includes("--help") || commandTokens.includes("-h")) { + return { kind: "help", text: `${command.helpInformation().trimEnd()}\n` }; + } + await parseStandaloneCommand(command, commandTokens); + + const color = options.color; + if (color !== "auto" && color !== "always" && color !== "never") { + throw new Error(`Invalid color mode: ${String(color)}`); + } + const requestedFormat = options.format; + if (requestedFormat !== "medium" && requestedFormat !== "compact") { + throw new Error(`Invalid history format: ${String(requestedFormat)}`); + } + const format = options.oneline ? "compact" : requestedFormat; + if (revision && options.all) { + throw new Error("`hunk log` accepts either a revision/range or --all, not both."); + } + const extensionPaths = Array.isArray(options.extension) + ? options.extension.filter((value): value is string => typeof value === "string") + : []; + + return { + kind: "history", + ...(revision ? { revision } : {}), + ...(options.all ? { all: true } : {}), + ...(options.firstParent ? { firstParent: true } : {}), + ...(typeof options.maxCount === "number" ? { maxCount: options.maxCount } : {}), + ...(typeof options.author === "string" ? { author: options.author } : {}), + ...(typeof options.grep === "string" ? { grep: options.grep } : {}), + ...(typeof options.since === "string" ? { since: options.since } : {}), + ...(typeof options.until === "string" ? { until: options.until } : {}), + ...(pathspecs.length ? { pathspecs } : {}), + color, + format, + ascii: Boolean(options.ascii), + interactive: Boolean(options.interactive), + ...(typeof options.theme === "string" ? { theme: options.theme } : {}), + ...(typeof options.vcs === "string" ? { vcs: options.vcs } : {}), + extensionsEnabled: extensionsEnabled && options.extensions !== false, + extensionPaths, + }; +} + /** Parse the patch-file / stdin patch entrypoint. */ async function parsePatchCommand(tokens: string[], argv: string[]): Promise { const command = createCliReferenceCommand("patch").argument("[file]"); @@ -963,6 +1074,7 @@ function requireReloadableCliInput(input: ParsedCliInput): CliInput { input.kind === "markup-guide" || input.kind === "extension-manage" || input.kind === "extension-cli" || + input.kind === "history" || input.kind === "update" ) { throw new Error( @@ -1966,6 +2078,7 @@ async function parseStashCommand( } const REVIEW_COMMAND_NAMES = new Set(["diff", "show", "patch", "pager", "difftool", "stash"]); +const EXTENSION_AWARE_COMMAND_NAMES = new Set([...REVIEW_COMMAND_NAMES, "log"]); interface LeadingCliFlags { args: string[]; @@ -2080,7 +2193,7 @@ export async function parseCli(argv: string[]): Promise { if ( extensionFlagTokens.length > 0 && isBuiltInCliCommandName(commandName) && - !REVIEW_COMMAND_NAMES.has(commandName) + !EXTENSION_AWARE_COMMAND_NAMES.has(commandName) ) { throw new Error( `\`${extensionFlagTokens[0]}\` must be used with a Hunk review command or an ` + @@ -2095,6 +2208,8 @@ export async function parseCli(argv: string[]): Promise { return parseDiffCommand(reviewRest, argv); case "show": return parseShowCommand(reviewRest, argv); + case "log": + return parseHistoryCommand(reviewRest, extensionsEnabled); case "patch": return parsePatchCommand(reviewRest, argv); case "pager": diff --git a/src/app/historyBootstrap.ts b/src/app/historyBootstrap.ts new file mode 100644 index 000000000..3474b16cc --- /dev/null +++ b/src/app/historyBootstrap.ts @@ -0,0 +1,148 @@ +import type { HistoryCommandInput } from "../core/run/commandInputs"; +import { collectSessionCustomThemes } from "../core/theme/customThemes"; +import type { NamedCustomThemeConfig } from "../extension-api/types"; +import { sanitizeTerminalLine } from "../lib/terminalText"; +import { + detectVcs, + extendVcsCatalog, + getDefaultVcsAdapter, + getVcsAdapter, + openVcsHistory, +} from "../core/vcs"; +import type { VcsCatalog, VcsHistorySource } from "../core/vcs/types"; +import { resolveExtensionVcsAdapters, resolveSessionVcsId } from "../extensions/apply"; +import { emitExtensionEvent, retireExtensionLoadResult } from "../extensions/events"; +import { mergeStartupNotices } from "../extensions/startup"; +import type { ExtensionLoadResult } from "../extensions/types"; +import { resolveConfiguredExtensions } from "./extensionBootstrap"; + +/** Fully owned resources required by static or interactive history output. */ +export interface HistoryBootstrap { + input: HistoryCommandInput; + source: VcsHistorySource; + providerId: string; + providerName: string; + repoRoot: string; + extensions: ExtensionLoadResult; + notices: readonly string[]; + customThemes: readonly NamedCustomThemeConfig[]; + close(): Promise; +} + +/** Resolve configured/user VCS adapters and open the selected history capability. */ +export async function loadHistoryBootstrap({ + input, + cwd = process.cwd(), + env = process.env, + baseVcsCatalog, + previousLoad, +}: { + input: HistoryCommandInput; + cwd?: string; + env?: NodeJS.ProcessEnv; + baseVcsCatalog: VcsCatalog; + previousLoad?: ExtensionLoadResult; +}): Promise { + // Reuse the established extension/config discovery with a non-executed review-shaped input. + // History-specific flags remain separate and never inherit review view preferences. + const runtimeInput = { + kind: "show" as const, + options: { + ...(input.vcs ? { vcs: input.vcs } : {}), + ...(input.theme ? { theme: input.theme } : {}), + extensions: input.extensionsEnabled, + ...(input.extensionPaths.length ? { extensionPaths: [...input.extensionPaths] } : {}), + }, + }; + const resolved = await resolveConfiguredExtensions({ + runtimeInput, + cwd, + env, + baseVcsCatalog, + previousLoad, + }); + const extensionAdapters = resolveExtensionVcsAdapters( + resolved.extensions.registry, + baseVcsCatalog, + ); + const sessionThemes = collectSessionCustomThemes( + resolved.configured.customThemes, + resolved.extensions.registry.themes, + ); + const catalog = extendVcsCatalog(baseVcsCatalog, extensionAdapters.adapters); + const explicitVcsId = input.vcs ?? resolved.configured.explicitVcsId; + const detection = detectVcs(cwd, catalog); + const settledVcs = resolveSessionVcsId(explicitVcsId, cwd, catalog); + const providerId = settledVcs.vcsId ?? detection?.id ?? getDefaultVcsAdapter(catalog).id; + let adapter; + try { + adapter = getVcsAdapter(providerId, catalog); + } catch (error) { + await retireExtensionLoadResult(resolved.extensions); + throw error; + } + let selectedDetection; + try { + selectedDetection = adapter.detect(cwd); + } catch { + selectedDetection = null; + } + const repoRoot = selectedDetection?.repoRoot ?? cwd; + + let source: VcsHistorySource; + try { + source = await openVcsHistory( + adapter, + { + ...(input.revision ? { revision: input.revision } : {}), + ...(input.all ? { all: true } : {}), + ...(input.firstParent ? { firstParent: true } : {}), + ...(input.maxCount !== undefined ? { maxCount: input.maxCount } : {}), + ...(input.author !== undefined ? { author: input.author } : {}), + ...(input.grep !== undefined ? { grep: input.grep } : {}), + ...(input.since !== undefined ? { since: input.since } : {}), + ...(input.until !== undefined ? { until: input.until } : {}), + ...(input.pathspecs ? { pathspecs: [...input.pathspecs] } : {}), + }, + { cwd: repoRoot }, + catalog, + ); + emitExtensionEvent(resolved.extensions, "startup", { cwd }); + } catch (error) { + await retireExtensionLoadResult(resolved.extensions); + throw error; + } + + const resolvedTheme = resolved.configured.input.options.theme; + let closed = false; + return { + input: resolvedTheme ? { ...input, theme: resolvedTheme } : input, + source, + providerId: sanitizeTerminalLine(adapter.id), + providerName: sanitizeTerminalLine(adapter.name), + repoRoot, + extensions: resolved.extensions, + customThemes: sessionThemes.themes, + notices: [ + ...(mergeStartupNotices(resolved.configured.startupNotices, resolved.extensions) ?? []).map( + (notice) => sanitizeTerminalLine(notice.message), + ), + ...sessionThemes.notices.map((notice) => sanitizeTerminalLine(notice.message)), + ...extensionAdapters.issues.map((issue) => sanitizeTerminalLine(issue.message)), + ...(settledVcs.unknownVcsId + ? [ + `Configured VCS "${sanitizeTerminalLine(settledVcs.unknownVcsId)}" is unavailable; using ${sanitizeTerminalLine(adapter.name)}.`, + ] + : []), + ], + async close() { + if (closed) return; + closed = true; + try { + await source.close(); + } finally { + await retireExtensionLoadResult(resolved.extensions); + } + }, + }; +} diff --git a/src/app/startup.ts b/src/app/startup.ts index bea624496..253c3d352 100644 --- a/src/app/startup.ts +++ b/src/app/startup.ts @@ -18,6 +18,7 @@ import type { CliInput, ExtensionCliInvocationInput, ExtensionManageCommandInput, + HistoryCommandInput, MarkupRenderCommandInput, ParsedCliInput, SelfUpdateCommandInput, @@ -58,6 +59,11 @@ export type StartupPlan = kind: "session-command"; input: SessionCommandInput; } + | { + kind: "history-static" | "history-interactive"; + bootstrap: import("./historyBootstrap").HistoryBootstrap; + input: HistoryCommandInput; + } | { kind: "plain-text-pager"; text: string; @@ -136,6 +142,13 @@ function applyDelegatedExtensionFlags( input: ParsedCliInput, invocation: ExtensionCliInvocationInput, ): ParsedCliInput { + if (input.kind === "history") { + return { + ...input, + extensionsEnabled: invocation.extensionsEnabled, + extensionPaths: [...invocation.extensionPaths], + }; + } if (!("options" in input)) return input; return { ...input, @@ -373,6 +386,26 @@ export async function prepareStartupPlan( }); } + if (parsedCliInput.kind === "history") { + const baseVcsCatalog = await loadBaseVcsCatalog(); + const { loadHistoryBootstrap } = await import("./historyBootstrap"); + const bootstrap = await loadHistoryBootstrap({ + input: parsedCliInput, + cwd: startupCwd, + env, + baseVcsCatalog, + previousLoad: preloadedExtensions, + }); + // The runner owns source/extension retirement; unlike ordinary headless plans, history must + // retain its provider cursor until every page has been consumed. + preloadedExtensions = undefined; + return { + kind: parsedCliInput.interactive ? "history-interactive" : "history-static", + bootstrap, + input: parsedCliInput, + }; + } + if (parsedCliInput.kind === "pager") { const stdinText = await whileStartupOwnsExtensions(readStdinText); const pagerOptions = parsedCliInput.options; diff --git a/src/core/history/lanePlanner.test.ts b/src/core/history/lanePlanner.test.ts new file mode 100644 index 000000000..f5b8dcfd7 --- /dev/null +++ b/src/core/history/lanePlanner.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from "bun:test"; +import { createHistoryLaneCheckpoint, planHistoryPage } from "./lanePlanner"; +import type { HistoryCommit } from "./types"; + +/** Build one deterministic history commit for graph tests. */ +function commit(revisionId: string, parentRevisionIds: string[] = []): HistoryCommit { + return { + revisionId, + displayId: revisionId, + parentRevisionIds, + subject: revisionId, + authorName: "Test", + authoredAt: "2026-01-01T00:00:00Z", + decorations: [], + }; +} + +describe("history lane planning", () => { + test("preserves first-parent continuity and ordered merge parents", () => { + const planned = planHistoryPage([ + commit("merge", ["main", "side"]), + commit("side", ["base"]), + commit("main", ["base"]), + commit("base"), + ]); + + expect(planned.rows.map((row) => [row.commit.revisionId, row.lane])).toEqual([ + ["merge", 0], + ["side", 1], + ["main", 0], + ["base", 0], + ]); + expect(planned.checkpoint.lanes).toEqual([]); + }); + + test("produces identical rows across every page partition", () => { + const commits = [ + commit("merge", ["main", "side"]), + commit("side", ["base"]), + commit("main", ["base"]), + commit("base"), + ]; + const whole = planHistoryPage(commits); + // Every bit chooses whether a page ends after that commit, covering all 2^(n-1) partitions. + for (let boundaries = 0; boundaries < 1 << (commits.length - 1); boundaries += 1) { + let checkpoint = createHistoryLaneCheckpoint(); + const rows = []; + let start = 0; + for (let index = 0; index < commits.length; index += 1) { + if (index < commits.length - 1 && (boundaries & (1 << index)) === 0) continue; + const page = planHistoryPage(commits.slice(start, index + 1), checkpoint); + rows.push(...page.rows); + checkpoint = page.checkpoint; + start = index + 1; + } + expect(rows).toEqual(whole.rows); + expect(checkpoint).toEqual(whole.checkpoint); + } + }); + + test("rejects duplicate revisions within one page", () => { + expect(() => planHistoryPage([commit("same"), commit("same")])).toThrow("duplicate revision"); + }); +}); diff --git a/src/core/history/lanePlanner.ts b/src/core/history/lanePlanner.ts new file mode 100644 index 000000000..032e6c23b --- /dev/null +++ b/src/core/history/lanePlanner.ts @@ -0,0 +1,86 @@ +import type { + HistoryCommit, + HistoryGraphCell, + HistoryLaneCheckpoint, + PlannedHistoryPage, +} from "./types"; + +/** Create the empty graph state used before the first history page. */ +export function createHistoryLaneCheckpoint(): HistoryLaneCheckpoint { + return { lanes: [] }; +} + +/** Return unique parent ids in the provider's declared order. */ +function orderedUniqueParents(commit: HistoryCommit) { + const seen = new Set(); + return commit.parentRevisionIds.filter((parent) => { + if (seen.has(parent)) return false; + seen.add(parent); + return true; + }); +} + +/** Plan one newest-first page while preserving enough lane state for continuation. */ +export function planHistoryPage( + commits: readonly HistoryCommit[], + checkpoint: HistoryLaneCheckpoint = createHistoryLaneCheckpoint(), +): PlannedHistoryPage { + const lanes = [...checkpoint.lanes]; + const pageRevisionIds = new Set(); + const rows: PlannedHistoryPage["rows"] = []; + + for (const commit of commits) { + if (pageRevisionIds.has(commit.revisionId)) { + throw new Error(`History contains duplicate revision ${commit.revisionId}.`); + } + pageRevisionIds.add(commit.revisionId); + + let lane = lanes.indexOf(commit.revisionId); + if (lane < 0) { + // Independent tips (for example `--all`) enter at the left edge. Missing shallow parents + // may also produce a new tip; neither case invents ancestry. + lane = 0; + lanes.unshift(commit.revisionId); + } + + const lanesBefore = [...lanes]; + const parents = orderedUniqueParents(commit); + lanes.splice(lane, 1, ...parents); + + // A merge parent can already be active through another child. Keep its leftmost lane and + // collapse duplicates so topology state stays bounded by the active frontier. + const deduplicated: string[] = []; + for (const revisionId of lanes) { + if (!deduplicated.includes(revisionId)) deduplicated.push(revisionId); + } + lanes.splice(0, lanes.length, ...deduplicated); + + const convergences = lanesBefore.flatMap((revisionId, from) => { + if (from === lane) return []; + const to = lanes.indexOf(revisionId); + return to >= 0 && to !== from ? [{ from, to }] : []; + }); + const width = Math.max(lanesBefore.length, lanes.length, lane + 1); + const cells: HistoryGraphCell[] = Array.from({ length: width }, (_, index) => ({ + kind: index === lane ? "node" : index < lanesBefore.length ? "vertical" : "empty", + })); + + rows.push({ + commit, + lane, + cells, + lanesBefore, + lanesAfter: [...lanes], + parentLanes: parents.flatMap((parent) => { + const index = lanes.indexOf(parent); + return index < 0 ? [] : [index]; + }), + convergences, + }); + } + + return { + rows, + checkpoint: { lanes: [...lanes] }, + }; +} diff --git a/src/core/history/types.ts b/src/core/history/types.ts new file mode 100644 index 000000000..2e1205bed --- /dev/null +++ b/src/core/history/types.ts @@ -0,0 +1,32 @@ +import type { ExtensionVcsHistoryCommit } from "../../extension-api/types"; + +/** One normalized commit in newest-first provider order. */ +export type HistoryCommit = ExtensionVcsHistoryCommit; + +/** JSON-safe state required to continue planning graph lanes on a later page. */ +export interface HistoryLaneCheckpoint { + lanes: string[]; +} + +/** One symbolic lane shown on a commit row. */ +export interface HistoryGraphCell { + kind: "vertical" | "node" | "empty"; +} + +/** One symbolic graph row plus the lane mapping after its commit. */ +export interface HistoryGraphRow { + commit: HistoryCommit; + lane: number; + cells: HistoryGraphCell[]; + lanesBefore: string[]; + lanesAfter: string[]; + parentLanes: number[]; + /** Existing active lanes that collapse into a parent lane after this commit. */ + convergences: Array<{ from: number; to: number }>; +} + +/** A planned page and the checkpoint used by the following page. */ +export interface PlannedHistoryPage { + rows: HistoryGraphRow[]; + checkpoint: HistoryLaneCheckpoint; +} diff --git a/src/core/process/pager.test.ts b/src/core/process/pager.test.ts index 84c7d2960..2db7b81f4 100644 --- a/src/core/process/pager.test.ts +++ b/src/core/process/pager.test.ts @@ -358,6 +358,44 @@ describe("plain text pager fallback", () => { ); }); + test("accepts EPIPE only when an early-closing pager exits successfully", async () => { + const pager = new EventEmitter() as EventEmitter & { stdin: PassThrough }; + pager.stdin = new PassThrough(); + await pagePlainText( + "long output", + { PAGER: "less -R" }, + createPagerDeps({ + spawnImpl() { + queueMicrotask(() => { + const error = Object.assign(new Error("broken pipe"), { code: "EPIPE" }); + pager.stdin.emit("error", error); + pager.emit("close", 0); + }); + return pager as never; + }, + }), + ); + }); + + test("rejects EPIPE when the pager itself reports failure", async () => { + const pager = new EventEmitter() as EventEmitter & { stdin: PassThrough }; + pager.stdin = new PassThrough(); + const promise = pagePlainText( + "long output", + { PAGER: "less -R" }, + createPagerDeps({ + spawnImpl() { + queueMicrotask(() => { + pager.stdin.emit("error", Object.assign(new Error("broken pipe"), { code: "EPIPE" })); + pager.emit("close", 1); + }); + return pager as never; + }, + }), + ); + await expect(promise).rejects.toThrow("Pager command failed"); + }); + test("throws when the pager exits with a non-zero status", async () => { const pager = new EventEmitter() as EventEmitter & { stdin: PassThrough }; pager.stdin = new PassThrough(); diff --git a/src/core/process/pager.ts b/src/core/process/pager.ts index a946ca1d1..83129b989 100644 --- a/src/core/process/pager.ts +++ b/src/core/process/pager.ts @@ -15,7 +15,7 @@ export function looksLikePatchInput(text: string) { ); } -const DEFAULT_TEXT_PAGER_COMMAND = "less -R"; +const DEFAULT_TEXT_PAGER_COMMAND = process.platform === "win32" ? "more" : "less -R"; interface ResolvedPagerCommand { command: string; @@ -142,9 +142,13 @@ export interface PlainTextPagerDeps { spawnImpl: (command: string, args: string[], options: SpawnOptions) => ChildProcess; } -/** Stream plain text through a normal pager, or write directly when not attached to a terminal. */ -export async function pagePlainText( - text: string, +export interface PlainTextPagerWriter { + write(text: string): Promise; + close(): Promise; +} + +/** Open one pager writer so callers can stream bounded chunks after deciding output will overflow. */ +export function openPlainTextPager( env: NodeJS.ProcessEnv = process.env, deps: PlainTextPagerDeps = { // Write through the descriptor rather than `process.stdout`: a piped consumer takes one @@ -158,45 +162,90 @@ export async function pagePlainText( }, spawnImpl: spawn, }, -) { +): PlainTextPagerWriter { if (!deps.stdout.isTTY) { - deps.stdout.write(sanitizeTerminalText(text)); - return; + return { + async write(text) { + deps.stdout.write(sanitizeTerminalText(text)); + }, + async close() {}, + }; } - const safeText = sanitizeTerminalText(text, { preserveAnsiStyle: true }); - const pagerSpec = resolveTextPagerSpec(env); const pagerCommand = pagerSpec.displayCommand; - let pager: ChildProcess; try { pager = deps.spawnImpl(pagerSpec.command, pagerSpec.args, { shell: false, stdio: ["pipe", "inherit", "inherit"], - env: { - ...env, - ...pagerSpec.env, - }, + env: { ...env, ...pagerSpec.env }, }); } catch (error) { throw new Error(`Pager command failed: ${pagerCommand}`, { cause: error }); } let spawnError: unknown; + let stdinError: NodeJS.ErrnoException | undefined; + let closed = false; const closeCode = new Promise((resolve) => { pager.once("error", (error) => { spawnError = error; }); - pager.once("close", (code) => { - resolve(typeof code === "number" ? code : null); - }); + pager.once("close", (code) => resolve(typeof code === "number" ? code : null)); + }); + pager.stdin?.once("error", (error: NodeJS.ErrnoException) => { + stdinError = error; }); - pager.stdin?.end(safeText); - const code = await closeCode; + return { + async write(text) { + if (closed || stdinError?.code === "EPIPE") return; + const safeText = sanitizeTerminalText(text, { preserveAnsiStyle: true }); + if (!pager.stdin?.write(safeText)) { + await new Promise((resolve) => { + const finish = () => { + pager.stdin?.off("drain", finish); + pager.stdin?.off("error", finish); + pager.off("close", finish); + resolve(); + }; + pager.stdin?.once("drain", finish); + pager.stdin?.once("error", finish); + pager.once("close", finish); + }); + } + }, + async close() { + if (closed) return; + closed = true; + if (!pager.stdin?.destroyed) pager.stdin?.end(); + const code = await closeCode; + const normalEarlyQuit = stdinError?.code === "EPIPE" && code === 0; + if ( + spawnError || + (stdinError && !normalEarlyQuit) || + (typeof code === "number" && code !== 0) + ) { + throw new Error(`Pager command failed: ${pagerCommand}`, { + cause: spawnError ?? stdinError, + }); + } + }, + }; +} - if (spawnError || (typeof code === "number" && code !== 0)) { - throw new Error(`Pager command failed: ${pagerCommand}`, { cause: spawnError }); +/** Stream plain text through a normal pager, or write directly when not attached to a terminal. */ +export async function pagePlainText( + text: string, + env: NodeJS.ProcessEnv = process.env, + deps: PlainTextPagerDeps = { stdout: process.stdout, spawnImpl: spawn }, +) { + if (!deps.stdout.isTTY) { + deps.stdout.write(sanitizeTerminalText(text)); + return; } + const pager = openPlainTextPager(env, deps); + await pager.write(text); + await pager.close(); } diff --git a/src/core/process/relaunch.test.ts b/src/core/process/relaunch.test.ts new file mode 100644 index 000000000..4241f0f57 --- /dev/null +++ b/src/core/process/relaunch.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from "bun:test"; +import { resolveCurrentHunkCommand } from "./relaunch"; + +describe("current Hunk relaunch resolution", () => { + test("keeps source and wrapper entrypoints", () => { + expect(resolveCurrentHunkCommand(["bun", "/repo/src/main.tsx"], "/bin/bun")).toEqual({ + command: "/bin/bun", + args: ["/repo/src/main.tsx"], + }); + expect(resolveCurrentHunkCommand(["node", "C:\\pkg\\bin\\hunk.cjs"], "node.exe")).toEqual({ + command: "node.exe", + args: ["C:\\pkg\\bin\\hunk.cjs"], + }); + }); + + test("uses the real executable for compiled Bun virtual paths", () => { + expect(resolveCurrentHunkCommand(["bun", "/$bunfs/root/hunk"], "/usr/bin/hunk")).toEqual({ + command: "/usr/bin/hunk", + args: [], + }); + expect(resolveCurrentHunkCommand(["bun", "B:\\~BUN\\root\\hunk.exe"], "C:\\hunk.exe")).toEqual({ + command: "C:\\hunk.exe", + args: [], + }); + }); +}); diff --git a/src/core/process/relaunch.ts b/src/core/process/relaunch.ts new file mode 100644 index 000000000..c8744851c --- /dev/null +++ b/src/core/process/relaunch.ts @@ -0,0 +1,31 @@ +const SCRIPT_ENTRYPOINT_PATTERN = /[\\/]|\.(?:[cm]?js|tsx?)$/; +const BUNFS_PREFIX = "/$bunfs/"; +const BUNFS_WINDOWS_PREFIX = "b:/~bun/"; + +export interface HunkLaunchCommand { + command: string; + args: string[]; +} + +/** Return whether an entrypoint is Bun's virtual compiled-executable path. */ +function isBunfsEntrypoint(entrypoint: string) { + return ( + entrypoint.startsWith(BUNFS_PREFIX) || + entrypoint.replaceAll("\\", "/").toLowerCase().startsWith(BUNFS_WINDOWS_PREFIX) + ); +} + +/** Resolve the executable and stable prefix needed to launch this Hunk installation again. */ +export function resolveCurrentHunkCommand( + argv = process.argv, + execPath = process.execPath, +): HunkLaunchCommand { + const entrypoint = argv[1]; + if (entrypoint && isBunfsEntrypoint(entrypoint)) { + return { command: execPath, args: [] }; + } + if (entrypoint && !entrypoint.startsWith("-") && SCRIPT_ENTRYPOINT_PATTERN.test(entrypoint)) { + return { command: execPath, args: [entrypoint] }; + } + return { command: execPath, args: [] }; +} diff --git a/src/core/run/cliCommandNames.ts b/src/core/run/cliCommandNames.ts index 0ec53b717..a0baa3659 100644 --- a/src/core/run/cliCommandNames.ts +++ b/src/core/run/cliCommandNames.ts @@ -2,6 +2,7 @@ export const BUILT_IN_CLI_COMMAND_NAMES = new Set([ "diff", "show", + "log", "patch", "pager", "difftool", diff --git a/src/core/run/commandInputs.ts b/src/core/run/commandInputs.ts index 8dca48432..b8652415e 100644 --- a/src/core/run/commandInputs.ts +++ b/src/core/run/commandInputs.ts @@ -110,6 +110,31 @@ export type CliInput = export type ReviewNoteSource = "ai" | "agent" | "user"; export type SessionCommentListType = "live" | "all" | ReviewNoteSource; +export type HistoryColorMode = "auto" | "always" | "never"; +export type HistoryFormat = "medium" | "compact"; + +/** Static-first VCS history invocation, deliberately separate from review view options. */ +export interface HistoryCommandInput { + kind: "history"; + revision?: string; + all?: boolean; + firstParent?: boolean; + maxCount?: number; + author?: string; + grep?: string; + since?: string; + until?: string; + pathspecs?: string[]; + color: HistoryColorMode; + format: HistoryFormat; + ascii: boolean; + interactive: boolean; + theme?: string; + vcs?: string; + extensionsEnabled: boolean; + extensionPaths: string[]; +} + export interface HelpCommandInput { kind: "help"; text: string; @@ -350,6 +375,7 @@ export type ExtensionManageCommandInput = export type ParsedCliInput = | CliInput + | HistoryCommandInput | HelpCommandInput | PagerCommandInput | DaemonServeCommandInput diff --git a/src/core/vcs/index.ts b/src/core/vcs/index.ts index 53113bc91..72717fcd5 100644 --- a/src/core/vcs/index.ts +++ b/src/core/vcs/index.ts @@ -1,6 +1,7 @@ import { relative, resolve } from "node:path"; import { HUNK_DEFAULT_VCS_DETECTION_PRIORITY } from "../../extension-api/types"; import { HunkUserError } from "../run/errors"; +import type { ExtensionVcsHistoryInput } from "../../extension-api/types"; import type { CliInput } from "../run/commandInputs"; import type { VcsAdapter, @@ -8,6 +9,7 @@ import type { VcsDetection, VcsId, VcsLoadContext, + VcsHistorySource, VcsOperation, VcsPatchResult, VcsReviewInput, @@ -152,6 +154,25 @@ export async function loadVcsReview( return await handler.load(operation.input, context); } +/** Open a provider-neutral history source or report that the selected backend lacks one. */ +export async function openVcsHistory( + adapter: VcsAdapter, + input: ExtensionVcsHistoryInput, + context: VcsLoadContext, + catalog: VcsCatalog, +): Promise { + if (!adapter.history) { + const supportingAdapter = catalog.adapters.find((candidate) => candidate.history); + throw new HunkUserError(`\`hunk log\` is not supported by ${adapter.name}.`, [ + ...(supportingAdapter + ? [`Use \`--vcs ${supportingAdapter.id}\` in a compatible repository.`] + : []), + "Use a VCS adapter that implements history browsing.", + ]); + } + return await adapter.history.open(input, context); +} + /** Build an adapter event plan, falling back to signature polling. */ export function createVcsWatchPlan( adapter: VcsAdapter, diff --git a/src/core/vcs/types.ts b/src/core/vcs/types.ts index bb056911c..c7d1b756e 100644 --- a/src/core/vcs/types.ts +++ b/src/core/vcs/types.ts @@ -1,4 +1,8 @@ -import type { ExtensionVcsWatchPlan } from "../../extension-api/types"; +import type { + ExtensionVcsHistoryInput, + ExtensionVcsHistoryPage, + ExtensionVcsWatchPlan, +} from "../../extension-api/types"; import type { DiffFile } from "../changeset/model"; import type { VcsDiffCommandInput, @@ -39,6 +43,17 @@ export interface VcsOperations { "stash-show"?: VcsOperation; } +/** Internal history cursor after extension-boundary validation. */ +export interface VcsHistorySource { + read(options: { limit: number; signal?: AbortSignal }): Promise; + close(): Promise; +} + +/** Optional provider-neutral read-only history capability. */ +export interface VcsHistoryCapability { + open(input: ExtensionVcsHistoryInput, context: VcsLoadContext): Promise; +} + /** * One adapter operation's result, after the conversion boundary. * @@ -73,6 +88,7 @@ export interface VcsAdapter { name: string; detect(cwd: string): VcsDetection | null; operations: VcsOperations; + history?: VcsHistoryCapability; /** Detection order weight; higher is consulted first. See the public contract. */ detectionPriority?: number; } diff --git a/src/extension-api/index.ts b/src/extension-api/index.ts index bf6057743..031bd2c2c 100644 --- a/src/extension-api/index.ts +++ b/src/extension-api/index.ts @@ -148,6 +148,12 @@ export type { ExtensionVcsFileSourceResult, ExtensionVcsFileSourceTooLarge, ExtensionVcsFileStats, + ExtensionVcsHistoryCapability, + ExtensionVcsHistoryCommit, + ExtensionVcsHistoryDecoration, + ExtensionVcsHistoryInput, + ExtensionVcsHistoryPage, + ExtensionVcsHistorySource, ExtensionVcsLoadContext, ExtensionVcsOperation, ExtensionVcsOperations, diff --git a/src/extension-api/types.ts b/src/extension-api/types.ts index 37e84fde8..a8aa2848a 100644 --- a/src/extension-api/types.ts +++ b/src/extension-api/types.ts @@ -21,7 +21,7 @@ * Extensions can branch on `hunk.apiVersion` so a newer Hunk can keep loading * older extensions without guessing at their expectations. */ -export const HUNK_EXTENSION_API_VERSION = 18; +export const HUNK_EXTENSION_API_VERSION = 19; export type HunkExtensionApiVersion = typeof HUNK_EXTENSION_API_VERSION; export type ExtensionNotifyType = "info" | "warning" | "error"; @@ -727,6 +727,60 @@ export interface ExtensionVcsShowInput { options: ExtensionVcsReviewOptions; } +/** One ref label decorating a history commit. */ +export interface ExtensionVcsHistoryDecoration { + kind: "head" | "local-branch" | "remote-branch" | "tag" | "ref"; + label: string; +} + +/** One immutable commit summary returned by a VCS history provider. */ +export interface ExtensionVcsHistoryCommit { + revisionId: string; + displayId: string; + parentRevisionIds: string[]; + subject: string; + /** Commit message content after the subject, preserving paragraph breaks. */ + body?: string; + authorName: string; + authorEmail?: string; + authoredAt: string; + decorations: ExtensionVcsHistoryDecoration[]; + logicalId?: string; +} + +/** Provider-neutral history traversal accepted by `hunk log`. */ +export interface ExtensionVcsHistoryInput { + revision?: string; + all?: boolean; + firstParent?: boolean; + maxCount?: number; + author?: string; + grep?: string; + since?: string; + until?: string; + pathspecs?: string[]; +} + +/** One bounded history read. `done` distinguishes EOF from a page boundary. */ +export interface ExtensionVcsHistoryPage { + commits: ExtensionVcsHistoryCommit[]; + done: boolean; +} + +/** A cancellable history cursor owned by its provider. */ +export interface ExtensionVcsHistorySource { + read(options: { limit: number; signal?: AbortSignal }): Promise; + close(): void | Promise; +} + +/** Optional read-only history capability implemented independently of review operations. */ +export interface ExtensionVcsHistoryCapability { + open( + input: ExtensionVcsHistoryInput, + context: ExtensionVcsLoadContext, + ): ExtensionVcsHistorySource | Promise; +} + /** Stash review request, as extension adapters receive it. */ export interface ExtensionVcsStashShowInput { kind: "stash-show"; @@ -972,6 +1026,8 @@ export interface ExtensionVcsAdapter { name: string; detect(cwd: string): ExtensionVcsDetection | null; operations?: ExtensionVcsOperations; + /** Optional static/interactive history enumeration capability. */ + history?: ExtensionVcsHistoryCapability; /** * Where this adapter sits in detection order; higher is consulted first. * diff --git a/src/extensions/default/vcs/git/history.test.ts b/src/extensions/default/vcs/git/history.test.ts new file mode 100644 index 000000000..d7f48a45e --- /dev/null +++ b/src/extensions/default/vcs/git/history.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, test } from "bun:test"; +import { buildGitHistoryArgs, parseGitHistory } from "./history"; + +describe("Git history production", () => { + test("builds the strict supported query with literal pathspec separation", () => { + expect( + buildGitHistoryArgs({ + revision: "main..feature", + firstParent: true, + maxCount: 12, + author: "Ada", + grep: "parser", + since: "2.weeks", + until: "yesterday", + pathspecs: ["src/file with spaces.ts", "--not-an-option"], + }), + ).toEqual([ + "log", + "--topo-order", + "--parents", + "--no-show-signature", + "--no-color", + "--abbrev=8", + "-z", + "--format=%H%x00%h%x00%P%x00%an%x00%ae%x00%aI%x00%s%x00%b", + "--first-parent", + "--max-count=12", + "--author=Ada", + "--grep=parser", + "--since=2.weeks", + "--until=yesterday", + "main..feature", + "--", + "src/file with spaces.ts", + "--not-an-option", + ]); + }); + + test("refuses option-like revisions", () => { + expect(() => buildGitHistoryArgs({ revision: "--output=/tmp/pwn" })).toThrow( + "Refused history revision", + ); + }); + + test("parses NUL-delimited commits and copies structured decorations", () => { + const decorations = new Map([["a".repeat(40), [{ kind: "head" as const, label: "HEAD" }]]]); + const text = [ + "a".repeat(40), + "aaaaaaaa", + `${"b".repeat(40)} ${"c".repeat(40)}`, + "Ada Lovelace", + "ada@example.com", + "2026-01-02T03:04:05Z", + "Merge work", + "Detailed rationale.\n", + ].join("\0"); + expect(parseGitHistory(text, decorations)).toEqual([ + { + revisionId: "a".repeat(40), + displayId: "aaaaaaaa", + parentRevisionIds: ["b".repeat(40), "c".repeat(40)], + subject: "Merge work", + body: "Detailed rationale.\n", + authorName: "Ada Lovelace", + authorEmail: "ada@example.com", + authoredAt: "2026-01-02T03:04:05Z", + decorations: [{ kind: "head", label: "HEAD" }], + }, + ]); + }); + + test("drops excluded secondary parents for first-parent topology", () => { + const text = [ + "a".repeat(40), + "aaaaaaaa", + `${"b".repeat(40)} ${"c".repeat(40)}`, + "Ada", + "ada@example.com", + "2026-01-01T00:00:00Z", + "Merge", + "", + ].join("\0"); + expect(parseGitHistory(text, new Map(), true)[0]!.parentRevisionIds).toEqual(["b".repeat(40)]); + }); + + test("rejects truncated records and invalid SHA object ids", () => { + expect(() => parseGitHistory("id\0short\0parent")).toThrow("truncated history record"); + expect(() => + parseGitHistory( + [ + "not-a-sha", + "short", + "", + "Ada", + "ada@example.com", + "2026-01-01T00:00:00Z", + "Bad", + "", + ].join("\0"), + ), + ).toThrow("invalid history object id"); + }); +}); diff --git a/src/extensions/default/vcs/git/history.ts b/src/extensions/default/vcs/git/history.ts new file mode 100644 index 000000000..e87910b9a --- /dev/null +++ b/src/extensions/default/vcs/git/history.ts @@ -0,0 +1,342 @@ +import { spawn } from "node:child_process"; +import { StringDecoder } from "node:string_decoder"; +import { + HunkExtensionUserError, + type ExtensionVcsHistoryCommit, + type ExtensionVcsHistoryDecoration, + type ExtensionVcsHistoryInput, + type ExtensionVcsHistorySource, +} from "hunkdiff/extension"; + +const HISTORY_FIELDS_PER_COMMIT = 8; +const FULL_OBJECT_ID_PATTERN = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/; +const ABBREVIATED_OBJECT_ID_PATTERN = /^[0-9a-f]{4,64}$/; + +interface GitHistoryOptions { + cwd: string; + gitExecutable?: string; +} + +/** Run one shell-free bounded Git query and retain byte-exact NUL delimiters. */ +function runGit( + args: string[], + { cwd, gitExecutable = "git" }: GitHistoryOptions, + acceptedExitCodes: readonly number[] = [0], +) { + let result: ReturnType; + try { + result = Bun.spawnSync([gitExecutable, ...args], { + cwd, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" }, + }); + } catch { + throw new HunkExtensionUserError(`Could not run ${gitExecutable}.`, { + suggestions: ["Install Git or configure Hunk to use another VCS backend."], + }); + } + + if (!acceptedExitCodes.includes(result.exitCode)) { + const message = result.stderr?.toString().trim().split("\n")[0]; + throw new HunkExtensionUserError(message || "Git could not read this repository.", { + suggestions: ["Check the revision, filters, and repository, then try again."], + }); + } + return result.stdout?.toString() ?? ""; +} + +/** Resolve the repository root, including a bare repository with no worktree. */ +function resolveHistoryRepoRoot(options: GitHistoryOptions) { + const worktree = runGit(["rev-parse", "--show-toplevel"], options, [0, 128]).trim(); + if (worktree) return worktree; + const gitDir = runGit(["rev-parse", "--absolute-git-dir"], options).trim(); + if (!gitDir) { + throw new HunkExtensionUserError("Not inside a Git repository.", { + suggestions: ["Run `hunk log` from a Git worktree or bare repository."], + }); + } + return gitDir; +} + +/** Return whether a default HEAD traversal has no first commit yet. */ +function hasHead(options: GitHistoryOptions) { + return runGit(["rev-parse", "--verify", "--quiet", "HEAD"], options, [0, 1]).trim().length > 0; +} + +/** Refuse a positional revision that Git could reinterpret as an option. */ +function requireRevision(value: string) { + if (!value || value.startsWith("-")) { + throw new HunkExtensionUserError(`Refused history revision \`${value}\`.`, { + suggestions: ["Pass a revision or range such as `HEAD`, `main`, or `main..feature`."], + }); + } + return value; +} + +/** Snapshot ref labels separately so display punctuation is never parsed as structure. */ +function readDecorations(options: GitHistoryOptions) { + const byCommit = new Map(); + const add = (revisionId: string, decoration: ExtensionVcsHistoryDecoration) => { + const entries = byCommit.get(revisionId) ?? []; + entries.push(decoration); + byCommit.set(revisionId, entries); + }; + + const raw = runGit( + [ + "for-each-ref", + "--format=%(objectname)%00%(objecttype)%00%(refname)%00%(*objectname)%00", + "refs/heads", + "refs/remotes", + "refs/tags", + ], + options, + ); + for (const record of raw.split("\n")) { + if (!record) continue; + const [objectId, objectType, refName, peeledId] = record.split("\0"); + if (!objectId || !objectType || !refName) continue; + const revisionId = objectType === "tag" && peeledId ? peeledId : objectId; + const decoration: ExtensionVcsHistoryDecoration = refName.startsWith("refs/heads/") + ? { kind: "local-branch", label: refName.slice("refs/heads/".length) } + : refName.startsWith("refs/remotes/") + ? { kind: "remote-branch", label: refName.slice("refs/remotes/".length) } + : refName.startsWith("refs/tags/") + ? { kind: "tag", label: refName.slice("refs/tags/".length) } + : { kind: "ref", label: refName }; + add(revisionId, decoration); + } + + const headId = runGit(["rev-parse", "--verify", "--quiet", "HEAD"], options, [0, 1]).trim(); + if (headId) { + const branch = runGit(["symbolic-ref", "--quiet", "--short", "HEAD"], options, [0, 1]).trim(); + add(headId, { kind: "head", label: branch ? `HEAD -> ${branch}` : "HEAD" }); + } + + const order = new Map([ + ["head", 0], + ["local-branch", 1], + ["remote-branch", 2], + ["tag", 3], + ["ref", 4], + ]); + for (const entries of byCommit.values()) { + entries.sort( + (left, right) => + (order.get(left.kind) ?? 9) - (order.get(right.kind) ?? 9) || + (left.label < right.label ? -1 : left.label > right.label ? 1 : 0), + ); + } + return byCommit; +} + +/** Build deterministic Git log argv from the deliberately small public grammar. */ +export function buildGitHistoryArgs(input: ExtensionVcsHistoryInput) { + const args = [ + "log", + "--topo-order", + "--parents", + "--no-show-signature", + "--no-color", + "--abbrev=8", + "-z", + "--format=%H%x00%h%x00%P%x00%an%x00%ae%x00%aI%x00%s%x00%b", + ]; + if (input.all) args.push("--all"); + if (input.firstParent) args.push("--first-parent"); + if (input.maxCount !== undefined) args.push(`--max-count=${input.maxCount}`); + if (input.author !== undefined) args.push(`--author=${input.author}`); + if (input.grep !== undefined) args.push(`--grep=${input.grep}`); + if (input.since !== undefined) args.push(`--since=${input.since}`); + if (input.until !== undefined) args.push(`--until=${input.until}`); + if (input.revision !== undefined) args.push(requireRevision(input.revision)); + if (input.pathspecs?.length) args.push("--", ...input.pathspecs); + return args; +} + +/** Parse fixed NUL-delimited machine fields into immutable commit summaries. */ +export function parseGitHistory( + text: string, + decorations: ReadonlyMap = new Map(), + firstParent = false, +): ExtensionVcsHistoryCommit[] { + if (!text) return []; + const fields = text.split("\0"); + if (fields.length % HISTORY_FIELDS_PER_COMMIT === 1 && fields.at(-1) === "") fields.pop(); + if (fields.length % HISTORY_FIELDS_PER_COMMIT !== 0) { + throw new Error("Git returned a truncated history record."); + } + + const commits: ExtensionVcsHistoryCommit[] = []; + for (let offset = 0; offset < fields.length; offset += HISTORY_FIELDS_PER_COMMIT) { + const revisionId = fields[offset]!; + const displayId = fields[offset + 1]!; + const parents = fields[offset + 2]!; + const authorName = fields[offset + 3]!; + const authorEmail = fields[offset + 4]!; + const authoredAt = fields[offset + 5]!; + const subject = fields[offset + 6]!; + const body = fields[offset + 7]!; + if (!revisionId || !displayId || !authoredAt) { + throw new Error("Git returned an incomplete history record."); + } + const allParents = parents ? parents.split(" ").filter(Boolean) : []; + const parentRevisionIds = firstParent ? allParents.slice(0, 1) : allParents; + if ( + !FULL_OBJECT_ID_PATTERN.test(revisionId) || + !ABBREVIATED_OBJECT_ID_PATTERN.test(displayId) || + parentRevisionIds.some((parent) => !FULL_OBJECT_ID_PATTERN.test(parent)) + ) { + throw new Error("Git returned an invalid history object id."); + } + commits.push({ + revisionId, + displayId, + parentRevisionIds, + subject: subject || "(no commit message)", + ...(body ? { body } : {}), + authorName: authorName || "Unknown author", + ...(authorEmail ? { authorEmail } : {}), + authoredAt, + decorations: [...(decorations.get(revisionId) ?? [])], + }); + } + return commits; +} + +/** Open a cancellable streaming history cursor over one long-lived Git process. */ +export function openGitHistory( + input: ExtensionVcsHistoryInput, + { cwd, gitExecutable = "git" }: GitHistoryOptions, +): ExtensionVcsHistorySource & { repoRoot: string } { + const repoRoot = resolveHistoryRepoRoot({ cwd, gitExecutable }); + const queryOptions = { cwd: repoRoot, gitExecutable }; + const decorations = readDecorations(queryOptions); + const empty = input.maxCount === 0 || (!input.revision && !input.all && !hasHead(queryOptions)); + if (empty) { + return { + repoRoot, + async read() { + return { commits: [], done: true }; + }, + close() {}, + }; + } + + let child: ReturnType; + try { + child = spawn(gitExecutable, buildGitHistoryArgs(input), { + cwd: repoRoot, + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" }, + windowsHide: true, + }); + } catch { + throw new HunkExtensionUserError(`Could not run ${gitExecutable}.`, { + suggestions: ["Install Git or configure Hunk to use another VCS backend."], + }); + } + + const decoder = new StringDecoder("utf8"); + const queue: ExtensionVcsHistoryCommit[] = []; + const fields: string[] = []; + const waiters = new Set<() => void>(); + let buffered = ""; + let stderr = ""; + let completed = false; + let closed = false; + let failure: unknown; + let reading = false; + const wake = () => { + for (const waiter of waiters) waiter(); + waiters.clear(); + }; + const consume = (text: string) => { + buffered += text; + for (;;) { + const delimiter = buffered.indexOf("\0"); + if (delimiter < 0) break; + fields.push(buffered.slice(0, delimiter)); + buffered = buffered.slice(delimiter + 1); + if (fields.length === HISTORY_FIELDS_PER_COMMIT) { + queue.push(...parseGitHistory(`${fields.join("\0")}\0`, decorations, input.firstParent)); + fields.length = 0; + } + } + wake(); + }; + + child.stdout!.on("data", (chunk: Buffer) => { + consume(decoder.write(chunk)); + if (queue.length >= 512) child.stdout!.pause(); + }); + child.stderr!.setEncoding("utf8"); + child.stderr!.on("data", (chunk: string) => { + stderr += chunk; + }); + child.once("error", (error) => { + failure = error; + completed = true; + wake(); + }); + child.once("close", (code) => { + consume(decoder.end()); + if (!failure && (fields.length > 0 || buffered.length > 0)) { + failure = new Error("Git returned a truncated history record."); + } else if (!failure && code !== 0 && !closed) { + failure = new HunkExtensionUserError( + stderr.trim().split("\n")[0] || "Git could not read this history.", + { + suggestions: ["Check the revision, filters, and repository, then try again."], + }, + ); + } + completed = true; + wake(); + }); + + const waitForData = (signal?: AbortSignal) => + new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(signal.reason ?? new Error("History read aborted.")); + return; + } + const ready = () => { + signal?.removeEventListener("abort", abort); + resolve(); + }; + const abort = () => { + waiters.delete(ready); + reject(signal?.reason ?? new Error("History read aborted.")); + }; + waiters.add(ready); + signal?.addEventListener("abort", abort, { once: true }); + }); + + return { + repoRoot, + async read({ limit, signal }) { + if (reading) throw new Error("Concurrent Git history reads are not supported."); + reading = true; + try { + const target = Math.min(limit, 256); + while (queue.length < target && !completed && !closed) await waitForData(signal); + if (signal?.aborted) throw signal.reason ?? new Error("History read aborted."); + if (failure) throw failure; + const commits = queue.splice(0, target); + if (!completed && !closed && queue.length < 256) child.stdout!.resume(); + return { commits, done: (completed || closed) && queue.length === 0 }; + } finally { + reading = false; + } + }, + close() { + if (closed) return; + closed = true; + if (!completed) child.kill(); + wake(); + }, + }; +} diff --git a/src/extensions/default/vcs/git/index.ts b/src/extensions/default/vcs/git/index.ts index d3a1b27ff..25f8ef7f8 100644 --- a/src/extensions/default/vcs/git/index.ts +++ b/src/extensions/default/vcs/git/index.ts @@ -19,6 +19,7 @@ import { type GitBackedInput, type GitDiffEndpoints, } from "./commands"; +import { openGitHistory } from "./history"; import { gitEndpointSourceSpec, readGitFileSource } from "./source"; import { describeDiffRange } from "../diffRange"; import { @@ -279,6 +280,11 @@ export function createGitVcsAdapter({ name: "Git", detect: detectGitRepo, detectionPriority: HUNK_VCS_DETECTION_BASELINE_PRIORITY, + history: { + open(input, { cwd }) { + return openGitHistory(input, { cwd, gitExecutable }); + }, + }, operations: { "working-tree-diff": { async load(input, { cwd }) { diff --git a/src/extensions/runExtension.test.ts b/src/extensions/runExtension.test.ts index 86d546818..f03e7bc7c 100644 --- a/src/extensions/runExtension.test.ts +++ b/src/extensions/runExtension.test.ts @@ -833,10 +833,24 @@ describe("toInternalVcsAdapter detection ids", () => { (returnedId) => mismatches.push(returnedId), ); - expect(adapter.detect("/repo")).toBe(detection); + expect(adapter.detect("/repo")).toEqual(detection); expect(mismatches).toEqual([]); }); + test("snapshots and sanitizes adapter metadata before it reaches diagnostics", () => { + let nameReads = 0; + const adapter = toInternalVcsAdapter({ + id: "demo", + get name() { + nameReads += 1; + return nameReads === 1 ? "Demo\x1b[2J" : "Changed"; + }, + detect: () => null, + }); + expect(adapter.name).toBe("Demo"); + expect(nameReads).toBe(1); + }); + test("treats a detection without a usable repoRoot as no detection", () => { // `detectVcs` measures distance with `path.relative(detected.repoRoot, cwd)`, // and does it outside its own per-adapter try/catch — so a missing repoRoot @@ -947,3 +961,109 @@ describe("toInternalVcsAdapter detection ids", () => { ]); }); }); + +describe("toInternalVcsAdapter history boundary", () => { + test("copies and sanitizes bounded history pages", async () => { + const commit = { + revisionId: "a".repeat(40), + displayId: "aaaaaaaa", + parentRevisionIds: [] as string[], + subject: "safe\x1b]52;c;cHdu\x07\nspoof", + authorName: "Ada\rLovelace", + authoredAt: "2026-01-01T00:00:00Z", + decorations: [{ kind: "head" as const, label: "HEAD\x1b[2J" }], + }; + let closed = 0; + const adapter = toInternalVcsAdapter({ + id: "demo", + name: "Demo", + detect: () => null, + history: { + open: () => ({ + read: async () => ({ commits: [commit], done: true }), + close: () => { + closed += 1; + }, + }), + }, + }); + const source = await adapter.history!.open({}, { cwd: "/repo" }); + const page = await source.read({ limit: 1 }); + + expect(page.commits[0]?.subject).toBe("safespoof"); + expect(page.commits[0]?.authorName).toBe("AdaLovelace"); + expect(page.commits[0]?.decorations[0]?.label).toBe("HEAD"); + expect(page.commits[0]).not.toBe(commit); + expect(closed).toBe(1); + }); + + test("snapshots source, page, commit, and decoration accessors exactly once", async () => { + const reads = { sourceRead: 0, commits: 0, subject: 0, label: 0 }; + const commit = { + revisionId: "a".repeat(40), + displayId: "aaaaaaaa", + parentRevisionIds: [], + get subject() { + reads.subject += 1; + return reads.subject === 1 ? "Stable" : "Changed"; + }, + authorName: "Ada", + authoredAt: "2026-01-01T00:00:00Z", + decorations: [ + { + kind: "tag", + get label() { + reads.label += 1; + return reads.label === 1 ? "v1" : "changed"; + }, + }, + ], + }; + const page = { + get commits() { + reads.commits += 1; + return reads.commits === 1 ? [commit] : [{}, {}]; + }, + done: true, + }; + const publicSource = { + get read() { + reads.sourceRead += 1; + return async () => page; + }, + close() {}, + }; + const adapter = toInternalVcsAdapter({ + id: "demo", + name: "Demo", + detect: () => null, + history: { open: () => publicSource as never }, + }); + const source = await adapter.history!.open({}, { cwd: "/repo" }); + const result = await source.read({ limit: 1 }); + expect(result.commits[0]?.subject).toBe("Stable"); + expect(result.commits[0]?.decorations[0]?.label).toBe("v1"); + expect(reads).toEqual({ sourceRead: 1, commits: 1, subject: 1, label: 1 }); + }); + + test("closes malformed and over-limit sources once", async () => { + let closed = 0; + const adapter = toInternalVcsAdapter({ + id: "demo", + name: "Demo", + detect: () => null, + history: { + open: () => ({ + read: async () => ({ commits: [{}, {}], done: false }) as never, + close: () => { + closed += 1; + }, + }), + }, + }); + const source = await adapter.history!.open({}, { cwd: "/repo" }); + await expect(source.read({ limit: 1 })).rejects.toThrow("more commits"); + await source.close(); + expect(closed).toBe(1); + }); +}); diff --git a/src/extensions/runExtension.ts b/src/extensions/runExtension.ts index 6345da02b..0efa675fe 100644 --- a/src/extensions/runExtension.ts +++ b/src/extensions/runExtension.ts @@ -27,8 +27,13 @@ import { import { parseKeyChord, toKeyChordList } from "../lib/commandKeys"; import { toUserFacingError } from "../core/run/errors"; import { toInternalVcsPatchResult } from "./vcsPatchResult"; -import type { ExtensionVcsOperation } from "../extension-api/types"; -import type { VcsAdapter, VcsOperation, VcsReviewInput } from "../core/vcs/types"; +import type { + ExtensionVcsHistoryCommit, + ExtensionVcsHistorySource, + ExtensionVcsOperation, +} from "../extension-api/types"; +import type { VcsAdapter, VcsHistorySource, VcsOperation, VcsReviewInput } from "../core/vcs/types"; +import { sanitizeTerminalLine, sanitizeTerminalText } from "../lib/terminalText"; import { defaultExtensionPaneSize, extensionPaneSize, isVerticalPanePlacement } from "./panes"; import { isReservedExtensionCliCommandName, @@ -170,6 +175,191 @@ function toInternalVcsOperation( }; } +/** Snapshot named properties once so accessors cannot change values after validation. */ +function snapshotProperties(value: Record, keys: readonly string[]) { + const snapshot: Record = {}; + for (const key of keys) snapshot[key] = value[key]; + return snapshot; +} + +/** Snapshot an array's length and each accepted element once, including for proxied arrays. */ +function snapshotArray(value: unknown[], maximum = Number.MAX_SAFE_INTEGER) { + const length = value.length; + if (!Number.isSafeInteger(length) || length < 0 || length > maximum) { + throw new Error("VCS history returned more values than allowed."); + } + const snapshot: unknown[] = []; + for (let index = 0; index < length; index += 1) snapshot.push(value[index]); + return snapshot; +} + +/** Copy and validate one extension-provided history commit before it reaches core or UI. */ +function normalizeHistoryCommit(value: unknown): ExtensionVcsHistoryCommit { + if (!isPlainObject(value)) { + throw new Error("VCS history returned a commit that is not an object."); + } + const snapshot = snapshotProperties(value, [ + "revisionId", + "displayId", + "parentRevisionIds", + "subject", + "body", + "authorName", + "authorEmail", + "authoredAt", + "decorations", + "logicalId", + ]); + const required = (key: string) => + assertNonEmptyString(snapshot[key], `VCS history commit ${key} must be a non-empty string.`); + const safeRevision = (revision: unknown, label: string) => { + const text = assertNonEmptyString(revision, `${label} must be a non-empty string.`); + if (text.startsWith("-") || sanitizeTerminalLine(text) !== text) { + throw new Error(`${label} must be a terminal-safe immutable revision id.`); + } + return text; + }; + const revisionId = safeRevision(snapshot.revisionId, "VCS history commit revisionId"); + const displayId = required("displayId"); + const authoredAt = required("authoredAt"); + if ( + !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(authoredAt) || + Number.isNaN(Date.parse(authoredAt)) + ) { + throw new Error("VCS history commit authoredAt must be an ISO timestamp."); + } + if (!Array.isArray(snapshot.parentRevisionIds)) { + throw new Error("VCS history commit parentRevisionIds must be an array."); + } + if (!Array.isArray(snapshot.decorations)) { + throw new Error("VCS history commit decorations must be an array."); + } + + const parentValues = snapshotArray(snapshot.parentRevisionIds, 256); + const decorationValues = snapshotArray(snapshot.decorations, 256); + const parentRevisionIds = parentValues.map((parent) => + safeRevision(parent, "VCS history parent revision id"), + ); + const decorationKinds = new Set(["head", "local-branch", "remote-branch", "tag", "ref"]); + const decorations = decorationValues.map((decoration) => { + if (!isPlainObject(decoration)) { + throw new Error("VCS history returned an invalid decoration."); + } + const fields = snapshotProperties(decoration, ["kind", "label"]); + if (typeof fields.kind !== "string" || !decorationKinds.has(fields.kind)) { + throw new Error("VCS history returned an invalid decoration."); + } + return { + kind: fields.kind as ExtensionVcsHistoryCommit["decorations"][number]["kind"], + label: sanitizeTerminalLine( + assertNonEmptyString(fields.label, "VCS history decoration labels must be non-empty."), + ).replaceAll("\t", " "), + }; + }); + + return { + revisionId, + displayId: sanitizeTerminalLine(displayId).replaceAll("\t", " "), + parentRevisionIds, + subject: sanitizeTerminalLine(required("subject")).replaceAll("\t", " "), + ...(typeof snapshot.body === "string" + ? { + body: sanitizeTerminalText(snapshot.body, { + preserveNewlines: true, + preserveTabs: false, + }), + } + : {}), + authorName: sanitizeTerminalLine(required("authorName")).replaceAll("\t", " "), + ...(typeof snapshot.authorEmail === "string" + ? { authorEmail: sanitizeTerminalLine(snapshot.authorEmail).replaceAll("\t", " ") } + : {}), + authoredAt, + decorations, + ...(typeof snapshot.logicalId === "string" + ? { logicalId: sanitizeTerminalLine(snapshot.logicalId).replaceAll("\t", " ") } + : {}), + }; +} + +/** Wrap an extension history source with bounded reads, copying, cleanup, and error translation. */ +async function toInternalHistorySource( + source: ExtensionVcsHistorySource, +): Promise { + if (!isPlainObject(source)) { + throw new Error("VCS history open() must return a source object."); + } + const sourceFields = snapshotProperties(source, ["read", "close"]); + const sourceRead = sourceFields.read; + const sourceClose = sourceFields.close; + if (typeof sourceRead !== "function" || typeof sourceClose !== "function") { + if (typeof sourceClose === "function") { + try { + await sourceClose.call(source); + } catch { + // The malformed shape remains the primary failure. + } + } + throw new Error("VCS history open() must return a source with read() and close()."); + } + let closed = false; + let done = false; + const acceptedIds = new Set(); + const close = async () => { + if (closed) return; + closed = true; + try { + await sourceClose.call(source); + } catch (error) { + throw toUserFacingError(error); + } + }; + + return { + async read({ limit, signal }) { + if (closed || done) return { commits: [], done: true }; + if (!Number.isSafeInteger(limit) || limit <= 0) { + throw new Error("VCS history reads require a positive integer limit."); + } + try { + if (signal?.aborted) throw signal.reason ?? new Error("History read aborted."); + const page = await sourceRead.call(source, { limit, signal }); + if (!isPlainObject(page)) { + throw new Error("VCS history returned an invalid page."); + } + const pageFields = snapshotProperties(page, ["commits", "done"]); + if (!Array.isArray(pageFields.commits) || typeof pageFields.done !== "boolean") { + throw new Error("VCS history returned an invalid page."); + } + let commitValues: unknown[]; + try { + commitValues = snapshotArray(pageFields.commits, limit); + } catch { + throw new Error("VCS history returned more commits than the requested page limit."); + } + const commits = commitValues.map(normalizeHistoryCommit); + for (const commit of commits) { + if (acceptedIds.has(commit.revisionId)) { + throw new Error(`VCS history returned duplicate revision ${commit.revisionId}.`); + } + acceptedIds.add(commit.revisionId); + } + done = pageFields.done; + if (done) await close(); + return { commits, done }; + } catch (error) { + try { + await close(); + } catch { + // Preserve the read/validation failure as the primary error. + } + throw toUserFacingError(error); + } + }, + close, + }; +} + /** * Accept the public adapter shape as the internal one. * @@ -199,7 +389,22 @@ export function toInternalVcsAdapter( /** Diagnostic sink for a `detect()` result whose id was rewritten. */ reportDetectionIdMismatch?: (returnedId: string) => void, ): VcsAdapter { - const operations = adapter.operations; + const adapterFields = snapshotProperties(adapter as unknown as Record, [ + "id", + "name", + "operations", + "history", + "detect", + "detectionPriority", + ]); + const adapterId = assertNonEmptyString(adapterFields.id, "registerVcsAdapter requires an id."); + if (sanitizeTerminalLine(adapterId) !== adapterId || adapterId.startsWith("-")) { + throw new Error("registerVcsAdapter requires a terminal-safe id."); + } + const adapterName = sanitizeTerminalLine( + assertNonEmptyString(adapterFields.name, "registerVcsAdapter requires a name."), + ).replaceAll("\t", " "); + const operations = adapterFields.operations; if (operations !== undefined && !isPlainObject(operations)) { throw new Error("registerVcsAdapter requires operations to be an object of review operations."); } @@ -213,39 +418,68 @@ export function toInternalVcsAdapter( } } - const detect = adapter.detect; + const history = adapterFields.history; + const historyFields = isPlainObject(history) ? snapshotProperties(history, ["open"]) : undefined; + const historyOpen = historyFields?.open; + if (history !== undefined && (!isPlainObject(history) || typeof historyOpen !== "function")) { + throw new Error("registerVcsAdapter history must provide an open() function."); + } + + const openHistory = historyOpen as NonNullable["open"]; + const detect = adapterFields.detect; + if (typeof detect !== "function") { + throw new Error("registerVcsAdapter requires a detect() function."); + } // Report once per adapter: detection runs on every session and reload, and a // repeated diagnostic for one authoring mistake is noise, not information. let reportedMismatch = false; return { - ...adapter, + id: adapterId, + name: adapterName, + detectionPriority: + typeof adapterFields.detectionPriority === "number" + ? adapterFields.detectionPriority + : undefined, detect(cwd: string) { const detected = detect(cwd); if (!detected || !isPlainObject(detected)) { return null; } - // `repoRoot` is a path every caller measures distance against, and the - // measurement happens outside detection's own error handling — so a - // detection missing it throws past `detectVcs` and aborts startup rather - // than being skipped. Treat it as "did not recognize this directory". - if (typeof detected.repoRoot !== "string" || detected.repoRoot.length === 0) { + // Snapshot detection metadata before validation so accessors cannot change it later. + const detectionFields = snapshotProperties(detected, ["id", "repoRoot"]); + if (typeof detectionFields.repoRoot !== "string" || detectionFields.repoRoot.length === 0) { return null; } - if (detected.id === adapter.id) { - return detected; - } - - if (!reportedMismatch) { + if (detectionFields.id !== adapterId && !reportedMismatch) { reportedMismatch = true; - reportDetectionIdMismatch?.(String(detected.id)); + reportDetectionIdMismatch?.(sanitizeTerminalLine(String(detectionFields.id))); } - return { ...detected, id: adapter.id }; + return { id: adapterId, repoRoot: detectionFields.repoRoot }; }, operations: internalOperations, + ...(history && { + history: { + async open(input, context) { + try { + const source = await openHistory.call( + history, + { + ...input, + ...(input.pathspecs ? { pathspecs: [...input.pathspecs] } : {}), + }, + context, + ); + return await toInternalHistorySource(source); + } catch (error) { + throw toUserFacingError(error); + } + }, + }, + }), }; } diff --git a/src/extensions/types.ts b/src/extensions/types.ts index 2fd466a62..26fad6753 100644 --- a/src/extensions/types.ts +++ b/src/extensions/types.ts @@ -76,6 +76,12 @@ export type { ExtensionSessionOptions, ExtensionThemeConfig, ExtensionVcsAdapter, + ExtensionVcsHistoryCapability, + ExtensionVcsHistoryCommit, + ExtensionVcsHistoryDecoration, + ExtensionVcsHistoryInput, + ExtensionVcsHistoryPage, + ExtensionVcsHistorySource, ExtensionWorkspace, ExtensionWorkspaceWriteRequest, ExtensionWorkspaceWriteResult, diff --git a/src/main.tsx b/src/main.tsx index 2e923b5cb..7137bc4ee 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -88,6 +88,18 @@ async function main() { ); } + if (startupPlan.kind === "history-static") { + const { runStaticHistory } = await import("./ui/history/runStaticHistory"); + await runStaticHistory(startupPlan.bootstrap); + return; + } + + if (startupPlan.kind === "history-interactive") { + const { runInteractiveHistory } = await import("./ui/history/runInteractiveHistory"); + await runInteractiveHistory(startupPlan.bootstrap); + return; + } + if (startupPlan.kind === "plain-text-pager") { await pagePlainText(startupPlan.text); process.exit(0); diff --git a/src/session/broker/brokerLauncher.ts b/src/session/broker/brokerLauncher.ts index 7bb9e257b..7fd6452b0 100644 --- a/src/session/broker/brokerLauncher.ts +++ b/src/session/broker/brokerLauncher.ts @@ -13,9 +13,8 @@ import { parseBrokerString, parseExactBrokerRecord, } from "@hunk/session-broker-core"; +import { resolveCurrentHunkCommand } from "../../core/process/relaunch"; import { resolveSessionBrokerConfig, type ResolvedSessionBrokerConfig } from "./brokerConfig"; - -const SCRIPT_ENTRYPOINT_PATTERN = /[\\/]|\.(?:[cm]?js|tsx?)$/; const DEFAULT_DAEMON_LOCK_STALE_MS = 15_000; const DEFAULT_DAEMON_STARTUP_TIMEOUT_MS = 3_000; const DEFAULT_DAEMON_HEALTH_POLL_INTERVAL_MS = 100; @@ -80,22 +79,6 @@ export interface EnsureSessionBrokerAvailableOptions { }) => ChildProcess; } -/** Detect Bun's virtual filesystem prefix used inside compiled single-file executables. */ -const BUNFS_PREFIX = "/$bunfs/"; -/** Bun's Windows equivalent mounts the compiled bundle on a virtual B: drive. */ -const BUNFS_WINDOWS_PREFIX = "b:/~bun/"; - -/** True when argv[1] is a Bun single-file-executable virtual path on any platform. */ -function isBunfsEntrypoint(entrypoint: string) { - if (entrypoint.startsWith(BUNFS_PREFIX)) { - return true; - } - - // Windows reports the virtual path with either separator depending on the shell, so - // normalize before comparing (e.g. "B:\\~BUN\\root\\hunk.exe" or "B:/~BUN/root/hunk.exe"). - return entrypoint.replaceAll("\\", "/").toLowerCase().startsWith(BUNFS_WINDOWS_PREFIX); -} - function safeRuntimeToken(value: string) { return value.replace(/[^a-z0-9]+/gi, "-").replace(/^-+|-+$/g, "") || "default"; } @@ -358,35 +341,8 @@ export function resolveDaemonLaunchCommand( argv = process.argv, execPath = process.execPath, ): DaemonLaunchCommand { - const entrypoint = argv[1]; - - // Bun-compiled single-file executables report argv as - // ["bun", "/$bunfs/root/", ...userArgs] (Unix) - // ["bun", "B:/~BUN/root/.exe", ...userArgs] (Windows) - // with execPath pointing to the real binary on disk. - // Detect the virtual path and use execPath directly; letting the Windows form fall through - // to the script-entrypoint branch would relaunch the binary with the virtual path as a bogus - // first argument and the daemon would never start (#502). - if (entrypoint && isBunfsEntrypoint(entrypoint)) { - return { - command: execPath, - args: ["daemon", "serve"], - }; - } - - // Running from source or a JS wrapper (bun src/main.tsx, node bin/hunk.cjs): - // reuse the runtime + script entrypoint. - if (entrypoint && !entrypoint.startsWith("-") && SCRIPT_ENTRYPOINT_PATTERN.test(entrypoint)) { - return { - command: execPath, - args: [entrypoint, "daemon", "serve"], - }; - } - - return { - command: execPath, - args: ["daemon", "serve"], - }; + const current = resolveCurrentHunkCommand(argv, execPath); + return { command: current.command, args: [...current.args, "daemon", "serve"] }; } /** Resolve the runtime paths used to coordinate one broker daemon per loopback host/port. */ diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 6ff7452df..8cb01ce8d 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -361,7 +361,9 @@ export function App({ currentPreferences: currentViewPreferences, configPath: bootstrap.viewPreferencesConfigPath, pagerMode, - promptSaveViewPreferences: bootstrap.input.options.promptSaveViewPreferences !== false, + promptSaveViewPreferences: + bootstrap.input.options.promptSaveViewPreferences !== false && + process.env.HUNK_RETURN_TO_HISTORY !== "1", transientViewPreferences: extensionSessionOptions.transientViewPreferences, onQuit, showNotice: showSessionNotice, @@ -1076,7 +1078,11 @@ export function App({ toggleFilesPane, triggerEditSelectedFile, triggerRefreshCurrentInput, - }), + }).map((command) => + process.env.HUNK_RETURN_TO_HISTORY === "1" && command.id === "hunk.app.quit" + ? { ...command, title: "Back to history" } + : command, + ), ...extensionAppCommands.commands, ], publishCommandExecuted, diff --git a/src/ui/history/runInteractiveHistory.ts b/src/ui/history/runInteractiveHistory.ts new file mode 100644 index 000000000..8665a59ec --- /dev/null +++ b/src/ui/history/runInteractiveHistory.ts @@ -0,0 +1,304 @@ +import { spawn } from "node:child_process"; +import { resolve } from "node:path"; +import { createHistoryLaneCheckpoint, planHistoryPage } from "../../core/history/lanePlanner"; +import type { HistoryGraphRow, HistoryLaneCheckpoint } from "../../core/history/types"; +import { resolveCurrentHunkCommand } from "../../core/process/relaunch"; +import { HunkUserError } from "../../core/run/errors"; +import { sanitizeTerminalLine } from "../../lib/terminalText"; +import { fitText } from "../lib/text"; +import { + background, + foreground, + projectHistoryRow, + resolveHistoryColor, + resolveHistoryTheme, +} from "./staticProjection"; +import { TerminalInputReader } from "./terminalInput"; +import type { HistoryRuntime } from "./types"; + +const ENTER_ALT = "\x1b[?1049h\x1b[?25l\x1b[?1000h\x1b[?1006h"; +const LEAVE_ALT = "\x1b[?1006l\x1b[?1000l\x1b[?25h\x1b[?1049l"; + +/** Run one child Hunk review after completely yielding ownership of the terminal. */ +async function openCommitReview(bootstrap: HistoryRuntime, row: HistoryGraphRow) { + const current = resolveCurrentHunkCommand(); + const extensionArgs = bootstrap.input.extensionPaths.flatMap((path) => [ + "--extension", + resolve(path), + ]); + const firstParent = row.commit.parentRevisionIds[0]; + const reviewArgs = firstParent + ? ["diff", firstParent, row.commit.revisionId] + : ["show", row.commit.revisionId]; + const args = [ + ...current.args, + ...reviewArgs, + "--vcs", + bootstrap.providerId, + ...(bootstrap.input.extensionsEnabled ? extensionArgs : ["--no-extensions"]), + ]; + const child = spawn(current.command, args, { + cwd: bootstrap.repoRoot, + env: { ...process.env, HUNK_RETURN_TO_HISTORY: "1" }, + stdio: "inherit", + }); + return await new Promise((resolveExit, reject) => { + child.once("error", reject); + child.once("exit", (code, signal) => resolveExit(signal ? 1 : (code ?? 1))); + }); +} + +/** Browse history as one minimal graph list and open immutable commits in ordinary Hunk review. */ +export async function runInteractiveHistory( + bootstrap: HistoryRuntime, + { + stdin = process.stdin, + stdout = process.stdout, + }: { + stdin?: NodeJS.ReadStream; + stdout?: NodeJS.WriteStream; + } = {}, +) { + if (!stdin.isTTY || !stdout.isTTY || typeof stdin.setRawMode !== "function") { + await bootstrap.close(); + throw new HunkUserError("`hunk log --interactive` requires a terminal.", [ + "Use plain `hunk log` for pipes and redirected output.", + ]); + } + + const input = new TerminalInputReader(stdin); + const abort = new AbortController(); + const rows: HistoryGraphRow[] = []; + let checkpoint: HistoryLaneCheckpoint = createHistoryLaneCheckpoint(); + let historyDone = false; + let selected = 0; + let top = 0; + let search = ""; + let notice = bootstrap.notices[0] ? sanitizeTerminalLine(bootstrap.notices[0]) : ""; + let lastClick = { index: -1, at: 0 }; + let active = false; + let stopped = false; + let loading = false; + const theme = resolveHistoryTheme(bootstrap.input.theme, bootstrap.customThemes); + + /** Fetch one bounded continuation page and preserve graph state across it. */ + const loadMore = async () => { + if (historyDone || loading || stopped) return; + loading = true; + try { + const page = await bootstrap.source.read({ limit: 256, signal: abort.signal }); + if (!page.done && page.commits.length === 0) + throw new Error("VCS history returned an empty page before EOF."); + const planned = planHistoryPage(page.commits, checkpoint); + rows.push(...planned.rows); + checkpoint = planned.checkpoint; + historyDone = page.done; + if (rows.length === 0 && historyDone) notice = "No commits found."; + } finally { + loading = false; + } + }; + const loadAll = async () => { + while (!historyDone && !stopped) await loadMore(); + }; + + const terminalWidth = () => (stdout.columns && stdout.columns > 0 ? stdout.columns : 80); + const terminalHeight = () => (stdout.rows && stdout.rows > 0 ? stdout.rows : 24); + const enterTerminal = () => { + stdin.setRawMode?.(true); + input.resume(); + stdout.write(ENTER_ALT); + active = true; + }; + const leaveTerminal = () => { + if (!active) return; + active = false; + input.pause(); + stdout.write(LEAVE_ALT); + stdin.setRawMode?.(false); + }; + const clampViewport = () => { + selected = Math.max(0, Math.min(Math.max(0, rows.length - 1), selected)); + const height = Math.max(1, terminalHeight() - 1); + if (selected < top) top = selected; + if (selected >= top + height) top = selected - height + 1; + top = Math.max(0, Math.min(top, Math.max(0, rows.length - height))); + }; + const render = () => { + clampViewport(); + const width = Math.max(1, terminalWidth()); + const height = Math.max(1, terminalHeight() - 1); + const visible = rows.slice(top, top + height); + const color = resolveHistoryColor({ + mode: bootstrap.input.color, + stdoutIsTTY: true, + env: process.env, + }); + const lines = visible.map((row, offset) => { + const isSelected = top + offset === selected; + const text = projectHistoryRow(row, { + ascii: bootstrap.input.ascii || process.env.TERM === "dumb", + color: color && !isSelected, + theme, + width, + }); + return isSelected && color + ? `${background(theme.selectedHunk)}${foreground(theme.text)}${text}\x1b[0m` + : isSelected + ? `\x1b[7m${text}\x1b[0m` + : text; + }); + while (lines.length < height) lines.push(""); + const footer = search + ? `/${search}` + : notice || + `↑↓/jk move / search n/N match y copy enter open q quit${historyDone ? "" : " ↓ load more"}`; + const footerText = fitText(footer, width, "…"); + const styledFooter = color + ? `${background(theme.panelAlt)}${foreground(theme.muted)}${footerText}\x1b[0m` + : `\x1b[7m${footerText}\x1b[0m`; + stdout.write(`\x1b[H\x1b[2J${lines.join("\n")}\n${styledFooter}`); + }; + const findMatch = async (direction: 1 | -1) => { + if (!search || rows.length === 0) return; + await loadAll(); + const needle = search.toLocaleLowerCase(); + for (let step = 1; step <= rows.length; step += 1) { + const index = (selected + direction * step + rows.length) % rows.length; + const commit = rows[index]!.commit; + const haystack = [ + commit.revisionId, + commit.displayId, + commit.subject, + commit.body ?? "", + commit.authorName, + commit.authorEmail ?? "", + ...commit.decorations.map((entry) => entry.label), + ] + .join(" ") + .toLocaleLowerCase(); + if (haystack.includes(needle)) { + selected = index; + notice = ""; + return; + } + } + notice = `No match for ${sanitizeTerminalLine(search)}`; + }; + const editSearch = async () => { + let draft = search; + for (;;) { + search = draft; + render(); + const key = await input.next(); + if (key === "\r" || key === "\n") { + search = draft; + await findMatch(1); + return; + } + if (key === "\x1b") return; + if (key === "\x7f") draft = Array.from(draft).slice(0, -1).join(""); + else if (/^[^\x00-\x1f\x7f]+$/u.test(key)) draft += key; + } + }; + const cleanup = () => { + if (stopped) return; + stopped = true; + abort.abort(new Error("History browser stopped.")); + leaveTerminal(); + }; + const onResize = () => render(); + const stopForSignal = (exitCode: number) => { + cleanup(); + process.exitCode = exitCode; + input.close(); + }; + const onInterrupt = () => stopForSignal(130); + const onHangup = () => stopForSignal(129); + const onTerminate = () => stopForSignal(143); + + process.once("SIGINT", onInterrupt); + process.once("SIGHUP", onHangup); + process.once("SIGTERM", onTerminate); + stdout.on("resize", onResize); + try { + await loadMore(); + if (stopped) return; + enterTerminal(); + render(); + while (!stopped) { + const key = await input.next(); + const height = Math.max(1, terminalHeight() - 1); + if (key === "q" || key === "\x03") break; + if (key === "\x1b[B" || key === "j") { + if (selected + 1 >= rows.length && !historyDone) await loadMore(); + selected += 1; + } else if (key === "\x1b[A" || key === "k") selected -= 1; + else if (key === "\x1b[6~") { + while (selected + height >= rows.length && !historyDone) await loadMore(); + selected += height; + } else if (key === "\x1b[5~") selected -= height; + else if (["\x1b[H", "\x1b[1~", "\x1bOH", "g"].includes(key)) selected = 0; + else if (["\x1b[F", "\x1b[4~", "\x1bOF", "G"].includes(key)) { + await loadAll(); + selected = rows.length - 1; + } else if (key === "/") await editSearch(); + else if (key === "n") await findMatch(1); + else if (key === "N") await findMatch(-1); + else if (key === "y" && rows[selected]) { + stdout.write( + `\x1b]52;c;${Buffer.from(rows[selected]!.commit.revisionId).toString("base64")}\x07`, + ); + notice = `Copied ${rows[selected]!.commit.displayId}`; + } else if ((key === "\r" || key === "\n") && rows[selected]) { + const selectedRow = rows[selected]!; + input.discardPending(); + leaveTerminal(); + const code = await openCommitReview(bootstrap, selectedRow); + if (stopped) break; + enterTerminal(); + notice = code === 0 ? "" : `Could not open ${rows[selected]!.commit.displayId}`; + } else { + const mouse = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/.exec(key); + if (mouse) { + const button = Number(mouse[1]); + const screenRow = Number(mouse[3]) - 1; + const visibleCount = Math.min(height, rows.length - top); + if (button === 64) selected -= 3; + else if (button === 65) { + if (selected + 3 >= rows.length && !historyDone) await loadMore(); + selected += 3; + } else if ( + button === 0 && + mouse[4] === "M" && + screenRow >= 0 && + screenRow < visibleCount + ) { + const index = top + screenRow; + selected = index; + const now = Date.now(); + if (lastClick.index === index && now - lastClick.at < 400) { + input.discardPending(); + leaveTerminal(); + const code = await openCommitReview(bootstrap, rows[index]!); + if (!stopped) enterTerminal(); + notice = code === 0 ? "" : `Could not open ${rows[index]!.commit.displayId}`; + } + lastClick = { index, at: now }; + } + } + } + render(); + } + } catch (error) { + if (!stopped) throw error; + } finally { + cleanup(); + input.close(); + stdout.off("resize", onResize); + process.off("SIGINT", onInterrupt); + process.off("SIGHUP", onHangup); + process.off("SIGTERM", onTerminate); + await bootstrap.close(); + } +} diff --git a/src/ui/history/runStaticHistory.test.ts b/src/ui/history/runStaticHistory.test.ts new file mode 100644 index 000000000..403d1a20d --- /dev/null +++ b/src/ui/history/runStaticHistory.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, test } from "bun:test"; +import { EventEmitter } from "node:events"; +import type { HistoryCommit } from "../../core/history/types"; +import type { HistoryRuntime } from "./types"; +import { runStaticHistory } from "./runStaticHistory"; + +/** Create a page-backed runtime and expose whether cleanup ran. */ +function runtime(commits: HistoryCommit[], maxCount?: number) { + let offset = 0; + let closed = 0; + const value: HistoryRuntime = { + input: { + kind: "history", + color: "never", + format: "medium", + ascii: false, + interactive: false, + extensionsEnabled: true, + extensionPaths: [], + ...(maxCount !== undefined ? { maxCount } : {}), + }, + providerId: "test", + providerName: "Test", + repoRoot: "/repo", + notices: [], + customThemes: [], + source: { + async read({ limit }) { + const page = commits.slice(offset, offset + limit); + offset += page.length; + return { commits: page, done: offset >= commits.length }; + }, + async close() {}, + }, + async close() { + closed += 1; + }, + }; + return { value, closed: () => closed }; +} + +const commits: HistoryCommit[] = ["a", "b"].map((id, index) => ({ + revisionId: id, + displayId: id.repeat(8), + parentRevisionIds: index === 0 ? ["b"] : [], + subject: `Commit ${id}`, + authorName: "Ada", + authoredAt: "2026-01-01T00:00:00Z", + decorations: [], +})); + +describe("static history runner", () => { + test("writes complete rows directly to non-TTY output and closes", async () => { + const history = runtime(commits); + let output = ""; + let paged = ""; + await runStaticHistory(history.value, { + stdout: { + isTTY: false, + columns: 80, + rows: 24, + write: (text) => ((output += String(text)), true), + }, + stderr: { write: () => true }, + env: {}, + pageText: async (text) => { + paged = text; + }, + }); + expect(output).toContain("Commit a"); + expect(output).toContain("Commit b"); + expect(paged).toBe(""); + expect(history.closed()).toBe(1); + }); + + test("treats a downstream EPIPE as normal and still closes the source", async () => { + const history = runtime(commits); + const stdout = new EventEmitter() as EventEmitter & { + isTTY: boolean; + columns: number; + rows: number; + write(text: string): boolean; + }; + stdout.isTTY = false; + stdout.columns = 80; + stdout.rows = 24; + stdout.write = () => { + queueMicrotask(() => + stdout.emit("error", Object.assign(new Error("broken pipe"), { code: "EPIPE" })), + ); + return false; + }; + await runStaticHistory(history.value, { + stdout: stdout as never, + stderr: { write: () => true }, + env: {}, + pageText: async () => {}, + }); + expect(history.closed()).toBe(1); + }); + + test("uses the pager only when TTY rows overflow", async () => { + const history = runtime(commits); + let paged = ""; + await runStaticHistory(history.value, { + stdout: { isTTY: true, columns: 80, rows: 2, write: () => true }, + stderr: { write: () => true }, + env: { TERM: "xterm" }, + pageText: async (text) => { + paged = text; + }, + }); + expect(paged).toContain("Commit a"); + expect(paged).toContain("Commit b"); + }); + + test("streams overflowing TTY pages through one bounded pager writer", async () => { + const history = runtime(commits); + let written = ""; + let closes = 0; + await runStaticHistory(history.value, { + stdout: { isTTY: true, columns: 80, rows: 2, write: () => true }, + stderr: { write: () => true }, + env: { TERM: "xterm" }, + pageText: async () => { + throw new Error("buffered pager should not run"); + }, + openPager: () => ({ + async write(text) { + written += text; + }, + async close() { + closes += 1; + }, + }), + }); + expect(written).toContain("Commit a"); + expect(written).toContain("Commit b"); + expect(closes).toBe(1); + }); + + test("keeps max-count zero silent even on a TTY", async () => { + const history = runtime([], 0); + let output = ""; + await runStaticHistory(history.value, { + stdout: { + isTTY: true, + columns: 80, + rows: 24, + write: (text) => ((output += String(text)), true), + }, + stderr: { write: () => true }, + env: {}, + pageText: async () => {}, + }); + expect(output).toBe(""); + }); +}); diff --git a/src/ui/history/runStaticHistory.ts b/src/ui/history/runStaticHistory.ts new file mode 100644 index 000000000..d7f2ec55c --- /dev/null +++ b/src/ui/history/runStaticHistory.ts @@ -0,0 +1,138 @@ +import { createHistoryLaneCheckpoint, planHistoryPage } from "../../core/history/lanePlanner"; +import { + openPlainTextPager, + pagePlainText, + type PlainTextPagerWriter, +} from "../../core/process/pager"; +import { sanitizeTerminalLine } from "../../lib/terminalText"; +import { + projectHistoryConvergence, + projectHistoryRecord, + projectHistoryRow, + resolveHistoryColor, + resolveHistoryTheme, +} from "./staticProjection"; +import type { HistoryRuntime } from "./types"; + +export interface StaticHistoryDeps { + stdout: Pick & + Partial>; + stderr: Pick; + env: NodeJS.ProcessEnv; + pageText: (text: string, env: NodeJS.ProcessEnv) => Promise; + /** Production opens a streaming pager; tests may omit it to exercise the legacy seam. */ + openPager?: (env: NodeJS.ProcessEnv) => PlainTextPagerWriter; +} + +/** Consume history incrementally and print safe normal-screen records. */ +export async function runStaticHistory( + bootstrap: HistoryRuntime, + deps: StaticHistoryDeps = { + stdout: process.stdout, + stderr: process.stderr, + env: process.env, + pageText: pagePlainText, + openPager: (env) => openPlainTextPager(env), + }, +) { + const { input } = bootstrap; + for (const notice of bootstrap.notices) { + deps.stderr.write(`hunk: warning: ${sanitizeTerminalLine(notice)}\n`); + } + + let outputClosed = false; + let outputFailure: unknown; + let releaseDrain: (() => void) | undefined; + const onOutputError = (error: NodeJS.ErrnoException) => { + if (error.code === "EPIPE") outputClosed = true; + else outputFailure = error; + releaseDrain?.(); + }; + deps.stdout.on?.("error", onOutputError); + const writeOutput = async (text: string) => { + if (outputFailure) throw outputFailure; + if (outputClosed) return false; + const accepted = deps.stdout.write(text); + if (!accepted && deps.stdout.once) { + await new Promise((resolve) => { + releaseDrain = resolve; + deps.stdout.once!("drain", resolve); + }); + releaseDrain = undefined; + } + if (outputFailure) throw outputFailure; + return !outputClosed; + }; + const stdoutIsTTY = Boolean(deps.stdout.isTTY); + const ascii = input.ascii || deps.env.TERM === "dumb"; + const color = resolveHistoryColor({ mode: input.color, stdoutIsTTY, env: deps.env }); + const theme = resolveHistoryTheme(input.theme, bootstrap.customThemes); + const terminalColumns = deps.stdout.columns; + const width = stdoutIsTTY + ? terminalColumns && terminalColumns > 0 + ? terminalColumns + : 80 + : undefined; + const bufferedLines: string[] = []; + const terminalRows = deps.stdout.rows; + const availableRows = Math.max(1, (terminalRows && terminalRows > 0 ? terminalRows : 24) - 1); + let pager: PlainTextPagerWriter | undefined; + let checkpoint = createHistoryLaneCheckpoint(); + let done = false; + let commitCount = 0; + try { + while (!done) { + const page = await bootstrap.source.read({ limit: 256 }); + const planned = planHistoryPage(page.commits, checkpoint); + checkpoint = planned.checkpoint; + const lines = planned.rows.flatMap((row) => + input.format === "compact" + ? [ + projectHistoryRow(row, { ascii, color, theme, width }), + projectHistoryConvergence(row, { ascii, color, theme, width }), + ].filter(Boolean) + : projectHistoryRecord(row, { ascii, color, theme, width }), + ); + commitCount += planned.rows.length; + if (stdoutIsTTY) { + if (pager) await pager.write(`${lines.join("\n")}\n`); + else { + bufferedLines.push(...lines); + if (deps.openPager && bufferedLines.length > availableRows) { + pager = deps.openPager(deps.env); + await pager.write(`${bufferedLines.join("\n")}\n`); + bufferedLines.length = 0; + } + } + } else if (lines.length > 0 && !(await writeOutput(`${lines.join("\n")}\n`))) { + return; + } + done = page.done; + if (page.commits.length === 0 && !done) { + throw new Error("VCS history returned an empty page before the end of history."); + } + } + + if (commitCount === 0) { + if (stdoutIsTTY && input.maxCount !== 0) await writeOutput("No commits found.\n"); + return; + } + if (!stdoutIsTTY) return; + if (pager) { + await pager.close(); + pager = undefined; + return; + } + + const text = `${bufferedLines.join("\n")}\n`; + if (bufferedLines.length > availableRows) await deps.pageText(text, deps.env); + else await writeOutput(text); + } finally { + try { + await pager?.close(); + } finally { + deps.stdout.off?.("error", onOutputError); + await bootstrap.close(); + } + } +} diff --git a/src/ui/history/staticProjection.test.ts b/src/ui/history/staticProjection.test.ts new file mode 100644 index 000000000..ef4e9ae4a --- /dev/null +++ b/src/ui/history/staticProjection.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, test } from "bun:test"; +import { measureTextWidth } from "../lib/text"; +import { planHistoryPage } from "../../core/history/lanePlanner"; +import type { HistoryCommit } from "../../core/history/types"; +import { + formatHistoryDecorations, + projectHistoryRecord, + projectHistoryRow, + renderHistoryConvergence, + renderHistoryGraph, + resolveHistoryColor, + resolveHistoryTheme, +} from "./staticProjection"; + +const commit: HistoryCommit = { + revisionId: "a".repeat(40), + displayId: "aaaaaaaa", + parentRevisionIds: ["b".repeat(40), "c".repeat(40)], + subject: "Improve 日本語 rendering\x1b]52;c;cHdu\x07\nspoof", + body: "First paragraph.\n\nSecond paragraph.", + authorName: "Ada\rLovelace", + authoredAt: "2026-01-02T03:04:05Z", + decorations: [ + { kind: "head", label: "HEAD -> main\x1b[2J" }, + { kind: "local-branch", label: "main" }, + { kind: "remote-branch", label: "origin/main" }, + { kind: "tag", label: "v1.0.0" }, + ], +}; +const row = planHistoryPage([commit]).rows[0]!; + +describe("static history projection", () => { + test("renders portable graph palettes", () => { + expect(renderHistoryGraph(row, false)).toBe("●─┬"); + expect(renderHistoryGraph(row, true)).toBe("*-+"); + + const octopus = planHistoryPage([{ ...commit, parentRevisionIds: ["b", "c", "d"] }]).rows[0]!; + expect(renderHistoryGraph(octopus, false)).toBe("●─┬─┬"); + expect(renderHistoryGraph(octopus, true)).toBe("*-+-+"); + }); + + test("sanitizes metadata and fits narrow output by display cells", () => { + const text = projectHistoryRow(row, { ascii: false, color: false, width: 40 }); + expect(text).not.toContain("\x1b"); + expect(text).not.toContain("\n"); + expect(text).toContain("aaaaaaaa"); + }); + + test("honors actual sub-20-column terminal widths", () => { + for (const width of [1, 8, 19]) { + const lines = projectHistoryRecord(row, { ascii: false, color: false, width }); + expect(lines.every((line) => measureTextWidth(line) <= width)).toBe(true); + } + }); + + test("keeps complete logical rows when width is omitted", () => { + const text = projectHistoryRow(row, { ascii: true, color: false }); + expect(text).toContain("Improve 日本語 rendering"); + expect(text).toContain("AdaLovelace"); + expect(text).toContain("2026-01-02"); + }); + + test("renders Git-like medium records with full metadata, bodies, and typed decorations", () => { + const lines = projectHistoryRecord(row, { ascii: false, color: false }); + expect(lines.join("\n")).toContain(`commit ${"a".repeat(40)}`); + expect(lines.join("\n")).toContain("Author: AdaLovelace"); + expect(lines.join("\n")).toContain("Date: 2026-01-02 03:04:05Z"); + expect(lines.join("\n")).toContain("First paragraph."); + expect(formatHistoryDecorations(row)).toBe(" (HEAD -> main, origin/main, tag: v1.0.0)"); + }); + + test("renders explicit convergence transitions", () => { + const planned = planHistoryPage([ + { ...commit, revisionId: "merge", parentRevisionIds: ["main", "side"] }, + { ...commit, revisionId: "side", parentRevisionIds: ["base"], decorations: [] }, + { ...commit, revisionId: "main", parentRevisionIds: ["base"], decorations: [] }, + ]); + expect(renderHistoryConvergence(planned.rows[2]!, false)).not.toBe(""); + }); + + test("resolves colors through the shared Hunk theme catalog", () => { + expect(resolveHistoryTheme("catppuccin-mocha").id).toBe("catppuccin-mocha"); + }); + + test("honors explicit color over terminal conventions", () => { + expect( + resolveHistoryColor({ mode: "always", stdoutIsTTY: false, env: { NO_COLOR: "1" } }), + ).toBe(true); + expect(resolveHistoryColor({ mode: "auto", stdoutIsTTY: true, env: { NO_COLOR: "1" } })).toBe( + false, + ); + expect(resolveHistoryColor({ mode: "auto", stdoutIsTTY: true, env: { TERM: "dumb" } })).toBe( + false, + ); + }); +}); diff --git a/src/ui/history/staticProjection.ts b/src/ui/history/staticProjection.ts new file mode 100644 index 000000000..fb08f4ae7 --- /dev/null +++ b/src/ui/history/staticProjection.ts @@ -0,0 +1,244 @@ +import type { HistoryGraphRow } from "../../core/history/types"; +import type { NamedCustomThemeConfig } from "../../extension-api/types"; +import { sanitizeTerminalLine, sanitizeTerminalText } from "../../lib/terminalText"; +import { fitText, measureTextWidth } from "../lib/text"; +import { resolveTheme, type AppTheme } from "../themes"; + +export interface HistoryProjectionOptions { + ascii: boolean; + color: boolean; + theme?: AppTheme; + /** Omit width to emit complete unpadded logical rows for pipes and files. */ + width?: number; +} + +/** Resolve history colors from the same built-in and custom themes as review. */ +export function resolveHistoryTheme( + themeId: string | undefined, + customThemes: readonly NamedCustomThemeConfig[] = [], +) { + return resolveTheme(themeId, null, customThemes); +} + +/** Convert a validated #rrggbb theme color to a terminal SGR foreground. */ +export function foreground(color: string) { + const [red, green, blue] = [1, 3, 5].map((offset) => + Number.parseInt(color.slice(offset, offset + 2), 16), + ); + return `\x1b[38;2;${red};${green};${blue}m`; +} + +/** Convert a validated #rrggbb theme color to a terminal SGR background. */ +export function background(color: string) { + const [red, green, blue] = [1, 3, 5].map((offset) => + Number.parseInt(color.slice(offset, offset + 2), 16), + ); + return `\x1b[48;2;${red};${green};${blue}m`; +} + +/** Render one symbolic lane prefix without consulting commit metadata or refs. */ +export function renderHistoryGraph(row: HistoryGraphRow, ascii: boolean) { + const vertical = ascii ? "|" : "│"; + const node = ascii ? "*" : "●"; + if (row.parentLanes.length <= 1) { + return row.cells + .map((cell) => (cell.kind === "node" ? node : cell.kind === "vertical" ? vertical : " ")) + .join(" ") + .trimEnd(); + } + + const laneCount = Math.max(row.lanesBefore.length, row.lanesAfter.length); + const characters = Array.from({ length: Math.max(1, laneCount * 2 - 1) }, () => " "); + for (let lane = 0; lane < row.lanesAfter.length; lane += 1) characters[lane * 2] = vertical; + characters[row.lane * 2] = node; + for (const parentLane of row.parentLanes.slice(1)) { + const from = Math.min(row.lane * 2, parentLane * 2); + const to = Math.max(row.lane * 2, parentLane * 2); + for (let index = from + 1; index < to; index += 1) { + if (index % 2 === 1) characters[index] = ascii ? "-" : "─"; + else if (characters[index] === vertical) characters[index] = ascii ? "+" : "┼"; + } + characters[parentLane * 2] = ascii ? "+" : parentLane > row.lane ? "┬" : "┴"; + } + return characters.join("").trimEnd(); +} + +/** Render active lanes after a commit for its metadata and message continuation lines. */ +export function renderHistoryContinuation(row: HistoryGraphRow, ascii: boolean) { + const vertical = ascii ? "|" : "│"; + return row.lanesAfter + .map(() => vertical) + .join(" ") + .trimEnd(); +} + +/** Render a lane-collapse transition that makes converging ancestry explicit. */ +export function renderHistoryConvergence(row: HistoryGraphRow, ascii: boolean) { + if (row.convergences.length === 0) return ""; + const width = Math.max(row.lanesBefore.length, row.lanesAfter.length) * 2 - 1; + const chars = Array.from({ length: Math.max(1, width) }, () => " "); + for (let lane = 0; lane < row.lanesAfter.length; lane += 1) chars[lane * 2] = ascii ? "|" : "│"; + for (const { from, to } of row.convergences) { + const start = Math.min(from, to) * 2 + 1; + const end = Math.max(from, to) * 2 - 1; + for (let index = start; index <= end; index += 1) { + chars[index] = from > to ? (ascii ? "/" : "╯") : ascii ? "\\" : "╰"; + } + } + return chars.join("").trimEnd(); +} + +/** Format typed refs in familiar Git decoration vocabulary without inferring topology. */ +export function formatHistoryDecorations(row: HistoryGraphRow) { + const values = row.commit.decorations + .map((entry) => ({ + kind: entry.kind, + label: sanitizeTerminalLine(entry.label).replaceAll("\t", " "), + })) + .filter((entry) => entry.label); + const headIndex = values.findIndex((entry) => entry.kind === "head"); + const headLabel = headIndex >= 0 ? values[headIndex]!.label : ""; + const attachedBranch = headLabel.startsWith("HEAD -> ") ? headLabel.slice("HEAD -> ".length) : ""; + const branchIndex = attachedBranch + ? values.findIndex((entry) => entry.kind === "local-branch" && entry.label === attachedBranch) + : -1; + const labels: string[] = []; + if (headIndex >= 0) labels.push(headLabel); + for (let index = 0; index < values.length; index += 1) { + if (index === headIndex || (headIndex >= 0 && index === branchIndex)) continue; + const entry = values[index]!; + labels.push(entry.kind === "tag" ? `tag: ${entry.label}` : entry.label); + } + return labels.length ? ` (${labels.join(", ")})` : ""; +} + +/** Clamp a logical terminal line before applying styling. */ +function clampLine(text: string, width: number | undefined) { + return width === undefined ? text : fitText(text, Math.max(1, width), "…"); +} + +/** Apply a semantic theme color only when styling is enabled. */ +function styled(text: string, color: string, enabled: boolean) { + return enabled ? `${foreground(color)}${text}\x1b[0m` : text; +} + +/** Color graph cells with a stable lane-index palette derived from the active Hunk theme. */ +function styledGraph(text: string, theme: AppTheme) { + const palette = [ + theme.accent, + theme.addedSignColor, + theme.removedSignColor, + theme.fileRenamed, + theme.noteBorder, + ]; + return Array.from(text, (character, index) => + character === " " + ? character + : styled(character, palette[Math.floor(index / 2) % palette.length]!, true), + ).join(""); +} + +/** Render one safe compact history row from symbolic topology and normalized metadata. */ +export function projectHistoryRow(row: HistoryGraphRow, options: HistoryProjectionOptions) { + const theme = options.theme ?? resolveHistoryTheme(undefined); + const graph = renderHistoryGraph(row, options.ascii); + const displayId = sanitizeTerminalLine(row.commit.displayId).replaceAll("\t", " "); + const subject = sanitizeTerminalLine(row.commit.subject).replaceAll("\t", " "); + const refs = formatHistoryDecorations(row); + const author = sanitizeTerminalLine(row.commit.authorName).replaceAll("\t", " "); + const date = row.commit.authoredAt.slice(0, 10); + const graphPrefix = `${graph} `; + const hashPrefix = `${displayId} `; + let suffix = `${subject}${refs} ${author} ${date}`; + if (options.width !== undefined) { + const available = Math.max( + 1, + options.width - measureTextWidth(graphPrefix) - measureTextWidth(hashPrefix), + ); + const candidates = [ + `${subject}${refs} ${author} ${date}`, + `${subject}${refs} ${date}`, + `${subject}${refs}`, + subject, + ]; + suffix = + candidates.find((candidate) => measureTextWidth(candidate) <= available) ?? + fitText(subject, available, "…"); + } + const plain = clampLine(`${graphPrefix}${hashPrefix}${suffix}`, options.width); + if (!options.color) return plain; + const graphText = plain.slice(0, graphPrefix.length); + const hashText = plain.slice(graphPrefix.length, graphPrefix.length + hashPrefix.length); + return `${styledGraph(graphText, theme)}${styled(hashText, theme.accent, true)}${plain.slice(graphPrefix.length + hashPrefix.length)}`; +} + +/** Render a themed standalone lane-collapse line for compact static output. */ +export function projectHistoryConvergence(row: HistoryGraphRow, options: HistoryProjectionOptions) { + const plain = clampLine(renderHistoryConvergence(row, options.ascii), options.width); + if (!plain || !options.color) return plain; + const theme = options.theme ?? resolveHistoryTheme(undefined); + return styledGraph(plain, theme); +} + +/** Render a complete Git-like medium record, including body and graph continuation. */ +export function projectHistoryRecord(row: HistoryGraphRow, options: HistoryProjectionOptions) { + const theme = options.theme ?? resolveHistoryTheme(undefined); + const graph = renderHistoryGraph(row, options.ascii); + const continuation = renderHistoryContinuation(row, options.ascii); + const fullId = sanitizeTerminalLine(row.commit.revisionId); + const refs = formatHistoryDecorations(row); + const authorName = sanitizeTerminalLine(row.commit.authorName).replaceAll("\t", " "); + const authorEmail = row.commit.authorEmail + ? ` <${sanitizeTerminalLine(row.commit.authorEmail).replaceAll("\t", " ")}>` + : ""; + const date = sanitizeTerminalLine(row.commit.authoredAt).replace("T", " "); + const subject = sanitizeTerminalLine(row.commit.subject).replaceAll("\t", " "); + const body = row.commit.body + ? sanitizeTerminalText(row.commit.body, { preserveNewlines: true, preserveTabs: false }).split( + "\n", + ) + : []; + while (body.at(-1) === "") body.pop(); + const prefix = (laneText: string) => (laneText ? `${laneText} ` : " "); + const plainLines = [ + `${prefix(graph)}commit ${fullId}${refs}`, + `${prefix(continuation)}Author: ${authorName}${authorEmail}`, + `${prefix(continuation)}Date: ${date}`, + prefix(continuation).trimEnd(), + `${prefix(continuation)} ${subject}`, + ...body.map((line) => `${prefix(continuation)} ${line}`), + ]; + const convergence = renderHistoryConvergence(row, options.ascii); + if (convergence) plainLines.push(convergence); + plainLines.push(""); + const fitted = plainLines.map((line) => clampLine(line, options.width)); + if (!options.color) return fitted; + return fitted.map((line, index) => { + if (!line) return line; + const laneWidth = index === 0 ? prefix(graph).length : prefix(continuation).length; + const lane = line.slice(0, laneWidth); + const rest = line.slice(laneWidth); + if (index === 0 && rest.startsWith("commit ")) { + const hashStart = "commit ".length; + const hashEnd = Math.min(rest.length, hashStart + fullId.length); + return `${styledGraph(lane, theme)}${rest.slice(0, hashStart)}${styled(rest.slice(hashStart, hashEnd), theme.accent, true)}${styled(rest.slice(hashEnd), theme.addedSignColor, true)}`; + } + const color = index >= 4 ? theme.text : theme.muted; + return `${styledGraph(lane, theme)}${styled(rest, color, true)}`; + }); +} + +/** Resolve color according to explicit CLI precedence and conventional terminal signals. */ +export function resolveHistoryColor({ + mode, + stdoutIsTTY, + env, +}: { + mode: "auto" | "always" | "never"; + stdoutIsTTY: boolean; + env: NodeJS.ProcessEnv; +}) { + if (mode === "always") return true; + if (mode === "never") return false; + return stdoutIsTTY && env.TERM !== "dumb" && !env.NO_COLOR; +} diff --git a/src/ui/history/terminalInput.test.ts b/src/ui/history/terminalInput.test.ts new file mode 100644 index 000000000..e71acded4 --- /dev/null +++ b/src/ui/history/terminalInput.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "bun:test"; +import { TerminalInputTokenizer } from "./terminalInput"; + +describe("TerminalInputTokenizer", () => { + test("queues multiple navigation and action keys from one chunk", () => { + const tokenizer = new TerminalInputTokenizer(); + + expect(tokenizer.push("\x1b[B\r")).toEqual(["\x1b[B", "\r"]); + }); + + test("retains split CSI and mouse sequences until they are complete", () => { + const tokenizer = new TerminalInputTokenizer(); + + expect(tokenizer.push("\x1b[")).toEqual([]); + expect(tokenizer.push("B/fi")).toEqual(["\x1b[B", "/", "f", "i"]); + expect(tokenizer.push("\x1b[<0;12;")).toEqual([]); + expect(tokenizer.push("4Mq")).toEqual(["\x1b[<0;12;4M", "q"]); + }); + + test("preserves UTF-8 characters split across byte chunks", () => { + const tokenizer = new TerminalInputTokenizer(); + const bytes = Buffer.from("猫"); + + expect(tokenizer.push(bytes.subarray(0, 2))).toEqual([]); + expect(tokenizer.push(bytes.subarray(2))).toEqual(["猫"]); + }); + + test("flushes a standalone escape without consuming the next action", () => { + const tokenizer = new TerminalInputTokenizer(); + + expect(tokenizer.push("\x1b")).toEqual([]); + expect(tokenizer.hasStandaloneEscape()).toBe(true); + expect(tokenizer.flushStandaloneEscape()).toEqual(["\x1b"]); + expect(tokenizer.push("q")).toEqual(["q"]); + }); +}); diff --git a/src/ui/history/terminalInput.ts b/src/ui/history/terminalInput.ts new file mode 100644 index 000000000..f51afe1de --- /dev/null +++ b/src/ui/history/terminalInput.ts @@ -0,0 +1,167 @@ +import { StringDecoder } from "node:string_decoder"; + +const ESCAPE = "\x1b"; + +/** Splits raw terminal bytes into complete key and mouse tokens across arbitrary chunks. */ +export class TerminalInputTokenizer { + private readonly decoder = new StringDecoder("utf8"); + private buffered = ""; + + /** Add one raw input chunk and return every complete token now available. */ + push(chunk: Buffer | string) { + this.buffered += typeof chunk === "string" ? chunk : this.decoder.write(chunk); + return this.takeCompleteTokens(); + } + + /** Return whether a lone Escape is waiting for a possible sequence suffix. */ + hasStandaloneEscape() { + return this.buffered === ESCAPE; + } + + /** Resolve a lone buffered Escape after the terminal's sequence grace period. */ + flushStandaloneEscape() { + if (!this.hasStandaloneEscape()) return []; + this.buffered = ""; + return [ESCAPE]; + } + + /** Flush decoder state and expose any remaining input when the stream closes. */ + finish() { + this.buffered += this.decoder.end(); + const tokens = this.takeCompleteTokens(); + if (this.buffered) { + tokens.push(...Array.from(this.buffered)); + this.buffered = ""; + } + return tokens; + } + + /** Consume complete characters, CSI sequences, and SS3 sequences from the buffer. */ + private takeCompleteTokens() { + const tokens: string[] = []; + while (this.buffered) { + if (!this.buffered.startsWith(ESCAPE)) { + const token = String.fromCodePoint(this.buffered.codePointAt(0)!); + tokens.push(token); + this.buffered = this.buffered.slice(token.length); + continue; + } + + if (this.buffered.length === 1) break; + const prefix = this.buffered[1]; + if (prefix === "[") { + let finalIndex = -1; + for (let index = 2; index < this.buffered.length; index += 1) { + const code = this.buffered.charCodeAt(index); + if (code >= 0x40 && code <= 0x7e) { + finalIndex = index; + break; + } + } + if (finalIndex < 0) break; + tokens.push(this.buffered.slice(0, finalIndex + 1)); + this.buffered = this.buffered.slice(finalIndex + 1); + continue; + } + + if (prefix === "O") { + if (this.buffered.length < 3) break; + tokens.push(this.buffered.slice(0, 3)); + this.buffered = this.buffered.slice(3); + continue; + } + + // Hunk has no Alt-key bindings here, so preserve Escape as its own action. + tokens.push(ESCAPE); + this.buffered = this.buffered.slice(1); + } + return tokens; + } +} + +/** Queues tokenized terminal input while allowing terminal ownership to pause for child review. */ +export class TerminalInputReader { + private readonly tokenizer = new TerminalInputTokenizer(); + private readonly queued: string[] = []; + private readonly waiting: Array<{ + resolve: (token: string) => void; + reject: (error: Error) => void; + }> = []; + private escapeTimer: ReturnType | undefined; + private endedError: Error | undefined; + + constructor(private readonly stream: NodeJS.ReadStream) { + stream.on("data", this.onData); + stream.on("end", this.onEnd); + stream.on("error", this.onError); + } + + /** Resume delivery from the caller-owned terminal stream. */ + resume() { + this.stream.resume(); + } + + /** Pause delivery while another process owns the terminal. */ + pause() { + this.clearEscapeTimer(); + this.stream.pause(); + } + + /** Return the next complete terminal token, retaining later tokens in order. */ + next() { + const token = this.queued.shift(); + if (token !== undefined) return Promise.resolve(token); + if (this.endedError) return Promise.reject(this.endedError); + return new Promise((resolve, reject) => this.waiting.push({ resolve, reject })); + } + + /** Drop typeahead before transferring terminal ownership to a child process. */ + discardPending() { + this.queued.length = 0; + } + + /** Detach listeners and reject any pending read. */ + close(error = new Error("Terminal input closed.")) { + this.finish(error, false); + } + + private readonly onData = (chunk: Buffer | string) => { + this.clearEscapeTimer(); + this.enqueue(this.tokenizer.push(chunk)); + if (this.tokenizer.hasStandaloneEscape()) { + this.escapeTimer = setTimeout(() => { + this.escapeTimer = undefined; + this.enqueue(this.tokenizer.flushStandaloneEscape()); + }, 25); + this.escapeTimer.unref?.(); + } + }; + + private readonly onEnd = () => this.finish(new Error("Terminal input closed."), true); + private readonly onError = (error: Error) => this.finish(error, true); + + private enqueue(tokens: string[]) { + for (const token of tokens) { + const waiter = this.waiting.shift(); + if (waiter) waiter.resolve(token); + else this.queued.push(token); + } + } + + private finish(error: Error, flush: boolean) { + if (this.endedError) return; + this.clearEscapeTimer(); + if (flush) this.enqueue(this.tokenizer.finish()); + this.endedError = error; + this.stream.off("data", this.onData); + this.stream.off("end", this.onEnd); + this.stream.off("error", this.onError); + this.stream.pause(); + for (const waiter of this.waiting.splice(0)) waiter.reject(error); + } + + private clearEscapeTimer() { + if (this.escapeTimer) clearTimeout(this.escapeTimer); + this.escapeTimer = undefined; + } +} diff --git a/src/ui/history/types.ts b/src/ui/history/types.ts new file mode 100644 index 000000000..b8649006c --- /dev/null +++ b/src/ui/history/types.ts @@ -0,0 +1,15 @@ +import type { HistoryCommandInput } from "../../core/run/commandInputs"; +import type { VcsHistorySource } from "../../core/vcs/types"; +import type { NamedCustomThemeConfig } from "../../extension-api/types"; + +/** Renderer-facing history resources, excluding app and extension ownership details. */ +export interface HistoryRuntime { + input: HistoryCommandInput; + source: VcsHistorySource; + providerId: string; + providerName: string; + repoRoot: string; + notices: readonly string[]; + customThemes: readonly NamedCustomThemeConfig[]; + close(): Promise; +} diff --git a/test/cli/log.test.ts b/test/cli/log.test.ts new file mode 100644 index 000000000..4cff76812 --- /dev/null +++ b/test/cli/log.test.ts @@ -0,0 +1,151 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +const tempDirs: string[] = []; +const mainPath = resolve(import.meta.dir, "../../src/main.tsx"); + +/** Run a command and fail the fixture immediately when it cannot complete. */ +function run(argv: string[], cwd: string, env: NodeJS.ProcessEnv = process.env) { + const proc = Bun.spawnSync(argv, { + cwd, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + env, + }); + return { + code: proc.exitCode, + stdout: proc.stdout?.toString() ?? "", + stderr: proc.stderr?.toString() ?? "", + }; +} + +/** Create a two-commit repository with deterministic author and dates. */ +function createRepo() { + const cwd = mkdtempSync(join(tmpdir(), "hunk-log-test-")); + tempDirs.push(cwd); + expect(run(["git", "init", "-q"], cwd).code).toBe(0); + const env = { + ...process.env, + GIT_AUTHOR_NAME: "Ada Lovelace", + GIT_AUTHOR_EMAIL: "ada@example.com", + GIT_COMMITTER_NAME: "Ada Lovelace", + GIT_COMMITTER_EMAIL: "ada@example.com", + }; + writeFileSync(join(cwd, "history.txt"), "one\n"); + expect(run(["git", "add", "history.txt"], cwd, env).code).toBe(0); + expect( + run(["git", "commit", "-q", "-m", "First commit"], cwd, { + ...env, + GIT_AUTHOR_DATE: "2026-01-01T00:00:00Z", + GIT_COMMITTER_DATE: "2026-01-01T00:00:00Z", + }).code, + ).toBe(0); + writeFileSync(join(cwd, "history.txt"), "two\n"); + const secondEnv = { + ...env, + GIT_AUTHOR_DATE: "2026-01-02T00:00:00Z", + GIT_COMMITTER_DATE: "2026-01-02T00:00:00Z", + }; + expect( + run(["git", "commit", "-qa", "-m", "Second commit", "-m", "A detailed body."], cwd, secondEnv) + .code, + ).toBe(0); + expect(run(["git", "tag", "v1.0.0"], cwd, secondEnv).code).toBe(0); + expect(run(["git", "tag", "-a", "v1.0.0-annotated", "-m", "release"], cwd, secondEnv).code).toBe( + 0, + ); + return cwd; +} + +afterEach(() => { + for (const path of tempDirs.splice(0)) rmSync(path, { recursive: true, force: true }); +}); + +describe("hunk log CLI contract", () => { + test("prints deterministic complete static rows without terminal controls", () => { + const cwd = createRepo(); + const result = run(["bun", "run", mainPath, "log", "--color", "never"], cwd); + + expect(result.code).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toContain("Second commit"); + expect(result.stdout).toContain("First commit"); + expect(result.stdout).toContain("A detailed body."); + expect(result.stdout).toContain("Author: Ada Lovelace "); + expect(result.stdout).toContain("Date: 2026-01-02 00:00:00Z"); + expect(result.stdout).toMatch(/commit [0-9a-f]{40} \(HEAD -> /); + expect(result.stdout).toContain("tag: v1.0.0"); + expect(result.stdout).toContain("tag: v1.0.0-annotated"); + expect(result.stdout).not.toContain("\x1b"); + }); + + test("supports explicit compact output and shared themes", () => { + const cwd = createRepo(); + const result = run( + [ + "bun", + "run", + mainPath, + "log", + "--oneline", + "--theme", + "catppuccin-mocha", + "--color", + "always", + "-n", + "1", + ], + cwd, + ); + expect(result.code).toBe(0); + expect(result.stdout).toContain("Second commit"); + expect(result.stdout).not.toContain("Author:"); + expect(result.stdout).toContain("\x1b[38;2;"); + }); + + test("honors max count, pathspecs, ASCII, and explicit color", () => { + const cwd = createRepo(); + const result = run( + [ + "bun", + "run", + mainPath, + "log", + "-n", + "1", + "--ascii", + "--color", + "always", + "--", + "history.txt", + ], + cwd, + ); + + expect(result.code).toBe(0); + const plain = result.stdout.replace(/\x1b\[[0-9;]*m/g, ""); + expect(plain).toContain("* commit"); + expect(result.stdout).toContain("Second commit"); + expect(result.stdout).not.toContain("First commit"); + expect(result.stdout).toContain("\x1b["); + }); + + test("is silent for max count zero and reports non-repositories cleanly", () => { + const cwd = createRepo(); + expect(run(["bun", "run", mainPath, "log", "-n", "0"], cwd)).toMatchObject({ + code: 0, + stdout: "", + stderr: "", + }); + + const outside = mkdtempSync(join(tmpdir(), "hunk-log-outside-")); + tempDirs.push(outside); + const failed = run(["bun", "run", mainPath, "log"], outside); + expect(failed.code).toBe(1); + expect(failed.stderr).toContain("not a git repository"); + expect(failed.stdout).not.toContain("\x1b"); + }); +}); diff --git a/test/pty/log-integration.test.ts b/test/pty/log-integration.test.ts new file mode 100644 index 000000000..e854cc754 --- /dev/null +++ b/test/pty/log-integration.test.ts @@ -0,0 +1,95 @@ +import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createPtyHarness } from "./harness"; + +const harness = createPtyHarness(); +const tempDirs: string[] = []; +setDefaultTimeout(45_000); + +/** Run one Git fixture command with deterministic author identity. */ +function git(cwd: string, args: string[]) { + const proc = Bun.spawnSync(["git", ...args], { + cwd, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + env: { + ...process.env, + GIT_AUTHOR_NAME: "History Tester", + GIT_AUTHOR_EMAIL: "history@example.com", + GIT_COMMITTER_NAME: "History Tester", + GIT_COMMITTER_EMAIL: "history@example.com", + }, + }); + if (proc.exitCode !== 0) throw new Error(proc.stderr?.toString() ?? "Git fixture failed."); +} + +/** Create two commits whose selected diff is visible in ordinary Hunk review. */ +function createHistoryRepo() { + const cwd = mkdtempSync(join(tmpdir(), "hunk-log-pty-")); + tempDirs.push(cwd); + git(cwd, ["init", "-q"]); + writeFileSync(join(cwd, "history.ts"), "export const historyValue = 'first';\n"); + git(cwd, ["add", "history.ts"]); + git(cwd, ["commit", "-qm", "First history commit"]); + writeFileSync(join(cwd, "history.ts"), "export const historyValue = 'second';\n"); + git(cwd, ["commit", "-qam", "Second history commit"]); + return cwd; +} + +afterEach(() => { + harness.cleanup(); + for (const path of tempDirs.splice(0)) rmSync(path, { recursive: true, force: true }); +}); + +describe("interactive hunk log", () => { + test("opens the selected immutable commit and returns to the retained history", async () => { + const cwd = createHistoryRepo(); + const session = await harness.launchHunk({ + args: ["log", "--interactive", "--color", "never", "--no-extensions"], + cwd, + cols: 100, + rows: 20, + }); + + try { + const history = await session.waitForText(/Second history commit/, { + timeout: 15_000, + }); + expect(history).toContain("First history commit"); + expect(history).toContain("enter open"); + + await session.press("enter"); + const review = await session.waitForText(/historyValue = 'second'/, { + timeout: 15_000, + }); + expect(review).toContain("history.ts"); + + await session.press("q"); + const returned = await session.waitForText(/Second history commit/, { + timeout: 15_000, + }); + expect(returned).toContain("enter open"); + + // Terminals may coalesce rapid navigation and activation into one stdin chunk. + session.writeRaw("\x1b[B\r"); + const rootReview = await session.waitForText(/historyValue = 'first'/, { + timeout: 15_000, + }); + expect(rootReview).toContain("history.ts"); + await session.press("q"); + await session.waitForText(/First history commit/, { timeout: 15_000 }); + + // Opening again without moving proves return restored the immutable-id selection. + await session.press("enter"); + await session.waitForText(/historyValue = 'first'/, { timeout: 15_000 }); + await session.press("q"); + await session.waitForText(/First history commit/, { timeout: 15_000 }); + await session.press("q"); + } finally { + session.close(); + } + }); +}); diff --git a/website/astro.config.mjs b/website/astro.config.mjs index fde7a894f..d91422878 100644 --- a/website/astro.config.mjs +++ b/website/astro.config.mjs @@ -131,6 +131,7 @@ export default defineConfig({ slug: "docs/workflows/working-trees-and-commits", }, { label: "Files and patches", slug: "docs/workflows/files-and-patches" }, + { label: "Git history", slug: "docs/workflows/git-history" }, { label: "Git pager and difftool", slug: "docs/workflows/git-pager-and-difftool" }, { label: "Jujutsu and Sapling", slug: "docs/workflows/jujutsu-and-sapling" }, { label: "Watch mode", slug: "docs/workflows/watch-mode" }, diff --git a/website/src/content/docs/docs/reference/cli.md b/website/src/content/docs/docs/reference/cli.md index ea51eaee5..43d3af1b1 100644 --- a/website/src/content/docs/docs/reference/cli.md +++ b/website/src/content/docs/docs/reference/cli.md @@ -16,32 +16,33 @@ This reference is generated from the command metadata used by Hunk itself. Run ` ## Common review options -| Option | Description | -| --------------------------- | --------------------------------------------------------------- | -| `--mode ` | layout mode: auto, split, stack | -| `--cursor-line