Files
ISEP/scripts/test-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

136 lines
10 KiB
Bash
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
# scripts/worktree 的測試(inkstone/ISEP#147)——重點是 `sweep`leo 在 Mac 上跑一次的整理工具。
#
# 它守什麼: 「一個 repo 只有一個資料夾」的現場整理那一半。fixture 照 leo 09-07 在 Finder 看到的那排
# 分身原樣造出來(七個,名字一字不差),每個分身的處境不同:
# course_gen-ticket-3-notes 推了、乾淨 ← 但 --skip course_gen(地端在處理):列、不動
# course_gen-ticket-4-handouts **沒推** ← 同上:列、不動
# ax-courses-ticket1 **沒推** ← 列出分支與路徑,不動
# ISEP-wt-122ship 推了、乾淨 ← --apply 收
# ISEP-wt-117 推了、有未追蹤檔 ← 不動
# ISEP-wt115 登記指向空氣 ← --apply prune
# InkStoneCo-wt-112 推了、乾淨 ← --apply 收
# 外加一份 `.worktrees/` 裡面的分身(新家)與一個沒人登記的孤兒目錄。
#
# 四個方向都要驗,缺一個就是假綠:
# A dry-run:列表正確(每份都在對的組)、**沒有動任何東西**
# B --apply:只收「推了且乾淨」的;沒推的、髒的、--skip 的、孤兒的一根寒毛都沒動
# C 判準是 git 的事實不是名字:同樣叫 course_gen 的,不 --skip 就照規則走
# D opencloselist 的基本盤(閘測試 G/H 群已驗過出路,這裡只驗工具本體)
#
# 全程離線:遠端是本機 bare repo。不打網路、不碰任何真的 repo。
set -u
cd "$(dirname "$0")/.." || exit 1
S="$(pwd)/scripts/worktree"
PASS=0; FAIL=0; N=0
ok(){ N=$((N+1)); PASS=$((PASS+1)); printf ' ✅ %s\n' "$1"; }
bad(){ N=$((N+1)); FAIL=$((FAIL+1)); printf ' ❌ %s\n' "$1"; }
chk(){ if eval "$2"; then ok "$1"; else bad "$1"; fi; } # chk <說明> <條件>
ROOT=$(mktemp -d "${TMPDIR:-/tmp}/isep-sweep.XXXXXX"); ROOT=$(cd "$ROOT" && pwd -P)
trap 'rm -rf "$ROOT"' EXIT
TP="$ROOT/tech_projects"; mkdir -p "$TP" "$ROOT/remotes"
mkrepo() { # mkrepo <名>:主 repo 本機 bare 遠端,main 已推
git init -q --bare "$ROOT/remotes/$1.git"
git init -q -b main "$TP/$1"
git -C "$TP/$1" config user.email t@t; git -C "$TP/$1" config user.name t
echo "$1" > "$TP/$1/README"; git -C "$TP/$1" add -A; git -C "$TP/$1" commit -qm init
git -C "$TP/$1" remote add origin "$ROOT/remotes/$1.git"
git -C "$TP/$1" push -q origin main 2>/dev/null
}
mkwt() { # mkwt <repo> <分身目錄名> <分支> [pushed|unpushed] [dirty]
git -C "$TP/$1" worktree add -q "$TP/$2" -b "$3"
echo "$2" > "$TP/$2/work.txt"; git -C "$TP/$2" add -A; git -C "$TP/$2" commit -qm "$2"
[ "${4:-pushed}" = pushed ] && git -C "$TP/$2" push -q origin "$3" 2>/dev/null
[ "${5:-}" = dirty ] && echo x > "$TP/$2/untracked.tmp"
return 0
}
mkrepo course_gen; mkrepo ax-courses; mkrepo ISEP; mkrepo InkStoneCo
mkwt course_gen course_gen-ticket-3-notes feat/3 pushed
mkwt course_gen course_gen-ticket-4-handouts feat/4 unpushed
mkwt ax-courses ax-courses-ticket1 feat/1 unpushed
mkwt ISEP ISEP-wt-122ship feat/122 pushed
mkwt ISEP ISEP-wt-117 feat/117 pushed dirty
mkwt ISEP ISEP-wt115 feat/115 pushed
rm -rf "$TP/ISEP-wt115" # 登記還在、目錄沒了 ⇒ 指向空氣
mkwt InkStoneCo InkStoneCo-wt-112 feat/112 pushed
# 新家裡的一份(推了、乾淨)——sweep 同樣要看得到、收得掉
python3 "$S" open inkstone/ISEP#140 --repo "$TP/ISEP" >/dev/null 2>&1
# 孤兒:.git 檔指向一個不存在的登記
mkdir -p "$TP/ISEP-wt-orphan"; printf 'gitdir: %s/ISEP/.git/worktrees/gone\n' "$TP" > "$TP/ISEP-wt-orphan/.git"
snapshot() { (cd "$TP" && find . -maxdepth 3 -not -path '*/.git/*' | sort | md5sum | cut -c1-12); }
echo
echo "── A dry-run:列得對、不動 ──"
BEFORE=$(snapshot)
OUT=$(python3 "$S" sweep "$TP" --skip course_gen 2>&1); RC=$?
chk "(1) dry-run 離開碼 2(有東西要人決定)" '[ "$RC" -eq 2 ]'
chk "(2) 沒有動任何東西(目錄樹快照相同)" '[ "$(snapshot)" = "$BEFORE" ]'
sec() { printf '%s\n' "$OUT" | awk -v s="$1" -v e='^(🟢|👻|🔴|🟡|❔|⏭️|❓|──)' 'index($0,s)==1{f=1;next} f&&$0~e{f=0} f'; }
chk "(3) ISEP-wt-122ship 在「推了且乾淨」" 'sec "🟢" | grep -qF "ISEP-wt-122ship"'
chk "(4) InkStoneCo-wt-112 在「推了且乾淨」" 'sec "🟢" | grep -qF "InkStoneCo-wt-112"'
chk "(5) .worktrees/ISEP-140 也在「推了且乾淨」" 'sec "🟢" | grep -qF ".worktrees/ISEP-140"'
chk "(6) ax-courses-ticket1 在「沒推」且印出分支 feat/1" 'sec "🔴" | grep -qF "ax-courses-ticket1" && sec "🔴" | grep -qF "feat/1"'
chk "(7) 沒推的印得出推的指令" 'sec "🔴" | grep -qF "push origin feat/1"'
chk "(8) ISEP-wt-117 在「有未 commit」" 'sec "🟡" | grep -qF "ISEP-wt-117"'
chk "(9) ISEP-wt115 在「指向空氣」" 'sec "👻" | grep -qF "ISEP-wt115"'
chk "(10) course_gen 兩份都在「--skip 不碰」" 'sec "⏭️" | grep -qF "course_gen-ticket-3-notes" && sec "⏭️" | grep -qF "course_gen-ticket-4-handouts"'
chk "(11) course_gen 沒有出現在任何會被動的組" '! sec "🟢" | grep -qF course_gen && ! sec "👻" | grep -qF course_gen'
chk "(12) 孤兒目錄在「沒人登記」" 'sec "❓" | grep -qF "ISEP-wt-orphan"'
chk "(13) 結尾講「沒有動任何東西」" 'printf "%s" "$OUT" | grep -qF "沒有動任何東西"'
echo
echo "── B --apply:只收推了且乾淨的 ──"
OUT=$(python3 "$S" sweep "$TP" --apply --skip course_gen 2>&1); RC=$?
chk "(14) --apply 離開碼 2(還有要人決定的)" '[ "$RC" -eq 2 ]'
chk "(15) ISEP-wt-122ship 收掉了" '[ ! -e "$TP/ISEP-wt-122ship" ]'
chk "(16) InkStoneCo-wt-112 收掉了" '[ ! -e "$TP/InkStoneCo-wt-112" ]'
chk "(17) .worktrees/ISEP-140 收掉了" '[ ! -e "$TP/ISEP/.worktrees/ISEP-140" ]'
chk "(18) ISEP-wt115 的空氣登記 prune 掉了" '! git -C "$TP/ISEP" worktree list | grep -qF ISEP-wt115'
chk "(19) 沒推的 ax-courses-ticket1 還在" '[ -d "$TP/ax-courses-ticket1" ]'
chk "(20) 髒的 ISEP-wt-117 還在、未追蹤檔還在" '[ -f "$TP/ISEP-wt-117/untracked.tmp" ]'
chk "(21) course_gen-ticket-3-notes(推了乾淨但 --skip)還在" '[ -d "$TP/course_gen-ticket-3-notes" ]'
chk "(22) course_gen-ticket-4-handouts 還在" '[ -d "$TP/course_gen-ticket-4-handouts" ]'
chk "(23) 孤兒目錄還在(沒人登記的只能人看)" '[ -d "$TP/ISEP-wt-orphan" ]'
chk "(24) 四個主 repo 都在、各自 main 沒動" 'for r in course_gen ax-courses ISEP InkStoneCo; do [ -f "$TP/$r/README" ] || exit 1; [ "$(git -C "$TP/$r" branch --show-current)" = main ] || exit 1; done'
chk "(25) 收掉的分身,分支還在(收的是目錄不是分支)" 'git -C "$TP/ISEP" rev-parse --verify -q refs/heads/feat/122 >/dev/null'
chk "(26) 結尾講收了 3 份" 'printf "%s" "$OUT" | grep -qF "收了 3 份"'
chk "(27) 第二次 --apply 冪等:沒有可收的、離開碼仍 2" 'python3 "$S" sweep "$TP" --apply --skip course_gen >/dev/null 2>&1; [ $? -eq 2 ] && [ -d "$TP/ax-courses-ticket1" ]'
echo
echo "── C 判準是事實不是名字 ──"
OUT=$(python3 "$S" sweep "$TP" 2>&1)
chk "(28) 不 --skip 時 course_gen-ticket-3-notes 落在「推了且乾淨」(名字不是判準)" 'sec "🟢" | grep -qF "course_gen-ticket-3-notes"'
chk "(29) course_gen-ticket-4-handouts 落在「沒推」、印 feat/4" 'sec "🔴" | grep -qF "course_gen-ticket-4-handouts" && sec "🔴" | grep -qF "feat/4"'
chk "(30) dry-run 仍然沒動 course_gen 那兩份" '[ -d "$TP/course_gen-ticket-3-notes" ] && [ -d "$TP/course_gen-ticket-4-handouts" ]'
# 問不到遠端:拿掉 remote 之後,推過的也不當成沒推、也不收
git -C "$TP/ax-courses" remote remove origin
OUT=$(python3 "$S" sweep "$TP" --apply --skip course_gen 2>&1)
chk "(31) 沒有 remote ⇒ ax-courses-ticket1 落在「問不到遠端」,不當成沒推" 'sec "❔" | grep -qF "ax-courses-ticket1"'
chk "(32) 問不到的也不收" '[ -d "$TP/ax-courses-ticket1" ]'
echo
echo "── D opencloselist 基本盤 ──"
P=$(python3 "$S" open inkstone/ISEP#150 --repo "$TP/ISEP" 2>/dev/null)
chk "(33) open 印出的路徑在 <repo>/.worktrees/ISEP-150" '[ "$P" = "$TP/ISEP/.worktrees/ISEP-150" ] && [ -d "$P" ]'
chk "(34) 主 repo git status 乾淨(info/exclude 有 /.worktrees/" '[ -z "$(git -C "$TP/ISEP" status --porcelain)" ] && grep -qxF "/.worktrees/" "$TP/ISEP/.git/info/exclude"'
chk "(35) 預設分支 feat/150" '[ "$(git -C "$P" branch --show-current)" = feat/150 ]'
chk "(36) tech_projects/ 沒有因 open 多出資料夾" '[ ! -e "$TP/ISEP-150" ] && [ ! -e "$TP/ISEP-wt-150" ]'
echo z > "$P/z"; git -C "$P" add -A; git -C "$P" -c user.email=t@t -c user.name=t commit -qm z
python3 "$S" close inkstone/ISEP#150 --repo "$TP/ISEP" >/dev/null 2>"$ROOT/close.err"; RC=$?
chk "(37) close 沒推 ⇒ 離開碼 2、點名分支與路徑" '[ "$RC" -eq 2 ] && grep -qF "feat/150" "$ROOT/close.err" && grep -qF "$P" "$ROOT/close.err"'
git -C "$P" push -q origin feat/150 2>/dev/null
python3 "$S" close inkstone/ISEP#150 --repo "$TP/ISEP" >/dev/null 2>&1; RC=$?
chk "(38) 推了之後 close ⇒ 0、目錄沒了" '[ "$RC" -eq 0 ] && [ ! -e "$P" ]'
python3 "$S" close inkstone/ISEP#150 --repo "$TP/ISEP" --quiet >/dev/null 2>"$ROOT/close2.err"; RC=$?
chk "(39) 再 close 一次 ⇒ 0、--quiet 不吵" '[ "$RC" -eq 0 ] && [ ! -s "$ROOT/close2.err" ]'
chk "(40) 票寫錯形狀 ⇒ 離開碼 1、講得出正確形狀" 'python3 "$S" open ISEP-150 --repo "$TP/ISEP" 2>&1 | grep -qF "owner/repo#N"'
chk "(41) list 列得出還在的分身" 'python3 "$S" list "$TP" 2>&1 | grep -qF "ISEP-wt-117"'
chk "(42) 上面所有動作之後 tech_projects/ 沒多任何資料夾" '[ "$(ls -1 "$TP" | grep -c .)" -eq 9 ]' # 4 repo 3 分身(ticket1117cg×24)+孤兒 9
echo
echo "══ 通過 $PASS 條,失敗 $FAIL 條(共 $N)══"
[ "$FAIL" -eq 0 ]