package cmd import ( "errors" "fmt" "strings" "github.com/cli/go-gh/v2/pkg/prompter" "github.com/github/gh-stack/internal/config" "github.com/github/gh-stack/internal/git" "github.com/github/gh-stack/internal/stack" "github.com/spf13/cobra" ) type initOptions struct { branches []string base string adopt bool // deprecated, kept for backward compat } func InitCmd(cfg *config.Config) *cobra.Command { opts := &initOptions{} cmd := &cobra.Command{ Use: "init [branches...]", Short: "Initialize a new stack", Long: `Initialize a new stack of branches in the current repository. 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 # 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) }, } cmd.Flags().StringVarP(&opts.base, "base", "b", "", "Trunk branch for stack (defaults to default branch)") cmd.Flags().BoolVarP(&opts.adopt, "adopt", "a", false, "Deprecated: existing branches are now adopted automatically") _ = cmd.Flags().MarkHidden("adopt") return cmd } func runInit(cfg *config.Config, opts *initOptions) error { gitDir, err := git.GitDir() if err != nil { cfg.Errorf("not a git repository") return ErrNotInStack } // Determine trunk branch trunk := opts.base // Enable git rerere so conflict resolutions are remembered. if err := ensureRerere(cfg); errors.Is(err, errInterrupt) { return ErrSilent } if trunk == "" { trunk, err = git.DefaultBranch() if err != nil { cfg.Errorf("unable to determine default branch\nUse -b to specify the trunk branch") return ErrNotInStack } } // Load existing stack file sf, err := stack.Load(gitDir) if err != nil { cfg.Errorf("failed to load stack state: %s", err) return ErrNotInStack } // Set repository context repo, err := cfg.Repo() if err == nil { sf.Repository = repo.Host + ":" + repo.Owner + "/" + repo.Name } currentBranch, _ := git.CurrentBranch() // Don't allow initializing a stack if the current branch is a non-trunk // member of another stack. Trunk branches (e.g. "main") can be shared // across multiple stacks. if currentBranch != "" { for _, s := range sf.FindAllStacksForBranch(currentBranch) { if s.IndexOf(currentBranch) >= 0 { cfg.Errorf("current branch %q is already part of a stack", currentBranch) return ErrInvalidArgs } } } // 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. 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 ...")) } // --- 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, trunkRef) if err != nil { return err } } else { // === INTERACTIVE PATH === if !cfg.IsInteractive() { cfg.Errorf("interactive input required; provide branch names as arguments") return ErrInvalidArgs } var interactiveAdopted bool branches, interactiveAdopted, err = runInteractiveInit(cfg, sf, trunk, trunkRef, currentBranch) if err != nil { return err } if interactiveAdopted { adopted[branches[0]] = true } } // --- Build stack --- trunkSHA, _ := git.RevParse(trunkRef) branchRefs := make([]stack.BranchRef, len(branches)) for i, b := range branches { parent := trunkRef if i > 0 { parent = branches[i-1] } base, _ := git.MergeBase(b, parent) branchRefs[i] = stack.BranchRef{Branch: b, Base: base} } newStack := stack.Stack{ Trunk: stack.BranchRef{ Branch: trunk, Head: trunkSHA, }, Branches: branchRefs, } sf.AddStack(newStack) // --- 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] 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++ } } if err := stack.Save(gitDir, sf); err != nil { return handleSaveError(cfg, err) } // --- 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, trunkRef 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 { // 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 := trunkRef 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, 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, trunkRef, currentBranch string) ([]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) } 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 { // Create a new branch — fall through to input prompt name, err := promptBranchName(cfg) if err != nil { return nil, false, err } branchName = name } } else { // On trunk or detached HEAD — prompt for name directly name, err := promptBranchName(cfg) if err != nil { return nil, false, err } branchName = name } // 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, trunkRef); err != nil { cfg.Errorf("creating branch %s: %s", branchName, err) return nil, false, ErrSilent } } return []string{branchName}, wasAdopted, nil } // promptBranchName prompts the user for the name of the first branch. func promptBranchName(cfg *config.Config) (string, error) { branchName, err := promptInput(cfg, "What's the name of the first branch:") 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 } return branchName, nil } // 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")) } }