#!/usr/bin/env sh # worklog-pending.sh — 待寫入工作日誌的暫存區(供 jsc-sdlc 階段回報與 jsc-log:worklog 使用)。 # # 為什麼要有這支腳本:SDLC 每個階段結束都要回報工作日誌連結。階段跑完卻沒寫日誌時, # 內容如果只留在對話裡,換一個工作階段就消失了,下次寫日誌也補不回來。所以暫存搬到檔案層: # 階段回報發現沒有日誌,就把該階段的內容存進本區並警告使用者;下次 jsc-log:worklog 寫入時 # 先把本區的內容一併寫進去,寫完才清掉。 # # 一次寫入的三段式(merge、commit、abort)解決同一件事:暫存只能在 wiki 寫入成功之後才清。 # merge 把暫存內容與本次條目合成一份要寫進 wiki 的檔案,同時記下這次併走了哪些暫存檔(併入清單)。 # commit wiki 寫入成功後呼叫,只刪掉併入清單上那幾個檔。 # abort wiki 寫入失敗後呼叫,暫存原封不動,只丟掉合併檔與併入清單。 # 只刪清單上的檔案,是為了保住 merge 之後、commit 之前另一個工作階段新存進來的內容。 # # 用法: # worklog-pending.sh add 把一段待寫入的日誌內容存起來,印出存放路徑 # worklog-pending.sh list 列出該 hash 的暫存檔路徑(一行一個,依時間排序) # worklog-pending.sh cat 依時間順序印出全部暫存內容 # worklog-pending.sh clear 清掉該 hash 的全部暫存(寫入日誌成功後才做) # worklog-pending.sh merge 合成「暫存內容+本次條目」,印出 MERGED= 與 CLAIM= # worklog-pending.sh commit 寫入成功後清掉併入清單上的暫存檔 # worklog-pending.sh abort 寫入失敗後保留暫存,只丟掉合併檔與併入清單 # # 為 jsc-gitea/tools/hash-id 算出的工作日誌 hash,也就是 LOG_{HASH} 的 HASH。 # 現行是完整 40 碼大寫十六進位;8 碼那種是舊規則留下的暫存檔,還沒遷移完之前一併收。 # 舊規則的首碼落在 0-9ABC 就改寫成 H 加原前 7 碼,十六個首碼有十三個會命中,所以既有的 # 8 碼暫存大多長成 H1A2B3C4。不收這種,遷移期間存進去的內容就再也取不回來。 # 存放位置: $JSC_HOME/worklog-pending/{hash}/{UTC 時間}-{pid}.md(JSC_HOME 預設 ~/.jsc) # 合併檔: $JSC_HOME/worklog-pending/.merge/{hash}-{UTC 時間}-{pid}.md # 併入清單: 同名換副檔名 .claim,一行一個被併走的暫存檔路徑 # # 結束碼: 0=成功 1=讀寫失敗 2=用法錯誤 3=該 hash 沒有暫存內容 # merge 沒有暫存內容時仍然 exit 0:合併檔至少有本次條目,呼叫端不必分兩條路走。 # # 陷阱: # - clear 與 commit 都只在日誌確實寫進 wiki 之後才呼叫。先清再寫,寫失敗就兩邊都沒有。 # - commit 只認 merge 產出的併入清單,且清單上的路徑必須落在該 hash 的暫存目錄底下, # 不然就當成用法錯誤擋下來——這道護欄擋的是拿別人的清單來刪檔。 # - 檔名帶 UTC 時間與 pid,同一秒內兩個工作階段各存各的,不會互相覆蓋。 set -eu JSC_HOME="${JSC_HOME:-$HOME/.jsc}" ROOT="$JSC_HOME/worklog-pending" MERGE_DIR="$ROOT/.merge" usage() { cat >&2 <<'EOF' 用法: worklog-pending.sh add 存一段待寫入的日誌內容 worklog-pending.sh list 列出暫存檔路徑 worklog-pending.sh cat 印出全部暫存內容 worklog-pending.sh clear 清掉全部暫存(寫入日誌成功後才做) worklog-pending.sh merge 合成「暫存內容+本次條目」,印出 MERGED= 與 CLAIM= worklog-pending.sh commit 寫入成功後清掉併入清單上的暫存檔 worklog-pending.sh abort 寫入失敗後保留暫存,只丟掉合併檔與併入清單 結束碼: 0=成功 1=讀寫失敗 2=用法錯誤 3=沒有暫存內容 EOF exit 2 } # hash 會直接拼進暫存目錄路徑,這裡是整支腳本唯一的格式驗證,擋的是路徑穿越。 # 放寬長度不等於放寬字元集:先確認每一個字都是十六進位,再比對長度。字元集這一關先過, # 「.」與「/」才進不了路徑;只看長度就會讓 ../ 那類值溜進來。 # 字元檢查逐字剝,不呼叫 grep 之類逐行比對的工具:值裡夾一個換行,逐行比對會拿第一行 # 當整個值判過,那正是路徑穿越要的缺口。 # # 這份樣式與 jsc-gitea/tools/page-name.sh、jsc-hooks/hooks/comment-scope.sh 是三份各自 # 獨立的定義,故意不共用:hook 與這個暫存區都必須自足,執行期不能相依別的 plugin 的安裝 # 路徑——那個路徑每個 CLI 不一樣,也可能根本沒裝,抓不到就等於整道護欄失效。三份的一致性 # 改在稽核時比對。 valid_hash() { raw="${1:-}" # H 開頭先剝掉再驗其餘:舊規則把首碼落在 0-9ABC 的 hash 改寫成 H 加原前 7 碼, # H 本身不是十六進位字元,不先剝就會被逐字剝那一關擋掉。剝的是固定字面 H, # 不是一個字元集,所以放進來的字仍然只有 H 與十六進位這兩種。 case "$raw" in H*) rest="${raw#H}"; hprefixed=1 ;; *) rest="$raw"; hprefixed=0 ;; esac while :; do case "$rest" in [0-9A-F]*) rest="${rest#?}" ;; *) break ;; esac done if [ -z "$rest" ]; then if [ "$hprefixed" -eq 1 ]; then case "$raw" in ????????) return 0 ;; esac else case "$raw" in ????????????????????????????????????????) return 0 ;; ????????) return 0 ;; esac fi fi echo "[jsc][工作日誌暫存][ERR]:hash 須為 40 碼大寫十六進位(現行),或 8 碼大寫十六進位、H 加 7 碼大寫十六進位(尚未遷移的舊暫存),收到「${1:-空值}」。" >&2 exit 2 } valid_claim() { # 併入清單只認 merge 產出的那一份,擋掉拿別處的清單來刪檔 case "$1" in "$MERGE_DIR"/*.claim) ;; *) echo "[jsc][工作日誌暫存][ERR]:併入清單須是 merge 產出的「$MERGE_DIR/*.claim」,收到「$1」。" >&2; exit 2 ;; esac [ -f "$1" ] || { echo "[jsc][工作日誌暫存][ERR]:找不到併入清單「$1」。" >&2; exit 1; } } cmd="${1:-}"; [ -n "$cmd" ] || usage shift || true hash="${1:-}"; [ -n "$hash" ] || usage valid_hash "$hash" dir="$ROOT/$hash" case "$cmd" in add) src="${2:-}" [ -n "$src" ] || usage [ -f "$src" ] || { echo "[jsc][工作日誌暫存][ERR]:找不到內容檔「$src」。" >&2; exit 1; } mkdir -p "$dir" || { echo "[jsc][工作日誌暫存][ERR]:建不出暫存目錄「$dir」。" >&2; exit 1; } stamp=$(date -u +%Y%m%dT%H%M%SZ) target="$dir/$stamp-$$.md" cp "$src" "$target" || { echo "[jsc][工作日誌暫存][ERR]:寫不進「$target」。" >&2; exit 1; } printf '%s\n' "$target" ;; list) [ -d "$dir" ] || exit 3 found=0 for f in "$dir"/*.md; do [ -f "$f" ] || continue printf '%s\n' "$f" found=1 done [ "$found" -eq 1 ] || exit 3 ;; cat) [ -d "$dir" ] || exit 3 found=0 for f in "$dir"/*.md; do [ -f "$f" ] || continue cat "$f" printf '\n' found=1 done [ "$found" -eq 1 ] || exit 3 ;; clear) [ -d "$dir" ] || exit 3 rm -rf "$dir" || { echo "[jsc][工作日誌暫存][ERR]:清不掉「$dir」。" >&2; exit 1; } printf '已清除暫存:%s\n' "$dir" ;; merge) # 合成這一次要寫進 wiki 的內容:暫存的在前(依時間),本次條目在後。 # 這裡只讀不刪——刪檔一律等 commit,也就是等 wiki 寫入回報成功。 src="${2:-}" [ -n "$src" ] || usage [ -f "$src" ] || { echo "[jsc][工作日誌暫存][ERR]:找不到本次條目檔「$src」。" >&2; exit 1; } mkdir -p "$MERGE_DIR" || { echo "[jsc][工作日誌暫存][ERR]:建不出合併目錄「$MERGE_DIR」。" >&2; exit 1; } stamp=$(date -u +%Y%m%dT%H%M%SZ) merged="$MERGE_DIR/$hash-$stamp-$$.md" claim="$MERGE_DIR/$hash-$stamp-$$.claim" : > "$merged" || { echo "[jsc][工作日誌暫存][ERR]:寫不進「$merged」。" >&2; exit 1; } : > "$claim" || { echo "[jsc][工作日誌暫存][ERR]:寫不進「$claim」。" >&2; exit 1; } taken=0 if [ -d "$dir" ]; then for f in "$dir"/*.md; do [ -f "$f" ] || continue cat "$f" >> "$merged" || { echo "[jsc][工作日誌暫存][ERR]:讀不到「$f」。" >&2; exit 1; } printf '\n' >> "$merged" printf '%s\n' "$f" >> "$claim" taken=$((taken + 1)) done fi cat "$src" >> "$merged" || { echo "[jsc][工作日誌暫存][ERR]:併不進本次條目「$src」。" >&2; exit 1; } printf '\n' >> "$merged" printf 'MERGED=%s\n' "$merged" printf 'CLAIM=%s\n' "$claim" printf 'PENDING=%s\n' "$taken" ;; commit) # 護欄:只有 wiki 寫入成功才輪到這一段。清單以外的暫存檔一律不動, # 因為那是 merge 之後才存進來的內容,還沒被寫進任何一頁。 claim="${2:-}" [ -n "$claim" ] || usage valid_claim "$claim" cleared=0 while IFS= read -r f; do [ -n "$f" ] || continue case "$f" in "$dir"/*) ;; *) echo "[jsc][工作日誌暫存][ERR]:併入清單裡的「$f」不在「$dir」底下,不清。" >&2; exit 2 ;; esac [ -e "$f" ] || continue rm -f "$f" || { echo "[jsc][工作日誌暫存][ERR]:清不掉「$f」。" >&2; exit 1; } cleared=$((cleared + 1)) done < "$claim" rmdir "$dir" 2>/dev/null || true rm -f "$claim" "${claim%.claim}.md" printf '已清除暫存 %s 個檔案(wiki 寫入成功後才做)。\n' "$cleared" ;; abort) # wiki 寫入失敗時走這裡:暫存一個都不刪,下一次 merge 會再把它們併進去。 claim="${2:-}" [ -n "$claim" ] || usage valid_claim "$claim" kept=0 while IFS= read -r f; do [ -n "$f" ] || continue if [ -e "$f" ]; then kept=$((kept + 1)) fi done < "$claim" rm -f "$claim" "${claim%.claim}.md" printf '暫存保留 %s 個檔案,合併檔與併入清單已丟掉。\n' "$kept" ;; *) usage ;; esac