Skip to content

Releases

residue v0.0.10

Choose a tag to compare

@github-actions github-actions released this 08 Nov 19:30
v0.0.10
57138b3

Removal and restore that stay safe when a worktree, or another residue run, changes under them, and less CPU and memory.

  • Nothing that turns up after the move is deleted. remove refuses a repository, a mounted volume or a folder it cannot read that appears in a worktree after residue moves it aside, and puts the worktree back (#106). A folder named .git in another case, such as private/.GIT/, keeps the worktree (#102). The size walk never follows a symbolic link, also one swapped in while it reads (#94).
  • Restore and its records tell the truth. A removal record whose worktree is back reads back, watch no longer removes that worktree again because its work landed, and residue restore of it finishes what an earlier restore left (#99). A restore racing a forget of the same record keeps the commits only its reflog held (#98), and a trash entry can no longer get stuck (#97).
  • Less CPU and memory. residue notices that a Git call has finished as soon as it exits (#82), on macOS each Git call starts sooner (#81), and git status uses a third to a half of the system CPU it did (#75). explain of a large worktree spends about half as long in git status (#83) and uses about 5 MB less memory (#78). On macOS no child process inherits another child's pipes, in residue (#114) and in the Mac app (#116). The Mac app uses less CPU and memory (#75, #95), and its text is brief (#77).
  • On macOS, an ignored folder whose name the disk stores decomposed gets its size (#103), and explain run from inside a worktree no longer names lsof as a process using it (#85). On Linux, the activity check sees a process that uses a worktree only through a mapped file (#84).
  • Changes from 0.0.9. A removal record's state in --json can now be back, unknown, incomplete or unreadable, so a record that did not plainly remove its worktree never reads removed (#99). On Linux, a worktree that a process uses only through a mapped file is kept as in use (#84). On NetBSD, OpenBSD and DragonFly, and on macOS 12 and earlier, every folder above a worktree must be readable, not only searchable (#94). --help and result lines are shorter; JSON documents, exit statuses and the lines the Mac app reads are unchanged (#77).

Details

  • A removal record whose worktree is back no longer reads as removed, and watch no longer removes that worktree again because its work landed. Back means Git lists a worktree with its checkout at the record's original path on its branch (detached when the record is), at the recorded HEAD or ahead of it: what a restore that kept its record leaves, or one stopped after git worktree add before it wrote its restored marker, or a git worktree add there by hand, or a run that moved the worktree back and could not drop the record. A locked worktree whose folder is gone is not back. Its state is now back; seeding adds no freed line for it; watch's landed rule keeps the worktree while the record lives, as it keeps a marked one; and residue restore of it, without --to, finishes the job: it writes the restored marker if there is none, puts back the saved config.worktree (next to the worktree's own when that one differs), and puts the saved HEAD reflog in front of the worktree's own, then drops the record once the worktree holds what its refs keep (checkout already_back). Before, a restore stopped after git worktree add left the commits only the old reflog held to git gc once the record was forgotten. In a reftable repository Git cannot take the old reflog back, so the record stays and the restore says why. When Git cannot compare the worktree's HEAD with the recorded one, the state is unknown, with back_error saying why, never removed: seeding adds nothing, watch keeps the worktree, and restore of it without --to refuses. A record with no info now reads incomplete and one residue cannot read unreadable, with their incomplete and error fields as before. Decision: new state values rather than new fields, so a script that reads only state never sees removed for a worktree that is back or for a record that deleted nothing; the Mac app's types take the new values, and it offers Restore only for removed, interrupted and back, and no freed size for a worktree that is back. A trash restore that lands while a purge deletes the same entry no longer makes the purge say "Git no longer lists it, so part of it may be gone": the purge also looks for the worktree at its original path, and when Git lists it there whole, the purge is skipped, says another run put it back, and drops its record. A worktree at a listed path counts as the record's only when its Git directory is the one the record was written for, the same directory and not one Git made again under its name, so another worktree added at that path never makes the purge drop the record and the refs that keep the trashed worktree's commits. Dropping a record another run already dropped is no longer an error. The removal child reads no records, so a worktree that is back is kept by watch's own filter, not by the child's --only-if-upstream-gone check. Tests: scripts/recovery-check.sh adds purge-restore-race (a purge held at git worktree remove while residue restore moves the worktree out of the trash), restore-stopped-before-mark (a restore killed at its first Git command in the worktree it recreated, then run again), purge-other-worktree and its reftable run (a purge's remove that stops part way while another worktree sits at the original path), back-locked-missing and back-unknown; .github/watch-smoke.sh adds step 9 (that worktree, its upstream gone, past --grace). purge-restore-race, restore-stopped-before-mark, back-unknown and step 9 fail on 0.0.9; the other three guard this change's own matching (#99).
  • remove no longer deletes a repository or a mounted volume that appears in a worktree after residue moves it aside. The check after the move compared only what the plan recorded. git status lists neither a .git inside a folder Git tracks nor anything under an ignored folder, so a git init in src/, or a volume mounted under node_modules/, passed that check. git worktree remove then deleted the repository with its commits, or the files on the volume. A folder nobody can read passed too, and the removal stopped part way. Now the check after the move refuses what the check before it refuses: a .git entry other than the worktree's own, a mount point, or an entry residue cannot read. The reason says what it found and where, such as contains a .git entry at src/.git after residue moved it aside; it is back in place, and the worktree is moved back. Tests: scripts/recovery-check.sh adds nested-repo-after-move, mount-after-move and unreadable-after-move, which plant each one between the plan and that check. All three fail on 0.0.9. mount-after-move mounts a disk image on macOS, and a tmpfs as root on Linux, FreeBSD and illumos; it is skipped elsewhere. unreadable-after-move is skipped as root, who can read any folder (#106).
  • On macOS, a child process residue starts, such as Git, no longer inherits the pipes of another child started at the same moment. The standard library makes a child's pipes and marks them close-on-exec in two steps, and residue inspects up to 8 worktrees at once, so a Git process started on one thread between the two steps held another's output open: that call ended only when the other process, and anything it left running, exited. Now one child starts at a time, and the lock is held for the start alone. Through residue's own process code, 335 of 48,000 children started on 8 threads had inherited a pipe before, and none now. A scan of 10 worktrees takes as long and uses as much CPU as before. This also fixes a test that failed now and then under load (#114, #111).
  • On macOS, a child process the Mac app starts, such as residue, no longer inherits the pipes of another child the app started at the same moment, the race #114 found in residue's own children. The window's requests run on several threads, so a quick one, such as reading the Automatic cleanup status, could wait until an unrelated scan ended. Now the app also starts one child at a time, holding the lock for the start alone, and app/src-tauri/clippy.toml refuses any other way to start one. Through the app's own process code, 844 of 48,000 children started on 8 threads had inherited a pipe before, and none now. Starting them took no longer (#116).
  • A worktree holding a folder named .git in another case, such as private/.GIT/, is now kept. On macOS Git's core.ignorecase is on, so Git skips that name in any case and git status lists nothing inside it. residue matched only .git, so it saw no ignored files and no nested repository, and could remove the worktree with that folder's files unlisted. The nested_repo check now blocks on any case of .git at any level, other than the worktree's own .git link. That includes a .GIT/ at the root, which a case-sensitive file system can hold beside the link: a repository made on macOS and copied to Linux keeps core.ignorecase on. The check names the entry it found: contains a .git entry at private/.GIT: another repository or checkout, or files Git does not list. Where Git is case-sensitive and lists such a folder's files, residue keeps the worktree when it need not, the safe direction for a name this rare (#102).
  • A restore and a forget of the same removal record at the same time no longer lose the commits only its reflog held without saying so. When a remove run, such as a watch child, forgot a record past --keep-removed while residue restore recreated its worktree, restore found the saved reflog gone and put none back. Then it said the record stayed "with its refs, so nothing it keeps is lost", though the record and its refs were gone and git gc could drop those commit...
Read more

residue v0.0.9

Choose a tag to compare

@github-actions github-actions released this 08 Nov 13:21
v0.0.9
d367f5b

Faster size measurement on macOS, and a true line when residue has not counted any freed space yet.

  • Faster on macOS. residue measures a worktree's size two to five times faster, with the same result to the byte. scan, explain, remove, automatic cleanup and the Mac app all measure this way. explain of a 98,000-file worktree takes about 1.0 s, down from 1.4 s (#72).
  • What residue freed, said truthfully. Before residue has counted any freed space, service status, residue stats, the line after a removal and the Mac app say so. They said residue had never freed any space (#70).

Details

  • On macOS, residue measures a worktree's size two to five times faster, with the same result to the byte. It reads each folder with one getattrlistbulk call, as du does, in place of one stat call per file. Folders, symbolic links and other entries that are not regular files still get stat, and a folder the call refuses is read as before. explain of a 98,000-file worktree takes about 1.0 s, down from 1.4 s; git status is most of what is left (#72).
  • When nothing freed is counted yet, service status, residue stats, the line after a removal and the Mac app say so, and say that residue counts what it frees from version 0.0.8 on. They said residue had never freed any space, which was false after a version before 0.0.8 removed a worktree (#70).

Mac app: residue-macos.dmg is the app for macOS 13 or later, on Apple silicon and Intel, with this release's residue inside. It is not notarized yet, so macOS blocks it the first time: the README has the steps to open it.

residue v0.0.8

Choose a tag to compare

@github-actions github-actions released this 08 Nov 02:20
v0.0.8
5f91baa

The Mac app, a record of the space residue frees, and one command to install.

  • The Mac app. Each release now includes residue-macos.dmg, residue in a window for macOS 13 or later: a map of where the space is, each worktree's detail, removal and restore, the automatic cleanup settings, and what residue has freed. Download it from https://residue.whiteheap.com/download/macos. It is not notarized yet, so macOS blocks it the first time; the README's Mac app section has the steps to open it (#39, #43).
  • What residue freed. After each cleanup residue says how much space it freed. scan, service status and a new command, residue stats [--json], show the totals for all time and the last 30 and 7 days, by command and by repository. The record stays on this machine (#38).
  • One command to install. curl -fsSL https://residue.whiteheap.com/install.sh | sh installs residue with Homebrew, apt, dnf, pacman or the release archive, and on macOS and Linux turns on automatic cleanup. Running it again upgrades (#30, #52).
  • Restore brings back more. Automatic cleanup takes --keep-removed to choose how long removal records are kept (#47). In a reftable repository a removal record keeps the commits the worktree's HEAD reflog held, also after git refs migrate (#48, #58). scan prints a recovery line that works for every interrupted removal (#49).
  • Changes from 0.0.7. stats is now a command word, so residue stats no longer scans a folder named stats; residue scan stats does. cargo install --git needs the package name: cargo install --locked --git https://github.com/whiteheap/residue --tag v0.0.8 residue.

Details

  • scan prints a recovery line that works for every interrupted removal (#49). A run killed during the delete can leave a worktree under its hidden name that Git can no longer use, because its .git file or its HEAD is gone. Git refuses to move that worktree, but scan still printed put it back: git worktree unlock ... && git worktree move .... Now it prints only the restore of its record: Git can no longer use it (its .git file is gone), so it cannot move it back; restore says what to clear, then recreates it: residue restore RECORD. The JSON recovery is that residue restore RECORD, or empty when no record names the worktree; it never deletes anything (AGENTS.md invariant). interrupted entries gain unusable (null, or why Git can no longer use it) and removal_record (the record's path, or null). residue restore also gives the steps for a worktree that lost its .git file, which it reported only as Git's validation failed, and its list says why Git can no longer use one.
  • residue restore and residue restore NAME never search the whole home folder (#49). Run from the home folder or a folder above it, they look only in the usual code folders that exist in it, the ones watch uses (~/src, ~/Work and the rest), and say which. When the name is in none of them, the message names the folders it searched and says to run the command inside the repository or to pass the record's path. When no code folder exists, both exit 1 and say so. Below the home folder nothing changes. Decision: search the code folders rather than refuse. A removal by watch with no ROOT is always in one of them, so residue restore fix-login from a new terminal, which opens in the home folder, finds it; a refusal would fail that common case. Before, the search read every folder under the home folder, and on macOS that asks for permission to Documents, Desktop and Downloads. #36 had guarded only a path ENTRY.
  • Tests (#49): scripts/recovery-check.sh checks scan's text and recovery for a worktree that lost its HEAD, adds a case for one that lost its .git file (remove-fails-part-way-dotgit), through to the restore, and one for a restore while the run that left it still runs (remove-fails-part-way-running): restore says to wait for that process and run it again, or, if the PID belongs to another process after a reboot, what to clear, where it printed put it back with: and an empty command. scripts/contract-check.sh adds restore-name-from-home. Each fails on main before this change.
  • Install (#30): one command installs residue and, on macOS and Linux, turns on automatic cleanup: curl -fsSL https://residue.whiteheap.com/install.sh | sh. The script (site/public/install.sh, POSIX sh) uses Homebrew on macOS, the signed apt, dnf or pacman repository on Linux when it can act as root, and otherwise the release archive, checked against SHA256SUMS and, when gh is logged in, its build-provenance attestation; it pins the SHA-256 of the signing key it adds. Then it runs residue service install, leaves an existing service alone, and prints what it did and how to undo it. --no-service, --archive, --prefix, --version and --dry-run. Running it again upgrades. CI runs it through Homebrew, apt, dnf, pacman and the archive (macOS, Ubuntu, Alpine, FreeBSD). README and docs/installation.md lead with it; Homebrew and the manual channels remain.
  • Install (#52): right after a release, when its FreeBSD archives are still being built, the script installs the newest release that has this machine's archive, names both versions and says to run it again later; before, it stopped with curl: (22) ... 404. It decides by the release's SHA256SUMS and finds the earlier release with one GitHub API call; when that call fails, it says the archive is still being built and how to pick a release with --version. --version stays exact: a release with no archive for this machine is a plain error, never another release.
  • How much space residue freed (#38). A remove that deletes or moves something ends with one line: freed about 812 MB (residue has freed about 14.2 GB on this machine since 2026-10-02), or for a move into the trash moved about 812 MB to the trash, freed once a remove run purges it after 7d (...). A preview, or a run that removes nothing, prints none. scan at a terminal ends with the total and the last seven days once anything was freed. The watch log adds freed about ... after each removal, with the UTC date, like the log's own times. On macOS and Linux, service status has a freed: line: what automatic cleanup freed in the last seven days and when it last removed a worktree. A new command, residue stats [--json], shows all time, the last 30 and 7 days, each by command (remove, a sweep, watch), by repository, the trash (moved there, and in it now) and restores; the JSON adds twelve weekly totals.
  • Decision (#38), a ledger per user, ledger.jsonl: ~/Library/Application Support/residue/ on macOS, whatever XDG_STATE_HOME says, so a terminal, the launchd agent and an app read one file; elsewhere $XDG_STATE_HOME/residue/ when it is an absolute path, else ~/.local/state/residue/. The folder is mode 0700, the file 0600. One JSON line per event (freed, trashed, restored), appended with one write(2) under O_APPEND once the deletion, move or restore succeeded, so runs at the same time never overwrite each other's lines on a local filesystem (NFS does not promise it). A line residue cannot read is skipped and counted, and each event counts once, by repository and entry. An append to a file whose last line was cut short starts on a line of its own, so only the cut line is lost. residue reads and writes the ledger only as a regular file, opened without waiting: a FIFO, a device or a folder at its path is a warning, never a command that waits forever. A time outside 1970 to 9999 is read as the nearest end. Each size is the reclaimable estimate the removal record keeps; APFS snapshots, clones and hard links can keep some of the space. A move into the trash is not freed until its purge, and a restore does not subtract. Nothing in it leaves the machine (AGENTS.md invariant). Not the removal records alone: --keep-removed forgets them and a restore takes them back, so a total read from them would shrink.
  • The ledger never affects a removal (AGENTS.md invariant): nothing reads it to decide anything, and a ledger residue cannot read or write is a warning, residue: warning: the stats ledger is not up to date (...); this does not change what was removed, on stderr, in warnings and in stats.error, never in errors and never in the exit status. restore adds it to its notes.
  • Seeding (#38): before it acts, a remove that will delete or move something, restore, and scan without --json add what the ledger lacks from the removal records and trash entries of the repositories they read, as lines marked "seeded": true: removals by 0.0.7, which wrote removal records and no ledger, a ledger that could not be written, and a run stopped between the deletion and the append. Not seeded: records --keep-removed forgot or a restore took back, removals by 0.0.6 or earlier (which wrote no record), interrupted removals (nothing was deleted), and records whose run may still be going. scan --json, explain and stats never write the ledger.
  • JSON, additive (#38): remove gains stats, its last field: freed_bytes, freed_worktrees, trashed_bytes, trashed_worktrees, total_freed_bytes, total_freed_worktrees, since, ledger and error. A ledger it could not write is also in warnings. New schema: residue.stats/v1, whose spans, twelve weeks and repositories each split by command (by: remove, sweep, watch, other), with the estimate sentence as estimate_note, for a client to show in residue's words. Scan, explain and restore documents are unchanged.
  • Changes from 0.0.7 (#38): stats is now a command word, so residue stats no longer scans a folder named stats; residue scan stats does.
  • Tests (#38): scripts/contract-check.sh gains 10 cases: stats --json on an empty ledger and on one with a damaged line (skipped and counted); the stats object in...
Read more

residue v0.0.7

Choose a tag to compare

@github-actions github-actions released this 07 Nov 22:35
v0.0.7
f73cb3b
  • Automatic cleanup in one command (#28): residue service install and residue watch take no ROOT. Without one they use the usual code folders in the home folder that exist: ~/src, ~/code, ~/Code, ~/dev, ~/Developer, ~/projects, ~/Projects, ~/repos, ~/git, ~/github, ~/GitHub, ~/work, ~/Work and ~/workspace, each once (on a case-insensitive filesystem ~/code and ~/Code are one folder), and print which. Never the home folder itself, which holds far more than code, and nothing under Documents, Desktop or Downloads, which macOS guards with a permission prompt a background service cannot answer. When none exists, both print one line that names the folders and how to pass a ROOT, and exit 2. A ROOT replaces the list. service install never prompts, so an installer can run it, and prints the folders and residue service uninstall to turn it off.
  • service install writes the resolved roots into the service, so it watches the same folders at every start, and marks them as the default ones. service status now shows what the service runs (runs:) and which folders it watches (watches:). Running service install again replaces the service: without a ROOT it keeps the ROOTs a user named for the service it replaces, dropping any that is no longer a folder, with a note, and a service on the default folders looks for them again, which picks up a folder created since. Options are not kept: each one not given goes back to its default, and the command says so when the service it replaced was a --dry-run. The Homebrew formula defines no service. If a Homebrew service for residue exists anyway, from a formula of the user's own, service install says so and exits 1, since only one watch runs per user, and service status points to brew services info residue.
  • Decision (#28), watch fetches. Until now residue never fetched, so watch saw a landed branch only after the user's own git fetch --prune, and an automatic cleanup could wait for one indefinitely. watch now runs git fetch --prune --no-tags for each repository about every 30 minutes (--fetch-every DURATION; each repository on its own clock, its interval varied by up to a tenth). It fetches only the remotes that a linked worktree's branch tracks, and nothing in a repository with no such branch, since no other ref decides anything watch does. It fetches in the main checkout with --no-write-fetch-head, --no-auto-maintenance and --no-recurse-submodules, so no file in a worktree's Git directory, which is the idle clock, changes. It never waits for a person: a new session with no terminal, GIT_TERMINAL_PROMPT=0, SSH_ASKPASS_REQUIRE=never, GCM_INTERACTIVE=never, and SSH BatchMode=yes unless the user set GIT_SSH_COMMAND, GIT_SSH or core.sshCommand, which is used as it is; and a five-minute deadline, after which the fetch and every program it started (SSH) are killed. A fetch only refreshes the remote-tracking refs that the scan and the removal read: it is never itself a reason to remove, every check still decides, and a failed fetch logs one line (once, until it changes or works again) and leaves that repository's refs as they were. A branch the fetch finds deleted is logged before any removal it leads to. --no-fetch turns it off; --dry-run still fetches. scan, explain and remove never fetch, so the removal child's three upstream reads stay local. The AGENTS.md invariants record this.
  • Upgrading: a service installed by 0.0.6 or earlier carries neither --fetch-every nor --no-fetch, so after the upgrade it fetches every 30 minutes. To keep the old behavior, install it again with --no-fetch and its ROOTs.
  • residue scan at a terminal ends with one line when automatic cleanup is off, that is when no residue service or Homebrew service is installed and no watch of this user is running: Automatic cleanup is off. Turn it on: residue service install, with the scanned PATHs added when the default folders do not hold every repository it listed. Only on macOS and Linux, only when it lists linked worktrees, never in a pipe or with --json; the JSON schema is unchanged.
  • residue scan --json (#45): each repository's free_space has device, the filesystem's device number (st_dev) as a decimal string, so a reader can show one free-space figure per disk, as watch decides per disk. A string, since a device number can exceed 2^53, past what a JavaScript number holds exactly. It groups repositories within one document only: it changes across reboots and mounts, so never store it or compare it across runs. The schema stays residue.scan/v1.
  • A child process that overruns its deadline and leads its own process group, as the fetch does, is killed with its group.
  • Decision (#31), every deletion is restorable. Until now only remove --trash could be undone; a worktree that remove, --no-trash, watch or the trash purge deleted was gone, and its HEAD commit was safe only while a branch or reflog held it. Now each of them first writes a removal record, residue-removed/ENTRY/info in the repository's Git common directory (the path, branch, HEAD, upstream, why, by which command (by remove, by sweep, by watch; a purge names the run that purged), when, the size and the ignored entries the deletion took, plus copies of the worktree's HEAD reflog and config.worktree), and a ref, refs/residue/removed/ENTRY, at that HEAD, so a deleted branch, an expired reflog or git gc cannot lose the commit. A removal writes them after the move aside and before its check at the new path, and drops them when that check fails; the purge writes them after its re-check. Both write them before git worktree remove; if any of it cannot be written, it deletes nothing (could not write its removal record, so residue deleted nothing, result skipped, exit 3; a purge keeps the entry in the trash). info is written under another name, synced and renamed into place, the record's directories are synced, and the refs are written in one git update-ref --stdin transaction with core.fsync=reference and core.fsyncMethod=fsync (Git 2.36 adds reference to its platform default; macOS's default method only writes out), so a crash leaves all of it or none.
  • Decision (#31), commits only the worktree's own reflog held are kept. git worktree remove deletes the worktree's Git directory and its HEAD reflog with it. A commit made on a detached HEAD and then checked out away from, or the commit before an amend or a rebase, could have that reflog as its only holder, so git gc deleted it after the removal although every check passed. Now the record pins those commits: one ref each, refs/residue/reflog/ENTRY/N, at the fewest commits that reach all of them (git merge-base --independent over every commit the reflog names, as an old or a new value, that no ref outside refs/residue/ and no worktree's HEAD reaches). Another reflog naming such a commit does not count as a holder: a branch's reflog goes with git branch -D, and reflogs expire. A reftable repository (Git 2.45 and later) has no logs/HEAD file to read or copy; there residue asks Git for the commits that reflog names (git rev-list --walk-reflogs HEAD, each entry's new value) and pins those, and since restore cannot write that reflog back, the record stays until --keep-removed. The worktree's private refs (refs/worktree/, refs/bisect/, refs/rewritten/) and an operation in progress already keep it from removal, so the reflog is all its Git directory adds. Removal does not wait on this; it pins and goes on. The invariant, in AGENTS.md: while a record lives, nothing residue removes, restores or forgets makes such a commit unreachable.
  • What --keep-removed can lose: once a record is forgotten, a commit only its refs held is left to git gc. That is the commits of a branch deleted after the removal, for example after a squash merge, and of work only the worktree's reflog held. For those, --keep-removed (30 days) is the only grace period. The README says so. Decision, a ref and not a branch: a branch would show in git branch, could be checked out, pushed or deleted by the user's own tools, and its name could collide; refs/residue/ is residue's alone. git log --all and git push --mirror see it.
  • residue restore lists trash entries and removal records together, newest first, with their kind and why they went, and residue restore ENTRY recreates a removed worktree with git worktree add at its path, never --force: on its branch when the branch is at the recorded HEAD or ahead of it; as BRANCH-restored at the recorded HEAD when the branch was rewritten, which stays as it is; on the branch created again when it was deleted; detached when it was. A branch checked out in another worktree, or anything at the path, refuses; --to PATH restores somewhere else. The ignored files the deletion took, such as node_modules/, are listed as not kept. A removal that did not finish, whose worktree Git still lists under its hidden name, is listed as interrupted, and restore moves it back; when Git deleted that worktree's HEAD reflog or config.worktree, restore puts the saved ones back, and when Git can no longer use it, restore says to delete it and run git worktree prune, after which the same restore recreates it. A recreated worktree gets the saved HEAD reflog back, in front of its new one. Then restore drops the record and its refs, whether it recreated the worktree or moved it back, but only once the worktree's HEAD contains the recorded HEAD and its own HEAD reflog names every pinned commit; when it could not put back the saved config.worktree or reflog, or a branch moved while it ran, the record stays, and restore says where and why. An ENTRY path is looked up from the nearest folder above it that exists, and when that finds no entry, from the current directory; but never from the home folder or a folder above ...
Read more

residue v0.0.6

Choose a tag to compare

@github-actions github-actions released this 07 Nov 13:15
v0.0.6
88a7835
  • Tests (#10): scripts/recovery-check.sh checks what remove leaves when it is stopped right before git worktree remove, when the check after the move fails, when the move back fails or the original path is taken, and when a second remover of the same worktree runs after the first (with and without the trash), each fault with a control that removes when nothing is injected. A git script first on PATH holds or fails one Git command at a time; residue has no test hook. 8 cases. The test scripts unset GIT_CONFIG_GLOBAL and GIT_CONFIG_SYSTEM, so a caller's global Git config is never read or written. scripts/race-check.sh now stops a run that does not end within 60 s of the answer, with everything it started, and fails it instead of waiting forever. Outside both: a kill during git worktree remove itself.

  • Tests (#8): scripts/contract-check.sh runs remove with no prompt against real Git, one repository per case, and pins its contract: the exit status (0; 3 for a refusal, a skip or an invalid target; 2 for a usage error), one residue.remove/v1 document on stdout with the per-target result, reason and failing check ids, diagnostics on stderr only, and afterwards the file bytes, the registration, the branch, an unrelated worktree and no leftovers. 17 cases; a setup failure stops it with status 2, never as a product result. CI runs it on macOS, Linux and FreeBSD (as root and as an ordinary user).

  • Tests (#9): the first in-crate tests, for the verdict fold in Report::finish, on synthetic checks only. No checks is unknown; a failed check keeps; an unknown one without a failure is unknown; a failed check outranks an unknown one; stale outranks every check; only passing checks make a candidate. Every set of up to five checks is compared with that contract, order never changes the verdict, and adding a failed or unknown check never yields a candidate. CI runs cargo test --locked on macOS and both Linux runners.

  • README: FreeBSD has a prebuilt x86_64 binary on every release since 0.0.5; the README still said it had none.

  • FreeBSD aarch64 (#2): every release from 0.0.6 on gets residue-vX.Y.Z-aarch64-unknown-freebsd.tar.gz next to the x86_64 archive, attested and listed in SHA256SUMS. packages.yml builds each from the tag's source in a FreeBSD 14 virtual machine of its own architecture (aarch64 is emulated on an x86_64 runner), checks that the toolchain's host triple matches the archive name, and runs --version and .github/smoke.sh there before packing it. The two builds are independent: one failing keeps the other's archive, and neither holds back the Linux packages.

  • NetBSD, OpenBSD, DragonFly and illumos (#2): activity probes. Before, residue built there but every worktree's activity was unknown, so nothing was ever a candidate. NetBSD, OpenBSD and DragonFly: the base system's ps lists the processes, then one sysctl per process asks the kernel for its working directory (kern.proc_args.PID.cwd, kern.proc_cwd.PID, kern.proc.cwd.PID), and on NetBSD and DragonFly for its executable. Decision, not fstat: on NetBSD and OpenBSD it names an open file only by mount point and inode (fstat(1): "Normally the name cannot be determined"), so it cannot say whether a process is inside a worktree. Open files are not read on these three, so a worktree with no use found is unknown there (see the decision below). illumos: /proc/PID/path/, the working directory, every open descriptor and every mapped object (proc(5)); not /proc/PID/cwd, whose readlink is empty. On all four, a process whose working directory the kernel does not name does not count as inspected, a ps line residue cannot read makes activity unknown, and so does a probe that inspected no process at all. JSON activity.backend is sysctl on the three BSDs and procfs on illumos.

  • Decision (#2), a probe that cannot see open files never clears a worktree: residue must never remove work that is in use. The NetBSD, OpenBSD and DragonFly probes read each process's working directory (and executable on NetBSD and DragonFly), not its open files, so a process that holds a file open inside a worktree while it works elsewhere was not seen, and the worktree could be a removal candidate. Now a backend that reads less than working directories and open files (Seen::limits) answers in_use when it finds a process inside the worktree and unknown otherwise, never not_observed: activity unknown (partial_probe), with the detail sysctl reads working directories only, not open files, so it cannot show that no process is using it (NetBSD and DragonFly: working directories and executables only). The decision is in Snapshot::evidence, the one place every check, re-check and check after the move asks, not a per-platform branch. So on those three systems nothing is a removal candidate, and remove, remove --all and watch remove nothing there; remove a worktree with git worktree remove. macOS (lsof), Linux (/proc), FreeBSD (procstat -f) and illumos (/proc/PID/path, which names open files) are unchanged. JSON: the shape is the same; activity.reason gains the value partial_probe, and activity.state is never not_observed on those three. The README documents the activity object. CI there: each script skips every case that expects a removal or a prompt, by name with the reason (scripts/partial-probe.sh, gated on uname -s), and checks instead that the clean, idle worktree is kept as unknown for that reason in scan, explain, remove --yes, remove under a terminal and watch, with nothing moved aside or put in the trash. Assertions on the other platforms are unchanged; the in-crate tests pin the decision on synthetic snapshots.

  • CI (#2): NetBSD 10, OpenBSD 7.8 and DragonFly 6.4 virtual machines run the build, both smoke tests and the race, contract and recovery checks, and an OmniOS r151054 (illumos) virtual machine runs the build, the smoke test and the contract and recovery checks, each as root and as an ordinary user. illumos skips the watch smoke test, because without inotify or kqueue a running watch wakes on its five-minute timer, past the test's two-minute limit, and the race check, because illumos script cannot run a command. NetBSD and OpenBSD run the race check through .github/script-adapter/script, which turns its script -q FILE CMD... into their script -c form. .github/smoke.sh leaves its scratch directory before deleting it, which illumos rm requires.

  • Fix (#7): watch chose a worktree because its upstream branch was gone, but the removal child did not know why, so an upstream that came back between the scan and the removal did not keep the worktree. remove --only-if-upstream-gone now carries that reason: the child removes a worktree only when the local refs show its branch's upstream gone, checked at the plan, in the re-check right before the move, and after the move right before the deletion. An upstream that exists, no upstream, a detached HEAD or a failed lookup keeps the worktree, with the reason in JSON and the text output, and exit status 3. watch passes it for work-landed removals only. Manual remove and disk-pressure removals are unchanged. residue still never fetches, so a remote change that has not reached the local refs is not seen. --only-if-upstream-gone with --all, or outside remove, is a usage error.

  • The third read comes after the move aside and the re-measure, which can take seconds on a large worktree. If the upstream is back by then, the worktree goes back in place. Every way the requirement keeps a worktree, including a failed lookup, is skipped.

  • Work landed now needs a remote-tracking upstream. A local upstream (branch.NAME.remote set to .) whose branch was deleted made a worktree look landed; --only-if-upstream-gone keeps it, and watch no longer picks it. scan --json and explain --json gain upstream_ref, the upstream's full refname (refs/remotes/origin/fix), and watch reads it.

  • Fix: an upstream counted as gone whenever Git marked it [gone], which Git also does when the ref exists but does not resolve to a commit (it names a tree, or it is a symbolic ref to a missing ref). It now counts as gone only when its ref is absent; anything else is a lookup error. scan's upstream_gone follows, so watch no longer picks such a worktree, and --only-if-upstream-gone keeps it.

residue v0.0.5

Choose a tag to compare

@github-actions github-actions released this 07 Nov 00:38
v0.0.5
9205512

Deletes what you saw, or nothing, and makes a scheduled sweep reversible. Until 0.0.4, remove re-ran the checks right before acting, but it compared the verdict, HEAD, branch, lock and the set of ignored entries, not what they held. Measured with scripts/race-check.sh against the 0.0.4 release binary: a file added inside node_modules/ while the prompt waited was deleted with the worktree (exit 0). And a scheduled sweep that took a paused agent's worktree took it for good.

  • Decision, the plan is a record: for each worktree it now keeps HEAD, the branch, the lock, the staged, modified and untracked files, the file count, size and newest modification time of every ignored entry, and the modification times of the worktree's index and logs/HEAD. Right before acting, residue compares a fresh record with it and skips on any difference, with a reason that names the difference: node_modules/ changed since you confirmed (1,204 -> 1,388 files), HEAD changed since you confirmed (a82c15ee7687 -> 55c48105f23a), changed since you confirmed: 1 untracked. Under --yes the reason says since the plan. logs/HEAD is fine here although --idle must not use it: a git gc between plan and removal only makes residue skip.
  • Decision, move aside before deleting: after the re-check, git worktree move renames the worktree to a hidden sibling, .NAME.residue-removing-PID, and residue locks it with the reason residue: removing. git worktree move is one rename by Git that updates only this worktree's record, and it refuses another filesystem instead of copying; residue skips a worktree that is a mount point. Not mv plus git worktree repair: repair rewrites the records of every worktree of the repository. At the new path residue checks again: Git lists it there with the planned HEAD, branch and residue's lock, a new process snapshot sees nobody inside it, and the record still matches. Then it unlocks it and git worktree remove, without --force, deletes it. On any difference or failure it moves the worktree back and unlocks it (...; it is back in place). This narrows the window between the last check and the deletion; it does not close it. From that check until Git finishes, a process that opens the hidden directory by its new name, or one the probe cannot see, can still write into it, and the write is deleted with it. The README says so.
  • A run killed partway leaves the worktree under its hidden name, still registered. scan lists it under interrupted removals with the one command that puts it back (git -C MAIN worktree unlock HIDDEN && git -C MAIN worktree move HIDDEN ORIGINAL, or git worktree remove HIDDEN when the directory is gone), and says when the PID that moved it still runs. Neither a sweep nor remove PATH touches such a worktree. Scan JSON: each repository gains interrupted, and summary gains interrupted_removals.
  • remove --trash moves each worktree into a trash instead of deleting it, and --idle turns it on, so a scheduled sweep is reversible; --no-trash deletes at once. Decision, where: residue-trash/ in the repository's Git common directory (.git/residue-trash/NAME-STAMP/). A move there is one rename on the repository's filesystem, where linked worktrees usually live; every worktree of the repository finds it; git status never shows it; and it goes away with the repository. A worktree on another filesystem is refused, never copied. Each entry holds the worktree and a record, info, with its original path, branch, HEAD, time and size. The worktree stays registered and locked (residue: in trash), so restore and purge are Git commands; its branch stays checked out there until then.
  • residue restore lists the trash of the repositories at or under the current directory; residue restore ENTRY (the entry name, the worktree's name or branch, or a path) moves it back with git worktree move and drops the entry. JSON schema residue.restore/v1.
  • Every remove run purges the trash entries older than --keep-trash DURATION (default 7d) in the repositories it covers, as part of the plan you confirm: each entry is checked again, without --idle (the trash's clock is --keep-trash), and deleted with git worktree remove without --force. The purge is what frees the disk, and the README says so. Remove JSON gains trash (enabled, keep, keep_seconds), purged, and trash_entry on each result; the result trashed is new. Scan JSON gains each repository's trash and summary.trash_entries. A sweep fails (exit 3) when an expired entry it meant to purge was skipped or failed, or a trash record could not be read; an entry kept because a check failed does not fail it.
  • README: "The trash", and "Run it on a schedule" with --keep-trash 7d in the command, the launchd plist and the systemd service.
  • JSON: each worktree's rule_sources is now its full effective list: --regenerable (command_line), the residue config file (residue_config), then its repository's Git config rules in the order Git reads them. Rules add up, so a duplicate applies once and its first entry is the source that wins. The top-level rule_sources stays the run-wide subset, now in the same order (command_line first). Before, a worktree listed only its Git config rules, so an agent judging one worktree had to merge two lists, and with Git config rules only, the top-level [] read as "no rules". The schemas stay at v1.
  • scripts/race-check.sh drives the release binary's prompt under a pseudo-terminal, changes the worktree while the prompt waits (an untracked file, a modified tracked file, a file in an ignored regenerable directory, a new commit, with and without --trash), answers yes, and checks for exit 3, a changed since you confirmed reason, and the worktree still in place; two control cases must be removed or trashed. CI runs it on macOS and both Linux runners.
  • Fix: a stale worktree in a sweep's kept list now says why it is stale.
  • residue watch ROOT...: stays running and removes a worktree when its work landed (its upstream branch was deleted and it has been unchanged for --grace, default 1h) or when the disk is low (below --min-free, default 10%: worktrees unchanged for --idle, default 2d, largest first, until free space is 5 points above). No schedule: it wakes on changes under refs/remotes, packed-refs and reftable (inotify on Linux, kqueue on macOS and the BSDs), on the moment a waiting worktree passes its wait, and on a filesystem dropping below the threshold. Decision: watch deletes nothing itself. Every removal is a residue remove --yes --idle --no-trash child process, so it makes only removals remove would make. Decision, no trash: --idle turns the trash on, and watch turns it off, because freeing the disk is its job, a trashed worktree keeps every byte until a later run purges it, and nothing would purge the trash of a repository where watch removes nothing more. Each of its runs purges that repository's trash entries older than 7 days, and the log names each one. --dry-run logs instead, --once acts once. One watch per user (a lock in $XDG_STATE_HOME/residue).
  • residue service install|status|uninstall: runs residue watch from login as a launchd agent on macOS or a systemd user service on Linux. Under Homebrew it starts the stable bin/residue link, so it survives brew upgrade.
  • FreeBSD: an activity probe, from the base system's procstat -f. Before, FreeBSD had no probe, so nothing was ever a candidate. A packaged lsof is not used: on FreeBSD 14 it listed processes without resolving their working directories, so a worktree in use came back as a candidate. The procstat parser fails closed: a line it cannot read makes activity unknown.
  • residue builds on illumos and Solaris, which have no tm_gmtoff. NetBSD, OpenBSD, DragonFly and illumos build but still have no probe, so nothing there is a candidate.
  • CI runs .github/smoke.sh on macOS, Linux x86_64 and arm64, and FreeBSD 14 as root and as an ordinary user: a clean candidate, a kept .env, a worktree a process is using, and a removal that keeps the branch.
  • Signed dnf and pacman repositories next to the apt one, at https://whiteheap.github.io/residue/rpm and https://whiteheap.github.io/residue/arch: dnf install residue on Fedora and RHEL, pacman -S residue on Arch and Omarchy, and dnf upgrade and pacman -Syu keep residue current. Every release updates all three. The .rpm files in the dnf repository are signed, and one key signs everything (packaging/signing-key.asc).

residue v0.0.4

Choose a tag to compare

@github-actions github-actions released this 06 Nov 22:43
v0.0.4
fda6a4f

Scheduled sweeps. A machine running agents all day leaked 44 worktrees (4.2 GB) in two days, so the plan was a daily unattended residue remove. The activity check cannot see a paused agent session: harnesses such as Claude Code reset the shell to the main checkout after every command, so a worktree whose agent is waiting for approval has no process inside it, and if its work is pushed it is a candidate. A person sees the plan before deleting; a scheduled run does not.

  • --idle DURATION on scan, explain and remove (30m, 12h, 2d, 1w; anything else, zero included, is a usage error). A worktree that changed within DURATION fails the new idle check, so it is keep, with a reason such as changed 3 hours ago (--idle 2d). A last change residue cannot read makes the check unknown, which keeps it too. The check is reported right after activity. Without --idle it does not run, and every 0.0.3 verdict and check is unchanged.
  • Decision, what counts as a change: the newest of (1) every entry the walk reaches in the checkout, ignored output included, (2) the files directly in the worktree's Git directory (index, HEAD, ORIG_HEAD, FETCH_HEAD, COMMIT_EDITMSG), and (3) the timestamp inside the newest HEAD reflog entry. The walk already reads every entry, so (1) costs nothing; it now also records which entry is newest. (2) catches add, commit, checkout, reset, stash and fetch when no file changed. (3) catches commits and checkouts by the time Git recorded. Not the mtime of logs/HEAD: measured with Git 2.50.1 and 2.43.0, git gc in the main checkout rewrites every linked worktree's logs/HEAD even when nothing expires, so its mtime would keep leaked worktrees in an active repository forever. Not the branch tip's commit time: a branch checked out in a worktree moves only through that worktree, so the reflog and index already record it, and a commit made elsewhere can be older than the checkout that brought it in. Not ctime (touch, tar and rsync -a set mtime on purpose) and not atime (backups and code search read every file). Reads leave no trace: measured on both Git versions, git status, git diff and git log on a settled worktree write nothing, and residue's own Git commands run with optional locks off, so a scan never resets the clock. The README tells people to lock a worktree that may sit without writes for longer than the idle time. Keeping a worktree that is still in use costs disk until the next run; removing one costs a paused agent its working directory, so the signal errs toward keep.
  • residue remove --all PATH... treats each PATH as a scan root and removes every candidate under it, with the same plan, re-check and git worktree remove without --force. Decision: remove --all --yes is a usage error without --idle, because an unattended sweep without it is exactly the case above. remove without --all still takes only worktree paths, so a typo cannot turn into a sweep. A sweep exits 0 when every candidate was removed (or there was none) and 3 when a candidate was skipped or failed or part of a PATH could not be read; the worktrees it keeps are listed under kept (N): and do not fail it. The remove JSON gains errors.
  • explain shows last change with the entry that set it (index in its Git directory, src/main.rs in the worktree, HEAD reflog: commit: fix login), replacing newest change on disk. The changed N ago fact in scan uses the same time. Every JSON document gains idle (threshold and seconds, or null); each worktree gains last_change (unix, at, source, what) and last_change_error. size.newest_mtime stays.
  • README: "Run it on a schedule", with a launchd LaunchAgent and a systemd user service and timer for a daily 04:00 sweep, how to preview the next run, why --idle matters, and git worktree lock for sessions that may pause longer.
  • Fix: rules from Git config named the wrong source. A rule from the global config (~/.gitconfig or GIT_CONFIG_GLOBAL) was reported as This repository's git config residue.regenerable adds: ".env", so a reader looked in .git/config while the rule applied to every repository. residue now reads the key with git config --show-scope --show-origin and names each group of rules by scope and file: global config (~/.gitconfig) adds ".env"; this repository's config (~/src/app/.git/config) adds "web/.tscache". An invalid value names its file too. In JSON, each worktree gains rule_sources (rule, scope, origin) for its Git config rules, and each document gains a top-level rule_sources for the residue config file (residue_config) and --regenerable (command_line). regenerable_config is unchanged: it is still the path of the residue config file, or null when there is none, and the schemas stay at v1.
  • Fix: times named no zone. The header said as of 00:23:02 in local time while explain said 2026-11-06 22:23 UTC. Text output now uses local time with its UTC offset everywhere (as of 00:23:02 +02:00, 2026-11-07 00:23 +02:00). JSON stays RFC 3339 UTC.
  • Fix: run from /, as launchd and systemd start a job, residue printed every path relative to /, without its leading slash (private/tmp/...). Paths are now absolute there.
  • README: regenerable rules apply only to ignored entries. An untracked file that is not ignored always blocks, and --regenerable does not change that.
  • A signed apt repository for Debian and Ubuntu at https://whiteheap.github.io/residue/apt, so sudo apt install residue works and apt upgrade keeps residue current. It holds the latest release for amd64 and arm64, and every release updates it.

residue v0.0.3

Choose a tag to compare

@github-actions github-actions released this 06 Nov 22:18
v0.0.3
178fa2d

Feedback from running 0.0.2 on an agent monorepo: a user set residue.regenerable through GIT_CONFIG_COUNT/GIT_CONFIG_KEY_<n>/GIT_CONFIG_VALUE_<n>, the environment form of git -c, and residue ignored it without a word.

  • Decision: residue still ignores residue.regenerable set through the environment (git -c and --config-env, which Git passes on as GIT_CONFIG_PARAMETERS; GIT_CONFIG_COUNT with its pairs; the legacy GIT_CONFIG). Every subprocess and CI step inherits the environment, so it must not widen what residue deletes. --regenerable is the explicit rule for one run, and Git config files are the deliberate rule for a repository.
  • It no longer ignores them silently. When the environment sets the key, residue prints a note on stderr naming the values, and points at --regenerable and git config --add residue.regenerable. Every JSON document gains ignored_environment_rules: the ignored values, an empty list when there are none, or null when Git could not read that environment (for example GIT_CONFIG_COUNT=1 with no GIT_CONFIG_KEY_0). Git parses the variables itself, so quoting and key case follow Git. The schemas stay at v1. residue runs no extra Git process when none of the variables is set.
  • Fix: 0.0.2 did not remove the legacy GIT_CONFIG variable. It makes git config read one file in place of every config file, so an inherited GIT_CONFIG replaced the repository's own rules, and a file it named with residue.regenerable = .env made a worktree holding .env a candidate. residue now removes GIT_CONFIG from its Git commands and reports the values in that file as ignored. It has no effect on other Git commands.
  • GIT_CONFIG_GLOBAL, GIT_CONFIG_SYSTEM and GIT_CONFIG_NOSYSTEM are unchanged and documented: they choose which config files Git reads, and residue reads the same files as your Git. A user who relocates the global config keeps its residue.regenerable rules, as in 0.0.2. GIT_CONFIG_NOSYSTEM skips the system file.
  • --help and the README say which sources count. AGENTS.md: after each release, upgrade this machine's Homebrew install.
  • Linux packages on every release, for x86_64 and arm64: .deb for Debian and Ubuntu, .rpm for Fedora and RHEL, and a pacman package for Arch and Omarchy. Each is attested and listed in SHA256SUMS. v0.0.2 has them too.
  • packaging/aur/PKGBUILD for the residue-bin AUR package.

residue v0.0.2

Choose a tag to compare

@github-actions github-actions released this 06 Nov 22:00
v0.0.2
b3eab56

Feedback from running 0.0.1 on a busy agent monorepo: every worktree there held .local-dev/ lock files, .cargo/config.toml and web/.tscache/, so nothing was a candidate unless each user added those names to a config file that then applied to every repository.

  • Rules per repository from Git config: git config --add residue.regenerable RULE. residue reads them with git config --get-all in each worktree. An invalid value makes the ignored check unknown and names the value. It does not stop the scan. explain lists the values. residue never reads rules from a file committed to the repository, because a cloned repository must not be able to declare your files disposable.
  • Path rules: a rule with a slash is a path from the worktree root (web/.tscache, .local-dev/*.lock). * matches within one segment. A path rule matches that entry and everything below it. Path rules work in the config file, --regenerable and Git config. Name rules are unchanged.
  • Git reports a fully ignored directory once, as dir/. When a path rule reaches inside one, residue judges each file in it during the walk it already makes. The directory is regenerable only if a rule matches every file in it. explain names the first file that no rule matches. Above 100,000 entries, the directory is kept.
  • Ignored entries show their file count and apparent size in the scan line, in explain (up to 8 entries), and on the deletes ignored: line of the remove plan, for example ignored: .local-dev/ (2 files, 0 B), .vercel/ (2 files, 1.2 KB) (+5 more; 7 entries hold 31 files, 3.1 MB). Each hard-linked file counts once. JSON ignored[] entries gain files and bytes (null when not measured). The schemas stay at v1.
  • The same walk sizes the ignored entries, judges collapsed directories and runs the safety checks. git status now runs before the walk. The order of the checks in the report is unchanged.
  • Snapshot time: the text output of scan, explain and remove starts with as of HH:MM:SS in local time. Every JSON document has generated_at in RFC 3339 UTC. explain text now starts with the same header line as scan.
  • remove still inspects each worktree twice, once for the plan and once right before deletion. Measured on macOS 26.3.1 (Apple silicon) with a 1.0 GB worktree of 46,572 entries (a pnpm node_modules and a Cargo target), over three rounds: residue explain took 0.65 to 0.83 s, git worktree remove of an identical copy took 2.69 to 3.04 s, and residue remove --yes took 3.68 to 4.34 s. The plan inspection is 13 to 22% of remove --yes (about 17% on average), under the 20% bar for removing it under --yes, so remove is unchanged.
  • README: the results[].result values, a scan as a snapshot, .vercel/ as a common non-regenerable entry, rules and their sources, the cost of the walk, and the platforms that ran 0.0.2.

residue v0.0.1

Choose a tag to compare

@github-actions github-actions released this 06 Nov 20:34
v0.0.1
7056e4f

First release.

  • residue scan [PATH...] lists the linked worktrees of each repository at or under PATH, with size, a verdict (candidate, keep, unknown, stale) and the reason for it.
  • residue explain PATH shows every check for one worktree, and facts that never block: local-only commits, a gone upstream, the newest change on disk, and size.
  • residue remove PATH... prints a plan, asks for confirmation (or accepts --yes), re-checks against a fresh process snapshot, then runs git worktree remove without --force. Branches and commits are kept.
  • Activity probe with three states (in_use, not_observed, unknown): lsof on macOS, /proc on Linux.
  • Ignored files block removal unless every one matches the regenerable list. Extend the list with ~/.config/residue/regenerable or --regenerable.
  • --json output for every command: residue.scan/v1, residue.explain/v1, residue.remove/v1.