Files
ISEP/hooks/line-needs-own-worktree.sh
T
isep-hand 95c6fee199 worktree 閘:heredoc 的 body 是資料不是指令,並把 inkstone/ISEP#125 的驗收表 A–F 寫成測試
#125 缺陷 D/E(09-02 兩次實地命中):用 heredoc 寫一份內含 `cd <目錄> && git checkout X` 示範的
說明文件,整條指令沒有 git 操作卻被擋。沿用 lib/strip_heredoc.py(D20 閘同一支)先剝 body 再解析。
A–F 六條照票上期望值進 hooks/tests/(A–E 0、F 2)。
(76) 把 tokenize 層「換行被當空白吞掉」寫成已知邊界測試(舊閘同樣 exit=0,漏擋不是誤攔,與 (53) 同層,另報)。
A24:69→77。

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

167 lines
11 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。
# 為什麼: 2026-08-29 inkstone/ISEP#109 comment 5391——每個 repo 只有一份工作目錄,所有線共用。#163 切到自己的分支、交回時沒切回來,總管後來在同一個目錄跑 ship.mjs,讀到的是別人留下的 HEAD。票號說得出「這是哪個任務」,沒有任何東西說得出「這個目錄現在是誰留下的狀態」。
# 誤觸時怎麼關: `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 的旁邊,不要開在 /tmp(2026-08-29 實測)──────
# $ git -C products/arcrun-rag worktree list
# /private/tmp/.../scratchpad/wt-180 … prunable ← 目錄被系統清掉了
# /private/tmp/.../scratchpad/wt164 … prunable ← 同上
# /private/tmp/wt167src … prunable
# products/arcrun-rag-wt88 … 完好
# products/arcrun-rag-wt104ingest … 完好
# ISEP 同一天有 **12 筆 prunable**,全部在 /private/tmp 底下。
# ⇒ 開在 `/tmp` 的會被系統清掉,而**登記簿還留著** ⇒ 登記簿說謊。
# 開在 repo 旁邊的那幾份,一份都沒掉。
#
# ── 順手把說謊的登記清掉(驗收條件 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
[ "${WORKTREE_OK:-}" = "1" ] && exit 0
[ "${CLAUDE_CODE_CHILD_SESSION:-}" = "1" ] || exit 0 # 總管在自己的目錄裡,不管
PAYLOAD=$(cat 2>/dev/null || echo '{}')
# 這條指令會動到哪個目錄的 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")
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**。
🔴 票號說得出「這是哪個任務」,**沒有任何東西說得出「這個目錄現在是誰留下的狀態」**。
(那次沒出錯貨——是文案契約閘擋下來的。閘有效,但那是最後一道。)
【現在怎麼做】開一份自己的,然後在裡面做事:
git -C $TOP worktree add $PARENT/$NAME-wt-<票號> -b <你的分支>
cd $PARENT/$NAME-wt-<票號>
🔴 **不要開在 /tmp 底下。** 同一天實測:$NAME 的登記簿裡指向 /private/tmp 的
那幾筆全部 prunable(目錄被系統清掉、登記還在),而開在 repo 旁邊的一份都沒掉。
收工時:
git -C $TOP worktree remove $PARENT/$NAME-wt-<票號>
git -C $TOP worktree prune
【真的要在共用目錄裡切(接手、緊急止血)】
WORKTREE_OK=1 git -C $TOP checkout <分支>
留痕在指令歷史上,而且**切完要切回去**——$NOW 是你進來時的樣子。
EOF
exit 2