未推警察改用「遠端有沒有這顆 commit」判斷,不再用「有沒有 upstream」(inkstone/ISEP#90 ①)

雲端 session 開出來的工作分支天生沒有 upstream,內容卻等於遠端 main
(薄殼 HEAD 081c547 = GitHub 遠端 main 081c547,同一顆)
⇒ 每個雲端 session、每次收工都被攔一次,08-27 一個 session 五次全是誤報。

三件:
· 判準改成問遠端(git ls-remote,快取 120 秒)。本機只答得準「有」,
  答「沒有」時才打網路——實測本 session 的 origin/main 落後遠端 4 天。
· 三態:遠端有/遠端沒有/問不到。**問不到一律不報**,不拿離線當罪證。
· 散落分支的基準也一起換成遠端實際那顆——基準過期會把已經在遠端 main 上的
  分支整批報成散落(包含雲端 session 自己那條)。

順手補雲端那格接線:`$CLAUDE_PROJECT_DIR` 在雲端是薄殼根,真身在 `$TOP/InkStoneCo/`。
舊的掃描清單四個路徑一個都不存在 ⇒ 真身有東西沒推,這支閘一輩子不會知道。

測試 hooks/tests/unpushed-police.test.sh:10/10(全離線,本機 bare repo 當遠端)。
拿舊版跑同一份:A 群 5 條全紅、B 群 4 條全綠 ⇒ 測試有鑑別力,不是改到寬鬆。
This commit is contained in:
claude-code
2026-08-28 00:00:36 +00:00
parent e0ac81b2e3
commit 352d7a0b81
2 changed files with 277 additions and 7 deletions
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env bash
# unpushed-police.sh 的迴歸測試(inkstone/ISEP#90
#
# 這支存在的理由是一個**穩定重現**的誤報:雲端 session 開出來的工作分支
# 天生沒有 upstream,內容卻等於遠端 main ⇒ 每個雲端 session、每次收工都被攔一次。
# 2026-08-27 一個 session 攔五次,五次全是誤報。
#
# 兩個方向都要有證據,而且 **A 群(不該報)比 B 群更重要**:
# 永遠在響的警報=訓練人忽略這個警報,下一條真的失蹤的分支會混在雜訊裡。
#
# 🔴 全程離線:用本機 bare repo 當「遠端」,`git ls-remote` 打的是檔案路徑。
# 不碰任何真 repo、不打網路、不寫 /tmp 的正式快取(走 UNPUSHED_CACHE_DIR)。
set -u
HOOK="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/unpushed-police.sh}"
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
export GIT_CONFIG_GLOBAL="$TMP/gitconfig"; : > "$GIT_CONFIG_GLOBAL"
export GIT_AUTHOR_NAME=t GIT_AUTHOR_EMAIL=t@t GIT_COMMITTER_NAME=t GIT_COMMITTER_EMAIL=t@t
PASS=0; FAIL=0; N=0
# mk <名字> → 造一個「遠端 bare + 本機 clone」的場,印出本機 repo 路徑
mk() {
d="$TMP/$1"; mkdir -p "$d"
git init -q --bare "$d/remote.git"
git init -q -b main "$d/work"
( cd "$d/work"
echo a > a.txt; git add a.txt; git commit -qm first
git remote add origin "$d/remote.git"
git push -q origin main
git branch --unset-upstream 2>/dev/null || true ) >/dev/null 2>&1
printf '%s\n' "$d/work"
}
# fire <repo> <期望 exit> <說明> [期望訊息片段]
fire() {
repo="$1"; want="$2"; desc="$3"; want_msg="${4:-}"
N=$((N+1))
out=$(printf '{}' | CLAUDE_PROJECT_DIR="$repo" UNPUSHED_CACHE_DIR="$TMP/cache-$N" \
bash "$HOOK" 2>&1); rc=$?
mkdir -p "$TMP/cache-$N" 2>/dev/null
ok=1
[ "$rc" -eq "$want" ] || ok=0
if [ -n "$want_msg" ] && ! printf '%s' "$out" | grep -qF "$want_msg"; then ok=0; fi
if [ "$ok" = 1 ]; then
printf ' ✅ %s\n' "$desc"; PASS=$((PASS+1))
else
printf ' ❌ %s —— 期望 exit=%s%s,實得 exit=%s\n' "$desc" "$want" \
"${want_msg:+ 且訊息含「$want_msg}" "$rc"
printf '%s\n' "$out" | sed -n '1,14p' | sed 's/^/ /'
FAIL=$((FAIL+1))
fi
}
# 每個案例先把快取目錄準備好(fire 用的是自己那一格,互不污染)
for i in $(seq 1 40); do mkdir -p "$TMP/cache-$i"; done
echo "── A 群:不該報(誤攔比漏擋更該修)──────────────────────────"
# ① 這就是雲端那個現場:分支沒有 upstream,但它那顆 commit 就是遠端 main 那顆
r=$(mk cloud)
( cd "$r" && git checkout -q -b claude/session-xyz && git branch -D main -q 2>/dev/null
git remote set-head origin -d 2>/dev/null; git update-ref -d refs/remotes/origin/main ) >/dev/null 2>&1
fire "$r" 0 "① 沒 upstream,但遠端就有這顆 commit(雲端每個 session 的現場)"
# ② HEAD 是遠端 main 的**祖先**(遠端已經走在前面)——同樣不是「沒推」
r=$(mk ancestor)
( cd "$r"
echo b > b.txt && git add b.txt && git commit -qm second && git push -q origin main
git checkout -q -b work HEAD~1
git update-ref -d refs/remotes/origin/main ) >/dev/null 2>&1
fire "$r" 0 "② HEAD 是遠端 main 的祖先 → 遠端已經有它"
# ③ 本機的 remote-tracking ref **過期**08-28 實測落後遠端 4 天的那一格)
r=$(mk stale)
( cd "$r"
old=$(git rev-parse HEAD)
echo b > b.txt && git add b.txt && git commit -qm second && git push -q origin main
git update-ref refs/remotes/origin/main "$old" # 本機那份停在舊的
git checkout -q -b claude/session-abc ) >/dev/null 2>&1
fire "$r" 0 "③ 本機 origin/main 過期,但遠端真的有這顆 → 不報"
# ④ 完全問不到遠端(沒有任何 remote)→ **不當成沒推**
r="$TMP/noremote"; git init -q -b main "$r"
( cd "$r" && echo a > a.txt && git add a.txt && git commit -qm only ) >/dev/null 2>&1
fire "$r" 0 "④ 沒有任何 remote=問不到 → 不報(不拿離線當罪證)"
# ⑤ 基準過期時,已經在遠端 main 上的分支不算散落
r=$(mk straystale)
( cd "$r"
old=$(git rev-parse HEAD)
git checkout -q -b feat/done
echo b > b.txt && git add b.txt && git commit -qm done
git push -q origin feat/done:main # 遠端 main 已經含它
git update-ref refs/remotes/origin/main "$old" ) >/dev/null 2>&1
fire "$r" 0 "⑤ 分支已在遠端 main 上,只是本機基準舊 → 不算散落"
# ⑥ stop_hook_active → 一律放行
r=$(mk stopactive)
( cd "$r" && echo x >> a.txt ) >/dev/null 2>&1
N=$((N+1))
out=$(printf '{"stop_hook_active":true}' | CLAUDE_PROJECT_DIR="$r" \
UNPUSHED_CACHE_DIR="$TMP/cache-$N" bash "$HOOK" 2>&1); rc=$?
if [ "$rc" -eq 0 ]; then printf ' ✅ %s\n' "⑥ stop_hook_activetrue → 放行"; PASS=$((PASS+1))
else printf ' ❌ %sexit=%s\n' "⑥ stop_hook_activetrue → 放行" "$rc"; FAIL=$((FAIL+1)); fi
echo "── B 群:該報(不能為了不吵就變成放行)────────────────────"
# ⑦ 真的有遠端沒有的 commit = 真的沒推
r=$(mk unpushed)
( cd "$r"
git checkout -q -b feat/real
echo b > b.txt && git add b.txt && git commit -qm "沒推的那筆" ) >/dev/null 2>&1
fire "$r" 2 "⑦ 分支有遠端沒有的 commit → 要報" "從未推過"
# ⑧ 有改動沒 commit
r=$(mk dirty)
( cd "$r" && echo more >> a.txt ) >/dev/null 2>&1
fire "$r" 2 "⑧ 工作區有未 commit 的改動 → 要報" "未 commit"
# ⑨ 散落分支:本地分支有遠端 main 沒有的東西
r=$(mk stray)
( cd "$r"
git checkout -q -b feat/lost
echo c > c.txt && git add c.txt && git commit -qm "躺著的那筆"
git checkout -q main ) >/dev/null 2>&1
fire "$r" 2 "⑨ 有分支沒併回、遠端也沒有 → 要報散落" "feat/lost"
# ⑩ 判準沒有滑回「有沒有 upstream」:有 upstream 但真的領先,照樣要報
r=$(mk ahead)
( cd "$r"
git branch --set-upstream-to=origin/main main
echo b > b.txt && git add b.txt && git commit -qm "領先一筆" ) >/dev/null 2>&1
fire "$r" 2 "⑩ 有 upstream 且真的領先 → 照樣要報" "領先"
echo
printf '通過 %s 條,失敗 %s 條(共 %s 條)\n' "$PASS" "$FAIL" "$N"
[ "$FAIL" = 0 ] || exit 1
+140 -7
View File
@@ -35,10 +35,107 @@ except Exception: print(False)
' 2>/dev/null)"
[ "$stop_active" = "True" ] && exit 0
# ══════════════════════════════════════════════════════════════════════
# 「遠端到底有沒有這顆 commit」——本檔唯一的判準(inkstone/ISEP#902026-08-28
# ══════════════════════════════════════════════════════════════════════
#
# 🔴 舊判準是「這條分支有沒有 upstream」,而**雲端 session 開出來的工作分支
# 天生就沒有 upstream**,內容卻等於遠端 main
#
# 薄殼 HEAD = 081c547d90dc2d80692485af083ca1f71f2094f2
# GitHub 遠端 main = 081c547d90dc2d80692485af083ca1f71f2094f2 ← 同一顆
#
# ⇒ 每個雲端 session、每次收工都被攔一次。2026-08-27 一個 session 攔五次,
# **五次全是誤報**。而永遠在響的警報=訓練人忽略這個警報
# (本檔第 ③ 段自己在 08-12 就寫過這句,這次是同一個病長在別的判準上)。
#
# 🔴 也不能改成「本機的 remote-tracking ref 有沒有含它」的變體:
# 那份 ref 會過期——08-28 實測本 session 的 `origin/main` 落後遠端整整 4 天。
# 本機只答得準「有」,答不準「沒有」⇒ 答「沒有」的時候要去問遠端。
#
# 三態,不是兩態:0=遠端有 1=遠端沒有 2=問不到(沒 remote/網路不通/沒權限)。
# **2 一律不報。** 誤攔比漏擋更該修(leo 2026-08-17:文字層那些閘 8 次誤攔、
# 0 次正確攔截)——把「離線」當成「你沒推」,就是拿雜訊懲罰謹慎。
GIT_ASK="env GIT_TERMINAL_PROMPT=0 GIT_ASKPASS=/bin/echo GIT_SSH_COMMAND=ssh\ -oBatchMode=yes"
_TO=""
command -v timeout >/dev/null 2>&1 && _TO="timeout 15"
command -v gtimeout >/dev/null 2>&1 && _TO="gtimeout 15"
# ls_remote_heads <repo> <remote> → 印出「<sha> <ref>」數行;問不到就印空、離開碼 1
# 快取 120 秒:Stop hook 每個回合都跑,同一個遠端不必每回合都打一次網路。
ls_remote_heads() {
_lr_repo="$1"; _lr_rm="$2"
_lr_key="$(printf '%s|%s' "$_lr_repo" "$_lr_rm" | cksum | tr -d ' /')"
# 快取位置可被 UNPUSHED_CACHE_DIR 覆寫——測試要能造「同一個 repo、遠端變了」的場,
# 不能被上一個案例的快取餵到舊答案(那會是假綠)。
_lr_cache="${UNPUSHED_CACHE_DIR:-/tmp}/.unpushed-lsremote-$_lr_key"
if [ -f "$_lr_cache" ]; then
_lr_now=$(date +%s 2>/dev/null || echo 0)
_lr_mt=$(stat -f %m "$_lr_cache" 2>/dev/null || stat -c %Y "$_lr_cache" 2>/dev/null || echo 0)
case "$_lr_now$_lr_mt" in *[!0-9]*) _lr_mt=0 ;; esac
if [ "$_lr_mt" -gt 0 ] && [ $((_lr_now - _lr_mt)) -lt 120 ]; then
[ -s "$_lr_cache" ] || return 1
cat "$_lr_cache"; return 0
fi
fi
if _lr_out="$($_TO $GIT_ASK git -C "$_lr_repo" ls-remote --heads "$_lr_rm" 2>/dev/null)" \
&& [ -n "$_lr_out" ]; then
printf '%s\n' "$_lr_out" > "$_lr_cache" 2>/dev/null || true
printf '%s\n' "$_lr_out"
return 0
fi
: > "$_lr_cache" 2>/dev/null || true # 記「這次問不到」,免得每回合都等一次 timeout
return 1
}
# remote_has_commit <repo> <sha> → 0 遠端有/1 遠端沒有/2 問不到
remote_has_commit() {
_rh_repo="$1"; _rh_sha="$2"
[ -n "$_rh_sha" ] || return 2
# ① 本機就答得出「有」:任何 remote-tracking ref 含這顆 ⇒ 一定推過,不必打網路
if [ -n "$(git -C "$_rh_repo" branch -r --contains "$_rh_sha" 2>/dev/null | head -1)" ]; then
return 0
fi
_rh_asked=0
for _rh_rm in $(git -C "$_rh_repo" remote 2>/dev/null); do
_rh_ls="$(ls_remote_heads "$_rh_repo" "$_rh_rm")" || continue
_rh_asked=1
# 遠端某條 head 就是這顆 ⇒ 推過(雲端那個誤報正是落在這一格)
printf '%s\n' "$_rh_ls" | awk '{print $1}' | grep -qx "$_rh_sha" && return 0
# 遠端那顆本機也有時,才問得出祖先關係(本機沒有就只能靠上面的等值比對)
printf '%s\n' "$_rh_ls" | while read -r _rh_rsha _; do
[ -n "$_rh_rsha" ] || continue
git -C "$_rh_repo" cat-file -e "${_rh_rsha}^{commit}" 2>/dev/null || continue
git -C "$_rh_repo" merge-base --is-ancestor "$_rh_sha" "$_rh_rsha" 2>/dev/null && exit 7
done
[ $? = 7 ] && return 0
done
[ "$_rh_asked" = 1 ] && return 1
return 2
}
# remote_sha_of <repo> <遠端名> <分支名> → 印出遠端那顆 sha(拿不到印空)
remote_sha_of() {
ls_remote_heads "$1" "$2" 2>/dev/null \
| awk -v b="refs/heads/$3" '$2 == b { print $1; exit }'
}
TOP="${CLAUDE_PROJECT_DIR:-$(cd "$(dirname "$0")/../.." && pwd)}"
# 掃描對象:頂層 + 各子 repo(存在才掃)。worktree 一併涵蓋(git -C 會自己解)。
REPOS="$TOP $TOP/matrix/arcrun $TOP/products/arcrun-rag $TOP/polaris/mira"
#
# 🔴 2026-08-28inkstone/ISEP#90)補雲端那一格:雲端的 `$CLAUDE_PROJECT_DIR`
# 是**薄殼根**,真身被 bootstrap.sh clone 到 `$TOP/InkStoneCo/`,子 repo 在
# `$TOP/InkStoneCo/matrix/arcrun` …。舊清單只認地端那種「頂層就是真身」的排法
# ⇒ **雲端上真身有東西沒推,本閘一輩子不會知道**(它掃的四個路徑一個都不存在)。
# ⇒ 這跟 ① 的誤攔是同一個病的兩面:在雲端,這支閘既亂叫、又看不到該看的地方。
REPOS=""
for _body in "$TOP" "$TOP/InkStoneCo"; do
[ -d "$_body/.git" ] || [ -f "$_body/.git" ] || continue
REPOS="$REPOS $_body $_body/matrix/arcrun $_body/products/arcrun-rag $_body/polaris/mira"
done
[ -n "$REPOS" ] || REPOS="$TOP"
problems=""
for r in $REPOS; do
@@ -50,17 +147,33 @@ for r in $REPOS; do
staged="$(git -C "$r" diff --cached --shortstat 2>/dev/null | head -1)"
# ② 已 commit 但沒推(**當前分支**
#
unknown=""
# 🔴 判準是「遠端有沒有這顆 commit」,不是「有沒有 upstream」(ISEP#90,見檔頭那段)。
# 有 upstream 時仍照原本的算法報「領先幾筆」——那個數字有用;
# 沒 upstream 時去問遠端,問不到就**不報**。
unpushed=""
br="$(git -C "$r" branch --show-current 2>/dev/null)"
if [ -n "$br" ]; then
head_sha="$(git -C "$r" rev-parse HEAD 2>/dev/null || true)"
up="$(git -C "$r" rev-parse --abbrev-ref "@{upstream}" 2>/dev/null || true)"
if [ -n "$up" ]; then
n="$(git -C "$r" rev-list --count "$up".."$br" 2>/dev/null || echo 0)"
[ "$n" != "0" ] && unpushed="領先 $up $n"
if [ "$n" != "0" ]; then
# upstream 本身可能過期(本機那份 ref 落後遠端 4 天是實測過的)
# ⇒ 先問遠端;遠端已經有這顆就不是「沒推」。
remote_has_commit "$r" "$head_sha"
[ "$?" = 0 ] || unpushed="領先 $up $n"
fi
else
# 沒 upstream=這條分支從來沒推過(今天 fix/cis-round3-install 就是這樣)
has_commits="$(git -C "$r" rev-list --count HEAD 2>/dev/null || echo 0)"
[ "$has_commits" != "0" ] && unpushed="分支 $br **從未推過**(無 upstream"
remote_has_commit "$r" "$head_sha"
case "$?" in
1) unpushed="分支 $br **從未推過**(遠端沒有 $(printf '%.7s' "$head_sha")" ;;
# 0=遠端有這顆(雲端 session 的工作分支就是這種)→ 不報
# 2=問不到遠端 → 不報,但這件事本身值得知道,收在下面當脈絡
2) [ -n "$(git -C "$r" rev-list --count HEAD 2>/dev/null)" ] \
&& unknown="分支 $br 問不到遠端(沒有 remote/網路不通)——**不當成沒推**" ;;
esac
fi
fi
@@ -79,9 +192,27 @@ for r in $REPOS; do
# **這正是同一晚重複八次的那個病:檢查跑了,但檢查的對象是錯的。**
# ⇒ 基準改成「整合分支」:gitea/main > origin/main > main > 當前分支。
base="$br"
base_label=""
for cand in gitea/main origin/main main; do
if git -C "$r" rev-parse --verify -q "$cand" >/dev/null 2>&1; then base="$cand"; break; fi
done
base_label="$base"
# 🔴 2026-08-28inkstone/ISEP#90):**基準的 ref 也會過期。**
# 08-28 實測:本機 `origin/main` 落後遠端整整 4 天 ⇒ 拿它當基準,
# 「已經在遠端 main 上」的分支會被整批報成散落——包括**雲端 session 自己
# 那條工作分支**(它的內容就等於遠端 main)。
# 08-15 那次訂正的是「基準挑錯了分支」,這次是「基準挑對了但那份是舊的」:
# 同一個病的第三面——檢查跑了,但檢查的對象是舊的。
# ⇒ 問一次遠端;拿得到、而且本機真的有那顆 commit(不然算不了祖先),才換過去。
case "$base_label" in
*/*) _base_rm="${base_label%%/*}"; _base_br="${base_label#*/}" ;;
*) _base_rm="origin"; _base_br="$base_label" ;;
esac
_base_fresh="$(remote_sha_of "$r" "$_base_rm" "$_base_br")"
if [ -n "$_base_fresh" ] && git -C "$r" cat-file -e "${_base_fresh}^{commit}" 2>/dev/null; then
base="$_base_fresh"
base_label="$_base_rm/$_base_br(遠端實際那顆 $(printf '%.7s' "$_base_fresh")"
fi
if [ -n "$base" ]; then
for b in $(git -C "$r" for-each-ref --format='%(refname:short)' refs/heads/ 2>/dev/null); do
[ "$b" = "$base" ] && continue
@@ -111,7 +242,7 @@ for r in $REPOS; do
age="$(git -C "$r" log -1 --format='%ad' --date=format:'%m-%d' "$b" 2>/dev/null)"
# 用真實換行累積,不用 \n 逃脫——否則後面的 printf '%b' 會把分支名再解讀一次
stray="$stray
- $b$cnt 筆未併入 $base(最後動 $age"
- $b$cnt 筆未併入 $base_label(最後動 $age"
done
fi
@@ -123,10 +254,12 @@ for r in $REPOS; do
[ -n "$unpushed" ] && line="$line
· $unpushed"
[ -n "$stray" ] && line="$line
· 🔀 **散落分支**(有 commit 沒併回 $br):$stray"
· 🔀 **散落分支**(有 commit 沒併回 $base_label):$stray"
# 刻意保留的分支**不會自己觸發警報**(那正是加它的目的),
# 但這個 repo 若本來就有別的問題要報,就順便把它列出來當脈絡——
# 免得「保留」變成另一種看不見。
[ -n "$line" ] && [ -n "$unknown" ] && line="$line
· ❔ $unknown"
[ -n "$line" ] && [ -n "$held" ] && line="$line
· ⏸️ 刻意保留(不觸發警報,理由在 .claude/branch-holds.md):$held"
[ -n "$line" ] && problems="$problems