Skip to content

Latest commit

 

History

History
1208 lines (1153 loc) · 81.7 KB

CHANGELOG.md

File metadata and controls

1208 lines (1153 loc) · 81.7 KB

Changelog

All notable changes to this project are documented here.

The format follows Keep a Changelog, and this project adheres to Semantic Versioning.

[Unreleased]

[0.5.0] - 2026-10-11

fdu 0.5.0 adds ages and size colors to the tree and combines several disjoint roots in one report. It changes the report schema to fdu.report/11 and includes Rust and Python API changes. See the release notes for upgrade details.

Added

  • The text tree shows each row’s age, between size and name: how long ago anything the row counts last changed, the newest modification among its own entry and every entry beneath it that it counts, files, directories, and symlinks alike. The scan root’s own time never counts. With no filter a directory’s tree age equals its --long age; under a filter each row ages what it counts. An age is gray unknown where part of the subtree was not listed, a gray — on a row that counts nothing, and blank on the … and N more files row.
  • Machine output: tree nodes carry mtime_ns, complete, age_ns, and modified_at; list rows carry modified_at; the envelope carries age_reference_at beside age_reference_ns. Each *_at field is the RFC 3339 UTC rendering of its exact nanoseconds, and modified_at is null wherever the time is only a lower bound.
  • Python: TreeNode gains mtime_ns, complete, age_ns, and modified_at, FileRow gains modified_at, and Report gains age_reference_at; each datetime is timezone-aware UTC, derived from the nanoseconds and floored to the microsecond.
  • Rust: TreeNode gains mtime_ns, complete, and age_ns.
  • Several paths in one report: fdu docs src answers what fdu docs and fdu src would, added. Sizes, counts, rows, shares, and display bounds are taken over every root together, once. The tree shows a (total) row and each root as a row named by how it was given, every share of the total; flat listings print each path after its root’s label, as find docs src does. A root equal to or inside another is refused, naming both: by canonical path, which sees through a symlink, and on Unix by device and inode, which sees a root whose path runs through an alias of another. A root that is itself an alias into another’s tree, a bind mount or a macOS firmlink such as /usr/local beside /System/Volumes/Data, is refused on Unix when the other root’s walk enters it, with exit status 2 as before the walk; Windows compares paths only. Every PATH is a directory; a file is refused naming the command that lists only the directories, fdu */, which is itself refused where one directory is a symlink to another (lib64 -> lib). Each root is walked separately and pays a walk’s fixed cost, so many small roots are much slower than one walk of their parent: 625 small directories took 1.72 s as roots against 0.23 s for their parent, about 7 times as long (exp-216, macOS, uncontrolled host); fdu --depth 1 --min-share 0% . shows every entry one level down in one walk, counting the hidden entries and files */ leaves out, and not walking the target of a symlink that */ names as a root. Each root’s snapshot lives where its own would, written once every root has been walked, on a bounded number of threads. --watch, --cache-status, and --cache-clear take one PATH.
  • Machine output over several roots: the envelope’s root is null and roots lists each root’s label and canonical path; a tree section has tree: null, a total row, and trees, one tree per root, each with its own remainder; list rows, status errors, and .gitignore refusals carry root, the position of their root in roots, and keep their paths relative to it. A report over one root is unchanged.
  • Python: fdu.report takes one path or a sequence of them. Over several roots Report.root is None and Report.roots is a tuple of ReportRoot; TreeSection gains total (TreeTotal) and trees (RootTree); FileRow, OperationError, and RefusedControl gain root.
  • Rust: Roots and NamedRoot name a report’s roots and validate them before any scan; RootsRequest shares one request across them, and RootsRequest::resolve validates paths, request, and delivery in the order one root always failed in; report_roots reads one report from one index per root, refusing indexes of different scopes and, on Unix, an index that entered another root through an alias; and prepare_roots_report, with its progress and scan-diagnostics variants, runs each root’s plan and returns the roots’ pending saves in one handle, with each root’s telemetry (RootsPrepared). PerformanceSummary::sum totals them, PendingSave::join_all names each failed write, labelled_path is the one rule text uses for a path under one of several roots, and ReportSource::weaker ranks sources. Delivery::cache_dir names a directory in which each root’s snapshot is named, and Delivery::for_root resolves it for one root; every route honors it. RequestError gains NoRoots, RootsOverlap, RootsRepeated, RootReachedInside, RootScopesDiffer, and CacheLocationTwice.

Changed

  • Breaking: the report schema is fdu.report/11, for the fields above.
  • Breaking: ages past a month read in months of 30.44 days (11mo) and past a year in years of 365.25 days (3y), in --long as in the tree; they previously read in days (4,382d). Time bounds still take days; --modified-before 2mo is refused and the refusal names the days, as in use 60d.
  • In a color terminal, the tree’s ages and sizes are colored, after kash’s file listings. Ages by recency: under a minute bold bright red, under an hour bold yellow, under a day bold bright green, under a month bold cyan, under a year blue, older gray. Sizes by magnitude, with kash’s tiers moved up for larger trees: under 16 KiB gray, under 1 MiB blue, under 1 GiB plain, under 100 GiB bold bright green, larger bold yellow. Nothing else changes style; every other size keeps its style, and --long, the other flat formats, and output without color are unchanged.
  • Breaking: --sort mtime on a tree orders rows by the age the column shows, their newest counted activity of any kind, where it ordered them by their newest regular file. newest_mtime_ns keeps its files-only meaning.
  • A watch measures each repaint’s ages, and Session::report and Python’s Watch.report() theirs, from the instant of that repaint, so a file written during the session no longer shows a negative age. The windows the query selects by stay the absolute bounds its request resolved when it was built. A repaint is still skipped when nothing a reader sees changed: ages rolling over repaint nothing, and any change in a row’s activity repaints.
  • A watch, an opened root’s observation, and a refresh re-read the directory a created, removed, or renamed entry lives in, so that directory’s own time, and with it its age, matches a one-shot report: deleting a directory’s newest file, or moving an old file in, makes it young there too. Under --view files the record for such a change is now followed by an upsert record for its directory, unless it is the scan root or the selection leaves directories out; the record shape and fdu.stream/2 are unchanged.
  • Rust: TreeNode struct literals need the three new fields.
  • Breaking (Rust): Report::root is Option<PathBuf>, None over several roots, beside the new Report::roots; TreeStatus::errors holds StatusIssue { root, issue } rather than Issue; FileRow and RefusedControl gain root; Section::Tree gains roots; ProgressSnapshot gains root, and over several roots a run’s phase starts over at each root while its counters keep adding.
  • Breaking (Rust): Delivery gains cache_dir, so its struct literals name it.
  • Breaking (Python): Report.root is Path | None.
  • A PATH that is a file is refused before anything reads its snapshot, naming it as given: fdu README.md --stale-ok and Python’s fdu.report("README.md", stale_ok=True) now fail because it is not a directory (exit 1, OSError) rather than because it has no snapshot. A delivery no route can carry is still refused first, as before: fdu README.md --cache off --stale-ok exits 2 for its delivery.
  • The PATH argument is PATH...; help reads Usage: fdu [OPTIONS] <PATH>.... The progress line names the root being walked and its position, src (2/3), and the perf: line sums every root’s walk and names each root’s tier when they differ.
  • On Linux the default one-shot tree stats each directory and symlink again, as every other route does, so a directory’s age there counts its own time and equals its age under --long or a cached run. 0.3.0 had stopped those stats because the tree read none of their attributes; the age column reads their times. This is expected to give back most of that change’s gain, about 3% of the default tree on a directory-dense node_modules tree and little on the Linux v6.12 source tree: exp-197 measured the gain at 3.6%, and the version that shipped kept 83% of the stats it saved. The give-back itself is measured on Linux before this release. macOS lists every child’s attributes in bulk and is unaffected, except in a directory that falls back to the portable reader, which now stats them too.

[0.4.0] - 2026-10-09

