npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@git.zone/tsdisk

v3.1.0

Published

Keeps Linux disk usage in check: measures and explains filesystem use, classifies caches, worktrees and VM images, previews and applies audited owner-tool cleanup, and checks free-space thresholds with trends and alerts.

Downloads

76

Readme

@git.zone/tsdisk

tsdisk keeps disk usage on Linux hosts in check. It measures and explains where the space on each filesystem went, classifies what it finds with a verdict and the evidence behind it, protects what must not be touched, hands cleanup to the tools that own each cache, and watches free-space thresholds on a schedule.

Issue Reporting and Security

For reporting bugs, issues, or security vulnerabilities, please visit community.foss.global/. This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a code.foss.global/ account to submit Pull Requests directly.

Install

pnpm add --global @git.zone/tsdisk

For one-off usage:

pnpm dlx @git.zone/tsdisk scan

tsdisk is a Linux CLI. It uses df, findmnt, GNU timeout and, for alerts, util-linux logger from the host. It bundles @git.zone/tsrust and @git.zone/tsdocker as fallback owner tools and downloads nothing at run time.

Commands

| Command | What it does | --json | | --- | --- | --- | | tsdisk scan [--root PATH]… [--record] | Measure, explain and classify the selected roots; never changes anything on disk (--record appends a trend snapshot) | tsdisk.scan envelope | | tsdisk explain PATH | Measure and classify one path and show the policy rules that decide it | tsdisk.explain envelope | | tsdisk budget [PATH] --target-used SIZE | List, safest class first, which removals bring the filesystem holding PATH down to SIZE used; never changes anything | tsdisk.budget envelope | | tsdisk cleanup [--only …] [--workspace] [--verbose] [--apply --yes] | Preview cleanup through the owning tools; apply owner-tool cleanup and remove stale, unused dependency trees | tsdisk.cleanup envelope | | tsdisk worktrees [--root REPO]… [--workspace] [--verbose] | Inventory linked git worktrees; never changes anything | tsdisk.worktrees envelope | | tsdisk adopt DIR [--apply --yes] | Hand an unmarked Cargo target to tsrust | tsdisk.adopt envelope | | tsdisk policy show\|validate\|explain | Show, validate or explain the layered disk policy | tsdisk.policy envelope | | tsdisk check [--alert] [--record] | Compare free space and growth with the policy thresholds; the exit code is the verdict | tsdisk.check envelope | | tsdisk schedule print\|write DIR | Generate the hourly check and the nightly scan as systemd user units | tsdisk.schedule envelope | | tsdisk audit [--since TIME] | List the actions recorded in the audit log | tsdisk.audit envelope | | tsdisk trend [--fs PATH] [--since TIME] | Show per-filesystem growth and what grew from the trend history | tsdisk.trend envelope | | tsdisk --help, tsdisk --version | Help and version | — |

Bare tsdisk, tsdisk --json and tsdisk --root PATH run scan. A command refuses every flag it does not use: scan --check, or --worktree-idle-hours without the worktree category, is an error instead of being ignored. Invalid arguments print an error on stderr and exit 1; with --json, stdout also carries a failure envelope (see JSON output contract). tsdisk never uses sudo; it runs as its caller. scan and cleanup print live progress every five seconds; with --json the progress goes to stderr, so stdout carries exactly one JSON document.

Scanning

tsdisk scan                                   # the policy's roots, else the current directory
tsdisk scan --root /mnt/data/foss.global --json
tsdisk scan --root /mnt/data/project-a --root /mnt/data/project-b
tsdisk scan --all                             # list findings below the noise floor one by one
tsdisk scan --root /mnt/data --budget 1800   # measure for up to 30 minutes instead of 120 seconds

scan is read-only. It never removes anything, never asks for sudo and never runs project code.

Roots. Repeat --root to scan several trees. Without --root, the scan uses the host policy's roots, else the current directory. Symlink roots and roots with symlink ancestors are rejected.

Measurement. tsdisk measures each root itself, in one pass that yields both figures: allocated bytes from each entry's block count and apparent bytes from its size (directories count with their blocks and an apparent size of 0, as with du --apparent-size). The walk reads directories on up to 12 reader threads (fewer on a host with fewer CPUs), each listing one directory at a time and lstat-ing its entries with blocking calls, and streams each listing in chunks instead of loading it whole; the main thread does the accounting. Each thread has 16 directories in flight and answers after every 8, so requests and answers carry several small directories per message: one message per directory cost the main thread more than accounting the directory. On a warm 48-core host it measures about 335,000 entries per second; a libuv thread-pool round trip per entry measured 42,000 and got slower with every pool thread added. A partly uncached tree on a busy host measures nearer 120,000 to 190,000; a full walk of a 44-million-entry filesystem took 235 seconds, which is why budget measures for 600 seconds by default. It never follows a symbolic link and never enters another filesystem mounted below a root. A file with several hard links counts once in the measured total and once in every directory total that holds one of its links, as du -s of that directory alone would report it, so a node_modules and the store it links into each show their full size on every run. The walk visits first the classifier's candidates that discovery found (node_modules, .git and bare repositories, CACHEDIR.TAG and tool-marked caches), then the top-level directories, each ranked by how many directories discovery counted below its top-level directory, then the rest depth first; a run that is cut off has covered what matters most. When the budget runs out, every root with unread directories gets one budget-exhausted cause naming how many directories were left; the totals above them are lower bounds. A directory the scanning user cannot read is a permission-denied cause, and one that fails otherwise a measure-error cause with its errno code; both name the directory, and the walk continues beside it. Every figure carries its provenance: the source (stat, statfs, qemu-img, docker or owner-tool), the time it was measured, and whether it is cached (cached figures keep their original time). Paths inside a protect root declared with measure: false are never read, provided the scan knows the root: it is declared in the host policy or in a policy above the scanned root, or discovery read the policy that declares it.

Policy discovery. Before measuring, the scan reads the .smartconfig.json policies below each root, so it knows the measure: false roots before measuring. Discovery walks directories only, 32 at a time, and reads no file but policies and cache markers. It does not enter node_modules, .git, bare repositories, CACHEDIR.TAG directories (Cargo targets among them) or tool-marked caches (tsrust targets, Docker registries); policies do not live there, and a measure: false root declared inside one of them is measured like its surroundings. When its budget runs out or a directory cannot be read, the scan still measures and classifies everything: the unread directories become a policy-discovery-incomplete cause with their bytes, and every finding in or above them is protected (rule floor.unverified-policy) until a discovery covers them. A measure: false root that only an unread policy declares is measured there too. Only a root that cannot be read at all is refused. Before every removal, policy.check reads the policies below the target with the same walk (directories only, the same directories skipped, never another filesystem), bounded to 100,000 directories; a search that is cut off or meets an unreadable directory protects the target as unverified.

