SLOPSHOPPER

handoff-runner

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

newpanebandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · handoff-runner
│ ┃ handoff ✕ › 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 │ │ › /handoff │ ⎿ handoff-runner: handoff 面板已開啟。 │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · handoff
沒有待辦的交接單。 [ 重新檢查 ][ 關閉 ]
README

handoff-runner

讓 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、不給執行按鈕。
  • 步驟由 mod 推導:要跑的 git 指令在每次檢查時由程式依參數與 repo 的當下狀態產生,只會是 git merge --ff-only <sha>、git update-ref …、(開啟時)git push <remote> <sha>:refs/heads/<target> 三種形狀,執行前再對形狀做一次白名單檢查;不會 force、不會刪分支。
  • 唯一的執行入口是面板的「執行」按鈕。model 可呼叫的工具與 /handoff 指令只能簽發或開面板;mod 不送 prompt、不派 subagent、不呼叫 model。這條由 tests/boundary.mjs 的靜態檢查守著(附違規注入的 known-positive)。
  • 畫面即執行:按下時重新推導一次,與畫面上的步驟(名稱+完整 argv)逐字比對,不同就不執行並記為 stale。
  • 只快轉到釘住的 commit:簽發後分支又前進、或目標分支被別人推進,事前檢查就不成立,要重新簽發。
  • 檢查用的 git 只有唯讀子命令(rev-parse、merge-base、status、diff --name-only、worktree list、check-ref-format、ls-remote),由白名單把關。Claude Code 以 $.process.run 跑 git 時關閉 repo hooks。

使用

簽發

三種入口,效果相同(都只寫交接單,不執行):

入口誰用說明
工具 handoff_issueClaude參數 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):

欄位預設說明
pushfalse快轉後 push 目標分支。預設關閉:push 是對外的動作,各人的 remote 習慣不同
remoteoriginpush 用的 remote
defaultTargetmain簽發時沒指定目標就用它,也是面板候選清單的目標

限制

  • 只做一件事:把 branch 快轉進 target(可選 push)。不做 merge commit、rebase、刪分支、打 tag。
  • 交接單與面板以 session 所在的 repo 為範圍;不同 repo 各自一份。
  • 按鈕只出現在終端機與 Claude Desktop 的 Code 分頁;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。

English

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.

  • The ticket carries parameters only (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.
  • The only execution path is the pane's Run button. The model-callable tool handoff_issue and /handoff issue <branch> [target] only write tickets.
  • Before running: the branch tip still equals the pinned sha, the target can fast-forward to it, the worktree holding the target has no uncommitted changes overlapping the landing, and (with push on) the remote can fast-forward. On press the steps are re-derived and must match what was displayed. After running: the target contains the sha, the holder's HEAD followed, and (with push on) the remote has it.
  • Tickets live in <git common dir>/handoffs/, shared by all worktrees and never committed. Push is off by default (userConfig.push).
Source 4 files
hooks/register.tsx 350 lines
1// 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}
350
hooks/git.ts 438 lines
1// 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}
438
hooks/logic.ts 311 lines
1// 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}
311
types/index.d.ts 94 lines
1// 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