Repository: git-town/git-town

Issue:
syncing a branch with deleted remote tries to check out main even if that is in another worktree
### Steps to reproduce

- Have a repo with this lineage:

  ```
  main
   \
    feature-1
   \
    feature-2
  ```

- Check out `feature-1`
- Check out `main` in another branch
- delete the remote of `feature-1` at the remote
- run `git sync`


### Command output

```shell
[feature-1] git fetch --prune --tags
From /tmp/13703270/result_1/origin
 - [deleted]         (none)     -> origin/feature-1

[feature-1] git checkout main
fatal: 'main' is already used by worktree at '/tmp/13703270/result_1/development_worktree'


Error: cannot check out branch "main": exit status 128

To continue after having resolved conflicts, run "git town continue".
To go back to where you started, run "git town undo".
To continue by skipping the current branch, run "git town skip".
```

### Additional information

- it should check out `feature-2`
- if there is no branch to check out, it should tell me that

Hints from issue comments:
@kevgo: The problem is that when Git Town checks out the parent or main branch when deleting a branch whose remote was shipped.

This isn't ideal, because a branch whose parent was deleted is most likely shipped. Shippable branches have no parent. So the only other considered branch is the main branch, and if that one is unavailable due to being in another worktree, Git Town stops with an error.

A better approach would be to check out any of the available local branches, starting with the immediate descendents.

Solution patch:
diff --git a/features/sync/all_branches/merge_sync_strategy/conflicts/feature_branch_main_branch_conflict/shipped_changes_conflict_with_existing_feature_branch.feature b/features/sync/all_branches/merge_sync_strategy/conflicts/feature_branch_main_branch_conflict/shipped_changes_conflict_with_existing_feature_branch.feature
index 7e97f05eb..04cf1a794 100644
--- a/features/sync/all_branches/merge_sync_strategy/conflicts/feature_branch_main_branch_conflict/shipped_changes_conflict_with_existing_feature_branch.feature
+++ b/features/sync/all_branches/merge_sync_strategy/conflicts/feature_branch_main_branch_conflict/shipped_changes_conflict_with_existing_feature_branch.feature
@@ -32,8 +32,7 @@ Feature: shipped changes conflict with multiple existing feature branches
       | BRANCH | COMMAND                       |
       | alpha  | git commit --no-edit          |
       |        | git push                      |
-      |        | git checkout main             |
-      | main   | git branch -D beta            |
+      |        | git branch -D beta            |
       |        | git checkout gamma            |
       | gamma  | git merge --no-edit --ff main |
     And Git Town prints the error:
diff --git a/features/sync/all_branches/merge_sync_strategy/preserve_git_history/preserve_git_history.feature b/features/sync/all_branches/merge_sync_strategy/preserve_git_history/preserve_git_history.feature
index 606e5a4a8..151e3eb7d 100644
--- a/features/sync/all_branches/merge_sync_strategy/preserve_git_history/preserve_git_history.feature
+++ b/features/sync/all_branches/merge_sync_strategy/preserve_git_history/preserve_git_history.feature
@@ -18,7 +18,7 @@ Feature: preserve the previous Git branch
     Given origin deletes the "previous" branch
     When I run "git-town sync --all"
     Then the current branch is still "current"
-    And the previous Git branch is now "main"
+    And there is now no previous Git branch
 
   Scenario: both branches deleted
     Given origin deletes the "previous" branch
diff --git a/features/sync/all_branches/merge_sync_strategy/prune/cli_flag.feature b/features/sync/all_branches/merge_sync_strategy/prune/cli_flag.feature
index bce3458bb..ba8a129e9 100644
--- a/features/sync/all_branches/merge_sync_strategy/prune/cli_flag.feature
+++ b/features/sync/all_branches/merge_sync_strategy/prune/cli_flag.feature
@@ -26,17 +26,15 @@ Feature: prune enabled via CLI
       |           | git checkout feature-1                            |
       | feature-1 | git merge --no-edit --ff main                     |
       |           | git merge --no-edit --ff origin/feature-1         |
-      |           | git checkout main                                 |
-      | main      | git push origin :feature-1                        |
-      |           | git branch -D feature-1                           |
       |           | git checkout feature-2                            |
