Repository navigation
Releases
residue v0.0.13
Install
curl -fsSL https://residue.whiteheap.com/install.sh | sh
brew install whiteheap/tap/residueEither one; every other way is in docs/installation.md. The script also turns on automatic cleanup, which deletes worktrees without asking; residue restore brings each back for 30 days. To install without it, run curl -fsSL https://residue.whiteheap.com/install.sh | sh -s -- --no-service. Homebrew installs without it. Or download the file for your machine below. The packages and the FreeBSD archives arrive within half an hour of the release.
| Machine | File |
|---|---|
| macOS, Apple silicon | residue-v0.0.13-aarch64-apple-darwin.tar.gz |
| macOS, Intel | residue-v0.0.13-x86_64-apple-darwin.tar.gz |
| Linux, static (musl), x86_64 | residue-v0.0.13-x86_64-unknown-linux-musl.tar.gz |
| Linux, static (musl), arm64 | residue-v0.0.13-aarch64-unknown-linux-musl.tar.gz |
| Debian, Ubuntu | residue_amd64.deb, residue_arm64.deb |
| Fedora, RHEL | residue.x86_64.rpm, residue.aarch64.rpm |
| Arch Linux | residue-x86_64.pkg.tar.zst, residue-aarch64.pkg.tar.zst |
| FreeBSD, x86_64 | residue-v0.0.13-x86_64-unknown-freebsd.tar.gz |
| FreeBSD, aarch64 | residue-v0.0.13-aarch64-unknown-freebsd.tar.gz |
Check a download: shasum -a 256 -c SHA256SUMS --ignore-missing, then gh attestation verify FILE --repo whiteheap/residue-cli.
The Mac app: https://github.com/whiteheap/residue-ui/releases
Automatic cleanup on macOS and the BSDs no longer stops for good when watch runs out of open files, and a worktree with an ignored entry residue could not measure is kept.
- A running
watchon macOS and the BSDs no longer runs out of open files. kqueue holds an open file for each folderwatchwatches, and launchd starts a service with a limit of 256. With many branch folders underrefs/remotes, the watches took them all, and every pass after that failed withcould not start: Too many open files, so automatic cleanup stopped and removed nothing.watchnow raises its limit on open files and keeps 128 of it free for the Git and residue each pass starts (#168). - On macOS, residue spells each name in a path as Git does. Git precomposes each name on its own and keeps a name it cannot convert, such as one with an emoji, as stored. residue now does the same, name by name, so its walk measures an ignored folder below such names (#172).
- An ignored entry the walk did not reach is not judged. A rule that matches it no longer makes it regenerable: the
ignoredcheck isunknown(the walk did not reach it), which keeps the worktree (#172). - For scripts and integrations (changed from 0.0.12): such an entry is
"regenerable": false, withfilesandbytesnull as before, and theignoredcheck'sstatusisunknown(#172).
Details
Events::room(kqueue): the softRLIMIT_NOFILEis raised toward the hard limit, at most 10,240 (OPEN_MAXon macOS), and the watches stop 128 below it, as well as atMAX_WATCHES. inotify watches hold no open file and are unchanged. Tests:events::testsand.github/watch-smoke.shscenario 11, a running watch underulimit -n 256with 302 folders to watch, which removes a worktree whose upstream is pruned.fsinfo::git_pathconverts each name that holds a non-ASCII byte with Apple'siconvfrom UTF-8-MAC to UTF-8 on its own, keeps a name the conversion refuses or leaves alone, and gives the path as bytes, so the bucket lookup matches Git's path byte for byte.inspect::judgecalls an entryunknownwhen the walk did not reach it or could not run, before the environment-file check. Tests:fsinfo::tests::each_name_is_spelled_on_its_own_and_a_refused_one_is_kept,fsinfo::tests::git_spells_a_path_name_by_name_with_apples_converterandfsinfo::tests::a_bucket_in_gits_spelling_is_found_below_a_refused_name(macOS),inspect::tests::an_ignored_entry_the_walk_did_not_reach_is_not_judged, andscripts/contract-check.shsection 12b,ignored-mixed-names-env,ignored-mixed-names-env-swappedandignored-mixed-names-clean(macOS; skipped by name elsewhere).
residue v0.0.12
Install
curl -fsSL https://residue.whiteheap.com/install.sh | sh
brew install whiteheap/tap/residueEither one; every other way is in docs/installation.md. The script also turns on automatic cleanup, which deletes worktrees without asking; residue restore brings each back for 30 days. To install without it, run curl -fsSL https://residue.whiteheap.com/install.sh | sh -s -- --no-service. Homebrew installs without it. Or download the file for your machine below. The packages and the FreeBSD archives arrive within half an hour of the release.
| Machine | File |
|---|---|
| macOS, Apple silicon | residue-v0.0.12-aarch64-apple-darwin.tar.gz |
| macOS, Intel | residue-v0.0.12-x86_64-apple-darwin.tar.gz |
| Linux, static (musl), x86_64 | residue-v0.0.12-x86_64-unknown-linux-musl.tar.gz |
| Linux, static (musl), arm64 | residue-v0.0.12-aarch64-unknown-linux-musl.tar.gz |
| Debian, Ubuntu | residue_amd64.deb, residue_arm64.deb |
| Fedora, RHEL | residue.x86_64.rpm, residue.aarch64.rpm |
| Arch Linux | residue-x86_64.pkg.tar.zst, residue-aarch64.pkg.tar.zst |
| FreeBSD, x86_64 | residue-v0.0.12-x86_64-unknown-freebsd.tar.gz |
| FreeBSD, aarch64 | residue-v0.0.12-aarch64-unknown-freebsd.tar.gz |
Check a download: shasum -a 256 -c SHA256SUMS --ignore-missing, then gh attestation verify FILE --repo whiteheap/residue-cli.
The Mac app: https://github.com/whiteheap/residue-ui/releases
Launch hardening: residue keeps more worktrees that are in use or hold what it cannot rebuild, stops on a mistyped command or an old Git before anything runs, replaces only the service you looked at, and comes with a skill for coding agents.
- An
.envinside a build folder is kept. An environment file inside an ignored directory a rule matches, such asbuild/.envordist/.env.production, now keeps its worktree, as an ignored.envat the top always has:ignored: build/.env. Templates (.env.example) and files innode_modulesand virtual environments do not count, and a rule naming the file itself (git config --add residue.regenerable build/.env) lets residue delete it (#158). - A bare repository's HEAD branch is kept. In the layout
git clone --bareand then agit worktree addper branch makes, the worktree on the branch the bare repository's HEAD names is its main checkout in all but name. Theheadcheck now blocks it:on main, the branch the bare repository's HEAD names; kept like a main checkout(#156). residue service uninstallandstatustake no arguments.residue service uninstall --dry-rununinstalled the service; now any argument but--helpis a usage error and nothing runs (#156).- A mistyped command is a usage error.
residue cleanprintedcould not inspect: clean: No such file or directoryand exited 0; now it saysresidue: clean is not a command, and there is no folder named clean here; commands: scan, explain, remove, restore, stats, watch, service(#156). residuein the home folder scans the usual code folders, asrestoredoes, and says which; it no longer walks the whole home folder. A PATH,~included, is scanned as given (#156).- An old Git stops
service installandwatch --oncebefore they start, and the install script before it adds a package repository or asks for a password. A runningwatchsays so once and waits for a newer Git, instead of failing every pass (#156). service installand the install script say what automatic cleanup removes, that it deletes without the trash and for how longresidue restorebrings a worktree back, and how to preview it (#156).- A skill for coding agents.
npx skills add whiteheap/residue-cli --skill residue -ggives Claude Code, Codex and other agents the steps to remove the worktree they created once a task is done, and what to do when residue keeps it (skills/residue/SKILL.md, #163). service install --expectreplaces only the service you looked at.residue service statusprints a fingerprint of the service. Given it,installwrites nothing and exits 4 when the service changed since, so the Mac app can no longer save over a change a terminal orbrew upgrademade while its dialog was open. Installs and uninstalls now run one at a time (#162).- A process in a worktree keeps it in use whatever characters its path has, such as é, 漢字, an emoji or a backslash, in the worktree's name or a folder above it. On macOS, residue now matches the names lsof prints escaped. Where it cannot be sure how lsof prints a path, activity is
unknown, which keeps the worktree. On FreeBSD, residue reads each process's working directory, executable and open files from the kernel (kern.proc.filedesc), byte for byte, instead of from procstat (#157). - On illumos, an open file whose path the kernel cannot give still keeps its worktree in use: residue finds the file by its inode. Where it cannot look, activity is
unknown, and the detail names the process. A deleted file does not count (#160). - A worktree that holds a very long path is kept. Each probe names a path whole only below a length: 1,004 bytes on macOS, 4,096 on Linux and 1,024 elsewhere. A worktree with a path that long anywhere inside it is now
unknown(long_path), so it is never a candidate, and says how long that path is. So is one whose paths would reach that length where residue deletes it, at its hidden name or in the trash, so the plan never lists a worktree that the check after the move then keeps (#165). - On FreeBSD, residue also reads the files each process has mapped into memory (
kern.proc.vmmap). A file a process holds that the kernel cannot name is placed by its inode, as on illumos: in use when the worktree holds it,unknown(unnamed_file) when residue cannot tell. A process running as your user that the kernel will not show makes activityunknown(probe_failed), and the reason names the process, as every one does withsecurity.bsd.unprivileged_proc_debugat 0. A process the kernel shows only to root whatever the settings, such as ssh-agent or the process sshd keeps for each login, is not inspected, as on Linux (#165). - A removal Git stopped partway says what is left. When
git worktree removefails after Git has dropped the worktree from its list, the reason says what is left at the hidden name and ends with the removal record'sresidue restore, instead of a line that moves it back. That restore recreates the worktree and notes the folder left behind, for you to look through and delete (#165). - For scripts and integrations (changed from 0.0.11):
- With no command first, the first PATH, the first argument before
--that is neither an option nor an option's value, exits 2 when it has no/and names nothing that exists (residue clean,residue --json clean); a command there says it goes first (residue --json scan).residue -- cleanstill scans./clean. scanwith no PATH, run from the home folder or above it, scans the usual code folders there and notes it on stderr (residue: note: scanned only the usual code folders in your home folder (...)); with none of them, or from/with HOME unset or/, it exits 2.service statusandservice uninstallwith any argument but-hor--helpexit 2.- On a Git older than 2.36,
service installexits 1 withresidue: git X.Y is too old; residue needs git 2.36 or later, andwatch --onceexits 1 with<time> residue watch: git X.Y is too old; residue needs git 2.36 or later. A runningwatchlogs<time> residue watch: git X.Y is too old; residue needs git 2.36 or later; checking again every 5 minutesonce, keeps running, and once Git is new enough logsgit X.Y is new enough now; startingand starts. - The
headcheck blocks the worktree on the branch a bare repository's HEAD names (statusblock), and isunknownwhen that HEAD cannot be read. service installprintsremoves:,deletes:andpreview:lines beforecheck:.watch --oncelogs one line when its pass removes, or would remove, nothing, saying why (nothing to remove now: ..., orno removal this pass: each worktree it tried was kept, as logged above).- An ignored entry a rule matches is
"regenerable": falsewhen it holds an environment file no rule names, and theignoredcheck blocks:ignored entries not known to be regenerable: build/ (2 files, 18 B; build/.env is an environment file no rule names). - The scan hint names the preview first:
Automatic cleanup is off. Preview it: residue watch --once --dry-run · turn it on: residue service install. service statusprintsfingerprint: HEX, 32 lowercase hex digits, after what the service runs and beforefreed:, also when no service is installed (exit 3);fingerprint: unknown (REASON)when it cannot read the service file. The first line and thelog:line are unchanged.service install --expect FINGERPRINTand--expect noneare new. When the service is not the one named,installwrites nothing, prints one line on stderr (residue service install: the service changed since that fingerprint was read, or another residue release printed it; nothing changed. Read it again: residue service status, or fornone,residue service install: a service or its file exists, and --expect none installs only when neither does; nothing changed. See it: residue service status), and exits 4. A value that is neithernonenor 32 lowercase hex digits, a missing value or a second--expectexits 2. A service it cannot read to compare, or a folder it cannot lock, exits 1. Without--expect,installis unchanged but for the lock below.service installandservice uninstalllock the folder of the service file (~/Library/LaunchAgents,~/.config/systemd/user). One that finds it locked prints `residue service install: waiting...
- With no command first, the first PATH, the first argument before
residue v0.0.11
Install
curl -fsSL https://residue.whiteheap.com/install.sh | sh
brew install whiteheap/tap/residueEither one; every other way is in docs/installation.md. The script also turns on automatic cleanup, which deletes worktrees without asking; residue restore brings each back for 30 days. To install without it, run curl -fsSL https://residue.whiteheap.com/install.sh | sh -s -- --no-service. Homebrew installs without it. Or download the file for your machine below.
| Machine | File |
|---|---|
| macOS, Apple silicon | residue-v0.0.11-aarch64-apple-darwin.tar.gz |
| macOS, Intel | residue-v0.0.11-x86_64-apple-darwin.tar.gz |
| Linux, static (musl), x86_64 | residue-v0.0.11-x86_64-unknown-linux-musl.tar.gz |
| Linux, static (musl), arm64 | residue-v0.0.11-aarch64-unknown-linux-musl.tar.gz |
| Debian, Ubuntu | residue_amd64.deb, residue_arm64.deb |
| Fedora, RHEL | residue.x86_64.rpm, residue.aarch64.rpm |
| Arch Linux | residue-x86_64.pkg.tar.zst, residue-aarch64.pkg.tar.zst |
| FreeBSD, x86_64 | residue-v0.0.11-x86_64-unknown-freebsd.tar.gz |
| FreeBSD, aarch64 | residue-v0.0.11-aarch64-unknown-freebsd.tar.gz |
Check a download: shasum -a 256 -c SHA256SUMS --ignore-missing, then gh attestation verify FILE --repo whiteheap/residue-cli.
The Mac app: https://github.com/whiteheap/residue-ui/releases
What changed
Restores that wait until nothing may still delete the worktree, a confirmed plan that runs as shown, and the Mac app in its own repository.
- A restore waits until nothing may still delete the worktree. A run that may delete a worktree holds its trash entry or removal record, and the Git it starts keeps that hold, so a restore refuses until the deletion has ended, also when residue was killed alone (#135, #136). A Git stopped partway keeps the removal record, and a restore after a stopped purge says what may be gone and puts back what it can (#137, #138). A restore lifts only a lock residue set (#139). A record from 0.0.10 or earlier, which has no hold, is checked for its run, its Git and any process using the worktree (#147).
- A removal skips a worktree that changed while residue moved it aside. A Git operation such as a bisect, or a worktree-private ref, that started during the move now makes residue move the worktree back and keep it (#147).
- A confirmed plan runs as shown.
remove --as-of TIMEmeasures every age at TIME, the plan'sgenerated_at, so a run that confirms a plan purges and forgets only what had expired then (#151). - After an upgrade, automatic cleanup runs the new release on its own (#149).
- Faster
explain,scanandremoveon a worktree with an untracked folder that holds only ignored files (#120). - The Mac app moved. It now releases from whiteheap/residue-ui, so this release has no
residue-macos.dmg. This repository is now whiteheap/residue-cli (#127). - Changes from 0.0.10:
- A removal record has a
hold flockline (#147). scan --jsonsetsremoval_running. It leavesrecoveryempty while a removal may still run or residue cannot tell (#136), and for a hidden worktree whose directory is gone and that no removal record names (#147).- A move back in
recoverystarts withtest ! -e ORIGINAL && test ! -L ORIGINAL &&. A word with a control character or a byte that is not UTF-8 is"$(printf 'FMT')", which any POSIX sh runs, instead of$'...'(#147). residue.remove/v1addsas_of(#151).- residue no longer suggests
git worktree prune. For a worktree whose directory is gone, it names the repair or the mount, and names agit worktree removeonly where a removal record keeps what that drops (#147). residue restoreof a record whose branch another worktree has checked out recreates the worktree onBRANCH-restoredinstead of refusing. A record readsbackwhen Git lists a worktree at its original path at the recorded HEAD, on any branch (#147).- On a filesystem with no flock(2), such as some network mounts, residue removes and restores nothing there and says why (#147).
- A removal record has a
Details
- After an upgrade, automatic cleanup runs the new release within about six minutes. Before each pass, and at least every five minutes while it waits,
watchchecks that the path the service starts still names the file it runs. Once an upgrade replaces that file,watchlogs why and exits 1 between passes, and launchd or systemd starts the new release. Before,watchexited only when its file was gone, so on most installs it kept the old release's own loop until the service restarted; each pass already ran the new release. Each start's first log line now ends with the version, such as· residue 0.0.11. Awatchthat 0.0.10 or earlier started has no such check and switches at its next start, such as your next login. Tests:.github/watch-smoke.shcase 10 (#149). - The repository is now whiteheap/residue-cli; links to github.com/whiteheap/residue redirect to it (#127).
- residue-cli's releases no longer include
residue-macos.dmg: the Mac app now releases from whiteheap/residue-ui, and the last DMG here is 0.0.10's. The download link, https://residue.whiteheap.com/download/macos, stays the same (#127). - A run that confirms another run's plan no longer purges or forgets what expired after that plan was made. The Mac app shows a plan with
residue remove --trash --jsonand, once you confirm, runs the same command with--yes. That second run measured--keep-trashand--keep-removedat its own start, so a trash entry or a removal record that expired while the dialog was open went without being shown. Nowremove --as-of TIMEmeasures every age the run judges at TIME, the plan'sgenerated_at: the trash's and the records' keep times, and--idle. A TIME later than the run's start counts as the start, so the flag never makes a run purge sooner. Under--as-of, a trash entry, a removal record or a repository's list of worktrees that changed at TIME or after it waits for a later run, so an entry whoseinfoor worktree arrived after the plan, or a record whose removal still ran then, is left too. Each PATH and trash entry is still checked against its current state.residue.remove/v1addsas_of, the time the run measured ages at, and the text header addsages as of. The app gets this once residue-ui passes--as-ofwith a release that has it. Tests:scripts/contract-check.shsection 14 and two usage cases (#151). explain,scanandremoveare faster on a worktree with an untracked folder that holds only ignored files, such as a folder of generated reports. residue read the whole worktree a second time withgit status --ignored=traditional(#83); now that secondgit statusreads only the topmost untracked folders that hold ignored entries, with literal pathspecs, and replaces what the first read listed under them. Past 64 folders or 64 KiB of their paths, or when that run fails, residue reads the whole worktree as before. Verdicts and theignoredentries in--jsonare the same: on 77 layouts on macOS and Linux each list equals Git's whole traditional one, and so do two real worktrees and ascanof 13. On a worktree of 112,653 entries the secondgit statustook 7 to 8 ms, down from 298 to 401 ms, andexplainused 0.51 to 0.79 s of system CPU, down from 0.71 to 1.48 s. Tests:scripts/contract-check.shsection 13 compares each layout with Git's list and counts thegit statuscalls, andscripts/recovery-check.shaddslimited-status-fails(#120).residue restoreof a trash entry no longer moves the worktree back while a purge deletes it. Before, a restore during the purge'sgit worktree removeput the worktree back as Git deleted it. In a replay with 3,000 tracked and 30,000 ignored files, Git left 3,000 tracked and 29,100 ignored files at the original path, with no Git directory, the purge dropped its removal record, andgit gcdeleted the commit only the HEAD reflog held. Now a run that may delete a worktree holds the entry that names it, an exclusive flock(2) on itsinfo(src/hold.rs). A purge holds the trash entry from before its re-check, and its Git holds it too until it exits. A restore takes that hold first, and while another run holds it, refuses, changes nothing and says to run it again once that run ends. In the same replay the restore refused, the purge completed, and the restore of the purge's record brought the worktree back with every tracked file and the commit. This replaces 0.0.10's check for a worktree another run put back, which could not see a restore that landed while Git deleted (#135).- Killing residue alone during
git worktree removeno longer lets that Git delete a worktree a restore moved back.kill PID, a Force Quit orwatch's deadline stops residue, but its Git child goes on deleting. Before,scanprinted the move back as the recovery andresidue restoremoved the worktree back while Git was still deleting. In a replay with 150,000 ignored files, that Git then deleted the restored worktree's Git directory, leaving 3,000 tracked and 139,078 ignored files that Git could not use. Now Git inherits the run's hold on the removal record, so the record stays held until Git exits. Meanwhilescansets the newinterrupted[].removal_running, prints no command, and says to run it again once the removal ends, andresidue restorerefuses. In the same replay the restore refused while Git ran. Once Git exited, it recreated the worktree from its record with the commit only its reflog held.scanchecks the hold by taking it for an instant, so a restore at that instant refuses and says to run it again. When the run that wrote the record has exited and another process still...
residue v0.0.10
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.
removerefuses 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.gitin another case, such asprivate/.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,watchno longer removes that worktree again because its work landed, andresidue restoreof 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 statususes a third to a half of the system CPU it did (#75).explainof a large worktree spends about half as long ingit 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
explainrun 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
statein--jsoncan now beback,unknown,incompleteorunreadable, so a record that did not plainly remove its worktree never readsremoved(#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).--helpand 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
watchno 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 aftergit worktree addbefore it wrote its restored marker, or agit worktree addthere 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. Itsstateis nowback; seeding adds nofreedline for it;watch's landed rule keeps the worktree while the record lives, as it keeps a marked one; andresidue restoreof it, without--to, finishes the job: it writes the restored marker if there is none, puts back the savedconfig.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 (checkoutalready_back). Before, a restore stopped aftergit worktree addleft the commits only the old reflog held togit gconce 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, thestateisunknown, withback_errorsaying why, neverremoved: seeding adds nothing,watchkeeps the worktree, and restore of it without--torefuses. A record with noinfonow readsincompleteand one residue cannot readunreadable, with theirincompleteanderrorfields as before. Decision: newstatevalues rather than new fields, so a script that reads onlystatenever seesremovedfor 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 forremoved,interruptedandback, and nofreedsize 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 isskipped, 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 bywatch's own filter, not by the child's--only-if-upstream-gonecheck. Tests:scripts/recovery-check.shaddspurge-restore-race(a purge held atgit worktree removewhileresidue restoremoves 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-worktreeand its reftable run (a purge's remove that stops part way while another worktree sits at the original path),back-locked-missingandback-unknown;.github/watch-smoke.shadds step 9 (that worktree, its upstream gone, past--grace).purge-restore-race,restore-stopped-before-mark,back-unknownand step 9 fail on 0.0.9; the other three guard this change's own matching (#99). removeno 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 statuslists neither a.gitinside a folder Git tracks nor anything under an ignored folder, so agit initinsrc/, or a volume mounted undernode_modules/, passed that check.git worktree removethen 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.gitentry other than the worktree's own, a mount point, or an entry residue cannot read. The reason says what it found and where, such ascontains 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.shaddsnested-repo-after-move,mount-after-moveandunreadable-after-move, which plant each one between the plan and that check. All three fail on 0.0.9.mount-after-movemounts a disk image on macOS, and a tmpfs as root on Linux, FreeBSD and illumos; it is skipped elsewhere.unreadable-after-moveis 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
scanof 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
scanended. Now the app also starts one child at a time, holding the lock for the start alone, andapp/src-tauri/clippy.tomlrefuses 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
.gitin another case, such asprivate/.GIT/, is now kept. On macOS Git'score.ignorecaseis on, so Git skips that name in any case andgit statuslists 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. Thenested_repocheck now blocks on any case of.gitat any level, other than the worktree's own.gitlink. 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 keepscore.ignorecaseon. 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
removerun, such as awatchchild, forgot a record past--keep-removedwhileresidue restorerecreated 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 andgit gccould drop those commit...
residue v0.0.9
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.explainof 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
getattrlistbulkcall, asdudoes, in place of onestatcall per file. Folders, symbolic links and other entries that are not regular files still getstat, and a folder the call refuses is read as before.explainof a 98,000-file worktree takes about 1.0 s, down from 1.4 s;git statusis 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
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 statusand 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 | shinstalls 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-removedto 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 aftergit refs migrate(#48, #58).scanprints a recovery line that works for every interrupted removal (#49). - Changes from 0.0.7.
statsis now a command word, soresidue statsno longer scans a folder namedstats;residue scan statsdoes.cargo install --gitneeds the package name:cargo install --locked --git https://github.com/whiteheap/residue --tag v0.0.8 residue.
Details
scanprints 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.gitfile or its HEAD is gone. Git refuses to move that worktree, butscanstill printedput 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 JSONrecoveryis thatresidue restore RECORD, or empty when no record names the worktree; it never deletes anything (AGENTS.md invariant).interruptedentries gainunusable(null, or why Git can no longer use it) andremoval_record(the record's path, or null).residue restorealso gives the steps for a worktree that lost its.gitfile, which it reported only as Git'svalidation failed, and its list says why Git can no longer use one.residue restoreandresidue restore NAMEnever 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 oneswatchuses (~/src,~/Workand 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 bywatchwith no ROOT is always in one of them, soresidue restore fix-loginfrom 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.shchecksscan's text andrecoveryfor a worktree that lost its HEAD, adds a case for one that lost its.gitfile (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 printedput it back with:and an empty command.scripts/contract-check.shaddsrestore-name-from-home. Each fails onmainbefore 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 againstSHA256SUMSand, whenghis logged in, its build-provenance attestation; it pins the SHA-256 of the signing key it adds. Then it runsresidue service install, leaves an existing service alone, and prints what it did and how to undo it.--no-service,--archive,--prefix,--versionand--dry-run. Running it again upgrades. CI runs it through Homebrew, apt, dnf, pacman and the archive (macOS, Ubuntu, Alpine, FreeBSD). README anddocs/installation.mdlead 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'sSHA256SUMSand 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.--versionstays exact: a release with no archive for this machine is a plain error, never another release. - How much space residue freed (#38). A
removethat 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 trashmoved 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.scanat a terminal ends with the total and the last seven days once anything was freed. Thewatchlog addsfreed about ...after each removal, with the UTC date, like the log's own times. On macOS and Linux,service statushas afreed: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, whateverXDG_STATE_HOMEsays, 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 onewrite(2)underO_APPENDonce 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 thereclaimableestimate 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-removedforgets 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, inwarningsand instats.error, never inerrorsand never in the exit status.restoreadds it to its notes. - Seeding (#38): before it acts, a
removethat will delete or move something,restore, andscanwithout--jsonadd 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-removedforgot 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,explainandstatsnever write the ledger. - JSON, additive (#38):
removegainsstats, its last field:freed_bytes,freed_worktrees,trashed_bytes,trashed_worktrees,total_freed_bytes,total_freed_worktrees,since,ledgeranderror. A ledger it could not write is also inwarnings. New schema:residue.stats/v1, whose spans, twelve weeks and repositories each split by command (by:remove,sweep,watch,other), with the estimate sentence asestimate_note, for a client to show in residue's words. Scan, explain and restore documents are unchanged. - Changes from 0.0.7 (#38):
statsis now a command word, soresidue statsno longer scans a folder namedstats;residue scan statsdoes. - Tests (#38):
scripts/contract-check.shgains 10 cases:stats --jsonon an empty ledger and on one with a damaged line (skipped and counted); thestatsobject in...
residue v0.0.7
- Automatic cleanup in one command (#28):
residue service installandresidue watchtake 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,~/Workand~/workspace, each once (on a case-insensitive filesystem~/codeand~/Codeare one folder), and print which. Never the home folder itself, which holds far more than code, and nothing underDocuments,DesktoporDownloads, 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 installnever prompts, so an installer can run it, and prints the folders andresidue service uninstallto turn it off. service installwrites the resolved roots into the service, so it watches the same folders at every start, and marks them as the default ones.service statusnow shows what the service runs (runs:) and which folders it watches (watches:). Runningservice installagain 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 installsays so and exits 1, since only onewatchruns per user, andservice statuspoints tobrew services info residue.- Decision (#28),
watchfetches. Until now residue never fetched, sowatchsaw a landed branch only after the user's owngit fetch --prune, and an automatic cleanup could wait for one indefinitely.watchnow runsgit fetch --prune --no-tagsfor 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 anythingwatchdoes. It fetches in the main checkout with--no-write-fetch-head,--no-auto-maintenanceand--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 SSHBatchMode=yesunless the user setGIT_SSH_COMMAND,GIT_SSHorcore.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-fetchturns it off;--dry-runstill fetches.scan,explainandremovenever 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-everynor--no-fetch, so after the upgrade it fetches every 30 minutes. To keep the old behavior, install it again with--no-fetchand its ROOTs. residue scanat a terminal ends with one line when automatic cleanup is off, that is when noresidue serviceor Homebrew service is installed and nowatchof 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'sfree_spacehasdevice, the filesystem's device number (st_dev) as a decimal string, so a reader can show one free-space figure per disk, aswatchdecides 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 staysresidue.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 --trashcould be undone; a worktree thatremove,--no-trash,watchor 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/infoin 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 andconfig.worktree), and a ref,refs/residue/removed/ENTRY, at that HEAD, so a deleted branch, an expired reflog orgit gccannot 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 beforegit worktree remove; if any of it cannot be written, it deletes nothing (could not write its removal record, so residue deleted nothing, resultskipped, exit 3; a purge keeps the entry in the trash).infois written under another name, synced and renamed into place, the record's directories are synced, and the refs are written in onegit update-ref --stdintransaction withcore.fsync=referenceandcore.fsyncMethod=fsync(Git 2.36 addsreferenceto 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 removedeletes 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, sogit gcdeleted 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 --independentover every commit the reflog names, as an old or a new value, that no ref outsiderefs/residue/and no worktree's HEAD reaches). Another reflog naming such a commit does not count as a holder: a branch's reflog goes withgit branch -D, and reflogs expire. A reftable repository (Git 2.45 and later) has nologs/HEADfile 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-removedcan lose: once a record is forgotten, a commit only its refs held is left togit 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 ingit 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 --allandgit push --mirrorsee it. residue restorelists trash entries and removal records together, newest first, with their kind and why they went, andresidue restore ENTRYrecreates a removed worktree withgit worktree addat its path, never--force: on its branch when the branch is at the recorded HEAD or ahead of it; asBRANCH-restoredat 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 PATHrestores somewhere else. The ignored files the deletion took, such asnode_modules/, are listed as not kept. A removal that did not finish, whose worktree Git still lists under its hidden name, is listed asinterrupted, and restore moves it back; when Git deleted that worktree's HEAD reflog orconfig.worktree, restore puts the saved ones back, and when Git can no longer use it, restore says to delete it and rungit 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 savedconfig.worktreeor 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 ...
residue v0.0.6
-
Tests (#10):
scripts/recovery-check.shchecks whatremoveleaves when it is stopped right beforegit 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. Agitscript first onPATHholds or fails one Git command at a time; residue has no test hook. 8 cases. The test scripts unsetGIT_CONFIG_GLOBALandGIT_CONFIG_SYSTEM, so a caller's global Git config is never read or written.scripts/race-check.shnow 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 duringgit worktree removeitself. -
Tests (#8):
scripts/contract-check.shrunsremovewith 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), oneresidue.remove/v1document 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 isunknown; a failed check keeps; an unknown one without a failure isunknown; 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 runscargo test --lockedon 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.gznext to the x86_64 archive, attested and listed inSHA256SUMS.packages.ymlbuilds 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--versionand.github/smoke.shthere 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'spslists 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, notfstat: 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 isunknownthere (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, apsline residue cannot read makes activityunknown, and so does a probe that inspected no process at all. JSONactivity.backendissysctlon the three BSDs andprocfson 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) answersin_usewhen it finds a process inside the worktree andunknownotherwise, nevernot_observed:activity unknown (partial_probe), with the detailsysctl 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 inSnapshot::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, andremove,remove --allandwatchremove nothing there; remove a worktree withgit 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.reasongains the valuepartial_probe, andactivity.stateis nevernot_observedon those three. The README documents theactivityobject. CI there: each script skips every case that expects a removal or a prompt, by name with the reason (scripts/partial-probe.sh, gated onuname -s), and checks instead that the clean, idle worktree is kept asunknownfor that reason inscan,explain,remove --yes,removeunder a terminal andwatch, 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
watchwakes on its five-minute timer, past the test's two-minute limit, and the race check, because illumosscriptcannot run a command. NetBSD and OpenBSD run the race check through.github/script-adapter/script, which turns itsscript -q FILE CMD...into theirscript -cform..github/smoke.shleaves its scratch directory before deleting it, which illumosrmrequires. -
Fix (#7):
watchchose 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-gonenow 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.watchpasses it for work-landed removals only. Manualremoveand 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-gonewith--all, or outsideremove, 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.remoteset to.) whose branch was deleted made a worktree look landed;--only-if-upstream-gonekeeps it, andwatchno longer picks it.scan --jsonandexplain --jsongainupstream_ref, the upstream's full refname (refs/remotes/origin/fix), andwatchreads 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'supstream_gonefollows, sowatchno longer picks such a worktree, and--only-if-upstream-gonekeeps it.
residue v0.0.5
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
indexandlogs/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--yesthe reason sayssince the plan.logs/HEADis fine here although--idlemust not use it: agit gcbetween plan and removal only makes residue skip. - Decision, move aside before deleting: after the re-check,
git worktree moverenames the worktree to a hidden sibling,.NAME.residue-removing-PID, and residue locks it with the reasonresidue: removing.git worktree moveis 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. Notmvplusgit worktree repair:repairrewrites 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 andgit 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.
scanlists it underinterrupted removalswith the one command that puts it back (git -C MAIN worktree unlock HIDDEN && git -C MAIN worktree move HIDDEN ORIGINAL, orgit worktree remove HIDDENwhen the directory is gone), and says when the PID that moved it still runs. Neither a sweep norremove PATHtouches such a worktree. Scan JSON: each repository gainsinterrupted, andsummarygainsinterrupted_removals. remove --trashmoves each worktree into a trash instead of deleting it, and--idleturns it on, so a scheduled sweep is reversible;--no-trashdeletes 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 statusnever 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 restorelists 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 withgit worktree moveand drops the entry. JSON schemaresidue.restore/v1.- Every
removerun purges the trash entries older than--keep-trash DURATION(default7d) 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 withgit worktree removewithout--force. The purge is what frees the disk, and the README says so. Remove JSON gainstrash(enabled,keep,keep_seconds),purged, andtrash_entryon each result; the resulttrashedis new. Scan JSON gains each repository'strashandsummary.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 7din the command, the launchd plist and the systemd service. - JSON: each worktree's
rule_sourcesis 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-levelrule_sourcesstays the run-wide subset, now in the same order (command_linefirst). 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.shdrives 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, achanged since you confirmedreason, 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
keptlist 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 underrefs/remotes,packed-refsandreftable(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:watchdeletes nothing itself. Every removal is aresidue remove --yes --idle --no-trashchild process, so it makes only removalsremovewould make. Decision, no trash:--idleturns the trash on, andwatchturns 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 wherewatchremoves nothing more. Each of its runs purges that repository's trash entries older than 7 days, and the log names each one.--dry-runlogs instead,--onceacts once. Onewatchper user (a lock in$XDG_STATE_HOME/residue).residue service install|status|uninstall: runsresidue watchfrom login as a launchd agent on macOS or a systemd user service on Linux. Under Homebrew it starts the stablebin/residuelink, so it survivesbrew 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 activityunknown. - 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.shon 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 residueon Fedora and RHEL,pacman -S residueon Arch and Omarchy, anddnf upgradeandpacman -Syukeep residue current. Every release updates all three. The.rpmfiles in the dnf repository are signed, and one key signs everything (packaging/signing-key.asc).
residue v0.0.4
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 DURATIONonscan,explainandremove(30m,12h,2d,1w; anything else, zero included, is a usage error). A worktree that changed within DURATION fails the newidlecheck, so it iskeep, with a reason such aschanged 3 hours ago (--idle 2d). A last change residue cannot read makes the checkunknown, which keeps it too. The check is reported right afteractivity. Without--idleit 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) catchesadd,commit,checkout,reset,stashandfetchwhen no file changed. (3) catches commits and checkouts by the time Git recorded. Not the mtime oflogs/HEAD: measured with Git 2.50.1 and 2.43.0,git gcin the main checkout rewrites every linked worktree'slogs/HEADeven 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,tarandrsync -aset 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 diffandgit logon 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 andgit worktree removewithout--force. Decision:remove --all --yesis a usage error without--idle, because an unattended sweep without it is exactly the case above.removewithout--allstill 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 underkept (N):and do not fail it. The remove JSON gainserrors.explainshowslast changewith the entry that set it (index in its Git directory,src/main.rs in the worktree,HEAD reflog: commit: fix login), replacingnewest change on disk. Thechanged N agofact inscanuses the same time. Every JSON document gainsidle(threshold and seconds, or null); each worktree gainslast_change(unix,at,source,what) andlast_change_error.size.newest_mtimestays.- 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
--idlematters, andgit worktree lockfor sessions that may pause longer. - Fix: rules from Git config named the wrong source. A rule from the global config (
~/.gitconfigorGIT_CONFIG_GLOBAL) was reported asThis repository's git config residue.regenerable adds: ".env", so a reader looked in.git/configwhile the rule applied to every repository. residue now reads the key withgit config --show-scope --show-originand 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 gainsrule_sources(rule,scope,origin) for its Git config rules, and each document gains a top-levelrule_sourcesfor the residue config file (residue_config) and--regenerable(command_line).regenerable_configis 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:02in local time whileexplainsaid2026-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
--regenerabledoes not change that. - A signed apt repository for Debian and Ubuntu at https://whiteheap.github.io/residue/apt, so
sudo apt install residueworks andapt upgradekeeps residue current. It holds the latest release for amd64 and arm64, and every release updates it.