From 103fc373836ca7c4cd011ff421af98afcc469457 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Wed, 25 Mar 2026 15:10:00 -0400 Subject: [PATCH 1/4] fix for initial branch missing prefix --- cmd/init.go | 9 +++++++-- cmd/init_test.go | 27 +++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/cmd/init.go b/cmd/init.go index c14d703..04201d4 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -145,8 +145,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,8 +161,9 @@ func runInit(cfg *config.Config, opts *initOptions) error { return ErrSilent } } + prefixed = append(prefixed, b) } - branches = opts.branches + branches = prefixed } else { // Interactive mode if !cfg.IsInteractive() { diff --git a/cmd/init_test.go b/cmd/init_test.go index 773b078..fc4c518 100644 --- a/cmd/init_test.go +++ b/cmd/init_test.go @@ -110,6 +110,33 @@ 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() + runInit(cfg, &initOptions{branches: []string{"b1", "b2"}, prefix: "feat"}) + output := collectOutput(cfg, outR, errR) + + 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_RerereAlreadyEnabled(t *testing.T) { gitDir := t.TempDir() enableRerereCalled := false From 3aa8c8eda366d8a9190ec39347d4e71fb6672b70 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Wed, 25 Mar 2026 15:48:32 -0400 Subject: [PATCH 2/4] consolidated prefix input and validation --- cmd/init.go | 86 +++++++++++++++++++++++++----------------------- cmd/init_test.go | 27 +++++++++++++++ 2 files changed, 71 insertions(+), 42 deletions(-) diff --git a/cmd/init.go b/cmd/init.go index 04201d4..158b3df 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -108,6 +108,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 { @@ -165,46 +208,13 @@ func runInit(cfg *config.Config, opts *initOptions) error { } 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) @@ -283,14 +293,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 fc4c518..2b56d12 100644 --- a/cmd/init_test.go +++ b/cmd/init_test.go @@ -1,6 +1,7 @@ package cmd import ( + "fmt" "io" "os" "testing" @@ -137,6 +138,32 @@ func TestInit_PrefixAppliedToExplicitBranches(t *testing.T) { 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_RerereAlreadyEnabled(t *testing.T) { gitDir := t.TempDir() enableRerereCalled := false From 569c63577f688ac17f4777da33d500f6142796db Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Wed, 25 Mar 2026 16:07:56 -0400 Subject: [PATCH 3/4] assert runInit success --- cmd/init_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cmd/init_test.go b/cmd/init_test.go index 2b56d12..95258c2 100644 --- a/cmd/init_test.go +++ b/cmd/init_test.go @@ -126,9 +126,10 @@ func TestInit_PrefixAppliedToExplicitBranches(t *testing.T) { defer restore() cfg, outR, errR := config.NewTestConfig() - runInit(cfg, &initOptions{branches: []string{"b1", "b2"}, prefix: "feat"}) + 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") From 0b9293a4cb256bb3eef4bd24c0899a2f09219d90 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Wed, 25 Mar 2026 16:13:08 -0400 Subject: [PATCH 4/4] disallow prefix or numbered flags with adopt --- cmd/init.go | 6 ++++++ cmd/init_test.go | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/cmd/init.go b/cmd/init.go index 158b3df..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() { diff --git a/cmd/init_test.go b/cmd/init_test.go index 95258c2..e7df3fc 100644 --- a/cmd/init_test.go +++ b/cmd/init_test.go @@ -165,6 +165,40 @@ func TestInit_InvalidPrefixRejectedBeforeBranchCreation(t *testing.T) { 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