Files
assist/tools/scan-repos.sh
jiantw83andClaude Opus 5 205da9b61f fix(assistant): 判定是不是存取庫交給 git 自己回答
原本看 .git 在不在。這台機器的工作目錄底下剛好有一個目錄的 .git 只剩
一個空的 info/,git 一句「not a git repository」——而掃描把它算成一個
存取庫,還印成「沒有 origin」。那個說法讀起來像「這個存取庫還沒接遠端」,
不像「這裡根本不是存取庫」,兩件事的處置完全不同。

改成由 git 回答,並核對它認定的頂層就是那個目錄:只問「是不是在工作樹裡」
不夠,git 會從那裡往上找,.git 壞掉時它會找到上一層的存取庫然後答「是」。
兩邊都解成實體路徑再比,掃描起點帶符號連結那一段時字串才對得上。

多一個 not_a_repo= 判定行與收尾計數,執行那一支照樣原樣轉出。
存取庫數從 19 個變 18 個。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 10:39:43 +08:00

300 lines
14 KiB
Bash
Executable File
Raw Permalink 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 sh
# scan-repos.sh — 掃出工作目錄底下的存取庫,交出 {repo} 代入點要用的那份清單。
#
# 用法:
# scan-repos.sh list [--scan-root {字面絕對路徑}] [--depth {0..3}] [--root {字面絕對根目錄}]
# scan-repos.sh paths [同上]
#
# list 一行一個存取庫,帶工作目錄、{owner}/{repo} 與對應的 REPO_{HASH} 頁名
# paths 只印工作目錄,一行一條。給 {repo} 代入點直接讀
#
# 環境變數:
# JSC_ASSIST_SCAN_ROOT 掃描起點。--scan-root 優先於它
# JSC_ASSIST_SCAN_EXCLUDE 排除清單,空白分隔,比對目錄名(不是整條路徑),可用萬用字元
# JSC_ASSIST_HASH_ID hash-id 路徑覆寫;找不到並排存取庫時才設
#
# 結束碼:
# 0 掃到至少一個存取庫,而且每一個都對得出 REPO_{HASH}、路徑也都送得進殼
# 1 掃到了,但有幾個對不出頁名或路徑被拒。可用的那幾個照印,被拒的逐行印在 rejected=
# 有 .git 卻不被 git 認的那幾個也算在這一碼裡,印在 not_a_repo=
# 2 用法錯誤:不認得的子命令或選項、選項缺值、--scan-root 不是絕對路徑、--depth 超出範圍
# 3 掃描起點沒設定,或那條路徑不是存在的目錄——**什麼都沒掃**,不等於「這台機器沒有存取庫」
# 4 掃過了,一個 git 存取庫都沒有
#
# --- 為什麼掃描起點不給預設值 ---
#
# 這份清單的用途是把存取庫路徑代進待辦簿的指令,然後在無人值守那一輪送進殼執行。
# 猜錯掃描起點的後果不是掃不到東西,是**在猜錯的那些目錄底下跑指令**,而那一輪沒有人
# 看得到它跑到哪裡去了。往上猜一層就從工作目錄變成家目錄,再往上就是整台機器。
# 所以沒設就回結束碼 3 並說清楚要設哪一個變數,不退回任何一條路徑。
# 排程那一輪的值由 schedule.sh install 當下從殼裡快照進條目,跟 wiki 存取庫那幾個變數同一條路。
#
# --- 為什麼預設只掃一層 ---
#
# 工作目錄的版面是「一個存取庫一個子目錄」,一層就夠。往下遞歸會踩到三種東西:
# 存取庫自己的 node_modules 與 vendor 底下的第三方存取庫、封存目錄裡的舊版存取庫、
# 還有 worktree 的複本。三種都會被當成現役存取庫盤點,而盤點結果讀起來完全正常。
# --depth 最多收到 3:真的有嵌套版面時夠用,而再深就不是「工作目錄那一層」了,
# 那種版面該把掃描起點指到那一層,不是把界線調鬆。
#
# --- 三種東西一定跳過,不看排除清單 ---
#
# 一、名稱以點開頭的目錄。快取、封存、暫存都藏在那裡,掃進去會把封存版當現役版盤點。
# 二、符號連結。連結會把掃描帶出掃描起點,而那條界線一旦被繞過就沒有第二道。
# 三、路徑裡有空白或殼層特殊字元的存取庫。執行那一支是用 `sh -c` 送出指令的,那條路徑
# 會被殼再解一次——帶空白的路徑會被切成兩個參數,帶金錢符號的會被展開成別的東西。
# 這種一律列進 rejected= 不交出去,不是靜靜跳過:那是一個真的存在的存取庫,
# 它沒有被盤點到這件事要有人知道。
set -u
usage() {
echo 'usage: scan-repos.sh {list|paths} [--scan-root 絕對路徑] [--depth 0..3] [--root 絕對路徑]' >&2
exit 2
}
die() { _c=$1; shift; printf '[jsc][助理掃描][ERR]:%s\n' "$*" >&2; exit "$_c"; }
# 逐項的判定行與收尾統計。paths 那個模式的標準輸出要是純路徑——呼叫端拿它當代入點來源,
# 混一行 unnamed= 進去就會被代成一條不存在的路徑然後送進殼。所以那個模式一律改走標準錯誤。
emit() { if [ "$MODE" = 'paths' ]; then printf '%s\n' "$*" >&2; else printf '%s\n' "$*"; fi; }
note() { printf '[jsc][助理掃描]:%s\n' "$*" >&2; }
warn() { printf '[jsc][助理掃描][WARN]:%s\n' "$*" >&2; }
[ "$#" -ge 1 ] || usage
MODE=$1; shift
case "$MODE" in
list|paths) ;;
*) usage ;;
esac
SCAN_ROOT=''
OPT_ROOT=''
DEPTH=1
while [ "$#" -gt 0 ]; do
case "$1" in
--scan-root) [ "$#" -ge 2 ] || usage; SCAN_ROOT=$2; shift 2 ;;
--root) [ "$#" -ge 2 ] || usage; OPT_ROOT=$2; shift 2 ;;
--depth) [ "$#" -ge 2 ] || usage; DEPTH=$2; shift 2 ;;
*) usage ;;
esac
done
case "$DEPTH" in
0|1|2|3) ;;
*) die 2 "--depth 只收 0 到 3,收到的是「$DEPTH」。0 只看掃描起點自己,1 是它底下那一層。要掃更深的版面就把掃描起點指到那一層。" ;;
esac
case "$OPT_ROOT" in
''|/*) ;;
*) die 2 "--root 要給字面絕對路徑,收到的是「$OPT_ROOT」。" ;;
esac
[ -n "$SCAN_ROOT" ] || SCAN_ROOT="${JSC_ASSIST_SCAN_ROOT:-}"
if [ -z "$SCAN_ROOT" ]; then
die 3 '掃描起點沒設定。這一支不猜:猜錯的後果是在猜錯的那些目錄底下跑指令,而無人值守那一輪沒有人看得到它跑到哪裡去了。請帶 --scan-root,或在殼裡設 JSC_ASSIST_SCAN_ROOT 之後重跑一次 schedule.sh install patrol,把值快照進排程條目。'
fi
case "$SCAN_ROOT" in
/*) ;;
*) die 2 "掃描起點要是字面絕對路徑,收到的是「$SCAN_ROOT」。權限層比對的是還沒展開的指令字面,帶變數或波浪號的路徑進不了允許清單。" ;;
esac
SCAN_ROOT=${SCAN_ROOT%/}
# 去掉尾斜線之後空字串就是根目錄。掃根目錄那一層等於把整台機器的第一層當成工作目錄,
# 那不是設定錯誤就是打錯字,兩種都不該照著跑。
[ -n "$SCAN_ROOT" ] || die 2 '掃描起點是根目錄。掃根目錄那一層等於把整台機器的第一層當成工作目錄,這一支不接。'
[ -d "$SCAN_ROOT" ] || die 3 "掃描起點不是存在的目錄:$SCAN_ROOT。什麼都沒掃,跟「這台機器沒有存取庫」不是同一件事——後者要回 4。"
TAB=$(printf '\t')
TMPD=$(mktemp -d 2>/dev/null) || die 3 '暫存目錄建不起來。'
trap 'rm -rf "$TMPD"' EXIT
# --- 找 hash-id ---
HERE=$(CDPATH= cd -P -- "$(dirname -- "$0")" && pwd -P)
ROOT="$OPT_ROOT"
[ -n "$ROOT" ] || ROOT=$(CDPATH= cd -- "$HERE/../.." 2>/dev/null && pwd -L) || ROOT=''
hash_id_sh() {
if [ -n "${JSC_ASSIST_HASH_ID:-}" ] && [ -f "$JSC_ASSIST_HASH_ID" ]; then
printf '%s' "$JSC_ASSIST_HASH_ID"; return 0
fi
for _d in "jsc-gitea" "gitea"; do
[ -n "$ROOT" ] && [ -f "$ROOT/$_d/tools/hash-id" ] && { printf '%s' "$ROOT/$_d/tools/hash-id"; return 0; }
done
return 1
}
HASH_ID=$(hash_id_sh) || HASH_ID=''
# 頁名的雜湊規則跟 jsc-sdlc 盤點頁是同一條:對那個存取庫自己的 {owner}/{repo} 取完整四十碼
# 大寫 SHA-1。共用那一支找不到就退回本機的 sha1sum——同一條規則、同一個值,只是少一層共用。
# 兩邊都算不出來就不硬湊一個頁名:湊出來的頁名指向沒有人讀的那一頁。
#
# 用哪一種先決定好,不在每一筆裡各判一次。算雜湊那一段跑在命令替換裡,也就是子殼裡,
# 在那裡設「這一輪用了退路」的旗標,回到外面就沒了——那句提示會永遠印不出來。
HASH_MODE='none'
if [ -n "$HASH_ID" ] && [ -n "$(printf '%s' probe | "$HASH_ID" 2>/dev/null)" ]; then
HASH_MODE='shared'
elif command -v sha1sum >/dev/null 2>&1; then
HASH_MODE='sha1sum'
elif command -v shasum >/dev/null 2>&1; then
HASH_MODE='shasum'
fi
hash40() { # $1=要算的字串
case "$HASH_MODE" in
shared) printf '%s' "$1" | "$HASH_ID" 2>/dev/null ;;
sha1sum) printf '%s' "$1" | sha1sum | awk '{print toupper($1)}' ;;
shasum) printf '%s' "$1" | shasum -a 1 | awk '{print toupper($1)}' ;;
*) return 1 ;;
esac
}
# --- 找候選目錄 ---
# 排除清單比對目錄名,不比對整條路徑:整條路徑會隨掃描起點變,同一份清單換一台機器就失效。
EXCLUDED_NAMES="${JSC_ASSIST_SCAN_EXCLUDE:-}"
excluded() { # $1=目錄名
[ -n "$EXCLUDED_NAMES" ] || return 1
for _pat in $EXCLUDED_NAMES; do
# shellcheck disable=SC2254
case "$1" in
$_pat) return 0 ;;
esac
done
return 1
}
CAND="$TMPD/cand.txt"
: >"$CAND"
LINKS="$TMPD/links.txt"
: >"$LINKS"
# find 預設不跟符號連結走,所以連結目錄不會出現在 -type d 的結果裡。這裡另外撈一次 -type l
# 是為了把它們印出來:一個被跳過的連結跟一個不存在的目錄,在報告上看起來不能是同一件事。
#
# 排除清單不寫進 find 的剪枝條件,改在下面逐個判定時濾掉。理由是剪掉的目錄就不會出現在
# 結果裡,也就報不出「這個被排除了」——而一份沒說自己排除了什麼的清單,讀起來跟沒有排除
# 清單一樣。代價是深度兩層以上時會走進被排除的目錄裡再一層,而深度上限是 3,所以最多一層。
if [ "$DEPTH" -ge 1 ]; then
find "$SCAN_ROOT" -mindepth 1 -maxdepth "$DEPTH" \
-name '.*' -prune -o -type l -print 2>/dev/null >"$LINKS"
find "$SCAN_ROOT" -mindepth 1 -maxdepth "$DEPTH" \
-name '.*' -prune -o -type d -print 2>/dev/null >"$CAND"
fi
# depth 0 與更深的版面都要把掃描起點自己算進來:工作目錄本身也可能就是一個存取庫。
printf '%s\n' "$SCAN_ROOT" >>"$CAND"
sort -u -o "$CAND" "$CAND"
# --- 逐個判定 ---
N_REPO=0; N_REJ=0; N_UNNAMED=0; N_EXCL=0; N_LINK=0; N_FAKE=0
OUT="$TMPD/out.txt"
: >"$OUT"
while IFS= read -r d; do
[ -n "$d" ] || continue
_name=$(basename -- "$d")
if excluded "$_name"; then
N_EXCL=$((N_EXCL + 1))
emit "excluded=$d reason=在排除清單上"
continue
fi
# .git 可能是目錄(一般存取庫),也可能是檔案(worktree 與子模組),所以先看它在不在,
# 但**在不在只是初篩,不是判定**。
[ -e "$d/.git" ] || continue
# 判定交給 git 自己。有 .git 不等於是存取庫:實測這台機器上有一個目錄底下的 .git 只剩
# 一個空的 info/,git 一句「not a git repository」,而只看 .git 在不在的掃描把它算成
# 一個存取庫、印成「沒有 origin」——那個說法讀起來像「這個存取庫還沒接遠端」,
# 不像「這裡根本不是存取庫」。兩件事的處置完全不同。
#
# 只問「是不是在工作樹裡」不夠:git 會從這個目錄往上找,$d/.git 壞掉時它會找到上一層的
# 存取庫然後回答「是」。所以要再核對它認定的頂層就是 $d 自己。兩邊都解成實體路徑再比,
# 掃描起點帶符號連結那一段時字串才對得上。
_top=$(git -C "$d" rev-parse --show-toplevel 2>/dev/null) || _top=''
_dreal=$(CDPATH= cd -P -- "$d" 2>/dev/null && pwd -P) || _dreal=''
_topreal=''
[ -n "$_top" ] && { _topreal=$(CDPATH= cd -P -- "$_top" 2>/dev/null && pwd -P) || _topreal=''; }
if [ -z "$_topreal" ] || [ "$_topreal" != "$_dreal" ]; then
N_FAKE=$((N_FAKE + 1))
if [ -z "$_top" ]; then
emit "not_a_repo=$d reason=有 .git 但 git 自己不認,多半是刪剩的殘骸"
else
emit "not_a_repo=$d reason=有 .git 但 git 認定的頂層是 $_top,這裡不是存取庫的根"
fi
continue
fi
case "$d" in
*[!A-Za-z0-9/._-]*)
N_REJ=$((N_REJ + 1))
emit "rejected=$d reason=路徑裡有空白或殼層特殊字元,送進 sh -c 會被再解一次"
continue ;;
esac
_url=$(git -C "$d" remote get-url origin 2>/dev/null) || _url=''
_slug=''
if [ -n "$_url" ]; then
# 兩種形狀:git@host:owner/repo.git 與 https://host/owner/repo.git。都只取最後兩段。
_s=${_url%.git}
_s=${_s#*://}
_s=${_s#*@}
case "$_s" in
*:*) _s=${_s#*:} ;;
esac
_s=${_s#/}
_owner=''
_repo=${_s##*/}
_rest=${_s%/*}
[ "$_rest" != "$_s" ] && _owner=${_rest##*/}
[ -n "$_owner" ] && [ -n "$_repo" ] && _slug="$_owner/$_repo"
fi
_page='-'
if [ -n "$_slug" ]; then
_h=$(hash40 "$_slug") || _h=''
[ -n "$_h" ] && _page="REPO_$_h"
fi
if [ "$_page" = '-' ]; then
N_UNNAMED=$((N_UNNAMED + 1))
if [ -z "$_slug" ]; then
emit "unnamed=$d reason=沒有 origin 遠端,或網址對不出 {owner}/{repo},算不出盤點頁頁名"
else
emit "unnamed=$d slug=$_slug reason=這台機器算不出 SHA-1,算不出盤點頁頁名"
fi
fi
N_REPO=$((N_REPO + 1))
printf '%s%s%s%s%s\n' "$d" "$TAB" "${_slug:--}" "$TAB" "$_page" >>"$OUT"
done <"$CAND"
while IFS= read -r l; do
[ -n "$l" ] || continue
N_LINK=$((N_LINK + 1))
emit "skipped_link=$l reason=符號連結,跟著走會把掃描帶出掃描起點"
done <"$LINKS"
if [ "$MODE" = 'paths' ]; then
cut -f1 "$OUT"
else
while IFS="$TAB" read -r p s g; do
printf 'repo=%s slug=%s page=%s\n' "$p" "$s" "$g"
done <"$OUT"
fi
emit "scan_root=$SCAN_ROOT depth=$DEPTH repos=$N_REPO unnamed=$N_UNNAMED rejected=$N_REJ excluded=$N_EXCL skipped_links=$N_LINK not_a_repo=$N_FAKE"
case "$HASH_MODE" in
sha1sum|shasum) note '找不到共用那一支 hash-id(jsc-gitea 的 tools/hash-id),這一輪的頁名改用本機的 SHA-1 算。兩者是同一條規則、值相同,只是少了一層共用;--root 餵對就會用共用那一支。' ;;
none) warn '這台機器既沒有共用那一支 hash-id,也沒有 sha1sum 與 shasum,這一輪一個盤點頁頁名都算不出來。存取庫路徑照樣交出去代 {repo},唯讀盤點跑得動;盤點結果沒有頁可以寫回去。' ;;
esac
[ "$N_REJ" -gt 0 ] && warn "有 $N_REJ 個存取庫的路徑送不進殼,這一份清單裡沒有它們,逐個印在上面的 rejected= 那幾行。那幾個是真的存在的存取庫,只是路徑帶了空白或殼層特殊字元——要盤點得到就得換路徑。"
[ "$N_UNNAMED" -gt 0 ] && warn "有 $N_UNNAMED 個存取庫對不出 REPO_{HASH} 盤點頁頁名,逐個印在上面的 unnamed= 那幾行。它們的路徑照樣交出去代 {repo},唯讀盤點跑得動;但盤點結果沒有頁可以寫回去。"
if [ "$N_REPO" -eq 0 ]; then
die 4 "掃過 $SCAN_ROOT 底下 $DEPTH 層,一個 git 存取庫都沒有。掃描起點指錯一層就會長這樣——工作目錄的版面是「一個存取庫一個子目錄」,指到某一個存取庫裡面就只剩它自己那一個,指到家目錄就一個都沒有。"
fi
[ "$N_FAKE" -gt 0 ] && warn "有 $N_FAKE 個目錄底下有 .git 卻不被 git 認,逐個印在上面的 not_a_repo= 那幾行。那不是「還沒接遠端的存取庫」,是刪剩的殘骸或者不是存取庫的根——只看 .git 在不在的掃描會把它算成一個存取庫。"
if [ "$N_REJ" -gt 0 ] || [ "$N_UNNAMED" -gt 0 ] || [ "$N_FAKE" -gt 0 ]; then
exit 1
fi
exit 0