129fe79ecb
## #105 daemon 會改用戶的檔案(不可逆,先修) 2026-08-14 21:45 實撞:InkStoneCo 在看守清單裡,daemon 回報「已把 1 張舊卡片歸位」, 實際把 system-dev/wiki/cards/autonomy/ 整個子目錄壓平改名,16 個版控中的檔案變成刪除。 真兇是 MigrateCardNames 的一句假設:「那兩個目錄從頭到尾只有 daemon 會寫」。 那句話在 vault 上成立,在 repo 上不成立——system-dev/wiki/cards/ 是 template 的規約 路徑,而 template 就是要裝進開發者自己的 repo,那裡本來就有人家自己的檔案。 - 新增 repoguard.go:`.git` 判準(含 linked worktree),與 #104 共用同一個判準源 - 版控中的資料夾一個檔都不自動動;改成記帳+講給使用者聽,出口是 collector tidy --apply - Blocked 只算「確定是我們寫的」(帶 arcrun- 標記)——報使用者自己的卡等於發假訊息 - 落卡與工作區改走 .arcrun-rag/(與 vault 同待遇),自帶 .gitignore(*) 讓它對 git 隱形 - 非版控資料夾(含 vault)行為與 #60 第三輪完全一致,不推翻前兩輪的成果 ## #104 接上開發 repo 會把上萬個原始檔排進佇列 leo 的規格:「它要辨識這個庫已經有 wiki,那就直接 ingest 了」「只有文件要讀,程式碼不用讀」。 - 新增 ingestplan.go:掃描前先問「這個資料夾是什麼」——all/curated-wiki/docs-only - 排除靠路徑身分不靠副檔名:依賴、建置產物、templatefs 範本、linked worktree、巢狀子 repo - 策略與擋掉的數字經 TriggerPayload.Plan 走進 status.json,CLI 走 stderr(排除規則要看得見) - 子專案自己的 wiki 刻意不收,但一定列出來讓使用者知道去哪裡找 - 與 TemplateOwns(把 system-dev/ 整棵當開發用的)的對撞用身分化解,不拿掉任一條: 我們代裝的資料夾沒有 .git(舊規則照舊),他自己的 repo 有(收那份 wiki) ## 實測 #105:舊版 daemon 對真 repo 跑一輪 → git status 32 行(16 個 D + 16 個 ??,與 21:45 撞到的一字不差);新版跑同一輪 → 0 行。另有真 git init 的端到端測試。 #104:造出 leo 那棵樹的形狀(InkStoneCo + products/arcrun-rag + 3 份出貨 worktree, 967 個文件檔)→ 舊版送 91 個檔(多收的全是 -wt*ship/ 裡的 templatefs 範本、 benchmark 結果、docs-site 產物),新版送 32 個,全部落在 system-dev/wiki/ 底下。 MachineMark 規約新增唯一例外 IsMachineOwnedRel:.arcrun-rag/ 底下的檔以目錄名為標記 (.gitignore 的檔名是 git 定的,改不得)。迴歸網判準同步換過去。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UUwsLkFEGN8496bZTqhjFq
161 lines
5.8 KiB
Go
161 lines
5.8 KiB
Go
// repoguard_test.go — arcrun-rag#105 的驗收網:**把一個 git repo 加進看守清單跑一輪,
|
||
// git status 必須乾淨。**
|
||
//
|
||
// 這張網刻意分兩層:
|
||
// - 單元層(不需要 git 執行檔):MigrateCardNames 在版控資料夾裡一個檔都沒動。
|
||
// 這是 2026-08-14 那件事的直接迴歸——當時被壓平改名的正是
|
||
// `system-dev/wiki/cards/autonomy/*.md`。
|
||
// - 端到端層(需要 git):真的 `git init`、真的 commit、真的跑一輪同步、
|
||
// 真的 `git status --porcelain` ⇒ 必須是空的。沒有 git 就 skip,不假裝驗過。
|
||
package collector
|
||
|
||
import (
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"sort"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// gitOrSkip 準備一個「已 commit 乾淨」的 repo;環境沒有 git 就 skip。
|
||
func gitOrSkip(t *testing.T, dir string) {
|
||
t.Helper()
|
||
if _, err := exec.LookPath("git"); err != nil {
|
||
t.Skip("這台機器沒有 git,跳過端到端層(單元層仍然有跑)")
|
||
}
|
||
for _, args := range [][]string{
|
||
{"init", "-q"},
|
||
{"config", "user.email", "test@example.com"},
|
||
{"config", "user.name", "test"},
|
||
{"add", "-A"},
|
||
{"commit", "-q", "-m", "初始"},
|
||
} {
|
||
cmd := exec.Command("git", args...)
|
||
cmd.Dir = dir
|
||
if out, err := cmd.CombinedOutput(); err != nil {
|
||
t.Fatalf("git %v 失敗:%v\n%s", args, err, out)
|
||
}
|
||
}
|
||
}
|
||
|
||
func gitStatus(t *testing.T, dir string) []string {
|
||
t.Helper()
|
||
cmd := exec.Command("git", "status", "--porcelain")
|
||
cmd.Dir = dir
|
||
out, err := cmd.CombinedOutput()
|
||
if err != nil {
|
||
t.Fatalf("git status 失敗:%v\n%s", err, out)
|
||
}
|
||
var lines []string
|
||
for _, l := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||
if strings.TrimSpace(l) != "" {
|
||
lines = append(lines, l)
|
||
}
|
||
}
|
||
return lines
|
||
}
|
||
|
||
// 🔴 2026-08-14 21:45 那件事的直接迴歸:leo 的 `InkStoneCo` 在看守清單裡,
|
||
// daemon 把 `system-dev/wiki/cards/autonomy/` 整個子目錄壓平改名,
|
||
// 16 個版控中的檔案顯示為刪除。
|
||
func TestMigrateCardNames_VersionedRepoUntouched(t *testing.T) {
|
||
root := t.TempDir()
|
||
// leo 自己寫的知識卡,住在 template 規約的卡片產物區底下(還有自己開的子目錄)。
|
||
writeFixture(t, root, map[string]string{
|
||
"system-dev/wiki/cards/autonomy/自動派工心法.md": "# 我自己寫的",
|
||
"system-dev/wiki/cards/autonomy/接關術.md": "# 我自己寫的",
|
||
"system-dev/wiki/cards/決策紀錄.md": "# 我自己寫的",
|
||
})
|
||
// 這個資料夾在版控裡(只放 .git 目錄就夠——判準不需要 git 執行檔)。
|
||
mustMkdir(t, filepath.Join(root, ".git"))
|
||
|
||
before := snapshotTree(t, root)
|
||
mig := MigrateCardNames(root)
|
||
after := snapshotTree(t, root)
|
||
|
||
if mig.Moved != 0 {
|
||
t.Fatalf("在版控資料夾裡動了 %d 個檔——這正是 #105 的病", mig.Moved)
|
||
}
|
||
if mig.RepoRoot != root {
|
||
t.Fatalf("沒認出這是版控資料夾(RepoRoot=%q,want %q)", mig.RepoRoot, root)
|
||
}
|
||
// Blocked 只算「確定是我們寫的」——使用者自己的卡不該被算進去報一個假數字。
|
||
if mig.Blocked != 0 {
|
||
t.Fatalf("Blocked=%d,但這三張都是使用者自己的卡(沒帶 arcrun- 標記),"+
|
||
"報進去等於告訴他「有 3 個舊卡片沒整理」,那是假訊息", mig.Blocked)
|
||
}
|
||
|
||
if len(before) != len(after) {
|
||
t.Fatalf("檔案數變了:%d → %d", len(before), len(after))
|
||
}
|
||
for rel, want := range before {
|
||
got, still := after[rel]
|
||
if !still {
|
||
t.Fatalf("版控中的檔案不見了:%s(這就是 leo 看到的那 16 個刪除)", rel)
|
||
}
|
||
if got.hash != want.hash {
|
||
t.Fatalf("版控中的檔案被改了:%s", rel)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 我們自己寫的卡(帶標記、只是位置舊)在版控資料夾裡同樣不自動動,
|
||
// 但**要被算進 Blocked 並講給使用者聽**——那個數字是誠實的,因為那些確實是我們的。
|
||
func TestMigrateCardNames_VersionedRepoReportsOwnCards(t *testing.T) {
|
||
root := t.TempDir()
|
||
writeFixture(t, root, map[string]string{
|
||
"system-dev/wiki/cards/arcrun-舊卡.md": "# 我們寫的,位置舊了",
|
||
"system-dev/wiki/cards/他自己的卡.md": "# 使用者的",
|
||
})
|
||
mustMkdir(t, filepath.Join(root, ".git"))
|
||
|
||
mig := MigrateCardNames(root)
|
||
if mig.Moved != 0 {
|
||
t.Fatalf("版控資料夾裡不該動任何檔,卻動了 %d 個", mig.Moved)
|
||
}
|
||
if mig.Blocked != 1 {
|
||
t.Fatalf("Blocked=%d,want 1(只有那張帶 arcrun- 標記的是我們的)", mig.Blocked)
|
||
}
|
||
}
|
||
|
||
// 非版控資料夾的行為必須與 #60 第三輪完全一致——本次改動不准把那兩輪的成果弄壞。
|
||
func TestMigrateCardNames_NonRepoStillMigrates(t *testing.T) {
|
||
root := t.TempDir()
|
||
writeFixture(t, root, map[string]string{
|
||
"system-dev/wiki/cards/舊卡.md": "# 舊",
|
||
})
|
||
mig := MigrateCardNames(root)
|
||
if mig.Moved != 1 {
|
||
t.Fatalf("非版控資料夾應照舊自動歸位,Moved=%d want 1", mig.Moved)
|
||
}
|
||
if _, err := os.Stat(filepath.Join(root, "system-dev", "wiki", "cards", "arcrun-舊卡.md")); err != nil {
|
||
t.Fatalf("舊卡沒被改名:%v", err)
|
||
}
|
||
}
|
||
|
||
// 🔴 #105 的驗收條件本身:**把一個 git repo 加進看守清單跑一輪,git status 必須乾淨。**
|
||
func TestSyncOnce_GitRepoStaysClean(t *testing.T) {
|
||
root := t.TempDir()
|
||
writeFixture(t, root, map[string]string{
|
||
"system-dev/wiki/cards/autonomy/自動派工心法.md": "# 我自己寫的知識卡",
|
||
"system-dev/wiki/status.md": "# 現況",
|
||
"docs/請假規則.md": "# 特休 14 天",
|
||
"README.md": "# 這個專案",
|
||
"main.go": "package main",
|
||
})
|
||
gitOrSkip(t, root)
|
||
|
||
if dirty := gitStatus(t, root); len(dirty) > 0 {
|
||
t.Fatalf("前置條件就不乾淨:%v", dirty)
|
||
}
|
||
|
||
runSyncOnce(t, root)
|
||
|
||
if dirty := gitStatus(t, root); len(dirty) > 0 {
|
||
sort.Strings(dirty)
|
||
t.Fatalf("跑完一輪後 git status 髒了(#105 的驗收條件就是這個):\n%s",
|
||
strings.Join(dirty, "\n"))
|
||
}
|
||
}
|