97309b5580
票上寫的真兇是錯的。`direct.go` 那一行 `skipDirNames{"system-dev"}` 不是唯一的
排除清單——同一個 Scan 呼叫下面幾行就是 `Plan: plan`,#104 的清單一直都接著。
拿 leo 真實的 `pms` 唯讀跑一輪現行 main:策略 docs-only、送 9 個檔、node_modules 零個。
他 08-16 看到 undici 文件,是因為手上的 daemon 是 v0.18.27,而修法 cc6e500 要到
v0.18.28(08-16 16:13,7f379d0)才被戳版號——那支 commit 自己就寫著
「changelog 停在 v0.18.27,而 collector/ 早已往前走 30 個檔(…cc6e500…)」。
但那個誤判之所以會發生,是因為底下有四個真的缺陷,這一版把它們一起修掉:
① 兩張表分居兩處 ⇒ 讀源碼的人只看得到一張。
`system-dev` 的保護搬進 IngestPlan(templateOwnedDirNames),
direct.go 不再手捏第二張清單。判準只剩一個地方。
② 排除規則生不生效,取決於呼叫端記不記得傳 Plan。
改成 Scan 自己算(Mode == "" ⇒ PlanIngest)。「忘了接」這個失敗模式不存在了。
③ 一張大表把「沒有人會這樣命名」與「這是普通英文字」混在一起,於是**誤殺**。
實測:一般筆記庫 8 份筆記只送出 1 份(build/樂高作品集、out/外出旅遊、
vendor/廠商聯絡簿…全被當成建置產物),而且回報「擋掉 0 個」。
拆成三種理由,強度不同、要求的佐證也不同:
① 使用者的 .gitignore 說的(新增 ignorerules.go,git 語法的安全子集)
② 名字本身就不是人話(node_modules、__pycache__…)——無條件
③ 泛用名(build/dist/out/vendor…)——**旁邊真的擺著專案檔才算**
🔴 判準一律不看 `.git`(leo 2026-08-16:「你不需要判斷有沒有 git,
我的 KB 筆記庫也有 git,是否用 github/gitea 追蹤完全沒意義」)。
`.gitignore` 只讀內容當線索,不拿存在當門檻。
順帶:鎖定檔(pnpm-lock.yaml…)不是知識——`.yaml` 進白名單後它變成了「知識」。
④ 「排除規則要看得見」只做了一半:整棵剪掉的子樹一個都沒數(pms 實測回報 0),
而且 Plan/ExcludedByPlan 只有 CLI 讀,daemon(使用者真正走的那條路)拿到就丟。
新增 ExcludedDirs(路徑+人話理由)+ SyncStatus.FolderPlans 寫進 status.json。
實測(唯讀跑 leo 的 `/Users/youlinhsieh/Documents/tech_projects/pms`):
139 個文件檔 → 送出 7 個,全是他自己的 README/docs;
6 個資料夾整棵跳過,每個都講得出理由;node_modules 與授權條款 0 個。
測試:collector 全綠(新增 12 案,含「裸呼叫 Scan 也必須排除別人的套件」、
「不准再有第二張排除清單」的源碼層守門、筆記庫不誤殺、同名看旁邊擺什麼決定);
arcrun-app 全綠。未出貨、未推 main。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
263 lines
8.6 KiB
Go
263 lines
8.6 KiB
Go
// ignorerules.go — 把使用者自己寫的 `.gitignore` 當成「他已經宣告過的排除清單」
|
||
// (arcrun-rag#104,2026-08-16 leo 實撞)。
|
||
//
|
||
// 🔴 為什麼要有這支檔:
|
||
//
|
||
// 2026-08-16 leo 把 `tech_projects/pms` 掛上同步,九分鐘後停掉。產出的 27 張卡裡
|
||
// 16 張是 undici 這個套件的 API 文件、5 張是別人的 MIT 授權條款,他自己的只有 5 張。
|
||
// 而 `pms/.gitignore` **第一行就是 `node_modules/`**。
|
||
// ⇒ 他早就講過那不是他的東西了,是我們沒讀。
|
||
//
|
||
// 🔴 但它不能是**唯一**判準(票上的紅線):不是每個資料夾都是 git repo
|
||
//
|
||
// (同日另一個試驗品 `Logseq-plugin` 就沒有 `.git`),一般筆記庫更不會有 `.gitignore`。
|
||
// ⇒ 本檔是**三種排除理由裡最有力的那一種**,不是全部。另兩種在 ingestplan.go:
|
||
// ① 使用者自己宣告過(本檔)
|
||
// ② 名字本身就不是人話(node_modules 這一類,見 toolOwnedDirNames)
|
||
// ③ 泛用名(build/dist/out…)+ 有佐證這是程式專案(見 ambiguousBuildDirNames)
|
||
//
|
||
// 支援的語法(刻意是 git 的子集,不支援的一律「不排除」——漏判只是多收一個資料夾,
|
||
// 誤判是把使用者的東西弄不見,代價不對稱就往安全那邊倒):
|
||
//
|
||
// # 註解、空行
|
||
// name 任何深度的 name(檔或目錄)
|
||
// name/ 只有目錄
|
||
// /name 綁在這份 .gitignore 所在的目錄
|
||
// a/b 含 `/` ⇒ 同樣綁在 .gitignore 所在的目錄
|
||
// *.log ? [abc] 萬用字元(`*` 不跨 `/`)
|
||
// **/x x/** 任意深度
|
||
// !name 反向(把上面排除掉的救回來)
|
||
//
|
||
// 未支援:`\` 跳脫、大小寫不敏感檔案系統的特例、`.git/info/exclude`、全域 gitignore。
|
||
package collector
|
||
|
||
import (
|
||
"bufio"
|
||
"os"
|
||
"path/filepath"
|
||
"regexp"
|
||
"strings"
|
||
)
|
||
|
||
// IgnoreRules=一棵樹裡所有 `.gitignore` 的合集。
|
||
//
|
||
// 巢狀 `.gitignore` 照 git 的規矩:深的那份優先於淺的,同一份裡「最後命中的那條贏」。
|
||
type IgnoreRules struct {
|
||
sets []ignoreSet // 依所在目錄深度由淺至深
|
||
// Present=這棵樹裡到底有沒有 `.gitignore`。
|
||
// 🔴 **只供診斷/測試,不准拿來當任何判準的門檻**(leo 2026-08-16:
|
||
// 他的 KB 筆記庫也有 git,版控相關的訊號分辨不出「筆記庫 vs 軟體專案」)。
|
||
// 沒有 `.gitignore` 的資料夾照樣要被正確處理,有的也不代表它是軟體專案。
|
||
Present bool
|
||
}
|
||
|
||
type ignoreSet struct {
|
||
dir string // 這份 .gitignore 所在目錄,相對監看根("" =根)
|
||
pats []ignorePattern
|
||
}
|
||
|
||
type ignorePattern struct {
|
||
negate bool
|
||
dirOnly bool
|
||
// re=命中「這個路徑本身」;reUnder=命中「這個路徑底下的東西」。
|
||
//
|
||
// 🔴 為什麼要拆成兩支:`node_modules/` 是 dirOnly,但它排除的**不只是那個目錄**
|
||
// ——底下的每一個檔案也都被排除了(git 的語意)。只用一支正規表示式、
|
||
// 再靠 `dirOnly && !isDir` 一律跳過,會讓
|
||
// `node_modules/undici/docs/api/Pool.md` 逃過去——那正是這一票的頭號實據。
|
||
re *regexp.Regexp
|
||
reUnder *regexp.Regexp
|
||
raw string
|
||
}
|
||
|
||
// matches 回答這條樣式命不命中。dirOnly 的樣式只有在「路徑本身是目錄」
|
||
// 或「路徑在它底下」時才算。
|
||
func (p ignorePattern) matches(rel string, isDir bool) bool {
|
||
if p.reUnder.MatchString(rel) {
|
||
return true // 在被排除的目錄底下——與它自己是不是目錄無關
|
||
}
|
||
if p.dirOnly && !isDir {
|
||
return false
|
||
}
|
||
return p.re.MatchString(rel)
|
||
}
|
||
|
||
// maxIgnoreScanDepth=找 `.gitignore` 時最多往下鑽幾層。
|
||
//
|
||
// 為什麼要有上限:找 ignore 檔本身也要走訪,而**走訪整棵樹正是這一票要避免的事**
|
||
// (2,127 個檔、78% 在依賴目錄底下)。真實專案的 `.gitignore` 幾乎都在根或第一、二層
|
||
// (monorepo 的 `packages/*/`、`workers/*/`);再深的漏掉,代價只是那一層少一種判準,
|
||
// 上面還有 toolOwned/ambiguous 兩道接著擋。
|
||
const maxIgnoreScanDepth = 3
|
||
|
||
// LoadIgnoreRules 從監看根往下收集 `.gitignore`(深度上限 maxIgnoreScanDepth)。
|
||
//
|
||
// 收集時就套用 toolOwnedDirNames 與隱藏目錄的規則——不然光是為了找 ignore 檔,
|
||
// 就得先走進 `node_modules` 一趟,那正是我們要省掉的那件事。
|
||
func LoadIgnoreRules(absRoot string) *IgnoreRules {
|
||
r := &IgnoreRules{}
|
||
var walk func(absDir, relDir string, depth int)
|
||
walk = func(absDir, relDir string, depth int) {
|
||
if pats := parseIgnoreFile(filepath.Join(absDir, ".gitignore")); len(pats) > 0 {
|
||
r.sets = append(r.sets, ignoreSet{dir: relDir, pats: pats})
|
||
r.Present = true
|
||
}
|
||
if depth >= maxIgnoreScanDepth {
|
||
return
|
||
}
|
||
entries, err := os.ReadDir(absDir)
|
||
if err != nil {
|
||
return
|
||
}
|
||
for _, e := range entries {
|
||
if !e.IsDir() {
|
||
continue
|
||
}
|
||
name := e.Name()
|
||
if strings.HasPrefix(name, ".") || toolOwnedDirNames[name] {
|
||
continue
|
||
}
|
||
child := name
|
||
if relDir != "" {
|
||
child = relDir + "/" + name
|
||
}
|
||
walk(filepath.Join(absDir, name), child, depth+1)
|
||
}
|
||
}
|
||
walk(absRoot, "", 0)
|
||
return r
|
||
}
|
||
|
||
// Ignores 回答「使用者的 .gitignore 有沒有說不要這一個」。relSlash 相對監看根。
|
||
//
|
||
// 語意照 git:先看淺的、再看深的;每一層裡最後命中的那條贏(所以 `!` 救得回來)。
|
||
func (r *IgnoreRules) Ignores(relSlash string, isDir bool) bool {
|
||
if r == nil || len(r.sets) == 0 {
|
||
return false
|
||
}
|
||
ignored := false
|
||
for _, set := range r.sets {
|
||
rel, ok := relativeTo(relSlash, set.dir)
|
||
if !ok {
|
||
continue // 這份 .gitignore 管不到這條路徑
|
||
}
|
||
for _, p := range set.pats {
|
||
if p.matches(rel, isDir) {
|
||
ignored = !p.negate // 同一份裡「最後命中的那條贏」(所以 `!` 救得回來)
|
||
}
|
||
}
|
||
}
|
||
return ignored
|
||
}
|
||
|
||
// relativeTo 把 relSlash 換算成「相對於 base 目錄」的路徑;不在 base 底下回 false。
|
||
func relativeTo(relSlash, base string) (string, bool) {
|
||
if base == "" {
|
||
return relSlash, true
|
||
}
|
||
if relSlash == base {
|
||
return "", false
|
||
}
|
||
if strings.HasPrefix(relSlash, base+"/") {
|
||
return strings.TrimPrefix(relSlash, base+"/"), true
|
||
}
|
||
return "", false
|
||
}
|
||
|
||
func parseIgnoreFile(path string) []ignorePattern {
|
||
f, err := os.Open(path)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
defer f.Close()
|
||
|
||
var out []ignorePattern
|
||
sc := bufio.NewScanner(f)
|
||
for sc.Scan() {
|
||
line := strings.TrimRight(sc.Text(), " \t")
|
||
if line == "" || strings.HasPrefix(line, "#") {
|
||
continue
|
||
}
|
||
p, ok := compileIgnorePattern(line)
|
||
if ok {
|
||
out = append(out, p)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// compileIgnorePattern 把一條 gitignore 樣式編成 regexp。
|
||
// 看不懂的樣式回 false(=不排除任何東西),理由見檔頭的「代價不對稱」。
|
||
func compileIgnorePattern(line string) (ignorePattern, bool) {
|
||
p := ignorePattern{raw: line}
|
||
if strings.HasPrefix(line, "!") {
|
||
p.negate = true
|
||
line = line[1:]
|
||
}
|
||
if strings.HasPrefix(line, "\\") { // 跳脫未支援
|
||
return p, false
|
||
}
|
||
if strings.HasSuffix(line, "/") {
|
||
p.dirOnly = true
|
||
line = strings.TrimSuffix(line, "/")
|
||
}
|
||
// 含 `/`(非結尾)或以 `/` 開頭 ⇒ 綁在這份 .gitignore 所在目錄;否則任何深度都算。
|
||
anchored := strings.HasPrefix(line, "/") || strings.Contains(line, "/")
|
||
line = strings.TrimPrefix(line, "/")
|
||
if line == "" {
|
||
return p, false
|
||
}
|
||
|
||
body := globToRegexp(line)
|
||
head := "^"
|
||
if !anchored {
|
||
head = "^(?:.*/)?" // 不含 `/` 的樣式在任何深度都算(git 語意)
|
||
}
|
||
re, err := regexp.Compile(head + body + "$")
|
||
if err != nil {
|
||
return p, false
|
||
}
|
||
// 「在它底下」的那一支——目錄被排除,底下整棵都跟著排除。
|
||
reUnder, err := regexp.Compile(head + body + "/.*$")
|
||
if err != nil {
|
||
return p, false
|
||
}
|
||
p.re, p.reUnder = re, reUnder
|
||
return p, true
|
||
}
|
||
|
||
// globToRegexp:`**` 跨目錄、`*` 不跨 `/`、`?` 單字元、`[...]` 原樣當字元類。
|
||
func globToRegexp(glob string) string {
|
||
var b strings.Builder
|
||
for i := 0; i < len(glob); i++ {
|
||
c := glob[i]
|
||
switch c {
|
||
case '*':
|
||
if i+1 < len(glob) && glob[i+1] == '*' {
|
||
i++
|
||
if i+1 < len(glob) && glob[i+1] == '/' {
|
||
i++
|
||
b.WriteString("(?:.*/)?") // `**/` =任意層數(含零層)
|
||
} else {
|
||
b.WriteString(".*")
|
||
}
|
||
} else {
|
||
b.WriteString("[^/]*")
|
||
}
|
||
case '?':
|
||
b.WriteString("[^/]")
|
||
case '[':
|
||
end := strings.IndexByte(glob[i:], ']')
|
||
if end < 0 {
|
||
b.WriteString(regexp.QuoteMeta(string(c)))
|
||
continue
|
||
}
|
||
b.WriteString(glob[i : i+end+1])
|
||
i += end
|
||
default:
|
||
b.WriteString(regexp.QuoteMeta(string(c)))
|
||
}
|
||
}
|
||
return b.String()
|
||
}
|