A /wt pane listing the repository's git worktrees: status, changes, copy path, open in VS Code, guarded delete

自己写的 Claude Code Mod(函数钩子插件),整个仓库是一个插件市场。终端和桌面 App 的 Code 标签页都能用。
| Mod | 作用 |
|---|---|
| git-band | 在输入框上方显示当前 git 分支、未提交文件数和 ↑↓ 提交数,每 3 秒刷新 |
| wt-panel | /wt 打开当前仓库的 worktree 总览面板:状态、改动、复制路径、VS Code 打开、带风险提醒的删除 |
| feishu-notify | 长任务结束或出错、权限弹窗或提问等你超过一定时间时,用 lark-cli 的机器人给你发飞书私信;/feishu-test 发测试消息。需要先 lark-cli auth login |
在 Claude Code 的终端会话里,每个 Mod 一行(<mod> 换成上表里的名字),第一次会问是否添加这个插件市场,回答 y,再选安装范围:
/plugin install <mod> --marketplace Jenwein/claude-mods
或者在命令行里:
claude plugin install <mod> --marketplace Jenwein/claude-mods
按用户范围安装后,之后的每个会话都会自动加载。
想边改边用,就把本地克隆加成插件市场再安装,插件会直接从这个文件夹读取:
claude plugin marketplace add <克隆所在的路径>
claude plugin install <mod>@claude-mods
改完文件后,在会话里运行 /reload-plugins 就会生效,不用重装。
检查和测试:
claude plugin validate <mod>
claude plugin test <mod>hooks/register.tsx 396 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, RenderSurface, Register } from 'claude-code'
3
4import type { DeleteMode, Worktree } from '../types'
5import type { Entry } from './model'
6import {
7 deleteWarnings,
8 displayName,
9 findCurrent,
10 isAppManaged,
11 nativePath,
12 parseStatus,
13 parseWorktreeList,
14 relativeTime,
15} from './model'
16
17const PANE = 'wt-panel'
18const TITLE = 'Worktrees'
19// Scans every worktree, so slower than the git band's poll.
20const POLL_MS = 30_000
21// Lines of a row's changes shown before the rest is cut.
22const MAX_LINES = 40
23
24const snapshot = atom({ plugin: 'wt-panel', key: 'snapshot' } as const, null)
25const expanded = atom({ plugin: 'wt-panel', key: 'expanded' } as const, null)
26const deleting = atom({ plugin: 'wt-panel', key: 'deleting' } as const, null)
27
28let isScanning = false
29
30const count = (stdout: string) => Number(stdout.trim()) || 0
31
32const firstLine = (text: string) => text.trim().split(/\r?\n/)[0] ?? ''
33
34async function git($: EngineInterface, dir: string, args: string[], timeoutMs = 30_000) {
35 return $.process.run(['git', '-C', dir, ...args], { timeoutMs })
36}
37
38// The main worktree's branch, as its upstream when it has one: a PR merged on
39// the remote shows there before the local branch is pulled.
40async function baseRef($: EngineInterface, root: string, branch: string) {
41 const upstream = await git($, root, ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`])
42 const name = upstream.stdout.trim()
43
44 return upstream.exitCode === 0 && name !== '' ? name : branch
45}
46
47async function inspect(
48 $: EngineInterface,
49 entry: Entry,
50 isMain: boolean,
51 root: string,
52 base: string | null,
53 current: string | null,
54): Promise<Worktree> {
55 const row: Worktree = {
56 path: entry.path,
57 name: displayName(entry.path, root),
58 branch: entry.branch,
59 head: entry.head,
60 isMain,
61 isCurrent: entry.path === current,
62 isAppManaged: isAppManaged(entry.path, root),
63 isLocked: entry.isLocked,
64 isMissing: entry.isMissing,
65 changed: 0,
66 unpushed: 0,
67 orphaned: 0,
68 ahead: 0,
69 behind: 0,
70 isMerged: false,
71 isGone: false,
72 lastCommitAt: 0,
73 }
74
75 if (entry.isMissing) {
76 return row
77 }
78
79 const isCompared = base !== null && !isMain
80 const [status, compared, last] = await Promise.all([
81 git($, entry.path, ['--no-optional-locks', 'status', '--porcelain=v2', '--branch']),
82 isCompared ? git($, entry.path, ['rev-list', '--left-right', '--count', `${base}...HEAD`]) : null,
83 git($, entry.path, ['log', '-1', '--format=%ct']),
84 ])
85 const parsed = parseStatus(status.stdout)
86
87 row.changed = parsed.changed
88 row.isGone = parsed.hasUpstream && parsed.ab === null
89 row.lastCommitAt = count(last.stdout)
90
91 if (compared !== null && compared.exitCode === 0) {
92 const [behind = '0', ahead = '0'] = compared.stdout.trim().split(/\s+/)
93 row.behind = Number(behind) || 0
94 row.ahead = Number(ahead) || 0
95 row.isMerged = entry.branch !== null && row.ahead === 0
96 }
97
98 if (entry.branch === null) {
99 row.orphaned = count((await git($, entry.path, ['rev-list', '--count', 'HEAD', '--not', '--branches', '--remotes'])).stdout)
100 } else if (parsed.ab !== null) {
101 row.unpushed = parsed.ab.ahead
102 } else {
103 row.unpushed = count((await git($, entry.path, ['rev-list', '--count', 'HEAD', '--not', '--remotes'])).stdout)
104 }
105
106 return row
107}
108
109async function scan($: EngineInterface) {
110 if (isScanning) {
111 return
112 }
113
114 isScanning = true
115
116 try {
117 const cwd = await $.session.cwd()
118 const listed = await git($, cwd, ['worktree', 'list', '--porcelain'])
119 const scannedAt = await $.clock.now()
120 const entries = listed.exitCode === 0 ? parseWorktreeList(listed.stdout) : []
121 const main = entries[0]
122
123 if (main === undefined) {
124 await update($, snapshot, () => ({ root: cwd, base: null, worktrees: [], scannedAt, error: '当前目录不是 git 仓库' }))
125
126 return
127 }
128
129 const base = main.branch === null ? null : await baseRef($, main.path, main.branch)
130 const current = findCurrent(
131 entries.map(entry => entry.path),
132 cwd,
133 )
134 const worktrees = await Promise.all(entries.map((entry, i) => inspect($, entry, i === 0, main.path, base, current)))
135
136 await update($, snapshot, () => ({ root: main.path, base, worktrees, scannedAt, error: null }))
137 } finally {
138 isScanning = false
139 }
140}
141
142async function tick($: EngineInterface) {
143 const panes = await $.ui.panes()
144
145 if (panes.some(pane => pane.id === PANE)) {
146 await scan($)
147 }
148}
149
150async function remove($: EngineInterface, row: Worktree, mode: DeleteMode, root: string) {
151 const force = row.isLocked ? ['--force', '--force'] : ['--force']
152 const removed = await git($, root, ['worktree', 'remove', ...force, row.path], 120_000)
153
154 if (removed.exitCode !== 0) {
155 $.ui.toast(`删除失败:${firstLine(removed.stderr)}`)
156 await update($, deleting, () => null)
157
158 return
159 }
160
161 if (mode === 'both' && row.branch !== null) {
162 const dropped = await git($, root, ['branch', '-D', row.branch])
163 $.ui.toast(
164 dropped.exitCode === 0
165 ? `已删除 ${row.name} 和分支 ${row.branch}`
166 : `目录已删除,分支 ${row.branch} 删除失败:${firstLine(dropped.stderr)}`,
167 )
168 } else {
169 $.ui.toast(`已删除 ${row.name}`)
170 }
171
172 await update($, deleting, () => null)
173 await update($, expanded, shown => (shown?.path === row.path ? null : shown))
174 await scan($)
175}
176
177async function copyPath($: EngineInterface, row: Worktree, surface: RenderSurface) {
178 const text = nativePath(row.path)
179 const copied = await $.ui.copy({ text, surface })
180
181 if (!copied.isCopied) {
182 const clipped = /^[A-Za-z]:\\/.test(text) ? await $.process.run(['cmd', '/c', 'clip'], { stdin: text }) : null
183
184 if (clipped === null || clipped.exitCode !== 0) {
185 $.ui.toast(`复制失败,路径是 ${text}`)
186
187 return
188 }
189 }
190
191 $.ui.toast(`已复制 ${text}`)
192}
193
194async function openInCode($: EngineInterface, row: Worktree) {
195 const path = nativePath(row.path)
196 // `code` is a .cmd script on Windows, which only cmd runs.
197 const argv = /^[A-Za-z]:\\/.test(path) ? ['cmd', '/c', 'code', path] : ['code', path]
198 const opened = await $.process.run(argv)
199
200 if (opened.exitCode !== 0) {
201 $.ui.toast(`打开 VS Code 失败:${firstLine(opened.stderr) || '找不到 code 命令'}`)
202 }
203}
204
205async function toggleChanges($: EngineInterface, row: Worktree, base: string | null) {
206 const shown = await read($, expanded)
207
208 if (shown?.path === row.path) {
209 await update($, expanded, () => null)
210
211 return
212 }
213
214 const [commits, uncommitted] = await Promise.all([
215 base !== null && !row.isMain ? git($, row.path, ['diff', '--stat', `${base}...HEAD`]) : null,
216 git($, row.path, ['status', '--short']),
217 ])
218 const sections = [
219 ...(commits === null ? [] : [`相对 ${base} 的提交:\n${commits.stdout.trimEnd() || '(无)'}`]),
220 `未提交:\n${uncommitted.stdout.trimEnd() || '(无)'}`,
221 ]
222 const all = sections.join('\n\n').split('\n')
223 const text = all.length > MAX_LINES ? [...all.slice(0, MAX_LINES), `…还有 ${all.length - MAX_LINES} 行`].join('\n') : all.join('\n')
224
225 await update($, expanded, () => ({ path: row.path, text }))
226}
227
228const summary = (row: Worktree, base: string | null, now: number) => {
229 const arrows = [row.ahead > 0 ? `↑${row.ahead}` : '', row.behind > 0 ? `↓${row.behind}` : ''].filter(Boolean).join(' ')
230
231 return [
232 row.changed > 0 ? `未提交 ${row.changed}` : '',
233 row.branch !== null && row.unpushed > 0 ? `未push ${row.unpushed}` : '',
234 row.branch === null && row.orphaned > 0 ? `游离提交 ${row.orphaned}` : '',
235 base !== null && !row.isMain ? (arrows === '' ? `与 ${base} 相同` : `相对 ${base} ${arrows}`) : '',
236 relativeTime(row.lastCommitAt, now),
237 ]
238 .filter(Boolean)
239 .join(' · ')
240}
241
242const tags = (row: Worktree) =>
243 [
244 row.isMain && { text: '主目录', color: 'subtle' },
245 row.isCurrent && { text: '当前会话', color: 'success' },
246 row.isAppManaged && { text: 'App 管理', color: 'suggestion' },
247 row.isLocked && { text: '已锁定', color: 'warning' },
248 row.isMerged && { text: '已合并', color: 'warning' },
249 row.isGone && { text: '远端已删', color: 'warning' },
250 ].filter((tag): tag is { text: string; color: string } => tag !== false)
251
252export const register: Register = on => {
253 on('session.start', async ($, e, next) => {
254 const started = await next(e)
255 await $.command.register({ name: 'wt', description: '打开 worktree 总览面板' })
256 $.clock.every(POLL_MS, () => void tick($))
257
258 return started
259 })
260
261 on('command.run', { command: 'wt' }, async $ => {
262 await $.ui.open({ id: PANE, title: TITLE })
263 await scan($)
264
265 return { text: '已打开 worktree 面板。' }
266 })
267
268 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
269 const { Box, Button, Code, Text } = $.ui.resolve(e)
270 const shot = await read($, snapshot)
271 const shown = await read($, expanded)
272 const pending = await read($, deleting)
273
274 if (shot === null) {
275 return <Text dimColor>正在扫描…</Text>
276 }
277
278 const refresh = <Button key="refresh" label="刷新" onPress={() => void scan($)} />
279
280 if (shot.error !== null) {
281 return (
282 <Box flexDirection="column" gap={1}>
283 <Text color="error">{shot.error}</Text>
284 {refresh}
285 </Box>
286 )
287 }
288
289 const header = [displayName(shot.root, shot.root), shot.base === null ? '' : `基准 ${shot.base}`, `${shot.worktrees.length} 个 worktree`]
290 .filter(Boolean)
291 .join(' · ')
292
293 const deleteArea = (row: Worktree) => {
294 if (pending === null || pending.path !== row.path) {
295 return null
296 }
297
298 const cancel = <Button key={`cancel:${row.path}`} label="取消" onPress={() => void update($, deleting, () => null)} />
299
300 if (pending.step === 'choose') {
301 return (
302 <Box gap={1}>
303 <Text>{`删除 ${row.name}:`}</Text>
304 <Button
305 key={`dir:${row.path}`}
306 label="只删目录"
307 onPress={() => void update($, deleting, () => ({ path: row.path, step: 'confirm' as const, mode: 'dir' as const }))}
308 />
309 {row.branch === null ? null : (
310 <Button
311 key={`both:${row.path}`}
312 label="目录 + 分支"
313 onPress={() => void update($, deleting, () => ({ path: row.path, step: 'confirm' as const, mode: 'both' as const }))}
314 />
315 )}
316 {cancel}
317 </Box>
318 )
319 }
320
321 const { mode } = pending
322 const warnings = deleteWarnings(row, mode, shot.base)
323
324 return (
325 <Box flexDirection="column">
326 <Text bold>{mode === 'both' ? `将删除目录 ${row.name} 和分支 ${row.branch}` : `将删除目录 ${row.name}`}</Text>
327 {warnings.map(warning => (
328 <Text color="warning">{`⚠ ${warning}`}</Text>
329 ))}
330 <Box gap={1}>
331 <Button
332 key={`confirm:${row.path}`}
333 label={warnings.length > 0 ? '仍然删除' : '确认删除'}
334 variant="primary"
335 onPress={() => void remove($, row, mode, shot.root)}
336 />
337 {cancel}
338 </Box>
339 </Box>
340 )
341 }
342
343 const rowOf = (row: Worktree) => {
344 const status = summary(row, shot.base, shot.scannedAt)
345 const isProtected = row.isMain || row.isCurrent
346
347 return (
348 <Box key={`row:${row.path}`} flexDirection="column">
349 <Box gap={1}>
350 <Text bold>{row.name}</Text>
351 {row.branch === null ? <Text dimColor>{`@${row.head}`}</Text> : <Text color="claude">{row.branch}</Text>}
352 {tags(row).map(tag => (
353 <Text color={tag.color}>{tag.text}</Text>
354 ))}
355 </Box>
356 {row.isMissing ? (
357 <Text color="error">目录已不存在,可运行 git worktree prune 清理</Text>
358 ) : (
359 <Box flexDirection="column">
360 {status === '' ? null : <Text dimColor>{status}</Text>}
361 <Box gap={1}>
362 <Button key={`copy:${row.path}`} label="复制路径" onPress={press => void copyPath($, row, press.surface)} />
363 <Button key={`code:${row.path}`} label="VS Code" onPress={() => void openInCode($, row)} />
364 <Button
365 key={`changes:${row.path}`}
366 label={shown?.path === row.path ? '收起' : '改动'}
367 onPress={() => void toggleChanges($, row, shot.base)}
368 />
369 {isProtected ? null : (
370 <Button
371 key={`delete:${row.path}`}
372 label="删除"
373 onPress={() => void update($, deleting, () => ({ path: row.path, step: 'choose' as const }))}
374 />
375 )}
376 </Box>
377 {shown?.path === row.path ? <Code source={shown.text} /> : null}
378 {deleteArea(row)}
379 </Box>
380 )}
381 </Box>
382 )
383 }
384
385 return (
386 <Box flexDirection="column" gap={1}>
387 <Box gap={1}>
388 <Text bold>{header}</Text>
389 {refresh}
390 </Box>
391 {shot.worktrees.map(rowOf)}
392 </Box>
393 )
394 })
395}
396hooks/model.ts 156 lines1import type { DeleteMode, Worktree } from '../types'
2
3export type Entry = {
4 path: string
5 head: string
6 branch: string | null
7 isLocked: boolean
8 isMissing: boolean
9}
10
11export type Status = {
12 branch: string | null
13 hasUpstream: boolean
14 /** Null with no upstream, or one deleted on the remote. */
15 ab: { ahead: number; behind: number } | null
16 changed: number
17}
18
19const lines = (stdout: string) => stdout.split(/\r?\n/)
20
21// Reads `git worktree list --porcelain`: blank-line separated records, the
22// main worktree first.
23export const parseWorktreeList = (stdout: string): Entry[] => {
24 const entries: Entry[] = []
25 let entry: Entry | null = null
26
27 for (const line of lines(stdout)) {
28 if (line.startsWith('worktree ')) {
29 entry = { path: line.slice('worktree '.length), head: '', branch: null, isLocked: false, isMissing: false }
30 entries.push(entry)
31 } else if (entry === null) {
32 continue
33 } else if (line.startsWith('HEAD ')) {
34 entry.head = line.slice('HEAD '.length, 'HEAD '.length + 7)
35 } else if (line.startsWith('branch ')) {
36 entry.branch = line.slice('branch '.length).replace(/^refs\/heads\//, '')
37 } else if (line === 'locked' || line.startsWith('locked ')) {
38 entry.isLocked = true
39 } else if (line === 'prunable' || line.startsWith('prunable ')) {
40 entry.isMissing = true
41 }
42 }
43
44 return entries
45}
46
47// Reads `git status --porcelain=v2 --branch`. Git prints `# branch.ab` only
48// for an upstream that still exists, so an upstream line without it is one
49// deleted on the remote.
50export const parseStatus = (stdout: string): Status => {
51 const status: Status = { branch: null, hasUpstream: false, ab: null, changed: 0 }
52
53 for (const line of lines(stdout)) {
54 if (line.startsWith('# branch.head ')) {
55 const head = line.slice('# branch.head '.length)
56 status.branch = head === '(detached)' ? null : head
57 } else if (line.startsWith('# branch.upstream ')) {
58 status.hasUpstream = true
59 } else if (line.startsWith('# branch.ab ')) {
60 const [plus = '+0', minus = '-0'] = line.slice('# branch.ab '.length).split(' ')
61 status.ab = { ahead: Number(plus.slice(1)) || 0, behind: Number(minus.slice(1)) || 0 }
62 } else if (line !== '' && !line.startsWith('#')) {
63 status.changed += 1
64 }
65 }
66
67 return status
68}
69
70const comparable = (path: string) => path.replaceAll('\\', '/').replace(/\/+$/, '').toLowerCase()
71
72const isInside = (parent: string, child: string) => {
73 const p = comparable(parent)
74 const c = comparable(child)
75
76 return c === p || c.startsWith(`${p}/`)
77}
78
79// The innermost worktree holding `cwd`: worktrees can sit inside the main one.
80export const findCurrent = (paths: readonly string[], cwd: string): string | null =>
81 paths
82 .filter(path => isInside(path, cwd))
83 .reduce<string | null>((best, path) => (best === null || path.length > best.length ? path : best), null)
84
85export const displayName = (path: string, root: string) => {
86 if (comparable(path) !== comparable(root) && isInside(root, path)) {
87 return path.replaceAll('\\', '/').slice(root.replaceAll('\\', '/').replace(/\/+$/, '').length + 1)
88 }
89
90 return path.replaceAll('\\', '/').replace(/\/+$/, '').split('/').pop() ?? path
91}
92
93// The Claude app keeps a session's worktree under the main one's .claude/worktrees.
94export const isAppManaged = (path: string, root: string) => {
95 const parent = `${root.replaceAll('\\', '/').replace(/\/+$/, '')}/.claude/worktrees`
96
97 return comparable(path) !== comparable(parent) && isInside(parent, path)
98}
99
100export const nativePath =(path: string) => (/^[A-Za-z]:\//.test(path) ? path.replaceAll('/', '\\') : path)
101
102export const relativeTime = (seconds: number, nowMs: number) => {
103 if (seconds <= 0) {
104 return ''
105 }
106
107 const elapsed = Math.max(0, nowMs / 1000 - seconds)
108 const days = elapsed / 86400
109
110 if (elapsed < 60) return '刚刚'
111 if (elapsed < 3600) return `${Math.floor(elapsed / 60)} 分钟前`
112 if (elapsed < 86400) return `${Math.floor(elapsed / 3600)} 小时前`
113 if (days < 30) return `${Math.floor(days)} 天前`
114 if (days < 365) return `${Math.floor(days / 30)} 个月前`
115
116 return `${Math.floor(days / 365)} 年前`
117}
118
119// What deleting would lose, said before the person confirms. Removing only the
120// folder keeps the branch, and with it every commit on it.
121export const deleteWarnings = (row: Worktree, mode: DeleteMode, base: string | null): string[] => {
122 const warnings: string[] = []
123 let losesCommits = false
124
125 if (row.changed > 0) {
126 warnings.push(`${row.changed} 个未提交的文件会丢失`)
127 }
128
129 if (row.branch === null && row.orphaned > 0) {
130 warnings.push(`${row.orphaned} 个提交不在任何分支上`)
131 losesCommits = true
132 }
133
134 if (mode === 'both' && row.branch !== null) {
135 if (row.unpushed > 0) {
136 warnings.push(`${row.unpushed} 个提交未 push`)
137 losesCommits = true
138 }
139
140 if (base !== null && row.ahead > 0) {
141 warnings.push(`${row.ahead} 个提交未合并进 ${base}`)
142 losesCommits = true
143 }
144 }
145
146 if (losesCommits) {
147 warnings.push('删除后这些提交只能靠 reflog 找回')
148 }
149
150 if (row.isLocked) {
151 warnings.push('worktree 已锁定,会强制删除')
152 }
153
154 return warnings
155}
156types/index.d.ts 65 lines1export type Worktree = {
2 /** As git prints it: forward slashes. */
3 path: string
4 /** Relative to the main worktree when inside it, else the folder's name. */
5 name: string
6 /** Null while HEAD is detached. */
7 branch: string | null
8 /** Short commit hash of HEAD. */
9 head: string
10 isMain: boolean
11 /** The session runs inside it. */
12 isCurrent: boolean
13 /** Made by the Claude app, under the main worktree's .claude/worktrees. */
14 isAppManaged: boolean
15 isLocked: boolean
16 /** Its folder is gone; `git worktree prune` clears it. */
17 isMissing: boolean
18 /** Paths staged, modified, untracked or unmerged. */
19 changed: number
20 /** Ahead of a live upstream; with none, commits on no remote branch. */
21 unpushed: number
22 /** Detached only: commits on no branch and no remote branch. */
23 orphaned: number
24 /** Against the base branch. */
25 ahead: number
26 behind: number
27 /** The branch holds nothing the base lacks. */
28 isMerged: boolean
29 /** Its upstream was deleted on the remote. */
30 isGone: boolean
31 /** Unix seconds of the last commit; 0 when unknown. */
32 lastCommitAt: number
33}
34
35export type Snapshot = {
36 /** The main worktree's path. */
37 root: string
38 /** The main worktree's branch, which the others are compared with. */
39 base: string | null
40 worktrees: Worktree[]
41 /** Milliseconds since the epoch. */
42 scannedAt: number
43 error: string | null
44}
45
46export type DeleteMode = 'dir' | 'both'
47
48export type Deleting =
49 | { path: string; step: 'choose' }
50 | { path: string; step: 'confirm'; mode: DeleteMode }
51
52export type Details = { path: string; text: string }
53
54declare module 'claude-code' {
55 interface PluginState {
56 'wt-panel': {
57 snapshot: Snapshot | null
58 /** The row whose changes are shown. */
59 expanded: Details | null
60 /** The row a delete is being chosen or confirmed for. */
61 deleting: Deleting | null
62 }
63 }
64}
65