7cacfdcf11
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
136 lines
10 KiB
Bash
136 lines
10 KiB
Bash
#!/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 open/close/list 的基本盤(閘測試 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 open/close/list 基本盤 ──"
|
||
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 分身(ticket1/117/cg×2=4)+孤兒 = 9
|
||
|
||
echo
|
||
echo "══ 通過 $PASS 條,失敗 $FAIL 條(共 $N)══"
|
||
[ "$FAIL" -eq 0 ]
|