Skip to content

Commit 18f15f0

Browse files
committed
feat(adopt): allow re-adoption of branches
This change allows `gh stack adopt` to _change_ the parent branch. Previously, it would error out if the current branch was already tracked.
1 parent 4c75ffd commit 18f15f0

4 files changed

Lines changed: 96 additions & 13 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ gh stack create <name>
206206

207207
Start tracking an existing branch by setting its parent.
208208

209-
By default, adopts the current branch. The parent must be either the trunk or another tracked branch.
209+
By default, adopts the current branch. The parent must be either the trunk or another tracked branch. If the branch is already tracked, `adopt` updates its parent to the specified branch (or does nothing if the parent is unchanged).
210210

211211
#### adopt Usage
212212

cmd/adopt.go

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,10 @@ func runAdopt(cmd *cobra.Command, args []string) error {
6262
return fmt.Errorf("branch %q does not exist", branchName)
6363
}
6464

65-
// Check if already tracked
66-
if _, getParentErr := cfg.GetParent(branchName); getParentErr == nil {
67-
return fmt.Errorf("branch %q is already tracked", branchName)
65+
// Check if already tracked, capture old parent if so
66+
oldParent, alreadyTracked := "", false
67+
if p, getParentErr := cfg.GetParent(branchName); getParentErr == nil {
68+
oldParent, alreadyTracked = p, true
6869
}
6970

7071
// Validate parent is trunk or tracked
@@ -94,6 +95,14 @@ func runAdopt(cmd *cobra.Command, args []string) error {
9495
}
9596
}
9697

98+
s := style.New()
99+
100+
// No-op: already tracked with the same parent
101+
if alreadyTracked && oldParent == parent {
102+
fmt.Printf("%s Branch %s is already tracked with parent %s\n", s.WarningIcon(), s.Branch(branchName), s.Branch(parent))
103+
return nil
104+
}
105+
97106
// Set parent
98107
if err := cfg.SetParent(branchName, parent); err != nil {
99108
return err
@@ -105,7 +114,10 @@ func runAdopt(cmd *cobra.Command, args []string) error {
105114
_ = cfg.SetForkPoint(branchName, forkPoint) //nolint:errcheck // best effort
106115
}
107116

108-
s := style.New()
109-
fmt.Printf("%s Adopted branch %s with parent %s\n", s.SuccessIcon(), s.Branch(branchName), s.Branch(parent))
117+
if alreadyTracked {
118+
fmt.Printf("%s Updated branch %s parent from %s to %s\n", s.SuccessIcon(), s.Branch(branchName), s.Branch(oldParent), s.Branch(parent))
119+
} else {
120+
fmt.Printf("%s Adopted branch %s with parent %s\n", s.SuccessIcon(), s.Branch(branchName), s.Branch(parent))
121+
}
110122
return nil
111123
}

cmd/adopt_test.go

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ func TestAdoptBranch(t *testing.T) {
3737
}
3838
}
3939

40-
func TestAdoptRejectsAlreadyTracked(t *testing.T) {
40+
func TestAdoptReparentsAlreadyTracked(t *testing.T) {
4141
dir := setupTestRepo(t)
4242

4343
cfg, _ := config.Load(dir)
@@ -46,14 +46,23 @@ func TestAdoptRejectsAlreadyTracked(t *testing.T) {
4646
trunk, _ := g.CurrentBranch()
4747
cfg.SetTrunk(trunk)
4848

49-
// Create and track a branch
50-
g.CreateBranch("tracked-feature")
51-
cfg.SetParent("tracked-feature", trunk)
49+
// Create two branches; track feature-a under trunk
50+
g.CreateBranch("feature-a")
51+
cfg.SetParent("feature-a", trunk)
52+
g.CreateBranch("feature-b")
53+
cfg.SetParent("feature-b", trunk)
54+
55+
// Simulate what runAdopt does when reparenting feature-b under feature-a
56+
if err := cfg.SetParent("feature-b", "feature-a"); err != nil {
57+
t.Fatalf("SetParent failed: %v", err)
58+
}
5259

53-
// Trying to get parent should succeed (it's tracked)
54-
_, err := cfg.GetParent("tracked-feature")
60+
parent, err := cfg.GetParent("feature-b")
5561
if err != nil {
56-
t.Error("expected branch to be tracked")
62+
t.Fatalf("GetParent failed: %v", err)
63+
}
64+
if parent != "feature-a" {
65+
t.Errorf("expected parent %q after reparent, got %q", "feature-a", parent)
5766
}
5867
}
5968

e2e/adopt_orphan_test.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,68 @@ package e2e_test
33

44
import "testing"
55

6+
func TestAdoptReparentsTrackedBranch(t *testing.T) {
7+
env := NewTestEnv(t)
8+
env.MustRun("init")
9+
10+
// Create feat-a and feat-b both off trunk (main)
11+
env.MustRun("create", "feat-a")
12+
env.CreateCommit("feat-a work")
13+
14+
env.Git("checkout", "main")
15+
env.MustRun("create", "feat-b")
16+
env.CreateCommit("feat-b work")
17+
18+
// feat-b is currently tracked with parent main; reparent onto feat-a
19+
result := env.MustRun("adopt", "--branch", "feat-b", "feat-a")
20+
21+
env.AssertStackParent("feat-b", "feat-a")
22+
if !result.ContainsStdout("feat-a") {
23+
t.Errorf("expected stdout to mention feat-a, got: %s", result.Stdout)
24+
}
25+
if !result.ContainsStdout("main") {
26+
t.Errorf("expected stdout to mention old parent main, got: %s", result.Stdout)
27+
}
28+
}
29+
30+
func TestAdoptNoOpWhenParentUnchangedE2E(t *testing.T) {
31+
env := NewTestEnv(t)
32+
env.MustRun("init")
33+
34+
env.Git("checkout", "-b", "existing-branch")
35+
env.CreateCommit("some work")
36+
env.MustRun("adopt", "main")
37+
38+
// Adopt again with same parent: should succeed and print a warning
39+
result := env.Run("adopt", "main")
40+
if !result.Success() {
41+
t.Errorf("expected no-op adopt to succeed, got exit %d: %s", result.ExitCode, result.Stderr)
42+
}
43+
if !result.ContainsStdout("already tracked") {
44+
t.Errorf("expected 'already tracked' in stdout, got: %s", result.Stdout)
45+
}
46+
}
47+
48+
func TestAdoptReparentCycleDetected(t *testing.T) {
49+
env := NewTestEnv(t)
50+
env.MustRun("init")
51+
52+
// Build trunk → feat-a → feat-b
53+
env.MustRun("create", "feat-a")
54+
env.CreateCommit("a work")
55+
env.MustRun("create", "feat-b")
56+
env.CreateCommit("b work")
57+
58+
// Attempting to reparent feat-a under feat-b would create a cycle
59+
result := env.Run("adopt", "--branch", "feat-a", "feat-b")
60+
if result.Success() {
61+
t.Error("expected cycle detection to fail, but adopt succeeded")
62+
}
63+
if !result.ContainsStderr("cycle") {
64+
t.Errorf("expected 'cycle' in stderr, got: %s", result.Stderr)
65+
}
66+
}
67+
668
func TestAdoptExistingBranch(t *testing.T) {
769
env := NewTestEnv(t)
870
env.MustRun("init")

0 commit comments

Comments
 (0)