Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion cmd/orphan.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package cmd
import (
"fmt"
"os"
"slices"

"github.com/boneskull/gh-stack/internal/config"
"github.com/boneskull/gh-stack/internal/git"
Expand Down Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions e2e/orphan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading