diff --git a/VERSION b/VERSION index ac39a10..f374f66 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.0 +0.9.1 diff --git a/digitdisk.1 b/digitdisk.1 index cfa6e88..6ad4c67 100644 --- a/digitdisk.1 +++ b/digitdisk.1 @@ -25,6 +25,7 @@ .Op Fl -top Ar N .Op Fl -places Ar файл .Op Fl -no-places +.Op Fl -no-fold .Op Fl -cross-device .Op Fl -max-depth Ar N .Nm digitdisk Cm clean Ar путь @@ -367,6 +368,14 @@ npm и сборка на Go порождают десятки процессов .It Fl -no-places .Cm analyze , clean : судить одними приметами, без справочника. +.It Fl -no-fold +.Cm analyze : +судить о каждом файле внутри +.Pa node_modules , +.Pa __pycache__ +и подобных каталогов. По умолчанию такой каталог считается целиком — байты +все до одного, как и без ключа, — но приговор выносится один, о самом +каталоге. На дереве из 82 923 записей это 8,4 секунды против 17,5. .It Fl -no-measure .Cm places : не считать размеры найденных мест, только назвать их. diff --git a/digitdisk.en.1 b/digitdisk.en.1 index 257cf87..2dbee98 100644 --- a/digitdisk.en.1 +++ b/digitdisk.en.1 @@ -25,6 +25,7 @@ .Op Fl -top Ar N .Op Fl -places Ar file .Op Fl -no-places +.Op Fl -no-fold .Op Fl -cross-device .Op Fl -max-depth Ar N .Nm digitdisk Cm clean Ar path @@ -374,6 +375,14 @@ a known-places directory of one's own instead of the built-in one. .It Fl -no-places .Cm analyze , clean : judge by the signs alone, without the directory. +.It Fl -no-fold +.Cm analyze : +judge every file inside +.Pa node_modules , +.Pa __pycache__ +and the like. By default such a directory is counted whole — every byte, the +same as without the flag — but one verdict is passed, about the directory +itself. On a tree of 82,923 entries that is 8.4 seconds against 17.5. .It Fl -no-measure .Cm places : do not measure the places that were found, only name them. diff --git a/host/internal/cli/cli.go b/host/internal/cli/cli.go index 00c14f4..0eb3217 100644 --- a/host/internal/cli/cli.go +++ b/host/internal/cli/cli.go @@ -176,6 +176,9 @@ var keys = []string{ " --max-depth N analyze, clean: предел глубины обхода; 0 — без предела", " --places ФАЙЛ analyze, clean, places: свой справочник известных мест", " --no-places analyze, clean: судить одними приметами, без справочника", + " --no-fold analyze: судить о каждом файле внутри node_modules и", + " подобных каталогов; по умолчанию такой каталог", + " считается целиком, а приговор выносится один — о нём", " --no-measure places: не считать размеры, только назвать места", " --apply clean: перенести в корзину, а не только показать план", " --trash КАТ clean: другая корзина; обязана лежать внутри корня", diff --git a/host/internal/lang/dict_cli.go b/host/internal/lang/dict_cli.go index 6f2ed7c..37e019d 100644 --- a/host/internal/lang/dict_cli.go +++ b/host/internal/lang/dict_cli.go @@ -53,6 +53,9 @@ func init() { " --max-depth N analyze, clean: предел глубины обхода; 0 — без предела": " --max-depth N analyze, clean: walk depth limit; 0 — no limit", " --places ФАЙЛ analyze, clean, places: свой справочник известных мест": " --places FILE analyze, clean, places: your own known-places file", " --no-places analyze, clean: судить одними приметами, без справочника": " --no-places analyze, clean: judge by signs alone, no directory", + " --no-fold analyze: судить о каждом файле внутри node_modules и": " --no-fold analyze: judge every file inside node_modules and", + " подобных каталогов; по умолчанию такой каталог": " the like; by default such a directory is counted", + " считается целиком, а приговор выносится один — о нём": " whole and gets a single verdict, about itself", " --no-measure places: не считать размеры, только назвать места": " --no-measure places: do not measure sizes, only name the places", " --apply clean: перенести в корзину, а не только показать план": " --apply clean: move into the trash, not just show the plan", " --trash КАТ clean: другая корзина; обязана лежать внутри корня": " --trash DIR clean: another trash; must lie inside the root", @@ -99,29 +102,30 @@ func init() { "Подробно: man digitdisk": "In full: man digitdisk", // ── пояснения ключей, которые печатает сам flag ────────────── - "язык вывода на этот запуск: ru или en": "output language for this run: ru or en", - "машиночитаемый вывод": "machine-readable output", - "что не измерено и почему": "what was not measured, and why", - "сколько процессов в каждом списке": "how many processes in each list", - "окно замера загрузки ЦП, мс": "CPU-busy sampling window, ms", - "живой экран, даже если о терминале не спрашивали": "the live screen, even where no terminal was asked about", - "печать одним снимком, без живого экрана": "print one snapshot, no live screen", - "период обновления живого экрана, мс": "live-screen refresh period, ms", - "сколько строк в списках": "how many rows in the lists", - "заходить на другие файловые системы": "cross into other filesystems", - "предел глубины обхода, 0 — без предела": "walk depth limit, 0 — no limit", - "свой справочник известных мест": "your own known-places directory", - "судить одними приметами, без справочника": "judge by signs alone, without the directory", - "перенести в корзину, а не только показать план": "move into the trash, not just show the plan", - "сколько строк в перечнях, 0 — без предела": "how many rows in the lists, 0 — no limit", - "корзина (по умолчанию <корень>/%s); обязана лежать внутри корня": "the trash (by default /%s); must lie inside the root", - "защитный список файлом": "the protect list as a file", - "не трогать: путь или «разряд:кэш»; можно повторять": "spare: a path or «разряд:кэш»; may repeat", - "показать, что вернулось бы, и не возвращать": "show what would come back, restore nothing", - "подтвердить стирание ровно N файлов": "confirm erasing exactly N files", - "сколько найденных мест печатать, 0 — без предела": "how many found places to print, 0 — no limit", - "не считать размеры, только назвать места": "do not measure sizes, only name the places", - "сколько корзин печатать, 0 — без предела": "how many trashes to print, 0 — no limit", + "язык вывода на этот запуск: ru или en": "output language for this run: ru or en", + "машиночитаемый вывод": "machine-readable output", + "что не измерено и почему": "what was not measured, and why", + "сколько процессов в каждом списке": "how many processes in each list", + "окно замера загрузки ЦП, мс": "CPU-busy sampling window, ms", + "живой экран, даже если о терминале не спрашивали": "the live screen, even where no terminal was asked about", + "печать одним снимком, без живого экрана": "print one snapshot, no live screen", + "период обновления живого экрана, мс": "live-screen refresh period, ms", + "сколько строк в списках": "how many rows in the lists", + "заходить на другие файловые системы": "cross into other filesystems", + "предел глубины обхода, 0 — без предела": "walk depth limit, 0 — no limit", + "свой справочник известных мест": "your own known-places directory", + "судить одними приметами, без справочника": "judge by signs alone, without the directory", + "судить о каждом файле внутри node_modules и подобных, а не о каталоге целиком": "judge every file inside node_modules and the like, not the directory whole", + "перенести в корзину, а не только показать план": "move into the trash, not just show the plan", + "сколько строк в перечнях, 0 — без предела": "how many rows in the lists, 0 — no limit", + "корзина (по умолчанию <корень>/%s); обязана лежать внутри корня": "the trash (by default /%s); must lie inside the root", + "защитный список файлом": "the protect list as a file", + "не трогать: путь или «разряд:кэш»; можно повторять": "spare: a path or «разряд:кэш»; may repeat", + "показать, что вернулось бы, и не возвращать": "show what would come back, restore nothing", + "подтвердить стирание ровно N файлов": "confirm erasing exactly N files", + "сколько найденных мест печатать, 0 — без предела": "how many found places to print, 0 — no limit", + "не считать размеры, только назвать места": "do not measure sizes, only name the places", + "сколько корзин печатать, 0 — без предела": "how many trashes to print, 0 — no limit", // ── отказы разбора командной строки ────────────────────────── "неизвестная подкоманда %q": "unknown subcommand %q", diff --git a/host/internal/lang/dict_report.go b/host/internal/lang/dict_report.go index b7e7649..dc81b20 100644 --- a/host/internal/lang/dict_report.go +++ b/host/internal/lang/dict_report.go @@ -99,8 +99,11 @@ func init() { " по разрядам (весь план, ключ --top на этот счёт не влияет):": " by class (the whole plan; --top does not affect these counts):", " пропущено %s (нет доступа %s, исчезло %s, иные ошибки %s, граница ФС %s, предел глубины %s)": " skipped %s (no access %s, vanished %s, other errors %s, fs boundary %s, depth limit %s)", " пропущено %s (нет доступа %s, исчезло %s, иные ошибки %s, граница ФС %s, предел глубины %s)": " skipped %s (no access %s, vanished %s, other errors %s, fs boundary %s, depth limit %s)", - " пуст не потому, что убирать нечего, а потому, что никто не решал.": " is empty not because there is nothing to remove, but because nobody decided.", - " решающий слой %s, договор версии %d": " decision layer %s, contract version %d", + " свёрнуто каталогов %s, записей в них %s (%s): сосчитаны все, приговор один на каталог": " folded directories %s, entries inside %s (%s): every byte counted, one verdict per directory", + " %s — %s, записей %s": " %s — %s, entries %s", + " и ещё %s": " and %s more", + " пуст не потому, что убирать нечего, а потому, что никто не решал.": " is empty not because there is nothing to remove, but because nobody decided.", + " решающий слой %s, договор версии %d": " decision layer %s, contract version %d", " свободно %s": " free %s", " своп %s из %s занято": " swap %s of %s used", " своп нет": " swap none", diff --git a/host/internal/report/report.go b/host/internal/report/report.go index 4b8b4d7..d9ee8d0 100644 --- a/host/internal/report/report.go +++ b/host/internal/report/report.go @@ -318,6 +318,25 @@ func Analyze(w io.Writer, l lang.Lang, r scan.Result) { p(l.F(" пропущено %s (нет доступа %s, исчезло %s, иные ошибки %s, граница ФС %s, предел глубины %s)", l.Num(int64(s.Total())), l.Num(int64(s.PermissionDenied)), l.Num(int64(s.Vanished)), l.Num(int64(s.OtherErrors)), l.Num(int64(s.DeviceBoundaries)), l.Num(int64(s.DepthLimited)))) + // Свёрнутое названо поимённо и числом: «быстро» без отчёта о том, за счёт + // чего, читается как «часть дерева не посмотрели», а посмотрели всю. + if len(r.Folded) > 0 { + folded := 0 + var bytes int64 + for _, f := range r.Folded { + folded += f.Entries + bytes += f.Bytes + } + p(l.F(" свёрнуто каталогов %s, записей в них %s (%s): сосчитаны все, приговор один на каталог", + l.Num(int64(len(r.Folded))), l.Num(int64(folded)), l.Bytes(bytes))) + for i, f := range r.Folded { + if i == 3 { + p(l.F(" и ещё %s", l.Num(int64(len(r.Folded)-3)))) + break + } + p(l.F(" %s — %s, записей %s", f.Path, l.Bytes(f.Bytes), l.Num(int64(f.Entries)))) + } + } p(l.F(" время %s с", l.Dec(r.DurationSeconds, 2))) p("") diff --git a/host/internal/scan/fold.go b/host/internal/scan/fold.go new file mode 100644 index 0000000..966d094 --- /dev/null +++ b/host/internal/scan/fold.go @@ -0,0 +1,74 @@ +// SPDX-FileCopyrightText: 2026 Marat Zimnurov +// SPDX-License-Identifier: BSD-2-Clause + +package scan + +import ( + "io/fs" + "path/filepath" +) + +// ЗАЧЕМ ЭТОТ СПИСОК ЕСТЬ, ЧИСЛОМ. +// +// Замер на дереве портала courses (82 923 записи, 1,4 ГБ): +// +// обход без решающего слоя 0,81 с +// обход с решающим слоем на flang 17,5 с +// из них один node_modules 9,9 с (50 384 записи) +// +// То есть девять десятых времени уходит на то, чтобы вынести пятьдесят тысяч +// отдельных приговоров о файлах внутри каталога, который человек удаляет +// целиком или не трогает вовсе. Свёртка спрашивает решающий слой ОДИН раз — о +// самом каталоге — и заряжает его приговором всё поддерево. +// +// ЧТО СВЁРТКА НЕ ДЕЛАЕТ. Она не пропускает каталог: байты сосчитаны все до +// одного, TotalBytes по-прежнему сходится с `du -sb`, жёсткие ссылки +// по-прежнему считаются один раз. Пропуск — это `--exclude` у чужих +// чистильщиков, и он врёт о размере; здесь размер честный, дешевле только +// приговор. +// +// ПРАВИЛО ОТБОРА, и оно строгое: сюда попадает имя, которое (1) принадлежит +// производным данным, восстановимым одной командой, (2) достаточно +// характерно, чтобы не совпасть со своим каталогом человека, и (3) обычно +// содержит тысячи записей. Поэтому здесь нет `build`, `dist`, `target` и +// `venv`: первые три — обычные имена рабочих каталогов, а `venv` слишком +// часто зовут иначе. Их свернуло бы имя, а не природа. +var foldNames = map[string]string{ + "node_modules": "npm/pnpm/yarn восстанавливают его из локфайла: npm ci", + "__pycache__": "Python перепишет байткод при следующем импорте", + ".mypy_cache": "кэш проверки типов, mypy соберёт заново", + ".pytest_cache": "кэш прогона тестов, pytest соберёт заново", + ".ruff_cache": "кэш линтера, ruff соберёт заново", + ".gradle": "кэш сборки Gradle в проекте", + ".tox": "окружения tox, восстанавливаются прогоном", + ".next": "кэш и вывод сборки Next.js", + ".nuxt": "кэш и вывод сборки Nuxt", + ".turbo": "кэш Turborepo", + ".parcel-cache": "кэш Parcel", + ".svelte-kit": "вывод сборки SvelteKit", +} + +// FoldName returns the reason a directory of this name is folded, and whether +// it is folded at all. Matching is by the directory's own name, not by path: +// a node_modules three levels down is the same node_modules. +func FoldName(name string) (string, bool) { + reason, ok := foldNames[name] + return reason, ok +} + +// FoldNames lists every folded name with its reason. Sorted output is the +// caller's business; the map is small and the caller usually prints it once. +func FoldNames() map[string]string { + out := make(map[string]string, len(foldNames)) + for name, reason := range foldNames { + out[name] = reason + } + return out +} + +// FoldByName is the Fold function the tool uses by default: fold a directory +// whose own name is in the list. +func FoldByName(path string, _ fs.FileInfo) bool { + _, ok := foldNames[filepath.Base(path)] + return ok +} diff --git a/host/internal/scan/scan.go b/host/internal/scan/scan.go index f6f506d..5154109 100644 --- a/host/internal/scan/scan.go +++ b/host/internal/scan/scan.go @@ -82,6 +82,28 @@ type Options struct { // and a work list is not a report. Observe func(Entry, fs.FileInfo) + // Fold, when set, is asked about every directory below the root. + // Returning true folds it: the walk still counts every byte inside it + // (TotalBytes stays du -sb compatible, hard links stay counted once), + // but the decision layer is asked ONCE — about the directory itself — + // instead of once per entry underneath, and nothing inside enters the + // rankings or Observe. + // + // This is not a speed trick with a cost hidden somewhere: it is the + // answer to a measurement. On a tree of 82 923 entries the walk itself + // takes 0,81 s and the same walk with the flang decision layer takes + // 17,5 s; 50 384 of those entries are one node_modules, and they alone + // cost 9,9 s. A person deletes node_modules whole or leaves it whole — + // fifty thousand separate verdicts about files inside it answer a + // question nobody asked. + // + // What the folded directory keeps: its own verdict, its own place in + // the rankings (with the size of the whole subtree, not of the + // directory inode), and its bytes in the class/verdict buckets — the + // subtree is charged to the verdict the directory itself received, so + // the buckets still sum back to TotalBytes. + Fold func(path string, info fs.FileInfo) bool + // Watch, when set, is called once for every accounted entry with the // bytes the walk CHARGED for it. Observe carries the entry's own // st_size, which is the right number for a ranking and the wrong one @@ -116,6 +138,15 @@ type Entry struct { Weight float64 `json:"вес"` } +// Folded is one directory the walk counted whole instead of entry by entry. +// Entries counts what is inside it (the directory itself is not counted here), +// Bytes is what those entries charged to TotalBytes. +type Folded struct { + Path string `json:"путь"` + Entries int `json:"записей"` + Bytes int64 `json:"байт"` +} + // Bucket is a count/size pair. type Bucket struct { Count int `json:"count"` @@ -162,6 +193,7 @@ type Result struct { Skipped Skips `json:"skipped"` ByClass map[core.Class]Bucket `json:"by_class"` ByVerdict map[core.Verdict]Bucket `json:"by_verdict"` + Folded []Folded `json:"folded,omitempty"` Removable []Entry `json:"removable_top"` Largest []Entry `json:"largest_top"` DurationSeconds float64 `json:"duration_seconds"` @@ -247,6 +279,15 @@ func Walk(opt Options) (Result, error) { // account turns one lstat result into a record, asks the decision layer // about it, and folds the answer into the totals. + // Свёрнутое поддерево заряжается в корзины приговором своего каталога: + // решающий слой о его содержимом не спрашивают вовсе, а корзины обязаны + // сойтись с TotalBytes. Пусто — обычный путь, решение на каждую запись. + type imposed struct { + class core.Class + verdict core.Verdict + } + var forced *imposed + account := func(path string, info fs.FileInfo, accessible bool) { res.Entries++ rec := core.Record{Path: path, Accessible: accessible} @@ -301,7 +342,14 @@ func Walk(opt Options) (Result, error) { } res.TotalBytes += charged - d := opt.Decider.Decide(rec) + var d core.Decision + if forced != nil { + // Внутри свёрнутого каталога: приговор один на всё поддерево, + // и он уже вынесен — о самом каталоге, один раз. + d = core.Decision{Class: forced.class, Verdict: forced.verdict} + } else { + d = opt.Decider.Decide(rec) + } // Buckets are charged the same bytes as TotalBytes, so they sum // back to it; an Entry still shows the path's own st_size. bc := res.ByClass[d.Class] @@ -313,14 +361,16 @@ func Walk(opt Options) (Result, error) { bv.Bytes += charged res.ByVerdict[d.Verdict] = bv - e := Entry{Path: path, Size: rec.Size, AgeDays: rec.AgeDays, Kind: k, - Class: d.Class, Verdict: d.Verdict, Weight: d.Weight} - largest.add(e) - if d.Verdict == core.VerdictRemovable { - removable.add(e) - } - if opt.Observe != nil { - opt.Observe(e, info) + if forced == nil { + e := Entry{Path: path, Size: rec.Size, AgeDays: rec.AgeDays, Kind: k, + Class: d.Class, Verdict: d.Verdict, Weight: d.Weight} + largest.add(e) + if d.Verdict == core.VerdictRemovable { + removable.add(e) + } + if opt.Observe != nil { + opt.Observe(e, info) + } } if opt.Watch != nil { opt.Watch(Step{Path: path, Charged: charged, Kind: k}) @@ -341,12 +391,80 @@ func Walk(opt Options) (Result, error) { } } - account(opt.Root, rootInfo, true) - type job struct { path string depth int } + + // foldDir считает каталог целиком: решение о нём выносится ОДИН раз, + // поддерево заряжается тем же приговором, и в рейтинги уходит одна + // запись — с размером всего поддерева, а не с st_size каталога. + foldDir := func(dir string, info fs.FileInfo, depth int) { + beforeEntries := res.Entries + beforeBytes := res.TotalBytes + + rec := core.Record{Path: dir, Accessible: true, Kind: core.KindDir, + Size: info.Size(), AgeDays: opt.Now.Sub(info.ModTime()).Hours() / 24} + if rec.AgeDays < 0 { + rec.AgeDays = 0 + } + d := opt.Decider.Decide(rec) + + // Сам каталог — обычной записью (счётчики, корзины), но без + // рейтинга: место в рейтинге он займёт ниже, с полным размером. + forced = &imposed{class: d.Class, verdict: d.Verdict} + account(dir, info, true) + + inner := []job{{dir, depth}} + for len(inner) > 0 { + cur := inner[len(inner)-1] + inner = inner[:len(inner)-1] + entries, err := os.ReadDir(cur.path) + if err != nil { + noteSkip(cur.path, err) + continue + } + for _, de := range entries { + child := filepath.Join(cur.path, de.Name()) + ci, err := de.Info() + if err != nil { + noteSkip(child, err) + account(child, nil, false) + continue + } + account(child, ci, true) + if !ci.IsDir() { + continue + } + if !opt.CrossDevice && devOf(ci) != rootDev { + res.Skipped.DeviceBoundaries++ + continue + } + if opt.MaxDepth > 0 && cur.depth+1 >= opt.MaxDepth { + res.Skipped.DepthLimited++ + continue + } + inner = append(inner, job{child, cur.depth + 1}) + } + } + forced = nil + + bytes := res.TotalBytes - beforeBytes + // −1: сам каталог в счёт свёрнутого содержимого не входит. + res.Folded = append(res.Folded, Folded{Path: dir, Entries: res.Entries - beforeEntries - 1, Bytes: bytes}) + + e := Entry{Path: dir, Size: bytes, AgeDays: rec.AgeDays, Kind: core.KindDir, + Class: d.Class, Verdict: d.Verdict, Weight: d.Weight} + largest.add(e) + if d.Verdict == core.VerdictRemovable { + removable.add(e) + } + if opt.Observe != nil { + opt.Observe(e, info) + } + } + + account(opt.Root, rootInfo, true) var stack []job if rootInfo.IsDir() { stack = append(stack, job{opt.Root, 0}) @@ -372,6 +490,14 @@ func Walk(opt Options) (Result, error) { account(child, nil, false) continue } + // Свёртка спрашивается ДО учёта: свёрнутый каталог идёт + // своим путём, где решение выносится один раз и поддерево + // заряжается им целиком. + if info.IsDir() && opt.Fold != nil && opt.Fold(child, info) && + (opt.CrossDevice || devOf(info) == rootDev) { + foldDir(child, info, cur.depth) + continue + } account(child, info, true) if !info.IsDir() { continue // symlinks are never followed diff --git a/host/internal/scan/scan_test.go b/host/internal/scan/scan_test.go index 539f083..db887f5 100644 --- a/host/internal/scan/scan_test.go +++ b/host/internal/scan/scan_test.go @@ -305,3 +305,89 @@ func TestWalkSizeRuleInExactBytes(t *testing.T) { t.Errorf("the directories' own size must still be reported, just not counted into the total") } } + +// TestFoldCountsEverythingAndDecidesOnce проверяет обещание свёртки целиком, а +// не по частям: числа обхода обязаны совпасть со свёрткой и без неё до +// единицы, приговор внутри свёрнутого каталога обязан быть вынесен ОДИН раз, а +// сам каталог обязан попасть в рейтинг с размером поддерева. +// +// Отрицательный контроль здесь встроен: тот же самый счётчик решений на том же +// дереве без Fold обязан быть больше единицы, иначе тест не отличил бы свёртку +// от пустого дерева. +func TestFoldCountsEverythingAndDecidesOnce(t *testing.T) { + root := t.TempDir() + heavy := filepath.Join(root, "node_modules") + if err := os.MkdirAll(filepath.Join(heavy, "pkg", "dist"), 0o755); err != nil { + t.Fatal(err) + } + write := func(path string, size int) { + if err := os.WriteFile(path, make([]byte, size), 0o644); err != nil { + t.Fatal(err) + } + } + write(filepath.Join(root, "своё.txt"), 100) + write(filepath.Join(heavy, "a.js"), 200) + write(filepath.Join(heavy, "pkg", "b.js"), 300) + write(filepath.Join(heavy, "pkg", "dist", "c.js"), 400) + + counting := &countingDecider{ready: true} + folded, err := Walk(Options{Root: root, Top: 5, Now: time.Now(), Decider: counting, Fold: FoldByName}) + if err != nil { + t.Fatal(err) + } + decisionsWithFold := len(counting.seen) + + counting2 := &countingDecider{ready: true} + plain, err := Walk(Options{Root: root, Top: 5, Now: time.Now(), Decider: counting2}) + if err != nil { + t.Fatal(err) + } + + if folded.Entries != plain.Entries || folded.Files != plain.Files || folded.Dirs != plain.Dirs { + t.Errorf("свёртка изменила счёт записей: %d/%d/%d против %d/%d/%d", + folded.Entries, folded.Files, folded.Dirs, plain.Entries, plain.Files, plain.Dirs) + } + if folded.TotalBytes != plain.TotalBytes { + t.Errorf("свёртка изменила объём: %d против %d", folded.TotalBytes, plain.TotalBytes) + } + // Приговоров со свёрткой: корень, «своё.txt» и сам node_modules — три. + // Без свёртки их восемь: те же три плюс пять записей внутри. + if decisionsWithFold != 3 { + t.Errorf("решений со свёрткой %d, ждали 3 (корень, свой файл, каталог)", decisionsWithFold) + } + if len(counting2.seen) <= decisionsWithFold { + t.Fatalf("отрицательный контроль не сработал: без свёртки решений %d, со свёрткой %d", + len(counting2.seen), decisionsWithFold) + } + if len(folded.Folded) != 1 || filepath.Base(folded.Folded[0].Path) != "node_modules" { + t.Fatalf("свёрнутое не названо: %+v", folded.Folded) + } + // Пять: a.js, pkg, pkg/b.js, pkg/dist, pkg/dist/c.js. Сам node_modules в + // счёт своего содержимого не входит. + if folded.Folded[0].Entries != 5 { + t.Errorf("внутри свёрнутого записей %d, на диске 5", folded.Folded[0].Entries) + } + if folded.Folded[0].Bytes != 900 { + t.Errorf("байт в свёрнутом %d, ждали 900", folded.Folded[0].Bytes) + } + var found bool + for _, e := range folded.Largest { + if filepath.Base(e.Path) == "node_modules" { + found = true + if e.Size != 900 { + t.Errorf("в рейтинге у свёрнутого размер %d, ждали 900 — размер поддерева", e.Size) + } + } + } + if !found { + t.Error("свёрнутый каталог не попал в рейтинг крупнейших") + } + // Корзины обязаны сойтись с итогом, иначе свёртка «потеряла» байты. + var bucket int64 + for _, b := range folded.ByClass { + bucket += b.Bytes + } + if bucket != folded.TotalBytes { + t.Errorf("корзины по разряду дают %d, итог %d", bucket, folded.TotalBytes) + } +} diff --git a/host/main.go b/host/main.go index 7433a70..723a546 100644 --- a/host/main.go +++ b/host/main.go @@ -375,6 +375,7 @@ func analyze(args []string, after ui.After) (printed bool, err error) { protectFile := fs.String("protect-file", "", l.T("защитный список файлом")) live := fs.Bool("live", false, l.T("живой экран, даже если о терминале не спрашивали")) plain := fs.Bool("plain", false, l.T("обойти молча и напечатать отчёт, без экрана")) + noFold := fs.Bool("no-fold", false, l.T("судить о каждом файле внутри node_modules и подобных, а не о каталоге целиком")) var protectArgs stringList fs.Var(&protectArgs, "protect", l.T("не трогать: путь или «разряд:кэш»; можно повторять")) rest, err := parseFlags(fs, args) @@ -400,6 +401,12 @@ func analyze(args []string, after ui.After) (printed bool, err error) { Top: *top, Decider: decider, } + // Свёртка тяжёлых каталогов включена по умолчанию: смысл её в скорости, + // а платит за неё только подробность приговора внутри — размер и итог + // остаются теми же. Кому нужна подробность, тот её просит. + if !*noFold { + opt.Fold = scan.FoldByName + } if len(rest) == 1 { opt.Root = rest[0] }