fdu 0.4.0 is a breaking release about what a report asks for and what it says. A content view now requests the analysis it shows: fdu . --view code,documents counts lines of code by language and words by document type from one scan, with no --analyze. A human report holds only the result on standard output, and every explanation follows it on standard error as a consolidated note: or tip: line, with one runnable tip that lifts every display limit. documents percentages now add up to 100%. On the command line the breaking changes are what --view=documents and --view=full run beside --analyze=lines or --analyze=code; in Rust, Request gains a private field, RequestError becomes #[non_exhaustive] with reshaped analysis refusals, and AxisNames gains a field. No option was removed, and the report, cache, and stream schemas are unchanged. The GitHub release text is docs/project/release-notes/0.4.0.md.

Added

  • Rust: ViewSpec::implies and ViewSpec::shows, the analyzers a view requests and the ones it displays; ViewSpec::defaults_for, the views a request shows its analysis in when the caller named none; AnalysisSet::union, named, and request_label; Request::implied_by; Request::read_opened, a read of an opened root that refuses in its words; and query::BasisHolder, which the analysis refusals carry.

Changed

  • Breaking: a content view requests the analysis it shows. fdu . --view=code,documents gives lines of code by language and words by document type from one scan, with no --analyze; --view=code and --view=documents give either alone. --analyze now adds analyzers beyond what the views imply: analysis shown in a metadata view (--analyze=code --view=languages), analysis run without display, or a wider set. Views with a metadata meaning (tree, languages, types, families, full) never imply analysis. In Python, fdu.report(root, Query(views=(View.CODE,))) runs code analysis; an index from fdu.open or fdu.scan is never widened by a read, and a view it cannot answer is refused with the analyzer named. documents now requires words analysis, so --analyze=lines --view=documents is refused on a held index. request.analyze in structured output is the enabled set, named or implied; the report schema is unchanged.
  • Breaking: --view=documents beside --analyze=lines or --analyze=code now runs words analysis as well, because documents implies it. --analyze=lines --view=documents --format=json reports request.analyze: ["lines", "words"] and share_metric: "document_words" where it reported ["lines"] and raw_words; a script that read raw-word shares by document type reads document_words instead.
  • Breaking: --view=full under --analyze=lines or --analyze=code no longer includes DOCUMENTS, which needs words analysis: the report lists it in omitted_views and says note: full omits documents without words analysis with tip: include them: --analyze all. A script that read the DOCUMENTS section of such a report adds words to --analyze. In Rust, ViewSpec::full_report partitions the same way, keeping a view only when the analyzers include everything it implies: full_report(AnalysisSet::LINES_ONLY) and full_report(AnalysisSet::CODE_ONLY) now return Documents among the omitted views rather than the shown ones.
  • Breaking: fdu_core::query::Request gains a private field recording which analyzers the caller named and which views implied the rest. Rust code outside the engine crate builds a request with Request::new, Request::read, or Request::build rather than a struct literal, and cannot destructure it exhaustively; its public fields basis, query, and now are unchanged. Request::implied_by() returns the views that implied analysis.
  • Breaking: fdu_core::query::RequestError is now #[non_exhaustive], so a later refusal is an additive change; code that matches it exhaustively adds a wildcard arm, and code that renders it with message or Display is unaffected.
  • Breaking: RequestError’s analysis refusals change shape. ViewNeedsContent(ViewSpec) and NeedsAnalyzer { item, analyzer } are removed: a view the basis cannot answer is ViewNeedsAnalyzer { view, held, holder }, and a metric sort without its analyzer is SortNeedsAnalyzer { metric, analyzer, held, holder }, where analyzer and held are AnalysisSet values and holder is the new BasisHolder: whether a request it built, a basis supplied whole, a retained index, or an opened root fixed the analyzers. WatchContent becomes WatchContent { named, views }, the analyzers the caller named and the views that implied the rest. AnalyzerNamedAsView::suggested_view is a ViewSpec rather than a &'static str; suggested_view.label() is the old string. ViewNeedsAnalyzer, SortNeedsAnalyzer, WatchContent, and AnalyzerNamedAsView are each #[non_exhaustive], so a field one gains later is an additive change: code outside the engine crate matches them with .., as in ViewNeedsAnalyzer { view, holder, .. } or WatchContent { .. }, and cannot construct them.
  • The messages around analysis name the exact remedy: note: code analysis not shown by summary with tip: show it: --view code, and note: full omits code, documents without analysis with tip: include them: --analyze all. Analysis that some selected view shows and some does not is named too: --analyze=code --view=documents says note: code analysis not shown by documents with tip: show it: --view documents,code. A refusal for missing analysis names a remedy its route accepts: an index names what it was opened with, an opened root says it runs no analyzer, and only a request that can add an analyzer is told to. A watch is the exception for now, because it checks a metric sort, and in Python a content view, before it refuses analysis: fdu . --watch --sort=code_lines says add --analyze code, and Index.watch() says its index was opened with analyze none, which sends the caller to reopen it with an analyzer that a watch then refuses. A refused watch names every axis that enabled analysis: fdu . --watch --analyze=words --view=code says --analyze words and --view code need words and code analysis.
  • Human reports hold only the result: rows, column headings, and multi-view section headers. Every explanation is a note: or tip: line on stderr after it, including what used to sit inside the result: the percentage denominator (Percentage column: code lines is now note: percentages are shares of code lines), share-floor omissions, a single view’s row bound, Ranked by, and the code overview’s coverage lines.
  • Grouped rows stack their measures. In types, families, languages, and documents, a row’s first line ends at its file count, and each further measure is a line of its own indented under it: lines with their breakdown, words with pages, the generated and vendored counts, and each reason files were not analyzed. A row with only a file count is unchanged. On the Linux kernel source the standard output of fdu . --view code,documents is now at most 88 columns wide, where its widest DOCUMENTS row was 154; the perf: line on standard error is still longer. Machine formats are unchanged.
  • Human grouped rows no longer show a documentation count (4,063 documentation). It counts files under a doc, docs, or documentation directory or named like a README, CHANGELOG, or CONTRIBUTING file, which in DOCUMENTS is nearly every file, and it read as a measure of the documents. Machine output keeps it unchanged: each row’s detection.flags.documentation count and each file’s classification.flags.documentation.
  • Notes and tips are consolidated: one note for what totals include, one listing every display limit that hid something (note: display limits: below 1% of root, depth 5), and one runnable tip that lifts them all (tip: show more: --min-share=0% --depth=all; in Python, min_share=0%, depth=all). Beside other views, a note names the section it is about (note: percentages are shares of code lines (CODE), document words (DOCUMENTS); 2 rows below min share in TYPES), and sections that share a denominator are listed under it once (code lines (CODE, LANGUAGES)). The flat-format notes are shorter (note: result stale, incomplete).
  • Breaking: fdu_core::query::AxisNames gains a public field, setting_separator, that joins several settings in one suggestion (" " for flags, ", " for keyword arguments).

Fixed

  • documents percentages add up to 100%, within display rounding. Each row’s share divided its document words by the total’s, but logical words are derived after pooling, so a pooled total of mixed formats differed from the sum of its rows in either direction: with a long-token format such as HTML beside ordinary prose the shares summed past 100% (101.6% on the Linux kernel), and with very short tokens they could sum well below it. Shares in every grouped view now divide by the sum of the rows’ values, which is what share.denominator and the total row’s share carry in structured output; the total row’s own document_words and pages are still the pooled count. Byte, code-line, and raw-word shares are unchanged, since their sums equal the total.

[0.3.0] - 2026-09-30

fdu 0.3.0 is a breaking release focused on Linux speed and stability. On Linux the default fdu PATH tree is faster, and uses much less memory on a large tree, and classifying entries against .gitignore is faster again. On Linux a stat of a directory’s child no longer mounts an unmounted autofs trigger, on any route. Four Rust API changes are breaking: counters::Counts gains three public fields and is non-exhaustive, Error gains a variant, UnrepresentableTotal, scan::ScanBackendDiagnostics gains three public fields and is non-exhaustive, and content::CoverageReason gains a variant, TextOnly. A .gitignore that starts with a byte-order mark, or holds a NUL byte inside a line, now reads as git reads it, which changes the .gitignore semantics version: a snapshot written by an earlier release is rebuilt rather than served. A Markdown file over 64 MiB is counted as plain text under the words unit, and its row and report say so; content analysis of a one-line source or a large Markdown file no longer holds the file in memory. No command-line option, report or cache schema, or Python API changed; a report’s coverage map has one more possible key, text_only. The GitHub release text is docs/project/release-notes/0.3.0.md.

