package cmd import ( "errors" "fmt" "net/url" "slices" "strconv" "strings" "sync" "github.com/AlecAivazis/survey/v2/terminal" "github.com/cli/go-gh/v2/pkg/api" "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/github" "github.com/github/gh-stack/internal/stack" "github.com/github/gh-stack/internal/theme" ) // ErrSilent indicates the error has already been printed to the user. // Execute() will exit with code 1 but will not print the error again. var ErrSilent = &ExitError{Code: 1} // Typed exit errors for programmatic detection by scripts and agents. var ( ErrNotInStack = &ExitError{Code: 2} // branch/stack not found ErrConflict = &ExitError{Code: 3} // rebase conflict ErrAPIFailure = &ExitError{Code: 4} // GitHub API error ErrInvalidArgs = &ExitError{Code: 5} // invalid arguments or flags ErrDisambiguate = &ExitError{Code: 6} // multiple stacks/remotes, can't auto-select ErrRebaseActive = &ExitError{Code: 7} // rebase already in progress ErrLockFailed = &ExitError{Code: 8} // could not acquire stack file lock ErrStacksUnavailable = &ExitError{Code: 9} // stacked PRs not available for this repository ErrModifyRecovery = &ExitError{Code: 10} // modify session interrupted, recovery required ) // ExitError is returned by commands to indicate a specific exit code. // Execute() extracts the code and passes it to os.Exit. type ExitError struct { Code int } func (e *ExitError) Error() string { return fmt.Sprintf("exit status %d", e.Code) } func (e *ExitError) Is(target error) bool { t, ok := target.(*ExitError) if !ok { return false } return e.Code == t.Code } // errInterrupt is a sentinel returned when a prompt is cancelled via Ctrl+C. // Callers should exit silently (the friendly message is already printed). var errInterrupt = errors.New("interrupt") // isInterruptError reports whether err is (or wraps) the survey interrupt, // which is raised when the user presses Ctrl+C during a prompt. func isInterruptError(err error) bool { return errors.Is(err, terminal.InterruptErr) } // printInterrupt prints a friendly message and should be called exactly once // per interrupted operation. The leading newline ensures the message starts // on its own line even if the cursor was mid-prompt. func printInterrupt(cfg *config.Config) { fmt.Fprintln(cfg.Err) cfg.Infof("Received interrupt, aborting operation") } // warnStacksUnavailable prints a warning when a stacks API call returns 404, // indicating stacked PRs are not enabled for the repository. func warnStacksUnavailable(cfg *config.Config) { cfg.Warningf("Stacked PRs are not enabled for this repository") } // stackLabel returns a " (stack #N)" suffix for appending to user-facing // messages when the human-facing stack number is known, or an empty string // otherwise. func stackLabel(number int) string { if number <= 0 { return "" } return fmt.Sprintf(" (stack #%d)", number) } // stackNumberByID resolves an internal stack ID (as stored in the local stack // file) to its human-facing stack number by consulting the remote stack list. // Returns ok=false when no remote stack matches the ID (e.g. it was deleted). func stackNumberByID(client github.ClientOps, id string) (number int, ok bool, err error) { if id == "" { return 0, false, nil } stacks, err := client.ListStacks() if err != nil { return 0, false, err } for _, rs := range stacks { if strconv.Itoa(rs.ID) == id { return rs.Number, true, nil } } return 0, false, nil } // ensureStackNumber returns the stack number for s, resolving and caching it // from the remote stack list by internal ID when the local model predates the // Number field (older stack files stored only the ID). Returns 0 when the stack // number can't be determined. func ensureStackNumber(client github.ClientOps, s *stack.Stack) (int, error) { if s.Number != 0 { return s.Number, nil } number, found, err := stackNumberByID(client, s.ID) if err != nil { return 0, err } if found { s.Number = number } return number, nil } // promptInput prompts the user for a single line of text input. The user's // input is rendered in the accent (cyan) color for visual distinction from the // prompt message. func promptInput(cfg *config.Config, prompt string) (string, error) { if cfg.InputFn != nil { return cfg.InputFn(prompt) } stdio := terminal.Stdio{In: cfg.In, Out: cfg.Out, Err: cfg.Err} rr := terminal.NewRuneReader(stdio) if err := rr.SetTermMode(); err != nil { return "", fmt.Errorf("failed to set terminal mode: %w", err) } defer func() { _ = rr.RestoreTermMode() }() // Render the prompt in survey style: green "?" + message icon := "?" useColor := cfg.Terminal.IsColorEnabled() if useColor { icon = theme.Success("?") } fmt.Fprintf(cfg.Out, "%s %s ", icon, prompt) // Color the user's echoed input with the accent (cyan) color. cyanStart, cyanReset := theme.FgSeqs(theme.ColorAccent) if useColor { fmt.Fprint(cfg.Out, cyanStart) } line, err := rr.ReadLine(0) // Reset color after input if useColor { fmt.Fprint(cfg.Out, cyanReset) } if err != nil { return "", err } return string(line), nil } // selectPromptPageSize matches the PageSize used by the go-gh prompter. const selectPromptPageSize = 20 // clearSelectPrompt erases the rendered Select prompt from the terminal. // survey/v2 does not call Cleanup on interrupt, leaving the question and // option lines visible. This function moves the cursor up past those lines // and clears to the end of the screen. func clearSelectPrompt(cfg *config.Config, numOptions int) { visible := numOptions if visible > selectPromptPageSize { visible = selectPromptPageSize } // 1 line for the question/filter + visible option lines lines := 1 + visible fmt.Fprintf(cfg.Out, "\033[%dA\033[J", lines) } // loadStackResult holds everything returned by loadStack. type loadStackResult struct { GitDir string StackFile *stack.StackFile Stack *stack.Stack CurrentBranch string PRDetails map[string]*github.PRDetails } // loadStack is the standard way to obtain a Stack for the current (or given) // 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") return nil, fmt.Errorf("not a git repository") } sf, err := stack.Load(gitDir) if err != nil { cfg.Errorf("failed to load stack state: %s", err) return nil, fmt.Errorf("failed to load stack state: %w", err) } if branch == "" { branch, 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) } } s, err := resolveStack(sf, branch, cfg) if err != nil { if errors.Is(err, errInterrupt) { return nil, errInterrupt } cfg.Errorf("%s", err) return nil, err } // Re-read current branch in case disambiguation caused a checkout. 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{ GitDir: gitDir, StackFile: sf, Stack: s, CurrentBranch: currentBranch, }, 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 — // including when there is no git repository, since a stack cannot be tracked // locally without one — so callers can fall back to a remote-only operation. A // non-nil error signals a real failure (the stack file could not be loaded) that // has already been reported via cfg. // // Stack files created before the number was tracked store only the internal ID // (Number == 0); such legacy stacks are resolved by mapping their ID to a remote // stack number so they can still be targeted by number. That mapping contacts // GitHub (ListStacks), so it is only attempted when allowRemote is true — callers // that must stay purely local (e.g. `--local`) pass false, and legacy stacks // whose number isn't recorded locally are reported as not tracked. func lookupStackByNumber(cfg *config.Config, number int, allowRemote bool) (result *loadStackResult, ok bool, err error) { gitDir, err := git.GitDir() if err != nil { // Not a git repository — nothing can be tracked locally. return nil, false, nil } sf, err := stack.Load(gitDir) if err != nil { cfg.Errorf("failed to load stack state: %s", err) return nil, false, fmt.Errorf("failed to load stack state: %w", err) } // Direct match on the tracked stack number. if result := stackResultByNumber(sf, gitDir, number); result != nil { return result, true, nil } // No direct match — backfill legacy stacks' numbers from the remote and // retry, so `gh stack unstack ` also works for stacks tracked // before the number was recorded locally. This reaches GitHub, so it is // skipped when the caller requires a purely local lookup. if allowRemote && backfillLegacyStackNumbers(cfg, sf, gitDir) { if result := stackResultByNumber(sf, gitDir, number); result != nil { return result, true, nil } } return nil, false, nil } // stackResultByNumber returns a loadStackResult for the locally tracked stack // whose Number matches, or nil when none does. func stackResultByNumber(sf *stack.StackFile, gitDir string, number int) *loadStackResult { for i := range sf.Stacks { if sf.Stacks[i].Number == number { currentBranch, _ := git.CurrentBranch() return &loadStackResult{ GitDir: gitDir, StackFile: sf, Stack: &sf.Stacks[i], CurrentBranch: currentBranch, } } } return nil } // backfillLegacyStackNumbers fills in the human-facing Number for locally // tracked stacks that predate it (Number == 0 but ID set) by mapping their // internal ID to the remote stack list, persisting any updates. Returns true // when at least one number was filled in. Best-effort: returns false on any // client or API error rather than failing the caller. func backfillLegacyStackNumbers(cfg *config.Config, sf *stack.StackFile, gitDir string) bool { needsResolve := false for i := range sf.Stacks { if sf.Stacks[i].Number == 0 && sf.Stacks[i].ID != "" { needsResolve = true break } } if !needsResolve { return false } client, err := cfg.GitHubClient() if err != nil { return false } stacks, err := client.ListStacks() if err != nil { return false } numberByID := make(map[string]int, len(stacks)) for _, rs := range stacks { numberByID[strconv.Itoa(rs.ID)] = rs.Number } changed := false for i := range sf.Stacks { if sf.Stacks[i].Number != 0 || sf.Stacks[i].ID == "" { continue } if n, ok := numberByID[sf.Stacks[i].ID]; ok && n != 0 { sf.Stacks[i].Number = n changed = true } } if changed { if err := stack.Save(gitDir, sf); err != nil { // Non-fatal: the in-memory backfill still lets us resolve the target. cfg.Warningf("could not persist stack numbers: %v", err) } } return changed } // handleSaveError translates a stack.Save error into the appropriate user // message and exit error. Lock contention and stale-file detection both // return ErrLockFailed (exit 8); other write failures return ErrSilent (exit 1). func handleSaveError(cfg *config.Config, err error) error { var lockErr *stack.LockError if errors.As(err, &lockErr) { cfg.Errorf("another process is currently editing the stack — try again later") return ErrLockFailed } var staleErr *stack.StaleError if errors.As(err, &staleErr) { cfg.Errorf("stack file was modified by another process — please re-run the command") return ErrLockFailed } cfg.Errorf("failed to save stack state: %s", err) return ErrSilent } // resolveStack finds the stack for the given branch, handling ambiguity when // a branch (typically a trunk) belongs to multiple stacks. If exactly one // stack matches, it is returned directly. If multiple stacks match, the user // is prompted to select one and the working tree is switched to the top branch // of the selected stack. Returns nil with no error if no stack contains the // branch. func resolveStack(sf *stack.StackFile, branch string, cfg *config.Config) (*stack.Stack, error) { stacks := sf.FindAllStacksForBranch(branch) switch len(stacks) { case 0: return nil, nil case 1: return stacks[0], nil } if !cfg.IsInteractive() { return nil, fmt.Errorf("branch %q belongs to multiple stacks; use an interactive terminal to select one", branch) } cfg.Warningf("Branch %q is the trunk of multiple stacks", branch) options := make([]string, len(stacks)) for i, s := range stacks { options[i] = s.DisplayChain() } p := prompter.New(cfg.In, cfg.Out, cfg.Err) selected, err := p.Select("Which stack would you like to use?", "", options) if err != nil { if isInterruptError(err) { clearSelectPrompt(cfg, len(options)) printInterrupt(cfg) return nil, errInterrupt } return nil, fmt.Errorf("stack selection: %w", err) } s := stacks[selected] if len(s.Branches) == 0 { return nil, fmt.Errorf("selected stack %q has no branches", s.DisplayChain()) } // Switch to the top branch of the selected stack so future commands // resolve unambiguously. topBranch := s.Branches[len(s.Branches)-1].Branch if topBranch != branch { if err := git.CheckoutBranch(topBranch); err != nil { return nil, fmt.Errorf("failed to checkout branch %s: %w", topBranch, err) } cfg.Successf("Switched to %s", topBranch) } return s, nil } // syncStackPRs discovers and updates pull request metadata for branches in a stack. // It also collects PRDetails for each branch, returned as a map keyed by branch name. // The returned map is consumed by LoadBranchNodes to avoid redundant API calls. // // When the stack has a remote ID, the stack API is the source of truth: the // authoritative PR list is fetched from the server and matched to local // branches by head branch name. PRs remain associated even if closed. // // When no remote stack exists, branch-name-based discovery is used: // // 1. No tracked PR — look for an OPEN PR by head branch name. // 2. Tracked PR (not merged) — refresh status by number; if closed, // clear the association and fall through to path 1. // 3. Tracked PR (merged) — skip; the merged state is final. // // The transient Queued flag is also populated from the API response. // // API calls for different branches are made concurrently to reduce latency. func syncStackPRs(cfg *config.Config, s *stack.Stack) map[string]*github.PRDetails { client, err := cfg.GitHubClient() if err != nil { return nil } // When the stack has a remote ID, the stack API is the source of truth. if s.ID != "" { if details, ok := syncStackPRsFromRemote(client, s); ok { return details } } // No remote stack (or remote sync failed) — local discovery. // Each branch is processed concurrently; results are collected and applied sequentially. type branchResult struct { index int pullRequest *stack.PullRequestRef queued bool details *github.PRDetails skip bool // true means keep existing data, don't update } results := make([]branchResult, len(s.Branches)) // Fetch PR data for all branches concurrently using a WaitGroup for // completion and a semaphore channel to cap the number of in-flight // API requests (see maxAPIConcurrency). var wg sync.WaitGroup sem := make(chan struct{}, maxAPIConcurrency) for i := range s.Branches { b := s.Branches[i] if b.IsMerged() { results[i] = branchResult{index: i, skip: true} // Provide PRDetails for merged branches from existing tracked PR if b.PullRequest != nil && b.PullRequest.Number != 0 { results[i].details = &github.PRDetails{ Number: b.PullRequest.Number, State: "MERGED", URL: b.PullRequest.URL, Merged: true, } } continue } wg.Add(1) go func(idx int, branch stack.BranchRef) { defer wg.Done() // Acquire a semaphore slot to limit concurrent API calls. sem <- struct{}{} defer func() { <-sem }() res := branchResult{index: idx} trackedResolved := false if branch.PullRequest != nil && branch.PullRequest.Number != 0 { // Tracked PR — refresh its state. pr, err := client.FindPRByNumber(branch.PullRequest.Number) if err != nil { // API error — keep existing tracked PR res.skip = true res.details = prDetailsFromTracked(branch.PullRequest) results[idx] = res return } if pr != nil && pr.State != "CLOSED" { // PR is open or merged — keep it res.pullRequest = &stack.PullRequestRef{ Number: pr.Number, ID: pr.ID, URL: pr.URL, Merged: pr.Merged, } res.queued = pr.IsQueued() res.details = prDetailsFromPR(pr) results[idx] = res trackedResolved = true } // Otherwise PR not found or closed — fall through to open-PR lookup } if trackedResolved { return } // No tracked PR (or cleared) — only adopt OPEN PRs. pr, err := client.FindPRForBranch(branch.Branch) if err != nil || pr == nil { results[idx] = res return } res.pullRequest = &stack.PullRequestRef{ Number: pr.Number, ID: pr.ID, URL: pr.URL, } res.queued = pr.IsQueued() // FindPRForBranch only returns OPEN PRs res.details = &github.PRDetails{ Number: pr.Number, State: "OPEN", URL: pr.URL, Title: pr.Title, Body: pr.Body, IsDraft: pr.IsDraft, Merged: false, IsQueued: pr.IsQueued(), } results[idx] = res }(i, b) } wg.Wait() // Apply results sequentially to preserve deterministic behavior. details := make(map[string]*github.PRDetails) for _, res := range results { if res.details != nil { details[s.Branches[res.index].Branch] = res.details } if res.skip { continue } b := &s.Branches[res.index] if res.pullRequest != nil { b.PullRequest = res.pullRequest b.Queued = res.queued } else if !b.IsMerged() { // Clear if we didn't find anything (and original was cleared during discovery) if b.PullRequest != nil && res.pullRequest == nil { b.PullRequest = nil b.Queued = false } } } return details } // maxAPIConcurrency limits the number of concurrent API calls to avoid hitting secondary rate limits. const maxAPIConcurrency = 6 // prDetailsFromPR builds PRDetails from a PullRequest returned by FindPRByNumber. func prDetailsFromPR(pr *github.PullRequest) *github.PRDetails { if pr == nil { return nil } return &github.PRDetails{ Number: pr.Number, State: pr.State, URL: pr.URL, Title: pr.Title, Body: pr.Body, IsDraft: pr.IsDraft, Merged: pr.Merged, IsQueued: pr.IsQueued(), } } // prDetailsFromTracked builds minimal PRDetails from a tracked PullRequestRef. func prDetailsFromTracked(ref *stack.PullRequestRef) *github.PRDetails { if ref == nil { return nil } state := "OPEN" if ref.Merged { state = "MERGED" } return &github.PRDetails{ Number: ref.Number, State: state, URL: ref.URL, Merged: ref.Merged, } } // enrichPRContent fills in the Title and Body of any existing PR whose details // were built without them (e.g. merged branches, which skip the live refresh in // syncStackPRs). It is used before the submit TUI renders an existing PR's // read-only card so it shows the real PR title and description. PRs that already // have a title (the common open/draft/queued case) are left untouched. func enrichPRContent(client github.ClientOps, details map[string]*github.PRDetails) { if client == nil { return } var wg sync.WaitGroup sem := make(chan struct{}, maxAPIConcurrency) for _, d := range details { if d == nil || d.Number == 0 || strings.TrimSpace(d.Title) != "" { continue } wg.Add(1) go func(d *github.PRDetails) { defer wg.Done() sem <- struct{}{} defer func() { <-sem }() if pr, err := client.FindPRByNumber(d.Number); err == nil && pr != nil { d.Title = pr.Title d.Body = pr.Body } }(d) } wg.Wait() } // syncStackPRsFromRemote uses the stack API to sync PR state. The remote // stack's PR list is the source of truth — PRs stay associated even if // closed. Returns the PRDetails map and true if sync succeeded, or nil and // false if we should fall back to local discovery. func syncStackPRsFromRemote(client github.ClientOps, s *stack.Stack) (map[string]*github.PRDetails, bool) { stacks, err := client.ListStacks() if err != nil { return nil, false } // Find our stack in the remote list. var remotePRNumbers []int for _, rs := range stacks { if strconv.Itoa(rs.ID) == s.ID { remotePRNumbers = rs.PRNumbers() // Backfill the human-facing stack number for stack files created // before it was tracked, so callers (view, submit TUI) can display // it. Persisted by whichever command later saves the stack file. if s.Number == 0 { s.Number = rs.Number } break } } if remotePRNumbers == nil { return nil, false } // Fetch each remote PR concurrently. Results are written to an ordered // slice (one slot per PR number) so that when we build the branch map // below, later entries win on duplicate HeadRefNames — matching the // sequential behavior of the old code. prResults := make([]*github.PullRequest, len(remotePRNumbers)) var wg sync.WaitGroup sem := make(chan struct{}, maxAPIConcurrency) // limits concurrent API calls for i, num := range remotePRNumbers { wg.Add(1) go func(idx, prNum int) { defer wg.Done() // Acquire a semaphore slot to limit concurrent API calls. sem <- struct{}{} defer func() { <-sem }() pr, err := client.FindPRByNumber(prNum) if err != nil || pr == nil { return } // Each goroutine writes to its own index — no lock needed. prResults[idx] = pr }(i, num) } wg.Wait() // Build map sequentially to preserve order semantics. prByBranch := make(map[string]*github.PullRequest, len(remotePRNumbers)) for _, pr := range prResults { if pr != nil { prByBranch[pr.HeadRefName] = pr } } // Match remote PRs to local branches and collect PRDetails. details := make(map[string]*github.PRDetails) for i := range s.Branches { b := &s.Branches[i] pr, ok := prByBranch[b.Branch] if !ok { continue } b.PullRequest = &stack.PullRequestRef{ Number: pr.Number, ID: pr.ID, URL: pr.URL, Merged: pr.Merged, } b.Queued = pr.IsQueued() details[b.Branch] = prDetailsFromPR(pr) } return details, true } // updateBaseSHAs refreshes the Base and Head SHAs for all active branches // in a stack. Call this after any operation that may have moved branch refs // (rebase, push, etc.). func updateBaseSHAs(s *stack.Stack) { // Collect all refs we need to resolve, then batch into one git call. var refs []string type refPair struct { index int parent string branch string } var pairs []refPair seen := make(map[string]bool) for i := range s.Branches { if s.Branches[i].IsMerged() { continue } parent := s.ActiveBaseBranch(s.Branches[i].Branch) branch := s.Branches[i].Branch pairs = append(pairs, refPair{i, parent, branch}) if !seen[parent] { refs = append(refs, parent) seen[parent] = true } if !seen[branch] { refs = append(refs, branch) seen[branch] = true } } if len(refs) == 0 { return } shaMap, err := git.RevParseMap(refs) if err != nil { return } for _, p := range pairs { if base, ok := shaMap[p.parent]; ok && canUpdateBase(base, p.branch, s.Branches[p.index].Base) { s.Branches[p.index].Base = base } if head, ok := shaMap[p.branch]; ok { s.Branches[p.index].Head = head } } } // canUpdateBase reports whether parentSHA can replace a branch's recorded base. // Once a base is known, only a parent tip the branch actually contains may // replace it; otherwise an amended parent would corrupt the next rebase // boundary. Empty bases retain the historical best-effort behavior. func canUpdateBase(parentSHA, branch, currentBase string) bool { if currentBase == "" || currentBase == parentSHA { return true } isAncestor, err := git.IsAncestor(parentSHA, branch) return err == nil && isAncestor } // activeBranchNames returns the branch names for all non-merged branches in a stack. func activeBranchNames(s *stack.Stack) []string { active := s.ActiveBranches() names := make([]string, len(active)) for i, b := range active { names[i] = b.Branch } return names } // fastForwardBranches fast-forwards each active stack branch to its remote // tracking branch when the local branch is strictly behind. Returns the names // of branches that were updated. Branches that are up-to-date, diverged, or // have no remote tracking branch are silently skipped. func fastForwardBranches(cfg *config.Config, s *stack.Stack, remote, currentBranch string) []string { var updated []string for _, br := range s.Branches { if br.IsSkipped() { continue } remoteRef := remote + "/" + br.Branch refs, err := git.RevParseMulti([]string{br.Branch, remoteRef}) if err != nil { // Remote tracking branch doesn't exist — skip. continue } localSHA, remoteSHA := refs[0], refs[1] if localSHA == remoteSHA { continue } isAncestor, err := git.IsAncestor(localSHA, remoteSHA) if err != nil || !isAncestor { // Diverged or error — skip. This commonly happens after a // local rebase and is handled by the push step. continue } // Local is behind remote — fast-forward. if currentBranch == br.Branch { if err := git.MergeFF(remoteRef); err != nil { cfg.Warningf("Failed to fast-forward %s from remote: %v", br.Branch, err) continue } } else { if err := git.UpdateBranchRef(br.Branch, remoteSHA); err != nil { cfg.Warningf("Failed to fast-forward %s from remote: %v", br.Branch, err) continue } } cfg.Successf("Fast-forwarded %s to %s", br.Branch, short(remoteSHA)) updated = append(updated, br.Branch) } return updated } // resolveOriginalRefs builds a map from branch name to current SHA for all // branches in the stack. Merged branches that no longer exist locally are // backfilled from the stack metadata. This map is used as the "before" state // for cascade rebases and conflict recovery. func resolveOriginalRefs(s *stack.Stack) (map[string]string, error) { branchNames := make([]string, 0, len(s.Branches)) for _, b := range s.Branches { if b.IsMerged() && !git.BranchExists(b.Branch) { continue } branchNames = append(branchNames, b.Branch) } originalRefs, err := git.RevParseMap(branchNames) if err != nil { return nil, fmt.Errorf("resolving branch SHAs: %w", err) } // Backfill merged branches that were deleted locally. for _, b := range s.Branches { if b.IsMerged() && !git.BranchExists(b.Branch) { if b.Head != "" { originalRefs[b.Branch] = b.Head } } } return originalRefs, nil } // ensureLocalTrunk ensures the trunk branch exists locally. If it does not, // it fetches the branch from the remote and creates a local tracking branch. // This handles the case where a user started their stack after renaming their // initial branch (e.g. `git branch -m newbranch`), leaving no local trunk. func ensureLocalTrunk(cfg *config.Config, trunk, remote string) error { if git.BranchExists(trunk) { return nil } if err := git.FetchBranches(remote, []string{trunk}); err != nil { return fmt.Errorf("could not fetch trunk branch %s from %s: %w", trunk, remote, err) } remoteTrunk := remote + "/" + trunk if err := git.CreateBranch(trunk, remoteTrunk); err != nil { return fmt.Errorf("could not create local trunk branch %s from %s: %w", trunk, remoteTrunk, err) } cfg.Successf("Created local trunk branch %s from %s", trunk, remoteTrunk) return nil } func normalizeTrunkBranch(trunk, remote string) string { if remote == "" || git.BranchExists(trunk) { return trunk } if stripped, ok := strings.CutPrefix(trunk, remote+"/"); ok && stripped != "" { return stripped } return trunk } func normalizeStackTrunk(cfg *config.Config, s *stack.Stack, remote string) { trunk := normalizeTrunkBranch(s.Trunk.Branch, remote) if trunk == s.Trunk.Branch { return } cfg.Warningf("Stack trunk %q is remote-qualified — using %q", s.Trunk.Branch, trunk) s.Trunk.Branch = trunk } type trunkTarget struct { Branch string Ref string SHA string Moved bool } func (t trunkTarget) Describe() string { return fmt.Sprintf("%s (%s)", t.Ref, short(t.SHA)) } // resolveTrunkTarget fetches the trunk explicitly, then returns the ref the // cascade must use. Updating the local trunk is best-effort; the fetched remote // ref remains the source of truth when the local branch is stale or immovable. func resolveTrunkTarget(cfg *config.Config, s *stack.Stack, remote, currentBranch string) (trunkTarget, error) { normalizeStackTrunk(cfg, s, remote) trunk := s.Trunk.Branch remoteRef := remote + "/" + trunk if err := git.FetchBranch(remote, trunk); err != nil { if errors.Is(err, git.ErrRemoteBranchNotFound) { return trunkWithoutRemote(cfg, trunk, remote) } cfg.Errorf("failed to fetch trunk branch %s from %s: %v", trunk, remote, err) return trunkTarget{}, ErrSilent } remoteSHA, err := git.RevParse(remoteRef) if err != nil { cfg.Errorf("could not resolve fetched trunk %s: %v", remoteRef, err) return trunkTarget{}, ErrSilent } cfg.Successf("Fetched latest %s from %s", trunk, remote) if !git.BranchExists(trunk) { if err := git.CreateBranch(trunk, remoteRef); err != nil { cfg.Errorf("could not create local trunk branch %s from %s: %v", trunk, remoteRef, err) return trunkTarget{}, ErrSilent } cfg.Successf("Created local trunk branch %s from %s", trunk, remoteRef) return trunkTarget{Branch: trunk, Ref: trunk, SHA: remoteSHA, Moved: true}, nil } localSHA, err := git.RevParse(trunk) if err != nil { cfg.Errorf("could not resolve local trunk branch %s: %v", trunk, err) return trunkTarget{}, ErrSilent } if localSHA == remoteSHA { cfg.Successf("Trunk %s is already up to date", trunk) return trunkTarget{Branch: trunk, Ref: trunk, SHA: localSHA}, nil } canFastForward, ffErr := git.IsAncestor(localSHA, remoteSHA) if ffErr == nil && canFastForward { var updateErr error if currentBranch == trunk { updateErr = git.MergeFF(remoteRef) } else { updateErr = git.UpdateBranchRef(trunk, remoteSHA) } if updateErr == nil { cfg.Successf("Trunk %s fast-forwarded to %s", trunk, short(remoteSHA)) return trunkTarget{Branch: trunk, Ref: trunk, SHA: remoteSHA, Moved: true}, nil } cfg.Warningf("Could not update local %s: %v", trunk, updateErr) } else if ffErr != nil { cfg.Warningf("Could not determine fast-forward status for %s: %v", trunk, ffErr) } else if isAncestor, ancErr := git.IsAncestor(remoteSHA, localSHA); ancErr == nil && isAncestor { // Keep unpushed local trunk commits when they already contain the // fetched remote tip. cfg.Successf("Trunk %s is ahead of %s — using the local branch", trunk, remoteRef) return trunkTarget{Branch: trunk, Ref: trunk, SHA: localSHA}, nil } else { cfg.Warningf("Local %s has diverged from %s", trunk, remoteRef) } cfg.Printf(" Rebasing the stack onto %s instead; local %s is unchanged.", remoteRef, trunk) return trunkTarget{Branch: trunk, Ref: remoteRef, SHA: remoteSHA}, nil } func trunkWithoutRemote(cfg *config.Config, trunk, remote string) (trunkTarget, error) { if !git.BranchExists(trunk) { cfg.Errorf("trunk branch %s exists neither locally nor on %s", trunk, remote) return trunkTarget{}, ErrSilent } if trackedRemote, err := git.UpstreamRemote(trunk); err == nil && trackedRemote != "" { cfg.Errorf("%s no longer exists on %s", trunk, remote) cfg.Printf(" Re-point the stack at an existing trunk branch, or use `%s`.", cfg.ColorCyan("gh stack rebase --no-trunk")) return trunkTarget{}, ErrSilent } localSHA, err := git.RevParse(trunk) if err != nil { cfg.Errorf("could not resolve local trunk branch %s: %v", trunk, err) return trunkTarget{}, ErrSilent } cfg.Warningf("Trunk %s only exists locally — %s has no such branch", trunk, remote) return trunkTarget{Branch: trunk, Ref: trunk, SHA: localSHA}, nil } // cascadeRebaseOpts holds parameters for a cascade rebase across a range of // stack branches. type cascadeRebaseOpts struct { Cfg *config.Config Stack *stack.Stack Branches []stack.BranchRef // the range of branches to rebase StartAbsIdx int // index of Branches[0] in Stack.Branches OriginalRefs map[string]string NeedsOnto bool OntoOldBase string CommitterDateIsAuthorDate bool TrunkRef string } func (o cascadeRebaseOpts) trunkRef() string { if o.TrunkRef != "" { return o.TrunkRef } return o.Stack.Trunk.Branch } // resolveRebaseOldBase returns a boundary the branch actually contains. // An ordinary merge-base is intentionally not a fallback: after a parent is // amended it falls below the old parent commit and would replay that commit // into the child, which is the corruption this helper prevents. func resolveRebaseOldBase(currentParentTip, recordedBase, newBase, branch string) (string, error) { isValid := func(candidate string) bool { if candidate == "" { return false } ok, err := git.IsAncestor(candidate, branch) return err == nil && ok } if isValid(currentParentTip) { return currentParentTip, nil } if recordedBase != currentParentTip && isValid(recordedBase) { return recordedBase, nil } if forkPoint, err := git.MergeBaseForkPoint(newBase, branch); err == nil && isValid(forkPoint) { return forkPoint, nil } return "", fmt.Errorf( "could not determine the previous base of %s after %s changed; rebase this branch manually", branch, newBase, ) } // cascadeRebaseResult describes the outcome of a cascade rebase. type cascadeRebaseResult struct { Rebased bool // at least one branch was successfully rebased Conflicted bool // a rebase conflict was detected (recoverable via --continue) Err error // a fatal error occurred (not recoverable via --continue) ConflictIdx int // absolute index in Stack.Branches of the conflicting branch ConflictBranch string // name of the conflicting branch ConflictBase string // base branch we were rebasing onto Remaining []string // branch names after the conflict point NeedsOnto bool // --onto state at the conflict point (for --continue) OntoOldBase string // ontoOldBase at the conflict point (for --continue) } // cascadeRebase performs a cascade rebase across the given branch range. It // stops at the first conflict and returns a result describing what happened. // The caller is responsible for conflict recovery (abort+restore or save state). func cascadeRebase(opts cascadeRebaseOpts) cascadeRebaseResult { s := opts.Stack cfg := opts.Cfg needsOnto := opts.NeedsOnto ontoOldBase := opts.OntoOldBase originalRefs := opts.OriginalRefs result := cascadeRebaseResult{} rebaseOpts := git.RebaseOpts{CommitterDateIsAuthorDate: opts.CommitterDateIsAuthorDate} trunkRef := opts.trunkRef() for i, br := range opts.Branches { absIdx := opts.StartAbsIdx + i var base string if absIdx == 0 { base = trunkRef } else { base = s.Branches[absIdx-1].Branch } // Skip merged and queued branches — but treat them differently for // downstream rebasing. if br.IsSkipped() { if br.IsMerged() { // A merged PR's commits are already in trunk, so downstream // branches must drop them by rebasing --onto the first // non-merged ancestor. ontoOldBase = originalRefs[br.Branch] needsOnto = true cfg.Successf("Skipping %s (PR %s merged)", br.Branch, cfg.PRLink(br.PullRequest.Number, br.PullRequest.URL)) } else { // A queued PR is frozen in the merge queue and its commits are // NOT yet in trunk. Downstream branches must stay stacked on top // of it, so do not switch to --onto (which would drop its // commits). Reset onto state in case a merged branch set it. needsOnto = false cfg.Successf("Skipping %s (PR %s queued)", br.Branch, cfg.PRLink(br.PullRequest.Number, br.PullRequest.URL)) } continue } if needsOnto { // Find --onto target: first non-merged ancestor, or trunk. Queued // ancestors keep their commits, so they are valid --onto targets. newBase := trunkRef for j := absIdx - 1; j >= 0; j-- { if !s.Branches[j].IsMerged() { newBase = s.Branches[j].Branch break } } actualOldBase, err := resolveRebaseOldBase(ontoOldBase, br.Base, newBase, br.Branch) if err != nil { return cascadeRebaseResult{ Rebased: result.Rebased, Err: err, } } if err := git.RebaseOnto(newBase, actualOldBase, br.Branch, rebaseOpts); err != nil { if git.IsRebaseStartError(err) { return cascadeRebaseResult{ Rebased: result.Rebased, Err: fmt.Errorf("could not start rebase of %s onto %s: %w", br.Branch, newBase, err), } } remaining := make([]string, 0, len(opts.Branches)-i-1) for j := i + 1; j < len(opts.Branches); j++ { remaining = append(remaining, opts.Branches[j].Branch) } return cascadeRebaseResult{ Rebased: result.Rebased, Conflicted: true, ConflictIdx: absIdx, ConflictBranch: br.Branch, ConflictBase: newBase, Remaining: remaining, NeedsOnto: true, OntoOldBase: originalRefs[br.Branch], } } cfg.Successf("Rebased %s onto %s (adjusted for merged PR)", br.Branch, newBase) result.Rebased = true ontoOldBase = originalRefs[br.Branch] } else { var rebaseErr error if absIdx > 0 { oldBase, err := resolveRebaseOldBase(originalRefs[base], br.Base, base, br.Branch) if err != nil { return cascadeRebaseResult{ Rebased: result.Rebased, Err: err, } } rebaseErr = git.RebaseOnto(base, oldBase, br.Branch, rebaseOpts) } else { if err := git.CheckoutBranch(br.Branch); err != nil { return cascadeRebaseResult{ Rebased: result.Rebased, Err: fmt.Errorf("checking out %s: %w", br.Branch, err), } } rebaseErr = git.Rebase(base, rebaseOpts) } if rebaseErr != nil { if git.IsRebaseStartError(rebaseErr) { return cascadeRebaseResult{ Rebased: result.Rebased, Err: fmt.Errorf("could not start rebase of %s onto %s: %w", br.Branch, base, rebaseErr), } } remaining := make([]string, 0, len(opts.Branches)-i-1) for j := i + 1; j < len(opts.Branches); j++ { remaining = append(remaining, opts.Branches[j].Branch) } return cascadeRebaseResult{ Rebased: result.Rebased, Conflicted: true, ConflictIdx: absIdx, ConflictBranch: br.Branch, ConflictBase: base, Remaining: remaining, NeedsOnto: false, OntoOldBase: originalRefs[br.Branch], } } cfg.Successf("Rebased %s onto %s", br.Branch, base) result.Rebased = true } } return result } // verifyStacked returns active branches in the requested range that do not // contain their effective parent. func verifyStacked(s *stack.Stack, trunkRef string, startIdx, endIdx int) []string { if trunkRef == "" { trunkRef = s.Trunk.Branch } if startIdx < 0 { startIdx = 0 } if endIdx > len(s.Branches) { endIdx = len(s.Branches) } var unstacked []string for i := startIdx; i < endIdx; i++ { br := s.Branches[i] if br.IsSkipped() { continue } parent := trunkRef for j := i - 1; j >= 0; j-- { if !s.Branches[j].IsMerged() { parent = s.Branches[j].Branch break } } isAnc, err := git.IsAncestor(parent, br.Branch) if err != nil || !isAnc { unstacked = append(unstacked, br.Branch) } } return unstacked } func stackNeedsRebase(s *stack.Stack, trunkRef string) bool { return len(verifyStacked(s, trunkRef, 0, len(s.Branches))) > 0 } func reportUnstacked(cfg *config.Config, trunkRef string, unstacked []string) { cfg.Errorf("rebase did not leave these branches on their expected parents: %s", strings.Join(unstacked, ", ")) cfg.Printf(" Trunk target: %s", trunkRef) } // resolvePR resolves a user-provided target to a stack and branch using // waterfall logic: PR URL → PR number → branch name. func resolvePR(cfg *config.Config, sf *stack.StackFile, target string) (*stack.Stack, *stack.BranchRef, error) { // Try parsing as a GitHub PR URL (e.g. https://github.com/owner/repo/pull/42). if prNumber, ok := parsePRURL(target); ok { s, b := sf.FindStackByPRNumber(prNumber) if s != nil && b != nil { return s, b, nil } } // Try parsing as a PR number. if prNumber, err := strconv.Atoi(target); err == nil && prNumber > 0 { s, b := sf.FindStackByPRNumber(prNumber) if s != nil && b != nil { return s, b, nil } } // Try matching as a branch name. stacks := sf.FindAllStacksForBranch(target) if len(stacks) > 0 { s := stacks[0] idx := s.IndexOf(target) if idx >= 0 { return s, &s.Branches[idx], nil } // Target matched as trunk — return the first active branch. if len(s.Branches) > 0 { return s, &s.Branches[0], nil } } return nil, nil, fmt.Errorf( "no locally tracked stack found for %q\n"+ "To pull down a stack from remote, use the PR number: `%s`", target, cfg.ColorCyan("gh stack checkout "), ) } // parsePRURL extracts a PR number from a GitHub pull request URL. // Returns the number and true if the URL matches, or 0 and false otherwise. func parsePRURL(raw string) (int, bool) { u, err := url.Parse(raw) if err != nil || u.Host == "" { return 0, false } // Match paths like /owner/repo/pull/123 parts := strings.Split(strings.Trim(u.Path, "/"), "/") if len(parts) < 4 || parts[2] != "pull" { return 0, false } n, err := strconv.Atoi(parts[3]) if err != nil || n <= 0 { return 0, false } return n, true } // ensureRerere checks whether git rerere is enabled and, if not, prompts the // user for permission before enabling it. If the user previously declined, // the prompt is suppressed. In non-interactive sessions the function is a // no-op so commands can still run in CI/scripting. // // Returns errInterrupt if the user pressed Ctrl+C during the prompt. func ensureRerere(cfg *config.Config) error { enabled, err := git.IsRerereEnabled() if err != nil || enabled { return nil } declined, _ := git.IsRerereDeclined() if declined { return nil } if !cfg.IsInteractive() { return nil } p := prompter.New(cfg.In, cfg.Out, cfg.Err) ok, err := p.Confirm("Enable git rerere to remember conflict resolutions?", true) if err != nil { if isInterruptError(err) { printInterrupt(cfg) return errInterrupt } return nil } if ok { _ = git.EnableRerere() } else { _ = git.SaveRerereDeclined() } return nil } // pickRemote determines which remote to use. If remoteOverride is // non-empty, it is returned directly. Otherwise it delegates to // git.ResolveRemote for config-based resolution and remote listing. // If multiple remotes exist with no configured default, the user is // prompted to select one interactively and offered the option to save // the choice via gh-stack.remote git config. func pickRemote(cfg *config.Config, branch, remoteOverride string) (string, error) { if remoteOverride != "" { return remoteOverride, nil } remote, err := git.ResolveRemote(branch) if err == nil { return remote, nil } var multi *git.ErrMultipleRemotes if !errors.As(err, &multi) { return "", err } if !cfg.IsInteractive() { return "", fmt.Errorf("multiple remotes configured; set remote.pushDefault or use an interactive terminal") } p := prompter.New(cfg.In, cfg.Out, cfg.Err) 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, promptErr := selectFn("Multiple remotes found. Which remote should be used?", "", multi.Remotes) if promptErr != nil { if isInterruptError(promptErr) { if cfg.SelectFn == nil { clearSelectPrompt(cfg, len(multi.Remotes)) } printInterrupt(cfg) return "", errInterrupt } return "", fmt.Errorf("remote selection: %w", promptErr) } selectedRemote := multi.Remotes[selected] // Offer to save the selected remote for future operations. save, confirmErr := confirmSaveRemote(cfg, selectedRemote) if confirmErr != nil { if errors.Is(confirmErr, errInterrupt) { return "", errInterrupt } // Non-fatal: proceed with the selected remote even if the prompt fails. return selectedRemote, nil } if save { if saveErr := git.SaveRemote(selectedRemote); saveErr == nil { cfg.Successf("Saved %q as the default remote for gh stack", selectedRemote) cfg.Printf("To change later, run: %s", cfg.ColorCyan("git config gh-stack.remote ")) cfg.Printf("To clear, run: %s", cfg.ColorCyan("git config --unset gh-stack.remote")) } else { cfg.Warningf("Could not save remote preference: %v", saveErr) } } return selectedRemote, nil } // confirmSaveRemote asks the user whether to persist the selected remote // for all future gh stack operations. Returns errInterrupt on Ctrl+C. func confirmSaveRemote(cfg *config.Config, remote string) (bool, error) { prompt := fmt.Sprintf("Save %q as the default remote for all gh stack operations?", remote) if cfg.ConfirmFn != nil { return cfg.ConfirmFn(prompt, true) } p := prompter.New(cfg.In, cfg.Out, cfg.Err) ok, err := p.Confirm(prompt, true) if err != nil { if isInterruptError(err) { printInterrupt(cfg) return false, errInterrupt } return false, err } return ok, nil } // ensureLocalBranchFromRemote creates a local branch tracking remote/ // if it does not already exist. Merged PRs whose remote ref has been deleted // are skipped (returns skipped=true, err=nil). A non-merged branch that cannot // be created is a hard failure (returns ErrSilent). Shared by importRemoteStack // and the sync remote-ahead pull. func ensureLocalBranchFromRemote(cfg *config.Config, remote string, pr *github.PullRequest) (skipped bool, err error) { branch := pr.HeadRefName if git.BranchExists(branch) { return false, nil } remoteRef := remote + "/" + branch if createErr := git.CreateBranch(branch, remoteRef); createErr != nil { if pr.Merged { cfg.Infof("Skipping merged branch %s", branch) return true, nil } cfg.Errorf("failed to pull branch %s from %s: %v", branch, remoteRef, createErr) return false, ErrSilent } _ = git.SetUpstreamTracking(branch, remote) cfg.Successf("Pulled branch %s", branch) return false, nil } // remoteReconcileResult reports how reconcileRemoteStack resolved the // relationship between the local stack and its tracked remote stack. type remoteReconcileResult struct { // stack, when non-nil, is the stack the caller should continue with. It // differs from the input stack only when "use remote as source of truth" // rebuilt the stack (which reslices StackFile.Stacks and invalidates the // original pointer). stack *stack.Stack // stop tells runSync to end the sync immediately after reconcile (before any // fast-forward, rebase, or push) and exit successfully. Set when the user // cancels or deletes the remote stack, and when a divergence is detected in a // non-interactive terminal. The resolving path prints its own outcome message. stop bool } // remoteStackClass classifies the relationship between the local stack's active // (non-merged) branches and its tracked remote stack's active branches. type remoteStackClass int const ( remoteStackInSync remoteStackClass = iota // sequences are identical remoteStackCleanAhead // local is a strict prefix of remote (remote appended on top) remoteStackLocalAhead // remote is a strict prefix of local (local appended on top) remoteStackDivergent // neither is a prefix of the other ) // reconcileRemoteStack brings remote-ahead stack changes into the local stack // and resolves divergences. It runs early in `sync` (after fetch, before // rebase/push) so any pulled branches participate in the normal flow. // // It only acts on stacks tracked on the remote (s.ID != ""). It is best-effort: // a missing client, stacked PRs being unavailable, or any API error causes it // to skip so the rest of sync still runs. Untracked stacks (s.ID == "") are // left to the syncStack/reconcileUntrackedStack path. func reconcileRemoteStack(cfg *config.Config, sf *stack.StackFile, s *stack.Stack, currentBranch, gitDir, remote string) (remoteReconcileResult, error) { var res remoteReconcileResult if s.ID == "" { return res, nil } client, err := cfg.GitHubClient() if err != nil { return res, nil } stacks, err := client.ListStacks() if err != nil { // Covers 404 (stacked PRs unavailable) and transient API errors. return res, nil } var remotePRNumbers []int found := false for _, rs := range stacks { if strconv.Itoa(rs.ID) == s.ID { remotePRNumbers = rs.PRNumbers() found = true break } } if !found { // The remote stack was deleted; the existing updateStack 404 → recreate // path in syncStack handles this. return res, nil } prs, err := fetchStackPRDetails(client, remotePRNumbers) if err != nil { return res, nil } localActive, remoteActive := activeStackSequences(s, prs) switch classifyRemoteStack(localActive, remoteActive) { case remoteStackInSync, remoteStackLocalAhead: // Nothing to pull; the existing flow pushes/updates the remote. Copy the // freshly fetched PR state (merged/queued) onto the local branches so the // fast-forward/rebase/push steps skip merged and merge-queued branches // rather than rewriting them before the later PR-sync step runs. syncRemotePRState(s, prs) return res, nil case remoteStackCleanAhead: return pullRemoteAdditions(cfg, sf, s, gitDir, remote, prs) default: return resolveStackDivergence(cfg, client, sf, s, currentBranch, gitDir, remote, prs, remoteActive) } } // activeStackSequences returns the ordered active (non-merged) branch-name // sequences for the local stack and the fetched remote PRs. Merged state is // taken from the freshly fetched remote PRs (by branch name) when available so // that a locally pruned merged branch does not look like a divergence. func activeStackSequences(s *stack.Stack, prs []*github.PullRequest) (localActive, remoteActive []string) { remoteMerged := make(map[string]bool, len(prs)) for _, pr := range prs { remoteMerged[pr.HeadRefName] = pr.Merged if !pr.Merged { remoteActive = append(remoteActive, pr.HeadRefName) } } for _, b := range s.Branches { merged := b.IsMerged() if m, ok := remoteMerged[b.Branch]; ok { merged = m } if !merged { localActive = append(localActive, b.Branch) } } return localActive, remoteActive } // classifyRemoteStack compares the active local and remote branch sequences. func classifyRemoteStack(localActive, remoteActive []string) remoteStackClass { if slicesEqualStr(localActive, remoteActive) { return remoteStackInSync } if isStrictPrefix(localActive, remoteActive) { return remoteStackCleanAhead } if isStrictPrefix(remoteActive, localActive) { return remoteStackLocalAhead } return remoteStackDivergent } // isStrictPrefix reports whether a is a strict prefix of b: a is shorter than b // and every element of a equals the element at the same position in b. func isStrictPrefix(a, b []string) bool { if len(a) >= len(b) { return false } for i := range a { if a[i] != b[i] { return false } } return true } // slicesEqualStr reports whether two string slices are element-wise equal. func slicesEqualStr(a, b []string) bool { if len(a) != len(b) { return false } for i := range a { if a[i] != b[i] { return false } } return true } // pullRemoteAdditions handles the clean append-on-top case: it fetches and // creates local branches for the remote PRs not yet tracked locally, appends // them (in remote order) to the stack, and persists. Merged remote PRs with no // local branch are ignored (they are not part of ongoing local work). func pullRemoteAdditions(cfg *config.Config, sf *stack.StackFile, s *stack.Stack, gitDir, remote string, prs []*github.PullRequest) (remoteReconcileResult, error) { var res remoteReconcileResult existing := make(map[string]bool, len(s.Branches)) for _, b := range s.Branches { existing[b.Branch] = true } var newPRs []*github.PullRequest for _, pr := range prs { if pr.Merged || existing[pr.HeadRefName] { continue } newPRs = append(newPRs, pr) } if len(newPRs) == 0 { return res, nil } // A remote-added branch must not collide with a branch already tracked by // another local stack, or with an existing local branch we would otherwise // adopt as "pulled" without actually fetching it. Abort rather than persist // duplicate ownership or a stale branch. for _, pr := range newPRs { if err := sf.ValidateNoDuplicateBranch(pr.HeadRefName); err != nil { cfg.Errorf("Cannot pull %s from the remote stack: %s", pr.HeadRefName, err) return res, ErrSilent } if git.BranchExists(pr.HeadRefName) { cfg.Errorf("Cannot pull %s from the remote stack: a local branch with that name already exists", pr.HeadRefName) return res, ErrSilent } } newBranchNames := make([]string, len(newPRs)) for i, pr := range newPRs { newBranchNames[i] = pr.HeadRefName } _ = git.FetchBranches(remote, newBranchNames) cfg.Printf("") cfg.Printf("Pulling %d new %s from the remote stack ...", len(newPRs), plural(len(newPRs), "branch", "branches")) added := 0 for _, pr := range newPRs { skipped, err := ensureLocalBranchFromRemote(cfg, remote, pr) if err != nil { return res, err } if skipped { continue } s.Branches = append(s.Branches, stack.BranchRef{ Branch: pr.HeadRefName, PullRequest: &stack.PullRequestRef{ Number: pr.Number, ID: pr.ID, URL: pr.URL, Merged: pr.Merged, }, }) added++ } if added > 0 { // Copy the freshly fetched PR state (including the transient queued flag) // onto the pulled branches so the rebase/push steps skip merge-queued ones. syncRemotePRState(s, prs) updateBaseSHAs(s) if err := stack.Save(gitDir, sf); err != nil { return res, handleSaveError(cfg, err) } cfg.Successf("Pulled %d new %s into the stack from the remote", added, plural(added, "branch", "branches")) } return res, nil } // resolveStackDivergence handles a stack whose local composition has diverged // from the remote (neither is a prefix of the other). In an interactive // terminal it prompts the user to resolve it; otherwise (or when the user // cancels) it aborts the sync so nothing is pushed or updated. func resolveStackDivergence(cfg *config.Config, client github.ClientOps, sf *stack.StackFile, s *stack.Stack, currentBranch, gitDir, remote string, prs []*github.PullRequest, remoteActive []string) (remoteReconcileResult, error) { cfg.Printf("") cfg.Warningf("Your local stack has diverged from the stack on GitHub") cfg.Printf(" Local: %s", s.DisplayChain()) cfg.Printf(" Remote: (%s) <- %s", s.Trunk.Branch, strings.Join(remoteActive, " <- ")) if !cfg.IsInteractive() { cfg.Printf(" Re-run in an interactive terminal to resolve, or import the remote stack with `%s`.", cfg.ColorCyan("gh stack checkout ")) cfg.Infof("Sync aborted — no changes were made") return remoteReconcileResult{stop: true}, nil } options := []string{ "Update local to match remote — replace your local stack with the remote version", "Delete the remote stack on GitHub — keep your local stack and recreate on remote later", "Cancel — make no changes", } p := prompter.New(cfg.In, cfg.Out, cfg.Err) 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("How would you like to resolve?", "", options) if err != nil { if isInterruptError(err) { if cfg.SelectFn == nil { clearSelectPrompt(cfg, len(options)) } printInterrupt(cfg) return remoteReconcileResult{stop: true}, errInterrupt } cfg.Errorf("selection failed: %v", err) return remoteReconcileResult{}, ErrSilent } switch selected { case 0: return resolveDivergenceUseRemote(cfg, sf, s, currentBranch, gitDir, remote, prs) case 1: return resolveDivergenceDeleteRemote(cfg, client, sf, s, gitDir) default: // Cancel: stop the sync without touching branches or PRs. cfg.Infof("Sync aborted — no changes were made") return remoteReconcileResult{stop: true}, nil } } // resolveDivergenceUseRemote replaces the local stack composition with the // remote's. It refuses when the working tree is dirty (the rebuild is // destructive to stack tracking). Local-only branches remain as git refs but // are no longer part of the stack. Returns the rebuilt stack pointer so the // caller can continue with it (importRemoteStack reslices StackFile.Stacks). func resolveDivergenceUseRemote(cfg *config.Config, sf *stack.StackFile, s *stack.Stack, currentBranch, gitDir, remote string, prs []*github.PullRequest) (remoteReconcileResult, error) { var res remoteReconcileResult // Replacing the local stack is destructive, so require a known-clean working // tree. Treat an inability to inspect the tree as a reason to abort (a failed // status must not be read as "clean"). dirty, err := git.HasUncommittedChanges() if err != nil { cfg.Errorf("Could not determine whether the working tree is clean: %v", err) return res, ErrSilent } if dirty { cfg.Errorf("You have uncommitted changes — commit or stash them before replacing your local stack with the remote") return res, ErrSilent } trunk := s.Trunk.Branch remoteStackID := s.ID remoteStackNumber := s.Number oldBranches := s.BranchNames() removeLocalStack(sf, s) // A remote PR branch must not already be owned by another local stack, or // importing it would write the same branch into two stacks. Validate against // the remaining stacks (the current one has been removed above). for _, pr := range prs { if err := sf.ValidateNoDuplicateBranch(pr.HeadRefName); err != nil { cfg.Errorf("Cannot adopt the remote stack: %s", err) return res, ErrSilent } } newStack, err := importRemoteStack(cfg, sf, gitDir, remote, trunk, prs, remoteStackID, remoteStackNumber) if err != nil { return res, err } // Populate the transient queued/merged state so the rebase/push steps skip // merge-queued or merged branches in the adopted stack. syncRemotePRState(newStack, prs) // If the user was on a branch that the remote stack no longer contains, // move them to the nearest surviving branch so they don't end up detached // from the stack. if target := nearestBranchAfterReplace(oldBranches, currentBranch, newStack); target != currentBranch { if err := git.CheckoutBranch(target); err != nil { cfg.Warningf("Failed to switch from %s to %s: %v", currentBranch, target, err) } else { cfg.Printf("Switched to %s (original branch %s is no longer in the stack)", target, currentBranch) } } if err := stack.Save(gitDir, sf); err != nil { return res, handleSaveError(cfg, err) } res.stack = newStack cfg.Successf("Local stack replaced with the remote version") return res, nil } // nearestBranchAfterReplace decides which branch to check out after the local // stack has been replaced with the remote version. If currentBranch still // exists in newStack (or the user was on the trunk / a non-stack branch), it is // returned unchanged. Otherwise it delegates to stack.NearestSurvivingBranch to // pick the nearest branch from the pre-replacement ordering (neighbor above, // then below), falling back to the top of the new stack, or the trunk if the new // stack has no branches. Mirrors the checkout behavior of `gh stack modify`. func nearestBranchAfterReplace(oldBranches []string, currentBranch string, newStack *stack.Stack) string { // Still in the new stack, or never a stack branch (e.g. the trunk): stay put. if newStack.IndexOf(currentBranch) >= 0 || slices.Index(oldBranches, currentBranch) < 0 { return currentBranch } if nearest := stack.NearestSurvivingBranch(oldBranches, currentBranch, func(name string) bool { return newStack.IndexOf(name) >= 0 }); nearest != "" { return nearest } // A dropped stack branch with no surviving neighbor — fall back to the top // of the new stack, then the trunk. if len(newStack.Branches) > 0 { return newStack.Branches[len(newStack.Branches)-1].Branch } return newStack.Trunk.Branch } // resolveDivergenceDeleteRemote deletes the diverged stack object on GitHub and // removes the local association (clearing the stack ID). The PRs and local // branches are left untouched — only the stack grouping on GitHub is removed. It // stops the sync and points the user at `gh stack submit` to recreate the stack // (which, unlike sync, also creates PRs for any un-submitted branches). func resolveDivergenceDeleteRemote(cfg *config.Config, client github.ClientOps, sf *stack.StackFile, s *stack.Stack, gitDir string) (remoteReconcileResult, error) { res := remoteReconcileResult{stop: true} number, err := ensureStackNumber(client, s) if err != nil || number == 0 { cfg.Warningf("Remote stack already deleted") } else if _, dissolved, unstackErr := client.Unstack(number); unstackErr != nil { var httpErr *api.HTTPError if errors.As(unstackErr, &httpErr) && httpErr.StatusCode == 404 { cfg.Warningf("Remote stack already deleted") } else { cfg.Errorf("failed to delete remote stack: %v", unstackErr) return res, ErrAPIFailure } } else if dissolved { cfg.Successf("Deleted the stack on GitHub") } else { cfg.Warningf("Some pull requests could not be unstacked and remain on GitHub") } s.ID = "" s.Number = 0 if err := stack.Save(gitDir, sf); err != nil { return res, handleSaveError(cfg, err) } cfg.Printf("") cfg.Printf("Your PRs and local branches are unchanged — only the stack on GitHub was removed.") cfg.Printf(" Run `%s` to recreate the stack on GitHub.", cfg.ColorCyan("gh stack submit")) cfg.Printf(" Run `%s` first if you want to change the stack's structure.", cfg.ColorCyan("gh stack modify")) return res, nil }