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…

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.
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.
| Row | Click or Enter |
|---|---|
| worktree | moves the session there with the engine's own /cd (the ● row is where you are) |
| branch | shows its upstream, age and last 5 commits, plus one action |
→ switch | git switch <branch> in the current worktree, only when that worktree has no uncommitted changes |
→ open worktree | when another worktree has the branch checked out (+), /cd there, since git would refuse the switch |
refresh (r) | reads git again |
close | closes 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.
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.
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.
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.
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
hooks/git-sidebar.tsx 353 lines1/**
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}
353hooks/git.ts 199 lines1// 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