Added

  • FDU_COUNTERS=1 reports three more rows in its control state group: ignore patterns tested, the .gitignore rules whose glob ran against an entry; ignore bucket probes, the lookups of an entry’s name in an ignore file’s indexed rules; and ignore bucket hits, those lookups that found a rule matching the entry.

Changed

  • Breaking: fdu_core::counters::Counts gains three public fields, ignore_patterns_tested, ignore_bucket_probes, and ignore_bucket_hits, which carry the rows above, and is now #[non_exhaustive], so a later counter is an additive change rather than another breaking one. Rust code outside the engine crate that built Counts with a struct literal, or destructured it exhaustively, must build it with Counts::default() and read or assign its fields instead; code that only reads its fields is unaffected.
  • Breaking: fdu_core::scan::ScanBackendDiagnostics gains three public fields, linux_dents_attempts, linux_dents_successes, and linux_dents_fallbacks, which count the Linux native reader’s listings on every route that lists directories; they are None off Linux and on a Linux build without glibc. The FDU_SCAN_DIAGNOSTICS=1 trace’s backend object carries them as linux_dents_attempts, linux_dents_fallbacks, and linux_dents_successes, ahead of the macOS keys, and dirs_read equals the native successes plus portable_directory_reads; no existing key changes meaning. The platform tuning guide documents them. It is now #[non_exhaustive] too, for the reason Counts is: Rust code outside the engine crate can read its fields but can no longer build it with a struct literal or destructure it exhaustively, and a later backend field is an additive change.
  • Breaking: fdu_core::Error gains UnrepresentableTotal { path, counter }, which every route returns for a tree, or Index::apply for a batch, whose total no u64 can hold (below); code that matches Error exhaustively must name it or use a wildcard arm.
  • Breaking: fdu_core::content::CoverageReason gains TextOnly, the coverage of a words record whose Markdown file was over the exact bound below and was counted as plain text; such an outcome carries a value, as Analyzed does, and AnalyzerCoverage counts it as analyzed. Code that matches CoverageReason exhaustively must name it or use a wildcard arm. These are the only public API changes.
  • On Linux the default tree is faster again on directory-dense trees: 8.9% faster on a node_modules tree and 3.1% faster on the Linux v6.12 source tree, on top of the gains below, in one paired cell of 20 pairs per tree (exp-201). A one-shot tree report takes each directory’s and symlink’s kind from its parent’s listing and no longer stats it a second time, since the tree reads none of their own attributes; --one-filesystem keeps the stat for the device number, and a listing stats its children until one stat succeeds, which proves the directory searchable (see Fixed). That removes the statx of every directory and symlink except one a listing starts with: 7,886 of the 79,961 on the node_modules tree (exp-197). The tree also decides from each child’s totals whether the child will show before building a row for it, and frees its index on a separate thread rather than the one that prints the answer (exp-199). No answer and no public API changed.
  • The default summary is faster on trees with many .gitignore files: on Linux, 6.2% faster on the Linux v6.12 source tree, which has 358 of them, in one paired cell of 20 pairs (exp-198; 6.1% stacked with the other changes in exp-201), and unchanged on a tree with none. The summary compares each entry’s parent directory by its bytes rather than by path components, and finds the ignore files that apply to a directory through an index keyed by the directory’s bytes; each .gitignore is read into a buffer sized from its length rather than grown from 32 bytes. No answer and no public API changed.
  • On Linux the default tree is 39% faster on the Linux v6.12 source tree (200 to 120 ms) and 10% faster on a node_modules tree (127 to 114 ms) than on 0.2.1’s engine, and the default summary 26% and 7% faster, each in one paired cell of 20 pairs (exp-194, exp-195). The changes below account for it.
  • When a one-shot tree report needs nothing but the tree, as the default fdu PATH does, fdu no longer keeps every file in its index: it keeps every directory and only the largest files that could reach the smallest share the tree prints, and folds the rest into their directory’s totals. The answer is unchanged. On a generated million-entry Linux tree the default tree’s peak memory fell 79%, from 292 to 62 MiB, and its wall time 9% against 0.2.1’s engine, in a 12-pair screen (exp-194).
  • Classifying entries against .gitignore is faster again. Each ignore file’s rules are indexed by literal name, extension, and suffix, anchored rules are grouped by depth, and the rules left over are ruled out by cheap byte checks before any glob runs. On the Linux v6.12 source tree full glob evaluations fell from about 110 per entry to 0.0019 (exp-178), and what .gitignore handling adds to the default tree fell from 52% of a walk without it to 1.6%. What the rules match is unchanged, and so is IGNORE_RULES_VERSION; fdu’s ignored set agrees with git check-ignore on every path of that tree.
  • On Linux builds against glibc, every walk, revalidation, reconciliation, and opened discovery reads each directory with getdents64 into a reused buffer and stats its entries with statx relative to the directory, instead of through the standard library. A directory it cannot read that way is read the portable way; musl builds use only the portable reader. On the Linux v6.12 source tree the default command’s fstat calls fell from 5,773 to 4, and the default summary was 9–10% faster on both real trees (exp-185, exp-186).

