diff --git a/.changeset/pretty-git-history.md b/.changeset/pretty-git-history.md new file mode 100644 index 000000000..49cef1d39 --- /dev/null +++ b/.changeset/pretty-git-history.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": minor +--- + +Add themed, static-first Git and Jujutsu history with compact output and an interactive browser that opens selected commits in Hunk. diff --git a/README.md b/README.md index d327d7a3f..5257ebf5d 100644 --- a/README.md +++ b/README.md @@ -87,11 +87,21 @@ 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 the selected provider's repository history +hunk log --interactive # browse history; Enter opens a commit in Hunk ``` +`hunk log` is a static-first, read-only history surface, not a repository manager. The selected VCS +adapter owns traversal, filtering, refs, and how a history item opens for review; the bundled Git +and Jujutsu adapters both implement that public capability. Default output keeps full commit, +author, date, message, branch/bookmark, 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). +Hunk auto-detects Jujutsu and Sapling checkouts, so `hunk diff [revset]` and `hunk show [revset]` use native revsets inside jj or Sapling workspaces. `hunk log --vcs jj` also reads JJ history directly, including in a non-colocated workspace. To override VCS detection, set `vcs = "git"` or `vcs = "jj"` or `vcs = "sl"` in [config](#config). ### Working with raw files and patches diff --git a/docs/extension-architecture.md b/docs/extension-architecture.md index 9bd50888b..ccf422cd3 100644 --- a/docs/extension-architecture.md +++ b/docs/extension-architecture.md @@ -346,6 +346,15 @@ bootstrap marker and also consults the available catalog; startup performs a second root/config pass when a global, config-path, or CLI adapter recognizes a repository unavailable to the bundled catalog. +`hunk log` follows the same boundary. Core/app and `src/ui/history/` own the built-in command, +validated graph planning, presentation, themes, paging, terminal lifecycle, and child-process +orchestration. The selected adapter's public `history` capability owns traversal, filtering, +immutable revision and parent identities, structured decorations, and the declarative review action +for a selected item. The host treats those ids as opaque and never constructs provider revision +syntax or decides root/merge comparison semantics. History pages remain child-before-parent across +the full cursor, including page boundaries; the extension conversion boundary validates that +ordering before core or UI consumes it. + ## Public contract rules The authoring surface is the `hunkdiff/extension` export — a façade over diff --git a/docs/extensions.md b/docs/extensions.md index 179eb1dbc..d4844dd62 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -280,10 +280,11 @@ new instances and run that shutdown/startup pair around the replacement. ### `hunk.apiVersion` -The API generation this Hunk speaks (currently `18`). Branch on it if you want -one file to support several Hunk versions. Version 18 lets lifecycle and custom-event handlers -request a host-owned review reload; version 17 adds structured review metadata to delegated -patch commands and projects it into pane availability and component props; version 16 adds pane-wide +The API generation this Hunk speaks (currently `19`). Branch on it if you want +one file to support several Hunk versions. Version 19 adds provider-owned history +enumeration and review planning; version 18 lets lifecycle and custom-event handlers request +a host-owned review reload; version 17 adds structured review metadata to delegated patch +commands and projects it into pane availability and component props; version 16 adds pane-wide `onActivate`; version 15 added `{ side, line }` to opted-in pane `currentLine` paint; version 14 added structured `rangeEndpoints` to two-revision VCS diff requests; version 13 added saved-note parent identities and @@ -500,6 +501,67 @@ 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 adds the optional, read-only `history` capability used by the built-in `hunk log` surface: + +```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() {}, + }; + }, + planReview(commit) { + return commit.parentRevisionIds[0] + ? { + kind: "revision-range", + fromRevisionId: commit.parentRevisionIds[0], + toRevisionId: commit.revisionId, + } + : { kind: "revision-show", revisionId: commit.revisionId }; + }, + }, +}); +``` + +The snippet above demonstrates static history production only; it is not a complete interactive +adapter. Add a `revision-show` operation for `revision-show` actions and a `working-tree-diff` +operation that accepts `rangeEndpoints` for `revision-range` actions before advertising interactive +opening. Otherwise Enter reports that the corresponding review operation is unsupported. + +History is deliberately separate from patch-producing `operations`. The built-in host owns command +routing, graph planning, themes, terminal lifecycle, and static/interactive presentation. The +adapter owns every repository semantic: traversal and filtering, immutable identities, refs, and +`planReview`'s decision about how roots and merges open through that adapter's ordinary review +operations. Hunk treats revision ids as opaque strings and never invents provider revision syntax. + +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. The +optional `logicalId` identifies the same logical change across provider rewrites (for example, a +Jujutsu change id); Hunk treats it as metadata and continues to key graph and review operations by +immutable `revisionId`. A `head` decoration carries an optional `attachedLocalBranch`; use that field +rather than embedding an arrow or branch identity in its display label. + +Every source must emit commits in **child-before-parent topological order**. If both a child and one +of its parents are included, the child appears first. This invariant spans the source's complete +lifetime: page boundaries do not reset it, and a parent returned on one page cannot be followed by +its child on a later page. 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 and parent-before-child output across +pages, forwards cancellation, and closes the source at EOF or failure. + +The bundled Git and Jujutsu extensions implement this public capability today; Sapling currently +reports it as unsupported. Jujutsu supplies commit/change identities, bookmarks, tags, traversal, +and native merge-review semantics without routing through a colocated Git repository. Third-party +adapters use exactly the same contract. + 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..3d8291273 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -45,6 +45,13 @@ 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. With a mouse, click a commit +id to open it immediately, click elsewhere on a row to select it, or double-click a row to open it. +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/scripts/generate-docs.test.ts b/scripts/generate-docs.test.ts index 11fb0d7d2..a94d27f3b 100644 --- a/scripts/generate-docs.test.ts +++ b/scripts/generate-docs.test.ts @@ -36,6 +36,8 @@ describe("generated website references", () => { expect(reference).toContain("hunk diff --staged"); expect(reference).toContain("--no-transparent-bg"); expect(reference).toContain("hunk markup render"); + expect(reference).not.toContain("## `hunk log`"); + expect(reference).not.toContain("`--vcs `"); expect(reference).toMatch( new RegExp( `\\| \\x60${SESSION_BROKER_HOST_ENV}\\x60\\s+\\| Bind host; defaults to loopback \\x60${DEFAULT_SESSION_BROKER_HOST}\\x60\\.`, diff --git a/scripts/generate-docs.ts b/scripts/generate-docs.ts index 75fb29470..6b1205827 100644 --- a/scripts/generate-docs.ts +++ b/scripts/generate-docs.ts @@ -50,18 +50,22 @@ function renderOptionTable(options: readonly (CliReferenceOption | AgentCommandO return "This command has no command-specific options."; } - const rows = options.map((option) => { - const referenceOption = option as CliReferenceOption; - const details = [ - option.description, - referenceOption.defaultValue ? `Default: ${referenceOption.defaultValue}.` : undefined, - referenceOption.hidden ? "Compatibility inverse; omitted from `--help`." : undefined, - "required" in option && option.required ? "Required." : undefined, - ] - .filter(Boolean) - .join(" "); - return `| \`${tableCell(option.flag)}\` | ${tableCell(proseSafe(details))} |`; - }); + const rows = options + .filter((option) => (option as CliReferenceOption).publicDocs !== false) + .map((option) => { + const referenceOption = option as CliReferenceOption; + const details = [ + option.description, + referenceOption.defaultValue ? `Default: ${referenceOption.defaultValue}.` : undefined, + referenceOption.hidden + ? (referenceOption.hiddenNote ?? "Hidden from `--help`.") + : undefined, + "required" in option && option.required ? "Required." : undefined, + ] + .filter(Boolean) + .join(" "); + return `| \`${tableCell(option.flag)}\` | ${tableCell(proseSafe(details))} |`; + }); return ["| Option | Description |", "| --- | --- |", ...rows].join("\n"); } @@ -118,8 +122,9 @@ function renderUsage(lines: readonly string[]) { /** Render the deterministic exhaustive CLI reference. */ export function renderCliReference() { - const commandSections = (Object.values(CLI_REFERENCE_COMMANDS) as CliReferenceCommand[]).map( - (command) => { + const commandSections = (Object.values(CLI_REFERENCE_COMMANDS) as CliReferenceCommand[]) + .filter((command) => command.publicDocs !== false) + .map((command) => { const pieces = [ `## \`hunk ${command.path}\``, "", @@ -164,8 +169,7 @@ export function renderCliReference() { ); } return pieces.join("\n"); - }, - ); + }); const sessionSections = SESSION_AGENT_COMMAND_LIST.map((command) => { const pieces = [ diff --git a/skills/hunk-extensions/SKILL.md b/skills/hunk-extensions/SKILL.md index f4c8ae713..363a36ec7 100644 --- a/skills/hunk-extensions/SKILL.md +++ b/skills/hunk-extensions/SKILL.md @@ -111,7 +111,7 @@ bad or duplicate id is skipped with a startup notice. | Reload after an external agent changes reviewed inputs | `ctx.review.requestReload()` in an event | | Read user-supplied settings | `hunk.config` (`[extension.]` table) | | Snapshot stable files and every saved review note | `ctx.review.snapshot()` in a command | -| Branch on the API generation (currently `18`) | `hunk.apiVersion` | +| Branch on the API generation (currently `17`) | `hunk.apiVersion` | Registration is only valid while the factory runs — Hunk seals the API object afterwards. diff --git a/src/app/cli.test.ts b/src/app/cli.test.ts index dd9333691..3c27a99ed 100644 --- a/src/app/cli.test.ts +++ b/src/app/cli.test.ts @@ -58,6 +58,9 @@ 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("attractive repository history"); + expect(parsed.text).not.toContain("Git commit history"); expect(parsed.text).toContain("hunk skill path"); expect(parsed.text).toContain("Global options:"); expect(parsed.text).toContain("Common review options:"); @@ -488,6 +491,105 @@ 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("preserves opaque provider-planned history reviews through the private handoff", async () => { + const encoded = (value: unknown) => + Buffer.from(JSON.stringify(value), "utf8").toString("base64url"); + expect( + await parseCli([ + "bun", + "hunk", + "diff", + "--history-review", + encoded({ + kind: "revision-range", + fromRevisionId: "-opaque:parent/α", + toRevisionId: "opaque:commit/β", + }), + "--vcs", + "-custom", + ]), + ).toMatchObject({ + kind: "vcs", + rangeEndpoints: { from: "-opaque:parent/α", to: "opaque:commit/β" }, + options: { vcs: "-custom" }, + }); + expect( + await parseCli([ + "bun", + "hunk", + "show", + "--history-review", + encoded({ kind: "revision-show", revisionId: "-opaque:root/γ" }), + "--vcs", + "demo", + ]), + ).toMatchObject({ + kind: "show", + ref: "-opaque:root/γ", + 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..53c0d8bb0 100644 --- a/src/app/cli.ts +++ b/src/app/cli.ts @@ -55,6 +55,7 @@ import { import { DEFAULT_FILE_GAP, DEFAULT_HUNK_GAP, parseReviewGap } from "../core/run/reviewGap"; import { DEFAULT_TAB_WIDTH, parseTabWidth } from "../core/run/tabWidth"; import { resolveCliVersion } from "../core/run/version"; +import type { ExtensionVcsHistoryReviewAction } from "../extension-api/types"; /** Structured option metadata shared by Commander registration and generated CLI docs. */ export interface CliReferenceOption { @@ -64,6 +65,7 @@ export interface CliReferenceOption { | "layout" | "cursorLine" | "positiveInt" + | "nonNegativeInt" | "tabWidth" | "fileGap" | "hunkGap" @@ -72,6 +74,10 @@ export interface CliReferenceOption { /** Default applied directly by Commander (as opposed to a config-resolved default). */ readonly commanderDefault?: string; readonly hidden?: boolean; + /** Include this option in released website documentation; defaults to true. */ + readonly publicDocs?: boolean; + /** Additional generated-documentation context for a hidden option. */ + readonly hiddenNote?: string; } /** Structured command metadata used by runtime parsers and generated CLI docs. */ @@ -80,6 +86,8 @@ export interface CliReferenceCommand { readonly summary: string; readonly synopsis: readonly string[]; readonly aliases?: readonly string[]; + /** Include this command in released website documentation; defaults to true. */ + readonly publicDocs?: boolean; /** Additional prose rendered after this command's generated usage block. */ readonly details?: readonly string[]; readonly options?: readonly CliReferenceOption[]; @@ -96,6 +104,12 @@ export const COMMON_REVIEW_OPTIONS = [ parse: "cursorLine", }, { flag: "--theme ", description: "named theme override" }, + { + flag: "--vcs ", + description: "select a VCS provider", + hidden: true, + publicDocs: false, + }, AUXILIARY_AGENT_OPTIONS.agentContext, { flag: "--pager", description: "use pager-style chrome" }, AUXILIARY_AGENT_OPTIONS.experimental, @@ -159,6 +173,7 @@ const DIFF_OPTIONS = [ flag: `--no-${AUXILIARY_AGENT_OPTIONS.excludeUntracked.flag.slice(2)}`, description: "include untracked files in working tree reviews", hidden: true, + hiddenNote: "Compatibility inverse; omitted from `--help`.", }, ] as const satisfies readonly CliReferenceOption[]; @@ -188,6 +203,51 @@ export const CLI_REFERENCE_COMMANDS = { commonReviewOptions: true, watch: true, }, + log: { + path: "log", + // Release preparation enables this when an installable build contains history browsing. + publicDocs: false, + summary: "print an attractive repository history", + synopsis: ["hunk log [revision-expression] [-- ]"], + details: [ + "Static output is the default. Use --interactive for the experimental history browser.", + "The selected VCS provider defines revision, filtering, and review semantics.", + ], + options: [ + { flag: "--all", description: "include history from every provider-visible head" }, + { 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 provider date" }, + { flag: "--until ", description: "show commits older than a provider 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 +439,7 @@ function buildCommonOptions( mode?: LayoutMode; cursorLine?: CursorLine; theme?: string; + vcs?: string; agentContext?: string; pager?: boolean; watch?: boolean; @@ -396,6 +457,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 +497,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 +576,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 repository 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", @@ -758,10 +823,48 @@ function resolveReloadSelector( ); } -/** Parse the overloaded `hunk diff` command. */ +/** Decode a private child-process handoff without interpreting provider revision ids. */ +function decodeHistoryReviewAction(payload: unknown): ExtensionVcsHistoryReviewAction { + if (typeof payload !== "string" || payload.length === 0) { + throw new Error("Invalid history review handoff."); + } + let value: unknown; + try { + value = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")); + } catch { + throw new Error("Invalid history review handoff."); + } + if (!value || typeof value !== "object") throw new Error("Invalid history review handoff."); + const action = value as Record; + if ( + action.kind === "revision-show" && + typeof action.revisionId === "string" && + action.revisionId.length > 0 + ) { + return { kind: "revision-show", revisionId: action.revisionId }; + } + if ( + action.kind === "revision-range" && + typeof action.fromRevisionId === "string" && + action.fromRevisionId.length > 0 && + typeof action.toRevisionId === "string" && + action.toRevisionId.length > 0 + ) { + return { + kind: "revision-range", + fromRevisionId: action.fromRevisionId, + toRevisionId: action.toRevisionId, + }; + } + throw new Error("Invalid history review handoff."); +} + +/** Parse the provider-neutral `hunk diff` command. */ async function parseDiffCommand(tokens: string[], argv: string[]): Promise { const { commandTokens, pathspecs } = splitPathspecArgs(tokens); - const command = createCliReferenceCommand("diff").argument("[targets...]"); + const command = createCliReferenceCommand("diff") + .addOption(new Option("--history-review ").hideHelp()) + .argument("[targets...]"); let parsedTargets: string[] = []; let parsedOptions: Record = {}; @@ -783,6 +886,26 @@ async function parseDiffCommand(tokens: string[], argv: string[]): Promise typeof value === "string") : undefined; + const historyReview = parsedOptions.historyReview; + + if (historyReview !== undefined) { + const action = decodeHistoryReviewAction(historyReview); + if ( + action.kind !== "revision-range" || + files || + parsedTargets.length > 0 || + staged || + normalizedPathspecs + ) { + throw new Error("Invalid history review handoff for `hunk diff`."); + } + return { + kind: "vcs", + rangeEndpoints: { from: action.fromRevisionId, to: action.toRevisionId }, + staged: false, + options, + }; + } if (files) { if (files.length !== 2 || parsedTargets.length > 0 || staged || normalizedPathspecs) { @@ -842,10 +965,12 @@ async function parseDiffCommand(tokens: string[], argv: string[]): Promise { const { commandTokens, pathspecs } = splitPathspecArgs(tokens); - const command = createCliReferenceCommand("show").argument("[ref]"); + const command = createCliReferenceCommand("show") + .addOption(new Option("--history-review ").hideHelp()) + .argument("[ref]"); let parsedRef: string | undefined; let parsedOptions: Record = {}; @@ -861,11 +986,77 @@ async function parseShowCommand(tokens: string[], argv: string[]): Promise 0) { + throw new Error("Invalid history review handoff for `hunk show`."); + } + return { kind: "show", ref: action.revisionId, options }; + } + return { kind: "show", ref: parsedRef, pathspecs: pathspecs.length > 0 ? pathspecs : undefined, - options: buildCommonOptions(parsedOptions, argv), + options, + }; +} + +/** Parse the deliberately small static-first `hunk log` grammar. */ +async function parseHistoryCommand( + tokens: string[], + extensionsEnabled: boolean, +): 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, }; } @@ -963,6 +1154,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 +2158,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 +2273,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 +2288,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..a60d57be0 --- /dev/null +++ b/src/app/historyBootstrap.ts @@ -0,0 +1,157 @@ +import type { HistoryCommandInput } from "../core/run/commandInputs"; +import { collectSessionCustomThemes } from "../core/theme/customThemes"; +import type { + ExtensionVcsHistoryCommit, + ExtensionVcsHistoryReviewAction, + NamedCustomThemeConfig, +} from "../extension-api/types"; +import { sanitizeTerminalLine } from "../lib/terminalText"; +import { + detectVcs, + extendVcsCatalog, + getDefaultVcsAdapter, + getVcsAdapter, + openVcsHistory, + planVcsHistoryReview, +} 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[]; + planReview(commit: ExtensionVcsHistoryCommit): Promise; + 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)}.`, + ] + : []), + ], + planReview(commit) { + return planVcsHistoryReview(adapter, commit, { cwd: repoRoot }); + }, + 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..95ed565d8 --- /dev/null +++ b/src/core/history/lanePlanner.test.ts @@ -0,0 +1,87 @@ +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("draws the side branch converging into an already-active first parent", () => { + const planned = planHistoryPage([ + commit("merge", ["main", "side"]), + commit("side", ["main"]), + commit("main"), + ]); + + expect(planned.rows[1]!.convergences).toEqual([{ from: 1, to: 0 }]); + expect(planned.checkpoint.lanes).toEqual([]); + }); + + test("uses explicit graph parents without changing review parents", () => { + const filtered = { ...commit("match", ["omitted"]), graphParentRevisionIds: [] }; + const olderFiltered = { + ...commit("older-match", ["another-omitted"]), + graphParentRevisionIds: [], + }; + const planned = planHistoryPage([filtered, olderFiltered]); + + expect(planned.rows.map((row) => row.lanesAfter)).toEqual([[], []]); + expect(filtered.parentRevisionIds).toEqual(["omitted"]); + }); + + 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..237b9095e --- /dev/null +++ b/src/core/history/lanePlanner.ts @@ -0,0 +1,100 @@ +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 graph parent ids in the provider's declared order. */ +function orderedUniqueParents(commit: HistoryCommit) { + const seen = new Set(); + return (commit.graphParentRevisionIds ?? 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); + const existingParents = parents.filter((parent) => { + const index = lanesBefore.indexOf(parent); + return index >= 0 && index !== lane; + }); + 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 }] : []; + }); + for (const parent of existingParents) { + const to = lanes.indexOf(parent); + if ( + to >= 0 && + lane !== to && + !convergences.some((edge) => edge.from === lane && edge.to === to) + ) { + convergences.push({ from: lane, 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..ec4d2449e 100644 --- a/src/core/process/pager.test.ts +++ b/src/core/process/pager.test.ts @@ -20,6 +20,7 @@ function createClosingPager(code = 0) { const OSC52_CLIPBOARD = "\x1b]52;c;SGVsbG8=\x07"; const CSI_CLEAR_SCREEN = "\x1b[2J"; const DCS_PAYLOAD = "\x1bPqpayload\x1b\\"; +const DEFAULT_TEXT_PAGER = process.platform === "win32" ? "more" : "less -R"; function expectNoUnsafeTerminalControls(text: string) { expect(text).not.toContain(OSC52_CLIPBOARD); @@ -116,17 +117,17 @@ describe("general pager detection", () => { describe("plain text pager fallback", () => { test("falls back to less when no pager is configured", () => { - expect(resolveTextPagerCommand({})).toBe("less -R"); + expect(resolveTextPagerCommand({})).toBe(DEFAULT_TEXT_PAGER); }); test("prefers HUNK_TEXT_PAGER and avoids recursive hunk launches", () => { expect(resolveTextPagerCommand({ HUNK_TEXT_PAGER: "bat --paging=always" })).toBe( "bat --paging=always", ); - expect(resolveTextPagerCommand({ HUNK_TEXT_PAGER: "hunk pager" })).toBe("less -R"); - expect(resolveTextPagerCommand({ PAGER: "env FOO=1 hunk pager" })).toBe("less -R"); + expect(resolveTextPagerCommand({ HUNK_TEXT_PAGER: "hunk pager" })).toBe(DEFAULT_TEXT_PAGER); + expect(resolveTextPagerCommand({ PAGER: "env FOO=1 hunk pager" })).toBe(DEFAULT_TEXT_PAGER); expect(resolveTextPagerCommand({ PAGER: String.raw`"C:\tools\hunk.exe" pager` })).toBe( - "less -R", + DEFAULT_TEXT_PAGER, ); }); @@ -342,7 +343,7 @@ describe("plain text pager fallback", () => { }); test("supports simple env wrappers while still blocking recursive hunk pagers", async () => { - expect(resolveTextPagerCommand({ PAGER: "env LESS=FRX hunk pager" })).toBe("less -R"); + expect(resolveTextPagerCommand({ PAGER: "env LESS=FRX hunk pager" })).toBe(DEFAULT_TEXT_PAGER); await pagePlainText( "plain text", @@ -358,6 +359,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..a874646e8 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,14 @@ 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 { + /** Return false when the pager closed early and the producer should stop reading. */ + 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 +163,95 @@ 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; + let pagerExited = false; const closeCode = new Promise((resolve) => { pager.once("error", (error) => { spawnError = error; }); pager.once("close", (code) => { + pagerExited = true; 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 || pagerExited || stdinError?.code === "EPIPE") return false; + 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); + }); + } + return !pagerExited && stdinError?.code !== "EPIPE"; + }, + 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..2a1e072c2 100644 --- a/src/core/vcs/index.ts +++ b/src/core/vcs/index.ts @@ -1,6 +1,11 @@ import { relative, resolve } from "node:path"; import { HUNK_DEFAULT_VCS_DETECTION_PRIORITY } from "../../extension-api/types"; import { HunkUserError } from "../run/errors"; +import type { + ExtensionVcsHistoryCommit, + ExtensionVcsHistoryInput, + ExtensionVcsHistoryReviewAction, +} from "../../extension-api/types"; import type { CliInput } from "../run/commandInputs"; import type { VcsAdapter, @@ -8,6 +13,7 @@ import type { VcsDetection, VcsId, VcsLoadContext, + VcsHistorySource, VcsOperation, VcsPatchResult, VcsReviewInput, @@ -152,6 +158,39 @@ 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); +} + +/** Ask the selected provider how one opaque history item should open in review. */ +export async function planVcsHistoryReview( + adapter: VcsAdapter, + commit: ExtensionVcsHistoryCommit, + context: VcsLoadContext, +): Promise { + if (!adapter.history) { + throw new HunkUserError(`\`hunk log\` is not supported by ${adapter.name}.`, [ + "Use a VCS adapter that implements history browsing.", + ]); + } + return await adapter.history.planReview(commit, 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..062676429 100644 --- a/src/core/vcs/types.ts +++ b/src/core/vcs/types.ts @@ -1,4 +1,10 @@ -import type { ExtensionVcsWatchPlan } from "../../extension-api/types"; +import type { + ExtensionVcsHistoryCommit, + ExtensionVcsHistoryInput, + ExtensionVcsHistoryPage, + ExtensionVcsHistoryReviewAction, + ExtensionVcsWatchPlan, +} from "../../extension-api/types"; import type { DiffFile } from "../changeset/model"; import type { VcsDiffCommandInput, @@ -39,6 +45,21 @@ 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 and review-planning capability. */ +export interface VcsHistoryCapability { + open(input: ExtensionVcsHistoryInput, context: VcsLoadContext): Promise; + planReview( + commit: ExtensionVcsHistoryCommit, + context: VcsLoadContext, + ): Promise; +} + /** * One adapter operation's result, after the conversion boundary. * @@ -73,6 +94,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..f2445ad17 100644 --- a/src/extension-api/index.ts +++ b/src/extension-api/index.ts @@ -148,6 +148,13 @@ export type { ExtensionVcsFileSourceResult, ExtensionVcsFileSourceTooLarge, ExtensionVcsFileStats, + ExtensionVcsHistoryCapability, + ExtensionVcsHistoryCommit, + ExtensionVcsHistoryDecoration, + ExtensionVcsHistoryInput, + ExtensionVcsHistoryPage, + ExtensionVcsHistoryReviewAction, + ExtensionVcsHistorySource, ExtensionVcsLoadContext, ExtensionVcsOperation, ExtensionVcsOperations, diff --git a/src/extension-api/types.ts b/src/extension-api/types.ts index 37e84fde8..5184853ae 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,108 @@ export interface ExtensionVcsShowInput { options: ExtensionVcsReviewOptions; } +/** One structured ref decorating a history commit. */ +export type ExtensionVcsHistoryDecoration = + | { + kind: "head"; + /** Display label for detached HEAD; normally `HEAD`. */ + label: string; + /** Local branch HEAD is attached to, without display punctuation. */ + attachedLocalBranch?: string; + } + | { + kind: "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[]; + /** + * Parent ids used only for graph topology when traversal filters omit intermediate commits. + * Omit this field when graph parents are identical to `parentRevisionIds`. + */ + graphParentRevisionIds?: string[]; + subject: string; + /** Commit message content after the subject, preserving paragraph breaks. */ + body?: string; + authorName: string; + authorEmail?: string; + authoredAt: string; + decorations: ExtensionVcsHistoryDecoration[]; + /** + * Optional logical identity that remains stable when the provider rewrites a revision. + * + * A Jujutsu change id is the canonical example. This is metadata only: Hunk + * continues to key graph and review operations by the immutable `revisionId`. + */ + 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 in child-before-parent topological order. + * + * Across every page from one source, a commit must appear before any of its + * parents that the source emits. Page boundaries never reset that invariant. + * `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 the next page while preserving the source-wide topological order. */ + read(options: { limit: number; signal?: AbortSignal }): Promise; + close(): void | Promise; +} + +/** A provider-owned declaration of how Hunk should review one history item. */ +export type ExtensionVcsHistoryReviewAction = + | { + kind: "revision-show"; + revisionId: string; + } + | { + kind: "revision-range"; + fromRevisionId: string; + toRevisionId: string; + }; + +/** Optional read-only history capability implemented independently of review operations. */ +export interface ExtensionVcsHistoryCapability { + open( + input: ExtensionVcsHistoryInput, + context: ExtensionVcsLoadContext, + ): ExtensionVcsHistorySource | Promise; + /** + * Declare how to open one returned commit in Hunk's ordinary review surface. + * + * Providers own root and merge semantics. Revision ids are opaque to the + * host; the returned action is passed to this adapter's review operation. + */ + planReview( + commit: ExtensionVcsHistoryCommit, + context: ExtensionVcsLoadContext, + ): ExtensionVcsHistoryReviewAction | Promise; +} + /** Stash review request, as extension adapters receive it. */ export interface ExtensionVcsStashShowInput { kind: "stash-show"; @@ -972,6 +1074,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..a5826c30e --- /dev/null +++ b/src/extensions/default/vcs/git/history.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, test } from "bun:test"; +import { createGitVcsAdapter } from "./index"; +import { buildGitHistoryArgs, gitHistoryUsesBoundaryTopology, parseGitHistory } from "./history"; + +describe("Git history production", () => { + test("builds the strict supported query with literal pathspec separation", () => { + expect( + buildGitHistoryArgs({ + revision: "main..feature", + all: true, + 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", + "--all", + "--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("marks repeated filtered gaps as graph boundaries without losing review parents", () => { + expect(gitHistoryUsesBoundaryTopology({ author: "Ada" })).toBe(true); + expect(gitHistoryUsesBoundaryTopology({ grep: "fix" })).toBe(true); + expect(gitHistoryUsesBoundaryTopology({})).toBe(false); + + const record = (revision: string, parent: string, subject: string) => + [ + revision.repeat(40), + revision.repeat(8), + parent.repeat(40), + "Ada", + "ada@example.com", + "2026-01-01T00:00:00Z", + subject, + "", + ].join("\0"); + const commits = parseGitHistory( + `${record("a", "d", "match one")}\0${record("b", "e", "match two")}\0${record("c", "f", "match three")}`, + new Map(), + false, + true, + ); + expect(commits.map((commit) => commit.parentRevisionIds)).toEqual([ + ["d".repeat(40)], + ["e".repeat(40)], + ["f".repeat(40)], + ]); + expect(commits.map((commit) => commit.graphParentRevisionIds)).toEqual([[], [], []]); + }); + + 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("owns first-parent merge and root review semantics", async () => { + const history = createGitVcsAdapter().history!; + const root = { + revisionId: "a".repeat(40), + displayId: "aaaaaaaa", + parentRevisionIds: [], + subject: "Root", + authorName: "Ada", + authoredAt: "2026-01-01T00:00:00Z", + decorations: [], + }; + expect(await history.planReview(root)).toEqual({ + kind: "revision-show", + revisionId: root.revisionId, + }); + expect( + await history.planReview({ + ...root, + revisionId: "b".repeat(40), + parentRevisionIds: ["c".repeat(40), "d".repeat(40)], + }), + ).toEqual({ + kind: "revision-range", + fromRevisionId: "c".repeat(40), + toRevisionId: "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..b99c05947 --- /dev/null +++ b/src/extensions/default/vcs/git/history.ts @@ -0,0 +1,381 @@ +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}$/; +const MAX_SYNC_OUTPUT_BYTES = 8 * 1024 * 1024; +const MAX_DECORATION_REFS = 10_000; +const MAX_HISTORY_STDERR_BYTES = 16 * 1024; + +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", + maxBuffer: MAX_SYNC_OUTPUT_BYTES, + 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", + `--count=${MAX_DECORATION_REFS + 1}`, + "--format=%(objectname)%00%(objecttype)%00%(refname)%00%(*objectname)%00", + "refs/heads", + "refs/remotes", + "refs/tags", + ], + options, + ); + const records = raw.split("\n").filter(Boolean); + if (records.length > MAX_DECORATION_REFS) { + throw new HunkExtensionUserError( + `Git history has more than ${MAX_DECORATION_REFS.toLocaleString("en-US")} decorated refs.`, + { suggestions: ["Reduce repository refs before running `hunk log`."] }, + ); + } + for (const record of records) { + 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: "HEAD", + ...(branch ? { attachedLocalBranch: branch } : {}), + }); + } + + 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, + omitGraphParents = 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, + ...(omitGraphParents ? { graphParentRevisionIds: [] } : {}), + subject: subject || "(no commit message)", + ...(body ? { body } : {}), + authorName: authorName || "Unknown author", + ...(authorEmail ? { authorEmail } : {}), + authoredAt, + decorations: [...(decorations.get(revisionId) ?? [])], + }); + } + return commits; +} + +/** Return whether traversal filters can omit direct parents from the emitted commit stream. */ +export function gitHistoryUsesBoundaryTopology(input: ExtensionVcsHistoryInput) { + return Boolean( + input.author !== undefined || + input.grep !== undefined || + input.since !== undefined || + input.until !== undefined || + input.pathspecs?.length, + ); +} + +/** 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 empty = input.maxCount === 0 || (!input.revision && !input.all && !hasHead(queryOptions)); + if (empty) { + return { + repoRoot, + async read() { + return { commits: [], done: true }; + }, + close() {}, + }; + } + + const decorations = readDecorations(queryOptions); + const omitGraphParents = gitHistoryUsesBoundaryTopology(input); + + 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, + omitGraphParents, + ), + ); + 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) => { + if (stderr.length < MAX_HISTORY_STDERR_BYTES) { + stderr += chunk.slice(0, MAX_HISTORY_STDERR_BYTES - stderr.length); + } + }); + 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..c92204ed6 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,21 @@ export function createGitVcsAdapter({ name: "Git", detect: detectGitRepo, detectionPriority: HUNK_VCS_DETECTION_BASELINE_PRIORITY, + history: { + open(input, { cwd }) { + return openGitHistory(input, { cwd, gitExecutable }); + }, + planReview(commit) { + const firstParent = commit.parentRevisionIds[0]; + return firstParent + ? { + kind: "revision-range" as const, + fromRevisionId: firstParent, + toRevisionId: commit.revisionId, + } + : { kind: "revision-show" as const, revisionId: commit.revisionId }; + }, + }, operations: { "working-tree-diff": { async load(input, { cwd }) { diff --git a/src/extensions/default/vcs/jujutsu/history.test.ts b/src/extensions/default/vcs/jujutsu/history.test.ts new file mode 100644 index 000000000..fb4fb0e32 --- /dev/null +++ b/src/extensions/default/vcs/jujutsu/history.test.ts @@ -0,0 +1,252 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createJjVcsAdapter } from "./index"; +import { + buildJjHistoryArgs, + buildJjHistoryRevset, + jjHistoryUsesBoundaryTopology, + openJjHistory, + parseJjHistory, +} from "./history"; + +const tempDirs: string[] = []; +const jjTest = Bun.which("jj") ? test : test.skip; + +/** Create a real JJ-only workspace without a colocated `.git` directory. */ +function createJjOnlyTestRepo() { + const repo = realpathSync(mkdtempSync(join(tmpdir(), "hunk-jj-history-"))); + tempDirs.push(repo); + const init = Bun.spawnSync(["jj", "git", "init", "--no-colocate", repo], { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + if (init.exitCode !== 0) throw new Error(init.stderr.toString()); + return repo; +} + +/** Run JJ with a deterministic identity for fixture commits. */ +function jj(repo: string, ...args: string[]) { + const result = Bun.spawnSync( + [ + "jj", + "--config", + 'user.name="Ada Lovelace"', + "--config", + 'user.email="ada@example.com"', + ...args, + ], + { cwd: repo, stdin: "ignore", stdout: "pipe", stderr: "pipe" }, + ); + if (result.exitCode !== 0) throw new Error(result.stderr.toString()); + return result.stdout.toString(); +} + +afterEach(() => { + for (const repo of tempDirs.splice(0)) rmSync(repo, { recursive: true, force: true }); +}); + +describe("Jujutsu history production", () => { + test("builds a provider-owned revset and preserves literal filesets", () => { + expect( + buildJjHistoryArgs({ + revision: "main..@", + firstParent: true, + maxCount: 12, + author: 'Ada "A"', + grep: "parser", + since: "2026-01-01", + until: "2026-02-01", + pathspecs: ["src/file with spaces.ts", "--not-an-option"], + }), + ).toEqual([ + "--ignore-working-copy", + "--no-pager", + "--color", + "never", + "log", + "--no-graph", + "--revisions", + '(first_ancestors((main..@)) ~ root()) & author(substring:"Ada \\"A\\"") & description(substring:"parser") & author_date(after:"2026-01-01") & author_date(before:"2026-02-01")', + "--template", + expect.any(String), + "--limit", + "12", + "--", + "src/file with spaces.ts", + "--not-an-option", + ]); + expect(buildJjHistoryRevset({ all: true })).toBe("(ancestors(visible_heads()) ~ root())"); + }); + + test("rejects option-like revisions", () => { + expect(() => buildJjHistoryRevset({ revision: "--at-operation=@" })).toThrow( + "Refused Jujutsu history revision", + ); + }); + + test("parses descriptions, logical ids, parents, and structured JJ refs", () => { + const raw = [ + "a".repeat(40), + "kkkkkkkk", + "k".repeat(32), + `${"b".repeat(40)} ${"c".repeat(40)}`, + "Ada Lovelace", + "ada@example.com", + "2026-01-02T03:04:05+00:00", + "Subject\n\nBody paragraph.\n", + "1", + `main\x1ffeature`, + "main@git", + "v1.0.0", + "v1.0.0@origin", + ].join("\0"); + expect(parseJjHistory(raw)).toEqual([ + { + revisionId: "a".repeat(40), + displayId: "kkkkkkkk", + logicalId: "k".repeat(32), + parentRevisionIds: ["b".repeat(40), "c".repeat(40)], + subject: "Subject", + body: "Body paragraph.", + authorName: "Ada Lovelace", + authorEmail: "ada@example.com", + authoredAt: "2026-01-02T03:04:05+00:00", + decorations: [ + { kind: "head", label: "@" }, + { kind: "local-branch", label: "main" }, + { kind: "local-branch", label: "feature" }, + { kind: "remote-branch", label: "main@git" }, + { kind: "tag", label: "v1.0.0" }, + { kind: "tag", label: "v1.0.0@origin" }, + ], + }, + ]); + }); + + test("keeps review parents but marks filtered topology as a boundary", () => { + expect(jjHistoryUsesBoundaryTopology({})).toBe(false); + expect(jjHistoryUsesBoundaryTopology({ revision: "main..@" })).toBe(false); + expect(jjHistoryUsesBoundaryTopology({ pathspecs: ["src"] })).toBe(true); + expect(jjHistoryUsesBoundaryTopology({ author: "Ada" })).toBe(true); + + const raw = [ + "a".repeat(40), + "aaaaaaaa", + "k".repeat(32), + "b".repeat(40), + "Ada", + "", + "2026-01-01T00:00:00+00:00", + "Commit\n", + "0", + "", + "", + "", + "", + ].join("\0"); + const parsed = parseJjHistory(raw, false, true)[0]!; + expect(parsed.parentRevisionIds).toEqual(["b".repeat(40)]); + expect(parsed.graphParentRevisionIds).toEqual([]); + }); + + test("drops JJ's synthetic root and excluded merge parents", () => { + const raw = [ + "a".repeat(40), + "aaaaaaaa", + "k".repeat(32), + `${"b".repeat(40)} ${"0".repeat(40)} ${"c".repeat(40)}`, + "Ada", + "", + "2026-01-01T00:00:00+00:00", + "Commit\n", + "0", + "", + "", + "", + "", + ].join("\0"); + expect(parseJjHistory(raw, true)[0]!.parentRevisionIds).toEqual(["b".repeat(40)]); + }); + + test("owns ordinary, merge, and root review semantics with revision-show", async () => { + const history = createJjVcsAdapter().history!; + for (const parentRevisionIds of [[], ["b".repeat(40)], ["b".repeat(40), "c".repeat(40)]]) { + const commit = { + revisionId: "a".repeat(40), + displayId: "aaaaaaaa", + logicalId: "k".repeat(32), + parentRevisionIds, + subject: "Commit", + authorName: "Ada", + authoredAt: "2026-01-01T00:00:00+00:00", + decorations: [], + }; + expect(await history.planReview(commit)).toEqual({ + kind: "revision-show", + revisionId: commit.revisionId, + }); + } + }); + + jjTest( + "streams bounded child-before-parent pages from a JJ-only repository", + async () => { + const repo = createJjOnlyTestRepo(); + expect(existsSync(join(repo, ".git"))).toBe(false); + writeFileSync(join(repo, "history.txt"), "one\n"); + jj(repo, "commit", "-m", "First commit"); + writeFileSync(join(repo, "history.txt"), "two\n"); + jj(repo, "commit", "-m", "Second commit\n\nDetailed body."); + jj(repo, "bookmark", "create", "main", "-r", "@-"); + jj(repo, "tag", "set", "v1.0.0", "-r", "@-"); + + const source = openJjHistory({ maxCount: 4 }, { cwd: repo }); + const commits = []; + for (;;) { + const page = await source.read({ limit: 1 }); + expect(page.commits.length).toBeLessThanOrEqual(1); + commits.push(...page.commits); + if (page.done) break; + } + await source.close(); + + expect(commits.map((commit) => commit.subject)).toEqual([ + "(no description set)", + "Second commit", + "First commit", + ]); + expect(commits[1]!.body).toBe("Detailed body."); + expect(commits[1]!.displayId).toMatch(/^[k-z]{8}$/); + expect(commits[1]!.logicalId).toMatch(/^[k-z]{32}$/); + expect(commits[1]!.decorations).toContainEqual({ kind: "local-branch", label: "main" }); + expect(commits[1]!.decorations).toContainEqual({ kind: "tag", label: "v1.0.0" }); + for (let index = 0; index < commits.length; index += 1) { + for (const parent of commits[index]!.parentRevisionIds) { + const parentIndex = commits.findIndex((commit) => commit.revisionId === parent); + if (parentIndex >= 0) expect(parentIndex).toBeGreaterThan(index); + } + } + + const filtered = openJjHistory({ pathspecs: ["history.txt"] }, { cwd: repo }); + const filteredPage = await filtered.read({ limit: 4 }); + await filtered.close(); + const filteredWithParent = filteredPage.commits.find( + (commit) => commit.parentRevisionIds.length > 0, + ); + expect(filteredWithParent?.parentRevisionIds).toHaveLength(1); + expect(filteredWithParent?.graphParentRevisionIds).toEqual([]); + + const cancelled = openJjHistory({}, { cwd: repo }); + const abort = new AbortController(); + abort.abort(new Error("cancel fixture")); + await expect(cancelled.read({ limit: 1, signal: abort.signal })).rejects.toThrow( + "cancel fixture", + ); + await cancelled.close(); + }, + 20_000, + ); +}); diff --git a/src/extensions/default/vcs/jujutsu/history.ts b/src/extensions/default/vcs/jujutsu/history.ts new file mode 100644 index 000000000..4f2138538 --- /dev/null +++ b/src/extensions/default/vcs/jujutsu/history.ts @@ -0,0 +1,369 @@ +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"; +import { normalizePathForOS } from "../../../../lib/osPath"; + +const HISTORY_FIELDS_PER_COMMIT = 13; +const REF_SEPARATOR = "\x1f"; +const FULL_COMMIT_ID_PATTERN = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/; +const DISPLAY_ID_PATTERN = /^[a-z0-9]{4,64}$/; +const ROOT_COMMIT_ID_PATTERN = /^0+$/; + +/** Emits stable machine fields without consulting a user's log template. */ +const JJ_HISTORY_TEMPLATE = + [ + "commit_id", + "change_id.short(8)", + "change_id", + 'parents.map(|p| p.commit_id()).join(" ")', + "author.name()", + "author.email()", + 'author.timestamp().format("%Y-%m-%dT%H:%M:%S%:z")', + "description", + 'if(current_working_copy, "1", "0")', + 'local_bookmarks.map(|r| r.name()).join("\\x1f")', + 'remote_bookmarks.map(|r| r.name() ++ "@" ++ r.remote()).join("\\x1f")', + 'local_tags.map(|r| r.name()).join("\\x1f")', + 'remote_tags.map(|r| r.name() ++ "@" ++ r.remote()).join("\\x1f")', + ].join(' ++ "\\0" ++ ') + ' ++ "\\0"'; + +export interface JjHistoryOptions { + cwd: string; + jjExecutable?: string; +} + +/** Return one safely quoted Jujutsu string literal for a generated revset. */ +function quoteRevsetString(value: string) { + return JSON.stringify(value); +} + +/** Reject a positional revset that the CLI could reinterpret as an option. */ +function requireHistoryRevision(value: string) { + if (!value || value.startsWith("-")) { + throw new HunkExtensionUserError(`Refused Jujutsu history revision \`${value}\`.`, { + suggestions: ["Pass a revision or revset such as `@`, `main`, or `main..@`."], + }); + } + return value; +} + +/** Build the provider-owned revset while preserving honest Jujutsu semantics. */ +export function buildJjHistoryRevset(input: ExtensionVcsHistoryInput) { + const start = input.revision + ? `(${requireHistoryRevision(input.revision)})` + : input.all + ? "visible_heads()" + : "@"; + const traversal = input.firstParent ? `first_ancestors(${start})` : `ancestors(${start})`; + const filters = [`(${traversal} ~ root())`]; + if (input.author !== undefined) { + filters.push(`author(substring:${quoteRevsetString(input.author)})`); + } + if (input.grep !== undefined) { + filters.push(`description(substring:${quoteRevsetString(input.grep)})`); + } + if (input.since !== undefined) { + filters.push(`author_date(after:${quoteRevsetString(input.since)})`); + } + if (input.until !== undefined) { + filters.push(`author_date(before:${quoteRevsetString(input.until)})`); + } + return filters.join(" & "); +} + +/** Build one deterministic, non-mutating `jj log` invocation. */ +export function buildJjHistoryArgs(input: ExtensionVcsHistoryInput) { + const args = [ + "--ignore-working-copy", + "--no-pager", + "--color", + "never", + "log", + "--no-graph", + "--revisions", + buildJjHistoryRevset(input), + "--template", + JJ_HISTORY_TEMPLATE, + ]; + if (input.maxCount !== undefined) args.push("--limit", String(input.maxCount)); + if (input.pathspecs?.length) args.push("--", ...input.pathspecs); + return args; +} + +/** Split a Jujutsu description into the medium-format subject and body fields. */ +function splitDescription(description: string) { + const normalized = description.endsWith("\n") ? description.slice(0, -1) : description; + const boundary = normalized.indexOf("\n"); + if (boundary < 0) { + return { subject: normalized || "(no description set)" }; + } + const subject = normalized.slice(0, boundary) || "(no description set)"; + const body = normalized.slice(boundary + 1).replace(/^\n/, ""); + return { subject, ...(body ? { body } : {}) }; +} + +/** Convert one ref-list field into typed, provider-neutral decorations. */ +function appendDecorations( + target: ExtensionVcsHistoryDecoration[], + field: string, + kind: ExtensionVcsHistoryDecoration["kind"], +) { + for (const label of field.split(REF_SEPARATOR)) { + if (label) target.push({ kind, label }); + } +} + +/** Parse fixed NUL-delimited Jujutsu template records. */ +export function parseJjHistory( + text: string, + firstParent = false, + omitGraphParents = 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("Jujutsu 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 logicalId = fields[offset + 2]!; + const parents = fields[offset + 3]!; + const authorName = fields[offset + 4]!; + const authorEmail = fields[offset + 5]!; + const authoredAt = fields[offset + 6]!; + const description = fields[offset + 7]!; + const currentWorkingCopy = fields[offset + 8]!; + const localBookmarks = fields[offset + 9]!; + const remoteBookmarks = fields[offset + 10]!; + const localTags = fields[offset + 11]!; + const remoteTags = fields[offset + 12]!; + + if ( + !FULL_COMMIT_ID_PATTERN.test(revisionId) || + !DISPLAY_ID_PATTERN.test(displayId) || + !logicalId || + !authoredAt + ) { + throw new Error("Jujutsu returned an invalid history record."); + } + const allParents = parents + ? parents.split(" ").filter((parent) => parent && !ROOT_COMMIT_ID_PATTERN.test(parent)) + : []; + if (allParents.some((parent) => !FULL_COMMIT_ID_PATTERN.test(parent))) { + throw new Error("Jujutsu returned an invalid history parent commit id."); + } + + const decorations: ExtensionVcsHistoryDecoration[] = []; + if (currentWorkingCopy === "1") decorations.push({ kind: "head", label: "@" }); + appendDecorations(decorations, localBookmarks, "local-branch"); + appendDecorations(decorations, remoteBookmarks, "remote-branch"); + appendDecorations(decorations, localTags, "tag"); + appendDecorations(decorations, remoteTags, "tag"); + + commits.push({ + revisionId, + displayId, + parentRevisionIds: firstParent ? allParents.slice(0, 1) : allParents, + ...(omitGraphParents ? { graphParentRevisionIds: [] } : {}), + ...splitDescription(description), + authorName: authorName || "Unknown author", + ...(authorEmail ? { authorEmail } : {}), + authoredAt, + decorations, + logicalId, + }); + } + return commits; +} + +/** Run a small synchronous JJ query used only to establish the repository root. */ +function resolveHistoryRepoRoot({ cwd, jjExecutable = "jj" }: JjHistoryOptions) { + let result: ReturnType; + try { + result = Bun.spawnSync( + [jjExecutable, "--ignore-working-copy", "--no-pager", "--color", "never", "root"], + { cwd, stdin: "ignore", stdout: "pipe", stderr: "pipe" }, + ); + } catch { + throw new HunkExtensionUserError(`Could not run ${jjExecutable}.`, { + suggestions: ["Install Jujutsu or configure Hunk to use another VCS backend."], + }); + } + if (result.exitCode !== 0) { + const message = result.stderr?.toString().trim().split("\n")[0]; + throw new HunkExtensionUserError(message || "Jujutsu could not read this repository.", { + suggestions: ["Run `hunk log --vcs jj` from a Jujutsu workspace."], + }); + } + const repoRoot = result.stdout?.toString().trim(); + if (!repoRoot) throw new Error("Jujutsu returned an empty repository root."); + return normalizePathForOS(repoRoot); +} + +/** Return whether traversal filters can omit direct parents from the emitted commit stream. */ +export function jjHistoryUsesBoundaryTopology(input: ExtensionVcsHistoryInput) { + return Boolean( + input.author !== undefined || + input.grep !== undefined || + input.since !== undefined || + input.until !== undefined || + input.pathspecs?.length, + ); +} + +/** Open a bounded, cancellable cursor over one long-lived `jj log` process. */ +export function openJjHistory( + input: ExtensionVcsHistoryInput, + { cwd, jjExecutable = "jj" }: JjHistoryOptions, +): ExtensionVcsHistorySource & { repoRoot: string } { + const repoRoot = resolveHistoryRepoRoot({ cwd, jjExecutable }); + if (input.maxCount === 0) { + return { + repoRoot, + async read() { + return { commits: [], done: true }; + }, + close() {}, + }; + } + + const omitGraphParents = jjHistoryUsesBoundaryTopology(input); + const child = spawn(jjExecutable, buildJjHistoryArgs(input), { + cwd: repoRoot, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + 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 reading = false; + let failure: unknown; + + const wake = () => { + for (const waiter of waiters) waiter(); + waiters.clear(); + }; + const stop = () => { + if (closed) return; + closed = true; + if (!completed) child.kill(); + wake(); + }; + 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( + ...parseJjHistory(`${fields.join("\0")}\0`, input.firstParent, omitGraphParents), + ); + fields.length = 0; + } + } + wake(); + }; + + child.stdout!.on("data", (chunk: Buffer) => { + try { + consume(decoder.write(chunk)); + if (queue.length >= 512) child.stdout!.pause(); + } catch (error) { + failure = error; + completed = true; + child.kill(); + wake(); + } + }); + child.stderr!.setEncoding("utf8"); + child.stderr!.on("data", (chunk: string) => { + stderr += chunk; + }); + child.once("error", () => { + failure = new HunkExtensionUserError(`Could not run ${jjExecutable}.`, { + suggestions: ["Install Jujutsu or configure Hunk to use another VCS backend."], + }); + completed = true; + wake(); + }); + child.once("close", (code) => { + if (!failure) { + try { + consume(decoder.end()); + } catch (error) { + failure = error; + } + } + if (!failure && (fields.length > 0 || buffered.length > 0)) { + failure = new Error("Jujutsu returned a truncated history record."); + } else if (!failure && code !== 0 && !closed) { + failure = new HunkExtensionUserError( + stderr.trim().split("\n")[0] || "Jujutsu could not read this history.", + { suggestions: ["Check the revset, filters, and filesets, then try again."] }, + ); + } + completed = true; + wake(); + }); + + const waitForData = (signal?: AbortSignal) => + new Promise((resolve, reject) => { + if (signal?.aborted) { + stop(); + reject(signal.reason ?? new Error("History read aborted.")); + return; + } + const ready = () => { + signal?.removeEventListener("abort", abort); + resolve(); + }; + const abort = () => { + waiters.delete(ready); + stop(); + 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 Jujutsu history reads are not supported."); + if (signal?.aborted) { + stop(); + throw signal.reason ?? new Error("History read aborted."); + } + reading = true; + try { + const target = Math.min(Math.max(1, 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: stop, + }; +} diff --git a/src/extensions/default/vcs/jujutsu/index.ts b/src/extensions/default/vcs/jujutsu/index.ts index 48018f053..f39cc9f3e 100644 --- a/src/extensions/default/vcs/jujutsu/index.ts +++ b/src/extensions/default/vcs/jujutsu/index.ts @@ -10,6 +10,7 @@ import { runJjText, type JjDiffEndpoints, } from "./commands"; +import { openJjHistory } from "./history"; import { readJjFileSource } from "./source"; import { describeDiffRange } from "../diffRange"; import { @@ -131,6 +132,16 @@ export function createJjVcsAdapter({ jjExecutable = "jj" }: Readonly { expect(mismatches).toEqual(["mercurial"]); }); + test("preserves terminal-safe adapter ids that begin with a dash", () => { + const adapter = toInternalVcsAdapter({ + id: "-custom", + name: "Custom", + detect: () => ({ id: "-custom", repoRoot: "/repo" }), + }); + + expect(adapter.id).toBe("-custom"); + expect(adapter.detect("/repo")).toEqual({ id: "-custom", repoRoot: "/repo" }); + }); + test("passes a matching detection through untouched, with no diagnostic", () => { const mismatches: string[] = []; const detection = { id: "hg", repoRoot: "/repo" }; @@ -833,10 +844,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 +972,301 @@ describe("toInternalVcsAdapter detection ids", () => { ]); }); }); + +describe("toInternalVcsAdapter history boundary", () => { + test("requires providers to own selected-item review planning", () => { + expect(() => + toInternalVcsAdapter({ + id: "incomplete", + name: "Incomplete", + detect: () => null, + history: { + open: () => ({ + read: async () => ({ commits: [], done: true }), + close() {}, + }), + } as never, + }), + ).toThrow("history must provide open() and planReview() functions"); + }); + + 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; + }, + }), + planReview: (selected) => ({ + kind: "revision-show", + revisionId: selected.revisionId, + }), + }, + }); + 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("rejects unsafe revision ids and required display fields erased by sanitization", async () => { + const validCommit = { + revisionId: "a".repeat(40), + displayId: "aaaaaaaa", + parentRevisionIds: [] as string[], + subject: "Safe subject", + authorName: "Ada", + authoredAt: "2026-01-01T00:00:00Z", + decorations: [], + }; + const readCommit = async (commit: typeof validCommit) => { + const adapter = toInternalVcsAdapter({ + id: "adversarial", + name: "Adversarial", + detect: () => null, + history: { + open: () => ({ + read: async () => ({ commits: [commit], done: true }), + close() {}, + }), + planReview: (selected) => ({ + kind: "revision-show", + revisionId: selected.revisionId, + }), + }, + }); + return adapter + .history!.open({}, { cwd: "/repo" }) + .then((source) => source.read({ limit: 1 })); + }; + + await expect(readCommit({ ...validCommit, revisionId: `unsafe\tid` })).rejects.toThrow( + "terminal-safe immutable revision id", + ); + for (const field of ["displayId", "subject", "authorName"] as const) { + await expect(readCommit({ ...validCommit, [field]: "\x1b[2J" })).rejects.toThrow( + `${field} must remain non-empty after sanitization`, + ); + } + }); + + test("rejects tabs in provider-owned review action revision ids", async () => { + const commit = { + revisionId: "a".repeat(40), + displayId: "aaaaaaaa", + parentRevisionIds: [] as string[], + subject: "Safe subject", + authorName: "Ada", + authoredAt: "2026-01-01T00:00:00Z", + decorations: [], + }; + const adapter = toInternalVcsAdapter({ + id: "adversarial-plan", + name: "Adversarial plan", + detect: () => null, + history: { + open: () => ({ + read: async () => ({ commits: [], done: true }), + close() {}, + }), + planReview: () => ({ kind: "revision-show", revisionId: "unsafe\tid" }), + }, + }); + + await expect(adapter.history!.planReview(commit, { cwd: "/repo" })).rejects.toThrow( + "terminal-safe immutable revision id", + ); + }); + + 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, + planReview: (selected) => ({ + kind: "revision-show", + revisionId: selected.revisionId, + }), + }, + }); + 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; + }, + }), + planReview: (selected) => ({ + kind: "revision-show", + revisionId: selected.revisionId, + }), + }, + }); + 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); + }); + + test("preserves custom-provider paging and provider-owned opaque review plans", async () => { + const root = { + revisionId: "opaque:root/revision", + displayId: "root", + parentRevisionIds: [], + subject: "Root", + authorName: "Ada", + authoredAt: "2026-01-01T00:00:00Z", + decorations: [], + }; + const child = { + ...root, + revisionId: "opaque:child/revision", + displayId: "child", + parentRevisionIds: [root.revisionId], + subject: "Child", + }; + let page = 0; + const adapter = toInternalVcsAdapter({ + id: "opaque", + name: "Opaque VCS", + detect: (cwd) => ({ id: "opaque", repoRoot: cwd }), + history: { + open: () => ({ + read: async () => + page++ === 0 ? { commits: [child], done: false } : { commits: [root], done: true }, + close() {}, + }), + planReview: (selected) => + selected.parentRevisionIds.length + ? { + kind: "revision-range", + fromRevisionId: `opaque:base-for/${selected.revisionId}`, + toRevisionId: selected.revisionId, + } + : { kind: "revision-show", revisionId: `opaque:root-view/${selected.revisionId}` }, + }, + }); + + const source = await adapter.history!.open({}, { cwd: "/repo" }); + expect((await source.read({ limit: 1 })).commits.map((commit) => commit.revisionId)).toEqual([ + child.revisionId, + ]); + expect((await source.read({ limit: 1 })).commits.map((commit) => commit.revisionId)).toEqual([ + root.revisionId, + ]); + await expect(adapter.history!.planReview(child, { cwd: "/repo" })).resolves.toEqual({ + kind: "revision-range", + fromRevisionId: `opaque:base-for/${child.revisionId}`, + toRevisionId: child.revisionId, + }); + await expect(adapter.history!.planReview(root, { cwd: "/repo" })).resolves.toEqual({ + kind: "revision-show", + revisionId: `opaque:root-view/${root.revisionId}`, + }); + }); + + test("rejects parent-before-child ordering across page boundaries", async () => { + const commit = (revisionId: string, parentRevisionIds: string[]) => ({ + revisionId, + displayId: revisionId, + parentRevisionIds, + subject: revisionId, + authorName: "Ada", + authoredAt: "2026-01-01T00:00:00Z", + decorations: [], + }); + let page = 0; + const adapter = toInternalVcsAdapter({ + id: "misordered", + name: "Misordered", + detect: () => null, + history: { + open: () => ({ + read: async () => + page++ === 0 + ? { commits: [commit("parent", [])], done: false } + : { commits: [commit("child", ["parent"])], done: true }, + close() {}, + }), + planReview: (selected) => ({ + kind: "revision-show", + revisionId: selected.revisionId, + }), + }, + }); + const source = await adapter.history!.open({}, { cwd: "/repo" }); + await source.read({ limit: 1 }); + await expect(source.read({ limit: 1 })).rejects.toThrow( + "VCS history returned parent parent before child child.", + ); + }); +}); diff --git a/src/extensions/runExtension.ts b/src/extensions/runExtension.ts index 6345da02b..d1619d4ca 100644 --- a/src/extensions/runExtension.ts +++ b/src/extensions/runExtension.ts @@ -27,8 +27,14 @@ 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, + ExtensionVcsHistoryReviewAction, + 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 +176,263 @@ 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", + "graphParentRevisionIds", + "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 safeDisplay = (key: "displayId" | "subject" | "authorName") => { + const text = sanitizeTerminalLine(required(key)).replaceAll("\t", " "); + if (text.trim().length === 0) { + throw new Error(`VCS history commit ${key} must remain non-empty after sanitization.`); + } + return text; + }; + const safeRevision = (revision: unknown, label: string) => { + const text = assertNonEmptyString(revision, `${label} must be a non-empty string.`); + if (text.includes("\t") || 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 = safeDisplay("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 ( + snapshot.graphParentRevisionIds !== undefined && + !Array.isArray(snapshot.graphParentRevisionIds) + ) { + throw new Error("VCS history commit graphParentRevisionIds must be an array when present."); + } + if (!Array.isArray(snapshot.decorations)) { + throw new Error("VCS history commit decorations must be an array."); + } + + const parentValues = snapshotArray(snapshot.parentRevisionIds, 256); + const graphParentValues = Array.isArray(snapshot.graphParentRevisionIds) + ? snapshotArray(snapshot.graphParentRevisionIds, 256) + : undefined; + const decorationValues = snapshotArray(snapshot.decorations, 256); + const parentRevisionIds = parentValues.map((parent) => + safeRevision(parent, "VCS history parent revision id"), + ); + const graphParentRevisionIds = graphParentValues?.map((parent) => + safeRevision(parent, "VCS history graph 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", "attachedLocalBranch"]); + if (typeof fields.kind !== "string" || !decorationKinds.has(fields.kind)) { + throw new Error("VCS history returned an invalid decoration."); + } + const kind = fields.kind as ExtensionVcsHistoryCommit["decorations"][number]["kind"]; + const label = sanitizeTerminalLine( + assertNonEmptyString(fields.label, "VCS history decoration labels must be non-empty."), + ).replaceAll("\t", " "); + if (kind === "head") { + const attachedLocalBranch = + fields.attachedLocalBranch === undefined + ? undefined + : sanitizeTerminalLine( + assertNonEmptyString( + fields.attachedLocalBranch, + "VCS history attached local branch must be non-empty.", + ), + ).replaceAll("\t", " "); + return { kind, label, ...(attachedLocalBranch ? { attachedLocalBranch } : {}) }; + } + if (fields.attachedLocalBranch !== undefined) { + throw new Error("Only a VCS history HEAD decoration may name an attached local branch."); + } + return { kind, label }; + }); + + return { + revisionId, + displayId, + parentRevisionIds, + ...(graphParentRevisionIds ? { graphParentRevisionIds } : {}), + subject: safeDisplay("subject"), + ...(typeof snapshot.body === "string" + ? { + body: sanitizeTerminalText(snapshot.body, { + preserveNewlines: true, + preserveTabs: false, + }), + } + : {}), + authorName: safeDisplay("authorName"), + ...(typeof snapshot.authorEmail === "string" + ? { authorEmail: sanitizeTerminalLine(snapshot.authorEmail).replaceAll("\t", " ") } + : {}), + authoredAt, + decorations, + ...(typeof snapshot.logicalId === "string" + ? { logicalId: sanitizeTerminalLine(snapshot.logicalId).replaceAll("\t", " ") } + : {}), + }; +} + +/** Copy and validate a provider-owned plan for opening one opaque history item. */ +function normalizeHistoryReviewAction(value: unknown): ExtensionVcsHistoryReviewAction { + if (!isPlainObject(value)) { + throw new Error("VCS history planReview() must return an action object."); + } + const fields = snapshotProperties(value, [ + "kind", + "revisionId", + "fromRevisionId", + "toRevisionId", + ]); + const revision = (candidate: unknown, label: string) => { + const text = assertNonEmptyString(candidate, `${label} must be a non-empty string.`); + if (text.includes("\t") || sanitizeTerminalLine(text) !== text) { + throw new Error(`${label} must be a terminal-safe immutable revision id.`); + } + return text; + }; + if (fields.kind === "revision-show") { + return { kind: "revision-show", revisionId: revision(fields.revisionId, "revisionId") }; + } + if (fields.kind === "revision-range") { + return { + kind: "revision-range", + fromRevisionId: revision(fields.fromRevisionId, "fromRevisionId"), + toRevisionId: revision(fields.toRevisionId, "toRevisionId"), + }; + } + throw new Error("VCS history planReview() returned an unsupported action kind."); +} + +/** 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}.`); + } + const emittedParent = commit.parentRevisionIds.find((parent) => acceptedIds.has(parent)); + if (emittedParent) { + throw new Error( + `VCS history returned parent ${emittedParent} before child ${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 +462,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) { + 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 +491,88 @@ export function toInternalVcsAdapter( } } - const detect = adapter.detect; + const history = adapterFields.history; + const historyFields = isPlainObject(history) + ? snapshotProperties(history, ["open", "planReview"]) + : undefined; + const historyOpen = historyFields?.open; + const historyPlanReview = historyFields?.planReview; + if ( + history !== undefined && + (!isPlainObject(history) || + typeof historyOpen !== "function" || + typeof historyPlanReview !== "function") + ) { + throw new Error("registerVcsAdapter history must provide open() and planReview() functions."); + } + + const openHistory = historyOpen as NonNullable["open"]; + const planHistoryReview = historyPlanReview as NonNullable< + ExtensionVcsAdapter["history"] + >["planReview"]; + 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); + } + }, + async planReview(commit, context) { + try { + return normalizeHistoryReviewAction( + await planHistoryReview.call(history, normalizeHistoryCommit(commit), context), + ); + } catch (error) { + throw toUserFacingError(error); + } + }, + }, + }), }; } diff --git a/src/extensions/types.ts b/src/extensions/types.ts index 2fd466a62..7d281f052 100644 --- a/src/extensions/types.ts +++ b/src/extensions/types.ts @@ -76,6 +76,13 @@ export type { ExtensionSessionOptions, ExtensionThemeConfig, ExtensionVcsAdapter, + ExtensionVcsHistoryCapability, + ExtensionVcsHistoryCommit, + ExtensionVcsHistoryDecoration, + ExtensionVcsHistoryInput, + ExtensionVcsHistoryPage, + ExtensionVcsHistoryReviewAction, + 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.test.ts b/src/ui/history/runInteractiveHistory.test.ts new file mode 100644 index 000000000..8d29616d4 --- /dev/null +++ b/src/ui/history/runInteractiveHistory.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, test } from "bun:test"; +import { PassThrough } from "node:stream"; +import type { HistoryRuntime } from "./types"; +import { historyReviewArgs, runInteractiveHistory } from "./runInteractiveHistory"; + +describe("history review child arguments", () => { + test("encodes provider-owned opaque actions without exposing ids to CLI option parsing", () => { + const range = historyReviewArgs({ + kind: "revision-range", + fromRevisionId: "-opaque:merge-parent/α", + toRevisionId: "opaque:merge-child/β", + }); + expect(range.slice(0, 2)).toEqual(["diff", "--history-review"]); + expect(JSON.parse(Buffer.from(range[2]!, "base64url").toString("utf8"))).toEqual({ + kind: "revision-range", + fromRevisionId: "-opaque:merge-parent/α", + toRevisionId: "opaque:merge-child/β", + }); + + const root = historyReviewArgs({ kind: "revision-show", revisionId: "-opaque:root/revision" }); + expect(root.slice(0, 2)).toEqual(["show", "--history-review"]); + expect(JSON.parse(Buffer.from(root[2]!, "base64url").toString("utf8"))).toEqual({ + kind: "revision-show", + revisionId: "-opaque:root/revision", + }); + }); +}); + +describe("interactive history loading", () => { + test("q interrupts an exhaustive read and closes the provider", async () => { + const stdin = new PassThrough() as unknown as NodeJS.ReadStream; + const stdout = new PassThrough() as unknown as NodeJS.WriteStream; + Object.assign(stdin, { isTTY: true, setRawMode() {} }); + Object.assign(stdout, { isTTY: true, columns: 80, rows: 12 }); + let reads = 0; + let readAborted = false; + let closed = false; + const runtime: HistoryRuntime = { + input: { + kind: "history", + color: "never", + format: "compact", + ascii: false, + interactive: true, + extensionsEnabled: false, + extensionPaths: [], + }, + source: { + async read({ signal }) { + reads += 1; + if (reads === 1) { + return { + commits: [ + { + revisionId: "a".repeat(40), + displayId: "aaaaaaaa", + parentRevisionIds: [], + subject: "First", + authorName: "Ada", + authoredAt: "2026-01-01T00:00:00Z", + decorations: [], + }, + ], + done: false, + }; + } + return await new Promise((_, reject) => { + signal?.addEventListener( + "abort", + () => { + readAborted = true; + reject(signal.reason); + }, + { once: true }, + ); + }); + }, + async close() {}, + }, + providerId: "test", + providerName: "Test", + repoRoot: "/repo", + notices: [], + customThemes: [], + async planReview() { + return { kind: "revision-show", revisionId: "a".repeat(40) }; + }, + async close() { + closed = true; + }, + }; + + const running = runInteractiveHistory(runtime, { stdin, stdout }); + while (reads < 1) await Bun.sleep(1); + // Terminals may coalesce the exhaustive-navigation key and quit. + stdin.write("Gq"); + await running; + expect(reads).toBe(2); + + expect(readAborted).toBe(true); + expect(closed).toBe(true); + }); +}); diff --git a/src/ui/history/runInteractiveHistory.ts b/src/ui/history/runInteractiveHistory.ts new file mode 100644 index 000000000..ca46ad514 --- /dev/null +++ b/src/ui/history/runInteractiveHistory.ts @@ -0,0 +1,364 @@ +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, + getHistoryCommitIdBounds, + projectHistoryRow, + resolveHistoryColor, + resolveHistoryTheme, +} from "./staticProjection"; +import { TerminalInputReader } from "./terminalInput"; +import type { ExtensionVcsHistoryReviewAction } from "../../extension-api/types"; +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"; + +/** Convert a provider-owned review declaration into one child Hunk invocation. */ +export function historyReviewArgs(action: ExtensionVcsHistoryReviewAction) { + const payload = Buffer.from(JSON.stringify(action), "utf8").toString("base64url"); + return [action.kind === "revision-range" ? "diff" : "show", "--history-review", payload]; +} + +/** Run one provider-planned child Hunk review after yielding terminal ownership. */ +async function openCommitReview( + bootstrap: HistoryRuntime, + action: ExtensionVcsHistoryReviewAction, +) { + const current = resolveCurrentHunkCommand(); + const extensionArgs = bootstrap.input.extensionPaths.flatMap((path) => [ + "--extension", + resolve(path), + ]); + const reviewArgs = historyReviewArgs(action); + 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); + + /** Read one page while letting quit interrupt an exhaustive traversal. */ + const readInterruptibly = async () => { + const pending = bootstrap.source.read({ limit: 256, signal: abort.signal }); + const settled = pending.then( + (page) => ({ kind: "page" as const, page }), + (error: unknown) => ({ kind: "error" as const, error }), + ); + const deferredKeys: string[] = []; + for (;;) { + const keyWait = new AbortController(); + // Put input first so a queued `q` wins when a fast provider page and + // coalesced `Gq` input are both already ready. + const result = await Promise.race([ + input.next(keyWait.signal).then((key) => ({ kind: "key" as const, key })), + settled, + ]); + if (result.kind === "page") { + keyWait.abort(); + input.prepend(deferredKeys); + return result.page; + } + if (result.kind === "error") { + keyWait.abort(); + input.prepend(deferredKeys); + throw result.error; + } + if (result.key === "q" || result.key === "\x03") { + cleanup(); + await settled; + return undefined; + } + deferredKeys.push(result.key); + } + }; + + /** Fetch one bounded continuation page and preserve graph state across it. */ + const loadMore = async (interruptible = false) => { + if (historyDone || loading || stopped) return; + loading = true; + try { + const page = interruptible + ? await readInterruptibly() + : await bootstrap.source.read({ limit: 256, signal: abort.signal }); + if (!page) return; + 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(true); + }; + + 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 = () => { + if (!active) return; + 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 === "\x03") { + cleanup(); + return; + } + if (key === "\x7f") draft = Array.from(draft).slice(0, -1).join(""); + else if (/^[^\x00-\x1f\x7f]+$/u.test(key)) draft += key; + } + }; + /** Open one provider-planned review while yielding terminal ownership. */ + const openRowReview = async (row: HistoryGraphRow) => { + const reviewAction = await bootstrap.planReview(row.commit); + input.discardPending(); + leaveTerminal(); + const code = await openCommitReview(bootstrap, reviewAction); + if (!stopped) enterTerminal(); + notice = code === 0 ? "" : `Could not open ${row.commit.displayId}`; + }; + const cleanup = () => { + if (stopped) return; + stopped = true; + abort.abort(new Error("History browser stopped.")); + leaveTerminal(); + }; + const onResize = () => { + if (active) 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]) { + await openRowReview(rows[selected]!); + if (stopped) break; + } else { + const mouse = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/.exec(key); + if (mouse) { + const button = Number(mouse[1]); + const screenColumn = Number(mouse[2]) - 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; + const row = rows[index]!; + selected = index; + const idBounds = getHistoryCommitIdBounds( + row, + bootstrap.input.ascii || process.env.TERM === "dumb", + ); + const clickedCommitId = screenColumn >= idBounds.start && screenColumn < idBounds.end; + const now = Date.now(); + if (clickedCommitId || (lastClick.index === index && now - lastClick.at < 400)) { + await openRowReview(row); + } + 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..d2e5a6f41 --- /dev/null +++ b/src/ui/history/runStaticHistory.test.ts @@ -0,0 +1,191 @@ +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; + let reads = 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: [], + async planReview(commit) { + return { kind: "revision-show", revisionId: commit.revisionId }; + }, + source: { + async read({ limit }) { + reads += 1; + 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, reads: () => reads }; +} + +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("stops traversing history when the pager closes early", async () => { + const manyCommits = Array.from({ length: 600 }, (_, index) => ({ + ...commits[1]!, + revisionId: `commit-${index}`, + displayId: `c${index}`, + subject: `Commit ${index}`, + })); + const history = runtime(manyCommits); + let closes = 0; + await runStaticHistory(history.value, { + stdout: { isTTY: true, columns: 80, rows: 2, write: () => true }, + stderr: { write: () => true }, + env: { TERM: "xterm" }, + pageText: async () => {}, + openPager: () => ({ + async write() { + return false; + }, + async close() { + closes += 1; + }, + }), + }); + expect(history.reads()).toBe(1); + expect(history.closed()).toBe(1); + 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..3527d4c09 --- /dev/null +++ b/src/ui/history/runStaticHistory.ts @@ -0,0 +1,139 @@ +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) { + if ((await pager.write(`${lines.join("\n")}\n`)) === false) return; + } else { + bufferedLines.push(...lines); + if (deps.openPager && bufferedLines.length > availableRows) { + pager = deps.openPager(deps.env); + if ((await pager.write(`${bufferedLines.join("\n")}\n`)) === false) return; + 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..1520bd128 --- /dev/null +++ b/src/ui/history/staticProjection.test.ts @@ -0,0 +1,115 @@ +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, + getHistoryCommitIdBounds, + projectHistoryRecord, + projectHistoryRow, + renderHistoryContinuation, + 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\x1b[2J", attachedLocalBranch: "main" }, + { 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("reports the compact commit-id hit target in display cells", () => { + const bounds = getHistoryCommitIdBounds(row); + const text = projectHistoryRow(row, { ascii: false, color: false }); + expect(text.slice(bounds.start, bounds.end)).toBe("aaaaaaaa"); + expect(bounds.end - bounds.start).toBe(8); + }); + + 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."); + const continuation = renderHistoryContinuation(row, false); + expect(lines[3]).toBe(continuation); + expect(lines[4]).toBe(`${continuation} Improve 日本語 renderingspoof`); + expect(lines[5]).toBe(continuation); + expect(lines[6]).toBe(`${continuation} First paragraph.`); + expect(lines.at(-1)).toBe(continuation); + expect(formatHistoryDecorations(row)).toBe(" (HEAD -> main, origin/main, tag: v1.0.0)"); + }); + + test("renders the golden no-fast-forward merge convergence", () => { + const planned = planHistoryPage([ + { ...commit, revisionId: "merge", parentRevisionIds: ["main", "side"] }, + { ...commit, revisionId: "side", parentRevisionIds: ["main"], decorations: [] }, + { ...commit, revisionId: "main", parentRevisionIds: [], decorations: [] }, + ]); + expect(renderHistoryConvergence(planned.rows[1]!, false)).toBe("│╯"); + expect(renderHistoryConvergence(planned.rows[1]!, true)).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: { NO_COLOR: "" } })).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..552e28df8 --- /dev/null +++ b/src/ui/history/staticProjection.ts @@ -0,0 +1,265 @@ +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", " "), + ...(entry.kind === "head" && entry.attachedLocalBranch + ? { + attachedLocalBranch: sanitizeTerminalLine(entry.attachedLocalBranch).replaceAll( + "\t", + " ", + ), + } + : {}), + })) + .filter((entry) => entry.label); + const headIndex = values.findIndex((entry) => entry.kind === "head"); + const head = headIndex >= 0 ? values[headIndex] : undefined; + const attachedBranch = head?.attachedLocalBranch ?? ""; + const branchIndex = attachedBranch + ? values.findIndex((entry) => entry.kind === "local-branch" && entry.label === attachedBranch) + : -1; + const labels: string[] = []; + if (head) labels.push(attachedBranch ? `${head.label} -> ${attachedBranch}` : head.label); + for (let index = 0; index < values.length; index += 1) { + if (index === headIndex || 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(""); +} + +/** Return the zero-based display-cell bounds occupied by a compact row's commit id. */ +export function getHistoryCommitIdBounds(row: HistoryGraphRow, ascii = false) { + const graphPrefix = `${renderHistoryGraph(row, ascii)} `; + const displayId = sanitizeTerminalLine(row.commit.displayId).replaceAll("\t", " "); + const start = measureTextWidth(graphPrefix); + return { start, end: start + measureTextWidth(displayId) }; +} + +/** 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.length + ? [ + prefix(continuation).trimEnd(), + ...body.map((line) => `${prefix(continuation)} ${line}`), + ] + : []), + ]; + const convergence = renderHistoryConvergence(row, options.ascii); + if (convergence) plainLines.push(convergence); + plainLines.push(prefix(continuation).trimEnd()); + 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" && !("NO_COLOR" in env); +} diff --git a/src/ui/history/terminalInput.test.ts b/src/ui/history/terminalInput.test.ts new file mode 100644 index 000000000..0d18822e4 --- /dev/null +++ b/src/ui/history/terminalInput.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "bun:test"; +import { PassThrough } from "node:stream"; +import { TerminalInputReader, 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"]); + }); +}); + +describe("TerminalInputReader", () => { + test("cancels a temporary wait without losing restored input", async () => { + const stream = new PassThrough() as unknown as NodeJS.ReadStream; + const reader = new TerminalInputReader(stream); + const abort = new AbortController(); + const waiting = reader.next(abort.signal); + + abort.abort(new Error("stop waiting")); + await expect(waiting).rejects.toThrow("stop waiting"); + reader.prepend(["j", "q"]); + expect(await reader.next()).toBe("j"); + expect(await reader.next()).toBe("q"); + reader.close(); + }); +}); diff --git a/src/ui/history/terminalInput.ts b/src/ui/history/terminalInput.ts new file mode 100644 index 000000000..52c5b395c --- /dev/null +++ b/src/ui/history/terminalInput.ts @@ -0,0 +1,193 @@ +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 token, allowing a temporary caller to cancel its wait. */ + next(signal?: AbortSignal) { + const token = this.queued.shift(); + if (token !== undefined) return Promise.resolve(token); + if (this.endedError) return Promise.reject(this.endedError); + if (signal?.aborted) { + return Promise.reject(signal.reason ?? new Error("Terminal input wait aborted.")); + } + return new Promise((resolve, reject) => { + const waiter = { + resolve: (value: string) => { + signal?.removeEventListener("abort", onAbort); + resolve(value); + }, + reject: (error: Error) => { + signal?.removeEventListener("abort", onAbort); + reject(error); + }, + }; + const onAbort = () => { + const index = this.waiting.indexOf(waiter); + if (index >= 0) this.waiting.splice(index, 1); + waiter.reject(signal?.reason ?? new Error("Terminal input wait aborted.")); + }; + this.waiting.push(waiter); + signal?.addEventListener("abort", onAbort, { once: true }); + }); + } + + /** Restore temporarily consumed tokens to the front of the input queue. */ + prepend(tokens: readonly string[]) { + this.queued.unshift(...tokens); + } + + /** 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..5f63a1f98 --- /dev/null +++ b/src/ui/history/types.ts @@ -0,0 +1,20 @@ +import type { HistoryCommandInput } from "../../core/run/commandInputs"; +import type { VcsHistorySource } from "../../core/vcs/types"; +import type { + ExtensionVcsHistoryCommit, + ExtensionVcsHistoryReviewAction, + 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[]; + planReview(commit: ExtensionVcsHistoryCommit): Promise; + close(): Promise; +} diff --git a/test/cli/log.test.ts b/test/cli/log.test.ts new file mode 100644 index 000000000..753465573 --- /dev/null +++ b/test/cli/log.test.ts @@ -0,0 +1,245 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { existsSync, 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"); +const jjTest = Bun.which("jj") ? test : test.skip; + +/** 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; +} + +/** Create a JJ-only fixture with no colocated Git worktree. */ +function createJjRepo() { + const cwd = mkdtempSync(join(tmpdir(), "hunk-log-jj-test-")); + tempDirs.push(cwd); + expect(run(["jj", "git", "init", "--no-colocate", cwd], tmpdir()).code).toBe(0); + const jj = (...args: string[]) => + run( + [ + "jj", + "--config", + 'user.name="Grace Hopper"', + "--config", + 'user.email="grace@example.com"', + ...args, + ], + cwd, + ); + writeFileSync(join(cwd, "history.txt"), "one\n"); + expect(jj("commit", "-m", "JJ first commit").code).toBe(0); + writeFileSync(join(cwd, "history.txt"), "two\n"); + expect(jj("commit", "-m", "JJ second commit\n\nJJ body.").code).toBe(0); + expect(jj("bookmark", "create", "main", "-r", "@-").code).toBe(0); + expect(jj("tag", "set", "v2.0.0", "-r", "@-").code).toBe(0); + expect(existsSync(join(cwd, ".git"))).toBe(false); + 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 provider filters, all-head traversal, and first-parent", () => { + const cwd = createRepo(); + expect(run(["git", "switch", "-q", "-c", "filtered-side", "HEAD~1"], cwd).code).toBe(0); + writeFileSync(join(cwd, "side.txt"), "side\n"); + const env = { + ...process.env, + GIT_AUTHOR_NAME: "Grace Hopper", + GIT_AUTHOR_EMAIL: "grace@example.com", + GIT_COMMITTER_NAME: "Grace Hopper", + GIT_COMMITTER_EMAIL: "grace@example.com", + GIT_AUTHOR_DATE: "2026-01-03T00:00:00Z", + GIT_COMMITTER_DATE: "2026-01-03T00:00:00Z", + }; + expect(run(["git", "add", "side.txt"], cwd, env).code).toBe(0); + expect(run(["git", "commit", "-q", "-m", "Side-only match"], cwd, env).code).toBe(0); + expect(run(["git", "switch", "-q", "-"], cwd).code).toBe(0); + + const result = run( + [ + "bun", + "run", + mainPath, + "log", + "--all", + "--first-parent", + "--author", + "Grace", + "--grep", + "Side-only", + "--since", + "2026-01-03T00:00:00Z", + "--until", + "2026-01-04T00:00:00Z", + "--color", + "never", + ], + cwd, + ); + + expect(result).toMatchObject({ code: 0, stderr: "" }); + expect(result.stdout).toContain("Side-only match"); + expect(result.stdout).toContain("Author: Grace Hopper "); + expect(result.stdout).not.toContain("Second commit"); + expect(result.stdout).not.toContain("First commit"); + }); + + 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["); + }); + + jjTest( + "uses the bundled JJ provider in a JJ-only repository", + () => { + const cwd = createJjRepo(); + const result = run(["bun", "run", mainPath, "log", "--vcs", "jj", "--color", "never"], cwd); + + expect(result.code).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toContain("JJ second commit"); + expect(result.stdout).toContain("JJ first commit"); + expect(result.stdout).toContain("JJ body."); + expect(result.stdout).toContain("Author: Grace Hopper "); + expect(result.stdout).toContain("main"); + expect(result.stdout).toContain("tag: v2.0.0"); + expect(result.stdout).toMatch(/commit [0-9a-f]{40}/); + expect(result.stdout).not.toContain("\x1b"); + }, + 20_000, + ); + + 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..56b3e956b --- /dev/null +++ b/test/pty/log-integration.test.ts @@ -0,0 +1,97 @@ +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"); + + // The first compact row starts with one graph cell plus two spaces, so x=4 + // lands inside its visible commit id. One press opens without a double-click. + session.writeRaw("\x1b[<0;5;1M"); + 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(); + } + }); +});