Filesystem verdicts. For every filesystem the scan touches, the statfs used bytes are reconciled with what was measured, and the difference is explained by causes:

| Cause | Meaning | | --- | --- | | protected-unmeasured | Protect roots declared with measure: false | | permission-denied | Paths the scanning user cannot read | | deleted-open-files | Deleted files that a process still holds open | | other-roots | Data on the filesystem outside the scanned roots | | fs-metadata | Filesystem metadata estimate | | budget-exhausted | A measurement or classification budget ran out | | measure-error | A measurement failed; detail names the error and its errno code | | changed-during-scan | Used bytes changed while the scan ran, or measured paths were deleted or moved before classification reached them | | policy-discovery-incomplete | Directories whose policy was not read; they are measured, and their findings are protected | | shared-extents | Measured bytes above the used bytes on a filesystem that shares extents (btrfs, XFS with reflink) |

Each cause has bytes (or null when only a bound exists), a bound (exact, estimate or remainder), example paths and a count. The filesystem verdict is complete when nothing is unaccounted and no cause was needed, explained when the causes account for the difference and any remainder is under 1 % of the used bytes or under 1 GiB, and incomplete when the remainder exceeds that tolerance or a measurement failed. An incomplete scan never ends in a bare complete: false: it always says which causes are missing how much.

Shared extents. A reflink copy or snapshot shares its data extents with the original, and each file's block count includes the shared extents, so the measured total can exceed the filesystem's used bytes. tsdisk asks once per filesystem whether it shares extents: btrfs always does, XFS does when xfs_info reports reflink=1 (it needs no root), and other filesystem types are not attributed. On a filesystem that shares extents, the bytes measured above the larger of the two statfs used-byte samples become one shared-extents cause with bound estimate. The measurement pass reads no per-file extent maps (FIEMAP takes one ioctl per file, Node has no ioctl interface, and handing tens of millions of files to an external tool such as filefrag would cost more than the measurement itself), so it cannot tell sharing apart from a measurement error there (the exclusive bytes of a counted finding or cleanup candidate do read them, for that subtree only), such as a file replaced while the walk runs and counted twice, and attributes the whole excess to sharing. Elsewhere, or when xfs_info cannot answer, the excess stays a measure-error whose detail says why, and the filesystem verdict is incomplete. Sharing can also hide unmeasured bytes: when shared extents outweigh them, the remainder reads as zero.

Findings. Entries of at least 16 MiB are classified largest first, without a depth limit. Every finding carries:

  • a verdict: protected or in-use (keep), review or report-only (check), or eligible (safe);
  • reasons in plain language, never a command line;
  • its measurement with provenance, and the bytes this path alone would free (exclusiveBytes) for eligible, review and node_modules findings;
  • its attribution: workspace root, project, repository, worktree, AGL lane (unknown when AGL does not answer) and, for archives, the repository and commit it came from;
  • its use: the processes, containers, VMs and agent sessions holding it, hidden processes, the newest change below it, and whether process visibility is host or sandbox;
  • the policy rule that decided the verdict, and the owner tool when one exists.

A Rust build cache inside an evidence archive (.nogit/archive, evidence, debug or worktree-evidence, at any depth) is a cache, never evidence: whatever its name (target, target-lane2, rustdb-test-support-target, a target inside a tsrust-matrix copy), its markers decide, and its classifier proposes eligible.

The scan never marks a finding eligible on its own. A holder makes it in-use; a protect rule makes it protected; anything without a quiescent use verdict, full host visibility, an owner tool and a complete measurement stays at review or below.