Fixed

  • A directory that is readable but not searchable (mode 0400) is reported the same way on every route: each of its children as an error, and none of them as an entry. In 0.2.1 the default --view summary counted such a directory’s subdirectory as a directory and reported it a second time when it failed to open, and reported nothing at all for its symlink, because it took both kinds from the parent’s listing without the stat that fails there, while the full index reported each child once as an error. A listing now takes directory and symlink kinds from itself only once one of its children’s stat has succeeded, which proves the directory searchable; the saving above keeps all but one stat per listing that starts with directories or symlinks.
  • A tree whose apparent or allocated bytes sum past what a u64 can hold now fails with Error::UnrepresentableTotal on every route, and fdu exits 1 naming the file at which the total left the range. A filesystem can produce one: tmpfs, XFS, and btrfs let anyone create a sparse file that claims 8 EiB apparent and allocates nothing, so three of them in one directory are enough. Earlier releases panicked in debug builds and wrapped the total in release builds, so fdu PATH reported 8,191 PiB for that directory. A one-shot report, whether it folds a summary or builds a full or folded index, keeps one checked running total at the root, which bounds every directory’s total beneath it. Index::apply, and every other route that commits a batch of observations, refuses a batch that would carry a whole-tree total of files, directories, apparent bytes, or allocated bytes out of range before it applies any of the batch: the index, its clock, and its journal are as they were. A batch is applied in order, so the index must be representable after each of its operations; a replacement, a kind change, or a removal that makes room in the same batch counts. A snapshot whose recorded sizes sum past u64 is now refused as corrupt when it is loaded rather than summed. A ScanReport’s bytes_walked and allocated_walked, which measure the walk rather than answer it, saturate at u64::MAX rather than wrapping.
  • A .gitignore that starts with a UTF-8 byte-order mark now applies its first rule, and a pattern ends at the first NUL byte inside its line, as git reads both. Earlier releases matched the mark as part of the first rule, so it never applied, and kept the bytes after a NUL as part of the rule. The .gitignore semantics version is 4, so a snapshot written under 3 is rebuilt rather than served.
  • --analyze code no longer holds a whole logical line in memory: the line classifier now runs in pieces over a 64 KiB window as a line arrives, carrying its lexer state and the facts the whole-line classifier read from the line, and a site whose lookahead the window’s edge cuts waits for more bytes rather than deciding on a cut token. A one-line minified or generated source therefore costs a worker the window, not the file. Every count is unchanged: a differential test holds the streaming scan to the previous whole-line classifier, kept verbatim as the oracle, for every supported language, comparing the cumulative counts after every line rather than only the file’s totals. It runs nine window sizes from one byte, which puts a piece edge at every byte, to the full 64 KiB; two-way splits of the source at every seventh byte, at windows of one and three bytes; and every two- and three-way chunking of a short mixed source, at a window of one byte. Its sources hold CRLF and lone CR, splices, byte-order marks, invalid UTF-8, Unicode whitespace, no trailing newline, and tokens longer than the window.
  • --analyze words no longer holds a Markdown file of any size in memory to render it: a Markdown file over 64 MiB is counted as plain text instead, in the same 64 KiB chunks as any other text file, and its record says so. The CommonMark parser needs the whole source, because list tightness, headings, and link references resolve across the document, so an exact rendered count costs a worker the file and a parse beside it: a few hundred megabytes at most up to the bound. The largest Markdown files in common corpora are a few megabytes, so every real file is still rendered exactly, and the bound exists for the generated or concatenated file that would otherwise exhaust memory. Such a file counts every word as visible and each blank-line run as a paragraph; its row carries counted as text, machine output carries it under text_only in the words coverage map, and the report notes how many of the files its views show were counted that way and how. The bound is fixed: no option lifts it, which makes it the one bound without a flag. The design principles record it as a deliberate exception with that reason; lifting it needs an analyzer option in the content identity, which the request model does not have yet. A Markdown file at or under the bound is rendered exactly, as before, and a file of any other type retains at most its 16 KiB classification prefix and one chunk, which a test now holds it to. The markdown-prose-v1 analyzer is version 2, so a cached words record from an earlier release is analyzed once more; the analysis sidecar’s format is unchanged.
  • --analyze no longer materializes every candidate file before its first read. A candidate holds two paths and a classification, so a million-file tree cost hundreds of megabytes of scheduling memory that the bounded worker channel then drained one at a time. Candidates are now walked in batches of 4,096 over one resumable walk of the index, which resumes a directory’s listing where the last batch stopped, and are read by one set of workers for the whole pass, from a queue of at most one batch, so no worker waits at a batch’s end for the slowest file in it; the count is taken first, without building any, so the progress denominator is exact as before, and every result is still applied conditionally on the revision and fingerprint its candidate carried. Answers, records, and counts are unchanged.
  • fdu --watch no longer repaints its aggregate views when nothing a reader sees has changed: a touch that leaves a file’s size alone, or a change to an entry the selection leaves out, moves the index but printed the same tree again under a new timestamp. A repaint’s identity is what the format renders of the answer with its generation instant held fixed, plus its tree status, its source and freshness, and the notes, tips, and warnings written beside it on stderr, so machine formats still repaint when a modification time they carry moves, and a retained observation gap, a coverage change, or a new note, such as a .gitignore refused for its line limit on an otherwise unchanged tree, repaints on every format. A session keeps a 128-bit digest of that identity rather than a copy of the rendered answer. Change records are never deduplicated. The rule is the engine’s, watch_session::Session::changed_report, which is new.
  • A C header is no longer classified as C++ because a C++ keyword appears inside an identifier, a comment, or a string literal: struct pid_namespace * holds namespace , and kernel comments discuss namespaces and templates freely. The probe now counts namespace and template only where they open a line and std:: and constexpr only as whole tokens, outside comments and literals. On the Linux v6.12 source tree 168 of 25,308 headers (0.66%) were labelled C++; line counts are unchanged, only the language breakdown moves.
  • A modeline names a language as a whole token: mode: conf-colon and mode: conf no longer classify a file as C, ft=css is not cs, and mode: gomod is not go. Linux’s Documentation/docutils.conf was counted as C code. Emacs’s mode: shell-script, its documented name for sh-mode, is shell, and a Vim compound filetype names the language of its first component, as Vim applies it first: ft=c.doxygen is C.
  • On Linux, no stat of a listed child triggers an automount, on any route or with any worker count: an unmounted autofs trigger directory (/net, /misc, a systemd automount unit) is reported as the trigger, as lstat, GNU du, dut, and bfs report it, and a walk no longer mounts, or hangs on, a network filesystem it would not descend into. 0.2.1 and earlier mounted the trigger by statting it, because the standard library stats with statx and without AT_NO_AUTOMOUNT on glibc. The walk root itself is resolved: listing it mounts it in any case, and --one-filesystem bounds the walk to the filesystem it finds there. musl builds were never affected: the standard library stats with fstatat there. The answer on a tree without automount triggers is unchanged (exp-196 records the non-regression screen).
  • fdu --watch keeps its progress line up while its first answer is built. The line stopped when the initial scan and its save returned, before the answer was built, so a heavy view such as --view full over a large tree showed nothing for the seconds that build takes. The build is now drawn as Summarizing, as a one-shot report’s is, and the line is erased once the answer exists, before it or anything else is written. The entry point is the engine’s, watch_session::Session::changed_report_with_progress, which is new; the answer and every later repaint are unchanged.
  • Built from the source distribution inside an unrelated git repository, such as a home directory kept in git, fdu --version and the installed skill’s “Generated by” line named that repository’s commit. A build now stamps a revision only when the repository tracks fdu’s own manifest.

[0.2.1] - 2026-09-29

fdu 0.2.1 is a patch release focused on Linux. The default fdu PATH and --view summary are about three times faster on source trees with many .gitignore files, the default summary reports its ignored share without building an index, and building the full index is faster on trees of many small directories. A .gitignore spelled in another case applies where git applies it, and a --stale-ok answer always says it is stale. No command-line option, schema, or Rust or Python API changed incompatibly. The GitHub release text is docs/project/release-notes/0.2.1.md.

Added

  • Python Report.warnings and Rust report_format::report_warnings carry what a reader must not miss about an answer, between its notes and tips. Today that is the stale-answer warning below; a verified answer carries none.

Changed

  • A --stale-ok answer now says it is stale on every format, plain text included: stderr carries a warn: stale answer line that names --stale-ok as the way to a fresh answer, and --quiet keeps it. Plain text used to mark it only with cache only at the end of the performance footer, which --quiet removes. Paths and long output no longer print a separate note: cache-only result line, which the warning replaces. Machine output is unchanged; provenance.source and freshness already said so.
  • The default --view summary no longer builds the full index to report its ignored share: it classifies each entry against .gitignore as it counts it, keeping only the rules and the heads of ignored subtrees. The answer is unchanged, on every surface. On macOS peak memory fell 69% on a 137k-entry source checkout and 58% on a 77k-entry tree with no .gitignore, with wall time no worse. On Linux it took 19% less time and 97% less peak memory (314 to 8.6 MiB) on a million-entry tree with no .gitignore, and 7% less time and 23% less memory on the Linux v6.12 source tree.
  • On Linux, building the full index on a tree of many small directories is 7–11% faster: the index builder returns each directory listing to the walker thread that allocated it, so glibc no longer frees it across threads. Measured on a million-entry generated tree (−10.6%) and a node_modules tree (−8.6%); trees with few, larger directories are unchanged.
  • Classifying entries against .gitignore is about three times faster on trees with many ignore files, which is what the default fdu PATH and --view summary do. Matching no longer allocates, and each directory’s governing ignore files are resolved once for all its entries instead of once per entry. On the Linux v6.12 source tree (358 .gitignore files) the default tree fell from 590 to 211 ms and the default summary from 505 to 167 ms; answers are unchanged.

Fixed

  • A .gitignore spelled in another case, such as .GITIGNORE, now applies where git applies it: on a case-insensitive volume (APFS and NTFS by default, an ext4 casefold directory), where opening <dir>/.gitignore finds it. Before, fdu took rules only from a file listed exactly as .gitignore, so its gitignored shares differed from git’s on such trees. On a case-sensitive volume nothing changes: a .GITIGNORE there is an ordinary file, as it is for git. Every route and surface agrees, including the default summary, --ignored, --watch, opened roots, and refresh; the rules are named .gitignore wherever they are reported. The engine fingerprint now mixes in the version of these rules, so nothing cached under the old rule is reused: each cached tree’s first run after upgrading scans cold, and its first analyzed run reads every file again.

[0.2.0] - 2026-09-28

fdu 0.2.0 makes the command people type cheaper and narrows what the cache does by default. --cache takes auto, on, or off: under auto a one-shot metadata report no longer writes a snapshot that no later one-shot report reads, and --stale-ok replaces --cache only. Code analysis gains an aligned code table and population control, tree output gains display bounds, multi-view reports resolve each file once, and a large one-shot index is freed after the answer instead of before it. Several flags, the report and cache-status schemas, and the macOS cache location changed incompatibly; Upgrading from 0.1.0 says what to change. The GitHub release text is docs/project/release-notes/0.2.0.md.

