Skip to content

feat: gc removes worktrees whose branch is merged - #1133

Merged
janicduplessis merged 4 commits into
mainfrom
feat/1113-gc-merged-worktrees
Sep 25, 2026
Merged

janicduplessis merged 4 commits into
mainfrom
feat/1113-gc-merged-worktrees

Conversation

@janicduplessis

@janicduplessis janicduplessis commented Sep 25, 2026 •

Copy link
Copy Markdown
Collaborator

Description

gc removed a linked worktree only with --worktrees, and only after 7 idle days (DEFAULT_WORKTREE_IDLE_DAYS in packages/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 plain gc --delete removes merged worktrees by default (#1113).

Solution

Every gc without --cache now sweeps Stim-managed linked worktrees (registered project roots and workspace.json roots, as before). A worktree is removable when its branch is merged; --worktrees keeps 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:

  • The default branch comes from origin/HEAD. gc runs git fetch origin +refs/heads/<default>:refs/remotes/origin/<default> once per repository from its source checkout, with a 30 s timeout and GIT_TERMINAL_PROMPT=0. It skips the fetch when that checkout's FETCH_HEAD is less than 10 minutes old, which covers the desktop app's 5-minute gc --json poll and a report followed by --delete. On failure, every worktree of that repository is kept with merge-unknown and the fetch error. Stale refs can only miss a recent merge; they never produce a false merge.
  • Merge commit: HEAD is an ancestor of origin/<default>, not on its first-parent line, and the branch's reflog shows a commit made on it (commit, cherry-pick, rebase or revert) that HEAD contains. A branch with no commit of its own is reported as no commits of its own beyond origin/main and kept. That covers a freshly created worktree, a branch cut from another branch that later merged, and a branch name reused with worktree add -B whose reflog still lists commits from its earlier life. A detached HEAD has no reflog to read and is kept.
  • The two patch-id signals below require the branch to change the tree. A branch whose commits cancel out (no net change beyond origin/main) is kept, and so is one made only of empty commits.
  • Both use git patch-id --verbatim, which, unlike the default patch id and git cherry, keeps whitespace, so "a b" and "ab" do not match. Diffs are piped to it through two new runFile options on the exec wrapper: input for stdin, and untrimmed, so trailing whitespace on a patch's last line survives. The default-branch side is git 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.
  • Rebase merge: the branch has no merge commits, and every commit since the merge base has the verbatim patch id of a default-branch commit. A branch that merged main into itself skips this check, because per-commit patch ids never see the content a merge commit adds.
  • Squash merge: the branch's whole diff since the merge base has the verbatim patch id of a default-branch commit.
  • Anything git cannot answer is 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 remove takes this as a mergedHead option 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.

removeWorktrees re-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 remove itself 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 remove would 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:

  • GitHub PR state is not consulted. A squash merge whose content changed during the merge (conflict resolution, suggested edits) does not match and is kept. A branch fast-forwarded into main is indistinguishable from a fresh branch and is kept too; --worktrees can still remove it once idle.
  • Only the origin remote is used. A repository without origin/HEAD gets a merge-unknown reason with git remote set-head origin --auto as the remedy.
  • Plain 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 polls gc --json every 5 minutes, so with clean worktrees present it causes a background fetch about every 10 minutes. GIT_TERMINAL_PROMPT=0 does not stop an SSH agent that asks for confirmation.

JSON: linkedWorktrees entries gain mergedInto, detail is also set for removable entries, and there are two new reason codes, not-merged and merge-unknown. worktreeSweep keeps its meaning (the idle threshold, null without --worktrees). The guide (cleanup gc, facts gc, agent safety list) and the website (worktrees.md, commands.md) document the signals.

Test plan

  • gc-workspaces.test.ts builds 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; a followup branch cut from the merged branch with no commits; an unmerged pushed branch; a merged but dirty branch; an evil branch that merged an older main with a file that is not on main, before main cherry-picked its commit; a spaced branch adding value.txt as ab (trailing spaces) while main added it as ab; and a reused branch name that had a commit in an earlier life and was reset onto the merged branch with worktree 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 local origin/main is stale until gc fetches. Plain gc --json reports merged, squashed and rebased as merged into origin/main, fresh, followup and reused as no commits of its own beyond origin/main, evil and spaced as unpushed (not merged), open as not-merged and dirty as dirty. gc --delete removes 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 before patch-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.
  • A second real-git test ages FETCH_HEAD past 10 minutes and moves the remote away: the fetch fails, the merged worktree is reported merge-unknown and 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).
  • Classification unit cases cover merged-but-dirty, in use, locked, submodules, the source checkout, and the unpushed exception only when the upstream was deleted.
  • A scratch repository reproduces the string-literal whitespace case ("a b" on the branch, "ab" on main): the verbatim check reports it not merged.
  • Known side effect: the fetch rewrites the source checkout's FETCH_HEAD, as any git fetch there does.
  • Real repositories on this machine, read-only apart from the fetch: the probe reports fix/profile-decode-keychain (PR fix(ios): decode provisioning profiles without touching the keychain #1036, squash-merged, remote branch deleted) as merged with coversUnpushed. 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 reports feat/1018-gc-workspace-reclaim as 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).
  • Built CLI with a scratch STIM_HOME and scratch repositories: after stim worktree warm in two worktrees, stim gc listed the squash-merged one as removable and the fresh one as kept. stim gc --delete removed it through worktree remove and kept the feat/squash branch.
  • pnpm run format:check, lint, build, typecheck, knip and test:runtime pass. pnpm test: 4 tests timed out at 5 s and 12 files tripped the ~/.stim/machine/eas guard from a concurrent stim run while load was 400 to 800. The 15 affected files passed 621/621 when rerun.

Fixes #1113

@janicduplessis
janicduplessis force-pushed the feat/1113-gc-merged-worktrees branch from 66f2f46 to 72a81e6 Compare September 25, 2026 05:00
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
janicduplessis force-pushed the feat/1113-gc-merged-worktrees branch from 75f4b3a to 9800da1 Compare September 25, 2026 05:08
@janicduplessis
janicduplessis merged commit 8eff6db into main Sep 25, 2026
8 checks passed
@janicduplessis
janicduplessis deleted the feat/1113-gc-merged-worktrees branch September 25, 2026 05:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

gc removes worktrees whose branch is merged

1 participant