SLOPSHOPPER

git-sidebar

A lazygit-style side pane: every worktree of the repository and its local branches as rows you click. A worktree row moves the session there with /cd; a branch…

newpanecommandtoaststatusprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · git-sidebar
│ ┃ git ✕ › fix the failing auth test and add an audit log call │ ┃ app │ ┃ ● git-sidebar: git-sidebar loaded: /git-sidebar opens the pane │ ┃ Worktrees (0) ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Branches (0) ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ [ refresh ] [ close ] ⏺ Bash(bun test) │ ┃ ● here * current + other worktree ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /git-sidebar │ ⎿ git-sidebar: 0 worktrees, 0 branches · click a row, or Tab and E │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ git-sidebar: git: app · 0 worktrees · 0 branches

Draws

Pane · git
app Worktrees (0) Branches (0) [ refresh ] [ close ] ● here * current + other worktree
README

git-sidebar

A small lazygit-style side pane for Claude Code: every worktree of the repository the session is in, and its local branches, as rows you can click.

app
Worktrees (3)
● app  main                ~2
  app-feature  feature/login  locked
  app-spike  spike
Branches (4)
* main                     ↓2
+ feature/login         ↑3 ↓1
+ spike
  old                     gone
[ refresh ] [ close ]

Run /git-sidebar to open it and /git-sidebar stop to close it.

Where it draws

In the fullscreen layout the engine docks the pane beside the transcript, floor to ceiling, splitting the screen in two. 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 main-screen layout the same pane sits above the prompt instead, with the same rows and the same actions.

The pane width is a request: columns (default 44) sets it when the pane opens, and a width you drag the dock to wins.

What a click does

RowClick or Enter
worktreemoves the session there with the engine's own /cd (the ● row is where you are)
branchshows its upstream, age and last 5 commits, plus one action
→ switchgit switch <branch> in the current worktree, only when that worktree has no uncommitted changes
→ open worktreewhen another worktree has the branch checked out (+), /cd there, since git would refuse the switch
refresh (r)reads git again
closecloses the pane

Mouse clicks land in the fullscreen layout. Everywhere else, focus the pane (ctrl+x tab), then move with Tab and press with Enter. Esc hands the keyboard back to the prompt and leaves the pane open.

The pane refreshes itself when it opens, after every /cd, and after each of Claude's turns, so a branch or worktree Claude just created shows up. While it is open, the status line under the prompt reads git: <branch> · N worktrees · N branches.

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: rev-parse --show-toplevel, worktree list --porcelain, for-each-ref refs/heads, status --porcelain in each worktree (the first 12), log -n5 for a selected branch, and switch when you press it. It never commits, stashes, deletes or fetches.

Options

  columns: number      width asked for the docked pane, 28-120 (default 44)
  maxBranches: number  local branches listed, most recently committed first (default 40)
  openOnStart: boolean open the pane when a session starts (default false)

Declared in .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 full id:

{ "pluginConfigs": { "git-sidebar@skills-dir": { "options": { "columns": 56 } } } }

With --plugin-dir the id is plain git-sidebar. A pane opened on its own at session start stays hidden below 144 columns until you run /git-sidebar.

Install

npx claude-code-templates@latest --mod ui/git-sidebar
claude

It is written to .claude/skills/git-sidebar/, which Claude Code auto-loads as git-sidebar@skills-dir in a trusted project (accept the trust prompt once; claude -p never shows it). For one session with hot reload: claude --plugin-dir .claude/skills/git-sidebar. claude plugin validate .claude/skills/git-sidebar prints every event it hooks and every $ call it makes.

If /git-sidebar is missing from the typeahead, the mod did not load: claude --debug and look for hooks module git-sidebar@… loaded in ~/.claude/debug/latest.

Tests

claude plugin test .claude/skills/git-sidebar

The tests answer $.process.run with a fake git, mount the pane on the terminal surface and press its rows.

Requirements. Mods are on by default in Claude Code 2.1.287+. Written and tested on 2.1.282 against the 2.1.278 declarations. Typed against Anthropic's declarations: https://github.com/anthropics/claude-code/tree/main/mods

