Show the git worktrees this session references in a pane

hooks/register.tsx 224 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import type { Snapshot, Worktree } from '../types'
4
5const PANE = 'worktrees'
6const TITLE = 'Worktrees'
7const REFRESH_MS = 30_000
8const MAX_PATHS_PER_CALL = 10
9const MAX_PARENT_HOPS = 4
10
11const snapshot = { plugin: 'worktrees', key: 'snapshot' } as const
12const referenced = { plugin: 'worktrees', key: 'referenced' } as const
13
14let isRefreshing = false
15let isRefreshPending = false
16// Keyed by directory so a path inside a nested worktree (e.g. .claude/worktrees/*) is never
17// attributed to the enclosing one.
18const toplevelCache = new Map<string, string | null>()
19
20function absolutePaths(text: string): string[] {
21 return [...new Set(text.match(/\/[^\s"'`:;|&<>()\\]+/g) ?? [])]
22}
23
24function parentOf(path: string): string {
25 return path.replace(/\/[^/]*\/?$/, '') || '/'
26}
27
28function basename(path: string): string {
29 return path.replace(/\/+$/, '').split('/').pop() ?? path
30}
31
32function shorten(path: string, home: string | undefined): string {
33 return home && path.startsWith(home) ? `~${path.slice(home.length)}` : path
34}
35
36async function toplevelOf($: EngineInterface, path: string): Promise<string | null> {
37 let dir = path.replace(/\/+$/, '') || '/'
38 for (let hop = 0; hop <= MAX_PARENT_HOPS && dir !== '/'; hop++) {
39 const cached = toplevelCache.get(dir)
40 if (cached !== undefined) return cached
41 const res = await $.process
42 .run(['git', '-C', dir, 'rev-parse', '--show-toplevel'], { timeoutMs: 5_000 })
43 .catch(() => null)
44 if (res && res.exitCode === 0) {
45 const top = res.stdout.trim()
46 toplevelCache.set(dir, top)
47 return top
48 }
49 // "not a git repository" for an existing directory is final; a missing path falls back to its parent.
50 if (res && /not a git repository/.test(res.stderr)) {
51 toplevelCache.set(dir, null)
52 return null
53 }
54 dir = parentOf(dir)
55 }
56 return null
57}
58
59async function describe($: EngineInterface, path: string, current: string | null): Promise<Worktree> {
60 const head = await $.process
61 .run(
62 ['git', '-C', path, 'rev-parse', '--abbrev-ref', 'HEAD', '--path-format=absolute', '--git-common-dir'],
63 { timeoutMs: 10_000 },
64 )
65 .catch(() => null)
66 if (!head || head.exitCode !== 0) {
67 return { path, repo: basename(parentOf(path)), branch: null, isCurrent: false, isMissing: true, dirtyCount: 0 }
68 }
69 const [ref = 'HEAD', commonDir = path] = head.stdout.trim().split('\n')
70 const repo = basename(commonDir.endsWith('/.git') ? parentOf(commonDir) : commonDir).replace(/\.git$/, '')
71 const st = await $.process
72 .run(['git', '-C', path, 'status', '--porcelain'], { timeoutMs: 10_000 })
73 .catch(() => null)
74 const dirtyCount = st && st.exitCode === 0 ? st.stdout.split('\n').filter(l => l !== '').length : 0
75 return {
76 path,
77 repo,
78 branch: ref === 'HEAD' ? null : ref,
79 isCurrent: path === current,
80 isMissing: false,
81 dirtyCount,
82 }
83}
84
85async function collect($: EngineInterface): Promise<Snapshot> {
86 const current = await toplevelOf($, await $.session.cwd())
87 const { value: seen = [] } = await $.state.get(referenced)
88 const roots = [...new Set([...(current ? [current] : []), ...seen])]
89 const worktrees = await Promise.all(roots.map(root => describe($, root, current)))
90 worktrees.sort((a, b) => a.repo.localeCompare(b.repo) || a.path.localeCompare(b.path))
91 return {
92 worktrees,
93 error: roots.length === 0 ? 'No git worktree referenced yet' : null,
94 updatedAt: Date.now(),
95 }
96}
97
98async function refresh($: EngineInterface): Promise<void> {
99 if (isRefreshing) {
100 isRefreshPending = true
101 return
102 }
103 isRefreshing = true
104 try {
105 do {
106 isRefreshPending = false
107 await $.state.set(snapshot, await collect($))
108 } while (isRefreshPending)
109 } catch (err) {
110 await $.state.set(snapshot, { worktrees: [], error: String(err), updatedAt: Date.now() })
111 } finally {
112 isRefreshing = false
113 }
114}
115
116async function track($: EngineInterface, input: unknown): Promise<void> {
117 const paths = absolutePaths(JSON.stringify(input)).slice(0, MAX_PATHS_PER_CALL)
118 const roots = (await Promise.all(paths.map(p => toplevelOf($, p)))).filter(
119 (r): r is string => r !== null,
120 )
121 if (roots.length === 0) return
122 const { value: seen = [] } = await $.state.get(referenced)
123 const added = [...new Set(roots)].filter(r => !seen.includes(r))
124 if (added.length === 0) return
125 await $.state.set(referenced, [...seen, ...added])
126 await refresh($)
127}
128
129export const register: Register = on => {
130 on('session.start', async ($, e, next) => {
131 await $.command.register({
132 name: 'worktrees',
133 description: 'Show the git worktrees this session references in a pane',
134 })
135 void $.ui.open({ id: PANE, title: TITLE })
136 void refresh($)
137 $.clock.every(REFRESH_MS, () => void refresh($))
138
139 return next(e)
140 })
141
142 on('command.run', { command: 'worktrees' }, async $ => {
143 await $.ui.open({ id: PANE, title: TITLE })
144 await refresh($)
145
146 return { text: 'Worktrees pane opened.' }
147 })
148
149 on('tool.call', async ($, e, next) => {
150 void track($, e).catch(() => undefined)
151
152 return next(e)
153 })
154
155 on('turn.complete', async ($, e, next) => {
156 void refresh($)
157
158 return next(e)
159 })
160
161 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
162 const { Box, Button, Text } = $.ui.resolve(e)
163 const { value } = await $.state.get(snapshot)
164 const home = await $.env.get('HOME')
165
166 if (value === undefined) {
167 return <Text dimColor>Loading…</Text>
168 }
169
170 const repos = [...new Set(value.worktrees.map(w => w.repo))]
171
172 return (
173 <Box flexDirection="column">
174 {value.error !== null && <Text dimColor>{value.error}</Text>}
175 {repos.map(repo => (
176 <Box key={`repo:${repo}`} flexDirection="column" marginBottom={1}>
177 <Text bold>{repo}</Text>
178 {value.worktrees
179 .filter(w => w.repo === repo)
180 .map(w => (
181 <Box key={w.path} flexDirection="column">
182 <Text
183 bold={w.isCurrent}
184 color={w.isCurrent ? 'green' : undefined}
185 dimColor={w.isMissing}
186 wrap="truncate-end"
187 >
188 {w.isCurrent ? '● ' : ' '}
189 {w.branch ?? '(detached)'}
190 {w.dirtyCount > 0 ? ` [${w.dirtyCount} changed]` : ''}
191 {w.isMissing ? ' (removed)' : ''}
192 </Text>
193 <Box flexDirection="row">
194 <Text>{' '}</Text>
195 <Button
196 key={`copy:${w.path}`}
197 label={'\u{29C9}'}
198 plain
199 dimColor
200 onPress={async press => {
201 const copied = await $.ui.copy({ text: w.path, surface: press.surface })
202 $.ui.toast(
203 copied.isCopied ? `Copied ${w.path}` : `Copy failed: ${copied.reason}`,
204 )
205 }}
206 />
207 <Text dimColor wrap="truncate-start">
208 {' '}
209 {shorten(w.path, home)}
210 </Text>
211 </Box>
212 </Box>
213 ))}
214 </Box>
215 ))}
216 <Text dimColor>
217 {value.worktrees.length} worktrees in {repos.length} repos · updated{' '}
218 {new Date(value.updatedAt).toLocaleTimeString()}
219 </Text>
220 </Box>
221 )
222 })
223}
224types/index.d.ts 21 lines1export type Worktree = {
2 path: string
3 repo: string
4 branch: string | null
5 isCurrent: boolean
6 isMissing: boolean
7 dirtyCount: number
8}
9
10export type Snapshot = {
11 worktrees: Worktree[]
12 error: string | null
13 updatedAt: number
14}
15
16declare module 'claude-code' {
17 interface PluginState {
18 worktrees: { snapshot: Snapshot; referenced: string[] }
19 }
20}
21