Skip to content

residue v0.0.11

Choose a tag to compare

@github-actions github-actions released this 09 Nov 12:12
· 52 commits to main since this release
v0.0.11
d1d4dc7

Install

curl -fsSL https://residue.whiteheap.com/install.sh | sh
brew install whiteheap/tap/residue

Either 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 TIME measures every age at TIME, the plan's generated_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, scan and remove on 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 flock line (#147).
    • scan --json sets removal_running. It leaves recovery empty 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 recovery starts with test ! -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/v1 adds as_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 a git worktree remove only where a removal record keeps what that drops (#147).
    • residue restore of a record whose branch another worktree has checked out recreates the worktree on BRANCH-restored instead of refusing. A record reads back when 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).

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, watch checks that the path the service starts still names the file it runs. Once an upgrade replaces that file, watch logs why and exits 1 between passes, and launchd or systemd starts the new release. Before, watch exited 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. A watch that 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.sh case 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 --json and, once you confirm, runs the same command with --yes. That second run measured --keep-trash and --keep-removed at its own start, so a trash entry or a removal record that expired while the dialog was open went without being shown. Now remove --as-of TIME measures every age the run judges at TIME, the plan's generated_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 whose info or 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/v1 adds as_of, the time the run measured ages at, and the text header adds ages as of. The app gets this once residue-ui passes --as-of with a release that has it. Tests: scripts/contract-check.sh section 14 and two usage cases (#151).
  • explain, scan and remove are 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 with git status --ignored=traditional (#83); now that second git status reads 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 the ignored entries in --json are the same: on 77 layouts on macOS and Linux each list equals Git's whole traditional one, and so do two real worktrees and a scan of 13. On a worktree of 112,653 entries the second git status took 7 to 8 ms, down from 298 to 401 ms, and explain used 0.51 to 0.79 s of system CPU, down from 0.71 to 1.48 s. Tests: scripts/contract-check.sh section 13 compares each layout with Git's list and counts the git status calls, and scripts/recovery-check.sh adds limited-status-fails (#120).
  • residue restore of a trash entry no longer moves the worktree back while a purge deletes it. Before, a restore during the purge's git worktree remove put 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, and git gc deleted 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 its info (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 remove no longer lets that Git delete a worktree a restore moved back. kill PID, a Force Quit or watch's deadline stops residue, but its Git child goes on deleting. Before, scan printed the move back as the recovery and residue restore moved 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. Meanwhile scan sets the new interrupted[].removal_running, prints no command, and says to run it again once the removal ends, and residue restore refuses. 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. scan checks 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, scan and residue restore say so and name the lsof command 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 flock line. 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 restore of the record, or of the trash entry its purge was deleting, refuses and names the process while the process the record names runs, while a git worktree remove of the worktree runs, or while a process uses the worktree or its Git directory, and scan sets removal_running and prints no command. Once Git lists the worktree nowhere, that deletion is over, and a restore no longer looks for that process. A watch that 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 hold remove, 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, and ps -p PID shows what it is. When that process is all that holds the restore back, the refusal and scan also name the move back by hand, for when ps -p PID shows 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 --json leaves recovery empty, and sets removal_running to null, whenever residue cannot tell whether a removal still runs, such as when it cannot open the removal record's info or read a record that may name the worktree. Before, recovery still named the move back. scan says 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.worktree intact 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 says part of its files may be gone, and residue restore moves 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 run git reset in it first, since git 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, and residue restore moves it back; before, the record went. residue decides that from one reading of the worktree, so the record and the message agree (#137).
  • residue restore of 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 and config.worktree from 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 its logs/, the restored worktree had no HEAD reflog, and git gc deleted 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 ENTRY refuses and names residue 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 restore no longer lifts a lock you set with git worktree lock after 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-removed only while it holds the record, so it no longer forgets one a restore is using: that forget is skipped, and the next run forgets the record (#147).
  • remove --trash holds the trash entry it makes from the moment its info is written until it has moved the worktree in and checked it, as a removal holds its record. A residue restore of that entry meanwhile refuses, says a run may be moving a worktree into it, names the lsof command that shows the run, and says to run it again once it ends. On a filesystem with no flock(2), remove --trash now 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 --json show each as kept from the start, once, with why: its purge or forget is kept, leaves the exit status as it was, and is not counted as something the run will do. residue restore of it refuses and changes nothing. For a trash entry it names the git worktree unlock and git worktree move that 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 the git -C MAIN worktree add that recreates the worktree at the recorded HEAD, as residue restore would: on its branch when that branch holds that HEAD and no worktree has it checked out, on a new branch BRANCH-restored when 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 with test ! -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 ENTRY refuses because a run, or a git worktree remove it started, holds the entry, it names the lsof command 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 remove or 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 prune also clears the record of every other unlocked worktree whose directory is missing. For a stale worktree in scan and explain, 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 NEWPATH if 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 gc does 3 months by default (gc.worktreePruneExpire) after the worktree's index last changed, the next git gc or git worktree prune does when it has no index or its gitdir file is missing or empty, and Git keeps it while it is locked or while the .git its gitdir file names is there. It names git -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 .git file, the note says that a .git file reading gitdir: COMMON/worktrees/ID reconnects it. This changes scan --json: for a hidden worktree whose directory is gone and that no removal record names, interrupted[].recovery is now empty, so a script that runs it no longer drops a HEAD and reflog that nothing else keeps; before, it was that git 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 with test ! -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, scan says 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 restore of 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 the git worktree add line a restore names where it cannot hold the record, so the two agree (#147).
  • A removal record now reads back when Git lists a worktree at its original path at the recorded HEAD on any branch, such as one the BRANCH-restored line made, and residue restore of 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).
  • remove checks 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 PATH checks 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 RECORD of 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 to residue restore ENTRY for an entry that no longer existed (#147).
  • residue restore ENTRY no 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 a git worktree remove of 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, and scan, name the mv that sets that record's directory aside as residue-damaged-NAME, keeping its files and refs (#147).
  • residue restore of 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 .git open, 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, whose residue restore puts 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 restore of 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 names residue restore RECORD for 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 the git -C MAIN worktree repair that reconnects them, then the git -C MAIN worktree unlock that 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 --json document, as every failed restore does, says its files stay at the original path, and names residue restore RECORD --to NEWPATH (#147).
  • Tests: scripts/recovery-check.sh adds stopped-while-git-runs, stopped-unheld-writer, unprobed-record, killed-unheld-git, purge-stopped-restore, purge-unheld-restore, purge-cut-restore, purge-killed-unheld-git and remove-fails-part-way-files, and changes purge-restore-race, trash-move-in-flight, remove-fails-part-way-running, move-back-fails, original-taken and the four forget races to the new behavior. Each of them fails on 0.0.10. It also adds unheld-finished-record and unheld-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 name remove, and not once a plain sleep has the PID, wherever residue reads arguments. restore-locks-unlocked holds a trash restore right before its move back: it has locked the worktree Git listed unlocked, so Git refuses a git worktree remove of 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-lock checks that a refused restore lifts that lock, old-damaged-record that an unreadable record from before the entry is ignored and a newer one is set aside by the line the refusal names, and stale-names-repair that scan names no command that deletes and gives the repair first, which brings back a worktree moved by hand, and gone-aside-no-record that scan --json gives no recovery for a hidden worktree whose directory is gone and that no record names. stale-keeps-reflog, also in a reftable repository, and stale-keeps-same-name check that scan names 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 survives git gc --prune=now; stale-locked-no-remove checks that a locked one gets no remove, move-back-guard that scan says what is at the original path and gives no move back while a directory is there, and that the move back in recovery refuses, restore-opens-no-fifo that a trash restore refuses at once on a FIFO or a link at .git, and restore-unlock-raced that a restore whose unlock a purge beat says it changed nothing and names the record that brings the worktree back. restore-raced-git-dir checks 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, and restore-move-stops-after-rename that 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-recreate runs 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/sh and 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, and noflock-kept checks 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-move and bisect-after-move check 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-fs that a record restore with --to on another filesystem makes no folder there, also when the folder --to named went after residue read it; and stale-no-index and leftover-dotgit-kept that scan says when Git drops or keeps the record of a gone worktree, and that git gc and git worktree prune then do as it says. scripts/contract-check.sh's restore-branch-elsewhere now checks that the restore makes BRANCH-restored at the recorded HEAD and leaves the other worktree on its branch. stopped-unheld-writer and killed-unheld-git also check when the move back by hand for a reused PID is named. restore-stopped-before-mark now 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).