SLOPSHOPPER

Git Graph

A Git Graph pane for Claude Code: branches, tags, remotes and each commit's details, refreshed after every turn.

newpanecommandtoastprocess
★ 1v0.3.36MITupdated 2026-10-24alvaromunozmx/git-graph-claude
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · git-graph
│ ┃ Git Graph ✕ › fix the failing auth test and add an audit log call │ ┃ ⎇ (detached HEAD) 0 commits all branches │ ┃ ⏺ Read(src/auth.ts) │ ┃ s: ⌕ find a message, author, hash or branch ⎿ Read 6 lines │ ┃ No commits yet. ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /git-graph │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Git Graph
⎇ (detached HEAD) 0 commits all branches r: ↻ ⚙ settings s: ⌕ find a message, author, hash or branch ⏎ find No commits yet.
README

Git Graph for Claude Code

test release license

A Git Graph pane for Claude Code, in Claude Desktop and in the terminal: your branches, tags and remotes laid out in lanes, like the VS Code extension, next to the conversation.

<img alt="The Git Graph pane in Claude Desktop: lanes, branches, tags, authors, dates and hashes" src="https://raw.githubusercontent.com/alvaromunozmx/git-graph-claude/assets/screenshot.png" />

Requirements

  • Claude Code 2.1.287 or later in the terminal, or 2.1.286 or later in Claude Desktop's Code tab. Git Graph is a mod: a plugin with a hooks module, and Claude Code runs mods by default from those versions (/status in the Code tab shows its version). It is tested on 2.1.299 (Claude Desktop) and 2.1.304 (terminal). Mods don't load under --safe-mode, with "disableAllHooks": true in your settings, or where your organization lets only its own mods load: then the plugin installs but /git-graph does not appear.
  • git on the PATH, and a folder inside a git repository (submodules are picked from the repo picker).
  • Claude Desktop's Code tab or the Claude Code terminal. It is not tested in Cowork.
  • Nothing else to install: no Node, no build step. Node 22.6+ is only needed to develop it (see Development).

Install

In a terminal session:

/plugin marketplace add alvaromunozmx/git-graph-claude
/plugin install git-graph@git-graph-claude

Or from a shell:

claude plugin marketplace add alvaromunozmx/git-graph-claude
claude plugin install git-graph@git-graph-claude

In Claude Desktop's Code tab, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with alvaromunozmx/git-graph-claude, then Add plugin. A plugin installed from a terminal at the user scope loads there too.

Don't upload git-graph.zip in Settings → Plugins → Upload a plugin: that reaches Cowork, not the Code tab.

Then run /reload-plugins (or start a new session) and /git-graph.

If /git-graph doesn't appear

  1. Run /reload-plugins, or start a new session: a session loads its plugins when it starts.
  2. Run /plugin and look for an error next to git-graph; claude plugin list from a shell shows whether it is installed and enabled.
  3. Check your Claude Code version: claude --version in a shell, /status in Claude Desktop's Code tab. Mods need 2.1.287 in the terminal and 2.1.286 in Claude Desktop.
  4. In a terminal, /plugin shows a dim mods active line naming the mods the session loaded. If git-graph isn't there, check for --safe-mode, for "disableAllHooks": true in ~/.claude/settings.json, and whether your organization lets only its own mods load.

