@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, explain, budget, cleanup and worktrees print live progress every five seconds (worktrees always to stderr); with --json the progress goes to stderr, so stdout carries exactly one JSON document. Only cleanup counts candidates while it runs, so only its progress names their count and potential cleanup; budget gives its figures in the plan.

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. A project makes a cache of its own plannable by writing a CACHEDIR.TAG into it; the copy a landing archives under .nogit/archive/<worktree>/ keeps the tag, while the rest of the archive stays evidence. Without a tag a project cache stays evidence, since nothing proves it rebuildable.
  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 gets one JSON POST (kind: "tsdisk.alert", tool version, host name, check time, overall status, and each filesystem at or above minSeverity with its breaches and contributors); when no filesystem reaches minSeverity (default warn; incomplete, warn or critical) nothing is sent. Only https URLs are accepted, or http to localhost, 127.0.0.1 or [::1]. Redirects are refused and the request ends after timeoutSeconds (1 to 60, default 10). Alerts carry no secrets: the URL never appears in output, logs, errors, JSON or the alert itself (policy show prints it as redacted).

The webhook is not repeated while nothing changes. Each filesystem in the body carries change against the last alert sent: new, changed (another status or other breached thresholds), unchanged, or resolved when it was in the last alert and is now below minSeverity. A run sends when any entry is not unchanged, so a recovery is reported once; an alert in which everything is unchanged is skipped (the delivery says when the last one was sent) until repeatHours (1 to 720, default 24) have passed since it. A failed request is retried on the next run. The alert state is alerts.jsonl in the state directory. When it cannot be read the alert is sent anyway, and the delivery reports it (stateError), also when there is nothing to send; when it cannot be written the delivery reports that too, so the next run may repeat the alert. journald gets every run regardless: it is the run log.

Scheduling the check and the nightly scan

tsdisk schedule generates two systemd user services with their timers: tsdisk-check runs tsdisk check --alert --record hourly (OnCalendar=hourly, up to 5 minutes randomized delay), and tsdisk-scan runs tsdisk scan --record --budget 1800 nightly (OnCalendar=*-*-* 03:00:00, up to 30 minutes randomized delay); both timers are Persistent=true. Together they keep the trend history that growth thresholds, repeat suppression and tsdisk trend read. tsdisk never installs, enables or reloads units: it prints them or writes them into an existing directory you name.

tsdisk schedule print                           # the four units on stdout (--json for the envelope)
tsdisk schedule write DIR                       # write tsdisk-check.{service,timer} and tsdisk-scan.{service,timer} into DIR
tsdisk schedule write DIR --policy FILE         # the units pass --policy FILE to the check and the scan
tsdisk schedule write DIR --exec /usr/local/bin/tsdisk   # run this launcher instead of Node + cli.js

Without --exec (an absolute path), the service runs the current Node binary with this package's resolved cli.js, and its PATH starts with Node's directory; after moving or upgrading either, generate the units again. Both services run at Nice=19 with idle I/O priority, CPUQuota=50%, MemoryMax=1G and NoNewPrivileges=yes; the check has a 15-minute limit, the scan two hours. Exit codes 10 and 11 fail the check unit on purpose, so systemctl --user --failed and OnFailure= see breaches. The scan measures the policy's roots, or else its working directory, the home directory (WorkingDirectory=~; schedule warns when the policy has no roots); it discards its report (StandardOutput=null) and keeps only the snapshot, and it fails the unit when the snapshot cannot be recorded. write refuses a directory that does not exist, a symlink, and any directory systemd loads units from; it never replaces a file: an identical file is left as it is, and a different file or non-file stops the write before anything is created. Install the written units yourself:

cp DIR/tsdisk-check.service DIR/tsdisk-check.timer DIR/tsdisk-scan.service DIR/tsdisk-scan.timer ~/.config/systemd/user/
systemctl --user daemon-reload && systemctl --user enable --now tsdisk-check.timer tsdisk-scan.timer

Audit log

Every action that changes the disk is recorded twice: an intent record before it runs and an outcome record after it finishes. cleanup --apply and adopt --apply write both through the audit log; the library's worktree and adopt apply functions take an IAuditSink, which AuditLog implements. applyProjectResources does not write audit records yet, which is why the CLI does not apply project resources.

tsdisk audit                          # every recorded action, oldest first
tsdisk audit --since 7d --json        # the last week as a tsdisk.audit envelope
tsdisk audit --since 2026-10-01T00:00:00Z

Storage. The log is audit.jsonl in the state directory, flushed with fsync before the action proceeds. At 16 MiB it is rotated, and the four newest rotated files are kept, so the log stays below about 80 MiB.

Fail closed. When the intent cannot be written, the action does not run, and the run stops as a safety breach (exit 1) that names the cause, such as Audit intent write failed (EACCES: …). When the outcome cannot be written after an action ran, the remaining actions are stopped the same way.

Records. Every record has id (a UUID), type, at, host, pid and uid. An intent carries the action (IAction: id, category, via, target path, device and inode, owner tool, reasons); an outcome carries the intentId and the result (IActionResult). The ids are the auditRecordIds of each result in the cleanup and adopt envelopes.

