#!/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