Repository navigation
residue v0.0.8
The Mac app, a record of the space residue frees, and one command to install.
- The Mac app. Each release now includes
residue-macos.dmg, residue in a window for macOS 13 or later: a map of where the space is, each worktree's detail, removal and restore, the automatic cleanup settings, and what residue has freed. Download it from https://residue.whiteheap.com/download/macos. It is not notarized yet, so macOS blocks it the first time; the README's Mac app section has the steps to open it (#39, #43). - What residue freed. After each cleanup residue says how much space it freed.
scan,service statusand a new command,residue stats [--json], show the totals for all time and the last 30 and 7 days, by command and by repository. The record stays on this machine (#38). - One command to install.
curl -fsSL https://residue.whiteheap.com/install.sh | shinstalls residue with Homebrew, apt, dnf, pacman or the release archive, and on macOS and Linux turns on automatic cleanup. Running it again upgrades (#30, #52). - Restore brings back more. Automatic cleanup takes
--keep-removedto choose how long removal records are kept (#47). In a reftable repository a removal record keeps the commits the worktree's HEAD reflog held, also aftergit refs migrate(#48, #58).scanprints a recovery line that works for every interrupted removal (#49). - Changes from 0.0.7.
statsis now a command word, soresidue statsno longer scans a folder namedstats;residue scan statsdoes.cargo install --gitneeds the package name:cargo install --locked --git https://github.com/whiteheap/residue --tag v0.0.8 residue.
Details
scanprints a recovery line that works for every interrupted removal (#49). A run killed during the delete can leave a worktree under its hidden name that Git can no longer use, because its.gitfile or its HEAD is gone. Git refuses to move that worktree, butscanstill printedput it back: git worktree unlock ... && git worktree move .... Now it prints only the restore of its record:Git can no longer use it (its .git file is gone), so it cannot move it back; restore says what to clear, then recreates it: residue restore RECORD. The JSONrecoveryis thatresidue restore RECORD, or empty when no record names the worktree; it never deletes anything (AGENTS.md invariant).interruptedentries gainunusable(null, or why Git can no longer use it) andremoval_record(the record's path, or null).residue restorealso gives the steps for a worktree that lost its.gitfile, which it reported only as Git'svalidation failed, and its list says why Git can no longer use one.residue restoreandresidue restore NAMEnever search the whole home folder (#49). Run from the home folder or a folder above it, they look only in the usual code folders that exist in it, the oneswatchuses (~/src,~/Workand the rest), and say which. When the name is in none of them, the message names the folders it searched and says to run the command inside the repository or to pass the record's path. When no code folder exists, both exit 1 and say so. Below the home folder nothing changes. Decision: search the code folders rather than refuse. A removal bywatchwith no ROOT is always in one of them, soresidue restore fix-loginfrom a new terminal, which opens in the home folder, finds it; a refusal would fail that common case. Before, the search read every folder under the home folder, and on macOS that asks for permission to Documents, Desktop and Downloads. #36 had guarded only a path ENTRY.- Tests (#49):
scripts/recovery-check.shchecksscan's text andrecoveryfor a worktree that lost its HEAD, adds a case for one that lost its.gitfile (remove-fails-part-way-dotgit), through to the restore, and one for a restore while the run that left it still runs (remove-fails-part-way-running): restore says to wait for that process and run it again, or, if the PID belongs to another process after a reboot, what to clear, where it printedput it back with:and an empty command.scripts/contract-check.shaddsrestore-name-from-home. Each fails onmainbefore this change. - Install (#30): one command installs residue and, on macOS and Linux, turns on automatic cleanup:
curl -fsSL https://residue.whiteheap.com/install.sh | sh. The script (site/public/install.sh, POSIX sh) uses Homebrew on macOS, the signed apt, dnf or pacman repository on Linux when it can act as root, and otherwise the release archive, checked againstSHA256SUMSand, whenghis logged in, its build-provenance attestation; it pins the SHA-256 of the signing key it adds. Then it runsresidue service install, leaves an existing service alone, and prints what it did and how to undo it.--no-service,--archive,--prefix,--versionand--dry-run. Running it again upgrades. CI runs it through Homebrew, apt, dnf, pacman and the archive (macOS, Ubuntu, Alpine, FreeBSD). README anddocs/installation.mdlead with it; Homebrew and the manual channels remain. - Install (#52): right after a release, when its FreeBSD archives are still being built, the script installs the newest release that has this machine's archive, names both versions and says to run it again later; before, it stopped with
curl: (22) ... 404. It decides by the release'sSHA256SUMSand finds the earlier release with one GitHub API call; when that call fails, it says the archive is still being built and how to pick a release with--version.--versionstays exact: a release with no archive for this machine is a plain error, never another release. - How much space residue freed (#38). A
removethat deletes or moves something ends with one line:freed about 812 MB (residue has freed about 14.2 GB on this machine since 2026-10-02), or for a move into the trashmoved about 812 MB to the trash, freed once a remove run purges it after 7d (...). A preview, or a run that removes nothing, prints none.scanat a terminal ends with the total and the last seven days once anything was freed. Thewatchlog addsfreed about ...after each removal, with the UTC date, like the log's own times. On macOS and Linux,service statushas afreed:line: what automatic cleanup freed in the last seven days and when it last removed a worktree. A new command,residue stats [--json], shows all time, the last 30 and 7 days, each by command (remove, a sweep,watch), by repository, the trash (moved there, and in it now) and restores; the JSON adds twelve weekly totals. - Decision (#38), a ledger per user,
ledger.jsonl:~/Library/Application Support/residue/on macOS, whateverXDG_STATE_HOMEsays, so a terminal, the launchd agent and an app read one file; elsewhere$XDG_STATE_HOME/residue/when it is an absolute path, else~/.local/state/residue/. The folder is mode 0700, the file 0600. One JSON line per event (freed,trashed,restored), appended with onewrite(2)underO_APPENDonce the deletion, move or restore succeeded, so runs at the same time never overwrite each other's lines on a local filesystem (NFS does not promise it). A line residue cannot read is skipped and counted, and each event counts once, by repository and entry. An append to a file whose last line was cut short starts on a line of its own, so only the cut line is lost. residue reads and writes the ledger only as a regular file, opened without waiting: a FIFO, a device or a folder at its path is a warning, never a command that waits forever. A time outside 1970 to 9999 is read as the nearest end. Each size is thereclaimableestimate the removal record keeps; APFS snapshots, clones and hard links can keep some of the space. A move into the trash is not freed until its purge, and a restore does not subtract. Nothing in it leaves the machine (AGENTS.md invariant). Not the removal records alone:--keep-removedforgets them and a restore takes them back, so a total read from them would shrink. - The ledger never affects a removal (AGENTS.md invariant): nothing reads it to decide anything, and a ledger residue cannot read or write is a warning,
residue: warning: the stats ledger is not up to date (...); this does not change what was removed, on stderr, inwarningsand instats.error, never inerrorsand never in the exit status.restoreadds it to its notes. - Seeding (#38): before it acts, a
removethat will delete or move something,restore, andscanwithout--jsonadd what the ledger lacks from the removal records and trash entries of the repositories they read, as lines marked"seeded": true: removals by 0.0.7, which wrote removal records and no ledger, a ledger that could not be written, and a run stopped between the deletion and the append. Not seeded: records--keep-removedforgot or a restore took back, removals by 0.0.6 or earlier (which wrote no record), interrupted removals (nothing was deleted), and records whose run may still be going.scan --json,explainandstatsnever write the ledger. - JSON, additive (#38):
removegainsstats, its last field:freed_bytes,freed_worktrees,trashed_bytes,trashed_worktrees,total_freed_bytes,total_freed_worktrees,since,ledgeranderror. A ledger it could not write is also inwarnings. New schema:residue.stats/v1, whose spans, twelve weeks and repositories each split by command (by:remove,sweep,watch,other), with the estimate sentence asestimate_note, for a client to show in residue's words. Scan, explain and restore documents are unchanged. - Changes from 0.0.7 (#38):
statsis now a command word, soresidue statsno longer scans a folder namedstats;residue scan statsdoes. - Tests (#38):
scripts/contract-check.shgains 10 cases:stats --jsonon an empty ledger and on one with a damaged line (skipped and counted); thestatsobject inremove --jsonand the ledger line; a move into the trash (moved, not freed) and its purge (freed,trash_purge); a restore (counted, not subtracted); seeding a lost ledger from the removal records and trash entries, once across two scans; a ledger residue cannot write (the worktree still removed, exit 0, the warning inwarningsand stderr, nothing inerrors); and the text line a run ends with, none for a preview, and theresidue statsreport; a ledger whose last line was cut short (the removal's line still counts, only the cut one is skipped); and a FIFO at the ledger path (the removal goes ahead with the warning inwarnings,statsexits 1, nothing waits). Each fails on 0.0.7..github/smoke.shchecks the freed line and reads the removal and the restore back fromstats --json;.github/watch-smoke.shcase 2 checks the log's freed line and thatstatscounts the removal underwatch. Both now run in a scratch home folder, so they never write the caller's ledger. In-crate tests: a line's round trip, a long line dropping the worktree name first, damaged lines skipped and counted, an event counted once, totals by time, command and repository, each week and repository split by command (in the JSON too, withestimate_note), seeding without duplicates, appended lines reading back, an append after a cut line, a FIFO refused, a time out of range kept in range, and the line a run ends with. - The Mac app (#39): every release has
residue-macos.dmg, residue in a window for macOS 13 or later, on Apple silicon and Intel, and https://github.com/whiteheap/residue/releases/latest/download/residue-macos.dmg always serves the latest one. Decision: the app moved here from whiteheap/residue-desktop (its commit180862f; that repository is archived) asapp/, and it is called residue, with the bundle identifiercom.whiteheap.residue. It has the CLI's version, fromCargo.toml, and the residue inside it is that release's own: the release joins the binaries of its two macOS archives withlipoand checks each architecture's UUID against them, so the app and the CLI can no longer drift apart. Its Restore screen lists the trash and the removal records (#31). The DMG is inSHA256SUMSand the provenance attestation with the archives. It is signed ad hoc and not notarized yet; the README's "Mac app" section has the first-launch steps. - The Mac app's Restore screen (#39) lists removal records beside the trash and recreates a worktree from one with
residue restore. Before the click, a record's row names the ignored files that do not come back (not_kept), or says an interrupted removal is moved back; after it, the screen says how the worktree came back: on its branch, with the branch created again, on a new branch beside a rewritten one, or detached. - The Mac app's remove confirmation (#39) lists the removal records the run forgets (
forgotten, older than--keep-removed) before the click, and residue'swarningsbefore and after it; its result counts the records forgotten. - The Mac app's Automatic cleanup preview (#39) is the saved service's pass:
residue watch --once --dry-runwith every option a save keeps, run by the binary a save installs with, so it fetches when the service would. The dialog shows the exact command and whether it fetched. It used to run the bundled residue with--no-fetch. - The Mac app (#39) reads a service's
--keep-removed(#55) like its other options and passes it on as written when folders are saved; Restore and Automatic cleanup say how long removal records last, from the service's value or residue's default30d, and say which. Any other option the app does not know is kept and passed on the same way, so a newer residue's option never blocks or drops on a save; only an argument that is neither an option nor a folder still blocks one. - The Mac app (#39): Back, or Escape, from a worktree's detail returns focus to the row that opened it.
- The Mac app (#39) is called residue wherever macOS names it: its executable is
residue, no longerresidue-app, so the menu bar, the Dock, Cmd-Tab, Activity Monitor and crash reports say residue, in apnpm tauri devrun too. The residue CLI inside it moved toContents/MacOS/residue-cli, so the app never runs itself in its place; the copy a save installs for automatic cleanup is still namedresidue. The app's Cargo package keeps the nameresidue-app, socargo install --git ... residuestill picks the CLI. - The Mac app (#43) draws where the space is: above the Worktrees list, a treemap of worktrees by reclaimable space that opens a worktree's detail or zooms into a repository, and moves by arrow keys; in a worktree's detail, how much of it is reclaimable and what its ignored files hold; on Restore, how long each trash entry has been there against
--keep-trash; on Automatic cleanup, each disk's free space against where cleanup starts and stops. Every chart shows residue's own numbers, marks what residue keeps by shade, never by a verdict's color, and has a text equivalent beside it. Screenshots of the Worktrees, Freed and Restore screens, on example repositories, are indocs/screenshots/; the README's "Mac app" section shows the first two and links the third, andapp/README.mdlinks all three. - The Mac app has a Freed screen (#43), from
residue stats --json: what residue freed on this machine in the last 7 and 30 days, all time and since the app was last open; each of the last twelve weeks as a column, with a table; each command's share; the trash and restores; and residue's estimate note. It is Command-4, and Folders moves to Command-5. - The Mac app's Worktrees summary (#43) shows what residue freed in the last 7 days,
about 86.1 MB, 5 worktrees, with residue's estimate note on hover, and opens the Freed screen. - The Mac app's removal result (#43) ends with the line
residue removeprints,Freed about 18.9 MB and moved about 8.4 MB to the trash, freed once a remove run purges it after 7d (residue has freed about 86.1 MB on this machine since 2026-10-30)., and residue's estimate note. - The Mac app's charts (#43): a hatched tile's label sits on a solid chip, so the hatch never runs through it; white text on a gray "kept" tile has a contrast of 6.0:1 (4.85:1 in light mode), and 9.2:1 on hover, where the hover was 3.3:1; and Back or Escape from a worktree's detail returns focus to the tile that opened it.
- The Mac app's Restore screen (#43) gives each removal record the date residue keeps it at least until,
restorable until at least Dec 8, 2026, 30 days after its removal, from the--keep-removedit was written with (keep_seconds, #47), with a meter of the time passed. A record without that value, and one whose removal did not finish, get no date. - The Mac app (#43) no longer says a removal forgets every record older than its own
--keep-removed. Restore, Automatic cleanup and the remove confirmation now say that each removal keeps the records it writes for at least its--keep-removed, and that no run forgets a record before the value it was written with (#47). So does the README's "Mac app" section, which also gains a line for the Freed screen. - The Mac app (#38) says what residue freed in each repository and what automatic cleanup freed while it was closed. The Freed screen lists each repository from
residue stats --json, most first,~/src/acme-api 3.3 GB, 8 worktrees, last Nov 7, 2026 at 12:51 AM, the first eight and thenShow the other 3 repositories. When automatic cleanup freed space since the app was last open, the Worktrees screen opens withAutomatic cleanup freed about 67.1 MB, 1 worktree, since the app was last open on Nov 8, 2026 at 3:52 AM.and a link to Freed, in a polite status a screen reader reads out. It says nothing on a first open, when automatic cleanup freed nothing, or when the totals went down. Decision: only automatic cleanup's share, never the total: a removal by hand, the app's own included, is the user's own doing, and the Freed screen's "Since the app was last open" has the total. cargo install --gitneeds the package name now:cargo install --locked --git https://github.com/whiteheap/residue --tag vX.Y.Z residue. Cargo searches the whole repository for packages and finds the app's crate too, so without the name it stops with "multiple packages with binaries found". The name works for every earlier tag as well. The CLI's package excludesapp/, and nothing at the root builds it.- CI (#39):
.github/workflows/app.ymltype-checks, lints, formats, runs clippy on and builds the app, with residue built from the same commit, whenapp/, the CLI's source or that workflow changes. It is not a required check.release.ymlruns as a dry run on a pull request that changes it and ongh workflow run release.yml --ref BRANCH: it builds and checks the archives and the DMG and attests and publishes nothing.packages.ymlkeeps the DMG's line when it rewritesSHA256SUMS. - Automatic cleanup chooses how long removal records are kept (#47):
residue watch --keep-removed DURATION, with the syntax, default (30d) and limits ofremove --keep-removed, passed to eachresidue remove --yes --idle --no-trashit starts. Until now that child always used30d.service install --keep-removed DURATIONwrites it into the service; every service it installs now carries--keep-removed, as it carries--fetch-every.service statusshows it on a new line,removal records: kept 12w (--keep-removed), and(--keep-removed, the default)for a service installed before. Aservice installthat replaces a service with another--keep-removedsays so:The service it replaced kept removal records 12w (--keep-removed). This one keeps new ones 30d; records already written keep their own. Pass --keep-removed 12w to keep the old value.Thewatchinglog line names it. The usage error for--keep-removedoutsideremovenow says it applies toremoveandwatch. - Decision (#47), a record keeps the promise it was written with: each removal writes its
--keep-removedinto the record'sinfo(keep 7257600 12w), and a run forgets a record only once it is older than both that and its own--keep-removed. Before, a plainresidue remove(30d) in a repository forgot a record that awatch --keep-removed 12wremoval wrote 31 days earlier, and with it the pins of commits only that record held. A record with nokeepline, written by 0.0.7 or earlier, has only the run's value, as before, and 0.0.7 reads a record with one, since it skips keys it does not know. No record is forgotten sooner than before.residue --help,watch --help, therestorefooter andscan's removed line say so. - JSON, additive (#47): each removal record in
restore --json(and inscan'sremovedandremove'sforgotten[].entry) gainskeepandkeep_seconds, null for a record without akeepline. - Tests (#47):
scripts/contract-check.shgainsrecord-keep-own(a record written with--keep-removed 12wand 31 days old survives a plainresidue removeof another worktree,restore --jsonshowskeep12wand30d, and at 85 days the next plain remove forgets it; fails on 9fcacb2, the first build of this PR, where the plain remove forgot it) andwatch-keep-removed(a record written with--keep-removed 1dand two days old is forgotten by awatch --keep-removed 1dremoval, whose own record keeps1d; fails on main at cadf019, exit 2, and on a build that parses the option but does not pass it to the child)..github/service-smoke.shinstalls with--keep-removed 12wand checks theruns:andremoval records:lines and the running watch's log, then that a second install says it goes back to30dand does. In-crate tests: reading the option back, its validation, a record'skeepround trip, a record without one, malformed ones, and the larger-of-two rule. - Removal records in a reftable repository (#48): the reflog pins now keep two more kinds of commit the worktree's HEAD reflog held. One is the oldest entry's old value, which Git reads as
HEAD@{N}: Git 2.50 to 2.56 expire every entry older than 30 days, so the commit before an amend or a rebase can end up there. The other is any commit the worktree's reflogs name that the main worktree's do not, such as one whose own entry, the one that moved HEAD to it,git gcexpired orgit reflog deletetook. Before, residue read only each entry's new value, and after the removalgit gcdeleted those commits. Decision: residue does not pin such an old value when another reflog, such as a branch's, also names it. No Git command prints it, so residue cannot tell it from that reflog's own history; that reflog keeps it untilgit branch -Dorgit stash dropdeletes the reflog, or it expires; and pinning every commit any reflog names would pin other branches' history in each record (AGENTS.md invariant).residue restorethere now says that it did not put the HEAD reflog back, and why, on stderr and inrestore.notes; before, a restore that pinned nothing dropped the record without a word.docs/platforms-and-testing.mdrecords the swapped expiry defaults and the command that re-checks them. Tests:scripts/contract-check.shgainsrecord-reflog-reftable-expiredandrecord-reflog-reftable-deleted, andrecord-reflog-reftablechecks the note; each fails on 0.0.7. - README (#61): it opens the way the site does: a hero image with the headline (light and dark), the GitHub description's second sentence, the install command, and a how-it-works chart that names every path a worktree can take: who starts residue, the 15 checks by family, the four verdicts, a removal in order with its delete and trash branches, what
residue restorebrings back and for how long, and the stats ledger.docs/assets/render.shrenders every PNG from its SVG at twice its size, in IBM Plex Mono, with headless Chrome; the site shows the same chart. - Restore after
git refs migrate(#58): a worktree removed while the repository kept its refs in files and restored aftergit refs migrate --ref-format=reftablelost the commits only its HEAD reflog held. Restore wrote the saved reflog tologs/HEAD, a file Git does not read in a reftable repository, then read that same file back to decide the worktree held those commits, so it dropped the record and its reflog refs, and the nextgit gcdeleted the commits. Restore now goes by how the repository keeps its refs when restore runs, not when the record was written. In reftable it writes nologs/HEAD, saysits HEAD reflog was not put backon stderr and inrestore.notes, and keeps a record that holds a saved reflog, with its refs, until--keep-removed. Before it drops any record, it asks Git which commits the restored worktree's HEAD reflog names (git rev-list --walk-reflogs HEADand--single-worktree --reflog), never the file. A record from a reftable removal restored after a migration back to files stays, as before. A removal in reftable no longer copies or reads alogs/HEADfile it finds there, such as the one an earlier restore wrote after the migration: Git does not read it, and residue read it in place of Git's reflog, so commits made in the restored worktree went unpinned. Tests:scripts/contract-check.shgainsrecord-reflog-migrated, both directions, skipped on a Git withoutgit refs migrate, which came in 2.46, andrecord-reflog-reftable-stray; both fail on 2c8a36d, wheregit gc --prune=nowlost the commit after the migration and the removal copied the stray file.
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.