SLOPSHOPPER

reflog

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…

newpanecommandtoastprocess
v0.1.0MITupdated 2026-10-06Nfet445/cc-reflog/cc-reflog
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · reflog
│ ┃ git ✕ › fix the failing auth test and add an audit log call │ ┃ app │ ┃ [ fetch ] ● reflog: cc-reflog loaded: /reflog opens the pane │ ┃ Worktrees (0) [ add ] ⏺ Read(src/auth.ts) │ ┃ Branches (0) ⎿ Read 6 lines │ ┃ History ⏺ Update(src/auth.ts) │ ┃ ╭──────────────────────────────────────────╮ ⎿ Added 2 lines, removed 1 line │ ┃ │ Filter the git tree ⏎ apply │ ⏺ Bash(bun test) │ ┃ ╰──────────────────────────────────────────╯ ⎿ 3 pass, 1 fail │ ┃ 1970-01-01 │ ┃ 1970-01-01 ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /reflog │ ⎿ reflog: 0 worktrees, 0 branches · click a row, or Tab and Enter │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · git
app [ fetch ] Worktrees (0) [ add ] Branches (0) History ╭──────────────────────────────────────────────────────────╮ │ Filter the git tree ⏎ apply │ ╰──────────────────────────────────────────────────────────╯ 1970-01-01 1970-01-01
README

cc-reflog

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.

Install

/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.

Where it draws

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.

What a click does

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.

Top row

ButtonAction
fetchgit fetch --all; shown when the repository has a remote
pullgit pull --ff-only on the current branch; shown when it has an upstream, a diverged branch is refused
pushasks 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.

Worktrees

RowClick or Enter
worktreemoves the session there with the engine's own /cd (the ● row is where you are)
addasks 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)

Branches

A branch row opens a popup under it with its upstream and age.

ButtonAction
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
renameasks for a new name, then git branch -m
more → deleteafter yes, git branch -d (git itself refuses an unmerged branch or one checked out elsewhere)
more → merge / rebaseafter yes, merges the branch into, or rebases, the current branch; refused on a dirty tree
history of this branchdraws 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.

Tags

Each tag is listed with its date. Click one for delete (after yes, git tag -d).

History

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.

RowClick or Enter
commitopens a popup under the row: full hash, author, date, message (wrapped), refs and changed files (up to maxFiles); click again to close
hashputs the full hash into the prompt at the cursor
tag / branchask for a name: git tag <name> / git branch <name> <hash>
checkoutgit switch --detach <hash>
cherry-pickgit cherry-pick <hash>
more → revertgit revert --no-edit <hash>
more → dropafter yes, git rebase --onto <hash>^ <hash>
more → merge / rebaseafter yes, merges the commit into, or rebases the current branch onto, the commit
more → reset --hardafter yes, git reset --hard <hash>
filter fielda 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 moreunder 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.

Safety rules

  • Everything except creating a tag or a branch is refused while the current worktree has uncommitted changes.
  • delete, merge, rebase, drop, reset --hard, push, worktree remove and tag delete ask yes / no first.
  • A merge, rebase, cherry-pick, revert or drop that stops on conflicts is left in place. The mod puts a prompt in the input line with the conflicted files and the --continue command, so the model can resolve it; the mod never resolves or aborts it itself. A failure with no conflicts is aborted.
  • It never uses --force or branch -D, never stashes, and the only commits it makes are the ones cherry-pick and revert create.

Staying current

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.

What it runs

Only git, by argv (no shell), through $.process.run.

  • Reads: 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.
  • Writes, only from buttons: 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.
  • Network, only from buttons: fetch --all, pull --ff-only, push.

Options

  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.

Develop

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

Source 2 files
hooks/cc-reflog.tsx 803 lines
1/**
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}
803
hooks/git.ts 496 lines
1// 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