#!/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 全部放行(判準 ②)。 set -eu [ "${WORKTREE_OK:-}" = "1" ] && exit 0 [ "${CLAUDE_CODE_CHILD_SESSION:-}" = "1" ] || exit 0 # 總管在自己的目錄裡,不管 PAYLOAD=$(cat 2>/dev/null || echo '{}') TARGET=$(printf '%s' "$PAYLOAD" | python3 -c ' import json, re, shlex, sys try: d = json.load(sys.stdin) except Exception: raise SystemExit ti = d.get("tool_input") or {} cmd = (ti.get("command") or "") if isinstance(ti, dict) else "" if not cmd: raise SystemExit cwd = d.get("cwd") or "" # 逐段拆(; && || 換行),只看每一段的第一個 git for seg in re.split(r"(?:&&|\|\||;|\n)", cmd): seg = seg.strip() if not seg: continue try: tok = shlex.split(seg) except ValueError: continue # 允許前面掛 env(VAR=值 git …) while tok and re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", tok[0]): tok.pop(0) if not tok or tok[0] != "git": continue tok = tok[1:] # git -C … where = cwd while tok and tok[0].startswith("-"): if tok[0] == "-C" and len(tok) > 1: where = tok[1]; tok = tok[2:]; continue if tok[0].startswith("--git-dir"): raise SystemExit # 自己指 git-dir 的花式用法,不猜 tok.pop(0) if not tok: continue sub = tok[0] if sub not in ("checkout", "switch"): continue rest = tok[1:] # 還原檔案的形狀,不動 HEAD ⇒ 放行 if "--" in rest: continue if any(a in ("-p", "--patch", "--") for a in rest): continue # 沒有任何目標(`git checkout` 自己會報錯)⇒ 沒事 if not [a for a in rest if not a.startswith("-")] and \ not any(a in ("-b", "-B", "-c", "-C") for a in rest): continue print(where or ".") break ' 2>/dev/null) || 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 < -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