Skip to content
Merged
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
101 changes: 57 additions & 44 deletions cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,13 +101,62 @@ func runInit(cfg *config.Config, opts *initOptions) error {

var branches []string

// --adopt takes existing branches as-is; --prefix and --numbered don't apply.
if opts.adopt && (opts.prefix != "" || opts.numbered) {
cfg.Errorf("--adopt cannot be combined with --prefix or --numbered")
return ErrInvalidArgs
}

// Validate --numbered requires a prefix (either from flag or interactive input,
// but for non-interactive paths we can check early).
if opts.numbered && opts.prefix == "" && !cfg.IsInteractive() {
cfg.Errorf("--numbered requires --prefix")
return ErrInvalidArgs
}

// Prompt for prefix interactively if not provided via flag and we're
// in interactive mode (not adopt, not explicit branches).
if opts.prefix == "" && !opts.adopt && len(opts.branches) == 0 && cfg.IsInteractive() {
p := prompter.New(cfg.In, cfg.Out, cfg.Err)
if opts.numbered {
// --numbered requires a prefix; prompt specifically for one
prefixInput, err := p.Input("Enter a branch prefix (required for --numbered)", "")
if err != nil {
if isInterruptError(err) {
printInterrupt(cfg)
return ErrSilent
}
cfg.Errorf("failed to read prefix: %s", err)
return ErrSilent
}
opts.prefix = strings.TrimSpace(prefixInput)
if opts.prefix == "" {
cfg.Errorf("--numbered requires a prefix")
return ErrInvalidArgs
}
} else {
prefixInput, err := p.Input("Set a branch prefix? (leave blank to skip)", "")
if err != nil {
if isInterruptError(err) {
printInterrupt(cfg)
return ErrSilent
}
cfg.Errorf("failed to read prefix: %s", err)
return ErrSilent
}
opts.prefix = strings.TrimSpace(prefixInput)
}
}

// Validate prefix, after it has been determined (from flag or prompt),
// before any branch creation.
if opts.prefix != "" {
if err := git.ValidateRefName(opts.prefix); err != nil {
cfg.Errorf("invalid prefix %q: must be a valid git ref component", opts.prefix)
Comment thread
skarim marked this conversation as resolved.
return ErrInvalidArgs
}
}

if opts.adopt {
// Adopt mode: validate all specified branches exist
if len(opts.branches) == 0 {
Expand Down Expand Up @@ -145,8 +194,12 @@ func runInit(cfg *config.Config, opts *initOptions) error {
}
}
} else if len(opts.branches) > 0 {
// Explicit branch names provided — create them
// Explicit branch names provided — apply prefix and create them
prefixed := make([]string, 0, len(opts.branches))
for _, b := range opts.branches {
if opts.prefix != "" {
b = opts.prefix + "/" + b
}
if err := sf.ValidateNoDuplicateBranch(b); err != nil {
cfg.Errorf("branch %q already exists in a stack", b)
return ErrInvalidArgs
Expand All @@ -157,49 +210,17 @@ func runInit(cfg *config.Config, opts *initOptions) error {
return ErrSilent
}
}
Comment thread
skarim marked this conversation as resolved.
Comment thread
skarim marked this conversation as resolved.
prefixed = append(prefixed, b)
}
branches = opts.branches
branches = prefixed
} else {
// Interactive mode
// Interactive mode — prefix was already prompted for above
if !cfg.IsInteractive() {
cfg.Errorf("interactive input required; provide branch names or use --adopt")
return ErrInvalidArgs
}
p := prompter.New(cfg.In, cfg.Out, cfg.Err)

// Step 1: Ask for prefix
if opts.prefix == "" {
if opts.numbered {
// --numbered requires a prefix; prompt specifically for one
prefixInput, err := p.Input("Enter a branch prefix (required for --numbered)", "")
if err != nil {
if isInterruptError(err) {
printInterrupt(cfg)
return ErrSilent
}
cfg.Errorf("failed to read prefix: %s", err)
return ErrSilent
}
opts.prefix = strings.TrimSpace(prefixInput)
if opts.prefix == "" {
cfg.Errorf("--numbered requires a prefix")
return ErrInvalidArgs
}
} else {
prefixInput, err := p.Input("Set a branch prefix? (leave blank to skip)", "")
if err != nil {
if isInterruptError(err) {
printInterrupt(cfg)
return ErrSilent
}
cfg.Errorf("failed to read prefix: %s", err)
return ErrSilent
}
opts.prefix = strings.TrimSpace(prefixInput)
}
}

// Step 2: Ask for branch name (unless --numbered auto-generates it)
if opts.numbered {
// Auto-generate numbered branch name
branchName := branch.NextNumberedName(opts.prefix, nil)
Expand Down Expand Up @@ -278,14 +299,6 @@ func runInit(cfg *config.Config, opts *initOptions) error {
}
}

// Validate prefix (from flag or interactive input)
if opts.prefix != "" {
if err := git.ValidateRefName(opts.prefix); err != nil {
cfg.Errorf("invalid prefix %q: must be a valid git ref component", opts.prefix)
return ErrInvalidArgs
}
}

// Build stack
trunkSHA, _ := git.RevParse(trunk)
branchRefs := make([]stack.BranchRef, len(branches))
Expand Down
89 changes: 89 additions & 0 deletions cmd/init_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cmd

import (
"fmt"
"io"
"os"
"testing"
Expand Down Expand Up @@ -110,6 +111,94 @@ func TestInit_PrefixStoredInStack(t *testing.T) {
assert.Equal(t, "feat", sf.Stacks[0].Prefix)
}

func TestInit_PrefixAppliedToExplicitBranches(t *testing.T) {
gitDir := t.TempDir()
var created []string
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
DefaultBranchFn: func() (string, error) { return "main", nil },
CurrentBranchFn: func() (string, error) { return "main", nil },
CreateBranchFn: func(name, base string) error {
created = append(created, name)
return nil
},
})
defer restore()

cfg, outR, errR := config.NewTestConfig()
err := runInit(cfg, &initOptions{branches: []string{"b1", "b2"}, prefix: "feat"})
output := collectOutput(cfg, outR, errR)

require.NoError(t, err, "runInit should succeed")
require.NotContains(t, output, "\u2717", "unexpected error")
assert.Equal(t, []string{"feat/b1", "feat/b2"}, created, "branches should be created with prefix")
Comment thread
skarim marked this conversation as resolved.

sf, err := stack.Load(gitDir)
require.NoError(t, err, "loading stack")
names := sf.Stacks[0].BranchNames()
assert.Equal(t, []string{"feat/b1", "feat/b2"}, names, "stack should store prefixed branch names")
}

func TestInit_InvalidPrefixRejectedBeforeBranchCreation(t *testing.T) {
gitDir := t.TempDir()
var created []string
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
DefaultBranchFn: func() (string, error) { return "main", nil },
CurrentBranchFn: func() (string, error) { return "main", nil },
ValidateRefNameFn: func(name string) error {
return fmt.Errorf("invalid ref name: %s", name)
},
CreateBranchFn: func(name, base string) error {
created = append(created, name)
return nil
},
})
defer restore()

