English · Русский
digitdisk prints two readings of a machine — where the disk space went,
directories by size and the largest files, and how the machine is feeling
right now, CPU, memory, disk and network — and it can act on the first of
them: clean removes files, in three steps, none of which is a surprise.
status and analyze read and write nothing. clean shows a plan and needs
--apply to move anything; what it moves goes to a корзина inside the tree you
named and comes back with restore; erasing is a separate command with a
separate confirmation. What may be removed is not a list of well-known paths
and not a pattern: it is exactly what the decision layer in core/ gives the
verdict «МожноУбрать», which is proved to be nothing outside Кэш, Журнал and
Сборка.
The concrete places — npm's cache, Go's build cache, Xcode's derived data —
digitdisk knows from a справочник, a separate data file edited without a
rebuild. The справочник names a разряд; the verdict is still the core's, and
thresholds, directories, symlinks and content-addressed stores are judged
exactly as before. To say "leave this alone" there is the
защитный список; what past cleanups did is
digitdisk history.
core/ the readings as a flang specification, plus the Go printed from it into core/out-go
host/ the Go host: system calls, the command line, the output
host/internal/places/places.conf the справочник of known places: data, not code
packaging/ the Homebrew formula
scripts/ the release build
tools/ the licence gate
docs/ notes that are not this page
The split is the whole design. Everything that can be decided without touching
the operating system is decided in core/: a specification in flang, checked by
flang's own runs and printed into Go. Everything that must touch the
operating system — walking directories, statfs, reading counters — is
hand-written Go in host/. The core never opens a file; the host never decides
what a number means.
core/out-go is printed, not written. Hand edits there are lost at the next
print — see AGENTS.md.
Released binaries: Linux (x86-64, arm64) and macOS (Apple Silicon, Intel). All four are produced by one cross-compilation on Linux, with CGO off and a repeatable fingerprint — see macOS below.
brew install digitable-lol/tap/digitdisk
digitdisk --versionThe formula installs the released binary. It does not compile anything on your
machine and does not need a Go toolchain. Its source is
packaging/homebrew/digitdisk.rb; the copy
Homebrew reads lives in
digitable-lol/homebrew-tap.
V=0.1.0; A=amd64 # or A=arm64
base=https://github.com/digitable-lol/digitdisk/releases/download/v$V
curl -fsSLO $base/digitdisk-$V-linux-$A.tar.gz
curl -fsSLO $base/SHA256SUMS
sha256sum --check --ignore-missing SHA256SUMS
tar -xzf digitdisk-$V-linux-$A.tar.gz
sudo install -m 0755 digitdisk-$V-linux-$A/digitdisk /usr/local/bin/digitdiskCheck the sums before unpacking, not after. Every release also carries the formula, so the two ways install the same bytes.
A Go toolchain is enough from a clean checkout, because the printed Go is
committed. The build runs inside host/: that is where the module lives, and
there is no module at the root.
cd host && go build -tags flangcore -o ../digitdisk .-tags flangcore is what puts the flang core inside the binary. Without it the
host builds against a placeholder that counts but decides nothing —
digitdisk --version names which one is inside, so a build is never in doubt.
The host builds and runs on macOS, arm64 and x86-64. It takes its facts from
sysctl(3), getfsstat(2), the routing socket and the documented functions of
libSystem instead of /proc and /sys; the flang core is untouched by any of
that, because the core has never known what a system call is.
cd host && GOOS=darwin go build -tags flangcore -o ../digitdisk .Why there is no cgo. The obvious way to call a C function from Go is cgo,
and it would have ended the release: the four binaries are cross-compiled on one
Linux machine and checked byte for byte against a second build of themselves,
and cgo ends both properties at once. So the calls are made the way the Go
standard library itself makes them on macOS — the symbol is recorded as a
dynamic import, a two-instruction assembly stub jumps to it, and the call goes
out through syscall.syscall6. The Go linker writes the import into the Mach-O
file and the system loader binds it to libSystem at start-up, exactly as it
binds the imports the runtime already needs. No Mac is needed to build this; one
is needed only to check it.
How the layouts are proved. The decoders are written from Apple's headers, not from anybody else's source, and no number is printed until its provenance has been confirmed on the machine itself:
| What is read | What proves it was read correctly |
|---|---|
process record (kinfo_proc) |
our own pid, parent and user turn up where we expect them |
process memory and threads (proc_taskinfo) |
the kernel says how many bytes it wrote; our own process holds pages and has at least one thread |
command line (KERN_PROCARGS2) |
our own arguments match os.Args word for word, which the runtime got by another road |
memory breakdown (vm_statistics64) |
no page count exceeds the machine's pages; the read-ahead pages do not outnumber the free ones the kernel folds them into; and the disjoint buckets sum to hw.memsize within a third of a percent |
| CPU busy share | it is a ratio of two differences, so it depends on no tick rate at all |
interface counters (if_data64) |
the MTU matches what the standard library reports |
If a check does not agree, the field stays empty rather than being printed on a
guess. On top of that, every push runs those same self-checks on live GitHub
macOS runners, on Apple Silicon and on Intel:
.github/workflows/check.yml does not only build — it takes a snapshot and
looks for numbers in it.
What macOS measures, and the call each number comes from:
| Reading | Source |
|---|---|
| host, kernel, release, model | sysctl kern.hostname, kern.osrelease, kern.version, kern.osproductversion, kern.osversion, hw.machine, hw.model |
| uptime | sysctl kern.boottime (struct timeval) |
| load average, cores | sysctl vm.loadavg (struct loadavg), hw.logicalcpu |
| CPU busy share | host_statistics(HOST_CPU_LOAD_INFO) |
| memory total, page size, swap | sysctl hw.memsize, hw.pagesize, vm.swapusage (struct xsw_usage) |
| memory free, cache, available, used, wired, compressed | host_statistics64(HOST_VM_INFO64) (struct vm_statistics64) |
| processes: the list, and how many | sysctl kern.proc.all (struct kinfo_proc) |
| per-process memory, threads, threads on a processor, CPU time | proc_pidinfo(PROC_PIDTASKINFO) (struct proc_taskinfo) |
| per-process command lines | sysctl {CTL_KERN, KERN_PROCARGS2, pid} |
| disks | getfsstat(2) (struct statfs) |
| interfaces, addresses | net.Interfaces |
| interface counters | sysctl NET_RT_IFLIST2 (struct if_data64) |
"Used" and "available" are the two sides of one statement: memory that is
neither free nor file cache is in use. It is the same reading free(1) gives on
Linux, and the report prints the arithmetic next to the number.
What is still missing on a Mac, and why. Two kinds, and they are not the same kind:
- Closed by permission, not by the language. The memory, threads and command
line of a process belonging to another user are refused to anybody but the
administrator: the kernel checks the owner. Running under
sudofills those rows in; nothing else will. - Not published by the system. Die temperature comes from the SMC through IOKit, and Apple documents no interface to it — what circulates is a reverse-engineered structure. A number read that way would be a guess wearing a unit, so there is none.
The report names them in one line and leaves it at that. The reasons live behind
digitdisk status --why and in --json.
Two more macOS facts worth knowing before reading a report: a walk of /
stops at /System/Volumes/Data unless --cross-device is given, because the
system and the data volume are two filesystems; and a directory the privacy
machinery refuses is counted as "нет доступа", the same as an unreadable
directory on Linux.
Reprinting the core additionally needs flang, and only when the specification changed:
make -C core # check, print into core/out-go and core/out-c, compareRelease archives are built by
scripts/build-release.sh, which builds every
target twice and refuses to package if the two builds differ: the same commit
and the same Go toolchain give the same archive, byte for byte.
./digitdisk analyze <path> # where the space went: directories by size, the largest files
./digitdisk status # how the machine feels: CPU, memory, disk, network
./digitdisk places # what the tool knows about concrete caches, and what of it is here
./digitdisk history <path> # what past cleanups under this root did
./digitdisk --version # version, build hash, toolchain, decision layerAll four readings take --json. None of them writes anything.
./digitdisk clean <path> # the plan: what, how much, why. Nothing is touched.
./digitdisk clean <path> --apply # move into <path>/.digitdisk-trash/<stamp>/
./digitdisk restore <trash> # put it all back
./digitdisk purge <trash> --confirm N # erase. This one cannot be undone.The default is the harmless one: clean without --apply opens no file for
writing and does not even create the корзина, so finding out what it would do
never means having it done.
--apply is a rename(2) into a корзина inside the same tree, which is why it
is instant and reversible — and why it frees no space at all: the bytes are
still there under another name. Only purge frees space, it needs --confirm N
with N the exact number of files in the корзина, and the failure message does
not tell you N — you get it by running purge with no flag and reading the
plan. A confirmation you can satisfy without looking confirms nothing.
Every корзина carries a journal.json: what was moved, from where, how many
bytes, when, and where it went. It is written before the first file moves, so
a crash in the middle still leaves something restore can empty back, and it
survives purge as the record of what is gone.
A file that changed between the walk and the move is not moved. digitdisk remembers each file's dev/ino, size, mtime and mode, checks them again before touching it, and refuses by name — "размер изменился (был 25 Б, стал 30 Б)" — rather than removing something it no longer recognises.
The plan is meant to be read. Every list stops at --top (15 by default,
the same as analyze; --top 0 prints all of it) and ends with a line saying
how many files and bytes were left out. The counts do not move: the total, the
bytes and the breakdown by разряд are computed over the whole plan and are
independent of --top — a summary that shrank with the screen would be a
summary of the screen. --json is never cut: --json is how scripts call this
tool, and a shortened work list would make clean --json | jq quietly wrong.
The core's rules know what a cache IS in general: a path component called
.cache, Caches, cache. That is enough to recognise a cache and not enough
to recognise npm's, whose store is ~/.npm/_cacache — no component with the
word cache anywhere in it. The missing knowledge is a LIST OF PLACES, and a
list is data.
./digitdisk places # the whole справочник and what of it is here
./digitdisk places --json # the same for a machine
./digitdisk clean <path> --places FILE # your own справочник instead of the built-in one
./digitdisk clean <path> --no-places # judge by приметы alone, as before 0.4.0It lives in
host/internal/places/places.conf, travels
inside the binary as the default, and is replaced whole — by --places or by
~/.config/digitdisk/places.conf. A row looks like this:
разряд | якорь | система | путь | переменная | имя | источник
кэш|дом|все|.npm//_cacache|npm_config_cache|npm: download cache|https://docs.npmjs.com/...
The last field is mandatory, and not for decoration: every row comes from the
tool's own documentation, and a place is listed only when that documentation
calls it a cache, a log, or derived data the tool will rebuild by itself. The
double slash splits the path into a base and a tail: the base is what the
environment variable relocates (npm_config_cache, GOCACHE, CARGO_HOME,
GRADLE_USER_HOME and others), so a developer who moved a cache gets their real
place instead of one that is no longer there.
How it reaches the verdict. A row becomes a "цепь" — the place's path with a
slash at both ends, /home/u/.npm/_cacache/. The host assembles the chain; the
core matches it, and the slashes are what makes the match a match of whole
COMPONENTS: /home/u/x.npm/_cacache/ does not contain /home/u/.npm/_cacache/,
because there is no slash before .npm. A справочник whose chains are not
bounded is refused whole («Справочник ограничен») — matching one as a bare
substring would bring back the bug fixed on 1 September.
What it may not do. It names a разряд and nothing else, and only four of them: Кэш, Журнал, Сборка, Загрузка. «Крупное» is decided by size and «Неизвестное» means "no place matched", and the core refuses to let a file assert either (постусловие «Место обосновано»). It softens no threshold, removes no directory and no symlink, and a content-addressed store stays untouched: постусловие И3 outranks any line of the file. Invariant И1 — «МожноУбрать» never leaves Кэш, Журнал and Сборка — holds exactly as before, however many places the file knows.
How to say "do not touch this", by path and by разряд:
./digitdisk clean <path> --protect ~/projects # the path and everything under it
./digitdisk clean <path> --protect разряд:Загрузка # a whole разряд
./digitdisk clean <path> --protect-file FILE # a list from a fileWithout a flag, ~/.config/digitdisk/protect.conf is read; a row there is
путь|~/projects|why or разряд|Журнал|why. A path written without a leading
slash protects that chain of components at any depth.
The защитный список lives in the host and not in the rules, and that is not
an implementation detail. The core answers one question — what this path IS —
and every answer it gives is proved; "do not touch my ~/projects" is not an
answer to that question: the path may very well be a cache, and writing the
opposite into the справочник would be putting a falsehood into the layer to get
an effect. An instruction from the person who owns the machine belongs where the
host already keeps its veto — next to the checks in internal/clean. That is
why it weakens nothing: the list can only subtract from a plan, and no
постусловие of the core moves because of it. What it protected is printed in its
own ЗАЩИЩЕНО section, with the rule and the file line, rather than quietly
missing from the plan — and it is kept apart from ОТКАЗАНО, because a refusal
means the two layers disagree and somebody should look at the rules, while a
protection means the rules worked and a person overruled the answer.
./digitdisk history <path> # a cleaned root, the корзина store, or one корзина
./digitdisk history <path> --jsonWhat was removed, when, how many bytes are sitting in корзины, how much went
back, how much was erased — and what puts the last one back. digitdisk
remembers nothing between runs: every number is read out of the same
journal.json files that restore and purge obey. A separate history
database would be a second account of the same events, and the two would
disagree the first time somebody moved a корзина with mv.
"Freed" in that summary counts only what was erased: moving into a корзина frees no bytes at all, and a number claiming otherwise would be a lie about the disk.
What is cleaned goes into digitdisk's own корзина inside the tree, not into the desktop Trash, for three reasons, one of which is a number.
The number. A корзина inside the tree means rename(2): on this machine
moving a gibibyte does not register on the timer at all (0.00 s, three runs),
because no bytes move. A корзина across a filesystem boundary turns the move
into a copy: the same gibibyte written to disk with fsync takes 0.91 s per
GiB (best of three: 0.91 / 1.01 / 1.12). The cost of reversibility would
become the size of the cleanup, the file would exist twice while the copy runs
(so the space has to be free beforehand), and a crash halfway would leave half a
file. Both ~/.local/share/Trash on Linux and ~/.Trash on macOS live in the
home directory, and cleaning usually happens on other volumes.
The write boundary. "Does not leave the tree you named" is a property of the
system calls digitdisk uses: everything goes through an os.Root opened on the
root, which cannot be walked out of even through a symlink. A cleanup of
/var/tmp that writes into ~/.local/share/Trash cancels that property.
The two systems have no common behaviour. On Linux the Trash is defined by
the freedesktop.org specification:
files/ and info/ with .trashinfo records — and the same specification says
the home Trash only accepts files from its own filesystem, while another volume
needs a .Trash-$uid at its top level, which digitdisk would have to create. On
macOS the layout is different and Finder's "Put Back" lives in an unpublished
store: the documented way in is NSFileManager trashItemAtURL:, which is Cocoa,
which is cgo, which is the end of cross-building four targets from one machine
with a reproducible digest. There is no common behaviour to implement here —
there are two different Trashes and one way to lie about freed space.
What there is instead: digitdisk's корзина is an ordinary directory. Whoever wants to hand it to the system Trash hands it over themselves, in one gesture, and knows they did.
In a terminal, digitdisk status opens a live screen in the Digitable Focus
palette: the sections of the printed report as pages that keep measuring
themselves. ← → and Tab move between them, 1…9 go straight to one,
↑ ↓ scroll a long one, p holds, r measures now, q leaves.
Everywhere else it prints, exactly as it always has. A pipe, a file,
/dev/null, --json, TERM=dumb and an empty TERM all receive the text
report: the screen is never drawn into something that is not a terminal, so
scripts see what they have always seen.
--plain |
print the snapshot once, even in a terminal |
--live |
demand the screen; fail rather than print if there is none |
--interval MS |
how often the screen measures again (default 2000) |
DIGITDISK_PALETTE |
carbon (default), paper, signal — the palettes of the stack |
NO_COLOR is honoured: the screen still runs, it is simply drawn without
colour.
- It does not delete by pattern, and it does not delete from a list of
paths. digitdisk does have a list of known places, and that list removes
nothing: the справочник names a РАЗРЯД, and what
cleanremoves is still exactly what the decision layer gave the verdict «МожноУбрать» — with the same thresholds, the same refusal to touch directories and symlinks, and the same refusal to touch content-addressed stores. A place is an argument, not an order. The host keeps a veto on top of that and refuses a directory, a symlink or anything unreadable even if the layer were to ask — and when the two disagree it prints the disagreement instead of acting on it. - It never deletes in one step, and never without being asked. There is no
flag that erases without a plan first and a separate confirmation after, and
cleanon its own touches nothing at all. - It does not leave the tree you named. Every path operation goes through
os.Rootopened on that directory: it resolves each component itself and cannot be walked out of, symbolic links included. The корзина must live inside the same tree — a корзина elsewhere would make every move a cross-filesystem copy, and the cost of reversibility would become the size of the cleanup. - It does not delete recursively.
os.RemoveAllappears nowhere in this tree andtools/licensing.flangfails the build if it ever does. Files go one at a time, from a list in a journal; empty directories go through the call that refuses a directory with anything in it. - It does not explain instead of measuring. Where there is a number, it is
printed; where there is none, a dash, and the name of the reading on one line
at the end. Why it is missing lives behind
digitdisk status --why, a flag of its own, and not in the middle of the report: a reader wants a number, not an essay about kernel calls. - It is not a fork of mole, and carries none of its GPL-3.0 code — the idea
came from there, the code did not. See
NOTICE.
| Document | What is in it |
|---|---|
LICENSE |
the binding text: BSD-2-Clause, verbatim |
LICENSE-RU.md |
what that licence means, in plain Russian |
NOTICE |
where the idea came from, what was deliberately not taken, and why |
AGENTS.md |
the rules of this tree: write boundary, no GPL, where removal may live, the order of the checks |
flang io tools/licensing.flang # no copyleft; SPDX headers; removal only in host/internal/clean
flang check core/disk-inventory.flang && flang test core/disk-inventory.flang
make -C core # check, emit to Go and C, cross-check the two emissions
cd host && go vet ./... && go test ./...
cd host && GOOS=darwin GOARCH=arm64 go build ./... && GOOS=darwin GOARCH=amd64 go build ./...
cd host && GOOS=darwin go vet ./... # the macOS host, checked from a machine that is not one
scripts/build-release.sh # release archives, sums, formula; verifies the build repeatsThe licensing guard and the emission cross-check are written in flang, not in
Python or JavaScript: neither is present in this tree. The flang compiler is a
single binary that needs only a C compiler (brew install flang, asdf, or
make -C bootstrap in a clone of the language); it does not require Node.
The tree is complete and installable: the licences and the gate, the flang core
printed into core/out-go, the Go host with status, analyze, places,
history and the three steps of clean / restore / purge, and the release path — scripts/build-release.sh, the
Homebrew formula, and the tag-driven workflow in
.github/workflows/release.yml. The version
lives in one place, VERSION; the build stamps it into the binary,
and the workflow refuses a tag that disagrees with it.