Repository navigation
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 holds it,scanandresidue restoresay so and name thelsofcommand that shows that process (#136). - A restore refuses while a removal that wrote its record without a hold may still be deleting the worktree. A record now says that its writer holds it, with a
hold flockline. One without that line comes from 0.0.10 or earlier, which take no hold, so a free hold says nothing about whether that removal still deletes. While Git still lists the worktree where the record's deletion started,residue restoreof the record, or of the trash entry its purge was deleting, refuses and names the process while the process the record names runs, while agit worktree removeof the worktree runs, or while a process uses the worktree or its Git directory, andscansetsremoval_runningand prints no command. Once Git lists the worktree nowhere, that deletion is over, and a restore no longer looks for that process. Awatchthat 0.0.10 or earlier started already runs each pass with the new release, except under Homebrew on Linux when it keeps old versions; there, After an upgrade says to restart the service once. A PID can be reused, so the process the record names counts while its arguments holdremove, as every residue run's do, whenever it started: a step of the clock moves the start time Linux gives. Where residue cannot read its arguments, it counts unless it started more than ten minutes after the record was written, on macOS, Linux, FreeBSD and illumos; elsewhere a restore can refuse for an unrelated process, andps -p PIDshows what it is. When that process is all that holds the restore back, the refusal andscanalso name the move back by hand, for whenps -p PIDshows it is not residue. The restore also refuses when residue cannot tell: when another user wrote the record and residue does not run as root, since it cannot see that user's processes, it says to run residue as that user; when the activity probe cannot show that no process uses the worktree, or reads the arguments of no other process, it names the Git commands that move the worktree back by hand once nothing can still delete it (#136, #147). scan --jsonleavesrecoveryempty, and setsremoval_runningtonull, whenever residue cannot tell whether a removal still runs, such as when it cannot open the removal record'sinfoor read a record that may name the worktree. Before,recoverystill named the move back.scansays why it cannot tell. A record residue can read but not write is checked through a read-only open (#136).- A Git stopped partway through a removal, by residue's time limit or a kill of Git alone, no longer leaves residue dropping the removal record while files are gone. Git deletes the checkout before its own directory, so it can stop with the HEAD, reflog and
config.worktreeintact and part of the files gone. residue checked only the Git directory, read the worktree as whole, moved it back and dropped the record. In a replay, Git killed after it deleted 1,142 of 60,000 ignored files left the worktree back in place, with no record. Now, once Git has run, residue also compares the files with what the plan measured. Any difference keeps the record and leaves the worktree under its hidden name, the reason sayspart of its files may be gone, andresidue restoremoves the worktree back. An edit made in the hidden directory while Git ran counts the same way, and the restore brings the worktree back with the edit. A worktree whose index is gone counts as not whole too, and its restore says to rungit resetin it first, sincegit restore .copies from the index. A removal that leaves the worktree whole under its hidden name, because residue could not move it back, now keeps its record as well, andresidue restoremoves it back; before, the record went. residue decides that from one reading of the worktree, so the record and the message agree (#137). residue restoreof a trash entry whose purge was stopped partway, as Ctrl-C stops residue and its Git together, now says part of the worktree may be gone. It also puts back the HEAD reflog andconfig.worktreefrom the purge's removal record, and drops that record only once the worktree holds what it keeps. Before, it said nothing and dropped the record at once. In a replay where the stop left the worktree without itslogs/, the restored worktree had no HEAD reflog, andgit gcdeleted the commit only that reflog held. Now it has its 3 reflog entries and the commit stays. The restore holds those records too, and refuses while another run holds one, or while a record it cannot read may be one of them. When the purge stopped after Git deleted the worktree's files,residue restore ENTRYrefuses and namesresidue restore RECORD, which now clears what Git kept of the worktree in the trash, under the holds on the record and the entry, and recreates it from the record. Before, each of those two restores said to run the other (#138).residue restoreno longer lifts a lock you set withgit worktree lockafter residue listed the worktree. It decided whether to unlock from the lock it read when it listed the entry, and Git has no unlock that checks the reason, so a lock set in between was lifted. Now a restore, and a purge, read Git's list of worktrees again under their hold, and lift only a residue lock they read then. Git locks only an unlocked worktree, so a lock read as residue's stays residue's unless someone unlocks it by hand in between. The removal model found this; it was not reproduced live (#139).- A remove run forgets a removal record past
--keep-removedonly while it holds the record, so it no longer forgets one a restore is using: that forget isskipped, and the next run forgets the record (#147). remove --trashholds the trash entry it makes from the moment itsinfois written until it has moved the worktree in and checked it, as a removal holds its record. Aresidue restoreof that entry meanwhile refuses, says a run may be moving a worktree into it, names thelsofcommand that shows the run, and says to run it again once it ends. On a filesystem with no flock(2),remove --trashnow moves nothing into the trash there, and says why. A trash entry or removal record an earlier release made there stays. The plan, the prompt and--jsonshow each as kept from the start, once, with why: its purge or forget iskept, leaves the exit status as it was, and is not counted as something the run will do.residue restoreof it refuses and changes nothing. For a trash entry it names thegit worktree unlockandgit worktree movethat put the worktree back by hand. For a record, it names the move back while Git lists the worktree under its hidden name, and says so when Git lists it at its original path again. Otherwise it names thegit -C MAIN worktree addthat recreates the worktree at the recorded HEAD, asresidue restorewould: on its branch when that branch holds that HEAD and no worktree has it checked out, on a new branchBRANCH-restoredwhen the branch has moved on without it or is checked out elsewhere, on a branch of its name when that branch is gone, and detached when the record names none. The line starts withtest ! -e PATH && test ! -L PATH &&, so it stops while anything is at the path, each of its words is quoted for any POSIX sh, and the note says the record still keeps the commits of the worktree's HEAD reflog (#147).- When
residue restore ENTRYrefuses because a run, or agit worktree removeit started, holds the entry, it names thelsofcommand that shows that process, also when the run was killed and only its Git is left (#147). - residue no longer suggests
git worktree prune,git worktree removeor any other command that clears Git's record of a worktree whose directory is gone, unless a removal record keeps what that drops: the worktree's HEAD, HEAD reflog and index.git worktree prunealso clears the record of every other unlocked worktree whose directory is missing. For astaleworktree inscanandexplain, and for a hidden worktree whose directory is gone and that no removal record names, the note first names what brings it back:git -C MAIN worktree repair NEWPATHif you moved it, and mounting its disk if that is not mounted. It then says when Git clears that record on its own, with that HEAD, reflog and index, by the rule Git applies:git gcdoes 3 months by default (gc.worktreePruneExpire) after the worktree's index last changed, the nextgit gcorgit worktree prunedoes when it has no index or itsgitdirfile is missing or empty, and Git keeps it while it is locked or while the.gititsgitdirfile names is there. It namesgit -C MAIN reflog show worktrees/ID/HEAD, which lists the commits that may go with it. For a locked worktree, such as one on a portable disk, it says Git keeps its record. When Git marks a worktree prunable but its directory is still there without its.gitfile, the note says that a.gitfile readinggitdir: COMMON/worktrees/IDreconnects it. This changesscan --json: for a hidden worktree whose directory is gone and that no removal record names,interrupted[].recoveryis now empty, so a script that runs it no longer drops a HEAD and reflog that nothing else keeps; before, it was thatgit worktree remove. With a record, it is still the remove, since the record keeps them.recovery's move back, and the move back a restore's refusal names, now start withtest ! -e ORIGINAL && test ! -L ORIGINAL &&, so they fail and change nothing while anything is at the original path: Git would move the worktree into a directory there. While something is there,scansays what it is and prints no move back. A path or name with a control character or a byte that is not UTF-8 is now one"$(printf '...')"word in the commands residue prints, with those bytes as\ooo, so each command names it exactly in any POSIX sh, dash included, and in bash and zsh, and no raw control byte reaches the terminal. A path that ends in a newline, which residue never removes, is a word that stops the command before it runs (#147). residue restoreof a removal record whose branch another worktree has checked out now recreates the worktree on a new branch,BRANCH-restored, at the recorded HEAD, and says which worktree has the branch; the branch and that worktree stay as they are. Before, it refused. One function makes that choice for the restore and for thegit worktree addline a restore names where it cannot hold the record, so the two agree (#147).- A removal record now reads
backwhen Git lists a worktree at its original path at the recorded HEAD on any branch, such as one theBRANCH-restoredline made, andresidue restoreof it gives that worktree what it lacks of the record and drops the record. Before, only a worktree on the recorded branch, or detached as the record was, counted (#147). removechecks again after the move aside for a merge, rebase, bisect or other Git operation in progress, and for worktree-private refs (refs/worktree/,refs/bisect/,refs/rewritten/), which the deletion would delete with the worktree. When one started after the plan, residue moves the worktree back and skips it. Before, those checks ran only before the move (#147).residue restore RECORD --to PATHchecks that the nearest folder that exists on the way to PATH is on the worktree's filesystem before it makes any folder, as a trash restore does, so refusing a PATH on another filesystem leaves no folder there (#147).residue restore RECORDof a purge that stopped after Git deleted the worktree's files now clears what Git kept and recreates the worktree also when the trash entry's folder is gone. Before, it sent you toresidue restore ENTRYfor an entry that no longer existed (#147).residue restore ENTRYno longer refuses because a removal record it cannot read is named like the worktree's, when a record it can read names the worktree. It still refuses while that unreadable record is held, or while agit worktree removeof the worktree runs. It also ignores such a record when its name dates it more than a day before the worktree went to the trash: a purge of the entry comes after the entry. Where an unreadable record still stops the restore, the refusal, andscan, name themvthat sets that record's directory aside asresidue-damaged-NAME, keeping its files and refs (#147).residue restoreof a trash entry now also keeps a purge by a release that takes no hold away from the worktree while it restores it. From the moment it holds the entry, it keeps the worktree's.gitopen, so the activity check of every release sees the worktree in use, and a purge keeps it. When Git lists the worktree unlocked in the trash, the restore first locks it with residue's trash lock, before it reads the removal records, and lifts that lock again if it then refuses. Once the worktree is back, the restore reads the records again: a purge's record written meanwhile stays, and the restore says part of the worktree may be gone and names that record, whoseresidue restoreputs back what the worktree lacks of its HEAD and reflog, or, once Git no longer lists the worktree there,residue restore RECORD --to NEWPATH, which recreates it.residue restoreof a purge's record whose worktree is already back now says the same. The restore opens only a regular.git, without following a symbolic link and without waiting, so a FIFO or a link into a hung mount there no longer stops it while it holds the entry: anything else is a refusal that says what it found and changes nothing. When such a purge unlocks or removes the worktree first, so that the restore's own unlock or move fails, the restore reads the records again, says what it changed, and namesresidue restore RECORDfor a purge's record that now keeps the worktree; before, it printed only Git's error. What it changed is nothing, the lock it lifted and could not put back, or a folder it made for the original path, which stays. When Git's move stopped after it moved the files, the restore says where they are now and names thegit -C MAIN worktree repairthat reconnects them, then thegit -C MAIN worktree unlockthat lifts the trash lock residue put back on them. A restore checks that the nearest folder that exists on the way to the original path is on the trash's filesystem before it makes any folder. When such a record came while the worktree moved and Git can no longer use it, the restore exits 1 with no--jsondocument, as every failed restore does, says its files stay at the original path, and namesresidue restore RECORD --to NEWPATH(#147).- Tests:
scripts/recovery-check.shaddsstopped-while-git-runs,stopped-unheld-writer,unprobed-record,killed-unheld-git,purge-stopped-restore,purge-unheld-restore,purge-cut-restore,purge-killed-unheld-gitandremove-fails-part-way-files, and changespurge-restore-race,trash-move-in-flight,remove-fails-part-way-running,move-back-fails,original-takenand the four forget races to the new behavior. Each of them fails on 0.0.10. It also addsunheld-finished-recordandunheld-reused-pid: a record without a hold line of a removal that finished holds no restore back while the process it names runs, and one written an hour before that process started holds it back while that process's arguments nameremove, and not once a plainsleephas the PID, wherever residue reads arguments.restore-locks-unlockedholds a trash restore right before its move back: it has locked the worktree Git listed unlocked, so Git refuses agit worktree removeof it, and a purge's record written then is named and kept, with its restore and, for a worktree Git no longer lists there, its restore with--to NEWPATH.restore-lifts-its-lockchecks that a refused restore lifts that lock,old-damaged-recordthat an unreadable record from before the entry is ignored and a newer one is set aside by the line the refusal names, andstale-names-repairthatscannames no command that deletes and gives the repair first, which brings back a worktree moved by hand, andgone-aside-no-recordthatscan --jsongives norecoveryfor a hidden worktree whose directory is gone and that no record names.stale-keeps-reflog, also in a reftable repository, andstale-keeps-same-namecheck thatscannames no command that deletes for gone worktrees, that each reflog line it names lists the commit only that worktree reached, and that after the repair that commit survivesgit gc --prune=now;stale-locked-no-removechecks that a locked one gets no remove,move-back-guardthatscansays what is at the original path and gives no move back while a directory is there, and that the move back inrecoveryrefuses,restore-opens-no-fifothat a trash restore refuses at once on a FIFO or a link at.git, andrestore-unlock-racedthat a restore whose unlock a purge beat says it changed nothing and names the record that brings the worktree back.restore-raced-git-dirchecks that a restore whose worktree Git can no longer use, after a purge's record came during its move, exits 1 and names that record's restore with--to NEWPATH, which brings the worktree back, andrestore-move-stops-after-renamethat a restore whose Git move stopped after its rename, into a folder the restore made, says so and names the repair and unlock that bring the worktree back whole. Where cc can build a library that residue loads first, as on macOS and on Linux with glibc,noflock-recreateruns each line a restore names on a filesystem with no flock(2) to recreate a worktree by hand, also for a path with a tab, in/bin/shand in dash where it is installed, checks that each shell reads the same words and that each line starts with its guard, and that a restore of each record afterwards says Git lists the worktree again, andnoflock-keptchecks that a sweep there keeps the record and the trash entry and says so once. Each of these fails on 0.0.10.private-ref-after-moveandbisect-after-movecheck that a remove whose worktree gains a worktree-private ref or starts a bisect after the move aside skips it and moves it back with that ref or bisect intact;restore-to-other-fsthat a record restore with--toon another filesystem makes no folder there, also when the folder--tonamed went after residue read it; andstale-no-indexandleftover-dotgit-keptthatscansays when Git drops or keeps the record of a gone worktree, and thatgit gcandgit worktree prunethen do as it says.scripts/contract-check.sh'srestore-branch-elsewherenow checks that the restore makesBRANCH-restoredat the recorded HEAD and leaves the other worktree on its branch.stopped-unheld-writerandkilled-unheld-gitalso check when the move back by hand for a reused PID is named.restore-stopped-before-marknow kills the restore itself, not only the shell that started it, which had let the restore run on and finish. The removal model (.agents/skills/verify-removal-races) models the holds, a Git that outlives residue, Git's files step, a remove run or purge of a release that takes no hold, also one killed alone while its Git goes on, and the record restore that clears what a stopped purge left, and the trash restore's open.git, its lock on a worktree Git lists unlocked, and its second read of the records. Every rule now holds in all fourteen setups, and each of its 32 mutants is caught (#135, #136, #137, #138, #139, #147).