Listing. tsdisk audit reads the rotated files and the live file without writing and pairs each intent with its outcome. Each entry has intentId, at, host, pid, action, outcome and status: the outcome's status, or no-outcome when the run stopped after the intent or the action is still running. An outcome whose intent was rotated away is listed without action. --since takes an ISO 8601 time or a duration (24h, 7d) and filters by intent time. Lines that are not records, such as a torn line, are listed in issues and make the command exit 2; a missing log is not an error.

The trend history is a series of snapshots in trend.jsonl in the state directory. check --record appends one per run with the used, available and total bytes of every policy filesystem; scan --record appends one with the same figures for the scanned filesystems plus the allocated bytes per filesystem, category and project, where a finding inside another finding of the same category and project counts once. Each figure keeps the time it was measured, so a long scan is dated by its statfs reading, not by its end. The scheduled units record both, hourly and nightly.

tsdisk trend                          # growth per filesystem over the last 7 days
tsdisk trend --since 30d --json       # a tsdisk.trend envelope for the last 30 days
tsdisk trend --fs /mnt/data           # one filesystem: a mount point or a checked policy path

For each filesystem (mount and device) trend shows the first and last sample in the window, the change in used bytes, the rate per day (when the samples span at least an hour), the space available now, and what grew most between the earliest and the latest complete scan of the same roots in the window. --fs takes an absolute path that is the filesystem's mount point or a policy path the check recorded for it; --since takes an ISO 8601 time or a duration (24h, 7d). trend only reads. A missing history is a warning, not an error; lines that are not snapshots, such as a torn line, are listed in issues and make it exit 2. The same history feeds warnGrowthPerDay.

scan --record writes the snapshot before printing the report; when it cannot be written the scan warns and exits 1. check --record treats a failed write as a warning, because the verdict is the check's result. tsdisk explain still measures freshly; it does not read stored figures.

State files

tsdisk keeps three append-only JSON Lines files in $XDG_STATE_HOME/tsdisk/, or ~/.local/state/tsdisk/ when XDG_STATE_HOME is unset or not absolute:

File Written by Rotated at Rotated files kept
audit.jsonl cleanup --apply, adopt --apply (audit log) 16 MiB 4
trend.jsonl check --record, scan --record (trends) 8 MiB 8
alerts.jsonl check --alert with a webhook (repeat suppression) 1 MiB 2

They are local files on purpose: tsdisk runs when the disk is nearly full and services fail, and a database dependency would stop cleanup, checks and alerts exactly then. The directory is created with mode 0700 and the files with 0600; a directory that belongs to another user, or a file that is a symlink, is refused. Every record is one JSON line with id, at, host, pid and uid, appended with a single write and flushed with fsync, together with the directory when the file is new. A line left torn by a crash is skipped by the next record, which starts on a line of its own, and readers list it as an issue. A full file is renamed to <name>.<UTC time>.<pid>.<id>.jsonl and the oldest rotated files beyond the limit are removed; a reader with --since skips rotated files renamed before that time.

Project cleanup resources

Declare generated artifacts under @git.zone/tsdisk in .smartconfig.json. The published JSON Schema validates this namespace while allowing other tools' configuration. With a project-local installation, an editor can use "$schema": "./node_modules/@git.zone/tsdisk/schema/smartconfig.schema.json".

{
  "@git.zone/tsdisk": {
    "schemaVersion": 1,
    "resources": [{
      "id": "guest-test-runs",
      "paths": [".nogit/debug/pallet-cni-*/run.*"],
      "preserve": ["**/result.json", "**/*.log"],
      "retention": { "minAgeDays": 7, "keepNewest": 2 },
      "provider": {
        "module": "./scripts/cleanup.mjs",
        "export": "guestRuns",
        "timeoutSeconds": 30
      }
    }]
  }
}

Resource IDs are unique within a project. paths are project-relative literals or * / ** patterns with a literal directory prefix. * matches within a component; ** matches zero or more complete components. Each match is a retention unit. preserve patterns are relative to each match; preserved directories retain their descendants. retention.keepFilesUnderBytes also preserves every regular file whose apparent size is below that many bytes, including sparse files. minAgeDays uses the newest entry modification time, including preserved evidence. keepNewest retains the newest matched candidates, breaking ties by path. protected: true prevents checks and deletion for that resource. Rules run before provider code and cannot be overridden by it.

A provider is optional for reporting. Without one, candidates remain unchecked once their retention period expires and cannot be deleted. This package's test-log declaration demonstrates that behavior. Age alone is not proof of inactivity.

# Discover and measure declared resources without importing project code.
tsdisk cleanup --root /mnt/data/foss.global --workspace --only=project --json
# Explicitly authorize provider checks in discovered projects.
tsdisk cleanup --root /mnt/data/foss.global --workspace --only=project --check

Only cleanup --check imports providers; scan never does and refuses --check. The cleanup command previews project resources only; applyProjectResources in the library applies them, but does not write audit records yet. Providers are trusted project code, running with the current user's permissions and environment. Their subprocess is not a sandbox. They never run as root. Docker and Rust use their owning tools as described above; --only=project selects just declared resources.

The module's named export implements the published ICleanupProvider interface:

interface ICleanupProvider {
  inspect(context: ICleanupProviderContext): Promise<ICleanupDecision[]>;
  withLock<T>(context: ICleanupProviderContext, operation: () => Promise<T>): Promise<T>;
  apply(context: ICleanupProviderContext): Promise<void>;
}