Source 2 files
hooks/git-sidebar.tsx 353 lines
1/**
2 * git-sidebar — 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). `/git-sidebar` 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 its last commits and what can be done with it:
12 *     `switch` (clean tree only) or `open worktree` when another worktree has it
13 *
14 * Git runs through `$.process.run` by argv (no shell); every read is in
15 * ./git.ts. Nothing here writes to the repository except `git switch`, which
16 * the mod refuses while the current worktree has uncommitted changes.
17 *
18 * Needs Claude Code >= 2.1.287.
19 *
20 * Options (pluginConfigs["git-sidebar@skills-dir"].options):
21 *   columns: number      width asked for the docked pane (default 44)
22 *   maxBranches: number  most recent local branches listed (default 40)
23 *   openOnStart: boolean open the pane when the session starts (default false)
24 */
25import type { ProcessRunResult, Register } from 'claude-code'
26import {
27  STATUS,
28  TOPLEVEL,
29  WORKTREES,
30  basename,
31  branchAction,
32  branchesArgv,
33  countDirty,
34  currentWorktree,
35  fit,
36  logArgv,
37  maxBranches,
38  paneColumns,
39  parseBranches,
40  parseLog,
41  parseWorktrees,
42  switchArgv,
43  trackLabel,
44} from './git.ts'
45import type { Commit, Snapshot } from './git.ts'
46
47const PANE = 'git'
48const COMMAND = 'git-sidebar'
49// worktrees whose `git status` is read on each refresh; the rest show no count
50const MAX_STATUS_READS = 12
51
52type Run = (argv: readonly string[], cwd?: string) => Promise<ProcessRunResult>
53
54let snap: Snapshot | undefined
55let error: string | undefined
56let selected: string | undefined
57let commits: Commit[] = []
58let isOpen = false
59
60const firstLine = (text: string) => text.trim().split('\n')[0] ?? ''
61
62async function readSnapshot(run: Run, branchLimit: number): Promise<void> {
63  const top = await run(TOPLEVEL)
64  if (top.exitCode !== 0) {
65    snap = undefined
66    error = firstLine(top.stderr) || 'not a git repository'
67    return
68  }
69  const [wts, refs] = await Promise.all([run(WORKTREES), run(branchesArgv(branchLimit))])
70  const worktrees = wts.exitCode === 0 ? parseWorktrees(wts.stdout) : []
71  await Promise.all(
72    worktrees
73      .filter(wt => !wt.bare && !wt.prunable)
74      .slice(0, MAX_STATUS_READS)
75      .map(async wt => {
76        const st = await run(STATUS, wt.path).catch(() => undefined)
77        if (st?.exitCode === 0) wt.dirty = countDirty(st.stdout)
78      }),
79  )
80  snap = {
81    root: top.stdout.trim(),
82    worktrees,
83    branches: refs.exitCode === 0 ? parseBranches(refs.stdout) : [],
84  }
85  error = refs.exitCode === 0 ? undefined : firstLine(refs.stderr)
86  if (selected && !snap.branches.some(b => b.name === selected)) {
87    selected = undefined
88    commits = []
89  }
90}
91
92function statusText(): string | undefined {
93  if (!isOpen) return undefined
94  if (!snap) return `git: ${error ?? 'loading'}`
95  const here = currentWorktree(snap)
96  const where = here?.branch ?? (here?.detached ? `detached ${here.head.slice(0, 7)}` : basename(snap.root))
97  const n = snap.worktrees.length
98  return `git: ${where} · ${n} worktree${n === 1 ? '' : 's'} · ${snap.branches.length} branches`
99}
100
101export const register: Register = (on, options) => {
102  const columns = paneColumns(options.columns)
103  const branchLimit = maxBranches(options.maxBranches)
104  const openOnStart = options.openOnStart === true
105
106  on('session.start', async ($, e, next) => {
107    const r = await next(e)
108    await $.command
109      .register({
110        name: COMMAND,
111        description: 'Worktrees and branches in a side pane; click one to go there (stop closes)',
112        argumentHint: '[stop|refresh]',
113        immediate: true,
114      })
115      .catch(err => $.ui.log(`git-sidebar: /${COMMAND} not registered: ${err}`))
116    $.ui.log(`git-sidebar loaded: /${COMMAND} opens the pane`, { to: 'debug' })
117    if (openOnStart) {
118      await readSnapshot((argv, cwd) => $.process.run(argv, { cwd }), branchLimit).catch(err => {
119        error = String(err)
120      })
121      isOpen = true
122      // unasked, the engine keeps it undrawn below 144 columns until the person opens it
123      await $.ui.open({ id: PANE, title: 'git', columns }).catch(err => {
124        isOpen = false
125        $.ui.log(`git-sidebar: pane not opened: ${err}`)
126      })
127      $.ui.status(statusText())
128    }
129    return r
130  })
131
132  on('command.run', { command: COMMAND }, async ($, e) => {
133    const arg = e.args.trim().toLowerCase()
134    if (arg === 'stop' || arg === 'close') {
135      await $.ui.close({ id: PANE }).catch(() => undefined)
136      isOpen = false
137      $.ui.status(undefined)
138      return { text: 'git-sidebar closed' }
139    }
140    await readSnapshot((argv, cwd) => $.process.run(argv, { cwd }), branchLimit).catch(err => {
141      error = String(err)
142    })
143    isOpen = true
144    await $.ui.open({ id: PANE, title: 'git', focus: true, columns })
145    $.ui.status(statusText())
146    $.ui.invalidate('ui.render')
147    const hint = e.presentation.isFullscreen
148      ? 'click a row, or Tab and Enter'
149      : 'drawn above the prompt; /tui fullscreen docks it beside the transcript'
150    const found = snap ? `${snap.worktrees.length} worktrees, ${snap.branches.length} branches` : error
151    return { text: `${found} · ${hint} · /${COMMAND} stop closes` }
152  })
153
154  // the session moved (a row of ours, or the person's own /cd): read the new worktree
155  on('command.run', { command: 'cd' }, async ($, e, next) => {
156    const r = await next(e)
157    if (!isOpen) return r
158    await readSnapshot((argv, cwd) => $.process.run(argv, { cwd }), branchLimit).catch(err => {
159      error = String(err)
160    })
161    $.ui.status(statusText())
162    $.ui.invalidate('ui.render')
163    return r
164  })
165
166  // Claude may have made a branch or a worktree during the turn
167  on('turn.complete', async ($, e, next) => {
168    const r = await next(e)
169    if (!isOpen || e.agentId) return r
170    await readSnapshot((argv, cwd) => $.process.run(argv, { cwd }), branchLimit).catch(err => {
171      error = String(err)
172    })
173    $.ui.status(statusText())
174    $.ui.invalidate('ui.render')
175    return r
176  })
177
178  on('ui.close', async ($, e, next) => {
179    if (e.id !== PANE) return next(e)
180    const r = await next(e)
181    isOpen = false
182    $.ui.status(undefined)
183    return r
184  })
185
186  on('ui.press', async ($, e, next) => {
187    if (e.plugin !== $.plugin.name || e.requestId !== PANE) return next(e)
188    const r = await next(e)
189    const key = e.element
190    const s = snap
191
192    if (key === 'close') {
193      await $.ui.close({ id: PANE }).catch(() => undefined)
194      return r
195    }
196    if (key === 'refresh') {
197      await readSnapshot((argv, cwd) => $.process.run(argv, { cwd }), branchLimit).catch(err => {
198        error = String(err)
199      })
200    } else if (s && key.startsWith('wt:')) {
201      const wt = s.worktrees[Number(key.slice(3))]
202      if (wt && wt.path === currentWorktree(s)?.path) $.ui.toast('git-sidebar: already in this worktree')
203      else if (wt) {
204        await $.command
205          .run({ command: 'cd', args: wt.path })
206          .catch(err => $.ui.toast(`git-sidebar: /cd failed: ${err}`))
207      }
208    } else if (s && key.startsWith('br:')) {
209      const branch = s.branches[Number(key.slice(3))]
210      if (branch && selected === branch.name) {
211        selected = undefined
212        commits = []
213      } else if (branch) {
214        selected = branch.name
215        const log = await $.process.run(logArgv(branch.name)).catch(() => undefined)
216        commits = log?.exitCode === 0 ? parseLog(log.stdout) : []
217      }
218    } else if (s && selected && key === 'open') {
219      const branch = s.branches.find(b => b.name === selected)
220      if (branch?.worktree) {
221        await $.command
222          .run({ command: 'cd', args: branch.worktree })
223          .catch(err => $.ui.toast(`git-sidebar: /cd failed: ${err}`))
224      }
225    } else if (s && selected && key === 'switch') {
226      const target = selected
227      const st = await $.process.run(STATUS)
228      if (st.exitCode !== 0 || countDirty(st.stdout) > 0) {
229        $.ui.toast(`git-sidebar: uncommitted changes here; commit or stash before switching to ${target}`)
230      } else {
231        const sw = await $.process.run(switchArgv(target))
232        if (sw.exitCode === 0) $.ui.toast(`git-sidebar: switched to ${target}`)
233        else $.ui.toast(`git-sidebar: ${firstLine(sw.stderr) || 'git switch failed'}`)
234        await readSnapshot((argv, cwd) => $.process.run(argv, { cwd }), branchLimit).catch(err => {
235          error = String(err)
236        })
237      }
238    }
239    $.ui.status(statusText())
240    $.ui.invalidate('ui.render')
241    return r
242  })
243
244  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
245    if (e.requestId !== PANE) return next(e)
246    const { Box, Text, Button } = $.ui.resolve(e)
247    const width = Math.max(20, e.props.bodyColumns - 1)
248    const noop = () => {}
249    const toolbar = (
250      <Box key="toolbar" flexDirection="row" columnGap={1}>
251        <Button key="refresh" label="refresh" hotkey="r" onPress={noop} />
252        <Button key="close" label="close" onPress={noop} />
253      </Box>
254    )
255
256    if (!snap) {
257      return (
258        <Box flexDirection="column">
259          <Text color="red">{fit(error ?? 'reading the repository…', width)}</Text>
260          {toolbar}
261        </Box>
262      )
263    }
264
265    const s = snap
266    const here = currentWorktree(s)
267    const chosen = selected ? s.branches.find(b => b.name === selected) : undefined
268    const action = chosen ? branchAction(s, chosen) : undefined
269
270    return (
271      <Box flexDirection="column">
272        <Text bold>{fit(basename(s.worktrees[0]?.path ?? s.root), width)}</Text>
273        {error ? <Text color="red">{fit(error, width)}</Text> : null}
274
275        <Box key="wt-head" marginTop={1}>
276          <Text bold color="cyan">{`Worktrees (${s.worktrees.length})`}</Text>
277        </Box>
278        {s.worktrees.map((wt, i) => {
279          const isHere = wt === here
280          const name = wt.branch ?? (wt.bare ? '(bare)' : `(${wt.head.slice(0, 7)})`)
281          const dirty = wt.dirty ? ` ~${wt.dirty}` : ''
282          const flags = `${wt.locked ? ' locked' : ''}${wt.prunable ? ' prunable' : ''}`
283          const room = width - 2 - dirty.length - flags.length
284          return (
285            <Box key={`wt-row:${i}`} flexDirection="row">
286              <Text color="green">{isHere ? '● ' : '  '}</Text>
287              <Button
288                key={`wt:${i}`}
289                plain
290                dimColor={!isHere}
291                label={fit(`${basename(wt.path)}  ${name}`, room)}
292                onPress={noop}
293              />
294              {dirty ? <Text color="yellow">{dirty}</Text> : null}
295              {flags ? <Text color="red">{flags}</Text> : null}
296            </Box>
297          )
298        })}
299
300        <Box key="br-head" marginTop={1}>
301          <Text bold color="cyan">{`Branches (${s.branches.length})`}</Text>
302        </Box>
303        {s.branches.map((b, i) => {
304          const isHere = here?.branch === b.name
305          const marker = isHere ? '* ' : b.worktree ? '+ ' : '  '
306          const track = trackLabel(b)
307          const room = width - 2 - (track ? track.length + 1 : 0)
308          return (
309            <Box key={`br-row:${i}`} flexDirection="row">
310              <Text color={isHere ? 'green' : 'blue'}>{marker}</Text>
311              <Button
312                key={`br:${i}`}
313                plain
314                dimColor={!isHere && selected !== b.name}
315                label={fit(b.name, room)}
316                onPress={noop}
317              />
318              {track ? <Text color={b.gone ? 'red' : 'magenta'}>{` ${track}`}</Text> : null}
319            </Box>
320          )
321        })}
322
323        {chosen ? (
324          <Box key="detail" flexDirection="column" marginTop={1} borderStyle="round" borderDimColor paddingX={1}>
325            <Text bold>{fit(chosen.name, width - 4)}</Text>
326            <Text dimColor>
327              {fit(`${chosen.upstream ?? 'no upstream'} · ${chosen.date}`, width - 4)}
328            </Text>
329            {commits.map((c, i) => (
330              <Text key={`c:${i}`} wrap="truncate-end">
331                <Text color="yellow">{c.sha}</Text>
332                {` ${c.subject}`}
333              </Text>
334            ))}
335            {action?.kind === 'switch' ? (
336              <Button key="switch" label="switch" hotkey="s" onPress={noop} />
337            ) : action?.kind === 'open' ? (
338              <Button key="open" label="open worktree" hotkey="o" onPress={noop} />
339            ) : (
340              <Text dimColor>checked out here</Text>
341            )}
342          </Box>
343        ) : null}
344
345        <Box key="foot" marginTop={1} flexDirection="column">
346          {toolbar}
347          <Text dimColor>{fit('● here  * current  + other worktree', width)}</Text>
348        </Box>
349      </Box>
350    )
351  })
352}
353
hooks/git.ts 199 lines
1// Pure git plumbing for git-sidebar: 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
30export type Commit = { sha: string; subject: string; date: string }
31
32export type Snapshot = {
33  /** toplevel of the worktree the session is in */
34  root: string
35  worktrees: Worktree[]
36  branches: Branch[]
37}
38
39export const TOPLEVEL = ['git', 'rev-parse', '--show-toplevel']
40export const WORKTREES = ['git', 'worktree', 'list', '--porcelain']
41export const STATUS = ['git', 'status', '--porcelain']
42
43const SEP = '%09'
44
45export function branchesArgv(max: number): string[] {
46  const fields = [
47    '%(refname:short)',
48    '%(upstream:short)',
49    '%(upstream:track,nobracket)',
50    '%(committerdate:relative)',
51    '%(worktreepath)',
52  ]
53  return [
54    'git',
55    'for-each-ref',
56    '--sort=-committerdate',
57    `--count=${Math.max(1, Math.floor(max))}`,
58    `--format=${fields.join(SEP)}`,
59    'refs/heads',
60  ]
61}
62
63export function logArgv(branch: string, count = 5): string[] {
64  return ['git', 'log', `-n${count}`, '--format=%h%x09%s%x09%cr', `refs/heads/${branch}`, '--']
65}
66
67export function switchArgv(branch: string): string[] {
68  return ['git', 'switch', '--no-guess', branch]
69}
70
71const stripHeads = (ref: string) => ref.replace(/^refs\/heads\//, '')
72
73/** Parses `git worktree list --porcelain`: blank-line separated records. */
74export function parseWorktrees(out: string): Worktree[] {
75  const list: Worktree[] = []
76  for (const block of out.split(/\n\s*\n/)) {
77    const lines = block.split('\n').filter(Boolean)
78    const first = lines[0]
79    if (!first?.startsWith('worktree ')) continue
80    const wt: Worktree = {
81      path: first.slice('worktree '.length),
82      head: '',
83      detached: false,
84      bare: false,
85      locked: false,
86      prunable: false,
87    }
88    for (const line of lines.slice(1)) {
89      if (line.startsWith('HEAD ')) wt.head = line.slice(5)
90      else if (line.startsWith('branch ')) wt.branch = stripHeads(line.slice(7))
91      else if (line === 'detached') wt.detached = true
92      else if (line === 'bare') wt.bare = true
93      else if (line === 'locked' || line.startsWith('locked ')) wt.locked = true
94      else if (line === 'prunable' || line.startsWith('prunable ')) wt.prunable = true
95    }
96    list.push(wt)
97  }
98  return list
99}
100
101/** Reads `ahead 2, behind 1` / `gone` / `` from %(upstream:track,nobracket). */
102export function parseTrack(track: string): { ahead: number; behind: number; gone: boolean } {
103  const ahead = /ahead (\d+)/.exec(track)
104  const behind = /behind (\d+)/.exec(track)
105  return {
106    ahead: ahead ? Number(ahead[1]) : 0,
107    behind: behind ? Number(behind[1]) : 0,
108    gone: track.trim() === 'gone',
109  }
110}
111
112/** Parses the tab-separated lines `branchesArgv` prints. */
113export function parseBranches(out: string): Branch[] {
114  const list: Branch[] = []
115  for (const line of out.split('\n')) {
116    if (!line.trim()) continue
117    const [name, upstream = '', track = '', date = '', worktree = ''] = line.split('\t')
118    if (!name) continue
119    list.push({
120      name,
121      upstream: upstream || undefined,
122      ...parseTrack(track),
123      date,
124      worktree: worktree || undefined,
125    })
126  }
127  return list
128}
129
130export function parseLog(out: string): Commit[] {
131  return out
132    .split('\n')
133    .filter(Boolean)
134    .map(line => {
135      const [sha = '', subject = '', date = ''] = line.split('\t')
136      return { sha, subject, date }
137    })
138}
139
140/** Lines of `git status --porcelain`: how many paths changed. */
141export function countDirty(out: string): number {
142  return out.split('\n').filter(line => line.trim() !== '').length
143}
144
145export function basename(path: string): string {
146  const parts = path.replace(/[\\/]+$/, '').split(/[\\/]/)
147  return parts[parts.length - 1] || path
148}
149
150/** Cuts `text` to `width` cells with an ellipsis; never below 1. */
151export function fit(text: string, width: number): string {
152  const w = Math.max(1, Math.floor(width))
153  const chars = Array.from(text)
154  if (chars.length <= w) return text
155  return chars.slice(0, Math.max(0, w - 1)).join('') + '…'
156}
157
158export function trackLabel(b: Pick<Branch, 'ahead' | 'behind' | 'gone' | 'upstream'>): string {
159  if (b.gone) return 'gone'
160  if (!b.upstream) return ''
161  const parts: string[] = []
162  if (b.ahead) parts.push(`↑${b.ahead}`)
163  if (b.behind) parts.push(`↓${b.behind}`)
164  return parts.join(' ') || '✓'
165}
166
167/** Paths compare equal across a trailing slash (git and the session disagree). */
168export function samePath(a: string | undefined, b: string | undefined): boolean {
169  if (!a || !b) return false
170  return a.replace(/[\\/]+$/, '') === b.replace(/[\\/]+$/, '')
171}
172
173export function currentWorktree(snap: Snapshot): Worktree | undefined {
174  return snap.worktrees.find(wt => samePath(wt.path, snap.root))
175}
176
177/** What pressing a branch row can offer next. */
178export type BranchAction =
179  | { kind: 'current' }
180  | { kind: 'open'; path: string }
181  | { kind: 'switch' }
182
183export function branchAction(snap: Snapshot, branch: Branch): BranchAction {
184  if (branch.worktree && samePath(branch.worktree, snap.root)) return { kind: 'current' }
185  if (branch.worktree) return { kind: 'open', path: branch.worktree }
186  return { kind: 'switch' }
187}
188
189/** A pane width the options can ask for, clamped to something drawable. */
190export function paneColumns(value: unknown): number {
191  const n = typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : 44
192  return Math.min(120, Math.max(28, n))
193}
194
195export function maxBranches(value: unknown): number {
196  const n = typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : 40
197  return Math.min(200, Math.max(1, n))
198}
199