Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.9.0
0.9.1
9 changes: 9 additions & 0 deletions digitdisk.1
Original file line number Diff line number Diff line change
Expand Up @@ -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 путь
Expand Down Expand Up @@ -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 :
не считать размеры найденных мест, только назвать их.
Expand Down
9 changes: 9 additions & 0 deletions digitdisk.en.1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions host/internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -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: другая корзина; обязана лежать внутри корня",
Expand Down
50 changes: 27 additions & 23 deletions host/internal/lang/dict_cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 <root>/%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 <root>/%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",
Expand Down
7 changes: 5 additions & 2 deletions host/internal/lang/dict_report.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
19 changes: 19 additions & 0 deletions host/internal/report/report.go
Original file line number Diff line number Diff line change
Expand Up @@ -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("")
Expand Down
74 changes: 74 additions & 0 deletions host/internal/scan/fold.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// SPDX-FileCopyrightText: 2026 Marat Zimnurov <zimtir@mail.ru>
// 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
}
Loading
Loading