Context contains the project root, declaration, and candidates with project-relative file paths, identities, allocated bytes and preservation flags. inspect returns exactly one decision per candidate, with a reason:

[{ "path": ".nogit/debug/pallet-cni-demo/run.123", "status": "eligible", "reason": "Guest exited; no producer owns this run." }]

Other decisions are retained, protected and blocked. A provider cannot expand the supplied paths. withLock must await the operation exactly once and release the lock in finally; artifact producers must use the same lock. Inspection and apply-time re-inspection run while holding it. Return blocked when inactivity cannot be established. apply receives only eligible candidates with preserved files removed from the file list. Delete only those exact regular files after validating producer state. Recursive candidate deletion is forbidden: candidates can contain retained evidence or new files. Empty directories may remain.

Before apply, tsdisk re-plans, checks configuration/provider-entry digests and candidate/file identities, then verifies them again inside the locked operation. Provider dependencies remain trusted project code; entry hashing does not freeze the dependency graph. Symlinks, mounts, Git metadata and nested project configs block candidates. Hardlinked files, ownership markers and the provider entry module are retained. Overlapping declarations block both candidates.

Resource traversal is bounded to 100,000 entries and 45 seconds per report, 1,024 matches per expansion, and depth 32 for patterns / 64 for contents. Provider checks have a 120-second report budget. Each provider process has a configurable 1–120-second timeout (default 30), an 8 MiB request limit and a 1 MiB output limit. Its private response pipe keeps logging out of JSON stdout. Process groups are terminated on timeout; providers must not leave background work behind.

projectResources JSON includes per-resource candidates, decisions, issues and statistics: discoveredBytes, eligibleBytes, retainedBytes, protectedBytes, blockedBytes, uncheckedBytes, and measured removedBytes. Five-second progress uses these totals. Discovered bytes describe the payload measured before apply; removed bytes are subtracted from remaining status totals. Inodes count once; directory metadata is omitted. Incomplete traversal is explicit in complete, issues and incompleteCount. These totals are separate from heuristic cache estimates, which can cover the same paths; do not add them. Shared extents and open files can make actual filesystem free-space changes differ from payload reduction.

Blocked project resources do not stop unrelated eligible resources, and a blocked candidate makes cleanup exit 2. Already applied work is never rolled back.

JSON output contract

Every command prints exactly one envelope on stdout with --json, on success and on failure; progress and warnings that are not part of the result go to stderr. A command that fails (invalid arguments, a policy that cannot be loaded, check without thresholds, a refused apply) prints a failure envelope of its own kind with error set and empty lists, writes the same message to stderr, and exits 1; arguments without a command count as scan. policy validate reports an invalid policy in its envelope instead. The contract is identified by kind plus the package version; there is no separate schema version field. Additive fields can appear in minor releases; removing or changing a field is a major release.

{
  "kind": "tsdisk.scan",
  "tool": "@git.zone/tsdisk",
  "version": "2.0.0",
  "host": "build-01",
  "startedAt": "2026-10-11T08:00:00.000Z",
  "finishedAt": "2026-10-11T08:01:12.000Z",
  "filesystems": [],
  "findings": [],
  "actions": [],
  "warnings": []
}
Field Type Meaning
kind string The envelope kind, see below
tool, version, host string @git.zone/tsdisk, the package version and the host name
startedAt, finishedAt ISO time When the command started and finished
filesystems IScanVerdict[] Per-filesystem accounting (scan)
findings IFinding[] Classified paths (scan)
actions IActionResult[] Action results (cleanup, adopt)
warnings string[] Non-fatal problems
error string Only in a failure envelope: why the command failed
explain IExplainReport Only in tsdisk.explain
cleanup ICleanupReport Only in tsdisk.cleanup
worktrees IWorktreeReport Only in tsdisk.worktrees
adopt IAdoptReport without results Only in tsdisk.adopt
policy IPolicyReport Only in tsdisk.policy
check ICheckReport Only in tsdisk.check
schedule IScheduleReport Only in tsdisk.schedule
audit IAuditReport Only in tsdisk.audit
trend ITrendReport Only in tsdisk.trend
Kind Emitted by Extra fields
tsdisk.scan scan, tsdisk --json complete, usage, apparentUsage, filesystemMeasurements, deletedOpen, classification, dockerEngine
tsdisk.explain explain explain; findings holds the path's finding, if any
tsdisk.cleanup cleanup cleanup; actions holds the executed actions' results
tsdisk.worktrees worktrees worktrees
tsdisk.adopt adopt adopt; actions holds the adoption result
tsdisk.policy policy show, validate, explain policy
tsdisk.check check check
tsdisk.schedule schedule print, write schedule
tsdisk.audit audit audit
tsdisk.trend trend trend

All types below are exported from the package root.

IScanVerdict: filesystem (IFilesystemRef: mount, device, fsType), usedBytes, measuredBytes, explained (IScanExplanation[]: cause, bytes or null, bound exact | estimate | remainder, paths, count, detail?), unaccountedBytes, verdict (complete | explained | incomplete), measuredAt, cached.

