From 352d7a0b81b27172c229a2d4c0b4416a7f2532ea Mon Sep 17 00:00:00 2001 From: claude-code Date: Fri, 28 Aug 2026 00:00:36 +0000 Subject: [PATCH] =?UTF-8?q?=E6=9C=AA=E6=8E=A8=E8=AD=A6=E5=AF=9F=E6=94=B9?= =?UTF-8?q?=E7=94=A8=E3=80=8C=E9=81=A0=E7=AB=AF=E6=9C=89=E6=B2=92=E6=9C=89?= =?UTF-8?q?=E9=80=99=E9=A1=86=20commit=E3=80=8D=E5=88=A4=E6=96=B7=EF=BC=8C?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E7=94=A8=E3=80=8C=E6=9C=89=E6=B2=92=E6=9C=89?= =?UTF-8?q?=20upstream=E3=80=8D=EF=BC=88inkstone/ISEP#90=20=E2=91=A0?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 雲端 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 條全綠 ⇒ 測試有鑑別力,不是改到寬鬆。 --- hooks/tests/unpushed-police.test.sh | 137 ++++++++++++++++++++++++++ hooks/unpushed-police.sh | 147 ++++++++++++++++++++++++++-- 2 files changed, 277 insertions(+), 7 deletions(-) create mode 100755 hooks/tests/unpushed-police.test.sh diff --git a/hooks/tests/unpushed-police.test.sh b/hooks/tests/unpushed-police.test.sh new file mode 100755 index 0000000..197fbe6 --- /dev/null +++ b/hooks/tests/unpushed-police.test.sh @@ -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 <期望 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_active=true → 放行"; PASS=$((PASS+1)) +else printf ' ❌ %s(exit=%s)\n' "⑥ stop_hook_active=true → 放行" "$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 diff --git a/hooks/unpushed-police.sh b/hooks/unpushed-police.sh index d9a6219..a5b1e2b 100755 --- a/hooks/unpushed-police.sh +++ b/hooks/unpushed-police.sh @@ -35,10 +35,107 @@ except Exception: print(False) ' 2>/dev/null)" [ "$stop_active" = "True" ] && exit 0 +# ══════════════════════════════════════════════════════════════════════ +# 「遠端到底有沒有這顆 commit」——本檔唯一的判準(inkstone/ISEP#90,2026-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 → 印出「 」數行;問不到就印空、離開碼 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 → 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 <遠端名> <分支名> → 印出遠端那顆 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-28(inkstone/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-28(inkstone/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