Use

  • /git-graph opens the pane: commits laid out in lanes, with the checked-out branch, local and remote branches and tags (◆) as badges, plus author, date and hash. It adapts to the pane's width.
  • The header has two lines. The first says where the repo stands (⎇ main, how many files have uncommitted changes, how many commits are drawn), with the branch picker, ⇣ fetch, ↻ and ⚙ settings on the right. The second finds (⌕ and the find field) on the left and walks the rows on the right: ↑ ↓ for the previous and next commit and HEAD for the commit HEAD points at. A narrow pane wraps each line's right side under its left.
  • A finished action (checkout, merge, push…) says so in a toast, since the pane already shows what it changed. The line under the header keeps errors, until the next action, and what is under way (fetching…).
  • ⌕ and the find field keep only the commits whose message, author, hash or branch and tag names hold the text, lit where they match. ✕ clear shows them all again.
  • Uncommitted Changes get a row of their own (○) above the commit they would follow. Click its ***** (where a commit has its hash) to list the changed files; ✎ commit… has Claude propose a message and commit them, and ⊟ stash…** stashes them, untracked files included, with an optional message.
  • Stashes (◈) fork off the commit they were made on, while every branch is drawn. Open one to apply it, pop it, make a branch… from it or drop it.
  • ⚙ settings opens the settings, kept between sessions, as a panel with a line per group:
  • Lanes: spaced / compact (with a gap, or side by side) and curves / glyphs (in Claude Desktop the lanes are SVG curves and dots by default, like Git Graph; the terminal draws them with box-drawing glyphs).
  • Columns: author, avatars (each author's initials on their own color; real pictures would need network access the pane does not have), date, and the date as absolute (19 Oct 2026 10:00), relative (3 hours ago) or iso (2026-10-19 10:00).
  • Dim: merge commits (on by default, like Git Graph) and commits HEAD does not reach.
  • The repo picker appears when the repo has submodules checked out: pick one to draw its history, with every action working there.
  • The branch picker shows every branch, only the current one, or any one branch alone, local branches listed apart from remote ones (also /git-graph all and /git-graph current). Where the surface has no picker, a button toggles all and current.
  • ⇣ fetch fetches every remote, with its tags, pruning the branches deleted there.
  • ↻ reloads. The graph also reloads on its own whenever Claude finishes a turn.
  • Keys, while the pane has the focus: j and k (↓ ↑) open the next and the previous commit (turning the page at its edges; with none open, the first of the page), h (HEAD) the one HEAD points at, s (⌕) goes to the find field, f fetches, r reloads, Esc closes the pane. Tab and the arrows walk the buttons. Under the pointer, each header button says what it does and which key presses it.
  • Click a branch or tag badge to open its actions under the row:
  • a branch: checkout, merge into current, rebase current onto this, rename…, push (setting its upstream), delete (asking again before it deletes unmerged commits), copy name; pull from origin/… moves it to its upstream, fast-forward only, checked out or not, and a line says what a push and a pull would move (↑1 to push, ↓2 to pull · origin/main).
  • on GitHub, GitLab or Bitbucket, a branch other than the remote's default also links to ↗ pull request into that default branch.
  • a remote branch: checkout (a local branch tracking it), merge into current, rebase current onto this, fetch, delete on remote.
  • a tag: checkout (detached), merge into current, push tag, delete tag.
  • Click a hash to open the commit under its row, titled with its hash and quick links (copy hash, copy subject, its page on the web), its actions as buttons in groups: what looks around, what changes the current branch, what deletes. A button waiting for its confirming press is drawn as the primary one. Under them: author, date, message and changed files. A signed commit says who signed it and whether git trusts the signature (GPG or SSH, as git is set up to check them). Each file shows its status (A, M, D, R) and the lines it added and deleted, and the commit its totals. Click a file to read its diff right there (merges against their first parent), and again to close it:
  • ↗ github.com (or gitlab.com, bitbucket.org) opens the commit's page. In the message, #123 links to that issue or pull request and web addresses are links.
  • ⇄ compare… marks it; every commit opened next lists the files that changed between the two, older to newer, each with its diff (the uncommitted row compares too). ✕ stop comparing in the header ends it.
  • copy hash and copy subject to the clipboard.
  • checkout: its local branch; or a new local branch tracking its remote one; or a detached HEAD.
  • new branch here: asks Claude, who asks you for the name and applies its own permissions.
  • tag…: opens a field under the commit. v1.2 makes a lightweight tag, v1.2: message an annotated one.
  • cherry-pick onto the current branch.
  • revert: a new commit that undoes it (a merge against its first parent).
  • merge into current: merges its branch, or the commit itself, into the current branch.
  • rebase current onto this.
  • reset current to this: then pick soft (keep the changes staged), mixed (keep them unstaged) or hard (discard them).

In motion

Opening a commit: its quick links (copy the hash or the subject, its page on GitHub), its actions, its message and files, and + Branch…, which hands the task to Claude.

<img alt="Opening a commit in the Git Graph pane: quick links, actions, message and files" src="https://raw.githubusercontent.com/alvaromunozmx/git-graph-claude/assets/gif-commit.gif" />

The branch picker, with local branches apart from remote ones, and a branch opened from its badge: how it stands against its upstream and what it can do.

<img alt="The branch picker in the Git Graph pane, then a branch opened from its badge" src="https://raw.githubusercontent.com/alvaromunozmx/git-graph-claude/assets/gif-branches.gif" />

The header's buttons, which say what they do under the pointer, and the settings: lanes, columns, date format and what is dimmed.

<img alt="The Git Graph pane's header buttons and settings panel" src="https://raw.githubusercontent.com/alvaromunozmx/git-graph-claude/assets/gif-settings.gif" />

A branch's panel and a commit's open together, the lanes going on beside them.

<img alt="A branch panel and a commit panel open in the Git Graph pane, with the lanes beside them" src="https://raw.githubusercontent.com/alvaromunozmx/git-graph-claude/assets/gif-panels.gif" />

Screenshots

Uncommitted changes, opened from their *****: the files changed in the working tree, with ✎ Commit…, ⊟ Stash… and ⇄ Compare**, here with a new file's diff open.

<img alt="The uncommitted changes opened in the Git Graph pane: the files changed, one with its diff open, and buttons to commit, stash or compare them" src="https://raw.githubusercontent.com/alvaromunozmx/git-graph-claude/assets/uncommitted.png" />

A commit, opened: its actions in groups, the author and date, its lines added and deleted, its message with links, and its files, each opening its diff.

<img alt="A commit opened in the Git Graph pane: its actions, author, date, message and changed files with lines added and deleted" src="https://raw.githubusercontent.com/alvaromunozmx/git-graph-claude/assets/commit-detail.png" />

A branch, opened from its badge: how it stands against its upstream, a link to open a pull request, and its actions.

<img alt="The current branch opened in the Git Graph pane: in step with its upstream, a pull request link, and push, pull and rename" src="https://raw.githubusercontent.com/alvaromunozmx/git-graph-claude/assets/branch.png" />

The settings, open under the header: a line per group.

<img alt="The settings panel of the Git Graph pane: lanes, columns and what is dimmed" src="https://raw.githubusercontent.com/alvaromunozmx/git-graph-claude/assets/settings.png" />

In the terminal, the same pane draws its lanes with box-drawing characters, which join from row to row there, and each key shows beside its button (s:, k:, j:, h:, f:, r:).

<img alt="The Git Graph pane in the Claude Code terminal: lanes drawn with box-drawing characters, branches, tags, authors, dates and hashes" src="https://raw.githubusercontent.com/alvaromunozmx/git-graph-claude/assets/terminal.png" />

<img alt="A commit opened in the Git Graph pane in the Claude Code terminal: its actions, message and changed files" src="https://raw.githubusercontent.com/alvaromunozmx/git-graph-claude/assets/terminal-commit.png" />

Actions that change the repo need a second click to confirm and wait for Claude to finish its turn. They refuse to run with uncommitted changes, except a soft or mixed reset. A cherry-pick, revert, merge or rebase that stops on a conflict is undone, leaving everything as it was.

Settings

limit: how many commits to load (150 by default). They are drawn a page of 40 at a time: older ▾ and ▴ newer turn the page.

How it works

It is a Claude Code mod: hooks/register.tsx registers /git-graph, opens a pane and draws it, and listens to turn.complete to reload. hooks/graph.ts reads git log and lays commits out in lanes; it does not depend on Claude Code.

What it runs and sends

Like every mod, Git Graph runs on your machine with your permissions. It has no server and no telemetry, and makes no network request of its own. claude plugin validate . lists every hook it registers and every call it makes.

  • git. Everything it shows and does is a git -C <repo> … command, run with GIT_TERMINAL_PROMPT=0 so git never waits for a password. It reads (log, status, diff, show, branch, tag, stash list, remote…) when the pane opens, when you press ↻ and when Claude finishes a turn. It changes the repo only when you press a button, and checkout, merge, rebase, cherry-pick, revert, reset, push, pop and every delete wait for a second, confirming press. Every command that changes the repo or a remote is in hooks/commands.ts.
  • Network. Only git reaches the network, and only when you press fetch, pull, push, push tag or delete on remote: to the remotes your repo already has, with the credentials git already uses.
  • Other programs. copy hash, copy subject and copy name run uname -s to tell the system, then its clipboard program with the text on standard input: pbcopy on macOS, wl-copy, xclip -selection clipboard or xsel --clipboard --input on Linux (the first one there), clip on Windows. No shell runs, and the text stays on your machine. Nothing else runs besides git.
  • Prompts to Claude. Two buttons send Claude a prompt in the current session, as if you had typed it, and only when you press them. Claude then works under its own permissions. Nothing else goes to Claude or to a model. The prompts, word for word:
  • ✎ commit…: Commit the current uncommitted changes. Look at the diff, propose a commit message and ask me before committing.
  • new branch here: Create a new git branch from commit <hash> ("<subject>"). Ask me for the branch name first, and whether to switch to it., with the commit's short hash and subject.
  • What it keeps. Your settings (lanes, columns, dimming, date format), in Claude Code's store for the plugin, on your machine. Nothing about your repos.
  • No credentials. It reads no token, key or password, from the environment or from files. A signed commit's signer and key fingerprint come from git show and are only shown in the pane. Pushing and fetching use whatever git is already set up with, inside git.
  • Hooks. session.start registers /git-graph and loads your settings; command.run answers /git-graph by opening the pane; turn.start and turn.complete note when Claude is working and reload the graph when it finishes, without changing the turn; ui.render draws the pane, and ui.close clears the open commit, the find field and the page when it closes. It does not watch or change tool calls or prompts.
  • Links. The ↗ links (a commit's page, a pull request, #123 in a message) are built from your first remote's address and open in your browser only when you click them. Avatars are initials drawn in the pane, not pictures fetched from the web.

Development

CommandWhat it does
npm run test:nodeThe lane layout's tests, with Node's test runner
npm run validateclaude plugin validate .
npm run test:modclaude plugin test .
npm run zipdist/git-graph.zip, ready to upload in Claude Desktop

To release: npm version patch (or minor, major) bumps package.json, plugin.json and marketplace.json together and tags the commit; git push --follow-tags publishes a GitHub release with the zip. Users update with /plugin marketplace update git-graph-claude.

The README's screenshots and GIFs live on the assets branch, not next to the code, so installing the plugin doesn't download them. Add a picture there and link it as https://raw.githubusercontent.com/alvaromunozmx/git-graph-claude/assets/<file>.

Changes

Every version and what it changed is in CHANGELOG.md. To report a bug or ask for something, open an issue.

License

MIT © Alvaro Muñoz. What the plugin collects (nothing) is in PRIVACY.md.

Source 4 files
hooks/register.tsx 1605 lines
1// Git Graph as a Claude Code mod.
2//
3// - /git-graph opens a pane with the repo's history laid out in lanes, like the VS Code
4//   extension: branches, remotes and tags as badges, author, date and hash per commit.
5// - Click a branch or tag badge for its actions: checkout, merge, rebase, rename, push, pull,
6//   delete, fetch, copy the name, and a link to open a pull request on GitHub, GitLab or Bitbucket.
7// - Click a hash to open that commit under its row: who signed it, its files and a row of actions. Mark one
8//   to compare, and every commit opened next lists what changed between the two.
9//   Click a file to read its diff right there.
10// - Actions that change the repo (checkout, cherry-pick, revert, merge, rebase, reset) ask for
11//   a second click to confirm; a tag is named in a field under the commit;
12//   "new branch" hands the task to Claude, so it asks for the name and its own permission.
13// - ⇣ fetch brings every remote's branches and tags, pruning the ones deleted there.
14// - A repo with submodules gets a repo picker: each submodule's history draws the same way.
15// - The branch picker draws every branch, the current one, or any one branch alone, local
16//   branches listed apart from remote ones.
17// - The find field keeps only the commits whose message, author, hash or refs hold the text.
18// - Uncommitted changes get a row of their own above the commit they would follow; stashes
19//   fork off the commit they were made on, to apply, pop, drop or branch from.
20// - The settings (lanes, columns, avatars, date format, what is dim) are kept between sessions;
21//   ⚙ opens them as a panel, a line per group.
22// - Keys, while the pane has the focus: j and k walk the commits (↓ ↑ in the header), h goes to
23//   HEAD, s finds (⌕), f fetches, r reloads; Esc closes the pane. The Buttons they press stay in
24//   the header, since a hotkey needs its Button drawn; each says what it does under the pointer.
25// - A success is a toast; the line under the header keeps errors and what is under way.
26// - Where the surface draws SVG (desktop), the lanes are curves, an SVG per row; the terminal
27//   draws them with box-drawing glyphs, which join there.
28// - The graph reloads after every turn, so commits Claude makes show up on their own.
29import { atom, read } from 'claude-code'
30import type { EngineInterface, EventOf, Register } from 'claude-code'
31
32import { checkoutCommit, checkoutRemote, git as cmd, splitRemote } from './commands'
33import { ancestorsOf, avatarOf, colorIndexOf, commitUrl, CURVE_CELL, curvesOf, DATE_ARGS, describeSignature, describeUpstream, FORMAT, forgeOf, glyphOf, goesDown, initialsOf, layout, linesDownOf, linkify, matches, parseDiff, parseLog, parseNameStatus, parseNumstat, parseStashes, parseStatus, parseSubmodules, parseTagInput, parseUpstreams, pullRequestUrl, SEP, SIGNATURE_FORMAT, splitBranches, splitMatch, STASH_FORMAT, summary, UPSTREAM_FORMAT, withStashes, withStats, WORKTREE, worktreeRow } from './graph'
34import type { Cell, Commit, CommitAction, DateFormat, Detail, Diff, FileChange, Graph, Note, Pending, Ref, RefAction, ResetMode, StashAction } from '../types'
35
36type Engine = EngineInterface
37type RenderOf<C extends string> = Extract<EventOf['ui.render'], { component: C }>
38/** The element table of the surface drawing the pane. */
39type Ui = any
40
41const PANE = 'git-graph'
42// Rows drawn at once: older and newer pages replace them. Every row goes to the surface on each
43// redraw, so the page keeps that bounded however long the history is
44const PAGE = 40
45
46const GRAPH_REF = { plugin: 'git-graph', key: 'graph' } as const
47const SELECTED_REF = { plugin: 'git-graph', key: 'selected' } as const
48const DETAIL_REF = { plugin: 'git-graph', key: 'detail' } as const
49const DIFF_REF = { plugin: 'git-graph', key: 'diff' } as const
50const ALL_REF = { plugin: 'git-graph', key: 'allBranches' } as const
51const FILTER_REF = { plugin: 'git-graph', key: 'branchFilter' } as const
52const REPO_REF = { plugin: 'git-graph', key: 'repo' } as const
53const PICKER_REDRAW_REF = { plugin: 'git-graph', key: 'pickerRedraw' } as const
54const PAGE_START_REF = { plugin: 'git-graph', key: 'pageStart' } as const
55const COMPACT_REF = { plugin: 'git-graph', key: 'compact' } as const
56const SETTINGS_REF = { plugin: 'git-graph', key: 'settingsOpen' } as const
57const MUTE_MERGES_REF = { plugin: 'git-graph', key: 'muteMerges' } as const
58const CURVES_REF = { plugin: 'git-graph', key: 'curves' } as const
59const AVATARS_REF = { plugin: 'git-graph', key: 'avatars' } as const
60const SHOW_AUTHOR_REF = { plugin: 'git-graph', key: 'showAuthor' } as const
61const SHOW_DATE_REF = { plugin: 'git-graph', key: 'showDate' } as const
62const DATE_FORMAT_REF = { plugin: 'git-graph', key: 'dateFormat' } as const
63const MUTE_OTHERS_REF = { plugin: 'git-graph', key: 'muteOthers' } as const
64const PENDING_REF = { plugin: 'git-graph', key: 'pending' } as const
65const NOTE_REF = { plugin: 'git-graph', key: 'note' } as const
66const QUERY_REF = { plugin: 'git-graph', key: 'query' } as const
67const TAGGING_REF = { plugin: 'git-graph', key: 'tagging' } as const
68const REF_REF = { plugin: 'git-graph', key: 'ref' } as const
69const STASHING_REF = { plugin: 'git-graph', key: 'stashing' } as const
70const COMPARE_REF = { plugin: 'git-graph', key: 'compareBase' } as const
71const RENAMING_REF = { plugin: 'git-graph', key: 'renaming' } as const
72const GRAPH = atom(GRAPH_REF, null)
73const SELECTED = atom(SELECTED_REF, null)
74const DETAIL = atom(DETAIL_REF, null)
75const DIFF = atom(DIFF_REF, null)
76const ALL = atom(ALL_REF, true)
77const FILTER = atom(FILTER_REF, null)
78const REPO = atom(REPO_REF, null)
79const PICKER_REDRAW = atom(PICKER_REDRAW_REF, 0)
80const PAGE_START = atom(PAGE_START_REF, 0)
81const COMPACT = atom(COMPACT_REF, false)
82const SETTINGS = atom(SETTINGS_REF, false)
83const MUTE_MERGES = atom(MUTE_MERGES_REF, true)
84// Curves by default: in a proportional font with tall rows, glyphs cannot join from row to row
85const CURVES = atom(CURVES_REF, true)
86const AVATARS = atom(AVATARS_REF, true)
87const SHOW_AUTHOR = atom(SHOW_AUTHOR_REF, true)
88const SHOW_DATE = atom(SHOW_DATE_REF, true)
89const DATE_FORMAT = atom(DATE_FORMAT_REF, 'absolute' as DateFormat)
90const DATE_FORMATS: DateFormat[] = ['absolute', 'relative', 'iso']
91const MUTE_OTHERS = atom(MUTE_OTHERS_REF, false)
92const PENDING = atom(PENDING_REF, null)
93const NOTE = atom(NOTE_REF, null)
94const QUERY = atom(QUERY_REF, '')
95const TAGGING = atom(TAGGING_REF, null)
96const REF = atom(REF_REF, null)
97const STASHING = atom(STASHING_REF, null)
98const COMPARE = atom(COMPARE_REF, null)
99const RENAMING = atom(RENAMING_REF, null)
100
101// One color per lane, cycling, like Git Graph's palette
102const LANE_COLORS = ['blue', 'magenta', 'green', 'yellow', 'cyan', 'red']
103const REF_COLOR: Record<Ref['kind'], string> = { head: 'green', local: 'blue', remote: 'magenta', tag: 'yellow' }
104const NOTE_COLOR: Record<Note['kind'], string> = { ok: 'green', error: 'red', info: 'cyan' }
105const AUTHOR_WIDTH = 14
106const DATE_WIDTH = 17
107const HASH_WIDTH = 7
108// What a row's lanes say to a reader that cannot see them
109const LANES_ALT = 'graph'
110// How far the lanes beside an open panel reach: the panel's own height clips them
111const PANEL_ROWS = 200
112const LANE_DOWN = Array.from({ length: PANEL_ROWS }, () => '│').join('\n')
113// An avatar and the space after it
114const AVATAR_WIDTH = 3
115// The settings' group names, in a column of their own
116const SETTING_LABEL_WIDTH = 8
117
118let limit = 150
119let isOpen = false
120let isBusy = false
121let loading: Promise<void> | null = null
122
123export const register: Register = (on, options) => {
124  limit = Math.max(20, Number(options.limit) || 150)
125
126  on('session.start', async ($, e, next) => {
127    await $.command.register({
128      name: 'git-graph',
129      description: 'Open the Git Graph pane · all: every branch · current: only this one',
130      argumentHint: '[all | current]',
131      immediate: true
132    })
133    await loadSettings($)
134    return next(e)
135  })
136
137  on('command.run', { command: 'git-graph' }, async ($, e) => {
138    const arg = e.args.trim().toLowerCase()
139    if (arg === 'all' || arg === 'current') {
140      await $.state.set(ALL_REF, arg === 'all')
141      await $.state.set(FILTER_REF, null)
142    } else if (arg !== '') return { text: 'Usage: /git-graph [all | current]' }
143    await reload($)
144    isOpen = true
145    await $.ui.open({ id: PANE, title: 'Git Graph', focus: true, closeOnEscape: true })
146    // The pane is the answer: nothing for the transcript or the model
147    return {}
148  })
149
150  on('turn.start', async ($, e, next) => {
151    if (e.agentId === undefined) isBusy = true
152    return next(e)
153  })
154
155  // Claude may have committed, switched branches or pulled: draw the history again
156  on('turn.complete', async ($, e, next) => {
157    const result = await next(e)
158    if (e.agentId === undefined) {
159      isBusy = false
160      if (isOpen) await reload($)
161    }
162    return result
163  })
164
165  // A drawing that throws leaves the pane empty: show the error in it instead
166  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
167    try {
168      return await drawPane($, e)
169    } catch (error) {
170      const message = error instanceof Error ? `${error.message}\n${error.stack ?? ''}` : String(error)
171      $.ui.log(`git-graph: ${message}`, { to: 'debug' })
172      const { Box, Text } = $.ui.resolve(e)
173      return (
174        <Box flexDirection="column">
175          <Text color="red">git-graph could not draw the pane:</Text>
176          {message.split('\n').slice(0, 12).map((line, i) => <Text key={`err-${i}`} dimColor wrap="truncate-end">{line || ' '}</Text>)}
177        </Box>
178      )
179    }
180  })
181
182  on('ui.close', { id: PANE }, async ($, e, next) => {
183    isOpen = false
184    await clearSelection($)
185    await $.state.set(PAGE_START_REF, 0)
186    await $.state.set(NOTE_REF, null)
187    await $.state.set(QUERY_REF, '')
188    await $.state.set(REF_REF, null)
189    await $.state.set(COMPARE_REF, null)
190    return next(e)
191  })
192}
193
194// ── Reading the repo ───────────────────────────────────────────────────────────
195
196/** Runs git in the repo drawn: the session's, or the submodule picked. */
197async function git ($: Engine, args: string[], okCodes: number[] = [0], timeoutMs?: number) {
198  return gitIn($, (await read($, REPO)) ?? (await $.session.cwd()), args, okCodes, timeoutMs)
199}
200
201async function gitIn ($: Engine, cwd: string, args: string[], okCodes: number[] = [0], timeoutMs?: number) {
202  // A remote that asks for a login fails at once instead of waiting for input nobody can type
203  const result = await $.process.run(['git', '-C', cwd, ...args], { env: { GIT_TERMINAL_PROMPT: '0' }, ...(timeoutMs ? { timeoutMs } : {}) })
204  if (!okCodes.includes(result.exitCode)) throw new Error((result.stderr || result.stdout).trim() || `git ${args[0]} failed`)
205  return result.stdout
206}
207
208function reload ($: Engine): Promise<void> {
209  // Two turns ending close together share one load
210  loading ??= load($).finally(() => { loading = null })
211  return loading
212}
213
214async function load ($: Engine) {
215  const allBranches = await read($, ALL)
216  const dateFormat = await read($, DATE_FORMAT)
217  let filter = await read($, FILTER)
218  let graph: Graph
219  try {
220    const [local, remote] = splitBranches(await git($, ['for-each-ref', '--format=%(refname)', 'refs/heads', 'refs/remotes']).catch(() => ''))
221    const branches = [...local, ...remote]
222    const upstreams = parseUpstreams(await git($, ['for-each-ref', `--format=${UPSTREAM_FORMAT}`, 'refs/heads']).catch(() => ''))
223    // A picked branch that was deleted since: back to the branches the toggle says
224    if (filter !== null && !branches.includes(filter)) {
225      filter = null
226      await $.state.set(FILTER_REF, null)
227    }
228    const [remoteList, branch, head, status, stashList, log] = await Promise.all([
229      git($, ['remote']),
230      git($, ['branch', '--show-current']).catch(() => ''),
231      git($, ['rev-parse', 'HEAD']).catch(() => ''),
232      git($, ['status', '--porcelain=v1', '-uall']).catch(() => ''),
233      // Stashes belong with every branch, not with one drawn alone
234      filter === null && allBranches ? git($, ['stash', 'list', `--format=${STASH_FORMAT}`, DATE_ARGS[dateFormat]]).catch(() => '') : Promise.resolve(''),
235      git($, [
236        // The stash ref is drawn from the stash list instead, one row per stash
237        'log', ...(filter !== null ? [filter] : allBranches ? ['--exclude=refs/stash', '--all'] : []), '--topo-order', `-n${limit}`,
238        `--format=${FORMAT}`, DATE_ARGS[dateFormat], '--'
239      ])
240    ])
241    const repos = await listRepos($)
242    const remotes = remoteList.split('\n').map(name => name.trim()).filter(Boolean).sort((a, b) => Number(b === 'origin') - Number(a === 'origin'))
243    const forge = remotes[0] ? forgeOf(await git($, ['remote', 'get-url', remotes[0]]).catch(() => '')) : null
244    // Where pull requests go: the remote's HEAD, else main or master, whichever exists
245    const remoteHead = remotes[0] ? (await git($, ['symbolic-ref', '--short', `refs/remotes/${remotes[0]}/HEAD`]).catch(() => '')).trim() : ''
246    const defaultBranch = remoteHead ? remoteHead.slice(remoteHead.indexOf('/') + 1) : branches.includes('master') && !branches.includes('main') ? 'master' : 'main'
247    const parsed = withStashes(parseLog(log, new Set(remotes)), parseStashes(stashList))
248    const changes = parseStatus(status)
249    // The uncommitted row leads to HEAD, so the layout draws its line down to it
250    const headHash = head.trim()
251    if (changes.length > 0) parsed.unshift(worktreeRow(changes, parsed.some(commit => commit.hash === headHash) ? headHash : null))
252    graph = {
253      commits: layout(parsed),
254      branch: branch.trim() || '(detached HEAD)',
255      changes,
256      branches,
257      localCount: local.length,
258      upstreams,
259      remotes,
260      head: headHash,
261      forge,
262      defaultBranch,
263      repos,
264      error: null,
265      loadedAt: await $.clock.now()
266    }
267  } catch (error) {
268    const message = error instanceof Error ? error.message : String(error)
269    graph = { commits: [], branch: '', changes: [], branches: [], localCount: 0, upstreams: {}, remotes: [], head: '', forge: null, defaultBranch: 'main', repos: await listRepos($).catch(() => []), error: /not a git repository/i.test(message) ? 'This folder is not a git repository.' : message, loadedAt: await $.clock.now() }
270  }
271  await $.state.set(GRAPH_REF, graph)
272
273  // A selected commit or badge that is no longer in the graph loses its details
274  const selected = await read($, SELECTED)
275  if (selected && !graph.commits.some(commit => commit.hash === selected)) await clearSelection($)
276  const ref = await read($, REF)
277  if (ref && !graph.commits.some(commit => commit.refs.some(item => item.kind === ref.kind && item.name === ref.name))) await selectRef($, null)
278}
279
280async function clearSelection ($: Engine) {
281  await $.state.set(SELECTED_REF, null)
282  await $.state.set(DETAIL_REF, null)
283  await $.state.set(DIFF_REF, null)
284  await $.state.set(PENDING_REF, null)
285  await $.state.set(TAGGING_REF, null)
286  await $.state.set(STASHING_REF, null)
287}
288
289async function select ($: Engine, hash: string) {
290  if ((await read($, SELECTED)) === hash) return clearSelection($)
291  await openCommit($, hash)
292}
293
294/** Opens a commit's detail, closing whichever was open. */
295async function openCommit ($: Engine, hash: string) {
296  await $.state.set(SELECTED_REF, hash)
297  await $.state.set(PENDING_REF, null)
298  await $.state.set(DIFF_REF, null)
299  await $.state.set(TAGGING_REF, null)
300  await $.state.set(DETAIL_REF, { hash, meta: '', message: '', files: [], isLoading: true })
301  const detail = await readDetail($, hash)
302  if ((await read($, SELECTED)) === hash) await $.state.set(DETAIL_REF, detail)
303}
304
305async function readDetail ($: Engine, hash: string): Promise<NonNullable<Detail>> {
306  const graph = await read($, GRAPH)
307  const base = await read($, COMPARE)
308  if (base !== null && base !== hash && graph) return readComparison($, graph, base, hash)
309  if (hash === WORKTREE) {
310    const stats = parseNumstat(await git($, ['diff', '--numstat', '-M', 'HEAD']).catch(() => ''))
311    const files = withStats(graph?.changes ?? [], stats)
312    return { hash, meta: `${summary(files)} in the working tree`, message: '', files }
313  }
314  const parent = graph?.commits.find(commit => commit.hash === hash)?.parents[0]
315  try {
316    // A merge is shown against its first parent, as Git Graph does
317    const range = parent ? [parent, hash] : ['--root', hash]
318    const [shown, names, numstat] = await Promise.all([
319      // The first line is the signature, as git checks it where signing is set up
320      git($, ['show', '-s', `--format=${SIGNATURE_FORMAT}%n%an <%ae> · %ad%n%B`, '--date=format:%d %b %Y %H:%M', hash]),
321      git($, ['diff-tree', '-r', '-M', '--name-status', '--no-commit-id', ...range]),
322      git($, ['diff-tree', '-r', '-M', '--numstat', '--no-commit-id', ...range]).catch(() => '')
323    ])
324    let lines = shown.trimEnd().split('\n')
325    let signature
326    if (lines[0]?.includes(SEP)) {
327      const [status = '', signer = '', fingerprint = ''] = lines[0].split(SEP)
328      signature = describeSignature(status, signer, fingerprint)
329      lines = lines.slice(1)
330    }
331    const [meta = '', ...message] = lines
332    const files = withStats(parseNameStatus(names), parseNumstat(numstat))
333    return { hash, meta: `${meta}  ·  ${summary(files)}`, message: message.join('\n').trim(), files, ...(signature ? { signature } : {}) }
334  } catch (error) {
335    return { hash, meta: `Could not read the commit: ${error instanceof Error ? error.message : String(error)}`, message: '', files: [] }
336  }
337}
338
339/** What changed from the older of two rows to the newer: the working tree is the newest of all. */
340async function readComparison ($: Engine, graph: Graph, base: string, hash: string): Promise<NonNullable<Detail>> {
341  const order = (target: string) => graph.commits.findIndex(commit => commit.hash === target)
342  const [from, to] = order(base) > order(hash) ? [base, hash] : [hash, base]
343  const label = (target: string) => (target === WORKTREE ? 'the working tree' : target.slice(0, HASH_WIDTH))
344  try {
345    const range = [from, ...(to === WORKTREE ? [] : [to])]
346    const [names, numstat] = await Promise.all([
347      git($, ['diff', '--name-status', '-M', ...range]),
348      git($, ['diff', '--numstat', '-M', ...range]).catch(() => '')
349    ])
350    const files = withStats(parseNameStatus(names), parseNumstat(numstat))
351    return { hash, meta: `${summary(files)} from ${label(from)} to ${label(to)}`, message: '', files, compare: { from, to } }
352  } catch (error) {
353    return { hash, meta: `Could not compare them: ${error instanceof Error ? error.message : String(error)}`, message: '', files: [] }
354  }
355}
356
357/** Marks this commit to compare others with, or stops comparing when it is the one marked. */
358async function toggleCompare ($: Engine, hash: string) {
359  const base = await read($, COMPARE)
360  // The header's comparing line says so: no note repeats it
361  await $.state.set(COMPARE_REF, base === hash ? null : hash)
362}
363
364async function stopComparing ($: Engine) {
365  await $.state.set(COMPARE_REF, null)
366  const selected = await read($, SELECTED)
367  if (selected) {
368    await $.state.set(DIFF_REF, null)
369    await $.state.set(DETAIL_REF, await readDetail($, selected))
370  }
371}
372
373/** The rows the pane draws now: the ones the find field keeps, up to the page shown. */
374async function visibleRows ($: Engine) {
375  const graph = await read($, GRAPH)
376  const query = (await read($, QUERY)).trim()
377  return (graph?.commits ?? []).filter(commit => query === '' || matches(commit, query))
378}
379
380/** j and k: open the next or the previous row, showing another page when the walk reaches it. */
381async function step ($: Engine, by: 1 | -1) {
382  const rows = await visibleRows($)
383  if (rows.length === 0) return
384  const selected = await read($, SELECTED)
385  const at = rows.findIndex(commit => commit.hash === selected)
386  // With nothing open, either key starts at the top of the page drawn
387  const next = at === -1 ? Math.min(rows.length - 1, await read($, PAGE_START)) : Math.min(rows.length - 1, Math.max(0, at + by))
388  await reveal($, next)
389  const target = rows[next]
390  if (target && target.hash !== selected) await openCommit($, target.hash)
391}
392
393/** h: open the commit HEAD points at. */
394async function goHead ($: Engine) {
395  const rows = await visibleRows($)
396  const at = rows.findIndex(commit => commit.refs.some(ref => ref.kind === 'head'))
397  if (at === -1) return note($, 'info', 'HEAD is not among the commits drawn')
398  await reveal($, at)
399  const target = rows[at]
400  if (target && target.hash !== (await read($, SELECTED))) await openCommit($, target.hash)
401}
402
403/** Turns to the page that draws the row at `index`. */
404async function reveal ($: Engine, index: number) {
405  const start = Math.floor(index / PAGE) * PAGE
406  if (start !== (await read($, PAGE_START))) await $.state.set(PAGE_START_REF, start)
407}
408
409/** s: the focus goes to the find field. */
410async function focusFind ($: Engine) {
411  await $.ui.focus({ requestId: PANE, key: 'find' }).catch(() => undefined)
412}
413
414/** Opens one file's diff under it, or closes it when it is the one open. */
415async function toggleDiff ($: Engine, hash: string, file: FileChange) {
416  const open = await read($, DIFF)
417  if (open && open.hash === hash && open.path === file.path) return $.state.set(DIFF_REF, null)
418  await $.state.set(DIFF_REF, { hash, path: file.path, hunks: '', isLoading: true })
419  const diff = await readDiff($, hash, file)
420  const now = await read($, DIFF)
421  if (now?.hash === hash && now.path === file.path) await $.state.set(DIFF_REF, diff)
422}
423
424async function readDiff ($: Engine, hash: string, file: FileChange): Promise<NonNullable<Diff>> {
425  const paths = ['--', ...(file.oldPath ? [file.oldPath] : []), file.path]
426  try {
427    let stdout: string
428    const detail = await read($, DETAIL)
429    const compare = detail?.hash === hash ? detail.compare : undefined
430    if (compare) {
431      stdout = await git($, ['diff', '-M', compare.from, ...(compare.to === WORKTREE ? [] : [compare.to]), ...paths])
432    } else if (hash === WORKTREE) {
433      // An untracked file is new against nothing; `--no-index` exits 1 when they differ
434      stdout = file.status === '?'
435        ? await git($, ['diff', '--no-index', '--', '/dev/null', file.path], [0, 1])
436        : await git($, ['diff', '-M', 'HEAD', ...paths]).catch(() => git($, ['diff', '--cached', '-M', ...paths]))
437    } else {
438      const graph = await read($, GRAPH)
439      const parent = graph?.commits.find(commit => commit.hash === hash)?.parents[0]
440      stdout = parent
441        ? await git($, ['diff', '-M', parent, hash, ...paths])
442        : await git($, ['show', '--format=', '-M', hash, ...paths])
443    }
444    return { hash, path: file.path, ...parseDiff(stdout) }
445  } catch (error) {
446    return { hash, path: file.path, hunks: '', note: `Could not read the diff: ${error instanceof Error ? error.message : String(error)}` }
447  }
448}
449
450// One function per value: $.state.set only takes a reference named in this file
451async function toggleAll ($: Engine) {
452  await $.state.set(ALL_REF, !(await read($, ALL)))
453  await $.state.set(FILTER_REF, null)
454  await reload($)
455}
456
457/** The session's repo, then each submodule checked out in it, deepest last. */
458async function listRepos ($: Engine) {
459  const cwd = await $.session.cwd()
460  const root = (await gitIn($, cwd, ['rev-parse', '--show-toplevel']).catch(() => '')).trim()
461  if (root === '') return []
462  const submodules = parseSubmodules(await gitIn($, root, ['submodule', 'status', '--recursive']).catch(() => ''))
463  return [{ path: root, label: root.split('/').at(-1) ?? root }, ...submodules.map(path => ({ path: `${root}/${path}`, label: path }))]
464}
465
466/** Draws another repo: what was open, picked or compared there does not carry over. */
467async function pickRepo ($: Engine, path: string) {
468  const graph = await read($, GRAPH)
469  await $.state.set(REPO_REF, path === graph?.repos[0]?.path ? null : path)
470  await $.state.set(FILTER_REF, null)
471  await $.state.set(COMPARE_REF, null)
472  await $.state.set(REF_REF, null)
473  await $.state.set(PAGE_START_REF, 0)
474  await clearSelection($)
475  await reload($)
476}
477
478/** The branch picker's value: `all`, `current`, or `ref:<branch>` to draw that branch alone. */
479async function pickBranches ($: Engine, value: string) {
480  // A separator picks nothing: the picker draws again with what it showed
481  if (value.startsWith('sep:')) return $.state.set(PICKER_REDRAW_REF, (await read($, PICKER_REDRAW)) + 1)
482  if (value === 'all' || value === 'current') {
483    await $.state.set(ALL_REF, value === 'all')
484    await $.state.set(FILTER_REF, null)
485  } else {
486    await $.state.set(FILTER_REF, value.slice(4))
487  }
488  await $.state.set(PAGE_START_REF, 0)
489  await reload($)
490}
491
492async function find ($: Engine, value: string) {
493  // Kept as typed: the field is drawn from it, and a trimmed space would vanish while typing
494  await $.state.set(QUERY_REF, value)
495  await $.state.set(PAGE_START_REF, 0)
496}
497
498async function toggleCompact ($: Engine) {
499  await $.state.set(COMPACT_REF, !(await read($, COMPACT)))
500  await saveSettings($)
501}
502
503async function toggleShowAuthor ($: Engine) {
504  await $.state.set(SHOW_AUTHOR_REF, !(await read($, SHOW_AUTHOR)))
505  await saveSettings($)
506}
507
508async function toggleShowDate ($: Engine) {
509  await $.state.set(SHOW_DATE_REF, !(await read($, SHOW_DATE)))
510  await saveSettings($)
511}
512
513/** absolute → relative → iso: the dates are read again from git in the new format. */
514async function cycleDateFormat ($: Engine) {
515  const now = await read($, DATE_FORMAT)
516  await $.state.set(DATE_FORMAT_REF, DATE_FORMATS[(DATE_FORMATS.indexOf(now) + 1) % DATE_FORMATS.length] ?? 'absolute')
517  await saveSettings($)
518  await reload($)
519}
520
521/** The settings, kept in the plugin's store so the next session draws the same way. */
522async function saveSettings ($: Engine) {
523  const settings = {
524    compact: await read($, COMPACT),
525    curves: await read($, CURVES),
526    avatars: await read($, AVATARS),
527    showAuthor: await read($, SHOW_AUTHOR),
528    showDate: await read($, SHOW_DATE),
529    dateFormat: await read($, DATE_FORMAT),
530    muteMerges: await read($, MUTE_MERGES),
531    muteOthers: await read($, MUTE_OTHERS)
532  }
533  await $.store.set('settings', settings).catch(() => undefined)
534}
535
536/** Reads the kept settings back; a value of the wrong kind keeps its default. */
537async function loadSettings ($: Engine) {
538  const saved = await $.store.get('settings').catch(() => undefined)
539  if (typeof saved !== 'object' || saved === null) return
540  const value = saved as Record<string, unknown>
541  const flag = (key: string) => (typeof value[key] === 'boolean' ? value[key] as boolean : undefined)
542  const compact = flag('compact')
543  if (compact !== undefined) await $.state.set(COMPACT_REF, compact)
544  const curves = flag('curves')
545  if (curves !== undefined) await $.state.set(CURVES_REF, curves)
546  const avatars = flag('avatars')
547  if (avatars !== undefined) await $.state.set(AVATARS_REF, avatars)
548  const showAuthor = flag('showAuthor')
549  if (showAuthor !== undefined) await $.state.set(SHOW_AUTHOR_REF, showAuthor)
550  const showDate = flag('showDate')
551  if (showDate !== undefined) await $.state.set(SHOW_DATE_REF, showDate)
552  const muteMerges = flag('muteMerges')
553  if (muteMerges !== undefined) await $.state.set(MUTE_MERGES_REF, muteMerges)
554  const muteOthers = flag('muteOthers')
555  if (muteOthers !== undefined) await $.state.set(MUTE_OTHERS_REF, muteOthers)
556  const dateFormat = DATE_FORMATS.find(format => format === value.dateFormat)
557  if (dateFormat) await $.state.set(DATE_FORMAT_REF, dateFormat)
558}
559
560async function toggleSettings ($: Engine) {
561  await $.state.set(SETTINGS_REF, !(await read($, SETTINGS)))
562}
563
564async function toggleCurves ($: Engine) {
565  await $.state.set(CURVES_REF, !(await read($, CURVES)))
566  await saveSettings($)
567}
568
569async function toggleAvatars ($: Engine) {
570  await $.state.set(AVATARS_REF, !(await read($, AVATARS)))
571  await saveSettings($)
572}
573
574async function toggleMuteMerges ($: Engine) {
575  await $.state.set(MUTE_MERGES_REF, !(await read($, MUTE_MERGES)))
576  await saveSettings($)
577}
578
579async function toggleMuteOthers ($: Engine) {
580  await $.state.set(MUTE_OTHERS_REF, !(await read($, MUTE_OTHERS)))
581  await saveSettings($)
582}
583
584/**
585 * Says how an action went. A success is a toast alone: the pane already shows what it changed (the
586 * branch in the header, a new row), so a line under the header would only repeat it. An error stays
587 * there, with a toast too, until the next action; info (fetching…, asked Claude) only there.
588 */
589async function note ($: Engine, kind: Note['kind'], text: string) {
590  await $.state.set(NOTE_REF, kind === 'ok' ? null : { kind, text })
591  if (kind !== 'info') $.ui.toast(`Git Graph · ${text}`)
592}
593
594// ── Actions ────────────────────────────────────────────────────────────────────
595
596// Each platform's clipboard programs, tried in order; the text goes in on standard input, no shell
597const CLIPBOARDS: Record<string, string[][]> = {
598  Darwin: [['pbcopy']],
599  Linux: [['wl-copy'], ['xclip', '-selection', 'clipboard'], ['xsel', '--clipboard', '--input']]
600}
601
602async function copyText ($: Engine, text: string, what: string) {
603  const platform = (await $.process.run(['uname', '-s']).catch(() => undefined))?.stdout.trim() ?? ''
604  for (const argv of CLIPBOARDS[platform] ?? [['clip']]) {
605    const result = await $.process.run(argv, { stdin: text }).catch(() => undefined)
606    if (result?.exitCode === 0) return note($, 'ok', `copied ${what}`)
607  }
608  await note($, 'error', 'could not reach the clipboard')
609}
610
611/** The words an action's confirming button asks with. */
612function confirmText (action: CommitAction, commit: Commit, graph: Graph): string {
613  const short = commit.hash.slice(0, HASH_WIDTH)
614  const branch = graph.branch
615  switch (action) {
616    case 'checkout': return `confirm: checkout ${checkoutCommit(commit, graph.remotes, graph.branches).label}?`
617    case 'cherry-pick': return `confirm: cherry-pick onto ${branch}?`
618    case 'revert': return `confirm: revert ${short} on ${branch}?`
619    case 'merge': return `confirm: merge ${mergeTarget(commit)} into ${branch}?`
620    case 'rebase': return `confirm: rebase ${branch} onto ${short}?`
621    case 'reset': return `reset ${branch} to ${short}:`
622  }
623}
624
625/** What a merge names: the commit's branch when it has one, so the message reads well. */
626function mergeTarget (commit: Commit): string {
627  return commit.refs.find(ref => ref.kind === 'local' || ref.kind === 'remote')?.name ?? commit.hash.slice(0, HASH_WIDTH)
628}
629
630/** First click arms the action, the second one runs it. */
631async function arm ($: Engine, action: CommitAction, hash: string) {
632  const pending = await read($, PENDING)
633  if (!pending || pending.action !== action || pending.hash !== hash) {
634    await $.state.set(PENDING_REF, { action, hash })
635    return
636  }
637  await $.state.set(PENDING_REF, null)
638  // A reset runs from the mode picked under it, not from a second click on it
639  if (action !== 'reset') await run($, action, hash)
640}
641
642async function run ($: Engine, action: CommitAction, hash: string, mode: ResetMode = 'mixed') {
643  await $.state.set(PENDING_REF, null)
644  if (isBusy) return note($, 'error', 'Claude is working: try again when the turn ends')
645
646  const graph = await read($, GRAPH)
647  const commit = graph?.commits.find(item => item.hash === hash)
648  if (!graph || !commit) return
649  const short = hash.slice(0, HASH_WIDTH)
650
651  // A soft or mixed reset keeps the working tree as it is; everything else wants it clean
652  if (!(action === 'reset' && mode !== 'hard')) {
653    const dirty = (await git($, ['status', '--porcelain']).catch(() => '')).trim()
654    if (dirty) return note($, 'error', 'uncommitted changes: commit or stash them first')
655  }
656
657  try {
658    switch (action) {
659      case 'checkout': {
660        const target = checkoutCommit(commit, graph.remotes, graph.branches)
661        await git($, target.args)
662        await note($, 'ok', `checked out ${target.label}`)
663        break
664      }
665      case 'cherry-pick':
666        await undoOnConflict($, cmd.cherryPick(hash), cmd.abort('cherry-pick'))
667        await note($, 'ok', `cherry-picked ${short} onto ${graph.branch}`)
668        break
669      case 'revert':
670        // A merge is reverted against its first parent, the side it was merged into
671        await undoOnConflict($, cmd.revert(hash, commit.parents.length > 1), cmd.abort('revert'))
672        await note($, 'ok', `reverted ${short} on ${graph.branch}`)
673        break
674      case 'merge':
675        await undoOnConflict($, cmd.merge(mergeTarget(commit)), cmd.abort('merge'))
676        await note($, 'ok', `merged ${mergeTarget(commit)} into ${graph.branch}`)
677        break
678      case 'rebase':
679        await undoOnConflict($, cmd.rebase(hash), cmd.abort('rebase'))
680        await note($, 'ok', `rebased ${graph.branch} onto ${short}`)
681        break
682      case 'reset':
683        await git($, cmd.reset(mode, hash))
684        await note($, 'ok', `reset ${graph.branch} to ${short} (${mode})`)
685        break
686    }
687  } catch (error) {
688    await note($, 'error', (error instanceof Error ? error.message : String(error)).split('\n')[0] ?? '')
689  }
690  await reload($)
691}
692
693/** Runs an action that can stop on a conflict; a conflict is undone, leaving the repo as it was. */
694async function undoOnConflict ($: Engine, args: string[], abort: string[]) {
695  try {
696    await git($, args)
697  } catch (error) {
698    await git($, abort).catch(() => '')
699    const message = error instanceof Error ? error.message : String(error)
700    throw new Error(/conflict/i.test(message) ? `${args[0]} stopped on a conflict and was undone: ask Claude to ${args[0]} and resolve it` : message)
701  }
702}
703
704async function toggleTagging ($: Engine, hash: string) {
705  await $.state.set(TAGGING_REF, (await read($, TAGGING)) === hash ? null : hash)
706}
707
708/** `name` makes a lightweight tag, `name: message` an annotated one. */
709async function tag ($: Engine, hash: string, value: string) {
710  const { name, message } = parseTagInput(value)
711  if (name === '') return
712  try {
713    await git($, ['check-ref-format', `refs/tags/${name}`]).catch(() => { throw new Error(`“${name}” is not a valid tag name`) })
714    await git($, cmd.tag(name, hash, message))
715    await $.state.set(TAGGING_REF, null)
716    await note($, 'ok', `tagged ${hash.slice(0, HASH_WIDTH)} as ${name}`)
717    await reload($)
718  } catch (error) {
719    await note($, 'error', (error instanceof Error ? error.message : String(error)).split('\n')[0] ?? '')
720  }
721}
722
723// ── Branch and tag badges ──────────────────────────────────────────────────────
724
725/** Opens a badge's actions, or closes them when it is the one open. */
726async function selectRef ($: Engine, ref: Ref | null) {
727  const open = await read($, REF)
728  const isSame = open && ref && open.kind === ref.kind && open.name === ref.name
729  await $.state.set(REF_REF, isSame ? null : ref)
730  await $.state.set(RENAMING_REF, null)
731  const pending = await read($, PENDING)
732  if (pending && !(pending.action in COMMIT_ACTIONS)) await $.state.set(PENDING_REF, null)
733}
734
735const COMMIT_ACTIONS: Record<CommitAction, true> = { checkout: true, 'cherry-pick': true, revert: true, merge: true, rebase: true, reset: true }
736
737function refConfirmText (action: RefAction, ref: Ref, graph: Graph): string {
738  const remote = graph.remotes[0] ?? 'origin'
739  switch (action) {
740    case 'checkout-ref': return ref.kind === 'remote' ? `confirm: checkout ${splitRemote(ref.name, graph.remotes).branch} tracking ${ref.name}?` : `confirm: checkout ${ref.name}?`
741    case 'merge-ref': return `confirm: merge ${ref.name} into ${graph.branch}?`
742    case 'rebase-ref': return `confirm: rebase ${graph.branch} onto ${ref.name}?`
743    case 'delete-branch': return `confirm: delete branch ${ref.name}?`
744    case 'force-delete-branch': return `confirm: delete ${ref.name} and its unmerged commits?`
745    case 'delete-remote-branch': return `confirm: delete ${ref.name} on the remote?`
746    case 'delete-tag': return `confirm: delete tag ${ref.name}?`
747    case 'push-ref': return ref.kind === 'tag' ? `confirm: push tag ${ref.name} to ${remote}?` : `confirm: push ${ref.name} to ${remote}?`
748  }
749}
750
751/** First click arms a badge's action, the second one runs it. */
752async function armRef ($: Engine, action: RefAction, ref: Ref) {
753  const pending = await read($, PENDING)
754  if (!pending || pending.action !== action || pending.hash !== ref.name) {
755    await $.state.set(PENDING_REF, { action, hash: ref.name })
756    return
757  }
758  await runRef($, action, ref)
759}
760
761async function runRef ($: Engine, action: RefAction, ref: Ref) {
762  await $.state.set(PENDING_REF, null)
763  if (isBusy) return note($, 'error', 'Claude is working: try again when the turn ends')
764  const graph = await read($, GRAPH)
765  if (!graph) return
766  const remote = graph.remotes[0] ?? 'origin'
767  // Deleting a ref or pushing it leaves the working tree alone; the rest want it clean
768  if (action === 'checkout-ref' || action === 'merge-ref' || action === 'rebase-ref') {
769    const dirty = (await git($, ['status', '--porcelain']).catch(() => '')).trim()
770    if (dirty) return note($, 'error', 'uncommitted changes: commit or stash them first')
771  }
772  try {
773    switch (action) {
774      case 'checkout-ref':
775        if (ref.kind === 'remote') {
776          // A local branch of that name already exists: switch to it rather than fail
777          await git($, checkoutRemote(ref.name, graph.remotes, graph.branches))
778        } else if (ref.kind === 'tag') {
779          await git($, cmd.detach(ref.name))
780        } else {
781          await git($, cmd.switchTo(ref.name))
782        }
783        await note($, 'ok', `checked out ${ref.name}`)
784        break
785      case 'merge-ref':
786        await undoOnConflict($, cmd.merge(ref.name), cmd.abort('merge'))
787        await note($, 'ok', `merged ${ref.name} into ${graph.branch}`)
788        break
789      case 'rebase-ref':
790        await undoOnConflict($, cmd.rebase(ref.name), cmd.abort('rebase'))
791        await note($, 'ok', `rebased ${graph.branch} onto ${ref.name}`)
792        break
793      case 'delete-branch':
794        try {
795          await git($, cmd.deleteBranch(ref.name, false))
796        } catch (error) {
797          // Not merged: offer to force it, which loses the commits only that branch has
798          if (/not fully merged/i.test(error instanceof Error ? error.message : '')) {
799            await $.state.set(PENDING_REF, { action: 'force-delete-branch', hash: ref.name })
800            return note($, 'error', `${ref.name} is not fully merged: press again to delete it anyway`)
801          }
802          throw error
803        }
804        await note($, 'ok', `deleted branch ${ref.name}`)
805        break
806      case 'force-delete-branch':
807        await git($, cmd.deleteBranch(ref.name, true))
808        await note($, 'ok', `deleted branch ${ref.name}`)
809        break
810      case 'delete-remote-branch': {
811        const target = splitRemote(ref.name, graph.remotes)
812        await git($, cmd.deleteRemoteBranch(target.remote, target.branch), [0], 120000)
813        await note($, 'ok', `deleted ${target.branch} on ${target.remote}`)
814        break
815      }
816      case 'delete-tag':
817        await git($, cmd.deleteTag(ref.name))
818        await note($, 'ok', `deleted tag ${ref.name}`)
819        break
820      case 'push-ref':
821        await git($, ref.kind === 'tag' ? cmd.pushTag(remote, ref.name) : cmd.pushBranch(remote, ref.name), [0], 120000)
822        await note($, 'ok', `pushed ${ref.name} to ${remote}`)
823        break
824    }
825  } catch (error) {
826    await note($, 'error', (error instanceof Error ? error.message : String(error)).split('\n')[0] ?? '')
827  }
828  await reload($)
829}
830
831/** Every remote's branches and tags; branches deleted on a remote go here too. */
832async function fetchAll ($: Engine) {
833  await note($, 'info', 'fetching…')
834  try {
835    await git($, cmd.fetchAll(), [0], 300000)
836    await note($, 'ok', 'fetched every remote')
837  } catch (error) {
838    await note($, 'error', (error instanceof Error ? error.message : String(error)).split('\n')[0] ?? '')
839  }
840  await reload($)
841}
842
843/** Pull and fetch change nothing local but the branch they update: they run on one click. */
844async function pullRef ($: Engine, ref: Ref) {
845  if (isBusy) return note($, 'error', 'Claude is working: try again when the turn ends')
846  try {
847    if (ref.kind === 'remote') {
848      const graph = await read($, GRAPH)
849      const target = splitRemote(ref.name, graph?.remotes ?? [])
850      await git($, cmd.fetchBranch(target.remote, target.branch), [0], 120000)
851      await note($, 'ok', `fetched ${ref.name}`)
852    } else {
853      // Only fast-forwards: a pull that would merge is Claude's to resolve. A branch not checked
854      // out is fetched into, which moves it to its upstream without touching the working tree
855      const graph = await read($, GRAPH)
856      const upstream = graph?.upstreams[ref.name]
857      if (!upstream) return note($, 'error', `${ref.name} has no upstream to pull from: push it first`)
858      await git($, ref.kind === 'head' ? cmd.pullCurrent() : cmd.pullOther(ref.name, upstream.name, graph.remotes), [0], 120000)
859      await note($, 'ok', `${ref.name} is at ${upstream.name}`)
860    }
861  } catch (error) {
862    await note($, 'error', (error instanceof Error ? error.message : String(error)).split('\n')[0] ?? '')
863  }
864  await reload($)
865}
866
867async function renameBranch ($: Engine, from: string, value: string) {
868  const to = value.trim()
869  if (to === '' || to === from) return $.state.set(RENAMING_REF, null)
870  try {
871    await git($, cmd.renameBranch(from, to))
872    await $.state.set(RENAMING_REF, null)
873    await $.state.set(REF_REF, null)
874    await note($, 'ok', `renamed ${from} to ${to}`)
875  } catch (error) {
876    await note($, 'error', (error instanceof Error ? error.message : String(error)).split('\n')[0] ?? '')
877  }
878  await reload($)
879}
880
881// ── Stashes ────────────────────────────────────────────────────────────────────
882
883async function toggleStashing ($: Engine, target: string) {
884  await $.state.set(STASHING_REF, (await read($, STASHING)) === target ? null : target)
885}
886
887/** Stashes the uncommitted changes, untracked files included, with the message given, if any. */
888async function stashChanges ($: Engine, message: string) {
889  if (isBusy) return note($, 'error', 'Claude is working: try again when the turn ends')
890  try {
891    await git($, cmd.stashPush(message))
892    await $.state.set(STASHING_REF, null)
893    await clearSelection($)
894    await note($, 'ok', 'stashed the uncommitted changes')
895  } catch (error) {
896    await note($, 'error', (error instanceof Error ? error.message : String(error)).split('\n')[0] ?? '')
897  }
898  await reload($)
899}
900
901/** Apply keeps the stash and runs on one click; pop and drop lose it, so they ask first. */
902async function stashAction ($: Engine, action: 'stash-apply' | StashAction, stash: Commit) {
903  if (action !== 'stash-apply') {
904    const pending = await read($, PENDING)
905    if (!pending || pending.action !== action || pending.hash !== stash.hash) {
906      await $.state.set(PENDING_REF, { action, hash: stash.hash })
907      return
908    }
909  }
910  await $.state.set(PENDING_REF, null)
911  if (isBusy) return note($, 'error', 'Claude is working: try again when the turn ends')
912  const name = stash.stash ?? 'stash@{0}'
913  const verb = action.slice(6) as 'apply' | 'pop' | 'drop'
914  try {
915    await git($, cmd.stash(verb, name))
916    await note($, 'ok', `${verb === 'drop' ? 'dropped' : verb === 'pop' ? 'popped' : 'applied'} ${name}`)
917    if (verb !== 'apply') await clearSelection($)
918  } catch (error) {
919    await note($, 'error', (error instanceof Error ? error.message : String(error)).split('\n')[0] ?? '')
920  }
921  await reload($)
922}
923
924/** A new branch at the commit the stash was made on, with the stash applied and dropped. */
925async function branchFromStash ($: Engine, stash: Commit, value: string) {
926  const name = value.trim()
927  if (name === '') return
928  try {
929    await git($, cmd.stashBranch(name, stash.stash ?? 'stash@{0}'))
930    await clearSelection($)
931    await note($, 'ok', `made branch ${name} from ${stash.stash}`)
932  } catch (error) {
933    await note($, 'error', (error instanceof Error ? error.message : String(error)).split('\n')[0] ?? '')
934  }
935  await reload($)
936}
937
938/** Writing the message is Claude's job: it proposes one and asks before committing. */
939async function commitChanges ($: Engine) {
940  await ask($, 'Commit the current uncommitted changes. Look at the diff, propose a commit message and ask me before committing.', 'asked Claude to commit the changes')
941}
942
943async function ask ($: Engine, text: string, done: string) {
944  if (isBusy) return note($, 'error', 'Claude is working: try again when the turn ends')
945  try {
946    const result = await $.prompt.submit({ text, asUser: true })
947    if (result?.drop) await note($, 'error', String(result.drop))
948    else await note($, 'info', done)
949  } catch (error) {
950    await note($, 'error', error instanceof Error ? error.message : String(error))
951  }
952}
953
954/** Naming a branch needs a conversation: Claude asks, and its own permissions apply. */
955async function newBranch ($: Engine, commit: Commit) {
956  const short = commit.hash.slice(0, HASH_WIDTH)
957  await ask($, `Create a new git branch from commit ${short} ("${commit.subject}"). Ask me for the branch name first, and whether to switch to it.`, `asked Claude for a branch from ${short}`)
958}
959
960// ── Drawing ────────────────────────────────────────────────────────────────────
961
962function cut (text: string, width: number) {
963  return text.length <= width ? text : `${text.slice(0, Math.max(0, width - 1))}…`
964}
965
966/**
967 * The surface's elements, with the ones it does not draw left out, so a drawing can ask for them:
968 * the terminal has no Svg, mobile no Input or Select.
969 */
970function elementsOf ($: Engine, e: RenderOf<'Pane'>): Ui {
971  const table: Ui = $.ui.resolve(e)
972  return {
973    Box: table.Box,
974    Text: table.Text,
975    Button: table.Button,
976    Link: table.Link,
977    Code: table.Code,
978    Markdown: table.Markdown,
979    Input: e.surface === 'mobile' ? undefined : table.Input,
980    Select: e.surface === 'mobile' ? undefined : table.Select,
981    Svg: e.surface === 'terminal' ? undefined : table.Svg
982  }
983}
984
985async function drawPane ($: Engine, e: RenderOf<'Pane'>) {
986  const ui = elementsOf($, e)
987  const { Box, Button, Input, Select, Text } = ui
988  const graph = await read($, GRAPH)
989  const selected = await read($, SELECTED)
990  const detail = await read($, DETAIL)
991  const diff = await read($, DIFF)
992  const allBranches = await read($, ALL)
993  const filter = await read($, FILTER)
994  const repo = await read($, REPO)
995  await read($, PICKER_REDRAW)
996  const compact = await read($, COMPACT)
997  const settingsOpen = await read($, SETTINGS)
998  const muteMerges = await read($, MUTE_MERGES)
999  // Only a surface that draws SVG gets curves
1000  const curves = Boolean(ui.Svg) && (await read($, CURVES))
1001  const avatars = await read($, AVATARS)
1002  const showAuthor = await read($, SHOW_AUTHOR)
1003  const showDate = await read($, SHOW_DATE)
1004  const dateFormat = await read($, DATE_FORMAT)
1005  const muteOthers = await read($, MUTE_OTHERS)
1006  const pending = await read($, PENDING)
1007  const tagging = await read($, TAGGING)
1008  const stashing = await read($, STASHING)
1009  const compareBase = await read($, COMPARE)
1010  const openRef = await read($, REF)
1011  const renaming = await read($, RENAMING)
1012  const lastNote = await read($, NOTE)
1013  const pageStart = await read($, PAGE_START)
1014  const columns = Math.max(40, e.props.bodyColumns)
1015  const count = graph?.commits.filter(commit => !commit.kind).length ?? 0
1016  const typed = await read($, QUERY)
1017  const query = typed.trim()
1018  const found = query === '' ? graph?.commits ?? [] : (graph?.commits ?? []).filter(commit => matches(commit, query))
1019  const matching = found.length
1020
1021  const isDrawn = graph !== null && !graph.error
1022  const header = (
1023    <Box key="header" flexDirection="column">
1024      {Select && graph && graph.repos.length > 1 && (
1025        <Select
1026          key="repo"
1027          label="repo "
1028          value={repo ?? graph.repos[0]?.path}
1029          options={graph.repos.map(item => ({ value: item.path, label: item.label }))}
1030          onSelect={(value: string) => pickRepo($, value)}
1031        />
1032      )}
1033      {/* Where the repo stands on the left, what changes the view on the right; a narrow pane wraps the two */}
1034      <Box flexDirection="row" justifyContent="space-between" flexWrap="wrap" columnGap={2}>
1035        {/* Where the repo stands: text alone, so no hotkey chip sits among it */}
1036        <Box flexDirection="row" columnGap={2} alignItems="center" flexShrink={1}>
1037          <Text color="green" bold wrap="truncate-end">{graph?.branch ? `⎇ ${graph.branch}` : 'Git Graph'}</Text>
1038          {graph && graph.changes.length > 0 && <Text color="yellow">{`● ${graph.changes.length} uncommitted`}</Text>}
1039          {isDrawn && <Text dimColor>{`${count} commits${count >= limit ? ` (last ${limit})` : ''}`}</Text>}
1040        </Box>
1041        <Box flexDirection="row" columnGap={2} alignItems="center" flexShrink={0}>
1042          {Select && graph && graph.branches.length > 0
1043            ? (
1044              <Select
1045                key="branches"
1046                value={filter !== null ? `ref:${filter}` : allBranches ? 'all' : 'current'}
1047                options={[
1048                  { value: 'all', label: 'all branches' },
1049                  { value: 'current', label: 'current branch' },
1050                  ...(graph.localCount > 0 ? [{ value: 'sep:local', label: '── local ──' }] : []),
1051                  ...graph.branches.slice(0, graph.localCount).map(name => ({ value: `ref:${name}`, label: name })),
1052                  ...(graph.branches.length > graph.localCount ? [{ value: 'sep:remote', label: '── remote ──' }] : []),
1053                  ...graph.branches.slice(graph.localCount).map(name => ({ value: `ref:${name}`, label: name }))
1054                ]}
1055                onSelect={(value: string) => pickBranches($, value)}
1056              />
1057              )
1058            : <Button key="branches" label={allBranches ? 'all branches' : 'current branch'} plain dimColor onPress={() => toggleAll($)} />}
1059          {graph && graph.remotes.length > 0 && withTip(ui, 'fetch', 'Fetch every remote and its tags · F', 'right',
1060            <Button key="fetch" label="⇣ fetch" hotkey="f" plain dimColor hover={{ scope: 'tip-fetch' }} onPress={() => fetchAll($)} />)}
1061          {withTip(ui, 'reload', 'Read the history again · R', 'right',
1062            <Button key="reload" label="↻" hotkey="r" plain dimColor hover={{ scope: 'tip-reload' }} onPress={() => reload($)} />)}
1063          {withTip(ui, 'settings', 'Lanes, columns and what is dimmed', 'right',
1064            <Button key="settings" label={settingsOpen ? '⚙ settings ▾' : '⚙ settings'} plain dimColor={!settingsOpen} hover={{ scope: 'tip-settings' }} onPress={() => toggleSettings($)} />)}
1065        </Box>
1066      </Box>
1067      {/* Finding on the left, walking the rows on the right, on one line: the field gives way to
1068          the walk, which a terminal's full-width field pushed onto a line of its own. The walk's
1069          buttons are drawn here, always, because a Button's hotkey only answers while it is drawn */}
1070      {isDrawn && (
1071        <Box flexDirection="row" justifyContent="space-between" columnGap={2} marginTop={1}>
1072          <Box flexDirection="row" columnGap={1} alignItems="center" flexGrow={1} flexShrink={1} minWidth={16} overflow="hidden">
1073            {Input && withTip(ui, 'focus-find', 'Find by message, author, hash or branch · S', 'left',
1074              <Button key="focus-find" label="⌕" hotkey="s" plain dimColor hover={{ scope: 'tip-focus-find' }} onPress={() => focusFind($)} />)}
1075            {Input && (
1076              <Box flexGrow={1}>
1077                <Input key="find" placeholder="find a message, author, hash or branch" value={typed} submitLabel="find" onInput={(value: string) => find($, value)} onSubmit={(value: string) => find($, value)} />
1078              </Box>
1079            )}
1080            {query !== '' && <Text dimColor>{`${matching} ${matching === 1 ? 'match' : 'matches'}`}</Text>}
1081            {query !== '' && <Button key="find-clear" label="✕ clear" plain dimColor onPress={() => find($, '')} />}
1082          </Box>
1083          <Box flexDirection="row" columnGap={2} alignItems="center" flexShrink={0}>
1084            {withTip(ui, 'previous', 'Open the commit above · K', 'right',
1085              <Button key="previous" label="↑" hotkey="k" plain dimColor hover={{ scope: 'tip-previous' }} onPress={() => step($, -1)} />)}
1086            {withTip(ui, 'next', 'Open the commit below · J', 'right',
1087              <Button key="next" label="↓" hotkey="j" plain dimColor hover={{ scope: 'tip-next' }} onPress={() => step($, 1)} />)}
1088            {withTip(ui, 'head', 'Open the commit HEAD points at · H', 'right',
1089              <Button key="head" label="HEAD" hotkey="h" plain dimColor hover={{ scope: 'tip-head' }} onPress={() => goHead($)} />)}
1090          </Box>
1091        </Box>
1092      )}
1093      {settingsOpen && drawSettings(ui, $, { compact, curves, avatars, showAuthor, showDate, dateFormat, muteMerges, muteOthers })}
1094      {compareBase !== null && (
1095        <Box flexDirection="row" columnGap={2}>
1096          <Text wrap="truncate-end"><Text color="cyan">{`⇄ comparing with ${compareBase === WORKTREE ? 'the working tree' : compareBase.slice(0, HASH_WIDTH)}`}</Text><Text dimColor>  · open another commit</Text></Text>
1097          <Button key="compare-stop" label="✕ stop comparing" plain dimColor onPress={() => stopComparing($)} />
1098        </Box>
1099      )}
1100      {lastNote && <Text color={NOTE_COLOR[lastNote.kind]} wrap="truncate-end">{lastNote.text}</Text>}
1101    </Box>
1102  )
1103
1104  if (!graph) return <Box flexDirection="column">{header}<Text dimColor>Loading…</Text></Box>
1105  if (graph.error) return <Box flexDirection="column">{header}<Text color="red">{graph.error}</Text></Box>
1106  if (graph.commits.length === 0) return <Box flexDirection="column">{header}<Text dimColor>No commits yet.</Text></Box>
1107
1108  if (query !== '' && found.length === 0) return <Box flexDirection="column">{header}<Text> </Text><Text dimColor>{`No commit in the last ${count} matches “${query}”.`}</Text></Box>
1109
1110  // The page drawn, kept inside the rows found when they shrink under it
1111  const start = Math.min(pageStart, Math.max(0, Math.floor((found.length - 1) / PAGE) * PAGE))
1112  const visible = found.slice(start, start + PAGE)
1113  const end = start + visible.length
1114  // Compact drops the gap after every lane: half as wide, the junction glyphs still connect
1115  const cellsOf = (commit: Commit) => {
1116    // Found commits are not neighbours: their lanes would not connect, so each keeps its dot alone
1117    if (query !== '') return [{ ch: '●', lane: commit.cells.find(cell => cell.ch === '●')?.lane ?? 0 }]
1118    const cells = compact ? commit.cells.filter((_, i) => i % 2 === 0) : commit.cells
1119    // The uncommitted row is hollow: nothing is committed there yet
1120    const node = commit.kind === 'worktree' ? '○' : commit.kind === 'stash' ? '◈' : '●'
1121    return node === '●' ? cells : cells.map(cell => (cell.ch === '●' ? { ...cell, ch: node } : cell))
1122  }
1123  const graphWidth = Math.max(...visible.map(commit => cellsOf(commit).length)) + 1
1124  // The avatar and the author's name are columns of their own: either can stand without the other
1125  const metaWidth = (avatars ? AVATAR_WIDTH : 0) + (showAuthor ? AUTHOR_WIDTH + 1 : 0) + (showDate ? DATE_WIDTH + 1 : 0)
1126  const showMeta = metaWidth > 0 && columns >= graphWidth + metaWidth + HASH_WIDTH + 30
1127  const textWidth = columns - graphWidth - HASH_WIDTH - 3 - (showMeta ? metaWidth + 1 : 0)
1128
1129  // Dim, as the settings say: merges, and commits that are no ancestor of HEAD
1130  const reached = muteOthers ? ancestorsOf(graph.commits, graph.head) : null
1131  const isMuted = (commit: Commit) => query === '' && !commit.kind &&
1132    ((muteMerges && commit.parents.length > 1) || (reached !== null && !reached.has(commit.hash)))
1133
1134  const rows = []
1135  for (const commit of visible) {
1136    const isSelected = commit.hash === selected
1137    const cells = cellsOf(commit)
1138    const isCurved = curves && query === ''
1139    rows.push(drawRow(ui, $, commit, cells, { curves: isCurved, avatars, showAuthor, showDate, graphWidth, textWidth, showMeta, isSelected, query, openRef, isMuted: isMuted(commit) }))
1140    const badge = openRef && commit.refs.find(ref => ref.kind === openRef.kind && ref.name === openRef.name)
1141    // A panel opened under a row keeps the lanes going beside it, down to the next commit
1142    const underRow = query === '' ? cells : []
1143    if (badge) rows.push(drawBeside(ui, `refs-${badge.kind}-${badge.name}`, underRow, isCurved, graphWidth, drawRefActions(ui, $, badge, pending, renaming === badge.name, graph)))
1144    if (isSelected) rows.push(drawBeside(ui, `detail-${commit.hash}`, underRow, isCurved, graphWidth, drawDetail(ui, $, commit, detail?.hash === commit.hash ? detail : null, diff, pending, { isTagging: tagging === commit.hash, isStashing: stashing === commit.hash, compareBase }, graph)))
1145  }
1146
1147  return (
1148    <Box flexDirection="column">
1149      {header}
1150      <Text> </Text>
1151      {start > 0 && <Button key="newer" label={`▴ newer ${Math.min(PAGE, start)}`} plain dimColor onPress={() => $.state.set(PAGE_START_REF, Math.max(0, start - PAGE))} />}
1152      {rows}
1153      {found.length > PAGE && (
1154        <Box key="pages" flexDirection="row" columnGap={2}>
1155          {end < found.length && <Button key="older" label={`older ${Math.min(PAGE, found.length - end)} ▾`} plain dimColor onPress={() => $.state.set(PAGE_START_REF, end)} />}
1156          <Text dimColor>{`${start + 1}–${end} of ${found.length}`}</Text>
1157        </Box>
1158      )}
1159    </Box>
1160  )
1161}
1162
1163/**
1164 * A button with a tip that shows while the pointer is on it: a card in the theme's inverse
1165 * colors, over the rows under it, hidden until the button's hover scope lights. The button
1166 * names that scope (`tip-<key>`); a surface that reports no pointer never shows it.
1167 */
1168function withTip (ui: Ui, key: string, tip: string, align: 'left' | 'right', button: unknown) {
1169  const { Box, Text } = ui
1170  return (
1171    <Box key={`tip-${key}`} flexShrink={0}>
1172      {button}
1173      <Box position="absolute" top={1} {...(align === 'right' ? { right: 0 } : { left: 0 })} display="none" hover={{ scope: `tip-${key}`, display: 'flex' }}>
1174        <Text inverse wrap="end">{` ${tip} `}</Text>
1175      </Box>
1176    </Box>
1177  )
1178}
1179
1180/** The settings, as a panel under the header: a line per group, its name in a dim column of its own. */
1181function drawSettings (
1182  ui: Ui, $: Engine,
1183  { compact, curves, avatars, showAuthor, showDate, dateFormat, muteMerges, muteOthers }: { compact: boolean; curves: boolean; avatars: boolean; showAuthor: boolean; showDate: boolean; dateFormat: DateFormat; muteMerges: boolean; muteOthers: boolean }
1184) {
1185  const { Box, Button, Text } = ui
1186  const check = (isOn: boolean, label: string) => `${isOn ? '☑' : '☐'} ${label}`
1187  return (
1188    <Box key="settings-panel" flexDirection="column" marginTop={1} borderStyle="round" borderColor="gray" paddingX={1}>
1189      {drawSetting(ui, 'lanes', 'Lanes', [
1190        <Button key="compact" label={compact ? 'compact' : 'spaced'} plain onPress={() => toggleCompact($)} />,
1191        ui.Svg && <Button key="curves" label={curves ? 'curves' : 'glyphs'} plain onPress={() => toggleCurves($)} />
1192      ])}
1193      {drawSetting(ui, 'columns', 'Columns', [
1194        <Button key="show-author" label={check(showAuthor, 'author')} plain onPress={() => toggleShowAuthor($)} />,
1195        <Button key="avatars" label={check(avatars, 'avatars')} plain onPress={() => toggleAvatars($)} />,
1196        <Button key="show-date" label={check(showDate, 'date')} plain onPress={() => toggleShowDate($)} />,
1197        <Button key="date-format" label={`date: ${dateFormat}`} plain onPress={() => cycleDateFormat($)} />
1198      ])}
1199      {drawSetting(ui, 'dim', 'Dim', [
1200        <Button key="mute-merges" label={check(muteMerges, 'merge commits')} plain onPress={() => toggleMuteMerges($)} />,
hooks/commands.ts 69 lines
1// Every git command that changes the repo or a remote, as the argument vector the pane runs.
2// Nothing here runs git: the pane runs these, and test/git.node-test.ts runs the same ones
3// against real repos to check what each leaves behind.
4import type { Commit, ResetMode } from '../types'
5
6/** A remote branch `origin/feature/x` → its remote and its branch there; the remotes' names decide. */
7export function splitRemote (name: string, remotes: string[]) {
8  const remote = remotes.find(item => name.startsWith(`${item}/`)) ?? name.slice(0, name.indexOf('/'))
9  return { remote, branch: name.slice(remote.length + 1) }
10}
11
12/** Checking out a remote branch: the local branch of that name when there is one, else a new one tracking it. */
13export function checkoutRemote (name: string, remotes: string[], branches: string[]): string[] {
14  const { branch } = splitRemote(name, remotes)
15  return branches.includes(branch) ? ['switch', branch] : ['switch', '--track', name]
16}
17
18/** Where a checkout of this commit lands: its local branch, its remote one's, or a detached HEAD. */
19export function checkoutCommit (commit: Commit, remotes: string[], branches: string[]): { label: string; args: string[] } {
20  const local = commit.refs.find(ref => ref.kind === 'local' || (ref.kind === 'head' && ref.name !== 'HEAD'))
21  if (local) return { label: local.name, args: ['switch', local.name] }
22  const remote = commit.refs.find(ref => ref.kind === 'remote')
23  if (remote) {
24    const { branch } = splitRemote(remote.name, remotes)
25    return branches.includes(branch)
26      ? { label: branch, args: ['switch', branch] }
27      : { label: `${branch} (tracking ${remote.name})`, args: ['switch', '--track', remote.name] }
28  }
29  return { label: `${commit.hash.slice(0, 7)} (detached)`, args: ['switch', '--detach', commit.hash] }
30}
31
32export const git = {
33  cherryPick: (hash: string) => ['cherry-pick', hash],
34  /** A merge is reverted against its first parent, the side it was merged into. */
35  revert: (hash: string, isMerge: boolean) => ['revert', '--no-edit', ...(isMerge ? ['-m', '1'] : []), hash],
36  merge: (target: string) => ['merge', '--no-edit', target],
37  rebase: (onto: string) => ['rebase', onto],
38  /** What undoes each of the four above when it stops on a conflict. */
39  abort: (command: 'cherry-pick' | 'revert' | 'merge' | 'rebase') => [command, '--abort'],
40  reset: (mode: ResetMode, hash: string) => ['reset', `--${mode}`, hash],
41  /** `message` makes it annotated. */
42  tag: (name: string, hash: string, message?: string) => ['tag', ...(message ? ['-a', name, '-m', message] : [name]), hash],
43  deleteTag: (name: string) => ['tag', '-d', name],
44  pushTag: (remote: string, name: string) => ['push', remote, `refs/tags/${name}`],
45  switchTo: (name: string) => ['switch', name],
46  detach: (target: string) => ['switch', '--detach', target],
47  renameBranch: (from: string, to: string) => ['branch', '-m', from, to],
48  /** `-d` refuses a branch with commits merged nowhere; `-D` deletes it anyway. */
49  deleteBranch: (name: string, force: boolean) => ['branch', force ? '-D' : '-d', name],
50  /** Pushes the branch and makes the remote's its upstream. */
51  pushBranch: (remote: string, name: string) => ['push', '-u', remote, name],
52  deleteRemoteBranch: (remote: string, branch: string) => ['push', remote, '--delete', branch],
53  fetchBranch: (remote: string, branch: string) => ['fetch', remote, branch],
54  fetchAll: () => ['fetch', '--all', '--prune', '--tags'],
55  /** The checked-out branch moves to its upstream, fast-forward only. */
56  pullCurrent: () => ['pull', '--ff-only'],
57  /**
58   * Another local branch moves to its upstream without being checked out: fetch into it, which
59   * git allows only as a fast-forward.
60   */
61  pullOther: (local: string, upstream: string, remotes: string[]) => {
62    const { remote, branch } = splitRemote(upstream, remotes)
63    return ['fetch', remote, `${branch}:${local}`]
64  },
65  stashPush: (message: string) => ['stash', 'push', '--include-untracked', ...(message.trim() ? ['-m', message.trim()] : [])],
66  stash: (verb: 'apply' | 'pop' | 'drop', name: string) => ['stash', verb, name],
67  stashBranch: (branch: string, name: string) => ['stash', 'branch', branch, name]
68}
69
hooks/graph.ts 499 lines
1// The pure part of Git Graph: reading `git log` and laying commits out in lanes.
2// Nothing here touches Claude Code, so it can be tested with plain Node.
3import type { Cell, Commit, DateFormat, FileChange, Forge, Ref, Signature, Upstream } from '../types'
4
5export const SEP = '\x1f'
6export const FORMAT = ['%H', '%P', '%D', '%an', '%ad', '%s'].join('%x1f')
7/** The `--date` git reads the date column with, per format. */
8export const DATE_ARGS: Record<DateFormat, string> = {
9  absolute: '--date=format:%d %b %Y %H:%M',
10  relative: '--date=relative',
11  iso: '--date=format:%Y-%m-%d %H:%M'
12}
13
14/** The hash of the row that stands for the working tree's uncommitted changes. */
15export const WORKTREE = 'worktree'
16
17/** `git status --porcelain=v1 -uall` → one change per file, the staged status first. */
18export function parseStatus (stdout: string): FileChange[] {
19  return stdout.split('\n').filter(line => line.length > 3).map(line => {
20    const [x = ' ', y = ' '] = line
21    const rest = line.slice(3)
22    const status = x === '?' ? '?' : x !== ' ' ? x : y
23    const arrow = rest.indexOf(' -> ')
24    return (status === 'R' || status === 'C') && arrow !== -1
25      ? { status, path: rest.slice(arrow + 4), oldPath: rest.slice(0, arrow) }
26      : { status, path: rest }
27  })
28}
29
30/** `git diff-tree --name-status` → one change per file; `R100 old new` keeps both paths. */
31export function parseNameStatus (stdout: string): FileChange[] {
32  return stdout.split('\n').filter(Boolean).map(line => {
33    const [code = '', first = '', second] = line.split('\t')
34    const status = code.charAt(0)
35    return second !== undefined ? { status, path: second, oldPath: first } : { status, path: first }
36  })
37}
38
39/** `git for-each-ref --format=%(refname)` → branch names, local first; the remotes' HEAD left out. */
40export function parseBranches (stdout: string): string[] {
41  return splitBranches(stdout).flat()
42}
43
44/** Local branches and remote ones, apart; the remotes' HEAD left out. */
45export function splitBranches (stdout: string): [string[], string[]] {
46  const local: string[] = []
47  const remote: string[] = []
48  for (const ref of stdout.split('\n').map(line => line.trim()).filter(Boolean)) {
49    if (ref.startsWith('refs/heads/')) local.push(ref.slice(11))
50    else if (ref.startsWith('refs/remotes/') && !ref.endsWith('/HEAD')) remote.push(ref.slice(13))
51  }
52  return [local, remote]
53}
54
55/** `v1.2` or `v1.2: the message`: a tag's name, and a message when it is annotated. */
56export function parseTagInput (value: string): { name: string; message?: string } {
57  const colon = value.indexOf(':')
58  if (colon === -1) return { name: value.trim() }
59  const message = value.slice(colon + 1).trim()
60  return message === '' ? { name: value.slice(0, colon).trim() } : { name: value.slice(0, colon).trim(), message }
61}
62
63/** `git submodule status --recursive` → the paths of the submodules checked out (not `-` ones). */
64export function parseSubmodules (stdout: string): string[] {
65  return stdout.split('\n').filter(line => line.length > 1 && line[0] !== '-').map(line => {
66    const [, path = ''] = line.slice(1).split(' ')
67    return path
68  }).filter(Boolean)
69}
70
71/** What a commit's detail asks git for its signature: status, signer, fingerprint. */
72export const SIGNATURE_FORMAT = ['%G?', '%GS', '%GK'].join('%x1f')
73
74/** git's `%G?` letter, with the signer and the signing key's fingerprint → the line the detail shows; none for an unsigned commit. */
75export function describeSignature (status: string, signer: string, fingerprint: string): Signature | undefined {
76  const by = [signer && `by ${signer}`, fingerprint && `(key ${fingerprint})`].filter(Boolean).join(' ')
77  const line = (words: string) => [words, by].filter(Boolean).join(' ')
78  switch (status) {
79    case 'G': return { text: line('✓ good signature'), kind: 'good' }
80    case 'U': return { text: line('✓ good signature, of unknown trust'), kind: 'unknown' }
81    case 'X': return { text: line('⚠ good signature that has expired'), kind: 'unknown' }
82    case 'Y': return { text: line('⚠ good signature by a key that has expired'), kind: 'unknown' }
83    case 'R': return { text: line('⚠ signature by a revoked key'), kind: 'bad' }
84    case 'B': return { text: line('✗ bad signature'), kind: 'bad' }
85    case 'E': return { text: line('? signed, but the signature cannot be checked here'), kind: 'unchecked' }
86    default: return undefined
87  }
88}
89
90/** What `for-each-ref` reads each local branch's upstream with: name, upstream, `[ahead 1, behind 2]`. */
91export const UPSTREAM_FORMAT = ['%(refname:short)', '%(upstream:short)', '%(upstream:track)'].join('%09')
92
93/** `git for-each-ref --format=UPSTREAM_FORMAT refs/heads` → the upstream of each branch that has one. */
94export function parseUpstreams (stdout: string): Record<string, Upstream> {
95  const upstreams: Record<string, Upstream> = {}
96  for (const line of stdout.split('\n')) {
97    const [branch = '', upstream = '', track = ''] = line.split('\t')
98    if (branch === '' || upstream === '') continue
99    upstreams[branch] = {
100      name: upstream,
101      ahead: Number(track.match(/ahead (\d+)/)?.[1] ?? 0),
102      behind: Number(track.match(/behind (\d+)/)?.[1] ?? 0),
103      ...(track.includes('gone') ? { isGone: true } : {})
104    }
105  }
106  return upstreams
107}
108
109/** `↑1 ↓2 origin/main`: what a branch would push and pull; in step when neither. */
110export function describeUpstream (upstream: Upstream): string {
111  if (upstream.isGone) return `${upstream.name} was deleted on the remote`
112  if (upstream.ahead === 0 && upstream.behind === 0) return `in step with ${upstream.name}`
113  const parts = [upstream.ahead > 0 && `↑${upstream.ahead} to push`, upstream.behind > 0 && `↓${upstream.behind} to pull`].filter(Boolean)
114  return `${parts.join(', ')} · ${upstream.name}`
115}
116
117/** Room for one file's diff in the pane: past it, the diff is cut at a hunk. */
118export const DIFF_LIMIT = 60000
119
120/**
121 * A `git diff` of one file → its hunks, from the first `@@` on, as a diff viewer reads them;
122 * `note` says why there are none (binary, mode only) or that the diff was cut.
123 */
124export function parseDiff (stdout: string): { hunks: string; note?: string } {
125  const start = stdout.startsWith('@@') ? 0 : stdout.indexOf('\n@@') + 1
126  if (start === 0 && !stdout.startsWith('@@')) {
127    if (/^Binary files /m.test(stdout)) return { hunks: '', note: 'Binary file: no text to compare.' }
128    return { hunks: '', note: stdout.trim() === '' ? 'No changes to show.' : 'Only the file mode changed.' }
129  }
130  const hunks = stdout.slice(start).trimEnd()
131  if (hunks.length <= DIFF_LIMIT) return { hunks }
132  const cut = hunks.lastIndexOf('\n@@', DIFF_LIMIT)
133  return { hunks: cut > 0 ? hunks.slice(0, cut) : hunks.slice(0, DIFF_LIMIT), note: `The diff is longer than the pane shows: ${Math.round(hunks.length / 1000)}k characters.` }
134}
135
136/** What `git stash list` is read with: hash, parents, name, author, date, subject. */
137export const STASH_FORMAT = ['%H', '%P', '%gd', '%an', '%ad', '%s'].join('%x1f')
138
139/**
140 * `git stash list --format=STASH_FORMAT` → one row per stash, leading to the commit it was made on.
141 * The name comes from the stash's place in the list: with `--date` git writes `%gd` as a date
142 * (`stash@{19 Oct 2026 22:39}`), which two stashes of the same minute share.
143 */
144export function parseStashes (stdout: string): Commit[] {
145  return stdout.split('\n').filter(line => line.includes(SEP)).map((line, index) => {
146    const [hash = '', parents = '', , author = '', date = '', ...subject] = line.split(SEP)
147    return { hash, parents: parents.split(' ').slice(0, 1).filter(Boolean), refs: [], author, date, subject: subject.join(SEP), cells: [], kind: 'stash', stash: `stash@{${index}}` }
148  })
149}
150
151/** Puts each stash just above the commit it was made on, so the layout forks it off there. */
152export function withStashes (commits: Commit[], stashes: Commit[]): Commit[] {
153  const result = [...commits]
154  for (const stash of stashes) {
155    const at = result.findIndex(commit => commit.hash === stash.parents[0])
156    if (at !== -1) result.splice(at, 0, stash)
157  }
158  return result
159}
160
161/** A numstat path → the file's path now: `src/{a => b}.ts` and `a => b` name the new one. */
162function numstatPath (path: string): string {
163  const braces = path.match(/^(.*)\{(.*) => (.*)\}(.*)$/)
164  if (braces) return `${braces[1]}${braces[3]}${braces[4]}`.replace('//', '/')
165  const arrow = path.indexOf(' => ')
166  return arrow === -1 ? path : path.slice(arrow + 4)
167}
168
169/** `--numstat` → lines added and deleted per path; `-` for both is a binary file. */
170export function parseNumstat (stdout: string): Map<string, { added: number; deleted: number } | 'binary'> {
171  const stats = new Map<string, { added: number; deleted: number } | 'binary'>()
172  for (const line of stdout.split('\n')) {
173    const [added = '', deleted = '', ...rest] = line.split('\t')
174    if (rest.length === 0) continue
175    const path = numstatPath(rest.join('\t'))
176    stats.set(path, added === '-' ? 'binary' : { added: Number(added), deleted: Number(deleted) })
177  }
178  return stats
179}
180
181/** The files with their line counts, where the numstat has them. */
182export function withStats (files: FileChange[], stats: ReturnType<typeof parseNumstat>): FileChange[] {
183  return files.map(file => {
184    const stat = stats.get(file.path)
185    if (stat === undefined) return file
186    return stat === 'binary' ? { ...file, isBinary: true } : { ...file, added: stat.added, deleted: stat.deleted }
187  })
188}
189
190/** `3 files changed, +40 −12`: the files and the lines they add and delete in all. */
191export function summary (files: FileChange[]): string {
192  const count = `${files.length} ${files.length === 1 ? 'file' : 'files'} changed`
193  const added = files.reduce((sum, file) => sum + (file.added ?? 0), 0)
194  const deleted = files.reduce((sum, file) => sum + (file.deleted ?? 0), 0)
195  return files.some(file => file.added !== undefined) ? `${count}, +${added} −${deleted}` : count
196}
197
198/** The row that stands for uncommitted changes, drawn above the commit it would follow. */
199export function worktreeRow (changes: FileChange[], head: string | null): Commit {
200  return {
201    hash: WORKTREE,
202    parents: head ? [head] : [],
203    refs: [],
204    author: '',
205    date: '',
206    subject: `Uncommitted Changes (${changes.length})`,
207    cells: [],
208    kind: 'worktree'
209  }
210}
211
212/** `HEAD -> main, origin/main, tag: v1.0` → refs, the checked-out branch first. */
213export function parseRefs (decoration: string, remotes: Set<string>): Ref[] {
214  const refs: Ref[] = []
215  for (const raw of decoration.split(',').map(part => part.trim()).filter(Boolean)) {
216    if (raw === 'HEAD') refs.push({ name: 'HEAD', kind: 'head' })
217    else if (raw.startsWith('HEAD -> ')) refs.push({ name: raw.slice(8), kind: 'head' })
218    else if (raw.startsWith('tag: ')) refs.push({ name: raw.slice(5), kind: 'tag' })
219    else if (raw.endsWith('/HEAD')) continue
220    else if (remotes.has(raw.split('/')[0] ?? '')) refs.push({ name: raw, kind: 'remote' })
221    else refs.push({ name: raw, kind: 'local' })
222  }
223  const order = { head: 0, local: 1, remote: 2, tag: 3 }
224  return refs.sort((a, b) => order[a.kind] - order[b.kind])
225}
226
227/** One `git log --format=FORMAT` output → commits, without their graph cells yet. */
228export function parseLog (stdout: string, remotes: Set<string>): Commit[] {
229  return stdout.split('\n').filter(line => line.includes(SEP)).map(line => {
230    const [hash = '', parents = '', decoration = '', author = '', date = '', ...subject] = line.split(SEP)
231    return {
232      hash,
233      parents: parents ? parents.split(' ') : [],
234      refs: parseRefs(decoration, remotes),
235      author,
236      date,
237      subject: subject.join(SEP),
238      cells: []
239    }
240  })
241}
242
243/**
244 * Lays commits (in --topo-order, newest first) out in lanes, like Git Graph does.
245 * Each lane waits for the commit it leads to; every row gets two cells per lane: the
246 * lane's glyph and the gap after it, which carries the horizontal lines of merges and forks.
247 */
248export function layout (commits: Commit[]): Commit[] {
249  const lanes: (string | null)[] = []
250
251  for (const commit of commits) {
252    let col = lanes.indexOf(commit.hash)
253    const isAwaited = col !== -1
254    if (col === -1) {
255      col = lanes.indexOf(null)
256      if (col === -1) col = lanes.length
257      lanes[col] = commit.hash
258    }
259
260    // Other children's lanes that end here, joining this commit
261    const joining = lanes.flatMap((hash, i) => (hash === commit.hash && i !== col ? [i] : []))
262    for (const i of joining) lanes[i] = null
263
264    // Where this commit's parents continue: the first in its own lane, the rest in lanes
265    // that already wait for them, or in new ones
266    lanes[col] = commit.parents[0] ?? null
267    const forks: number[] = []
268    const opened = new Set<number>()
269    for (const parent of commit.parents.slice(1)) {
270      let lane = lanes.indexOf(parent)
271      if (lane === -1) {
272        // Never a lane that ends in this row: one cell cannot draw a join and a fork at once,
273        // and the fork would seem to come from nowhere
274        lane = lanes.findIndex((hash, i) => hash === null && i !== col && !joining.includes(i))
275        if (lane === -1) lane = lanes.length
276        lanes[lane] = parent
277        opened.add(lane)
278      }
279      forks.push(lane)
280    }
281
282    const width = Math.max(lanes.length, col + 1, ...joining.map(i => i + 1))
283    const cells: Cell[] = []
284    for (let i = 0; i < width; i++) {
285      let ch = ' '
286      if (i === col) ch = '●'
287      else if (joining.includes(i)) ch = i > col ? '╯' : '╰'
288      else if (opened.has(i)) ch = i > col ? '╮' : '╭'
289      else if (lanes[i] !== null && lanes[i] !== undefined) ch = '│'
290      cells.push(i === col ? { ch, lane: i, up: isAwaited, down: commit.parents.length > 0 } : { ch, lane: i }, { ch: ' ', lane: i })
291    }
292
293    // Horizontal lines from the commit to every lane that joins it or forks from it
294    for (const target of [...joining, ...forks]) {
295      const [from, to] = target > col ? [2 * col + 1, 2 * target - 1] : [2 * target + 1, 2 * col - 1]
296      for (const cell of cells.slice(from, to + 1)) {
297        if (cell.ch === ' ' || cell.ch === '─') {
298          cell.ch = '─'
299          cell.lane = target
300        } else {
301          // A lane or another join or fork stands in the way: the line passes through it,
302          // and the cell keeps its own lane for what it draws
303          if (cell.ch === '│') cell.ch = '┼'
304          cell.across = target
305        }
306      }
307    }
308
309    while (cells.at(-1)?.ch === ' ') cells.pop()
310    while (lanes.length > 0 && lanes[lanes.length - 1] === null) lanes.pop()
311    commit.cells = cells
312  }
313
314  return commits
315}
316
317/** Whether a commit matches what the find field holds: its subject, author, hash or a ref, any case. */
318export function matches (commit: Commit, query: string): boolean {
319  const needle = query.trim().toLowerCase()
320  if (needle === '' || commit.kind) return needle === ''
321  return commit.hash.startsWith(needle) ||
322    commit.subject.toLowerCase().includes(needle) ||
323    commit.author.toLowerCase().includes(needle) ||
324    commit.refs.some(ref => ref.name.toLowerCase().includes(needle))
325}
326
327/** A text cut in three around the first place it holds the query, for drawing that part lit. */
328export function splitMatch (text: string, query: string): [string, string, string] {
329  const needle = query.trim().toLowerCase()
330  const at = needle === '' ? -1 : text.toLowerCase().indexOf(needle)
331  return at === -1 ? [text, '', ''] : [text.slice(0, at), text.slice(at, at + needle.length), text.slice(at + needle.length)]
332}
333
334/** Every commit HEAD reaches through its parents, among the ones loaded. */
335export function ancestorsOf (commits: Commit[], head: string): Set<string> {
336  const parents = new Map(commits.map(commit => [commit.hash, commit.parents]))
337  const reached = new Set<string>()
338  const queue = head ? [head] : []
339  while (queue.length > 0) {
340    const hash = queue.pop() as string
341    if (reached.has(hash) || !parents.has(hash)) continue
342    reached.add(hash)
343    queue.push(...(parents.get(hash) ?? []))
344  }
345  return reached
346}
347
348/** A remote's address, in scp form (user@host:owner/repo.git) or as an https, ssh or git URL → its web address. */
349export function forgeOf (url: string): Forge | null {
350  const trimmed = url.trim()
351  const match = trimmed.match(/^(?:https?|ssh|git):\/\/(?:[^@/]+@)?([^/:]+)(?::\d+)?\/(.+?)(?:\.git)?\/?$/) ??
352    trimmed.match(/^[\w.-]+@([^:/]+):(.+?)(?:\.git)?\/?$/)
353  if (!match) return null
354  const host = (match[1] ?? '').toLowerCase()
355  const kind = host.includes('github') ? 'github' : host.includes('gitlab') ? 'gitlab' : host.includes('bitbucket') ? 'bitbucket' : 'other'
356  return { base: 'https://' + host + '/' + match[2], host, kind }
357}
358
359/** A commit's page on its forge, where the forge is one whose pages are known. */
360export function commitUrl (forge: Forge | null, hash: string): string | null {
361  if (!forge || forge.kind === 'other') return null
362  return `${forge.base}${forge.kind === 'gitlab' ? '/-/commit/' : forge.kind === 'bitbucket' ? '/commits/' : '/commit/'}${hash}`
363}
364
365/** An issue's page (GitHub sends a pull request's number on to it as well). */
366export function issueUrl (forge: Forge | null, number: string): string | null {
367  if (!forge || forge.kind === 'other') return null
368  return `${forge.base}${forge.kind === 'gitlab' ? '/-/issues/' : '/issues/'}${number}`
369}
370
371/** The page that opens a pull (or merge) request from `branch` into `into`, where the forge is known. */
372export function pullRequestUrl (forge: Forge | null, branch: string, into: string): string | null {
373  if (!forge || forge.kind === 'other' || branch === into) return null
374  const from = encodeURIComponent(branch)
375  const to = encodeURIComponent(into)
376  if (forge.kind === 'gitlab') return `${forge.base}/-/merge_requests/new?merge_request%5Bsource_branch%5D=${from}&merge_request%5Btarget_branch%5D=${to}`
377  if (forge.kind === 'bitbucket') return `${forge.base}/pull-requests/new?source=${from}&dest=${to}`
378  // GitHub reads the branches in the path, where a branch's slashes stay slashes
379  const path = (name: string) => name.split('/').map(encodeURIComponent).join('/')
380  return `${forge.base}/compare/${path(into)}...${path(branch)}?expand=1`
381}
382
383/** A commit message as markdown: its web addresses and `#123` issue numbers become links. */
384export function linkify (message: string, forge: Forge | null): string {
385  return message.replace(/(https?:\/\/[^\s<>]+[^\s<>.,;:!?)\]'"])|(^|[^\w&/#])#(\d+)\b/gm, (whole, url: string | undefined, before: string | undefined, number: string | undefined) => {
386    if (url) return `<${url}>`
387    const href = issueUrl(forge, number ?? '')
388    return href ? `${before ?? ''}[#${number}](${href})` : whole
389  })
390}
391
392/** Git Graph's own lane colors, for the curves desktop draws. */
393export const CURVE_COLORS = ['#0085d9', '#d9008f', '#00d90a', '#d98500', '#a300d9', '#ff0000', '#00d9cc', '#e138e8']
394
395/** The size of one cell of the curves, in CSS pixels: a desktop row's height, and a lane step. */
396// A lane step of two cells, 14 px, stays inside the column's width: a desktop cell is about 7.5 px
397export const CURVE_CELL = { width: 7, height: 19 }
398
399/**
400 * One row's cells as an SVG of curves, like Git Graph draws: a line per lane, rounded joins
401 * and forks, and the commit's dot; hollow for uncommitted changes, a diamond for a stash.
402 */
403export function curvesOf (cells: Cell[]): string {
404  const { width: w, height: h } = CURVE_CELL
405  const mid = h / 2
406  // One path per color, its segments joined: every row's SVG goes to the surface on each redraw
407  const strokes = new Map<string, string>()
408  const nodes: string[] = []
409  cells.forEach((cell, i) => {
410    const color = CURVE_COLORS[cell.lane % CURVE_COLORS.length] ?? CURVE_COLORS[0]
411    const x = i * w + w / 2
412    const left = i * w
413    const right = left + w
414    const line = (d: string, stroke = color) => strokes.set(stroke ?? '', (strokes.get(stroke ?? '') ?? '') + d)
415    // A line passing through goes under what the cell draws, in its own lane's color
416    if (cell.across !== undefined) line(`M${left} ${mid}H${right}`, CURVE_COLORS[cell.across % CURVE_COLORS.length])
417    switch (cell.ch) {
418      case '│': case '┼': line(`M${x} 0V${h}`); break
419      case '─': line(`M${left} ${mid}H${right}`); break
420      case '╮': line(`M${left} ${mid}Q${x} ${mid} ${x} ${h}`); break
421      case '╭': line(`M${right} ${mid}Q${x} ${mid} ${x} ${h}`); break
422      case '╯': line(`M${x} 0Q${x} ${mid} ${left} ${mid}`); break
423      case '╰': line(`M${x} 0Q${x} ${mid} ${right} ${mid}`); break
424      case '●': case '○': case '◈':
425        if (cell.up) line(`M${x} 0V${mid}`)
426        if (cell.down) line(`M${x} ${mid}V${h}`)
427        nodes.push(cell.ch === '○'
428          ? `<circle cx="${x}" cy="${mid}" r="3.5" fill="none" stroke="${color}" stroke-width="1.5" stroke-dasharray="2 1.5"/>`
429          : cell.ch === '◈'
430            ? `<rect x="${x - 3.5}" y="${mid - 3.5}" width="7" height="7" transform="rotate(45 ${x} ${mid})" fill="${color}"/>`
431            : `<circle cx="${x}" cy="${mid}" r="4" fill="${color}"/>`)
432        break
433    }
434  })
435  return svgOf(Math.max(1, cells.length) * w, h, strokes, nodes.join(''))
436}
437
438/** An SVG of one stroked path per color, under whatever else it holds. */
439function svgOf (width: number, height: number, strokes: Map<string, string>, over: string): string {
440  const paths = [...strokes].map(([stroke, d]) => `<path stroke="${stroke}" d="${d}"/>`).join('')
441  return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><g fill="none" stroke-width="2" stroke-linecap="round">${paths}</g>${over}</svg>`
442}
443
444/** `Alvaro Muñoz` → `AM`; one name gives its first two letters. */
445export function initialsOf (name: string): string {
446  // Words that start with a letter or digit: `Ana <a@b.c>` is Ana's
447  const words = name.trim().split(/[\s._-]+/).filter(word => /^[\p{L}\p{N}]/u.test(word))
448  if (words.length === 0) return '?'
449  const letters = words.length === 1 ? Array.from(words[0] ?? '').slice(0, 2) : [Array.from(words[0] ?? '')[0], Array.from(words.at(-1) ?? '')[0]]
450  return letters.join('').toUpperCase()
451}
452
453/** The same author always gets the same color, out of `count`. */
454export function colorIndexOf (name: string, count: number): number {
455  let hash = 0
456  for (const ch of name) hash = (hash * 31 + (ch.codePointAt(0) ?? 0)) >>> 0
457  return hash % count
458}
459
460/** An author's avatar for the surfaces that draw SVG: initials on a circle of their color. */
461export function avatarOf (name: string, size = 16): string {
462  const color = CURVE_COLORS[colorIndexOf(name, CURVE_COLORS.length)]
463  const text = initialsOf(name).replace(/[<>&"]/g, '')
464  const r = size / 2
465  return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}"><circle cx="${r}" cy="${r}" r="${r}" fill="${color}"/><text x="${r}" y="${r}" dy=".35em" text-anchor="middle" font-family="sans-serif" font-size="${size * 0.45}" font-weight="600" fill="#fff">${text}</text></svg>`
466}
467
468/** Whether a cell's lane goes on below its row: a lane, a crossing, a fork, or a commit with a parent. */
469export function goesDown (cell: Cell): boolean {
470  if (cell.ch === '│' || cell.ch === '┼' || cell.ch === '╮' || cell.ch === '╭') return true
471  return (cell.ch === '●' || cell.ch === '○' || cell.ch === '◈') && cell.down === true
472}
473
474/**
475 * The lanes that go on below a row, as straight lines `rows` rows tall: drawn beside a panel
476 * opened under that row, so the lanes reach the next commit through it.
477 */
478export function linesDownOf (cells: Cell[], rows: number): string {
479  const { width: w, height: h } = CURVE_CELL
480  const strokes = new Map<string, string>()
481  cells.forEach((cell, i) => {
482    if (!goesDown(cell)) return
483    const stroke = CURVE_COLORS[cell.lane % CURVE_COLORS.length] ?? ''
484    strokes.set(stroke, `${strokes.get(stroke) ?? ''}M${i * w + w / 2} 0V${rows * h}`)
485  })
486  return svgOf(Math.max(1, cells.length) * w, rows * h, strokes, '')
487}
488
489/** A join or fork that a line passes through draws as a tee: `╮` becomes `┬`, `╯` becomes `┴`. */
490export function glyphOf (cell: Cell): string {
491  if (cell.across === undefined) return cell.ch
492  return cell.ch === '╮' || cell.ch === '╭' ? '┬' : cell.ch === '╯' || cell.ch === '╰' ? '┴' : cell.ch
493}
494
495/** The cells as plain text, for tests and for the transcript fallback. */
496export function draw (commit: Commit): string {
497  return commit.cells.map(glyphOf).join('')
498}
499
types/index.d.ts 151 lines
1/**
2 * One cell of the graph column: a glyph and the lane it belongs to. A commit's own cell also
3 * says whether its lane comes from above (a child waits for it) and goes on below (a parent).
4 * `across` is the lane of a horizontal line that passes through a cell drawing something else.
5 */
6export type Cell = { ch: string; lane: number; up?: boolean; down?: boolean; across?: number }
7
8export type Ref = { name: string; kind: 'head' | 'local' | 'remote' | 'tag' }
9
10export type Commit = {
11  hash: string
12  parents: string[]
13  refs: Ref[]
14  author: string
15  date: string
16  subject: string
17  cells: Cell[]
18  /** A row that is no commit on a branch: the working tree's uncommitted changes, or a stash */
19  kind?: 'worktree' | 'stash'
20  /** A stash's name, `stash@{0}` */
21  stash?: string
22}
23
24/**
25 * A file a commit (or the working tree) changed: A, M, D, R, C, T or ? (untracked), with the
26 * lines it added and deleted, when they are known; a binary file has no lines.
27 */
28export type FileChange = { status: string; path: string; oldPath?: string; added?: number; deleted?: number; isBinary?: true }
29
30/** A local branch's upstream (`origin/main`) and the commits only one side has; `gone` when deleted there. */
31export type Upstream = { name: string; ahead: number; behind: number; isGone?: boolean }
32
33/** Where the repo's first remote lives on the web, for links to its commits and issues. */
34export type Forge = { base: string; host: string; kind: 'github' | 'gitlab' | 'bitbucket' | 'other' }
35
36export type Graph = {
37  commits: Commit[]
38  branch: string
39  /** Files with uncommitted changes */
40  changes: FileChange[]
41  /** Local branches, then remote ones, for the branch picker */
42  branches: string[]
43  /** How many of `branches` are local: the rest are remote */
44  localCount: number
45  /** Each local branch's upstream, and how far it is ahead of and behind it */
46  upstreams: Record<string, Upstream>
47  /** The repo's remotes, `origin` first when there is one */
48  remotes: string[]
49  /** The commit HEAD points at, or '' in a repo with no commit */
50  head: string
51  /** The first remote's web address, when it has one */
52  forge: Forge | null
53  /** The branch the first remote's HEAD names, which pull requests go into */
54  defaultBranch: string
55  /** The session's repo and its submodules, for the repo picker: absolute paths */
56  repos: { path: string; label: string }[]
57  error: string | null
58  loadedAt: number
59}
60
61/**
62 * The open commit: who and when, its message, and the files it changed; or, while a compare is
63 * on, the files that changed from the older of the two commits to the newer.
64 */
65export type Detail = {
66  hash: string
67  meta: string
68  message: string
69  files: FileChange[]
70  isLoading?: boolean
71  compare?: { from: string; to: string }
72  /** What git says of the commit's signature, when it has one */
73  signature?: Signature
74} | null
75
76/** A commit's GPG or SSH signature, as git checked it: the line to show and how it reads. */
77export type Signature = { text: string; kind: 'good' | 'unknown' | 'bad' | 'unchecked' }
78
79/** One file's diff, opened under it in the commit's detail: unified-diff hunks, or why there are none. */
80export type Diff = { hash: string; path: string; hunks: string; note?: string; isLoading?: boolean } | null
81
82/** An action on a commit that changes the repo: it waits for a confirming click. */
83export type CommitAction = 'checkout' | 'cherry-pick' | 'revert' | 'merge' | 'rebase' | 'reset'
84
85/** How far `reset` goes: the branch only, also the index, or also the working tree. */
86export type ResetMode = 'soft' | 'mixed' | 'hard'
87
88/** An action on a branch or tag badge that changes the repo or a remote: it waits for a confirming click. */
89export type RefAction = 'checkout-ref' | 'merge-ref' | 'rebase-ref' | 'delete-branch' | 'force-delete-branch' | 'delete-remote-branch' | 'delete-tag' | 'push-ref'
90
91/** An action on a stash that loses it: it waits for a confirming click. */
92export type StashAction = 'stash-pop' | 'stash-drop'
93
94/** An action waiting for its confirming click: on a commit or stash (its hash) or on a ref (its name). */
95export type Pending = { action: CommitAction | RefAction | StashAction; hash: string }
96
97/** How the date column reads: `19 Oct 2026 10:00`, `3 hours ago`, or `2026-10-19 10:00`. */
98export type DateFormat = 'absolute' | 'relative' | 'iso'
99
100export type Note = { kind: 'ok' | 'error' | 'info'; text: string }
101
102declare module 'claude-code' {
103  interface PluginState {
104    'git-graph': {
105      graph: Graph | null
106      selected: string | null
107      detail: Detail
108      diff: Diff
109      allBranches: boolean
110      /** The repo drawn: a submodule's absolute path; null: the session's own */
111      repo: string | null
112      /** Counts the picks of a separator in the branch picker, so the picker draws again */
113      pickerRedraw: number
114      /** One branch to draw alone, picked in the header; null: every branch or the current one */
115      branchFilter: string | null
116      /** Where the page of rows drawn starts, in the rows the find field keeps */
117      pageStart: number
118      compact: boolean
119      /** The settings row under the header is open */
120      settingsOpen: boolean
121      /** Desktop draws the lanes as curves (SVG) instead of box-drawing glyphs */
122      curves: boolean
123      /** Each author has an avatar: their initials on their own color */
124      avatars: boolean
125      /** The author column is drawn */
126      showAuthor: boolean
127      /** The date column is drawn */
128      showDate: boolean
129      dateFormat: DateFormat
130      /** Merge commits are drawn dim */
131      muteMerges: boolean
132      /** Commits HEAD does not reach are drawn dim */
133      muteOthers: boolean
134      pending: Pending | null
135      note: Note | null
136      /** The branch or tag badge whose actions are open */
137      ref: Ref | null
138      /** The branch whose rename field is open */
139      renaming: string | null
140      /** What a stash field is open for: stashing the changes, or a branch from a stash (its hash) */
141      stashing: string | null
142      /** The commit marked to compare others with */
143      compareBase: string | null
144      /** The commit whose tag field is open */
145      tagging: string | null
146      /** What the find field holds: rows that match it are the only ones drawn */
147      query: string
148    }
149  }
150}
151