Skip to content
Draft
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
31 changes: 20 additions & 11 deletions cmd/add.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,10 +180,9 @@ func runAdd(cfg *config.Config, opts *addOptions, args []string) error {
return ErrInvalidArgs
}

if git.BranchExists(branchName) {
cfg.Errorf("branch %q already exists", branchName)
return ErrInvalidArgs
}
// If the branch already exists in git but is not part of any stack,
// adopt it instead of erroring. This mirrors the init command's behavior.
adopted := git.BranchExists(branchName)

// Stage changes before creating the branch so we can fail early if
// there's nothing to commit (avoids leaving an empty orphan branch).
Expand All @@ -193,10 +192,12 @@ func runAdd(cfg *config.Config, opts *addOptions, args []string) error {
}
}

// Create the new branch from the current HEAD and check it out
if err := git.CreateBranch(branchName, currentBranch); err != nil {
cfg.Errorf("failed to create branch: %s", err)
return ErrSilent
if !adopted {
// Create the new branch from the current HEAD and check it out
if err := git.CreateBranch(branchName, currentBranch); err != nil {
cfg.Errorf("failed to create branch: %s", err)
return ErrSilent
}
}

if err := git.CheckoutBranch(branchName); err != nil {
Expand Down Expand Up @@ -227,10 +228,18 @@ func runAdd(cfg *config.Config, opts *addOptions, args []string) error {

// Print summary
position := len(s.Branches)
if commitSHA != "" {
cfg.Successf("Created branch %s (layer %d) with commit %s", cfg.ColorBold(branchName), position, commitSHA)
if adopted {
if commitSHA != "" {
cfg.Successf("Adopted branch %s (layer %d) with commit %s", cfg.ColorBold(branchName), position, commitSHA)
} else {
cfg.Successf("Adopted existing branch %q into the stack", branchName)
}
} else {
cfg.Successf("Created and checked out branch %q", branchName)
if commitSHA != "" {
cfg.Successf("Created branch %s (layer %d) with commit %s", cfg.ColorBold(branchName), position, commitSHA)
} else {
cfg.Successf("Created and checked out branch %q", branchName)
}
}

return nil
Expand Down
115 changes: 115 additions & 0 deletions cmd/add_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -474,3 +474,118 @@ func TestAdd_FromTrunk(t *testing.T) {
names := sf.Stacks[0].BranchNames()
assert.Equal(t, "newbranch", names[len(names)-1], "new branch should be appended to stack")
}

func TestAdd_AdoptsExistingBranch(t *testing.T) {
gitDir := t.TempDir()
saveStack(t, gitDir, stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{{Branch: "b1"}},
})

createBranchCalled := false
var checkedOut string
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
CurrentBranchFn: func() (string, error) { return "b1", nil },
BranchExistsFn: func(name string) bool { return name == "existing-branch" },
CreateBranchFn: func(name, base string) error {
createBranchCalled = true
return nil
},
CheckoutBranchFn: func(name string) error {
checkedOut = name
return nil
},
RevParseFn: func(ref string) (string, error) { return "abc", nil },
})
defer restore()

cfg, outR, errR := config.NewTestConfig()
err := runAdd(cfg, &addOptions{}, []string{"existing-branch"})
output := collectOutput(cfg, outR, errR)

require.NoError(t, err)
require.NotContains(t, output, "\u2717", "unexpected error")
assert.False(t, createBranchCalled, "CreateBranch should NOT be called for existing branch")
assert.Equal(t, "existing-branch", checkedOut, "should checkout the existing branch")
assert.Contains(t, output, "Adopted")

sf, err := stack.Load(gitDir)
require.NoError(t, err)
names := sf.Stacks[0].BranchNames()
assert.Equal(t, "existing-branch", names[len(names)-1], "adopted branch appended to stack")
}

func TestAdd_RejectsExistingBranchInStack(t *testing.T) {
gitDir := t.TempDir()
// Two stacks: the current one and another that owns "taken-branch"
sf := &stack.StackFile{
SchemaVersion: 1,
Stacks: []stack.Stack{
{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{{Branch: "b1"}},
},
{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{{Branch: "taken-branch"}},
},
},
}
require.NoError(t, stack.Save(gitDir, sf), "saving seed stacks")

restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
CurrentBranchFn: func() (string, error) { return "b1", nil },
BranchExistsFn: func(name string) bool { return name == "taken-branch" },
})
defer restore()

cfg, outR, errR := config.NewTestConfig()
runAdd(cfg, &addOptions{}, []string{"taken-branch"})
output := collectOutput(cfg, outR, errR)

assert.Contains(t, output, "already exists in the stack")
}

func TestAdd_AdoptsExistingBranchWithCommit(t *testing.T) {
gitDir := t.TempDir()
saveStack(t, gitDir, stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{{Branch: "b1"}},
})

createBranchCalled := false
commitCalled := false
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
CurrentBranchFn: func() (string, error) { return "b1", nil },
BranchExistsFn: func(name string) bool { return name == "existing-branch" },
RevParseMultiFn: func(refs []string) ([]string, error) {
return []string{"aaa", "bbb"}, nil // different SHAs = branch has commits
},
CreateBranchFn: func(name, base string) error {
createBranchCalled = true
return nil
},
CheckoutBranchFn: func(name string) error { return nil },
RevParseFn: func(ref string) (string, error) { return "abc", nil },
StageAllFn: func() error { return nil },
HasStagedChangesFn: func() bool { return true },
CommitFn: func(msg string) (string, error) {
commitCalled = true
return "def1234567890", nil
},
})
defer restore()

cfg, outR, errR := config.NewTestConfig()
err := runAdd(cfg, &addOptions{stageAll: true, message: "new commit"}, []string{"existing-branch"})
output := collectOutput(cfg, outR, errR)

require.NoError(t, err)
require.NotContains(t, output, "\u2717", "unexpected error")
assert.False(t, createBranchCalled, "CreateBranch should NOT be called")
assert.True(t, commitCalled, "Commit should be called on the adopted branch")
assert.Contains(t, output, "Adopted")
}
Loading