From eb74310728f6152a425c3b7ac9fc294653bcff36 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Thu, 27 Aug 2026 01:51:07 -0400 Subject: [PATCH 1/3] offer to initialize stack when running add from outside a stack --- README.md | 4 +- cmd/add.go | 103 ++++++++-- cmd/add_test.go | 250 +++++++++++++++++++++++++ cmd/utils.go | 53 ++++-- docs/src/content/docs/reference/cli.md | 4 +- 5 files changed, 378 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 2729d0a..226544f 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,9 @@ Add a new branch on top of the current stack. gh stack add [flags] [branch] ``` -Creates a new branch at the current HEAD, adds it to the top of the stack, and checks it out. Must be run while on the topmost branch of a stack. If no branch name is given, prompts for one. +For an existing stack, creates a new branch at the current HEAD, adds it to the top of the stack, and checks it out. Must be run while on the topmost branch of a stack. If no branch name is given, prompts for one. + +When run interactively from a branch that is not part of a stack, `add` offers to initialize a new stack instead. The supplied or auto-generated branch name becomes the first layer; without one, the standard `init` prompts are used. You can optionally stage changes and create a commit as part of the `add` flow. When `-m` is provided without an explicit branch name, the branch name is auto-generated in date+slug format (e.g., `03-24-add_login`). diff --git a/cmd/add.go b/cmd/add.go index ca42fdd..5b994db 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -3,6 +3,7 @@ package cmd import ( "fmt" + "github.com/cli/go-gh/v2/pkg/prompter" "github.com/github/gh-stack/internal/branch" "github.com/github/gh-stack/internal/config" "github.com/github/gh-stack/internal/git" @@ -59,7 +60,7 @@ func runAdd(cfg *config.Config, opts *addOptions, args []string) error { return ErrInvalidArgs } - result, err := loadStack(cfg, "") + result, err := loadStackOptional(cfg, "") if err != nil { return ErrNotInStack } @@ -70,6 +71,14 @@ func runAdd(cfg *config.Config, opts *addOptions, args []string) error { return ErrModifyRecovery } + if result.Stack == nil { + branchName, err := addBranchNameFromArgs(cfg, opts, args) + if err != nil { + return err + } + return initializeStackFromAdd(cfg, opts, branchName, result.CurrentBranch) + } + sf := result.StackFile s := result.Stack currentBranch := result.CurrentBranch @@ -122,21 +131,11 @@ func runAdd(cfg *config.Config, opts *addOptions, args []string) error { // explicit name -> used verbatim // -m without a name -> auto-generated from the commit message // neither -> prompt for a name - var branchName string - var explicitName string - if len(args) > 0 { - explicitName = args[0] + branchName, err := addBranchNameFromArgs(cfg, opts, args) + if err != nil { + return err } - - if explicitName != "" { - branchName = explicitName - } else if opts.message != "" { - branchName = branch.DateSlug(opts.message) - if branchName == "" { - cfg.Errorf("could not generate branch name") - return ErrSilent - } - } else { + if branchName == "" { // No -m and no explicit name — prompt for one. for { input, err := promptInput(cfg, "Enter a name for the new branch:") @@ -242,6 +241,80 @@ func runAdd(cfg *config.Config, opts *addOptions, args []string) error { return nil } +func addBranchNameFromArgs(cfg *config.Config, opts *addOptions, args []string) (string, error) { + if len(args) > 0 && args[0] != "" { + return args[0], nil + } + if opts.message == "" { + return "", nil + } + + branchName := branch.DateSlug(opts.message) + if branchName == "" { + cfg.Errorf("could not generate branch name") + return "", ErrSilent + } + return branchName, nil +} + +func initializeStackFromAdd(cfg *config.Config, opts *addOptions, branchName, currentBranch string) error { + if !cfg.IsInteractive() { + reportBranchNotInStack(cfg, currentBranch, false) + return ErrNotInStack + } + + prompt := "Would you like to initialize a new stack?" + var confirmed bool + var err error + if cfg.ConfirmFn != nil { + confirmed, err = cfg.ConfirmFn(prompt, true) + } else { + p := prompter.New(cfg.In, cfg.Out, cfg.Err) + confirmed, err = p.Confirm(prompt, true) + } + if err != nil { + if isInterruptError(err) { + printInterrupt(cfg) + return ErrSilent + } + cfg.Errorf("failed to read confirmation: %s", err) + return ErrSilent + } + if !confirmed { + reportBranchNotInStack(cfg, currentBranch, false) + return ErrNotInStack + } + + wantsCommit := opts.message != "" || opts.stageAll || opts.stageTracked + if wantsCommit { + if err := stageAndValidate(cfg, opts); err != nil { + return ErrSilent + } + } + + initOpts := &initOptions{} + if branchName != "" { + initOpts.branches = []string{branchName} + } + if err := runInit(cfg, initOpts); err != nil { + return err + } + + if wantsCommit { + sha, err := doCommit(opts.message) + if err != nil { + cfg.Errorf("failed to commit: %s", err) + return ErrSilent + } + if branchName == "" { + branchName, _ = git.CurrentBranch() + } + cfg.Successf("Created commit %s on %s", cfg.ColorBold(sha), branchName) + } + + return nil +} + // stageAndValidate stages files (if -A or -u is set) and verifies there are // staged changes to commit. Prints a user-facing error and returns non-nil // if staging fails or there is nothing to commit. diff --git a/cmd/add_test.go b/cmd/add_test.go index 6a7eb25..aa042b5 100644 --- a/cmd/add_test.go +++ b/cmd/add_test.go @@ -4,6 +4,7 @@ import ( "testing" "time" + "github.com/AlecAivazis/survey/v2/terminal" "github.com/github/gh-stack/internal/config" "github.com/github/gh-stack/internal/git" "github.com/github/gh-stack/internal/stack" @@ -589,3 +590,252 @@ func TestAdd_AdoptExistingBranchWithoutCommonBaseFails(t *testing.T) { require.NoError(t, loadErr) assert.Equal(t, []string{"b1"}, sf.Stacks[0].BranchNames()) } + +func TestAdd_InitializesStackWithExplicitBranch(t *testing.T) { + gitDir := t.TempDir() + var createdBranch, createdBase, checkedOut string + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "unstacked", nil }, + DefaultBranchFn: func() (string, error) { return "main", nil }, + IsRerereEnabledFn: func() (bool, error) { return true, nil }, + CreateBranchFn: func(name, base string) error { + createdBranch = name + createdBase = base + return nil + }, + CheckoutBranchFn: func(name string) error { + checkedOut = name + return nil + }, + RevParseFn: func(ref string) (string, error) { return "sha-" + ref, nil }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + cfg.ForceInteractive = true + cfg.ConfirmFn = func(prompt string, defaultValue bool) (bool, error) { + assert.Equal(t, "Would you like to initialize a new stack?", prompt) + assert.True(t, defaultValue) + return true, nil + } + + err := runAdd(cfg, &addOptions{}, []string{"first-layer"}) + output := collectOutput(cfg, outR, errR) + + require.NoError(t, err) + assert.NotContains(t, output, "not part of a stack") + assert.Equal(t, "first-layer", createdBranch) + assert.Equal(t, "main", createdBase) + assert.Equal(t, "first-layer", checkedOut) + + sf, loadErr := stack.Load(gitDir) + require.NoError(t, loadErr) + require.Len(t, sf.Stacks, 1) + assert.Equal(t, []string{"first-layer"}, sf.Stacks[0].BranchNames()) +} + +func TestAdd_InitializesStackWithPromptedBranch(t *testing.T) { + gitDir := t.TempDir() + var createdBranch string + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "main", nil }, + DefaultBranchFn: func() (string, error) { return "main", nil }, + IsRerereEnabledFn: func() (bool, error) { return true, nil }, + CreateBranchFn: func(name, base string) error { + createdBranch = name + return nil + }, + CheckoutBranchFn: func(string) error { return nil }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + cfg.ForceInteractive = true + cfg.ConfirmFn = func(string, bool) (bool, error) { return true, nil } + cfg.InputFn = func(prompt string) (string, error) { + assert.Equal(t, "What's the name of the first branch:", prompt) + return "prompted-layer", nil + } + + err := runAdd(cfg, &addOptions{}, nil) + output := collectOutput(cfg, outR, errR) + + require.NoError(t, err) + require.NotContains(t, output, "\u2717", "unexpected error") + assert.Equal(t, "prompted-layer", createdBranch) + + sf, loadErr := stack.Load(gitDir) + require.NoError(t, loadErr) + require.Len(t, sf.Stacks, 1) + assert.Equal(t, []string{"prompted-layer"}, sf.Stacks[0].BranchNames()) +} + +func TestAdd_InitializesGeneratedBranchAndCommits(t *testing.T) { + gitDir := t.TempDir() + currentBranch := "unstacked" + stageAllCalled := false + commitCalled := false + expectedBranch := time.Now().Format("01-02") + "-first_layer" + + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return currentBranch, nil }, + DefaultBranchFn: func() (string, error) { return "main", nil }, + IsRerereEnabledFn: func() (bool, error) { return true, nil }, + CreateBranchFn: func(name, base string) error { + assert.Equal(t, expectedBranch, name) + assert.Equal(t, "main", base) + return nil + }, + CheckoutBranchFn: func(name string) error { + require.True(t, stageAllCalled, "changes should be staged before initialization") + currentBranch = name + return nil + }, + StageAllFn: func() error { + stageAllCalled = true + return nil + }, + HasStagedChangesFn: func() bool { return true }, + CommitFn: func(message string) (string, error) { + assert.Equal(t, "First layer", message) + assert.Equal(t, expectedBranch, currentBranch) + commitCalled = true + return "abc123", nil + }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + cfg.ForceInteractive = true + cfg.ConfirmFn = func(string, bool) (bool, error) { return true, nil } + + err := runAdd(cfg, &addOptions{stageAll: true, message: "First layer"}, nil) + output := collectOutput(cfg, outR, errR) + + require.NoError(t, err) + require.NotContains(t, output, "\u2717", "unexpected error") + assert.True(t, stageAllCalled) + assert.True(t, commitCalled) + + sf, loadErr := stack.Load(gitDir) + require.NoError(t, loadErr) + require.Len(t, sf.Stacks, 1) + assert.Equal(t, []string{expectedBranch}, sf.Stacks[0].BranchNames()) +} + +func TestAdd_MissingStackWithoutConfirmationReturnsNotInStack(t *testing.T) { + tests := []struct { + name string + interactive bool + confirmed bool + }{ + { + name: "non-interactive", + interactive: false, + }, + { + name: "declined", + interactive: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gitDir := t.TempDir() + createCalled := false + confirmCalled := false + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "unstacked", nil }, + CreateBranchFn: func(string, string) error { + createCalled = true + return nil + }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + cfg.ForceInteractive = tt.interactive + cfg.ConfirmFn = func(string, bool) (bool, error) { + confirmCalled = true + return tt.confirmed, nil + } + + err := runAdd(cfg, &addOptions{}, []string{"first-layer"}) + output := collectOutput(cfg, outR, errR) + + assert.ErrorIs(t, err, ErrNotInStack) + assert.Contains(t, output, `current branch "unstacked" is not part of a stack`) + assert.Contains(t, output, "gh stack checkout") + assert.Contains(t, output, "gh stack init") + assert.False(t, createCalled) + assert.Equal(t, tt.interactive, confirmCalled) + }) + } +} + +func TestAdd_InitConfirmationError(t *testing.T) { + tests := []struct { + name string + confirmErr error + wantOutput string + }{ + { + name: "interrupt", + confirmErr: terminal.InterruptErr, + wantOutput: "Received interrupt, aborting operation", + }, + { + name: "prompt failure", + confirmErr: assert.AnError, + wantOutput: "failed to read confirmation", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gitDir := t.TempDir() + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "unstacked", nil }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + cfg.ForceInteractive = true + cfg.ConfirmFn = func(string, bool) (bool, error) { + return false, tt.confirmErr + } + + err := runAdd(cfg, &addOptions{}, []string{"first-layer"}) + output := collectOutput(cfg, outR, errR) + + assert.ErrorIs(t, err, ErrSilent) + assert.Contains(t, output, tt.wantOutput) + }) + } +} + +func TestAdd_LoaderFailureDoesNotOfferInitialization(t *testing.T) { + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return "", assert.AnError }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + cfg.ForceInteractive = true + cfg.ConfirmFn = func(string, bool) (bool, error) { + t.Fatal("confirmation should not be requested for a loader failure") + return false, nil + } + + err := runAdd(cfg, &addOptions{}, []string{"first-layer"}) + output := collectOutput(cfg, outR, errR) + + assert.ErrorIs(t, err, ErrNotInStack) + assert.Contains(t, output, "not a git repository") + assert.NotContains(t, output, "gh stack init") +} diff --git a/cmd/utils.go b/cmd/utils.go index 6da90fa..65b198e 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -194,14 +194,27 @@ type loadStackResult struct { } // loadStack is the standard way to obtain a Stack for the current (or given) -// branch. It resolves the git directory, loads the stack file, determines the -// branch, calls resolveStack (which may prompt for disambiguation), checks for -// a nil stack, and re-reads the current branch (in case disambiguation caused -// a checkout). Errors are printed via cfg and returned. +// branch. It delegates to loadStackOptional, reports when the branch is not in +// a stack, and returns an error in that case. // // loadStack does NOT acquire the stack file lock. The lock is acquired // automatically by stack.Save() when writing. func loadStack(cfg *config.Config, branch string) (*loadStackResult, error) { + result, err := loadStackOptional(cfg, branch) + if err != nil { + return nil, err + } + if result.Stack == nil { + reportBranchNotInStack(cfg, result.CurrentBranch, branch != "") + return nil, fmt.Errorf("branch %q is not part of a stack", result.CurrentBranch) + } + return result, nil +} + +// loadStackOptional performs the same lookup as loadStack, but returns a +// result with a nil Stack when the branch is not tracked instead of reporting +// an error. Other lookup failures are still reported and returned. +func loadStackOptional(cfg *config.Config, branch string) (*loadStackResult, error) { gitDir, err := git.GitDir() if err != nil { cfg.Errorf("not a git repository") @@ -214,7 +227,6 @@ func loadStack(cfg *config.Config, branch string) (*loadStackResult, error) { return nil, fmt.Errorf("failed to load stack state: %w", err) } - branchFromArg := branch != "" if branch == "" { branch, err = git.CurrentBranch() if err != nil { @@ -231,22 +243,15 @@ func loadStack(cfg *config.Config, branch string) (*loadStackResult, error) { cfg.Errorf("%s", err) return nil, err } - if s == nil { - if branchFromArg { - cfg.Errorf("branch %q is not part of a stack", branch) - } else { - cfg.Errorf("current branch %q is not part of a stack", branch) - } - cfg.Printf("Checkout an existing stack using `%s` or create a new stack using `%s`", - cfg.ColorCyan("gh stack checkout"), cfg.ColorCyan("gh stack init")) - return nil, fmt.Errorf("branch %q is not part of a stack", branch) - } // Re-read current branch in case disambiguation caused a checkout. - currentBranch, err := git.CurrentBranch() - if err != nil { - cfg.Errorf("failed to get current branch: %s", err) - return nil, fmt.Errorf("failed to get current branch: %w", err) + currentBranch := branch + if s != nil { + currentBranch, err = git.CurrentBranch() + if err != nil { + cfg.Errorf("failed to get current branch: %s", err) + return nil, fmt.Errorf("failed to get current branch: %w", err) + } } return &loadStackResult{ @@ -257,6 +262,16 @@ func loadStack(cfg *config.Config, branch string) (*loadStackResult, error) { }, nil } +func reportBranchNotInStack(cfg *config.Config, branch string, branchFromArg bool) { + if branchFromArg { + cfg.Errorf("branch %q is not part of a stack", branch) + } else { + cfg.Errorf("current branch %q is not part of a stack", branch) + } + cfg.Printf("Checkout an existing stack using `%s` or create a new stack using `%s`", + cfg.ColorCyan("gh stack checkout"), cfg.ColorCyan("gh stack init")) +} + // lookupStackByNumber looks up the locally tracked stack whose stack number // matches the given value, without printing a "not tracked" error. It returns // ok=false (with a nil error) when no local stack resolves to that number — diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index 33084e3..e167645 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -69,7 +69,9 @@ gh stack add [flags] [branch] > **Note:** `-A` and `-u` are mutually exclusive. -Creates a new branch at the current HEAD, adds it to the top of the stack, and checks it out. Must be run while on the topmost branch of a stack. If no branch name is given, prompts for one. +For an existing stack, creates a new branch at the current HEAD, adds it to the top of the stack, and checks it out. Must be run while on the topmost branch of a stack. If no branch name is given, prompts for one. + +When run interactively from a branch that is not part of a stack, `add` offers to initialize a new stack instead. The supplied or auto-generated branch name becomes the first layer; without one, the standard `init` prompts are used. You can optionally stage changes and create a commit as part of the `add` flow. When `-m` is provided without an explicit branch name, the branch name is auto-generated in date+slug format (e.g., `03-24-add_login`). From d59ff6130d94ffdc9e86c5ab1d7edca8db90db7a Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Thu, 27 Aug 2026 01:52:03 -0400 Subject: [PATCH 2/3] fetch trunk if missing locally --- cmd/add_test.go | 26 ++++++++++++++++----- cmd/init.go | 16 +++++++++++++ cmd/init_test.go | 61 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 6 deletions(-) diff --git a/cmd/add_test.go b/cmd/add_test.go index aa042b5..85ea748 100644 --- a/cmd/add_test.go +++ b/cmd/add_test.go @@ -1,6 +1,7 @@ package cmd import ( + "fmt" "testing" "time" @@ -593,22 +594,32 @@ func TestAdd_AdoptExistingBranchWithoutCommonBaseFails(t *testing.T) { func TestAdd_InitializesStackWithExplicitBranch(t *testing.T) { gitDir := t.TempDir() - var createdBranch, createdBase, checkedOut string + trunkExists := false + var created [][2]string + var checkedOut string restore := git.SetOps(&git.MockOps{ GitDirFn: func() (string, error) { return gitDir, nil }, CurrentBranchFn: func() (string, error) { return "unstacked", nil }, DefaultBranchFn: func() (string, error) { return "main", nil }, IsRerereEnabledFn: func() (bool, error) { return true, nil }, + BranchExistsFn: func(name string) bool { return name == "main" && trunkExists }, + RevParseFn: func(ref string) (string, error) { + if ref == "main" && !trunkExists { + return "", fmt.Errorf("unknown revision %s", ref) + } + return "sha-" + ref, nil + }, CreateBranchFn: func(name, base string) error { - createdBranch = name - createdBase = base + created = append(created, [2]string{name, base}) + if name == "main" { + trunkExists = true + } return nil }, CheckoutBranchFn: func(name string) error { checkedOut = name return nil }, - RevParseFn: func(ref string) (string, error) { return "sha-" + ref, nil }, }) defer restore() @@ -625,8 +636,10 @@ func TestAdd_InitializesStackWithExplicitBranch(t *testing.T) { require.NoError(t, err) assert.NotContains(t, output, "not part of a stack") - assert.Equal(t, "first-layer", createdBranch) - assert.Equal(t, "main", createdBase) + assert.Equal(t, [][2]string{ + {"main", "origin/main"}, + {"first-layer", "main"}, + }, created) assert.Equal(t, "first-layer", checkedOut) sf, loadErr := stack.Load(gitDir) @@ -684,6 +697,7 @@ func TestAdd_InitializesGeneratedBranchAndCommits(t *testing.T) { CurrentBranchFn: func() (string, error) { return currentBranch, nil }, DefaultBranchFn: func() (string, error) { return "main", nil }, IsRerereEnabledFn: func() (bool, error) { return true, nil }, + BranchExistsFn: func(name string) bool { return name == "main" }, CreateBranchFn: func(name, base string) error { assert.Equal(t, expectedBranch, name) assert.Equal(t, "main", base) diff --git a/cmd/init.go b/cmd/init.go index 847be2a..8df2d95 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -106,6 +106,22 @@ func runInit(cfg *config.Config, opts *initOptions) error { } } + // The repository's default branch may only exist on the remote if the + // initial local branch was renamed before starting the stack. + if _, err := git.RevParse(trunk); err != nil { + remote, err := pickRemote(cfg, currentBranch, "") + if err != nil { + if !errors.Is(err, errInterrupt) { + cfg.Errorf("failed to resolve remote: %s", err) + } + return ErrSilent + } + if err := ensureLocalTrunk(cfg, trunk, remote); err != nil { + cfg.Errorf("%s", err) + return ErrSilent + } + } + // --- Flag validation --- // --adopt is deprecated; print a notice and continue normally. diff --git a/cmd/init_test.go b/cmd/init_test.go index 687932e..fa083a2 100644 --- a/cmd/init_test.go +++ b/cmd/init_test.go @@ -69,6 +69,67 @@ func TestInit_CustomTrunk(t *testing.T) { assert.Equal(t, "develop", sf.Stacks[0].Trunk.Branch) } +func TestInit_RestoresMissingLocalTrunk(t *testing.T) { + gitDir := t.TempDir() + trunkExists := false + var fetchedRemote string + var fetchedBranches []string + var created [][2]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 "renamed-branch", nil }, + IsRerereEnabledFn: func() (bool, error) { return true, nil }, + BranchExistsFn: func(name string) bool { + return name == "renamed-branch" || (name == "main" && trunkExists) + }, + ResolveRemoteFn: func(branch string) (string, error) { + assert.Equal(t, "renamed-branch", branch) + return "origin", nil + }, + FetchBranchesFn: func(remote string, branches []string) error { + fetchedRemote = remote + fetchedBranches = branches + return nil + }, + RevParseFn: func(ref string) (string, error) { + if ref == "main" && !trunkExists { + return "", fmt.Errorf("unknown revision %s", ref) + } + return "sha-" + ref, nil + }, + CreateBranchFn: func(name, base string) error { + created = append(created, [2]string{name, base}) + if name == "main" { + trunkExists = true + } + return nil + }, + CheckoutBranchFn: func(string) error { return nil }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + err := runInit(cfg, &initOptions{branches: []string{"first-layer"}}) + output := collectOutput(cfg, outR, errR) + + require.NoError(t, err) + assert.Equal(t, "origin", fetchedRemote) + assert.Equal(t, []string{"main"}, fetchedBranches) + assert.Equal(t, [][2]string{ + {"main", "origin/main"}, + {"first-layer", "main"}, + }, created) + assert.Contains(t, output, "Created local trunk branch main from origin/main") + + sf, loadErr := stack.Load(gitDir) + require.NoError(t, loadErr) + require.Len(t, sf.Stacks, 1) + assert.Equal(t, "main", sf.Stacks[0].Trunk.Branch) + assert.Equal(t, []string{"first-layer"}, sf.Stacks[0].BranchNames()) +} + func TestInit_AdoptExistingBranches(t *testing.T) { gitDir := t.TempDir() restore := git.SetOps(&git.MockOps{ From 89a65744c0fc62f30e3103da512cce4ed734f99b Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Thu, 27 Aug 2026 02:26:58 -0400 Subject: [PATCH 3/3] disambiguate local trunk branch during init --- cmd/add_test.go | 4 ++-- cmd/init.go | 19 ++++++++++--------- cmd/init_test.go | 8 +++----- 3 files changed, 15 insertions(+), 16 deletions(-) diff --git a/cmd/add_test.go b/cmd/add_test.go index 85ea748..b11b5be 100644 --- a/cmd/add_test.go +++ b/cmd/add_test.go @@ -638,7 +638,7 @@ func TestAdd_InitializesStackWithExplicitBranch(t *testing.T) { assert.NotContains(t, output, "not part of a stack") assert.Equal(t, [][2]string{ {"main", "origin/main"}, - {"first-layer", "main"}, + {"first-layer", "refs/heads/main"}, }, created) assert.Equal(t, "first-layer", checkedOut) @@ -700,7 +700,7 @@ func TestAdd_InitializesGeneratedBranchAndCommits(t *testing.T) { BranchExistsFn: func(name string) bool { return name == "main" }, CreateBranchFn: func(name, base string) error { assert.Equal(t, expectedBranch, name) - assert.Equal(t, "main", base) + assert.Equal(t, "refs/heads/main", base) return nil }, CheckoutBranchFn: func(name string) error { diff --git a/cmd/init.go b/cmd/init.go index 8df2d95..533f47f 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -108,7 +108,7 @@ func runInit(cfg *config.Config, opts *initOptions) error { // The repository's default branch may only exist on the remote if the // initial local branch was renamed before starting the stack. - if _, err := git.RevParse(trunk); err != nil { + if currentBranch != trunk && !git.BranchExists(trunk) { remote, err := pickRemote(cfg, currentBranch, "") if err != nil { if !errors.Is(err, errInterrupt) { @@ -121,6 +121,7 @@ func runInit(cfg *config.Config, opts *initOptions) error { return ErrSilent } } + trunkRef := "refs/heads/" + trunk // --- Flag validation --- @@ -138,7 +139,7 @@ func runInit(cfg *config.Config, opts *initOptions) error { if len(opts.branches) > 0 { // === ARGS PATH === - branches, adopted, err = resolveArgBranches(cfg, opts, sf, trunk) + branches, adopted, err = resolveArgBranches(cfg, opts, sf, trunkRef) if err != nil { return err } @@ -151,7 +152,7 @@ func runInit(cfg *config.Config, opts *initOptions) error { } var interactiveAdopted bool - branches, interactiveAdopted, err = runInteractiveInit(cfg, sf, trunk, currentBranch) + branches, interactiveAdopted, err = runInteractiveInit(cfg, sf, trunk, trunkRef, currentBranch) if err != nil { return err } @@ -162,10 +163,10 @@ func runInit(cfg *config.Config, opts *initOptions) error { // --- Build stack --- - trunkSHA, _ := git.RevParse(trunk) + trunkSHA, _ := git.RevParse(trunkRef) branchRefs := make([]stack.BranchRef, len(branches)) for i, b := range branches { - parent := trunk + parent := trunkRef if i > 0 { parent = branches[i-1] } @@ -227,7 +228,7 @@ func runInit(cfg *config.Config, opts *initOptions) error { // resolveArgBranches handles the args path: classifies each branch as // adopted (exists) or created (missing), validates all before creating any. -func resolveArgBranches(cfg *config.Config, opts *initOptions, sf *stack.StackFile, trunk string) ([]string, map[string]bool, error) { +func resolveArgBranches(cfg *config.Config, opts *initOptions, sf *stack.StackFile, trunkRef string) ([]string, map[string]bool, error) { adopted := make(map[string]bool) // Phase 1: resolve final names, classify, validate @@ -260,7 +261,7 @@ func resolveArgBranches(cfg *config.Config, opts *initOptions, sf *stack.StackFi if bi.exists { adopted[bi.name] = true } else { - parent := trunk + parent := trunkRef if i > 0 { parent = resolved[i-1].name } @@ -279,7 +280,7 @@ func resolveArgBranches(cfg *config.Config, opts *initOptions, sf *stack.StackFi // multi-branch args, then offers to use the current branch or create a new // one. Returns the branches and whether the branch was adopted (already // existed). -func runInteractiveInit(cfg *config.Config, sf *stack.StackFile, trunk, currentBranch string) ([]string, bool, error) { +func runInteractiveInit(cfg *config.Config, sf *stack.StackFile, trunk, trunkRef, currentBranch string) ([]string, bool, error) { p := prompter.New(cfg.In, cfg.Out, cfg.Err) cfg.Printf("Initializing a stack from %s.", trunk) @@ -347,7 +348,7 @@ func runInteractiveInit(cfg *config.Config, sf *stack.StackFile, trunk, currentB if git.BranchExists(branchName) { wasAdopted = true } else { - if err := git.CreateBranch(branchName, trunk); err != nil { + if err := git.CreateBranch(branchName, trunkRef); err != nil { cfg.Errorf("creating branch %s: %s", branchName, err) return nil, false, ErrSilent } diff --git a/cmd/init_test.go b/cmd/init_test.go index fa083a2..1b11041 100644 --- a/cmd/init_test.go +++ b/cmd/init_test.go @@ -69,7 +69,7 @@ func TestInit_CustomTrunk(t *testing.T) { assert.Equal(t, "develop", sf.Stacks[0].Trunk.Branch) } -func TestInit_RestoresMissingLocalTrunk(t *testing.T) { +func TestInit_RestoresMissingLocalTrunkWhenTagResolves(t *testing.T) { gitDir := t.TempDir() trunkExists := false var fetchedRemote string @@ -94,9 +94,7 @@ func TestInit_RestoresMissingLocalTrunk(t *testing.T) { return nil }, RevParseFn: func(ref string) (string, error) { - if ref == "main" && !trunkExists { - return "", fmt.Errorf("unknown revision %s", ref) - } + // A tag named "main" can resolve even when the local branch is absent. return "sha-" + ref, nil }, CreateBranchFn: func(name, base string) error { @@ -119,7 +117,7 @@ func TestInit_RestoresMissingLocalTrunk(t *testing.T) { assert.Equal(t, []string{"main"}, fetchedBranches) assert.Equal(t, [][2]string{ {"main", "origin/main"}, - {"first-layer", "main"}, + {"first-layer", "refs/heads/main"}, }, created) assert.Contains(t, output, "Created local trunk branch main from origin/main")