| Classifier | Finds | Verdict | | --- | --- | --- | | owner-data | nosqldb, MongoDB, MinIO, smartstorage (.policies/ with .multipart/, or .manifests/ with .buckets/) and SQLite stores, backups, .nogit/local, Git LFS, bare repositories, Ghost content, declared protect roots | protected; duplicate files inside are reported, never offered | | vm-image | qcow2, vmdk, vdi, vhdx and raw VM disk images with their sizes, internal snapshots, backing chains, users and reclaimable estimate | protected, in-use or report-only; tsdisk never changes an image | | standalone-checkout | Full git clones below a .nogit | report-only, with the proof an owner needs before removing one by hand | | trash | ~/.local/share/Trash and .Trash-<uid> | review | | rust-target | tsrust-marked targets | review, eligible inside an evidence archive; protected when the marker forbids pruning | | cargo-target | Any Cargo target, whatever its name: Cargo's CACHEDIR.TAG plus build metadata, or a .rustc_info.json with Cargo's rustc_fingerprint; inside an evidence archive either marker alone | review, eligible inside an evidence archive; adopt it for owner cleanup | | docker-registry | tsdocker registry caches and their small stubs | review (it holds releases, which tsdocker's targeted prune keeps) or report-only; eligible inside an evidence archive, pruned only by tsdocker prune --target DIR --apply; protected when the marker forbids pruning. Budget plans a project's registry through tsdisk cleanup --only docker | | tsdeno-staging | Legacy Deno leftover: tsdeno self-extracting staging beside a binary deno compile built (dist/binaries/.<binary>, marked by tsdeno or found by its generations): the root, each extracted generation and each <hash>.cache code cache; a generation extracted before the binary was built, or a code cache without a current generation, is superseded | report-only; budget lists only superseded generations and code caches, owner-review with rm -rf (tsdeno is deprecated in favour of @git.zone/tssea, so nothing prunes this staging any more); protected when the marker forbids pruning | | smartsea-cache | A smartsea application cache, where the Node.js SEAs @git.zone/tssea builds extract their embedded assets (~/.cache/smartsea/<app> by default; found only by its .smartsea-cache.json marker, wherever the SEA's environment put it): the root, each generation (kept while used within 24 hours, else prunable) and each .tmp-/.trash- directory (scratch within an hour, else stale-scratch), judged by @push.rocks/smartsea/layout without a current generation, which only the SEA knows | report-only; budget lists only prunable generations, owner-review with mv -T -- GEN APP/.trash-tsdisk-GEN then rm -rf (the rename first, as smartsea's own pruning does), and stale-scratch with rm -rf | | tssea-cache | tssea's Node.js download cache (~/.cache/tssea by default; found only by its structure node/v<version>/<target>/entry.json, judged by @git.zone/tssea/cache): the root, each download (entry) and each .tmp-/.trash- directory (scratch while it or a direct child changed within an hour, else stale-scratch). It runs before user-cache | report-only; budget lists each download and each stale-scratch directory, owner-review with rm -rf (tssea downloads a release again, checked against its pinned SHA-256, and never removes what a killed build left) | | build-tree | A build tree: each child of a workspace's .nogit/builds and a smartbrowser Paint build cache (SMARTBROWSER_PAINT_BUILD_CACHE: chromium*/.smartbrowser-paint-fetch, or tools/bazelisk beside a skia-* clone), with details.role root; the .nogit/builds folder itself (folder) and each git clone the build fetched into a tree (checkout, instead of standalone-checkout). Outputs and caches inside, such as Chromium out/ configurations and Bazel caches, are classified on their own | report-only; budget plans the tree as one rebuildable cache, owner-review with rm -rf, the clones it fetched named and going with it: their protection as repository roots alone does not block it; owner data or another protection inside still does. The builds folder is no candidate | | bazel-cache | A Bazel output user root (_bazel_<user> with output bases, the install base or the repository cache cache/repos) and a Bazelisk home (BAZELISK_HOME: downloads/ with bazelbuild/, metadata/ or sha256/), outside ~/.cache | report-only; budget plans it as a rebuildable cache with rm -rf, on the needs-root line when a root container left it | | deno-dir, corepack-home, ci-export | A Deno cache directory (DENO_DIR: Deno's cache databases beside npm/, gen/, deps/, remote/ or dl/), a legacy Deno leftover; a Corepack home (v1/<manager>/<version>/.corepack), and a project exported for a CI run (.nogit/ci-* with a manifest and no .git), outside ~/.cache; each is one cache, nothing inside is listed apart | report-only; budget plans them as rebuildable caches with rm -rf (also a Deno dir: deno clean would need Deno installed), sudo rm -rf on the needs-root line when a root run left entries behind | | tsbundle-temp | tsbundle temp workspaces, by their marker | report-only (tsbundle removes stale ones itself), eligible inside an evidence archive; protected when the marker forbids pruning | | evidence | .nogit/debug, evidence, archive and worktree-evidence folders, with their age and, for the folder directly in .nogit, possible twins among its subtrees (archive and extracted copy, or equal sampled trees) | report-only, or protected by policy | | nogit-scratch, backup | Anything else directly in a .nogit (scratch builds, ci-* and qualification-* runs); copies named as backups (backup, -pre-, mongodump, pgdump, .bak), with their age | report-only | | gitzone-releases | GitZone release records below .git/gitzone/releases, with what gitzone release prune would free | protected as git internals; budget lists the estimate owner-review with cd -- REPO && gitzone release prune --apply | | chromium-build, git-internal | Chromium build trees, git object storage | report-only, or protected by policy | | project-build-output | Generated project output: a folder its scripts declare (tsbuild, tsrust, tsbundle, tswatch, and the legacy deno compile) or reference, the @git.zone/tssea outDir and the outDir of every legacy @git.zone/tsdeno compile target in .smartconfig.json, or a git-ignored, untracked dist or dist_* folder at a project root. A tsbundle sea: true bundle's <to>.smartsea.json lies in the bundle output its to declares | protected when it holds tracked files, else report-only | | node-modules | Dependency trees, with install time and owning worktree | report-only, with exclusive bytes; cleanup --only=node-modules removes a stale, reinstallable and unused one | | cachedir, user-cache | CACHEDIR.TAG directories and known caches in ~/.cache (Puppeteer, Playwright, Deno, a tssea folder that holds no tssea downloads, Visual Studio Code) | report-only |

Policy can make a category stricter, never eligible.

VM images. A file is a VM image when it carries the qcow2 signature, when qemu-img info reads another format than raw (vmdk, vdi, vhdx and the rest; it is asked only when the file's first 512 bytes carry a header qemu probes for, or its name ends in .dmg), or, being raw, when it is named .img or .raw and holds a boot signature, or a qemu process or libvirt domain names it. tsdisk reports its virtual and allocated size, each internal snapshot with its age and saved VM state, its backing chain, and who uses it: qemu processes (by guest name, never by command line), the domains of libvirt's qemu:///system and, for a caller other than root whose session daemon already runs, qemu:///session (a running domain holds the image, a defined one is listed), and every process fuser finds. The reclaimable estimate is the allocated bytes minus qemu-img measure of the current state: what deleting the internal snapshots and compacting with qemu-img convert would free. For an overlay the measure includes its backing image, so the estimate is a floor. Every probe reads with shared locks (qemu-img -U, force-share), so it answers while the VM runs. The text report prints these details and the owner's commands below the finding: virsh snapshot-delete for each internal snapshot a libvirt domain keeps, so its metadata goes too, then qemu-img convert to a .compact copy that carries no internal snapshots, to be moved over the image. tsdisk never runs them: the owner does, with every VM using the image shut down, and a VM or process holding the image blocks it.

Budgets. Usage measurement has 120 seconds, 600 for budget (--budget SECONDS on scan, explain and budget sets another), policy discovery 1,000,000 directories and 45 seconds, classification a quarter of the usage budget and at least 45 seconds, with 50,000 classified entries scaled alike (a --budget 1800 scan classifies for 450 seconds and up to 500,000 entries, a default budget run 150 seconds and 166,667 entries; a directory with more entries than that is left a gap rather than listed), the AGL lane lookup 10 seconds and the deleted-open-file survey 5 seconds. A spent budget becomes a budget-exhausted or policy-discovery-incomplete cause or a classification gap; it never hides bytes.

Output. The text report prints one line per filesystem (verdict, used, measured and unaccounted bytes with provenance), its causes, then every finding with its size, verdict, path and provenance. Docker registry stubs are folded into one line per filesystem. Warnings come last.

| Exit | Meaning | | --- | --- | | 0 | The scan is complete | | 2 | The scan is incomplete; the causes say why | | 1 | Invalid input or a failure |

Old .nogit/worktree-evidence/ folders from tsdisk 1.x are reported as evidence like any other.

Noise floor. The text listing folds findings smaller than the host policy's noiseFloorBytes (default 64 MiB) into one line per project and category, with their count and allocated bytes; a finding inside another member of its group is counted once. --all lists every finding instead. JSON never folds and refuses --all. Paths are classified from 16 MiB up (minimumBytes), so with the default floor findings from 16 to 64 MiB are folded. A folded finding inside a larger listed finding shows up twice, in its group line and within the larger finding's bytes; the lines are not meant to be summed.

Docker engine. After the filesystems, the scan summarizes each Docker context's storage from docker system df (images, containers, volumes and build cache with their reclaimable bytes) and lists restarting and long-exited containers with review-only hints. The summary is report-only: tsdisk never prunes shared engine state. Each Docker call is limited to 15 seconds and the summary to 120 seconds, counted from its first call, so a long measurement never spends the summary's budget; when Docker is missing or does not answer, the scan adds a warning and stays otherwise complete. JSON carries it as dockerEngine.

Explaining a path

tsdisk explain /mnt/data/project/.nogit/tsrust-target
tsdisk explain /mnt/data/project/node_modules --json

tsdisk explain PATH measures PATH freshly, classifies exactly that path whatever its size, and prints its category, verdict, size, reasons, attribution, use and owner tool, followed by the policy layers and the rules that protect it or decide its categories. A path no classifier recognizes is reported as such; tsdisk never offers it for cleanup. It is read-only like scan, skips the Docker engine summary and exits 2 when the measurement is incomplete.

Budget

# Which removals bring /mnt/data down to 550 GiB used?
tsdisk budget /mnt/data --target-used 550G
tsdisk budget /mnt/data --target-used 550G --json

tsdisk budget PATH --target-used SIZE measures and classifies PATH like scan, then plans for the filesystem that holds PATH. SIZE takes B, K, M, G and T (powers of 1024, as df -h prints them; KiB … TiB are the same) and may be fractional (1.5T). The plan lists every removal candidate on that filesystem in safety order:

  1. rebuildable-cache: Rust and Cargo targets, node_modules, tsbundle temp, unused smartsea generations and tssea Node.js downloads, project build output, Chromium build output, tsdocker registry caches, CACHEDIR.TAG caches and user caches.
  2. stale-worktree: merged, clean, inactive linked worktrees, with their ignored content.
  3. evidence: .nogit debug, evidence and archive directories, other .nogit scratch (nogit-scratch: builds, ci-* and qualification-* runs, anything no other classifier claims), and the trash.
  4. release-records: GitZone release assets beyond the newest three releases (an estimate, see below).
  5. backup: backup copies.
  6. vm-image: VM disk images.

Within a class, candidates tsdisk cleanup --apply removes (auto-apply: through their owner tool, or a dependency tree tsdisk removes itself) come first, then the owner's own commands (owner-review), then candidates that need root (needs-root); within each, stale candidates (newest change at least 7 days ago) come before recent ones and those of unknown age, each largest first. Every candidate prints its last change. Ages come from modification times; a modification time in the future (Bazel stamps files with 2036) is replaced by the entry's change time, and a change time in the future makes the age unknown, never recent.

Linked worktrees the scan finds are checked by the worktree inventory, with the same lane, process and quiescence checks as cleanup --only=worktree: a worktree the inventory clears (merged, clean, unlocked, idle past its threshold, no submodules, no process or lane inside it, process state known) is a stale-worktree candidate with its allocated size, the days since its last git activity, and the commands git -C REPO worktree remove PATH and git -C REPO branch -d BRANCH. Its ignored files go with it, so copy evidence worth keeping out first (worktree apply through the library archives it). Git clones nested inside it (such as vendor checkouts under .nogit/debug) are named for a check for unpushed work rather than blocking it: git worktree remove deletes them without --force. Every other worktree is listed as blocked with the inventory's reasons; caches and scratch inside it remain candidates of their own. Every candidate prints the command that removes it, its reclaimable bytes (exclusive bytes where measured, else allocated bytes as an upper bound), a running total, and * while it is needed to reach the target. A candidate inside one already listed is counted once; caches and build output inside a worktree or folder are listed before it, and its last reason names how many and their bytes, which its own reclaim leaves out. budget counts exclusive bytes only for the candidates its plan needs (with the next ones while fewer than two are needed), then plans again, since a candidate that frees less than its allocated bytes makes later ones needed; it stops when the needed candidates are counted or the scan's sizing budget (exclusiveTimeoutMs) runs out, and then warns that the plan may free less than it lists. The other candidates keep their allocated bytes with the reason that budget sizes only what its plan needs. Protected and in-use paths, and every candidate containing one, are listed as blocked with their holders and never planned; so are marked tsrust targets their cleanup dry run keeps. A candidate holding entries the running user cannot delete (an entry in a directory it cannot write or search, an entry owned by another user in a sticky directory it does not own, or a directory it can list but not read into) is needs-root when it is a rebuildable cache, build output or trash: removing it as this user would stop halfway, so its command is sudo rm -rf -- PATH (for a worktree, followed by git -C REPO worktree prune and git -C REPO branch -d BRANCH), its reasons give the count, and the plan ends with one owner line, needs root (N path(s), SIZE allocated): sudo rm -rf -- PATH..., for all of them (JSON: budget.needsRoot with paths, allocatedBytes and command). Evidence, research, archives, scratch, backups and VM images that need root stay owner-review with their size, the command sudo rm -rf -- PATH and a first reason that names the count and says root is needed, and never join that line: keeping them is the owner's decision. When every entry a candidate holds that this user cannot delete lies in candidates inside it that the needs-root line takes (a root-owned Bazel cache or clone in a build tree, a root-owned cache in a scratch folder), only those parts join the line: they are listed before it, and the candidate itself keeps its own command and apply, counted without them, with a first reason that names them and says to run it after that line. A tsdocker registry with retained release journals stays owner-review with tsdocker prune --target PATH --via-container --apply and the same note, since sudo rm -rf would take the journals. The usage walk counts such entries per subtree (undeletableEntries on the walk's entries and the findings' measurements) with access(2), so ACLs count; the size is a lower bound where a directory below could not be read. A directory the scan cannot read at all also leaves its policy unverified, which protects the finding, so it is blocked rather than needs-root; a worktree is needs-root when only its ownershipIssues keep it from being eligible and one of them is an entry this user cannot unlink. Release records never are: gitzone owns their retention. Storage tsdisk sees on another filesystem, such as a Docker engine under /var/lib/docker when the target is /mnt/data, is listed separately and never counted, since removing it frees nothing on the target. budget is read-only, measures for 600 seconds unless --budget sets another (JSON reports it as budget.usageBudgetSeconds), exits 2 when the scan is incomplete, and refuses --root: give the path as the argument. An incomplete plan names each cause with its count and examples (JSON: budget.incompleteCauses, each with cause budget-exhausted, permission-denied or measure-error, stage measure for the walk and classification or deleted-open for the /proc survey, count and examples). Causes the scan explains without a gap, such as a failed statfs read at the end, are taken from filesystems[].explained. It suggests raising --budget only when measuring or classifying ran out of time; for paths or /proc fd tables this user cannot read it says that --budget does not change it, and it points at the needs-root line when there is one. A run as a user other than root on a shared host stays incomplete for that reason alone.

gitzone release keeps one directory per version below <git common dir>/gitzone/releases/v1/: its journal and its payload (package*.tgz, asset.<sha256>.bin). Their retention belongs to gitzone release prune (gitzone 8.11.0 and later), run in the repository. tsdisk reads the records (report only) and estimates what it would free by the same rules: records ordered newest first by journal creation, the payloads of the newest release.recordRetention completed records (from the repository's .smartconfig.json, default 3) kept, incomplete records and those a release intent holds kept as well, every journal kept. The estimate is zero while a journal is missing or invalid, since gitzone then stops --apply before any deletion, or while release.recordRetention is invalid; the reasons name locks or preparation directories of a release at work, beside which gitzone refuses to prune. The budget lists the estimate as (estimate), owner-review, with cd -- REPO && gitzone release prune --apply; gitzone release prune without --apply reports the exact figures. It is not auto-apply, since tsdisk cleanup does not run gitzone. The scan protects release records as git internals, which tsdisk never removes; that protection alone does not keep the estimate out of the budget, but any other protection or a holder blocks it.

Each node_modules candidate on the target filesystem that is neither protected nor in use is checked as cleanup --only=node-modules would check it (four at once, 300 seconds for all; a tree left unchecked says so). An eligible one is auto-apply with the command tsdisk cleanup --root PROJECT --only node-modules --apply --yes and the reasons it qualifies; any other stays owner-review with rm -rf -- PATH and the first condition it failed (Not removed automatically: No pnpm-lock.yaml beside package.json: …). Budget checks with the default 14 days and still changes nothing.

A marked tsrust target cache is auto-apply only when the dry run of its printed command, tsdisk cleanup --root PROJECT --only rust --apply --yes, removes it: budget runs that command's preview (--only rust without --apply, the default 14 days) for each project with such a target, so tsrust's own retention, marker, identity and holder checks and the use check decide (four projects at once, 300 seconds for all). A target that preview keeps, such as one changed within 14 days, is listed as blocked with the reason (Not removed automatically: tsdisk cleanup --only rust (14-day retention) keeps it: …) and never counted, since the printed command would leave it in place; a target the preview accepts but that holds entries this user cannot delete is needs-root; a target left unchecked stays owner-review.

A marked tsdocker registry in a project's .nogit (docker-registry, or a marked session registry in it when that one is unmarked) is checked the same way with tsdisk cleanup --root PROJECT --only docker --apply --yes: tsdocker's marker, identity, mount and container checks and the use check decide. An accepted registry is auto-apply; tsdocker's targeted prune keeps its marker, releases/ with the release candidates' journals and registries, and every marked session registry in it, so budget measures those, names them in the reasons and leaves them out of the reclaim (a failed measurement is named, and the reclaim then counts the whole registry). A registry the preview keeps, or one in an evidence archive, stays owner-review with tsdocker prune --target PATH --apply (--via-container when it holds root-owned files); for one the preview keeps, its reclaim leaves out the same parts, since that prune keeps them too. The owner tools only preview without --apply, so every printed tsrust prune and tsdocker prune carries it.

Cleanup

# Preview Rust, Docker and declared project resources in the current project.
tsdisk cleanup
# Select an individual project's owning tool.
tsdisk cleanup --root /mnt/data/project --only=docker
# pnpm has no prune dry-run: preview identifies its configured store.
tsdisk cleanup --root /mnt/data/project --only=pnpm --json
# Apply owner-tool cleanup after reviewing the preview; each action is recorded in the audit log.
tsdisk cleanup --root /mnt/data/project --only=rust,docker --apply --yes
# Remove stale, reinstallable and unused node_modules below a workspace (opt-in; preview first).
tsdisk cleanup --root /mnt/data --workspace --only=node-modules --dry-run
tsdisk cleanup --root /mnt/data/project --only=node-modules --apply --yes
# Preview linked worktrees below a workspace (opt-in).
tsdisk cleanup --root /mnt/data --workspace --only=worktree
# Review the VM disk images below a workspace, largest reclaimable first.
tsdisk cleanup --root /mnt/data --workspace --only=vm

Cleanup defaults to a preview of rust,docker,project,vm for the current project. --dry-run (-n) explicitly selects preview mode and cannot be combined with --apply. Repeat --root to select multiple projects, and use --only=rust,docker,pnpm,project,worktree,vm,node-modules to choose categories. pnpm is opt-in because the configured store may be shared; tsdisk never deletes a pnpm store directory. worktree and node-modules are opt-in as well; see Git worktrees and Dependency trees. --days N (default 14) sets the Rust and dependency tree retention.

Rust and Docker cleanup act on marked owner caches in the project's .nogit: every directory directly in it that carries tsrust's marker, whatever its name (tsrust's own tsrust-target and tsrust-* caches and adopted Cargo targets such as .nogit/target-lane2), and tsdocker's docker-registry when it carries the marker itself, otherwise its marked session directories (never releases). Each needs a valid owner marker and safeToPrune: true. The owning tool is resolved per project: the project's own @git.zone/tsrust (4.2.1 or later) or @git.zone/tsdocker (3.12.0 or later), otherwise the copy bundled with tsdisk. A package qualifies only when its package.json names it, its version is an exact release at or above the minimum, and its bin is a file inside the package; tsdisk runs that file with its own Node.js, never through a shim or PATH. The plan shows which copy was used and why a project copy was rejected. When neither qualifies, the action is blocked; nothing is downloaded. Rust previews pass --days 14 by default.

Rust cleanup also lists each unmarked Cargo target of a project (rust/target, ts_rust/target or any directory at any depth below its .nogit, such as .nogit/clean-room/builder/cargo-check-target or codex-rs/target in a worktree under .nogit/worktrees, without an owner marker, holding Cargo's CACHEDIR.TAG or a .rustc_info.json with Cargo's rustc_fingerprint), in a linked worktree and inside .nogit/archive as well. Targets are found by content, whatever their name. A workspace run takes them from its scan and gives each to the project holding the outermost .nogit at or below the scanned root; a project's own run walks its .nogit (12 levels, 50,000 entries, 45 seconds), and only a spent walk budget joins discoveryIssues. Each is a candidate with its size, the age of its newest file (newest mtime or ctime) and the exact tsdisk adopt DIR command, printed as unmarked: adopt to clean:; a target anywhere but directly in .nogit adds the tsrust prune --target preview that prunes it once adopted. JSON carries it as the action's unmarkedTarget (path, adoptCommand, pruneCommand?, newestChangeAt?, newestChangeIssue?). An unmarked target has no owner action: neither the preview nor --apply ever runs anything on it until it is adopted.

Owner tools run in targeted mode on exactly one directory: tsrust prune --workspace <project> --target <dir> --days N --json and tsdocker prune --target <dir> --json, each with --apply on execution. Targeted tsdocker never removes containers. A Docker cache is eligible only when tsdocker plans exactly this cache with no root-owned files; root-owned files block with a pointer to tsdocker prune --target <dir> --via-container --apply, because tsdisk never uses sudo or root containers. Bundle, matrix, and legacy registry findings do not become execution targets.

Each owner preview runs through the cleanup executor. It verifies the target's identity and policy, runs the owner's dry run and checks the JSON output against the owner's contract: tsrust's result must name exactly this target as its only candidate, with the planned device and inode, the owner marker recognized, its reason, meetsThresholds and a holder verdict whose check names the failing check; tsdocker's report (schema 1) must name exactly this cache as its only candidate with the planned device and inode and no containers. Anything else blocks the action. A cache tsrust keeps is skipped with tsrust's reason: under its retention rules (for example below prune thresholds with its newest change), or a holder tsrust reports, with the failing check (lock, process, hidden-process, scan or inspection) and tsrust's holder reason, which names the PIDs. An accepted preview is then checked for current use; a held, recently changed or sandbox-visible cache is skipped, and the result names what blocks it: holders (kind, PID and process name), an active AGL lane, the count of processes hiding their open files, a newest change inside the quiescence window, or the sandbox. Owner previews time out after 240 seconds, and the owners' own lock waits (TSRUST_LOCK_WAIT_TIMEOUT, TSDOCKER_HOST_LEASE_WAIT_TIMEOUT) end 60 seconds earlier.

Rust rechecks retention, markers, cache identity, and active use at execution time. A cargo, rustc or rust-analyzer process blocks removal only while its working directory lies inside the project (or cannot be read); a Rust tool working in another project does not. Any process using the cache itself, and inaccessible process state (including protected same-user processes), still block removal. Linux tsrust builds and pruning share a cache guard.

--workspace discovers nested projects and marked Docker/Rust cache owners and reports unsupported owners separately. It never downloads tools. Workspace discovery uses depth 12, 50,000 inspections and 45 seconds per root, and reports incomplete traversal. The text report ends with its scan warnings counted per kind (symlink, read-error, depth-limit, entry-limit and the others) with one example each; --verbose lists every kept warning instead (each scan keeps its first 200 and counts the rest), and JSON carries them as discoveryIssues and discoveryIssueCounts. With --only=pnpm --workspace, each project's store is resolved and each real store is previewed once; older stores are report-only.

vm reviews VM disk images: each file below a root (with --workspace, below the workspace; up to 1,000 per root) named .qcow2, .qcow, .img, .raw, .vmdk, .vdi, .vhd or .vhdx, or under any name (r0926-node.qcow2.old) starting with a qcow, QED, VDI, VHDX, VMDK or dynamic VHD header, that allocates at least 16 MiB and is a VM image becomes an owner-review item: its sizes, internal snapshots with their ages, users, reclaimable estimate and the owner's virsh snapshot-delete and qemu-img convert commands, in JSON as the action's vmImage. tsdisk never applies it; a VM or process holding the image sets vmImage.blockedBy, which the text prints as blocked: without changing the exit code.

Sizes. The preview measures each candidate: the marked owner cache, the unmarked Cargo target (counted in the totals so the payoff of adopting it shows, though cleanup frees it only after adoption), the pnpm store, each eligible dependency tree and each eligible worktree; a VM image's size is its qemu-img estimate. Apply does not measure; on a nearly full disk it goes straight to the owner tools. allocated counts every inode below it once; reclaimable is what removing it alone would free: files hard-linked from outside it are left out, and on XFS and btrfs every counted file's extent map is read (batched filefrag -v, xfs_io where filefrag is missing) and extents shared with any other file (reflinks) are left out as well. Reclaimable is therefore a floor; for a pnpm store it counts the files no project links or clones, which pnpm store prune decides on. Actions and worktrees are listed largest reclaimable first, unmeasured ones last; the first line and the progress line give the totals, counting a candidate inside another one once. Each candidate gets at most 120 seconds and all of them, worktrees included, 300 seconds; a candidate left unmeasured says why.

Apply. --apply requires --yes; there is no interactive confirmation. It runs owner-tool cleanup (--only rust, docker) and the removal of dependency trees (node-modules) only; pnpm, project, worktree and vm apply are refused by the cleanup command. On apply, the executor handles each action on its own: it re-verifies identity and policy, reruns the owner preview, checks use against a fresh holder snapshot, records the intent in the audit log, runs the owner with --apply, verifies containment and records the outcome. The owner's JSON result decides whether the action applied, was skipped, or failed; the exit code alone never counts as success. A failed or blocked action does not stop the others, and actions whose targets overlap within the run are blocked while the rest run; only a safety breach stops the run. Owner commands time out after 240 seconds, with a five-second termination grace period. Completed actions are not rolled back; review owning-tool state before retrying an interrupted operation.

| Exit | Meaning | | --- | --- | | 0 | The preview or apply succeeded | | 2 | Discovery, the pnpm store resolution, project resources or the worktree inventory is incomplete, or a project resource candidate is blocked. Every scan warning counts, even one skipped symbolic link; an action that the owner tool or the use check skips does not | | 3 | Some actions failed or were blocked; the others ran | | 1 | Invalid input or a safety breach |

Every cleanup result includes blockedSummary: actionCount, blockedCount and groups of blocked actions by category and reason. Each group lists every project path. When no qualifying owner tool resolved, the group also names the owner package and the minimum version the prune contract requires, and each project carries its installed version (installedVersion: null when the owner package is missing); groups of refused owner previews carry neither. Human output prints the same summary after the plan, so the projects to upgrade are visible at a glance. Tools are never downloaded.

Cleanup runs as the project owner and refuses filesystem roots and symlink project paths. It never uses sudo and has no --sudo or --no-sudo option. Before the owner preview, the executor counts the target's entries this user cannot delete (an entry in a directory it cannot write or search, or another user's entry in a sticky directory it does not own); any such entry blocks the action before an owner command runs, so cleanup never deletes part of a target. The action is reported as needing root only after the owner preview and the use check accept the target, because the owner runs the printed root removal without them. The result carries needsRoot (undeletableEntries, allocatedBytes), and the run ends with one owner line, needs root (N path(s), SIZE allocated): sudo rm -rf -- PATH..., covering these targets and every worktree the inventory marks needsRoot (JSON: cleanup.needsRoot). A target with a directory it cannot read or search is blocked by its unverified policy instead (the executor's own walk blocks it as well). A Docker cache with root-owned files is blocked; its owner removes it with tsdocker's --via-container. No plans or application state are persisted.

Dependency trees

No package manager has a command that removes a project's installed tree, so --only=node-modules removes node_modules itself, and only when it is provably stale, reinstallable and unused. It checks PROJECT/node_modules of each --root, and with --workspace every node_modules the workspace scan finds below it. Every condition must hold; otherwise the tree is retained and the preview names the first condition that failed:

  1. It is a pnpm (.modules.yaml), npm (.package-lock.json) or Bun (.bun/) tree beside a regular package.json, a real directory on the root's filesystem, reached through no symbolic link (neither node_modules nor a directory above it).
  2. Its layout's lockfile sits beside the manifest: pnpm-lock.yaml; package-lock.json or npm-shrinkwrap.json; bun.lock or bun.lockb. Any git state counts (tracked, modified, untracked, ignored, no repository): the reinstall (pnpm install --frozen-lockfile, npm ci, bun install --frozen-lockfile) reads the working-tree lockfile, which cleanup never touches. The reason states the state, for example pnpm-lock.yaml is not tracked by git; the reinstall uses the working-tree lockfile, which cleanup keeps.
  3. No policy protects it, no node-modules category policy sets a verdict, and the project lies in no evidence directory, .nogit backup copy or owner data. A category minAgeDays above --days raises the retention.
  4. It is stale: the install metadata changed more than --days N (default 14) days ago, and so did every project entry the quiescence walk covers (everything outside node_modules and regenerable caches, bounded like the worktree walk; an incomplete walk retains it). A metadata time in the future leaves the age unknown and retains it.
  5. It is unused: no live AGL lane owns the project (an unknown lane state retains it); no process works in the project's checkout (its linked worktree or repository, when it lies in one) or holds a file under the tree open or mapped, deleted files included; no session holds it; no same-user process with an unreadable working directory names the project in its command line or has an unreadable command line; no other process names the project in its command line (a service started elsewhere may have loaded the tree and closed its files); and tsdisk itself does not run from the tree. Run cleanup from outside the project: its own shell's working directory counts.

A dry run stops there and measures the eligible trees. --apply --yes then walks each eligible tree once with the scan's threaded usage walk: a mount at or below it (read from the mount table, so bind mounts count), a directory it cannot read or entries this user cannot unlink block it (the last as needs-root, reported only after the use checks passed, since sudo rm -rf bypasses them). It records the intent in the audit log, checks every condition again with a fresh holder snapshot (a tree that became used, changed or was replaced is skipped, and the outcome says why), renames node_modules into a new private directory beside it (.tsdisk-trash-node_modules-*), verifies that the moved directory is the checked one by device and inode and that the mount table shows no filesystem mounted below it since the walk (a recursive delete would cross into one; either failure moves it back and stops the run), deletes that directory without following links and records the outcome. The rename is atomic and stays on the filesystem, so no reader or installer ever meets a half-deleted node_modules, and a new install cannot mix with the deletion. A deletion that fails midway fails the action and names the trash directory and how many entries remain in it; the manifest and lockfile always stay. Apply refuses to run as root. The text preview prints each eligible tree's reasons and its reinstall command (reinstall: cd PROJECT && pnpm install --frozen-lockfile).

Adopting Cargo targets

tsdisk adopt /mnt/data/project/.nogit/target-lane2              # preview
tsdisk adopt /mnt/data/project/.nogit/target-lane2 --apply --yes

tsdisk adopt DIR hands an unmarked Cargo target (a cargo-target finding) to tsrust (adopt capability, 4.2.1 or later, resolved from the nearest project with a package.json): tsrust verifies that the directory holds only Cargo build profiles and writes its owner marker. cleanup --only rust then plans a target that sits directly in a project's .nogit, whatever its name; a target elsewhere, such as rust/target, is pruned with tsrust prune --target DIR. DIR must be a real directory, not a symlink or a filesystem root, and adopt refuses to run as root. Adoption runs through the same executor and policy checks; it removes nothing, so tsrust's 24-hour quiescence window does not apply (a cargo-target policy minimum age still does), and a held target is skipped. Without --apply it previews; --apply --yes records the adoption in the audit log like cleanup. When no qualifying tsrust resolves, the report is blocked and exits 3.

Git worktrees

tsdisk worktrees --root /mnt/data/project            # one repository
tsdisk worktrees --root /mnt/data --workspace --json # every repository below /mnt/data

tsdisk worktrees and cleanup --only=worktree work with explicit repository roots (each --root must contain a .git directory; without --root, the current directory) and with --workspace, which uses the discovered repositories. Both list every linked worktree with its status, reasons, the exact git commands and the evidence destination, and the allocated size of a worktree that passed the checks (cleanup adds its reclaimable bytes and lists the largest first); --worktree-idle-hours N (default 24) changes the idle threshold. worktrees exits 2 when the inventory or repository discovery is incomplete. Its text ends with the discovery warnings counted per kind with one example each, or every warning with --verbose. The CLI previews only; worktree apply is available through the library, with an IAuditSink such as AuditLog.

Each entry records repo, path, branch (null when detached), head, locked, missing (directory gone), dirty (git status --porcelain, including untracked files), merged (HEAD is an ancestor of the local main, else master; null when neither exists), activeProcess, activityCheck, lastActivityAt and idleHours (newest mtime of the worktree's gitdir index, HEAD and logs/HEAD), ignoredEvidence, ignoredInventory, measurement, identity, protected, status, reasons, commands and evidenceDestination. Root-owned files are listed in ownershipIssues, with an ownershipRepair hint that points to tsdocker's --via-container. Each issue's undeletable says whether this user cannot unlink it (a parent it cannot write or search, or another user's entry in a sticky parent); a file owned by another user in a writable directory is listed but deletable. A worktree retained only for its ownership issues, at least one of them undeletable, is marked needsRoot: git worktree remove would fail halfway, and budget prints its sudo rm -rf removal.

A worktree is eligible only when it exists, is clean, merged, unlocked, not protected by policy, has no active process, and has been idle for at least the idle threshold; a missing, unlocked registration is prunable-registration; everything else is retained with reasons. activeProcess is true when any readable /proc/*/cwd lies at or under the worktree. Non-dumpable same-user processes such as sshd sessions, gpg-agent or sandboxed agents have unreadable working directories; the report lists them in unreadableProcesses (pid, command from /proc/<pid>/comm, and a boolean cmdlineReadable). For those processes tsdisk reads /proc/<pid>/cmdline only to match worktree paths; command lines can carry credentials and never appear in JSON, human output or reasons: a command line naming the worktree or a path below it retains the worktree, and a process whose command line is unreadable too keeps every worktree retained as unknown. Otherwise the worktree must pass a filesystem quiescence check (activityCheck: "filesystem-quiescence"): nothing below it may have been modified within the idle window. The walk skips node_modules and regenerable caches, never follows symlinks, and stops at a filesystem boundary, depth 32, 100,000 entries or 15 seconds; any of these, or a read error, retains the worktree. Symlinked worktree paths, worktrees whose .git link does not match the repository registration, worktrees with submodules, and repositories not owned by the current user are retained. Git runs with GIT_OPTIONAL_LOCKS=0, so the inventory never rewrites an index; each git command runs in its own process group with a 15-second limit.

ignoredInventory lists every ignored path from git status --ignored, inside and outside .nogit, as evidence or regenerable. Regenerable paths are node_modules, top-level dist_* folders, .nogit/tstest_cache, .nogit/testlogs, .nogit/worktrees, tsrust and tsbundle caches whose marker allows pruning, Cargo targets (Cargo's CACHEDIR.TAG, or a .rustc_info.json with Cargo's rustc_fingerprint), and tsdocker stubs of 64 KiB or less. Everything else is evidence and is listed in ignoredEvidence. An ignored directory that holds a tsrust or Cargo target below it, such as an archive inside .nogit/archive that contains a .nogit/target-lane2, is split along that target: the target is its own regenerable entry, left for git (or pruned by tsrust first), and only its siblings move into the archive. The search walks directories only, never follows a symbolic link or enters another filesystem, and is bounded like the inventory itself.

Applying an eligible worktree (applyWorktreeCleanup or removeWorktree with apply and yes, never as root) re-inspects it against every eligibility condition with a fresh process snapshot, checks that branch, HEAD, device and inode are unchanged, and records each step in the audit sink it is given (without one it reports a safety breach and changes nothing). Then:

  1. Evidence moves into <repo>/.nogit/archive/<worktree>-<YYYY-MM-DD>-<commit12>/ (the next free name is claimed; nothing is merged or overwritten), by rename on the same filesystem or by a verified copy across filesystems, with a manifest.json that records the repository, worktree, commit, time and every entry.
  2. When the caller supplies owner tools, they prune the regenerable caches they own; a payload change outside the declared target fails the step.
  3. git -C <repo> worktree remove <path> runs, never with --force.
  4. git -C <repo> branch -d <branch> runs unless the worktree is detached or on main or master. A refused -d is reported, never retried with -D.
  5. git -C <repo> worktree prune runs for repositories whose planned stale registrations are still missing.

A failed step leaves a repair hint; completed work is not rolled back. Worktree cleanup runs as the repository owner and never uses sudo.

Disk policy

Policy decides what no cleanup may touch and how strict each category is. It has four layers: built-in defaults, the host file $XDG_CONFIG_HOME/tsdisk/policy.json (default ~/.config/tsdisk/policy.json), @git.zone/tsdisk.workspace sections in .smartconfig.json files above a project, and the project's own @git.zone/tsdisk section. The most specific layer wins. The host file and the built-in floor are minimums that no project can relax.

tsdisk policy show                      # layers, roots, thresholds, alerts and protected roots
tsdisk policy validate --policy FILE    # exit 1 when FILE or any layer is invalid
tsdisk policy explain PATH --category rust-target   # deciding rule and layer for PATH
{
  "roots": ["/mnt/data/foss.global"],
  "filesystems": { "/": { "warnFreeBytes": "150GiB", "criticalFreeBytes": "50GiB" } },
  "protect": [{ "path": "/mnt/data/books", "reason": "accounting data", "measure": false }],
  "categories": { "rust-target": { "minAgeDays": 14 } },
  "noiseFloorBytes": "64MiB"
}

protect entries refuse the path, everything below it and every directory that contains it. They match by canonical path and by device and inode, so neither a symlink nor a rename evades them. Workspace and project entries are relative to their .smartconfig.json. Mount points, git internals and every git repository root (a directory that contains .git, or a bare repository) are always protected, as are the directories above them; only git-aware removal of a linked worktree passes the repository rule. A directory is checked against the policy of every project below it; if that policy cannot be read completely, the directory is refused. Category verdicts are review, report-only or protected; policy never makes a category eligible. noiseFloorBytes sets the noise floor of the scan's text listing.

The host file must be owned by you or root, and neither the file nor its directory may be writable by group or others. A host file that configures a webhook must not be readable by group or others either (chmod 600). Project .smartconfig.json files must not be symlinks. A policy file that is not valid JSON is reported with its line and column only, never with the surrounding text, which may hold a secret. Any invalid or unreadable layer makes the command exit 1. --policy FILE replaces the default host file for policy, check, schedule and scan; the file must then exist.

Threshold checks and alerts

tsdisk check compares every filesystem in the host policy's filesystems with its thresholds and returns the verdict as its exit code, for systemd timers and CI. It reads statfs once per path and never scans or removes anything. It takes no --root; filesystems come from the policy.

tsdisk check                            # report per filesystem; exit code is the verdict
tsdisk check --json                     # tsdisk.check envelope on stdout, warnings on stderr
tsdisk check --alert                    # also send journald entries and the webhook
tsdisk check --record                   # also append a trend snapshot for growth and `tsdisk trend`
tsdisk check --policy FILE              # check against FILE instead of the default host file
{
  "filesystems": {
    "/": { "warnFreeBytes": "150GiB", "criticalFreeBytes": "50GiB" },
    "/mnt/data": { "warnFreePercent": 15, "criticalFreePercent": 5, "warnGrowthPerDay": "200GiB" }
  },
  "alerts": { "webhook": { "url": "https://hooks.example.com/tsdisk/TOKEN", "minSeverity": "warn", "timeoutSeconds": 10, "repeatHours": 24 } }
}

Free space is what an unprivileged user can still write (statfs available blocks, the space df shows as Avail); free percent is available ÷ (used + available), so it complements df's Use%. A threshold is breached when free space is below it; exactly at the limit is fine. Percent values lie between 0 and 100 exclusive, and each critical limit must be at or below its warn limit. Every filesystems entry needs at least one threshold. A path is checked on the filesystem that contains it, after resolving symlinks.

warnGrowthPerDay is breached when used space grew faster than the limit per day. The growth compares the current used bytes with the trend sample of the same filesystem (same mount and device) taken closest to 24 hours earlier, within 12 to 48 hours, and scales the difference to one day; the result shows the rate and the time of that sample. Without such a sample, or when the trend history is unreadable, the threshold is not evaluated and says why; a filesystem with only growth thresholds is then incomplete. A warn or critical filesystem also lists what grew most between the last two recorded complete scans of the same roots (contributors: up to five categories or projects); a scan its budget cut short covers less and is not compared.

| Exit | Meaning | | --- | --- | | 0 | every filesystem is above its thresholds | | 10 | at least one warn threshold is breached | | 11 | at least one critical threshold is breached | | 2 | at least one filesystem could not be measured or evaluated (and none is critical or warn) | | 1 | the policy is invalid or configures no filesystem thresholds |

The worst filesystem decides: critical beats warn, warn beats incomplete. Failed alert delivery, and a trend history that cannot be read or a snapshot that cannot be written, are warnings and do not change the exit code.

Alerts are sent only with --alert. journald always gets one entry per checked filesystem through logger --journald (util-linux), with SYSLOG_IDENTIFIER=tsdisk, PRIORITY 2 for critical, 4 for warn, 3 for incomplete and 6 for ok, MESSAGE_ID ac35ca219c4044be8c7625ea254a99c2 for threshold results and fb44b873de364ad69ee0ad0090849a0a for incomplete ones, and TSDISK_STATUS, TSDISK_PATH, TSDISK_MOUNT, TSDISK_FSTYPE, TSDISK_AVAILABLE_BYTES, TSDISK_TOTAL_BYTES, TSDISK_AVAILABLE_PERCENT and TSDISK_BREACHES fields:

journalctl --user -t tsdisk -p warning                        # warn and critical results
journalctl --user MESSAGE_ID=ac35ca219c4044be8c7625ea254a99c2 -o json

The webhook