測試不再偽造「有一筆推 main 在等總管裁」(inkstone/ISEP#59)

現象(實測,不是推論):跑完 main-and-prod-push-guard 那三支測試之後

    $ git status --short
     M hooks/lib/__pycache__/strip_heredoc.cpython-314.pyc
     M pending-main-push/unnamed--ISEP.md
    ?? pending-main-push/unnamed--A.md

成因:這支閘擋下推 main 的同時,會把那次請求寫成
`<hooks 的上一層>/pending-main-push/<誰>--<repo>.md`,而三支測試的測資本來
就全是推 main。於是每跑一次測試,工作區就多/改幾筆**偽造的待裁決**。

兩個後果,後者比較貴:
  ① `git add -A` 很容易把它們帶進 commit(08-27 那次真的帶進去了,事後才拔掉)
  ② 總管的迴圈讀那個目錄,讀到的每一筆都該是真的在等他裁——
     測試每跑一次就偽造一筆 ⇒ **下一筆真的請求會混在雜訊裡**。
     這跟「永遠在響的警報」是同一個病。

改法(產品程式碼一行都沒動):
- 新增 hooks/tests/lib/hook-sandbox.sh:把整個 hooks/ 複製到暫存區再跑複本。
  閘算 pending 目錄的位置靠的是 `$0` 的上一層 ⇒ 請求寫進暫存區。
  **刻意不在閘上開一個「寫去哪」的環境變數**——那種開關同時是一條把紀錄關掉的路。
- 三支測試改測沙盒複本,並各補兩條斷言:
  ① repo 的 pending-main-push 一個位元都沒動
  ② 沙盒裡**真的有**留下請求(只驗 ① 的話,把留紀錄的功能整個關掉也會綠)
- pending-main-push/ 不再進版控(它是本機狀態不是原始碼),只留一份 README 說明規約;
  既有的兩筆與那顆被追蹤的 .pyc 一併 `git rm --cached`,檔案留在硬碟上不刪。

實測:
- 三支各自 10/10、19/19、13/13(原本 8/17/11,各多兩條新斷言)
- 把 `H` 改回真跡重跑 ⇒ 新斷言兩條都紅(11/13)+工作區又髒
  ⇒ 這兩條斷言真的抓得到它要抓的東西
- 全套 16 支測試檔跑完 0 失敗,`git status` 沒有多出任何一行
- `claude plugin validate .` ✔ Validation passed

沒有升版:這次只動測試與版控範圍,沒有任何閘的行為改變,
不需要靠新版本號送到任何人手上;請跟著總管下一個 release 一起出去。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 18:30:14 +08:00
parent 7795cd705a
commit 950c3e1919
9 changed files with 175 additions and 70 deletions
+101
View File
@@ -0,0 +1,101 @@
# hooks/tests/lib/hook-sandbox.sh — 讓「會寫檔的閘」在測試時寫到暫存區,不寫進 repo。
# 不是獨立掛的閘(沒進 hooks.json),給 hooks/tests 與 scripts/test-* `source` 用。
#
# 🔴 為什麼需要這支(inkstone/ISEP#59 comment 4779 第 ① 條,實測重演過):
# main-and-prod-push-guard.sh 擋下「推 main」的同時,會把那次請求寫成一份
# `<hooks 的上一層>/pending-main-push/<誰>--<repo>.md`——**那份檔案的語意是
# 「有一筆推 main 正在等總管裁」**。而測試的測資本來就全是「推 main」,
# 於是每跑一次測試,工作區就多/改幾筆**偽造的待裁決**:
#
# $ git status --short # 跑完測試之後(修之前的實測)
# M pending-main-push/unnamed--ISEP.md ← 被測試覆寫(它是被追蹤的)
# ?? pending-main-push/unnamed--A.md ← 跨 repo 測試建的臨時 repo
#
# 兩個後果,後者比較貴:
# ① `git add -A` 很容易把它們帶進 commit(08-27 那次真的帶進去了,事後才拔掉)
# ② **永遠在響的警報**:總管的迴圈讀這個目錄,讀到的每一筆都該是真的在等他裁。
# 測試每跑一次就偽造一筆 ⇒ 下一筆真的請求會混在雜訊裡。
#
# 做法:把整個 `hooks/` 複製到暫存區再跑那支複本。閘算 `pending-main-push` 的位置
# 靠的是 `$0` 的上一層,所以複本會寫進暫存區,**產品程式碼一行都不用改**
# (不必為了測試在閘上多開一個「寫去哪」的開關——那種開關會變成關掉紀錄的路)。
#
# 用法:
# . "$(dirname "$0")/lib/hook-sandbox.sh"
# hook_sandbox "$REAL_HOOK" || exit 1
# G=$HOOK_SANDBOX_HOOK # 之後一律測 $G
# before=$(hook_sandbox_hostsum) # repo 那份 pending-main-push 的指紋
# ... 跑測試 ...
# hook_sandbox_assert "$before" # 回 0=乾淨且沙盒裡真的有留下請求
# hook_sandbox <真跡 hook 的路徑> → 建好沙盒,設三個變數,回 0/1
# HOOK_SANDBOX_HOOK 沙盒裡那支同名 hook(之後一律測這支)
# HOOK_SANDBOX 沙盒根(`pending-main-push` 會長在這底下)
# HOOK_SANDBOX_HOST 真跡 repo 的 pending-main-push(拿來比對有沒有被弄髒)
#
# 🔴 **刻意不用 `printf` 把路徑印出來讓呼叫端 `G=$(hook_sandbox …)` 接**——
# 命令替換跑在子殼裡,函式設的那三個變數**回不到呼叫端**。
# 寫這支的當下就這樣寫,第一次跑的結果是:`HOOK_SANDBOX` 在呼叫端是空的
# ⇒ hostsum 兩次都回 `NODIR`(相等)、沙盒的請求數當然是 0
# ⇒ **第一條斷言變成「拿空的比空的」的假綠**。
# 所以改成「設變數、不印」,而且 hook_sandbox_assert 開頭會擋空值(見下)。
hook_sandbox() {
_hs_real=$1
_hs_hooks=$(CDPATH= cd -- "$(dirname -- "$_hs_real")" && pwd) || return 1
HOOK_SANDBOX=$(mktemp -d) || return 1
cp -R "$_hs_hooks" "$HOOK_SANDBOX/hooks" || return 1
HOOK_SANDBOX_HOST="${_hs_hooks%/hooks}/pending-main-push"
HOOK_SANDBOX_HOOK="$HOOK_SANDBOX/hooks/$(basename "$_hs_real")"
[ -f "$HOOK_SANDBOX_HOOK" ] || return 1
}
# hook_sandbox_hostsum → 印出真跡 repo 那份 pending-main-push 的指紋(檔名+內容)
# 目錄不存在時印 NODIR——「本來就沒有」跟「空的」要分得出來。
hook_sandbox_hostsum() {
if [ -d "${HOOK_SANDBOX_HOST:-/nonexistent}" ]; then
find "$HOOK_SANDBOX_HOST" -type f 2>/dev/null | LC_ALL=C sort | while IFS= read -r _f; do
cksum "$_f" 2>/dev/null || printf 'UNREADABLE %s\n' "$_f"
done
else
printf 'NODIR\n'
fi
}
# hook_sandbox_assert <before 的指紋> → 印出兩行結果,全過回 0
# 🔴 兩件都要驗,只驗前者會養出「把紀錄關掉也算通過」的假綠:
# ① repo 那份 pending-main-push 一個位元都沒動(測試沒有偽造待裁決)
# ② 沙盒那份真的多了請求檔(**留請求這件事本身還在做**)
hook_sandbox_assert() {
_hs_before=$1
# fail-closed:沙盒沒建起來(或變數沒傳到這裡)時,下面兩條會變成「空的比空的」=假綠。
if [ -z "${HOOK_SANDBOX:-}" ] || [ -z "${HOOK_SANDBOX_HOST:-}" ]; then
printf ' ❌ %-58s\n' "沙盒變數是空的——這兩條斷言等於沒跑,當失敗處理"
HS_PASS=0; HS_FAIL=2; return 1
fi
_hs_after=$(hook_sandbox_hostsum)
_hs_rc=0; HS_PASS=0; HS_FAIL=0
if [ "$_hs_before" = "$_hs_after" ]; then
printf ' ✅ %-58s\n' "repo 的 pending-main-push 沒被測試碰過"; HS_PASS=$((HS_PASS+1))
else
printf ' ❌ %-58s\n' "repo 的 pending-main-push 被測試寫髒了"
printf ' before/after diff\n'
printf '%s\n' "$_hs_before" > "$HOOK_SANDBOX/.before"
printf '%s\n' "$_hs_after" > "$HOOK_SANDBOX/.after"
diff "$HOOK_SANDBOX/.before" "$HOOK_SANDBOX/.after" | sed 's/^/ /'
_hs_rc=1; HS_FAIL=$((HS_FAIL+1))
fi
_hs_n=$(find "$HOOK_SANDBOX/pending-main-push" -type f 2>/dev/null | wc -l | tr -d ' ')
if [ "${_hs_n:-0}" -gt 0 ]; then
printf ' ✅ %-58s%s 筆)\n' "請求有被留下來,只是留在沙盒裡" "$_hs_n"; HS_PASS=$((HS_PASS+1))
else
printf ' ❌ %-58s\n' "沙盒裡一筆請求都沒有——留請求的機制可能被關掉了"
_hs_rc=1; HS_FAIL=$((HS_FAIL+1))
fi
return $_hs_rc
}
hook_sandbox_cleanup() {
case "${HOOK_SANDBOX:-}" in
/*/*) rm -rf "$HOOK_SANDBOX" ;; # 只刪 mktemp 給的那種深路徑,不接受空值/根目錄
esac
}
@@ -9,10 +9,21 @@
# 路徑務必給絕對路徑——測試會 cd 進臨時建立的 A/B repo 再呼叫它,相對路徑
# 到那時就對不上了(自己撞過一次:exit=127 command not found)。
set -u
G="$1"
REAL="$1"
# 🔴 測沙盒裡的複本,不測真跡:這支閘擋下推 main 時會把請求寫進
# `<repo>/pending-main-push/`,而這裡每一條測資都是推 main
# ⇒ 直接測真跡,每跑一次就在 ISEP 的工作區偽造幾筆「還沒裁」
# (實測留下過 `pending-main-push/unnamed--A.md`——A 是這支自己建的臨時 repo,
# inkstone/ISEP#59 comment 4779 第 ① 條)。收尾會驗兩件(見 lib 檔頭)。
. "$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)/lib/hook-sandbox.sh"
hook_sandbox "$REAL" || { echo "❌ 沙盒建不起來"; exit 1; }
G=$HOOK_SANDBOX_HOOK
HOSTSUM_BEFORE=$(hook_sandbox_hostsum)
STAMP=/tmp/.main-push-ok
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"; rm -f "$STAMP"' EXIT
trap 'rm -rf "$WORK"; rm -f "$STAMP"; hook_sandbox_cleanup' EXIT
for d in A B; do
git init -q -b main "$WORK/$d"
@@ -101,5 +112,9 @@ touch -t "$(date -v-16M +%Y%m%d%H%M.%S 2>/dev/null || date -d '-16 minutes' +%Y%
t "16 分鐘前開的戳記 → 已過期,推 B 應擋" "$A" "cd $B && git push origin HEAD:main" 2
rm -f "$STAMP"
echo "── 測試自己不准弄髒工作區(inkstone/ISEP#59)──"
hook_sandbox_assert "$HOSTSUM_BEFORE"
pass=$((pass+HS_PASS)); fail=$((fail+HS_FAIL))
echo "────── 通過 $pass 失敗 $fail"
[ "$fail" = 0 ]
+15 -1
View File
@@ -1,7 +1,17 @@
#!/usr/bin/env bash
# 八向實測 main-and-prod-push-guard.sh
# 放在檔案裡跑,因為測試字串本身會觸發「舊版」那支閘(第五次誤攔)。
G="$1" # 要測的 hook 路徑
REAL="$1" # 要測的 hook 路徑(真跡)
# 🔴 不要直接測真跡:這支閘擋下推 main 的同時會把請求寫進 `<repo>/pending-main-push/`
# 而這裡的測資全是推 main ⇒ 每跑一次就在工作區偽造幾筆「還沒裁」(inkstone/ISEP#59)。
# 改測沙盒裡的複本,閘照原樣跑,請求寫進暫存區。收尾會驗兩件(見 lib 檔頭)。
. "$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)/lib/hook-sandbox.sh"
hook_sandbox "$REAL" || { echo "❌ 沙盒建不起來"; exit 1; }
G=$HOOK_SANDBOX_HOOK
trap 'hook_sandbox_cleanup' EXIT
HOSTSUM_BEFORE=$(hook_sandbox_hostsum)
pass=0; fail=0
t() { # t <說明> <指令> <期望 exit>
@@ -25,5 +35,9 @@ t "直接推預設分支" 'git push origin ma'"in" 2
t "HEAD:預設分支" 'git push origin HEAD:ma'"in" 2
t "推 master" 'git push -q origin mas'"ter" 2
echo "── 測試自己不准弄髒工作區(inkstone/ISEP#59)──"
hook_sandbox_assert "$HOSTSUM_BEFORE"
pass=$((pass+HS_PASS)); fail=$((fail+HS_FAIL))
echo "────── 通過 $pass 失敗 $fail"
[ "$fail" = "0" ]