544 lines
20 KiB
Go
544 lines
20 KiB
Go
// cleanup.go — 斷連時把 daemon 自己寫進使用者資料夾的東西收回來
|
||
// (arcrun-rag#138,leo 2026-08-24:「碎型會在每個資料夾安裝隱藏資料夾,人工刪除不容易,
|
||
// 所以當它斷連,應該要可以幫它把 Arcrun RAG 建立的資料夾刪掉」)。
|
||
//
|
||
// 🔴 這是本 repo 唯一一支會**刪使用者資料夾裡的檔**的程式碼。tidy.go 的紅線是
|
||
// 「一行 os.Remove 都沒有」——那條紅線在這裡不成立(使用者要的就是刪掉),
|
||
// 所以改用另一組更嚴的規矩:
|
||
//
|
||
// ① **只刪帳面上認得出來的東西。** 不靠「路徑看起來像我們的」「副檔名是 .md」
|
||
// 這類推測——每一筆都要說得出一句**我們自己寫下的證據**(見 CleanupItem.Evidence)。
|
||
// ② **一個認不出來的鄰居,就讓整個目錄留下。** `.wiki/` 裡只要有一個檔不在帳本上、
|
||
// 也沒有標記,就從「刪整個目錄」降級成「只刪認得出來的那幾個檔」,目錄本身留著。
|
||
// ⇒ 誤留一點殘渣是可接受的;誤刪使用者的知識不是。
|
||
// ③ **先看清單再動手。** PlanCleanup 只讀不寫,ApplyCleanup 動手前**重算一次**計畫
|
||
// (不吃呼叫端傳回來的舊清單)——畫面上看到的是快照,刪的當下才是真相。
|
||
//
|
||
// 認人的三種證據(全部是 daemon 自己寫下去的字,不是猜的):
|
||
//
|
||
// E1 `.arcrun-rag/` 工作區:目錄裡的 `.gitignore` 內容**逐字**等於 workspaceIgnoreBody
|
||
// (repoguard.go 寫的那份,自己寫著「整個資料夾可以安全刪除」)。
|
||
// E2 `.wiki/` 卡片目錄:目錄裡的 `.gitignore` 內容逐字等於 wikiIgnoreBody
|
||
// (wikishape.go 寫的,自己寫著「不是你的檔案」),或目錄裡有解析得開的
|
||
// `manifest.json`(我們的 wiki 帳本)。
|
||
// E3 `system-dev/wiki/cards/` 底下帶 MachineMark(`arcrun-`)前綴的檔
|
||
// ——machinemark.go 的規約:daemon 在監看根底下產生的每個檔名都帶這個前綴。
|
||
//
|
||
// 🔴 三個**刻意不刪**的東西(每一個都是實查出來的,不是保守而已):
|
||
//
|
||
// ① `.arcrun-rag/legacy-template/`:那是 tidy.go 的**收容處**,裡面裝的是從使用者
|
||
// 資料夾**搬進來的檔**。tidy 自己說得很清楚:「TemplateOwns 是路徑身分判準,
|
||
// 分不出『daemon 鋪的』還是『repo 本來就有的』」⇒ 那些檔的所有權我們判不了。
|
||
// 而 `.arcrun-rag/.gitignore` 上「整個資料夾可以安全刪除」那句話是 #105 寫的,
|
||
// 當時工作區裡還沒有收容處。**不把那句話套到收容處上**(#138 紅線第三條問的正是這題)。
|
||
// ② 另一個**還在監看清單裡**的資料夾底下的東西:實務上真的會巢狀
|
||
// (2026-08-24 現場:`pms` 與 `pms/pms_v1_legacy` 同時在看守)。移除外層時把內層的
|
||
// 工作區刪掉=把一個還在跑的同步弄壞。
|
||
// ③ 任何 symlink:不跟隨、不刪。刪一條指出去的連結會刪到監看根以外的東西。
|
||
package collector
|
||
|
||
import (
|
||
"encoding/json"
|
||
"io/fs"
|
||
"os"
|
||
"path/filepath"
|
||
"regexp"
|
||
"sort"
|
||
"strings"
|
||
)
|
||
|
||
// 產物分類(給畫面分組用,也是 Apply 的處理方式)。
|
||
const (
|
||
CleanupKindWorkspace = "workspace" // `.arcrun-rag/` 工作區(整個目錄)
|
||
CleanupKindWorkFile = "work-file" // 工作區裡的單一項目(收容處存在時的降級路徑)
|
||
CleanupKindWikiDir = "wiki-dir" // `.wiki/` 卡片目錄(整個目錄)
|
||
CleanupKindWikiFile = "wiki-file" // `.wiki/` 裡的單一產物(有陌生鄰居時的降級路徑)
|
||
CleanupKindMarked = "marked-card" // 帶 arcrun- 標記的卡片(非隱藏的卡片產物區)
|
||
)
|
||
|
||
// CleanupItem=一筆「打算刪掉」的東西。Evidence 是白話的認人依據,會直接顯示給使用者看
|
||
// ——沒有 Evidence 的東西不准進這個清單。
|
||
type CleanupItem struct {
|
||
Rel string `json:"rel"` // 相對監看根,斜線分隔
|
||
IsDir bool `json:"is_dir"`
|
||
Kind string `json:"kind"`
|
||
Evidence string `json:"evidence"`
|
||
Files int `json:"files"` // 這一筆底下有幾個檔(目錄才 > 1)
|
||
Bytes int64 `json:"bytes"`
|
||
}
|
||
|
||
// CleanupKeep=一筆「認得出來但刻意不刪」或「認不出來所以不敢刪」的東西。
|
||
// 這份清單和 Remove 一樣要給使用者看——沉默地留下殘渣,跟沉默地刪掉一樣糟。
|
||
type CleanupKeep struct {
|
||
Rel string `json:"rel"`
|
||
Reason string `json:"reason"`
|
||
}
|
||
|
||
// CleanupPlan=一次清理的完整帳目。PlanCleanup 只讀不寫,這份東西就是「動手前的清單」。
|
||
type CleanupPlan struct {
|
||
Root string `json:"root"`
|
||
Remove []CleanupItem `json:"remove"`
|
||
Keep []CleanupKeep `json:"keep"`
|
||
Files int `json:"files"` // Remove 涵蓋的檔案總數
|
||
Bytes int64 `json:"bytes"`
|
||
}
|
||
|
||
// CleanupFailure=刪不掉的那一筆(權限、檔案正被開著…)。
|
||
type CleanupFailure struct {
|
||
Rel string `json:"rel"`
|
||
Error string `json:"error"`
|
||
}
|
||
|
||
// CleanupResult=實際刪掉了什麼。
|
||
type CleanupResult struct {
|
||
Removed []string `json:"removed"`
|
||
Failed []CleanupFailure `json:"failed"`
|
||
Files int `json:"files"`
|
||
Bytes int64 `json:"bytes"`
|
||
}
|
||
|
||
var cleanupBakRe = regexp.MustCompile(`^(.*)\.bak-\d+$`)
|
||
|
||
// WorkspaceIgnoreBodyForTest/WikiIgnoreBodyForTest 讓**別的 package 的迴歸網**
|
||
// (cmd/arcrun-app)造得出「與 daemon 真正寫下去一字不差」的宣告檔。
|
||
//
|
||
// 為什麼不讓測試自己抄一份字串:抄的那份會漂。認人的判準是「逐字相等」,
|
||
// 判準與樣本一旦不同源,這兩份 .gitignore 改一個字就會有一整層測試靜默失效。
|
||
// 名字帶 ForTest 是說給讀 code 的人聽的——**production 路徑不准依賴它們**。
|
||
func WorkspaceIgnoreBodyForTest() string { return workspaceIgnoreBody }
|
||
func WikiIgnoreBodyForTest() string { return wikiIgnoreBody }
|
||
|
||
// PlanCleanup 列出「把 root 從監看清單移除時,可以連帶收掉的 daemon 產物」。
|
||
//
|
||
// **這支函式不寫任何檔案。** otherRoots=其他仍在監看清單裡的資料夾(絕對路徑);
|
||
// 落在它們底下的東西一律不碰(見檔頭不刪清單②)。
|
||
func PlanCleanup(root string, otherRoots []string) (*CleanupPlan, error) {
|
||
absRoot, err := filepath.Abs(root)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
absRoot = filepath.Clean(absRoot)
|
||
if st, serr := os.Lstat(absRoot); serr != nil {
|
||
return nil, serr
|
||
} else if !st.IsDir() {
|
||
return nil, &fs.PathError{Op: "cleanup", Path: absRoot, Err: fs.ErrInvalid}
|
||
}
|
||
|
||
// 只留「真的在 absRoot 底下」的其他根——不在底下的本來就走不到,留著只會拖慢比對。
|
||
var others []string
|
||
for _, o := range otherRoots {
|
||
a, oerr := filepath.Abs(o)
|
||
if oerr != nil {
|
||
continue
|
||
}
|
||
a = filepath.Clean(a)
|
||
if a != absRoot && pathWithin(absRoot, a) {
|
||
others = append(others, a)
|
||
}
|
||
}
|
||
|
||
p := &cleanupPlanner{
|
||
root: absRoot,
|
||
others: others,
|
||
cards: collectWikiCardIndex(absRoot, others),
|
||
plan: &CleanupPlan{Root: absRoot},
|
||
}
|
||
p.walk(absRoot)
|
||
sort.Slice(p.plan.Remove, func(i, j int) bool { return p.plan.Remove[i].Rel < p.plan.Remove[j].Rel })
|
||
sort.Slice(p.plan.Keep, func(i, j int) bool { return p.plan.Keep[i].Rel < p.plan.Keep[j].Rel })
|
||
return p.plan, nil
|
||
}
|
||
|
||
// ApplyCleanup 真的動手刪。
|
||
//
|
||
// 🔴 它**重算一次計畫**,不接受呼叫端傳進來的舊清單——使用者看清單、按確定之間可能過了
|
||
// 幾秒或幾分鐘,那段時間裡他可能剛好把一個自己的檔存進 `.wiki/`。畫面上那份是快照,
|
||
// 刪的當下重算的這份才是判準。回傳的 plan 就是實際執行的那一份(呼叫端要顯示就用它)。
|
||
func ApplyCleanup(root string, otherRoots []string) (*CleanupPlan, *CleanupResult, error) {
|
||
plan, err := PlanCleanup(root, otherRoots)
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
res := &CleanupResult{}
|
||
// 由深到淺刪:先刪目錄裡的東西,再刪目錄自己(降級路徑會同時出現父目錄底下的多筆)。
|
||
items := append([]CleanupItem(nil), plan.Remove...)
|
||
sort.Slice(items, func(i, j int) bool { return len(items[i].Rel) > len(items[j].Rel) })
|
||
for _, it := range items {
|
||
abs := filepath.Join(plan.Root, filepath.FromSlash(it.Rel))
|
||
// 最後一道機械閘:不管計畫怎麼算的,目標一定要在監看根底下,而且不是 symlink。
|
||
if !pathWithin(plan.Root, abs) {
|
||
res.Failed = append(res.Failed, CleanupFailure{Rel: it.Rel, Error: "路徑不在這個資料夾底下,沒有刪"})
|
||
continue
|
||
}
|
||
st, serr := os.Lstat(abs)
|
||
if serr != nil {
|
||
if os.IsNotExist(serr) {
|
||
continue // 已經不在了(重複按、或上一輪刪過)=不是失敗
|
||
}
|
||
res.Failed = append(res.Failed, CleanupFailure{Rel: it.Rel, Error: serr.Error()})
|
||
continue
|
||
}
|
||
if st.Mode()&os.ModeSymlink != 0 {
|
||
res.Failed = append(res.Failed, CleanupFailure{Rel: it.Rel, Error: "這是一條捷徑(symlink),沒有刪"})
|
||
continue
|
||
}
|
||
var derr error
|
||
if it.IsDir {
|
||
derr = os.RemoveAll(abs)
|
||
} else {
|
||
derr = os.Remove(abs)
|
||
}
|
||
if derr != nil {
|
||
res.Failed = append(res.Failed, CleanupFailure{Rel: it.Rel, Error: derr.Error()})
|
||
continue
|
||
}
|
||
res.Removed = append(res.Removed, it.Rel)
|
||
res.Files += it.Files
|
||
res.Bytes += it.Bytes
|
||
}
|
||
sort.Strings(res.Removed)
|
||
return plan, res, nil
|
||
}
|
||
|
||
// ── 以下是實作細節 ────────────────────────────────────────────────
|
||
|
||
type cleanupPlanner struct {
|
||
root string
|
||
others []string
|
||
cards map[string]bool // 帳本上每張卡的絕對路徑
|
||
plan *CleanupPlan
|
||
}
|
||
|
||
func (p *cleanupPlanner) rel(abs string) string {
|
||
r, err := filepath.Rel(p.root, abs)
|
||
if err != nil {
|
||
return filepath.ToSlash(abs)
|
||
}
|
||
return filepath.ToSlash(r)
|
||
}
|
||
|
||
func (p *cleanupPlanner) remove(abs, kind, evidence string, isDir bool) {
|
||
files, bytes := countTree(abs)
|
||
p.plan.Remove = append(p.plan.Remove, CleanupItem{
|
||
Rel: p.rel(abs), IsDir: isDir, Kind: kind, Evidence: evidence, Files: files, Bytes: bytes,
|
||
})
|
||
p.plan.Files += files
|
||
p.plan.Bytes += bytes
|
||
}
|
||
|
||
func (p *cleanupPlanner) keep(abs, reason string) {
|
||
p.plan.Keep = append(p.plan.Keep, CleanupKeep{Rel: p.rel(abs), Reason: reason})
|
||
}
|
||
|
||
// walk 走整棵樹。刻意**不用 filepath.WalkDir**:我們要在「進不進去某個目錄」這件事上
|
||
// 完全自己說了算(其他隱藏目錄整棵跳過、別的監看根整棵跳過、symlink 不跟隨)。
|
||
func (p *cleanupPlanner) walk(dir string) {
|
||
entries, err := os.ReadDir(dir)
|
||
if err != nil {
|
||
return
|
||
}
|
||
for _, e := range entries {
|
||
abs := filepath.Join(dir, e.Name())
|
||
if e.Type()&os.ModeSymlink != 0 {
|
||
continue // 捷徑一律不碰(可能指到監看根以外)
|
||
}
|
||
if !e.IsDir() {
|
||
continue // 目錄以外的檔,只有在下面那幾個已認定的目錄裡才會被處理
|
||
}
|
||
if p.underOtherRoot(abs) {
|
||
p.keep(abs, "這個資料夾也還在同步清單裡,它底下的東西留給它自己管")
|
||
continue
|
||
}
|
||
switch e.Name() {
|
||
case workspaceRelDir:
|
||
p.planWorkspace(abs)
|
||
continue
|
||
case wikiRelDir:
|
||
p.planWikiDir(abs)
|
||
continue
|
||
}
|
||
if strings.HasPrefix(e.Name(), ".") {
|
||
continue // 其他隱藏目錄整棵跳過——daemon 的 Scan 也不進去,那裡不會有我們的東西
|
||
}
|
||
if p.rel(abs) == cardsRelDir {
|
||
p.planMarkedCards(abs)
|
||
continue
|
||
}
|
||
p.walk(abs)
|
||
}
|
||
}
|
||
|
||
func (p *cleanupPlanner) underOtherRoot(abs string) bool {
|
||
for _, o := range p.others {
|
||
if abs == o || pathWithin(o, abs) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// planWorkspace 處理 `.arcrun-rag/`。
|
||
func (p *cleanupPlanner) planWorkspace(abs string) {
|
||
if !fileHasExactBody(filepath.Join(abs, ".gitignore"), workspaceIgnoreBody) {
|
||
p.keep(abs, "這個 .arcrun-rag 目錄裡沒有我們寫的 .gitignore 宣告,認不出是不是我們建的,所以不動它")
|
||
return
|
||
}
|
||
const ev = "目錄裡的 .gitignore 逐字等於我們寫的那份(自己寫著「整個資料夾可以安全刪除」)"
|
||
|
||
quarantine := filepath.Join(abs, "legacy-template")
|
||
if st, err := os.Lstat(quarantine); err == nil && st.IsDir() && !isEmptyDir(quarantine) {
|
||
// 收容處裡是「從你的資料夾搬進來的檔」⇒ 整個目錄不能一次刪,改成逐項刪、留下收容處。
|
||
p.keep(quarantine, "這裡面是以前從你的資料夾搬進來的檔案(不是我們產生的),所有權判不了,一律留著讓你自己處理")
|
||
entries, err := os.ReadDir(abs)
|
||
if err != nil {
|
||
return
|
||
}
|
||
for _, e := range entries {
|
||
if e.Name() == "legacy-template" {
|
||
continue
|
||
}
|
||
child := filepath.Join(abs, e.Name())
|
||
if e.Type()&os.ModeSymlink != 0 {
|
||
p.keep(child, "這是一條捷徑(symlink),不碰")
|
||
continue
|
||
}
|
||
p.remove(child, CleanupKindWorkFile, ev, e.IsDir())
|
||
}
|
||
return
|
||
}
|
||
p.remove(abs, CleanupKindWorkspace, ev, true)
|
||
}
|
||
|
||
// planWikiDir 處理一個 `.wiki/`。
|
||
//
|
||
// 兩步:① 這個目錄是不是我們的(要有我們寫下的宣告)② 裡面**每一個**東西是不是都認得。
|
||
// 第二步是這支函式的重點——認得全部才刪整個目錄,有一個認不得就降級成逐檔刪。
|
||
func (p *cleanupPlanner) planWikiDir(abs string) {
|
||
evidence := ""
|
||
if fileHasExactBody(filepath.Join(abs, ".gitignore"), wikiIgnoreBody) {
|
||
evidence = "目錄裡的 .gitignore 逐字等於我們寫的那份(自己寫著「這個資料夾是 Arcrun RAG 產生的 wiki,不是你的檔案」)"
|
||
} else if isOurWikiManifest(filepath.Join(abs, "manifest.json")) {
|
||
evidence = "目錄裡有我們寫的 manifest.json(wiki 帳本,記著每張卡對應哪份原稿)"
|
||
} else {
|
||
p.keep(abs, "這個 .wiki 目錄裡既沒有我們寫的 .gitignore、也沒有我們的 manifest.json,認不出是不是我們建的,所以不動它")
|
||
return
|
||
}
|
||
|
||
entries, err := os.ReadDir(abs)
|
||
if err != nil {
|
||
p.keep(abs, "讀不到這個目錄的內容("+err.Error()+"),所以不動它")
|
||
return
|
||
}
|
||
type known struct {
|
||
name string
|
||
isDir bool
|
||
}
|
||
var ours []known
|
||
var strangers []string
|
||
accounted := map[string]bool{}
|
||
for _, e := range entries {
|
||
if p.wikiEntryIsOurs(abs, e) {
|
||
accounted[e.Name()] = true
|
||
}
|
||
}
|
||
for _, e := range entries {
|
||
if accounted[e.Name()] {
|
||
ours = append(ours, known{e.Name(), e.IsDir()})
|
||
continue
|
||
}
|
||
// `.bak-<unixnano>` 是 safeWriteCard 的備份:本體認得,它就認得。
|
||
if m := cleanupBakRe.FindStringSubmatch(e.Name()); m != nil && accounted[m[1]] {
|
||
ours = append(ours, known{e.Name(), e.IsDir()})
|
||
continue
|
||
}
|
||
strangers = append(strangers, e.Name())
|
||
}
|
||
|
||
if len(strangers) == 0 {
|
||
p.remove(abs, CleanupKindWikiDir, evidence, true)
|
||
return
|
||
}
|
||
// 降級:只刪認得出來的那幾個,目錄與陌生鄰居原封不動。
|
||
for _, k := range ours {
|
||
p.remove(filepath.Join(abs, k.name), CleanupKindWikiFile, evidence, k.isDir)
|
||
}
|
||
for _, s := range strangers {
|
||
p.keep(filepath.Join(abs, s), "這個檔不在我們的帳本上、也沒有 arcrun- 標記——可能是你自己放進來的,留著")
|
||
}
|
||
}
|
||
|
||
// wikiEntryIsOurs 回答「`.wiki/` 裡的這一項是不是我們寫的」。四條路,全部要拿得出證據。
|
||
func (p *cleanupPlanner) wikiEntryIsOurs(dir string, e os.DirEntry) bool {
|
||
if e.Type()&os.ModeSymlink != 0 {
|
||
return false // 捷徑一律不算我們的
|
||
}
|
||
abs := filepath.Join(dir, e.Name())
|
||
if e.IsDir() {
|
||
return false // 我們從來不在 .wiki/ 底下開子目錄
|
||
}
|
||
switch {
|
||
case e.Name() == ".gitignore":
|
||
return fileHasExactBody(abs, wikiIgnoreBody)
|
||
case e.Name() == "manifest.json":
|
||
return isOurWikiManifest(abs)
|
||
case e.Name() == "00-INDEX.md":
|
||
return fileHasPrefix(abs, "# 00-INDEX")
|
||
case p.cards[abs]:
|
||
return true // 帳本上有這張卡
|
||
case IsMarked(e.Name()):
|
||
return true // machinemark.go 的規約前綴
|
||
}
|
||
return false
|
||
}
|
||
|
||
// planMarkedCards 處理 `system-dev/wiki/cards/`——那是**看得見**的卡片產物區
|
||
// (監看根不在筆記庫也不在版控時的落點)。這裡不刪目錄,只刪帶標記的檔:
|
||
// 那個目錄是 system-dev-template 的規約路徑,使用者自己的卡也住在那裡(#105 的教訓)。
|
||
func (p *cleanupPlanner) planMarkedCards(abs string) {
|
||
_ = filepath.WalkDir(abs, func(path string, d fs.DirEntry, err error) error {
|
||
if err != nil || d.IsDir() || d.Type()&os.ModeSymlink != 0 {
|
||
return nil
|
||
}
|
||
if IsMarked(d.Name()) {
|
||
p.remove(path, CleanupKindMarked, "檔名帶 arcrun- 標記——daemon 產生的每個檔都帶這個前綴(machinemark.go 規約)", false)
|
||
}
|
||
return nil
|
||
})
|
||
}
|
||
|
||
// collectWikiCardIndex 把樹裡每一份 `.wiki/manifest.json` 讀出來,攤成「這些絕對路徑是我們的卡」。
|
||
//
|
||
// 為什麼要掃整棵樹而不是只讀監看根那一份:實務上樹裡會有**別的監看根**留下的帳本
|
||
// (`pms/pms_v1_legacy/.wiki/manifest.json`)。帳本裡的卡路徑是相對**它自己的根**
|
||
// (=那個 `.wiki` 的上層目錄),所以每份帳本各自解析。
|
||
func collectWikiCardIndex(absRoot string, others []string) map[string]bool {
|
||
out := map[string]bool{}
|
||
var walk func(dir string)
|
||
walk = func(dir string) {
|
||
entries, err := os.ReadDir(dir)
|
||
if err != nil {
|
||
return
|
||
}
|
||
for _, e := range entries {
|
||
if !e.IsDir() || e.Type()&os.ModeSymlink != 0 {
|
||
continue
|
||
}
|
||
abs := filepath.Join(dir, e.Name())
|
||
if e.Name() == wikiRelDir {
|
||
addManifestCards(out, abs)
|
||
continue
|
||
}
|
||
if strings.HasPrefix(e.Name(), ".") {
|
||
continue
|
||
}
|
||
walk(abs)
|
||
}
|
||
}
|
||
walk(absRoot)
|
||
// 別的監看根底下那份帳本也要讀——它記的卡可能落在**我們這一側**(節點在共同祖先下)。
|
||
// 讀帳本是純讀取,不代表會刪那邊的東西(刪不刪由 underOtherRoot 決定)。
|
||
for _, o := range others {
|
||
addManifestCards(out, filepath.Join(o, wikiRelDir))
|
||
}
|
||
return out
|
||
}
|
||
|
||
func addManifestCards(out map[string]bool, wikiDir string) {
|
||
path := filepath.Join(wikiDir, "manifest.json")
|
||
m, ok := readOurWikiManifest(path)
|
||
if !ok {
|
||
return
|
||
}
|
||
owner := filepath.Dir(wikiDir) // 帳本裡的路徑相對這一層
|
||
for _, d := range m.Docs {
|
||
for _, c := range d.Cards {
|
||
c = strings.TrimSpace(c)
|
||
if c == "" || strings.Contains(c, "..") || filepath.IsAbs(filepath.FromSlash(c)) {
|
||
continue // 帳本被改壞時不讓它把我們指到樹外面
|
||
}
|
||
out[filepath.Join(owner, filepath.FromSlash(c))] = true
|
||
}
|
||
}
|
||
}
|
||
|
||
func readOurWikiManifest(path string) (*wikiManifest, bool) {
|
||
data, err := os.ReadFile(path)
|
||
if err != nil {
|
||
return nil, false
|
||
}
|
||
var m wikiManifest
|
||
if json.Unmarshal(data, &m) != nil {
|
||
return nil, false
|
||
}
|
||
// 認的是形狀,不是「檔名叫 manifest.json」:要有版本號,而且每筆 doc 都要有我們的鍵。
|
||
if m.Version <= 0 {
|
||
return nil, false
|
||
}
|
||
for _, d := range m.Docs {
|
||
if d.Node == "" || d.DocID == "" {
|
||
return nil, false
|
||
}
|
||
}
|
||
return &m, true
|
||
}
|
||
|
||
func isOurWikiManifest(path string) bool {
|
||
_, ok := readOurWikiManifest(path)
|
||
return ok
|
||
}
|
||
|
||
// fileHasExactBody:檔案內容**逐字**等於 want。逐字比對是刻意的——「開頭像」不夠,
|
||
// 有人可能在我們的宣告後面接自己的規則,那份 .gitignore 就不只是我們的了。
|
||
func fileHasExactBody(path, want string) bool {
|
||
data, err := os.ReadFile(path)
|
||
return err == nil && string(data) == want
|
||
}
|
||
|
||
func fileHasPrefix(path, want string) bool {
|
||
f, err := os.Open(path)
|
||
if err != nil {
|
||
return false
|
||
}
|
||
defer f.Close()
|
||
buf := make([]byte, len(want))
|
||
n, _ := f.Read(buf)
|
||
return n == len(want) && string(buf) == want
|
||
}
|
||
|
||
// pathWithin 回答 child 是不是 parent 底下(不含 parent 自己)。純字串比對,
|
||
// 呼叫端傳進來的都已經是 Abs+Clean 過的路徑。
|
||
func pathWithin(parent, child string) bool {
|
||
rel, err := filepath.Rel(parent, child)
|
||
if err != nil {
|
||
return false
|
||
}
|
||
return rel != "." && !strings.HasPrefix(rel, "..") && !filepath.IsAbs(rel)
|
||
}
|
||
|
||
func isEmptyDir(dir string) bool {
|
||
entries, err := os.ReadDir(dir)
|
||
return err == nil && len(entries) == 0
|
||
}
|
||
|
||
// countTree 算一筆刪除目標涵蓋幾個檔、多少位元組(給清單顯示「會刪掉 N 個檔」)。
|
||
func countTree(abs string) (files int, bytes int64) {
|
||
st, err := os.Lstat(abs)
|
||
if err != nil {
|
||
return 0, 0
|
||
}
|
||
if !st.IsDir() {
|
||
return 1, st.Size()
|
||
}
|
||
_ = filepath.WalkDir(abs, func(_ string, d fs.DirEntry, err error) error {
|
||
if err != nil || d.IsDir() {
|
||
return nil
|
||
}
|
||
if info, ierr := d.Info(); ierr == nil {
|
||
files++
|
||
bytes += info.Size()
|
||
}
|
||
return nil
|
||
})
|
||
return files, bytes
|
||
}
|