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..b11b5be 100644 --- a/cmd/add_test.go +++ b/cmd/add_test.go @@ -1,9 +1,11 @@ package cmd import ( + "fmt" "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 +591,265 @@ 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() + 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 { + created = append(created, [2]string{name, base}) + if name == "main" { + trunkExists = true + } + return nil + }, + CheckoutBranchFn: func(name string) error { + checkedOut = name + return 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, [][2]string{ + {"main", "origin/main"}, + {"first-layer", "refs/heads/main"}, + }, created) + 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 }, + BranchExistsFn: func(name string) bool { return name == "main" }, + CreateBranchFn: func(name, base string) error { + assert.Equal(t, expectedBranch, name) + assert.Equal(t, "refs/heads/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/init.go b/cmd/init.go index 847be2a..533f47f 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -106,6 +106,23 @@ 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 currentBranch != trunk && !git.BranchExists(trunk) { + 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 + } + } + trunkRef := "refs/heads/" + trunk + // --- Flag validation --- // --adopt is deprecated; print a notice and continue normally. @@ -122,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 } @@ -135,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 } @@ -146,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] } @@ -211,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 @@ -244,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 } @@ -263,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) @@ -331,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 687932e..1b11041 100644 --- a/cmd/init_test.go +++ b/cmd/init_test.go @@ -69,6 +69,65 @@ func TestInit_CustomTrunk(t *testing.T) { assert.Equal(t, "develop", sf.Stacks[0].Trunk.Branch) } +func TestInit_RestoresMissingLocalTrunkWhenTagResolves(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) { + // A tag named "main" can resolve even when the local branch is absent. + 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", "refs/heads/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{ 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`).