#!/usr/bin/env bash # 主線那個檔「隨 repo 走」的迴歸測試(inkstone/ISEP#130) # # ── 這支在守什麼 ────────────────────────────────────────────────────── # 主線原本只住在家目錄(`~/.claude/isep-countdown/mainline.json`)。雲端是另一台機器, # 家目錄空的、clone 下來的 repo 才是它拿得到的東西 ⇒ 2026-09-05 leo: # 「routine 每天早上開啟後抓不到任務」。PR inkstone/InkStoneCo#118 把主線檔放進 # `InkStoneCo/system-dev/mainline.json`,這裡驗 ISEP 這一半: # A 讀:家目錄沒有 ⇒ 讀 repo 那份(雲端的形狀:$CLAUDE_PROJECT_DIR 是薄殼根,真身在 InkStoneCo/) # B 兩份都在 ⇒ `set_at` 較新的算數,不分本機雲端 # C 寫:`set`/`adopt`/`clear` 兩份一起動;`refresh` 那種只寫家目錄(不弄髒 InkStoneCo 工作樹) # D 哪裡都沒有 ⇒ 仍然答得出「現在沒有主線」(ISEP#82 驗收第 4 條沒有被本票弄壞) # # 用法:bash hooks/tests/mainline-repo-mirror.test.sh # 🔴 全程離線:狀態走 ISEP_COUNTDOWN_STATE_DIR、repo 那份走 TMP 底下的假 InkStoneCo、 # TICKET_HOST 指到連不上的位址。不碰真家目錄、不碰真 InkStoneCo。 set -u ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" MAINLINE="$ROOT/../scripts/mainline" LIB="$ROOT/lib/mainline.py" TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT export ISEP_COUNTDOWN_STATE_DIR="$TMP/home-state"; mkdir -p "$ISEP_COUNTDOWN_STATE_DIR" export TICKET_HOST="http://127.0.0.1:9" unset ISEP_MAINLINE_REPO_FILE ISEP_MAINLINE_FIXTURE SHELL_ROOT="$TMP/shell"; HUB="$SHELL_ROOT/InkStoneCo"; mkdir -p "$HUB/system-dev" PASS=0; FAIL=0 ok(){ printf ' ✅ %s\n' "$1"; PASS=$((PASS+1)); } no(){ printf ' ❌ %s —— %s\n' "$1" "$2"; FAIL=$((FAIL+1)); } write_ms(){ # write_ms <檔> <標題> python3 - "$1" "$2" "$3" <<'PY' import json, sys json.dump({"ref": "inkstone/Arcrun#48", "owner": "inkstone", "repo": "Arcrun", "id": 48, "title": sys.argv[2], "description": "測試用。", "due_on": "2026-09-13T23:59:59Z", "open_issues": 3, "closed_issues": 0, "members": ["inkstone/ISEP#130"], "set_at": sys.argv[3], "refreshed_at": sys.argv[3]}, open(sys.argv[1], "w"), ensure_ascii=False, indent=2) PY } show(){ CLAUDE_PROJECT_DIR="$1" python3 "$MAINLINE" 2>&1 | head -1; } echo "── A. 家目錄沒有 ⇒ 讀 repo 那份 ──" write_ms "$HUB/system-dev/mainline.json" "雲端才讀得到的那一條" "2026-09-07 07:30" OUT=$(show "$SHELL_ROOT") case "$OUT" in *"雲端才讀得到的那一條"*) ok "① \$CLAUDE_PROJECT_DIR 是薄殼根、真身在 InkStoneCo/(雲端的形狀)⇒ 答得出主線" ;; *) no "① 薄殼根形狀讀不到 repo 那份" "$OUT" ;; esac OUT=$(show "$HUB") case "$OUT" in *"雲端才讀得到的那一條"*) ok "② \$CLAUDE_PROJECT_DIR 直接是 InkStoneCo ⇒ 也答得出" ;; *) no "② InkStoneCo 根讀不到" "$OUT" ;; esac OUT=$(cd "$HUB" && env -u CLAUDE_PROJECT_DIR python3 "$MAINLINE" 2>&1 | head -1) case "$OUT" in *"雲端才讀得到的那一條"*) ok "③ 沒設 \$CLAUDE_PROJECT_DIR、只是 cwd 在 InkStoneCo ⇒ 也答得出" ;; *) no "③ cwd 形狀讀不到" "$OUT" ;; esac [ ! -e "$ISEP_COUNTDOWN_STATE_DIR/mainline.json" ] && ok "④ 只是讀,家目錄那份沒有被順手建出來" \ || no "④ 讀的時候寫了家目錄" "$(ls "$ISEP_COUNTDOWN_STATE_DIR")" echo "── B. 兩份都在 ⇒ set_at 較新的算數 ──" write_ms "$ISEP_COUNTDOWN_STATE_DIR/mainline.json" "本機舊的" "2026-09-01 10:00" OUT=$(show "$SHELL_ROOT") case "$OUT" in *"雲端才讀得到的那一條"*) ok "⑤ repo 那份較新(別台機器後標的)⇒ 用 repo 那份" ;; *) no "⑤ 沒用較新的 repo 那份" "$OUT" ;; esac write_ms "$ISEP_COUNTDOWN_STATE_DIR/mainline.json" "本機新的" "2026-09-08 09:00" OUT=$(show "$SHELL_ROOT") case "$OUT" in *"本機新的"*) ok "⑥ 家目錄那份較新 ⇒ 用家目錄那份" ;; *) no "⑥ 沒用較新的家目錄那份" "$OUT" ;; esac write_ms "$ISEP_COUNTDOWN_STATE_DIR/mainline.json" "同時標的(家目錄)" "2026-09-07 07:30" OUT=$(show "$SHELL_ROOT") case "$OUT" in *"同時標的(家目錄)"*) ok "⑦ 一樣新 ⇒ 家目錄那份(它才有 refresh 過的進度)" ;; *) no "⑦ 平手沒選家目錄" "$OUT" ;; esac echo "── C. 寫:誰該動 repo 那份 ──" R=$(CLAUDE_PROJECT_DIR="$SHELL_ROOT" python3 - "$LIB" "$HUB/system-dev/mainline.json" <<'PY' import importlib.util, json, os, sys spec = importlib.util.spec_from_file_location("ml", sys.argv[1]); ML = importlib.util.module_from_spec(spec); spec.loader.exec_module(ML) rp = sys.argv[2] before = open(rp).read() d = ML.load(); d["refreshed_at"] = "2026-09-09 00:00"; d["open_issues"] = 1 ML.save(d) # refresh 的形狀:只寫家目錄 r1 = open(rp).read() == before d["title"] = "set 之後的新主線"; d["set_at"] = "2026-09-09 00:01" ML.save(d, mirror=True) # set/adopt 的形狀:兩份一起 r2 = json.load(open(rp)).get("title") == "set 之後的新主線" r3 = json.load(open(ML.path())).get("title") == "set 之後的新主線" r4 = open(rp).read().endswith("\n") print("|".join(str(x) for x in (r1, r2, r3, r4))) PY ) [ "${R%%|*}" = "True" ] && ok "⑧ save()(refresh 的形狀)不碰 repo 那份——不弄髒 InkStoneCo 工作樹" || no "⑧ refresh 動了 repo 那份" "$R" R2=${R#*|}; [ "${R2%%|*}" = "True" ] && ok "⑨ save(mirror=True)(set/adopt 的形狀)寫進 repo 那份" || no "⑨ mirror 沒寫 repo" "$R" R3=${R2#*|}; [ "${R3%%|*}" = "True" ] && ok "⑩ 同一次也寫了家目錄那份" || no "⑩ 家目錄沒寫" "$R" [ "${R##*|}" = "True" ] && ok "⑪ repo 那份收尾有換行(diff 才不會每次多一行 no newline)" || no "⑪ 沒換行" "$R" OUT=$(CLAUDE_PROJECT_DIR="$SHELL_ROOT" python3 "$MAINLINE" clear 2>&1) if [ ! -e "$ISEP_COUNTDOWN_STATE_DIR/mainline.json" ] && [ ! -e "$HUB/system-dev/mainline.json" ]; then ok "⑫ clear ⇒ 兩份都拿掉(否則下次讀又從 repo 那份長回來)" else no "⑫ clear 沒清乾淨" "$(ls "$ISEP_COUNTDOWN_STATE_DIR" "$HUB/system-dev" 2>&1 | tr '\n' ' ')"; fi case "$OUT" in *"repo 那份也刪了"*) ok "⑬ clear 講得出 repo 那份也動了(要 commit 才到雲端)" ;; *) no "⑬ clear 沒講 repo 那份" "$OUT" ;; esac # set 要打網路,這裡只驗它印出來的那段「repo 那份寫了沒」是接在 repo_path 上的 R=$(CLAUDE_PROJECT_DIR="$SHELL_ROOT" python3 - "$LIB" "$HUB" <<'PY' import importlib.util, os, sys spec = importlib.util.spec_from_file_location("ml", sys.argv[1]); ML = importlib.util.module_from_spec(spec); spec.loader.exec_module(ML) print(ML.repo_path(for_write=True) == os.path.join(sys.argv[2], "system-dev", "mainline.json")) PY ) [ "$R" = "True" ] && ok "⑭ 檔還不存在時,寫的目標仍指到 InkStoneCo/system-dev/(set 建得出它)" || no "⑭ 寫的目標不對" "$R" echo "── D. 哪裡都沒有 ⇒ 仍然答得出「現在沒有主線」 ──" OUT=$(show "$SHELL_ROOT") case "$OUT" in *"現在沒有主線"*) ok "⑮ 兩份都沒有 ⇒ 「現在沒有主線」(ISEP#82 第 4 條沒被弄壞)" ;; *) no "⑮ 沒主線時答不出來" "$OUT" ;; esac OUT=$(ISEP_MAINLINE_REPO_FILE=none show "$SHELL_ROOT") case "$OUT" in *"現在沒有主線"*) ok "⑯ ISEP_MAINLINE_REPO_FILE=none ⇒ 不看 repo(既有測試靠它把外界定住)" ;; *) no "⑯ none 沒生效" "$OUT" ;; esac printf 'not json' > "$HUB/system-dev/mainline.json" OUT=$(show "$SHELL_ROOT") case "$OUT" in *"現在沒有主線"*) ok "⑰ repo 那份壞掉 ⇒ 當成沒有,不炸" ;; *) no "⑰ 壞檔炸了" "$OUT" ;; esac echo; echo "通過 $PASS 條,失敗 $FAIL 條" [ "$FAIL" -eq 0 ]