From b0504f95dbe21ae92b3b9dccb3dc80b53c299e87 Mon Sep 17 00:00:00 2001 From: Nathan Nutter Date: Sun, 4 Oct 2026 18:40:36 -0500 Subject: [PATCH 1/2] Add regression test for removing in-sync main worktree A fresh worktree on main that is in sync with origin/main is reported as not merged, so remove refuses to delete it. The test creates main and removes it immediately, reproducing the failure. --- internal/timber/remove_test.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/internal/timber/remove_test.go b/internal/timber/remove_test.go index a98f0a8..1ded7b9 100644 --- a/internal/timber/remove_test.go +++ b/internal/timber/remove_test.go @@ -123,6 +123,17 @@ func TestRemoveWithNoArgsFromSubdirectoryRemovesCurrentWorktree(t *testing.T) { testRepository.assertPathMissing(t, testRepository.worktreePath(branchName)) } +func TestRemoveMainWhenInSyncWithUpstream(t *testing.T) { + t.Parallel() + + testRepository := newTestRepository(t) + require.NoError(t, testRepository.runTimber(t, "create", at(testRepoName, "main")).err) + + result := testRepository.runTimber(t, "remove", at(testRepoName, "main")) + require.NoError(t, result.err, result.stderr) + testRepository.assertPathMissing(t, testRepository.worktreePath("main")) +} + func TestRemoveFailsWhenUnmergedWithoutForce(t *testing.T) { t.Parallel() const branchName = "feature/unmerged" From 69bbeeed3913c85b0306229afbddfbfc9f958b8e Mon Sep 17 00:00:00 2001 From: Nathan Nutter Date: Sun, 4 Oct 2026 18:42:10 -0500 Subject: [PATCH 2/2] Treat an in-sync default branch as merged for remove The self-named upstream guard rejects any branch tracking its own remote ref, which is right for feature branches but wrong for the default branch tracking itself: main contained in origin/main has nothing unpushed to lose, so removing its worktree is safe. --- internal/timber/repository.go | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/internal/timber/repository.go b/internal/timber/repository.go index c8cbbb5..d5eb511 100644 --- a/internal/timber/repository.go +++ b/internal/timber/repository.go @@ -62,8 +62,11 @@ func (x *Repository) branchExists(branchName string) (bool, error) { func (x *Repository) branchMergedToUpstream(branchName string, branchRef referenceName, upstreamRef referenceName) (bool, error) { // A self-named upstream (foo tracking origin/foo) is trivially // contained after a push; it says nothing about landing on the - // upstream branch, so never treat it as merged. - if isSelfNamedUpstream(branchName, upstreamRef) { + // upstream branch, so never treat it as merged. The default branch + // tracking itself (main tracking origin/main) is the exception: its + // upstream is the landing branch, so containment means everything + // local is pushed and safe to remove. + if isSelfNamedUpstream(branchName, upstreamRef) && !x.isDefaultUpstream(upstreamRef) { return false, nil } @@ -89,6 +92,16 @@ func (x *Repository) branchMergedToUpstream(branchName string, branchRef referen return false, err } +// isDefaultUpstream reports whether ref is the remote-tracking ref of the +// repository's default branch (for example refs/remotes/origin/main). +func (x *Repository) isDefaultUpstream(ref referenceName) bool { + defaultBranch, err := x.remoteHeadBranch() + if err != nil { + return false + } + return string(ref) == remoteRefPrefix+defaultBranch +} + func (x *Repository) branchStillExists(branchRef referenceName) (bool, error) { _, err := x.git("show-ref", "--verify", "--quiet", string(branchRef)) if err == nil {