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

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" />
/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.PATH, and a folder inside a git repository (submodules are picked from the repo picker).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.
/git-graph doesn't appear/reload-plugins, or start a new session: a session loads its plugins when it starts./plugin and look for an error next to git-graph; claude plugin list from a shell shows whether it is installed and enabled.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./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./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.⎇ 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.fetching…).***** (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.19 Oct 2026 10:00), relative (3 hours ago) or iso (2026-10-19 10:00)./git-graph all and /git-graph current). Where the surface has no picker, a button toggles all and current.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.↑1 to push, ↓2 to pull · origin/main).#123 links to that issue or pull request and web addresses are links.v1.2 makes a lightweight tag, v1.2: message an annotated one.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" />
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.
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.
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.
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 -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.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.Commit the current uncommitted changes. Look at the diff, propose a commit message and ask me before committing.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.git show and are only shown in the pane. Pushing and fetching use whatever git is already set up with, inside git.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.#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.| Command | What it does |
|---|---|
npm run test:node | The lane layout's tests, with Node's test runner |
npm run validate | claude plugin validate . |
npm run test:mod | claude plugin test . |
npm run zip | dist/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>.
Every version and what it changed is in CHANGELOG.md. To report a bug or ask for something, open an issue.
MIT © Alvaro Muñoz. What the plugin collects (nothing) is in PRIVACY.md.
hooks/register.tsx 1605 lines1// 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 lines1// 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}
69hooks/graph.ts 499 lines1// 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}
499types/index.d.ts 151 lines1/**
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