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
263 lines
9.1 KiB
Go
263 lines
9.1 KiB
Go
// tidy_test.go — arcrun-rag#60 驗收條件③:已經寫進去的舊產物要有辦法一次認出來/改名。
|
||
package collector
|
||
|
||
import (
|
||
"os"
|
||
"path/filepath"
|
||
"sort"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
func TestMarkName_Idempotent(t *testing.T) {
|
||
if got := MarkName("status.md"); got != "arcrun-status.md" {
|
||
t.Fatalf("MarkName=%q", got)
|
||
}
|
||
// 冪等:daemon 每輪自動跑、tidy 也可能重跑,不准疊成 arcrun-arcrun-。
|
||
twice := MarkName(MarkName("status.md"))
|
||
if twice != "arcrun-status.md" {
|
||
t.Fatalf("重複加標記疊起來了:%q", twice)
|
||
}
|
||
if !IsMarked("arcrun-x.md") || IsMarked("x.md") {
|
||
t.Fatal("IsMarked 判斷錯")
|
||
}
|
||
if got := UnmarkName("arcrun-x.md"); got != "x.md" {
|
||
t.Fatalf("UnmarkName=%q", got)
|
||
}
|
||
}
|
||
|
||
// 舊產物:卡片與備份檔都要被認出來並改名;使用者自己的檔一個都不准動。
|
||
func TestTidy_RenamesLegacyCardsOnly(t *testing.T) {
|
||
root := t.TempDir()
|
||
mustMkdir(t, filepath.Join(root, "logseq"))
|
||
|
||
// 使用者自己的東西(絕對不准動)
|
||
mine := map[string]string{
|
||
"journals/2026_08_10.md": "我的日記",
|
||
"pages/讀書筆記.md": "我的筆記",
|
||
}
|
||
for rel, body := range mine {
|
||
p := filepath.Join(root, filepath.FromSlash(rel))
|
||
mustMkdir(t, filepath.Dir(p))
|
||
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
|
||
// 舊版 daemon 落下的產物(兩個卡片目錄各放一份,加一個備份檔)
|
||
legacy := []string{
|
||
".arcrun-rag/wiki/cards/2026_08_10.md",
|
||
".arcrun-rag/wiki/cards/2026_08_10.md.bak-1723459200000000000",
|
||
"system-dev/wiki/cards/會議記錄.md",
|
||
}
|
||
for _, rel := range legacy {
|
||
p := filepath.Join(root, filepath.FromSlash(rel))
|
||
mustMkdir(t, filepath.Dir(p))
|
||
if err := os.WriteFile(p, []byte("機器寫的"), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
|
||
// ① dry-run:什麼都不該動
|
||
rep, err := Tidy(root, false)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
for _, rel := range legacy {
|
||
if _, serr := os.Stat(filepath.Join(root, filepath.FromSlash(rel))); serr != nil {
|
||
t.Fatalf("dry-run 竟然動了檔案:%s 不見了", rel)
|
||
}
|
||
}
|
||
// 🔴 第三輪(2026-08-12)改了這裡的期望,這是**刻意的行為變更**:
|
||
//
|
||
// 本 fixture 的 root 是 vault,卻有一張卡躺在 `system-dev/wiki/cards/`——那是
|
||
// **看得見的**目錄,Logseq/Obsidian 會把它當成一頁。第二輪只把它就地改名
|
||
// (加上 arcrun- 前綴)就算收拾完,於是「不撞名」達成了、「不要在使用者的
|
||
// 筆記庫裡多出機器頁面」沒達成。改名救不了位置。
|
||
// ⇒ 現在位置不對的卡一律**搬**進隱藏的 vaultCardsRelDir,動作是 would-move。
|
||
// 已經在隱藏目錄、只是少個前綴的,仍然是就地 would-rename(行為不變)。
|
||
var previewed []string
|
||
for _, it := range rep.Items {
|
||
previewed = append(previewed, it.Rel+" → "+it.To+"("+it.Action+")")
|
||
if it.Action != TidyActionWillRename && it.Action != TidyActionWillMove {
|
||
t.Fatalf("dry-run 出現非預期動作:%+v", it)
|
||
}
|
||
if strings.HasPrefix(it.To, cardsRelDir+"/") {
|
||
t.Fatalf("vault 裡的卡片被留在看得見的目錄:%+v", it)
|
||
}
|
||
}
|
||
sort.Strings(previewed)
|
||
t.Logf("dry-run 預覽:\n %s", strings.Join(previewed, "\n "))
|
||
if len(rep.Items) != len(legacy) {
|
||
t.Fatalf("認出來的舊產物數量不對:got %d want %d(%+v)", len(rep.Items), len(legacy), rep.Items)
|
||
}
|
||
|
||
// ② --apply:真的改名
|
||
if _, err := Tidy(root, true); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
want := []string{
|
||
".arcrun-rag/wiki/cards/arcrun-2026_08_10.md",
|
||
".arcrun-rag/wiki/cards/arcrun-2026_08_10.md.bak-1723459200000000000",
|
||
// 第三輪:這一張本來在 system-dev/wiki/cards/(看得見),現在搬進隱藏目錄。
|
||
".arcrun-rag/wiki/cards/arcrun-會議記錄.md",
|
||
}
|
||
for _, rel := range want {
|
||
if _, serr := os.Stat(filepath.Join(root, filepath.FromSlash(rel))); serr != nil {
|
||
t.Fatalf("改名後的檔案不存在:%s", rel)
|
||
}
|
||
}
|
||
for _, rel := range legacy {
|
||
if _, serr := os.Stat(filepath.Join(root, filepath.FromSlash(rel))); serr == nil {
|
||
t.Fatalf("舊名字還在——應該是改名不是複製:%s", rel)
|
||
}
|
||
}
|
||
// 使用者的檔案原封不動
|
||
for rel, body := range mine {
|
||
data, rerr := os.ReadFile(filepath.Join(root, filepath.FromSlash(rel)))
|
||
if rerr != nil {
|
||
t.Fatalf("使用者的檔案不見了:%s(%v)", rel, rerr)
|
||
}
|
||
if string(data) != body {
|
||
t.Fatalf("使用者的檔案被改了:%s → %q", rel, data)
|
||
}
|
||
}
|
||
|
||
// ③ 冪等:再跑一次沒有東西可做
|
||
rep3, err := Tidy(root, true)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(rep3.Items) != 0 {
|
||
t.Fatalf("第二次跑不該再有動作:%+v", rep3.Items)
|
||
}
|
||
}
|
||
|
||
// vault 裡的 template 殘留(status.md/mistakes.md 那批)=搬走,不刪。
|
||
func TestTidy_VaultTemplateLeftoversAreMovedNotDeleted(t *testing.T) {
|
||
root := t.TempDir()
|
||
mustMkdir(t, filepath.Join(root, "logseq"))
|
||
leftovers := map[string]string{
|
||
"system-dev/wiki/status.md": "template 的 status",
|
||
"system-dev/wiki/mistakes.md": "template 的 mistakes",
|
||
"scripts/sdd-active-check.sh": "#!/bin/sh",
|
||
}
|
||
for rel, body := range leftovers {
|
||
p := filepath.Join(root, filepath.FromSlash(rel))
|
||
mustMkdir(t, filepath.Dir(p))
|
||
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
|
||
rep, err := Tidy(root, true)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
moved := 0
|
||
for _, it := range rep.Items {
|
||
if it.Kind == TidyKindTemplate {
|
||
if it.Action != TidyActionMoved {
|
||
t.Fatalf("vault 裡的 template 殘留應該被搬走:%+v", it)
|
||
}
|
||
moved++
|
||
}
|
||
}
|
||
if moved != len(leftovers) {
|
||
t.Fatalf("搬走的數量不對:got %d want %d", moved, len(leftovers))
|
||
}
|
||
for rel, body := range leftovers {
|
||
// 原位不該還在
|
||
if _, serr := os.Stat(filepath.Join(root, filepath.FromSlash(rel))); serr == nil {
|
||
t.Fatalf("原位的殘留還在:%s", rel)
|
||
}
|
||
// 但**內容必須還找得到**——本指令從不刪東西
|
||
p := filepath.Join(root, filepath.FromSlash(legacyTemplateRelDir), filepath.FromSlash(rel))
|
||
data, rerr := os.ReadFile(p)
|
||
if rerr != nil {
|
||
t.Fatalf("搬過去的檔案讀不到(東西被弄丟了?):%s(%v)", p, rerr)
|
||
}
|
||
if string(data) != body {
|
||
t.Fatalf("搬移過程中內容變了:%s", rel)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 非 vault(很可能是使用者自己的開發 repo)=template 只列出來,一個都不准動。
|
||
func TestTidy_PlainFolderTemplateOnlyReported(t *testing.T) {
|
||
root := t.TempDir()
|
||
rel := "system-dev/wiki/status.md"
|
||
p := filepath.Join(root, filepath.FromSlash(rel))
|
||
mustMkdir(t, filepath.Dir(p))
|
||
if err := os.WriteFile(p, []byte("這是我 repo 裡真正的 status"), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
rep, err := Tidy(root, true) // 即使 --apply 也不准動
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(rep.Items) != 1 || rep.Items[0].Action != TidyActionReport {
|
||
t.Fatalf("非 vault 的 template 應該只列出不動手:%+v", rep.Items)
|
||
}
|
||
data, rerr := os.ReadFile(p)
|
||
if rerr != nil || string(data) != "這是我 repo 裡真正的 status" {
|
||
t.Fatalf("非 vault 的 template 被動了:%v / %q", rerr, data)
|
||
}
|
||
}
|
||
|
||
// 目的地已經有同名檔=跳過並回報,絕不覆蓋(safewrite.go 同一條紅線)。
|
||
func TestTidy_DoesNotClobberExistingTarget(t *testing.T) {
|
||
root := t.TempDir()
|
||
cardDir := filepath.Join(root, filepath.FromSlash(cardsRelDir))
|
||
mustMkdir(t, cardDir)
|
||
if err := os.WriteFile(filepath.Join(cardDir, "x.md"), []byte("舊的"), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := os.WriteFile(filepath.Join(cardDir, "arcrun-x.md"), []byte("新的"), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
rep, err := Tidy(root, true)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(rep.Items) != 1 || rep.Items[0].Action != TidyActionSkipped {
|
||
t.Fatalf("目的地已存在時應跳過:%+v", rep.Items)
|
||
}
|
||
if data, _ := os.ReadFile(filepath.Join(cardDir, "arcrun-x.md")); string(data) != "新的" {
|
||
t.Fatalf("既有目標被覆蓋了:%q", data)
|
||
}
|
||
if data, _ := os.ReadFile(filepath.Join(cardDir, "x.md")); string(data) != "舊的" {
|
||
t.Fatalf("來源被動了:%q", data)
|
||
}
|
||
}
|
||
|
||
// MigrateCardNames(daemon 每輪自動跑的那個)只碰卡片產物區。
|
||
func TestMigrateCardNames_TouchesOnlyCardDirs(t *testing.T) {
|
||
root := t.TempDir()
|
||
mustMkdir(t, filepath.Join(root, "logseq"))
|
||
// 卡片區的舊卡(該改名)
|
||
cardDir := filepath.Join(root, filepath.FromSlash(vaultCardsRelDir))
|
||
mustMkdir(t, cardDir)
|
||
if err := os.WriteFile(filepath.Join(cardDir, "舊卡.md"), []byte("c"), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// template 殘留(自動路不准碰——那要人確認是不是他自己的 repo)
|
||
tplDir := filepath.Join(root, "system-dev", "wiki")
|
||
mustMkdir(t, tplDir)
|
||
if err := os.WriteFile(filepath.Join(tplDir, "status.md"), []byte("s"), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
if mig := MigrateCardNames(root); mig.Moved != 1 {
|
||
t.Fatalf("改名筆數=%d,want 1", mig.Moved)
|
||
}
|
||
if _, err := os.Stat(filepath.Join(cardDir, "arcrun-舊卡.md")); err != nil {
|
||
t.Fatalf("舊卡沒被改名:%v", err)
|
||
}
|
||
if _, err := os.Stat(filepath.Join(tplDir, "status.md")); err != nil {
|
||
t.Fatalf("自動路不該碰 template 殘留,但它不見了:%v", err)
|
||
}
|
||
}
|