diff --git a/README.md b/README.md index af1f849..e447b17 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,9 @@ Initialize a new stack in the current repository. gh stack init [flags] [branches...] ``` -Creates an entry in `.git/gh-stack` to track stack state. In interactive mode (no arguments), prompts you to name branches and offers to use the current branch as the first layer. In interactive mode, you'll also be prompted to set an optional branch prefix (unless adopting existing branches). When a prefix is set, branch names you enter are automatically prefixed. When explicit branch names are given, creates any that don't already exist (branching from the trunk). The trunk defaults to the repository's default branch unless overridden with `--base`. +Initializes a new stack locally. In interactive mode (no arguments), prompts for a branch name and offers to use the current branch as the first layer. If a branch name contains slashes (e.g., `feat/api`), prompts if you would like to use a prefix (e.g., `feat/`) for all branches in the stack. + +When explicit branch names are given, existing branches are adopted automatically and any missing branches are created. The trunk defaults to the repository's default branch unless overridden with `--base`. Use `--numbered` with `--prefix` to enable auto-incrementing numbered branch names (`prefix/01`, `prefix/02`, …). Without `--numbered`, you'll always be prompted to provide a meaningful branch name. @@ -85,7 +87,6 @@ Enables `git rerere` automatically so that conflict resolutions are remembered a | Flag | Description | |------|-------------| | `-b, --base ` | Trunk branch for the stack (defaults to the repository's default branch) | -| `-a, --adopt` | Adopt existing branches into a stack instead of creating new ones | | `-p, --prefix ` | Set a branch name prefix for the stack | | `-n, --numbered` | Use auto-incrementing numbered branch names (requires `--prefix`) | @@ -102,7 +103,7 @@ gh stack init feature-auth feature-api feature-ui gh stack init --base develop feature-auth # Adopt existing branches into a stack -gh stack init --adopt feature-auth feature-api +gh stack init feature-auth feature-api # Set a prefix — you'll be prompted for a branch name gh stack init -p feat diff --git a/cmd/init.go b/cmd/init.go index 3db867a..f89025e 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -16,9 +16,9 @@ import ( type initOptions struct { branches []string base string - adopt bool prefix string numbered bool + adopt bool // deprecated, kept for backward compat } func InitCmd(cfg *config.Config) *cobra.Command { @@ -27,10 +27,28 @@ func InitCmd(cfg *config.Config) *cobra.Command { cmd := &cobra.Command{ Use: "init [branches...]", Short: "Initialize a new stack", - Long: `Initialize a stack object in the local repo. + Long: `Initialize a new stack of branches in the current repository. -Unless specified, prompts user to create/select branch for first layer of the stack. -Trunk defaults to default branch, unless specified otherwise.`, +You can pass multiple branch names to create a multi-layer stack in one +command. Existing branches are adopted automatically; missing branches are +created. By default, the first branch is based on the default branch, and +each subsequent branch is based on the previous one. + +Use --base to specify a different trunk branch.`, + Example: ` # Create a stack with a new branch + $ gh stack init my-feature + + # Create a multi-layer stack at once + $ gh stack init auth-layer api-routes ui-components + + # Adopt existing branches into a stack (bottom to top) + $ gh stack init feat/auth feat/api feat/ui + + # Create a stack with auto-numbered branches (feat/01, feat/02, etc.) + $ gh stack init --prefix feat --numbered + + # Specify a different trunk branch + $ gh stack init --base develop my-feature`, RunE: func(cmd *cobra.Command, args []string) error { opts.branches = args return runInit(cfg, opts) @@ -38,9 +56,10 @@ Trunk defaults to default branch, unless specified otherwise.`, } cmd.Flags().StringVarP(&opts.base, "base", "b", "", "Trunk branch for stack (defaults to default branch)") - cmd.Flags().BoolVarP(&opts.adopt, "adopt", "a", false, "Track existing branches as part of a stack") cmd.Flags().StringVarP(&opts.prefix, "prefix", "p", "", "Branch name prefix for the stack") cmd.Flags().BoolVarP(&opts.numbered, "numbered", "n", false, "Use auto-incrementing numbered branch names (requires --prefix)") + cmd.Flags().BoolVarP(&opts.adopt, "adopt", "a", false, "Deprecated: existing branches are now adopted automatically") + _ = cmd.Flags().MarkHidden("adopt") return cmd } @@ -95,27 +114,52 @@ func runInit(cfg *config.Config, opts *initOptions) error { } } - var branches []string + // --- Flag validation --- - // --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 + // --adopt is deprecated; print a notice and continue normally. + if opts.adopt { + cfg.Warningf("The --adopt flag is deprecated. Existing branches are now adopted automatically.") + cfg.Printf("You can simply run: %s", + cfg.ColorCyan("gh stack init ...")) } - // Validate --numbered requires a prefix (either from flag or interactive input, - // but for non-interactive paths we can check early). + // --numbered requires a prefix (either from flag or interactive input). 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 + // Validate explicit --prefix before 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 + } + } + + // --- Branch collection --- + + var branches []string + adopted := make(map[string]bool) // tracks which branches were adopted (existed already) + + if len(opts.branches) > 0 { + // === ARGS PATH === + branches, adopted, err = resolveArgBranches(cfg, opts, sf, trunk) + if err != nil { + return err + } + + // Prefix detection (only when --prefix not explicitly set) + if opts.prefix == "" { + if detected := detectPrefix(branches); detected != "" { + opts.prefix = detected + } + } + + } else if opts.numbered { + // === NUMBERED PATH (unchanged) === + if opts.prefix == "" && cfg.IsInteractive() { + p := prompter.New(cfg.In, cfg.Out, cfg.Err) prefixInput, err := p.Input("Enter a branch prefix (required for --numbered)", "") if err != nil { if isInterruptError(err) { @@ -130,153 +174,41 @@ func runInit(cfg *config.Config, opts *initOptions) error { 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 { - cfg.Errorf("--adopt requires at least one branch name") + branchName := branch.NextNumberedName(opts.prefix, nil) + if err := sf.ValidateNoDuplicateBranch(branchName); err != nil { + cfg.Errorf("branch %q already exists in a stack", branchName) return ErrInvalidArgs } - for _, b := range opts.branches { - if !git.BranchExists(b) { - cfg.Errorf("branch %q does not exist", b) - return ErrInvalidArgs - } - if err := sf.ValidateNoDuplicateBranch(b); err != nil { - cfg.Errorf("branch %q already exists in a stack", b) - return ErrInvalidArgs - } - } - branches = opts.branches - } else if len(opts.branches) > 0 { - // 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 - } - if !git.BranchExists(b) { - if err := git.CreateBranch(b, trunk); err != nil { - cfg.Errorf("creating branch %s: %s", b, err) - return ErrSilent - } + if git.BranchExists(branchName) { + adopted[branchName] = true + } else { + if err := git.CreateBranch(branchName, trunk); err != nil { + cfg.Errorf("creating branch %s: %s", branchName, err) + return ErrSilent } - prefixed = append(prefixed, b) } - branches = prefixed + branches = []string{branchName} + } else { - // Interactive mode — prefix was already prompted for above + // === INTERACTIVE PATH === if !cfg.IsInteractive() { - cfg.Errorf("interactive input required; provide branch names or use --adopt") + cfg.Errorf("interactive input required; provide branch names as arguments") return ErrInvalidArgs } - p := prompter.New(cfg.In, cfg.Out, cfg.Err) - if opts.numbered { - // Auto-generate numbered branch name - branchName := branch.NextNumberedName(opts.prefix, nil) - if err := sf.ValidateNoDuplicateBranch(branchName); err != nil { - cfg.Errorf("branch %q already exists in a stack", branchName) - return ErrInvalidArgs - } - if !git.BranchExists(branchName) { - if err := git.CreateBranch(branchName, trunk); err != nil { - cfg.Errorf("creating branch %s: %s", branchName, err) - return ErrSilent - } - } - branches = []string{branchName} - } else { - if currentBranch != "" && currentBranch != trunk { - // Already on a non-trunk branch — offer to use it - useCurrentBranch, err := p.Confirm( - fmt.Sprintf("Would you like to use %s as the first layer of your stack?", currentBranch), - true, - ) - if err != nil { - if isInterruptError(err) { - printInterrupt(cfg) - return ErrSilent - } - cfg.Errorf("failed to confirm branch selection: %s", err) - return ErrSilent - } - if useCurrentBranch { - if err := sf.ValidateNoDuplicateBranch(currentBranch); err != nil { - cfg.Errorf("branch %q already exists in the stack", currentBranch) - return ErrInvalidArgs - } - branches = []string{currentBranch} - } - } - - if len(branches) == 0 { - prompt := "What branch would you like to use as the first layer of your stack?" - if opts.prefix != "" { - prompt = fmt.Sprintf("Enter a name for the first branch (will be prefixed with %s/)", opts.prefix) - } - branchName, err := p.Input(prompt, "") - if err != nil { - if isInterruptError(err) { - printInterrupt(cfg) - return ErrSilent - } - cfg.Errorf("failed to read branch name: %s", err) - return ErrSilent - } - branchName = strings.TrimSpace(branchName) - - if branchName == "" { - cfg.Errorf("branch name cannot be empty") - return ErrInvalidArgs - } - - if opts.prefix != "" { - branchName = opts.prefix + "/" + branchName - } - - if err := sf.ValidateNoDuplicateBranch(branchName); err != nil { - cfg.Errorf("branch %q already exists in a stack", branchName) - return ErrInvalidArgs - } - if !git.BranchExists(branchName) { - if err := git.CreateBranch(branchName, trunk); err != nil { - cfg.Errorf("creating branch %s: %s", branchName, err) - return ErrSilent - } - } - branches = []string{branchName} - } + var interactiveAdopted bool + branches, interactiveAdopted, err = runInteractiveInit(cfg, sf, trunk, currentBranch, opts) + if err != nil { + return err + } + if interactiveAdopted { + adopted[branches[0]] = true } } - // Build stack + // --- Build stack --- + trunkSHA, _ := git.RevParse(trunk) branchRefs := make([]stack.BranchRef, len(branches)) for i, b := range branches { @@ -300,55 +232,291 @@ func runInit(cfg *config.Config, opts *initOptions) error { sf.AddStack(newStack) - // Discover existing PRs for the new stack's branches. - // For adopt, only record open/draft PRs (ignore closed/merged). - // For non-adopt, use the standard sync which also detects merges. + // --- PR detection --- + // Use FindPRForBranch for all branches. For adopted branches this + // finds existing PRs; for created branches it harmlessly returns nil. latestStack := &sf.Stacks[len(sf.Stacks)-1] - if opts.adopt { - if client, clientErr := cfg.GitHubClient(); clientErr == nil { - for i := range latestStack.Branches { - b := &latestStack.Branches[i] - pr, err := client.FindPRForBranch(b.Branch) - if err != nil || pr == nil { - continue - } - b.PullRequest = &stack.PullRequestRef{ - Number: pr.Number, - ID: pr.ID, - URL: pr.URL, - } + prCount := 0 + if client, clientErr := cfg.GitHubClient(); clientErr == nil { + for i := range latestStack.Branches { + b := &latestStack.Branches[i] + pr, err := client.FindPRForBranch(b.Branch) + if err != nil || pr == nil { + continue } + b.PullRequest = &stack.PullRequestRef{ + Number: pr.Number, + ID: pr.ID, + URL: pr.URL, + } + prCount++ } - } else { - _ = syncStackPRs(cfg, latestStack) } if err := stack.Save(gitDir, sf); err != nil { return handleSaveError(cfg, err) } - // Print result - if opts.adopt { - cfg.Printf("Adopting stack with trunk %s and %d branches", trunk, len(branches)) - cfg.Printf("Initializing stack: %s", newStack.DisplayChain()) - cfg.Printf("You can continue working on %s", branches[len(branches)-1]) - } else { - cfg.Successf("Creating stack with trunk %s and branch %s", trunk, branches[len(branches)-1]) - // Switch to last branch if not already there - lastBranch := branches[len(branches)-1] - if currentBranch != lastBranch { - if err := git.CheckoutBranch(lastBranch); err != nil { - cfg.Errorf("switching to branch %s: %s", lastBranch, err) - return ErrSilent + // --- Output: switch to top branch + "What's next" --- + + lastBranch := branches[len(branches)-1] + if currentBranch != lastBranch { + if err := git.CheckoutBranch(lastBranch); err != nil { + cfg.Errorf("switching to branch %s: %s", lastBranch, err) + return ErrSilent + } + } + + hasAdopted := len(adopted) > 0 + + printWhatsNext(cfg, &newStack, branches, hasAdopted, prCount) + + return nil +} + +// 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) { + adopted := make(map[string]bool) + + // Phase 1: resolve final names, classify, validate + type branchInfo struct { + name string + exists bool + } + resolved := make([]branchInfo, 0, len(opts.branches)) + + for _, b := range opts.branches { + // Apply explicit --prefix (not detected prefix) + if opts.prefix != "" { + b = opts.prefix + "/" + b + } + + // Validate ref name before checking existence or creating + if err := git.ValidateRefName(b); err != nil { + cfg.Errorf("invalid branch name %q: must be a valid git ref", b) + return nil, nil, ErrInvalidArgs + } + + exists := git.BranchExists(b) + + if err := sf.ValidateNoDuplicateBranch(b); err != nil { + cfg.Errorf("branch %q already exists in a stack", b) + return nil, nil, ErrInvalidArgs + } + + resolved = append(resolved, branchInfo{name: b, exists: exists}) + } + + // Phase 2: create missing branches + branches := make([]string, 0, len(resolved)) + for i, bi := range resolved { + if bi.exists { + adopted[bi.name] = true + } else { + parent := trunk + if i > 0 { + parent = resolved[i-1].name + } + if err := git.CreateBranch(bi.name, parent); err != nil { + cfg.Errorf("creating branch %s: %s", bi.name, err) + return nil, nil, ErrSilent + } + } + branches = append(branches, bi.name) + } + + return branches, adopted, nil +} + +// runInteractiveInit runs the interactive init flow: prints hint about +// multi-branch args, offers current branch or new branch, then runs +// prefix detection. Returns the branches and whether the branch was adopted +// (already existed). +func runInteractiveInit(cfg *config.Config, sf *stack.StackFile, trunk, currentBranch string, opts *initOptions) ([]string, bool, error) { + p := prompter.New(cfg.In, cfg.Out, cfg.Err) + + cfg.Printf("Initializing a stack from %s.", trunk) + cfg.Printf("Have multiple branches already? Run: %s", + cfg.ColorCyan("gh stack init ...")) + cfg.Printf("") + + var branchName string + + if currentBranch != "" && currentBranch != trunk { + // On a non-trunk branch — offer select + options := []string{ + fmt.Sprintf("Use current branch (%s) as the first layer", currentBranch), + "Create a new branch", + } + selectFn := func(prompt, def string, opts []string) (int, error) { + if cfg.SelectFn != nil { + return cfg.SelectFn(prompt, def, opts) } - cfg.Printf("Switched to branch %s", lastBranch) + return p.Select(prompt, def, opts) + } + selected, err := selectFn("What do you want to start with?", "", options) + if err != nil { + if isInterruptError(err) { + if cfg.SelectFn == nil { + clearSelectPrompt(cfg, len(options)) + } + printInterrupt(cfg) + return nil, false, ErrSilent + } + cfg.Errorf("failed to read selection: %s", err) + return nil, false, ErrSilent + } + + if selected == 0 { + // Use current branch + if err := sf.ValidateNoDuplicateBranch(currentBranch); err != nil { + cfg.Errorf("branch %q already exists in a stack", currentBranch) + return nil, false, ErrInvalidArgs + } + branchName = currentBranch } else { - cfg.Printf("You can continue working on %s", lastBranch) + // Create a new branch — fall through to input prompt + name, err := promptBranchName(cfg, p, opts.prefix) + if err != nil { + return nil, false, err + } + branchName = name + } + } else { + // On trunk or detached HEAD — prompt for name directly + name, err := promptBranchName(cfg, p, opts.prefix) + if err != nil { + return nil, false, err } + branchName = name } - cfg.Printf("To add a new layer to your stack, run `%s`", cfg.ColorCyan("gh stack add")) - cfg.Printf("When you're ready to push to GitHub and open a stack of PRs, run `%s`", cfg.ColorCyan("gh stack submit")) + // Validate and create branch (track whether it was adopted) + wasAdopted := false + if err := sf.ValidateNoDuplicateBranch(branchName); err != nil { + cfg.Errorf("branch %q already exists in a stack", branchName) + return nil, false, ErrInvalidArgs + } + if git.BranchExists(branchName) { + wasAdopted = true + } else { + if err := git.CreateBranch(branchName, trunk); err != nil { + cfg.Errorf("creating branch %s: %s", branchName, err) + return nil, false, ErrSilent + } + } - return nil + // Prefix detection (interactive path, no --prefix flag) + if opts.prefix == "" { + if lastSlash := strings.LastIndex(branchName, "/"); lastSlash > 0 { + detected := branchName[:lastSlash] + usePrefix, err := p.Confirm( + fmt.Sprintf("Use %q as a prefix for new branches in this stack?", detected+"/"), + true, + ) + if err != nil { + if isInterruptError(err) { + printInterrupt(cfg) + return nil, false, ErrSilent + } + // Not fatal — just skip prefix + } else if usePrefix { + opts.prefix = detected + } + } + } + + return []string{branchName}, wasAdopted, nil +} + +// promptBranchName prompts the user for a branch name, applying the +// explicit --prefix if set. +func promptBranchName(cfg *config.Config, p *prompter.Prompter, prefix string) (string, error) { + prompt := "What's the name of the first branch?" + if prefix != "" { + prompt = fmt.Sprintf("Enter a name for the first branch (will be prefixed with %s/)", prefix) + } + branchName, err := p.Input(prompt, "") + if err != nil { + if isInterruptError(err) { + printInterrupt(cfg) + return "", ErrSilent + } + cfg.Errorf("failed to read branch name: %s", err) + return "", ErrSilent + } + branchName = strings.TrimSpace(branchName) + if branchName == "" { + cfg.Errorf("branch name cannot be empty") + return "", ErrInvalidArgs + } + if prefix != "" { + branchName = prefix + "/" + branchName + } + return branchName, nil +} + +// detectPrefix finds a common prefix across branches by splitting each +// at its last slash. Returns the prefix (without trailing slash) if all +// branches share the same one, or "" otherwise. +func detectPrefix(branches []string) string { + if len(branches) == 0 { + return "" + } + var common string + for i, b := range branches { + lastSlash := strings.LastIndex(b, "/") + if lastSlash <= 0 { + return "" // no slash or leading slash — no prefix + } + prefix := b[:lastSlash] + if i == 0 { + common = prefix + } else if prefix != common { + return "" // different prefixes + } + } + return common +} + +// printWhatsNext prints the scenario-aware "What's next" block after init. +func printWhatsNext(cfg *config.Config, s *stack.Stack, branches []string, hasAdopted bool, prCount int) { + lastBranch := branches[len(branches)-1] + + // Build the chain: main → branch1 → branch2 + parts := []string{s.Trunk.Branch} + for _, b := range s.Branches { + parts = append(parts, b.Branch) + } + chain := strings.Join(parts, " → ") + + // Success line + if hasAdopted { + cfg.Successf("Adopted %d %s: %s", + len(branches), plural(len(branches), "branch", "branches"), chain) + } else { + cfg.Successf("Created stack: %s", chain) + } + + // Position + cfg.Printf(" You're on %s (top of stack).", lastBranch) + + // PR summary (only when adopting and at least one PR found) + if hasAdopted && prCount > 0 { + cfg.Printf(" Found PRs for %d of %d %s.", + prCount, len(branches), plural(len(branches), "branch", "branches")) + } + + cfg.Printf("") + cfg.Printf("What's next:") + if hasAdopted { + cfg.Printf(" • see the full stack: %s", cfg.ColorCyan("gh stack view")) + cfg.Printf(" • move between branches: %s", cfg.ColorCyan("gh stack switch")) + cfg.Printf(" • link these PRs into a Stack on GitHub: %s", cfg.ColorCyan("gh stack submit")) + } else { + cfg.Printf(" • commit your work as usual, then add a layer: %s", cfg.ColorCyan("gh stack add")) + cfg.Printf(" • see your stack any time: %s", cfg.ColorCyan("gh stack view")) + cfg.Printf(" • when ready to open PRs: %s", cfg.ColorCyan("gh stack submit")) + } } diff --git a/cmd/init_test.go b/cmd/init_test.go index 3ed70bc..8b92b03 100644 --- a/cmd/init_test.go +++ b/cmd/init_test.go @@ -166,47 +166,31 @@ 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) { +func TestInit_AdoptFlagShowsDeprecationWarning(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 }, + BranchExistsFn: func(string) bool { return true }, }) defer restore() cfg, outR, errR := config.NewTestConfig() - err := runInit(cfg, &initOptions{adopt: true, branches: []string{"b1"}, numbered: true}) + err := runInit(cfg, &initOptions{adopt: true, branches: []string{"b1"}}) output := collectOutput(cfg, outR, errR) - assert.ErrorIs(t, err, ErrInvalidArgs) - assert.Contains(t, output, "--adopt cannot be combined with --prefix or --numbered") + require.NoError(t, err) + assert.Contains(t, output, "--adopt flag is deprecated") } func TestInit_RerereAlreadyEnabled(t *testing.T) { gitDir := t.TempDir() enableRerereCalled := false 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 }, + GitDirFn: func() (string, error) { return gitDir, nil }, + DefaultBranchFn: func() (string, error) { return "main", nil }, + CurrentBranchFn: func() (string, error) { return "main", nil }, IsRerereEnabledFn: func() (bool, error) { return true, nil }, EnableRerereFn: func() error { enableRerereCalled = true @@ -249,21 +233,29 @@ func TestInit_RefuseIfBranchAlreadyInStack(t *testing.T) { assert.Contains(t, output, "already part of a stack") } -func TestInit_AdoptNonexistentBranch(t *testing.T) { +func TestInit_AdoptNonexistentBranch_CreatesIt(t *testing.T) { + // --adopt with missing branch now creates it (no error, just a deprecation warning) 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 }, BranchExistsFn: func(string) bool { return false }, + CreateBranchFn: func(name, base string) error { + created = append(created, name) + return nil + }, }) defer restore() cfg, outR, errR := config.NewTestConfig() - runInit(cfg, &initOptions{branches: []string{"nonexistent"}, adopt: true}) + err := runInit(cfg, &initOptions{branches: []string{"nonexistent"}, adopt: true}) output := collectOutput(cfg, outR, errR) - assert.Contains(t, output, "does not exist") + require.NoError(t, err) + assert.Contains(t, output, "--adopt flag is deprecated") + assert.Equal(t, []string{"nonexistent"}, created) } func TestInit_MultipleBranches_CreatesAll(t *testing.T) { @@ -379,3 +371,450 @@ func TestInit_AdoptIgnoresClosedAndMergedPRs(t *testing.T) { assert.Nil(t, b.PullRequest, "closed/merged PRs should not be recorded for branch %s", b.Branch) } } + +// --- Tests for spec scenarios --- + +func TestInit_ImplicitAdopt_AllExist(t *testing.T) { + // Scenario 8: all branches exist → implicit adopt, PR detection runs + 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 }, + BranchExistsFn: func(string) bool { return true }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + FindPRForBranchFn: func(branch string) (*github.PullRequest, error) { + if branch == "b1" { + return &github.PullRequest{Number: 10, ID: "PR_10", URL: "https://example.com/10"}, nil + } + return nil, nil + }, + } + + err := runInit(cfg, &initOptions{branches: []string{"b1", "b2", "b3"}}) + output := collectOutput(cfg, outR, errR) + + require.NoError(t, err) + require.NotContains(t, output, "\u2717") + assert.Contains(t, output, "Adopted") + assert.Contains(t, output, "Found PRs for 1 of 3") + + sf, _ := stack.Load(gitDir) + assert.Equal(t, []string{"b1", "b2", "b3"}, sf.Stacks[0].BranchNames()) + assert.NotNil(t, sf.Stacks[0].Branches[0].PullRequest) +} + +func TestInit_ImplicitAdopt_AllMissing(t *testing.T) { + // Scenario 7: all branches missing → create all + 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", "b3"}}) + output := collectOutput(cfg, outR, errR) + + require.NoError(t, err) + require.NotContains(t, output, "\u2717") + assert.Contains(t, output, "Created stack") + assert.NotContains(t, output, "Adopted") + assert.Equal(t, []string{"b1", "b2", "b3"}, created) +} + +func TestInit_ImplicitAdopt_Mixed(t *testing.T) { + // Scenario 11: mixed → adopts existing, creates missing + gitDir := t.TempDir() + existing := map[string]bool{"existing1": true, "existing2": true} + 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 }, + BranchExistsFn: func(name string) bool { return existing[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{"existing1", "new1", "existing2"}}) + output := collectOutput(cfg, outR, errR) + + require.NoError(t, err) + require.NotContains(t, output, "\u2717") + assert.Contains(t, output, "Adopted") + assert.Equal(t, []string{"new1"}, created) + + sf, _ := stack.Load(gitDir) + assert.Equal(t, []string{"existing1", "new1", "existing2"}, sf.Stacks[0].BranchNames()) +} + +func TestInit_PrefixDetection_ArgsCommonPrefix(t *testing.T) { + // Scenario 9: args all share prefix → set silently + 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 }, + CreateBranchFn: func(name, base string) error { return nil }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + err := runInit(cfg, &initOptions{branches: []string{"feat/a", "feat/b", "feat/c"}}) + collectOutput(cfg, outR, errR) + + require.NoError(t, err) + sf, _ := stack.Load(gitDir) + assert.Equal(t, "feat", sf.Stacks[0].Prefix) +} + +func TestInit_PrefixDetection_ArgsMixedPrefix(t *testing.T) { + // Scenario 10: args mixed prefixes → no prefix + 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 }, + CreateBranchFn: func(name, base string) error { return nil }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + err := runInit(cfg, &initOptions{branches: []string{"feat/a", "bug/b"}}) + collectOutput(cfg, outR, errR) + + require.NoError(t, err) + sf, _ := stack.Load(gitDir) + assert.Equal(t, "", sf.Stacks[0].Prefix) +} + +func TestInit_PrefixDetection_ArgsNoSlash(t *testing.T) { + // No slashes → no prefix + 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 }, + CreateBranchFn: func(name, base string) error { return nil }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + err := runInit(cfg, &initOptions{branches: []string{"auth", "api", "ui"}}) + collectOutput(cfg, outR, errR) + + require.NoError(t, err) + sf, _ := stack.Load(gitDir) + assert.Equal(t, "", sf.Stacks[0].Prefix) +} + +func TestInit_PrefixDetection_NestedPrefix(t *testing.T) { + // Scenario 6: sameen/feat/x → prefix "sameen/feat" + 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 }, + CreateBranchFn: func(name, base string) error { return nil }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + err := runInit(cfg, &initOptions{branches: []string{"sameen/feat/a", "sameen/feat/b"}}) + collectOutput(cfg, outR, errR) + + require.NoError(t, err) + sf, _ := stack.Load(gitDir) + assert.Equal(t, "sameen/feat", sf.Stacks[0].Prefix) +} + +func TestInit_ExplicitPrefixSkipsDetection(t *testing.T) { + // Scenario 14: --prefix with args → explicit wins + 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 }, + CreateBranchFn: func(name, base string) error { return nil }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + err := runInit(cfg, &initOptions{branches: []string{"b1", "b2"}, prefix: "foo"}) + collectOutput(cfg, outR, errR) + + require.NoError(t, err) + sf, _ := stack.Load(gitDir) + assert.Equal(t, "foo", sf.Stacks[0].Prefix) + assert.Equal(t, []string{"foo/b1", "foo/b2"}, sf.Stacks[0].BranchNames()) +} + +func TestInit_WhatsNext_Fresh(t *testing.T) { + // Scenario 17: fresh single-branch → fresh format + 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 }, + CreateBranchFn: func(name, base string) error { return nil }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + runInit(cfg, &initOptions{branches: []string{"my-feature"}}) + output := collectOutput(cfg, outR, errR) + + assert.Contains(t, output, "Created stack") + assert.Contains(t, output, "main → my-feature") + assert.Contains(t, output, "top of stack") + assert.Contains(t, output, "What's next:") + assert.Contains(t, output, "gh stack add") + assert.Contains(t, output, "gh stack view") + assert.Contains(t, output, "gh stack submit") + assert.NotContains(t, output, "Adopted") +} + +func TestInit_WhatsNext_AdoptedWithPRs(t *testing.T) { + // Scenario 18: adopted multi-branch, 2 of 3 PRs → adopt format with PR count + 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 }, + BranchExistsFn: func(string) bool { return true }, + }) + defer restore() + + prBranches := map[string]bool{"b1": true, "b3": true} + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + FindPRForBranchFn: func(branch string) (*github.PullRequest, error) { + if prBranches[branch] { + return &github.PullRequest{Number: 1, ID: "PR_1", URL: "https://example.com/1"}, nil + } + return nil, nil + }, + } + + runInit(cfg, &initOptions{branches: []string{"b1", "b2", "b3"}}) + output := collectOutput(cfg, outR, errR) + + assert.Contains(t, output, "Adopted 3 branches") + assert.Contains(t, output, "Found PRs for 2 of 3") + assert.Contains(t, output, "What's next:") + assert.Contains(t, output, "gh stack view") + assert.Contains(t, output, "gh stack switch") + assert.Contains(t, output, "gh stack submit") +} + +func TestInit_WhatsNext_AdoptedNoPRs(t *testing.T) { + // Scenario 19: adopted, 0 PRs → no PR summary line + 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 }, + BranchExistsFn: func(string) bool { return true }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + FindPRForBranchFn: func(branch string) (*github.PullRequest, error) { + return nil, nil + }, + } + + runInit(cfg, &initOptions{branches: []string{"b1", "b2"}}) + output := collectOutput(cfg, outR, errR) + + assert.Contains(t, output, "Adopted 2 branches") + assert.NotContains(t, output, "Found PRs") +} + +func TestInit_WhatsNext_MixedWithPR(t *testing.T) { + // Scenario 20: mixed (1 adopted, 1 created), 1 PR → adopt format + 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 }, + BranchExistsFn: func(name string) bool { return name == "existing" }, + CreateBranchFn: func(name, base string) error { return nil }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + FindPRForBranchFn: func(branch string) (*github.PullRequest, error) { + if branch == "existing" { + return &github.PullRequest{Number: 5, ID: "PR_5", URL: "https://example.com/5"}, nil + } + return nil, nil + }, + } + + runInit(cfg, &initOptions{branches: []string{"existing", "new-branch"}}) + output := collectOutput(cfg, outR, errR) + + assert.Contains(t, output, "Adopted") + assert.Contains(t, output, "Found PRs for 1 of 2") +} + +func TestInit_Interactive_OnTrunk(t *testing.T) { + // Scenario 1: on trunk → shows hint about multi-branch args + // Note: full interactive Input() prompt requires TTY; test via args path instead. + // Here we verify that the hint line appears and non-interactive errors correctly. + 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() + // Not interactive → should error with guidance + err := runInit(cfg, &initOptions{}) + output := collectOutput(cfg, outR, errR) + + assert.ErrorIs(t, err, ErrInvalidArgs) + assert.Contains(t, output, "interactive input required") +} + +func TestInit_Interactive_OnFeatureBranch_UseCurrent(t *testing.T) { + // Scenario 2: on feature branch → select, choose "use current" + 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 "feat/auth", nil }, + BranchExistsFn: func(name string) bool { return name == "feat/auth" }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + cfg.ForceInteractive = true + // Select option 0 = "Use current branch" + cfg.SelectFn = func(prompt, def string, options []string) (int, error) { + return 0, nil + } + + err := runInit(cfg, &initOptions{}) + output := collectOutput(cfg, outR, errR) + + require.NoError(t, err) + assert.Contains(t, output, "Initializing a stack from main") + // Branch already exists → should be treated as adopted + assert.Contains(t, output, "Adopted") + + sf, _ := stack.Load(gitDir) + require.Len(t, sf.Stacks, 1) + assert.Equal(t, []string{"feat/auth"}, sf.Stacks[0].BranchNames()) + // Prefix detection Y/n prompt fails gracefully without a TTY, + // so prefix is not set. The args-path prefix detection is tested separately. +} + +func TestInit_TwoPassValidation_NoBranchCreatedOnError(t *testing.T) { + // Verify that if arg 3 fails validation, args 1 and 2 are NOT created + gitDir := t.TempDir() + + // Pre-create a stack with "dup" branch + sf := &stack.StackFile{ + SchemaVersion: 1, + Stacks: []stack.Stack{{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "dup"}}, + }}, + } + require.NoError(t, stack.Save(gitDir, sf)) + + 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{"new1", "new2", "dup"}}) + output := collectOutput(cfg, outR, errR) + + assert.ErrorIs(t, err, ErrInvalidArgs) + assert.Contains(t, output, "already exists in a stack") + assert.Empty(t, created, "no branches should be created when later arg fails validation") +} + +func TestInit_TwoPassValidation_InvalidRefName(t *testing.T) { + // Verify that an invalid ref name in the args list prevents any branch creation + 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 { + if name == "invalid..name" { + return fmt.Errorf("invalid ref name: %s", name) + } + return 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{"valid-branch", "invalid..name", "another-branch"}}) + output := collectOutput(cfg, outR, errR) + + assert.ErrorIs(t, err, ErrInvalidArgs) + assert.Contains(t, output, "invalid branch name") + assert.Empty(t, created, "no branches should be created when an arg has an invalid ref name") +} + +func TestDetectPrefix(t *testing.T) { + tests := []struct { + name string + branches []string + want string + }{ + {"common prefix", []string{"feat/a", "feat/b", "feat/c"}, "feat"}, + {"nested prefix", []string{"sameen/feat/a", "sameen/feat/b"}, "sameen/feat"}, + {"mixed prefixes", []string{"feat/a", "bug/b"}, ""}, + {"no slashes", []string{"auth", "api", "ui"}, ""}, + {"empty list", []string{}, ""}, + {"single branch with slash", []string{"feat/x"}, "feat"}, + {"single branch no slash", []string{"auth"}, ""}, + {"leading slash only", []string{"/x"}, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := detectPrefix(tt.branches) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/docs/src/content/docs/faq.md b/docs/src/content/docs/faq.md index 0365e8f..6a587bd 100644 --- a/docs/src/content/docs/faq.md +++ b/docs/src/content/docs/faq.md @@ -35,17 +35,17 @@ You can also add PRs to an existing stack from the GitHub UI. See [Adding to an Use `gh stack modify` to restructure a stack. It opens an interactive terminal UI where you can reorder, drop, fold (combine), and rename branches — then applies all changes at once. See the [Restructuring Stacks](/gh-stack/guides/modify/) guide for a full walkthrough. -Alternatively, you can manually tear down and re-create the stack with `gh stack unstack` and `gh stack init --adopt`: +Alternatively, you can manually tear down and re-create the stack with `gh stack unstack` and `gh stack init`: ```sh # 1. Remove the stack gh stack unstack # 2. Make structural changes (reorder, rename, delete branches) -git branch -m old-name new-name +git branch -m api-roots api-routes # 3. Re-create the stack with the new structure -gh stack init --adopt branch-1 branch-2 branch-3 +gh stack init db-migrations api-routes frontend ``` ### How do I delete my stack? @@ -263,9 +263,9 @@ You can also use `--base` to specify a different trunk branch and `--open` to ma gh stack link --base develop --open change1 change2 change3 ``` -Alternatively, if you want full local stack tracking (for commands like `rebase`, `sync`, and navigation), you can adopt existing branches to local tracking with `gh stack`: +Alternatively, if you want full local stack tracking (for commands like `rebase`, `sync`, and navigation), you can adopt existing branches to local tracking with `gh stack init`: ```bash -gh stack init --adopt change1 change2 change3 +gh stack init change1 change2 change3 gh stack submit ``` diff --git a/docs/src/content/docs/guides/workflows.md b/docs/src/content/docs/guides/workflows.md index 1237874..d4a1470 100644 --- a/docs/src/content/docs/guides/workflows.md +++ b/docs/src/content/docs/guides/workflows.md @@ -137,6 +137,34 @@ This command: If a conflict is detected during the rebase, all branches are restored to their original state, and you're advised to run `gh stack rebase` to resolve conflicts interactively. +## Existing Branches into a Stack + +If you already have a set of branches that form a logical chain, you can organize them into a stack by passing them to `gh stack init`. Existing branches are adopted automatically — no special flags needed. + +```sh +# Adopt three existing branches into a stack (bottom to top) +gh stack init feat/auth feat/api feat/ui +``` + +The order matters: branches are listed from bottom (closest to trunk) to top (furthest from trunk). Any PRs already open for these branches are detected and linked to the stack. + +You can also mix existing and new branches in one command: + +```sh +# feat/auth exists, feat/api-v2 will be created +gh stack init feat/auth feat/api-v2 +``` + +After organizing branches into a stack, run `gh stack submit` to create a Stack on GitHub and link the PRs together. + +```sh +# View the new stack +gh stack view + +# Create/update PRs and link them as a Stack on GitHub +gh stack submit +``` + ## Structuring Your Stack Think of a stack from the reviewer's perspective: the PRs should tell a **cohesive story**. A reviewer reading the PRs in sequence should understand the progression of changes. diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index f2bac85..9cab10b 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -27,9 +27,9 @@ Initialize a new stack in the current repository. gh stack init [flags] [branches...] ``` -Creates an entry in `.git/gh-stack` to track stack state. In interactive mode (no arguments), prompts you to name branches and offers to use the current branch as the first layer. You'll also be prompted to set an optional branch prefix. When a prefix is set, branch names you enter are automatically prefixed. +Initializes a new stack locally. In interactive mode (no arguments), prompts for a branch name and offers to use the current branch as the first layer. If a branch name contains slashes (e.g., `feat/api`), prompts if you would like to use a prefix (e.g., `feat/`) for all branches in the stack. -When explicit branch names are given, creates any that don't already exist (branching from the trunk). The trunk defaults to the repository's default branch unless overridden with `--base`. +When explicit branch names are given, existing branches are adopted automatically and any missing branches are created. The trunk defaults to the repository's default branch unless overridden with `--base`. Use `--numbered` with `--prefix` to enable auto-incrementing branch names (`prefix/01`, `prefix/02`, …). @@ -38,7 +38,6 @@ Enables `git rerere` automatically so that conflict resolutions are remembered a | Flag | Description | |------|-------------| | `-b, --base ` | Trunk branch for the stack (defaults to the repository's default branch) | -| `-a, --adopt` | Adopt existing branches into a stack instead of creating new ones | | `-p, --prefix ` | Set a branch name prefix for the stack | | `-n, --numbered` | Use auto-incrementing numbered branch names (requires `--prefix`) | @@ -48,14 +47,14 @@ Enables `git rerere` automatically so that conflict resolutions are remembered a # Interactive — prompts for branch names gh stack init -# Non-interactive — specify branches upfront -gh stack init feature-auth feature-api feature-ui +# Non-interactive — specify first branch upfront +gh stack init feature-auth # Use a different trunk branch gh stack init --base develop feature-auth -# Adopt existing branches into a stack -gh stack init --adopt feature-auth feature-api +# Adopt or create multiple branches at once +gh stack init feature-auth feature-api feature-ui # Set a prefix — prompts for a branch name suffix gh stack init -p feat @@ -235,7 +234,7 @@ You must have a branch from the stack checked out locally. The command targets t Deletes the stack on GitHub first, if it exists, then removes it from local tracking. If the remote deletion fails, the local state is left untouched so you can retry. Use `--local` to skip the remote deletion and only remove local tracking. -This is useful when you need to restructure a stack — remove a branch, reorder branches, rename branches, or make other large changes. After unstacking, use `gh stack init --adopt` to re-create the stack with the desired structure. +This is useful when you need to restructure a stack — remove a branch, reorder branches, rename branches, or make other large changes. After unstacking, use `gh stack init` to re-create the stack with the desired structure — existing branches are adopted automatically. | Flag | Description | |------|-------------| diff --git a/internal/git/mock_ops.go b/internal/git/mock_ops.go index 05b8c51..ed123c5 100644 --- a/internal/git/mock_ops.go +++ b/internal/git/mock_ops.go @@ -4,47 +4,47 @@ package git // Each field is an optional function that, when set, handles the corresponding // Ops method call. When nil, a reasonable default is returned. type MockOps struct { - GitDirFn func() (string, error) - RootDirFn func() (string, error) - CurrentBranchFn func() (string, error) - BranchExistsFn func(string) bool - CheckoutBranchFn func(string) error - FetchFn func(string) error - FetchBranchesFn func(string, []string) error - DefaultBranchFn func() (string, error) - CreateBranchFn func(string, string) error - PushFn func(string, []string, bool, bool) error - ResolveRemoteFn func(string) (string, error) - RebaseFn func(string) error - EnableRerereFn func() error - IsRerereEnabledFn func() (bool, error) - IsRerereDeclinedFn func() (bool, error) - SaveRerereDeclinedFn func() error - RebaseOntoFn func(string, string, string) error - RebaseContinueFn func() error - RebaseAbortFn func() error - IsRebaseInProgressFn func() bool - ConflictedFilesFn func() ([]string, error) - FindConflictMarkersFn func(string) (*ConflictMarkerInfo, error) - IsAncestorFn func(string, string) (bool, error) - RevParseFn func(string) (string, error) - RevParseMultiFn func([]string) ([]string, error) - MergeBaseFn func(string, string) (string, error) - LogFn func(string, int) ([]CommitInfo, error) - LogRangeFn func(string, string) ([]CommitInfo, error) - DiffStatRangeFn func(string, string) (int, int, error) - DiffStatFilesFn func(string, string) ([]FileDiffStat, error) - DeleteBranchFn func(string, bool) error - DeleteRemoteBranchFn func(string, string) error - ResetHardFn func(string) error - SetUpstreamTrackingFn func(string, string) error - MergeFFFn func(string) error - UpdateBranchRefFn func(string, string) error - StageAllFn func() error - StageTrackedFn func() error - HasStagedChangesFn func() bool - CommitFn func(string) (string, error) - CommitInteractiveFn func() (string, error) + GitDirFn func() (string, error) + RootDirFn func() (string, error) + CurrentBranchFn func() (string, error) + BranchExistsFn func(string) bool + CheckoutBranchFn func(string) error + FetchFn func(string) error + FetchBranchesFn func(string, []string) error + DefaultBranchFn func() (string, error) + CreateBranchFn func(string, string) error + PushFn func(string, []string, bool, bool) error + ResolveRemoteFn func(string) (string, error) + RebaseFn func(string) error + EnableRerereFn func() error + IsRerereEnabledFn func() (bool, error) + IsRerereDeclinedFn func() (bool, error) + SaveRerereDeclinedFn func() error + RebaseOntoFn func(string, string, string) error + RebaseContinueFn func() error + RebaseAbortFn func() error + IsRebaseInProgressFn func() bool + ConflictedFilesFn func() ([]string, error) + FindConflictMarkersFn func(string) (*ConflictMarkerInfo, error) + IsAncestorFn func(string, string) (bool, error) + RevParseFn func(string) (string, error) + RevParseMultiFn func([]string) ([]string, error) + MergeBaseFn func(string, string) (string, error) + LogFn func(string, int) ([]CommitInfo, error) + LogRangeFn func(string, string) ([]CommitInfo, error) + DiffStatRangeFn func(string, string) (int, int, error) + DiffStatFilesFn func(string, string) ([]FileDiffStat, error) + DeleteBranchFn func(string, bool) error + DeleteRemoteBranchFn func(string, string) error + ResetHardFn func(string) error + SetUpstreamTrackingFn func(string, string) error + MergeFFFn func(string) error + UpdateBranchRefFn func(string, string) error + StageAllFn func() error + StageTrackedFn func() error + HasStagedChangesFn func() bool + CommitFn func(string) (string, error) + CommitInteractiveFn func() (string, error) ValidateRefNameFn func(string) error RenameBranchFn func(string, string) error CherryPickFn func([]string) error diff --git a/skills/gh-stack/SKILL.md b/skills/gh-stack/SKILL.md index 8b57a5e..7f7105e 100644 --- a/skills/gh-stack/SKILL.md +++ b/skills/gh-stack/SKILL.md @@ -141,7 +141,8 @@ Small, incidental fixes (e.g., fixing a typo you noticed) can go in the current |------|---------| | Create a stack (recommended) | `gh stack init -p feat auth` | | Create a stack without prefix | `gh stack init auth` | -| Adopt existing branches | `gh stack init --adopt branch-a branch-b` | +| Create a stack of multiple branches | `gh stack init auth api frontend` | +| Adopt existing branches | `gh stack init existing-branch-a existing-branch-b` | | Set custom trunk | `gh stack init --base develop branch-a` | | Add a branch to stack (suffix only if prefix set) | `gh stack add api-routes` | | Add branch + stage all + commit | `gh stack add -Am "message" api-routes` | @@ -387,7 +388,7 @@ gh stack unstack git branch -m old-branch-1 new-branch-1 # 3. Re-create the stack with the new structure -gh stack init --base main --adopt new-branch-1 new-branch-2 new-branch-3 +gh stack init --base main new-branch-1 new-branch-2 new-branch-3 ``` --- @@ -417,21 +418,20 @@ gh stack init branch-a branch-b branch-c # Use a different trunk branch gh stack init --base develop branch-a branch-b -# Adopt existing branches into a stack -gh stack init --adopt branch-a branch-b branch-c +# Adopt existing branches into a stack (handled automatically if the branches exist) +gh stack init branch-a branch-b branch-c ``` | Flag | Description | |------|-------------| | `-b, --base ` | Trunk branch (defaults to the repo's default branch) | -| `-a, --adopt` | Adopt existing branches instead of creating new ones | | `-p, --prefix ` | Branch name prefix. Subsequent `add` calls only need the suffix (e.g., with `-p feat`, `gh stack add auth` creates `feat/auth`) | **Behavior:** - Using `-p` is recommended — it simplifies branch naming for subsequent `add` calls - Creates any branches that don't already exist (branching from the trunk branch) -- In `--adopt` mode: validates all branches exist, rejects if any is already in a stack or has an existing PR +- Existing branches are adopted automatically; missing branches are created from the trunk - Checks out the last branch in the list - Enables `git rerere` so conflict resolutions are remembered across rebases. On first run in a repo, this may trigger a confirmation prompt — pre-configure with `git config rerere.enabled true` to avoid it @@ -797,7 +797,7 @@ gh stack unstack [flags] ```bash # Tear down the stack (locally and on GitHub), then rebuild gh stack unstack -gh stack init --base main --adopt branch-2 branch-1 branch-3 # reordered +gh stack init --base main branch-2 branch-1 branch-3 # reordered # Only remove local tracking (keep the stack on GitHub) gh stack unstack --local