A lazygit-style side pane for Claude Code: worktrees, local branches, tags and a commit graph as rows you click, with branch and commit popups for the everyday…

A lazygit-style side pane for Claude Code: the worktrees of the repository the session is in, its local branches, tags and a commit graph, all as rows you can click. Branch and commit popups hold the everyday git actions, so you rarely leave the session to run them.
app
[ fetch ] [ pull ] [ push ]
Worktrees (3) add
● app main ~2
app-feature feature/login locked
app-spike spike
Branches (4)
* main ↓2
+ feature/login ↑3 ↓1
+ spike
old gone
Tags (1)
v1 2026-10-01
History
╭──────────────────────────╮
│ Filter the git tree │
╰──────────────────────────╯
● add spike (HEAD -> ..){origin}
● fix graph
load 50 more
Run /reflog to open it (or read git again if it is open) and /reflog stop to close it.
/plugin marketplace add Nfet445/cc-reflog
/plugin install reflog@cc-reflog
or from a shell: claude plugin marketplace add Nfet445/cc-reflog and claude plugin install reflog@cc-reflog. Requires Claude Code 2.1.287 or newer.
In the fullscreen layout the engine docks the pane beside the transcript, floor to ceiling. That layout needs a terminal at least 110 columns wide; turn it on with /tui fullscreen (the session restarts and resumes) or CLAUDE_CODE_NO_FLICKER=1. On the classic layout the same pane sits above the prompt, with the same rows and actions.
The pane width is a request: columns (default 56) sets it when the pane opens, and a width you drag the dock to wins.
Mouse clicks land in the fullscreen layout. Everywhere else, focus the pane (ctrl+x tab), move with Tab and press with Enter. Esc hands the keyboard back to the prompt and leaves the pane open.
Buttons come in two styles: primary for actions, secondary for more / less. more in a popup reveals the rarer or riskier buttons; pressing anything else folds them again.
| Button | Action |
|---|---|
fetch | git fetch --all; shown when the repository has a remote |
pull | git pull --ff-only on the current branch; shown when it has an upstream, a diverged branch is refused |
push | asks yes / no, then pushes the current branch; a branch with no upstream gets --set-upstream to the first remote. Never --force |
fetch and pull run at once. All three run with GIT_TERMINAL_PROMPT=0 and a timeout.
| Row | Click or Enter |
|---|---|
| worktree | moves the session there with the engine's own /cd (the ● row is where you are) |
add | asks for a branch name and runs git worktree add into <repo>-<name> next to the main worktree (-b when the branch does not exist) |
… | lock / unlock, and remove after yes (no --force; git refuses a dirty or locked one; not offered for the current worktree) |
A branch row opens a popup under it with its upstream and age.
| Button | Action |
|---|---|
switch (s) | git switch <branch> in the current worktree, only when that worktree has no uncommitted changes |
open worktree (o) | instead of switch, when another worktree has the branch checked out (+): /cd there |
rename | asks for a new name, then git branch -m |
more → delete | after yes, git branch -d (git itself refuses an unmerged branch or one checked out elsewhere) |
more → merge / rebase | after yes, merges the branch into, or rebases, the current branch; refused on a dirty tree |
history of this branch | draws only that branch's commits; the button then reads show all history |
The current branch's popup reads checked out here instead of the buttons above.
Each tag is listed with its date. Click one for delete (after yes, git tag -d).
The graph is laid out by the mod itself, drawn with kitty's box characters. A row's refs are shortened to fit: (HEAD -> ..) for branches, {origin} for remotes, [v1] for tags; the full lists are in the commit popup. (branches) are magenta, {remotes} red, [tags] green.
| Row | Click or Enter |
|---|---|
| commit | opens a popup under the row: full hash, author, date, message (wrapped), refs and changed files (up to maxFiles); click again to close |
| hash | puts the full hash into the prompt at the cursor |
tag / branch | ask for a name: git tag <name> / git branch <name> <hash> |
checkout | git switch --detach <hash> |
cherry-pick | git cherry-pick <hash> |
more → revert | git revert --no-edit <hash> |
more → drop | after yes, git rebase --onto <hash>^ <hash> |
more → merge / rebase | after yes, merges the commit into, or rebases the current branch onto, the commit |
more → reset --hard | after yes, git reset --hard <hash> |
| filter field | a rounded box under History, always shown (placeholder Filter the git tree): type text and press Enter to keep the commits whose message has it (case-insensitive, literal), drawn flat; an empty field shows all; clear (shown while a filter or branch scope is set) resets both |
load N more | under the graph while it is full: reads N more commits (N is maxCommits) |
On the checked-out commit (HEAD) the buttons that would do nothing are hidden: checkout, cherry-pick, revert and merge.
delete, merge, rebase, drop, reset --hard, push, worktree remove and tag delete ask yes / no first.--continue command, so the model can resolve it; the mod never resolves or aborts it itself. A failure with no conflicts is aborted.--force or branch -D, never stashes, and the only commits it makes are the ones cherry-pick and revert create.The pane reads git when it opens, after every /cd, after each of Claude's turns (so a branch or worktree Claude just created shows up) and on /reflog.
Markers: ● the worktree the session is in, * its branch, + a branch checked out in another worktree, ~N uncommitted paths, ↑ / ↓ ahead / behind its upstream, gone when the upstream branch was deleted.
Only git, by argv (no shell), through $.process.run.
rev-parse --show-toplevel, worktree list --porcelain, remote, for-each-ref (branches and tags), status --porcelain in each worktree (the first 12), log --branches --topo-order -n<maxCommits>, show for a commit popup.switch, switch --detach, branch, branch -m, branch -d, tag, tag -d, merge, rebase, rebase --onto, cherry-pick, revert, reset --hard, worktree add / remove / lock / unlock.fetch --all, pull --ff-only, push. columns: number width asked for the docked pane, 28-120 (default 56)
maxBranches: number local branches listed, most recently committed first, 0 lists all (default 0)
maxCommits: number commits drawn in the history graph, newest first, 0 draws all (default 50)
maxFiles: number changed files listed in a commit's popup, 0 lists all (default 10)
openOnStart: boolean open the pane when a session starts (default true)
Declared in cc-reflog/.claude-plugin/plugin.json (userConfig). Set them in /config, in user settings (~/.claude/settings.json, never project settings), with --settings <file> or in managed settings, under the plugin's id (reflog@cc-reflog when installed from the marketplace, plain reflog with --plugin-dir):
{ "pluginConfigs": { "reflog": { "options": { "columns": 56 } } } }
A pane opened on its own at session start stays hidden below 144 columns until you run /reflog.
There is no build step. From the repository root:
claude --plugin-dir cc-reflog # try it live; edits hot-reload, then /reflog
claude plugin test cc-reflog # the tests
claude plugin validate cc-reflog # lists every hooked event and every `$` call
A pane that is already open does not redraw on /reload-plugins; run /reflog stop and /reflog.
If /reflog is missing from the typeahead, the mod did not load: run claude --debug and look for hooks module reflog@… loaded in ~/.claude/debug/latest.
The tests answer $.process.run with a fake git, mount the pane on the terminal surface and press its rows.
Layout of the code:
cc-reflog/hooks/git.ts — pure git plumbing: argv builders and output parsers, no engine calls.cc-reflog/hooks/cc-reflog.tsx — the engine side: state, the /reflog command, ui.press handling and rendering.cc-reflog/tests/cc-reflog.test.tsx — the tests above.Requirements. Claude Code 2.1.287+. Typed against Anthropic's declarations: https://github.com/anthropics/claude-code/tree/main/mods
hooks/cc-reflog.tsx 803 lines1/**
2 * cc-reflog — Claude Mod
3 *
4 * A small lazygit-style pane: every worktree of the repository the session is
5 * in, and its local branches, as rows you can click (or Tab to and press
6 * Enter). `/reflog` opens it; in the fullscreen layout (`/tui fullscreen`)
7 * the engine docks it beside the transcript, floor to ceiling, otherwise it
8 * sits above the prompt.
9 *
10 * - a worktree row moves the session there, through the engine's own `/cd`
11 * - a branch row shows what can be done with it:
12 * `switch` (clean tree only) or `open worktree` when another worktree has it,
13 * `rename`, `delete`, `merge` and `rebase`; the commit popup offers the same
14 * for each other branch that points at the commit
15 *
16 * Git runs through `$.process.run` by argv (no shell); every read is in
17 * ./git.ts. The only writes are the ones a branch's buttons ask for: `git switch`,
18 * `branch -m`, `branch -d`, `merge` and `rebase` (a merge or rebase that stops is
19 * left in place: the prompt asks the model to resolve the conflicts). Switch,
20 * merge and rebase are refused while the current worktree has uncommitted changes; delete, merge and rebase ask "yes / no" first.
21 * A commit popup adds `tag`, `branch`, `checkout` (detached), `cherry-pick`,
22 * `revert`, `drop`, `merge`, `rebase` and `reset --hard`; drop, merge, rebase and
23 * reset ask "yes / no", and all but tag and branch need a clean tree.
24 * `fetch` (Branches) and, in the current branch's popup, `pull` (--ff-only) and
25 * `push` (asks "yes / no", never --force) talk to the remote; remote refs in the
26 * graph are drawn as {origin/main}.
27 * Tags can be deleted, worktrees added, locked and removed (no --force); the
28 * history can be filtered by message or limited to one branch, and loads more.
29 *
30 * Needs Claude Code >= 2.1.287.
31 *
32 * Options (pluginConfigs["reflog"].options):
33 * columns: number width asked for the docked pane (default 56)
34 * maxBranches: number most recent local branches listed, 0 lists all (default 0)
35 * maxCommits: number commits drawn in the history graph, 0 draws all (default 50)
36 * maxFiles: number changed files listed in a commit's popup, 0 lists all (default 10)
37 * openOnStart: boolean open the pane when the session starts (default true)
38 */
39import type { ProcessRunResult, Register } from 'claude-code'
40import {
41 STATUS,
42 TOPLEVEL,
43 WORKTREES,
44 basename,
45 commitStamp,
46 branchAction,
47 branchColor,
48 branchesArgv,
49 countDirty,
50 currentWorktree,
51 fit,
52 ICONS,
53 graphArgv,
54 maxBranches,
55 limitFiles,
56 maxCommits,
57 maxFiles,
58 paneColumns,
59 parseBranches,
60 parseShow,
61 layoutGraph,
62 parseGraphLog,
63 parseWorktrees,
64 showArgv,
65 deleteArgv,
66 mergeArgv,
67 rebaseArgv,
68 TAGS,
69 REMOTES,
70 FETCH,
71 PULL,
72 NET_INIT,
73 pushArgv,
74 splitRefs,
75 parseTags,
76 tagDeleteArgv,
77 worktreePath,
78 worktreeAddArgv,
79 worktreeRemoveArgv,
80 worktreeLockArgv,
81 tagArgv,
82 newBranchArgv,
83 detachArgv,
84 pickArgv,
85 revertArgv,
86 dropArgv,
87 resetArgv,
88 PICK_ABORT,
89 REVERT_ABORT,
90 CONFIRMED_COMMIT_OPS,
91 renameArgv,
92 MERGE_ABORT,
93 REBASE_ABORT,
94 CONFLICTS,
95 switchArgv,
96 trackLabel,
97} from './git.ts'
98import type { Branch, BranchOp, CommitDetail, CommitOp, ConfirmedOp, GraphRow, Snapshot } from './git.ts'
99
100const PANE = 'git'
101// commit buttons that do nothing on the checked-out commit
102const HEAD_HIDDEN = ['checkout', 'pick', 'revert', 'merge']
103const LANE_COLORS = ['magenta', 'yellow', 'cyan', 'green', 'red', 'blue']
104const COMMAND = 'reflog'
105// worktrees whose `git status` is read on each refresh; the rest show no count
106const MAX_STATUS_READS = 12
107
108type Run = (argv: readonly string[], cwd?: string) => Promise<ProcessRunResult>
109
110let snap: Snapshot | undefined
111let error: string | undefined
112let selected: string | undefined
113// merge topology of all branches, newest first, read on each refresh
114let graph: GraphRow[] = []
115// the commit whose details are open under the graph
116let shown: CommitDetail | undefined
117let commitLimit = 50
118// how many more commits `load more` adds: the configured size, 0 when the history is unlimited
119let commitStep = 50
120// the history view: commits whose message has `filter`, and/or those of one branch only
121let filter = ''
122let scope: string | undefined
123let fileLimit = 10
124// a delete, merge or rebase waiting for "yes", and the popup (`b` branch, `c` commit) that asked
125let pending: { op: ConfirmedOp; name: string } | undefined
126// a branch being renamed, in the popup that asked
127let renaming: { name: string } | undefined
128// a commit action waiting for "yes", or for the name of the tag or branch to create
129let commitAsk: { op: CommitOp; hash: string } | undefined
130let naming: { op: 'tag' | 'branch'; hash: string } | undefined
131// the open tag popup, and whether it waits for "yes" to delete
132let selectedTag: string | undefined
133let tagAsk = false
134// the current branch's push waiting for "yes"
135let pushAsk = false
136// whether the popups show their rarer buttons (`more` toggles it, any other press folds it)
137let moreOps = false
138// the worktree whose menu is open (index), whether its removal waits for "yes", and the "add" input
139let wtMenu: number | undefined
140let wtAsk = false
141let addingWt = false
142let isOpen = false
143
144const firstLine = (text: string) => text.trim().split('\n')[0] ?? ''
145
146async function readSnapshot(run: Run, branchLimit: number): Promise<void> {
147 const top = await run(TOPLEVEL)
148 if (top.exitCode !== 0) {
149 snap = undefined
150 error = firstLine(top.stderr) || 'not a git repository'
151 return
152 }
153 const [wts, refs, log, tagOut, remoteOut] = await Promise.all([
154 run(WORKTREES),
155 run(branchesArgv(branchLimit)),
156 run(graphArgv(commitLimit, { branch: scope, grep: filter || undefined })).catch(() => undefined),
157 run(TAGS).catch(() => undefined),
158 run(REMOTES).catch(() => undefined),
159 ])
160 const worktrees = wts.exitCode === 0 ? parseWorktrees(wts.stdout) : []
161 await Promise.all(
162 worktrees
163 .filter(wt => !wt.bare && !wt.prunable)
164 .slice(0, MAX_STATUS_READS)
165 .map(async wt => {
166 const st = await run(STATUS, wt.path).catch(() => undefined)
167 if (st?.exitCode === 0) wt.dirty = countDirty(st.stdout)
168 }),
169 )
170 snap = {
171 root: top.stdout.trim(),
172 worktrees,
173 branches: refs.exitCode === 0 ? parseBranches(refs.stdout) : [],
174 tags: tagOut?.exitCode === 0 ? parseTags(tagOut.stdout) : [],
175 remotes: remoteOut?.exitCode === 0 ? remoteOut.stdout.split('\n').filter(Boolean) : [],
176 }
177 error = refs.exitCode === 0 ? undefined : firstLine(refs.stderr)
178 // a text filter leaves commits whose parents are not in the list, so they are drawn flat
179 graph = log?.exitCode === 0 ? layoutGraph(parseGraphLog(log.stdout).map(c => (filter ? { ...c, parents: [] } : c))) : []
180 if (selected && !snap.branches.some(b => b.name === selected)) selected = undefined
181 if (scope && !snap.branches.some(b => b.name === scope)) scope = undefined
182 if (selectedTag && !snap.tags.some(t => t.name === selectedTag)) selectedTag = undefined
183 if (shown && !graph.some(row => row.hash === shown?.hash)) shown = undefined
184}
185
186export const register: Register = (on, options) => {
187 const columns = paneColumns(options.columns)
188 const branchLimit = maxBranches(options.maxBranches)
189 commitLimit = maxCommits(options.maxCommits)
190 commitStep = commitLimit
191 fileLimit = maxFiles(options.maxFiles)
192 const openOnStart = options.openOnStart !== false
193
194 on('session.start', async ($, e, next) => {
195 const r = await next(e)
196 await $.command
197 .register({
198 name: COMMAND,
199 description: 'Worktrees and branches in a side pane; click one to go there (stop closes)',
200 argumentHint: '[stop|refresh]',
201 immediate: true,
202 })
203 .catch(err => $.ui.log(`cc-reflog: /${COMMAND} not registered: ${err}`))
204 $.ui.log(`cc-reflog loaded: /${COMMAND} opens the pane`, { to: 'debug' })
205 if (openOnStart) {
206 await readSnapshot((argv, cwd) => $.process.run(argv, { cwd }), branchLimit).catch(err => {
207 error = String(err)
208 })
209 isOpen = true
210 // unasked, the engine keeps it undrawn below 144 columns until the person opens it
211 await $.ui.open({ id: PANE, title: 'git', columns }).catch(err => {
212 isOpen = false
213 $.ui.log(`cc-reflog: pane not opened: ${err}`)
214 })
215 }
216 return r
217 })
218
219 on('command.run', { command: COMMAND }, async ($, e) => {
220 const arg = e.args.trim().toLowerCase()
221 if (arg === 'stop' || arg === 'close') {
222 await $.ui.close({ id: PANE }).catch(() => undefined)
223 isOpen = false
224 return { text: 'cc-reflog closed' }
225 }
226 await readSnapshot((argv, cwd) => $.process.run(argv, { cwd }), branchLimit).catch(err => {
227 error = String(err)
228 })
229 isOpen = true
230 await $.ui.open({ id: PANE, title: 'git', focus: true, columns })
231 $.ui.invalidate('ui.render')
232 const hint = e.presentation.isFullscreen
233 ? 'click a row, or Tab and Enter'
234 : 'drawn above the prompt; /tui fullscreen docks it beside the transcript'
235 const found = snap ? `${snap.worktrees.length} worktrees, ${snap.branches.length} branches` : error
236 return { text: `${found} · ${hint} · /${COMMAND} stop closes` }
237 })
238
239 // the session moved (a row of ours, or the person's own /cd): read the new worktree
240 on('command.run', { command: 'cd' }, async ($, e, next) => {
241 const r = await next(e)
242 if (!isOpen) return r
243 await readSnapshot((argv, cwd) => $.process.run(argv, { cwd }), branchLimit).catch(err => {
244 error = String(err)
245 })
246 $.ui.invalidate('ui.render')
247 return r
248 })
249
250 // Claude may have made a branch or a worktree during the turn
251 on('turn.complete', async ($, e, next) => {
252 const r = await next(e)
253 if (!isOpen || e.agentId) return r
254 await readSnapshot((argv, cwd) => $.process.run(argv, { cwd }), branchLimit).catch(err => {
255 error = String(err)
256 })
257 $.ui.invalidate('ui.render')
258 return r
259 })
260
261 on('ui.close', async ($, e, next) => {
262 if (e.id !== PANE) return next(e)
263 const r = await next(e)
264 isOpen = false
265 return r
266 })
267
268 on('ui.press', async ($, e, next) => {
269 if (e.plugin !== $.plugin.name || e.requestId !== PANE) return next(e)
270 const r = await next(e)
271 const key = e.element
272 const s = snap
273 const asked = pending
274 const askedCommit = commitAsk
275 const askedTag = tagAsk
276 const askedPush = pushAsk
277 const askedWt = wtAsk
278 const wasMore = moreOps
279 moreOps = false
280 pending = undefined
281 renaming = undefined
282 commitAsk = undefined
283 naming = undefined
284 tagAsk = false
285 pushAsk = false
286 wtAsk = false
287 addingWt = false
288 const reread = () =>
289 readSnapshot((argv, cwd) => $.process.run(argv, { cwd }), branchLimit).catch(err => {
290 error = String(err)
291 })
292 const clean = async (what: string) => {
293 const st = await $.process.run(STATUS)
294 if (st.exitCode === 0 && countDirty(st.stdout) === 0) return true
295 $.ui.toast(`cc-reflog: uncommitted changes here; commit or stash before ${what}`)
296 return false
297 }
298 // a stop on conflicts stays as it is and the prompt asks the model to resolve it; a
299 // failure that left no conflicts is aborted
300 const stopped = async (what: string, cont: string, abort: readonly string[] | undefined, out: ProcessRunResult) => {
301 const res = await $.process.run(CONFLICTS)
302 const files = res.exitCode === 0 ? res.stdout.split('\n').filter(Boolean) : []
303 if (files.length === 0) {
304 if (abort) await $.process.run(abort).catch(() => undefined)
305 $.ui.toast(`cc-reflog: ${what} failed${abort ? ' and was aborted' : ''}: ${firstLine(out.stderr) || firstLine(out.stdout)}`)
306 return
307 }
308 const text = `${what} stopped on conflicts in:\n${files.map(f => `- ${f}`).join('\n')}\nResolve them, then run \`${cont}\`.`
309 const put = await $.prompt.fill({ text, mode: 'insert' })
310 $.ui.toast(
311 put.isFilled
312 ? `cc-reflog: ${what} stopped on conflicts; the prompt asks to resolve them`
313 : `cc-reflog: ${what} stopped on conflicts in ${files.length} file(s); resolve them, then ${cont}`,
314 )
315 }
316 // merge or rebase
317 const integrate = async (op: 'merge' | 'rebase', name: string) => {
318 if (!(await clean(`${op === 'merge' ? 'merging' : 'rebasing onto'} ${name}`))) return
319 const out = await $.process.run(op === 'merge' ? mergeArgv(name) : rebaseArgv(name))
320 if (out.exitCode === 0) $.ui.toast(`cc-reflog: ${op === 'merge' ? 'merged' : 'rebased onto'} ${name}`)
321 else await stopped(`${op} ${name}`, `git ${op} --continue`, op === 'merge' ? MERGE_ABORT : REBASE_ABORT, out)
322 await reread()
323 }
324 // a commit action on the current branch
325 const applyCommit = async (op: CommitOp, hash: string) => {
326 const short = hash.slice(0, 7)
327 if (op === 'merge' || op === 'rebase') return integrate(op, hash)
328 if (!(await clean(`${op} ${short}`))) return
329 const [argv, abort, cont] = {
330 checkout: [detachArgv(hash)],
331 pick: [pickArgv(hash), PICK_ABORT, 'git cherry-pick --continue'],
332 revert: [revertArgv(hash), REVERT_ABORT, 'git revert --continue'],
333 drop: [dropArgv(hash), REBASE_ABORT, 'git rebase --continue'],
334 reset: [resetArgv(hash)],
335 }[op as 'checkout' | 'pick' | 'revert' | 'drop' | 'reset']
336 const out = await $.process.run(argv)
337 if (out.exitCode === 0) $.ui.toast(`cc-reflog: ${op} ${short} done`)
338 else if (cont) await stopped(`${op} ${short}`, cont as string, abort, out)
339 else $.ui.toast(`cc-reflog: ${op} failed: ${firstLine(out.stderr) || firstLine(out.stdout)}`)
340 await reread()
341 }
342 const apply = async (op: BranchOp, name: string) => {
343 if (op === 'open') {
344 const path = s?.branches.find(b => b.name === name)?.worktree
345 if (path) {
346 await $.command.run({ command: 'cd', args: path }).catch(err => $.ui.toast(`cc-reflog: /cd failed: ${err}`))
347 }
348 } else if (op === 'switch') {
349 if (!(await clean(`switching to ${name}`))) return
350 const sw = await $.process.run(switchArgv(name))
351 if (sw.exitCode === 0) $.ui.toast(`cc-reflog: switched to ${name}`)
352 else $.ui.toast(`cc-reflog: ${firstLine(sw.stderr) || 'git switch failed'}`)
353 await reread()
354 } else if (op === 'delete') {
355 const out = await $.process.run(deleteArgv(name))
356 if (out.exitCode === 0) $.ui.toast(`cc-reflog: ${firstLine(out.stdout) || `deleted ${name}`}`)
357 else $.ui.toast(`cc-reflog: ${firstLine(out.stderr) || 'git branch -d failed'}`)
358 await reread()
359 } else if (op === 'merge' || op === 'rebase') await integrate(op, name)
360 }
361
362 // fetch, pull and push talk to the remote; each reports git's own answer
363 const sync = async (what: string, argv: readonly string[]) => {
364 $.ui.toast(`cc-reflog: ${what}…`)
365 const out = await $.process.run(argv, NET_INIT).catch(err => ({ exitCode: 1, stdout: '', stderr: String(err) }))
366 const said = firstLine(out.stderr) || firstLine(out.stdout)
367 $.ui.toast(`cc-reflog: ${what} ${out.exitCode === 0 ? 'done' : `failed: ${said}`}`)
368 await reread()
369 }
370
371 if (key === 'fetch') {
372 await sync('fetch', FETCH)
373 } else if (key === 'pull') {
374 await sync('pull', PULL)
375 } else if (key === 'push') {
376 pushAsk = true
377 } else if (key === 'yes' && askedPush) {
378 const b = s?.branches.find(x => x.name === currentWorktree(s)?.branch)
379 const remote = s?.remotes[0] ?? ''
380 if (b && (b.upstream || remote)) await sync(`push ${b.name}`, pushArgv(b.name, b.upstream, remote))
381 else $.ui.toast('cc-reflog: no remote to push to')
382 } else if (s && key.startsWith('wt:')) {
383 const wt = s.worktrees[Number(key.slice(3))]
384 if (wt && wt.path === currentWorktree(s)?.path) $.ui.toast('cc-reflog: already in this worktree')
385 else if (wt) {
386 await $.command
387 .run({ command: 'cd', args: wt.path })
388 .catch(err => $.ui.toast(`cc-reflog: /cd failed: ${err}`))
389 }
390 } else if (s && key.startsWith('br:')) {
391 const branch = s.branches[Number(key.slice(3))]
392 if (branch) selected = selected === branch.name ? undefined : branch.name
393 } else if (shown && key === 'sha') {
394 const hash = shown.hash
395 const put = await $.prompt.fill({ text: hash, mode: 'insert' })
396 $.ui.toast(put.isFilled ? `cc-reflog: ${hash} put in the prompt` : 'cc-reflog: no prompt to put the hash in')
397 } else if (key.startsWith('cm:')) {
398 const hash = graph[Number(key.slice(3))]?.hash
399 if (!hash || shown?.hash === hash) shown = undefined
400 else {
401 const out = await $.process.run(showArgv(hash))
402 if (out.exitCode === 0) shown = parseShow(out.stdout)
403 else $.ui.toast(`cc-reflog: ${firstLine(out.stderr) || 'git show failed'}`)
404 }
405 } else if (selected && (key === 'open' || key === 'switch')) {
406 await apply(key, selected)
407 } else if (key.startsWith('do:')) {
408 const [, op = '', ...rest] = key.split(':')
409 const name = rest.join(':')
410 if (op === 'rename') renaming = { name }
411 else if (op === 'delete' || op === 'merge' || op === 'rebase') pending = { op, name }
412 } else if (key.startsWith('opsmore')) {
413 moreOps = !wasMore
414 } else if (key === 'more') {
415 commitLimit += commitStep
416 await reread()
417 } else if (key === 'clear') {
418 filter = ''
419 scope = undefined
420 await reread()
421 } else if (key === 'scope' && selected) {
422 scope = scope === selected ? undefined : selected
423 await reread()
424 } else if (s && key.startsWith('tg:')) {
425 const name = s.tags[Number(key.slice(3))]?.name
426 selectedTag = selectedTag === name ? undefined : name
427 } else if (key === 'tdel' && selectedTag) tagAsk = true
428 else if (key === 'yes' && askedTag && selectedTag) {
429 const out = await $.process.run(tagDeleteArgv(selectedTag))
430 $.ui.toast(`cc-reflog: ${out.exitCode === 0 ? firstLine(out.stdout) || 'tag deleted' : firstLine(out.stderr) || 'git tag -d failed'}`)
431 await reread()
432 } else if (key === 'wtadd') addingWt = true
433 else if (key.startsWith('wtm:')) {
434 const i = Number(key.slice(4))
435 wtMenu = wtMenu === i ? undefined : i
436 } else if (s && wtMenu !== undefined && (key === 'wtlock' || key === 'wtdel' || (key === 'yes' && askedWt))) {
437 const wt = s.worktrees[wtMenu]
438 if (wt && key === 'wtdel') wtAsk = true
439 else if (wt) {
440 const out = await $.process.run(key === 'wtlock' ? worktreeLockArgv(wt.path, !wt.locked) : worktreeRemoveArgv(wt.path))
441 if (out.exitCode === 0) {
442 $.ui.toast(`cc-reflog: ${key === 'wtlock' ? (wt.locked ? 'unlocked' : 'locked') : 'removed'} ${basename(wt.path)}`)
443 if (key === 'yes') wtMenu = undefined
444 } else $.ui.toast(`cc-reflog: ${firstLine(out.stderr) || 'git worktree failed'}`)
445 await reread()
446 }
447 } else if (shown && key.startsWith('cx:')) {
448 const op = key.slice(3) as CommitOp
449 if (op === 'tag' || op === 'branch') naming = { op, hash: shown.hash }
450 else if (CONFIRMED_COMMIT_OPS.includes(op)) commitAsk = { op, hash: shown.hash }
451 else await applyCommit(op, shown.hash)
452 } else if (key === 'yes' && asked) {
453 await apply(asked.op, asked.name)
454 } else if (key === 'yes' && askedCommit) {
455 await applyCommit(askedCommit.op, askedCommit.hash)
456 }
457 $.ui.invalidate('ui.render')
458 return r
459 })
460
461 on('ui.input', async ($, e, next) => {
462 if (e.plugin !== $.plugin.name || e.requestId !== PANE || e.kind !== 'submit') return next(e)
463 if (!['rename', 'newname', 'filterq', 'wtnew'].includes(e.element)) return next(e)
464 const r = await next(e)
465 const reread = () =>
466 readSnapshot((argv, cwd) => $.process.run(argv, { cwd }), branchLimit).catch(err => {
467 error = String(err)
468 })
469 if (e.element === 'filterq') {
470 filter = e.value.trim()
471 await reread()
472 $.ui.invalidate('ui.render')
473 return r
474 }
475 if (e.element === 'wtnew') {
476 addingWt = false
477 const name = e.value.trim()
478 const main = snap?.worktrees[0]?.path
479 if (name && main) {
480 const out = await $.process.run(
481 worktreeAddArgv(worktreePath(main, name), name, snap?.branches.some(b => b.name === name) ?? false),
482 )
483 $.ui.toast(`cc-reflog: ${out.exitCode === 0 ? `worktree ${name} added` : firstLine(out.stderr) || 'git worktree add failed'}`)
484 await reread()
485 }
486 $.ui.invalidate('ui.render')
487 return r
488 }
489 const from = e.element === 'rename' ? renaming?.name : undefined
490 const create = e.element === 'newname' ? naming : undefined
491 renaming = undefined
492 naming = undefined
493 const to = e.value.trim()
494 if (create && to) {
495 const out = await $.process.run((create.op === 'tag' ? tagArgv : newBranchArgv)(to, create.hash))
496 if (out.exitCode === 0) $.ui.toast(`cc-reflog: ${create.op} ${to} created`)
497 else $.ui.toast(`cc-reflog: ${firstLine(out.stderr) || `git ${create.op} failed`}`)
498 await readSnapshot((argv, cwd) => $.process.run(argv, { cwd }), branchLimit).catch(err => {
499 error = String(err)
500 })
501 } else if (from && to && to !== from) {
502 const out = await $.process.run(renameArgv(from, to))
503 if (out.exitCode === 0) {
504 $.ui.toast(`cc-reflog: renamed ${from} to ${to}`)
505 if (selected === from) selected = to
506 } else $.ui.toast(`cc-reflog: ${firstLine(out.stderr) || 'git branch -m failed'}`)
507 await readSnapshot((argv, cwd) => $.process.run(argv, { cwd }), branchLimit).catch(err => {
508 error = String(err)
509 })
510 }
511 $.ui.invalidate('ui.render')
512 return r
513 })
514
515 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
516 if (e.requestId !== PANE) return next(e)
517 const { Box, Text, Button, Input } = $.ui.resolve(e)
518 const width = Math.max(20, e.props.bodyColumns - 1)
519 const noop = () => {}
520 if (!snap) {
521 return (
522 <Box flexDirection="column">
523 <Text color="red">{fit(error ?? 'reading the repository…', width)}</Text>
524 </Box>
525 )
526 }
527
528 const s = snap
529 const here = currentWorktree(s)
530 const current = s.branches.find(b => b.name === here?.branch)
531 const chosen = selected ? s.branches.find(b => b.name === selected) : undefined
532 // lanes are padded to one width so the hashes line up in a column
533 const laneWidth = Math.max(0, ...graph.map(row => row.cells.length))
534 const now = new Date()
535 const stampWidth = Math.max(0, ...graph.map(row => (row.commit ? commitStamp(row.commit.time, now).length : 0)))
536 const shaWidth = Math.max(0, ...graph.map(row => row.commit?.sha.length ?? 0))
537 // the buttons of a branch popup; `switch` and `open` keep their own keys, the hotkeys of the pane
538 const ops = (b: Branch) => {
539 const act = branchAction(s, b)
540 if (act.kind === 'current') return null
541 if (pending?.name === b.name) {
542 return (
543 <Box key="ops" flexDirection="row" columnGap={1}>
544 <Text color="yellow">{`${pending.op} ${b.name}?`}</Text>
545 <Button key="yes" label="yes" variant="primary" onPress={noop} />
546 <Button key="no" label="no" variant="primary" onPress={noop} />
547 </Box>
548 )
549 }
550 if (renaming?.name === b.name) {
551 return <Input key="rename" label="name " value={b.name} submitLabel="rename" autoFocus onSubmit={noop} />
552 }
553 const go = act.kind === 'switch' ? 'switch' : 'open'
554 const goLabel = go === 'switch' ? 'switch' : 'open worktree'
555 return (
556 <Box key="ops" flexDirection="row" columnGap={1}>
557 <Button key={go} label={goLabel} hotkey={go === 'switch' ? 's' : 'o'} variant="primary" onPress={noop} />
558 <Button key={`do:rename:${b.name}`} label="rename" variant="primary" onPress={noop} />
559 <Button key="opsmore:branch" label={moreOps ? 'less' : 'more'} variant="secondary" onPress={noop} />
560 {moreOps
561 ? (['delete', 'merge', 'rebase'] as const).map(op => (
562 <Button key={`do:${op}:${b.name}`} label={op} variant="primary" onPress={noop} />
563 ))
564 : null}
565 </Box>
566 )
567 }
568
569 return (
570 <Box flexDirection="column">
571 <Text bold>{fit(basename(s.worktrees[0]?.path ?? s.root), width)}</Text>
572 {error ? <Text color="red">{fit(error, width)}</Text> : null}
573 {pushAsk ? (
574 <Box key="push-ask" flexDirection="row" columnGap={1}>
575 <Text color="yellow">{`push to ${current?.upstream ?? s.remotes[0] ?? '?'}?`}</Text>
576 <Button key="yes" label="yes" variant="primary" onPress={noop} />
577 <Button key="no" label="no" variant="primary" onPress={noop} />
578 </Box>
579 ) : (
580 <Box key="sync" flexDirection="row" columnGap={1}>
581 {s.remotes.length > 0 ? <Button key="fetch" label="fetch" variant="primary" onPress={noop} /> : null}
582 {current?.upstream ? <Button key="pull" label="pull" variant="primary" onPress={noop} /> : null}
583 {current && (current.upstream || s.remotes.length > 0) ? <Button key="push" label="push" variant="primary" onPress={noop} /> : null}
584 </Box>
585 )}
586
587 <Box key="wt-head" flexDirection="row" columnGap={1}>
588 <Text bold color="cyan">{`Worktrees (${s.worktrees.length})`}</Text>
589 {addingWt ? null : <Button key="wtadd" label="add" variant="primary" onPress={noop} />}
590 </Box>
591 {addingWt ? <Input key="wtnew" label="branch " value="" submitLabel="add" autoFocus onSubmit={noop} /> : null}
592 {s.worktrees.map((wt, i) => {
593 const isHere = wt === here
594 const name = wt.branch ?? (wt.bare ? '(bare)' : `(${wt.head.slice(0, 7)})`)
595 const dirty = wt.dirty ? ` ~${wt.dirty}` : ''
596 const flags = `${wt.locked ? ' locked' : ''}${wt.prunable ? ' prunable' : ''}`
597 return (
598 <Box key={`wt-row:${i}`} flexDirection="column">
599 <Box key="row" flexDirection="row">
600 <Text>{' '}</Text>
601 <Text color="green">{isHere ? ICONS.here : ICONS.other}</Text>
602 <Button
603 key={`wt:${i}`}
604 plain
605 dimColor={!isHere}
606 label={`${basename(wt.path)} ${name}`}
607 onPress={noop}
608 />
609 {dirty ? <Text color="yellow">{dirty}</Text> : null}
610 {flags ? <Text color="red">{flags}</Text> : null}
611 {i > 0 ? <Button key={`wtm:${i}`} plain dimColor={wtMenu !== i} label=" …" onPress={noop} /> : null}
612 </Box>
613 {wtMenu === i ? (
614 <Box key="wt-detail" flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
615 <Text dimColor>{fit(wt.path, width - 4)}</Text>
616 {wtAsk ? (
617 <Box key="wt-ask" flexDirection="row" columnGap={1}>
618 <Text color="yellow">remove?</Text>
619 <Button key="yes" label="yes" variant="primary" onPress={noop} />
620 <Button key="no" label="no" variant="primary" onPress={noop} />
621 </Box>
622 ) : (
623 <Box key="wt-ops" flexDirection="row" columnGap={1}>
624 <Button key="wtlock" label={wt.locked ? 'unlock' : 'lock'} variant="primary" onPress={noop} />
625 {isHere ? null : <Button key="wtdel" label="remove" variant="primary" onPress={noop} />}
626 </Box>
627 )}
628 </Box>
629 ) : null}
630 </Box>
631 )
632 })}
633
634 <Box key="br-head" flexDirection="row">
635 <Text bold color="cyan">{`Branches (${s.branches.length})`}</Text>
636 </Box>
637 {s.branches.map((b, i) => {
638 const isHere = here?.branch === b.name
639 const marker = isHere ? ICONS.current : b.worktree ? ICONS.elsewhere : ICONS.plain
640 const track = trackLabel(b)
641 return (
642 <Box key={`br-row:${i}`} flexDirection="column">
643 <Box key="row" flexDirection="row">
644 <Text>{' '}</Text>
645 <Text color={isHere ? 'green' : 'blue'}>{marker}</Text>
646 <Text color={branchColor(b.name)}>● </Text>
647 <Button
648 key={`br:${i}`}
649 plain
650 dimColor={!isHere && selected !== b.name}
651 label={b.name}
652 onPress={noop}
653 />
654 {track ? <Text color={b.gone ? 'red' : 'magenta'}>{` ${track}`}</Text> : null}
655 </Box>
656 {chosen === b ? (
657 <Box key="detail" flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
658 <Text bold>{fit(b.name, width - 4)}</Text>
659 <Text dimColor>{fit(`${b.upstream ?? 'no upstream'} · ${b.date}`, width - 4)}</Text>
660 {ops(b) ?? <Text dimColor>checked out here</Text>}
661 <Button key="scope" label={scope === b.name ? 'show all history' : 'history of this branch'} variant="primary" onPress={noop} />
662 </Box>
663 ) : null}
664 </Box>
665 )
666 })}
667
668 {s.tags.length > 0 ? (
669 <Box key="tg-head" flexDirection="row">
670 <Text bold color="cyan">{`Tags (${s.tags.length})`}</Text>
671 </Box>
672 ) : null}
673 {s.tags.map((t, i) => (
674 <Box key={`tg-row:${i}`} flexDirection="column">
675 <Box key="row" flexDirection="row">
676 <Text>{' '}</Text>
677 <Text color="green">◆ </Text>
678 <Button key={`tg:${i}`} plain dimColor={selectedTag !== t.name} label={t.name} onPress={noop} />
679 <Text dimColor>{` ${t.date}`}</Text>
680 </Box>
681 {selectedTag === t.name ? (
682 <Box key="tg-detail" flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
683 {tagAsk ? (
684 <Box key="tg-ask" flexDirection="row" columnGap={1}>
685 <Text color="yellow">delete?</Text>
686 <Button key="yes" label="yes" variant="primary" onPress={noop} />
687 <Button key="no" label="no" variant="primary" onPress={noop} />
688 </Box>
689 ) : (
690 <Button key="tdel" label="delete" variant="primary" onPress={noop} />
691 )}
692 </Box>
693 ) : null}
694 </Box>
695 ))}
696
697 <Box key="graph-head" flexDirection="row" columnGap={1}>
698 <Text bold color="cyan">History</Text>
699 {scope ? <Text color={branchColor(scope)}>{fit(`@${scope}`, 16)}</Text> : null}
700 {filter || scope ? <Button key="clear" label="clear" variant="primary" onPress={noop} /> : null}
701 </Box>
702 <Box key="filter-box" flexDirection="row" borderStyle="round" borderDimColor paddingX={1}>
703 <Input key="filterq" placeholder="Filter the git tree" value={filter} submitLabel="apply" onSubmit={noop} />
704 </Box>
705 {graph.map((row, i) => {
706 const { heads, remotes, tags } = splitRefs(row.commit?.refs ?? '', s.remotes)
707 // the ref labels share what the fixed columns leave, less a minimum for the subject
708 let left = width - (2 + stampWidth + 1 + shaWidth + 1 + laneWidth) - 1 - 8
709 const label = (text: string) => {
710 const out = left > 1 && text ? fit(text, left) : ''
711 left -= out.length
712 return out
713 }
714 // the row keeps short labels (`HEAD -> ..`, the remote's name); the popup has the full names
715 const refs = label(heads.length ? ` (${heads.map(h => (h.startsWith('HEAD -> ') ? 'HEAD -> ..' : h)).join(', ')})` : '')
716 const far = label(remotes.length ? ` {${[...new Set(remotes.map(r => r.split('/')[0]))].join(', ')}}` : '')
717 const tagged = label(tags.length ? ` [${tags.join(', ')}]` : '')
718 // the subject takes the rest; the columns never shrink
719 const room = width - (2 + stampWidth + 1 + shaWidth + 1 + laneWidth + refs.length + far.length + tagged.length) - 1
720 return (
721 <Box key={`g:${i}`} flexDirection="column">
722 <Box key="row" flexDirection="row">
723 <Box key="cols" flexDirection="row" flexShrink={0}>
724 <Text>{' '}</Text>
725 <Text>{`${(row.commit ? commitStamp(row.commit.time, now) : '').padEnd(stampWidth)} `}</Text>
726 <Text color="yellow">{`${(row.commit?.sha ?? '').padEnd(shaWidth)} `}</Text>
727 {row.cells.map((cell, j) => (
728 <Text key={`g:${i}:${j}`} color={LANE_COLORS[cell.color % LANE_COLORS.length]}>
729 {cell.ch}
730 </Text>
731 ))}
732 <Text>{' '.repeat(laneWidth - row.cells.length)}</Text>
733 {refs ? <Text color="magenta">{refs}</Text> : null}
734 {far ? <Text color="red">{far}</Text> : null}
735 {tagged ? <Text color="green">{tagged}</Text> : null}
736 </Box>
737 {row.commit ? (
738 <Button key={`cm:${i}`} plain dimColor={shown?.hash !== row.hash} label={` ${fit(row.commit.subject, Math.max(8, room))}`} onPress={noop} />
739 ) : null}
740 </Box>
741 {shown && shown.hash === row.hash ? (
742 <Box key="commit" flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
743 <Button key="sha" plain label={shown.hash} onPress={noop} />
744 {heads.length ? <Text color="magenta" wrap="wrap">{`(${heads.join(', ')})`}</Text> : null}
745 {remotes.length ? <Text color="red" wrap="wrap">{`{${remotes.join(', ')}}`}</Text> : null}
746 {tags.length ? <Text color="green" wrap="wrap">{`[${tags.join(', ')}]`}</Text> : null}
747 <Text wrap="truncate-end">
748 <Text color="green">{shown.author.replace(/\s*<.*>$/, '')}</Text>
749 <Text color="cyan">{` ${shown.author.match(/<.*>$/)?.[0] ?? ''}`}</Text>
750 <Text dimColor>{` · ${shown.date}`}</Text>
751 </Text>
752 {shown.message.map((line, k) => (
753 <Text key={`cm-msg:${k}`} wrap="wrap">{line}</Text>
754 ))}
755 {limitFiles(shown.files, fileLimit).map((line, k) => (
756 <Text key={`cm-file:${k}`} dimColor>{fit(line, width - 4)}</Text>
757 ))}
758 {commitAsk?.hash === row.hash ? (
759 <Box key="cx-ask" flexDirection="row" columnGap={1}>
760 <Text color="yellow">{commitAsk.op === 'reset' ? 'reset --hard?' : `${commitAsk.op}?`}</Text>
761 <Button key="yes" label="yes" variant="primary" onPress={noop} />
762 <Button key="no" label="no" variant="primary" onPress={noop} />
763 </Box>
764 ) : naming?.hash === row.hash ? (
765 <Input key="newname" label={`${naming.op} `} value="" submitLabel="create" autoFocus onSubmit={noop} />
766 ) : (
767 <Box key="cx" flexDirection="column">
768 {(moreOps
769 ? [['tag', 'branch', 'checkout', 'pick', 'more'], ['revert', 'drop', 'merge', 'rebase', 'reset']]
770 : [['tag', 'branch', 'checkout', 'pick', 'more']]
771 )
772 .map(line => (row.hash === here?.head ? line.filter(op => !HEAD_HIDDEN.includes(op)) : line))
773 .filter(line => line.length > 0)
774 .map(line => (
775 <Box key={`cx-row:${line[0]}`} flexDirection="row" columnGap={1}>
776 {line.map(op =>
777 op === 'more' ? (
778 <Button key="opsmore:commit" label={moreOps ? 'less' : 'more'} variant="secondary" onPress={noop} />
779 ) : (
780 <Button key={`cx:${op}`} label={op === 'pick' ? 'cherry-pick' : op === 'reset' ? 'reset --hard' : op} variant="primary" onPress={noop} />
781 ),
782 )}
783 </Box>
784 ))}
785 </Box>
786 )}
787 </Box>
788 ) : null}
789 </Box>
790 )
791 })}
792
793 {commitStep > 0 && graph.filter(row => row.commit).length >= commitLimit ? (
794 <Box key="more-row" flexDirection="row">
795 <Text>{' '}</Text>
796 <Button key="more" label={`load ${commitStep} more`} variant="primary" onPress={noop} />
797 </Box>
798 ) : null}
799 </Box>
800 )
801 })
802}
803hooks/git.ts 496 lines1// Pure git plumbing for cc-reflog: the argv each read runs and the parsers
2// for their output. No engine calls here, so tests/git.test.ts runs it as is.
3
4export type Worktree = {
5 path: string
6 head: string
7 /** short branch name; absent when detached or bare */
8 branch?: string
9 detached: boolean
10 bare: boolean
11 locked: boolean
12 prunable: boolean
13 /** changed paths from `git status --porcelain`; undefined when not read */
14 dirty?: number
15}
16
17export type Branch = {
18 name: string
19 upstream?: string
20 ahead: number
21 behind: number
22 /** the upstream was deleted on the remote */
23 gone: boolean
24 /** committer date, relative ("3 days ago") */
25 date: string
26 /** absolute path of the worktree that has it checked out, if any */
27 worktree?: string
28}
29
30/** `time` is the commit time in unix seconds */
31export type Commit = { sha: string; subject: string; time: number }
32
33/** a commit as `git log` reports it, with the full hashes the lanes are traced by */
34export type GraphCommit = { hash: string; parents: string[]; commit: Commit & { refs: string } }
35
36/** One character of a lane drawing; `color` picks the lane's color. */
37export type GraphCell = { ch: string; color: number }
38
39/** one line of the history graph; a line with no commit only carries lanes through */
40export type GraphRow = { cells: GraphCell[]; commit?: GraphCommit['commit']; hash?: string }
41
42export type Tag = { name: string; date: string }
43
44export type Snapshot = {
45 /** toplevel of the worktree the session is in */
46 root: string
47 worktrees: Worktree[]
48 branches: Branch[]
49 tags: Tag[]
50 /** names of the configured remotes */
51 remotes: string[]
52}
53
54/** glyphs the tree draws */
55export type Icons = {
56 /** worktree row: the session is here / elsewhere */
57 here: string
58 other: string
59 /** branch row: current / checked out in another worktree / plain */
60 current: string
61 elsewhere: string
62 plain: string
63}
64
65export const ICONS: Icons = {
66 here: '● ',
67 other: ' ',
68 current: '* ',
69 elsewhere: '+ ',
70 plain: ' ',
71}
72
73const BRANCH_COLORS = [
74 'red',
75 'green',
76 'yellow',
77 'blue',
78 'magenta',
79 'cyan',
80 'redBright',
81 'greenBright',
82 'yellowBright',
83 'blueBright',
84 'magentaBright',
85 'cyanBright',
86]
87
88/** a color of its own for a branch: the same name always gets the same one */
89export function branchColor(name: string): string {
90 let h = 0
91 for (const ch of name) h = (h * 31 + ch.charCodeAt(0)) >>> 0
92 return BRANCH_COLORS[h % BRANCH_COLORS.length]
93}
94
95export const TOPLEVEL = ['git', 'rev-parse', '--show-toplevel']
96export const WORKTREES = ['git', 'worktree', 'list', '--porcelain']
97export const STATUS = ['git', 'status', '--porcelain']
98
99const SEP = '%09'
100
101/** `max` of 0 lists every branch */
102export function branchesArgv(max: number): string[] {
103 const fields = [
104 '%(refname:short)',
105 '%(upstream:short)',
106 '%(upstream:track,nobracket)',
107 '%(committerdate:relative)',
108 '%(worktreepath)',
109 ]
110 return [
111 'git',
112 'for-each-ref',
113 '--sort=-committerdate',
114 ...(max > 0 ? [`--count=${Math.floor(max)}`] : []),
115 `--format=${fields.join(SEP)}`,
116 'refs/heads',
117 ]
118}
119
120/** which commits the history shows: one branch instead of all, and/or those whose message has `grep` */
121export type GraphView = { branch?: string; grep?: string }
122
123/** history of all local branches, newest first; `max` of 0 reads everything */
124export function graphArgv(max: number, view: GraphView = {}): string[] {
125 return [
126 'git',
127 'log',
128 view.branch ?? '--branches',
129 '--topo-order',
130 ...(view.grep ? ['-i', '-F', `--grep=${view.grep}`] : []),
131 ...(max > 0 ? [`-n${Math.floor(max)}`] : []),
132 '--format=%H%x09%P%x09%h%x09%s%x09%ct%x09%D',
133 '--',
134 ]
135}
136
137/** one commit in full: header, message, then the changed files */
138export function showArgv(hash: string): string[] {
139 return ['git', 'show', '--stat', '--no-color', '--format=%H%x09%an <%ae>%x09%ai%n%B%x01', hash]
140}
141
142export const TAGS = ['git', 'for-each-ref', '--sort=-creatordate', '--format=%(refname:short)%09%(creatordate:short)', 'refs/tags']
143export const REMOTES = ['git', 'remote']
144export const FETCH = ['git', 'fetch', '--all']
145/** fast-forward only: a diverged branch is refused, never merged */
146export const PULL = ['git', 'pull', '--ff-only']
147/** never `--force`; a branch with no upstream is published to `remote` and tracks it */
148export const pushArgv = (branch: string, upstream: string | undefined, remote: string) =>
149 upstream ? ['git', 'push'] : ['git', 'push', '--set-upstream', remote, branch]
150/** one failing network call must not hang the pane, nor ask for a password it cannot show */
151export const NET_INIT = { env: { GIT_TERMINAL_PROMPT: '0' }, timeoutMs: 120000 }
152export const tagDeleteArgv = (name: string) => ['git', 'tag', '-d', name]
153
154export function parseTags(out: string): Tag[] {
155 return out
156 .split('\n')
157 .filter(line => line.trim())
158 .map(line => {
159 const [name = '', date = ''] = line.split('\t')
160 return { name, date }
161 })
162}
163
164/** a new worktree goes next to the main one, as `<repo>-<name>` */
165export function worktreePath(mainPath: string, name: string): string {
166 const parent = mainPath.replace(/\/+$/, '').split('/').slice(0, -1).join('/')
167 return `${parent}/${basename(mainPath)}-${name.replace(/\//g, '-')}`
168}
169/** an existing branch is checked out as it is, any other name is created */
170export const worktreeAddArgv = (path: string, name: string, exists: boolean) =>
171 exists ? ['git', 'worktree', 'add', path, name] : ['git', 'worktree', 'add', '-b', name, path]
172/** no `--force`: git refuses a dirty or locked worktree */
173export const worktreeRemoveArgv = (path: string) => ['git', 'worktree', 'remove', path]
174export const worktreeLockArgv = (path: string, lock: boolean) => ['git', 'worktree', lock ? 'lock' : 'unlock', path]
175
176export function switchArgv(branch: string): string[] {
177 return ['git', 'switch', '--no-guess', branch]
178}
179
180/** `branch -d` only: git itself refuses an unmerged branch or one checked out in a worktree */
181export const deleteArgv = (branch: string) => ['git', 'branch', '-d', branch]
182export const renameArgv = (from: string, to: string) => ['git', 'branch', '-m', from, to]
183export const mergeArgv = (branch: string) => ['git', 'merge', '--no-edit', branch]
184export const rebaseArgv = (branch: string) => ['git', 'rebase', branch]
185export const MERGE_ABORT = ['git', 'merge', '--abort']
186export const REBASE_ABORT = ['git', 'rebase', '--abort']
187
188/** what a branch popup can ask for; the last three first ask "yes / no" */
189export type BranchOp = 'switch' | 'open' | 'rename' | 'delete' | 'merge' | 'rebase'
190export type ConfirmedOp = Extract<BranchOp, 'delete' | 'merge' | 'rebase'>
191
192export const tagArgv = (name: string, hash: string) => ['git', 'tag', name, hash]
193export const newBranchArgv = (name: string, hash: string) => ['git', 'branch', name, hash]
194export const detachArgv = (hash: string) => ['git', 'switch', '--detach', hash]
195export const pickArgv = (hash: string) => ['git', 'cherry-pick', hash]
196export const revertArgv = (hash: string) => ['git', 'revert', '--no-edit', hash]
197/** drop = replay what follows the commit onto its parent */
198export const dropArgv = (hash: string) => ['git', 'rebase', '--onto', `${hash}^`, hash]
199export const resetArgv = (hash: string) => ['git', 'reset', '--hard', hash]
200/** files git left unmerged by a stop on conflicts */
201export const CONFLICTS = ['git', 'diff', '--name-only', '--diff-filter=U']
202export const PICK_ABORT = ['git', 'cherry-pick', '--abort']
203export const REVERT_ABORT = ['git', 'revert', '--abort']
204
205/** what a commit popup can ask for; `tag` and `branch` take a name, the last four first ask "yes / no" */
206export type CommitOp = 'tag' | 'branch' | 'checkout' | 'pick' | 'revert' | 'drop' | 'merge' | 'rebase' | 'reset'
207export const CONFIRMED_COMMIT_OPS: CommitOp[] = ['drop', 'merge', 'rebase', 'reset']
208
209/** A commit's `%D` decorations told apart: local refs (`HEAD -> main`), remote-tracking refs (`origin/main`) and tags. */
210export function splitRefs(refs: string, remotes: string[]): { heads: string[]; remotes: string[]; tags: string[] } {
211 const out = { heads: [] as string[], remotes: [] as string[], tags: [] as string[] }
212 for (const ref of refs.split(', ').filter(Boolean)) {
213 if (ref.startsWith('tag: ')) out.tags.push(ref.slice(5))
214 else if (remotes.includes(ref.split('/')[0] ?? '')) out.remotes.push(ref)
215 else out.heads.push(ref)
216 }
217 return out
218}
219
220const stripHeads = (ref: string) => ref.replace(/^refs\/heads\//, '')
221
222/** Parses `git worktree list --porcelain`: blank-line separated records. */
223export function parseWorktrees(out: string): Worktree[] {
224 const list: Worktree[] = []
225 for (const block of out.split(/\n\s*\n/)) {
226 const lines = block.split('\n').filter(Boolean)
227 const first = lines[0]
228 if (!first?.startsWith('worktree ')) continue
229 const wt: Worktree = {
230 path: first.slice('worktree '.length),
231 head: '',
232 detached: false,
233 bare: false,
234 locked: false,
235 prunable: false,
236 }
237 for (const line of lines.slice(1)) {
238 if (line.startsWith('HEAD ')) wt.head = line.slice(5)
239 else if (line.startsWith('branch ')) wt.branch = stripHeads(line.slice(7))
240 else if (line === 'detached') wt.detached = true
241 else if (line === 'bare') wt.bare = true
242 else if (line === 'locked' || line.startsWith('locked ')) wt.locked = true
243 else if (line === 'prunable' || line.startsWith('prunable ')) wt.prunable = true
244 }
245 list.push(wt)
246 }
247 return list
248}
249
250/** Reads `ahead 2, behind 1` / `gone` / `` from %(upstream:track,nobracket). */
251export function parseTrack(track: string): { ahead: number; behind: number; gone: boolean } {
252 const ahead = /ahead (\d+)/.exec(track)
253 const behind = /behind (\d+)/.exec(track)
254 return {
255 ahead: ahead ? Number(ahead[1]) : 0,
256 behind: behind ? Number(behind[1]) : 0,
257 gone: track.trim() === 'gone',
258 }
259}
260
261/** Parses the tab-separated lines `branchesArgv` prints. */
262export function parseBranches(out: string): Branch[] {
263 const list: Branch[] = []
264 for (const line of out.split('\n')) {
265 if (!line.trim()) continue
266 const [name, upstream = '', track = '', date = '', worktree = ''] = line.split('\t')
267 if (!name) continue
268 list.push({
269 name,
270 upstream: upstream || undefined,
271 ...parseTrack(track),
272 date,
273 worktree: worktree || undefined,
274 })
275 }
276 return list
277}
278
279/** Local `HH:MM` for a commit made today, `YYYY-MM-DD` for any other day. */
280export function commitStamp(time: number, now = new Date()): string {
281 const d = new Date(time * 1000)
282 const two = (n: number) => String(n).padStart(2, '0')
283 const day = `${d.getFullYear()}-${two(d.getMonth() + 1)}-${two(d.getDate())}`
284 const today = `${now.getFullYear()}-${two(now.getMonth() + 1)}-${two(now.getDate())}`
285 return day === today ? `${two(d.getHours())}:${two(d.getMinutes())}` : day
286}
287
288export type CommitDetail = { hash: string; author: string; date: string; message: string[]; files: string[] }
289
290/** Parses `showArgv` output: a tab-separated header line, the message, a \x01 mark, then the stat. */
291export function parseShow(out: string): CommitDetail {
292 const [head = '', stat = ''] = out.split('\x01')
293 const [first = '', ...rest] = head.split('\n')
294 const [hash = '', author = '', date = ''] = first.split('\t')
295 return {
296 hash,
297 author,
298 date,
299 message: rest.join('\n').trim().split('\n'),
300 files: stat.split('\n').map(l => l.trim()).filter(Boolean),
301 }
302}
303
304export function parseGraphLog(out: string): GraphCommit[] {
305 return out
306 .split('\n')
307 .filter(Boolean)
308 .map(line => {
309 const [hash = '', parents = '', sha = '', subject = '', time = '', refs = ''] = line.split('\t')
310 return { hash, parents: parents.split(' ').filter(Boolean), commit: { sha, subject, time: Number(time), refs } }
311 })
312}
313
314type Lane = { hash: string; color: number }
315
316// kitty's branch drawing symbols, as lazygit uses them (U+F5D0 to U+F60D)
317const PUA_HORIZONTAL = '\uf5d0'
318const PUA_VERTICAL = '\uf5d1'
319// dot symbols by the edges its lines touch (up, down, left, right): [commit, merge]
320const PUA_DOTS: Record<string, [string, string]> = {
321 '': ['\uf5ef', '\uf5ee'],
322 r: ['\uf5f1', '\uf5f0'],
323 l: ['\uf5f3', '\uf5f2'],
324 lr: ['\uf5f5', '\uf5f4'],
325 d: ['\uf5f7', '\uf5f6'],
326 u: ['\uf5f9', '\uf5f8'],
327 ud: ['\uf5fb', '\uf5fa'],
328 dr: ['\uf5fd', '\uf5fc'],
329 dl: ['\uf5ff', '\uf5fe'],
330 ur: ['\uf601', '\uf600'],
331 ul: ['\uf603', '\uf602'],
332 udr: ['\uf605', '\uf604'],
333 udl: ['\uf607', '\uf606'],
334 dlr: ['\uf609', '\uf608'],
335 ulr: ['\uf60b', '\uf60a'],
336 udlr: ['\uf60d', '\uf60c'],
337}
338
339const firstFree = (lanes: (Lane | undefined)[]) => {
340 const at = lanes.findIndex(l => !l)
341 return at < 0 ? lanes.length : at
342}
343
344/**
345 * Lays commits out in lanes, one row per commit: the dot sits in its lane, a vertical
346 * line carries the other lanes, and a horizontal line with a corner joins a lane that
347 * starts or ends at this commit.
348 * A lane is two characters wide. Cells are kitty's branch drawing symbols, which the
349 * terminal draws edge to edge, so lanes stay joined without extra rows.
350 */
351export function layoutGraph(list: GraphCommit[]): GraphRow[] {
352 const rows: GraphRow[] = []
353 let lanes: (Lane | undefined)[] = []
354 let nextColor = 0
355 const bars = (active: (Lane | undefined)[]) => {
356 const cells: GraphCell[] = []
357 active.forEach((l, c) => {
358 cells[2 * c] = { ch: l ? PUA_VERTICAL : ' ', color: l?.color ?? 0 }
359 cells[2 * c + 1] = { ch: ' ', color: 0 }
360 })
361 return cells
362 }
363 list.forEach(c => {
364 const at = lanes.findIndex(l => l?.hash === c.hash)
365 const col = at < 0 ? firstFree(lanes) : at
366 const own = lanes[col] ?? { hash: c.hash, color: nextColor++ }
367 const before = [...lanes]
368 before[col] = own
369 // lanes that were waiting for this commit end in it
370 const converging = before.flatMap((l, j) => (l?.hash === c.hash && j !== col ? [j] : []))
371 const after = [...before]
372 for (const j of converging) after[j] = undefined
373 after[col] = undefined
374 const links: { to: number; kind: 'up' | 'tee' | 'new'; color: number }[] = converging.map(j => ({
375 to: j,
376 kind: 'up',
377 color: before[j]!.color,
378 }))
379 c.parents.forEach((p, k) => {
380 const known = after.findIndex(l => l?.hash === p)
381 // a first parent already waited for in another lane is left to join this lane on its own
382 // row, so a lane keeps its place and color as git draws it
383 if (known >= 0 && k > 0) links.push({ to: known, kind: 'tee', color: after[known]!.color })
384 else if (k === 0) after[col] = { hash: p, color: own.color }
385 else {
386 const to = firstFree(after)
387 after[to] = { hash: p, color: nextColor++ }
388 links.push({ to, kind: 'new', color: after[to]!.color })
389 }
390 })
391 const width = Math.max(before.length, after.length)
392 const cells = bars(Array.from({ length: width }, (_, j) => (j === col || converging.includes(j) ? undefined : before[j])))
393 for (const { to, kind, color } of links) {
394 const [lo, hi] = to < col ? [to, col] : [col, to]
395 // the line runs over the lanes it crosses, so it has no gaps
396 for (let x = 2 * lo + 1; x < 2 * hi; x++) cells[x] = { ch: PUA_HORIZONTAL, color }
397 const right = to > col
398 const corner = {
399 up: right ? '\uf5d9' : '\uf5d8',
400 tee: right ? '\uf5df' : '\uf5dc',
401 new: right ? '\uf5d7' : '\uf5d6',
402 }[kind]
403 cells[2 * to] = { ch: corner, color }
404 }
405 const edges =
406 (at >= 0 ? 'u' : '') +
407 (after[col] ? 'd' : '') +
408 (links.some(l => l.to < col) ? 'l' : '') +
409 (links.some(l => l.to > col) ? 'r' : '')
410 cells[2 * col] = { ch: PUA_DOTS[edges]![c.parents.length > 1 ? 1 : 0]!, color: own.color }
411 for (let x = 0; x < 2 * width; x++) cells[x] ??= { ch: ' ', color: 0 }
412 rows.push({ cells, commit: c.commit, hash: c.hash })
413 lanes = after
414 while (lanes.length && !lanes[lanes.length - 1]) lanes.pop()
415 })
416 return rows
417}
418
419/** Lines of `git status --porcelain`: how many paths changed. */
420export function countDirty(out: string): number {
421 return out.split('\n').filter(line => line.trim() !== '').length
422}
423
424export function basename(path: string): string {
425 const parts = path.replace(/[\\/]+$/, '').split(/[\\/]/)
426 return parts[parts.length - 1] || path
427}
428
429/** Cuts `text` to `width` cells with an ellipsis; never below 1. */
430export function fit(text: string, width: number): string {
431 const w = Math.max(1, Math.floor(width))
432 const chars = Array.from(text)
433 if (chars.length <= w) return text
434 return chars.slice(0, Math.max(0, w - 1)).join('') + '…'
435}
436
437export function trackLabel(b: Pick<Branch, 'ahead' | 'behind' | 'gone' | 'upstream'>): string {
438 if (b.gone) return 'gone'
439 if (!b.upstream) return ''
440 const parts: string[] = []
441 if (b.ahead) parts.push(`↑${b.ahead}`)
442 if (b.behind) parts.push(`↓${b.behind}`)
443 return parts.join(' ') || '✓'
444}
445
446/** Paths compare equal across a trailing slash (git and the session disagree). */
447export function samePath(a: string | undefined, b: string | undefined): boolean {
448 if (!a || !b) return false
449 return a.replace(/[\\/]+$/, '') === b.replace(/[\\/]+$/, '')
450}
451
452export function currentWorktree(snap: Snapshot): Worktree | undefined {
453 return snap.worktrees.find(wt => samePath(wt.path, snap.root))
454}
455
456/** What pressing a branch row can offer next. */
457export type BranchAction =
458 | { kind: 'current' }
459 | { kind: 'open'; path: string }
460 | { kind: 'switch' }
461
462export function branchAction(snap: Snapshot, branch: Branch): BranchAction {
463 if (branch.worktree && samePath(branch.worktree, snap.root)) return { kind: 'current' }
464 if (branch.worktree) return { kind: 'open', path: branch.worktree }
465 return { kind: 'switch' }
466}
467
468/** A pane width the options can ask for, clamped to something drawable. */
469export function paneColumns(value: unknown): number {
470 const n = typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : 56
471 return Math.min(120, Math.max(28, n))
472}
473
474export function maxCommits(value: unknown): number {
475 const n = typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : 50
476 return Math.max(0, n)
477}
478
479export function maxFiles(value: unknown): number {
480 const n = typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : 10
481 return Math.max(0, n)
482}
483
484/** Keeps the first `max` file lines of a `--stat` (0 keeps all), a "… N more" line, and the totals line. */
485export function limitFiles(lines: string[], max: number): string[] {
486 const totals = /^\d+ files? changed/.test(lines.at(-1) ?? '') ? lines.slice(-1) : []
487 const files = lines.slice(0, lines.length - totals.length)
488 if (max <= 0 || files.length <= max) return lines
489 return [...files.slice(0, max), `… ${files.length - max} more`, ...totals]
490}
491
492export function maxBranches(value: unknown): number {
493 const n = typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : 0
494 return Math.max(0, n)
495}
496