Surface affected
Specific surface: internal/operations/clean.go (FindMergedCandidates), internal/git/branch.go (MergedBranches), reached via rimba clean --merged and the installed post-merge hook (internal/hook/hook.go).
rimba version
rimba 1.9.6 (commit 96a2883, go1.26.4)
Environment
- OS: macOS (Darwin 25.5.0, arm64)
- Shell: zsh
- git version: 2.54.0
Reproduction steps
- Install the post-merge hook:
rimba hook install (it runs rimba clean --merged --force on the default branch after a merge).
- Create a fresh worktree from the default branch:
rimba add foo → branch feature/foo at the same commit as main, no commits of its own.
- In a different worktree, do work and merge it into
main (e.g. git merge feature/bar or a PR merge) — this fires the post-merge hook.
- Observe that the still-empty
feature/foo worktree has been removed and its branch force-deleted.
Expected behavior
A freshly-created worktree whose branch has no commits of its own should NOT be considered "merged" and should be left untouched by rimba clean --merged.
Actual behavior
feature/foo is removed and its branch is force-deleted (git branch -D), even though nothing was ever merged from it. With the post-merge hook this happens silently (the hook passes --force, so there is no confirmation prompt).
Root cause
git branch --merged <ref> lists every branch reachable from <ref> — including a brand-new branch whose tip is the base commit (an ancestor, trivially "merged"). FindMergedCandidates (internal/operations/clean.go:70-74) adds any such branch to the removal set with no check for whether the branch has any commits of its own:
for _, e := range git.FilterEntries(entries, mainBranch) {
if mergedSet[e.Branch] { // fresh worktree branch matches here — no own-commits guard
result.Candidates = append(result.Candidates, CleanCandidate{Path: e.Path, Branch: e.Branch})
continue
}
// squash fallback already guards the empty-branch case (branch.go:147-149)
...
}
The squash-merge path (IsSquashMerged, internal/git/branch.go:147-149) already guards this exact case via mergeBase == tip; the plain --merged ancestor path does not.
Proposed fix
Add an own-commits guard to the --merged ancestor path in FindMergedCandidates: before treating a mergedSet hit as a candidate, skip it when the branch tip sits on the base branch's first-parent (mainline) history — i.e. the branch never contributed a commit. This protects fresh/empty worktrees while still removing genuinely merged branches:
- fresh worktree (tip on main's first-parent chain) → skip (protected)
- squash-merged (tip has unique commits) → remove
- merge-commit-merged (tip is the merge's second parent, off mainline) → remove
- fast-forward-merged (tip on mainline) → skip — an accepted, safe false-negative (kept rather than wrongly deleted)
Notes:
- Put the guard in candidate finding, not removal, so
--force (used by the post-merge hook) cannot bypass it.
- Reuse existing helpers where possible (
git.MergeBase, git.IsMergeBaseAncestor in internal/git/sync.go); add a small first-parent containment check in internal/git/.
- Scope to
--merged only. rimba clean --stale can wrongly remove a fresh worktree branched from an old base too, but that is a separate concern and out of scope for this issue.
Regression test
Add a case to internal/operations/clean_test.go (alongside TestFindMergedCandidatesNormalMerge): a worktree whose branch is in git branch --merged output but has no own commits (tip on base's first-parent chain) must NOT appear in result.Candidates. This test fails on main today and passes after the guard.
Surface affected
internal/operations/)internal/git/)cmd/*)Specific surface:
internal/operations/clean.go(FindMergedCandidates),internal/git/branch.go(MergedBranches), reached viarimba clean --mergedand the installedpost-mergehook (internal/hook/hook.go).rimba version
rimba 1.9.6 (commit 96a2883, go1.26.4)
Environment
Reproduction steps
rimba hook install(it runsrimba clean --merged --forceon the default branch after a merge).rimba add foo→ branchfeature/fooat the same commit asmain, no commits of its own.main(e.g.git merge feature/baror a PR merge) — this fires the post-merge hook.feature/fooworktree has been removed and its branch force-deleted.Expected behavior
A freshly-created worktree whose branch has no commits of its own should NOT be considered "merged" and should be left untouched by
rimba clean --merged.Actual behavior
feature/foois removed and its branch is force-deleted (git branch -D), even though nothing was ever merged from it. With the post-merge hook this happens silently (the hook passes--force, so there is no confirmation prompt).Root cause
git branch --merged <ref>lists every branch reachable from<ref>— including a brand-new branch whose tip is the base commit (an ancestor, trivially "merged").FindMergedCandidates(internal/operations/clean.go:70-74) adds any such branch to the removal set with no check for whether the branch has any commits of its own:The squash-merge path (
IsSquashMerged,internal/git/branch.go:147-149) already guards this exact case viamergeBase == tip; the plain--mergedancestor path does not.Proposed fix
Add an own-commits guard to the
--mergedancestor path inFindMergedCandidates: before treating amergedSethit as a candidate, skip it when the branch tip sits on the base branch's first-parent (mainline) history — i.e. the branch never contributed a commit. This protects fresh/empty worktrees while still removing genuinely merged branches:Notes:
--force(used by the post-merge hook) cannot bypass it.git.MergeBase,git.IsMergeBaseAncestorininternal/git/sync.go); add a small first-parent containment check ininternal/git/.--mergedonly.rimba clean --stalecan wrongly remove a fresh worktree branched from an old base too, but that is a separate concern and out of scope for this issue.Regression test
Add a case to
internal/operations/clean_test.go(alongsideTestFindMergedCandidatesNormalMerge): a worktree whose branch is ingit branch --mergedoutput but has no own commits (tip on base's first-parent chain) must NOT appear inresult.Candidates. This test fails onmaintoday and passes after the guard.