Changed

  • On macOS, --watch and an opened root report a file held open for writing, such as a growing log or a SQLite write-ahead log, as of its last close. The kernel emits a content event for descriptor writes only when the last descriptor closes, and a watcher now reacts to the events it gets. Earlier, the whole-tree reconcile that every rename triggered also re-read such files; quiet trees already behaved this way. A one-shot report, or an explicit refresh from Rust or Python, still reads their current size.
  • Breaking: --cache takes auto, on, or off, and auto depends on the kind of request. A one-shot metadata report under auto no longer writes a snapshot, since no later one-shot report reads it; content analysis, --watch, and an opened index still read, revalidate, and write. --cache on also writes after a one-shot metadata report. Python’s CachePolicy is AUTO, ON, OFF; Rust’s CachePolicy is Auto, On, Off, and Plan::persists replaces CachePolicy::writes.
    • A default fdu PATH therefore no longer leaves a snapshot for a later open, --watch, or --analyze run of the same root, and that run starts with a walk unless an earlier run used --cache on or was itself one of those runs. A one-shot metadata report never read the snapshot: revalidating it stats every entry anyway, so it cannot make a metadata report cheaper, and on Linux it measured slower than a fresh walk (usage guide, platform tuning guide). What the missing snapshot costs the later run is small: on the million-entry Linux tree, an open or watch that loaded one reached its first answer 0.22 s sooner (1.00 s against 1.22 s, one probe), and a repeated --analyze keeps its savings in the content sidecar, which analysis runs still write.
  • Breaking: --cache only is now --stale-ok (Python stale_ok=True, Rust Delivery::stale_ok), which answers from the snapshot without touching the tree. Leave a snapshot for it with --cache on. Rust callers that build a Delivery by struct literal must now name stale_ok, or build it with Delivery::new(cache, cache_path) or Delivery::stale_ok(cache_path).
  • Breaking: --ignored=include|exclude|only controls population, replacing --exclude-ignored and --only-ignored. Exclusion prunes safely ignored subtrees and skips their content analysis; only analyzes ignored bodies.
  • Tree output defaults to depth 5 and a 1% share of the selected root, with significant file leaves. --breadth bounds children; --limit bounds rows per section.
  • Code analysis defaults to a source-line overview with language and population totals, coverage, and metric sorting. The code table aligns per-language counts and a bold TOTAL row; display limits preserve complete totals. Correct multiline literals, heredocs, Rust lifetimes, and JavaScript regex handling; invalidate prior code-analysis records.
  • Human output uses consistent names, primary totals, and gray parenthetical details. A code overview row’s parenthetical names only its gitignored share, and any share whose ignore status is unknown, never the non-ignored remainder. Performance includes ignore-file and rule counts plus total files/s and represented GiB/s.
  • Breaking: --workers sets content-analysis concurrency (zero selects available parallelism), replacing --analysis-workers.
  • Breaking: Cache data uses <key>.metadata.bin and <key>.analysis.bin. macOS and Linux default to ~/.cache/fdu, so the macOS default moved from ~/Library/Caches/fdu; --cache-dir and FDU_CACHE_DIR select an exact destination.
  • Breaking: Report schema is fdu.report/10; cache status is fdu.cache/3. Python exposes matching population, code overview, display limits, and cache destination controls.
  • A report with two or more unfiltered metric views (types, families, languages, documents) resolves each regular file’s content record and path classification once and feeds every view from one pass, instead of once per view (H153). Output is unchanged; an exact oracle compares the combined result with separately built single-view reports. An exploratory macOS measurement found 100 four-view report constructions about 2.5 times faster on a 137,085-entry tree (exp-159). It is not a scan or end-to-end speedup, the default command and single-view reports do not take this path, and the change is retained provisionally until a quiet rerun settles its inconclusive major-fault gate.
  • A one-shot report frees an index of at least 65,536 entries on a detached thread, named fdu-index-release, after the answer is complete, instead of before returning it (H156). On the million-entry Linux tree that release was 95 ms of a 1.39 s --cache off report, and the default tree job took 3.19% less wall time (95% interval 1.79% to 4.88% less, exp-160). Library callers of prepare_report or Python’s fdu.report get the answer sooner and see the memory return moments after the call rather than before it. The release stays inline on Windows, with FDU_COUNTERS=1, for smaller indexes, and when the thread cannot be started.
  • The fdu command installed from the Python wheel starts about 44 ms faster: fdu --version takes 22 ms rather than 65 ms on an Apple silicon Mac. The package imports its Python API on first use, so the command, which hands its arguments to the native command line, loads only the native module.

Removed

  • Breaking: --cache refresh and --cache read-only, with their Python and Rust counterparts. Each retired value is refused with its replacement named. read-only has no exact replacement: reading a snapshot without ever writing one is gone. Under auto, a failed snapshot write is reported as a warning, so a read-only cache directory still answers; --cache off reads nothing.

Fixed

  • A rename no longer makes --watch or an opened root reconcile the whole tree. The event backends report each side of a rename as its own event, and every one of them re-walked the root: on a busy 476k-entry agent-state tree on macOS, where atomic temp-file writes rename constantly, that was a full walk every 20 s, 48% of a core, and 6.9 GB of snapshot rewrites an hour. Each side is now verified as its own path, like a create or a remove: a vanished name is removed with its subtree, a present file is updated, and only a directory that arrives by rename is relisted, bounded by that directory. A rename that changes only case reconciles its parent. Kernel event loss, a rename of the root itself, and kqueue renames still reconcile the root. The removed root walks also happened to re-read files held open for writing, whose writes macOS reports only when they close; on macOS such a file is now current as of its last close.

Upgrading from 0.1.0

  • Command lines. Replace --cache only with --stale-ok, --cache refresh with --cache on, and drop --cache read-only (see Removed); fdu refuses each retired value and names its replacement. Replace --exclude-ignored with --ignored=exclude, --only-ignored with --ignored=only, and --analysis-workers N with --workers N. These three are refused as unknown arguments with exit status 2, and the parser’s suggestion does not name the replacement: for --exclude-ignored it suggests --exclude, a different option. A script that relied on a one-shot report to leave a snapshot, for a later --stale-ok read or a warm open or --watch, adds --cache on to that report.
  • Machine output. A consumer pinned to fdu.report/7 moves to fdu.report/10, and one pinned to fdu.cache/2 moves to fdu.cache/3; fdu.stream/2 is unchanged. The machine-output reference describes the current shapes.
  • Cache files. 0.2.0 neither reads nor removes the files 0.1.0 wrote. Each cached tree’s first 0.2.0 run walks it, as after every upgrade, because the engine fingerprint mixes in the crate version. On macOS without XDG_CACHE_HOME, 0.1.0 wrote to ~/Library/Caches/fdu, which 0.2.0 never looks at: delete that directory. Elsewhere the old files share 0.2.0’s directory under their old names, <key>.fdu and <key>.content; fdu --cache-status=all lists them as unrecognized, and fdu --cache-clear=all leaves them in place. Delete them by hand, or run 0.1.0’s fdu --cache-clear=all before upgrading.
  • Rust. CachePolicy is Auto, On, Off: Only becomes Delivery::stale_ok, Refresh becomes On, and ReadOnly is gone. CachePolicy::writes is replaced by Plan::persists. A Delivery built by struct literal must name stale_ok, or use Delivery::new or Delivery::stale_ok.
  • Python. CachePolicy.ONLY becomes stale_ok=True on fdu.report and fdu.open, CachePolicy.REFRESH becomes CachePolicy.ON, and CachePolicy.READ_ONLY is gone.

[0.1.0] - 2026-09-25

The first release. fdu walks a directory tree once and answers, for every directory at once, how big it is, how many files it holds, what changed most recently, and what kinds of files it contains. It caches that index between runs and can keep it live. The same engine ships three ways: the fdu command line, the fdu Python package, and the fdu-core Rust crate, which the fdu crate re-exports. The GitHub release text is docs/project/release-notes/0.1.0.md.