IFinding: path, filesystem, category (the classifier id), verdict (protected | in-use | review | eligible | report-only), reasons, measurement, attribution, use, owner?, policy, details? (classifier-specific, for example a qcow2 snapshot list).

  • IMeasurement: apparentBytes, allocatedBytes, exclusiveBytes? (absent when the probe did not finish), sharedHint? (hardlinks | reflinks | none), source (stat | statfs | qemu-img | docker | owner-tool), measuredAt, cached.
  • IAttribution: workspaceRoot?, project?, repository?, worktree?, lane? ({ id, ticket?, state, lastActivityAt? } or unknown), archiveOf? ({ repository, commit? }).
  • IUseVerdict: state (in-use | quiescent | unknown), holders ({ pid, name, kind, orphan? }, kind fd | cwd | exe | map | mount | container | vm | cmdline | session; names, never command lines), hiddenProcesses, quiescentSince?, lane?, checkedAt, visibility (host | sandbox).
  • IOwnerToolRef: tool, command, resolvedVersion?, via (project | bundled).
  • IPolicyRef: rule, source (builtin | host | workspace | project), file?.

IActionResult: actionId, status (applied | skipped | blocked | failed), before?, after?, statfsFreedBytes?, owner?, error? (never a command line), auditRecordIds.

Scan extras. usage and apparentUsage are the allocated and apparent figures of the one measurement pass (roots, measuredBytes, entries, records, complete, gaps, budgetSeconds, source, measuredAt, cached, mode, excludedPaths); each gap (IUsageGap) has the directory path, its scan root, a reason (budget-exhausted, permission-denied or read-error), the errno code, unreadDirectories for a spent budget and a detail. filesystemMeasurements holds the statfs samples per filesystem at start and end, and extentSharing (reflink: true, false or null when the probe could not tell, and a detail). deletedOpen lists deleted-open files with totals per device and its own complete and issues; warnings summarize those issues with one line per reason (count and three example paths), since a scan without root meets hundreds of unreadable /proc/N/fd tables. A process that exits or closes a descriptor while it is read (ESRCH, ENOENT where the process directory itself is gone, or ENOENT through a descriptor link, which resolves for as long as the descriptor is open) is a vanished issue and leaves deletedOpen complete; ENOENT below a process directory that still exists stays a read-error. classification has complete, visitedEntries (the entries classified, which the entry budget counts; their listings do not count) and gaps (path, bytes and the reason a subtree could not be classified: budget-exhausted, permission-denied, measure-error, or vanished for a path deleted or moved after the walk measured it, which leaves classification complete and is explained as changed-during-scan). policyDiscovery has complete, visitedDirectories and gaps (IScanPolicyGap: path, the scan root whose discovery left it, reason budget-exhausted, permission-denied or read-error, and errno code). dockerEngine (IDockerEngineReport, report-only) has contexts (context, endpoint, sameEngineAs?, usage per Docker type with parsed sizeBytes and reclaimableBytes, totals, restartingContainers, staleExitedContainers, hints, issues), staleExitedDays, issues and complete.

IExplainReport: path, classified (whether a classifier recognized the path), policy (IPolicyExplanation: the protecting rule or the deciding category rules and their layers).

IPolicyReport: action, path, hostFile, hostFileFound, valid, errors, host? (the resolved host policy, webhook URL redacted), sources, protectedRoots, mountPointCount, explanation?.

ICheckReport: hostFile, status (ok | incomplete | warn | critical), exitCode, filesystems (IFilesystemCheck[]: path, filesystem?, totalBytes?, usedBytes?, availableBytes?, availablePercent?, measuredAt?, status, thresholds (threshold, limit, actual?, status, reason?, baselineAt?), error?, contributors?), alerts? (with --alert: sink, status, entries, error?, detail?, stateError?). The webhook body is an IAlertPayload (kind: "tsdisk.alert"; each filesystem has change).

ITrendReport: directory, files, since, filesystem?, filesystems (ITrendFilesystem[]: mount, device, fsType, paths, samples, first, last (at, usedBytes, availableBytes, totalBytes), growthBytes, growthPerDayBytes (null under an hour), contributors? (from, to, categories: category, project?, growthBytes, bytes)), issues (file, line, reason).

IScheduleReport: action (print | write), units (name, content), directory?, written? (name, path, result written | unchanged).

