Your repository's git worktrees in a band above the prompt (/worktrees): branch, folder and a short Haiku summary of each, with open here, copy path and delete.

A Claude Code mod that lists your repository's git worktrees in a band above the prompt. Run /worktrees to show or hide it.
Worktrees · 3 · ctrl+x tab to focus, ↑↓ to select
❯ feat/login [here, 2 changed] · Add OAuth login page and callback
~/.worktrees/app/feat-login
main [main] · No changes yet
~/dev/app
fix/typo · Fix README typo
~/.worktrees/app/fix-typo
o: Open here c: Copy path d: Delete r: Refresh q: Close
Each worktree shows its branch, its folder, and a 4-10 word summary of what the branch does. Haiku writes the summary from the branch's commit messages since it left the default branch, the files it changed, and any uncommitted changes. Tags show which worktree Claude Code is working in (here), the main checkout (main), uncommitted changes, locked worktrees, and worktrees whose folder is gone.
ctrl+x tab (or a click) focuses the band. Tab and the arrow keys move between worktrees, and moving onto one selects it. Then press:o Open here: moves Claude Code's working directory into the worktree (/cd).c Copy path: copies the worktree's folder to the clipboard.d Delete: asks first. y deletes the worktree, b also deletes its branch (git branch -d, so an unmerged branch is kept), n cancels. A worktree with uncommitted changes or a lock is force-deleted only after a warning. The main checkout, and the worktree Claude Code is working in, can't be deleted from here.r Refresh, q Close.Esc goes back to the prompt.The list refreshes when it opens, after each turn while it is shown, and on r.
In /config:
| Option | Default | What it does |
|---|---|---|
summaryModel | haiku | The model that writes summaries: an alias or a full model id. |
showSummaries | true | Off: no model calls. Each worktree shows its latest commit's subject instead. |
Summaries are cached per worktree and only asked for again when the branch's commits or changes move. A branch with no commits and no changes shows No changes yet without a model call.
From Claude Code:
/plugin marketplace add matt-winfield/skills
/plugin install worktrees@matt-winfield-skills
Or from the shell:
claude plugin marketplace add matt-winfield/skills
claude plugin install worktrees@matt-winfield-skills
It works well with the worktree skill, which creates worktrees for agents, and cleanup-worktrees, which clears out the merged ones.
The hooks module is hooks/register.tsx. The parsing and prompt helpers are in hooks/git.ts.
/worktrees is registered with $.command.register at session.start. It toggles the isOpen state, and /worktrees refresh reads the list again.git worktree list --porcelain and git status --porcelain run through $.process.run. Summaries come from $.model.complete, three at a time, and are kept in $.store keyed by a hash of what the model read.ui.render hook on AbovePrompt draws the band. Each worktree is a Button, so it can be clicked, and a ui.focus hook selects the row the focus ring lands on. Actions are Buttons with hotkeys./cd <path> with $.command.run. On a build without /cd it falls back to the EnterWorktree tool.$.ui.copy. Delete is git worktree remove (with --force after the warning), then git branch -d.claude plugin validate ./claude-code-mods/worktrees # from the repository root
claude plugin test ./claude-code-mods/worktrees # runs hooks/*.test.ts(x) against the engine
claude --plugin-dir ./claude-code-mods/worktrees # try it in a session; edits hot-reload
MIT © Matthew Winfield. See LICENSE.
hooks/register.tsx 337 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderNode, RenderSurface } from 'claude-code'
3
4import { cleanSummary, hash, parseWorktrees, summaryPrompt, tildify, worktreeOf } from './git'
5import type { Confirm, Worktree } from '../types'
6
7const isOpen = atom({ plugin: 'worktrees', key: 'isOpen' } as const, false)
8const isLoading = atom({ plugin: 'worktrees', key: 'isLoading' } as const, false)
9const list = atom({ plugin: 'worktrees', key: 'list' } as const, [] as Worktree[])
10const summaries = atom({ plugin: 'worktrees', key: 'summaries' } as const, {} as Record<string, string>)
11const selected = atom({ plugin: 'worktrees', key: 'selected' } as const, null as string | null)
12const confirm = atom({ plugin: 'worktrees', key: 'confirm' } as const, null as Confirm | null)
13const notice = atom({ plugin: 'worktrees', key: 'notice' } as const, null as string | null)
14
15const ROW = 'row:'
16// Summaries are asked for a few at a time, and each one gives up after this long.
17const SUMMARY_CONCURRENCY = 3
18const SUMMARY_TIMEOUT_MS = 20_000
19
20type Git = { isOk: boolean; out: string; err: string }
21
22async function git ($: EngineInterface, cwd: string, args: string[]): Promise<Git> {
23 try {
24 const { exitCode, stdout, stderr } = await $.process.run(['git', ...args], { cwd, timeoutMs: 15_000 })
25 return { isOk: exitCode === 0, out: stdout, err: stderr.trim() }
26 } catch (error) {
27 return { isOk: false, out: '', err: error instanceof Error ? error.message : String(error) }
28 }
29}
30
31const firstLine = (text: string) => text.split('\n').find(line => line.trim() !== '')?.replace(/^(fatal|error): /, '') ?? ''
32
33/** Whether a drawing beneath shows nothing: no text, no control. */
34function isBlank (node: RenderNode | undefined): boolean {
35 if (node === undefined || node === null) return true
36 if (typeof node === 'string') return node.trim() === ''
37 return (node.type === 'Box' || node.type === 'Text') && (node.children ?? []).every(isBlank)
38}
39
40/** The ref a branch is compared against: origin's default branch, else a local main or master. */
41async function defaultRef ($: EngineInterface, cwd: string): Promise<string | undefined> {
42 const remote = await git($, cwd, ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'])
43 if (remote.isOk && remote.out.trim() !== '') return remote.out.trim()
44 for (const ref of ['origin/main', 'origin/master', 'main', 'master']) {
45 if ((await git($, cwd, ['rev-parse', '--verify', '--quiet', ref])).isOk) return ref
46 }
47 return undefined
48}
49
50// What the person set in /config, as the module last registered with.
51let model = 'haiku'
52let showSummaries = true
53// The band's id, as the engine draws it: what `$.ui.focus` names it by.
54let bandId: string | undefined
55// Summaries being written, by path, so a refresh mid-way does not ask twice.
56const writing = new Set<string>()
57
58async function say ($: EngineInterface, text: string | null) {
59 await update($, notice, () => text)
60}
61
62/** One worktree's summary: from the store while its changes stand still, else from the model. */
63async function summaryOf ($: EngineInterface, wt: Worktree, base: string | undefined): Promise<string> {
64 if (wt.isPrunable) return 'Folder is missing'
65 const since = base === undefined ? undefined : await git($, wt.path, ['merge-base', base, 'HEAD'])
66 const range = since?.isOk === true ? `${since.out.trim()}..HEAD` : undefined
67 const commits = await git($, wt.path, range ? ['log', '--format=%s', '-n', '30', range] : ['log', '--format=%s', '-n', '5'])
68 const files = range ? await git($, wt.path, ['diff', '--name-status', since!.out.trim()]) : { out: '' }
69 const status = await git($, wt.path, ['status', '--porcelain'])
70 const prompt = summaryPrompt(wt.branch ?? 'detached HEAD', commits.out, files.out, status.out)
71 if (prompt === undefined) return 'No changes yet'
72
73 const key = hash(`${model}\n${prompt}`)
74 const cached = (await $.store.get(`summary:${wt.path}`)) as { key: string; text: string } | undefined
75 if (cached?.key === key) return cached.text
76
77 const fallback = cleanSummary(commits.out) || 'Uncommitted changes'
78 if (!showSummaries) return fallback
79 const reply = await $.model.complete({ model, prompt, maxTokens: 60, effort: 'low', timeoutMs: SUMMARY_TIMEOUT_MS })
80 .catch(() => undefined)
81 if (reply?.isAnswered !== true) return fallback
82 const text = cleanSummary(reply.text) || fallback
83 await $.store.set(`summary:${wt.path}`, { key, text })
84 return text
85}
86
87/** Writes each worktree's summary into the band as it arrives. */
88async function summarise ($: EngineInterface, worktrees: Worktree[]) {
89 const todo = worktrees.filter(wt => !writing.has(wt.path))
90 if (todo.length === 0) return
91 for (const wt of todo) writing.add(wt.path)
92 const base = await defaultRef($, todo[0]!.path)
93 const queue = [...todo]
94 const work = async () => {
95 for (let wt = queue.shift(); wt !== undefined; wt = queue.shift()) {
96 const path = wt.path
97 const text = await summaryOf($, wt, base).catch(() => '')
98 writing.delete(path)
99 await update($, summaries, all => ({ ...all, [path]: text }))
100 }
101 }
102 await Promise.all(Array.from({ length: Math.min(SUMMARY_CONCURRENCY, todo.length) }, work))
103}
104
105/** Reads the worktrees again; their summaries follow without holding the caller up. */
106async function refresh ($: EngineInterface) {
107 await update($, isLoading, () => true)
108 try {
109 const cwd = await $.session.cwd()
110 const listed = await git($, cwd, ['worktree', 'list', '--porcelain'])
111 if (!listed.isOk) {
112 await update($, list, () => [])
113 await say($, `Not in a git repository (${firstLine(listed.err) || cwd})`)
114 return
115 }
116 const found = parseWorktrees(listed.out)
117 const current = worktreeOf(found.map(wt => wt.path), cwd)
118 const worktrees: Worktree[] = await Promise.all(found.map(async wt => {
119 const status = wt.isPrunable ? { out: '' } : await git($, wt.path, ['status', '--porcelain'])
120 const changes = status.out.split('\n').filter(line => line.trim() !== '').length
121 return { ...wt, isCurrent: wt.path === current, changes }
122 }))
123 await update($, list, () => worktrees)
124 await update($, selected, path =>
125 worktrees.some(wt => wt.path === path) ? path : (current ?? worktrees[0]?.path ?? null))
126 void summarise($, worktrees)
127 } finally {
128 await update($, isLoading, () => false)
129 }
130}
131
132async function open ($: EngineInterface) {
133 await update($, isOpen, () => true)
134 await update($, confirm, () => null)
135 await say($, null)
136 await refresh($)
137}
138
139async function close ($: EngineInterface) {
140 await update($, isOpen, () => false)
141 await update($, confirm, () => null)
142 await say($, null)
143}
144
145async function chosen ($: EngineInterface) {
146 const path = await read($, selected)
147 return (await read($, list)).find(wt => wt.path === path)
148}
149
150/** Moves the session into the worktree, as `/cd <path>` does. */
151async function moveInto ($: EngineInterface) {
152 const wt = await chosen($)
153 if (wt === undefined) return
154 if (wt.isCurrent) return say($, 'Claude Code is already working in this worktree')
155 if (wt.isPrunable) return say($, 'Its folder is missing: delete it instead')
156 try {
157 await $.command.run({ command: 'cd', args: wt.path })
158 } catch {
159 // Builds without /cd: the EnterWorktree tool switches into an existing worktree too.
160 await $.tool.call({ tool: 'EnterWorktree', path: wt.path }).catch(() => undefined)
161 }
162 await say($, null)
163 await refresh($)
164 if (!(await chosen($))?.isCurrent) await say($, `Could not move into ${wt.path}`)
165 else $.ui.toast(`Working in ${wt.branch ?? wt.path}`)
166}
167
168async function copyPath ($: EngineInterface, surface: RenderSurface) {
169 const wt = await chosen($)
170 if (wt === undefined) return
171 const copied = await $.ui.copy({ text: wt.path, surface })
172 if (copied.isCopied) $.ui.toast(`Copied ${wt.path}`)
173 else await say($, `Could not copy the path (${copied.reason})`)
174}
175
176async function askDelete ($: EngineInterface) {
177 const wt = await chosen($)
178 if (wt === undefined) return
179 if (wt.isMain) return say($, 'The main worktree is the repository itself: it is not deleted from here')
180 if (wt.isCurrent) return say($, 'Claude Code is working in this worktree: open another one first')
181 await say($, null)
182 await update($, confirm, () => ({ path: wt.path, isForce: wt.changes > 0 || wt.isLocked }))
183 if (bandId !== undefined) void $.ui.focus({ requestId: bandId, key: 'cancel' }).catch(() => undefined)
184}
185
186/** Removes the worktree waiting on confirmation, and its branch when asked and merged. */
187async function remove ($: EngineInterface, withBranch: boolean) {
188 const waiting = await read($, confirm)
189 const worktrees = await read($, list)
190 const wt = worktrees.find(one => one.path === waiting?.path)
191 // Git runs from the main checkout, or from the session's directory where the main repository is bare.
192 const cwd = worktrees.find(one => one.isMain)?.path ?? await $.session.cwd()
193 await update($, confirm, () => null)
194 if (waiting === null || wt === undefined) return
195 const force = waiting.isForce ? (wt.isLocked ? ['--force', '--force'] : ['--force']) : []
196 // A worktree whose folder is gone is removed the same way, by its path: a prune would drop every missing one.
197 const removed = await git($, cwd, ['worktree', 'remove', ...force, wt.path])
198 if (!removed.isOk) {
199 await refresh($)
200 return say($, `Not deleted: ${firstLine(removed.err)}`)
201 }
202 let text = `Deleted ${tildify(wt.path, await $.env.get('HOME'))}`
203 if (withBranch && wt.branch !== undefined) {
204 const branch = await git($, cwd, ['branch', '-d', wt.branch])
205 text += branch.isOk ? ` and branch ${wt.branch}` : `; kept branch ${wt.branch}: ${firstLine(branch.err)}`
206 }
207 await $.store.delete(`summary:${wt.path}`)
208 await refresh($)
209 await say($, text)
210}
211
212export const register: Register = (on, options) => {
213 model = typeof options.summaryModel === 'string' && options.summaryModel !== '' ? options.summaryModel : 'haiku'
214 showSummaries = options.showSummaries !== false
215
216 on('session.start', async ($, e, next) => {
217 const started = await next(e)
218 await $.command.register({
219 name: 'worktrees',
220 description: "Show this repository's git worktrees above the prompt (run again to hide)",
221 argumentHint: '[refresh]'
222 })
223 if (await read($, isOpen)) void refresh($)
224 return started
225 })
226
227 // `/worktrees` shows or hides the band; `/worktrees refresh` keeps it up and reads them again.
228 on('command.run', { command: 'worktrees' }, async ($, e) => {
229 if ((await read($, isOpen)) && (e.args ?? '').trim() !== 'refresh') {
230 await close($)
231 return { text: 'Worktrees hidden.' }
232 }
233 await open($)
234 const count = (await read($, list)).length
235 return { text: count === 0 ? 'No worktrees found.' : `${count} worktree${count === 1 ? '' : 's'} above the prompt. ctrl+x tab (or a click) focuses them.` }
236 })
237
238 // A turn can add, remove or change worktrees: read them again while the band is up.
239 on('turn.complete', async ($, e, next) => {
240 const result = await next(e)
241 if (await read($, isOpen)) void refresh($)
242 return result
243 })
244
245 // Walking the rows with Tab or the arrows selects the one the focus lands on.
246 on('ui.focus', { component: 'AbovePrompt' }, async ($, e, next) => {
247 if (e.plugin === 'worktrees' && e.element?.startsWith(ROW)) {
248 const path = e.element.slice(ROW.length)
249 await update($, selected, () => path)
250 }
251 return next(e)
252 }).catch(($, e, next) => next(e))
253
254 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
255 if (!(await read($, isOpen)) || e.props.hasSurvey) return next(e)
256 bandId = e.requestId
257 const { Box, Button, Text } = $.ui.resolve(e)
258 const worktrees = await read($, list)
259 const texts = await read($, summaries)
260 const current = await read($, selected)
261 const waiting = await read($, confirm)
262 const message = await read($, notice)
263 const loading = await read($, isLoading)
264 const home = await $.env.get('HOME')
265 const beneath = await next(e)
266 const target = worktrees.find(wt => wt.path === waiting?.path)
267
268 const row = (wt: Worktree) => {
269 const isSelected = wt.path === current
270 const tags = [
271 wt.isCurrent ? 'here' : '',
272 wt.isMain ? 'main' : '',
273 wt.isLocked ? 'locked' : '',
274 wt.isPrunable ? 'missing' : '',
275 wt.changes > 0 ? `${wt.changes} changed` : ''
276 ].filter(Boolean)
277 const summary = texts[wt.path] ?? (showSummaries ? 'summarising…' : '')
278 return (
279 <Box key={`wt:${wt.path}`} flexDirection="column">
280 <Button key={`${ROW}${wt.path}`} plain onPress={() => update($, selected, () => wt.path)}>
281 <Text color="claude">{isSelected ? '❯ ' : ' '}</Text>
282 <Text bold={isSelected} color={wt.branch === undefined ? 'warning' : 'suggestion'}>
283 {wt.branch ?? `detached ${wt.head.slice(0, 7)}`}
284 </Text>
285 {tags.length > 0 ? <Text color={wt.changes > 0 ? 'warning' : undefined} dimColor={wt.changes === 0}> [{tags.join(', ')}]</Text> : ''}
286 {summary !== '' ? <Text dimColor> · </Text> : ''}
287 {summary !== '' ? <Text italic dimColor={texts[wt.path] === undefined}>{summary}</Text> : ''}
288 </Button>
289 <Text dimColor wrap="truncate-middle">{' '}{tildify(wt.path, home)}</Text>
290 </Box>
291 )
292 }
293
294 const actions = (
295 <Box key="actions" flexDirection="row" flexWrap="wrap" columnGap={2}>
296 <Button key="open" plain hotkey="o" label="Open here" onPress={() => moveInto($)} />
297 <Button key="copy" plain hotkey="c" label="Copy path" onPress={press => copyPath($, press.surface)} />
298 <Button key="delete" plain hotkey="d" label="Delete" onPress={() => askDelete($)} />
299 <Button key="refresh" plain hotkey="r" label="Refresh" onPress={() => refresh($)} />
300 <Button key="close" plain hotkey="q" role="dismiss" label="Close" onPress={() => close($)} />
301 </Box>
302 )
303
304 const asking = target === undefined ? null : (
305 <Box key="confirm" flexDirection="column">
306 <Text color="warning">
307 Delete {tildify(target.path, home)}
308 {target.changes > 0 ? `? It has ${target.changes} uncommitted change${target.changes === 1 ? '' : 's'}, which will be lost.` : '?'}
309 {target.isLocked ? ' It is locked.' : ''}
310 </Text>
311 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
312 <Button key="remove" plain hotkey="y" label={target.changes > 0 || target.isLocked ? 'Force delete worktree' : 'Delete worktree'} onPress={() => remove($, false)} />
313 {target.branch !== undefined
314 ? <Button key="remove-branch" plain hotkey="b" label={`Delete worktree + branch ${target.branch}`} onPress={() => remove($, true)} />
315 : null}
316 <Button key="cancel" plain hotkey="n" label="Cancel" onPress={() => update($, confirm, () => null)} />
317 </Box>
318 </Box>
319 )
320
321 return (
322 <Box flexDirection="column">
323 <Box key="title" flexDirection="row">
324 <Text bold>Worktrees</Text>
325 <Text dimColor>
326 {' '}· {worktrees.length}{loading ? ' · reading…' : ''} · ctrl+x tab to focus, ↑↓ to select
327 </Text>
328 </Box>
329 {worktrees.map(row)}
330 {message !== null ? <Text key="notice" color="warning">{message}</Text> : null}
331 {asking ?? actions}
332 {isBlank(beneath) ? null : beneath}
333 </Box>
334 )
335 })
336}
337hooks/git.ts 89 lines1import type { Worktree } from '../types'
2
3/** One worktree as `git worktree list --porcelain` reports it, before its status is read. */
4export type Listed = Omit<Worktree, 'isCurrent' | 'changes'>
5
6/**
7 * The worktrees in `git worktree list --porcelain` output, the main one first. Bare entries are
8 * left out: they have no files to open.
9 */
10export function parseWorktrees (porcelain: string): Listed[] {
11 const found: Listed[] = []
12 // The main worktree is listed first, even when it is a bare repository that is itself left out.
13 let isFirst = true
14 for (const block of porcelain.split(/\n\s*\n/)) {
15 let path: string | undefined
16 let head = ''
17 let branch: string | undefined
18 let isBare = false
19 let isLocked = false
20 let isPrunable = false
21 for (const line of block.split('\n')) {
22 const space = line.indexOf(' ')
23 const key = space < 0 ? line : line.slice(0, space)
24 const value = space < 0 ? '' : line.slice(space + 1)
25 if (key === 'worktree') path = value
26 else if (key === 'HEAD') head = value
27 else if (key === 'branch') branch = value.replace(/^refs\/heads\//, '')
28 else if (key === 'bare') isBare = true
29 else if (key === 'locked') isLocked = true
30 else if (key === 'prunable') isPrunable = true
31 }
32 if (path === undefined) continue
33 const isMain = isFirst
34 isFirst = false
35 if (isBare) continue
36 found.push({ path, head, branch, isMain, isLocked, isPrunable })
37 }
38 return found
39}
40
41/** The worktree `cwd` is in: the one whose path is the longest prefix of it. */
42export function worktreeOf (paths: readonly string[], cwd: string): string | undefined {
43 const inside = paths.filter(path => cwd === path || cwd.startsWith(path.endsWith('/') ? path : `${path}/`))
44 return inside.sort((a, b) => b.length - a.length)[0]
45}
46
47/** `/home/me/dev/app` as `~/dev/app`. */
48export function tildify (path: string, home: string | undefined): string {
49 if (home === undefined || home === '') return path
50 const root = home.replace(/[\\/]+$/, '')
51 if (path === root) return '~'
52 return path.startsWith(`${root}/`) ? `~${path.slice(root.length)}` : path
53}
54
55/** What the summary model reads about one branch, or undefined when it has nothing to say. */
56export function summaryPrompt (branch: string, commits: string, files: string, status: string): string | undefined {
57 const lines = (text: string, max: number) => text.split('\n').map(line => line.trimEnd()).filter(Boolean).slice(0, max)
58 const subjects = lines(commits, 30)
59 const changed = lines(files, 40)
60 const pending = lines(status, 40)
61 if (subjects.length === 0 && changed.length === 0 && pending.length === 0) return undefined
62 return [
63 'Summarise what this git branch changes in 4 to 10 words, as a short phrase (e.g. "Add OAuth login with token refresh").',
64 'Reply with the phrase only: no quotes, no trailing full stop, no preamble.',
65 '',
66 `Branch: ${branch}`,
67 subjects.length > 0 ? `Commits (newest first):\n${subjects.join('\n')}` : 'Commits: none yet',
68 changed.length > 0 ? `Files changed against the default branch:\n${changed.join('\n')}` : '',
69 pending.length > 0 ? `Uncommitted changes (git status):\n${pending.join('\n')}` : ''
70 ].filter(Boolean).join('\n')
71}
72
73/** The model's reply as one short line: the first line, unquoted, without a trailing full stop. */
74export function cleanSummary (reply: string): string {
75 const first = reply.trim().split('\n')[0] ?? ''
76 const line = first.replace(/^summary:\s*/i, '').replace(/^["'`*]+|["'`*]+$/g, '').replace(/\.$/, '').trim()
77 return line.length > 80 ? `${line.slice(0, 79)}…` : line
78}
79
80/** A short, stable hash of a string (FNV-1a), to tell when a worktree's changes moved. */
81export function hash (text: string): string {
82 let h = 0x811c9dc5
83 for (let i = 0; i < text.length; i++) {
84 h ^= text.charCodeAt(i)
85 h = Math.imul(h, 0x01000193)
86 }
87 return (h >>> 0).toString(36)
88}
89types/index.d.ts 33 lines1/** One worktree of the repository, as `git worktree list --porcelain` reports it. */
2export type Worktree = {
3 path: string
4 head: string
5 /** The branch checked out, without `refs/heads/`; absent when HEAD is detached. */
6 branch?: string
7 isMain: boolean
8 /** The session's directory is inside it. */
9 isCurrent: boolean
10 isLocked: boolean
11 /** Its folder is gone: `git worktree prune` would drop it. */
12 isPrunable: boolean
13 /** How many files `git status` lists as changed or untracked. */
14 changes: number
15}
16
17/** A delete waiting on the person's word. */
18export type Confirm = { path: string; isForce: boolean }
19
20declare module 'claude-code' {
21 interface PluginState {
22 worktrees: {
23 isOpen: boolean
24 isLoading: boolean
25 list: Worktree[]
26 summaries: Record<string, string>
27 selected: string | null
28 confirm: Confirm | null
29 notice: string | null
30 }
31 }
32}
33