Added

  • Request model. The command line, the Python package, and the Rust library share one typed Request (Basis, Query, now), one Delivery, the axis grammars, and Request::DEFAULTS. Allocated size is the default everywhere, including SizeMetric::default and an opened selection that names no metric. A refused request is a usage error: exit status 2 on the command line, InvalidArgumentError (a ValueError) in Python. Query.axes is &'static AxisNames, so a refusal names flags or fields in the caller’s vocabulary.
  • Command line. fdu PATH prints a size-sorted tree two levels deep with ten rows per directory: the default list view in tree format. Bare fdu prints help and scans nothing. Sizes are allocated bytes unless --size apparent asks for file lengths.
    • Every option belongs to one axis: scope (PATH, --scan-depth, --one-filesystem), content (--analyze), selection (--include, --exclude, --min-size, --modified-since, --modified-before, --kind, --depth, --limit, --sort, --reverse, --size), view, format, and mode (--cache, --watch, --analysis-workers). --depth and --limit bound only what is rendered; --scan-depth bounds what is scanned.
    • --view takes list, summary, tree, families, types, extensions, languages, documents, largest, recent, files, or full. Several views in one run share one walk. list, the default when nothing is analyzed, is every matching entry: text draws it as the directory tree, and JSON, JSON Lines, and YAML give it as flat rows. tree is the directory hierarchy, in machine formats too. largest and recent are the 20 largest and most recently modified regular files; files lists every matching entry in name order; full is every view but list and files.
    • Directories are selected like files. --kind dir --include node_modules --modified-before 30d finds stale build and environment directories: a directory’s size is the eligible regular-file bytes below it, and its modification time is the newest on it or any eligible descendant, directories and symbolic links included. Size and modification bounds test that subtree rather than the directory inode, so without --kind a time bound also matches every directory with activity in range. Exclusions win throughout a subtree, nested matching directories are each listed, and aggregate totals count covered contents once.
    • --format text|tree|paths|long|json|jsonl|yaml, with --tree and --long as shorthands, and color decided by --color auto|always|never, NO_COLOR, and FORCE_COLOR. tree is the bounded directory tree; paths prints every matching path, one per line; long adds each one’s size and signed modification age, measured against one instant for the whole report. A flat list is complete and size-ranked unless --limit or --sort says otherwise. tree applies to the list, files, and tree views, and paths and long to those and to largest and recent; grouped and mixed views, and full, use text or a machine format. Results go to stdout, warnings and errors to stderr, and a one-shot report in text or tree format ends with a gray performance line.
    • A run at an interactive terminal that takes longer than half a second shows one progress line on stderr: the root, the phase (Loading, Scanning, Revalidating, Indexing, Analyzing, Saving, Summarizing), the counts so far in right-aligned columns, and elapsed time, erased before any output. Its bytes, like the performance line’s, are measured as the answer’s are: allocated unless --size apparent. It is never drawn when stderr is not a terminal, TERM is dumb (or unset, except on Windows), or CI is set to a non-empty value. --progress=auto, the default, also skips JSON, JSON Lines, and YAML; --progress=always draws it for those too, and --progress=never turns it off. Ctrl-C while it is drawn erases it, prints fdu: interrupted, and ends the run as an unhandled Ctrl-C would: by SIGINT on Unix, with STATUS_CONTROL_C_EXIT on Windows.
    • Exit status 0 means a complete result, 1 a failed command, and 2 a partial result or a usage error. --allow-partial accepts a partial result as success.
    • fdu --docs prints the usage guide and fdu --skill prints a portable agent skill, both without a PATH and without scanning. The skill runs an fdu on PATH and otherwise uvx fdu@latest, names the persistent installs (uv tool install fdu, uv tool upgrade fdu, cargo install --locked fdu), and states the build that wrote it, asking to be re-installed when fdu --version differs.
    • fdu --install-skill writes the skill to .agents/skills/fdu/SKILL.md and .claude/skills/fdu/SKILL.md under the git root of the current directory, or the current directory outside a repository; --agent-base DIR writes DIR/skills/fdu/SKILL.md instead, for one agent’s user scope. It reports each file as installed, updated, or unchanged, and writes through a staged file and a rename. It replaces only files carrying its generated-by marker, and refuses a SKILL.md written by hand with exit status 2 before writing anything.
  • Machine output. Every report uses fdu.report/7, watch changes use fdu.stream/2, and --cache-status uses the separate fdu.cache/2 document. Reports carry the effective request, structural status, and per-tier provenance. status.errors retains bounded structured details and status.errors_omitted counts the remaining failures. Each requested analyzer has its own coverage counts; metrics from unrequested analyzers stay absent. JSON, JSON Lines, YAML, and Python models share the answer shape, including lossless platform-tagged identities for paths that are not valid Unicode. A field change bumps the schema version.
    • Without --view or --analyze, a machine-format report is the list view: a files array holding every matching entry, unless --limit bounds it. --view tree gives the directory hierarchy instead.
    • A list row carries path, kind, bytes, allocated, a directory’s descendant files and dirs, complete, mtime_ns, ignored, and a signed age_ns measured against the report’s age_reference_ns. A directory whose subtree was not listed in full, at a --scan-depth boundary or where the scan failed inside it, has complete: false, lower-bound sizes and counts, and a null age, and matches no modification bound.
  • Content analysis, opt-in with --analyze, which takes lines, code, words, a comma-separated set of them, none, or all.
    • lines counts physical, blank, and nonblank lines and raw words; code adds code-sloc-v1 code, comment, and blank lines for Rust, Python, JavaScript, TypeScript, Go, Java, C, C++, C#, Ruby, PHP, Swift, Kotlin, shell, and SQL; words adds normalized words, paragraphs, and derived pages, and reader-visible words for Markdown.
    • Binary files, unsupported encodings, invalid UTF-8, and code without a shipped SLOC analyzer are reported as coverage and do not make a run partial; an I/O failure or a file that changes while it is read does.
    • Classification stays path-only for exact filenames and known extensions. Only unresolved paths and .h files receive bounded probes for shebangs, modelines, binary signatures, and generated-file markers, and every metric row reports how its files were detected and with what confidence.
    • A content sidecar keyed by the analyzer set keeps results between runs, so an unchanged file is not reopened; it answers only the analyzer set that wrote it.
    • --view never turns on an analyzer, and a view that displays none of what was read says so.
  • Cache. By default a report or open saves a snapshot of its index under the user cache directory: $XDG_CACHE_HOME/fdu when that is set, otherwise ~/Library/Caches/fdu on macOS, ~/.cache/fdu on other Unix systems, and %LOCALAPPDATA%\fdu on Windows.
    • --cache auto|refresh|read-only|only|off chooses how a run uses it, and --cache-status[=root|all] and --cache-clear[=root|all] inspect and remove snapshots without scanning.
    • A snapshot serves only the scan scope that wrote it, and a corrupt, truncated, or unrecognized file is treated as absent. Unix snapshots are created owner-only (0600).
    • Cache status reports every file in the cache directory with a state: current, stale for a snapshot another fdu version or format wrote or a header this build cannot read, leftover for one of fdu’s own files left by an interrupted write, unrecognized for a file that is not fdu’s, and absent for a root with no snapshot. A stale row names why (older_format, newer_format, other_engine, unreadable) and a leftover row what it is.
    • --cache-clear and --cache-clear=all remove stale snapshots as well as current ones, so an upgrade’s invalidated snapshots are reclaimed rather than deleted by hand, and --cache-clear=all also reclaims fdu’s own leftovers: a staging file a killed writer never renamed, once it is older than the age its reaper uses, and a content sidecar whose snapshot is gone. A file is removed only if its name and its magic both say it is fdu’s; a symbolic link or a directory is never followed or removed, and an unrecognized file is always left in place and reported.
    • An unfiltered --no-gitignore --view summary is the one composition that retains no index and writes no snapshot. Reading .gitignore, which is the default, needs the index to classify entries, so an unfiltered --view summary retains it and saves a snapshot like any other report.
    • Cache-only content restore avoids repeated roll-up, candidate, classification, and path work, streams records into the retained index, and repeated unfiltered metric views share one entry traversal. Individual development comparisons on exploratory, uncontrolled, warm-steady M1 Pro/APFS runs measured roughly 4% to 13% lower wall time for cache-only restore changes and 10% lower peak RSS from streamed restore. A separate workload that included setup plus 100 four-view reports measured 19% lower wall time. These results are not additive and do not measure an ordinary one-shot command or Linux; the performance evidence preserves each workload and interval.
  • Watch. fdu --watch repeats the same query as the tree changes. Aggregate views repaint at most every --interval, which takes the age grammar, including 200ms; the 2-second default is the engine’s WatchDelivery::DEFAULT_INTERVAL, which Python WatchOptions uses too. --view files --format jsonl emits one fdu.stream/2 record per change. Events are verified by stat, and a backend overflow or rescan request becomes a reconcile of the affected subtree rather than a dropped event. An upsert carries ignored, and so does a removal a rule edit caused; an ordinary removal, an invalidation, and every record of a run that read no rules omit it. A watch is metadata-only on every surface: the command line refuses --analyze with --watch, and a Rust Session or Python Index.watch() refuses an index opened with content analysis, as Error::UnsupportedScanConfig or InvalidArgumentError. The request model also refuses a narrowed scan scope and cache-only, so a library or Python caller cannot ask for what the command line refuses.
  • .gitignore roll-ups, read by default on every surface. An index keeps ignored and unignored roll-ups for every directory beside the totals, and every report says how much of each size the tree’s own rules ignore.
    • Every .gitignore inside the scanned root governs its own directory and everything below it, and deeper files take precedence. Negation, anchored and directory-only patterns, **, and git’s bracket expressions follow git, pinned against verdicts recorded from git check-ignore. Matching is byte-exact and case-sensitive on every platform.
    • A watch or refresh that sees a .gitignore change re-reads its rules.
    • fdu PATH, fdu --watch, fdu.report, fdu.open, fdu.scan, fdu_core::open, prepare_report, and opened roots all observe control state. --no-gitignore on the command line, and read_controls off on Rust ScanConfig or Python ScanOptions, reads no rules for one request and is a snapshot scope of its own.
    • An index that did not observe control state says so rather than guessing: Index::is_ignored, controls, and the partition accessors return Error::ControlStateNotObserved instead of calling every entry unignored, and a row from such a run carries null rather than “nothing ignored”.
    • Text summary, tree, and extension rows end with the ignored part of their size, as (128 B ignored), and leave it off a row with nothing ignored. The performance line counts the rule files read, as ignore rules 1 file, or says no ignore rules.
    • In machine formats summary and tree rows carry an ignored object (files, dirs, bytes, allocated), extension rows one without dirs (files, bytes, allocated), and file rows an ignored flag; each is null where no rule was read.
    • --exclude-ignored and --only-ignored, Selection::ignored in Rust and Selection(ignored=...) in Python, report one side; sizes, ordering, and --min-size follow the entries shown. Either filter over a run that read no rules is refused, not answered with an empty report: a usage error on the command line, InvalidArgumentError in Python, and Error::ControlStateNotObserved from prepare_report and a watch session.
    • A --watch stream maintains the entry set its selection names, so a rule edit that moves an entry into --exclude-ignored or --only-ignored streams the upsert that draws it and one that moves it out streams the removal, even though nothing about the file changed on disk.
    • A .gitignore that cannot be read makes the result partial, exit status 2 unless --allow-partial.
    • Two independent limits bound the rules an index retains, each a size or unbounded, and each liftable without moving the other. --gitignore-budget SIZE|all bounds the retained control state, 4 MiB by default; --gitignore-line-limit SIZE|all bounds one pattern, 16 KiB by default. They are ControlLimits { budget, line_limit } on ScanConfig::control_limits and OpenOptions::control_limits, and control_budget and control_line_limit on Python ScanOptions and OpenedOptions. Both are part of the snapshot scope.
    • A .gitignore past either limit is refused rather than ending the scan: its rules do not apply, every size stays exact, and the result stays complete with exit status 0. An opened root completes the directory and keeps watching, and a watch keeps applying events. Identical .gitignore contents are stored and charged once, so a tree of package checkouts spends a fraction of the budget it otherwise would.
    • A refusal is reported, not silent. Reports carry ignore_rules in every machine format: null when the run read no rules at all, otherwise limits (budget and line_limit, each bytes or null), the applied and refused counts, and refusals, each with a path and a reason naming the limit that fired. A text note names the refused files’ directories and the flag that lifts each limit that fired. Index::control_coverage and ReadDiagnostics::controls carry the same, Python mirrors both, and EffectiveChange::ControlRefusalUpdated reports a refusal recorded or lifted.
  • Python package. import fdu gives typed, immutable options, reports, roll-ups, provenance, cache values, and change feeds.
    • fdu.report answers one query while retaining the least state it needs; fdu.open and fdu.scan return an Index with total, rollup, children, provenance, report, refresh, since, and watch.
    • Under the default auto cache policy, Index.refresh() saves the refreshed metadata and content, and iterating Index.watch() saves verified changes. A save that fails during a watch issues a Python warning rather than ending the feed.
    • Report.as_dict() returns the command line’s JSON. A row’s ignored is an IgnoredTally on SummaryRow and TreeNode, an ExtensionTally on ExtensionRow, and a bool on FileRow, each None where no rule was read. Status.ignore_rules reports the limits, what was applied, and what was refused.
    • Query.format chooses Format.TREE, PATHS, or LONG before reading, and Report.render() serializes a report as the command line does. Rendering a folded tree as a flat list, or a flat list as a tree, raises InvalidArgumentError; request another report from the index instead. A default Query keeps the directory tree, so its as_dict() carries a tree; set Query.format to Format.JSON for the flat rows fdu PATH --format json prints.
    • cache_path, cache_status, list_caches, clear_cache, and clear_all_caches manage snapshots. A CacheStatus carries state, with stale_reason, format_version, and leftover_kind where they apply. clear_cache returns whether it removed a snapshot, and clear_all_caches returns a ClearSummary counting the snapshots it removed and the leftovers it reclaimed.
    • Native calls are bulk, and open, scan, and refresh release the GIL. A failure that stops an operation raises an FduError subclass, and one that makes a scan partial is reported on Status.
    • The wheel installs an fdu console script that runs the native command line, --install-skill and --progress included. The script restores SIGINT to the default disposition before entering the native CLI, so Ctrl-C interrupts --watch the way it does a cargo install binary.
  • Opened roots for interactive clients. OpenedIndex::open in Rust, and fdu.opened.OpenedIndex.open in Python, return while discovery continues.
    • read() answers several projections (Lookup, RollUp, Tree, Flat, Aggregate, Report, Continue, Diagnostics) from one engine version, with bounded pages and opaque continuations. One projection can be refused, as ProjectionResult::Refused or Python RefusedResult, while the others answer.
    • changes() resumes an exact change journal from a cursor. journal_capacity_bytes bounds it, 8 MiB by default and at least 64 KiB, and a consumer that falls further behind receives a reset outcome and re-reads.
    • refresh() verifies named paths, prioritize() steers discovery, observation keeps the root live, and close() joins every worker. A worker panic surfaces as Error::OpenedWorkerPanicked in Rust and as OpenedIndexError in Python.
    • Selection inside a read matches the portable path, in which % is written %25 and a byte that is not UTF-8 is percent-escaped, so 100%.txt is matched as 100%25.txt. The portable_path a Lookup, Tree, or Flat row carries passes back as a filter unchanged. A Report projection’s rows carry only the native path, so a name containing % or a byte that is not UTF-8, taken from one, does not.
    • Options cover hidden-name pruning with an allow list, special-file exclusion, a file budget, and a custom file-type registry (File Rollup registry schema 3 or 4).
  • Rust library. fdu-core is the engine, and fdu re-exports it alongside the command line.
    • open takes a Basis, what to scan and read, and a Delivery, how to carry it out: cache policy and location, partial-answer acceptance, workers, and traversal scheduling. plan validates a request and delivery for one route into a Plan, which fixes how much state the run retains, whether it loads a snapshot, and whether it verifies the filesystem.
    • open returns an Index whose per-directory roll-ups come from pre-computed state, and prepare_report answers one report without retaining an index.
    • A Progress handle passed to prepare_report_with_progress or Session::start_with_progress observes the phases and counts the command line’s progress line draws.
    • report_format::render returns Result<String>, refusing a conversion between a folded tree and a flat list that the report cannot answer.
    • Producers submit verified observations, and the index commits exact, clocked change batches that Index::since reads.
    • watch is the only build feature: off by default in fdu-core, on by default in fdu. .gitignore handling is always compiled in.
    • The minimum supported Rust version is 1.85.
    • Published fdu-core requirements are caret ranges of the reviewed minimum. Cargo.lock still pins exact versions for this workspace and for cargo install --locked.
  • Packaging. The fdu-core and fdu crates; the fdu Python source distribution; and one CPython 3.12+ abi3 wheel for each of Linux x86-64 and arm64 (manylinux2014, glibc 2.17), macOS x86-64 and arm64 (macOS 11), and Windows x86-64. Wheels carry type information, license text, and a CycloneDX SBOM.

