diff --git a/cmd/orphan.go b/cmd/orphan.go index 7b5f9eb..b033d52 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,10 @@ 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) + node, err = disconnectedNode(cfg, branchName) + if err != nil { + return err + } } // Check for children @@ -90,3 +94,46 @@ func runOrphan(cmd *cobra.Command, args []string) error { return nil } + +func disconnectedNode(cfg *config.Config, branchName string) (*tree.Node, error) { + trackedBranches, err := cfg.ListTrackedBranches() + if err != nil { + return nil, fmt.Errorf("branch %q is not tracked", branchName) + } + if !slices.Contains(trackedBranches, branchName) { + return nil, fmt.Errorf("branch %q is not tracked", branchName) + } + + childrenByParent := make(map[string][]string) + for _, branch := range trackedBranches { + parent, parentErr := cfg.GetParent(branch) + if parentErr != nil { + continue + } + childrenByParent[parent] = append(childrenByParent[parent], branch) + } + for parent := range childrenByParent { + slices.Sort(childrenByParent[parent]) + } + + visiting := make(map[string]bool) + var build func(string) *tree.Node + build = func(name string) *tree.Node { + node := &tree.Node{Name: name} + visiting[name] = true + defer delete(visiting, name) + + for _, childName := range childrenByParent[name] { + if visiting[childName] { + continue + } + child := build(childName) + child.Parent = node + node.Children = append(node.Children, child) + } + + return node + } + + return build(branchName), nil +} diff --git a/e2e/orphan_test.go b/e2e/orphan_test.go index 50793fc..4cf9803 100644 --- a/e2e/orphan_test.go +++ b/e2e/orphan_test.go @@ -53,3 +53,64 @@ 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) + } +} + +func TestOrphanDisconnectedBranchWithDescendantsRequiresForce(t *testing.T) { + env := NewTestEnv(t) + env.MustRun("init") + + env.MustRun("create", "feat-a") + env.CreateCommit("feat-a work") + env.MustRun("create", "feat-b") + env.CreateCommit("feat-b work") + + env.Git("config", "branch.feat-a.stackParent", "missing-branch") + + result := env.Run("orphan", "feat-a") + if result.Success() { + t.Fatal("expected orphan of disconnected branch with descendants to require --force") + } + if !result.ContainsStderr("has children") { + t.Errorf("expected error about children, got: %s", result.Stderr) + } + if v := env.GetStackConfig("branch.feat-a.stackParent"); v != "missing-branch" { + t.Errorf("expected feat-a stackParent to remain after failed orphan, got %q", v) + } + if v := env.GetStackConfig("branch.feat-b.stackParent"); v != "feat-a" { + t.Errorf("expected feat-b stackParent to remain after failed orphan, got %q", v) + } + + env.MustRun("orphan", "--force", "feat-a") + + if v := env.GetStackConfig("branch.feat-a.stackParent"); v != "" { + t.Errorf("expected feat-a stackParent cleared after forced orphan, got %q", v) + } + if v := env.GetStackConfig("branch.feat-b.stackParent"); v != "" { + t.Errorf("expected feat-b stackParent cleared after forced orphan, got %q", v) + } +}