ICleanupReport and IAdoptReport. The cleanup report has dryRun, actions (ICleanupAction[], each with category, cwd, scope, blocked?, ownerTool?, ownerAction?, unmarkedTarget?, preview?, result?, and for pnpm command, previewArgs, applyArgs and pnpmStore; owner-cache, unmarked-target and pnpm actions carry measurement: path, allocatedBytes?, reclaimableBytes?, sharedBytes?, sharedHint?, reason?, measuredAt), pnpmStores?, results (IActionResult[]), safetyBreach?, projectResources? (its own versioned resource report), worktrees? (IWorktreeReport), blockedSummary, discoveryIssues (each scan's first 200), discoveryIssueCounts (every warning counted per kind) and, in a preview, totals (candidates, measured, unmeasured, allocatedBytes, reclaimableBytes). The adopt report has dryRun, target, ownerTool, blocked?, safetyBreach? and exitCode; its results are the envelope's actions.

IWorktreeReport: idleHours, repositories, worktrees (IWorktreeEntry[], see Git worktrees), counts per status, unreadableProcesses, issues and complete.

Exit codes are shared by every command:

Code Meaning Commands
0 OK all
1 Invalid input, a failed precondition or a safety breach all
2 Incomplete: something could not be measured or evaluated scan, explain, cleanup, worktrees, check
3 Some isolated actions failed or were blocked; the others ran cleanup, adopt
10 A warn threshold is breached check
11 A critical threshold is breached check

Migrating from 1.6.0

JSON. The 1.x report with schemaVersion: 1 is gone. scan --json and tsdisk --json print the tsdisk.scan envelope; dispatch on kind.

1.x field 2.0
schemaVersion removed; use kind and version
generatedAt startedAt / finishedAt
roots usage.roots
toolCaches findings with a category
usage usage (allocated) and apparentUsage; reconciled per filesystem in filesystems
issues, issueCounts filesystems[].explained, classification.gaps, warnings
reclaim exclusiveBytes on each eligible, review and node-modules finding
worktrees worktrees --json → worktrees, or cleanup --only=worktree --json → cleanup.worktrees
projectResources cleanup --only=project --json → cleanup.projectResources
usagePriorityPaths, gitRepositories not emitted; scanWorkspace() still returns both
dockerEngine dockerEngine in the tsdisk.scan envelope
sections removed with the host report; filesystems are explained in filesystems, deleted-open files in deletedOpen

Scan. Roots come from --root, else the host policy's roots, else the current directory; /mnt/data is no longer added automatically, so set roots in the policy for scheduled or scripted scans. Classification has no depth limit. Bare tsdisk runs scan; the 1.x interactive host report (journal usage, Docker context storage, an ncdu export, sudo) and its environment variables DOCKER_TIMEOUT, DU_TIMEOUT, NCDU_TIMEOUT, TSDISK_NCDU_EXPORT, TSDISK_KEEP_NCDU_EXPORT and TSDISK_INSTALL_NCDU are gone. A command refuses flags it does not use: scan --check is an error (run provider checks with cleanup --only=project --check), and --worktree-idle-hours needs worktrees or cleanup --only=worktree. The text listing folds findings below the noise floor; --all lists them.

Cleanup.

  • No sudo: --sudo and --no-sudo are rejected as unknown arguments. A Docker cache with root-owned files is blocked; its owner runs tsdocker prune --target <dir> --via-container --apply.
  • Owner tools: tsrust 4.1.0 or later (was 1.13.2) and tsdocker 3.12.0 or later (was 3.6.0). A project with an older copy uses tsdisk's bundled copy, and the plan says why. node_modules/.bin shims are never run.
  • Rust and Docker actions target one marked .nogit cache each instead of a whole project. rust/target and ts_rust/target are no longer cleanup targets: run tsdisk adopt DIR, then tsrust prune --target DIR. A Cargo target directly in .nogit, whatever its name, is planned by cleanup --only=rust once adopted.
  • Apply always needs --yes; there is no prompt. Apply runs owner-tool cleanup only and records every action in the audit log before and after it runs; a record that cannot be written stops the run. In 1.6.0 the CLI also applied pnpm, project and worktree cleanup; in 2.0 it previews them, and project resources and worktrees can be applied through the library.
  • Each action runs on its own. A failed or blocked action exits 3 instead of 1 or 2, and no longer stops the others; a safety breach exits 1.
  • cleanup --json and adopt --json print the tsdisk.cleanup and tsdisk.adopt envelopes. The cleanup report moves into cleanup, and its results become the envelope's actions; the adopt report moves into adopt, with its results in actions. A refused apply prints a failure envelope. The cleanup report drops previews and worktreeResults; each action carries preview and result; safetyBreach is new.

Worktrees. Evidence moves into <repo>/.nogit/archive/<worktree>-<date>-<commit>/ with a manifest.json instead of being copied into .nogit/worktree-evidence/<name>[-N]/. It now covers every ignored path that is not regenerable, inside and outside .nogit, so ignoredEvidence lists more paths. Existing .nogit/worktree-evidence/ folders are not migrated; scans report them as evidence.

Policy. tsdisk 2.0 reads a host policy and .smartconfig.json policy sections. Rules to know when writing one:

  • Every filesystems entry needs at least one threshold.
  • A host file with a webhook must not be readable by group or others (chmod 600); neither the file nor its directory may be writable by group or others.
  • A policy file that is not valid JSON is reported with its line and column only.
  • --policy FILE works with policy, check, schedule and scan; cleanup always reads the default host file.

Library.

  • ICleanupAction: command, previewArgs and applyArgs are pnpm-only and optional; sudoArgs and requiresSudo are gone; ownerAction, preview, result, pnpmStore and measurement are new.
  • ICleanupOwnerTool gains capability, via, resolvedVersion and projectIssue; minimumVersion may be null.
  • dockerPreviewRequiresSudo is removed.
  • applyWorktreeCleanup and removeWorktree need an auditSink; without one they report a safety breach and change nothing. IWorktreeEntry gains ignoredInventory, ownershipIssues, ownershipRepair, measurement, identity, protected and evidenceDestination; IWorktreeApplyResult gains evidenceArchivedTo, manifest, statfsFreedBytes, auditRecordIds, removalState, repair and safetyBreach.
  • Removed with the 1.x host report: main, collectDiskReport, collectProjectToolCacheReport, diagnosticCommandComplete, parseDuOutput and the IDiagnosticSection type. Use collectMeasuredScanReport for a scan, scanWorkspace for marked tool caches and measureTreeUsage for usage.
  • IRuntimeContext is { onProgress?, runningAsRoot }, and IRunOptions loses its sudo fields.

Library API

collectMeasuredScanReport({ roots?, policy?, policyFile?, lanes?, attribution?, laneTimeoutSeconds?, minimumBytes?, usageTimeoutSeconds?, policyMaxEntries?, policyTimeoutMs?, classificationMaxEntries?, classificationTimeoutMs?, deletedOpenMaxEntries?, deletedOpenTimeoutMs?, docker?, libvirtUris?, classifyRootsOnly?, onProgress? }) runs the measured scan and returns the tsdisk.scan envelope with its extras; it classifies entries from minimumBytes up (default 16 MiB); docker replaces the Docker CLI runner (createDockerRunner(budgetMs?) is the default), libvirtUris replaces the libvirt connections VM image holders are read from ([] asks none), and classifyRootsOnly classifies each root itself and skips the Docker summary. runMeasuredScanCommand(options) is the CLI command around it, foldNoiseFloor(findings, floorBytes) its text folding, and runExplainCommand(options) and runWorktreesCommand(options) are the explain and worktrees commands. measureFilesystems(roots) and reconcileFilesystem(snapshot, accounting) produce the per-filesystem verdicts, classifyMeasuredTree(usage, options) the findings (classificationConcurrency, 8, entries at a time, each after its ancestors; options.concurrency sets another), and foldDockerRegistryStubs(findings) the folded stub summaries.

createCleanupPlan(options, resolver?, nogitCargoTargets?) plans cleanup without executing it; without nogitCargoTargets (Cargo targets already found below the project's .nogit) the Rust plan walks .nogit itself through nogitCargoTargetCandidates(project), and unmarkedCargoTargets(project, candidates?) keeps the unmarked ones. rustTargetProject(target, root?) names the project a target belongs to, only one at or below root; executeOwnerActions(actions, options) runs the planned owner actions through a CleanupExecutor (adapter, safety, use, audit, context, quiescenceHours, timeoutSeconds), and cleanupExitCode(results, incomplete?, safetyBreach?) derives the exit code. resolveOwnerTool(project, capability, options?) resolves a project or bundled owner tool. adoptTarget(options, deps?) runs adopt; apply needs deps.audit, an IAuditSink whose recordIntent and recordOutcome resolve once a record is durable. new AuditLog({ directory?, maxBytes?, keepRotated? }) is that sink, the log described under Audit log; readAuditLog({ directory?, since? }) returns the IAuditReport that tsdisk audit prints, and defaultStateDirectory(env?) the state directory every log uses. new TrendLog(options?) and new AlertLog(options?) take the same options; TrendLog.record(content) appends a snapshot and readTrendLog({ directory?, since? }) returns the snapshots, files and issues, and AlertLog.record(content) and AlertLog.lastSent() keep the alert state. All three build on new JsonlLog(name, { maxBytes, keepRotated }, options?), whose append(build) resolves with the record id once the line is durable, and readJsonlLog(directory, name, parse, { rotatedSince? }).

scanWorkspace(root, { maxDepth, maxEntries, timeoutMs, vmImagesByContent?, onProgress? }) returns findings, issues, issue counts, usage-priority paths, completion status, and the directory/symlink inspection count. measureTreeUsage(roots, { timeoutSeconds, trackedPaths?, priorityPaths?, onProgress?, minimumRetainedBytes?, excludedPaths?, readerThreads? }) performs the usage pass and returns { allocated, apparent, complete }, two IUsageReports from the same walk. Priority paths only reorder the walk; they cannot expand scan scope. readerThreads sets how many threads read directories, 1 to 16 (default 12, or the host's CPU count when lower). UsageAccumulator is the bookkeeping behind it: hard-link counting, inclusive directory totals, and the largest entries it retains. estimateReclaimableSpace(findings) returns the conservative candidate summary. The existing classification and byte-formatting exports remain available. parseCliOptions(args) exposes validated CLI options, and parseCliCommand(args) the command they name, also when they are otherwise invalid. summarizeBlockedActions(actions) groups blocked owner actions; groups of unresolved owner tools carry installed and minimum versions. planVmImageActions(files, { classifier?, minimumAllocatedBytes? }) turns VM image files into owner-review actions; scanWorkspace returns the candidate files as vmImageCandidates: by name, and with vmImagesByContent also every other file of at least 16 MiB whose header vmImageSignature(path) recognises (qcow, qed, vdi, vhdx, vmdk or vpc). new VmImageClassifier({ context?, procRoot?, holders?, matcher?, libvirtUris? }) classifies VM images, and after prepare(files) its review(file) returns the IVmImageReview (sizes, snapshots with ageDays, users, holders, reclaim, ownerCommands, blockedBy, issues); formatVmImageReview(review, indent?) prints it, and vmImageSummaryOf(details) reads it back from a scan finding's details.

collectWorktreeInventory(repositories, { idleHours?, timeoutMs?, readProcesses?, onProgress? }) classifies linked worktrees; applyWorktreeCleanup(report, options?) re-inspects and applies eligible entries, removeWorktree(repo, worktree, options?) one worktree and archiveWorktreeEvidence(repo, worktree, options?) only its evidence, as described under Git worktrees; all three need options.auditSink. readProcessCwds() is the default /proc working-directory source; its in-memory command lines are matching input only. formatWorktreeReport(report) returns the human worktree section. scanWorkspace also returns gitRepositories. collectDockerEngineReport(run) builds the report-only Docker engine summary that scan includes as dockerEngine from a Docker command runner; parseDockerSystemDf(stdout), parseDockerSize(value) and classifyDockerContainers(inspectJson, nowMs?, staleExitedDays?) expose its parsers.

createReadOnlyInspector({ root?, maxOperations?, matcher? }) gives classifiers bounded read-only access below root: lstat, readdir(path, { limit? }) and readFile(path, maxBytes). It never follows a symbolic link and never leaves root. A missing entry resolves to undefined; anything it cannot read, including a spent operation budget, throws an InspectorError with a reason. Given a ProtectedPathMatcher, it refuses every path in a measure: false protect root (unmeasured), by path and by the identity of each directory up to root, so a renamed root is refused too. The measured scan gives each classified entry its own inspector, rooted at the directory (a file's parent directory) and bound to the scan's matcher, and records an InspectorError as a measure-error gap.

OwnerDataClassifier({ matcher?, duplicates? }) (registered first) marks owner data protected and never descends into it: nosqldb, MongoDB and SQLite (with a -wal, -shm or -journal file) stores, backup/backups directories, archives whose name contains backup or -pre-, .nogit/local, .git/lfs, bare repositories, Ghost content/ and the protect roots declared in policy. Detected roots are added to the given ProtectedPathMatcher, so every removal refuses them and their ancestors. Inside backups, app state, Ghost content and declared roots it reports duplicate files (findDuplicates(root, inspect, maxHashBytes): equal size and SHA-256, sampled to the first and last MiB above 2 MiB, hard links and unmeasured entries skipped); the search has its own inspector, bound to matcher and bounded by duplicates: { maxOperations?, maxHashBytes? } (defaults 100,000 operations and 256 MiB per root), and duplicates are never offered. The measured scan classifies with registeredClassifiersWith(new OwnerDataClassifier({ matcher: policy.matcher }), new VmImageClassifier({ holders, matcher: policy.matcher })), so owner data it finds joins the scan's matcher, VM images report the scan's holders, and a held image and its backing chain join the matcher as floor.held-vm-image. It counts exclusive bytes only for eligible and review findings and for report-only findings whose classification sets countExclusive (node_modules, whose reclaim figure is the report); protected, in-use and other report-only findings are never counted, and the count's limits (shared extents left out, unfinished probe) become reasons. Counting runs after classification, largest finding first, two subtrees at once (exclusiveSizingConcurrency), within its own budget (scan option exclusiveTimeoutMs, default the classification budget); a finding it does not reach has no exclusiveBytes, the reason Exclusive bytes unmeasured: the sizing budget ran out; allocated bytes are an upper bound., and the scan warns. classifyMeasuredTree takes exclusiveTimeoutMs, exclusiveConcurrency, and deferExclusive, which counts nothing and lists the findings that need it in exclusivePending; sizeExclusiveBytes(findings, probe, { deadline, concurrency? }) counts a list in order and returns those it did not reach. Every finding carries the attribution chain (workspace root, repository, worktree, project, archive origin and lane) from AttributionResolver with the scan roots as workspace roots and AglLaneSource as lanes (scan option lanes replaces it, attribution replaces the whole chain); the AGL lane lookup starts before measurement and has its own bound (scan option laneTimeoutSeconds, default 10), and when AGL does not answer in time the lane is unknown, which refuses removal. Classifiers get the same chain through input.attribution(), resolved once per path. A node_modules finding records installMetadataModifiedAt from the layout's install metadata (.modules.yaml, .package-lock.json or .bun), assuming pnpm, npm and Bun rewrite that file on every install (not verified against each package manager), and worktree: { path, linked, lane } for the checkout it belongs to (lane is null without one, unknown when AGL does not answer); an archived lane adds a reason. ClassifierRegistry.with(...classifiers) is the copy that replaces registered classifiers by id. A marker the inspector cannot read throws, so the scan records a gap instead of a verdict. StandaloneCheckoutClassifier({ run? }) reports a full git clone below a .nogit as report-only with the proof an owner needs before removing it by hand: no changes or untracked files, every commit on a remote-tracking ref (as last fetched), no stash, no operation in progress, no linked worktrees, plus the ignored-file inventory. tsdisk never removes either.

collectProjectResources(projectRoots, { check?, onProgress?, timeoutMs?, maxEntries? }) reads project declarations and returns a versioned resource report. applyProjectResources(report, onProgress?) revalidates and applies eligible resources. resourceStatistics(resources) computes exclusive payload totals. All configuration, provider, candidate, decision and report interfaces are exported from the package root. scanWorkspace also returns projectRoots.

Attribution and use (read-only): HolderIndex.build({ procRoot?, run?, sessionSources?, sessionLookbackHours?, sessionWindowHours?, orphanMinAgeHours?, ancestry? }) indexes /proc cwd, exe, root, fd and map entries, bind mounts, running containers, qemu images and agent sessions (running claude, codex and opencode processes, unarchived AGL fleet workers, the Codex state store). holdersOf(path, { checkout }) lists them by comm or source name, never by command line; hiddenProcesses(path) counts unreadable processes that name nothing there; visibility is sandbox when detectVisibility() finds a nested pid namespace, a foreign pid 1 or hidepid. A path is held by a process with a file, executable, mapping or command-line path inside it, and by a process, session or lane whose working directory lies inside the checkout that owns it: the nearest repository or linked worktree root at or above the path (checkoutOf(path)), or the path itself when no repository contains it. A working directory above that checkout holds nothing in it: neither a workspace root above every repository nor a main checkout above its linked worktrees under .nogit/worktrees/. A cleanup run from inside a checkout is therefore held by its own shell there. Holders are found by path, never by inode: a process holding one hard link of a file (pnpm links its store into every project) holds only the tree that link lies in, not every tree with another link. VM image holders are the exception: they compare device and inode and ask fuser, since qemu-img writes the shared inode in place. A cwd or exe holder whose process has run at least orphanMinAgeHours (default 24) and was adopted by init or a systemd user manager carries orphan (IOrphanEvidence: startedAt, parentPid, parentName, cwdDeleted, exeDeleted and killHint, such as kill 4242); budget, explain and cleanup skip reasons print it as orphan since … under systemd 700, cwd deleted; if unneeded: kill 4242. The hint is for the owner, who checks the process first; services systemd starts look the same, and tsdisk never signals a process. UseProbe({ holders, lanes }).useOf(path, { quiescenceHours }) returns the in-use/quiescent/unknown verdict and isEligibleUse(verdict, hours) the eligibility gate. AglLaneSource reads lanes from agl fleet worker list --archived --json and passes AGL's states through; only a terminal state (isTerminalLaneState: archived) releases a lane, and an unarchived lane covering the path outranks any archived one. AttributionResolver({ workspaceRoots?, lanes? }).attribute(path) resolves workspace root, project, repository, worktree, lane and archive origin. ExclusiveBytesProbe({ maxEntries?, timeoutMs?, run?, readerThreads?, extentRuns? }).exclusiveBytes(path) counts inodes whose links all lie in the subtree on its filesystem (bytes) and every inode once (allocatedBytes), reading directories on up to readerThreads threads (default 4) like the usage walk; on XFS and btrfs it reads the extent map of every counted file, largest first, with up to extentRuns (default 4) filefrag runs of up to 1,024 files at once, and leaves shared extents out (sharedBytes, sharedHint: 'reflinks'). A spent budget during the extent reads keeps bytes with a reason naming it an upper bound; a spent walk budget leaves it out. measureCleanupActions(actions, options?, worktrees?) measures cleanup candidates under one budget and returns the worktree measurements; cleanupTotals(actions, worktrees?) and compareReclaimable total and sort them.

DiskPolicy.load({ hostPolicyPath?, requireHostFile?, mountPoints?, discoveryBudget? }) reads the host policy. policy.check(path, { linkedWorktreeRemoval?, facts? }) returns the protection verdict with every refusing rule; only git-aware worktree removal sets linkedWorktreeRemoval, and it exempts nothing but a verified linked worktree's own repository rule. facts, a ProtectionFacts, lets a run of read-only checks read each path's lstat and repository kind once; it is for reporting only, and a removal checks without it. isLinkedWorktree(directory) and repositoryKind(directory) expose that detection; policy.scopeFor(directory) returns the layered category rules; policy.explain(path, categoryIds?) combines both. ProtectedPathMatcher, ProtectionFacts, canonicalPath, defaultHostPolicyPath, parseByteSize and the parseHostPolicy, parseWorkspacePolicy and parseProjectPolicy parsers are exported as well.

checkFilesystems(policy.host, { history?, historyUnavailable?, ...measureOptions }) measures every policy filesystem, evaluating growth against history (trend snapshots, oldest first), and evaluateThresholds(path, thresholds, sample, growth?) judges one sample; summarizeCheck(hostFile, checks) returns the ICheckReport with status and exit code (checkExitCode(status)). deliverAlerts(report, policy.host.alerts, { runner?, fetch?, checkedAt?, alertLog? }) sends the journald entries (sendJournald, journaldEntry, journaldMessageIds) and the webhook (WebhookTarget.deliver(alertPayload(report, minSeverity, checkedAt, lastSent?))), suppressing unchanged repeats when it has an AlertLog. estimateGrowth(snapshots, filesystem, usedBytes, measuredAt) returns the daily growth with its baseline sample, growthContributors(snapshots, mount, { baseline?, limit? }) what grew between two complete scans of the same roots, checkTrendSnapshot(checks) and scanTrendSnapshot(report) the snapshots --record appends, and buildTrendReport(read, { since, filesystem? }) the ITrendReport that tsdisk trend prints. A WebhookTarget keeps its URL private and serializes and inspects as redacted. generateScheduleUnits({ command, policyFile?, pathDirectories? }) returns the unit files and writeScheduleUnits(directory, units) writes them under the rules above; quoteUnitWord(word, 'command' | 'assignment') (only command lines double $) and unitSearchDirectories are exported as well.

This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the repository license file.

Please note: The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.

Trademarks

This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.

Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.

Company Information

Task Venture Capital GmbH
Registered at District Court Bremen HRB 35230 HB, Germany

For any legal inquiries or further information, please contact us via email at hello@task.vc.

By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.

S
Description
No description provided
Readme MIT
1.8 MiB
0 Stars 3 Watchers 0 Forks
Languages
TypeScript 100%