Skip to content

disktree --du: GNU du as a headless mode of disktree, with a persistent index - #61

Open
aronchick wants to merge 11 commits into
tobi:mainfrom
aronchick:fm/disktree-du
Open

aronchick wants to merge 11 commits into
tobi:mainfrom
aronchick:fm/disktree-du

Conversation

@aronchick

@aronchick aronchick commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Closes #47

disktree --du is GNU du: the same options, output, messages and exit
status. It now runs as a headless mode of the existing disktree binary,
before settings, window or GPUI setup. Everything after --du is parsed as a
GNU du argument. When the binary is invoked through a link named du, it
selects the same mode automatically, so existing scripts and coding agents
can keep calling du without a second binary on disk.

Why

Agents run du -sh * repeatedly over the same tree. Every GNU du run is a
full walk, and every answer is a bare number. This makes the walk much cheaper,
makes repeats nearly free when the caller accepts a short catch-up, and can
hand an agent what disktree already knows about a directory: its kind, why it
can go, and the command that frees it.

Numbers

A 670 GB tree of 6.65 million entries, Apple M5 Max, APFS, warm cache:

command time
GNU du 9.11, du -s 148 s
disktree --du -s 25 s
disktree --du -s --max-age=1h, through the FSEvents journal 2 to 3 s
disktree --du -s --max-age=1h, by directory (no journal) 3.4 s

