diff --git a/cmd/orphan.go b/cmd/orphan.go index 7b5f9eb..97ab54b 100644 --- a/cmd/orphan.go +++ b/cmd/orphan.go @@ -4,6 +4,7 @@ package cmd import ( "fmt" "os" + "slices" "github.com/boneskull/gh-stack/internal/config" "github.com/boneskull/gh-stack/internal/git" @@ -59,7 +60,30 @@ func runOrphan(cmd *cobra.Command, args []string) error { node := tree.FindNode(root, branchName) if node == nil { - return fmt.Errorf("branch %q is not tracked", branchName) + // FindNode walks Parent->Children links starting from trunk, so a + // branch whose recorded parent is missing or itself untracked is + // disconnected from the tree even though it still has a + // stackParent entry in git config. Treat that as orphan-able + // rather than rejecting: the branch IS tracked, the link is just + // dangling. (#116) + trackedBranches, listErr := cfg.ListTrackedBranches() + if listErr != nil { + return fmt.Errorf("branch %q is not tracked", branchName) + } + if !slices.Contains(trackedBranches, branchName) { + return fmt.Errorf("branch %q is not tracked", branchName) + } + // A disconnected branch has no children in the in-memory tree + // (children require a parent link from this branch, which Build + // would have wired up before disconnecting it), so drop straight + // into the cleanup path without the children check. + s := style.New() + _ = cfg.RemoveParent(branchName) //nolint:errcheck // best effort cleanup + _ = cfg.RemovePR(branchName) //nolint:errcheck // best effort cleanup + _ = cfg.RemoveForkPoint(branchName) //nolint:errcheck // best effort cleanup + _ = cfg.RemovePRBase(branchName) //nolint:errcheck // best effort cleanup + fmt.Printf("%s Orphaned %s\n", s.SuccessIcon(), s.Branch(branchName)) + return nil } // Check for children diff --git a/e2e/orphan_test.go b/e2e/orphan_test.go index 50793fc..46d7ac6 100644 --- a/e2e/orphan_test.go +++ b/e2e/orphan_test.go @@ -53,3 +53,29 @@ func TestOrphanForceClearsPRBaseOnDescendants(t *testing.T) { t.Errorf("expected feat-b stackPRBase cleared, got %q", v) } } + +func TestOrphanDisconnectedBranchIsAllowed(t *testing.T) { + env := NewTestEnv(t) + env.MustRun("init") + + // Create the branch chain main -> feat-a, then manually delete the + // parent link so feat-a's recorded parent ("missing-branch") is not + // itself a tracked branch. This mirrors the scenario in #116 where + // the parent is no longer valid and `gh stack orphan` previously + // refused to orphan the current branch. + env.MustRun("create", "feat-a") + env.CreateCommit("feat-a work") + env.Git("config", "branch.feat-a.stackParent", "missing-branch") + + if env.GetStackConfig("branch.feat-a.stackParent") != "missing-branch" { + t.Fatal("expected feat-a parent override to land before orphan") + } + + result := env.Run("orphan", "feat-a") + if !result.Success() { + t.Fatalf("expected orphan of disconnected branch to succeed, got exit %d stderr=%q", result.ExitCode, result.Stderr) + } + if v := env.GetStackConfig("branch.feat-a.stackParent"); v != "" { + t.Errorf("expected feat-a stackParent cleared after orphan, got %q", v) + } +}