diff --git a/cmd/init.go b/cmd/init.go index c14d703..24e3dae 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -101,6 +101,12 @@ 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() { @@ -108,6 +114,49 @@ func runInit(cfg *config.Config, opts *initOptions) error { 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) + return ErrInvalidArgs + } + } + if opts.adopt { // Adopt mode: validate all specified branches exist if len(opts.branches) == 0 { @@ -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 @@ -157,49 +210,17 @@ func runInit(cfg *config.Config, opts *initOptions) error { return ErrSilent } } + 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) @@ -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)) diff --git a/cmd/init_test.go b/cmd/init_test.go index 773b078..e7df3fc 100644 --- a/cmd/init_test.go +++ b/cmd/init_test.go @@ -1,6 +1,7 @@ package cmd import ( + "fmt" "io" "os" "testing" @@ -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") + + 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