Skip to content

Releases

residue v0.0.13

Choose a tag to compare

@github-actions github-actions released this 09 Nov 21:02
v0.0.13
2090ca8

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. The packages and the FreeBSD archives arrive within half an hour of the release.

Machine File
macOS, Apple silicon residue-v0.0.13-aarch64-apple-darwin.tar.gz
macOS, Intel residue-v0.0.13-x86_64-apple-darwin.tar.gz
Linux, static (musl), x86_64 residue-v0.0.13-x86_64-unknown-linux-musl.tar.gz
Linux, static (musl), arm64 residue-v0.0.13-aarch64-unknown-linux-musl.tar.gz
Debian, Ubuntu residue_amd64.deb, residue_arm64.deb
Fedora, RHEL residue.x86_64.rpm, residue.aarch64.rpm
Arch Linux residue-x86_64.pkg.tar.zst, residue-aarch64.pkg.tar.zst
FreeBSD, x86_64 residue-v0.0.13-x86_64-unknown-freebsd.tar.gz
FreeBSD, aarch64 residue-v0.0.13-aarch64-unknown-freebsd.tar.gz

Check a download: shasum -a 256 -c SHA256SUMS --ignore-missing, then gh attestation verify FILE --repo whiteheap/residue-cli.

The Mac app: https://github.com/whiteheap/residue-ui/releases

