bg session 在 worktree 做完的分支,由你在面板按一顆按鈕快轉進 main:交接單只帶參數,步驟由 mod 推導,執行前檢查、執行後驗證

讓 bg session 在 git worktree 裡做完的分支,由你在面板按一顆按鈕快轉(fast-forward)進目標分支(通常是 main)。
適用情境:你用 bg session+worktree 平行開發。分支做完了,但 main 被另一個 checkout(通常是你的主工作目錄)持有,bg session 改不到它,只能停下來等你手動合併。handoff-runner 把「等你手動合併」變成一張交接單:session 簽發,你在面板看過事前檢查與要跑的完整指令後按「執行」。
bg session(worktree) 你(任何同 repo 的 session)
──────────────────────── ─────────────────────────────────
做完 feature/x
呼叫工具 handoff_issue ──寫交接單──▶ prompt 上方出現 ⇄ 1 張交接單等你
/handoff 打開面板:事前檢查+完整指令
按「執行」→ 快轉 → 事後驗證 → toast
這個 mod 的設計重點是「session 可以請求落地,但不能自己落地」:
branch、target 和簽發當下釘住的 sha。沒有路徑、沒有指令。手寫或被竄改的交接單裡多出的欄位一律忽略,參數不合法就判 invalid、不給執行按鈕。git merge --ff-only <sha>、git update-ref …、(開啟時)git push <remote> <sha>:refs/heads/<target> 三種形狀,執行前再對形狀做一次白名單檢查;不會 force、不會刪分支。/handoff 指令只能簽發或開面板;mod 不送 prompt、不派 subagent、不呼叫 model。這條由 tests/boundary.mjs 的靜態檢查守著(附違規注入的 known-positive)。rev-parse、merge-base、status、diff --name-only、worktree list、check-ref-format、ls-remote),由白名單把關。Claude Code 以 $.process.run 跑 git 時關閉 repo hooks。三種入口,效果相同(都只寫交接單,不執行):
| 入口 | 誰用 | 說明 |
|---|---|---|
工具 handoff_issue | Claude | 參數 branch(必填)、target(預設 main)。在 session 裡列為 mcp__handoff-runner__handoff_issue |
/handoff issue <branch> [target] | 你 | 手動簽發 |
| 面板的「簽發 …」按鈕 | 你 | 列出被 worktree checkout、可快轉進預設目標、還沒有待辦單的分支 |
簽發時會檢查:兩個分支名合法、都存在於本機、branch 還沒併進 target、target 能快轉到 branch(不能就請先 rebase)。同一分支同一 commit 重簽會沿用舊單;分支前進後重簽會撤掉舊單。
想讓 bg session 自動用它,可以在專案的 CLAUDE.md 加一句:「在 worktree 完成的分支,用 handoff_issue 工具簽發交接單,不要自己合併到 main。」
/handoff 開關面板。每張待辦單顯示:
feature/x → main
✓ P1 feature/x = 3f2a9c10,交接單釘住 3f2a9c10
✓ P2 main(8b1e4d22)可快轉到 3f2a9c10
✓ P3 /path/to/repo 持有 main,工作樹乾淨
執行 S1 main 快轉到 3f2a9c10(在 /path/to/repo)
git -C /path/to/repo merge --ff-only 3f2a9c10…
[ 執行 ] [ 撤單 ]
| 檢查 | 內容 |
|---|---|
| P1 | 分支 tip 仍是釘住的 sha |
| P2 | 目標分支是 sha 的祖先(可快轉) |
| P3 | 目標分支被哪個 worktree 持有;有持有者時,它未 commit 的變更不能與這次落地改到的檔案重疊(無重疊的變更會保留)。沒有持有者時改用 git update-ref,並帶預期舊值,目標在這之間被動過就失敗 |
| P4 | (push 開啟時)remote 上的目標分支能快轉到 sha;讀不到 remote 也擋下 |
P1、P2 不成立判 stale,P3、P4 不成立判 blocked,兩者都沒有執行按鈕。
按「執行」:取得認領鎖(避免多個 session 重複執行)→ 重新推導並比對 → 依序執行(已完成的步驟略過、失敗即停)→ 事後驗證 → 寫結果檔 → 釋放鎖 → toast 一行摘要。
| 驗證 | 內容 |
|---|---|
| V1 | 本機目標分支已含 sha |
| V2 | (有持有者時)持有者的 HEAD 跟上目標分支 |
| V3 | (push 開啟時)remote 上的目標分支已含 sha |
<git common dir>/handoffs/(一般 repo 就是 .git/handoffs/)。在 .git 裡,不會被 commit,同一個 repo 的所有 worktree 都看得到。
{
"schema": 1,
"id": "x-20261004-120000",
"kind": "ref-land",
"created_at": "2026-10-04T12:00:00.000Z",
"issuer": { "via": "tool" },
"params": { "branch": "feature/x", "target": "main", "sha": "<40 位 commit 雜湊>" }
}
同目錄的 <id>.result.json 是最近一次結果(passed/failed/stale/blocked/dismissed,含每步的 exit code 與驗證),<id>.lock/ 與 <id>.lock.json 是認領鎖(逾時 15 分鐘可被接手)。
/config 裡的 handoff-runner 欄位(或 settings 的 pluginConfigs):
| 欄位 | 預設 | 說明 |
|---|---|---|
push | false | 快轉後 push 目標分支。預設關閉:push 是對外的動作,各人的 remote 習慣不同 |
remote | origin | push 用的 remote |
defaultTarget | main | 簽發時沒指定目標就用它,也是面板候選清單的目標 |
branch 快轉進 target(可選 push)。不做 merge commit、rebase、刪分支、打 tag。claude -p 沒有面板,只能簽發。claude plugin validate ./plugins/handoff-runner
v0.1.0 的結果:
hooks: session.start, tool.call{tool=mcp__handoff-runner__handoff_issue}, command.run{command=handoff},
ui.close, ui.render{component=AbovePrompt}, ui.render{component=Pane, requestId=handoff-runner}
calls: $.clock.every, $.command.register, $.fs.list, $.fs.read, $.fs.write, $.process.run,
$.session.cwd, $.state.get, $.state.set, $.tool.register, $.ui.close, $.ui.open,
$.ui.resolve, $.ui.toast
$.fs.* 只讀寫 <git common dir>/handoffs/ 底下的檔。$.process.run 跑三種東西:唯讀的 git 檢查、mkdir/rmdir(認領鎖)、以及按下「執行」後的落地步驟。$.tool.register 註冊簽發工具;它不執行任何 git 寫入。push(此時會跑 git ls-remote 與 git push)。不呼叫模型、不改動任何 tool call 或 prompt。claude --plugin-dir ./plugins/handoff-runner # 單次載入
claude plugin test ./plugins/handoff-runner # 面板、按鈕、簽發(假 git)
node plugins/handoff-runner/tests/real-git.mjs # 在暫存 repo 上用真 git 落地(Node 23.6+)
node plugins/handoff-runner/tests/boundary.mjs # 唯一執行入口的靜態檢查
hooks/git.ts 是交接單存取與唯讀檢查(沒有執行入口),hooks/logic.ts 是純函式,hooks/register.tsx 是面板與唯一的執行點 runTicket。tsconfig.json 依賴 .claude-plugin/types/,那是 Claude Code 產生的型別檔,不放進 repo。
handoff-runner lets a background Claude Code session that finished a branch in a git worktree hand it off to you: the session issues a ticket, and you fast-forward the target branch (usually main) with one button press in the /handoff pane.
branch, target, pinned sha), never commands or paths. The git steps are derived by the mod from those parameters and the repo's current state, shown in full, and limited to merge --ff-only, update-ref with an expected old value, and (opt-in) a non-force push.handoff_issue and /handoff issue <branch> [target] only write tickets.<git common dir>/handoffs/, shared by all worktrees and never committed. Push is off by default (userConfig.push).hooks/register.tsx 350 lines1// handoff-runner:bg session 在 worktree 做完的分支,由使用者在面板按一顆按鈕快轉進目標分支。
2//
3// - 簽發:model 呼叫工具 `handoff_issue`,或使用者打 `/handoff issue <branch> [target]`、在面板按「簽發」。
4// 簽發只寫一張只帶參數(branch/target/sha)的交接單,不執行任何 git 寫入。
5// - 執行:**唯一的執行入口是 `runTicket`,只從「執行」按鈕的 onPress 呼叫**。
6// 本 mod 不送 prompt、不派 subagent、不呼叫 model;工具與指令都只能簽發或開面板。
7// - 按下時:認領鎖 → 重新推導 → 與畫面逐字比對 → 逐步執行(已完成者略過、失敗即停)
8// → 事後驗證並寫結果檔 → 釋放鎖 → toast 一行摘要。
9
10import { atom, read, update } from 'claude-code'
11import type { Register } from 'claude-code'
12
13import type { Rendered, RunLog, TicketRow } from '../types'
14import * as git from './git'
15import type { Config, Host } from './git'
16import { GIT_TIMEOUT_MS, REMOTE_RE, STEP_TIMEOUT_MS, TICK_MS, TOAST_MS, argvText, bandText, branchNameShapeOk, checkLine, pending, preflight, runSteps, short, stepLine, truncate } from './logic'
17
18const PANE = 'handoff-runner'
19const TOOL = 'handoff_issue'
20const GIT_ENV = { GIT_TERMINAL_PROMPT: '0' }
21
22const tickets = atom({ plugin: 'handoff-runner', key: 'tickets' } as const, null)
23const failure = atom({ plugin: 'handoff-runner', key: 'failure' } as const, null)
24const rendered = atom({ plugin: 'handoff-runner', key: 'rendered' } as const, {})
25const running = atom({ plugin: 'handoff-runner', key: 'running' } as const, {})
26const candidates = atom({ plugin: 'handoff-runner', key: 'candidates' } as const, [])
27const isPaneOpen = atom({ plugin: 'handoff-runner', key: 'isPaneOpen' } as const, false)
28
29// 模組層狀態:熱重載時歸零。
30let common: string | null | undefined
31let inFlight = false
32let cfg: Config = { push: false, remote: 'origin' }
33let defaultTarget = 'main'
34
35function host($: any): Host {
36 return {
37 git: async (args, cwd) => {
38 try {
39 const r = await $.process.run(['git', ...args], { cwd, env: GIT_ENV, timeoutMs: GIT_TIMEOUT_MS })
40 return { exitCode: r.exitCode, stdout: String(r.stdout ?? ''), stderr: String(r.stderr ?? '') }
41 } catch (err) {
42 return { exitCode: null, stdout: '', stderr: truncate(String(err), 200) }
43 }
44 },
45 read: async path => {
46 try {
47 return String(await $.fs.read(path))
48 } catch {
49 return null
50 }
51 },
52 write: (path, text) => $.fs.write(path, text),
53 list: async dir => {
54 try {
55 return (await $.fs.list(dir)).filter((e: any) => e.kind === 'file').map((e: any) => String(e.name))
56 } catch {
57 return []
58 }
59 },
60 mkdirExclusive: async path => {
61 try {
62 return (await $.process.run(['mkdir', path], { timeoutMs: GIT_TIMEOUT_MS })).exitCode === 0
63 } catch {
64 return false
65 }
66 },
67 rmdir: async path => {
68 try {
69 await $.process.run(['rmdir', path], { timeoutMs: GIT_TIMEOUT_MS })
70 } catch {}
71 },
72 now: () => Date.now(),
73 }
74}
75
76function toast($: any, text: string): void {
77 try {
78 $.ui.toast(text, { timeoutMs: TOAST_MS })
79 } catch {}
80}
81
82async function repo($: any): Promise<string | null> {
83 if (common === undefined) common = await git.commonDir(host($), await $.session.cwd())
84 return common
85}
86
87async function poll($: any): Promise<void> {
88 if (inFlight) return
89 inFlight = true
90 try {
91 const dir = await repo($)
92 if (dir === null) {
93 await update($, tickets, () => [])
94 return
95 }
96 let rows: TicketRow[]
97 try {
98 rows = await git.listTickets(host($), dir)
99 } catch (err) {
100 await update($, failure, () => truncate(String(err), 120))
101 return
102 }
103 await update($, failure, () => null)
104 await update($, tickets, () => rows)
105 if (await read($, isPaneOpen)) {
106 const next: Record<string, Rendered> = {}
107 for (const row of pending(rows)) next[row.id] = await git.render(host($), dir, row.id, cfg)
108 await update($, rendered, () => next)
109 const found = await git.candidates(host($), dir, defaultTarget)
110 await update($, candidates, () => found)
111 }
112 } finally {
113 inFlight = false
114 }
115}
116
117function refresh($: any): void {
118 void poll($).catch(() => {})
119}
120
121/** 簽發:只寫交接單,不執行。工具、指令、面板按鈕共用。 */
122async function issueText($: any, branch: unknown, target: unknown, via: string): Promise<{ ok: boolean; text: string }> {
123 const r = await git.issue(host($), await $.session.cwd(), { branch, target: target ?? defaultTarget, via })
124 if (!r.ok) return { ok: false, text: `交接單未簽發:${r.error}` }
125 refresh($)
126 const p = r.ticket.params
127 const head = r.reused ? `已有相同的交接單 ${r.ticket.id}` : `已簽發交接單 ${r.ticket.id}`
128 const extra = r.superseded.length ? `;取代了舊單 ${r.superseded.join('、')}` : ''
129 return {
130 ok: true,
131 text: `${head}:${p.branch}(${short(p.sha)})→ ${p.target}${extra}。落地要由使用者在 /handoff 面板按「執行」;這個工具不會執行任何 git 寫入。`,
132 }
133}
134
135/** 唯一的執行路徑。只從「執行」按鈕的 onPress 呼叫。 */
136async function runTicket($: any, id: string): Promise<void> {
137 if ((await read($, running))[id]) return
138 await update($, running, m => ({ ...m, [id]: true }))
139 const h = host($)
140 const dir = await repo($)
141 try {
142 if (dir === null) return
143 const claimed = await git.claim(h, dir, id)
144 if (!claimed.ok) {
145 toast($, `handoff:${claimed.detail}`)
146 return
147 }
148 try {
149 const log: RunLog = { aborted: null, forced_stale_lock: claimed.forced, steps: [] }
150 const fresh = await git.render(h, dir, id, cfg)
151 log.aborted = preflight((await read($, rendered))[id], fresh)
152 if (log.aborted === null) {
153 log.steps = await runSteps(fresh.steps, async argv => {
154 const r = await $.process.run(argv, { env: GIT_ENV, timeoutMs: STEP_TIMEOUT_MS })
155 return { exitCode: r.exitCode, stderr: String(r.stderr ?? '') }
156 })
157 }
158 const result = await git.record(h, dir, id, log, cfg)
159 toast($, `handoff:${result.summary}`)
160 } finally {
161 await git.release(h, dir, id)
162 }
163 } catch (err) {
164 toast($, `handoff:執行中斷(${truncate(String(err), 80)})`)
165 } finally {
166 await update($, running, m => ({ ...m, [id]: false }))
167 refresh($)
168 }
169}
170
171async function dismissTicket($: any, id: string): Promise<void> {
172 const dir = await repo($)
173 if (dir === null) return
174 const r = await git.dismiss(host($), dir, id)
175 toast($, r.ok ? `handoff:已撤單 ${id}` : `handoff:撤單失敗(${r.detail})`)
176 refresh($)
177}
178
179async function issueFromPane($: any, branch: string, target: string): Promise<void> {
180 const r = await issueText($, branch, target, 'pane')
181 toast($, r.text.split('。')[0] ?? r.text)
182}
183
184const USAGE = '用法:/handoff 開關面板;/handoff issue <branch> [target] 簽發交接單'
185
186export const register: Register = (on, options) => {
187 cfg = {
188 push: options.push === true,
189 remote: typeof options.remote === 'string' && REMOTE_RE.test(options.remote) ? options.remote : 'origin',
190 }
191 defaultTarget = typeof options.defaultTarget === 'string' && branchNameShapeOk(options.defaultTarget) ? options.defaultTarget : 'main'
192
193 on('session.start', async ($, e, next) => {
194 const out = await next(e)
195 await $.command.register({ name: 'handoff', description: '交接單面板:檢查、執行、撤單', argumentHint: '[issue <branch> [target]]' })
196 await $.tool.register({
197 name: TOOL,
198 description:
199 '為一個已完成的分支簽發交接單,請使用者把它快轉(fast-forward)進目標分支。' +
200 '用在 worktree 裡的工作做完、但目標分支被別的 checkout 持有、你自己無法落地的時候。' +
201 '這個工具只記錄 branch、target 與當下的 commit,不執行任何 git 寫入;' +
202 '實際落地由使用者在 /handoff 面板檢查後按「執行」。branch 必須能快轉進 target(必要時先 rebase)。',
203 inputSchema: {
204 type: 'object',
205 properties: {
206 branch: { type: 'string', description: '要落地的本機分支名稱' },
207 target: { type: 'string', description: `目標分支,預設 ${defaultTarget}` },
208 },
209 required: ['branch'],
210 additionalProperties: false,
211 },
212 })
213 $.clock.every(TICK_MS, () => refresh($))
214 refresh($)
215 return out
216 })
217
218 on('tool.call', { tool: 'mcp__handoff-runner__handoff_issue' }, async ($, e: any) => {
219 const r = await issueText($, e.branch, e.target, 'tool')
220 return r.ok ? { result: r.text } : { deny: r.text }
221 })
222
223 on('command.run', { command: 'handoff' }, async ($, e) => {
224 const args = String(e.args ?? '').trim().split(/\s+/).filter(Boolean)
225 if (args[0] === 'issue') {
226 if (!args[1] || args.length > 3) return { text: USAGE }
227 return { text: (await issueText($, args[1], args[2], 'command')).text }
228 }
229 if (args.length > 0) return { text: USAGE }
230 if (await read($, isPaneOpen)) {
231 await $.ui.close({ id: PANE }).catch(() => {})
232 await update($, isPaneOpen, () => false)
233 return { text: 'handoff 面板已關閉。' }
234 }
235 await update($, isPaneOpen, () => true)
236 await $.ui.open({ id: PANE, title: 'handoff' })
237 await poll($)
238 return { text: 'handoff 面板已開啟。' }
239 })
240
241 on('ui.close', async ($, e: any, next) => {
242 if (e.id === PANE) await update($, isPaneOpen, () => false)
243 return next(e)
244 })
245
246 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
247 if ((e.props as any)?.hasSurvey) return next(e)
248 const fail = await read($, failure)
249 const text = bandText(await read($, tickets), fail)
250 if (text === null) return next(e)
251 const { Box, Text } = $.ui.resolve(e)
252 const below = await next(e)
253 return (
254 <Box flexDirection="column">
255 <Text color={fail !== null ? 'yellow' : 'cyan'} wrap="truncate-end">
256 {text}
257 </Text>
258 {below}
259 </Box>
260 )
261 })
262
263 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
264 const { Box, Text, Button } = $.ui.resolve(e)
265 const rows = pending(await read($, tickets))
266 const byId = await read($, rendered)
267 const busy = await read($, running)
268 const fail = await read($, failure)
269 const cands = await read($, candidates)
270
271 const out: any[] = []
272 if (fail !== null) out.push(<Text color="yellow" wrap="truncate-end">無法讀取交接單:{fail}</Text>)
273 if (rows.length === 0 && fail === null) out.push(<Text dimColor>沒有待辦的交接單。</Text>)
274
275 for (const row of rows) {
276 const r = byId[row.id]
277 out.push(
278 <Text key={`title:${row.id}`} bold wrap="truncate-end">
279 {row.title}
280 </Text>,
281 )
282 if (!r) {
283 out.push(<Text dimColor>檢查中…</Text>)
284 continue
285 }
286 for (const c of r.prechecks) {
287 out.push(
288 <Text color={c.ok ? undefined : 'yellow'} dimColor={c.ok || undefined} wrap="truncate-end">
289 {checkLine(c)}
290 </Text>,
291 )
292 }
293 for (const s of r.steps) {
294 out.push(
295 <Box flexDirection="column">
296 <Text dimColor={s.done_already || undefined} wrap="truncate-end">
297 {stepLine(s)}
298 </Text>
299 <Text dimColor wrap="truncate-end">
300 {' '}
301 {argvText(s)}
302 </Text>
303 </Box>,
304 )
305 }
306 if (r.last_result?.summary) out.push(<Text dimColor wrap="truncate-end">上次:{r.last_result.summary}</Text>)
307 const buttons: any[] = []
308 if (busy[row.id]) {
309 out.push(<Text color="cyan">執行中…</Text>)
310 } else if (r.status === 'ready') {
311 buttons.push(<Button key={`run:${row.id}`} variant="primary" label="執行" onPress={() => void runTicket($, row.id)} />)
312 } else {
313 out.push(
314 <Text color="yellow" wrap="truncate-end">
315 無法執行({r.status}):{r.reason || '事前檢查未通過'}
316 </Text>,
317 )
318 }
319 if (!busy[row.id]) {
320 buttons.push(<Button key={`dismiss:${row.id}`} label="撤單" dimColor onPress={() => void dismissTicket($, row.id).catch(() => {})} />)
321 }
322 out.push(<Box key={`buttons:${row.id}`}>{buttons}</Box>)
323 }
324
325 if (cands.length > 0) {
326 out.push(<Text key="cands" dimColor>可簽發(worktree 裡可快轉進 {defaultTarget} 的分支):</Text>)
327 for (const c of cands) {
328 out.push(
329 <Button
330 key={`issue:${c.branch}`}
331 label={`簽發 ${c.branch}(${short(c.sha)})→ ${c.target}`}
332 dimColor
333 onPress={() => void issueFromPane($, c.branch, c.target).catch(() => {})}
334 />,
335 )
336 }
337 }
338
339 return (
340 <Box flexDirection="column">
341 {out}
342 <Box>
343 <Button key="refresh" label="重新檢查" onPress={() => refresh($)} />
344 <Button key="close" label="關閉" role="dismiss" onPress={() => $.ui.close({ id: PANE }).catch(() => {})} />
345 </Box>
346 </Box>
347 )
348 })
349}
350hooks/git.ts 438 lines1// handoff-runner 的交接單存取與唯讀 git 檢查:簽發、列出、推導(render)、認領、紀錄、撤單。
2//
3// 這個檔案**沒有執行入口**:
4// - 交接單只存 branch/target/sha 三個參數,不存路徑或指令;argv 在 render 當下由參數推導。
5// - 這裡的 git 只有唯讀查詢,全部經 `roGit`,它在呼叫前以白名單擋下任何會寫入的子命令。
6// - 會改 ref 的步驟只在 register.tsx 的按鈕 handler 裡執行(見 tests/boundary.mjs)。
7//
8// 交接單住 `<git common dir>/handoffs/`:`<id>.json` 交接單、`<id>.lock/` 認領鎖、
9// `<id>.lock.json` 鎖的時間、`<id>.result.json` 最近一次結果。在 `.git` 裡,不會被 commit,
10// 同一個 repo 的所有 worktree 都看得到。
11
12import type { Candidate, Check, Params, Rendered, Result, RunLog, Status, Ticket, TicketRow } from '../types'
13import {
14 ID_RE,
15 LOCK_STALE_MS,
16 SCHEMA,
17 branchNameShapeOk,
18 buildSteps,
19 fingerprint,
20 isClosed,
21 parseLsRemote,
22 parseNamesZ,
23 parseResult,
24 parseStatusZ,
25 parseTicket,
26 parseWorktrees,
27 short,
28 summarize,
29 ticketId,
30 titleOf,
31} from './logic'
32import type { Worktree } from './logic'
33
34export type ProcResult = { exitCode: number | null; stdout: string; stderr: string }
35
36/** mod 提供給這個檔案的能力。`git` 只會被 `roGit` 呼叫。 */
37export type Host = {
38 /** 跑 `git <args>`(cwd 為 `cwd`);不 throw */
39 git(args: string[], cwd: string): Promise<ProcResult>
40 /** 讀檔;不存在回 null */
41 read(path: string): Promise<string | null>
42 write(path: string, text: string): Promise<void>
43 /** 目錄下的檔名;目錄不存在回 [] */
44 list(dir: string): Promise<string[]>
45 /** 建立目錄,已存在就失敗(原子操作,當鎖用) */
46 mkdirExclusive(path: string): Promise<boolean>
47 rmdir(path: string): Promise<void>
48 now(): number
49}
50
51export type Config = { push: boolean; remote: string }
52
53// ── 唯讀 git ────────────────────────────────────────────────────────────────
54
55/** `roGit` 唯一接受的子命令。新增前先確認它不會改變 ref、物件、工作樹或遠端。 */
56export const READONLY_GIT: ReadonlySet<string> = new Set([
57 'rev-parse',
58 'merge-base',
59 'ls-remote',
60 'status',
61 'worktree',
62 'check-ref-format',
63 'diff',
64])
65
66export async function roGit(host: Host, args: string[], cwd: string): Promise<ProcResult> {
67 const sub = args[0] ?? ''
68 if (!READONLY_GIT.has(sub)) return { exitCode: -2, stdout: '', stderr: `refused: ${sub} 不是唯讀 git 子命令` }
69 if (sub === 'worktree' && args[1] !== 'list') return { exitCode: -2, stdout: '', stderr: 'refused: 只允許 worktree list' }
70 if (args.some(a => a.startsWith('--output'))) return { exitCode: -2, stdout: '', stderr: 'refused: 會寫檔的選項' }
71 return host.git(args, cwd)
72}
73
74/** session 所在 repo 的 git common dir(絕對路徑);不在 git repo 內回 null。 */
75export async function commonDir(host: Host, cwd: string): Promise<string | null> {
76 const r = await roGit(host, ['rev-parse', '--path-format=absolute', '--git-common-dir'], cwd)
77 const dir = r.exitCode === 0 ? r.stdout.trim().replace(/\/+$/, '') : ''
78 return dir.startsWith('/') ? dir : null
79}
80
81export async function rev(host: Host, common: string, ref: string): Promise<string | null> {
82 const r = await roGit(host, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`], common)
83 const out = r.stdout.trim()
84 return r.exitCode === 0 && out ? out : null
85}
86
87async function branchTip(host: Host, common: string, branch: string): Promise<string | null> {
88 return rev(host, common, `refs/heads/${branch}`)
89}
90
91/** true/false;讀不到(含物件不在本機)回 null。 */
92export async function isAncestor(host: Host, common: string, older: string, newer: string): Promise<boolean | null> {
93 const r = await roGit(host, ['merge-base', '--is-ancestor', older, newer], common)
94 return r.exitCode === 0 ? true : r.exitCode === 1 ? false : null
95}
96
97/** `tip` 已含 `sha`(等於或為其後代)=已落地。讀不到一律 false。 */
98export async function contains(host: Host, common: string, tip: string | null, sha: string): Promise<boolean> {
99 return tip !== null && (tip === sha || (await isAncestor(host, common, sha, tip)) === true)
100}
101
102export async function worktrees(host: Host, common: string): Promise<Worktree[] | null> {
103 const r = await roGit(host, ['worktree', 'list', '--porcelain'], common)
104 return r.exitCode === 0 ? parseWorktrees(r.stdout) : null
105}
106
107export async function holderOf(host: Host, common: string, branch: string): Promise<string | null> {
108 return (await worktrees(host, common))?.find(w => !w.bare && w.branch === branch)?.path ?? null
109}
110
111async function refOk(host: Host, common: string, name: unknown): Promise<boolean> {
112 if (!branchNameShapeOk(name)) return false
113 return (await roGit(host, ['check-ref-format', '--branch', name], common)).exitCode === 0
114}
115
116async function remoteTip(host: Host, common: string, remote: string, target: string): Promise<{ sha: string | null } | null> {
117 const ref = `refs/heads/${target}`
118 const r = await roGit(host, ['ls-remote', remote, ref], common)
119 if (r.exitCode !== 0) return null
120 return { sha: parseLsRemote(r.stdout)[ref] ?? null }
121}
122
123// ── 檔案 ────────────────────────────────────────────────────────────────────
124
125export function handoffsDir(common: string): string {
126 return `${common}/handoffs`
127}
128
129function pathOf(common: string, id: string, suffix: string): string {
130 if (!ID_RE.test(id)) throw new Error(`交接單 id 不合法:${id}`)
131 return `${handoffsDir(common)}/${id}${suffix}`
132}
133
134export async function loadTicket(host: Host, common: string, id: string): Promise<{ ticket: Ticket } | { error: string }> {
135 if (!ID_RE.test(id)) return { error: `交接單 id 不合法:${id}` }
136 const text = await host.read(pathOf(common, id, '.json'))
137 if (text === null) return { error: `找不到交接單 ${id}` }
138 const p = parseTicket(id, text)
139 return p.ok ? { ticket: p.value } : { error: `交接單 ${id} ${p.error}` }
140}
141
142export async function loadResult(host: Host, common: string, id: string): Promise<Result | null> {
143 return parseResult(await host.read(pathOf(common, id, '.result.json')))
144}
145
146async function writeResult(host: Host, common: string, result: Result): Promise<void> {
147 await host.write(pathOf(common, result.id, '.result.json'), JSON.stringify(result, null, 2) + '\n')
148}
149
150function historyOf(previous: Result | null): string[] {
151 return previous ? [...(previous.history ?? []), previous.summary] : []
152}
153
154// ── 簽發 ────────────────────────────────────────────────────────────────────
155
156export type Issued = { ok: true; ticket: Ticket; reused: boolean; superseded: string[] } | { ok: false; error: string }
157
158/** 檢查參數並寫一張交接單。sha 在此當下釘住。 */
159export async function issue(
160 host: Host,
161 cwd: string,
162 input: { branch: unknown; target: unknown; via: string },
163): Promise<Issued> {
164 const common = await commonDir(host, cwd)
165 if (common === null) return { ok: false, error: '目前的目錄不在 git repo 內' }
166 const { branch, target } = input
167 if (!(await refOk(host, common, branch))) return { ok: false, error: `branch 不是合法的分支名:${JSON.stringify(branch)}` }
168 if (!(await refOk(host, common, target))) return { ok: false, error: `target 不是合法的分支名:${JSON.stringify(target)}` }
169 if (branch === target) return { ok: false, error: 'branch 與 target 不能相同' }
170 const sha = await branchTip(host, common, branch as string)
171 if (sha === null) return { ok: false, error: `找不到本機分支 ${branch}` }
172 const tgt = await branchTip(host, common, target as string)
173 if (tgt === null) return { ok: false, error: `找不到本機分支 ${target}` }
174 if (await contains(host, common, tgt, sha)) return { ok: false, error: `${target} 已含 ${branch}(${short(sha)}),不需要落地` }
175 if ((await isAncestor(host, common, tgt, sha)) !== true) {
176 return { ok: false, error: `${target} 無法快轉到 ${branch}:先把 ${branch} rebase 到 ${target} 上再簽發` }
177 }
178
179 const params: Params = { branch: branch as string, target: target as string, sha }
180 const superseded: string[] = []
181 for (const row of await listTickets(host, common)) {
182 if (!row.params || row.params.branch !== params.branch || row.params.target !== params.target) continue
183 if (row.params.sha === sha) {
184 const loaded = await loadTicket(host, common, row.id)
185 if ('ticket' in loaded) return { ok: true, ticket: loaded.ticket, reused: true, superseded }
186 } else if ((await dismiss(host, common, row.id, '已被同分支的新交接單取代')).ok) {
187 superseded.push(row.id)
188 }
189 }
190
191 const now = new Date(host.now())
192 let id = ticketId(params.branch, now)
193 for (let n = 2; (await host.read(pathOf(common, id, '.json'))) !== null; n++) id = `${ticketId(params.branch, now)}-${n}`
194 const ticket: Ticket = { schema: SCHEMA, id, kind: 'ref-land', created_at: now.toISOString(), issuer: { via: input.via }, params }
195 await host.write(pathOf(common, id, '.json'), JSON.stringify(ticket, null, 2) + '\n')
196 return { ok: true, ticket, reused: false, superseded }
197}
198
199// ── 列出 ────────────────────────────────────────────────────────────────────
200
201export async function listTickets(host: Host, common: string, includeClosed = false): Promise<TicketRow[]> {
202 const names = (await host.list(handoffsDir(common))).filter(n => n.endsWith('.json') && !n.endsWith('.result.json') && !n.endsWith('.lock.json'))
203 const rows: TicketRow[] = []
204 for (const name of names.sort()) {
205 const id = name.slice(0, -'.json'.length)
206 if (!ID_RE.test(id)) continue
207 const result = await loadResult(host, common, id)
208 if (isClosed(result) && !includeClosed) continue
209 const loaded = await loadTicket(host, common, id)
210 if ('error' in loaded) {
211 rows.push({ id, title: id, error: loaded.error, outcome: result?.outcome ?? null, summary: result?.summary ?? null })
212 continue
213 }
214 const t = loaded.ticket
215 rows.push({ id, title: titleOf(t.params), created_at: t.created_at, params: t.params, outcome: result?.outcome ?? null, summary: result?.summary ?? null })
216 }
217 return rows
218}
219
220/** 面板手動簽發的候選:被 worktree 持有、可快轉進 target、還沒落地也還沒有同 sha 待辦單的分支。 */
221export async function candidates(host: Host, common: string, target: string): Promise<Candidate[]> {
222 if (!branchNameShapeOk(target)) return []
223 const tgt = await branchTip(host, common, target)
224 const trees = await worktrees(host, common)
225 if (tgt === null || trees === null) return []
226 const open = new Set((await listTickets(host, common)).filter(r => r.params?.target === target).map(r => `${r.params!.branch}@${r.params!.sha}`))
227 const out: Candidate[] = []
228 for (const w of trees) {
229 if (w.bare || !w.branch || w.branch === target || !branchNameShapeOk(w.branch)) continue
230 const sha = await branchTip(host, common, w.branch)
231 if (sha === null || open.has(`${w.branch}@${sha}`)) continue
232 if (await contains(host, common, tgt, sha)) continue
233 if ((await isAncestor(host, common, tgt, sha)) !== true) continue
234 out.push({ branch: w.branch, sha, target, worktree: w.path })
235 }
236 return out
237}
238
239// ── render:事前檢查+步驟推導(只讀) ─────────────────────────────────────
240
241async function prechecks(host: Host, common: string, p: Params, cfg: Config): Promise<{ checks: Check[]; status: Status; holder: string | null; targetTip: string; landed: boolean; pushDone: boolean; pushDetail: string }> {
242 const { branch, target, sha } = p
243 const checks: Check[] = []
244 let status: Status = 'ready'
245 const block = () => {
246 if (status === 'ready') status = 'blocked'
247 }
248 const tgt = (await branchTip(host, common, target)) ?? ''
249 const holder = await holderOf(host, common, target)
250 const landed = await contains(host, common, tgt || null, sha)
251
252 if (landed) {
253 checks.push({ name: 'P1', ok: true, detail: `${target}(${short(tgt)})已含 ${short(sha)},快轉已完成` })
254 } else {
255 const src = await branchTip(host, common, branch)
256 const p1 = src === sha
257 checks.push({ name: 'P1', ok: p1, detail: `${branch} = ${short(src)},交接單釘住 ${short(sha)}` })
258 const p2 = tgt !== '' && (await isAncestor(host, common, tgt, sha)) === true
259 checks.push({ name: 'P2', ok: p2, detail: `${target}(${short(tgt || null)})${p2 ? '可快轉到' : '無法快轉到'} ${short(sha)}` })
260 if (!(p1 && p2)) status = 'stale'
261
262 if (holder === null) {
263 checks.push({ name: 'P3', ok: true, detail: `${target} 沒有被任何 worktree checkout,直接更新 ref` })
264 } else {
265 const st = await roGit(host, ['status', '--porcelain', '-z', '--untracked-files=all'], holder)
266 const df = tgt ? await roGit(host, ['diff', '--name-only', '-z', '--no-renames', tgt, sha], common) : null
267 if (st.exitCode !== 0 || !df || df.exitCode !== 0) {
268 checks.push({ name: 'P3', ok: false, detail: `${holder} 無法判定未 commit 變更是否與這次落地重疊` })
269 block()
270 } else {
271 const dirty = parseStatusZ(st.stdout)
272 const landing = parseNamesZ(df.stdout)
273 const overlap = [...dirty].filter(x => landing.has(x)).sort()
274 if (overlap.length) {
275 const list = overlap.slice(0, 5).join('、') + (overlap.length > 5 ? '…' : '')
276 checks.push({ name: 'P3', ok: false, detail: `${holder} 有與這次落地重疊的未 commit 變更:${list}` })
277 block()
278 } else {
279 const note = dirty.size ? `(另有 ${dirty.size} 個未 commit 變更,與這次落地無重疊)` : ',工作樹乾淨'
280 checks.push({ name: 'P3', ok: true, detail: `${holder} 持有 ${target}${note}` })
281 }
282 }
283 }
284 }
285
286 let pushDone = false
287 let pushDetail = ''
288 if (cfg.push) {
289 const remote = await remoteTip(host, common, cfg.remote, target)
290 if (remote === null) {
291 checks.push({ name: 'P4', ok: false, detail: `讀不到 ${cfg.remote}(ls-remote 失敗);關掉 push 設定可只做本機落地` })
292 block()
293 } else if (remote.sha === null) {
294 checks.push({ name: 'P4', ok: true, detail: `${cfg.remote} 還沒有 ${target},push 會建立它` })
295 } else if (await contains(host, common, remote.sha, sha)) {
296 pushDone = true
297 checks.push({ name: 'P4', ok: true, detail: `${cfg.remote}/${target}(${short(remote.sha)})已含 ${short(sha)}` })
298 } else if ((await isAncestor(host, common, remote.sha, sha)) === true) {
299 checks.push({ name: 'P4', ok: true, detail: `${cfg.remote}/${target}(${short(remote.sha)})可快轉到 ${short(sha)}` })
300 } else {
301 pushDetail = `${cfg.remote}/${target} 有本機沒有的 commit`
302 checks.push({ name: 'P4', ok: false, detail: `${cfg.remote}/${target}(${short(remote.sha)})無法快轉到 ${short(sha)}:先 fetch 並更新本機` })
303 block()
304 }
305 }
306 return { checks, status, holder, targetTip: tgt, landed, pushDone, pushDetail }
307}
308
309export async function render(host: Host, common: string, id: string, cfg: Config): Promise<Rendered> {
310 const result = ID_RE.test(id) ? await loadResult(host, common, id) : null
311 const loaded = await loadTicket(host, common, id)
312 const empty = { prechecks: [], steps: [], fingerprint: '', last_result: result }
313 if ('error' in loaded) return { id, status: 'invalid', title: id, reason: loaded.error, ...empty }
314 const p = loaded.ticket.params
315 const title = titleOf(p)
316 if (isClosed(result)) return { id, status: 'done', title, reason: result!.summary, ...empty }
317 if (!(await refOk(host, common, p.branch)) || !(await refOk(host, common, p.target))) {
318 return { id, status: 'invalid', title, reason: '分支名不合法', ...empty }
319 }
320 if ((await branchTip(host, common, p.target)) === null) {
321 return { id, status: 'invalid', title, reason: `找不到本機分支 ${p.target}`, ...empty }
322 }
323 const pre = await prechecks(host, common, p, cfg)
324 const steps = buildSteps({
325 common,
326 params: p,
327 holder: pre.holder,
328 targetTip: pre.targetTip,
329 landed: pre.landed,
330 push: cfg.push ? { remote: cfg.remote, done: pre.pushDone, detail: pre.pushDetail } : null,
331 })
332 const reason = pre.checks.filter(c => !c.ok).map(c => c.detail).join(';')
333 return { id, status: pre.status, title, prechecks: pre.checks, steps, fingerprint: fingerprint(steps), reason, last_result: result }
334}
335
336// ── verify:事後驗證(只讀) ────────────────────────────────────────────────
337
338export async function verify(host: Host, common: string, p: Params, cfg: Config): Promise<Check[]> {
339 const { target, sha } = p
340 const checks: Check[] = []
341 const tgt = await branchTip(host, common, target)
342 checks.push({ name: 'V1', ok: await contains(host, common, tgt, sha), detail: `本機 ${target}(${short(tgt)})已含 ${short(sha)}` })
343 const holder = await holderOf(host, common, target)
344 if (holder !== null) {
345 const head = await rev(host, holder, 'HEAD')
346 checks.push({ name: 'V2', ok: head !== null && head === tgt, detail: `${holder} 的 HEAD(${short(head)})跟上 ${target}` })
347 }
348 if (cfg.push) {
349 const remote = await remoteTip(host, common, cfg.remote, target)
350 const ok = remote !== null && (await contains(host, common, remote.sha, sha))
351 checks.push({ name: 'V3', ok, detail: remote === null ? `讀不到 ${cfg.remote}(ls-remote 失敗)` : `${cfg.remote}/${target} 已含 ${short(sha)}` })
352 }
353 return checks
354}
355
356// ── claim/record/dismiss:只寫 handoffs/ 底下的檔 ─────────────────────────
357
358export type Claim = { ok: boolean; forced: boolean; detail: string }
359
360/** 以「建立目錄」當鎖:已存在且未逾時就拿不到;逾時則強制接手並標記。 */
361export async function claim(host: Host, common: string, id: string): Promise<Claim> {
362 const loaded = await loadTicket(host, common, id)
363 if ('error' in loaded) return { ok: false, forced: false, detail: loaded.error }
364 const dir = pathOf(common, id, '.lock')
365 const stamp = pathOf(common, id, '.lock.json')
366 let forced = false
367 for (let attempt = 0; attempt < 2; attempt++) {
368 if (await host.mkdirExclusive(dir)) {
369 await host.write(stamp, JSON.stringify({ at: host.now() }) + '\n')
370 return { ok: true, forced, detail: '' }
371 }
372 let at = 0
373 try {
374 at = Number(JSON.parse((await host.read(stamp)) ?? '{}').at) || 0
375 } catch {}
376 if (host.now() - at <= LOCK_STALE_MS) return { ok: false, forced: false, detail: '另一個 session 正在執行這張單' }
377 await host.rmdir(dir)
378 forced = true
379 }
380 return { ok: false, forced: false, detail: '無法建立認領鎖' }
381}
382
383export async function release(host: Host, common: string, id: string): Promise<void> {
384 await host.rmdir(pathOf(common, id, '.lock'))
385}
386
387/**
388 * 合併執行紀錄與事後驗證,寫 `<id>.result.json`,回傳結果。
389 * 已通過或已撤單的單是終態:不重寫結果檔,回傳前一次結果並把摘要換成「無需再執行」。
390 * 不釋放認領鎖,由呼叫端在 finally 釋放。
391 */
392export async function record(host: Host, common: string, id: string, log: RunLog, cfg: Config): Promise<Result> {
393 const loaded = await loadTicket(host, common, id)
394 const previous = await loadResult(host, common, id)
395 if (previous !== null && isClosed(previous)) {
396 const what = previous.outcome === 'passed' ? '✓ 已落地' : '已撤單'
397 return { ...previous, summary: `${what},無需再執行(上次:${previous.summary})` }
398 }
399 const checks = 'ticket' in loaded && !log.aborted ? await verify(host, common, loaded.ticket.params, cfg) : []
400 const s = summarize(log, checks)
401 const result: Result = {
402 schema: SCHEMA,
403 id,
404 outcome: s.outcome,
405 summary: s.summary,
406 finished_at: new Date(host.now()).toISOString(),
407 forced_stale_lock: Boolean(log.forced_stale_lock),
408 steps: s.steps,
409 checks,
410 history: historyOf(previous),
411 }
412 await writeResult(host, common, result)
413 return result
414}
415
416/** 撤單:先拿認領鎖,避免撤掉正在執行的單。 */
417export async function dismiss(host: Host, common: string, id: string, summary = '已撤單(未執行)'): Promise<{ ok: boolean; detail: string }> {
418 const c = await claim(host, common, id)
419 if (!c.ok) return { ok: false, detail: c.detail }
420 try {
421 const previous = await loadResult(host, common, id)
422 await writeResult(host, common, {
423 schema: SCHEMA,
424 id,
425 outcome: 'dismissed',
426 summary,
427 finished_at: new Date(host.now()).toISOString(),
428 forced_stale_lock: c.forced,
429 steps: [],
430 checks: [],
431 history: historyOf(previous),
432 })
433 return { ok: true, detail: '' }
434 } finally {
435 await release(host, common, id)
436 }
437}
438hooks/logic.ts 311 lines1// handoff-runner 的純函式:參數格式、git 輸出解析、步驟推導、執行紀錄的組裝規則。不碰 $、不跑程式。
2
3import type { Aborted, Check, Outcome, Params, Rendered, Result, RunLog, Step, StepLog, StepRow, Ticket, TicketRow } from '../types'
4
5export const SCHEMA = 1
6export const TICK_MS = 60_000
7export const GIT_TIMEOUT_MS = 30_000
8export const STEP_TIMEOUT_MS = 600_000
9export const TOAST_MS = 8_000
10/** 認領鎖的存活上限:步驟單次上限 10 分鐘,再留餘裕 */
11export const LOCK_STALE_MS = 15 * 60_000
12
13export const SHA_RE = /^[0-9a-f]{40}([0-9a-f]{24})?$/
14export const ID_RE = /^[a-z0-9][a-z0-9._-]{0,120}$/
15/** remote 名稱只接受保守的字元集,避免被當成選項或 URL */
16export const REMOTE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
17
18export type Parsed<T> = { ok: true; value: T } | { ok: false; error: string }
19
20export function truncate(s: string, max: number): string {
21 const one = s.replace(/\s+/g, ' ').trim()
22 return one.length <= max ? one : `${one.slice(0, max - 1)}…`
23}
24
25export function short(sha: string | null | undefined): string {
26 return sha ? sha.slice(0, 8) : '無法解析'
27}
28
29/** 分支名稱的第一道關:`git check-ref-format --branch` 之前先擋掉會被當成選項或 refspec 的寫法。 */
30export function branchNameShapeOk(name: unknown): name is string {
31 return (
32 typeof name === 'string' &&
33 name.length > 0 &&
34 name.length <= 200 &&
35 !name.startsWith('-') &&
36 !name.startsWith('refs/') &&
37 !/[\s:+^~?*[\\\x00-\x1f\x7f]/.test(name)
38 )
39}
40
41export function slug(branch: string): string {
42 const tail = branch.split('/').pop() ?? branch
43 return (
44 tail
45 .toLowerCase()
46 .replace(/[^a-z0-9._-]+/g, '-')
47 .replace(/^[-._]+|-+$/g, '')
48 .slice(0, 60) || 'x'
49 )
50}
51
52/** `<slug>-<UTC yyyymmdd-hhmmss>`;同秒重複時呼叫端加序號。 */
53export function ticketId(branch: string, now: Date): string {
54 const p = (n: number) => String(n).padStart(2, '0')
55 const stamp = `${now.getUTCFullYear()}${p(now.getUTCMonth() + 1)}${p(now.getUTCDate())}-${p(now.getUTCHours())}${p(now.getUTCMinutes())}${p(now.getUTCSeconds())}`
56 return `${slug(branch)}-${stamp}`
57}
58
59export function titleOf(p: Pick<Params, 'branch' | 'target'>): string {
60 return `${p.branch} → ${p.target}`
61}
62
63/** 交接單 JSON → Ticket。只認得 schema 1 的 ref-land;多出的欄位一律丟掉,不會被讀到。 */
64export function parseTicket(id: string, text: string): Parsed<Ticket> {
65 let v: any
66 try {
67 v = JSON.parse(text)
68 } catch {
69 return { ok: false, error: '不是合法 JSON' }
70 }
71 if (v === null || typeof v !== 'object') return { ok: false, error: '不是 JSON 物件' }
72 if (v.schema !== SCHEMA) return { ok: false, error: `未知 schema:${String(v.schema)}` }
73 if (v.kind !== 'ref-land') return { ok: false, error: `未知 kind:${String(v.kind)}` }
74 if (v.id !== id) return { ok: false, error: 'id 與檔名不符' }
75 const p = v.params
76 if (p === null || typeof p !== 'object') return { ok: false, error: '缺 params' }
77 const params: Params = { branch: p.branch, target: p.target, sha: p.sha }
78 if (!branchNameShapeOk(params.branch)) return { ok: false, error: `branch 不合法:${JSON.stringify(p.branch)}` }
79 if (!branchNameShapeOk(params.target)) return { ok: false, error: `target 不合法:${JSON.stringify(p.target)}` }
80 if (typeof params.sha !== 'string' || !SHA_RE.test(params.sha)) return { ok: false, error: 'sha 必須是完整的 commit 雜湊' }
81 if (params.branch === params.target) return { ok: false, error: 'branch 與 target 相同' }
82 return {
83 ok: true,
84 value: {
85 schema: SCHEMA,
86 id,
87 kind: 'ref-land',
88 created_at: typeof v.created_at === 'string' ? v.created_at : '',
89 issuer: { via: typeof v.issuer?.via === 'string' ? v.issuer.via : '' },
90 params,
91 },
92 }
93}
94
95export function parseResult(text: string | null): Result | null {
96 if (text === null) return null
97 try {
98 const v = JSON.parse(text)
99 return v && typeof v === 'object' && typeof v.outcome === 'string' ? (v as Result) : null
100 } catch {
101 return null
102 }
103}
104
105export function isClosed(r: Result | null): boolean {
106 return r !== null && (r.outcome === 'passed' || r.outcome === 'dismissed')
107}
108
109// ── git 輸出解析 ────────────────────────────────────────────────────────────
110
111export type Worktree = { path: string; branch: string | null; bare: boolean }
112
113export function parseWorktrees(out: string): Worktree[] {
114 const items: Worktree[] = []
115 let cur: Worktree | null = null
116 for (const line of [...out.split('\n'), '']) {
117 if (line.startsWith('worktree ')) {
118 cur = { path: line.slice('worktree '.length).trim(), branch: null, bare: false }
119 } else if (cur && line.startsWith('branch ')) {
120 const ref = line.slice('branch '.length).trim()
121 cur.branch = ref.startsWith('refs/heads/') ? ref.slice('refs/heads/'.length) : ref
122 } else if (cur && line.trim() === 'bare') {
123 cur.bare = true
124 } else if (cur && line.trim() === '') {
125 items.push(cur)
126 cur = null
127 }
128 }
129 return items
130}
131
132/** `status --porcelain -z`:已追蹤的修改與未追蹤檔;rename/copy 的兩端都列入。 */
133export function parseStatusZ(out: string): Set<string> {
134 const paths = new Set<string>()
135 const entries = out.split('\0')
136 for (let i = 0; i < entries.length; i++) {
137 const entry = entries[i] ?? ''
138 if (entry.length < 4) continue
139 paths.add(entry.slice(3))
140 if (entry[0] === 'R' || entry[0] === 'C') paths.add(entries[++i] ?? '')
141 }
142 paths.delete('')
143 return paths
144}
145
146export function parseNamesZ(out: string): Set<string> {
147 return new Set(out.split('\0').filter(Boolean))
148}
149
150/** `ls-remote` → `{ ref: sha }`。 */
151export function parseLsRemote(out: string): Record<string, string> {
152 const found: Record<string, string> = {}
153 for (const line of out.split('\n')) {
154 const [sha, ref] = line.split('\t')
155 if (sha && ref && SHA_RE.test(sha)) found[ref] = sha
156 }
157 return found
158}
159
160// ── 步驟推導 ────────────────────────────────────────────────────────────────
161
162export type StepInput = {
163 /** git common dir(`.git`),沒有持有者時在這裡更新 ref */
164 common: string
165 params: Params
166 /** 目前 checkout 目標分支的 worktree;null 表示沒有 */
167 holder: string | null
168 /** 推導當下目標分支的 commit,作為 update-ref 的預期舊值 */
169 targetTip: string
170 /** 本機目標分支已含 sha */
171 landed: boolean
172 push: { remote: string; done: boolean; detail: string } | null
173}
174
175export function buildSteps(i: StepInput): Step[] {
176 const { branch, target, sha } = i.params
177 const land: string[] = i.holder
178 ? ['git', '-C', i.holder, 'merge', '--ff-only', sha]
179 : ['git', '-C', i.common, 'update-ref', '-m', `handoff: ${branch} → ${target}`, `refs/heads/${target}`, sha, i.targetTip]
180 const steps: Step[] = [
181 {
182 name: 'S1',
183 label: i.holder ? `${target} 快轉到 ${short(sha)}(在 ${i.holder})` : `${target} 快轉到 ${short(sha)}(只更新 ref)`,
184 argv: land,
185 done_already: i.landed,
186 detail: '',
187 },
188 ]
189 if (i.push) {
190 steps.push({
191 name: 'S2',
192 label: `push ${short(sha)} 到 ${i.push.remote}/${target}`,
193 argv: ['git', '-C', i.common, 'push', i.push.remote, `${sha}:refs/heads/${target}`],
194 done_already: i.push.done,
195 detail: i.push.detail,
196 })
197 }
198 return steps
199}
200
201export function fingerprint(steps: Step[]): string {
202 return JSON.stringify(steps.map(s => [s.name, s.argv]))
203}
204
205/** 執行步驟允許的形狀:`git -C <絕對路徑> {merge --ff-only|update-ref|push}`,不得帶強制選項。 */
206export function argvAllowed(argv: readonly string[]): boolean {
207 if (argv[0] !== 'git' || argv[1] !== '-C' || !argv[2]?.startsWith('/')) return false
208 const rest = argv.slice(3)
209 if (rest.some(a => a === '-f' || a.startsWith('--force') || a.startsWith('+') || a === '--delete' || a === '-d')) return false
210 if (rest[0] === 'merge') return rest[1] === '--ff-only' && rest.length === 3
211 if (rest[0] === 'update-ref') return rest.length === 6 && rest[1] === '-m' && rest[3]!.startsWith('refs/heads/')
212 if (rest[0] === 'push') return rest.length === 3 && /^[0-9a-f]+:refs\/heads\//.test(rest[2]!)
213 return false
214}
215
216/**
217 * 執行前的守門:按下時重新推導的結果能不能照畫面執行。
218 * 回 null 表示可以;否則回 `RunLog.aborted` 的值。
219 */
220export function preflight(shown: Rendered | undefined, fresh: Rendered): Aborted {
221 if (fresh.status === 'blocked') return 'blocked'
222 if (fresh.status !== 'ready') return 'stale'
223 if (!shown || shown.fingerprint !== fresh.fingerprint) return 'mismatch'
224 if (fresh.steps.some(s => !argvAllowed(s.argv))) return 'mismatch'
225 return null
226}
227
228export function shouldStop(log: StepLog): boolean {
229 return log.ran && log.exit_code !== 0
230}
231
232export type Exec = (argv: string[]) => Promise<{ exitCode: number | null; stderr: string }>
233
234/** 依序跑步驟:已完成者略過、失敗即停。exec 由呼叫端提供(唯一的執行點在 mod 的按鈕 handler)。 */
235export async function runSteps(steps: Step[], exec: Exec): Promise<StepLog[]> {
236 const logs: StepLog[] = []
237 for (const step of steps) {
238 if (step.done_already) {
239 logs.push({ name: step.name, ran: false })
240 continue
241 }
242 let log: StepLog
243 try {
244 const r = await exec(step.argv)
245 log = { name: step.name, ran: true, exit_code: r.exitCode, stderr: r.stderr }
246 } catch (err) {
247 log = { name: step.name, ran: true, exit_code: null, stderr: String(err) }
248 }
249 logs.push(log)
250 if (shouldStop(log)) break
251 }
252 return logs
253}
254
255export function tail(text: string): string {
256 const lines = text.split('\n').filter(l => l.trim())
257 return (lines[lines.length - 1] ?? '').slice(0, 200)
258}
259
260/** 執行紀錄+事後驗證 → 結果。 */
261export function summarize(log: RunLog, checks: Check[]): { outcome: Outcome; summary: string; steps: StepRow[] } {
262 const steps: StepRow[] = log.steps.map(s => ({
263 name: String(s.name),
264 ran: Boolean(s.ran),
265 exit_code: s.exit_code ?? null,
266 stderr_tail: tail(String(s.stderr ?? '')),
267 }))
268 const failed = steps.find(s => s.ran && s.exit_code !== 0)
269 const skipped = steps.filter(s => !s.ran).map(s => s.name)
270 if (log.aborted === 'stale' || log.aborted === 'mismatch') {
271 const why = log.aborted === 'mismatch' ? '(畫面與重新檢查的內容不同)' : ''
272 return { outcome: 'stale', summary: `✗ 事前檢查不再成立,未執行任何步驟${why}`, steps }
273 }
274 if (log.aborted === 'blocked') return { outcome: 'blocked', summary: '✗ 事前檢查擋下,未執行任何步驟', steps }
275 if (failed) {
276 return { outcome: 'failed', summary: `✗ ${failed.name} 失敗(exit ${failed.exit_code}):${failed.stderr_tail || '無 stderr'}`, steps }
277 }
278 const bad = checks.find(c => !c.ok)
279 if (bad) return { outcome: 'failed', summary: `✗ 驗證 ${bad.name} 未通過:${bad.detail}`, steps }
280 const total = steps.length + checks.length
281 return { outcome: 'passed', summary: `✓ ${total}/${total} 通過${skipped.length ? `(${skipped.join('、')} 已略過)` : ''}`, steps }
282}
283
284// ── 顯示 ────────────────────────────────────────────────────────────────────
285
286/** 待辦=尚未通過也未撤單的單子。 */
287export function pending(rows: TicketRow[] | null): TicketRow[] {
288 return (rows ?? []).filter(r => r.outcome !== 'passed' && r.outcome !== 'dismissed')
289}
290
291/** band 文字;null 表示不佔行。 */
292export function bandText(rows: TicketRow[] | null, failure: string | null): string | null {
293 if (failure !== null) return `⇄ handoff-runner 無法讀取交接單(${failure})`
294 const list = pending(rows)
295 if (list.length === 0) return null
296 const head = list[0]!.title
297 return list.length === 1 ? `⇄ 1 張交接單等你:${head} · /handoff` : `⇄ ${list.length} 張交接單等你:${head} 等 · /handoff`
298}
299
300export function stepLine(step: Step): string {
301 return `${step.done_already ? '略過' : '執行'} ${step.name} ${step.label}`
302}
303
304export function argvText(step: Step): string {
305 return step.argv.join(' ')
306}
307
308export function checkLine(c: Check): string {
309 return `${c.ok ? '✓' : '✗'} ${c.name} ${c.detail}`
310}
311types/index.d.ts 94 lines1// handoff-runner 的型別契約:交接單、結果檔的 JSON 形狀,與 $.state 的值。
2
3/** 交接單只帶這三個參數。沒有路徑、沒有指令:步驟由 mod 依參數推導。 */
4export type Params = {
5 /** 要落地的分支 */
6 branch: string
7 /** 目標分支(通常是 main) */
8 target: string
9 /** 簽發當下 branch 的 commit,落地只會把 target 快轉到這個 commit */
10 sha: string
11}
12
13export type Ticket = {
14 schema: 1
15 id: string
16 kind: 'ref-land'
17 created_at: string
18 issuer: { via: string }
19 params: Params
20}
21
22export type Check = { name: string; ok: boolean; detail: string }
23
24export type Step = {
25 name: string
26 label: string
27 argv: string[]
28 /** 已經是完成狀態,按下時略過 */
29 done_already: boolean
30 detail: string
31}
32
33export type Status = 'ready' | 'stale' | 'blocked' | 'invalid' | 'done'
34
35export type Outcome = 'passed' | 'failed' | 'stale' | 'blocked' | 'dismissed'
36
37export type StepRow = { name: string; ran: boolean; exit_code: number | null; stderr_tail: string }
38
39export type Result = {
40 schema: 1
41 id: string
42 outcome: Outcome
43 summary: string
44 finished_at: string
45 forced_stale_lock: boolean
46 steps: StepRow[]
47 checks: Check[]
48 history: string[]
49}
50
51export type Rendered = {
52 id: string
53 status: Status
54 title: string
55 prechecks: Check[]
56 steps: Step[]
57 /** 步驟名稱與 argv 的正規化字串;畫面與按下時重新推導的結果必須逐字相同 */
58 fingerprint: string
59 reason: string
60 last_result: Result | null
61}
62
63export type Aborted = null | 'stale' | 'blocked' | 'mismatch'
64
65export type StepLog = { name: string; ran: boolean; exit_code?: number | null; stderr?: string }
66
67export type RunLog = { aborted: Aborted; forced_stale_lock: boolean; steps: StepLog[] }
68
69export type TicketRow = {
70 id: string
71 title: string
72 created_at?: string
73 params?: Params
74 outcome?: Outcome | null
75 summary?: string | null
76 error?: string
77}
78
79/** 面板手動簽發的候選:被 worktree 持有、可快轉進目標、還沒有待辦單的分支 */
80export type Candidate = { branch: string; sha: string; target: string; worktree: string }
81
82declare module 'claude-code' {
83 interface PluginState {
84 'handoff-runner': {
85 tickets: TicketRow[] | null
86 failure: string | null
87 rendered: Record<string, Rendered>
88 running: Record<string, boolean>
89 candidates: Candidate[]
90 isPaneOpen: boolean
91 }
92 }
93}
94