SLOPSHOPPER

worktrees

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.

newbandcommandtoastmodelprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · worktrees
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /worktrees ⎿ worktrees: No worktrees found. Worktrees · 0 · ctrl+x tab to focus, ↑↓ to select o: Open here c: Copy path d: Delete r: Refresh q: Close ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Worktrees · 0 · ctrl+x tab to focus, ↑↓ to select o: Open here c: Copy path d: Delete r: Refresh q: Close ⟨Claude Code's own drawing⟩
README

Worktrees

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.

Using it

  • Mouse: click a worktree to select it, then click an action.
  • Keyboard: 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.

Settings

In /config:

OptionDefaultWhat it does
summaryModelhaikuThe model that writes summaries: an alias or a full model id.
showSummariestrueOff: 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.

Install

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.

How it works

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.
  • A 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.
  • Open here runs /cd <path> with $.command.run. On a build without /cd it falls back to the EnterWorktree tool.
  • Copy path is $.ui.copy. Delete is git worktree remove (with --force after the warning), then git branch -d.

Development

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.

Source 3 files
hooks/register.tsx 337 lines
1import { 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}
337
hooks/git.ts 89 lines
1import 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}
89
types/index.d.ts 33 lines
1/** 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