Repository navigation
residue v0.0.5
Deletes what you saw, or nothing, and makes a scheduled sweep reversible. Until 0.0.4, remove re-ran the checks right before acting, but it compared the verdict, HEAD, branch, lock and the set of ignored entries, not what they held. Measured with scripts/race-check.sh against the 0.0.4 release binary: a file added inside node_modules/ while the prompt waited was deleted with the worktree (exit 0). And a scheduled sweep that took a paused agent's worktree took it for good.
- Decision, the plan is a record: for each worktree it now keeps HEAD, the branch, the lock, the staged, modified and untracked files, the file count, size and newest modification time of every ignored entry, and the modification times of the worktree's
indexandlogs/HEAD. Right before acting, residue compares a fresh record with it and skips on any difference, with a reason that names the difference:node_modules/ changed since you confirmed (1,204 -> 1,388 files),HEAD changed since you confirmed (a82c15ee7687 -> 55c48105f23a),changed since you confirmed: 1 untracked. Under--yesthe reason sayssince the plan.logs/HEADis fine here although--idlemust not use it: agit gcbetween plan and removal only makes residue skip. - Decision, move aside before deleting: after the re-check,
git worktree moverenames the worktree to a hidden sibling,.NAME.residue-removing-PID, and residue locks it with the reasonresidue: removing.git worktree moveis one rename by Git that updates only this worktree's record, and it refuses another filesystem instead of copying; residue skips a worktree that is a mount point. Notmvplusgit worktree repair:repairrewrites the records of every worktree of the repository. At the new path residue checks again: Git lists it there with the planned HEAD, branch and residue's lock, a new process snapshot sees nobody inside it, and the record still matches. Then it unlocks it andgit worktree remove, without--force, deletes it. On any difference or failure it moves the worktree back and unlocks it (...; it is back in place). This narrows the window between the last check and the deletion; it does not close it. From that check until Git finishes, a process that opens the hidden directory by its new name, or one the probe cannot see, can still write into it, and the write is deleted with it. The README says so. - A run killed partway leaves the worktree under its hidden name, still registered.
scanlists it underinterrupted removalswith the one command that puts it back (git -C MAIN worktree unlock HIDDEN && git -C MAIN worktree move HIDDEN ORIGINAL, orgit worktree remove HIDDENwhen the directory is gone), and says when the PID that moved it still runs. Neither a sweep norremove PATHtouches such a worktree. Scan JSON: each repository gainsinterrupted, andsummarygainsinterrupted_removals. remove --trashmoves each worktree into a trash instead of deleting it, and--idleturns it on, so a scheduled sweep is reversible;--no-trashdeletes at once. Decision, where:residue-trash/in the repository's Git common directory (.git/residue-trash/NAME-STAMP/). A move there is one rename on the repository's filesystem, where linked worktrees usually live; every worktree of the repository finds it;git statusnever shows it; and it goes away with the repository. A worktree on another filesystem is refused, never copied. Each entry holds the worktree and a record,info, with its original path, branch, HEAD, time and size. The worktree stays registered and locked (residue: in trash), so restore and purge are Git commands; its branch stays checked out there until then.residue restorelists the trash of the repositories at or under the current directory;residue restore ENTRY(the entry name, the worktree's name or branch, or a path) moves it back withgit worktree moveand drops the entry. JSON schemaresidue.restore/v1.- Every
removerun purges the trash entries older than--keep-trash DURATION(default7d) in the repositories it covers, as part of the plan you confirm: each entry is checked again, without--idle(the trash's clock is--keep-trash), and deleted withgit worktree removewithout--force. The purge is what frees the disk, and the README says so. Remove JSON gainstrash(enabled,keep,keep_seconds),purged, andtrash_entryon each result; the resulttrashedis new. Scan JSON gains each repository'strashandsummary.trash_entries. A sweep fails (exit 3) when an expired entry it meant to purge was skipped or failed, or a trash record could not be read; an entry kept because a check failed does not fail it. - README: "The trash", and "Run it on a schedule" with
--keep-trash 7din the command, the launchd plist and the systemd service. - JSON: each worktree's
rule_sourcesis now its full effective list:--regenerable(command_line), the residue config file (residue_config), then its repository's Git config rules in the order Git reads them. Rules add up, so a duplicate applies once and its first entry is the source that wins. The top-levelrule_sourcesstays the run-wide subset, now in the same order (command_linefirst). Before, a worktree listed only its Git config rules, so an agent judging one worktree had to merge two lists, and with Git config rules only, the top-level[]read as "no rules". The schemas stay at v1. scripts/race-check.shdrives the release binary's prompt under a pseudo-terminal, changes the worktree while the prompt waits (an untracked file, a modified tracked file, a file in an ignored regenerable directory, a new commit, with and without--trash), answers yes, and checks for exit 3, achanged since you confirmedreason, and the worktree still in place; two control cases must be removed or trashed. CI runs it on macOS and both Linux runners.- Fix: a stale worktree in a sweep's
keptlist now says why it is stale. residue watch ROOT...: stays running and removes a worktree when its work landed (its upstream branch was deleted and it has been unchanged for--grace, default 1h) or when the disk is low (below--min-free, default 10%: worktrees unchanged for--idle, default 2d, largest first, until free space is 5 points above). No schedule: it wakes on changes underrefs/remotes,packed-refsandreftable(inotify on Linux, kqueue on macOS and the BSDs), on the moment a waiting worktree passes its wait, and on a filesystem dropping below the threshold. Decision:watchdeletes nothing itself. Every removal is aresidue remove --yes --idle --no-trashchild process, so it makes only removalsremovewould make. Decision, no trash:--idleturns the trash on, andwatchturns it off, because freeing the disk is its job, a trashed worktree keeps every byte until a later run purges it, and nothing would purge the trash of a repository wherewatchremoves nothing more. Each of its runs purges that repository's trash entries older than 7 days, and the log names each one.--dry-runlogs instead,--onceacts once. Onewatchper user (a lock in$XDG_STATE_HOME/residue).residue service install|status|uninstall: runsresidue watchfrom login as a launchd agent on macOS or a systemd user service on Linux. Under Homebrew it starts the stablebin/residuelink, so it survivesbrew upgrade.- FreeBSD: an activity probe, from the base system's
procstat -f. Before, FreeBSD had no probe, so nothing was ever a candidate. A packaged lsof is not used: on FreeBSD 14 it listed processes without resolving their working directories, so a worktree in use came back as a candidate. The procstat parser fails closed: a line it cannot read makes activityunknown. - residue builds on illumos and Solaris, which have no
tm_gmtoff. NetBSD, OpenBSD, DragonFly and illumos build but still have no probe, so nothing there is a candidate. - CI runs
.github/smoke.shon macOS, Linux x86_64 and arm64, and FreeBSD 14 as root and as an ordinary user: a clean candidate, a kept.env, a worktree a process is using, and a removal that keeps the branch. - Signed dnf and pacman repositories next to the apt one, at https://whiteheap.github.io/residue/rpm and https://whiteheap.github.io/residue/arch:
dnf install residueon Fedora and RHEL,pacman -S residueon Arch and Omarchy, anddnf upgradeandpacman -Syukeep residue current. Every release updates all three. The.rpmfiles in the dnf repository are signed, and one key signs everything (packaging/signing-key.asc).