Repository navigation
Releases
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.
residue v0.0.3
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.regenerableset through the environment (git -cand--config-env, which Git passes on asGIT_CONFIG_PARAMETERS;GIT_CONFIG_COUNTwith its pairs; the legacyGIT_CONFIG). Every subprocess and CI step inherits the environment, so it must not widen what residue deletes.--regenerableis 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
--regenerableandgit config --add residue.regenerable. Every JSON document gainsignored_environment_rules: the ignored values, an empty list when there are none, or null when Git could not read that environment (for exampleGIT_CONFIG_COUNT=1with noGIT_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_CONFIGvariable. It makesgit configread one file in place of every config file, so an inheritedGIT_CONFIGreplaced the repository's own rules, and a file it named withresidue.regenerable = .envmade a worktree holding.envacandidate. residue now removesGIT_CONFIGfrom 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_SYSTEMandGIT_CONFIG_NOSYSTEMare 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 itsresidue.regenerablerules, as in 0.0.2.GIT_CONFIG_NOSYSTEMskips the system file.--helpand 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:
.debfor Debian and Ubuntu,.rpmfor Fedora and RHEL, and a pacman package for Arch and Omarchy. Each is attested and listed inSHA256SUMS. v0.0.2 has them too. packaging/aur/PKGBUILDfor theresidue-binAUR package.
residue v0.0.2
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 withgit config --get-allin each worktree. An invalid value makes theignoredcheckunknownand names the value. It does not stop the scan.explainlists 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,--regenerableand 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.explainnames 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
scanline, inexplain(up to 8 entries), and on thedeletes ignored:line of theremoveplan, for exampleignored: .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. JSONignored[]entries gainfilesandbytes(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 statusnow runs before the walk. The order of the checks in the report is unchanged. - Snapshot time: the text output of
scan,explainandremovestarts withas of HH:MM:SSin local time. Every JSON document hasgenerated_atin RFC 3339 UTC.explaintext now starts with the same header line asscan. removestill 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 pnpmnode_modulesand a Cargotarget), over three rounds:residue explaintook 0.65 to 0.83 s,git worktree removeof an identical copy took 2.69 to 3.04 s, andresidue remove --yestook 3.68 to 4.34 s. The plan inspection is 13 to 22% ofremove --yes(about 17% on average), under the 20% bar for removing it under--yes, soremoveis unchanged.- README: the
results[].resultvalues, 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
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 PATHshows 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 runsgit worktree removewithout--force. Branches and commits are kept.- Activity probe with three states (
in_use,not_observed,unknown):lsofon macOS,/procon Linux. - Ignored files block removal unless every one matches the regenerable list. Extend the list with
~/.config/residue/regenerableor--regenerable. --jsonoutput for every command:residue.scan/v1,residue.explain/v1,residue.remove/v1.