feat: gc removes worktrees whose branch is merged - #1133
Merged
Merged
Conversation
janicduplessis
force-pushed
the
feat/1113-gc-merged-worktrees
branch
from
September 25, 2026 05:00
66f2f46 to
72a81e6
Compare
Plain gc reports every Stim-managed linked worktree whose branch is merged into origin/HEAD, and gc --delete removes it through worktree remove. Merge state comes from git: a merge commit, a rebase merge, or a squash merge detected by patch-id, after one bounded fetch per repository. Unknown state keeps the worktree.
… it merged A branch cut from a merged branch no longer counts as merged, the rebase-merge check skips branches with merge commits, a branch with no net change is not merged, and gc skips the fetch when the checkout fetched in the last 10 minutes.
… checkout Merge detection pipes diffs to git patch-id --verbatim, so a whitespace-only difference no longer matches, and writes no synthetic commit. worktree remove keeps the checkout when its HEAD moves while devices are reclaimed.
…t HEAD contains Patch text reaches git patch-id untrimmed, a reflog commit from an earlier life of a reused branch name no longer counts, renames keep both paths in the pathspec, and worktree remove snapshots HEAD before its final inspection.
janicduplessis
force-pushed
the
feat/1113-gc-merged-worktrees
branch
from
September 25, 2026 05:08
75f4b3a to
9800da1
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
gcremoved a linked worktree only with--worktrees, and only after 7 idle days (DEFAULT_WORKTREE_IDLE_DAYSinpackages/stim-cli/src/commands/gc/worktrees.ts). Agent work ends when its branch merges, often within hours, so finished worktrees pile up. The maintainer decided that plaingc --deleteremoves merged worktrees by default (#1113).Solution
Every
gcwithout--cachenow sweeps Stim-managed linked worktrees (registered project roots andworkspace.jsonroots, as before). A worktree is removable when its branch is merged;--worktreeskeeps the idle rule as a second reason. Each entry reports why it is removed (merged into origin/main,idle 9d) or kept.Merge detection (
src/workspace/merge-state.ts) uses git only:origin/HEAD. gc runsgit fetch origin +refs/heads/<default>:refs/remotes/origin/<default>once per repository from its source checkout, with a 30 s timeout andGIT_TERMINAL_PROMPT=0. It skips the fetch when that checkout'sFETCH_HEADis less than 10 minutes old, which covers the desktop app's 5-minutegc --jsonpoll and a report followed by--delete. On failure, every worktree of that repository is kept withmerge-unknownand the fetch error. Stale refs can only miss a recent merge; they never produce a false merge.origin/<default>, not on its first-parent line, and the branch's reflog shows a commit made on it (commit,cherry-pick,rebaseorrevert) that HEAD contains. A branch with no commit of its own is reported asno commits of its own beyond origin/mainand kept. That covers a freshly created worktree, a branch cut from another branch that later merged, and a branch name reused withworktree add -Bwhose reflog still lists commits from its earlier life. A detached HEAD has no reflog to read and is kept.no net change beyond origin/main) is kept, and so is one made only of empty commits.git patch-id --verbatim, which, unlike the default patch id andgit cherry, keeps whitespace, so"a b"and"ab"do not match. Diffs are piped to it through two newrunFileoptions on the exec wrapper:inputfor stdin, anduntrimmed, so trailing whitespace on a patch's last line survives. The default-branch side isgit log -p --no-merges <base>..origin/<default> -- <files the branch changes>(listed with--no-renames, so a rename keeps both paths), which also keeps the output small.merge-unknown, never merged.A squash- or rebase-merged branch whose remote branch was deleted has commits that no remote-tracking ref reaches, so the existing unpushed check would block it. When the patch-id proof holds and the branch's upstream is
[gone], those commits do not block removal, because their content is on the default branch.worktree removetakes this as amergedHeadoption and applies it only while HEAD still equals that commit. The branch is kept, because it still has unique commits. All other checks are unchanged: dirty (untracked files count), unpushed, submodules, locked, in use, source checkout.removeWorktreesre-checks, under the removal locks, that a merged worktree's HEAD has not moved since the report (idle worktrees keep their idleness re-check).worktree removeitself now also compares HEAD from just before its final inspection with HEAD after it reclaims devices, which can take a while, and keeps the checkout when HEAD moved.git worktree removewould otherwise delete a clean detached checkout whose only reference is a new commit. Without--worktrees, the report leaves out the source checkout and non-git roots, which only mean something for the idle sweep.Trade-offs:
--worktreescan still remove it once idle.originremote is used. A repository withoutorigin/HEADgets amerge-unknownreason withgit remote set-head origin --autoas the remedy.gc, including the dry run, now fetches the default branch of each repository that has a worktree whose verdict depends on merge state, at most once per 10 minutes. A worktree already kept for another reason (dirty, in use, ...) triggers no fetch. The desktop app pollsgc --jsonevery 5 minutes, so with clean worktrees present it causes a background fetch about every 10 minutes.GIT_TERMINAL_PROMPT=0does not stop an SSH agent that asks for confirmation.JSON:
linkedWorktreesentries gainmergedInto,detailis also set for removable entries, and there are two new reason codes,not-mergedandmerge-unknown.worktreeSweepkeeps its meaning (the idle threshold,nullwithout--worktrees). The guide (cleanup gc,facts gc,agentsafety list) and the website (worktrees.md,commands.md) document the signals.Test plan
gc-workspaces.test.tsbuilds real git repositories with a bare remote and these worktrees: a branch merged with a merge commit; a two-commit branch squash-merged after main moved on; a one-commit branch cherry-picked onto main (a rebase merge); a fresh branch; afollowupbranch cut from the merged branch with no commits; an unmerged pushed branch; a merged but dirty branch; anevilbranch that merged an older main with a file that is not on main, before main cherry-picked its commit; aspacedbranch addingvalue.txtasab(trailing spaces) while main added it asab; and areusedbranch name that had a commit in an earlier life and was reset onto the merged branch withworktree add -B. The squashed, rebased, evil and spaced branches have their remote branch deleted and their tracking ref removed. The merges happen in a second clone, so localorigin/mainis stale until gc fetches. Plaingc --jsonreports merged, squashed and rebased asmerged into origin/main, fresh, followup and reused asno commits of its own beyond origin/main, evil and spaced asunpushed(not merged), open asnot-mergedand dirty asdirty.gc --deleteremoves exactly the merged, squashed and rebased worktrees and keeps the squashed branch. Each of these cases was checked against the bug it guards. With stdout trimmed beforepatch-id, spaced is reported merged. Without the merge-commit guard, evil is reported merged. When the reflog check ignores ancestry, reused is reported merged. When the check relies on ancestry alone, followup is reported merged. In each case the test fails.FETCH_HEADpast 10 minutes and moves the remote away: the fetch fails, the merged worktree is reportedmerge-unknownand not removed, even though local refs already contain the merge. With the remote back, a commit pushed from the worktree while gc is running is caught by the HEAD re-check (Kept the worktree ...: its HEAD moved since gc checked it)."a b"on the branch,"ab"on main): the verbatim check reports it not merged.FETCH_HEAD, as anygit fetchthere does.fix/profile-decode-keychain(PR fix(ios): decode provisioning profiles without touching the keychain #1036, squash-merged, remote branch deleted) as merged withcoversUnpushed. It reports the open PRs fix: refuse --eas-profile when eas-cli predates build:download --build-id #1129 and fix: report the public Metro origin on remote Android and skip adb-only steps #1130 as not merged. It reportsfeat/1018-gc-workspace-reclaimas not merged: feat(gc): reclaim workspace build outputs, orphaned workspace dirs and finished worktrees #1021 merged a different head (eac0d2b65) than the local branch (c55d56c35).STIM_HOMEand scratch repositories: afterstim worktree warmin two worktrees,stim gclisted the squash-merged one as removable and the fresh one as kept.stim gc --deleteremoved it throughworktree removeand kept thefeat/squashbranch.pnpm run format:check,lint,build,typecheck,knipandtest:runtimepass.pnpm test: 4 tests timed out at 5 s and 12 files tripped the~/.stim/machine/easguard from a concurrent stim run while load was 400 to 800. The 15 affected files passed 621/621 when rerun.Fixes #1113