Files
ISEP/hooks/tests/line-needs-own-worktree.test.sh
T
Leo 358f67abf3 閘認得出「動的是哪個 repo」,不再拿 cwd 當答案(inkstone/ISEP#109 → comment 5398)
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>
2026-08-29 21:31:42 +08:00

220 lines
12 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
# line-needs-own-worktree.sh 的迴歸測試(inkstone/ISEP#109 → comment 5391)。
#
# **離線、不打網路、不花錢、每次結果一樣**——判準只有兩個,兩個都是 git 自己回答的:
# ①這條命令會不會移動 HEAD ②那個目錄是不是主工作目錄。所以不需要語意判官。
#
# 每個案例都自己開一顆真的 git repo(外加一份真的 linked worktree)。
#
# 三群,兩個方向都要有證據:
# A 群「不該擋」——總管、唯讀動作、還原檔案、已經在自己的 worktree 裡、逃生門、
# worktree add 本身(**這群紅了比漏擋嚴重**:擋掉它等於線動不了工)
# B 群「該擋」 ——線在共用目錄裡 checkoutswitch-b,含 -C 與複合命令
# C 群「訊息與副作用」——講得出目錄與指令、不要開在 /tmp、逃生門、
# 以及**真的把說謊的登記清掉**(驗收條件 7)
#
# 用法:hooks/tests/line-needs-own-worktree.test.sh [hook 路徑]
set -u
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
HOOK="${1:-$(cd "$HERE/.." && pwd)/line-needs-own-worktree.sh}"
PASS=0; FAIL=0; N=0
ROOT=$(mktemp -d "${TMPDIR:-/tmp}/isep-wt.XXXXXX")
clean() { rm -rf "$ROOT"; }
trap clean EXIT
# 🔴 macOS 的 $TMPDIR 是 /var/folders/… 而 git 回的是解析過的 /private/var/…
# 測試比對前先把路徑解析掉,不然會拿「同一個目錄的兩種寫法」互比而假紅。
ROOT=$(cd "$ROOT" && pwd -P)
REPO="$ROOT/shared-repo"
mkdir -p "$REPO"
git -C "$REPO" init -q -b main
git -C "$REPO" config user.email t@t; git -C "$REPO" config user.name t
echo hi > "$REPO/a.txt"; git -C "$REPO" add -A; git -C "$REPO" commit -qm init
git -C "$REPO" worktree add -q "$ROOT/own-wt" -b mine
OWN="$ROOT/own-wt"
PLAIN="$ROOT/not-a-repo"; mkdir -p "$PLAIN"
# 第二顆 repo:D 群要驗「認出的是哪一個」,一顆 repo 分不出對錯
REPO2="$ROOT/other-repo"
mkdir -p "$REPO2"
git -C "$REPO2" init -q -b main
git -C "$REPO2" config user.email t@t; git -C "$REPO2" config user.name t
echo hi > "$REPO2/b.txt"; git -C "$REPO2" add -A; git -C "$REPO2" commit -qm init
# payload <command> [cwd]
payload() {
python3 - "$1" "${2:-$REPO}" <<'PY'
import json, sys
print(json.dumps({"session_id": "S-WT", "hook_event_name": "PreToolUse", "cwd": sys.argv[2],
"tool_name": "Bash", "tool_input": {"command": sys.argv[1]}},
ensure_ascii=False))
PY
}
# t <期望 exit> <說明> <command> [cwd] [env 前綴]
t() {
local want="$1" desc="$2" cmd="$3" cwd="${4:-$REPO}" envs="${5:-CLAUDE_CODE_CHILD_SESSION=1}"
N=$((N+1))
local err rc
err=$(printf '%s' "$(payload "$cmd" "$cwd")" | env $envs bash "$HOOK" 2>&1 >/dev/null); rc=$?
LAST_ERR="$err"
if [ "$rc" -eq "$want" ]; then
printf ' ✅ %s\n' "$desc"; PASS=$((PASS+1))
else
printf ' ❌ %s —— 期望 exit=%s,實得 exit=%s\n' "$desc" "$want" "$rc"
printf '%s\n' "$err" | sed -n '1,8p' | sed 's/^/ /'
FAIL=$((FAIL+1))
fi
}
has() { N=$((N+1)); if printf '%s' "$LAST_ERR" | grep -qF -- "$1"; then printf ' ✅ %s\n' "$2"; PASS=$((PASS+1)); else printf ' ❌ %s —— 找不到「%s」\n' "$2" "$1"; FAIL=$((FAIL+1)); fi; }
hasnt() { N=$((N+1)); if printf '%s' "$LAST_ERR" | grep -qF -- "$1"; then printf ' ❌ %s —— 出現了「%s」\n' "$2" "$1"; FAIL=$((FAIL+1)); else printf ' ✅ %s\n' "$2"; PASS=$((PASS+1)); fi; }
echo
echo "── A 群:不該擋(誤攔比漏擋嚴重——擋錯了線就動不了工)──"
t 0 "① 總管(不是線)在共用目錄切分支 ⇒ 不管(那是他自己的目錄)" \
"git checkout -b feat/x" "$REPO" "CLAUDE_CODE_CHILD_SESSION="
t 0 "② git status ⇒ 唯讀,不管" "git status"
t 0 "③ git log --oneline ⇒ 唯讀,不管" "git log --oneline -5"
t 0 "④ git branch -a ⇒ 唯讀,不管" "git branch -a"
t 0 "⑤ git checkout -- a.txt(還原檔案,不動 HEAD)⇒ 放行" "git checkout -- a.txt"
t 0 "⑥ git checkout HEAD -- a.txt ⇒ 放行" "git checkout HEAD -- a.txt"
t 0 "⑦ git restore a.txt ⇒ 放行(根本不是 checkout" "git restore a.txt"
t 0 "⑧ git checkout -p ⇒ 互動式挑 hunk,不動 HEAD,放行" "git checkout -p"
t 0 "⑨ **已經在自己的 worktree 裡**切分支 ⇒ 放行(這是我們要它做的事)" \
"git checkout -b feat/inside" "$OWN"
t 0 "⑩ git worktree add ⇒ 放行(那是解法本身,不是病)" \
"git worktree add $ROOT/wt2 -b feat/y"
t 0 "⑪ 逃生門 WORKTREE_OK=1 ⇒ 放行" \
"git checkout -b feat/z" "$REPO" "CLAUDE_CODE_CHILD_SESSION=1 WORKTREE_OK=1"
t 0 "⑫ 不是 git 命令 ⇒ 不管" "npm test"
t 0 "⑬ 目錄根本不是 git repo ⇒ 不管" "git checkout -b feat/x" "$PLAIN"
t 0 "⑭ 命令裡有 checkout 這個字但不是 git 的(不看措辭,看動作)" \
"echo '別忘了 git checkout 回 main'"
t 0 "⑮ payload 壞掉 ⇒ 放行(不擋在不確定上)" ""
N=$((N+1))
brc=$(printf 'not json' | CLAUDE_CODE_CHILD_SESSION=1 bash "$HOOK" >/dev/null 2>&1; echo $?)
if [ "$brc" -eq 0 ]; then printf ' ✅ ⑯ 完全不是 JSON 的 payload ⇒ 放行\n'; PASS=$((PASS+1));
else printf ' ❌ ⑯ payload 壞掉卻擋下來了(exit=%s\n' "$brc"; FAIL=$((FAIL+1)); fi
echo
echo "── B 群:該擋 ──"
t 2 "⑰ 線在共用目錄 git checkout -b ⇒ 擋" "git checkout -b feat/new"
t 2 "⑱ 線在共用目錄 git switch -c ⇒ 擋" "git switch -c feat/new2"
t 2 "⑲ 線在共用目錄 git checkout main ⇒ 擋(切回去也是切)" "git checkout main"
t 2 "⑳ git -C <共用目錄> checkout(人在別的地方)⇒ 擋" "git -C $REPO checkout -b feat/c" "$PLAIN"
t 2 "㉑ 複合命令 cd … && git checkout ⇒ 擋(一段一段拆)" "cd $REPO && git checkout -b feat/d"
t 2 "㉒ 前面掛 env 的 git checkout ⇒ 擋" "FOO=1 git checkout -b feat/e"
t 2 "㉓ git switch main ⇒ 擋" "git switch main"
echo
echo "── C 群:訊息與副作用 ──"
t 2 "㉔ 擋下來(下面幾條檢查這一次的訊息)" "git checkout -b feat/msg"
has "$REPO" "㉕ 說得出是哪個目錄"
has "worktree add" "㉖ 給得出開自己 worktree 的指令"
has "不要開在 /tmp" "㉗ 講得出不要開在 /tmp08-29 實測:那些全 prunable"
has "WORKTREE_OK=1" "㉘ 講得出逃生門"
has "worktree prune" "㉙ 講得出收工要怎麼收"
hasnt '$(' "㉚ 訊息沒有被 shell 展開"
# 驗收條件 7:說謊的登記真的被清掉
git -C "$REPO" worktree add -q "$ROOT/ghost" -b ghosted
rm -rf "$ROOT/ghost"
N=$((N+1))
if git -C "$REPO" worktree list 2>/dev/null | grep -q prunable; then
printf ' ✅ ㉛ 造出一筆「指向空氣」的登記(目錄砍掉、登記還在)\n'; PASS=$((PASS+1))
else
printf ' ❌ ㉛ 造不出 prunable 的登記,下一條測不到東西\n'; FAIL=$((FAIL+1))
fi
t 2 "㉜ 這時候擋下來" "git checkout -b feat/prune"
has "指向空氣" "㉝ 訊息說得出清掉了幾筆說謊的登記"
N=$((N+1))
if git -C "$REPO" worktree list 2>/dev/null | grep -q prunable; then
printf ' ❌ ㉞ prune 沒有真的執行——登記簿還在說謊\n'; FAIL=$((FAIL+1))
else
printf ' ✅ ㉞ 說謊的登記真的被清掉了(prune 是收工動作,不是偶爾整理)\n'; PASS=$((PASS+1))
fi
N=$((N+1))
if [ -d "$OWN" ] && git -C "$REPO" worktree list | grep -q "own-wt"; then
printf ' ✅ ㉟ 而且還在的那份 worktree 一根寒毛都沒動\n'; PASS=$((PASS+1))
else
printf ' ❌ ㉟ prune 把還在的 worktree 也弄掉了——那是災難\n'; FAIL=$((FAIL+1))
fi
echo
echo "── D 群:認得出是**哪一個** repoinkstone/ISEP#109 → comment 5398)──"
# 為什麼這群非有不可:舊版一律拿 payload 的 cwd 當答案,於是
# `cd <別的 repo> && git checkout` 擋是擋對了,訊息卻指著 cwd 那個 repo。
# 🔴 照著那個訊息做的人,會在錯的 repo 開一份用不到的 worktree
# 真正要隔離的那個沒開到——而他以為自己隔離好了。給錯下一步比不擋更糟。
t 2 "㊱ cd <另一個 repo> && git checkout ⇒ 擋(人站在 REPO" \
"cd $REPO2 && git checkout -b feat/x" "$REPO"
has "$REPO2" "㊲ 認出的是 cd 進去的那個 repo"
hasnt "$REPO/" "㊳ **不是** cwd 那個 repo(舊版就是在這裡指錯)"
t 2 "㊴ git -C <另一個 repo> checkout ⇒ 擋" \
"git -C $REPO2 checkout -b feat/y" "$REPO"
has "$REPO2" "㊵ 認出的是 -C 指的那個 repo"
hasnt "$REPO/" "㊶ 不是 cwd 那個"
t 2 "㊷ cd A && git -C B checkout ⇒ 擋(兩個都在,誰贏?)" \
"cd $REPO2 && git -C $REPO checkout -b feat/z" "$PLAIN"
has "$REPO" "㊸ **-C 贏**——git 自己就是這個優先序(-C 蓋掉當下的 cwd)"
hasnt "$REPO2" "㊹ cd 的目標沒有贏"
t 2 "㊺ 相對路徑的 cd ⇒ 擋,而且接得對" \
"cd shared-repo && git checkout main" "$ROOT"
has "$REPO" "㊻ 相對 cd 接在 cwd 後面算出正確的 repo"
t 2 "㊼ cd A && git -C <相對> checkout ⇒ 相對的 -C 接在 cd **之後**算" \
"cd $ROOT && git -C shared-repo checkout main" "$PLAIN"
has "$REPO" "㊽ 不是接在 cwd 後面(接錯會指到 not-a-repo/shared-repo"
t 2 "㊾ 從自己的 worktree cd 回共用目錄再切 ⇒ 擋" \
"cd ../shared-repo && git checkout main" "$OWN"
has "$REPO" "㊿ 認出的是走回去的那個共用目錄"
t 2 "(51) 子殼的 cd 不外洩:(cd 別處 && true) ; git checkout ⇒ 擋在 cwd 這個 repo" \
"(cd $REPO2 && true) ; git checkout -b feat/sub" "$REPO"
has "$REPO" "(52) 子殼裡的 cd 出來就不算數了(沿用 push_target_dir 已驗過的範圍化)"
# 🔴 已知邊界,寫成測試而不是寫成註解——註解會過期,測試紅了才知道它變了。
# `);` 中間沒空白時,shlex 的 punctuation_chars 會把它併成一顆 token
# 整段就斷在那裡 ⇒ **漏擋**(不是指錯 repo,是完全沒看到)。
# 這個邊界是 push_target_dir 的 tokenize 層帶來的,兩道閘一起有;
# 要修得動那層,會連帶改到推 main 那道閘的偵測範圍 ⇒ 不在本票裡動(另報)。
t 0 "(53) 已知邊界:右括號與分號黏在一起 ⇒ 這道閘看不到(漏擋,不是誤攔)" \
"(cd $REPO2 && true); git checkout -b feat/glued" "$REPO"
echo
echo "── D 群反面:認不出來的時候**不准猜**(誤攔+指錯路,兩個一起犯)──"
t 0 "(54) cd 到不是 repo 的地方 ⇒ 放行(舊版會擋,還會指著 cwd 那個 repo" \
"cd $PLAIN && git checkout -b feat/x" "$REPO"
t 0 "(55) cd 進**自己的 worktree** 再切 ⇒ 放行(那正是我們要它做的事)" \
"cd $OWN && git checkout -b feat/inside2" "$REPO"
t 0 "(56) cd 的目標是沒展開的變數 ⇒ 放行(猜錯會叫人去錯的 repo 開 worktree" \
'cd $SOMEREPO && git checkout -b feat/x' "$REPO"
t 0 "(57) cd - ⇒ 放行(上一個目錄是什麼,tokenize 看不出來)" \
"cd - && git checkout -b feat/x" "$REPO"
t 0 "(58) cd 到別的 repo 但只是還原檔案 ⇒ 放行(不動 HEAD 的形狀照樣認得)" \
"cd $REPO2 && git checkout -- b.txt" "$REPO"
N=$((N+1))
if [ -f "$(cd "$HERE/.." && pwd)/lib/checkout_target_dir.py" ]; then
printf ' ✅ (59) 解析器跟閘住在一起(少了它,閘只會印出錯的下一步)\n'; PASS=$((PASS+1))
else
printf ' ❌ (59) lib/checkout_target_dir.py 不見了\n'; FAIL=$((FAIL+1))
fi
clean
echo
echo "══ $PASS/$N 通過${FAIL:+$FAIL 個失敗} ══"
[ "$FAIL" -eq 0 ]