mirror of
https://github.com/github/gh-stack.git
synced 2026-09-14 20:26:28 +08:00
355 lines
10 KiB
Go
355 lines
10 KiB
Go
package cmd
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"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"
|
|
"github.com/github/gh-stack/internal/stack"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
type initOptions struct {
|
|
branches []string
|
|
base string
|
|
adopt bool
|
|
prefix string
|
|
numbered bool
|
|
}
|
|
|
|
func InitCmd(cfg *config.Config) *cobra.Command {
|
|
opts := &initOptions{}
|
|
|
|
cmd := &cobra.Command{
|
|
Use: "init [branches...]",
|
|
Short: "Initialize a new stack",
|
|
Long: `Initialize a stack object in the local repo.
|
|
|
|
Unless specified, prompts user to create/select branch for first layer of the stack.
|
|
Trunk defaults to default branch, unless specified otherwise.`,
|
|
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, "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)")
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
var branches []string
|
|
|
|
// --adopt takes existing branches as-is; --prefix and --numbered don't apply.
|
|
if opts.adopt && (opts.prefix != "" || opts.numbered) {
|
|
cfg.Errorf("--adopt cannot be combined with --prefix or --numbered")
|
|
return ErrInvalidArgs
|
|
}
|
|
|
|
// Validate --numbered requires a prefix (either from flag or interactive input,
|
|
// but for non-interactive paths we can check early).
|
|
if opts.numbered && opts.prefix == "" && !cfg.IsInteractive() {
|
|
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
|
|
prefixInput, err := p.Input("Enter a branch prefix (required for --numbered)", "")
|
|
if err != nil {
|
|
if isInterruptError(err) {
|
|
printInterrupt(cfg)
|
|
return ErrSilent
|
|
}
|
|
cfg.Errorf("failed to read prefix: %s", err)
|
|
return ErrSilent
|
|
}
|
|
opts.prefix = strings.TrimSpace(prefixInput)
|
|
if opts.prefix == "" {
|
|
cfg.Errorf("--numbered requires a prefix")
|
|
return ErrInvalidArgs
|
|
}
|
|
} else {
|
|
prefixInput, err := p.Input("Set a branch prefix? (leave blank to skip)", "")
|
|
if err != nil {
|
|
if isInterruptError(err) {
|
|
printInterrupt(cfg)
|
|
return ErrSilent
|
|
}
|
|
cfg.Errorf("failed to read prefix: %s", err)
|
|
return ErrSilent
|
|
}
|
|
opts.prefix = strings.TrimSpace(prefixInput)
|
|
}
|
|
}
|
|
|
|
// Validate prefix, after it has been determined (from flag or prompt),
|
|
// before any branch creation.
|
|
if opts.prefix != "" {
|
|
if err := git.ValidateRefName(opts.prefix); err != nil {
|
|
cfg.Errorf("invalid prefix %q: must be a valid git ref component", opts.prefix)
|
|
return ErrInvalidArgs
|
|
}
|
|
}
|
|
|
|
if opts.adopt {
|
|
// Adopt mode: validate all specified branches exist
|
|
if len(opts.branches) == 0 {
|
|
cfg.Errorf("--adopt requires at least one branch name")
|
|
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
|
|
}
|
|
}
|
|
prefixed = append(prefixed, b)
|
|
}
|
|
branches = prefixed
|
|
} else {
|
|
// Interactive mode — prefix was already prompted for above
|
|
if !cfg.IsInteractive() {
|
|
cfg.Errorf("interactive input required; provide branch names or use --adopt")
|
|
return ErrInvalidArgs
|
|
}
|
|
p := prompter.New(cfg.In, cfg.Out, cfg.Err)
|
|
|
|
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}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Build stack
|
|
trunkSHA, _ := git.RevParse(trunk)
|
|
branchRefs := make([]stack.BranchRef, len(branches))
|
|
for i, b := range branches {
|
|
parent := trunk
|
|
if i > 0 {
|
|
parent = branches[i-1]
|
|
}
|
|
base, _ := git.MergeBase(b, parent)
|
|
branchRefs[i] = stack.BranchRef{Branch: b, Base: base}
|
|
}
|
|
|
|
newStack := stack.Stack{
|
|
Prefix: opts.prefix,
|
|
Numbered: opts.numbered,
|
|
Trunk: stack.BranchRef{
|
|
Branch: trunk,
|
|
Head: trunkSHA,
|
|
},
|
|
Branches: branchRefs,
|
|
}
|
|
|
|
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.
|
|
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,
|
|
}
|
|
}
|
|
}
|
|
} 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
|
|
}
|
|
cfg.Printf("Switched to branch %s", lastBranch)
|
|
} else {
|
|
cfg.Printf("You can continue working on %s", lastBranch)
|
|
}
|
|
}
|
|
|
|
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"))
|
|
|
|
return nil
|
|
}
|