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
This commit is contained in:
@@ -949,13 +949,30 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
|
||||
// arcrun-rag#60:把上一版 daemon 落下的舊卡歸位——沒帶 arcrun- 標記的改名,
|
||||
// 位置不對的(第三輪:監看根在筆記庫裡,卡卻落在看得見的 system-dev/wiki/cards/)搬進隱藏目錄。
|
||||
// 自動跑而不是叫人下指令——leo 的紅線:「他不該為了保護自己的筆記去學新選項」。
|
||||
// 只碰卡片產物區(那整個目錄只有我們會寫),目標已存在就跳過、永不刪檔,見 tidy.go。
|
||||
// 目標已存在就跳過、永不刪檔,見 tidy.go。
|
||||
// template 殘留不在這裡處理:那要人確認「這是不是你自己的 repo」,走 `collector tidy`。
|
||||
//
|
||||
// 🔴 arcrun-rag#105:監看根在版控裡時**一個檔都不動**(MigrateCardNames 自己擋),
|
||||
// 這裡負責把「沒動、以及為什麼沒動」講出來——靜默跳過等於讓使用者猜。
|
||||
if !dryRun {
|
||||
if n := MigrateCardNames(absRoot); n > 0 {
|
||||
// daemon 自己的工作區先自我忽略,之後落卡/收容/身分標記檔才不會弄髒使用者的
|
||||
// git status(#105 驗收條件就是「跑一輪 git status 必須乾淨」)。
|
||||
EnsureWorkspaceIgnored(absRoot)
|
||||
mig := MigrateCardNames(absRoot)
|
||||
if mig.Moved > 0 {
|
||||
results = append(results, DirectResult{
|
||||
Type: "warning", Path: absRoot, Status: "skipped",
|
||||
Error: fmt.Sprintf("已把 %d 張舊卡片歸位(加上 arcrun- 前綴/搬離筆記軟體看得到的位置)", n),
|
||||
Error: fmt.Sprintf("已把 %d 張舊卡片歸位(加上 arcrun- 前綴/搬離筆記軟體看得到的位置)", mig.Moved),
|
||||
})
|
||||
}
|
||||
if mig.Blocked > 0 {
|
||||
results = append(results, DirectResult{
|
||||
Type: "warning", Path: absRoot, Status: "skipped",
|
||||
Error: fmt.Sprintf(
|
||||
"這個資料夾在版本控制裡(%s),所以有 %d 個舊卡片我沒有自動整理"+
|
||||
"——改名搬移會變成你 git status 上的刪除。要整理請自己跑:"+
|
||||
"collector tidy --folder %s(先看清單,確認後再加 --apply)",
|
||||
mig.RepoRoot, mig.Blocked, absRoot),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -973,6 +990,23 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
|
||||
for k, v := range m.Entries {
|
||||
preScanEntries[k] = v
|
||||
}
|
||||
// arcrun-rag#104:走訪之前先問「這個資料夾是什麼」——是開發專案就只讀它整理好的
|
||||
// wiki(沒有 wiki 才退到文件區),是一般資料夾/筆記庫才全收。見 ingestplan.go。
|
||||
plan := PlanIngest(absRoot)
|
||||
|
||||
// 🔴 #104 的一個必然後果:curated-wiki 模式要收的正是 `system-dev/wiki/`,
|
||||
// 而 daemon-beta task 2 為了「template 代裝的產物區不要被當原稿」把整個
|
||||
// `system-dev` 列進 SkipDirNames——兩者直接對撞,不處理的話這個模式會一個檔都收不到。
|
||||
//
|
||||
// 解法不是拿掉那條保護,是**看它保護的是誰**:task 2 擋的是「**我們自己**代裝進
|
||||
// 使用者資料夾的 template 產物」;curated-wiki 模式的前提則是「**使用者自己**
|
||||
// 在他的 repo 裡整理好的知識庫」——同一個路徑,兩種身分,由 PlanIngest 分辨
|
||||
// (他的 repo 有 `.git`,我們代裝的資料夾沒有)。所以只在 curated-wiki 模式解除。
|
||||
skipDirNames := map[string]bool{"system-dev": true}
|
||||
if plan.Mode == IngestCuratedWiki && strings.HasPrefix(plan.WikiRelDir, "system-dev/") {
|
||||
skipDirNames = map[string]bool{}
|
||||
}
|
||||
|
||||
payload, err := Scan(absRoot, m, ScanOptions{
|
||||
MaxRemovedRatio: cfg.MaxRemoved,
|
||||
SkipPaths: map[string]bool{
|
||||
@@ -981,7 +1015,8 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
|
||||
filepath.Join(absRoot, "CLAUDE.md"): true,
|
||||
},
|
||||
// template 代裝後 system-dev/(wiki 產物區)不得被當原稿掃進 ingest(task 2)
|
||||
SkipDirNames: map[string]bool{"system-dev": true},
|
||||
SkipDirNames: skipDirNames,
|
||||
Plan: plan,
|
||||
})
|
||||
if err != nil {
|
||||
return append(results, DirectResult{Status: "failed", Error: err.Error()}), 1, nil, rootProgress{}
|
||||
|
||||
+9
-3
@@ -73,15 +73,21 @@ const cardsRelDir = "system-dev/wiki/cards"
|
||||
const vaultCardsRelDir = ".arcrun-rag/wiki/cards"
|
||||
|
||||
// cardsRelDirFor 決定某次萃取的卡片該落在哪個相對路徑:
|
||||
// 監看根不在任何筆記庫範圍內=cardsRelDir(一般資料夾行為,零改變);
|
||||
// 落在筆記庫範圍內=vaultCardsRelDir(隱藏目錄)。
|
||||
// 監看根既不在筆記庫、也不在版控裡=cardsRelDir(一般資料夾行為,零改變);
|
||||
// 落在筆記庫或版控範圍內=vaultCardsRelDir(隱藏目錄)。
|
||||
//
|
||||
// 🔴 arcrun-rag#60 第三輪:判準從 IsVault(只看監看根**這一層**)換成 DetectVaultContext
|
||||
// (監看根**在不在**某個筆記庫裡)。前者在「監看根是 vault 底下的子資料夾」時回 false,
|
||||
// 於是卡片落回 `<監看根>/system-dev/wiki/cards/`——那個位置就在使用者的 vault 裡面,
|
||||
// 而且不是隱藏目錄,Logseq/Obsidian 會把每一張卡收編成一頁。全文見 vault.go 第三輪那段。
|
||||
//
|
||||
// 🔴 arcrun-rag#105:**版控中的資料夾與 vault 同待遇**。理由是同一個形狀——
|
||||
// `system-dev/wiki/cards/` 在 repo 裡不是空地,那是 template 規約路徑,
|
||||
// 使用者自己的卡就住在那裡;而且不管撞不撞名,往那裡寫東西就是往他的 git status
|
||||
// 裡塞 untracked 檔。落進 `.arcrun-rag/`(配 EnsureWorkspaceIgnored 自我忽略)
|
||||
// =寫得進去、又不弄髒他的版控。
|
||||
func cardsRelDirFor(absRoot string) string {
|
||||
if DetectVaultContext(absRoot).InVault() {
|
||||
if DetectVaultContext(absRoot).InVault() || UnderVersionControl(absRoot) {
|
||||
return vaultCardsRelDir
|
||||
}
|
||||
return cardsRelDir
|
||||
|
||||
+323
@@ -0,0 +1,323 @@
|
||||
// ingestplan.go — 掃描之前先問一句「這個資料夾是什麼」,再決定要讀哪些檔
|
||||
// (arcrun-rag#104,2026-08-14 leo 規格)。
|
||||
//
|
||||
// 🔴 leo 的原話就是這支檔的規格,順序不能顛倒:
|
||||
//
|
||||
// 「這些庫都早就萃好了,所以它要**辨識這個庫已經有 wiki,那就直接 ingest 了**」
|
||||
// 「它應該**第一步查看 wiki 裡有沒有內容**,再去辨識哪些檔案是需要讀的,
|
||||
// **只有文件要讀,程式碼不用讀**」
|
||||
//
|
||||
// 🔴 為什麼非做不可(實測,非推測):leo 把 5 個資料夾接上去,App 顯示
|
||||
// 12,022 份檔案、11,183 卡在佇列。光 `InkStoneCo` 一個就 8,339 份——
|
||||
// **而那個 repo 真正整理好的知識庫只有 `system-dev/wiki/` 的幾十張,差兩個數量級。**
|
||||
// 更糟的是掃到 45 個名為 `wiki` 的目錄(8 份 `.claude/worktrees`、5 份出貨用 worktree、
|
||||
// 8 份 `templatefs` 範本、還有 `.next`/`.vercel` 建置產物)⇒ **同一份被收十幾次**,
|
||||
// 與 2026-08-13 查明的「48 萬筆廢資料」是同款成因(重複放大)。
|
||||
//
|
||||
// ⇒ 本檔回答一個問題:**這個監看根該收哪些檔**。三種答案:
|
||||
//
|
||||
// IngestAll 一般資料夾/筆記庫——收全部(行為與先前完全一致,零改變)
|
||||
// IngestCuratedWiki 版控中的專案,而且**已經有整理好的 wiki** ⇒ 只收那一份
|
||||
// IngestDocsOnly 版控中的專案,但沒有現成 wiki ⇒ 只收文件區,程式碼一律不讀
|
||||
//
|
||||
// 🔴 「不要用副檔名白名單當唯一判準」(票上的紅線):本檔的主判準是**路徑身分**
|
||||
// (這個目錄在這個 repo 裡扮演什麼角色),副檔名只是最後一道。`.md` 在 repo 裡
|
||||
// 大多不是知識——是 README、是 CHANGELOG、是 template 範本、是建置產物。
|
||||
//
|
||||
// 🔴 「排除規則要看得見」(票上的紅線,同 #121):本檔算出來的每一條理由都會經由
|
||||
// TriggerPayload.Plan 走進 status.json ⇒ 使用者看得到「有 8,000 個檔沒被收,因為它們是程式碼」,
|
||||
// 而不是安靜地少收。
|
||||
package collector
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// IngestMode=這個監看根的收檔策略。
|
||||
type IngestMode string
|
||||
|
||||
const (
|
||||
IngestAll IngestMode = "all" // 收全部(一般資料夾/筆記庫)
|
||||
IngestCuratedWiki IngestMode = "curated-wiki" // 只收現成的整理好的 wiki
|
||||
IngestDocsOnly IngestMode = "docs-only" // 只收文件區
|
||||
)
|
||||
|
||||
// IngestPlan=掃描前算出來的策略,連同「講給使用者聽的理由」。
|
||||
//
|
||||
// 🔴 Reason 不是 debug 訊息,是**產品文案**:使用者看到「12,022 個檔只送了 32 個」
|
||||
// 的當下,唯一能讓他不慌的東西就是這句話。用他的話寫,不要寫路徑術語。
|
||||
type IngestPlan struct {
|
||||
Mode IngestMode `json:"mode"`
|
||||
// RepoRoot=版控工作目錄的根(Mode != IngestAll 時非空)。
|
||||
RepoRoot string `json:"repo_root,omitempty"`
|
||||
// WikiRelDir=現成 wiki 的相對路徑(僅 IngestCuratedWiki)。
|
||||
WikiRelDir string `json:"wiki_rel_dir,omitempty"`
|
||||
// DocRelDirs=要收的文件目錄(僅 IngestDocsOnly;根層 .md 另由 keepsRootDoc 放行)。
|
||||
DocRelDirs []string `json:"doc_rel_dirs,omitempty"`
|
||||
// Reason=一句話講給使用者聽的「為什麼只收這些」。
|
||||
Reason string `json:"reason"`
|
||||
// OtherWikiDirs=這個 repo 底下**其他**子專案自己的 wiki(相對路徑)。
|
||||
// 刻意不收(見 wantsPath 的說明),但一定要列出來——不然使用者只會覺得東西不見了。
|
||||
OtherWikiDirs []string `json:"other_wiki_dirs,omitempty"`
|
||||
}
|
||||
|
||||
// curatedWikiCandidates=「整理好的知識庫」慣例位置,依優先序。
|
||||
// `system-dev/wiki` 是 system-dev-template 的規約(leo 全部的 repo 都是這個),
|
||||
// 其餘兩個是一般開源專案的慣例。
|
||||
var curatedWikiCandidates = []string{"system-dev/wiki", "docs/wiki", "wiki"}
|
||||
|
||||
// docDirCandidates=沒有現成 wiki 時,「文件住在哪」的慣例位置。
|
||||
var docDirCandidates = []string{"docs", "doc", "documentation"}
|
||||
|
||||
// noiseDirNames=任何模式下都整棵跳過的目錄名。
|
||||
//
|
||||
// 分三類,全部都是「這個 repo 的零件,不是誰的知識」:
|
||||
// - 依賴:別人的原始碼,不是使用者的
|
||||
// - 建置產物:從別的檔生出來的,收了就是同一份內容收兩次(#104 實據:`.next`/`.vercel`)
|
||||
// - 範本/樣板:`templatefs` 是我們自己要鋪給別人的檔,收自己鋪的東西最荒謬
|
||||
// (#104 實據:8 份 `collector/templatefs/system-dev/wiki`)
|
||||
var noiseDirNames = map[string]bool{
|
||||
// 依賴
|
||||
"node_modules": true, "vendor": true, "bower_components": true,
|
||||
"site-packages": true, "venv": true, "virtualenv": true, "__pycache__": true,
|
||||
"Pods": true, "Carthage": true,
|
||||
// 建置產物/快取
|
||||
"dist": true, "build": true, "out": true, "target": true, "bin": true, "obj": true,
|
||||
".next": true, ".nuxt": true, ".vercel": true, ".output": true, ".turbo": true,
|
||||
".parcel-cache": true, "coverage": true, ".pytest_cache": true, ".gradle": true,
|
||||
// 範本/樣板(我們自己鋪給別人的檔)
|
||||
"templatefs": true, "template-fs": true, "skeleton": true,
|
||||
// 測試素材(不是知識,是給程式吃的樣本)
|
||||
"testdata": true, "fixtures": true, "__fixtures__": true, "__snapshots__": true,
|
||||
}
|
||||
|
||||
// PlanIngest 決定某個監看根的收檔策略。**這支是 #104 的入口,Scan 在走訪前呼叫一次。**
|
||||
//
|
||||
// 判斷順序刻意照 leo 的原話:先問「是不是專案」,再問「有沒有現成 wiki」,最後才退到文件區。
|
||||
//
|
||||
// 為什麼「是不是專案」用版控(`.git`)判:那是唯一不必猜的訊號,而且與 #105 同一個判準
|
||||
// ——一個資料夾在版控裡,就代表裡面有人在追每個檔案的歷史,那幾乎必然是原始碼專案而不是
|
||||
// 誰的筆記本。判準只有一個地方(repoguard.go),兩張票共用,不會漂移。
|
||||
func PlanIngest(absRoot string) IngestPlan {
|
||||
repoRoot := DetectRepoRoot(absRoot)
|
||||
if repoRoot == "" {
|
||||
return IngestPlan{
|
||||
Mode: IngestAll,
|
||||
Reason: "這是一般資料夾,裡面的文件我全部都會讀。",
|
||||
}
|
||||
}
|
||||
|
||||
others := otherWikiDirs(absRoot)
|
||||
if wiki := findCuratedWiki(absRoot); wiki != "" {
|
||||
return IngestPlan{
|
||||
Mode: IngestCuratedWiki,
|
||||
RepoRoot: repoRoot,
|
||||
WikiRelDir: wiki,
|
||||
Reason: "這是一個開發專案,而且你已經整理好一份知識庫(" + wiki + ")——" +
|
||||
"我直接讀那一份就好,不再把整個專案的原始碼與零散檔案重萃一次。",
|
||||
OtherWikiDirs: others,
|
||||
}
|
||||
}
|
||||
|
||||
docs := existingDocDirs(absRoot)
|
||||
reason := "這是一個開發專案,我只讀文件、不讀程式碼。"
|
||||
if len(docs) > 0 {
|
||||
reason = "這是一個開發專案,我只讀文件(" + strings.Join(docs, "、") + ")與根目錄的說明檔,不讀程式碼。"
|
||||
}
|
||||
return IngestPlan{
|
||||
Mode: IngestDocsOnly,
|
||||
RepoRoot: repoRoot,
|
||||
DocRelDirs: docs,
|
||||
Reason: reason,
|
||||
OtherWikiDirs: others,
|
||||
}
|
||||
}
|
||||
|
||||
// findCuratedWiki 回傳第一個「存在且真的有內容」的現成 wiki 目錄(相對路徑),沒有回空字串。
|
||||
//
|
||||
// 「有內容」=底下至少有一個 `.md`。空目錄不算——不然一個剛跑完 template 安裝、
|
||||
// wiki 還沒寫的 repo 會被判成 curated-wiki,結果一個檔都不收(那比收太多更糟:
|
||||
// 使用者會以為系統壞了)。
|
||||
func findCuratedWiki(absRoot string) string {
|
||||
for _, rel := range curatedWikiCandidates {
|
||||
dir := filepath.Join(absRoot, filepath.FromSlash(rel))
|
||||
if !isDir(dir) {
|
||||
continue
|
||||
}
|
||||
if dirHasMarkdown(dir) {
|
||||
return rel
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// dirHasMarkdown 淺層回答「這個目錄樹裡有沒有 .md」,找到一個就停(不走完整棵)。
|
||||
func dirHasMarkdown(dir string) bool {
|
||||
found := false
|
||||
_ = filepath.WalkDir(dir, func(p string, d os.DirEntry, err error) error {
|
||||
if err != nil || found {
|
||||
return nil
|
||||
}
|
||||
if d.IsDir() {
|
||||
if p != dir && strings.HasPrefix(d.Name(), ".") {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if strings.EqualFold(filepath.Ext(d.Name()), ".md") && !strings.HasPrefix(d.Name(), ".") {
|
||||
found = true
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return found
|
||||
}
|
||||
|
||||
func existingDocDirs(absRoot string) []string {
|
||||
var out []string
|
||||
for _, rel := range docDirCandidates {
|
||||
if isDir(filepath.Join(absRoot, filepath.FromSlash(rel))) {
|
||||
out = append(out, rel)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// otherWikiDirs 找出監看根底下**其他**地方的 wiki(子專案自己的知識庫)。
|
||||
//
|
||||
// 🔴 為什麼找出來卻不收:leo 的 `InkStoneCo` 底下有 `products/*`、`matrix/*` 這些
|
||||
// **各自獨立的 repo**,每一個都有自己的 `system-dev/wiki`。它們是**別的專案**的知識,
|
||||
// 混進這個資料夾的知識庫裡,AR-Mira 搜一個主題就會回一堆分不清屬於誰的東西。
|
||||
// 要收哪一個,是使用者的決定——把那個子專案自己加進看守清單即可。
|
||||
//
|
||||
// 但**一定要講出來**:使用者接了一個 monorepo 卻只看到 32 張卡,不告訴他其餘的在哪,
|
||||
// 他只會覺得東西不見了(票上的紅線:不要讓他猜)。
|
||||
//
|
||||
// 走訪時套用與 Scan 相同的跳過規則(隱藏目錄、noise、linked worktree、
|
||||
// 已知的 curated 位置自己),所以出貨用 worktree 與 templatefs 的那十幾份不會列進來。
|
||||
func otherWikiDirs(absRoot string) []string {
|
||||
seen := map[string]bool{}
|
||||
for _, rel := range curatedWikiCandidates {
|
||||
seen[rel] = true
|
||||
}
|
||||
var out []string
|
||||
_ = filepath.WalkDir(absRoot, func(p string, d os.DirEntry, err error) error {
|
||||
if err != nil || !d.IsDir() || p == absRoot {
|
||||
return nil
|
||||
}
|
||||
name := d.Name()
|
||||
if strings.HasPrefix(name, ".") || noiseDirNames[name] {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
if IsLinkedWorktree(p) {
|
||||
return filepath.SkipDir // 同一個 repo 的第二份簽出,內容重複
|
||||
}
|
||||
rel, rerr := filepath.Rel(absRoot, p)
|
||||
if rerr != nil {
|
||||
return nil
|
||||
}
|
||||
relSlash := filepath.ToSlash(rel)
|
||||
if name == "wiki" && !seen[relSlash] && dirHasMarkdown(p) {
|
||||
out = append(out, relSlash)
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
})
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 走訪判準:Scan 在 WalkDir 裡逐目錄/逐檔問這兩支
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// SkipsDir 回答「走訪時要不要整棵跳過這個目錄」。relSlash 是相對監看根的路徑。
|
||||
//
|
||||
// 三類跳過,全模式適用:
|
||||
// 1. noise(依賴/建置產物/範本)——名字判準,見 noiseDirNames
|
||||
// 2. linked worktree——`.git` 是檔案。#104 實據裡的 `.claude/worktrees/…` 與
|
||||
// `products/arcrun-rag-wt60ship` 全是這一種:主 repo 的第二份簽出,收了就是重複
|
||||
// 3. 巢狀 repo(自己有 `.git` 的子目錄)——那是別的專案,見 otherWikiDirs 的說明
|
||||
//
|
||||
// 再加上模式限定的:curated-wiki 只走那一份 wiki 的路;docs-only 只走文件目錄的路。
|
||||
// (隱藏目錄由 Scan 自己擋,那條規則比本檔更早存在,不搬過來。)
|
||||
func (p IngestPlan) SkipsDir(relSlash, absPath string) bool {
|
||||
name := filepath.Base(relSlash)
|
||||
if noiseDirNames[name] {
|
||||
return true
|
||||
}
|
||||
if IsLinkedWorktree(absPath) {
|
||||
return true
|
||||
}
|
||||
// 巢狀 repo:監看根自己不算(relSlash == "." 走不到這裡,Scan 只對子目錄呼叫)。
|
||||
if IsRepoRoot(absPath) {
|
||||
return true
|
||||
}
|
||||
switch p.Mode {
|
||||
case IngestCuratedWiki:
|
||||
// 只有「通往那份 wiki 的路」與「那份 wiki 底下」要走。
|
||||
return !onPathTo(relSlash, p.WikiRelDir)
|
||||
case IngestDocsOnly:
|
||||
for _, d := range p.DocRelDirs {
|
||||
if onPathTo(relSlash, d) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// KeepsFile 回答「這個檔要不要收」。relSlash 是相對監看根的路徑。
|
||||
//
|
||||
// curated-wiki:只收那份 wiki 底下的檔。
|
||||
// docs-only:收文件目錄底下的檔,外加**根層的說明檔**(README/CONTRIBUTING 那些是
|
||||
// 專案唯一的入門文件,把它們漏掉,一個只有 README 的 repo 會變成一個檔都不收)。
|
||||
// all:全收,判準交回 Scan 原本的副檔名白名單。
|
||||
func (p IngestPlan) KeepsFile(relSlash string) bool {
|
||||
switch p.Mode {
|
||||
case IngestCuratedWiki:
|
||||
return strings.HasPrefix(relSlash, p.WikiRelDir+"/")
|
||||
case IngestDocsOnly:
|
||||
for _, d := range p.DocRelDirs {
|
||||
if strings.HasPrefix(relSlash, d+"/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return !strings.Contains(relSlash, "/") // 根層說明檔
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// OverridesTemplateOwned 回答「這個檔雖然被 TemplateOwns 認成『開發用的』,
|
||||
// 但本次策略仍然要收它嗎」。
|
||||
//
|
||||
// 🔴 只有 curated-wiki 模式、且只針對那份 wiki 底下的檔會回 true。
|
||||
//
|
||||
// 為什麼需要這個開關:`TemplateOwns` 把 `system-dev/` 整棵當成「開發用的、不該進知識庫」
|
||||
// (2026-08-06,理由是「我們代裝進使用者資料夾的 template 產物不是他的知識」)。
|
||||
// 但 leo 2026-08-14 的規格要的正是那個位置——`system-dev/wiki/` 在**他自己的 repo** 裡
|
||||
// 是他親手整理的知識庫,而且是唯一該收的東西。
|
||||
//
|
||||
// 兩條規則對撞,用**身分**化解而不是拿掉任何一條:
|
||||
// - 我們代裝的資料夾沒有 `.git` ⇒ PlanIngest 回 IngestAll ⇒ 這裡回 false ⇒ 舊規則照舊
|
||||
// - 他自己的 repo 有 `.git` 且有現成 wiki ⇒ curated-wiki ⇒ 這裡回 true ⇒ 收那份 wiki
|
||||
func (p IngestPlan) OverridesTemplateOwned(relSlash string) bool {
|
||||
if p.Mode != IngestCuratedWiki {
|
||||
return false
|
||||
}
|
||||
return strings.HasPrefix(relSlash, p.WikiRelDir+"/")
|
||||
}
|
||||
|
||||
// onPathTo 回答「relSlash 是不是 target 的祖先、target 自己、或 target 的子孫」
|
||||
// ——也就是走訪時「這條路要不要繼續走下去」。
|
||||
func onPathTo(relSlash, target string) bool {
|
||||
if relSlash == target ||
|
||||
strings.HasPrefix(relSlash, target+"/") ||
|
||||
strings.HasPrefix(target, relSlash+"/") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
// ingestplan_test.go — arcrun-rag#104 的驗收網:**接上一個開發用的 repo 時,
|
||||
// 送進知識庫的應該是那幾十張整理好的卡,不是幾千個原始檔。**
|
||||
//
|
||||
// fixture 刻意照票上那份實據的形狀造:45 個名為 wiki 的目錄裡,8 份 `.claude/worktrees`、
|
||||
// 5 份出貨用 worktree、8 份 `templatefs` 範本、還有 `.next`/`.vercel` 建置產物。
|
||||
package collector
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// eventPaths 取出本輪要送上去的檔案路徑(排序,好當實測輸出貼進 issue)。
|
||||
func eventPaths(p *TriggerPayload) []string {
|
||||
var out []string
|
||||
for _, e := range p.Events {
|
||||
out = append(out, e.Path)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// scanWithPlan 照 daemon 的真實接法跑一輪掃描(PlanIngest + curated-wiki 時解除
|
||||
// system-dev 的 SkipDirNames,見 direct.go 那段註解)。
|
||||
func scanWithPlan(t *testing.T, root string) (*TriggerPayload, IngestPlan) {
|
||||
t.Helper()
|
||||
plan := PlanIngest(root)
|
||||
skip := map[string]bool{"system-dev": true}
|
||||
if plan.Mode == IngestCuratedWiki && strings.HasPrefix(plan.WikiRelDir, "system-dev/") {
|
||||
skip = map[string]bool{}
|
||||
}
|
||||
m := &Manifest{Entries: map[string]*ManifestEntry{}}
|
||||
payload, err := Scan(root, m, ScanOptions{SkipDirNames: skip, Plan: plan})
|
||||
if err != nil {
|
||||
t.Fatalf("掃描失敗:%v", err)
|
||||
}
|
||||
return payload, plan
|
||||
}
|
||||
|
||||
// makeMonorepoFixture 造一個「leo 的 InkStoneCo」形狀的 repo。
|
||||
// 回傳「真正整理好的那份 wiki」有幾個檔——那就是唯一該被送上去的量。
|
||||
func makeMonorepoFixture(t *testing.T) (root string, curatedCount int) {
|
||||
t.Helper()
|
||||
root = t.TempDir()
|
||||
|
||||
files := map[string]string{}
|
||||
|
||||
// ① 真正整理好的知識庫(唯一該收的)
|
||||
curated := []string{"status.md", "principles.md", "mistakes.md", "decisions-summary.md", "INDEX.md"}
|
||||
for _, n := range curated {
|
||||
files["system-dev/wiki/"+n] = "# 整理好的知識:" + n
|
||||
}
|
||||
files["system-dev/wiki/cards/autonomy/自動派工心法.md"] = "# 我自己寫的卡"
|
||||
curatedCount = len(curated) + 1
|
||||
|
||||
// ② 程式碼與一般專案檔(leo:「只有文件要讀,程式碼不用讀」)
|
||||
for i := 0; i < 40; i++ {
|
||||
files[fmt.Sprintf("collector/file%02d.go", i)] = "package collector"
|
||||
files[fmt.Sprintf("collector/note%02d.md", i)] = "# 散落在程式碼旁邊的說明"
|
||||
}
|
||||
files["README.md"] = "# 專案說明"
|
||||
files["CHANGELOG.md"] = "# 版本紀錄"
|
||||
|
||||
// ③ templatefs 範本:我們自己要鋪給別人的檔,不是誰的知識(實據:8 份)
|
||||
for i := 0; i < 8; i++ {
|
||||
files[fmt.Sprintf("collector/templatefs/system-dev/wiki/status%d.md", i)] = "# 範本"
|
||||
}
|
||||
|
||||
// ④ 建置產物(實據:.next/.vercel)
|
||||
files["landing/.next/server/pages/doc.md"] = "# 建置產物"
|
||||
files["landing/.vercel/output/static/doc.md"] = "# 建置產物"
|
||||
files["landing/node_modules/some-pkg/README.md"] = "# 別人的套件"
|
||||
|
||||
for rel, body := range files {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 監看根是版控中的專案
|
||||
mustMkdir(t, filepath.Join(root, ".git"))
|
||||
|
||||
// ⑤ 出貨用 worktree(`.git` 是**檔案**)——主 repo 的第二份簽出,內容重複(實據:5 份)
|
||||
for i := 0; i < 5; i++ {
|
||||
wt := filepath.Join(root, fmt.Sprintf("products/arcrun-rag-wt%dship", i))
|
||||
mustMkdir(t, filepath.Join(wt, "system-dev", "wiki"))
|
||||
if err := os.WriteFile(filepath.Join(wt, ".git"), []byte("gitdir: /elsewhere\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, n := range curated {
|
||||
if err := os.WriteFile(filepath.Join(wt, "system-dev", "wiki", n), []byte("# 同一份 wiki 的第 N 份副本"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ⑥ 巢狀的獨立子專案(自己有 .git 目錄)——那是別的專案的知識
|
||||
sub := filepath.Join(root, "products", "arcrun-rag")
|
||||
mustMkdir(t, filepath.Join(sub, ".git"))
|
||||
mustMkdir(t, filepath.Join(sub, "system-dev", "wiki"))
|
||||
if err := os.WriteFile(filepath.Join(sub, "system-dev", "wiki", "status.md"), []byte("# 子專案自己的知識"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
return root, curatedCount
|
||||
}
|
||||
|
||||
// 🔴 #104 的驗收條件本身:接上一個開發 repo,送進去的是幾十張,不是幾千個。
|
||||
func TestPlanIngest_MonorepoSendsOnlyCuratedWiki(t *testing.T) {
|
||||
root, curatedCount := makeMonorepoFixture(t)
|
||||
|
||||
// 對照組:沒有策略時(也就是修好之前的行為)會送多少。
|
||||
baseM := &Manifest{Entries: map[string]*ManifestEntry{}}
|
||||
baseline, err := Scan(root, baseM, ScanOptions{SkipDirNames: map[string]bool{"system-dev": true}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
payload, plan := scanWithPlan(t, root)
|
||||
got := eventPaths(payload)
|
||||
|
||||
t.Logf("策略:%s(%s)", plan.Mode, plan.Reason)
|
||||
t.Logf("修好之前會送:%d 個檔|現在送:%d 個檔(策略擋掉 %d 個)",
|
||||
len(baseline.Events), len(got), payload.ExcludedByPlan)
|
||||
for _, p := range got {
|
||||
t.Logf(" → %s", p)
|
||||
}
|
||||
|
||||
if plan.Mode != IngestCuratedWiki {
|
||||
t.Fatalf("策略=%s,want %s(這個 repo 有現成的 system-dev/wiki)", plan.Mode, IngestCuratedWiki)
|
||||
}
|
||||
if len(got) != curatedCount {
|
||||
t.Fatalf("送了 %d 個檔,want %d(只有整理好的那份 wiki):%v", len(got), curatedCount, got)
|
||||
}
|
||||
for _, p := range got {
|
||||
if !strings.HasPrefix(p, "system-dev/wiki/") {
|
||||
t.Fatalf("送了不該送的檔:%s", p)
|
||||
}
|
||||
}
|
||||
// 量級檢查:這一票的實據是「差 115 倍」,修好之後不該只差一點點。
|
||||
if len(baseline.Events) < len(got)*5 {
|
||||
t.Fatalf("對照組只有 %d 個檔,fixture 沒造出「被淹沒」的形狀,這個測試證明不了什麼",
|
||||
len(baseline.Events))
|
||||
}
|
||||
}
|
||||
|
||||
// 同一份 wiki 被出貨用 worktree/templatefs 收十幾次——那是「48 萬筆廢資料」的同款成因。
|
||||
func TestPlanIngest_NoDuplicateWikiCopies(t *testing.T) {
|
||||
root, _ := makeMonorepoFixture(t)
|
||||
payload, _ := scanWithPlan(t, root)
|
||||
|
||||
seen := map[string]int{}
|
||||
for _, p := range eventPaths(payload) {
|
||||
seen[filepath.Base(p)]++
|
||||
}
|
||||
for base, n := range seen {
|
||||
if n > 1 {
|
||||
t.Fatalf("%s 被收了 %d 次——同一份內容重複放大,AR-Mira 會搜出一堆一模一樣的東西", base, n)
|
||||
}
|
||||
}
|
||||
for _, p := range eventPaths(payload) {
|
||||
for _, bad := range []string{"templatefs/", "-wt0ship/", ".next/", ".vercel/", "node_modules/"} {
|
||||
if strings.Contains(p, bad) {
|
||||
t.Fatalf("收到了不該收的 %s(命中 %q)", p, bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 排除規則要看得見:使用者要知道「有幾千個檔沒被收、為什麼」,以及「其他子專案的 wiki 在哪」。
|
||||
func TestPlanIngest_ExclusionsAreVisible(t *testing.T) {
|
||||
root, _ := makeMonorepoFixture(t)
|
||||
payload, plan := scanWithPlan(t, root)
|
||||
|
||||
if plan.Reason == "" {
|
||||
t.Fatal("沒有給使用者一句話解釋——他只會覺得系統壞了(票上的紅線)")
|
||||
}
|
||||
if !strings.Contains(plan.Reason, plan.WikiRelDir) {
|
||||
t.Fatalf("理由沒說出是讀了哪一份 wiki:%q", plan.Reason)
|
||||
}
|
||||
if payload.ExcludedByPlan <= 0 {
|
||||
t.Fatal("擋掉的檔案數是 0——那個數字就是「有 N 個檔沒被收」要顯示的東西")
|
||||
}
|
||||
// 子專案的 wiki 刻意不收,但一定要列出來讓使用者知道去哪裡找。
|
||||
if len(plan.OtherWikiDirs) == 0 {
|
||||
t.Fatal("沒列出子專案自己的 wiki——使用者接了 monorepo 只看到幾十張,會以為東西不見了")
|
||||
}
|
||||
t.Logf("理由:%s", plan.Reason)
|
||||
t.Logf("擋掉 %d 個檔|其他子專案的 wiki:%v", payload.ExcludedByPlan, plan.OtherWikiDirs)
|
||||
}
|
||||
|
||||
// 沒有現成 wiki 的 repo:退到「只讀文件、不讀程式碼」(leo 明講的第三步)。
|
||||
func TestPlanIngest_RepoWithoutWikiReadsDocsOnly(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
files := map[string]string{
|
||||
"README.md": "# 專案",
|
||||
"docs/請假規則.md": "# 特休 14 天",
|
||||
"docs/報銷政策.md": "# 每日 3000 元",
|
||||
"src/main.go": "package main",
|
||||
"src/說明.md": "# 散在程式碼旁邊",
|
||||
"internal/notes.md": "# 也是程式碼旁邊",
|
||||
}
|
||||
for rel, body := range files {
|
||||
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)
|
||||
}
|
||||
}
|
||||
mustMkdir(t, filepath.Join(root, ".git"))
|
||||
|
||||
payload, plan := scanWithPlan(t, root)
|
||||
got := eventPaths(payload)
|
||||
t.Logf("策略:%s(%s)|送:%v", plan.Mode, plan.Reason, got)
|
||||
|
||||
if plan.Mode != IngestDocsOnly {
|
||||
t.Fatalf("策略=%s,want %s", plan.Mode, IngestDocsOnly)
|
||||
}
|
||||
want := []string{"README.md", "docs/報銷政策.md", "docs/請假規則.md"}
|
||||
if strings.Join(got, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("送了 %v,want %v(程式碼旁邊的 .md 不該收)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// 一般資料夾/筆記庫:行為與修這一票之前**完全一致**,全部照收。
|
||||
// 這條是防止「修好 repo 卻把一般使用者的資料夾弄壞」——他們才是產品的主要客群。
|
||||
func TestPlanIngest_PlainFolderUnchanged(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeFixture(t, root, map[string]string{
|
||||
"會議記錄.md": "# 會議",
|
||||
"專案/請假規則.md": "# 特休",
|
||||
"專案/深/一點/報銷.md": "# 報銷",
|
||||
"src/main.go": "package main", // 非文件,本來就被副檔名白名單擋
|
||||
"隨手/notes/2026.md": "# 隨手記",
|
||||
})
|
||||
|
||||
plan := PlanIngest(root)
|
||||
if plan.Mode != IngestAll {
|
||||
t.Fatalf("一般資料夾的策略=%s,want %s——不准因為修 repo 而改變一般使用者的行為",
|
||||
plan.Mode, IngestAll)
|
||||
}
|
||||
|
||||
payload, _ := scanWithPlan(t, root)
|
||||
got := eventPaths(payload)
|
||||
want := []string{"會議記錄.md", "專案/深/一點/報銷.md", "專案/請假規則.md", "隨手/notes/2026.md"}
|
||||
sort.Strings(want)
|
||||
if strings.Join(got, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("一般資料夾送了 %v,want %v", got, want)
|
||||
}
|
||||
if payload.ExcludedByPlan != 0 {
|
||||
t.Fatalf("一般資料夾不該有任何檔被策略擋掉,卻擋了 %d 個", payload.ExcludedByPlan)
|
||||
}
|
||||
}
|
||||
|
||||
// 空的 wiki 目錄不算「已經整理好」——不然剛裝完 template 的 repo 會一個檔都不收,
|
||||
// 那比收太多更糟(使用者會以為系統壞了)。
|
||||
func TestPlanIngest_EmptyWikiFallsBackToDocs(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustMkdir(t, filepath.Join(root, "system-dev", "wiki"))
|
||||
mustMkdir(t, filepath.Join(root, ".git"))
|
||||
writeFixture(t, root, map[string]string{"docs/說明.md": "# 說明"})
|
||||
|
||||
plan := PlanIngest(root)
|
||||
if plan.Mode != IngestDocsOnly {
|
||||
t.Fatalf("wiki 是空的,策略應退到 %s,卻是 %s", IngestDocsOnly, plan.Mode)
|
||||
}
|
||||
}
|
||||
@@ -55,3 +55,44 @@ func IsMarked(base string) bool {
|
||||
func UnmarkName(base string) string {
|
||||
return strings.TrimPrefix(base, MachineMark)
|
||||
}
|
||||
|
||||
// IsMachineOwnedRel 回答「這個相對路徑是不是我們寫的東西」——**規約的完整判準**,
|
||||
// 迴歸網(vault_footprint_test.go/vault_subdir_test.go)用的就是這一支。
|
||||
//
|
||||
// 兩條路,第二條是規約唯一認可的例外:
|
||||
//
|
||||
// ① basename 帶 MachineMark ⇒ 走在使用者的檔案之間也一眼分得出(規約主體)
|
||||
// ② 路徑上**任何一層**是 `.arcrun-rag/` ⇒ **那個目錄名自己就是標記**,且它是隱藏目錄
|
||||
//
|
||||
// ②「任何一層」而不是「開頭」:呼叫端給的相對起點不一定是監看根——迴歸網比對整個
|
||||
// 筆記庫時,同一個檔的相對路徑是 `docs/.arcrun-rag/…`(監看根是 vault 底下的 docs/)。
|
||||
// 判「誰擁有這個檔」不該受呼叫端從哪裡起算影響。
|
||||
//
|
||||
// 🔴 為什麼需要 ②(arcrun-rag#105):我們得在使用者的 repo 裡放一份 `.gitignore`
|
||||
// (讓整個工作區對 git 隱形,他跑一輪 `git status` 才會是乾淨的)。那個檔名是
|
||||
// **git 定的,我們改不得**——叫 `arcrun-.gitignore` 的話 git 根本不認,功能等於沒做。
|
||||
//
|
||||
// 這不是替規約開後門:規約的目的是「打開資料夾一眼分得出哪些是機器寫的」,
|
||||
// 而 `.arcrun-rag/` 這個目錄名把整叢東西一次講完,比逐檔前綴**更強**。
|
||||
// ⚠️ 例外只有這一個。監看根底下**其他任何位置**的新檔,一律得帶前綴——
|
||||
// 想在別處寫一個「名字不能改」的檔時,先想清楚那個檔為什麼不能住進 `.arcrun-rag/`。
|
||||
func IsMachineOwnedRel(relSlash string) bool {
|
||||
if IsMarked(pathBase(relSlash)) {
|
||||
return true
|
||||
}
|
||||
for _, seg := range strings.Split(relSlash, "/") {
|
||||
if seg == workspaceRelDir {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// pathBase 取斜線路徑的最後一段(不用 filepath.Base:這裡的輸入一律是斜線分隔的
|
||||
// 相對路徑,不該受執行平台的分隔符影響)。
|
||||
func pathBase(relSlash string) string {
|
||||
if i := strings.LastIndex(relSlash, "/"); i >= 0 {
|
||||
return relSlash[i+1:]
|
||||
}
|
||||
return relSlash
|
||||
}
|
||||
|
||||
@@ -163,13 +163,30 @@ func run(args []string, mode runMode) int {
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
// arcrun-rag#104:CLI 與 daemon 走同一套收檔策略——兩邊對同一個資料夾得出不同答案
|
||||
// 本身就是 bug(同 vault.go 開頭那條「安裝器與 daemon 必須一致」)。
|
||||
// 這裡刻意**不**動 CLI 原有的 SkipDirNames 行為(它本來就沒有;`system-dev/` 由
|
||||
// TemplateOwns 擋,curated-wiki 模式的解除也走 Plan 那條路,見 scan.go)。
|
||||
plan := PlanIngest(absRoot)
|
||||
payload, err := Scan(absRoot, m, ScanOptions{
|
||||
MaxRemovedRatio: *ratio,
|
||||
SkipPaths: map[string]bool{absManifest: true}, // manifest 若住在 root 底下,不掃自己
|
||||
Plan: plan,
|
||||
})
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
// 策略走 stderr:stdout 是給程式吃的 payload JSON,不能污染。
|
||||
// 印出來不是 debug 訊息,是 #104 的紅線——排除規則要看得見。
|
||||
fmt.Fprintf(os.Stderr, "收檔策略:%s — %s\n", plan.Mode, plan.Reason)
|
||||
if payload.ExcludedByPlan > 0 {
|
||||
fmt.Fprintf(os.Stderr, "依這個策略跳過了 %d 個檔案。\n", payload.ExcludedByPlan)
|
||||
}
|
||||
if len(plan.OtherWikiDirs) > 0 {
|
||||
fmt.Fprintf(os.Stderr,
|
||||
"這個資料夾底下還有 %d 個子專案有自己的知識庫,我沒有收(要收請個別加進看守清單):%v\n",
|
||||
len(plan.OtherWikiDirs), plan.OtherWikiDirs)
|
||||
}
|
||||
|
||||
exitCode := 0
|
||||
|
||||
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
// repoguard.go — 認出「這個監看根是版本控制中的工作目錄」,並據此把 daemon 的手綁起來
|
||||
// (arcrun-rag#105,2026-08-14 leo 實撞)。
|
||||
//
|
||||
// 🔴 為什麼要有這支檔:
|
||||
//
|
||||
// 2026-08-14 21:45,`InkStoneCo` 在看守清單裡,daemon 回報「已把 1 張舊卡片歸位」,
|
||||
// 實際做的事是把 `system-dev/wiki/cards/autonomy/` 整個子目錄**壓平+改名**:
|
||||
// `cards/autonomy/X.md` → `cards/arcrun-X.md`,**16 個版控中的檔案顯示為刪除**。
|
||||
// 那些是 leo 自己寫的知識卡,不是 daemon 的產物。
|
||||
//
|
||||
// 程式沒有做錯它以為自己在做的事——`MigrateCardNames` 的註解白紙黑字寫著
|
||||
// 「那兩個目錄從頭到尾只有 daemon 會寫」。**錯的是那個假設**:
|
||||
// `system-dev/wiki/cards/` 是 system-dev-template 的規約路徑,而 template 的用途
|
||||
// 正是「裝進開發者自己的 repo」——所以在**任何裝過 template 的 repo 裡**,
|
||||
// 那個目錄裡本來就有人家自己的檔案。「只有我們會寫」在 vault 上成立,在 repo 上不成立。
|
||||
//
|
||||
// 🔴 判準:`.git` 的存在。這夠不夠?(#105 明著問了這題)
|
||||
//
|
||||
// 夠,而且它是我們手上**唯一**不必猜的訊號:
|
||||
// `.git` 在=這裡的每個檔案都有人在追它的歷史,改名/搬移都會變成 `git status` 上的
|
||||
// 刪除+新增,使用者一個 `git add -A` 就把我們的手筆 commit 進他的歷史。
|
||||
// 它會不會漏(hg/svn/沒版控但很珍貴的資料夾)?會。但漏判的代價是「少整理一次」,
|
||||
// 誤判的代價是「改掉別人版控中的檔案」——**代價不對稱,就往安全那邊倒**。
|
||||
//
|
||||
// ⇒ 兩條規則,本檔負責第一條,落點那條在 extract.go:
|
||||
//
|
||||
// ① 版控中的資料夾:**一個檔都不自動動**(改名/搬移一律停手,改成報告給使用者)。
|
||||
// ② daemon 自己要寫的東西(卡片、工作區):落進 `.arcrun-rag/`,並讓那個目錄
|
||||
// 對 git 完全隱形(見 EnsureWorkspaceIgnored)⇒ 跑完一輪 `git status` 是乾淨的。
|
||||
package collector
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// workspaceRelDir=daemon 在監看根底下唯一有權寫入的目錄。
|
||||
// 卡片產物區(vaultCardsRelDir)與收容處(legacyTemplateRelDir)都住在它底下。
|
||||
const workspaceRelDir = ".arcrun-rag"
|
||||
|
||||
// DetectRepoRoot 從 absPath 自己開始往上找最近的版控工作目錄,回傳它的根;
|
||||
// 找不到回空字串。
|
||||
//
|
||||
// 認的是 `.git` **存在**,不分目錄或檔案:
|
||||
// - 目錄=一般 clone。
|
||||
// - 檔案=linked worktree(`git worktree add` 產生,內容是 `gitdir: …`)。
|
||||
// 那一種同樣是版控中的工作目錄,同樣不能亂動;而且 #104 的實據裡
|
||||
// (`.claude/worktrees/…`、`products/arcrun-rag-wt60ship`)滿地都是。
|
||||
//
|
||||
// 停止條件與 DetectVaultContext 同一套保守原則:走到檔案系統根或使用者家目錄就停
|
||||
// (家目錄本身若是個 repo——有人會 `git init ~`——不拿它把底下每個監看根都判成版控,
|
||||
// 那種病態擺法的漏判,遠比誤判所有人便宜)。
|
||||
func DetectRepoRoot(absPath string) string {
|
||||
dir := filepath.Clean(absPath)
|
||||
home := ""
|
||||
if h, err := os.UserHomeDir(); err == nil && h != "" {
|
||||
home = filepath.Clean(h)
|
||||
}
|
||||
for {
|
||||
if home != "" && dir == home {
|
||||
return "" // 家目錄(含)以上不猜
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Join(dir, ".git")); err == nil {
|
||||
return dir
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
return "" // 檔案系統根
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
|
||||
// UnderVersionControl 是 DetectRepoRoot 的布林簡寫。
|
||||
func UnderVersionControl(absPath string) bool { return DetectRepoRoot(absPath) != "" }
|
||||
|
||||
// IsLinkedWorktree 回答「這個目錄是不是 git 的 linked worktree 根」
|
||||
// (`.git` 是檔案而非目錄)。#104 用它整棵跳過出貨用 worktree——
|
||||
// 那些是主 repo 的第二份簽出,內容與主 repo 相同,收進去就是同一份收十幾次。
|
||||
func IsLinkedWorktree(absDir string) bool {
|
||||
info, err := os.Lstat(filepath.Join(absDir, ".git"))
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
||||
// IsRepoRoot 回答「這一層自己就是版控工作目錄的根」(不往上找)。
|
||||
// #104 用它認出巢狀的子專案:監看根底下的獨立 repo 有自己的知識,
|
||||
// 但那是**另一個專案**,要收請把它自己加進看守清單。
|
||||
func IsRepoRoot(absDir string) bool {
|
||||
_, err := os.Lstat(filepath.Join(absDir, ".git"))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// workspaceIgnoreBody=寫進 `.arcrun-rag/.gitignore` 的內容。
|
||||
//
|
||||
// `*` 讓這個目錄底下的一切(含 .gitignore 自己)對 git 隱形 ⇒ 整個 `.arcrun-rag/`
|
||||
// 不會出現在 `git status` 的 untracked 清單裡。**這正是 #105 驗收條件的機制**:
|
||||
// daemon 要有地方放東西,但使用者的 `git status` 必須乾淨。
|
||||
//
|
||||
// 為什麼不去改使用者的 `.gitignore`:那是他版控中的檔案,改它就是我們自己犯的錯
|
||||
// (同一條紅線)。自帶一份放在自己的目錄裡,git 一樣認,而且他刪掉整個
|
||||
// `.arcrun-rag/` 就等於把我們留下的痕跡清乾淨,不留殘渣在他的檔案裡。
|
||||
const workspaceIgnoreBody = "# 這個資料夾是 Arcrun RAG 同步小幫手的工作區,不是你的檔案。\n" +
|
||||
"# `*` 讓整個目錄對 git 隱形,你的 git status 不會因為我們跑過而變髒。\n" +
|
||||
"# 整個資料夾可以安全刪除(下次同步會重建,已同步的紀錄會重來一次)。\n" +
|
||||
"*\n"
|
||||
|
||||
// EnsureWorkspaceIgnored 確保 `<absRoot>/.arcrun-rag/.gitignore` 存在。
|
||||
//
|
||||
// 只在監看根落在版控範圍內時才需要(非版控資料夾沒有 git status 可弄髒),
|
||||
// 但無條件呼叫也無害——多一個隱藏檔,不影響 vault(點開頭目錄筆記軟體本來就不掃)。
|
||||
//
|
||||
// 冪等且不覆蓋:檔案已存在就什麼都不做(使用者可能自己改過內容)。
|
||||
// 失敗一律靜默——寫不進去最多是 git status 多一行 untracked,不值得中斷同步。
|
||||
func EnsureWorkspaceIgnored(absRoot string) {
|
||||
dir := filepath.Join(absRoot, workspaceRelDir)
|
||||
target := filepath.Join(dir, ".gitignore")
|
||||
if _, err := os.Stat(target); err == nil {
|
||||
return
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return
|
||||
}
|
||||
_ = os.WriteFile(target, []byte(workspaceIgnoreBody), 0o644)
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
// 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"))
|
||||
}
|
||||
}
|
||||
@@ -118,6 +118,13 @@ type TriggerPayload struct {
|
||||
// DuplicateFormats=本輪偵測到、同檔名主幹的多格式重複(2026-08-07,見 FormatDuplicate)。
|
||||
// 同 Skipped:只給本機使用者看,不隨 payload 送雲端(schema additionalProperties:false 會擋)。
|
||||
DuplicateFormats []FormatDuplicate `json:"-"`
|
||||
|
||||
// Plan/ExcludedByPlan=這一輪用了什麼收檔策略、據此擋掉幾個檔(arcrun-rag#104)。
|
||||
// 同上,`json:"-"`:給本機使用者看的,不送雲端。
|
||||
// 🔴 這兩個欄位就是 #104 那條紅線的載體——「排除規則要看得見」。少了它們,
|
||||
// 使用者接上一個一萬檔的 repo 只看到 32 個進度,會以為系統壞了。
|
||||
Plan IngestPlan `json:"-"`
|
||||
ExcludedByPlan int `json:"-"`
|
||||
}
|
||||
|
||||
// FormatDuplicate=同一份內容被偵測到有多種格式並存(同檔名主幹、不同副檔名)。
|
||||
@@ -217,6 +224,9 @@ type ScanOptions struct {
|
||||
// SkipDirNames:目錄名黑名單(任一層命中整棵跳過)。daemon-beta task 2:
|
||||
// template 代裝後 `system-dev/`(wiki 產物區)不得被當成原稿掃進 ingest。
|
||||
SkipDirNames map[string]bool
|
||||
// Plan:收檔策略(arcrun-rag#104)。零值=IngestAll,行為與加這個欄位之前**完全一致**
|
||||
// ——既有呼叫端與測試不必全部改。要拿到 #104 的效果就傳 PlanIngest(root)。
|
||||
Plan IngestPlan
|
||||
}
|
||||
|
||||
const DefaultMaxRemovedRatio = 0.4
|
||||
@@ -259,11 +269,21 @@ func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) {
|
||||
var skipped []SkippedFile
|
||||
skippedOther := 0
|
||||
var skippedOtherNames []string
|
||||
// arcrun-rag#104:被策略擋掉的檔案數。**一定要數出來**——票上的紅線是
|
||||
// 「用戶要知道有 8,000 個檔沒被收,因為它們是程式碼」,不是安靜地少收。
|
||||
excludedByPlan := 0
|
||||
err := filepath.WalkDir(root, func(p string, d fs.DirEntry, werr error) error {
|
||||
if werr != nil {
|
||||
return werr
|
||||
}
|
||||
name := d.Name()
|
||||
relOf := func() (string, bool) {
|
||||
rel, rerr := filepath.Rel(root, p)
|
||||
if rerr != nil {
|
||||
return "", false
|
||||
}
|
||||
return filepath.ToSlash(rel), true
|
||||
}
|
||||
if d.IsDir() {
|
||||
if p != root && strings.HasPrefix(name, ".") {
|
||||
return filepath.SkipDir // 隱藏目錄(.git、.obsidian…)整棵跳過
|
||||
@@ -271,11 +291,23 @@ func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) {
|
||||
if p != root && opts.SkipDirNames[name] {
|
||||
return filepath.SkipDir // 名單目錄(system-dev…)整棵跳過
|
||||
}
|
||||
// #104:依策略整棵跳過(依賴/建置產物/範本/worktree/巢狀 repo/
|
||||
// 以及非本次策略要收的區域)。整棵跳掉的檔不逐一計數——
|
||||
// 那個數字對使用者沒有意義,Plan.Reason 那句話才是他要的解釋。
|
||||
if p != root {
|
||||
if rel, ok := relOf(); ok && opts.Plan.SkipsDir(rel, p) {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if strings.HasPrefix(name, ".") {
|
||||
return nil
|
||||
}
|
||||
if rel, ok := relOf(); ok && !opts.Plan.KeepsFile(rel) {
|
||||
excludedByPlan++
|
||||
return nil
|
||||
}
|
||||
if abs, aerr := filepath.Abs(p); aerr == nil && opts.SkipPaths[abs] {
|
||||
return nil
|
||||
}
|
||||
@@ -284,8 +316,17 @@ func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) {
|
||||
// template 本來就不是隱藏的,靠隱藏判斷會漏掉一大半。
|
||||
// 也不計進「有 N 個檔案沒有被整理」——那是給使用者看他自己的檔案的,
|
||||
// 我們自己鋪的東西不該佔用他的注意力。
|
||||
if rel, rerr := filepath.Rel(root, p); rerr == nil && TemplateOwns(filepath.ToSlash(rel)) {
|
||||
return nil
|
||||
//
|
||||
// 🔴 arcrun-rag#104 例外:curated-wiki 模式下,`system-dev/wiki/` **正是要收的那一份**。
|
||||
// 上面那條規則(2026-08-06)與 leo 2026-08-14 的規格直接對撞,衝突由身分化解:
|
||||
// · 我們代裝 template 的資料夾(沒有 `.git`)⇒ 那些檔是**我們鋪的**,照舊不收
|
||||
// · 使用者自己的 repo(有 `.git`)⇒ 那份 wiki 是**他寫的**,正是他要我們讀的
|
||||
// 判準只有 PlanIngest 一個地方,見 ingestplan.go。
|
||||
if rel, rerr := filepath.Rel(root, p); rerr == nil {
|
||||
relSlash := filepath.ToSlash(rel)
|
||||
if TemplateOwns(relSlash) && !opts.Plan.OverridesTemplateOwned(relSlash) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(name))
|
||||
if !allowedExt[ext] {
|
||||
@@ -505,5 +546,7 @@ func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) {
|
||||
SkippedOther: skippedOther,
|
||||
SkippedOtherNames: skippedOtherNames,
|
||||
DuplicateFormats: duplicateFormats,
|
||||
Plan: opts.Plan,
|
||||
ExcludedByPlan: excludedByPlan,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -192,18 +192,56 @@ func collectTemplateItems(absRoot string, isVault bool) []TidyItem {
|
||||
return items
|
||||
}
|
||||
|
||||
// CardMigration=MigrateCardNames 一輪的帳目。
|
||||
//
|
||||
// Blocked 不是「失敗」,是**刻意沒動**:那個資料夾在版控裡,我們沒有資格自己決定
|
||||
// 改別人版控中的檔案。它要被講出來(direct.go 收進 results ⇒ status.json ⇒ App),
|
||||
// 不是靜默跳過——#105 的紅線與 #104 同一條:「不要讓用戶猜」。
|
||||
type CardMigration struct {
|
||||
Moved int // 真的改名/搬移了幾個
|
||||
Blocked int // 認出是舊卡、但因為在版控裡而沒動的
|
||||
RepoRoot string // Blocked > 0 時:那個版控工作目錄的根
|
||||
}
|
||||
|
||||
// MigrateCardNames 把卡片產物區裡「位置不對或沒帶標記」的舊卡歸位。daemon 每輪自動呼叫。
|
||||
// 回傳實際動到的筆數。**只碰卡片產物區**,其餘一概不動。
|
||||
// **只碰卡片產物區**,其餘一概不動。
|
||||
//
|
||||
// 為什麼可以自動:那兩個目錄從頭到尾只有 daemon 會寫(見 cardsRelDirFor 的註解),
|
||||
// 不存在「誤把使用者的檔案改名」的可能。目標已存在就跳過,不覆蓋。
|
||||
// 🔴 第四輪=arcrun-rag#105(2026-08-14 實撞):**版控中的資料夾一個檔都不動。**
|
||||
//
|
||||
// 🔴 第三輪起也負責**搬移**(不只改名):監看根在 vault 裡時,舊版寫在可見目錄的卡
|
||||
// 會被搬進 `.arcrun-rag/wiki/cards/`。這是自動的,因為紅線寫著「不准要使用者去設定
|
||||
// 什麼開關才能保護自己的筆記」——他不該為了收拾機器留下的東西去學一個新指令。
|
||||
func MigrateCardNames(absRoot string) int {
|
||||
n := 0
|
||||
for _, it := range collectCardItems(absRoot) {
|
||||
// 前三輪(#60,f71e5a2/578ac2b)修的都是同一個場景的深度——「卡片會不會汙染
|
||||
// 使用者的**筆記庫**」,方向一路往「更自動」走,因為那條紅線是「不准要使用者
|
||||
// 去設定什麼開關才能保護自己的筆記」。**本輪不推翻那個方向**:非版控資料夾
|
||||
// (含 vault)的行為與 578ac2b 完全一致。
|
||||
//
|
||||
// 本輪修的是另一個場景——「監看的是他的 **git repo**」。上一版這裡的理由是
|
||||
// 「那兩個目錄從頭到尾只有 daemon 會寫,不存在誤把使用者的檔案改名的可能」。
|
||||
// 那句話在 repo 上錯了,而且錯得很貴:`system-dev/wiki/cards/` 是
|
||||
// system-dev-template 的規約路徑,而 template 就是要裝進**開發者自己的 repo**——
|
||||
// leo 的 `InkStoneCo` 裡那個目錄底下是他自己寫的知識卡(還有 `autonomy/` 這種
|
||||
// 他自己開的子目錄)。daemon 把它們壓平改名,**16 個版控中的檔案當場變成刪除**。
|
||||
//
|
||||
// ⇒ 判準加一問「這裡有沒有 `.git`」(見 repoguard.go 對「這夠不夠」的討論)。
|
||||
// 有=停手、記帳、講給使用者聽,要整理請他自己跑 `collector tidy --apply`
|
||||
// (那條路預設 dry-run,會先把要動什麼列給他看)。
|
||||
// 兩條紅線不衝突:「不要他學新選項才能保護筆記」管的是筆記庫;
|
||||
// repo 那邊 leo 的原話是「**預設應該是不動,要動要有出口**」。
|
||||
func MigrateCardNames(absRoot string) CardMigration {
|
||||
items := collectCardItems(absRoot)
|
||||
if repoRoot := DetectRepoRoot(absRoot); repoRoot != "" {
|
||||
// 只把**確定是我們寫的**(帶 arcrun- 標記、只是位置不對)算進 Blocked。
|
||||
// 沒帶標記的在 repo 裡預設就是使用者自己的檔案——把它們算進來,訊息就會變成
|
||||
// 「有 40 個舊卡片沒整理」,而那 40 張是 leo 自己寫的知識卡,
|
||||
// 報一個全錯的數字比不報更糟(#105 要的是誠實,不是熱心)。
|
||||
blocked := 0
|
||||
for _, it := range items {
|
||||
if IsMarked(filepath.Base(it.Rel)) {
|
||||
blocked++
|
||||
}
|
||||
}
|
||||
return CardMigration{Blocked: blocked, RepoRoot: repoRoot}
|
||||
}
|
||||
out := CardMigration{}
|
||||
for _, it := range items {
|
||||
from := filepath.Join(absRoot, filepath.FromSlash(it.Rel))
|
||||
to := filepath.Join(absRoot, filepath.FromSlash(it.To))
|
||||
if _, err := os.Stat(to); err == nil {
|
||||
@@ -213,10 +251,10 @@ func MigrateCardNames(absRoot string) int {
|
||||
continue
|
||||
}
|
||||
if err := os.Rename(from, to); err == nil {
|
||||
n++
|
||||
out.Moved++
|
||||
}
|
||||
}
|
||||
return n
|
||||
return out
|
||||
}
|
||||
|
||||
// Tidy 掃出所有舊產物;apply=false 只報告,apply=true 才真的改名/搬移。
|
||||
|
||||
+2
-2
@@ -250,8 +250,8 @@ func TestMigrateCardNames_TouchesOnlyCardDirs(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if n := MigrateCardNames(root); n != 1 {
|
||||
t.Fatalf("改名筆數=%d,want 1", n)
|
||||
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)
|
||||
|
||||
@@ -213,7 +213,7 @@ func TestVaultFootprint_EveryNewFileIsMarked(t *testing.T) {
|
||||
continue
|
||||
}
|
||||
added = append(added, rel)
|
||||
if !IsMarked(filepath.Base(rel)) {
|
||||
if !IsMachineOwnedRel(rel) {
|
||||
unmarked = append(unmarked, rel)
|
||||
}
|
||||
}
|
||||
@@ -312,7 +312,7 @@ func TestPlainFolderFootprint_EveryNewFileIsMarked(t *testing.T) {
|
||||
continue
|
||||
}
|
||||
newCount++
|
||||
if !IsMarked(filepath.Base(rel)) {
|
||||
if !IsMachineOwnedRel(rel) {
|
||||
t.Fatalf("一般資料夾也不准有沒帶標記的新檔:%s", rel)
|
||||
}
|
||||
}
|
||||
|
||||
+20
-12
@@ -262,7 +262,7 @@ func assertVaultUntouched(t *testing.T, vaultDir string, before, after map[strin
|
||||
t.Logf("筆記庫裡新增的檔案(%d 個):%v", len(added), added)
|
||||
|
||||
for _, rel := range added {
|
||||
if !IsMarked(filepath.Base(rel)) {
|
||||
if !IsMachineOwnedRel(rel) {
|
||||
t.Fatalf("筆記庫裡多了一個沒帶 %q 標記的檔:%s", MachineMark, rel)
|
||||
}
|
||||
if !isHiddenRel(rel) {
|
||||
@@ -324,11 +324,19 @@ func TestVaultFootprint_WatchRootInsideVault(t *testing.T) {
|
||||
if len(added) == 0 {
|
||||
t.Fatal("這一輪什麼都沒產出——紅線是「東西照樣產出、但不撞進使用者的命名空間」")
|
||||
}
|
||||
// 卡片確實落在 docs/.arcrun-rag/... 底下(在監看根裡,且是隱藏的)。
|
||||
wantPrefix := filepath.ToSlash(filepath.Join("docs", vaultCardsRelDir)) + "/"
|
||||
// 產物一律落在 docs/.arcrun-rag/ 底下(在監看根裡,且是隱藏的),
|
||||
// 其中**卡片**還要再進一層 wiki/cards/。
|
||||
// 分兩層檢查是因為工作區裡不只有卡片:#105 起還有一份 `.gitignore`
|
||||
// (讓整個工作區對 git 隱形)。它同樣是隱藏的、同樣不撞使用者的命名空間,
|
||||
// 但它本來就不該住在 cards/ 裡。
|
||||
workspacePrefix := filepath.ToSlash(filepath.Join("docs", workspaceRelDir)) + "/"
|
||||
cardsPrefix := filepath.ToSlash(filepath.Join("docs", vaultCardsRelDir)) + "/"
|
||||
for _, rel := range added {
|
||||
if !strings.HasPrefix(rel, wantPrefix) {
|
||||
t.Fatalf("產物 %s 不在預期的隱藏卡片區 %s 底下", rel, wantPrefix)
|
||||
if !strings.HasPrefix(rel, workspacePrefix) {
|
||||
t.Fatalf("產物 %s 不在預期的隱藏工作區 %s 底下", rel, workspacePrefix)
|
||||
}
|
||||
if strings.HasSuffix(rel, ".md") && !strings.HasPrefix(rel, cardsPrefix) {
|
||||
t.Fatalf("卡片 %s 不在預期的隱藏卡片區 %s 底下", rel, cardsPrefix)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -396,7 +404,7 @@ func TestVaultFootprint_SubVaultBelowWatchRoot(t *testing.T) {
|
||||
continue
|
||||
}
|
||||
produced++
|
||||
if !IsMarked(filepath.Base(rel)) {
|
||||
if !IsMachineOwnedRel(rel) {
|
||||
t.Fatalf("監看根上多了一個沒帶標記的檔:%s", rel)
|
||||
}
|
||||
}
|
||||
@@ -466,12 +474,12 @@ func TestMigrateCardNames_RelocatesVisibleCardsInsideVault(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Logf("收拾前:%v", relList(t, watch))
|
||||
n := MigrateCardNames(watch)
|
||||
mig := MigrateCardNames(watch)
|
||||
got := relList(t, watch)
|
||||
t.Logf("收拾後(搬了 %d 筆):%v", n, got)
|
||||
t.Logf("收拾後(搬了 %d 筆):%v", mig.Moved, got)
|
||||
|
||||
if n != 2 {
|
||||
t.Fatalf("搬了 %d 筆,want 2", n)
|
||||
if mig.Moved != 2 {
|
||||
t.Fatalf("搬了 %d 筆,want 2", mig.Moved)
|
||||
}
|
||||
for _, rel := range got {
|
||||
if !isHiddenRel(rel) {
|
||||
@@ -483,8 +491,8 @@ func TestMigrateCardNames_RelocatesVisibleCardsInsideVault(t *testing.T) {
|
||||
}
|
||||
|
||||
// 冪等:再跑一次不該有任何動作。
|
||||
if again := MigrateCardNames(watch); again != 0 {
|
||||
t.Fatalf("第二次跑又搬了 %d 筆,應為 0(冪等)", again)
|
||||
if again := MigrateCardNames(watch); again.Moved != 0 {
|
||||
t.Fatalf("第二次跑又搬了 %d 筆,應為 0(冪等)", again.Moved)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user