Skip to content

residue v0.0.8

Choose a tag to compare

@github-actions github-actions released this 08 Nov 02:20
· 70 commits to main since this release
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 remove --json and 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 in warnings and stderr, nothing in errors); and the text line a run ends with, none for a preview, and the residue stats report; 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 in warnings, stats exits 1, nothing waits). Each fails on 0.0.7. .github/smoke.sh checks the freed line and reads the removal and the restore back from stats --json; .github/watch-smoke.sh case 2 checks the log's freed line and that stats counts the removal under watch. 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, with estimate_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 commit 180862f; that repository is archived) as app/, and it is called residue, with the bundle identifier com.whiteheap.residue. It has the CLI's version, from Cargo.toml, and the residue inside it is that release's own: the release joins the binaries of its two macOS archives with lipo and 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 in SHA256SUMS and 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's warnings before 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-run with 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 default 30d, 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 longer residue-app, so the menu bar, the Dock, Cmd-Tab, Activity Monitor and crash reports say residue, in a pnpm tauri dev run too. The residue CLI inside it moved to Contents/MacOS/residue-cli, so the app never runs itself in its place; the copy a save installs for automatic cleanup is still named residue. The app's Cargo package keeps the name residue-app, so cargo install --git ... residue still 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 in docs/screenshots/; the README's "Mac app" section shows the first two and links the third, and app/README.md links 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 remove prints, 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-removed it 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 then Show the other 3 repositories. When automatic cleanup freed space since the app was last open, the Worktrees screen opens with Automatic 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 --git needs 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 excludes app/, and nothing at the root builds it.
  • CI (#39): .github/workflows/app.yml type-checks, lints, formats, runs clippy on and builds the app, with residue built from the same commit, when app/, the CLI's source or that workflow changes. It is not a required check. release.yml runs as a dry run on a pull request that changes it and on gh workflow run release.yml --ref BRANCH: it builds and checks the archives and the DMG and attests and publishes nothing. packages.yml keeps the DMG's line when it rewrites SHA256SUMS.
  • Automatic cleanup chooses how long removal records are kept (#47): residue watch --keep-removed DURATION, with the syntax, default (30d) and limits of remove --keep-removed, passed to each residue remove --yes --idle --no-trash it starts. Until now that child always used 30d. service install --keep-removed DURATION writes it into the service; every service it installs now carries --keep-removed, as it carries --fetch-every. service status shows it on a new line, removal records: kept 12w (--keep-removed), and (--keep-removed, the default) for a service installed before. A service install that replaces a service with another --keep-removed says 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. The watching log line names it. The usage error for --keep-removed outside remove now says it applies to remove and watch.
  • Decision (#47), a record keeps the promise it was written with: each removal writes its --keep-removed into the record's info (keep 7257600 12w), and a run forgets a record only once it is older than both that and its own --keep-removed. Before, a plain residue remove (30d) in a repository forgot a record that a watch --keep-removed 12w removal wrote 31 days earlier, and with it the pins of commits only that record held. A record with no keep line, 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, the restore footer and scan's removed line say so.
  • JSON, additive (#47): each removal record in restore --json (and in scan's removed and remove's forgotten[].entry) gains keep and keep_seconds, null for a record without a keep line.
  • Tests (#47): scripts/contract-check.sh gains record-keep-own (a record written with --keep-removed 12w and 31 days old survives a plain residue remove of another worktree, restore --json shows keep 12w and 30d, 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) and watch-keep-removed (a record written with --keep-removed 1d and two days old is forgotten by a watch --keep-removed 1d removal, whose own record keeps 1d; 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.sh installs with --keep-removed 12w and checks the runs: and removal records: lines and the running watch's log, then that a second install says it goes back to 30d and does. In-crate tests: reading the option back, its validation, a record's keep round 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 gc expired or git reflog delete took. Before, residue read only each entry's new value, and after the removal git gc deleted 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 until git branch -D or git stash drop deletes the reflog, or it expires; and pinning every commit any reflog names would pin other branches' history in each record (AGENTS.md invariant). residue restore there now says that it did not put the HEAD reflog back, and why, on stderr and in restore.notes; before, a restore that pinned nothing dropped the record without a word. docs/platforms-and-testing.md records the swapped expiry defaults and the command that re-checks them. Tests: scripts/contract-check.sh gains record-reflog-reftable-expired and record-reflog-reftable-deleted, and record-reflog-reftable checks 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 restore brings back and for how long, and the stats ledger. docs/assets/render.sh renders 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 after git refs migrate --ref-format=reftable lost the commits only its HEAD reflog held. Restore wrote the saved reflog to logs/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 next git gc deleted 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 no logs/HEAD, says its HEAD reflog was not put back on stderr and in restore.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 HEAD and --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 a logs/HEAD file 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.sh gains record-reflog-migrated, both directions, skipped on a Git without git refs migrate, which came in 2.46, and record-reflog-reftable-stray; both fail on 2c8a36d, where git gc --prune=now lost 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.