-      | feature-2 | git merge --no-edit --ff main                     |
+      | feature-2 | git push origin :feature-1                        |
+      |           | git branch -D feature-1                           |
+      |           | git merge --no-edit --ff main                     |
       |           | git merge --no-edit --ff origin/feature-2         |
-      |           | git checkout main                                 |
-      | main      | git push origin :feature-2                        |
-      |           | git branch -D feature-2                           |
       |           | git checkout feature-3                            |
-      | feature-3 | git merge --no-edit --ff main                     |
+      | feature-3 | git push origin :feature-2                        |
+      |           | git branch -D feature-2                           |
+      |           | git merge --no-edit --ff main                     |
       |           | git push                                          |
       |           | git push --tags                                   |
     And this lineage exists now
diff --git a/features/sync/current_branch/feature_branch/merge_sync_strategy/prune/cli_flag.feature b/features/sync/current_branch/feature_branch/merge_sync_strategy/prune/cli_flag.feature
index 6a12baa05..46d86961b 100644
--- a/features/sync/current_branch/feature_branch/merge_sync_strategy/prune/cli_flag.feature
+++ b/features/sync/current_branch/feature_branch/merge_sync_strategy/prune/cli_flag.feature
@@ -24,10 +24,9 @@ Feature: prune enabled via CLI
       |           | git checkout feature-1                            |
       | feature-1 | git merge --no-edit --ff main                     |
       |           | git merge --no-edit --ff origin/feature-1         |
-      |           | git checkout main                                 |
-      | main      | git push origin :feature-1                        |
-      |           | git branch -D feature-1                           |
       |           | git checkout feature-2                            |
+      | feature-2 | git push origin :feature-1                        |
+      |           | git branch -D feature-1                           |
     And this lineage exists now
       """
       main
diff --git a/features/sync/current_branch/feature_branch/merge_sync_strategy/worktree/current_branch_remote_gone_and_main_in_other_worktree.feature b/features/sync/current_branch/feature_branch/merge_sync_strategy/worktree/current_branch_remote_gone_and_main_in_other_worktree.feature
new file mode 100644
index 000000000..aaea821dc
--- /dev/null
+++ b/features/sync/current_branch/feature_branch/merge_sync_strategy/worktree/current_branch_remote_gone_and_main_in_other_worktree.feature
@@ -0,0 +1,28 @@
+Feature: sync a branch whose branch is gone while main is active in another worktree
+
+  Background:
+    Given a Git repo with origin
+    And the branches
+      | NAME      | TYPE    | PARENT | LOCATIONS     |
+      | feature-1 | feature | main   | local, origin |
+      | feature-2 | feature | main   | local, origin |
+    And origin deletes the "feature-1" branch
+    And the current branch is "feature-1"
+    And branch "main" is active in another worktree
+    When I run "git-town sync"
+
+  Scenario: result
+    Then Git Town runs the commands
+      | BRANCH    | COMMAND                  |
+      | feature-1 | git fetch --prune --tags |
+      |           | git checkout feature-2   |
+      | feature-2 | git branch -D feature-1  |
+    And the initial commits exist now
+
+  Scenario: undo
+    When I run "git-town undo"
+    Then Git Town runs the commands
+      | BRANCH    | COMMAND                                         |
+      | feature-2 | git branch feature-1 {{ sha 'initial commit' }} |
+      |           | git checkout feature-1                          |
+    And the initial commits exist now
diff --git a/features/sync/current_branch/feature_branch/merge_sync_strategy/worktree/current_branch_remote_gone_no_other_branch_in_worktree.feature b/features/sync/current_branch/feature_branch/merge_sync_strategy/worktree/current_branch_remote_gone_no_other_branch_in_worktree.feature
new file mode 100644
index 000000000..e3699cc10
--- /dev/null
+++ b/features/sync/current_branch/feature_branch/merge_sync_strategy/worktree/current_branch_remote_gone_no_other_branch_in_worktree.feature
@@ -0,0 +1,26 @@
+Feature: sync a branch whose tracking branch is gone while no other branch is available in the current worktree
+
+  Background:
+    Given a Git repo with origin
+    And the branches
+      | NAME      | TYPE    | PARENT | LOCATIONS     |
+      | feature-1 | feature | main   | local, origin |
+    And origin deletes the "feature-1" branch
+    And the current branch is "feature-1"
+    And branch "main" is active in another worktree
+    When I run "git-town sync"
+
+  Scenario: result
+    Then Git Town runs the commands
+      | BRANCH    | COMMAND                  |
+      | feature-1 | git fetch --prune --tags |
+    And Git Town prints the error:
+      """
+      there is no other branch to switch to
+      """
+    And the initial commits exist now
+
+  Scenario: undo
+    When I run "git-town undo"
+    Then Git Town runs no commands
+    And the initial commits exist now
diff --git a/features/sync/current_branch/perennial_branch/tracking/deleted.feature b/features/sync/current_branch/perennial_branch/tracking/deleted.feature
index 58681a7b4..49a8d2f81 100644
--- a/features/sync/current_branch/perennial_branch/tracking/deleted.feature
+++ b/features/sync/current_branch/perennial_branch/tracking/deleted.feature
@@ -15,9 +15,10 @@ Feature: sync perennial branch that was deleted at the remote
     Then Git Town runs the commands
       | BRANCH    | COMMAND                  |
       | perennial | git fetch --prune --tags |
+      |           | git checkout feature-1   |
+      | feature-1 | git branch -D perennial  |
       |           | git checkout main        |
-      | main      | git branch -D perennial  |
-      |           | git push --tags          |
+      | main      | git push --tags          |
     And Git Town prints:
       """
       deleted branch "perennial"