Automatic cleanup on macOS and the BSDs no longer stops for good when watch runs out of open files, and a worktree with an ignored entry residue could not measure is kept.

  • A running watch on macOS and the BSDs no longer runs out of open files. kqueue holds an open file for each folder watch watches, and launchd starts a service with a limit of 256. With many branch folders under refs/remotes, the watches took them all, and every pass after that failed with could not start: Too many open files, so automatic cleanup stopped and removed nothing. watch now raises its limit on open files and keeps 128 of it free for the Git and residue each pass starts (#168).
  • On macOS, residue spells each name in a path as Git does. Git precomposes each name on its own and keeps a name it cannot convert, such as one with an emoji, as stored. residue now does the same, name by name, so its walk measures an ignored folder below such names (#172).
  • An ignored entry the walk did not reach is not judged. A rule that matches it no longer makes it regenerable: the ignored check is unknown (the walk did not reach it), which keeps the worktree (#172).
  • For scripts and integrations (changed from 0.0.12): such an entry is "regenerable": false, with files and bytes null as before, and the ignored check's status is unknown (#172).

Details

  • Events::room (kqueue): the soft RLIMIT_NOFILE is raised toward the hard limit, at most 10,240 (OPEN_MAX on macOS), and the watches stop 128 below it, as well as at MAX_WATCHES. inotify watches hold no open file and are unchanged. Tests: events::tests and .github/watch-smoke.sh scenario 11, a running watch under ulimit -n 256 with 302 folders to watch, which removes a worktree whose upstream is pruned.
  • fsinfo::git_path converts each name that holds a non-ASCII byte with Apple's iconv from UTF-8-MAC to UTF-8 on its own, keeps a name the conversion refuses or leaves alone, and gives the path as bytes, so the bucket lookup matches Git's path byte for byte. inspect::judge calls an entry unknown when the walk did not reach it or could not run, before the environment-file check. Tests: fsinfo::tests::each_name_is_spelled_on_its_own_and_a_refused_one_is_kept, fsinfo::tests::git_spells_a_path_name_by_name_with_apples_converter and fsinfo::tests::a_bucket_in_gits_spelling_is_found_below_a_refused_name (macOS), inspect::tests::an_ignored_entry_the_walk_did_not_reach_is_not_judged, and scripts/contract-check.sh section 12b, ignored-mixed-names-env, ignored-mixed-names-env-swapped and ignored-mixed-names-clean (macOS; skipped by name elsewhere).

residue v0.0.12

Choose a tag to compare

@github-actions github-actions released this 09 Nov 18:57
v0.0.12
c901a0b

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. The packages and the FreeBSD archives arrive within half an hour of the release.

Machine File
macOS, Apple silicon residue-v0.0.12-aarch64-apple-darwin.tar.gz
macOS, Intel residue-v0.0.12-x86_64-apple-darwin.tar.gz
Linux, static (musl), x86_64 residue-v0.0.12-x86_64-unknown-linux-musl.tar.gz
Linux, static (musl), arm64 residue-v0.0.12-aarch64-unknown-linux-musl.tar.gz
Debian, Ubuntu residue_amd64.deb, residue_arm64.deb
Fedora, RHEL residue.x86_64.rpm, residue.aarch64.rpm
Arch Linux residue-x86_64.pkg.tar.zst, residue-aarch64.pkg.tar.zst
FreeBSD, x86_64 residue-v0.0.12-x86_64-unknown-freebsd.tar.gz
FreeBSD, aarch64 residue-v0.0.12-aarch64-unknown-freebsd.tar.gz

Check a download: shasum -a 256 -c SHA256SUMS --ignore-missing, then gh attestation verify FILE --repo whiteheap/residue-cli.

The Mac app: https://github.com/whiteheap/residue-ui/releases

Launch hardening: residue keeps more worktrees that are in use or hold what it cannot rebuild, stops on a mistyped command or an old Git before anything runs, replaces only the service you looked at, and comes with a skill for coding agents.

  • An .env inside a build folder is kept. An environment file inside an ignored directory a rule matches, such as build/.env or dist/.env.production, now keeps its worktree, as an ignored .env at the top always has: ignored: build/.env. Templates (.env.example) and files in node_modules and virtual environments do not count, and a rule naming the file itself (git config --add residue.regenerable build/.env) lets residue delete it (#158).
  • A bare repository's HEAD branch is kept. In the layout git clone --bare and then a git worktree add per branch makes, the worktree on the branch the bare repository's HEAD names is its main checkout in all but name. The head check now blocks it: on main, the branch the bare repository's HEAD names; kept like a main checkout (#156).
  • residue service uninstall and status take no arguments. residue service uninstall --dry-run uninstalled the service; now any argument but --help is a usage error and nothing runs (#156).
  • A mistyped command is a usage error. residue clean printed could not inspect: clean: No such file or directory and exited 0; now it says residue: clean is not a command, and there is no folder named clean here; commands: scan, explain, remove, restore, stats, watch, service (#156).
  • residue in the home folder scans the usual code folders, as restore does, and says which; it no longer walks the whole home folder. A PATH, ~ included, is scanned as given (#156).
  • An old Git stops service install and watch --once before they start, and the install script before it adds a package repository or asks for a password. A running watch says so once and waits for a newer Git, instead of failing every pass (#156).
  • service install and the install script say what automatic cleanup removes, that it deletes without the trash and for how long residue restore brings a worktree back, and how to preview it (#156).
  • A skill for coding agents. npx skills add whiteheap/residue-cli --skill residue -g gives Claude Code, Codex and other agents the steps to remove the worktree they created once a task is done, and what to do when residue keeps it (skills/residue/SKILL.md, #163).
  • service install --expect replaces only the service you looked at. residue service status prints a fingerprint of the service. Given it, install writes nothing and exits 4 when the service changed since, so the Mac app can no longer save over a change a terminal or brew upgrade made while its dialog was open. Installs and uninstalls now run one at a time (#162).
  • A process in a worktree keeps it in use whatever characters its path has, such as é, 漢字, an emoji or a backslash, in the worktree's name or a folder above it. On macOS, residue now matches the names lsof prints escaped. Where it cannot be sure how lsof prints a path, activity is unknown, which keeps the worktree. On FreeBSD, residue reads each process's working directory, executable and open files from the kernel (kern.proc.filedesc), byte for byte, instead of from procstat (#157).
  • On illumos, an open file whose path the kernel cannot give still keeps its worktree in use: residue finds the file by its inode. Where it cannot look, activity is unknown, and the detail names the process. A deleted file does not count (#160).
  • A worktree that holds a very long path is kept. Each probe names a path whole only below a length: 1,004 bytes on macOS, 4,096 on Linux and 1,024 elsewhere. A worktree with a path that long anywhere inside it is now unknown (long_path), so it is never a candidate, and says how long that path is. So is one whose paths would reach that length where residue deletes it, at its hidden name or in the trash, so the plan never lists a worktree that the check after the move then keeps (#165).
  • On FreeBSD, residue also reads the files each process has mapped into memory (kern.proc.vmmap). A file a process holds that the kernel cannot name is placed by its inode, as on illumos: in use when the worktree holds it, unknown (unnamed_file) when residue cannot tell. A process running as your user that the kernel will not show makes activity unknown (probe_failed), and the reason names the process, as every one does with security.bsd.unprivileged_proc_debug at 0. A process the kernel shows only to root whatever the settings, such as ssh-agent or the process sshd keeps for each login, is not inspected, as on Linux (#165).
  • A removal Git stopped partway says what is left. When git worktree remove fails after Git has dropped the worktree from its list, the reason says what is left at the hidden name and ends with the removal record's residue restore, instead of a line that moves it back. That restore recreates the worktree and notes the folder left behind, for you to look through and delete (#165).
  • For scripts and integrations (changed from 0.0.11):
    • With no command first, the first PATH, the first argument before -- that is neither an option nor an option's value, exits 2 when it has no / and names nothing that exists (residue clean, residue --json clean); a command there says it goes first (residue --json scan). residue -- clean still scans ./clean.
    • scan with no PATH, run from the home folder or above it, scans the usual code folders there and notes it on stderr (residue: note: scanned only the usual code folders in your home folder (...)); with none of them, or from / with HOME unset or /, it exits 2.
    • service status and service uninstall with any argument but -h or --help exit 2.
    • On a Git older than 2.36, service install exits 1 with residue: git X.Y is too old; residue needs git 2.36 or later, and watch --once exits 1 with <time> residue watch: git X.Y is too old; residue needs git 2.36 or later. A running watch logs <time> residue watch: git X.Y is too old; residue needs git 2.36 or later; checking again every 5 minutes once, keeps running, and once Git is new enough logs git X.Y is new enough now; starting and starts.
    • The head check blocks the worktree on the branch a bare repository's HEAD names (status block), and is unknown when that HEAD cannot be read.
    • service install prints removes:, deletes: and preview: lines before check:. watch --once logs one line when its pass removes, or would remove, nothing, saying why (nothing to remove now: ..., or no removal this pass: each worktree it tried was kept, as logged above).
    • An ignored entry a rule matches is "regenerable": false when it holds an environment file no rule names, and the ignored check blocks: ignored entries not known to be regenerable: build/ (2 files, 18 B; build/.env is an environment file no rule names).
    • The scan hint names the preview first: Automatic cleanup is off. Preview it: residue watch --once --dry-run · turn it on: residue service install.
    • service status prints fingerprint: HEX, 32 lowercase hex digits, after what the service runs and before freed:, also when no service is installed (exit 3); fingerprint: unknown (REASON) when it cannot read the service file. The first line and the log: line are unchanged.
    • service install --expect FINGERPRINT and --expect none are new. When the service is not the one named, install writes nothing, prints one line on stderr (residue service install: the service changed since that fingerprint was read, or another residue release printed it; nothing changed. Read it again: residue service status, or for none, residue service install: a service or its file exists, and --expect none installs only when neither does; nothing changed. See it: residue service status), and exits 4. A value that is neither none nor 32 lowercase hex digits, a missing value or a second --expect exits 2. A service it cannot read to compare, or a folder it cannot lock, exits 1. Without --expect, install is unchanged but for the lock below.
    • service install and service uninstall lock the folder of the service file (~/Library/LaunchAgents, ~/.config/systemd/user). One that finds it locked prints `residue service install: waiting...
Read more

residue v0.0.11

Choose a tag to compare

@github-actions github-actions released this 09 Nov 12:12
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...
Read more

residue v0.0.10

Choose a tag to compare

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

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

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

Details

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

residue v0.0.9

Choose a tag to compare

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

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

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

Details

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

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

residue v0.0.8

Choose a tag to compare

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

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

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

Details

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

residue v0.0.7

Choose a tag to compare

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

residue v0.0.6

Choose a tag to compare

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

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

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

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

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

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

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

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

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

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

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

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

residue v0.0.5

Choose a tag to compare

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

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

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

residue v0.0.4

Choose a tag to compare

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

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

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