Files
ISEP/hooks/line-needs-own-worktree.sh
T
isep-hand 7cacfdcf11 一個 repo 只有一個資料夾:分身住 repo 裡面的 .worktrees/,收工由同一支閘收掉(inkstone/ISEP#147)
leo 2026-09-07:「一個 repo 只看得到一個資料夾」「禁止這樣產出一大堆資料夾」。
根因:line-needs-own-worktree.sh 教工人開在 repo 旁邊,「收工 remove」只寫在閘訊息裡、沒機制驗,
每條線留一份 ⇒ tech_projects/ 長出 ISEP-wt-117/InkStoneCo-wt-112…一排。規則在文字裡不在機器上。

- scripts/worktree(新):open/close/list/sweep 四個動詞。分身住 <repo>/.worktrees/<票 repo>-<N>
  (走 .git/info/exclude,不動被追蹤的檔);「推了沒」只有這一份判準(三態:有/沒有/問不到,
  問不到不當成沒推也不收);sweep 預設只列不動,--apply 只收「推了且乾淨」,--skip 是操作者點名。
- hooks/line-needs-own-worktree.sh:擋下時印 `scripts/worktree open`(絕對路徑,貼了就跑);
  同一支多掛 PostToolUse Agent|Task——從【工單】認出分身,推了收、沒推點名分支+路徑(exit 2),
  不 force、不刪。+0 支、+1 條(61 支/86 條,當場數的)。
- docs/governance/worktree-placement.md(新)+ principles 一行:禁令與閘互指。
- 測試:line-needs-own-worktree.test.sh 77→110(G 群:閘印的 open 那行原樣跑、旁邊不多任何東西;
  H 群:PostToolUse 推了收/沒推點名/安靜的方向);scripts/test-worktree.sh 42 條(七個分身的 fixture,
  名字照 leo 看到的那排)。舊閘跑新測試 90/110,紅的 20 條正是這次改到的行為。
- permissions-allow.json 加 worktree 四種形狀(出路在雲端被分類器擋就走不通)。
- README/plugin.json/hooks-inventory 的數字在這棵樹上實數:61 支、86 條、62 支腳本。

待總管定版(改了會被載入的東西)。沒碰 hooks/lib/mainline.py。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0178ef1fGw3XeZtpN7LaZrm4
2026-09-07 07:13:21 +00:00

243 lines
15 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# 管什麼: 一條派工線要在「共用的工作目錄」裡切分支時擋下來,叫它去開一份自己的 worktree——開在 repo **裡面**`.worktrees/`),不開在旁邊;同一支閘在那條線收工那一刻(PostToolUse Agent|Task)把分身收掉:分支推了就 remove,沒推就點名分支與路徑,不靜默留著。
# 為什麼: 2026-08-29 inkstone/ISEP#109 comment 5391——每個 repo 只有一份工作目錄,所有線共用。#163 切到自己的分支、交回時沒切回來,總管後來在同一個目錄跑 ship.mjs,讀到的是別人留下的 HEAD。票號說得出「這是哪個任務」,沒有任何東西說得出「這個目錄現在是誰留下的狀態」。
# 🔴 2026-09-07 inkstone/ISEP#147:第一版教工人「開在 repo 旁邊、收工 worktree remove」,而 remove 那一步只寫在閘訊息裡、沒有任何機制驗 ⇒ 每個工人留一份,leo 在 Finder 看到 `ISEP-wt-117``ISEP-wt115``InkStoneCo-wt-112``course_gen-ticket-3-notes`…一排同頭不同尾的分身。leo:「一個 repo 只看得到一個資料夾」「禁止這樣產出一大堆資料夾」。規則在文字裡不在機器上——所以現在兩端都在機器上:開在裡面(Finder 看不到),收在 PostToolUse(忘了也會被收)。工具=scripts/worktreeopenclosesweep 同一份「推了沒」判準);規約=docs/governance/worktree-placement.md。
# 誤觸時怎麼關: `WORKTREE_OK=1 git checkout …`——認的是**指令字串裡**這個字面前綴(留痕就在指令歷史上)。
# 🔴 2026-09-07inkstone/ISEP#109 → comment 6587):舊版讀的是 hook 自己的環境變數 `$WORKTREE_OK`
# 而 PreToolUse hook 跟指令不是同一個行程——指令前綴的賦值到不了它 ⇒ 閘印出來的出路在 Claude Code
# 底下永遠走不通(總管三次照貼三次被擋)。現在兩種都認:環境變數(本機 shell export 過)與字面前綴
# (閘訊息教的那一行)。判準是位置不是字:前綴必須掛在會移動 HEAD 的那條 git 指令上,見
# lib/checkout_target_dir.py 檔頭;測試 E 群把閘印出來的那一行原樣餵回去驗它走得通。
#
# line-needs-own-worktree.sh — 共用工作目錄裡不准切分支(PreToolUse: Bash
#
# ── 判準:git 自己回答的兩個問題,不看任何措辭 ────────────────────────
# ① 這條命令會不會**移動 HEAD**?(checkout / switch,含 -b / -c
# —— 動作有限且可枚舉。`git checkout -- <檔>``git restore` 是還原檔案,不動 HEAD,放行。
# ② 它要動的那個目錄,是不是那個 repo 的**主工作目錄**(=大家共用的那一份)?
# git rev-parse --git-dir == git rev-parse --git-common-dir ⇒ 主工作目錄(共用)
# 兩者不同 ⇒ 這已經是一份 worktree(自己的)
# —— 這是 git 自己算出來的事實,**不需要任何人去登記**。
#
# ①②都成立、而且送出命令的是一條線(`CLAUDE_CODE_CHILD_SESSION=1`)⇒ 擋。
#
# ── 為什麼是「線」而不是所有人(這不是總管例外)──────────────────────
# 共用工作目錄**就是總管那一份**——他在自己的目錄裡切分支不會偷走別人的狀態。
# 會出事的是**線把總管的目錄留在自己的分支上**(08-29 實錄就是這個形狀)。
# ⇒ 規則不是「誰比較大」,是「**誰的目錄**」。線要動工,就去開一份自己的。
#
# ── 為什麼不靠 `.claude/branch-holds.md`(驗收條件 6 的原話:不靠人記得)─────
# `not-my-branch-guard.sh` 守的是「**別 commit 到別人登記過的分支**」——
# ① 只認 `git commit`、② 只看頂層 repo、③ **要有人先手動登記**。
# 而 08-29 出事的是子 repo`products/arcrun-rag`、`matrix/arcrun`)、動作是 `checkout`、
# 而且沒有人登記過。**兩支守的不是同一件事,這支不動那支。**
#
# ── worktree 開在哪:repo **裡面**的 `.worktrees/`,不在旁邊、不在 /tmp ─────────
# /tmp2026-08-29 實測):
# $ git -C products/arcrun-rag worktree list
# /private/tmp/.../scratchpad/wt-180 … prunable ← 目錄被系統清掉了
# /private/tmp/wt167src … prunable
# ISEP 同一天有 **12 筆 prunable**,全部在 /private/tmp 底下。
# ⇒ 開在 `/tmp` 的會被系統清掉,而**登記簿還留著** ⇒ 登記簿說謊。
# 旁邊(2026-09-07 inkstone/ISEP#147 實錄):一份都沒掉——**也一份都沒收**。
# tech_projects/ 底下躺著 ISEP-wt-122ship、ISEP-wt-117、ISEP-wt115、InkStoneCo-wt-112…
# leo 在 Finder 看到的是「一個 repo 長出一排同頭不同尾的分身」。
# ⇒ 住 `<repo>/.worktrees/<票 repo>-<票號>`Finder 看不到(點開頭)、`git status` 看不到
# `scripts/worktree open` 寫進 `.git/info/exclude`,不動被追蹤的檔)、
# `git worktree list` 照樣列得到(收工那一半靠它找)。
#
# ── 收工那一半:同一支閘掛在 PostToolUse(Agent|Task)inkstone/ISEP#147)───────
# 派工單裡的 `【工單】owner/repo#N` 就是分身的名字(`.worktrees/<repo>-<N>`),
# 跟 baton-handback-guard.shissue-status-autoflip.sh 同一個位置、同一種抓法。
# 線收工那一刻:`scripts/worktree close <票>` ——
# 推了且乾淨 ⇒ removeprune,安靜。
# 沒推/有未 commit/問不到遠端 ⇒ exit 2 點名分支與路徑,並印推的指令。**不 force、不刪。**
# 為什麼不掛 SubagentStop:那裡拿不到票號(見 baton-handback-guard.sh 檔頭)。
# 為什麼不另開一支閘:教你開的和替你收的是同一件事的兩半,拆成兩支就又是「規則在兩個地方」。
#
# ── 順手把說謊的登記清掉(驗收條件 7:prune 是收工動作,不是偶爾整理)───────
# 擋下來的同時對那個 repo 跑一次 `git worktree prune`,並把清掉幾筆講出來。
# prune **只刪「目錄已經不存在」的登記**,不碰任何還在的 worktree、不碰任何檔案。
#
# ── 誠實邊界 ────────────────────────────────────────────────────
# 1. 它只擋 `checkout``switch`。**已經切過去之後**的 commit/build 這支看不到
# (那是別的閘的地盤)。它守的是「別把共用目錄帶離原位」這一個動作。
# 2. 它不知道那個 repo 現在有沒有別條線在動——**不知道就一律隔離**,
# 因為「現在剛好沒人」是會變的,而分支留在那裡是不會自己回去的。
# 3. `git worktree add` 之後那份 worktree 裡的 checkout 全部放行(判準 ②)。
# 4. **目錄解不出來就放行**`cd $VAR && git checkout`、`cd -`、引號壞掉)。
# 不是漏了,是選的:猜錯會指著別的 repo 叫人去開 worktree,而他會照做、
# 然後以為自己隔離好了。**寧可漏擋,也不要給錯的下一步**(comment 5398)。
set -eu
PAYLOAD=$(cat 2>/dev/null || echo '{}')
# ── 收工那一半(PostToolUse Agent|Task):先於任何環境變數判斷——這裡跑的是總管的 session ──
EVENT=$(printf '%s' "$PAYLOAD" | python3 -c '
import json, sys
try:
d = json.load(sys.stdin)
except Exception:
raise SystemExit
sys.stdout.write(str(d.get("hook_event_name") or ""))
' 2>/dev/null) || EVENT=""
if [ "${EVENT:-}" = "PostToolUse" ]; then
SPEC=$(printf '%s' "$PAYLOAD" | python3 -c '
import json, re, sys
try:
d = json.load(sys.stdin)
except Exception:
raise SystemExit
if d.get("tool_name") not in ("Agent", "Task"):
raise SystemExit
p = (d.get("tool_input") or {}).get("prompt") or ""
seen = []
for line in p.splitlines():
if "【工單】" not in line:
continue
for m in re.finditer(r"([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+)#(\d+)", line):
t = "%s/%s#%s" % m.groups()
if t not in seen:
seen.append(t)
sys.stdout.write(" ".join(seen))
' 2>/dev/null) || SPEC=""
[ -n "${SPEC:-}" ] || exit 0
WT_TOOL="$(cd "$(dirname "$0")/.." && pwd)/scripts/worktree"
if [ ! -f "$WT_TOOL" ]; then
echo "⚠️ line-needs-own-worktree:找不到 scripts/worktree,這條線的分身這一次沒有收。" >&2
exit 0
fi
ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
# 掃哪裡:專案根、它的上一層(Mactech_projects/ 底下 ISEP 與 InkStoneCo 是兄弟)、
# 雲端薄殼底下的真身(子 repo 在 InkStoneCo/matrix|products|polaris 底下,工具自己會往下找一層)。
SCAN="--scan $ROOT --scan $(dirname "$ROOT")"
[ -d "$ROOT/InkStoneCo" ] && SCAN="$SCAN --scan $ROOT/InkStoneCo"
RC=0
for T in $SPEC; do
rc=0
# shellcheck disable=SC2086
OUT=$(python3 "$WT_TOOL" close "$T" $SCAN --quiet 2>&1 >/dev/null) || rc=$?
[ -n "${OUT:-}" ] && printf '%s\n' "$OUT" | sed 's/^/ /' >&2
[ "$rc" -eq 2 ] && RC=2
done
[ "$RC" -eq 2 ] || exit 0
cat >&2 <<'EOF'
🌳 這條線收工了,但它的分身還留在 repo 裡(上面點名的那幾份)。
不會靜默留著、也不會替它刪沒推的東西——推了再收,或確認作廢後自己刪。
規約:docs/governance/worktree-placement.mdinkstone/ISEP#147
EOF
exit 2
fi
[ "${WORKTREE_OK:-}" = "1" ] && exit 0
[ "${CLAUDE_CODE_CHILD_SESSION:-}" = "1" ] || exit 0 # 總管在自己的目錄裡,不管
# 這條指令會動到哪個目錄的 HEAD——**不是問「hook 站在哪」**。
# 2026-08-29 實測(inkstone/ISEP#109 → comment 5398):`cd <別的 repo> && git checkout`
# 舊版拿 payload 的 cwd 當答案,於是擋對了、卻**指著另一個 repo 叫人去開 worktree**。
# 照著做的人會在錯的 repo 開一份用不到的,真正要隔離的那個沒開到,而他以為隔離好了。
# 🔴 一道閘給錯下一步,比不擋更糟。解析住在 lib/checkout_target_dir.py(純 tokenize
# 不執行任何指令;cd 鏈、子殼不外洩、-C 贏過 cd 的優先序都在那裡,理由見該檔檔頭)。
CMD=$(printf '%s' "$PAYLOAD" | python3 -c '
import json, sys
try:
d = json.load(sys.stdin)
except Exception:
raise SystemExit
ti = d.get("tool_input") or {}
sys.stdout.write((ti.get("command") or "") if isinstance(ti, dict) else "")
' 2>/dev/null) || exit 0
[ -n "${CMD:-}" ] || exit 0
# heredoc 的 body 是資料不是指令(inkstone/ISEP#125 缺陷 DE2026-09-02 兩次實地命中):
# 用 heredoc 寫一份「內含 `cd <目錄> && git checkout X` 示範」的說明文件,整條指令沒有任何
# git 操作,卻被當成在切分支擋下——被擋的是「寫文件」這個動作。跟 github-contact-guard 一樣
# 先把 body 剝掉再解析;剝不動就用原字串(fail-open 的是「這支 helper 有沒有跑」,不是判準)。
CMD=$(printf '%s' "$CMD" | python3 "$(dirname "$0")/lib/strip_heredoc.py" 2>/dev/null || printf '%s' "$CMD")
[ -n "${CMD:-}" ] || exit 0
CWD=$(printf '%s' "$PAYLOAD" | python3 -c '
import json, sys
try:
d = json.load(sys.stdin)
except Exception:
raise SystemExit
sys.stdout.write(d.get("cwd") or "")
' 2>/dev/null) || exit 0
[ -n "${CWD:-}" ] && [ -d "$CWD" ] || CWD=$PWD
RESOLVER="$(dirname "$0")/lib/checkout_target_dir.py"
if [ ! -f "$RESOLVER" ]; then
# 解析器不見了=安裝壞了。**不靜默**:講出來再放行,因為這時候唯一能給的
# 「下一步」就是錯的那一個(拿 cwd 當答案),而那正是本輪修掉的病。
echo "⚠️ line-needs-own-worktree:找不到 lib/checkout_target_dir.py,這一次沒有檢查。" >&2
exit 0
fi
EXPR=$(printf '%s' "$CMD" | python3 "$RESOLVER" 2>/dev/null) || exit 0
[ -n "${EXPR:-}" ] || exit 0 # 沒有會移動 HEAD 的 checkout/switch
[ "$EXPR" = "?" ] && exit 0 # 目錄解不出來(cd $VAR / cd -)⇒ 不猜,見該檔檔頭
# 出路:那條 checkout 自己帶著 `WORKTREE_OK=1` 前綴 ⇒ 放行(comment 6587)。
# 看的是指令字串裡的位置,不是 hook 的環境——hook 的環境裡永遠不會有它。
ESC=$(printf '%s' "$CMD" | python3 "$RESOLVER" --escape WORKTREE_OK=1 2>/dev/null) || ESC=""
[ "${ESC:-}" = "1" ] && exit 0
TARGET=$(cd "$CWD" 2>/dev/null && cd "$EXPR" 2>/dev/null && pwd) || exit 0
[ -n "${TARGET:-}" ] || exit 0
# 那個目錄是不是 repo 的**主工作目錄**(=共用的那一份)——git 自己回答
GD=$(git -C "$TARGET" rev-parse --git-dir 2>/dev/null) || exit 0
CD=$(git -C "$TARGET" rev-parse --git-common-dir 2>/dev/null) || exit 0
ABS() { (cd "$TARGET" 2>/dev/null && cd "$1" 2>/dev/null && pwd) || printf '%s' "$1"; }
[ "$(ABS "$GD")" = "$(ABS "$CD")" ] || exit 0 # 已經在自己的 worktree 裡 ⇒ 放行
TOP=$(git -C "$TARGET" rev-parse --show-toplevel 2>/dev/null) || exit 0
NAME=$(basename "$TOP")
PARENT=$(dirname "$TOP")
# 印給人複製的指令用工具的絕對路徑——相對路徑會對貼的人的 cwd 解析(mistakes.md「出路跑不動」)
WT_TOOL="$(cd "$(dirname "$0")/.." && pwd)/scripts/worktree"
NOW=$(git -C "$TOP" rev-parse --abbrev-ref HEAD 2>/dev/null || echo '?')
# 驗收條件 7:說謊的登記順手清掉(只刪目錄已經不存在的那些)
BEFORE=$(git -C "$TOP" worktree list 2>/dev/null | grep -c 'prunable' || true)
git -C "$TOP" worktree prune 2>/dev/null || true
PRUNED=""
[ "${BEFORE:-0}" -gt 0 ] && PRUNED="
🧹 順手清掉 $BEFORE 筆**指向空氣**的 worktree 登記(目錄早就被清掉、登記還留著)。
只刪登記,沒有動任何還在的 worktree、沒有動任何檔案。"
cat >&2 <<EOF
🌳 這是**共用的工作目錄**,不要在這裡切分支。
目錄:$TOP
它現在在:$NOW
$PRUNED
【為什麼(inkstone/ISEP#1092026-08-29 實錄)】
每個 repo 只有**一份**工作目錄,所有線共用。那天 arcrun-rag#163 切到自己的分支、
交回時沒切回來;總管後來在同一個目錄跑 ship.mjs,**讀到的是別人留下的 HEAD**。
🔴 票號說得出「這是哪個任務」,**沒有任何東西說得出「這個目錄現在是誰留下的狀態」**。
(那次沒出錯貨——是文案契約閘擋下來的。閘有效,但那是最後一道。)
【現在怎麼做】開一份自己的——住在 repo **裡面**的 .worktrees/,然後在裡面做事:
python3 $WT_TOOL open <owner/repo#票號> --repo $TOP -b <你的分支>
cd $TOP/.worktrees/<票的 repo 名>-<票號>
🔴 **不要開在 /tmp 底下**08-29 實測:$NAME 指向 /private/tmp 的登記全部 prunable),
**也不要開在 repo 旁邊**09-07 實錄:$PARENT/ 底下躺了一排 $NAME-wt-…,
leo 在 Finder 看到「一個 repo 一排分身」——inkstone/ISEP#147,禁止)。
裡面的那份 Finder 看不到、git status 看不到,git worktree list 找得到。
收工時(推了會收掉;沒推會點名,不會靜默留著;你忘了,線收工那一刻這支閘也會替你收):
python3 $WT_TOOL close <owner/repo#票號> --repo $TOP
規約:docs/governance/worktree-placement.md
【真的要在共用目錄裡切(接手、緊急止血)】
WORKTREE_OK=1 git -C $TOP checkout <分支>
留痕在指令歷史上,而且**切完要切回去**——$NOW 是你進來時的樣子。
EOF
exit 2