Upgrading from a pre-release build

This applies only to anyone who ran fdu built from a development checkout.

  • A cached tree can scan cold once. A snapshot serves only the engine and scope that wrote it. The engine fingerprint mixes the crate version, the snapshot format (now version 5), and the versions of the classification rules and of the per-entry facts revalidation checks; the scope adds the type-rule and .gitignore settings. Development builds already carried version 0.1.0, but the per-entry facts version was raised on every platform, not only Windows, when Windows validation gained change time and file identity. A snapshot from any development build without that change (anything built from main before pull request #117 merged on 2026-09-23) therefore does not serve 0.1.0, whatever its format, and --cache-status lists it as stale, with older_format or other_engine as the reason. Nor is a snapshot written under other type rules or .gitignore limits, or one that read no .gitignore, for a run that reads it. That tree’s first run scans cold and replaces the snapshot when the run saves one. The content sidecar, now format 7, carries the same engine fingerprint, so the first analyzed run on such a tree reads every file again. The crate version moves at every release, so each later upgrade costs one cold run per cached tree.
  • fdu --cache-clear=all reclaims the rest. A root that is never scanned again keeps a file this build cannot read, and clearing now takes it: --cache-clear and --cache-clear=all remove stale snapshots as well as current ones. fdu --cache-status=all lists each one as stale with the reason first, so you can see what a clear would take before running it. Files that are not fdu’s are never removed, and are reported as left in place.
  • The gitignore build feature is gone. A dependency declaring features = ["gitignore"] fails to resolve; remove it. fdu’s default build features are ["watch"].
  • Update names a development build used. Flags, report schema versions, and Rust and Python interfaces were renamed before this release, and ### Added gives each under its released name. A consumer pinned to a development build’s fdu.report version moves to fdu.report/7, and a script that read the directory tree from a machine format without --view adds --view tree, because the default there is the list view. Rust callers open with a Basis and a Delivery, and handle the Result that report_format::render returns.

Compatibility

0.1.x may add fields and variants to public Rust types such as ReadProjection, ProjectionResult, ProjectionRefusal, LimitedProjection, IssueKind, ImpactDomain, Error, ReportRequest, TreePage, ReadResponse, RollUp, Provenance, StateTransition, ReportSource, Attrs, Query, Basis, Delivery, WatchDelivery, Route, ProgressPhase, ProgressSnapshot, and FileRow. Those additions are breaking under Cargo’s semver rules for exhaustive types; they land in 0.2 rather than behind #[non_exhaustive] on 0.1.0.

Known limitations

  • Memory. fdu builds an exact index that later questions reuse, and every report but an unfiltered --no-gitignore --view summary retains it, so peak memory grows with the entries retained. The 2026-09-16 tool comparison, run on the release candidate over a generated 1,000,001-entry tree, measured fdu’s depth-one tree report, with its cache off, near 285 MiB of peak RSS, against 29 MiB for dumac, 21 MiB for dua, and 641 MiB for dust; each tool was invoked under a comparison contract that returned only a total. --no-gitignore --view summary retains no index: on that tree it took 4.876 s at 15.0 MiB, against 4.942 s at 285.7 MiB for the default --view summary, with identical totals. The default summary retains the index because classifying entries against .gitignore needs it. On a 328k-file checkout with many .gitignore files, four runs of the same command peaked at 68, 68, 101 and 128 MiB, against 12 to 14 MiB without the index. The cost is the index’s, so it follows the entries retained and how the allocator grew on that run, and is a range rather than a fixed multiple.
  • Links. A symbolic link is listed as an entry but never followed, and adds nothing to a directory’s totals; no command-line option follows links, and a ScanConfig or OpenOptions with follow_symlinks set is refused. A file with several hard links is counted once for each path, where du counts it once.
  • Cache retention. Nothing prunes snapshots of roots that are never scanned again, or bounds the cache directory’s size. --cache-clear takes a root or the whole directory, so there is no way to clear only the stale snapshots an upgrade left; and because a snapshot in a newer format is stale to an older build in exactly the way an older one is, running an older build’s --cache-clear=all removes a newer build’s snapshots. Status names every file and its state before anything is removed.
  • Cache scope. A snapshot that observed .gitignore can serve a request with --no-gitignore by omitting control state. A request that needs rules cannot use a snapshot that never observed them. Projected reads preserve the stronger snapshot. Changing either .gitignore limit invalidates a snapshot that observed controls.
  • Content analysis is one-shot on every surface: the command line refuses --analyze with --watch, a Rust Session and Python Index.watch() refuse an index opened with analysis, and a refresh reanalyzes after reconciling. SLOC covers 15 languages, with no embedded-language or syntax-tree metrics. Sidecars and coverage are scoped to the analyzer set, so a request for analyzers the stored set lacks reads the files again.
  • Analysis memory. Unknown-type detection uses a bounded prefix. Code analysis retains its longest logical line, and Markdown word analysis retains the whole input and parser state. Extremely long lines or large Markdown files can exhaust memory; these analyzers do not truncate input or silently skip it by size.
  • .gitignore fidelity. .git/info/exclude, core.excludesFile, and .gitignore files above the scanned root are not read, and a nested repository is not a boundary.
  • .gitignore budget. A .gitignore inside a directory an ancestor’s rules already ignore is still read and charged against the budget, which git never does. Classification stays right, because an ignored parent settles its descendants, but a tree whose ignored directories hold most of its rule files can reach the 4 MiB default and see refusals for rules that could not have changed a verdict. --gitignore-budget lifts it.
  • Ignored shares appear in summary, tree, extension, and file rows only; types, families, languages, and documents rows do not carry one yet, though --exclude-ignored and --only-ignored do filter those views.
  • Watch backends. A watch uses the platform’s native event backend on every filesystem, with no polling fallback for network filesystems that do not deliver events.
  • A watched row’s ignored bit. Under --exclude-ignored or --only-ignored a stream keeps its entry set exact across rule edits. Under the default selection it keeps membership live but does not restate a row’s ignored bit after a rule edit, so re-read a listing when the bit itself matters.
  • Opened roots. A Tree page can exceed its max_work by the width of one directory level. The opened-root types (OpenedIndex and its ReadProjection projections, TreePage, ReadResponse, and the fdu.opened types that mirror them) are expected to change in 0.2, as the 0.x rule allows.
  • Roll-up metrics are a fixed set; there is no interface for custom per-directory reducers.
  • JSON integers. Fingerprints, option hashes, and nanosecond timestamps are JSON numbers. Values above 2^53 lose precision in JavaScript JSON.parse and any other IEEE 754 binary64 consumer. Read them as strings, or use a parser that preserves integers, if exact identity matters.
  • Performance evidence comes mainly from an M1 Pro MacBook with a local APFS SSD. Linux measurements are from virtualized hosts, Windows has none, and CI checks behavior rather than timing.
  • Platform coverage. Linux arm64 wheels are cross-built and inspected, not executed, before release. There is no wheel for musl Linux or Windows arm64; those systems build from the source distribution with Rust 1.85 or newer.
  • Free-threaded CPython (such as 3.14t) is not supported. It cannot install the abi3 wheels, and an installer there falls back to building the source distribution; if uv selects a free-threaded interpreter, pass --python 3.14 or --python 3.12.