diff --git a/internal/cmd/sync/sync_deleted_branch.go b/internal/cmd/sync/sync_deleted_branch.go
index 2d185ea1a..3644cd010 100644
--- a/internal/cmd/sync/sync_deleted_branch.go
+++ b/internal/cmd/sync/sync_deleted_branch.go
@@ -69,7 +69,7 @@ func syncDeletedFeatureBranchProgram(branch gitdomain.LocalBranchName, initialPa
 // deletes the given local branch as part of syncing it
 func syncDeleteLocalBranchProgram(branch gitdomain.LocalBranchName, args BranchProgramArgs) {
 	args.Program.Value.Add(
-		&opcodes.CheckoutParentOrMain{
+		&opcodes.CheckoutDescendentOrOtherIfNeeded{
 			Branch: branch,
 		},
 		&opcodes.BranchLocalDeleteContent{
diff --git a/internal/git/commands.go b/internal/git/commands.go
index 1edba83ed..1b430d0db 100644
--- a/internal/git/commands.go
+++ b/internal/git/commands.go
@@ -95,6 +95,32 @@ func (self *Commands) BranchInSyncWithTracking(querier subshelldomain.Querier, b
 	return branchSHA == trackingSHA, nil
 }
 
+func (self *Commands) BranchesAvailableInCurrentWorktree(querier subshelldomain.Querier) (gitdomain.LocalBranchNames, error) {
+	branches, err := branchesQuery(querier)
+	if err != nil {
+		return gitdomain.LocalBranchNames{}, err
+	}
+	result := gitdomain.LocalBranchNames{}
+	for _, branch := range branches {
+		// Skip symbolic refs
+		if branch.Symref {
+			continue
+		}
+		// Only include local branches
+		if !isLocalRefName(branch.RefName) {
+			continue
+		}
+		// Include branches that are not checked out in other worktrees
+		// A branch is available in the current worktree if:
+		// - It's not checked out anywhere (!branch.Worktree), OR
+		// - It's checked out in the current worktree (branch.Head)
+		if !branch.Worktree || branch.Head {
+			result = append(result, branch.BranchName.LocalName())
+		}
+	}
+	return result, nil
+}
+
 func (self *Commands) BranchesSnapshot(querier subshelldomain.Querier) (gitdomain.BranchesSnapshot, error) {
 	branches, err := branchesQuery(querier)
 	if err != nil {
diff --git a/internal/git/gitdomain/local_branch_names.go b/internal/git/gitdomain/local_branch_names.go
index cccfae2ea..aa794d6dc 100644
--- a/internal/git/gitdomain/local_branch_names.go
+++ b/internal/git/gitdomain/local_branch_names.go
@@ -51,19 +51,21 @@ func (self LocalBranchNames) Contains(branch LocalBranchName) bool {
 	return slices.Contains(self, branch)
 }
 
-// Hoist moves the given needle to the front of the list.
-func (self LocalBranchNames) Hoist(needle LocalBranchName) LocalBranchNames {
+// Hoist returns the given list with the given branches moved to the front,
+// in the order they are given.
+func (self LocalBranchNames) Hoist(branches ...LocalBranchName) LocalBranchNames {
 	result := make(LocalBranchNames, 0, len(self))
-	foundNeedle := false
-	for _, branch := range self {
-		if branch == needle {
-			foundNeedle = true
-		} else {
+	// Add the hoisted branches first, in the order given
+	for _, branch := range branches {
+		if slices.Contains(self, branch) {
 			result = append(result, branch)
 		}
 	}
-	if foundNeedle {
-		result = append(LocalBranchNames{needle}, result...)
+	// Add the remaining branches in their original order
+	for _, branch := range self {
+		if !slices.Contains(branches, branch) {
+			result = append(result, branch)
+		}
 	}
 	return result
 }
diff --git a/internal/messages/en.go b/internal/messages/en.go
index bf3f12f72..7e182d69f 100644
--- a/internal/messages/en.go
+++ b/internal/messages/en.go
@@ -47,6 +47,7 @@ const (
 	BranchIsNowPrototype            = "branch %q is now a prototype branch\n"
 	BranchLocalProblem              = "cannot determine whether the local branch %q exists: %w"
 	BranchLocalSHAProblem           = "cannot determine SHA of local branch %q: %w"
+	BranchNotAvailable              = "there is no other branch to switch to"
 	BranchNotInSyncWithParent       = `branch %q is not in sync with its parent, please run "git town sync" and try again`
 	BranchOtherWorktree             = `branch %q is active in another worktree`
 	BranchParentChanged             = "branch %q is now a child of %q"
diff --git a/internal/vm/opcodes/all.go b/internal/vm/opcodes/all.go
index 388dc3b67..4f5c2c88b 100644
--- a/internal/vm/opcodes/all.go
+++ b/internal/vm/opcodes/all.go
@@ -34,11 +34,12 @@ func All() []shared.Opcode {
 		&ChangesDiscard{},
 		&ChangesStage{},
 		&ChangesUnstageAll{},
+		&CheckoutDescendentOrOtherIfNeeded{},
+		&CheckoutDescendentOrOther{},
 		&CheckoutFirstExisting{},
 		&CheckoutHistoryPreserve{},
 		&CheckoutIfExists{},
 		&CheckoutIfNeeded{},
-		&CheckoutParentOrMain{},
 		&CheckoutUncached{},
 		&Checkout{},
 		&CherryPickAbort{},
diff --git a/internal/vm/opcodes/branch_delete_if_empty_at_runtime.go b/internal/vm/opcodes/branch_delete_if_empty_at_runtime.go
index 7ede5da4f..c9c3543ed 100644
--- a/internal/vm/opcodes/branch_delete_if_empty_at_runtime.go
+++ b/internal/vm/opcodes/branch_delete_if_empty_at_runtime.go
@@ -21,7 +21,7 @@ func (self *BranchDeleteIfEmptyAtRuntime) Run(args shared.RunArgs) error {
 	}
 	if !hasUnmergedChanges {
 		args.PrependOpcodes(
-			&CheckoutParentOrMain{
+			&CheckoutDescendentOrOther{
 				Branch: self.Branch,
 			},
 			&BranchTrackingDelete{
diff --git a/internal/vm/opcodes/branch_with_remote_gone_delete_if_empty_at_runtime.go b/internal/vm/opcodes/branch_with_remote_gone_delete_if_empty_at_runtime.go
index 9bdf521b4..af4820906 100644
--- a/internal/vm/opcodes/branch_with_remote_gone_delete_if_empty_at_runtime.go
+++ b/internal/vm/opcodes/branch_with_remote_gone_delete_if_empty_at_runtime.go
@@ -28,7 +28,7 @@ func (self *BranchWithRemoteGoneDeleteIfEmptyAtRuntime) Run(args shared.RunArgs)
 		})
 	} else {
 		args.PrependOpcodes(
-			&CheckoutParentOrMain{
+			&CheckoutDescendentOrOther{
 				Branch: self.Branch,
 			},
 			&BranchLocalDeleteContent{
diff --git a/internal/vm/opcodes/checkout_descendent_or_other.go b/internal/vm/opcodes/checkout_descendent_or_other.go
new file mode 100644
index 000000000..53dc32150
--- /dev/null
+++ b/internal/vm/opcodes/checkout_descendent_or_other.go
@@ -0,0 +1,33 @@
+package opcodes
+
+import (
+	"errors"
+	"slices"
+
+	"github.com/git-town/git-town/v22/internal/git/gitdomain"
+	"github.com/git-town/git-town/v22/internal/messages"
+	"github.com/git-town/git-town/v22/internal/vm/shared"
+)
+
+// CheckoutAncestorOrOther checks out the first available ancestor branch of the current branch
+// or any other branch available in the current worktree.
+type CheckoutDescendentOrOther struct {
+	Branch gitdomain.LocalBranchName
+}
+
+func (self *CheckoutDescendentOrOther) Run(args shared.RunArgs) error {
+	availableBranches, err := args.Git.BranchesAvailableInCurrentWorktree(args.Backend)
+	if err != nil {
+		return err
+	}
+	availableBranches = availableBranches.Remove(self.Branch)
+	descendents := args.Config.Value.NormalConfig.Lineage.Descendants(self.Branch, args.Config.Value.NormalConfig.Order)
+	ancestors := args.Config.Value.NormalConfig.Lineage.Ancestors(self.Branch)
+	slices.Reverse(ancestors)
+	branches := availableBranches.Hoist(ancestors...).Hoist(descendents...)
+	for _, branch := range branches {
+		args.PrependOpcodes(&CheckoutIfNeeded{Branch: branch})
+		return nil
+	}
+	return errors.New(messages.BranchNotAvailable)
+}
diff --git a/internal/vm/opcodes/checkout_descendent_or_other_if_needed.go b/internal/vm/opcodes/checkout_descendent_or_other_if_needed.go
new file mode 100644
index 000000000..4278b4917
--- /dev/null
+++ b/internal/vm/opcodes/checkout_descendent_or_other_if_needed.go
@@ -0,0 +1,30 @@
+package opcodes
+
+import (
+	"github.com/git-town/git-town/v22/internal/git/gitdomain"
+	"github.com/git-town/git-town/v22/internal/vm/shared"
+)
+
+// CheckoutDescendentOrOtherIfNeeded checks out the first available ancestor branch of the current branch
+// or any other branch available in the current worktree
+// if the current branch is the same as the given branch.
+type CheckoutDescendentOrOtherIfNeeded struct {
+	Branch gitdomain.LocalBranchName
+}
+
+func (self *CheckoutDescendentOrOtherIfNeeded) Run(args shared.RunArgs) error {
+	currentBranchOpt, err := args.Git.CurrentBranch(args.Backend)
+	if err != nil {
+		return err
+	}
+	currentBranch, hasCurrentBranch := currentBranchOpt.Get()
+	if !hasCurrentBranch {
+		// Detached HEAD - not on the branch, so no need to checkout
+		return nil
+	}
+	if currentBranch != self.Branch {
+		return nil
+	}
+	args.PrependOpcodes(&CheckoutDescendentOrOther{Branch: self.Branch})
+	return nil
+}
diff --git a/internal/vm/opcodes/checkout_parent_or_main.go b/internal/vm/opcodes/checkout_parent_or_main.go
deleted file mode 100644
index 863157128..000000000
--- a/internal/vm/opcodes/checkout_parent_or_main.go
+++ /dev/null
@@ -1,18 +0,0 @@
-package opcodes
-
-import (
-	"github.com/git-town/git-town/v22/internal/git/gitdomain"
-	"github.com/git-town/git-town/v22/internal/vm/shared"
-)
-
-// CheckoutParentOrMain checks out the parent branch of the current branch,
-// or the main branch if the current branch has no parent.
-type CheckoutParentOrMain struct {
-	Branch gitdomain.LocalBranchName
-}
-
-func (self *CheckoutParentOrMain) Run(args shared.RunArgs) error {
-	parent := args.Config.Value.NormalConfig.Lineage.Parent(self.Branch).GetOr(args.Config.Value.ValidatedConfigData.MainBranch)
-	args.PrependOpcodes(&CheckoutIfNeeded{Branch: parent})
-	return nil
-}



Test patch:
diff --git a/internal/git/commands_test.go b/internal/git/commands_test.go
index f44713002..8fd7b5589 100644
--- a/internal/git/commands_test.go
+++ b/internal/git/commands_test.go
@@ -355,6 +355,58 @@ func TestBackendCommands(t *testing.T) {
 		})
 	})
 
+	t.Run("BranchesAvailableInCurrentWorktree", func(t *testing.T) {
+		t.Parallel()
+		t.Run("includes branches not checked out anywhere", func(t *testing.T) {
+			t.Parallel()
+			runtime := testruntime.Create(t)
+			runtime.CreateBranch("branch1", initial.BranchName())
+			runtime.CreateBranch("branch2", initial.BranchName())
+			runtime.CheckoutBranch("branch1")
+			available := asserts.NoError1(runtime.Git.BranchesAvailableInCurrentWorktree(runtime))
+			must.SliceContainsAll(t, gitdomain.LocalBranchNames{
+				gitdomain.NewLocalBranchName("initial"),
+				gitdomain.NewLocalBranchName("branch1"),
+				gitdomain.NewLocalBranchName("branch2"),
+			}, available)
+		})
+
+		t.Run("excludes branch checked out in another worktree", func(t *testing.T) {
+			t.Parallel()
+			runtime := testruntime.Create(t)
+			runtime.CreateBranch("branch1", initial.BranchName())
+			runtime.CreateBranch("branch2", initial.BranchName())
+			worktreeDir := t.TempDir()
+			runtime.AddWorktree(worktreeDir, "branch1")
+			available := asserts.NoError1(runtime.Git.BranchesAvailableInCurrentWorktree(runtime))
+			must.SliceContainsAll(t, gitdomain.LocalBranchNames{
+				gitdomain.NewLocalBranchName("initial"),
+				gitdomain.NewLocalBranchName("branch2"),
+			}, available)
+		})
+
+		t.Run("excludes remote branches", func(t *testing.T) {
+			t.Parallel()
+			origin := testruntime.Create(t)
+			local := testruntime.Clone(origin.TestRunner, t.TempDir())
+			origin.CreateBranch("remote-branch", initial.BranchName())
+			local.Fetch()
+			available := asserts.NoError1(local.Git.BranchesAvailableInCurrentWorktree(local))
+			must.SliceContainsAll(t, gitdomain.LocalBranchNames{
+				gitdomain.NewLocalBranchName("initial"),
+			}, available)
+		})
+
+		t.Run("empty repository", func(t *testing.T) {
+			t.Parallel()
+			runtime := testruntime.Create(t)
+			available := asserts.NoError1(runtime.Git.BranchesAvailableInCurrentWorktree(runtime))
+			must.SliceContainsAll(t, gitdomain.LocalBranchNames{
+				gitdomain.NewLocalBranchName("initial"),
+			}, available)
+		})
+	})
+
 	t.Run("BranchesSnapshot", func(t *testing.T) {
 		t.Parallel()
 		t.Run("recognizes the active branch names", func(t *testing.T) {
diff --git a/internal/git/gitdomain/local_branch_names_test.go b/internal/git/gitdomain/local_branch_names_test.go
index ebf18fe51..fa5201ad0 100644
--- a/internal/git/gitdomain/local_branch_names_test.go
+++ b/internal/git/gitdomain/local_branch_names_test.go
@@ -46,14 +46,14 @@ func TestLocalBranchNames(t *testing.T) {
 
 	t.Run("Hoist", func(t *testing.T) {
 		t.Parallel()
-		t.Run("haystack contains needle", func(t *testing.T) {
+		t.Run("haystack contains needles", func(t *testing.T) {
 			t.Parallel()
-			branches := gitdomain.NewLocalBranchNames("one", "two", "three")
-			have := branches.Hoist("two")
-			want := gitdomain.NewLocalBranchNames("two", "one", "three")
+			branches := gitdomain.NewLocalBranchNames("one", "two", "three", "four")
+			have := branches.Hoist("four", "two")
+			want := gitdomain.NewLocalBranchNames("four", "two", "one", "three")
 			must.Eq(t, want, have)
 		})
-		t.Run("haystack does not contain needle", func(t *testing.T) {
+		t.Run("haystack does not contain needles", func(t *testing.T) {
 			t.Parallel()
 			branches := gitdomain.NewLocalBranchNames("one", "two", "three")
 			have := branches.Hoist("zonk")