Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/pretty-git-history.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 9 additions & 0 deletions docs/extension-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
70 changes: 66 additions & 4 deletions docs/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:

Expand Down
7 changes: 7 additions & 0 deletions docs/keybindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)_ |
Expand Down
2 changes: 2 additions & 0 deletions scripts/generate-docs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>`");
expect(reference).toMatch(
new RegExp(
`\\| \\x60${SESSION_BROKER_HOST_ENV}\\x60\\s+\\| Bind host; defaults to loopback \\x60${DEFAULT_SESSION_BROKER_HOST}\\x60\\.`,
Expand Down
36 changes: 20 additions & 16 deletions scripts/generate-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
Expand Down Expand Up @@ -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}\``,
"",
Expand Down Expand Up @@ -164,8 +169,7 @@ export function renderCliReference() {
);
}
return pieces.join("\n");
},
);
});

const sessionSections = SESSION_AGENT_COMMAND_LIST.map((command) => {
const pieces = [
Expand Down
2 changes: 1 addition & 1 deletion skills/hunk-extensions/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<id>]` 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.
Expand Down
102 changes: 102 additions & 0 deletions src/app/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:");
Expand Down Expand Up @@ -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"]);

Expand Down
Loading
Loading