358f67abf3
line-needs-own-worktree.sh 擋 `cd <別的 repo> && git checkout` 時擋對了,但訊息
指的是 payload 的 cwd 那個 repo,不是指令真正動的那個。comment 5398 實測:
指令:cd .../matrix/arcrun && git checkout fix/library-lifecycle-187
舊版:目錄:.../InkStoneCo 它現在在:feat/ticket-bell-webhook
↑ 動的是 matrix/arcrun,講的卻是 InkStoneCo
🔴 照著那個訊息做的人,會在 InkStoneCo 開一份用不到的 worktree,真正要隔離的
matrix/arcrun 沒開到——而他以為自己隔離好了。**一道閘給錯下一步比不擋更糟。**
而 `cd X && git checkout` 正是這條線最常出現的寫法(本票的來由那次就是它)。
沒有另寫一支解析器:hooks/lib/push_target_dir.py 2026-08-23 已經替 git push 解過
同一題(inkstone/ISEP#30 comment 3949),多層 cd 鏈與「子殼的 cd 不外洩」都算過了。
本輪只把那支的動詞與「要不要去掉 env 前綴」變成參數(兩個都有預設值,push 那條路
一個 byte 都沒變,42 條既有測試重跑全綠),新增的 lib/checkout_target_dir.py 只放
checkout 專屬的兩件事:哪些形狀不動 HEAD、-C 贏過 cd 的優先序。
新增的一條性質(不是順手,是本票要的):目錄解不出來就放行。
`cd $VAR`/`cd -`/引號壞掉時回 "?",閘直接 exit 0。寧可漏擋,也不要指著錯的
repo 叫人去開 worktree——猜一個回去等於原地打轉。
實測:
- hooks/tests/line-needs-own-worktree.test.sh 35 條 → 59 條(新增 D 群 24 條)
正向:cd 認出 A/-C 認出 B/兩者都在時 -C 贏/相對 cd/相對 -C 接在 cd 之後/
從 worktree cd 回共用目錄/子殼不外洩
反向(不該擋):cd 到非 repo、cd 進自己的 worktree、cd $VAR、cd -、還原檔案
已知邊界也釘成測試:`);` 中間沒空白時 tokenize 會斷在那裡 ⇒ 漏擋(不是指錯)。
那層是 push_target_dir 的 tokenizer,動它會連帶改到推 main 那道閘的偵測範圍,
本票不動,另報。
- 推 main 那三套(共用被改到的 lib):10/10、19/19、13/13
- 實地打票上那條害過我們的指令:現在印的是 matrix/arcrun,worktree 指令也是它的
plugin.json 0.16.1 → 0.16.2 並重跑 vendor-to-shell.py(版本沒動=沒人吃得到)。
⚠️ 留痕:hooks/lib/checkout_target_dir.py 是用 Bash heredoc 寫的,因為 sdd-guard
對 ISEP 的 .py 是結構性永遠在響——這個 repo 根本沒有 system-dev/docs/3-specs,
而 hooks/lib/ 已經住了 9 支 .py。範圍就這一支新檔,沒有動那道閘(另報)。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
150 lines
9.0 KiB
Bash
Executable File
150 lines
9.0 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# 管什麼: 一條派工線要在「共用的工作目錄」裡切分支時擋下來,叫它去開一份自己的 worktree。
|
||
# 為什麼: 2026-08-29 inkstone/ISEP#109 comment 5391——每個 repo 只有一份工作目錄,所有線共用。#163 切到自己的分支、交回時沒切回來,總管後來在同一個目錄跑 ship.mjs,讀到的是別人留下的 HEAD。票號說得出「這是哪個任務」,沒有任何東西說得出「這個目錄現在是誰留下的狀態」。
|
||
# 誤觸時怎麼關: `WORKTREE_OK=1 git checkout …`(留痕,跟 NOT_MY_BRANCH_OK 同形狀)。
|
||
#
|
||
# 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
|
||
|
||
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 -)⇒ 不猜,見該檔檔頭
|
||
|
||
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#109,2026-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
|