How it stays exact

  • The walk is parallel; the decisions are not.
    crates/disktree-core/src/du/walk.rs lists and stats on every core but
    keeps each directory's entries in the order GNU's fts visits them:
    readdir order, read in batches of 100,000, and a batch over 10,000 entries
    sorted by inode (skipped on tmpfs, NFS, CIFS and Lustre, as gnulib does).
    report.rs ports du.c's process_file and applies it in that order, so
    first-seen hard links, -x, --exclude, depths, thresholds, -S and
    --inodes come out as GNU's do.
  • The command line is glibc's. args.rs emulates getopt_long: argument
    permutation, clustered short options, unique-prefix long options and their
    exact complaints. num.rs ports gnulib's xstrtol suffixes and
    human_readable ceiling rounding. disktree's own options match only when
    spelled in full, so no GNU abbreviation changes meaning.
  • macOS reads a directory in one call. bulk.rs uses getattrlistbulk
    and takes from it only what converts to exactly the struct stat du would
    have seen: regular files and symlinks with one link (st_blocks is total
    allocation rounded up to 512-byte blocks, and st_size is data fork
    length, as XNU's vn_stat fills them). Directories, hard-linked files,
    anything else and any entry reported with an error go through fstatat.
    Output matches the plain walk and GNU on /Applications, compressed files
    included.
  • Without --max-age, every answer is a full walk. The snapshot is
    written alongside at no measurable cost.

--max-age and what it can miss

Each walk is kept as a snapshot (~/Library/Caches/disktree/du on macOS and
~/.cache/disktree/du elsewhere). With --max-age=AGE, a snapshot younger
than AGE is brought up to date instead of walked again:

  • macOS: the FSEvents journal, replayed from the event id recorded before
    the snapshot's walk, names every directory whose entries changed or that
    holds a file written and closed since. Only those are read; everything else
    is shared from the snapshot. A journal reset (new UUID), dropped or wrapped
    events, or a replay that does not finish falls back to the next method.
  • Elsewhere: every directory is stat'ed and read again when its ctime or
    mtime moved.

A file still open for writing is not in the journal until it closes. Files
modified within an hour of the snapshot, plus every multiply-linked file, are
therefore stat'ed again each time. This covers the usual log or build still
being written. By directory, a file that grew in place is caught only if it
was one of those. AGE counts from the last full walk and a catch-up never
resets it, so nothing can go unseen longer than AGE. A directory on another
file system below the operand is always read again in a journal catch-up,
since its changes are in another journal. A unit test covers that path;
mounting a disk image was not available for an end-to-end check. --fresh
walks everything.

--json reports as_of and source (walk, journal or directories) for
the answer. Each entry includes its size, kind, reclaim reason, a clean command
(cargo clean --manifest-path ..., pnpm store prune,
npm cache clean --force, or rm -rf for build output), and last write.
Behind a du symlink the switches come from DISKTREE_DU_JSON,
DISKTREE_DU_MAX_AGE, DISKTREE_DU_FRESH and DISKTREE_DU_INDEX.

Tests

  • crates/disktree-app/tests/gnu.rs builds a fixture with the cases du treats
    specially: hard links within and across operands, symlinks to files,
    directories, nothing and a loop, a sparse file, an unreadable directory,
    odd names, a 10,050-entry directory, a directory deeper than PATH_MAX,
    and one readable but not searchable. It runs 78 argument sets against
    the GNU du it finds, three ways each: a fresh walk, with the snapshot, and
    caught up with --max-age. Output, messages and exit status must match.
  • It passes against GNU du 9.11 (Homebrew, macOS), 9.4 (Ubuntu 24.04,
    what CI runs) and 9.7 (Debian 13), the last two run in containers. Two
    cases GNU changed after 9.4 are compared only against 9.11: a negative
    --max-depth (it became signed) and the wording of a bad --time-style.
  • Catch-up tests grow an old file in place, add and remove entries deep in the
    tree, and require the --max-age answer to equal a fresh walk's, through
    the journal and by directory. A unit test replays the journal for a real
    write.
  • The suite fails when the inode sort is disabled.
  • An independent adversarial review found a dozen issues: a racy-clean gap in
    the by-directory catch-up, deep paths, other file systems under the operand,
    snapshot keys with excludes, non-searchable directories, xstrtol error
    values, write errors, -X parsing and corrupt-snapshot bounds. Each is
    fixed, with a regression case where one could be built.
  • cargo xtask lint and cargo xtask test pass on macOS. Clippy is clean for
    x86_64-unknown-linux-gnu and x86_64-pc-windows-msvc.

Unsafe

Two modules are macOS-only, in the style of windows.rs: bulk.rs for
getattrlistbulk and fsevents.rs for FSEvents through CoreServices. Each
has a module-level allow with a reason and a SAFETY comment on every
block. Safe code parses each returned buffer, and a record that does not parse
sends the directory back to readdir.

Relation to #74

Issue #74 proposes disktree --cli, a readable text tree from the existing
binary. This PR does not implement that tree printer. It establishes the same
early headless dispatch point: select the mode before settings or GPUI, write
the result to stdout, and ship it in the one binary users already have. A
future --cli branch can sit beside --du at that boundary while using the
existing scan pipeline.

Relation to #32

This does not depend on #32. It uses disktree-core classification directly.
If #32 lands, its JSON command surface can call the same
disktree_core::du module.

Known differences from GNU

The README lists these too. They are all rare:

  • du >&- exits 0. Rust reopens a closed standard output on /dev/null
    before main, so the write error GNU reports never happens.
  • Hard links to one file inside a directory of more than 10,000 entries can
    come out in another order because GNU sorts with the unstable qsort.
  • Numbers use the C locale.
  • --time formats go through chrono, so %Z and %x differ. The named styles
    match.
  • In a UTF-8 locale, --exclude wildcards match bytes.
  • Diagnostics do not escape control characters inside ‘...’.

Not done

  • Linux keeps no journal an unprivileged process can read, so --max-age
    stats every directory there.
  • Numbers are formatted as in the C locale: no digit grouping for -B "'1",
    and . as the decimal point.
  • On Windows the mode says it needs st_dev, st_ino and st_blocks, then
    exits.

A new binary that is GNU du 9.x on the command line and in its output,
byte for byte, including diagnostics and exit status. It is meant to be
linked as du, so whatever already runs du gets it.

The walk lists and stats on every core, keeping each directory in the
order fts visits it (readdir order, batches over 10,000 entries sorted
by inode). A serial pass then applies du's process_file in that order,
so first-seen hard links, -x, --exclude, depth and threshold behave as
GNU's do.

Each operand's walk is kept as a snapshot. By default a later walk
reuses a directory's listing when its ctime and mtime are unchanged and
older than the snapshot by the racy margin, and still stats every
entry, so the answer is exact. --max-age answers from a young snapshot
without walking. --json adds kind, reclaim reason, clean command and
last write per entry, and says how old the numbers are.

tests/gnu.rs compares 73 argument sets against GNU du, cold, with the
index, and from it.
getattrlistbulk returns a buffer of entries with their attributes, for
about what the listing costs alone, instead of readdir and an fstatat
per entry. Only what converts to exactly the struct stat du would have
seen is taken from it: regular files and symlinks with one link, their
st_blocks being the total allocation rounded up to 512-byte blocks and
st_size the data fork's length, as XNU's vn_stat fills them.
Directories, hard-linked files, anything else and any entry reported
with an error still go through fstatat, and a record that does not
parse sends the whole directory back to readdir.

On a 6.65M-entry tree this takes a walk from 40 s to 25-29 s (GNU du:
148 s). Output is identical to the stat walk and to GNU du on
/Applications, compressed files included, for blocks, apparent sizes
and inode counts. DISKTREE_DU_BULK=0 turns it off.
A snapshot is read only when it can help: to answer for --max-age, or
where listings can be reused, which with macOS's bulk attributes they
cannot. Snapshots are written while the answer prints, not after it.

The format stores a device, a readdir inode and a ctime only when they
differ from the directory's, the entry's inode and its mtime, and no
longer keeps access times; --max-age does not answer --time=atime. A
6.65M-entry snapshot goes from 470 MB to 313 MB, and a walk that writes
one takes as long as one that does not.
A snapshot young enough for --max-age is now brought up to date from
what changed since it was taken, rather than returned as it was.

On macOS the FSEvents journal names every directory whose entries
changed or that holds a file written and closed since the snapshot;
only those are read again and every other listing is shared from the
snapshot. Elsewhere, or when the journal cannot vouch for the whole
interval (a new journal UUID, dropped or wrapped events), every
directory is stat'ed and read again when its ctime or mtime moved.

Neither sees a file still open for writing, since FSEvents reports a
write at close, so files last modified within an hour of the snapshot,
and files with more than one link, are stat'ed again every time.

On a 6.65M-entry tree a catch-up takes 2-3 s through the journal and
3.4 s by directory, against 25 s for a walk. --json reports which one
answered. DISKTREE_DU_JOURNAL=0 skips the journal.
Items only macOS uses are gated to it, and on Windows, where the binary
only parses its command line, the unused rest is allowed with a reason.
Clippy is clean for aarch64-apple-darwin, x86_64-unknown-linux-gnu and
x86_64-pc-windows-msvc.

The comparison reports every case that differs rather than the first,
and knows which GNU it is talking to. Messages are compared with the
program name normalised, since coreutils before 9.2 prefixed them with
the full argv[0]. It runs against GNU du 9.4 and later (CI's Ubuntu
24.04 has 9.4), skipping two cases GNU changed after that: a negative
--max-depth, and the wording of a bad --time-style. It passes on
Ubuntu 24.04 (9.4), Debian 13 (9.7) and Homebrew's 9.11.

README gains an "As du" section with timings; AGENTS.md no longer says
windows.rs holds the workspace's only unsafe code.
The journal's paths are the directory's own. Ask the kernel for the
path of the open operand (F_GETPATH) rather than resolving the name it
was given, and test that an operand typed in another case than the
directory's, on a case-insensitive volume, is still caught up.
- A catch-up by directory now treats a directory whose timestamps were
  within 2 s of the snapshot's walk as changed, as the default mode's
  racy-clean rule does. Without it a same-tick change could be carried
  into a later default run.
- --max-age counts from the last full walk, recorded in the snapshot; a
  catch-up that rewrites the snapshot keeps it, so what a catch-up cannot
  see is seen within AGE. Snapshots are no longer marked as confirmed.
- A journal catch-up reads again every directory on another file system
  below the operand: its changes are in another journal.
- With excludes, the snapshot key includes the operand as spelled, since
  exclude patterns match that spelling.
- Directories deeper than PATH_MAX are opened a piece at a time, as fts
  does, instead of failing with ENAMETOOLONG.
- A directory getattrlistbulk cannot read from the start (readable but not
  searchable) is listed with readdir, so each entry is reported as GNU does.
- The default walk stops at a directory that is its own ancestor (a bind
  mount), as the report already did.
- A bad block size keeps the value gnulib's xstrtol understood: a bad
  DU_BLOCK_SIZE falls back as GNU's does, and '-B 16Z' is 'too large'.
- -X drops trailing white space and blank lines and reads - from stdin; a
  --files0-from list that fails part way is reported where it fails and the
  total still printed; an ambiguous option is repeated with its =value.
- Standard output failing ends du as GNU's does: status 141 for a closed
  pipe, 'write error' and 1 otherwise.
- Corrupt snapshots cannot recurse without bound or allocate by a bogus
  count; concurrent writers of one snapshot use distinct temporary files;
  worker threads get 64 MiB stacks for deep trees.

The comparison against GNU gains six cases (a tree deeper than PATH_MAX,
a directory that is readable but not searchable, a -X file with CRLF and
blank lines, --files0-from naming a directory, an ambiguous option with a
value, and one operand spelled two ways with an exclude). README lists
the differences that remain.
@aronchick
aronchick marked this pull request as ready for review September 29, 2026 15:26
@aronchick aronchick changed the title disktree-du: GNU du on a parallel walk, with a persistent index disktree --du: GNU du as a headless mode of disktree, with a persistent index Oct 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

du for agents: a cached, GNU-compatible front end on disktree-core (building on #32)

1 participant