Files
arcrun-collector/repoguard_test.go
T
Claude 129fe79ecb fix(collector): daemon 不再改用戶版控中的檔案,接 repo 只讀整理好的 wiki(#105/#104)
## #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
2026-08-14 14:41:08 +00:00

161 lines
5.8 KiB
Go
Raw 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.
// 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=%qwant %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=%dwant 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"))
}
}