cfg, outR, errR := config.NewTestConfig()
err := runInit(cfg, &initOptions{branches: []string{"mybranch"}, prefix: "bad..prefix"})
output := collectOutput(cfg, outR, errR)

assert.ErrorIs(t, err, ErrInvalidArgs, "should reject invalid prefix")
assert.Contains(t, output, "invalid prefix")
assert.Empty(t, created, "no branches should be created when prefix is invalid")
}

func TestInit_AdoptRejectsPrefix(t *testing.T) {
gitDir := t.TempDir()
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
DefaultBranchFn: func() (string, error) { return "main", nil },
CurrentBranchFn: func() (string, error) { return "main", nil },
})
defer restore()

cfg, outR, errR := config.NewTestConfig()
err := runInit(cfg, &initOptions{adopt: true, branches: []string{"b1"}, prefix: "feat"})
output := collectOutput(cfg, outR, errR)

assert.ErrorIs(t, err, ErrInvalidArgs)
assert.Contains(t, output, "--adopt cannot be combined with --prefix or --numbered")
}

func TestInit_AdoptRejectsNumbered(t *testing.T) {
gitDir := t.TempDir()
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
DefaultBranchFn: func() (string, error) { return "main", nil },
CurrentBranchFn: func() (string, error) { return "main", nil },
})
defer restore()

cfg, outR, errR := config.NewTestConfig()
err := runInit(cfg, &initOptions{adopt: true, branches: []string{"b1"}, numbered: true})
output := collectOutput(cfg, outR, errR)

assert.ErrorIs(t, err, ErrInvalidArgs)
assert.Contains(t, output, "--adopt cannot be combined with --prefix or --numbered")
}

func TestInit_RerereAlreadyEnabled(t *testing.T) {
gitDir := t.TempDir()
enableRerereCalled := false
Expand Down
Loading