diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index d564e185..36a0e1cb 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -17,7 +17,7 @@ No Makefile, no code generation, no external linter config. Standard Go toolchai - `cmd/`: One Cobra command per file. Each exports `Cmd(cfg *config.Config)` with logic in `run()`. - `internal/git/`: `Ops` interface (52 methods) wrapping git CLI. `MockOps` for tests. Package-level functions delegate to swappable `ops` variable. -- `internal/github/`: `ClientOps` interface (13 methods) for GitHub API. `MockClient` for tests. Stack operations use the public Stacks REST API (`/repos/{owner}/{repo}/stacks`). +- `internal/github/`: `ClientOps` interface (18 methods) for GitHub API. `MockClient` for tests. Stack operations use the public Stacks REST API (`/repos/{owner}/{repo}/stacks`); merges use the async merge API (`/repos/{owner}/{repo}/pulls/{n}/merge-async`) with an explicit `merge_action` (`direct_merge` or `merge_queue`) chosen from the base branch's merge-queue detection. `merge_action` is optional — omitting it (or sending `default`) lets the server auto-route (merge queue if one is configured, else direct merge) — but the CLI sends it explicitly so a wrong detection fails loudly instead of silently merging directly. - `internal/config/`: `Config` struct passed to all commands. Holds I/O, colors, and test hooks (`SelectFn`, `ConfirmFn`, `InputFn`, `GitHubClientOverride`). - `internal/stack/`: Stack file (`.git/gh-stack`, JSON) management with file locking. - `internal/tui/`: bubbletea views (`stackview`, `modifyview`). diff --git a/AGENTS.md b/AGENTS.md index 98875637..efe966fb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,7 +35,7 @@ internal/ gitops.go # Ops interface (52 methods) mock_ops.go # MockOps. Each method has a corresponding *Fn field. github/ # github.ClientOps interface + real Client - client_interface.go # ClientOps interface (13 methods) + client_interface.go # ClientOps interface (18 methods) mock_client.go # MockClient. Uses function-pointer fields for testing. stack/ # stack file (.git/gh-stack) management, JSON schema, locking schema.json # JSON Schema for the stack file format @@ -57,7 +57,7 @@ skills/ # AI agent skill definition (SKILL.md) | Group | Commands | |-------|----------| | Stack management | `init`, `add`, `view`, `checkout`, `modify`, `unstack` | -| Remote operations | `submit`, `sync`, `rebase`, `push`, `link` | +| Remote operations | `submit`, `sync`, `rebase`, `push`, `link`, `merge` | | Navigation | `switch`, `up`, `down`, `top`, `bottom`, `trunk` | | Utilities | `alias`, `feedback` | @@ -109,7 +109,7 @@ if errors.As(err, &exitErr) { ... } ### Key interfaces - **`git.Ops`** (`internal/git/gitops.go`): 52 methods wrapping git CLI calls. The production implementation uses `cli/go-gh`'s `client.Command()` via `run()` and `runSilent()` helpers. Package-level functions (e.g., `git.CurrentBranch()`) delegate to a swappable package-level `ops` variable. -- **`github.ClientOps`** (`internal/github/client_interface.go`): 13 methods for GitHub API (PRs, stacks). Stack operations use the public Stacks REST API (`/repos/{owner}/{repo}/stacks`): `ListStacks`, `FindStackForPR`, `GetStack`, `CreateStack`, `AddToStack` (delta append), `Unstack`. Injected via `cfg.GitHubClientOverride` in tests. +- **`github.ClientOps`** (`internal/github/client_interface.go`): 18 methods for GitHub API (PRs, stacks, merges). Stack operations use the public Stacks REST API (`/repos/{owner}/{repo}/stacks`): `ListStacks`, `FindStackForPR`, `GetStack`, `CreateStack`, `AddToStack` (delta append), `Unstack`. Async stack merges use `RepoMergeConfig` (GraphQL: allowed merge methods + viewer's default), `BaseBranchUsesMergeQueue` (GraphQL: detects a base-branch merge queue to select the explicit `merge_action`), `MergeStackAsync`, and `GetAsyncMergeResult` (`/repos/{owner}/{repo}/pulls/{n}/merge-async`). Injected via `cfg.GitHubClientOverride` in tests. - **`config.Config`** (`internal/config/config.go`): Central configuration passed to all commands. Holds I/O streams, color functions, and test hook fields (`SelectFn`, `ConfirmFn`, `InputFn`, `RepoOverride`). ### Stack file diff --git a/README.md b/README.md index 0943f7d4..f680c87c 100644 --- a/README.md +++ b/README.md @@ -347,13 +347,13 @@ gh stack sync --prune ### `gh stack push` -Push all branches in the current stack to the remote. +Push active branches in the current stack to the remote. ``` gh stack push [flags] ``` -Pushes every branch to the remote using `--force-with-lease --atomic`. This is a lightweight wrapper around `git push` that knows about all branches in the stack. It does not create or update pull requests — use `gh stack submit` for that. +Pushes every active branch (excluding merged and queued branches) in one `git push` using explicit per-branch `--force-with-lease` checks. The update is not atomic: branches whose leases pass may update even if another branch is rejected. Fix the rejected branch and rerun the command; branches already updated will be unchanged. This command does not create or update pull requests — use `gh stack submit` for that. | Flag | Description | |------|-------------| @@ -416,7 +416,7 @@ If the PRs are not yet in a stack, a new stack is created. If some of the PRs ar | Flag | Description | |------|-------------| -| `--base ` | Base branch for the bottom of the stack (default: `main`) | +| `--base ` | Base branch for the bottom of the stack (defaults to the repository's default branch) | | `--open` | Mark new and existing PRs as ready for review | | `--remote ` | Remote to push to (defaults to auto-detected remote) | @@ -439,6 +439,46 @@ gh stack link 42 43 feature-auth feature-ui gh stack link --base develop --open feat-a feat-b feat-c ``` +### `gh stack merge` + +Merge one or multiple stacked PRs at once. + +``` +gh stack merge [ | ] +``` + +All members of the stack up to and including your chosen pull request are merged into the base branch in a single, all-or-nothing operation: if any PR can't be merged, none are. + +With no argument, the current active local stack is used. Pass a stack number to merge a stack you don't have checked out (a purely remote operation), or a pull request number to merge directly up to that PR. + +In an interactive terminal, a short wizard walks you through choosing which PRs to merge, picking the merge method, and confirming. In a non-interactive terminal, or with `--yes`, the whole stack (or everything up to the given PR) is merged without prompting, using your last-used merge method unless one is specified. + +Only basic pull request state is checked before merging (open and not a draft); GitHub evaluates branch protection and repository rules when the merge runs, so any such failure is reported back to you. **Bypassing merge requirements is not supported** for stacked PR merges. + +If the base branch uses a merge queue, the stack is added to the queue instead of merging directly. The queue chooses the merge method, so the wizard skips the method step and any `--merge-method` (or `--squash`/`--rebase`/`--merge`) flag is ignored with a warning. The selected pull requests are added to the queue together but merge as the queue processes them — they may land in separate groups rather than all at once. + +| Flag | Description | +|------|-------------| +| `--merge-method ` | Merge method to use: `merge`, `squash`, or `rebase` | +| `--merge` / `--squash` / `--rebase` | Shorthands for the corresponding merge method | +| `-y, --yes` | Merge without prompting for confirmation | + +**Examples:** + +```sh +# Merge the current stack (interactive picker) +gh stack merge + +# Merge a stack you don't have checked out, by stack number +gh stack merge 7 + +# Merge everything up to and including PR #42 +gh stack merge 42 + +# Merge the whole current stack without prompting, squashing +gh stack merge --yes --squash +``` + ### `gh stack view` View the current stack. diff --git a/cmd/add.go b/cmd/add.go index 7e50a524..ca42fddc 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -169,6 +169,14 @@ func runAdd(cfg *config.Config, opts *addOptions, args []string) error { // If the branch already exists in git but is not part of any stack, // adopt it instead of erroring. This mirrors the init command's behavior. adopted := git.BranchExists(branchName) + var adoptedBase string + if adopted { + adoptedBase, err = git.MergeBase(currentBranch, branchName) + if err != nil { + cfg.Errorf("failed to determine the common base of %s and %s: %s", currentBranch, branchName, err) + return ErrSilent + } + } // Stage changes before creating the branch so we can fail early if // there's nothing to commit (avoids leaving an empty orphan branch). @@ -191,9 +199,12 @@ func runAdd(cfg *config.Config, opts *addOptions, args []string) error { return ErrSilent } - base, err := git.RevParse(currentBranch) - if err != nil { - cfg.Warningf("could not resolve base SHA for %s: %s", currentBranch, err) + base := adoptedBase + if !adopted { + base, err = git.RevParse(currentBranch) + if err != nil { + cfg.Warningf("could not resolve base SHA for %s: %s", currentBranch, err) + } } s.Branches = append(s.Branches, stack.BranchRef{Branch: branchName, Base: base}) diff --git a/cmd/add_test.go b/cmd/add_test.go index 9bb82d2c..6a7eb259 100644 --- a/cmd/add_test.go +++ b/cmd/add_test.go @@ -446,6 +446,11 @@ func TestAdd_AdoptsExistingBranch(t *testing.T) { GitDirFn: func() (string, error) { return gitDir, nil }, CurrentBranchFn: func() (string, error) { return "b1", nil }, BranchExistsFn: func(name string) bool { return name == "existing-branch" }, + MergeBaseFn: func(parent, branch string) (string, error) { + assert.Equal(t, "b1", parent) + assert.Equal(t, "existing-branch", branch) + return "common-base", nil + }, CreateBranchFn: func(name, base string) error { createBranchCalled = true return nil @@ -472,6 +477,8 @@ func TestAdd_AdoptsExistingBranch(t *testing.T) { require.NoError(t, err) names := sf.Stacks[0].BranchNames() assert.Equal(t, "existing-branch", names[len(names)-1], "adopted branch appended to stack") + assert.Equal(t, "common-base", sf.Stacks[0].Branches[len(sf.Stacks[0].Branches)-1].Base, + "adopted branch should record the actual common ancestor") } func TestAdd_RejectsExistingBranchInStack(t *testing.T) { @@ -520,6 +527,7 @@ func TestAdd_AdoptsExistingBranchWithCommit(t *testing.T) { GitDirFn: func() (string, error) { return gitDir, nil }, CurrentBranchFn: func() (string, error) { return "b1", nil }, BranchExistsFn: func(name string) bool { return name == "existing-branch" }, + MergeBaseFn: func(string, string) (string, error) { return "common-base", nil }, RevParseMultiFn: func(refs []string) ([]string, error) { return []string{"aaa", "bbb"}, nil // different SHAs = branch has commits }, @@ -548,3 +556,36 @@ func TestAdd_AdoptsExistingBranchWithCommit(t *testing.T) { assert.True(t, commitCalled, "Commit should be called on the adopted branch") assert.Contains(t, output, "Adopted") } + +func TestAdd_AdoptExistingBranchWithoutCommonBaseFails(t *testing.T) { + gitDir := t.TempDir() + saveStack(t, gitDir, stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1"}}, + }) + + checkedOut := false + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "b1", nil }, + BranchExistsFn: func(name string) bool { return name == "unrelated" }, + MergeBaseFn: func(string, string) (string, error) { return "", assert.AnError }, + CheckoutBranchFn: func(string) error { + checkedOut = true + return nil + }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + err := runAdd(cfg, &addOptions{}, []string{"unrelated"}) + output := collectOutput(cfg, outR, errR) + + assert.ErrorIs(t, err, ErrSilent) + assert.False(t, checkedOut) + assert.Contains(t, output, "failed to determine the common base") + + sf, loadErr := stack.Load(gitDir) + require.NoError(t, loadErr) + assert.Equal(t, []string{"b1"}, sf.Stacks[0].BranchNames()) +} diff --git a/cmd/link.go b/cmd/link.go index 86905bd0..033478dc 100644 --- a/cmd/link.go +++ b/cmd/link.go @@ -77,7 +77,7 @@ only when it matches an existing stack.`, }, } - cmd.Flags().StringVar(&opts.base, "base", "main", "Base branch for the bottom of the stack") + cmd.Flags().StringVar(&opts.base, "base", "", "Base branch for the bottom of the stack (defaults to the repository default branch)") cmd.Flags().BoolVar(&opts.open, "open", false, "Mark new and existing PRs as ready for review") cmd.Flags().StringVar(&opts.remote, "remote", "", "Remote to push to (defaults to auto-detected remote)") @@ -209,6 +209,18 @@ func runLinkCreateOrUpdate(cfg *config.Config, client github.ClientOps, opts *li } } + // Resolve the base branch for the bottom of the stack. When --base isn't + // given, default to the repository's default branch (like `gh stack init`). + bottomBase := opts.base + if bottomBase == "" { + base, err := git.DefaultBranch() + if err != nil { + cfg.Errorf("unable to determine default branch\nUse --base to specify the base branch") + return ErrSilent + } + bottomBase = base + } + // Create PRs for branches that don't have one yet. needsCreation := 0 for _, r := range found { @@ -219,13 +231,13 @@ func runLinkCreateOrUpdate(cfg *config.Config, client github.ClientOps, opts *li if needsCreation > 0 { cfg.Printf("Creating %d %s...", needsCreation, plural(needsCreation, "PR", "PRs")) } - resolved, err := createMissingPRs(cfg, client, opts, prArgs, found, templateContent, opts.base) + resolved, err := createMissingPRs(cfg, client, opts, prArgs, found, templateContent, bottomBase) if err != nil { return err } // Fix base branches for existing PRs with wrong bases. - fixBaseBranches(cfg, client, opts, resolved, opts.base) + fixBaseBranches(cfg, client, opts, resolved, bottomBase) // Upsert the stack (reuse the stacks fetched above). prNumbers := make([]int, len(resolved)) diff --git a/cmd/link_test.go b/cmd/link_test.go index d9739611..96fd1a6d 100644 --- a/cmd/link_test.go +++ b/cmd/link_test.go @@ -33,6 +33,9 @@ func newLinkGitMock(branches ...string) *git.MockOps { // --- PR-number tests --- func TestLink_PRNumbers_CreateNewStack(t *testing.T) { + restore := git.SetOps(newLinkGitMock()) + defer restore() + var createdPRs []int cfg, _, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ @@ -69,6 +72,9 @@ func TestLink_PRNumbers_CreateNewStack(t *testing.T) { } func TestLink_PRNumbers_UpdateExistingStack(t *testing.T) { + restore := git.SetOps(newLinkGitMock()) + defer restore() + var updatedNumber int var updatedPRs []int cfg, _, errR := config.NewTestConfig() @@ -110,6 +116,9 @@ func TestLink_PRNumbers_UpdateExistingStack(t *testing.T) { } func TestLink_PRNumbers_ExactMatch_NoOp(t *testing.T) { + restore := git.SetOps(newLinkGitMock()) + defer restore() + cfg, _, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ FindPRByNumberFn: func(n int) (*github.PullRequest, error) { @@ -269,6 +278,9 @@ func TestLink_StacksUnavailable(t *testing.T) { } func TestLink_Create422(t *testing.T) { + restore := git.SetOps(newLinkGitMock()) + defer restore() + cfg, _, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ FindPRByNumberFn: func(n int) (*github.PullRequest, error) { @@ -561,6 +573,9 @@ func TestLink_ReportsMultipleIneligiblePRs(t *testing.T) { // Regression test to ensure a queued PR that is already a member of // the target stack does not block adding new PRs to that same stack. func TestLink_AllowsQueuedPRAlreadyInStack(t *testing.T) { + restore := git.SetOps(newLinkGitMock()) + defer restore() + var updatedNumber int var updatedPRs []int cfg, _, errR := config.NewTestConfig() @@ -614,6 +629,9 @@ func TestLink_AllowsQueuedPRAlreadyInStack(t *testing.T) { // TestLink_AllowsMergedPRAlreadyInStack verifies the exemption also covers // state-based ineligibility (merged/closed) for PRs already in the stack. func TestLink_AllowsMergedPRAlreadyInStack(t *testing.T) { + restore := git.SetOps(newLinkGitMock()) + defer restore() + var updatedPRs []int cfg, _, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ @@ -660,6 +678,9 @@ func TestLink_AllowsMergedPRAlreadyInStack(t *testing.T) { // TestLink_AllowsAutoMergePRAlreadyInStack verifies the exemption also covers // auto-merge-enabled PRs already in the stack. func TestLink_AllowsAutoMergePRAlreadyInStack(t *testing.T) { + restore := git.SetOps(newLinkGitMock()) + defer restore() + var updatedPRs []int cfg, _, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ @@ -1293,6 +1314,216 @@ func TestLink_FixesBaseBranches(t *testing.T) { assert.Contains(t, output, "Updated base branch") } +// TestLink_DefaultBase_RetargetsBottomPRToDefaultBranch is a regression test +// for #260: when --base is omitted, the bottom of the stack must be based on +// the repository's default branch (resolved via git.DefaultBranch), not a +// hardcoded "main". Here the default branch is "develop", so the bottom PR — +// which currently targets "main" — should be retargeted to "develop". +func TestLink_DefaultBase_RetargetsBottomPRToDefaultBranch(t *testing.T) { + defaultBranchCalled := false + restore := git.SetOps(&git.MockOps{ + BranchExistsFn: func(string) bool { return false }, + DefaultBranchFn: func() (string, error) { + defaultBranchCalled = true + return "develop", nil + }, + }) + defer restore() + + var baseUpdates []struct { + number int + base string + } + + cfg, _, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + FindPRByNumberFn: func(n int) (*github.PullRequest, error) { + switch n { + case 10: + // Bottom PR currently targets "main"; it should be retargeted + // to the repository default branch ("develop"). + return &github.PullRequest{ + Number: 10, HeadRefName: "feat-a", BaseRefName: "main", + URL: "https://github.com/o/r/pull/10", + }, nil + case 20: + return &github.PullRequest{ + Number: 20, HeadRefName: "feat-b", BaseRefName: "feat-a", + URL: "https://github.com/o/r/pull/20", + }, nil + } + return nil, nil + }, + UpdatePRBaseFn: func(number int, base string) error { + baseUpdates = append(baseUpdates, struct { + number int + base string + }{number, base}) + return nil + }, + ListStacksFn: func() ([]github.RemoteStack, error) { return []github.RemoteStack{}, nil }, + CreateStackFn: func([]int) (*github.RemoteStack, error) { return &github.RemoteStack{ID: 1, Number: 1}, nil }, + } + + cmd := LinkCmd(cfg) + cmd.SetArgs([]string{"10", "20"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + _, _ = io.ReadAll(errR) + + assert.NoError(t, err) + assert.True(t, defaultBranchCalled, "git.DefaultBranch should be consulted when --base is omitted") + // The bottom PR (#10) should be retargeted to the default branch, not "main". + require.Len(t, baseUpdates, 1) + assert.Equal(t, 10, baseUpdates[0].number) + assert.Equal(t, "develop", baseUpdates[0].base) +} + +// TestLink_DefaultBase_CreatesBottomPROnDefaultBranch verifies that a newly +// created bottom PR is based on the repository default branch when --base is +// omitted, rather than a hardcoded "main". +func TestLink_DefaultBase_CreatesBottomPROnDefaultBranch(t *testing.T) { + restore := git.SetOps(&git.MockOps{ + BranchExistsFn: func(name string) bool { return name == "feat-a" || name == "feat-b" }, + PushFn: func(string, []string, bool, bool) error { return nil }, + ResolveRemoteFn: func(string) (string, error) { return "origin", nil }, + DefaultBranchFn: func() (string, error) { return "develop", nil }, + }) + defer restore() + + var createdPRs []struct{ base, head string } + cfg, _, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + FindPRForBranchFn: func(string) (*github.PullRequest, error) { return nil, nil }, + CreatePRFn: func(base, head, title, body string, draft bool) (*github.PullRequest, error) { + createdPRs = append(createdPRs, struct{ base, head string }{base, head}) + n := len(createdPRs) + return &github.PullRequest{ + Number: n, HeadRefName: head, BaseRefName: base, + URL: fmt.Sprintf("https://github.com/o/r/pull/%d", n), + }, nil + }, + ListStacksFn: func() ([]github.RemoteStack, error) { return []github.RemoteStack{}, nil }, + CreateStackFn: func([]int) (*github.RemoteStack, error) { return &github.RemoteStack{ID: 1, Number: 1}, nil }, + } + + cmd := LinkCmd(cfg) + cmd.SetArgs([]string{"feat-a", "feat-b"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + _, _ = io.ReadAll(errR) + + assert.NoError(t, err) + require.Len(t, createdPRs, 2) + // Bottom PR based on the repository default branch, not "main". + assert.Equal(t, "develop", createdPRs[0].base) + // Second PR chains off the previous branch. + assert.Equal(t, "feat-a", createdPRs[1].base) +} + +// TestLink_DefaultBase_ErrorWhenUnresolvable verifies that link fails with a +// helpful message when --base is omitted and the default branch cannot be +// determined. +func TestLink_DefaultBase_ErrorWhenUnresolvable(t *testing.T) { + restore := git.SetOps(&git.MockOps{ + BranchExistsFn: func(string) bool { return false }, + DefaultBranchFn: func() (string, error) { return "", fmt.Errorf("no default branch") }, + }) + defer restore() + + cfg, _, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + FindPRByNumberFn: func(n int) (*github.PullRequest, error) { + return &github.PullRequest{ + Number: n, HeadRefName: fmt.Sprintf("b%d", n), BaseRefName: "main", + }, nil + }, + ListStacksFn: func() ([]github.RemoteStack, error) { return []github.RemoteStack{}, nil }, + } + + cmd := LinkCmd(cfg) + cmd.SetArgs([]string{"10", "20"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + output := string(errOut) + + assert.ErrorIs(t, err, ErrSilent) + assert.Contains(t, output, "unable to determine default branch") +} + +// TestLink_ExplicitBase_SkipsDefaultBranchResolution verifies that passing +// --base bypasses default branch resolution and is used as the bottom base. +func TestLink_ExplicitBase_SkipsDefaultBranchResolution(t *testing.T) { + defaultBranchCalled := false + restore := git.SetOps(&git.MockOps{ + BranchExistsFn: func(string) bool { return false }, + DefaultBranchFn: func() (string, error) { + defaultBranchCalled = true + return "develop", nil + }, + }) + defer restore() + + var baseUpdates []struct { + number int + base string + } + + cfg, _, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + FindPRByNumberFn: func(n int) (*github.PullRequest, error) { + switch n { + case 10: + return &github.PullRequest{ + Number: 10, HeadRefName: "feat-a", BaseRefName: "main", + URL: "https://github.com/o/r/pull/10", + }, nil + case 20: + return &github.PullRequest{ + Number: 20, HeadRefName: "feat-b", BaseRefName: "feat-a", + URL: "https://github.com/o/r/pull/20", + }, nil + } + return nil, nil + }, + UpdatePRBaseFn: func(number int, base string) error { + baseUpdates = append(baseUpdates, struct { + number int + base string + }{number, base}) + return nil + }, + ListStacksFn: func() ([]github.RemoteStack, error) { return []github.RemoteStack{}, nil }, + CreateStackFn: func([]int) (*github.RemoteStack, error) { return &github.RemoteStack{ID: 1, Number: 1}, nil }, + } + + cmd := LinkCmd(cfg) + cmd.SetArgs([]string{"--base", "release", "10", "20"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + _, _ = io.ReadAll(errR) + + assert.NoError(t, err) + assert.False(t, defaultBranchCalled, "git.DefaultBranch must not be consulted when --base is given") + // The bottom PR (#10) should be retargeted to the explicit base, not "main". + require.Len(t, baseUpdates, 1) + assert.Equal(t, 10, baseUpdates[0].number) + assert.Equal(t, "release", baseUpdates[0].base) +} + func TestLink_DuplicateBranchResolvesToSamePR(t *testing.T) { cfg, _, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ @@ -1319,6 +1550,9 @@ func TestLink_DuplicateBranchResolvesToSamePR(t *testing.T) { } func TestLink_UpdateDeletedStack_FallsBackToCreate(t *testing.T) { + restore := git.SetOps(newLinkGitMock()) + defer restore() + var created bool cfg, _, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ @@ -1859,6 +2093,9 @@ func TestLink_PRNumbers_NoTemplateUsesFooter(t *testing.T) { // --- PR URL tests --- func TestLink_PRURLs_CreateNewStack(t *testing.T) { + restore := git.SetOps(newLinkGitMock()) + defer restore() + var createdPRs []int cfg, _, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ @@ -1924,6 +2161,9 @@ func TestLink_PRURLs_NotFound(t *testing.T) { } func TestLink_MixedURLsAndNumbers(t *testing.T) { + restore := git.SetOps(newLinkGitMock()) + defer restore() + var createdPRs []int cfg, _, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ @@ -2270,6 +2510,9 @@ func TestLink_AddMode_ExemptsIneligibleExistingMember(t *testing.T) { } func TestLink_NumericFirstArgNotAStack_UsesCreateMode(t *testing.T) { + restore := git.SetOps(newLinkGitMock()) + defer restore() + var createdPRs []int cfg, _, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ diff --git a/cmd/merge.go b/cmd/merge.go new file mode 100644 index 00000000..be61147b --- /dev/null +++ b/cmd/merge.go @@ -0,0 +1,699 @@ +package cmd + +import ( + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/cli/go-gh/v2/pkg/api" + "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/tui/mergeview" + "github.com/spf13/cobra" +) + +type mergeOptions struct { + mergeMethod string + squash bool + rebase bool + merge bool + yes bool + + // pollInterval and maxPolls control the status polling loop in the + // non-interactive path. Zero values fall back to sane defaults; tests set + // them to keep runs fast. + pollInterval time.Duration + maxPolls int +} + +// mergeTarget describes an explicitly requested pull request to merge up to. +type mergeTarget struct { + prNumber int + hasPR bool +} + +// MergeCmd builds the `gh stack merge` command. +func MergeCmd(cfg *config.Config) *cobra.Command { + opts := &mergeOptions{} + + cmd := &cobra.Command{ + Use: "merge [ | ]", + Short: "Merge a stack of pull requests", + Long: `Merge some or all of a stack of pull requests using GitHub's atomic stack +merge. All members of the stack up to and including your chosen pull request are +merged into the base branch in a single, all-or-nothing operation: if any PR +cannot be merged, none are. + +With no argument, the stack for the current branch is used. Pass a stack number +to merge a stack you don't have checked out, or a pull request number to merge +directly up to that PR. A bare number is treated first as a stack number, then +as a pull request number. + +In an interactive terminal, a short wizard lets you choose how far up the stack +to merge (everything below your selection is always included), pick the merge +method, and confirm, then shows live progress. In a non-interactive terminal, or +with --yes, the whole stack (or everything up to the given PR) is merged without +prompting, using your last-used merge method unless one is specified. + +Only basic pull request state is checked before merging (open and not a draft); +GitHub evaluates branch protection and repository rules when the merge runs, so +any such failure is reported back to you. Bypassing merge requirements is not +supported for stacks. + +If the base branch uses a merge queue, the stack is added to the queue and merges +once the queue processes it; otherwise it is merged directly.`, + Example: ` # Merge the current stack (interactive picker) + $ gh stack merge + + # Merge a stack you don't have checked out, by stack number + $ gh stack merge 7 + + # Merge everything up to and including PR #42 + $ gh stack merge 42 + + # Merge the whole current stack without prompting, squashing + $ gh stack merge --yes --squash`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runMerge(cfg, opts, args) + }, + } + + cmd.Flags().StringVar(&opts.mergeMethod, "merge-method", "", "Merge method to use: merge, squash, or rebase") + cmd.Flags().BoolVar(&opts.merge, "merge", false, "Merge with a merge commit") + cmd.Flags().BoolVar(&opts.squash, "squash", false, "Squash and merge") + cmd.Flags().BoolVar(&opts.rebase, "rebase", false, "Rebase and merge") + cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false, "Merge without prompting for confirmation") + + return cmd +} + +func runMerge(cfg *config.Config, opts *mergeOptions, args []string) error { + method, err := resolveMergeMethodFlag(opts) + if err != nil { + cfg.Errorf("%s", err) + return ErrInvalidArgs + } + + client, err := cfg.GitHubClient() + if err != nil { + cfg.Errorf("failed to create GitHub client: %s", err) + return ErrAPIFailure + } + + remoteStack, target, err := resolveMergeStack(cfg, client, args) + if err != nil { + return err + } + + candidates, blocker := mergeCandidates(remoteStack) + + preselectIndex := -1 + targetPR := 0 + if target.hasPR { + idx := indexOfPR(candidates, target.prNumber) + if idx < 0 { + return explainNonMergeableTarget(cfg, remoteStack, target.prNumber, blocker) + } + preselectIndex = idx + targetPR = candidates[idx].Number + } else if len(candidates) == 0 { + return explainNothingToMerge(cfg, remoteStack, blocker) + } + + base := remoteStack.Base.Ref + + // Detect whether the base branch merges through a merge queue so the wizard + // can skip the merge-method step and enqueue instead of merging directly. + usesMergeQueue := baseBranchUsesMergeQueue(client, base) + + var mergeCfg *github.RepoMergeConfig + var allowed []string + if usesMergeQueue { + // The queue picks the merge method from its own configuration, so a + // requested method does not apply. + if method != "" { + cfg.Warningf("the base branch %q uses a merge queue; ignoring the merge method", base) + method = "" + } + } else { + mergeCfg, err = client.RepoMergeConfig() + if err != nil { + cfg.Errorf("failed to fetch repository merge settings: %s", err) + return ErrAPIFailure + } + allowed = mergeCfg.AllowedMethods() + if len(allowed) == 0 { + cfg.Errorf("this repository does not allow any merge methods") + return ErrAPIFailure + } + if method != "" && !mergeCfg.Allows(method) { + cfg.Errorf("this repository does not allow %s merges", method) + return ErrInvalidArgs + } + } + + if cfg.IsInteractive() && !opts.yes { + defaultMethod := "" + if mergeCfg != nil { + defaultMethod = mergeCfg.DefaultMethod + } + return runMergeInteractive(cfg, client, remoteStack.Number, base, candidates, allowed, defaultMethod, method, preselectIndex, usesMergeQueue, opts) + } + + // Non-interactive (or --yes): merge the whole stack (or up to the given PR) + // without prompting. + if !target.hasPR { + // A draft or closed pull request partway up the stack blocks everything + // above it. Rather than silently merging only the portion below it, + // refuse and let the user target an explicit pull request. + if blocker != nil { + top := candidates[len(candidates)-1].Number + cfg.Errorf("cannot merge the whole stack: pull request #%d is %s", blocker.Number, blockerState(blocker)) + cfg.Printf("Merge up to #%d with `%s`", top, cfg.ColorCyan(fmt.Sprintf("gh stack merge %d", top))) + return ErrInvalidArgs + } + targetPR = candidates[len(candidates)-1].Number + } + if !usesMergeQueue && method == "" { + method = mergeCfg.DefaultMethod + if !mergeCfg.Allows(method) { + method = allowed[0] + } + } + return runMergeHeadless(cfg, client, base, candidates, targetPR, method, usesMergeQueue, opts) +} + +// resolveMergeStack determines the remote stack (and any explicitly targeted PR) +// from the command arguments. It never reads local PR state: the local stack +// file is consulted only to discover the stack number when no argument is given. +func resolveMergeStack(cfg *config.Config, client github.ClientOps, args []string) (*github.RemoteStack, mergeTarget, error) { + if len(args) == 0 { + rs, err := resolveActiveRemoteStack(cfg, client) + return rs, mergeTarget{}, err + } + + n, err := strconv.Atoi(strings.TrimSpace(args[0])) + if err != nil || n <= 0 { + cfg.Errorf("invalid argument %q: expected a stack number or pull request number", args[0]) + return nil, mergeTarget{}, ErrInvalidArgs + } + + // Try as a stack number first (mirrors `gh stack checkout`). + rs, err := client.GetStack(n) + if err == nil && rs != nil { + return rs, mergeTarget{}, nil + } + if err != nil && !isNotFound(err) { + cfg.Errorf("failed to fetch stack #%d: %s", n, err) + return nil, mergeTarget{}, ErrAPIFailure + } + + // Not a stack number: try as a pull request number. + rs, err = client.FindStackForPR(n) + if err != nil { + if isNotFound(err) { + warnStacksUnavailable(cfg) + return nil, mergeTarget{}, ErrStacksUnavailable + } + cfg.Errorf("failed to look up pull request #%d: %s", n, err) + return nil, mergeTarget{}, ErrAPIFailure + } + if rs == nil { + cfg.Errorf("#%d is not a stack number or a stacked pull request", n) + return nil, mergeTarget{}, ErrNotInStack + } + return rs, mergeTarget{prNumber: n, hasPR: true}, nil +} + +// resolveActiveRemoteStack reads only the local stack number for the current +// branch, then fetches the full stack (and its PR states) from GitHub. +func resolveActiveRemoteStack(cfg *config.Config, client github.ClientOps) (*github.RemoteStack, error) { + gitDir, err := git.GitDir() + if err != nil { + cfg.Errorf("not a git repository") + return nil, ErrNotInStack + } + sf, err := stack.Load(gitDir) + if err != nil { + cfg.Errorf("failed to load stack state: %s", err) + return nil, ErrNotInStack + } + currentBranch, err := git.CurrentBranch() + if err != nil { + cfg.Errorf("failed to get current branch: %s", err) + return nil, ErrNotInStack + } + + stacks := sf.FindAllStacksForBranch(currentBranch) + if len(stacks) == 0 { + cfg.Errorf("current branch %q is not part of a stack", currentBranch) + cfg.Printf("Checkout a stack first, or specify which stack or pull request to merge with `%s`", cfg.ColorCyan("gh stack merge [number]")) + return nil, ErrNotInStack + } + if len(stacks) > 1 { + cfg.Errorf("branch %q belongs to multiple stacks", currentBranch) + cfg.Printf("Checkout a stack first, or specify which stack or pull request to merge with `%s`", cfg.ColorCyan("gh stack merge [number]")) + return nil, ErrDisambiguate + } + s := stacks[0] + if s.ID == "" && s.Number == 0 { + cfg.Errorf("this stack has not been submitted to GitHub yet; run `gh stack submit` first") + return nil, ErrNotInStack + } + + number, err := ensureStackNumber(client, s) + if err != nil { + if isNotFound(err) { + warnStacksUnavailable(cfg) + return nil, ErrStacksUnavailable + } + cfg.Errorf("failed to resolve stack number: %s", err) + return nil, ErrAPIFailure + } + if number == 0 { + cfg.Errorf("could not determine the stack number for the current stack") + return nil, ErrNotInStack + } + + rs, err := client.GetStack(number) + if err != nil { + if isNotFound(err) { + warnStacksUnavailable(cfg) + return nil, ErrStacksUnavailable + } + cfg.Errorf("failed to fetch stack #%d: %s", number, err) + return nil, ErrAPIFailure + } + return rs, nil +} + +func runMergeInteractive(cfg *config.Config, client github.ClientOps, stackNumber int, base string, candidates []mergeview.PRItem, allowed []string, viewerDefault, methodFlag string, preselectIndex int, usesMergeQueue bool, opts *mergeOptions) error { + defaultMethod := viewerDefault + if methodFlag != "" { + defaultMethod = methodFlag + } + + // Enrich the picker with PR titles (best-effort; the branch is shown either way). + nums := make([]int, len(candidates)) + for i, c := range candidates { + nums[i] = c.Number + } + if titles, err := client.PRTitles(nums); err == nil { + for i := range candidates { + if t := titles[candidates[i].Number]; t != "" { + candidates[i].Title = t + } + } + } + + submit, poll := mergeFuncs(client, mergeActionFor(usesMergeQueue)) + + model := mergeview.New(mergeview.Options{ + PRs: candidates, + StackNumber: stackNumber, + BaseRef: base, + AllowedMethods: allowed, + DefaultMethod: defaultMethod, + PreselectTopIndex: preselectIndex, + UsesMergeQueue: usesMergeQueue, + Submit: submit, + Poll: poll, + PollInterval: opts.pollInterval, + }) + + final, err := tea.NewProgram(model, tea.WithInput(cfg.In), tea.WithOutput(cfg.Out)).Run() + if err != nil { + cfg.Errorf("failed to run merge: %s", err) + return ErrSilent + } + + out := final.(mergeview.Model).Outcome() + switch { + case out.Err != nil: + if errors.Is(out.Err, github.ErrAsyncMergeUnavailable) { + warnAsyncMergeUnavailable(cfg) + return ErrStacksUnavailable + } + cfg.Errorf("merge failed: %s", out.Err) + return ErrAPIFailure + case out.Merged: + mergedSuccess(cfg, prNumberList(out.MergedPRs), base, out.SHA) + return nil + case out.Enqueued: + enqueuedSuccess(cfg, prNumberList(out.MergedPRs), base) + return nil + case out.Failed: + cfg.Errorf("merge failed: %s", out.Message) + cfg.Printf("Stack merges are atomic, so nothing was merged.") + return mergeFailureExit(out.Message) + case out.WatchStopped: + cfg.Infof("Stopped watching. Merge is still in progress. Check the pull requests on GitHub.") + return ErrSilent + default: + // Cancelled via esc/ctrl+c before submitting. + cfg.Infof("Cancelled operation, nothing merged") + return ErrSilent + } +} + +func runMergeHeadless(cfg *config.Config, client github.ClientOps, base string, candidates []mergeview.PRItem, targetPR int, method string, usesMergeQueue bool, opts *mergeOptions) error { + nums := numbersUpTo(candidates, targetPR) + list := prNumberList(nums) + + if usesMergeQueue { + cfg.Printf("Adding %s to the merge queue for %s...", list, base) + } else { + cfg.Printf("Merging %s into %s via %s...", list, base, method) + } + + res, err := client.MergeStackAsync(targetPR, method, mergeActionFor(usesMergeQueue)) + if err != nil { + if errors.Is(err, github.ErrAsyncMergeUnavailable) { + warnAsyncMergeUnavailable(cfg) + return ErrStacksUnavailable + } + cfg.Errorf("failed to start merge: %s", err) + return ErrAPIFailure + } + + if res.IsMerged() { + mergedSuccess(cfg, list, base, res.Details.SHA) + return nil + } + if res.IsEnqueued() { + enqueuedSuccess(cfg, list, base) + return nil + } + if res.IsFailed() { + cfg.Errorf("merge failed: %s", res.Details.Message) + cfg.Printf("Stack merges are atomic, so nothing was merged.") + return mergeFailureExit(res.Details.Message) + } + + uuid := res.Details.UUID + if uuid == "" { + cfg.Errorf("merge did not start as expected") + return ErrAPIFailure + } + interval := opts.pollInterval + if interval <= 0 { + interval = time.Second + } + maxPolls := opts.maxPolls + if maxPolls <= 0 { + maxPolls = 600 + } + + for i := 0; i < maxPolls; i++ { + time.Sleep(interval) + + status, err := client.GetAsyncMergeResult(targetPR, uuid) + if err != nil { + cfg.Errorf("failed to check merge status: %s", err) + return ErrAPIFailure + } + if status.IsMerged() { + mergedSuccess(cfg, list, base, status.Details.SHA) + return nil + } + if status.IsEnqueued() { + enqueuedSuccess(cfg, list, base) + return nil + } + if status.IsFailed() { + cfg.Errorf("merge failed: %s", status.Details.Message) + cfg.Printf("Stack merges are atomic, so nothing was merged.") + return mergeFailureExit(status.Details.Message) + } + } + + cfg.Warningf("Merge is still in progress. Check the pull requests on GitHub.") + return ErrAPIFailure +} + +// baseBranchUsesMergeQueue reports whether the stack's base branch merges through +// a merge queue. Detection tailors the wizard (skipping the method step and +// switching to enqueue wording) and selects the explicit merge_action. On a +// lookup failure it falls back to the direct-merge flow (method step shown, +// "direct_merge" sent), matching what the user is shown. +func baseBranchUsesMergeQueue(client github.ClientOps, base string) bool { + uses, err := client.BaseBranchUsesMergeQueue(base) + if err != nil { + return false + } + return uses +} + +// mergeActionFor maps merge-queue detection to the explicit async-merge action: a +// detected queue forces "merge_queue" (the server rejects it when the branch has +// no queue, guarding against a wrong guess), and everything else forces a +// "direct_merge". Being explicit ensures the merge matches the wizard the user +// saw rather than letting the server choose the routing. +func mergeActionFor(usesMergeQueue bool) string { + if usesMergeQueue { + return github.MergeActionMergeQueue + } + return github.MergeActionDirectMerge +} + +// mergeFuncs returns submit/poll closures that adapt the GitHub client to the +// mergeview injection points. The merge action is fixed for the session. +func mergeFuncs(client github.ClientOps, mergeAction string) (mergeview.SubmitFunc, mergeview.PollFunc) { + submit := func(targetPR int, method string) (mergeview.MergeStatus, error) { + res, err := client.MergeStackAsync(targetPR, method, mergeAction) + if err != nil { + return mergeview.MergeStatus{}, err + } + return toMergeStatus(res), nil + } + poll := func(targetPR int, uuid string) (mergeview.MergeStatus, error) { + res, err := client.GetAsyncMergeResult(targetPR, uuid) + if err != nil { + return mergeview.MergeStatus{}, err + } + return toMergeStatus(res), nil + } + return submit, poll +} + +func toMergeStatus(res *github.AsyncMergeResult) mergeview.MergeStatus { + status := mergeview.StatusPending + switch { + case res.IsMerged(): + status = mergeview.StatusMerged + case res.IsEnqueued(): + status = mergeview.StatusEnqueued + case res.IsFailed(): + status = mergeview.StatusFailed + } + return mergeview.MergeStatus{ + Status: status, + Message: res.Details.Message, + UUID: res.Details.UUID, + SHA: res.Details.SHA, + } +} + +// mergeCandidates returns the pull requests that can be merged, ordered bottom to +// top: the contiguous run of open, non-draft PRs starting from the bottom of the +// stack (already-merged PRs at the bottom are skipped). The first draft or +// closed PR blocks everything above it and is returned as the blocker. +func mergeCandidates(rs *github.RemoteStack) (items []mergeview.PRItem, blocker *github.RemoteStackPR) { + if rs == nil { + return nil, nil + } + for i := range rs.PRDetails { + pr := rs.PRDetails[i] + if pr.IsMerged() { + continue + } + if pr.Draft || pr.State == "closed" { + b := pr + return items, &b + } + items = append(items, mergeview.PRItem{Number: pr.Number, Branch: pr.Head.Ref}) + } + return items, nil +} + +func explainNothingToMerge(cfg *config.Config, rs *github.RemoteStack, blocker *github.RemoteStackPR) error { + if allMerged(rs) { + cfg.Successf("This stack is already fully merged.") + return nil + } + if blocker != nil { + cfg.Errorf("nothing to merge: pull request #%d is %s", blocker.Number, blockerState(blocker)) + return ErrNotInStack + } + cfg.Errorf("this stack has no open pull requests to merge") + return ErrNotInStack +} + +func explainNonMergeableTarget(cfg *config.Config, rs *github.RemoteStack, prNumber int, blocker *github.RemoteStackPR) error { + pr := findRemotePR(rs, prNumber) + switch { + case pr == nil: + cfg.Errorf("pull request #%d is not part of this stack", prNumber) + return ErrInvalidArgs + case pr.IsMerged(): + cfg.Successf("pull request #%d is already merged", prNumber) + return nil + case pr.Draft: + cfg.Errorf("pull request #%d is a draft; mark it ready for review before merging", prNumber) + return ErrInvalidArgs + case pr.State == "closed": + cfg.Errorf("pull request #%d is closed", prNumber) + return ErrInvalidArgs + case blocker != nil: + cfg.Errorf("pull request #%d cannot be merged yet: #%d below it is %s", prNumber, blocker.Number, blockerState(blocker)) + return ErrInvalidArgs + default: + cfg.Errorf("pull request #%d cannot be merged", prNumber) + return ErrInvalidArgs + } +} + +func warnAsyncMergeUnavailable(cfg *config.Config) { + cfg.Warningf("Async stack merge is not available for this repository") +} + +// mergeFailureExit maps a merge failure message to an exit code: rebase/merge +// conflicts get ErrConflict, everything else ErrAPIFailure. +func mergeFailureExit(message string) error { + if strings.Contains(strings.ToLower(message), "conflict") { + return ErrConflict + } + return ErrAPIFailure +} + +func resolveMergeMethodFlag(opts *mergeOptions) (string, error) { + var picks []string + if opts.merge { + picks = append(picks, github.MergeMethodMerge) + } + if opts.squash { + picks = append(picks, github.MergeMethodSquash) + } + if opts.rebase { + picks = append(picks, github.MergeMethodRebase) + } + if opts.mergeMethod != "" { + mm := strings.ToLower(strings.TrimSpace(opts.mergeMethod)) + switch mm { + case github.MergeMethodMerge, github.MergeMethodSquash, github.MergeMethodRebase: + picks = append(picks, mm) + default: + return "", fmt.Errorf("invalid --merge-method %q: must be merge, squash, or rebase", opts.mergeMethod) + } + } + + distinct := map[string]struct{}{} + for _, p := range picks { + distinct[p] = struct{}{} + } + if len(distinct) > 1 { + return "", errors.New("only one merge method may be specified") + } + for p := range distinct { + return p, nil + } + return "", nil +} + +func indexOfPR(items []mergeview.PRItem, number int) int { + for i, it := range items { + if it.Number == number { + return i + } + } + return -1 +} + +func numbersUpTo(items []mergeview.PRItem, targetPR int) []int { + var nums []int + for _, it := range items { + nums = append(nums, it.Number) + if it.Number == targetPR { + break + } + } + return nums +} + +func prNumberList(nums []int) string { + parts := make([]string, len(nums)) + for i, n := range nums { + parts[i] = fmt.Sprintf("#%d", n) + } + return strings.Join(parts, ", ") +} + +func findRemotePR(rs *github.RemoteStack, number int) *github.RemoteStackPR { + if rs == nil { + return nil + } + for i := range rs.PRDetails { + if rs.PRDetails[i].Number == number { + return &rs.PRDetails[i] + } + } + return nil +} + +func allMerged(rs *github.RemoteStack) bool { + if rs == nil || len(rs.PRDetails) == 0 { + return false + } + for i := range rs.PRDetails { + if !rs.PRDetails[i].IsMerged() { + return false + } + } + return true +} + +func blockerState(pr *github.RemoteStackPR) string { + if pr.Draft { + return "a draft" + } + if pr.State == "closed" { + return "closed" + } + return "not mergeable" +} + +func shortMergeSHA(sha string) string { + if len(sha) > 7 { + return sha[:7] + } + return sha +} + +// mergedSuccess prints the merge success line, appending the merge commit SHA in +// parentheses when known: "Merged #1, #2 into main (abc1234)". +func mergedSuccess(cfg *config.Config, list, base, sha string) { + if sha != "" { + cfg.Successf("Merged %s into %s (%s)", list, base, shortMergeSHA(sha)) + return + } + cfg.Successf("Merged %s into %s", list, base) +} + +// enqueuedSuccess prints the success line when the base branch uses a merge +// queue: the stack was added to the queue and will merge once it's processed. +func enqueuedSuccess(cfg *config.Config, list, base string) { + cfg.Successf("Added %s to the merge queue for %s", list, base) + cfg.Printf("They will merge once the queue processes them.") +} + +func isNotFound(err error) bool { + var httpErr *api.HTTPError + return errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound +} diff --git a/cmd/merge_test.go b/cmd/merge_test.go new file mode 100644 index 00000000..e9e63609 --- /dev/null +++ b/cmd/merge_test.go @@ -0,0 +1,628 @@ +package cmd + +import ( + "errors" + "net/http" + "testing" + "time" + + "github.com/cli/go-gh/v2/pkg/api" + "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/tui/mergeview" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func openStackPR(n int, ref string) github.RemoteStackPR { + return github.RemoteStackPR{Number: n, State: "open", Head: github.RemoteStackPRHead{Ref: ref}} +} + +func draftStackPR(n int, ref string) github.RemoteStackPR { + return github.RemoteStackPR{Number: n, State: "open", Draft: true, Head: github.RemoteStackPRHead{Ref: ref}} +} + +func closedStackPR(n int, ref string) github.RemoteStackPR { + return github.RemoteStackPR{Number: n, State: "closed", Head: github.RemoteStackPRHead{Ref: ref}} +} + +func mergedStackPR(n int, ref string) github.RemoteStackPR { + at := "2026-01-01T00:00:00Z" + return github.RemoteStackPR{Number: n, State: "closed", MergedAt: &at, Head: github.RemoteStackPRHead{Ref: ref}} +} + +func remoteStack(number int, base string, prs ...github.RemoteStackPR) *github.RemoteStack { + nums := make([]int, len(prs)) + for i, p := range prs { + nums[i] = p.Number + } + return &github.RemoteStack{ + ID: number, + Number: number, + Base: github.RemoteStackBase{Ref: base}, + Open: true, + PullRequests: nums, + PRDetails: prs, + } +} + +func notFoundErr() error { return &api.HTTPError{StatusCode: http.StatusNotFound} } + +func fastOptions() *mergeOptions { + return &mergeOptions{pollInterval: time.Millisecond, maxPolls: 5} +} + +// setupLocalStack writes a single-stack file and mocks git so no-arg resolution +// finds it. +func setupLocalStack(t *testing.T, number int, currentBranch string, branches ...string) { + t.Helper() + gitDir := t.TempDir() + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return currentBranch, nil }, + }) + t.Cleanup(restore) + + refs := make([]stack.BranchRef, len(branches)) + for i, b := range branches { + refs[i] = stack.BranchRef{Branch: b} + } + writeStackFile(t, gitDir, stack.Stack{ + ID: "s", + Number: number, + Trunk: stack.BranchRef{Branch: "main"}, + Branches: refs, + }) +} + +func TestRunMerge_NoArg_MergesWholeStack(t *testing.T) { + setupLocalStack(t, 100, "b2", "b1", "b2", "b3") + + var gotPR int + var gotMethod string + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + GetStackFn: func(n int) (*github.RemoteStack, error) { + assert.Equal(t, 100, n) + return remoteStack(100, "main", openStackPR(1, "b1"), openStackPR(2, "b2"), openStackPR(3, "b3")), nil + }, + RepoMergeConfigFn: func() (*github.RepoMergeConfig, error) { + return &github.RepoMergeConfig{MergeAllowed: true, SquashAllowed: true, RebaseAllowed: true, DefaultMethod: "squash"}, nil + }, + MergeStackAsyncFn: func(pr int, method, mergeAction string) (*github.AsyncMergeResult, error) { + gotPR, gotMethod = pr, method + return &github.AsyncMergeResult{Status: github.AsyncMergeStatusPending, Details: github.AsyncMergeDetails{UUID: "u"}}, nil + }, + GetAsyncMergeResultFn: func(pr int, uuid string) (*github.AsyncMergeResult, error) { + return &github.AsyncMergeResult{Status: github.AsyncMergeStatusMerged, Details: github.AsyncMergeDetails{SHA: "abc1234"}}, nil + }, + } + + err := runMerge(cfg, fastOptions(), nil) + output := collectOutput(cfg, outR, errR) + + require.NoError(t, err) + assert.Equal(t, 3, gotPR, "targets the top of the stack") + assert.Equal(t, "squash", gotMethod, "uses the viewer default method") + assert.Contains(t, output, "Merged #1, #2, #3 into main") +} + +func TestRunMerge_StackNumberArg(t *testing.T) { + var gotPR int + gotAction := "unset" + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + GetStackFn: func(n int) (*github.RemoteStack, error) { + assert.Equal(t, 7, n) + return remoteStack(7, "main", openStackPR(10, "a"), openStackPR(11, "b")), nil + }, + MergeStackAsyncFn: func(pr int, method, mergeAction string) (*github.AsyncMergeResult, error) { + gotPR, gotAction = pr, mergeAction + return &github.AsyncMergeResult{Status: github.AsyncMergeStatusPending, Details: github.AsyncMergeDetails{UUID: "u"}}, nil + }, + } + + err := runMerge(cfg, fastOptions(), []string{"7"}) + output := collectOutput(cfg, outR, errR) + + require.NoError(t, err) + assert.Equal(t, 11, gotPR) + assert.Equal(t, github.MergeActionDirectMerge, gotAction, "a non-queue base sends an explicit direct_merge action") + assert.Contains(t, output, "Merged #10, #11 into main") +} + +func TestRunMerge_MergeQueue_Headless(t *testing.T) { + gotMethod, gotAction := "unset", "unset" + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + GetStackFn: func(n int) (*github.RemoteStack, error) { + return remoteStack(7, "main", openStackPR(10, "a"), openStackPR(11, "b")), nil + }, + BaseBranchUsesMergeQueueFn: func(base string) (bool, error) { + assert.Equal(t, "main", base) + return true, nil + }, + MergeStackAsyncFn: func(pr int, method, mergeAction string) (*github.AsyncMergeResult, error) { + gotMethod, gotAction = method, mergeAction + return &github.AsyncMergeResult{ + Status: github.AsyncMergeStatusEnqueued, + Details: github.AsyncMergeDetails{Message: "Pull request was added to the merge queue."}, + }, nil + }, + } + + err := runMerge(cfg, fastOptions(), []string{"7"}) + output := collectOutput(cfg, outR, errR) + + require.NoError(t, err) + assert.Equal(t, "", gotMethod, "a merge queue picks the method; none is sent") + assert.Equal(t, github.MergeActionMergeQueue, gotAction, "an explicit merge_queue action is sent") + assert.Contains(t, output, "merge queue") +} + +func TestRunMerge_MergeQueue_IgnoresMethodFlag(t *testing.T) { + gotMethod, gotAction := "unset", "unset" + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + GetStackFn: func(n int) (*github.RemoteStack, error) { + return remoteStack(7, "main", openStackPR(10, "a"), openStackPR(11, "b")), nil + }, + BaseBranchUsesMergeQueueFn: func(base string) (bool, error) { return true, nil }, + MergeStackAsyncFn: func(pr int, method, mergeAction string) (*github.AsyncMergeResult, error) { + gotMethod, gotAction = method, mergeAction + return &github.AsyncMergeResult{ + Status: github.AsyncMergeStatusEnqueued, + Details: github.AsyncMergeDetails{Message: "queued"}, + }, nil + }, + } + + opts := fastOptions() + opts.squash = true + err := runMerge(cfg, opts, []string{"7"}) + output := collectOutput(cfg, outR, errR) + + require.NoError(t, err) + assert.Equal(t, "", gotMethod, "the requested method is ignored under a merge queue") + assert.Equal(t, github.MergeActionMergeQueue, gotAction) + assert.Contains(t, output, "ignoring the merge method") +} + +func TestRunMerge_MergeQueueDetectionError_FallsBackToDirect(t *testing.T) { + gotMethod, gotAction := "unset", "unset" + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + GetStackFn: func(n int) (*github.RemoteStack, error) { + return remoteStack(7, "main", openStackPR(10, "a"), openStackPR(11, "b")), nil + }, + BaseBranchUsesMergeQueueFn: func(base string) (bool, error) { + return false, errors.New("boom") + }, + RepoMergeConfigFn: func() (*github.RepoMergeConfig, error) { + return &github.RepoMergeConfig{MergeAllowed: true, DefaultMethod: "merge"}, nil + }, + MergeStackAsyncFn: func(pr int, method, mergeAction string) (*github.AsyncMergeResult, error) { + gotMethod, gotAction = method, mergeAction + return &github.AsyncMergeResult{ + Status: github.AsyncMergeStatusMerged, + Details: github.AsyncMergeDetails{SHA: "abc1234"}, + }, nil + }, + } + + err := runMerge(cfg, fastOptions(), []string{"7"}) + output := collectOutput(cfg, outR, errR) + + require.NoError(t, err) + assert.Equal(t, "merge", gotMethod, "detection failure falls back to a direct merge with a method") + assert.Equal(t, github.MergeActionDirectMerge, gotAction, "the direct-merge UI sends an explicit direct_merge action") + assert.Contains(t, output, "Merged") +} + +func TestRunMerge_PRNumberArg(t *testing.T) { + var gotPR int + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + GetStackFn: func(n int) (*github.RemoteStack, error) { + return nil, notFoundErr() // not a stack number + }, + FindStackForPRFn: func(n int) (*github.RemoteStack, error) { + assert.Equal(t, 2, n) + return remoteStack(5, "main", openStackPR(1, "b1"), openStackPR(2, "b2"), openStackPR(3, "b3")), nil + }, + MergeStackAsyncFn: func(pr int, method, mergeAction string) (*github.AsyncMergeResult, error) { + gotPR = pr + return &github.AsyncMergeResult{Status: github.AsyncMergeStatusPending, Details: github.AsyncMergeDetails{UUID: "u"}}, nil + }, + } + + err := runMerge(cfg, fastOptions(), []string{"2"}) + output := collectOutput(cfg, outR, errR) + + require.NoError(t, err) + assert.Equal(t, 2, gotPR, "targets exactly the requested PR") + assert.Contains(t, output, "Merged #1, #2 into main") + assert.NotContains(t, output, "#3") +} + +func TestRunMerge_SquashFlag(t *testing.T) { + var gotMethod string + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + GetStackFn: func(n int) (*github.RemoteStack, error) { + return remoteStack(7, "main", openStackPR(1, "b1"), openStackPR(2, "b2")), nil + }, + MergeStackAsyncFn: func(pr int, method, mergeAction string) (*github.AsyncMergeResult, error) { + gotMethod = method + return &github.AsyncMergeResult{Status: github.AsyncMergeStatusPending, Details: github.AsyncMergeDetails{UUID: "u"}}, nil + }, + } + + opts := fastOptions() + opts.squash = true + err := runMerge(cfg, opts, []string{"7"}) + _ = collectOutput(cfg, outR, errR) + + require.NoError(t, err) + assert.Equal(t, "squash", gotMethod) +} + +func TestRunMerge_ConflictingMethodFlags(t *testing.T) { + cfg, outR, errR := config.NewTestConfig() + opts := fastOptions() + opts.squash = true + opts.rebase = true + + err := runMerge(cfg, opts, []string{"7"}) + output := collectOutput(cfg, outR, errR) + + assert.ErrorIs(t, err, ErrInvalidArgs) + assert.Contains(t, output, "only one merge method") +} + +func TestRunMerge_InvalidMergeMethod(t *testing.T) { + cfg, outR, errR := config.NewTestConfig() + opts := fastOptions() + opts.mergeMethod = "fast-forward" + + err := runMerge(cfg, opts, []string{"7"}) + output := collectOutput(cfg, outR, errR) + + assert.ErrorIs(t, err, ErrInvalidArgs) + assert.Contains(t, output, "invalid --merge-method") +} + +func TestRunMerge_DisallowedMethod(t *testing.T) { + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + GetStackFn: func(n int) (*github.RemoteStack, error) { + return remoteStack(7, "main", openStackPR(1, "b1"), openStackPR(2, "b2")), nil + }, + RepoMergeConfigFn: func() (*github.RepoMergeConfig, error) { + return &github.RepoMergeConfig{MergeAllowed: true, DefaultMethod: "merge"}, nil + }, + } + opts := fastOptions() + opts.squash = true + + err := runMerge(cfg, opts, []string{"7"}) + output := collectOutput(cfg, outR, errR) + + assert.ErrorIs(t, err, ErrInvalidArgs) + assert.Contains(t, output, "does not allow squash") +} + +func TestRunMerge_DraftTarget(t *testing.T) { + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + GetStackFn: func(n int) (*github.RemoteStack, error) { return nil, notFoundErr() }, + FindStackForPRFn: func(n int) (*github.RemoteStack, error) { + return remoteStack(5, "main", openStackPR(1, "b1"), openStackPR(2, "b2"), draftStackPR(3, "b3")), nil + }, + } + + err := runMerge(cfg, fastOptions(), []string{"3"}) + output := collectOutput(cfg, outR, errR) + + assert.ErrorIs(t, err, ErrInvalidArgs) + assert.Contains(t, output, "#3 is a draft") +} + +func TestRunMerge_BlockerBelowTarget(t *testing.T) { + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + GetStackFn: func(n int) (*github.RemoteStack, error) { return nil, notFoundErr() }, + FindStackForPRFn: func(n int) (*github.RemoteStack, error) { + return remoteStack(5, "main", openStackPR(1, "b1"), draftStackPR(2, "b2"), openStackPR(3, "b3")), nil + }, + } + + err := runMerge(cfg, fastOptions(), []string{"3"}) + output := collectOutput(cfg, outR, errR) + + assert.ErrorIs(t, err, ErrInvalidArgs) + assert.Contains(t, output, "#2 below it is a draft") +} + +func TestRunMerge_AlreadyMergedTarget(t *testing.T) { + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + GetStackFn: func(n int) (*github.RemoteStack, error) { return nil, notFoundErr() }, + FindStackForPRFn: func(n int) (*github.RemoteStack, error) { + return remoteStack(5, "main", mergedStackPR(1, "b1"), openStackPR(2, "b2")), nil + }, + } + + err := runMerge(cfg, fastOptions(), []string{"1"}) + output := collectOutput(cfg, outR, errR) + + require.NoError(t, err) + assert.Contains(t, output, "#1 is already merged") +} + +func TestRunMerge_WholeStackBlockedByDraft(t *testing.T) { + submitCalled := false + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + GetStackFn: func(n int) (*github.RemoteStack, error) { + return remoteStack(5, "main", openStackPR(1, "b1"), draftStackPR(2, "b2"), openStackPR(3, "b3")), nil + }, + RepoMergeConfigFn: func() (*github.RepoMergeConfig, error) { + return &github.RepoMergeConfig{MergeAllowed: true, DefaultMethod: "merge"}, nil + }, + MergeStackAsyncFn: func(pr int, method, mergeAction string) (*github.AsyncMergeResult, error) { + submitCalled = true + return nil, nil + }, + } + + err := runMerge(cfg, fastOptions(), []string{"5"}) + output := collectOutput(cfg, outR, errR) + + assert.ErrorIs(t, err, ErrInvalidArgs) + assert.Contains(t, output, "cannot merge the whole stack") + assert.Contains(t, output, "#2 is a draft") + assert.Contains(t, output, "gh stack merge 1") + assert.False(t, submitCalled, "must not silently merge only the portion below the blocker") +} + +func TestRunMerge_NothingToMerge_AllMerged(t *testing.T) { + setupLocalStack(t, 100, "b1", "b1", "b2") + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + GetStackFn: func(n int) (*github.RemoteStack, error) { + return remoteStack(100, "main", mergedStackPR(1, "b1"), mergedStackPR(2, "b2")), nil + }, + } + + err := runMerge(cfg, fastOptions(), nil) + output := collectOutput(cfg, outR, errR) + + require.NoError(t, err) + assert.Contains(t, output, "already fully merged") +} + +func TestRunMerge_SubmitNotMergeable(t *testing.T) { + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + GetStackFn: func(n int) (*github.RemoteStack, error) { + return remoteStack(7, "main", openStackPR(1, "b1"), openStackPR(2, "b2")), nil + }, + MergeStackAsyncFn: func(pr int, method, mergeAction string) (*github.AsyncMergeResult, error) { + return nil, errors.New("the stack can no longer be merged as requested; refresh and try again") + }, + } + + err := runMerge(cfg, fastOptions(), []string{"7"}) + output := collectOutput(cfg, outR, errR) + + assert.ErrorIs(t, err, ErrAPIFailure) + assert.Contains(t, output, "failed to start merge") + assert.Contains(t, output, "can no longer be merged") +} + +func TestRunMerge_PollFailedConflict(t *testing.T) { + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + GetStackFn: func(n int) (*github.RemoteStack, error) { + return remoteStack(7, "main", openStackPR(1, "b1"), openStackPR(2, "b2")), nil + }, + MergeStackAsyncFn: func(pr int, method, mergeAction string) (*github.AsyncMergeResult, error) { + return &github.AsyncMergeResult{Status: github.AsyncMergeStatusPending, Details: github.AsyncMergeDetails{UUID: "u"}}, nil + }, + GetAsyncMergeResultFn: func(pr int, uuid string) (*github.AsyncMergeResult, error) { + return &github.AsyncMergeResult{Status: github.AsyncMergeStatusFailed, Details: github.AsyncMergeDetails{Message: "Merge conflict: could not merge."}}, nil + }, + } + + err := runMerge(cfg, fastOptions(), []string{"7"}) + output := collectOutput(cfg, outR, errR) + + assert.ErrorIs(t, err, ErrConflict) + assert.Contains(t, output, "merge failed: Merge conflict") + assert.Contains(t, output, "nothing was merged") +} + +func TestRunMerge_AlreadyMergedOnSubmit(t *testing.T) { + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + GetStackFn: func(n int) (*github.RemoteStack, error) { + return remoteStack(7, "main", openStackPR(1, "b1"), openStackPR(2, "b2")), nil + }, + MergeStackAsyncFn: func(pr int, method, mergeAction string) (*github.AsyncMergeResult, error) { + return &github.AsyncMergeResult{Status: github.AsyncMergeStatusMerged, Details: github.AsyncMergeDetails{SHA: "abc"}}, nil + }, + } + + err := runMerge(cfg, fastOptions(), []string{"7"}) + output := collectOutput(cfg, outR, errR) + + require.NoError(t, err) + assert.Contains(t, output, "Merged #1, #2 into main") +} + +func TestRunMerge_Enqueued(t *testing.T) { + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + GetStackFn: func(n int) (*github.RemoteStack, error) { + return remoteStack(7, "main", openStackPR(1, "b1"), openStackPR(2, "b2")), nil + }, + MergeStackAsyncFn: func(pr int, method, mergeAction string) (*github.AsyncMergeResult, error) { + return &github.AsyncMergeResult{Status: github.AsyncMergeStatusPending, Details: github.AsyncMergeDetails{UUID: "u"}}, nil + }, + GetAsyncMergeResultFn: func(pr int, uuid string) (*github.AsyncMergeResult, error) { + return &github.AsyncMergeResult{Status: github.AsyncMergeStatusEnqueued, Details: github.AsyncMergeDetails{Message: "Pull request was added to the merge queue."}}, nil + }, + } + + err := runMerge(cfg, fastOptions(), []string{"7"}) + output := collectOutput(cfg, outR, errR) + + require.NoError(t, err) + assert.Contains(t, output, "Added #1, #2 to the merge queue for main") +} + +func TestRunMerge_EnqueuedOnSubmit(t *testing.T) { + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + GetStackFn: func(n int) (*github.RemoteStack, error) { + return remoteStack(7, "main", openStackPR(1, "b1"), openStackPR(2, "b2")), nil + }, + MergeStackAsyncFn: func(pr int, method, mergeAction string) (*github.AsyncMergeResult, error) { + return &github.AsyncMergeResult{Status: github.AsyncMergeStatusEnqueued, Details: github.AsyncMergeDetails{Message: "Pull request was added to the merge queue."}}, nil + }, + } + + err := runMerge(cfg, fastOptions(), []string{"7"}) + output := collectOutput(cfg, outR, errR) + + require.NoError(t, err) + assert.Contains(t, output, "Added #1, #2 to the merge queue for main") +} + +func TestRunMerge_AsyncMergeUnavailable(t *testing.T) { + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + GetStackFn: func(n int) (*github.RemoteStack, error) { + return remoteStack(7, "main", openStackPR(1, "b1"), openStackPR(2, "b2")), nil + }, + MergeStackAsyncFn: func(pr int, method, mergeAction string) (*github.AsyncMergeResult, error) { + return nil, github.ErrAsyncMergeUnavailable + }, + } + + err := runMerge(cfg, fastOptions(), []string{"7"}) + output := collectOutput(cfg, outR, errR) + + assert.ErrorIs(t, err, ErrStacksUnavailable) + assert.Contains(t, output, "not available for this repository") +} + +func TestRunMerge_StacksUnavailable(t *testing.T) { + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + GetStackFn: func(n int) (*github.RemoteStack, error) { return nil, notFoundErr() }, + FindStackForPRFn: func(n int) (*github.RemoteStack, error) { return nil, notFoundErr() }, + } + + err := runMerge(cfg, fastOptions(), []string{"5"}) + output := collectOutput(cfg, outR, errR) + + assert.ErrorIs(t, err, ErrStacksUnavailable) + assert.Contains(t, output, "not enabled for this repository") +} + +func TestRunMerge_NoArg_NotInStack(t *testing.T) { + setupLocalStack(t, 100, "other", "b1", "b2") // current branch not in stack + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{} + + err := runMerge(cfg, fastOptions(), nil) + output := collectOutput(cfg, outR, errR) + + assert.ErrorIs(t, err, ErrNotInStack) + assert.Contains(t, output, "not part of a stack") + assert.Contains(t, output, "Checkout a stack first, or specify which stack or pull request to merge with") + assert.Contains(t, output, "gh stack merge [number]") +} + +func TestRunMerge_DefaultMethodFallsBackToAllowed(t *testing.T) { + var gotMethod string + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + GetStackFn: func(n int) (*github.RemoteStack, error) { + return remoteStack(7, "main", openStackPR(1, "b1"), openStackPR(2, "b2")), nil + }, + RepoMergeConfigFn: func() (*github.RepoMergeConfig, error) { + // Viewer default is a method the repo no longer allows. + return &github.RepoMergeConfig{SquashAllowed: true, DefaultMethod: "merge"}, nil + }, + MergeStackAsyncFn: func(pr int, method, mergeAction string) (*github.AsyncMergeResult, error) { + gotMethod = method + return &github.AsyncMergeResult{Status: github.AsyncMergeStatusPending, Details: github.AsyncMergeDetails{UUID: "u"}}, nil + }, + } + + err := runMerge(cfg, fastOptions(), []string{"7"}) + _ = collectOutput(cfg, outR, errR) + + require.NoError(t, err) + assert.Equal(t, "squash", gotMethod, "falls back to the only allowed method") +} + +func TestResolveMergeMethodFlag(t *testing.T) { + tests := []struct { + name string + opts mergeOptions + want string + wantErr bool + }{ + {"none", mergeOptions{}, "", false}, + {"merge", mergeOptions{merge: true}, "merge", false}, + {"squash", mergeOptions{squash: true}, "squash", false}, + {"rebase", mergeOptions{rebase: true}, "rebase", false}, + {"merge-method", mergeOptions{mergeMethod: "SQUASH"}, "squash", false}, + {"redundant same", mergeOptions{squash: true, mergeMethod: "squash"}, "squash", false}, + {"conflicting bools", mergeOptions{squash: true, rebase: true}, "", true}, + {"conflicting flag+bool", mergeOptions{merge: true, mergeMethod: "squash"}, "", true}, + {"invalid", mergeOptions{mergeMethod: "ff"}, "", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := resolveMergeMethodFlag(&tt.opts) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestMergeCandidates(t *testing.T) { + t.Run("all open", func(t *testing.T) { + items, blocker := mergeCandidates(remoteStack(1, "main", openStackPR(1, "a"), openStackPR(2, "b"))) + assert.Nil(t, blocker) + require.Len(t, items, 2) + assert.Equal(t, 1, items[0].Number) + }) + t.Run("leading merged skipped", func(t *testing.T) { + items, blocker := mergeCandidates(remoteStack(1, "main", mergedStackPR(1, "a"), openStackPR(2, "b"), openStackPR(3, "c"))) + assert.Nil(t, blocker) + assert.Equal(t, []mergeview.PRItem{{Number: 2, Branch: "b"}, {Number: 3, Branch: "c"}}, items) + }) + t.Run("draft blocks above", func(t *testing.T) { + items, blocker := mergeCandidates(remoteStack(1, "main", openStackPR(1, "a"), draftStackPR(2, "b"), openStackPR(3, "c"))) + require.NotNil(t, blocker) + assert.Equal(t, 2, blocker.Number) + assert.Equal(t, []mergeview.PRItem{{Number: 1, Branch: "a"}}, items) + }) + t.Run("closed blocks", func(t *testing.T) { + items, blocker := mergeCandidates(remoteStack(1, "main", closedStackPR(1, "a"), openStackPR(2, "b"))) + require.NotNil(t, blocker) + assert.Empty(t, items) + }) +} diff --git a/cmd/modify.go b/cmd/modify.go index ba2e5404..341ae28d 100644 --- a/cmd/modify.go +++ b/cmd/modify.go @@ -231,7 +231,7 @@ func runModifyAbort(cfg *config.Config) error { cfg.Printf("The stack may be in an inconsistent state.") cfg.Printf("Try `%s` to fix, or `%s` + `%s` to recreate.", cfg.ColorCyan("gh stack rebase"), cfg.ColorCyan("gh stack unstack --local"), - cfg.ColorCyan("gh stack init --adopt")) + cfg.ColorCyan("gh stack init")) return ErrSilent } cfg.Successf("Stack restored successfully") diff --git a/cmd/push.go b/cmd/push.go index 8b35c880..34bee03d 100644 --- a/cmd/push.go +++ b/cmd/push.go @@ -19,13 +19,14 @@ func PushCmd(cfg *config.Config) *cobra.Command { cmd := &cobra.Command{ Use: "push", - Short: "Push all branches in the current stack to the remote", - Long: `Push all branches in the current stack to the remote. - -Uses --force-with-lease and --atomic to ensure safe, all-or-nothing pushes. -Merged and queued branches are automatically skipped. This command is safe to -run repeatedly — it will only update branches that have changed.`, - Example: ` # Push all stack branches to the default remote + Short: "Push active branches in the current stack to the remote", + Long: `Push active branches in the current stack to the remote. + +Uses explicit per-branch --force-with-lease checks. Updates are not atomic: a +branch may update even if another branch is rejected. Fix the rejected branch +and run the command again; branches already updated will be unchanged. +Merged and queued branches are automatically skipped.`, + Example: ` # Push active stack branches to the default remote $ gh stack push # Push to a specific remote @@ -77,7 +78,7 @@ func runPush(cfg *config.Config, opts *pushOptions) error { } s := stacks[0] - // Push all active branches atomically + // Push all active branches with explicit per-branch leases. remote, err := pickRemote(cfg, currentBranch, opts.remote) if err != nil { if !errors.Is(err, errInterrupt) { diff --git a/cmd/rebase.go b/cmd/rebase.go index 9b79efdc..3b4d4827 100644 --- a/cmd/rebase.go +++ b/cmd/rebase.go @@ -36,6 +36,10 @@ type rebaseState struct { OntoOldBase string `json:"ontoOldBase,omitempty"` CommitterDateIsAuthorDate bool `json:"committerDateIsAuthorDate,omitempty"` NoTrunk bool `json:"noTrunk,omitempty"` + TrunkRef string `json:"trunkRef,omitempty"` + TrunkSHA string `json:"trunkSha,omitempty"` + StartIndex int `json:"startIndex,omitempty"` + EndIndex int `json:"endIndex,omitempty"` } const rebaseStateFile = "gh-stack-rebase-state" @@ -125,6 +129,7 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { return ErrSilent } + var trunk trunkTarget if !opts.noTrunk { // Resolve remote for fetch and trunk comparison remote, err := pickRemote(cfg, currentBranch, opts.remote) @@ -135,22 +140,16 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { return ErrSilent } - if err := git.Fetch(remote); err != nil { - cfg.Warningf("Failed to fetch %s: %v", remote, err) - } else { - cfg.Successf("Fetched %s", remote) + trunk, err = resolveTrunkTarget(cfg, s, remote, currentBranch) + if err != nil { + return err } - // Ensure trunk exists locally before fast-forward or cascade rebase. - if err := ensureLocalTrunk(cfg, s.Trunk.Branch, remote); err != nil { - cfg.Errorf("%s", err) + // Fast-forward stack branches that are behind their remote tracking branch. + if err := git.FetchBranches(remote, activeBranchNames(s)); err != nil { + cfg.Errorf("failed to fetch stack branches from %s: %v", remote, err) return ErrSilent } - - // Fast-forward trunk so the cascade rebase targets the latest upstream. - fastForwardTrunk(cfg, s.Trunk.Branch, remote, currentBranch) - - // Fast-forward stack branches that are behind their remote tracking branch. fastForwardBranches(cfg, s, remote, currentBranch) } @@ -222,10 +221,16 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { NeedsOnto: needsOnto, OntoOldBase: ontoOldBase, CommitterDateIsAuthorDate: opts.committerDateIsAuthorDate, + TrunkRef: trunk.Ref, }) if rebaseResult.Err != nil { cfg.Errorf("%v", rebaseResult.Err) + if rebaseResult.Rebased { + restoreRebaseRefs(cfg, currentBranch, originalRefs) + } else { + _ = git.CheckoutBranch(currentBranch) + } return ErrSilent } @@ -242,6 +247,10 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { OntoOldBase: rebaseResult.OntoOldBase, CommitterDateIsAuthorDate: opts.committerDateIsAuthorDate, NoTrunk: opts.noTrunk, + TrunkRef: trunk.Ref, + TrunkSHA: trunk.SHA, + StartIndex: startIdx, + EndIndex: endIdx, } if err := saveRebaseState(gitDir, state); err != nil { cfg.Warningf("failed to save rebase state: %s", err) @@ -259,6 +268,14 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { _ = git.CheckoutBranch(currentBranch) + if unstacked := verifyStacked(s, trunk.Ref, startIdx, endIdx); len(unstacked) > 0 { + reportUnstacked(cfg, trunk.Ref, unstacked) + if rebaseResult.Rebased { + restoreRebaseRefs(cfg, currentBranch, originalRefs) + } + return ErrSilent + } + updateBaseSHAs(s) _ = syncStackPRs(cfg, s) @@ -284,7 +301,7 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { if opts.noTrunk { cfg.Printf("%s rebased locally (without trunk)", rangeDesc) } else { - cfg.Printf("%s rebased locally with %s", rangeDesc, s.Trunk.Branch) + cfg.Printf("%s rebased locally with %s", rangeDesc, trunk.Describe()) } cfg.Printf("To push up your changes, run `%s`", cfg.ColorCyan("gh stack push")) @@ -314,6 +331,14 @@ func continueRebase(cfg *config.Config, gitDir string) error { if s == nil { return fmt.Errorf("no stack found for branch %s", state.OriginalBranch) } + trunkRef := state.TrunkRef + if trunkRef == "" { + trunkRef = s.Trunk.Branch + } + trunkBase := state.TrunkSHA + if trunkBase == "" { + trunkBase = trunkRef + } // Refresh PR state before selecting the base and cascading the remaining // branches. The queued flag is transient (not persisted), so it was lost @@ -343,7 +368,7 @@ func continueRebase(cfg *config.Config, gitDir string) error { var baseBranch string if state.UseOnto { // The --onto path targets the first non-merged ancestor, or trunk. - baseBranch = s.Trunk.Branch + baseBranch = trunkRef for j := state.CurrentBranchIndex - 1; j >= 0; j-- { if !s.Branches[j].IsMerged() { baseBranch = s.Branches[j].Branch @@ -353,7 +378,7 @@ func continueRebase(cfg *config.Config, gitDir string) error { } else if state.CurrentBranchIndex > 0 { baseBranch = s.Branches[state.CurrentBranchIndex-1].Branch } else { - baseBranch = s.Trunk.Branch + baseBranch = trunkRef } cfg.Successf("Rebased %s onto %s", conflictBranch, baseBranch) @@ -385,10 +410,13 @@ func continueRebase(cfg *config.Config, gitDir string) error { NeedsOnto: state.UseOnto, OntoOldBase: state.OntoOldBase, CommitterDateIsAuthorDate: state.CommitterDateIsAuthorDate, + TrunkRef: trunkBase, }) if result.Err != nil { cfg.Errorf("%v", result.Err) + restoreRebaseRefs(cfg, state.OriginalBranch, state.OriginalRefs) + clearRebaseState(gitDir) return ErrSilent } @@ -414,9 +442,23 @@ func continueRebase(cfg *config.Config, gitDir string) error { } } - clearRebaseState(gitDir) _ = git.CheckoutBranch(state.OriginalBranch) + verifyStart, verifyEnd := state.StartIndex, state.EndIndex + if verifyEnd <= verifyStart { + verifyStart, verifyEnd = 0, len(s.Branches) + if state.NoTrunk { + verifyStart = 1 + } + } + if unstacked := verifyStacked(s, trunkBase, verifyStart, verifyEnd); len(unstacked) > 0 { + reportUnstacked(cfg, trunkRef, unstacked) + restoreRebaseRefs(cfg, state.OriginalBranch, state.OriginalRefs) + clearRebaseState(gitDir) + return ErrSilent + } + + clearRebaseState(gitDir) updateBaseSHAs(s) _ = syncStackPRs(cfg, s) @@ -425,8 +467,10 @@ func continueRebase(cfg *config.Config, gitDir string) error { if state.NoTrunk { cfg.Printf("All branches in stack rebased locally (without trunk)") + } else if state.TrunkSHA != "" { + cfg.Printf("All branches in stack rebased locally with %s (%s)", trunkRef, short(state.TrunkSHA)) } else { - cfg.Printf("All branches in stack rebased locally with %s", s.Trunk.Branch) + cfg.Printf("All branches in stack rebased locally with %s", trunkRef) } cfg.Printf("To push up your changes and open/update the stack of PRs, run `%s`", cfg.ColorCyan("gh stack submit")) diff --git a/cmd/rebase_test.go b/cmd/rebase_test.go index 683bfe36..1be2f9f9 100644 --- a/cmd/rebase_test.go +++ b/cmd/rebase_test.go @@ -2,9 +2,11 @@ package cmd import ( "encoding/json" + "errors" "fmt" "io" "os" + "os/exec" "path/filepath" "strings" "testing" @@ -44,9 +46,9 @@ func newRebaseMock(tmpDir string, currentBranch string) *git.MockOps { } return "sha-" + ref, nil }, - IsAncestorFn: func(a, d string) (bool, error) { return true, nil }, - FetchFn: func(string) error { return nil }, - EnableRerereFn: func() error { return nil }, + IsAncestorFn: func(a, d string) (bool, error) { return true, nil }, + FetchFn: func(string) error { return nil }, + EnableRerereFn: func() error { return nil }, IsRebaseInProgressFn: func() bool { return false }, } } @@ -242,11 +244,11 @@ func TestRebase_OntoPropagatesToSubsequentBranches(t *testing.T) { "b4 should rebase --onto b3 with b3's original SHA as oldBase") } -// TestRebase_StaleOntoOldBase_FallsBackToMergeBase verifies that when a branch +// TestRebase_StaleOntoOldBase_UsesForkPoint verifies that when a branch // was already rebased past the merged branch's tip (e.g. by a previous run), -// the stale ontoOldBase is detected via IsAncestor and replaced with -// merge-base(newBase, branch) to avoid replaying already-applied commits. -func TestRebase_StaleOntoOldBase_FallsBackToMergeBase(t *testing.T) { +// the stale ontoOldBase is replaced with a reflog fork-point that the branch +// actually contains. +func TestRebase_StaleOntoOldBase_UsesForkPoint(t *testing.T) { s := stack.Stack{ Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{ @@ -286,11 +288,11 @@ func TestRebase_StaleOntoOldBase_FallsBackToMergeBase(t *testing.T) { } return true, nil } - mock.MergeBaseFn = func(a, b string) (string, error) { + mock.MergeBaseForkPointFn = func(a, b string) (string, error) { if a == "main" && b == "b2" { - return "main-b2-mergebase", nil + return "main-b2-forkpoint", nil } - return "default-mergebase", nil + return "default-forkpoint", nil } mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error { rebaseCalls = append(rebaseCalls, rebaseCall{newBase, oldBase, branch}) @@ -312,9 +314,9 @@ func TestRebase_StaleOntoOldBase_FallsBackToMergeBase(t *testing.T) { assert.NoError(t, err) require.Len(t, rebaseCalls, 2) - // b2: stale ontoOldBase detected → falls back to merge-base(main, b2) - assert.Equal(t, rebaseCall{"main", "main-b2-mergebase", "b2"}, rebaseCalls[0], - "b2 should use merge-base as oldBase when ontoOldBase is stale") + // b2: stale ontoOldBase detected → uses fork-point(main, b2) + assert.Equal(t, rebaseCall{"main", "main-b2-forkpoint", "b2"}, rebaseCalls[0], + "b2 should use the reflog fork-point when ontoOldBase is stale") // b3: b2's SHA is a valid ancestor → uses it directly assert.Equal(t, rebaseCall{"b2", "b2-on-main-sha", "b3"}, rebaseCalls[1], @@ -643,7 +645,7 @@ func TestRebase_SkipsMergedBranches(t *testing.T) { s := stack.Stack{ Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{ - {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 42, Merged: true}}, + {Branch: "b1", Head: "sha-b1", PullRequest: &stack.PullRequestRef{Number: 42, Merged: true}}, {Branch: "b2"}, }, } @@ -1293,11 +1295,7 @@ func TestRebase_FastForwardsBranchFromRemote(t *testing.T) { return "sha-" + ref, nil } mock.IsAncestorFn = func(a, d string) (bool, error) { - // b1-local is ancestor of b1-remote → can fast-forward - if a == "b1-local-sha" && d == "b1-remote-sha" { - return true, nil - } - return false, nil + return true, nil } mock.UpdateBranchRefFn = func(branch, sha string) error { updateBranchRefCalls = append(updateBranchRefCalls, struct{ branch, sha string }{branch, sha}) @@ -1415,7 +1413,11 @@ func TestRebase_BranchDiverged_NoFF(t *testing.T) { } // Neither is ancestor of the other — diverged mock.IsAncestorFn = func(a, d string) (bool, error) { - return false, nil + if (a == "b1-local-sha" && d == "b1-remote-sha") || + (a == "b1-remote-sha" && d == "b1-local-sha") { + return false, nil + } + return true, nil } mock.UpdateBranchRefFn = func(string, string) error { updateBranchRefCalls++ @@ -1963,3 +1965,296 @@ func TestRebase_NoTrunk_ConflictSavesState(t *testing.T) { assert.True(t, loaded.NoTrunk, "saved rebase state should preserve NoTrunk flag") } + +func TestResolveRebaseOldBase(t *testing.T) { + t.Run("uses current parent tip when the branch contains it", func(t *testing.T) { + restore := git.SetOps(&git.MockOps{ + IsAncestorFn: func(ancestor, branch string) (bool, error) { + return ancestor == "current-parent" && branch == "child", nil + }, + }) + defer restore() + + oldBase, err := resolveRebaseOldBase("current-parent", "recorded-base", "parent", "child") + require.NoError(t, err) + assert.Equal(t, "current-parent", oldBase) + }) + + t.Run("uses recorded base after the parent was rewritten", func(t *testing.T) { + restore := git.SetOps(&git.MockOps{ + IsAncestorFn: func(ancestor, branch string) (bool, error) { + return ancestor == "recorded-base" && branch == "child", nil + }, + }) + defer restore() + + oldBase, err := resolveRebaseOldBase("amended-parent", "recorded-base", "parent", "child") + require.NoError(t, err) + assert.Equal(t, "recorded-base", oldBase) + }) + + t.Run("uses fork point when metadata was already corrupted", func(t *testing.T) { + restore := git.SetOps(&git.MockOps{ + IsAncestorFn: func(ancestor, branch string) (bool, error) { + return ancestor == "old-parent" && branch == "child", nil + }, + MergeBaseForkPointFn: func(ref, branch string) (string, error) { + return "old-parent", nil + }, + }) + defer restore() + + oldBase, err := resolveRebaseOldBase("amended-parent", "amended-parent", "parent", "child") + require.NoError(t, err) + assert.Equal(t, "old-parent", oldBase) + }) + + t.Run("fails when no safe boundary can be recovered", func(t *testing.T) { + restore := git.SetOps(&git.MockOps{ + IsAncestorFn: func(string, string) (bool, error) { return false, nil }, + MergeBaseForkPointFn: func(string, string) (string, error) { + return "", errors.New("no fork point") + }, + }) + defer restore() + + _, err := resolveRebaseOldBase("amended-parent", "amended-parent", "parent", "child") + require.Error(t, err) + assert.Contains(t, err.Error(), "rebase this branch manually") + }) +} + +type amendedParentRepo struct { + dir string + gitDir string + oldParent string + newParent string +} + +func issue250Git(t *testing.T, dir string, args ...string) string { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=Test", + "GIT_AUTHOR_EMAIL=test@example.com", + "GIT_COMMITTER_NAME=Test", + "GIT_COMMITTER_EMAIL=test@example.com", + ) + out, err := cmd.CombinedOutput() + require.NoError(t, err, "git %s:\n%s", strings.Join(args, " "), out) + return strings.TrimSpace(string(out)) +} + +func issue250GitMayFail(t *testing.T, dir string, args ...string) error { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=Test", + "GIT_AUTHOR_EMAIL=test@example.com", + "GIT_COMMITTER_NAME=Test", + "GIT_COMMITTER_EMAIL=test@example.com", + ) + return cmd.Run() +} + +func issue250WriteFile(t *testing.T, dir, name, content string) { + t.Helper() + require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0644)) +} + +func setupAmendedParentRepo(t *testing.T, corruptBase bool) amendedParentRepo { + t.Helper() + remoteDir := filepath.Join(t.TempDir(), "remote.git") + cloneDir := filepath.Join(t.TempDir(), "clone") + + issue250Git(t, ".", "-c", "safe.bareRepository=all", "init", "--bare", "-b", "main", remoteDir) + issue250Git(t, ".", "clone", remoteDir, cloneDir) + issue250Git(t, cloneDir, "config", "user.name", "Test") + issue250Git(t, cloneDir, "config", "user.email", "test@example.com") + + issue250WriteFile(t, cloneDir, "base.txt", "base\n") + issue250Git(t, cloneDir, "add", ".") + issue250Git(t, cloneDir, "commit", "-m", "base") + issue250Git(t, cloneDir, "push", "-u", "origin", "main") + mainSHA := issue250Git(t, cloneDir, "rev-parse", "main") + + issue250Git(t, cloneDir, "checkout", "-b", "parent") + issue250WriteFile(t, cloneDir, "old-parent.txt", "old parent\n") + issue250Git(t, cloneDir, "add", ".") + issue250Git(t, cloneDir, "commit", "-m", "parent old") + oldParent := issue250Git(t, cloneDir, "rev-parse", "parent") + issue250Git(t, cloneDir, "push", "-u", "origin", "parent") + + issue250Git(t, cloneDir, "checkout", "-b", "child") + issue250WriteFile(t, cloneDir, "child.txt", "child\n") + issue250Git(t, cloneDir, "add", ".") + issue250Git(t, cloneDir, "commit", "-m", "child commit") + childSHA := issue250Git(t, cloneDir, "rev-parse", "child") + issue250Git(t, cloneDir, "push", "-u", "origin", "child") + + gitDir := filepath.Join(cloneDir, ".git") + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main", Head: mainSHA}, + Branches: []stack.BranchRef{ + {Branch: "parent", Head: oldParent, Base: mainSHA}, + {Branch: "child", Head: childSHA, Base: oldParent}, + }, + } + writeStackFile(t, gitDir, s) + + issue250Git(t, cloneDir, "checkout", "parent") + issue250Git(t, cloneDir, "rm", "old-parent.txt") + issue250WriteFile(t, cloneDir, "new-parent.txt", "new parent\n") + issue250Git(t, cloneDir, "add", ".") + issue250Git(t, cloneDir, "commit", "--amend", "-m", "parent amended") + newParent := issue250Git(t, cloneDir, "rev-parse", "parent") + + if corruptBase { + issue250Git(t, cloneDir, "push", "--force", "origin", "parent") + s.Branches[0].Head = newParent + s.Branches[1].Base = newParent + writeStackFile(t, gitDir, s) + } + issue250Git(t, cloneDir, "checkout", "child") + + return amendedParentRepo{ + dir: cloneDir, + gitDir: gitDir, + oldParent: oldParent, + newParent: newParent, + } +} + +func issue250TestConfig(t *testing.T) *config.Config { + t.Helper() + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{} + t.Cleanup(func() { + _ = cfg.Out.Close() + _ = cfg.Err.Close() + _ = outR.Close() + _ = errR.Close() + }) + return cfg +} + +func withIssue250Repo(t *testing.T, dir string) { + t.Helper() + originalDir, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(dir)) + t.Cleanup(func() { _ = os.Chdir(originalDir) }) +} + +func assertIssue250History(t *testing.T, repo amendedParentRepo) { + t.Helper() + subjects := strings.Split(issue250Git(t, repo.dir, "log", "--format=%s", "main..child"), "\n") + assert.Equal(t, []string{"child commit", "parent amended"}, subjects) + assert.Error(t, issue250GitMayFail(t, repo.dir, "merge-base", "--is-ancestor", repo.oldParent, "child")) + require.NoError(t, issue250GitMayFail(t, repo.dir, "merge-base", "--is-ancestor", repo.newParent, "child")) + _, oldErr := os.Stat(filepath.Join(repo.dir, "old-parent.txt")) + assert.True(t, os.IsNotExist(oldErr)) + _, newErr := os.Stat(filepath.Join(repo.dir, "new-parent.txt")) + assert.NoError(t, newErr) +} + +func TestIntegration_AmendedParentPushThenRebase(t *testing.T) { + repo := setupAmendedParentRepo(t, false) + withIssue250Repo(t, repo.dir) + cfg := issue250TestConfig(t) + + require.NoError(t, runPush(cfg, &pushOptions{remote: "origin"})) + + sf, err := stack.Load(repo.gitDir) + require.NoError(t, err) + require.Len(t, sf.Stacks, 1) + assert.Equal(t, repo.oldParent, sf.Stacks[0].Branches[1].Base, + "push must not replace the child's valid base with an amended parent tip") + + require.NoError(t, runRebase(cfg, &rebaseOptions{remote: "origin"})) + assertIssue250History(t, repo) +} + +func TestIntegration_AmendedParentRecoversCorruptedBase(t *testing.T) { + repo := setupAmendedParentRepo(t, true) + withIssue250Repo(t, repo.dir) + cfg := issue250TestConfig(t) + + require.NoError(t, runRebase(cfg, &rebaseOptions{remote: "origin"})) + assertIssue250History(t, repo) +} + +func TestIntegration_AmendedParentWithoutForkPointFailsSafely(t *testing.T) { + repo := setupAmendedParentRepo(t, true) + issue250Git(t, repo.dir, "reflog", "expire", "--expire=now", "--all") + require.Error(t, issue250GitMayFail(t, repo.dir, "merge-base", "--fork-point", "parent", "child")) + + withIssue250Repo(t, repo.dir) + cfg := issue250TestConfig(t) + parentBefore := issue250Git(t, repo.dir, "rev-parse", "parent") + childBefore := issue250Git(t, repo.dir, "rev-parse", "child") + + err := runRebase(cfg, &rebaseOptions{remote: "origin"}) + require.Error(t, err) + assert.Equal(t, parentBefore, issue250Git(t, repo.dir, "rev-parse", "parent")) + assert.Equal(t, childBefore, issue250Git(t, repo.dir, "rev-parse", "child")) +} + +func TestIntegration_AdoptedBranchRebasesFromCommonAncestor(t *testing.T) { + remoteDir := filepath.Join(t.TempDir(), "remote.git") + cloneDir := filepath.Join(t.TempDir(), "clone") + + issue250Git(t, ".", "-c", "safe.bareRepository=all", "init", "--bare", "-b", "main", remoteDir) + issue250Git(t, ".", "clone", remoteDir, cloneDir) + issue250Git(t, cloneDir, "config", "user.name", "Test") + issue250Git(t, cloneDir, "config", "user.email", "test@example.com") + + issue250WriteFile(t, cloneDir, "base.txt", "base\n") + issue250Git(t, cloneDir, "add", ".") + issue250Git(t, cloneDir, "commit", "-m", "base") + issue250Git(t, cloneDir, "push", "-u", "origin", "main") + mainSHA := issue250Git(t, cloneDir, "rev-parse", "main") + + issue250Git(t, cloneDir, "checkout", "-b", "parent") + issue250WriteFile(t, cloneDir, "parent.txt", "parent\n") + issue250Git(t, cloneDir, "add", ".") + issue250Git(t, cloneDir, "commit", "-m", "parent commit") + parentSHA := issue250Git(t, cloneDir, "rev-parse", "parent") + + issue250Git(t, cloneDir, "checkout", "-b", "imported", "main") + issue250WriteFile(t, cloneDir, "imported-one.txt", "one\n") + issue250Git(t, cloneDir, "add", ".") + issue250Git(t, cloneDir, "commit", "-m", "imported one") + issue250WriteFile(t, cloneDir, "imported-two.txt", "two\n") + issue250Git(t, cloneDir, "add", ".") + issue250Git(t, cloneDir, "commit", "-m", "imported two") + + gitDir := filepath.Join(cloneDir, ".git") + writeStackFile(t, gitDir, stack.Stack{ + Trunk: stack.BranchRef{Branch: "main", Head: mainSHA}, + Branches: []stack.BranchRef{ + {Branch: "parent", Head: parentSHA, Base: mainSHA}, + }, + }) + + issue250Git(t, cloneDir, "checkout", "parent") + withIssue250Repo(t, cloneDir) + cfg := issue250TestConfig(t) + + require.NoError(t, runAdd(cfg, &addOptions{}, []string{"imported"})) + + sf, err := stack.Load(gitDir) + require.NoError(t, err) + require.Len(t, sf.Stacks, 1) + require.Len(t, sf.Stacks[0].Branches, 2) + assert.Equal(t, mainSHA, sf.Stacks[0].Branches[1].Base, + "adopting a separate main-based branch should record main as its old boundary") + + require.NoError(t, runRebase(cfg, &rebaseOptions{remote: "origin"})) + + subjects := strings.Split(issue250Git(t, cloneDir, "log", "--format=%s", "main..imported"), "\n") + assert.Equal(t, []string{"imported two", "imported one", "parent commit"}, subjects) + require.NoError(t, issue250GitMayFail(t, cloneDir, "merge-base", "--is-ancestor", "parent", "imported")) +} diff --git a/cmd/root.go b/cmd/root.go index bb1a1706..f4100c74 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -23,7 +23,7 @@ locally, then push to GitHub to create your stack of PRs.`, $ gh stack init # Or turn an existing set of branches into a stack - $ gh stack init --adopt branch1 branch2 branch3 + $ gh stack init branch1 branch2 branch3 # Make changes and commit, then add a branch to the stack $ gh stack add branch4 @@ -112,6 +112,10 @@ locally, then push to GitHub to create your stack of PRs.`, linkCmd.GroupID = "remote" root.AddCommand(linkCmd) + mergeCmd := MergeCmd(cfg) + mergeCmd.GroupID = "remote" + root.AddCommand(mergeCmd) + // Navigation commands switchCmd := SwitchCmd(cfg) switchCmd.GroupID = "nav" diff --git a/cmd/root_test.go b/cmd/root_test.go index 8138c7a7..47328fd9 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -10,7 +10,7 @@ import ( func TestRootCmd_SubcommandRegistration(t *testing.T) { root := RootCmd() - expected := []string{"init", "add", "checkout", "push", "sync", "unstack", "view", "rebase", "up", "down", "top", "bottom", "alias", "feedback", "submit"} + expected := []string{"init", "add", "checkout", "push", "sync", "unstack", "view", "rebase", "up", "down", "top", "bottom", "alias", "feedback", "submit", "merge"} registered := make(map[string]bool) for _, cmd := range root.Commands() { diff --git a/cmd/sync.go b/cmd/sync.go index 34bbbc05..c9aeda7b 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -107,9 +107,11 @@ func runSync(cfg *config.Config, opts *syncOptions) error { // Fetch trunk + active branches so tracking refs are current for // fast-forward detection (Step 2) and --force-with-lease (Step 4). - fetchTargets := append([]string{s.Trunk.Branch}, activeBranchNames(s)...) - _ = git.FetchBranches(remote, fetchTargets) - cfg.Successf("Fetched latest changes from %s", remote) + normalizeStackTrunk(cfg, s, remote) + if err := git.FetchBranches(remote, activeBranchNames(s)); err != nil { + cfg.Errorf("failed to fetch stack branches from %s: %v", remote, err) + return ErrSilent + } // --- Step 1b: Reconcile remote-ahead stack changes --- // Pull in branches for PRs that were added to the stack on GitHub, or @@ -139,19 +141,19 @@ func runSync(cfg *config.Config, opts *syncOptions) error { currentBranch = cb } - // --- Step 2: Fast-forward trunk --- - trunk := s.Trunk.Branch - trunkUpdated := fastForwardTrunk(cfg, trunk, remote, currentBranch) + // --- Step 2: Resolve trunk --- + trunk, err := resolveTrunkTarget(cfg, s, remote, currentBranch) + if err != nil { + return err + } // --- Step 2b: Fast-forward stack branches behind their remote tracking branch --- updatedBranches := fastForwardBranches(cfg, s, remote, currentBranch) - branchesUpdated := len(updatedBranches) > 0 // --- Step 3: Cascade rebase --- - // Rebase if trunk or any branch moved, or if the stack is stale - // (branches not yet rebased onto their parent's current tip). - needsRebase := trunkUpdated || branchesUpdated || stackNeedsRebase(s) + needsRebase := trunk.Moved || len(updatedBranches) > 0 || stackNeedsRebase(s, trunk.Ref) rebased := false + var originalRefs map[string]string if needsRebase { cfg.Printf("") cfg.Printf("Rebasing stack ...") @@ -159,7 +161,7 @@ func runSync(cfg *config.Config, opts *syncOptions) error { // Sync PR state to detect merged PRs before rebasing. _ = syncStackPRs(cfg, s) - originalRefs, err := resolveOriginalRefs(s) + originalRefs, err = resolveOriginalRefs(s) if err != nil { cfg.Warningf("Could not resolve branch SHAs — skipping rebase: %v", err) } else { @@ -169,11 +171,16 @@ func runSync(cfg *config.Config, opts *syncOptions) error { Branches: s.Branches, StartAbsIdx: 0, OriginalRefs: originalRefs, + TrunkRef: trunk.Ref, }) if result.Err != nil { cfg.Errorf("%v", result.Err) - _ = git.CheckoutBranch(currentBranch) + if result.Rebased { + restoreRebaseRefs(cfg, currentBranch, originalRefs) + } else { + _ = git.CheckoutBranch(currentBranch) + } stack.SaveNonBlocking(gitDir, sf) return ErrSilent } @@ -204,6 +211,16 @@ func runSync(cfg *config.Config, opts *syncOptions) error { _ = git.CheckoutBranch(currentBranch) } + if unstacked := verifyStacked(s, trunk.Ref, 0, len(s.Branches)); len(unstacked) > 0 { + _ = git.CheckoutBranch(currentBranch) + reportUnstacked(cfg, trunk.Ref, unstacked) + if rebased && originalRefs != nil { + restoreRebaseRefs(cfg, currentBranch, originalRefs) + } + stack.SaveNonBlocking(gitDir, sf) + return ErrSilent + } + // --- Step 4: Push --- cfg.Printf("") branches := activeBranchNames(s) @@ -329,7 +346,7 @@ func runSync(cfg *config.Config, opts *syncOptions) error { } } if needsSwitch { - switchTarget := trunk + switchTarget := trunk.Branch for _, b := range s.Branches { if !b.IsSkipped() { switchTarget = b.Branch @@ -385,6 +402,7 @@ func runSync(cfg *config.Config, opts *syncOptions) error { // unavailable, or a divergence). Report only what actually happened. cfg.Successf("Branches synced") } + cfg.Printf(" Stacked on %s", trunk.Describe()) return nil } @@ -392,6 +410,12 @@ func runSync(cfg *config.Config, opts *syncOptions) error { func restoreBranches(originalRefs map[string]string) []string { var errors []string for branch, sha := range originalRefs { + if !git.BranchExists(branch) { + continue + } + if currentSHA, err := git.RevParse(branch); err == nil && currentSHA == sha { + continue + } if err := git.CheckoutBranch(branch); err != nil { errors = append(errors, fmt.Sprintf("checkout %s: %s", branch, err)) continue @@ -403,6 +427,12 @@ func restoreBranches(originalRefs map[string]string) []string { return errors } +func restoreRebaseRefs(cfg *config.Config, originalBranch string, originalRefs map[string]string) { + restoreErrors := restoreBranches(originalRefs) + _ = git.CheckoutBranch(originalBranch) + reportRestoreStatus(cfg, restoreErrors) +} + // reportRestoreStatus prints whether branch restoration succeeded or partially failed. func reportRestoreStatus(cfg *config.Config, restoreErrors []string) { if len(restoreErrors) > 0 { diff --git a/cmd/sync_test.go b/cmd/sync_test.go index 4f76d8e7..ee364985 100644 --- a/cmd/sync_test.go +++ b/cmd/sync_test.go @@ -139,10 +139,10 @@ func TestSync_TrunkUpToDate_StackStale(t *testing.T) { } return "sha-" + ref, nil } - // Stack branches are NOT rebased onto trunk — parent is not an ancestor. + // Stack branches are NOT rebased onto trunk until the cascade runs. + rebased := false mock.IsAncestorFn = func(a, d string) (bool, error) { - // main is NOT an ancestor of b1 → stack is stale - if a == "main" && d == "b1" { + if a == "main" && d == "b1" && !rebased { return false, nil } return true, nil @@ -150,10 +150,12 @@ func TestSync_TrunkUpToDate_StackStale(t *testing.T) { mock.CheckoutBranchFn = func(string) error { return nil } mock.RebaseFn = func(base string, opts git.RebaseOpts) error { rebaseCalls = append(rebaseCalls, rebaseCall{branch: "(rebase)" + base}) + rebased = true return nil } mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error { rebaseCalls = append(rebaseCalls, rebaseCall{newBase, oldBase, branch}) + rebased = true return nil } mock.PushFn = func(remote string, branches []string, force, atomic bool) error { @@ -298,10 +300,13 @@ func TestSync_TrunkFastForward_WhenOnTrunk(t *testing.T) { if ref == "origin/main" { return "remote-sha", nil } + if strings.HasPrefix(ref, "origin/") { + return "sha-" + strings.TrimPrefix(ref, "origin/"), nil + } return "sha-" + ref, nil } mock.IsAncestorFn = func(a, d string) (bool, error) { - return a == "local-sha" && d == "remote-sha", nil + return true, nil } mock.MergeFFFn = func(target string) error { mergeFFCalls = append(mergeFFCalls, target) @@ -470,6 +475,11 @@ func TestSync_RebaseConflict_RestoresAll(t *testing.T) { var checkouts []string currentBranch := "b1" abortCalled := false + branchSHAs := map[string]string{ + "b1": "sha-b1", + "b2": "sha-b2", + "b3": "sha-b3", + } mock := newSyncMock(tmpDir, "b1") mock.RevParseFn = func(ref string) (string, error) { @@ -479,10 +489,13 @@ func TestSync_RebaseConflict_RestoresAll(t *testing.T) { if ref == "origin/main" { return "remote-sha", nil } + if sha, ok := branchSHAs[ref]; ok { + return sha, nil + } return "sha-" + ref, nil } mock.IsAncestorFn = func(a, d string) (bool, error) { - return a == "local-sha" && d == "remote-sha", nil + return true, nil } mock.UpdateBranchRefFn = func(string, string) error { return nil } mock.CheckoutBranchFn = func(name string) error { @@ -490,7 +503,10 @@ func TestSync_RebaseConflict_RestoresAll(t *testing.T) { currentBranch = name return nil } - mock.RebaseFn = func(string, git.RebaseOpts) error { return nil } // b1 succeeds + mock.RebaseFn = func(string, git.RebaseOpts) error { + branchSHAs["b1"] = "rebased-b1" + return nil + } mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error { if branch == "b2" { return fmt.Errorf("conflict") @@ -503,6 +519,7 @@ func TestSync_RebaseConflict_RestoresAll(t *testing.T) { } mock.ResetHardFn = func(ref string) error { resets = append(resets, resetCall{currentBranch, ref}) + branchSHAs[currentBranch] = ref return nil } @@ -523,14 +540,15 @@ func TestSync_RebaseConflict_RestoresAll(t *testing.T) { assert.Contains(t, output, "Conflict detected") assert.Contains(t, output, "gh stack rebase") - // All branches should be restored + // The branch rewritten before the conflict should be restored. Unchanged + // branches are left alone. resetMap := make(map[string]string) for _, r := range resets { resetMap[r.branch] = r.sha } assert.Equal(t, "sha-b1", resetMap["b1"]) - assert.Equal(t, "sha-b2", resetMap["b2"]) - assert.Equal(t, "sha-b3", resetMap["b3"]) + assert.NotContains(t, resetMap, "b2") + assert.NotContains(t, resetMap, "b3") _ = abortCalled // RebaseAbort is called if IsRebaseInProgress returns true } @@ -625,7 +643,7 @@ func TestSync_PushForceFlagDependsOnRebase(t *testing.T) { return "sha-" + ref, nil } mock.IsAncestorFn = func(a, d string) (bool, error) { - return a == "local-sha" && d == "remote-sha", nil + return true, nil } mock.UpdateBranchRefFn = func(string, string) error { return nil } } else { @@ -822,10 +840,10 @@ func TestSync_QueuedBranch_DownstreamStaysStacked(t *testing.T) { "queued b1 must not be pushed") } -// TestSync_StaleOntoOldBase_FallsBackToMergeBase verifies that when a branch +// TestSync_StaleOntoOldBase_UsesForkPoint verifies that when a branch // was already rebased past the merged branch's tip, sync detects the stale -// ontoOldBase and falls back to merge-base for the correct divergence point. -func TestSync_StaleOntoOldBase_FallsBackToMergeBase(t *testing.T) { +// ontoOldBase and uses a reflog fork-point for the correct divergence point. +func TestSync_StaleOntoOldBase_UsesForkPoint(t *testing.T) { s := stack.Stack{ Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{ @@ -871,11 +889,11 @@ func TestSync_StaleOntoOldBase_FallsBackToMergeBase(t *testing.T) { } return true, nil } - mock.MergeBaseFn = func(a, b string) (string, error) { + mock.MergeBaseForkPointFn = func(a, b string) (string, error) { if a == "main" && b == "b2" { - return "main-b2-mergebase", nil + return "main-b2-forkpoint", nil } - return "default-mergebase", nil + return "default-forkpoint", nil } mock.UpdateBranchRefFn = func(string, string) error { return nil } mock.CheckoutBranchFn = func(string) error { return nil } @@ -900,9 +918,9 @@ func TestSync_StaleOntoOldBase_FallsBackToMergeBase(t *testing.T) { assert.NoError(t, err) require.Len(t, rebaseOntoCalls, 2) - // b2: stale ontoOldBase → falls back to merge-base(main, b2) - assert.Equal(t, rebaseCall{"main", "main-b2-mergebase", "b2"}, rebaseOntoCalls[0], - "b2 should use merge-base as oldBase when ontoOldBase is stale") + // b2: stale ontoOldBase → uses fork-point(main, b2) + assert.Equal(t, rebaseCall{"main", "main-b2-forkpoint", "b2"}, rebaseOntoCalls[0], + "b2 should use the reflog fork-point when ontoOldBase is stale") // b3: b2's SHA is a valid ancestor → uses it directly assert.Equal(t, rebaseCall{"b2", "b2-on-main-sha", "b3"}, rebaseOntoCalls[1], @@ -938,7 +956,7 @@ func TestSync_PushFailureAfterRebase(t *testing.T) { return "sha-" + ref, nil } mock.IsAncestorFn = func(a, d string) (bool, error) { - return a == "local-sha" && d == "remote-sha", nil + return true, nil } mock.UpdateBranchRefFn = func(string, string) error { return nil } mock.CheckoutBranchFn = func(string) error { return nil } @@ -1006,10 +1024,7 @@ func TestSync_BranchFastForward_TriggersRebase(t *testing.T) { return "sha-" + ref, nil } mock.IsAncestorFn = func(a, d string) (bool, error) { - if a == "b1-local-sha" && d == "b1-remote-sha" { - return true, nil - } - return false, nil + return true, nil } mock.MergeFFFn = func(target string) error { mergeFFCalls = append(mergeFFCalls, target) @@ -1096,13 +1111,7 @@ func TestSync_BranchFastForward_WithTrunkUpdate(t *testing.T) { return "sha-" + ref, nil } mock.IsAncestorFn = func(a, d string) (bool, error) { - if a == "trunk-local" && d == "trunk-remote" { - return true, nil - } - if a == "b2-local" && d == "b2-remote" { - return true, nil - } - return false, nil + return true, nil } mock.UpdateBranchRefFn = func(branch, sha string) error { updateBranchRefCalls = append(updateBranchRefCalls, struct{ branch, sha string }{branch, sha}) diff --git a/cmd/trunk_target_test.go b/cmd/trunk_target_test.go new file mode 100644 index 00000000..ef864824 --- /dev/null +++ b/cmd/trunk_target_test.go @@ -0,0 +1,519 @@ +package cmd + +import ( + "errors" + "io" + "os" + "path/filepath" + "testing" + + "github.com/github/gh-stack/internal/config" + "github.com/github/gh-stack/internal/git" + "github.com/github/gh-stack/internal/stack" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func trunkTargetMock(localSHA, remoteSHA string) *git.MockOps { + return &git.MockOps{ + BranchExistsFn: func(string) bool { return true }, + RevParseFn: func(ref string) (string, error) { + switch ref { + case "main": + return localSHA, nil + case "origin/main": + return remoteSHA, nil + default: + return "sha-" + ref, nil + } + }, + } +} + +func TestNormalizeTrunkBranch(t *testing.T) { + t.Run("strips the selected remote prefix", func(t *testing.T) { + restore := git.SetOps(&git.MockOps{ + BranchExistsFn: func(string) bool { return false }, + }) + defer restore() + + assert.Equal(t, "main", normalizeTrunkBranch("origin/main", "origin")) + }) + + t.Run("preserves a real local branch with the remote prefix", func(t *testing.T) { + restore := git.SetOps(&git.MockOps{ + BranchExistsFn: func(name string) bool { return name == "origin/main" }, + }) + defer restore() + + assert.Equal(t, "origin/main", normalizeTrunkBranch("origin/main", "origin")) + }) +} + +func TestResolveTrunkTarget(t *testing.T) { + t.Run("normalizes a remote-qualified trunk before fetching", func(t *testing.T) { + mock := trunkTargetMock("same", "same") + mock.BranchExistsFn = func(name string) bool { return name == "main" } + var fetchedBranch string + mock.FetchBranchFn = func(remote, branch string) error { + assert.Equal(t, "origin", remote) + fetchedBranch = branch + return nil + } + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + s := &stack.Stack{Trunk: stack.BranchRef{Branch: "origin/main"}} + target, err := resolveTrunkTarget(cfg, s, "origin", "b1") + + require.NoError(t, err) + assert.Equal(t, "main", fetchedBranch) + assert.Equal(t, "main", s.Trunk.Branch) + assert.Equal(t, "main", target.Ref) + }) + + t.Run("falls back to fetched remote ref when local trunk cannot move", func(t *testing.T) { + mock := trunkTargetMock("local", "remote") + mock.IsAncestorFn = func(a, d string) (bool, error) { + return a == "local" && d == "remote", nil + } + mock.UpdateBranchRefFn = func(string, string) error { + return errors.New("branch is checked out in another worktree") + } + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + target, err := resolveTrunkTarget(cfg, &stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + }, "origin", "b1") + + require.NoError(t, err) + assert.Equal(t, "origin/main", target.Ref) + assert.Equal(t, "remote", target.SHA) + }) + + t.Run("keeps local trunk when it contains fetched remote tip", func(t *testing.T) { + mock := trunkTargetMock("local-ahead", "remote") + mock.IsAncestorFn = func(a, d string) (bool, error) { + return a == "remote" && d == "local-ahead", nil + } + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + target, err := resolveTrunkTarget(cfg, &stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + }, "origin", "b1") + + require.NoError(t, err) + assert.Equal(t, "main", target.Ref) + assert.Equal(t, "local-ahead", target.SHA) + }) + + t.Run("uses intentional local-only trunk", func(t *testing.T) { + mock := trunkTargetMock("local", "") + mock.FetchBranchFn = func(string, string) error { + return git.ErrRemoteBranchNotFound + } + mock.UpstreamRemoteFn = func(string) (string, error) { return "", errors.New("unset") } + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + target, err := resolveTrunkTarget(cfg, &stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + }, "origin", "b1") + + require.NoError(t, err) + assert.Equal(t, "main", target.Ref) + assert.Equal(t, "local", target.SHA) + }) + + t.Run("fails when tracked trunk was deleted", func(t *testing.T) { + mock := trunkTargetMock("local", "") + mock.FetchBranchFn = func(string, string) error { + return git.ErrRemoteBranchNotFound + } + mock.UpstreamRemoteFn = func(string) (string, error) { return "origin", nil } + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + _, err := resolveTrunkTarget(cfg, &stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + }, "origin", "b1") + + assert.ErrorIs(t, err, ErrSilent) + }) + + t.Run("fails closed on transport error", func(t *testing.T) { + mock := trunkTargetMock("local", "cached") + mock.FetchBranchFn = func(string, string) error { + return errors.New("network unavailable") + } + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + _, err := resolveTrunkTarget(cfg, &stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + }, "origin", "b1") + + assert.ErrorIs(t, err, ErrSilent) + }) +} + +func TestVerifyStackedUsesResolvedTrunk(t *testing.T) { + s := &stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1"}}, + } + restore := git.SetOps(&git.MockOps{ + IsAncestorFn: func(a, d string) (bool, error) { + return a == "main" && d == "b1", nil + }, + }) + defer restore() + + assert.Empty(t, verifyStacked(s, "main", 0, 1)) + assert.Equal(t, []string{"b1"}, verifyStacked(s, "origin/main", 0, 1)) +} + +func TestVerifyStackedKeepsQueuedBranchAsParent(t *testing.T) { + s := &stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 1}}, + {Branch: "b2"}, + }, + } + s.Branches[0].Queued = true + + restore := git.SetOps(&git.MockOps{ + IsAncestorFn: func(a, d string) (bool, error) { + return a == "b1" && d == "b2", nil + }, + }) + defer restore() + + assert.Empty(t, verifyStacked(s, "new-main", 0, 2), + "downstream branches remain stacked on queued branches while trunk moves") +} + +func TestRebase_FetchFailureStopsBeforeCascade(t *testing.T) { + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1"}}, + }) + + rebaseCalls := 0 + mock := newRebaseMock(tmpDir, "b1") + mock.BranchExistsFn = func(string) bool { return true } + mock.FetchBranchFn = func(string, string) error { return errors.New("network unavailable") } + mock.RebaseFn = func(string, git.RebaseOpts) error { rebaseCalls++; return nil } + restore := git.SetOps(mock) + defer restore() + + cfg, _, errR := config.NewTestConfig() + cmd := RebaseCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + output, _ := io.ReadAll(errR) + assert.ErrorIs(t, err, ErrSilent) + assert.Zero(t, rebaseCalls) + assert.Contains(t, string(output), "failed to fetch trunk branch") + assert.NotContains(t, string(output), "rebased locally") +} + +func TestRebase_StartErrorDoesNotWriteRecoveryState(t *testing.T) { + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1"}}, + }) + + mock := newRebaseMock(tmpDir, "b1") + mock.BranchExistsFn = func(string) bool { return true } + mock.CheckoutBranchFn = func(string) error { return nil } + mock.RebaseFn = func(string, git.RebaseOpts) error { + return &git.RebaseStartError{Err: errors.New("branch is checked out elsewhere")} + } + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + cmd := RebaseCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + assert.ErrorIs(t, err, ErrSilent) + _, statErr := os.Stat(filepath.Join(tmpDir, rebaseStateFile)) + assert.True(t, os.IsNotExist(statErr)) +} + +func TestRebase_LaterStartErrorRestoresEarlierBranches(t *testing.T) { + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1"}, + {Branch: "b2"}, + }, + }) + + branchSHAs := map[string]string{"b1": "old-b1", "b2": "old-b2"} + currentBranch := "b1" + var resets []resetCall + + mock := newRebaseMock(tmpDir, currentBranch) + mock.BranchExistsFn = func(string) bool { return true } + mock.RevParseFn = func(ref string) (string, error) { + if ref == "main" || ref == "origin/main" { + return "trunk", nil + } + if sha, ok := branchSHAs[ref]; ok { + return sha, nil + } + if len(ref) > len("origin/") && ref[:len("origin/")] == "origin/" { + return branchSHAs[ref[len("origin/"):]], nil + } + return "sha-" + ref, nil + } + mock.CheckoutBranchFn = func(branch string) error { + currentBranch = branch + return nil + } + mock.RebaseFn = func(string, git.RebaseOpts) error { + branchSHAs["b1"] = "rebased-b1" + return nil + } + mock.RebaseOntoFn = func(string, string, string, git.RebaseOpts) error { + return &git.RebaseStartError{Err: errors.New("branch is checked out elsewhere")} + } + mock.ResetHardFn = func(ref string) error { + resets = append(resets, resetCall{currentBranch, ref}) + branchSHAs[currentBranch] = ref + return nil + } + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + cmd := RebaseCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + assert.ErrorIs(t, err, ErrSilent) + assert.Equal(t, "old-b1", branchSHAs["b1"]) + assert.Equal(t, "old-b2", branchSHAs["b2"]) + assert.Equal(t, []resetCall{{branch: "b1", sha: "old-b1"}}, resets) +} + +func TestSync_LaterStartErrorRestoresEarlierBranches(t *testing.T) { + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1"}, + {Branch: "b2"}, + }, + }) + + branchSHAs := map[string]string{"b1": "old-b1", "b2": "old-b2"} + currentBranch := "b1" + pushes := 0 + + mock := newSyncMock(tmpDir, currentBranch) + mock.RevParseFn = func(ref string) (string, error) { + if ref == "main" || ref == "origin/main" { + return "trunk", nil + } + if sha, ok := branchSHAs[ref]; ok { + return sha, nil + } + if len(ref) > len("origin/") && ref[:len("origin/")] == "origin/" { + return branchSHAs[ref[len("origin/"):]], nil + } + return "sha-" + ref, nil + } + stacked := false + mock.IsAncestorFn = func(a, d string) (bool, error) { + if a == "main" && d == "b1" { + return stacked, nil + } + return true, nil + } + mock.CheckoutBranchFn = func(branch string) error { + currentBranch = branch + return nil + } + mock.RebaseFn = func(string, git.RebaseOpts) error { + branchSHAs["b1"] = "rebased-b1" + stacked = true + return nil + } + mock.RebaseOntoFn = func(string, string, string, git.RebaseOpts) error { + return &git.RebaseStartError{Err: errors.New("branch is checked out elsewhere")} + } + mock.ResetHardFn = func(ref string) error { + branchSHAs[currentBranch] = ref + return nil + } + mock.PushFn = func(string, []string, bool, bool) error { + pushes++ + return nil + } + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + cmd := SyncCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + assert.ErrorIs(t, err, ErrSilent) + assert.Equal(t, "old-b1", branchSHAs["b1"]) + assert.Equal(t, "old-b2", branchSHAs["b2"]) + assert.Zero(t, pushes) +} + +func TestRebase_ContinueVerificationFailureRestoresAndClearsState(t *testing.T) { + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1"}, + {Branch: "b2"}, + {Branch: "b3"}, + }, + }) + + state := &rebaseState{ + CurrentBranchIndex: 1, + ConflictBranch: "b2", + RemainingBranches: []string{"b3"}, + OriginalBranch: "b1", + OriginalRefs: map[string]string{ + "b1": "old-b1", + "b2": "old-b2", + "b3": "old-b3", + }, + TrunkRef: "main", + TrunkSHA: "trunk", + StartIndex: 0, + EndIndex: 3, + } + require.NoError(t, saveRebaseState(tmpDir, state)) + + branchSHAs := map[string]string{"b1": "old-b1", "b2": "old-b2", "b3": "old-b3"} + currentBranch := "b2" + rebaseInProgress := true + cascadeDone := false + + mock := newRebaseMock(tmpDir, currentBranch) + mock.BranchExistsFn = func(string) bool { return true } + mock.RevParseFn = func(ref string) (string, error) { + if sha, ok := branchSHAs[ref]; ok { + return sha, nil + } + return "sha-" + ref, nil + } + mock.IsRebaseInProgressFn = func() bool { return rebaseInProgress } + mock.RebaseContinueFn = func(git.RebaseOpts) error { + rebaseInProgress = false + branchSHAs["b2"] = "rebased-b2" + return nil + } + mock.IsAncestorFn = func(a, d string) (bool, error) { + if a == "b2" && d == "b3" { + return !cascadeDone, nil + } + return true, nil + } + mock.RebaseOntoFn = func(string, string, string, git.RebaseOpts) error { + cascadeDone = true + branchSHAs["b3"] = "rebased-b3" + return nil + } + mock.CheckoutBranchFn = func(branch string) error { + currentBranch = branch + return nil + } + mock.ResetHardFn = func(ref string) error { + branchSHAs[currentBranch] = ref + return nil + } + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + cmd := RebaseCmd(cfg) + cmd.SetArgs([]string{"--continue"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + assert.ErrorIs(t, err, ErrSilent) + assert.Equal(t, "old-b2", branchSHAs["b2"]) + assert.Equal(t, "old-b3", branchSHAs["b3"]) + _, statErr := os.Stat(filepath.Join(tmpDir, rebaseStateFile)) + assert.True(t, os.IsNotExist(statErr), "terminal verification failure must clear stale continuation state") +} + +func TestSync_UnstackedCascadeDoesNotPush(t *testing.T) { + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1"}, + {Branch: "b2"}, + }, + }) + + pushes := 0 + mock := newSyncMock(tmpDir, "b1") + mock.RevParseFn = func(ref string) (string, error) { + switch ref { + case "main": + return "local", nil + case "origin/main": + return "remote", nil + default: + return "sha-" + ref, nil + } + } + mock.IsAncestorFn = func(a, d string) (bool, error) { + if a == "local" && d == "remote" { + return true, nil + } + if a == "main" && d == "b1" { + return false, nil + } + return true, nil + } + mock.UpdateBranchRefFn = func(string, string) error { return nil } + mock.CheckoutBranchFn = func(string) error { return nil } + mock.RebaseFn = func(string, git.RebaseOpts) error { return nil } + mock.RebaseOntoFn = func(string, string, string, git.RebaseOpts) error { return nil } + mock.PushFn = func(string, []string, bool, bool) error { pushes++; return nil } + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + cmd := SyncCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + assert.ErrorIs(t, err, ErrSilent) + assert.Zero(t, pushes) +} diff --git a/cmd/utils.go b/cmd/utils.go index 1c5dc139..6da90fa7 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -791,7 +791,7 @@ func updateBaseSHAs(s *stack.Stack) { return } for _, p := range pairs { - if base, ok := shaMap[p.parent]; ok { + 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 { @@ -800,6 +800,18 @@ func updateBaseSHAs(s *stack.Stack) { } } +// 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() @@ -909,57 +921,125 @@ func ensureLocalTrunk(cfg *config.Config, trunk, remote string) error { return nil } -// fastForwardTrunk fast-forwards the trunk branch to match its remote tracking -// branch. Returns true if trunk was updated. -func fastForwardTrunk(cfg *config.Config, trunk, remote, currentBranch string) bool { - // If the local trunk branch doesn't exist, there's nothing to - // fast-forward. Callers should use ensureLocalTrunk beforehand if - // they need trunk to be resolvable as a local ref. - if !git.BranchExists(trunk) { - return false +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 +} - localSHA, remoteSHA := "", "" - trunkRefs, trunkErr := git.RevParseMulti([]string{trunk, remote + "/" + trunk}) - if trunkErr == nil { - localSHA, remoteSHA = trunkRefs[0], trunkRefs[1] +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 +} - if trunkErr != nil { - cfg.Warningf("Could not compare trunk %s with remote — skipping trunk update", trunk) - return false +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 } - if localSHA == remoteSHA { - cfg.Successf("Trunk %s is already up to date", trunk) - return false + 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 } - isAncestor, err := git.IsAncestor(localSHA, remoteSHA) + localSHA, err := git.RevParse(trunk) if err != nil { - cfg.Warningf("Could not determine fast-forward status for %s: %v", trunk, err) - return false + cfg.Errorf("could not resolve local trunk branch %s: %v", trunk, err) + return trunkTarget{}, ErrSilent } - if !isAncestor { - cfg.Warningf("Trunk %s has diverged from %s — skipping trunk update", trunk, remote) - cfg.Printf(" Local and remote %s have diverged. Resolve manually.", trunk) - return false + if localSHA == remoteSHA { + cfg.Successf("Trunk %s is already up to date", trunk) + return trunkTarget{Branch: trunk, Ref: trunk, SHA: localSHA}, nil } - if currentBranch == trunk { - if err := git.MergeFF(remote + "/" + trunk); err != nil { - cfg.Warningf("Failed to fast-forward %s: %v", trunk, err) - return false - } + 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 { - if err := git.UpdateBranchRef(trunk, remoteSHA); err != nil { - cfg.Warningf("Failed to fast-forward %s: %v", trunk, err) - return false - } + cfg.Warningf("Local %s has diverged from %s", trunk, remoteRef) } - cfg.Successf("Trunk %s fast-forwarded to %s", trunk, short(remoteSHA)) - return true + 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 @@ -973,6 +1053,43 @@ type cascadeRebaseOpts struct { 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. @@ -999,13 +1116,14 @@ func cascadeRebase(opts cascadeRebaseOpts) cascadeRebaseResult { 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 = s.Trunk.Branch + base = trunkRef } else { base = s.Branches[absIdx-1].Branch } @@ -1034,7 +1152,7 @@ func cascadeRebase(opts cascadeRebaseOpts) cascadeRebaseResult { if needsOnto { // Find --onto target: first non-merged ancestor, or trunk. Queued // ancestors keep their commits, so they are valid --onto targets. - newBase := s.Trunk.Branch + newBase := trunkRef for j := absIdx - 1; j >= 0; j-- { if !s.Branches[j].IsMerged() { newBase = s.Branches[j].Branch @@ -1042,18 +1160,21 @@ func cascadeRebase(opts cascadeRebaseOpts) cascadeRebaseResult { } } - // If ontoOldBase is stale (not an ancestor of the branch), the - // branch was already rebased past it. Fall back to - // merge-base(newBase, branch) to avoid replaying already-applied - // commits. - actualOldBase := ontoOldBase - if isAnc, err := git.IsAncestor(ontoOldBase, br.Branch); err == nil && !isAnc { - if mb, err := git.MergeBase(newBase, br.Branch); err == nil { - actualOldBase = mb + 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) @@ -1076,7 +1197,14 @@ func cascadeRebase(opts cascadeRebaseOpts) cascadeRebaseResult { } else { var rebaseErr error if absIdx > 0 { - rebaseErr = git.RebaseOnto(base, originalRefs[base], br.Branch, rebaseOpts) + 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{ @@ -1088,6 +1216,12 @@ func cascadeRebase(opts cascadeRebaseOpts) cascadeRebaseResult { } 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) @@ -1112,29 +1246,48 @@ func cascadeRebase(opts cascadeRebaseOpts) cascadeRebaseResult { return result } -// stackNeedsRebase returns true if any active branch in the stack is not based -// on its parent's current tip. This detects when the stack needs rebasing even -// if trunk was not updated in the current run. -func stackNeedsRebase(s *stack.Stack) bool { - trunk := s.Trunk.Branch - for i, br := range s.Branches { +// 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 } - // Find the nearest non-skipped parent. - parent := trunk + parent := trunkRef for j := i - 1; j >= 0; j-- { - if !s.Branches[j].IsSkipped() { + if !s.Branches[j].IsMerged() { parent = s.Branches[j].Branch break } } isAnc, err := git.IsAncestor(parent, br.Branch) if err != nil || !isAnc { - return true + unstacked = append(unstacked, br.Branch) } } - return false + 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 diff --git a/cmd/utils_test.go b/cmd/utils_test.go index feb89fc9..464e6196 100644 --- a/cmd/utils_test.go +++ b/cmd/utils_test.go @@ -682,7 +682,7 @@ func TestStackNeedsRebase_AllCurrent(t *testing.T) { restore := git.SetOps(mock) defer restore() - assert.False(t, stackNeedsRebase(s), "stack should not need rebase when all branches are current") + assert.False(t, stackNeedsRebase(s, ""), "stack should not need rebase when all branches are current") } func TestStackNeedsRebase_FirstBranchStale(t *testing.T) { @@ -705,7 +705,7 @@ func TestStackNeedsRebase_FirstBranchStale(t *testing.T) { restore := git.SetOps(mock) defer restore() - assert.True(t, stackNeedsRebase(s), "stack should need rebase when first branch is stale") + assert.True(t, stackNeedsRebase(s, ""), "stack should need rebase when first branch is stale") } func TestStackNeedsRebase_SkipsMergedBranches(t *testing.T) { @@ -725,7 +725,7 @@ func TestStackNeedsRebase_SkipsMergedBranches(t *testing.T) { restore := git.SetOps(mock) defer restore() - assert.False(t, stackNeedsRebase(s), "should skip merged branches and find stack up to date") + assert.False(t, stackNeedsRebase(s, ""), "should skip merged branches and find stack up to date") } // setTestRepo sets RepoOverride so tests don't depend on real git context. @@ -851,3 +851,37 @@ func TestEnrichPRContent(t *testing.T) { assert.Equal(t, "Fetched body", details["merged"].Body) assert.Equal(t, "Has it", details["open"].Title, "PRs that already have a title are untouched") } + +func TestUpdateBaseSHAsPreservesLastValidBase(t *testing.T) { + s := &stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "parent", Base: "main-tip"}, + {Branch: "child", Base: "old-parent"}, + }, + } + + restore := git.SetOps(&git.MockOps{ + RevParseFn: func(ref string) (string, error) { + switch ref { + case "main": + return "main-tip", nil + case "parent": + return "amended-parent", nil + case "child": + return "child-tip", nil + default: + return "", errors.New("unknown ref") + } + }, + IsAncestorFn: func(ancestor, branch string) (bool, error) { + return !(ancestor == "amended-parent" && branch == "child"), nil + }, + }) + defer restore() + + updateBaseSHAs(s) + + assert.Equal(t, "old-parent", s.Branches[1].Base) + assert.Equal(t, "child-tip", s.Branches[1].Head) +} diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 01776261..d1d991ba 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -13,7 +13,7 @@ export default defineConfig({ integrations: [ starlight({ title: 'GitHub Stacked PRs', - description: 'Break large changes into small, reviewable pull requests that build on each other — with native GitHub support and the gh stack CLI.', + description: 'Break large changes into small, reviewable pull requests. Manage your stacks on GitHub, with the gh stack CLI, or via our APIs.', favicon: '/favicon.svg', logo: { src: './src/assets/github-invertocat.svg', @@ -73,6 +73,9 @@ export default defineConfig({ items: [ { label: 'CLI Commands', slug: 'reference/cli' }, { label: 'Webhooks', slug: 'reference/webhooks' }, + { label: 'GraphQL API', slug: 'reference/graphql-api' }, + { label: 'REST API', slug: 'reference/rest-api' }, + { label: 'Merge API', slug: 'reference/merge-api' }, ], }, { diff --git a/docs/package-lock.json b/docs/package-lock.json index 8656976f..285d4137 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -9,34 +9,34 @@ "version": "0.0.1", "dependencies": { "@astrojs/starlight": "^0.41.1", - "astro": "^7.0.3", - "sharp": "^0.34.5" + "astro": "^7.1.3", + "sharp": "^0.35.3" } }, "node_modules/@astrojs/compiler-binding": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding/-/compiler-binding-0.2.3.tgz", - "integrity": "sha512-Xz3iBNse+hXXD25IXxsuXEt2ai8klAWE15CRm/EQBc9+aE3jXaF07DZx+iakk3HC6NHvWlEPzLPyxsLgPzOJsw==", + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding/-/compiler-binding-0.3.1.tgz", + "integrity": "sha512-DaAUj29AIBU2XdJ8uwcab8lW5O2pk9pY8AXkcMw0sw77nVa3oeTYRcO+Dvbbpoexf6ThMc0FMWYCQ/wN1/T7oQ==", "license": "MIT", "engines": { "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@astrojs/compiler-binding-darwin-arm64": "0.2.3", - "@astrojs/compiler-binding-darwin-x64": "0.2.3", - "@astrojs/compiler-binding-linux-arm64-gnu": "0.2.3", - "@astrojs/compiler-binding-linux-arm64-musl": "0.2.3", - "@astrojs/compiler-binding-linux-x64-gnu": "0.2.3", - "@astrojs/compiler-binding-linux-x64-musl": "0.2.3", - "@astrojs/compiler-binding-wasm32-wasi": "0.2.3", - "@astrojs/compiler-binding-win32-arm64-msvc": "0.2.3", - "@astrojs/compiler-binding-win32-x64-msvc": "0.2.3" + "@astrojs/compiler-binding-darwin-arm64": "0.3.1", + "@astrojs/compiler-binding-darwin-x64": "0.3.1", + "@astrojs/compiler-binding-linux-arm64-gnu": "0.3.1", + "@astrojs/compiler-binding-linux-arm64-musl": "0.3.1", + "@astrojs/compiler-binding-linux-x64-gnu": "0.3.1", + "@astrojs/compiler-binding-linux-x64-musl": "0.3.1", + "@astrojs/compiler-binding-wasm32-wasi": "0.3.1", + "@astrojs/compiler-binding-win32-arm64-msvc": "0.3.1", + "@astrojs/compiler-binding-win32-x64-msvc": "0.3.1" } }, "node_modules/@astrojs/compiler-binding-darwin-arm64": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-darwin-arm64/-/compiler-binding-darwin-arm64-0.2.3.tgz", - "integrity": "sha512-sJIHeL1ONXEBLob8ZaXfmX6iCftUno08G/cMXj2FJnL0xNbHuELcEq1mjxHVFHNgUYu4P7xJNm2mpc0zUEPoKw==", + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-darwin-arm64/-/compiler-binding-darwin-arm64-0.3.1.tgz", + "integrity": "sha512-IEmEF2fUIlTHtpeE/isyEGVOB14cEyh/LZOFYt6wn3jNyVpdC8aR5OZ+RzFUR/f+8ZDM1LaMwZKvoA7eMyJeFw==", "cpu": [ "arm64" ], @@ -50,9 +50,9 @@ } }, "node_modules/@astrojs/compiler-binding-darwin-x64": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-darwin-x64/-/compiler-binding-darwin-x64-0.2.3.tgz", - "integrity": "sha512-P0NYu6aaIeLCqFfszxxBHL0a5WRaYigNVbDoO654Gi5Q2au5duDb5xZBv5EqUg4qnQVC173FXNvGZu1M7nk+/w==", + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-darwin-x64/-/compiler-binding-darwin-x64-0.3.1.tgz", + "integrity": "sha512-GF2kIxjpPDLsn94zbZNMsxEmkU828QqnmM7kiQJnaooS3jmI+I7kk6+oI6EpwOsK3femCMdcm+wmOsEqtGrmjQ==", "cpu": [ "x64" ], @@ -66,9 +66,9 @@ } }, "node_modules/@astrojs/compiler-binding-linux-arm64-gnu": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-arm64-gnu/-/compiler-binding-linux-arm64-gnu-0.2.3.tgz", - "integrity": "sha512-PqVN5AqhuDqfx3ejaerwrC8codpV9jnyKV+IOel027qsJ1anFUJLdjUlY8VVys0xgd8lmqveX11OkcaQj/otTg==", + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-arm64-gnu/-/compiler-binding-linux-arm64-gnu-0.3.1.tgz", + "integrity": "sha512-XJL3SDmOtVrqFhCirNcHwE91+IesJqlgNo23I4qW9QUYfwzm/TBZuH61fgqsb1ttgR1mMYz6ooPWs0JDhwMqpQ==", "cpu": [ "arm64" ], @@ -82,9 +82,9 @@ } }, "node_modules/@astrojs/compiler-binding-linux-arm64-musl": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-arm64-musl/-/compiler-binding-linux-arm64-musl-0.2.3.tgz", - "integrity": "sha512-O3e2CbN4yTsRguWYNnRd0p5YQ0H3fb7KpcR0W4R319q/gq5B1pJ7eqNbiO3b8g2AuiEcRTiUz5jeGT9j69cxOQ==", + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-arm64-musl/-/compiler-binding-linux-arm64-musl-0.3.1.tgz", + "integrity": "sha512-xqE8BVbDoBueK/B47w30PtkVofUWJKGkwoMVE+EOMLf11rnoANxIAdA9FPqY+rng4oNI5ndHGsri1yPj2k8vZQ==", "cpu": [ "arm64" ], @@ -98,9 +98,9 @@ } }, "node_modules/@astrojs/compiler-binding-linux-x64-gnu": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-x64-gnu/-/compiler-binding-linux-x64-gnu-0.2.3.tgz", - "integrity": "sha512-hbLBjXVp+96psMe7/7uqyrquGiULXANrq6REVxxPK/I5VzebZ7LHmSfykmByUbLyR1u+K6CTBKgvdQsK2L+2Xw==", + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-x64-gnu/-/compiler-binding-linux-x64-gnu-0.3.1.tgz", + "integrity": "sha512-1y0StU1qiCuDFH3rmbRJXcxdfHxFPrES1Rd+RLffosvUR7I2cH5SF5SFnBN9vXpzpkmyElZm3Yr47iJBPN7vVA==", "cpu": [ "x64" ], @@ -114,9 +114,9 @@ } }, "node_modules/@astrojs/compiler-binding-linux-x64-musl": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-x64-musl/-/compiler-binding-linux-x64-musl-0.2.3.tgz", - "integrity": "sha512-vIiEvOwrJfHZMaTmqUCrFTIwMYL0+PD3Rvy7kFDQgERyx3zhaw8CPa01MCCqa+/sj344BGrXKZ6ti37SgNLMhw==", + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-x64-musl/-/compiler-binding-linux-x64-musl-0.3.1.tgz", + "integrity": "sha512-16q0fYf7kpbmdObZEeZJEup8hQv/whgNwVjrSvT8umrKwLDSnNIWiQpm09lQQu6bweZB0XyIvHwlPitvJhC+hg==", "cpu": [ "x64" ], @@ -130,9 +130,9 @@ } }, "node_modules/@astrojs/compiler-binding-wasm32-wasi": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-wasm32-wasi/-/compiler-binding-wasm32-wasi-0.2.3.tgz", - "integrity": "sha512-p9S2X8z/mUR2SMzAVJRFMCt8YaalKR+pjl2DgpdjzCQc6ww4bo8kiy54tgKqxZeNF5c+/2tCDTQIxVSm9V1FsA==", + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-wasm32-wasi/-/compiler-binding-wasm32-wasi-0.3.1.tgz", + "integrity": "sha512-cB456shIwDv/PrVT+2QG7LFndpHkVge5HjqADKZgGaAc9JHVktCtjSrcdkRQ+3tbkPazNKaTLRjXLIiz2NIx9g==", "cpu": [ "wasm32" ], @@ -146,9 +146,9 @@ } }, "node_modules/@astrojs/compiler-binding-win32-arm64-msvc": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-win32-arm64-msvc/-/compiler-binding-win32-arm64-msvc-0.2.3.tgz", - "integrity": "sha512-vcCG6JttIb5vbSmcxO2O398hpVj7lQ349iS7cjgYP6ZuLVEnw+9qPAr2MM2kJkU5wEGZqJ2gyi/M7UJoPwH1iQ==", + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-win32-arm64-msvc/-/compiler-binding-win32-arm64-msvc-0.3.1.tgz", + "integrity": "sha512-ur/9+If/yTE69mmeX5MqSZndL0HOyx67GeNZUy3N7wVdWpLz9UTJXwyWS4UR2PUQHitghjsM5xoX0Ge56WRVQQ==", "cpu": [ "arm64" ], @@ -162,9 +162,9 @@ } }, "node_modules/@astrojs/compiler-binding-win32-x64-msvc": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-win32-x64-msvc/-/compiler-binding-win32-x64-msvc-0.2.3.tgz", - "integrity": "sha512-hKssjNvC36e00Inb1GW1JsVyCFSCGnIjKem4S8q0VIW6cpWAUpvYB4qQU2HIDGD6SDX0ork4F5sWkNWkp2hrGQ==", + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-win32-x64-msvc/-/compiler-binding-win32-x64-msvc-0.3.1.tgz", + "integrity": "sha512-k0W+kDBzDkNZOqu4kElDvCOIbKw5Ut9S1WZ1Krj3KTgNuBERNKXsMMsRLLcbgfdMdbe7bTekQLshZrrvmYpmwA==", "cpu": [ "x64" ], @@ -178,12 +178,15 @@ } }, "node_modules/@astrojs/compiler-rs": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@astrojs/compiler-rs/-/compiler-rs-0.2.3.tgz", - "integrity": "sha512-JRAtRcPxS4JeAZEIQFQ6GecBs/Wyp4m6/E8vBNxSgVfo1AtRVLUqRCl5oCGOZ0X/BSBB3Vef/7IlzyiGKi2ORA==", + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-rs/-/compiler-rs-0.3.1.tgz", + "integrity": "sha512-aT7xkgsbNoS6nriY5qKpbihK43slFHO41iqgHCTdOvn1ifaQxLCc5yXy+6GzAtiafoaC1zA7OwVXCXMsvUZOkg==", "license": "MIT", "dependencies": { - "@astrojs/compiler-binding": "0.2.3" + "@astrojs/compiler-binding": "0.3.1" + }, + "engines": { + "node": ">=22.12.0" } }, "node_modules/@astrojs/internal-helpers": { @@ -228,17 +231,34 @@ } }, "node_modules/@astrojs/markdown-satteri": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@astrojs/markdown-satteri/-/markdown-satteri-0.3.2.tgz", - "integrity": "sha512-feXuUPy41gVfeM7EHT1ciUim8ozGr+YHXab9uUBc1Hk8y60DQosO8ldL+AoPXnCAoGj1OChwHfvXmmJ6XVnY9A==", + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@astrojs/markdown-satteri/-/markdown-satteri-0.3.4.tgz", + "integrity": "sha512-6Lvt/bQZEBW+zzdhPblvfZEy5PGEYJaUsUqaCgwHeRPxZJL1gc9I+DRLKWJjjYTWDzVUTzXlMq4WwSK+X34CVw==", "license": "MIT", "dependencies": { - "@astrojs/internal-helpers": "0.10.0", + "@astrojs/internal-helpers": "0.10.1", "@astrojs/prism": "4.0.2", "github-slugger": "^2.0.0", + "hast-util-from-html": "^2.0.3", "satteri": "^0.9.1" } }, + "node_modules/@astrojs/markdown-satteri/node_modules/@astrojs/internal-helpers": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@astrojs/internal-helpers/-/internal-helpers-0.10.1.tgz", + "integrity": "sha512-5phcroT/vmOOrYuuAxtkbPixy5hePtlz9i8K4OeDv3dNK6/UQRuXPOSRTxIOBbUY5Sonw2UaxjbuVc43Mcir6Q==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.4", + "@types/mdast": "^4.0.4", + "js-yaml": "^4.1.1", + "picomatch": "^4.0.4", + "retext-smartypants": "^6.2.0", + "shiki": "^4.0.2", + "smol-toml": "^1.6.0", + "unified": "^11.0.5" + } + }, "node_modules/@astrojs/mdx": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/@astrojs/mdx/-/mdx-7.0.0.tgz", @@ -343,16 +363,15 @@ } }, "node_modules/@astrojs/telemetry": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/@astrojs/telemetry/-/telemetry-3.3.2.tgz", - "integrity": "sha512-j8DNruA8ors99Al39RYZPJK4DC1bKkoNm93mAMuBhY9TCNC4R8n1q7ovFnJ5qhGh5Lsh7pa1gpQVpYpsJPeTHQ==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@astrojs/telemetry/-/telemetry-3.3.3.tgz", + "integrity": "sha512-C1TLn5sPJr0x4vk56piHWKbnqlEB8BKyte5Y45V02U+D7BGO5eMqZDH5aPjnkXQWJggvmsTXxH03QMZ9NgWLzQ==", "license": "MIT", "dependencies": { "ci-info": "^4.4.0", "dset": "^3.1.4", "is-docker": "^4.0.0", - "is-wsl": "^3.1.1", - "which-pm-runs": "^1.1.0" + "package-manager-detector": "^1.6.0" }, "engines": { "node": "18.20.8 || ^20.3.0 || >=22.0.0" @@ -1059,9 +1078,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", + "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", "cpu": [ "arm64" ], @@ -1071,19 +1090,19 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" + "@img/sharp-libvips-darwin-arm64": "1.3.2" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", + "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", "cpu": [ "x64" ], @@ -1093,19 +1112,38 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" + "@img/sharp-libvips-darwin-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", + "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", + "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", "cpu": [ "arm64" ], @@ -1119,9 +1157,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", - "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", + "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", "cpu": [ "x64" ], @@ -1135,9 +1173,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", - "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", + "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", "cpu": [ "arm" ], @@ -1151,9 +1189,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", - "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", + "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", "cpu": [ "arm64" ], @@ -1167,9 +1205,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", - "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", + "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", "cpu": [ "ppc64" ], @@ -1183,9 +1221,9 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", - "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", + "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", "cpu": [ "riscv64" ], @@ -1199,9 +1237,9 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", - "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", + "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", "cpu": [ "s390x" ], @@ -1215,9 +1253,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", - "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", + "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", "cpu": [ "x64" ], @@ -1231,9 +1269,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", - "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", + "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", "cpu": [ "arm64" ], @@ -1247,9 +1285,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", - "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", + "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", "cpu": [ "x64" ], @@ -1263,9 +1301,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", - "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", + "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", "cpu": [ "arm" ], @@ -1275,19 +1313,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" + "@img/sharp-libvips-linux-arm": "1.3.2" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", - "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", + "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", "cpu": [ "arm64" ], @@ -1297,19 +1335,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" + "@img/sharp-libvips-linux-arm64": "1.3.2" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", - "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", + "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", "cpu": [ "ppc64" ], @@ -1319,19 +1357,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.4" + "@img/sharp-libvips-linux-ppc64": "1.3.2" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", - "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", + "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", "cpu": [ "riscv64" ], @@ -1341,19 +1379,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.2.4" + "@img/sharp-libvips-linux-riscv64": "1.3.2" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", - "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", + "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", "cpu": [ "s390x" ], @@ -1363,19 +1401,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.4" + "@img/sharp-libvips-linux-s390x": "1.3.2" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", + "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", "cpu": [ "x64" ], @@ -1385,19 +1423,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" + "@img/sharp-libvips-linux-x64": "1.3.2" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", - "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", + "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", "cpu": [ "arm64" ], @@ -1407,19 +1445,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", + "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", "cpu": [ "x64" ], @@ -1429,38 +1467,54 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + "@img/sharp-libvips-linuxmusl-x64": "1.3.2" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", - "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", + "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", + "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", "cpu": [ "wasm32" ], - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "license": "Apache-2.0", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.7.0" + "@img/sharp-wasm32": "0.35.3" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", - "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", + "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", "cpu": [ "arm64" ], @@ -1470,16 +1524,16 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", - "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", + "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", "cpu": [ "ia32" ], @@ -1489,16 +1543,16 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": "^20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", - "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", + "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", "cpu": [ "x64" ], @@ -1508,7 +1562,7 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" @@ -2271,15 +2325,15 @@ } }, "node_modules/astro": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/astro/-/astro-7.0.3.tgz", - "integrity": "sha512-CK+G+Tl2DMV1EXCwVG45vyurxf2IfRTklMxDhRKn+tst9Yl8rWXpudL62Fa6zin5Bt968FBvuyASj1aJShROZg==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/astro/-/astro-7.1.3.tgz", + "integrity": "sha512-4dhPyAAXthf3xLEYnG8SeL7yr/nTPPABfY7e9YF0yuO+vK9Xp+8Q5j4xzsmL3GueukQv4oNwGNTBepLOiDGeJA==", "license": "MIT", "dependencies": { - "@astrojs/compiler-rs": "^0.2.2", - "@astrojs/internal-helpers": "0.10.0", - "@astrojs/markdown-satteri": "0.3.2", - "@astrojs/telemetry": "3.3.2", + "@astrojs/compiler-rs": "^0.3.1", + "@astrojs/internal-helpers": "0.10.1", + "@astrojs/markdown-satteri": "0.3.4", + "@astrojs/telemetry": "3.3.3", "@capsizecss/unpack": "^4.0.0", "@clack/prompts": "^1.1.0", "@oslojs/encoding": "^1.1.0", @@ -2290,7 +2344,7 @@ "ci-info": "^4.4.0", "clsx": "^2.1.1", "common-ancestor-path": "^2.0.0", - "cookie": "^1.1.1", + "cookie": "^2.0.1", "devalue": "^5.8.1", "diff": "^8.0.3", "dset": "^3.1.4", @@ -2307,14 +2361,13 @@ "magic-string": "^0.30.21", "magicast": "^0.5.2", "mrmime": "^2.0.1", - "neotraverse": "^0.6.18", + "neotraverse": "^1.0.1", "obug": "^2.1.1", "p-limit": "^7.3.0", "p-queue": "^9.1.0", "package-manager-detector": "^1.6.0", "piccolore": "^0.1.3", "picomatch": "^4.0.4", - "rehype": "^13.0.2", "semver": "^7.7.4", "shiki": "^4.0.2", "smol-toml": "^1.6.0", @@ -2324,9 +2377,7 @@ "tinyglobby": "^0.2.15", "ultrahtml": "^1.6.0", "unifont": "~0.7.4", - "unist-util-visit": "^5.1.0", "unstorage": "^1.17.5", - "vfile": "^6.0.3", "vite": "^8.0.13", "vitefu": "^1.1.2", "xxhash-wasm": "^1.1.0", @@ -2346,10 +2397,10 @@ "url": "https://opencollective.com/astrodotbuild" }, "optionalDependencies": { - "sharp": "^0.34.0" + "sharp": "^0.34.0 || ^0.35.0" }, "peerDependencies": { - "@astrojs/markdown-remark": "7.2.0" + "@astrojs/markdown-remark": "7.2.1" }, "peerDependenciesMeta": { "@astrojs/markdown-remark": { @@ -2370,6 +2421,22 @@ "astro": "^4.0.0-beta || ^5.0.0-beta || ^3.3.0 || ^6.0.0-beta || ^7.0.0" } }, + "node_modules/astro/node_modules/@astrojs/internal-helpers": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@astrojs/internal-helpers/-/internal-helpers-0.10.1.tgz", + "integrity": "sha512-5phcroT/vmOOrYuuAxtkbPixy5hePtlz9i8K4OeDv3dNK6/UQRuXPOSRTxIOBbUY5Sonw2UaxjbuVc43Mcir6Q==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.4", + "@types/mdast": "^4.0.4", + "js-yaml": "^4.1.1", + "picomatch": "^4.0.4", + "retext-smartypants": "^6.2.0", + "shiki": "^4.0.2", + "smol-toml": "^1.6.0", + "unified": "^11.0.5" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -2548,12 +2615,12 @@ } }, "node_modules/cookie": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", - "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-2.0.1.tgz", + "integrity": "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==", "license": "MIT", "engines": { - "node": ">=18" + "node": ">=22" }, "funding": { "type": "opencollective", @@ -3670,39 +3737,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/is-inside-container": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", - "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", - "license": "MIT", - "dependencies": { - "is-docker": "^3.0.0" - }, - "bin": { - "is-inside-container": "cli.js" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-inside-container/node_modules/is-docker": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", - "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", - "license": "MIT", - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-plain-obj": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", @@ -3715,25 +3749,10 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-wsl": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", - "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", - "license": "MIT", - "dependencies": { - "is-inside-container": "^1.0.0" - }, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", "funding": [ { "type": "github", @@ -5158,9 +5177,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.15", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", - "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "funding": [ { "type": "github", @@ -5176,9 +5195,9 @@ } }, "node_modules/neotraverse": { - "version": "0.6.18", - "resolved": "https://registry.npmjs.org/neotraverse/-/neotraverse-0.6.18.tgz", - "integrity": "sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/neotraverse/-/neotraverse-1.0.1.tgz", + "integrity": "sha512-WmmLty1YWwJl9yZi77v2dVIV6X2kuYV8YYBI/G3LWGKdGHmHUvL1z7FW0iDvEvGAwNEoc5x1tOOOyDnf5jJw/w==", "license": "MIT", "engines": { "node": ">= 10" @@ -5421,9 +5440,9 @@ } }, "node_modules/postcss": { - "version": "8.5.16", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", - "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", "funding": [ { "type": "opencollective", @@ -5440,7 +5459,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -5969,9 +5988,9 @@ } }, "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -5981,47 +6000,52 @@ } }, "node_modules/sharp": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", - "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", - "hasInstallScript": true, + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", + "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", "license": "Apache-2.0", "dependencies": { - "@img/colour": "^1.0.0", + "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", - "semver": "^7.7.3" + "semver": "^7.8.5" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.5", - "@img/sharp-darwin-x64": "0.34.5", - "@img/sharp-libvips-darwin-arm64": "1.2.4", - "@img/sharp-libvips-darwin-x64": "1.2.4", - "@img/sharp-libvips-linux-arm": "1.2.4", - "@img/sharp-libvips-linux-arm64": "1.2.4", - "@img/sharp-libvips-linux-ppc64": "1.2.4", - "@img/sharp-libvips-linux-riscv64": "1.2.4", - "@img/sharp-libvips-linux-s390x": "1.2.4", - "@img/sharp-libvips-linux-x64": "1.2.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", - "@img/sharp-libvips-linuxmusl-x64": "1.2.4", - "@img/sharp-linux-arm": "0.34.5", - "@img/sharp-linux-arm64": "0.34.5", - "@img/sharp-linux-ppc64": "0.34.5", - "@img/sharp-linux-riscv64": "0.34.5", - "@img/sharp-linux-s390x": "0.34.5", - "@img/sharp-linux-x64": "0.34.5", - "@img/sharp-linuxmusl-arm64": "0.34.5", - "@img/sharp-linuxmusl-x64": "0.34.5", - "@img/sharp-wasm32": "0.34.5", - "@img/sharp-win32-arm64": "0.34.5", - "@img/sharp-win32-ia32": "0.34.5", - "@img/sharp-win32-x64": "0.34.5" + "@img/sharp-darwin-arm64": "0.35.3", + "@img/sharp-darwin-x64": "0.35.3", + "@img/sharp-freebsd-wasm32": "0.35.3", + "@img/sharp-libvips-darwin-arm64": "1.3.2", + "@img/sharp-libvips-darwin-x64": "1.3.2", + "@img/sharp-libvips-linux-arm": "1.3.2", + "@img/sharp-libvips-linux-arm64": "1.3.2", + "@img/sharp-libvips-linux-ppc64": "1.3.2", + "@img/sharp-libvips-linux-riscv64": "1.3.2", + "@img/sharp-libvips-linux-s390x": "1.3.2", + "@img/sharp-libvips-linux-x64": "1.3.2", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", + "@img/sharp-libvips-linuxmusl-x64": "1.3.2", + "@img/sharp-linux-arm": "0.35.3", + "@img/sharp-linux-arm64": "0.35.3", + "@img/sharp-linux-ppc64": "0.35.3", + "@img/sharp-linux-riscv64": "0.35.3", + "@img/sharp-linux-s390x": "0.35.3", + "@img/sharp-linux-x64": "0.35.3", + "@img/sharp-linuxmusl-arm64": "0.35.3", + "@img/sharp-linuxmusl-x64": "0.35.3", + "@img/sharp-webcontainers-wasm32": "0.35.3", + "@img/sharp-win32-arm64": "0.35.3", + "@img/sharp-win32-ia32": "0.35.3", + "@img/sharp-win32-x64": "0.35.3" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/shiki": { @@ -6147,9 +6171,9 @@ } }, "node_modules/svgo": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.0.1.tgz", - "integrity": "sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.0.2.tgz", + "integrity": "sha512-ekx94z1rRc5LDi6oSUaeRnYhd0UOJxdtQCL2rF8xpWxD3TPAsISWOrxezqGovqS38GRZOdpDfvQe3ts6F7nsng==", "license": "MIT", "dependencies": { "commander": "^11.1.0", @@ -6690,15 +6714,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/which-pm-runs": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/which-pm-runs/-/which-pm-runs-1.1.0.tgz", - "integrity": "sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/xxhash-wasm": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz", diff --git a/docs/package.json b/docs/package.json index ebee0797..9858f54e 100644 --- a/docs/package.json +++ b/docs/package.json @@ -11,7 +11,7 @@ }, "dependencies": { "@astrojs/starlight": "^0.41.1", - "astro": "^7.0.3", - "sharp": "^0.34.5" + "astro": "^7.1.3", + "sharp": "^0.35.3" } } diff --git a/docs/src/assets/screenshots/add-to-existing-stack.png b/docs/src/assets/screenshots/add-to-existing-stack.png index 212614b0..7e28a397 100644 Binary files a/docs/src/assets/screenshots/add-to-existing-stack.png and b/docs/src/assets/screenshots/add-to-existing-stack.png differ diff --git a/docs/src/assets/screenshots/modify-stack-tui.png b/docs/src/assets/screenshots/modify-stack-tui.png index b13b870f..fb3c2474 100644 Binary files a/docs/src/assets/screenshots/modify-stack-tui.png and b/docs/src/assets/screenshots/modify-stack-tui.png differ diff --git a/docs/src/assets/screenshots/newly-created-stack.png b/docs/src/assets/screenshots/newly-created-stack.png index cd43ec4c..efdc4927 100644 Binary files a/docs/src/assets/screenshots/newly-created-stack.png and b/docs/src/assets/screenshots/newly-created-stack.png differ diff --git a/docs/src/assets/screenshots/stack-merge-box.png b/docs/src/assets/screenshots/stack-merge-box.png index 2a4b3550..6e967bad 100644 Binary files a/docs/src/assets/screenshots/stack-merge-box.png and b/docs/src/assets/screenshots/stack-merge-box.png differ diff --git a/docs/src/assets/screenshots/stack-navigator.png b/docs/src/assets/screenshots/stack-navigator.png index 859111a9..7349c599 100644 Binary files a/docs/src/assets/screenshots/stack-navigator.png and b/docs/src/assets/screenshots/stack-navigator.png differ diff --git a/docs/src/assets/screenshots/stack-recommendation-banner.png b/docs/src/assets/screenshots/stack-recommendation-banner.png new file mode 100644 index 00000000..9849e109 Binary files /dev/null and b/docs/src/assets/screenshots/stack-recommendation-banner.png differ diff --git a/docs/src/assets/screenshots/stack-recommendation-dialog-add.png b/docs/src/assets/screenshots/stack-recommendation-dialog-add.png new file mode 100644 index 00000000..9442d692 Binary files /dev/null and b/docs/src/assets/screenshots/stack-recommendation-dialog-add.png differ diff --git a/docs/src/assets/screenshots/stack-recommendation-dialog-create.png b/docs/src/assets/screenshots/stack-recommendation-dialog-create.png new file mode 100644 index 00000000..fda745c6 Binary files /dev/null and b/docs/src/assets/screenshots/stack-recommendation-dialog-create.png differ diff --git a/docs/src/assets/screenshots/stacked-prs.png b/docs/src/assets/screenshots/stacked-prs.png index b185f702..4d9ea7ed 100644 Binary files a/docs/src/assets/screenshots/stacked-prs.png and b/docs/src/assets/screenshots/stacked-prs.png differ diff --git a/docs/src/assets/screenshots/unstack-entire-stack.png b/docs/src/assets/screenshots/unstack-entire-stack.png index b46da86a..7f6c3263 100644 Binary files a/docs/src/assets/screenshots/unstack-entire-stack.png and b/docs/src/assets/screenshots/unstack-entire-stack.png differ diff --git a/docs/src/content/docs/faq.md b/docs/src/content/docs/faq.md index 890c5208..37e1b2c0 100644 --- a/docs/src/content/docs/faq.md +++ b/docs/src/content/docs/faq.md @@ -7,7 +7,7 @@ description: Frequently asked questions about GitHub Stacked PRs. ### What is a Stacked PR? How is it different from a regular PR? -A Stacked PR is a pull request that is part of an ordered chain of PRs, where each PR targets the branch of the PR below it instead of targeting `main` directly. Each PR in the stack represents one focused layer of a larger change. Individually, each PR is still a regular pull request — it just has a different base branch, and GitHub understands the relationship between the PRs in the stack. +A Stacked PR is a pull request that is part of an ordered chain of PRs, where each PR targets the branch of the PR below it instead of targeting the merge target directly. Each PR in the stack represents one focused layer of a larger change. Individually, each PR is still a regular pull request — it just has a different base branch, and GitHub understands the relationship between the PRs in the stack. ### How do I create a Stacked PR? @@ -25,11 +25,17 @@ gh stack submit You can also create stacks entirely from the GitHub UI — create the first PR normally, then when creating subsequent PRs, select the option to add them to a stack. See [Creating a Stack from the UI](/gh-stack/guides/ui/#creating-a-stack-from-the-ui) for a walkthrough. +If you already have open PRs whose branches line up, GitHub will detect and suggest turning them into a stack. See [Turning Existing PRs into a Stack](/gh-stack/guides/ui/#turning-existing-prs-into-a-stack). + ### How do I add PRs to my stack? Use `gh stack add ` to add a new branch on top of the current stack. When you run `gh stack submit`, a PR is created for each branch, and they are linked together as a Stack on GitHub. -You can also add PRs to an existing stack from the GitHub UI. See [Adding to an Existing Stack](/gh-stack/guides/ui/#adding-to-an-existing-stack) for details. +You can also add PRs to an existing stack from the GitHub UI — either a brand-new PR or an already-open PR (via the recommendation banner), added to the top of the stack. See [Adding to an Existing Stack](/gh-stack/guides/ui/#adding-to-an-existing-stack) for details. + +### How many PRs can a stack contain? + +A stack can contain up to **100 pull requests**. If your work requires more than 100 PRs, split it into multiple stacks. ### How can I modify my stack? @@ -52,12 +58,23 @@ gh stack init db-migrations api-routes frontend **From the CLI** — Run `gh stack unstack` (or `gh stack delete`) to delete the stack on GitHub and remove local tracking. You can also unstack any stack by its number from anywhere in the repository — `gh stack unstack 7` — whether or not it's checked out locally. Use `--local` to only remove local tracking. -**From the UI** — You can unstack PRs from the GitHub UI — see [Unstacking](/gh-stack/guides/ui/#unstacking) for a walkthrough. This dissolves the association between PRs, turning them back into standard independent PRs. +**From the UI** — You can unstack PRs from the GitHub UI — see [Unstacking](/gh-stack/guides/ui/#unstacking) for a walkthrough. This dissolves the association between the PRs, turning them back into standard independent PRs. + +Unstacking only removes **open, draft, and closed** PRs from the stack. **Merged and queued PRs remain part of the stack** — once a PR has merged (or is queued for merge) as part of a stack, it can't be unstacked. A stack is fully dissolved only when none of its PRs have merged or are queued for merge; otherwise it persists with those PRs still in it. ### Can stacks be created across forks? No, Stacked PRs currently require all branches to be in the same repository. Cross-fork stacks are not supported. +### Can a stack target a branch other than my default branch? + +Yes. A stack's **trunk** (the base branch of the bottom PR) can be any branch in the repository, such as a release branch or a long-lived feature branch. It defaults to your repository's default branch (e.g., `main`), but you can pick a different one: + +- **CLI** — pass `--base ` to `gh stack init` or `gh stack link` (for example, `gh stack init --base release`). +- **Web** — create the bottom PR against whatever branch you want as the trunk; the rest of the stack chains on top of it. + +The same behavior applies to whatever trunk your stack targets — branch protection rules, required checks, and CI are all evaluated against your stack's base branch. + ## Checks, Rules & Requirements ### How are branch protection rules evaluated for Stacked PRs? @@ -75,7 +92,7 @@ GitHub Actions workflows trigger as if each PR in the stack is targeting the bas ### How do I access stack metadata in my GitHub Actions workflow? -For advanced use cases, you can access the stack's base ref and base SHA in workflow expressions via `github.event.pull_request.stack`. This property is only present when the PR belongs to a stack. +For advanced use cases, you can access the stack's metadata in workflow expressions via `github.event.pull_request.stack`. This property is only present when the PR belongs to a stack. ```yaml jobs: @@ -89,6 +106,7 @@ jobs: run: | echo "Stack base ref: ${{ github.event.pull_request.stack.base.ref }}" echo "Stack base SHA: ${{ github.event.pull_request.stack.base.sha }}" + echo "PR ${{ github.event.pull_request.stack.position }} of ${{ github.event.pull_request.stack.size }} in the stack" - name: Run a step only when the stack targets a release branch if: github.event.pull_request.stack != null && startsWith(github.event.pull_request.stack.base.ref, 'release/') @@ -97,10 +115,46 @@ jobs: | Expression | Description | |------------|-------------| +| `github.event.pull_request.stack.number` | The stack's number, scoped to the repository. | +| `github.event.pull_request.stack.size` | Total number of pull requests in the stack. | +| `github.event.pull_request.stack.position` | 1-based position of this PR within the stack (`1` is the bottom). | | `github.event.pull_request.stack.base.ref` | The branch the entire stack ultimately targets (e.g., `main`). | -| `github.event.pull_request.stack.base.sha` | The HEAD SHA of that target branch at the time of the event. | +| `github.event.pull_request.stack.base.sha` | The HEAD SHA of the stack's base branch. | + +See the [Webhooks reference](/gh-stack/reference/webhooks/) for the full details on the `stack` object in webhook payloads, or the [REST API reference](/gh-stack/reference/rest-api/) to read the same object on demand from a pull request. -See the [Webhooks reference](/gh-stack/reference/webhooks/) for the full details on the `stack` object in webhook payloads. +### Why isn't the `stack` object in my `pull_request.opened` webhook? + +A pull request is always **created before it's added to a stack**, so the `pull_request.opened` event never includes the `stack` object — at that moment the PR isn't part of any stack yet. The same is true for any other event that fires before the PR joins a stack. + +To learn exactly when a PR becomes part of a stack, listen for the `pull_request` event with the **`stacked`** action. It fires when a PR is added to a stack and carries the `stack` object. See the [`stacked` event](/gh-stack/reference/webhooks/#the-stacked-event) in the Webhooks reference for the full payload. + +### How can I optimize CI usage for a stack? + +Because a workflow runs for every PR in a stack, a large stack can multiply your CI usage. You can use the `stack` fields to selectively run jobs based on the position of the current PR in the stack. + +Two conditions are especially useful for deciding where a job should run: + +- **Lowest unmerged PR** — the PR currently at the bottom of the remaining stack. Because it targets the stack base directly, `github.event.pull_request.stack.base.ref` equals `github.event.pull_request.base.ref`. +- **Top PR** — the last PR in the stack, containing the full set of changes. It's the PR where `github.event.pull_request.stack.position` equals `github.event.pull_request.stack.size`. + +```yaml +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Run for the lowest unmerged PR in the stack + if: github.event.pull_request.stack != null && github.event.pull_request.stack.base.ref == github.event.pull_request.base.ref + run: echo "Lowest unmerged PR in the stack" + + - name: Run for the top PR in the stack + if: github.event.pull_request.stack != null && github.event.pull_request.stack.position == github.event.pull_request.stack.size + run: echo "Top PR in the stack" +``` + +As PRs merge from the bottom up, the lowest unmerged PR changes: once the bottom PR lands, the next PR is rebased to target the stack base directly, so it becomes the new lowest unmerged PR on the following workflow run. You can also gate on the original bottom PR with `github.event.pull_request.stack.position == 1`, or on any specific layer using `position`. ### Do all previous PRs need to be passing checks before I can merge? @@ -121,13 +175,21 @@ If the stack is not linear (e.g., after changes were pushed to a lower branch), Every PR in a stack must meet the same merge requirements as a PR targeting the stack base (e.g., `main`): required reviews, passing CI checks, CODEOWNER approvals, and a linear history. All PRs below it must also meet these requirements. See the [Checks, Rules & Requirements](#checks-rules--requirements) section above for details. +### Can I bypass the rules to merge a Stacked PR? + +Not yet — bypassing rules is coming soon, but currently unavailable for stacked PRs. You can't bypass a stack's branch protection rules or rulesets to merge it before its requirements are met, so every PR in the stack must satisfy its rules and required checks before the stack can land. + +### Can I enable auto-merge on a Stacked PR? + +Not yet — auto-merge is coming soon, but currently unavailable for stacked PRs, for both direct merges and the merge queue. You can't set a PR in a stack to land automatically once its requirements are met. Until then, merge the stack (or the part you want to land) yourself once its PRs are ready. + ### How does merging a stack of PRs differ from merging a regular PR? -Stacks merge from the bottom up as a single atomic operation. When you click merge on a PR in a stack, that PR and all unmerged PRs below it land on the base branch together. PRs above remain open, and the remaining stack is automatically rebased so the next PR targets `main` directly. +Stacks merge from the bottom up. When you click merge on a PR in a stack, that PR and all unmerged PRs below it land on the base branch together; PRs above remain open, and the remaining stack is automatically rebased so the next PR targets your base branch directly. With a direct merge the group lands as a single atomic operation; through a merge queue the PRs enter the queue together and are evaluated individually, from the bottom up. ### What happens when you merge a PR in the middle of the stack? -When you click merge on a PR in the middle of the stack, that PR and all unmerged PRs below it land on the base branch together as a single atomic operation, ordered from the bottom up in the resulting history. PRs above the selected one remain open. After the merge, the lowest unmerged PR is updated to target the stack base directly, and a cascading rebase runs across the remaining branches. +When you click merge on a PR in the middle of the stack, that PR and all unmerged PRs below it land on the base branch together, ordered from the bottom up in the resulting history. PRs above the selected one remain open. After the merge, the lowest unmerged PR is updated to target the stack base directly, and a cascading rebase runs across the remaining branches. It is not possible to merge a middle PR in isolation: the PRs below it always merge with it. @@ -175,13 +237,14 @@ When you merge a stack using the merge commit strategy, it creates **one merge c ### How does rebase merge work? -With rebase merge, the commits from each PR in the stack are replayed onto the base branch, creating a linear history without merge commits. The full set of commits lands as a single atomic operation. +With rebase merge, the commits from each PR in the stack are replayed onto the base branch, creating a linear history without merge commits. The full set of commits lands on the base branch. ### Do all PRs get merged at once or one at a time? -All PRs in the stack land in a single atomic operation. When you click merge on a PR, that PR and all unmerged PRs below it are merged together onto the base branch at the same time, ordered from the bottom up in the resulting history. PRs above the selected one remain open. +It depends on the merge method: -This applies whether or not a merge queue is enabled. With a merge queue, the same atomic landing happens once the stack's merge group reaches the front of the queue. +- **Direct merge** — All the included PRs land in a single atomic operation. The selected PR and every unmerged PR below it are merged together onto the base branch at once, ordered from the bottom up. Either the whole group lands, or if any part fails, none of it does. +- **Merge queue** — The PRs enter the queue together and are evaluated individually, from the bottom up. If a PR fails while in the queue, it and all its descendants are ejected, while the PRs below it are unaffected. ### Can I merge only part of a stack? What happens to the remaining unmerged PRs? @@ -197,15 +260,22 @@ Closing a PR in the middle of the stack will block all PRs above it from being m ### What happens when there is an error merging a PR in the middle of a stack? -Pre-merge checks run before any merge attempt, but a merge can still fail (e.g., due to an unexpected merge conflict or intermittent failure). If a failure occurs partway through, merging stops at that PR. PRs below it that successfully merged remain landed on the base branch; the failed PR and PRs above it stay open. Resolve the issue on the failed PR and retry to land the rest of the stack. +Pre-merge checks run before any merge attempt, but a merge can still fail (e.g., due to a merge conflict or intermittent failure). The behavior depends on the merge method: + +- **Direct merge** — The merge is atomic. If any part fails, the entire operation is rolled back and nothing is merged. +- **Merge queue** — Because each PR is evaluated individually, a failure ejects that PR and all its descendants (the PRs stacked above it) from the queue, while the PRs below it are unaffected. You can fix the issue on the failed PR and requeue the ejected PRs. ### Do Stacked PRs support merge queue? -Yes, Stacked PRs fully support merging via merge queue. When you merge a stack through the merge queue: +Yes, Stacked PRs fully support merging via GitHub merge queue. When you merge a stack through the merge queue: + +- **All PRs in the stack enter the queue together** in the correct order and are evaluated individually, from the bottom up. +- **If a PR is ejected from the merge queue** (for example, because it fails), that PR and all its descendants are ejected too, while the PRs below it are unaffected. +- **The queue makes a best-effort attempt to keep the stack together** in a single merge group. If the stack is too large to fit, it lands across consecutive merge groups: as much of the stack as fits goes into the current group, and the remaining PRs continue in subsequent groups until the full stack has landed. The stack order is preserved, so downstack PRs are merged before upstack PRs. + +### How do I merge a stack programmatically? -- **All PRs in the stack are added to the queue** in the correct order, ensuring a linear sequence. -- **If a PR is removed or ejected from the merge queue**, all PRs above it in the stack are also ejected and removed from the queue. -- **Stacks are kept in the same merge group on a best-effort basis.** To keep a stack together, the merge queue allows the merge group to exceed its configured max size by up to 50%. If the stack is too large to fit within that buffer, it splits across consecutive merge groups: as much of the stack as fits goes into the current group, and the remaining PRs continue in subsequent groups until the full stack has landed. +Stacks are merged through GitHub's asynchronous [Merge API](/gh-stack/reference/merge-api/). The legacy synchronous merge APIs (REST and GraphQL) do not support stack merges. You submit a merge request for a PR and poll for the result; every PR in the stack up to and including the one you request is merged into the base branch. The [`gh stack merge`](/gh-stack/reference/cli/#gh-stack-merge) CLI command is built on this API and is the easiest way to merge from the command line. ## Local Development diff --git a/docs/src/content/docs/guides/stacked-prs.md b/docs/src/content/docs/guides/stacked-prs.md index 4336ce44..04356f4f 100644 --- a/docs/src/content/docs/guides/stacked-prs.md +++ b/docs/src/content/docs/guides/stacked-prs.md @@ -5,7 +5,7 @@ description: Practical guide for reviewing, merging, and managing stacked pull r This guide covers the practical day-to-day experience of working with Stacked PRs — how to review them, how merging works step by step, and how to keep things in sync from the CLI. -For an introduction to what stacks are and how GitHub supports them natively, see the [Overview](/gh-stack/introduction/overview/). For a visual walkthrough of the UI, see [Stacked PRs in the GitHub UI](/gh-stack/guides/ui/). +For an introduction to what stacks are and how it works in GitHub, see the [Overview](/gh-stack/introduction/overview/). For a visual walkthrough of the UI, see [Stacked PRs in the GitHub UI](/gh-stack/guides/ui/). ## Reviewing Stacked PRs @@ -21,14 +21,17 @@ Each PR in a stack shows only the diff for its layer — the changes between its - **Review individual PRs** when you're focusing on a specific concern (e.g., reviewing only the API layer). - **Use the stack map** to navigate between PRs without going back to the PR list. -## Merging from the Bottom Up +## Merging a Stack -Stacks are merged **from the bottom up** — you can merge any number of PRs at once, as long as they form a contiguous group starting from the lowest unmerged PR. For example, in a stack of four PRs, you can merge just the bottom one, or the bottom three together, but you cannot merge only the second and third PRs while leaving the first unmerged. Mid-stack merges are not allowed. +Merging is driven by a single action: **click Merge on the highest PR you want to land, and that PR plus every unmerged PR below it are merged together, from the bottom up.** You do not need to merge PRs one at a time, unless you choose to. -1. When the lowest unmerged PR (and any PRs above it that you want to include) meet all merge requirements, merge them. -2. After the merge, the remaining stack is **automatically rebased** — the next unmerged PR's base is updated to target `main` directly. -3. The next unmerged PR is now at the bottom and can be reviewed, approved, and merged. -4. Repeat until the entire stack is landed. +- **To land the whole stack**, merge the **top** PR — every PR below it lands with it in a single step. +- **To land part of the stack**, merge a lower PR — the PRs below it come along, and the PRs above stay open. +- **To land a single PR**, merge the **bottom** PR - only that PR will be merged, and the rest of the PRs stay open. + +You can merge any contiguous group, as long as it starts from the lowest unmerged PR. In a stack of four PRs you can land just the bottom one, or the bottom three together, but you can't merge only the second and third while leaving the first unmerged — a PR always brings the unmerged PRs below it along. Merging a stacked PR always merges all the unmerged PRs below it as well. + +When you land only part of a stack, the remaining PRs are **automatically rebased** and retargeted so the next unmerged PR targets your base branch directly and is immediately ready to review and merge. Once the entire stack has landed, it is complete and can't be extended. If you add new branches on top and run `gh stack submit`, the CLI automatically starts a **new** stack rooted at the trunk for those branches (a new PR on a fully merged stack would target the trunk directly rather than chaining onto the merged PRs). diff --git a/docs/src/content/docs/guides/ui.md b/docs/src/content/docs/guides/ui.md index 0f4f9e48..53fd912c 100644 --- a/docs/src/content/docs/guides/ui.md +++ b/docs/src/content/docs/guides/ui.md @@ -7,9 +7,9 @@ This guide walks through the key UI components and workflows for working with St ## Navigating Stacked PRs -When a pull request is part of a stack, a **stack navigator** appears in the PR header. This component gives you an at-a-glance view of the entire stack and lets you jump between PRs. +When a pull request is part of a stack, a **stack map** appears in the PR header. This component gives you an at-a-glance view of the entire stack and lets you jump between PRs. -The stack navigator shows: +The stack map shows: - All PRs in the stack, listed in order from top to bottom - Which PR you're currently viewing (highlighted) @@ -17,7 +17,7 @@ The stack navigator shows: - Link to Add to Stack, where you can create a new PR that targets the head of the topmost PR - Unstack option to dissolve the association between PRs, turning them back into standard PRs -![The stack navigator in a PR header](../../../assets/screenshots/stack-navigator.png) +![The stack map in a PR header](../../../assets/screenshots/stack-navigator.png) ## Creating a Stack from the UI @@ -37,15 +37,37 @@ When you create the next PR, set its base branch to the first PR's branch. You'l ### Step 3: Confirm the stack -After creating the PR, you'll see the stack navigator appear in the header, showing both PRs linked together. +After creating the PR, you'll see the stack map appear in the header, showing both PRs linked together. -![The stack navigator showing the newly created stack](../../../assets/screenshots/newly-created-stack.png) +![The stack map showing the newly created stack](../../../assets/screenshots/newly-created-stack.png) Repeat this process for each additional PR in the stack — each one targets the branch of the PR before it. +## Turning Existing PRs into a Stack + +If you already have open PRs whose branches line up (each PR's base branch is the head branch of the PR below it), GitHub recognizes the chain and shows a **recommendation banner** offering to turn them into a stack. + +![Banner recommending that eligible PRs be turned into a stack](../../../assets/screenshots/stack-recommendation-banner.png) + +Click the banner to open a dialog that previews the stack, listing each PR in order from top to bottom. Review it and confirm to link the PRs together into a stack. + +![Dialog previewing the stack before it's created](../../../assets/screenshots/stack-recommendation-dialog-create.png) + +Once you confirm, the PRs are stacked and the stack map appears in each PR's header. + ## Adding to an Existing Stack -If a stack already exists and you want to add a new PR to it: +You can add a PR to the top of an existing stack either when you create the PR or after it already exists. + +### Add an existing PR + +If you already have an open PR whose base branch is the head branch of the stack's topmost PR, GitHub shows a **recommendation banner** on that PR, giving you an option to add it to the stack. Click it to preview and confirm, and the PR is added to the top of the existing stack. + +![Recommendation dialog for adding an existing PR to a stack](../../../assets/screenshots/stack-recommendation-dialog-add.png) + +### Create a new PR on the stack + +To create a brand-new PR directly on top of the stack: 1. Open a PR in the stack, click the stack icon in the header, and click **Add**. @@ -77,9 +99,13 @@ Before a PR in the stack can be merged, the following conditions must be met: ![Merge box for a stacked pull request](../../../assets/screenshots/stack-merge-box.png) +:::note[Rule bypass & auto-merge currently unsupported] +**Rule bypass** and **auto-merge** are coming soon, but currently unavailable for stacked PRs. You can't enable auto-merge on a PR in a stack, and you can't bypass a stack's rules to merge before its requirements are met. +::: + ### Rebasing from the UI -When the stack is not linear (e.g., after changes were pushed to a lower branch, or after `main` has moved ahead), a **Rebase Stack** button appears in the merge box. Clicking it triggers a server-side cascading rebase that: +When the stack is not linear (e.g., after changes were pushed to a lower branch, or after the trunk has moved ahead), a **Rebase Stack** button appears in the merge box. Clicking it triggers a server-side cascading rebase that: 1. Rebases the entire stack on top of the latest trunk (e.g., `main`) HEAD. 2. Rebases every unmerged branch on top of the latest changes from its base branch, working from the bottom of the stack upward. @@ -95,10 +121,12 @@ Commits created by a server-side rebase are **not signed**. If your repository r If you want to reorder or reorganize the PRs in a stack from the UI, you must first dissolve the stack and then re-create it. For CLI users, `gh stack modify` provides an interactive way to [restructure a stack](/gh-stack/guides/modify/) — including reordering, inserting, dropping, and renaming branches — without needing to dissolve it. -### Dissolving the Entire Stack +### Dissolving the Stack -To dissolve the stack entirely (turning all Stacked PRs back into independent PRs), use the unstack option on the stack itself. +To dissolve the stack, use the Unstack option on the stack. ![Dissolving an entire stack](../../../assets/screenshots/unstack-entire-stack.png) -After unstacking, each PR retains its current base branch but is no longer linked to the other PRs. The stack navigator and stack-related merge requirements disappear from all affected PRs. +Unstacking removes the **open, draft, and closed** PRs from the stack. Each of those PRs keeps its current base branch but is no longer linked to the others, and the stack map and stack-related merge requirements disappear from them. + +**Merged and queued PRs stay in the stack.** Once a PR has merged — or is queued for merge — as part of a stack, it remains part of that stack and can't be unstacked. So if every PR in the stack is open, draft, or closed, unstacking removes them all and the stack is dissolved entirely; if any PR has already merged or is queued for merge, the stack persists with those PRs still in it. diff --git a/docs/src/content/docs/guides/workflows.md b/docs/src/content/docs/guides/workflows.md index a102281e..26e4f1f4 100644 --- a/docs/src/content/docs/guides/workflows.md +++ b/docs/src/content/docs/guides/workflows.md @@ -33,7 +33,10 @@ gh stack rebase # 7. Push the updated branches gh stack push -# 8. Sync upstream changes as PRs get merged +# 8. Land the stack once it's approved (merges bottom to top, atomically) +gh stack merge + +# 9. Sync upstream changes as PRs get merged gh stack sync ``` @@ -120,6 +123,32 @@ gh stack push The rebase ensures all branches above the changed one pick up the fixes. `gh stack push` uses `--force-with-lease` to safely update the rebased branches. +## Merging Your Stack + +When your stack is approved, land it with `gh stack merge`. Regular `gh pr merge` doesn't work with stacked PRs — `gh stack merge` uses GitHub's atomic stack merge, which merges every PR up to and including your chosen one in a single, all-or-nothing operation. If any PR can't be merged, none are. + +```sh +# Merge the current stack (interactive picker for how far up to merge) +gh stack merge + +# Merge everything up to and including a specific PR +gh stack merge 42 + +# Merge a stack you don't have checked out, by its stack number +gh stack merge 7 + +# Merge without prompting for confirmation, specifying the merge method +gh stack merge --yes --squash +``` + +In an interactive terminal, a short wizard lets you choose how far up the stack to merge, pick the merge method (only the ones your repository allows, defaulting to your last-used method), and confirm — then shows live progress. In a non-interactive terminal, or with `--yes`, the whole stack (or everything up to the given PR) is merged without prompting. After merging, run `gh stack sync` to update your local branches. + +If the base branch uses a merge queue, `gh stack merge` adds the stack to the queue instead of merging directly. The queue chooses the merge method, so the wizard skips the method step and any merge method you pass (for example `--squash`) is ignored with a warning. The selected pull requests are added to the queue together but merge as the queue processes them — they may land in separate groups rather than all at once. + +:::note[Bypassing merge requirements not supported] +Stack merges do not support bypassing merge requirements. +::: + ## Syncing After Merges When a PR at the bottom of the stack is merged on GitHub, use `gh stack sync` to update your local state: @@ -249,7 +278,7 @@ gh stack push This is equivalent but distinct from updating your branch using a merge commit. The key difference is that after changing a lower branch, rebase maintains a linear commit history so the unique set of commits on each branch have clean diffs. -`gh stack push` then handles the force push safely via `--force-with-lease --atomic`, ensuring either all branches update or none do. +`gh stack push` then handles the force push with explicit per-branch `--force-with-lease` checks. The multi-branch push is not atomic: branches whose leases pass may update even if another branch is rejected. Fix the rejected branch and rerun the command; branches already updated will be unchanged. For a simpler all-in-one flow, `gh stack sync` combines fetch, rebase, and push into a single command — useful when you just need to pull in the latest upstream changes: diff --git a/docs/src/content/docs/index.mdx b/docs/src/content/docs/index.mdx index 285b784e..b16bcd19 100644 --- a/docs/src/content/docs/index.mdx +++ b/docs/src/content/docs/index.mdx @@ -1,10 +1,10 @@ --- title: GitHub Stacked PRs -description: Break large changes into small, reviewable, stacked pull requests with first-class GitHub support. +description: Break large changes into small, reviewable, stacked pull requests on GitHub. template: splash hero: title: GitHub Stacked PRs - tagline: Break large changes into small, reviewable pull requests that build on each other — with native GitHub support and the gh stack CLI. + tagline: Break large changes into small, reviewable pull requests. Manage your stacks on GitHub, with the gh stack CLI, or via our APIs. actions: - text: Quick Start link: /gh-stack/getting-started/quick-start/ @@ -21,7 +21,7 @@ import StackDiagram from '../../components/StackDiagram.astro'; import stackNavigator from '../../assets/screenshots/stack-navigator.png'; - + Arrange pull requests in an ordered stack and merge them all in one click. Each PR represents one focused layer of your change, reviewed independently and landed together. @@ -41,21 +41,21 @@ Large pull requests are hard to review, slow to merge, and prone to conflicts. R ## Arranging PRs in a Stack -A **stack** is a series of pull requests in the same repository where each PR targets the branch of the PR below it, forming an ordered chain that ultimately lands on your main branch. +A **stack** is a series of pull requests in the same repository where each PR targets the branch of the PR below it, forming an ordered chain that ultimately lands on your trunk branch (e.g., `main`). GitHub understands stacks end-to-end: the pull request UI shows a **stack map** so reviewers can navigate between layers, branch protection rules are enforced against the **final target branch** (not just the direct base), and CI runs for every PR in the stack as if they were targeting the final branch.
- The stack navigator in a pull request header + The stack map in a pull request header
## Working with Stacks -**While the `gh stack` CLI makes the local workflow seamless, it is entirely optional.** You can create and manage Stacked PRs directly via the GitHub UI, the API, or your standard Git workflow. If you choose to use the CLI, it handles creating branches, managing rebases, pushing to GitHub, and creating PRs with the correct base branches. On GitHub, the PR UI gives reviewers the context they need — a stack map for navigation, focused diffs for each layer, and proper rules enforcement. +**While the `gh stack` CLI makes the local workflow seamless, it is entirely optional.** You can create and manage Stacked PRs directly via the GitHub UI, the API, or your standard Git workflow. If you choose to use the CLI, it handles creating branches, managing rebases, pushing to GitHub, and creating PRs with the correct base branches. On GitHub, the PR UI gives reviewers the context they need — a stack map, focused diffs for each layer, and proper rules enforcement. -When you're ready to merge, you can merge all or a part of the stack. Each PR can be merged directly or through the merge queue. **If you want to merge multiple PRs at once (e.g., the bottom two PRs in a stack), simply wait for CI to pass on those specific layers, and you can merge them in a single step.** After a merge, the remaining PRs in the stack are automatically rebased so the lowest unmerged PR targets the updated base branch. +When you're ready to merge, click **Merge** on the highest PR you want to land — that PR **and every unmerged PR below it are merged together, from the bottom up**. Merge the top PR to land the whole stack in one click, or merge a lower PR to land just part of it (the PRs above stay open). After a partial merge, the remaining PRs are automatically rebased so the lowest unmerged PR targets the updated base branch. ## Get Started diff --git a/docs/src/content/docs/introduction/overview.md b/docs/src/content/docs/introduction/overview.md index eeec98ab..a85a4ff3 100644 --- a/docs/src/content/docs/introduction/overview.md +++ b/docs/src/content/docs/introduction/overview.md @@ -1,6 +1,6 @@ --- title: Overview -description: What stacked pull requests are, why they matter, and how GitHub supports them natively. +description: What stacked pull requests are and how they work in GitHub. --- ## Why Stacks? @@ -15,7 +15,7 @@ For developers who want to break large changes into smaller, dependent parts, th A **pull request stack** consists of two or more pull requests in the same repository where: -- The **first (bottom) pull request** targets the main branch (e.g., `main`). +- The **first (bottom) pull request** targets the stack's **trunk** — this can be any branch, and defaults to your repository's default branch (e.g., `main`). - Each subsequent pull request targets the branch of the PR below it. ``` @@ -34,20 +34,20 @@ Each pull request in a stack: ## GitHub Stacked PRs -GitHub supports Stacked PRs natively, combining a rich pull request UI with the `gh stack` CLI to give both authors and reviewers a seamless experience. +Stacked pull requests build on the existing pull request experience in GitHub, allowing authors to group a chain of individual PRs together as a stack. Together with the `gh stack` CLI, authors and reviewers can easily create, modify, navigate, and merge stacks. ### Stack Map in the PR UI When a pull request is part of a stack, a **stack map** appears at the top of the PR page. It shows every PR in the stack, their status, and lets you navigate to any layer with one click. This gives reviewers immediate context about where a PR fits in the bigger picture. -![The stack navigator in a pull request header](../../../assets/screenshots/stack-navigator.png) +![The stack map in a pull request header](../../../assets/screenshots/stack-navigator.png) ### Rules and CI Enforcement The merge requirements for any PR in the stack are determined by the **bottom PR's base** — typically `main`. This means: -- **Branch protection rules** like CODEOWNER approvals are enforced on every PR in the stack, even mid-stack PRs that don't directly target `main`. -- **CI checks** triggered by pull requests on `main` run for all PRs in the stack, not just the bottom one. +- **Branch protection rules** like CODEOWNER approvals are enforced on every PR in the stack, even mid-stack PRs that don't directly target the trunk. +- **CI checks** triggered by pull requests targeting the trunk (e.g., `main`) run for all PRs in the stack, not just the bottom one. This ensures that every layer of the stack meets the same quality bar before it can be merged. @@ -55,12 +55,23 @@ This ensures that every layer of the stack meets the same quality bar before it ### Merging Stacks -The entire stack does not need to be merged at once, but PRs must be merged **from the bottom up**. GitHub supports two merge methods: +You can merge your entire stack, a single PR, or a portion of the stack spanning multiple PRs. When you click **Merge** on any PR, that PR and every unmerged PR below it are merged together, from the bottom up. So you can: -- **Direct merge** — Merges a PR (and all non-merged PRs below it) in a single operation, as long as all conditions are met. -- **Merge queue** — Works as usual but is stack-aware. For example, if the bottom PR is removed from the queue, all other PRs in the stack are also removed. +- **Land the entire stack in one click** by merging the top PR — every PR below it comes with it. +- **Land part of the stack** by merging a mid-stack PR — the PRs below it come along, and the PRs above stay open. -The resulting commit history is the same as merging each PR individually, starting from the bottom. +You can't merge a PR while leaving an unmerged PR below it behind. Merging a stacked PR always merges all the unmerged PRs below it as well. + +GitHub supports two merge methods: + +- **Direct merge** — The selected PR and all unmerged PRs below it land in a single **atomic** operation. Either the whole group merges, or if any part fails, nothing is merged and the operation is rolled back. +- **Merge queue** — The PRs enter the queue together and each PR is evaluated **individually**, from the bottom up. If a PR fails while in the queue, that PR and all its descendants are ejected from the queue, while prior PRs are unaffected. The queue makes a best-effort attempt to keep the whole stack in a single merge group; if the stack is too large to fit, it lands across consecutive groups. If PRs are split across merge groups, the stack order is preserved so downstack PRs are merged before upstack PRs. + +In both methods, the resulting commit history is the same as if each PR had been merged individually, starting from the bottom. + +:::note[Rule bypass & auto-merge currently unsupported] +Rule bypass and auto-merge functionality are coming soon, but currently unavailable for stacked PR merges. A stack merge can only be triggered once all selected PRs meet their requirements. See the [FAQ](/gh-stack/faq/#can-i-bypass-the-rules-to-merge-a-stacked-pr) for details. +::: ### Merge Methods @@ -76,7 +87,7 @@ Rebasing is the trickiest part of working with Stacked PRs, and GitHub handles i - **In the PR UI** — A **Rebase Stack** button lets you trigger a server-side cascading rebase. It rebases the entire stack on top of the latest trunk, updates every unmerged branch, and force-pushes the results. See [Rebasing from the UI](/gh-stack/guides/ui/#rebasing-from-the-ui) for details. - **From the CLI** — `gh stack rebase` performs the same cascading rebase locally. -- **After partial merges** — When you merge a PR at the bottom of the stack, the remaining branches are automatically rebased so the next PR targets `main` and is ready for review and merge. +- **After partial merges** — When you merge a PR at the bottom of the stack, the remaining branches are automatically rebased so the next PR targets the trunk and is ready for review and merge. - **Safe squash-merge handling** — Squash merges are fully supported. The rebase engine safely replays your unique commits on top of the squashed base, avoiding artificial merge conflicts. See the [FAQ](/gh-stack/faq/#how-does-squash-merge-work) for a detailed description of how this works. ## The CLI: `gh stack` diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index b34556f7..33084e30 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -27,16 +27,16 @@ Initialize a new stack in the current repository. gh stack init [flags] [branches...] ``` +| Flag | Description | +|------|-------------| +| `-b, --base ` | Trunk branch for the stack (defaults to the repository's default branch) | + 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. 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`. Enables `git rerere` automatically so that conflict resolutions are remembered across rebases. -| Flag | Description | -|------|-------------| -| `-b, --base ` | Trunk branch for the stack (defaults to the repository's default branch) | - **Examples:** ```sh @@ -61,10 +61,6 @@ Add a new branch on top of the current stack. gh stack add [flags] [branch] ``` -Creates a new branch at the current HEAD, adds it to the top of the stack, and checks it out. Must be run while on the topmost branch of a stack. If no branch name is given, prompts for one. - -You can optionally stage changes and create a commit as part of the `add` flow. When `-m` is provided without an explicit branch name, the branch name is auto-generated in date+slug format (e.g., `03-24-add_login`). - | Flag | Description | |------|-------------| | `-A, --all` | Stage all changes (including untracked files); requires `-m` | @@ -73,6 +69,10 @@ You can optionally stage changes and create a commit as part of the `add` flow. > **Note:** `-A` and `-u` are mutually exclusive. +Creates a new branch at the current HEAD, adds it to the top of the stack, and checks it out. Must be run while on the topmost branch of a stack. If no branch name is given, prompts for one. + +You can optionally stage changes and create a commit as part of the `add` flow. When `-m` is provided without an explicit branch name, the branch name is auto-generated in date+slug format (e.g., `03-24-add_login`). + **Examples:** ```sh @@ -106,13 +106,13 @@ View the current stack. gh stack view [flags] ``` -Shows all branches in the stack, their ordering, PR links, and the most recent commit with a relative timestamp. Output is piped through a pager (respects `GIT_PAGER`, `PAGER`, or defaults to `less -R`). - | Flag | Description | |------|-------------| | `-s, --short` | Compact output (branch names only) | | `--json` | Output stack data as JSON | +Shows all branches in the stack, their ordering, PR links, and the most recent commit with a relative timestamp. Output is piped through a pager (respects `GIT_PAGER`, `PAGER`, or defaults to `less -R`). + **Examples:** ```sh @@ -164,13 +164,13 @@ Interactively restructure the current stack. gh stack modify [flags] ``` -Opens an interactive terminal UI for restructuring a stack. All changes are staged in the TUI and applied together when you press `Ctrl+S`. Branches from merged PRs cannot be modified. - | Flag | Description | |------|-------------| | `--continue` | Continue after resolving conflicts | | `--abort` | Abort the modify session and restore the stack to its pre-modify state | +Opens an interactive terminal UI for restructuring a stack. All changes are staged in the TUI and applied together when you press `Ctrl+S`. Branches from merged PRs cannot be modified. + **Preconditions:** The command checks these conditions before opening the TUI: @@ -228,6 +228,10 @@ Remove a stack from local tracking and unstack it on GitHub. Also available as ` gh stack unstack [] [flags] ``` +| Flag | Description | +|------|-------------| +| `--local` | Only remove the stack locally (keep it on GitHub) | + With no argument, the command targets the active stack — the one that contains the currently checked out branch — unstacking it on GitHub and removing local tracking. Provide a stack number (the identifier shown in the github.com stack UI) to unstack a specific stack on GitHub. This works from anywhere in the repository, whether or not the stack is checked out locally — the stack is unstacked directly through the GitHub API. When the stack is also available locally, its local tracking is removed as well. @@ -236,10 +240,6 @@ PRs that are merged, merging, or queued for merge cannot be removed from a stack This is useful when you need to restructure a stack — remove a branch, insert 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 | -|------|-------------| -| `--local` | Only remove the stack locally (keep it on GitHub) | - **Examples:** ```sh @@ -265,6 +265,12 @@ Push all branches and create/update PRs and the stack on GitHub. gh stack submit [flags] ``` +| Flag | Description | +|------|-------------| +| `--auto` | Skip the editor and use auto-generated PR titles | +| `--open` | Create new PRs as ready for review instead of drafts, and mark existing PRs as ready for review | +| `--remote ` | Remote to push to (defaults to auto-detected remote) | + Creates a Stacked PR for every branch in the stack, pushing branches to the remote. After creating PRs, `submit` automatically creates a **Stack** on GitHub to link the PRs together. If the stack already exists on GitHub (e.g., from a previous submit), new PRs are added to the existing stack. If every PR in the stack has already been merged, that stack is complete and can't be extended. In that case `submit` automatically starts a **new** stack rooted at the trunk for your unmerged branches and creates it on GitHub, leaving the merged stack untouched. @@ -280,12 +286,6 @@ If the branches already have open PRs but no stack exists on GitHub, you will ha In the editor, new PRs default to **ready for review**; flip any PR to **draft** with the ready ↔ draft toggle. With `--auto`, new PRs are created as **drafts** unless you pass `--open`. -| Flag | Description | -|------|-------------| -| `--auto` | Skip the editor and use auto-generated PR titles | -| `--open` | Create new PRs as ready for review instead of drafts, and mark existing PRs as ready for review | -| `--remote ` | Remote to push to (defaults to auto-detected remote) | - **Examples:** ```sh @@ -302,10 +302,15 @@ Fetch, rebase, push, and sync PR state in a single command. gh stack sync [flags] ``` +| Flag | Description | +|------|-------------| +| `--remote ` | Remote to fetch from and push to (defaults to auto-detected remote) | +| `--prune` | Delete local branches for merged PRs | + Performs a synchronization of the entire stack: 1. **Fetch** — fetches the latest changes from `origin`. -2. **Reconcile the remote stack** — mirrors the GitHub stack locally. When PRs have been added to the stack on GitHub (the remote is ahead of your local stack), their branches are pulled down and appended to your local stack automatically. When the local and remote stacks have genuinely diverged (for example, you added a branch locally while different PRs were added to the stack on GitHub), you are prompted to resolve (see [Diverged stacks](#diverged-stacks) below). In a non-interactive terminal a divergence aborts the sync (nothing is pushed or updated). +2. **Reconcile the remote stack** — mirrors the GitHub stack locally. When PRs have been added to the stack on GitHub (the remote is ahead of your local stack), their branches are pulled down and appended to your local stack automatically. When the local and remote stacks have genuinely diverged (for example, you added a branch locally while different PRs were added to the stack on GitHub), you are prompted to resolve (see **Diverged stacks** below). In a non-interactive terminal a divergence aborts the sync (nothing is pushed or updated). 3. **Fast-forward trunk** — fast-forwards the trunk branch to match the remote (skips if diverged). 4. **Cascade rebase** — rebases all stack branches onto their updated parents (only if trunk moved). If a conflict is detected, all branches are restored to their original state, and you are advised to run `gh stack rebase` to resolve conflicts interactively. 5. **Push** — pushes all branches (uses `--force-with-lease` if a rebase occurred). @@ -325,11 +330,6 @@ When neither stack is a clean prefix of the other — for example, you added a b In a non-interactive terminal, a divergence aborts the sync (exit success) without pushing branches or updating PRs; resolve it by unstacking and recreating the stack. -| Flag | Description | -|------|-------------| -| `--remote ` | Remote to fetch from and push to (defaults to auto-detected remote) | -| `--prune` | Delete local branches for merged PRs | - **Examples:** ```sh @@ -347,12 +347,6 @@ Pull from remote and do a cascading rebase across the stack. gh stack rebase [flags] [branch] ``` -Fetches the latest changes from `origin`, then ensures each branch in the stack has the tip of the previous layer in its commit history. Rebases branches in order from trunk upward. - -If a branch's PR has been merged, the rebase automatically switches to `--onto` mode to correctly replay commits on top of the merge target. - -If a rebase conflict occurs, the operation pauses and prints the conflicted files with line numbers. Resolve the conflicts, stage with `git add`, and continue with `--continue`. To undo the entire rebase, use `--abort` to restore all branches to their pre-rebase state. - | Flag | Description | |------|-------------| | `--downstack` | Only rebase branches from trunk to the current branch | @@ -367,6 +361,12 @@ If a rebase conflict occurs, the operation pauses and prints the conflicted file |----------|-------------| | `[branch]` | Target branch (defaults to the current branch) | +Fetches the latest changes from `origin`, then ensures each branch in the stack has the tip of the previous layer in its commit history. Rebases branches in order from trunk upward. + +If a branch's PR has been merged, the rebase automatically switches to `--onto` mode to correctly replay commits on top of the merge target. + +If a rebase conflict occurs, the operation pauses and prints the conflicted files with line numbers. Resolve the conflicts, stage with `git add`, and continue with `--continue`. To undo the entire rebase, use `--abort` to restore all branches to their pre-rebase state. + **Examples:** ```sh @@ -394,18 +394,18 @@ gh stack rebase --committer-date-is-author-date ### `gh stack push` -Push all branches in the current stack to the remote. +Push active branches in the current stack to the remote. ```sh gh stack push [flags] ``` -Pushes every branch to the remote using `--force-with-lease --atomic`. This is a lightweight wrapper around `git push` that knows about all branches in the stack. It does not create or update pull requests — use `gh stack submit` for that. - | Flag | Description | |------|-------------| | `--remote ` | Remote to push to (defaults to auto-detected remote) | +Pushes every active branch (excluding merged and queued branches) in one `git push` using explicit per-branch `--force-with-lease` checks. The update is not atomic: branches whose leases pass may update even if another branch is rejected. Fix the rejected branch and rerun the command; branches already updated will be unchanged. This command does not create or update pull requests — use `gh stack submit` for that. + **Examples:** ```sh @@ -421,6 +421,12 @@ Link PRs into a stack on GitHub without local tracking. gh stack link [flags] [...] ``` +| Flag | Description | +|------|-------------| +| `--base ` | Base branch for the bottom of the stack (defaults to the repository's default branch); ignored when adding to an existing stack | +| `--open` | Mark new and existing PRs as ready for review | +| `--remote ` | Remote to push to (defaults to auto-detected remote) | + Creates or updates a stack on GitHub from branch names or PR numbers/URLs. This command does not create or modify any `gh-stack` local tracking state. It is designed for users who manage branches with other tools locally (e.g., jj, Sapling, git-town) and want to simply open a stack of PRs. Arguments are provided in stack order (bottom to top). Branch arguments are automatically pushed to the remote before creating or looking up PRs. For branches that already have open PRs, those PRs are used. For branches without PRs, new PRs are created automatically with the correct base branch chaining. Existing PRs whose base branch doesn't match the expected chain are corrected automatically. @@ -429,12 +435,6 @@ If the PRs are not yet in a stack, a new stack is created. If some of the PRs ar To grow an existing stack without re-listing its PRs, pass a stack number (the number shown in the GitHub stack UI) as the first argument. The remaining arguments are appended to the top of that stack. Arguments already in the stack are skipped, and arguments that belong to a different stack are rejected. Because stack and PR numbers never overlap, a numeric first argument is treated as a stack only when it matches an existing stack — otherwise it is treated as a PR or branch. -| Flag | Description | -|------|-------------| -| `--base ` | Base branch for the bottom of the stack (default: `main`); ignored when adding to an existing stack | -| `--open` | Mark new and existing PRs as ready for review | -| `--remote ` | Remote to push to (defaults to auto-detected remote) | - **Examples:** ```sh @@ -460,6 +460,50 @@ gh stack link --base develop --open feat-a feat-b feat-c --- +### `gh stack merge` + +Merge one or multiple stacked PRs at once. + +```sh +gh stack merge [ | ] +``` + +All members of the stack up to and including your chosen pull request are merged into the base branch in a single, all-or-nothing operation: if any PR can't be merged, none are. + +With no argument, the current active local stack is used. Pass a stack number to merge a stack you don't have checked out (a purely remote operation), or a pull request number to merge directly up to that PR. + +In an interactive terminal, a short wizard walks you through choosing which PRs to merge, picking the merge method, and confirming. In a non-interactive terminal, or with `--yes`, the whole stack (or everything up to the given PR) is merged without prompting, using your last-used merge method unless one is specified. + +Only basic pull request state is checked before merging (open and not a draft); GitHub evaluates branch protection and repository rules when the merge runs, so any such failure is reported back to you. **Bypassing merge requirements is not supported** for stacked PR merges. + +If the base branch uses a merge queue, the stack is added to the queue instead of merging directly. The queue chooses the merge method, so the wizard skips the method step and any `--merge-method` (or `--squash`/`--rebase`/`--merge`) flag is ignored with a warning. The selected pull requests are added to the queue together but merge as the queue processes them — they may land in separate groups rather than all at once. + +Under the hood, this command uses the asynchronous [Merge API](/gh-stack/reference/merge-api/). + +| Flag | Description | +|------|-------------| +| `--merge-method ` | Merge method to use: `merge`, `squash`, or `rebase` | +| `--merge` / `--squash` / `--rebase` | Shorthands for the corresponding merge method | +| `-y, --yes` | Merge without prompting for confirmation | + +**Examples:** + +```sh +# Merge the current stack (interactive picker) +gh stack merge + +# Merge a stack you don't have checked out, by stack number +gh stack merge 7 + +# Merge everything up to and including PR #42 +gh stack merge 42 + +# Merge the whole current stack without prompting, squashing +gh stack merge --yes --squash +``` + +--- + ## Navigation Move between branches in the current stack without having to remember branch names. The **bottom** of the stack is the branch closest to the trunk, and the **top** is furthest from it. `up` moves away from trunk; `down` moves toward it. @@ -566,14 +610,14 @@ Create a short command alias so you can type less. gh stack alias [flags] [name] ``` -Installs a small wrapper script into `~/.local/bin/` that forwards all arguments to `gh stack`. The default alias name is `gs`, but you can choose any name by passing it as an argument. After setup, you can run `gs push` instead of `gh stack push`. - -On Windows, automatic alias creation is not supported — the command prints manual instructions for creating a batch file or PowerShell function. - | Flag | Description | |------|-------------| | `--remove` | Remove a previously created alias | +Installs a small wrapper script into `~/.local/bin/` that forwards all arguments to `gh stack`. The default alias name is `gs`, but you can choose any name by passing it as an argument. After setup, you can run `gs push` instead of `gh stack push`. + +On Windows, automatic alias creation is not supported — the command prints manual instructions for creating a batch file or PowerShell function. + **Examples:** ```sh @@ -635,3 +679,4 @@ GH_STACK_THEME=light gh stack view | 7 | Rebase already in progress | | 8 | Stack is locked by another process | | 9 | Stacked PRs not enabled for this repository | +| 10 | Modify session interrupted (recovery required) | diff --git a/docs/src/content/docs/reference/graphql-api.md b/docs/src/content/docs/reference/graphql-api.md new file mode 100644 index 00000000..828e8ebd --- /dev/null +++ b/docs/src/content/docs/reference/graphql-api.md @@ -0,0 +1,96 @@ +--- +title: GraphQL API +description: Reference for the read-only stack fields and objects on the GraphQL PullRequest type. +--- + +The GraphQL API exposes a pull request's stack membership through two read-only fields on the `PullRequest` type, backed by a small set of stack objects. These fields are **read-only** — there are no stack mutations via GraphQL. To create or modify stacks use the [REST API](/gh-stack/reference/rest-api/). + +## Fields on `PullRequest` + +| Field | Type | Description | +|-------|------|-------------| +| `stack` | `PullRequestStack` | The stack this pull request belongs to, or `null` if it is not part of a stack. | +| `stackEntry` | `PullRequestStackEntry` | This pull request's entry within its stack (including its position), or `null` if it is not part of a stack. | + +## Objects + +### `PullRequestStack` + +A stack of pull requests. + +| Field | Type | Description | +|-------|------|-------------| +| `id` | `ID!` | The Node ID of the `PullRequestStack` object. | +| `number` | `Int!` | A number uniquely identifying the stack within its repository. | +| `size` | `Int!` | The total number of pull requests in the stack. | +| `baseRefName` | `String!` | The branch that the stack's pull requests target. | +| `entries` | `PullRequestStackEntryConnection!` | The entries in the stack. | + +### `PullRequestStackEntry` + +A member of a `PullRequestStack`. + +| Field | Type | Description | +|-------|------|-------------| +| `id` | `ID!` | The Node ID of the `PullRequestStackEntry` object. | +| `position` | `Int!` | This entry's position in the stack, where `1` is the closest to the base branch, `2` is stacked on top of `1`, and so on. | +| `pullRequest` | `PullRequest` | The pull request that occupies this position in the stack. | +| `stack` | `PullRequestStack` | The stack that this entry is a part of. | + +### `PullRequestStackEntryConnection` + +A paginated connection of stack entries. Follows the standard [GraphQL connection pattern](https://docs.github.com/en/graphql/guides/introduction-to-graphql#connection). + +| Field | Type | Description | +|-------|------|-------------| +| `edges` | `[PullRequestStackEntryEdge]` | A list of edges. | +| `nodes` | `[PullRequestStackEntry]` | A list of the entries. | +| `pageInfo` | `PageInfo!` | Information to aid in pagination. | +| `totalCount` | `Int!` | The total number of entries in the connection. | + +### `PullRequestStackEntryEdge` + +An edge in a `PullRequestStackEntryConnection`. + +| Field | Type | Description | +|-------|------|-------------| +| `cursor` | `String!` | A cursor for use in pagination. | +| `node` | `PullRequestStackEntry` | The item at the end of the edge. | + +## Example + +Read a pull request's stack, its position, and the first 5 pull requests in the stack: + +```graphql +{ + repository(owner: "OWNER", name: "REPO") { + pullRequest(number: 42) { + number + baseRefName + stackEntry { + position + } + stack { + number + size + baseRefName + entries(first: 5) { + totalCount + nodes { + position + pullRequest { + number + title + state + } + } + } + } + } + } +} +``` + +The pull request's own `baseRefName` is the branch it directly targets (the PR below it in the stack), while `stack.baseRefName` is the branch the entire stack ultimately targets. These differ for every PR in the stack except the bottom one. + +`entries` is a paginated connection (ex: `first: 5` returns up to the first 5 entries). Check the `totalCount` for the full size and if a stack has more entries, page through the rest with the connection's `pageInfo`. diff --git a/docs/src/content/docs/reference/merge-api.md b/docs/src/content/docs/reference/merge-api.md new file mode 100644 index 00000000..91f6168b --- /dev/null +++ b/docs/src/content/docs/reference/merge-api.md @@ -0,0 +1,154 @@ +--- +title: Merge API +description: Reference for the asynchronous merge API — the required method for merging stacked pull requests. +--- + +Stacked pull requests are merged through a new **asynchronous merge API**. Because a stack merge can involve several pull requests that may take up to a few minutes to merge, the merge runs in the background: you submit a merge request and then poll for its result. + +This is the **required method for merging stacked PRs**. A stack cannot be merged with the legacy synchronous [merge endpoints](https://docs.github.com/rest/pulls/pulls#merge-a-pull-request) or [mutations](https://docs.github.com/en/graphql/reference/pulls#mutation-mergepullrequest). When you merge a stacked pull request, every pull request in the stack up to and including the one you request is merged or queued to merge into the base branch. + +## How it works + +Merging is a two-step flow: + +1. **Submit** a merge request with `PUT .../merge-async`, then read the `status`. Only a `pending` response includes a `uuid` to poll. The submission may resolve immediately to `merged` (the pull request was already merged) or `failed` (the pull request is closed or a draft), both of which are terminal. It may also resolve to `enqueued` (the pull request was already added to the merge queue). +2. **Poll** a `pending` request for its result with `GET .../merge-async/{uuid}` until the `status` is no longer `pending`. + +Only basic pull request state is checked when you submit (the PR must be open and not a draft). Branch protection and repository rules are evaluated later, when the merge actually runs, and a rule failure is reported as a `failed` result while polling. A stack merge request is **atomic**: either the whole group of pull requests lands (or is added to the merge queue), or none of it does. + +## Submit a merge request + +``` +PUT /repos/{owner}/{repo}/pulls/{pull_number}/merge-async +``` + +Merges the pull request (and, for a stacked PR, everything below it in the stack) into the base branch in the background. Only a `pending` response returns a `uuid` to fetch the result. The submit can also resolve immediately — `merged` if the pull request was already merged, or `failed` if it cannot be merged (for example, it is closed or a draft). It may also resolve to `enqueued` (the pull request was already added to the merge queue). + +All body fields are optional. + +| Body field | Type | Description | +|------------|------|-------------| +| `merge_method` | `string` | The merge method: `merge`, `squash`, or `rebase`. Defaults to a merge commit. Not supported on `merge_queue` merge actions. | +| `merge_action` | `string` | How to merge: `default` (recommended), `direct_merge`, or `merge_queue`. `default` picks the most appropriate option — it merges directly, or adds the stack to the base branch's merge queue when the branch requires one. `direct_merge` forces a direct merge; `merge_queue` uses the merge queue, if available. Omitting this field is equivalent to `default`. | +| `commit_title` | `string` | Title for the automatic commit message. Not supported on `merge_queue` merge actions. | +| `commit_message` | `string` | Extra detail to append to the automatic commit message. Not supported on `merge_queue` merge actions. | +| `sha` | `string` | SHA that the pull request head must match to allow the merge. If the PR head does not match the provided SHA, the merge is rejected. | + +```sh +echo '{"merge_method": "squash", "merge_action": "default"}' | \ + gh api --method PUT repos/OWNER/REPO/pulls/102/merge-async --input - +``` + +### Responses + +| Status | When | Body `status` | +|--------|------|---------------| +| `202 Accepted` | The merge request was accepted and will run in the background. | `pending` | +| `200 OK` | The pull request was already merged. | `merged` | +| `409 Conflict` | A merge request already exists for this pull request. The existing request's `uuid` is returned — its options may differ from those you requested. | `pending` | +| `400 Bad Request` | The pull request is not ready to be merged (for example, it is closed or a draft). | `failed` | +| `404 Not Found` | Async merge is not available for this repository, or the pull request was not found. | — | +| `422 Unprocessable Entity` | The request body failed validation (for example, an invalid `merge_method` or `merge_action` value). | — | + +```json +// 202 Accepted +{ + "status": "pending", + "details": { + "message": "Merge request enqueued.", + "uuid": "630b9d5e-3f2a-4f7e-8b0c-2d5f9a8c1e42", + "merge_method": "squash", + "merge_action": "default", + "expected_head_sha": "6dcb09b5b57875f334f61aebed695e2e4193db5e" + } +} +``` + +## Get the result of a merge + +``` +GET /repos/{owner}/{repo}/pulls/{pull_number}/merge-async/{uuid} +``` + +Fetches the current result of a merge request, identified by the `uuid` returned when the merge was submitted. A valid lookup always returns `200 OK`. Read the `status` field to see where the merge stands. Poll this endpoint (e.g., once a second) until the `status` is no longer `pending`. + +The result is retained for **24 hours** after its most recent update. After that window the request expires and this endpoint returns `404 Not Found` for the UUID. + +```sh +gh api repos/OWNER/REPO/pulls/102/merge-async/630b9d5e-3f2a-4f7e-8b0c-2d5f9a8c1e42 +``` + +```json +// still running +{ + "status": "pending", + "details": { + "message": "Merge request is in progress.", + "uuid": "630b9d5e-3f2a-4f7e-8b0c-2d5f9a8c1e42", + "merge_method": "squash", + "merge_action": "default", + "expected_head_sha": "6dcb09b5b57875f334f61aebed695e2e4193db5e" + } +} + +// merged directly +{ + "status": "merged", + "details": { + "message": "Pull request was merged.", + "sha": "6dcb09b5b57875f334f61aebed695e2e4193db5e" + } +} + +// added to the merge queue +{ + "status": "enqueued", + "details": { + "message": "Pull request was added to the merge queue." + } +} + +// could not be merged +{ + "status": "failed", + "details": { + "message": "Merge conflict: the pull request could not be merged." + } +} +``` + +## The result object + +Both endpoints return the same object: a `status` enum and a `details` object. + +| Field | Type | Description | +|-------|------|-------------| +| `status` | `string` | The state of the merge: `pending`, `merged`, `enqueued`, or `failed`. | +| `details` | `object` | Details for the current state (see below). | + +### Status values + +| Status | Meaning | +|--------|---------| +| `pending` | The merge is running in the background. Keep polling. | +| `merged` | The stack was merged directly. `details.sha` is the resulting merge commit. | +| `enqueued` | The stack was added to the base branch's merge queue. It will merge once the queue processes it. This is a terminal state for the merge request — track the merge queue for the final outcome. | +| `failed` | The merge was attempted but could not complete (for example, a merge conflict or an unmet branch rule). `details.message` explains why. Because the merge is atomic, nothing was merged. | + +### Details fields + +The fields present in `details` depend on the state: + +| Field | Type | Present when | Description | +|-------|------|--------------|-------------| +| `message` | `string` | always | A human-readable description of the current state. | +| `uuid` | `string` | `pending` | The identifier of the merge request, used to poll for the result. | +| `merge_method` | `string` | `pending` | The merge method being used (`merge`, `squash`, or `rebase`). | +| `merge_action` | `string` | `pending` | The requested merge action (`default`, `direct_merge`, or `merge_queue`). | +| `expected_head_sha` | `string` | `pending` | The SHA the pull request head must match for the merge to proceed. | +| `sha` | `string` | `merged` | The resulting merge commit SHA. | + +## Limitations + +- **Bypassing merge requirements is not supported.** You cannot use admin privileges to bypass a stack's branch protection rules or rulesets; every pull request in the stack must satisfy its requirements before the stack can land. +- **Auto-merge is not supported.** A stacked pull request cannot be set to merge automatically once its requirements are met. diff --git a/docs/src/content/docs/reference/rest-api.md b/docs/src/content/docs/reference/rest-api.md new file mode 100644 index 00000000..1f227242 --- /dev/null +++ b/docs/src/content/docs/reference/rest-api.md @@ -0,0 +1,203 @@ +--- +title: REST API +description: Reference for the Stacks REST API and the stack object on pull request resources. +--- + +GitHub exposes stacks through the REST API in two ways: + +1. **A `stack` object on pull request resources** — every pull request returned by the REST API carries a `stack` object describing its stack membership when it belongs to one. +2. **A dedicated Stacks API** — endpoints to list, read, create, extend, and dissolve stacks directly. + +:::caution[Private Preview] +Stacked PRs is currently in private preview. These endpoints are only available for repositories where the feature is enabled. [Sign up for the waitlist →](https://gh.io/stacksbeta) +::: + +## The `stack` object on Pull Requests + +When a pull request belongs to a stack, GitHub includes a `stack` object on the pull request resource. This lets you read a PR's stack membership — the stack it belongs to, its size, and this PR's position within it — directly from the pull request, without a separate lookup. + +The `stack` object is present on every REST endpoint that returns a pull request, including: + +| Endpoint | Description | +|----------|-------------| +| `GET /repos/{owner}/{repo}/pulls` | List pull requests | +| `GET /repos/{owner}/{repo}/pulls/{pull_number}` | Get a pull request | + +```sh +gh api /repos/OWNER/REPO/pulls/42 --jq '.stack' +``` + +```json +{ + "id": 123456, + "number": 50, + "size": 5, + "position": 2, + "base": { + "ref": "main", + "sha": "def456..." + } +} +``` + +### Fields + +| Field | Type | Description | +|-------|------|-------------| +| `stack.id` | `integer` | Global identifier for the stack. | +| `stack.number` | `integer` | The stack's number, scoped to the repository (shown in the GitHub UI). | +| `stack.size` | `integer` | Total number of pull requests in the stack. | +| `stack.position` | `integer` | 1-based position of this PR within the stack, where `1` is the bottom (the PR closest to the stack's base). | +| `stack.base.ref` | `string` | The branch the entire stack ultimately targets (e.g., `main`). | +| `stack.base.sha` | `string` | The HEAD SHA of the stack's base branch. | + +The pull request's own `base.ref` is the branch it directly targets (the PR below it in the stack), while `stack.base.ref` is the ultimate target of the entire stack. These differ for every PR in the stack except the bottom one. + +The `stack` object is **only present** when the pull request belongs to a stack. For standalone PRs, the field is `null`. + +The same object is delivered on `pull_request` webhook events. See the [Webhooks reference](/gh-stack/reference/webhooks/) for details. + +## The Stacks API + +The Stacks API provides endpoints to read and manage stacks directly. A stack is addressed by its **stack number** — the repository-scoped number shown in the GitHub UI (the same value as `stack.number` on a pull request). + +If stacked PRs are not enabled for the repository, these endpoints return `404 Not Found`. + +### List stacks + +``` +GET /repos/{owner}/{repo}/stacks +``` + +Lists the stacks in a repository, ordered by stack number (newest first). + +| Parameter | In | Type | Description | +|-----------|-----|------|-------------| +| `pull_request` | query | `integer` | Filter to the stack containing this pull request number. | +| `per_page` | query | `integer` | Results per page (max 100). | +| `page` | query | `integer` | Page number of the results. | + +```sh +# All stacks in the repository +gh api repos/OWNER/REPO/stacks + +# The stack containing PR #102 +gh api "repos/OWNER/REPO/stacks?pull_request=102" +``` + +```json +[ + { + "id": 9876543, + "number": 42, + "node_id": "S_kwDOABCDEF4AAAAA", + "url": "https://api.github.com/repos/octocat/hello-world/stacks/42", + "base": { "ref": "main" }, + "open": true, + "created_at": "2026-04-15T10:00:00Z", + "pull_requests": [ + { + "number": 101, + "state": "open", + "draft": false, + "merged_at": null, + "head": { "ref": "user-model", "sha": "aaa1111..." } + }, + { + "number": 102, + "state": "open", + "draft": false, + "merged_at": null, + "head": { "ref": "user-api", "sha": "bbb2222..." } + } + ] + } +] +``` + +### Get a stack + +``` +GET /repos/{owner}/{repo}/stacks/{stack_number} +``` + +Returns a single stack by its stack number. + +```sh +gh api repos/OWNER/REPO/stacks/42 +``` + +### Create a stack + +``` +POST /repos/{owner}/{repo}/stacks +``` + +Creates a stack from an ordered list of pull request numbers, from the **bottom of the stack to the top**. Each pull request's base ref must match the previous pull request's head ref, forming a valid chain. Returns `201 Created` with the new stack. + +| Body field | Type | Description | +|------------|------|-------------| +| `pull_requests` | `array[integer]` | Ordered pull request numbers, bottom to top. Minimum 2, maximum 100. | + +```sh +echo '{"pull_requests": [101, 102, 103]}' | \ + gh api --method POST repos/OWNER/REPO/stacks --input - +``` + +### Add pull requests to a stack + +``` +POST /repos/{owner}/{repo}/stacks/{stack_number}/add +``` + +Appends pull requests onto the **top** of an existing stack. Provide only the pull requests you want to add (the delta), from the current top of the stack upward. The first new pull request's base ref must match the current top pull request's head ref. Returns `200 OK` with the updated stack. + +| Body field | Type | Description | +|------------|------|-------------| +| `pull_requests` | `array[integer]` | Ordered pull request numbers to append, from the current top upward. Minimum 1, maximum 100. | + +```sh +echo '{"pull_requests": [104]}' | \ + gh api --method POST repos/OWNER/REPO/stacks/42/add --input - +``` + +### Unstack + +``` +POST /repos/{owner}/{repo}/stacks/{stack_number}/unstack +``` + +Removes the unmerged pull requests from a stack. This endpoint takes no request body. Pull requests that cannot be unstacked (those merged, merging, or queued for merge) are left in place. + +- When pull requests remain in the stack, the updated stack is returned with `200 OK`. +- When no pull requests remain, the stack is dissolved and `204 No Content` is returned. + +```sh +gh api --method POST repos/OWNER/REPO/stacks/42/unstack +``` + +### The stack resource + +Each stack is represented by the following resource. The get, create, and add endpoints return a single stack; the list endpoint returns an array of them; and unstack returns the remaining merged stack, or `204 No Content` when the stack is dissolved. + +| Field | Type | Description | +|-------|------|-------------| +| `id` | `integer` | Global identifier for the stack. | +| `number` | `integer` | The stack's number, scoped to the repository. Used to address the stack in these endpoints. | +| `node_id` | `string` | Global node ID for the stack. | +| `url` | `string` | The API URL of the stack. | +| `base.ref` | `string` | The branch the stack targets (e.g., `main`). | +| `open` | `boolean` | Whether the stack has any open pull request. `false` when all pull requests are merged or closed. | +| `created_at` | `string` | Timestamp when the stack was created (ISO 8601). | +| `pull_requests` | `array` | The pull requests in the stack, ordered from bottom to top. | + +Each entry in `pull_requests` is a minimal pull request representation: + +| Field | Type | Description | +|-------|------|-------------| +| `number` | `integer` | The pull request number. | +| `state` | `string` | `open` or `closed`. | +| `draft` | `boolean` | Whether the pull request is a draft. | +| `merged_at` | `string \| null` | Timestamp when the pull request was merged, or `null` if not merged. | +| `head.ref` | `string` | The head branch of the pull request. | +| `head.sha` | `string` | The HEAD SHA of that branch. | diff --git a/docs/src/content/docs/reference/webhooks.md b/docs/src/content/docs/reference/webhooks.md index 349fdd49..35883ff3 100644 --- a/docs/src/content/docs/reference/webhooks.md +++ b/docs/src/content/docs/reference/webhooks.md @@ -1,17 +1,19 @@ --- title: Webhooks -description: Reference for the stack object in pull_request webhook event payloads. +description: Reference for the stacked action and stack object in pull_request webhook event payloads. --- When a pull request belongs to a stack, GitHub adds a `stack` property to the `pull_request` object in webhook event payloads. This lets apps and integrations inspect the stack's ultimate target branch — not just the direct parent branch of the PR. -The `stack` object is included in the `pull_request` webhook payload for all [pull request lifecycle events](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#pull_request). - +The `stack` object is included in the `pull_request` webhook payload for [pull request lifecycle events](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#pull_request) that fire while the pull request is part of a stack. +:::note[The `opened` event never includes a stack] +A pull request is always **created before it is added to a stack**, so the `pull_request` event with the `opened` action never carries a `stack` object — at that point the PR is not yet part of any stack. Listen for the [`stacked` action](#the-stacked-event) to learn exactly when a PR joins a stack and to receive its `stack` object. +::: ## The `stack` Object -The `stack` object is nested inside the `pull_request` object and contains information about the stack's base branch: +The `stack` object is nested inside the `pull_request` object. It identifies the stack, describes this PR's place within it, and reports the stack's base branch and ultimate merge target: ```json { @@ -24,6 +26,10 @@ The `stack` object is nested inside the `pull_request` object and contains infor "sha": "abc123..." }, "stack": { + "id": 123456, + "number": 50, + "size": 5, + "position": 2, "base": { "ref": "main", "sha": "def456..." @@ -37,15 +43,90 @@ The `stack` object is nested inside the `pull_request` object and contains infor | Field | Type | Description | |-------|------|-------------| +| `pull_request.stack.id` | `integer` | Global identifier for the stack. | +| `pull_request.stack.number` | `integer` | The stack's number, scoped to the repository. | +| `pull_request.stack.size` | `integer` | Total number of pull requests in the stack. | +| `pull_request.stack.position` | `integer` | 1-based position of this PR within the stack, where `1` is the bottom (the PR closest to the stack's base). | | `pull_request.stack.base.ref` | `string` | The branch the entire stack ultimately targets (e.g., `main`). | -| `pull_request.stack.base.sha` | `string` | The HEAD SHA of that target branch at the time of the event. | +| `pull_request.stack.base.sha` | `string` | The HEAD SHA of the stack's base branch. | `pull_request.base.ref` is the direct parent branch of an individual PR (the branch below it in the stack), while `pull_request.stack.base.ref` is the ultimate target of the entire stack. These differ for all PRs in the stack except the bottom one. The `stack` object is **only present** when the pull request belongs to a stack. For standalone PRs, the field is null. +## The `stacked` Event + +GitHub delivers the `pull_request` event with the `stacked` action when a pull request is **added to a stack**. Because a PR is always created before it joins a stack, the `opened` event never includes a `stack` object — the `stacked` action is the event to listen for when you need to know exactly when a PR becomes part of a stack. + +| | | +|---|---| +| **Event** (`X-GitHub-Event` header) | `pull_request` | +| **Action** | `stacked` | +| **Fires when** | A pull request is added to a stack | + +The `stacked` payload surfaces the joined stack as a **top-level `stack` object**, in addition to the `stack` nested under `pull_request`. The two objects use the same [fields](#fields) and always match, so you can read either one. + +```json +{ + "action": "stacked", + "number": 42, + "stack": { + "id": 123456, + "number": 50, + "size": 5, + "position": 2, + "base": { + "ref": "main", + "sha": "def456..." + } + }, + "pull_request": { + "number": 42, + "title": "Add API routes", + "base": { + "ref": "feat/auth-layer", + "sha": "abc123..." + }, + "stack": { + "id": 123456, + "number": 50, + "size": 5, + "position": 2, + "base": { + "ref": "main", + "sha": "def456..." + } + } + } +} +``` + +The top-level `stack` object is unique to the `stacked` event; other `pull_request` actions (such as `opened` or `synchronize`) only carry the `stack` nested inside `pull_request`. + ## GitHub Actions -GitHub Actions automatically evaluates workflow triggers using the stack's base branch. If a PR is part of a stack targeting `main`, any workflow configured to run on pull requests targeting `main` will run for every PR in the stack — no workflow changes are required. +GitHub Actions automatically evaluates workflow triggers using the stack's base branch. For example, if a PR is part of a stack targeting `main`, any workflow configured to run on pull requests targeting `main` will run for every PR in the stack — no workflow changes are required. The `stack` object is also available in GitHub Actions workflow expressions via `github.event.pull_request.stack`. See [How do I access stack metadata in my GitHub Actions workflow?](/gh-stack/faq/#how-do-i-access-stack-metadata-in-my-github-actions-workflow) in the FAQ for examples. + +### Optimizing CI usage + +Because a workflow runs for every PR in a stack, you can use the `stack` fields to selectively run jobs. For example, if you only plan on merging one PR at a time, you can choose to only run CI for the lowest unmerged PR. Compare the stack's base ref to the PR's own base ref to detect the **lowest unmerged PR**, and compare `position` to `size` to detect the **top PR**. Note that on a standalone PR the `stack` object is `null`, so you can check that to ensure this logic only applies to stacks. + +```yaml +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Run for the lowest unmerged PR in the stack + if: github.event.pull_request.stack != null && github.event.pull_request.stack.base.ref == github.event.pull_request.base.ref + run: echo "Lowest unmerged PR in the stack" + + - name: Run for the top PR in the stack + if: github.event.pull_request.stack != null && github.event.pull_request.stack.position == github.event.pull_request.stack.size + run: echo "Top PR in the stack" +``` + +See [How can I optimize CI usage for a stack?](/gh-stack/faq/#how-can-i-optimize-ci-usage-for-a-stack) for more detail. diff --git a/internal/git/git.go b/internal/git/git.go index 678d13f8..180083f6 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -2,6 +2,7 @@ package git import ( "context" + "errors" "fmt" "os" "os/exec" @@ -64,6 +65,40 @@ func runInteractive(args ...string) error { return cmd.Run() } +// RebaseStartError indicates that git rejected a rebase before creating any +// rebase state. There is nothing to continue or abort in this case. +type RebaseStartError struct { + Err error +} + +func (e *RebaseStartError) Error() string { + return e.Err.Error() +} + +func (e *RebaseStartError) Unwrap() error { + return e.Err +} + +func IsRebaseStartError(err error) bool { + var startErr *RebaseStartError + return errors.As(err, &startErr) +} + +func runRebaseCommand(args []string, opts RebaseOpts) error { + if IsRebaseInProgress() { + return &RebaseStartError{Err: errors.New("a rebase is already in progress")} + } + err := runSilent(args...) + if err == nil { + return nil + } + err = tryAutoResolveRebase(err, opts) + if err != nil && !IsRebaseInProgress() { + return &RebaseStartError{Err: err} + } + return err +} + // rebaseContinueOnce runs a single git rebase --continue without auto-resolve. func rebaseContinueOnce(opts RebaseOpts) error { args := []string{"rebase"} @@ -83,6 +118,9 @@ func rebaseContinueOnce(opts RebaseOpts) error { func tryAutoResolveRebase(originalErr error, opts RebaseOpts) error { for i := 0; i < 1000; i++ { if !IsRebaseInProgress() { + if i == 0 { + return originalErr + } return nil } conflicts, err := ConflictedFiles() @@ -134,6 +172,11 @@ func Fetch(remote string) error { return ops.Fetch(remote) } +// FetchBranch fetches one branch into its remote-tracking ref. +func FetchBranch(remote, branch string) error { + return ops.FetchBranch(remote, branch) +} + // FetchBranches fetches specific branches from a remote, // updating their tracking refs. func FetchBranches(remote string, branches []string) error { @@ -295,6 +338,12 @@ func MergeBase(a, b string) (string, error) { return ops.MergeBase(a, b) } +// MergeBaseForkPoint returns where branch forked from ref, using ref's reflog +// to see through rewrites such as amend, rebase, or force-push. +func MergeBaseForkPoint(ref, branch string) (string, error) { + return ops.MergeBaseForkPoint(ref, branch) +} + // Log returns recent commits for the given branch. func Log(ref string, maxCount int) ([]CommitInfo, error) { return ops.Log(ref, maxCount) @@ -348,6 +397,11 @@ func SetUpstreamTracking(branch, remote string) error { return ops.SetUpstreamTracking(branch, remote) } +// UpstreamRemote returns the remote configured as a branch's upstream. +func UpstreamRemote(branch string) (string, error) { + return ops.UpstreamRemote(branch) +} + // MergeFF fast-forwards the currently checked-out branch using a merge. func MergeFF(target string) error { return ops.MergeFF(target) diff --git a/internal/git/gitops.go b/internal/git/gitops.go index 523c8e68..2464f4ec 100644 --- a/internal/git/gitops.go +++ b/internal/git/gitops.go @@ -17,6 +17,11 @@ type RebaseOpts struct { CommitterDateIsAuthorDate bool } +// ErrRemoteBranchNotFound indicates that a requested branch does not exist on +// the remote. It is distinct from transport, authentication, and other fetch +// failures. +var ErrRemoteBranchNotFound = errors.New("remote branch not found") + // Ops defines the interface for git operations used by commands. // The package-level functions are the default production implementation. // Tests can substitute a mock via SetOps(). @@ -27,6 +32,7 @@ type Ops interface { BranchExists(name string) bool CheckoutBranch(name string) error Fetch(remote string) error + FetchBranch(remote, branch string) error FetchBranches(remote string, branches []string) error DefaultBranch() (string, error) CreateBranch(name, base string) error @@ -50,6 +56,7 @@ type Ops interface { RevParse(ref string) (string, error) RevParseMulti(refs []string) ([]string, error) MergeBase(a, b string) (string, error) + MergeBaseForkPoint(ref, branch string) (string, error) Log(ref string, maxCount int) ([]CommitInfo, error) LogRange(base, head string) ([]CommitInfo, error) DiffStatRange(base, head string) (additions, deletions int, err error) @@ -59,6 +66,7 @@ type Ops interface { DeleteTrackingRef(remote, branch string) error ResetHard(ref string) error SetUpstreamTracking(branch, remote string) error + UpstreamRemote(branch string) (string, error) MergeFF(target string) error UpdateBranchRef(branch, sha string) error StageAll() error @@ -123,6 +131,17 @@ func (d *defaultOps) Fetch(remote string) error { return client.Fetch(context.Background(), remote, "") } +func (d *defaultOps) FetchBranch(remote, branch string) error { + refspec := fmt.Sprintf("+refs/heads/%s:refs/remotes/%s/%s", branch, remote, branch) + if err := runSilent("fetch", remote, refspec); err != nil { + if isMissingRemoteRefError(err) { + return fmt.Errorf("%w: %s/%s", ErrRemoteBranchNotFound, remote, branch) + } + return err + } + return nil +} + func (d *defaultOps) FetchBranches(remote string, branches []string) error { if len(branches) == 0 { return nil @@ -142,11 +161,22 @@ func (d *defaultOps) FetchBranches(remote string, branches []string) error { } // Fallback: one branch may be absent on the remote or deleted since // the last fetch. Fetch individually so one missing branch doesn't - // block the rest. Per-branch failure is expected and tolerated. + // block the rest, while still surfacing real fetch failures. + var fetchErr error for _, rs := range refspecs { - _ = runSilent("fetch", remote, rs) + err := runSilent("fetch", remote, rs) + if err == nil || isMissingRemoteRefError(err) { + continue + } + if fetchErr == nil { + fetchErr = fmt.Errorf("fetching from %s: %w", remote, err) + } } - return nil + return fetchErr +} + +func isMissingRemoteRefError(err error) bool { + return err != nil && strings.Contains(err.Error(), "couldn't find remote ref") } func (d *defaultOps) DefaultBranch() (string, error) { @@ -245,11 +275,7 @@ func (d *defaultOps) Rebase(base string, opts RebaseOpts) error { args = append(args, "--committer-date-is-author-date") } args = append(args, base) - err := runSilent(args...) - if err == nil { - return nil - } - return tryAutoResolveRebase(err, opts) + return runRebaseCommand(args, opts) } func (d *defaultOps) EnableRerere() error { @@ -302,11 +328,7 @@ func (d *defaultOps) RebaseOnto(newBase, oldBase, branch string, opts RebaseOpts args = append(args, "--committer-date-is-author-date") } args = append(args, "--onto", newBase, oldBase, branch) - err := runSilent(args...) - if err == nil { - return nil - } - return tryAutoResolveRebase(err, opts) + return runRebaseCommand(args, opts) } func (d *defaultOps) RebaseContinue(opts RebaseOpts) error { @@ -417,6 +439,10 @@ func (d *defaultOps) MergeBase(a, b string) (string, error) { return run("merge-base", a, b) } +func (d *defaultOps) MergeBaseForkPoint(ref, branch string) (string, error) { + return run("merge-base", "--fork-point", ref, branch) +} + func (d *defaultOps) Log(ref string, maxCount int) ([]CommitInfo, error) { format := "%H\t%s\t%at" output, err := run("log", ref, "--format="+format, "-n", strconv.Itoa(maxCount)) @@ -564,6 +590,10 @@ func (d *defaultOps) SetUpstreamTracking(branch, remote string) error { return runSilent("branch", "--set-upstream-to="+remote+"/"+branch, branch) } +func (d *defaultOps) UpstreamRemote(branch string) (string, error) { + return run("config", "--get", "branch."+branch+".remote") +} + func (d *defaultOps) MergeFF(target string) error { return runSilent("merge", "--ff-only", target) } diff --git a/internal/git/mock_ops.go b/internal/git/mock_ops.go index 9c2ce434..25396f5d 100644 --- a/internal/git/mock_ops.go +++ b/internal/git/mock_ops.go @@ -12,6 +12,7 @@ type MockOps struct { BranchExistsFn func(string) bool CheckoutBranchFn func(string) error FetchFn func(string) error + FetchBranchFn func(string, string) error FetchBranchesFn func(string, []string) error DefaultBranchFn func() (string, error) CreateBranchFn func(string, string) error @@ -35,6 +36,7 @@ type MockOps struct { RevParseFn func(string) (string, error) RevParseMultiFn func([]string) ([]string, error) MergeBaseFn func(string, string) (string, error) + MergeBaseForkPointFn func(string, string) (string, error) LogFn func(string, int) ([]CommitInfo, error) LogRangeFn func(string, string) ([]CommitInfo, error) DiffStatRangeFn func(string, string) (int, int, error) @@ -44,6 +46,7 @@ type MockOps struct { DeleteTrackingRefFn func(string, string) error ResetHardFn func(string) error SetUpstreamTrackingFn func(string, string) error + UpstreamRemoteFn func(string) (string, error) MergeFFFn func(string) error UpdateBranchRefFn func(string, string) error StageAllFn func() error @@ -106,6 +109,13 @@ func (m *MockOps) Fetch(remote string) error { return nil } +func (m *MockOps) FetchBranch(remote, branch string) error { + if m.FetchBranchFn != nil { + return m.FetchBranchFn(remote, branch) + } + return nil +} + func (m *MockOps) FetchBranches(remote string, branches []string) error { if m.FetchBranchesFn != nil { return m.FetchBranchesFn(remote, branches) @@ -276,6 +286,13 @@ func (m *MockOps) MergeBase(a, b string) (string, error) { return "", nil } +func (m *MockOps) MergeBaseForkPoint(ref, branch string) (string, error) { + if m.MergeBaseForkPointFn != nil { + return m.MergeBaseForkPointFn(ref, branch) + } + return "", nil +} + func (m *MockOps) Log(ref string, maxCount int) ([]CommitInfo, error) { if m.LogFn != nil { return m.LogFn(ref, maxCount) @@ -339,6 +356,13 @@ func (m *MockOps) SetUpstreamTracking(branch, remote string) error { return nil } +func (m *MockOps) UpstreamRemote(branch string) (string, error) { + if m.UpstreamRemoteFn != nil { + return m.UpstreamRemoteFn(branch) + } + return "", nil +} + func (m *MockOps) MergeFF(target string) error { if m.MergeFFFn != nil { return m.MergeFFFn(target) diff --git a/internal/git/rebase_start_test.go b/internal/git/rebase_start_test.go new file mode 100644 index 00000000..bd6d12f8 --- /dev/null +++ b/internal/git/rebase_start_test.go @@ -0,0 +1,87 @@ +package git + +import ( + "errors" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func rebaseTestRepo(t *testing.T) string { + t.Helper() + _, clone := setupBareAndClone(t) + + gitExec(t, clone, "checkout", "-b", "feature") + writeFile(t, clone, "feature.txt", "feature") + gitExec(t, clone, "add", ".") + gitExec(t, clone, "commit", "-m", "feature") + + gitExec(t, clone, "checkout", "main") + writeFile(t, clone, "main.txt", "main update") + gitExec(t, clone, "add", ".") + gitExec(t, clone, "commit", "-m", "main update") + gitExec(t, clone, "checkout", "feature") + + return clone +} + +func TestIntegration_RebaseRefusedBeforeStart(t *testing.T) { + clone := rebaseTestRepo(t) + restore := withGitDir(t, clone) + defer restore() + + writeFile(t, clone, "feature.txt", "uncommitted") + + err := Rebase("main", RebaseOpts{}) + require.Error(t, err) + assert.True(t, IsRebaseStartError(err)) + assert.False(t, IsRebaseInProgress()) +} + +func TestIntegration_RebaseConflictIsNotStartError(t *testing.T) { + clone := rebaseTestRepo(t) + restore := withGitDir(t, clone) + defer restore() + + writeFile(t, clone, "init.txt", "feature version") + gitExec(t, clone, "add", "init.txt") + gitExec(t, clone, "commit", "-m", "feature conflict") + + gitExec(t, clone, "checkout", "main") + writeFile(t, clone, "init.txt", "main version") + gitExec(t, clone, "add", "init.txt") + gitExec(t, clone, "commit", "-m", "main conflict") + gitExec(t, clone, "checkout", "feature") + + err := Rebase("main", RebaseOpts{}) + require.Error(t, err) + assert.False(t, IsRebaseStartError(err)) + assert.True(t, IsRebaseInProgress()) + gitExec(t, clone, "rebase", "--abort") +} + +func TestIntegration_FetchBranchRejectsDeletedRemoteBranchWithStaleTrackingRef(t *testing.T) { + bare, clone := setupBareAndClone(t) + + gitExec(t, clone, "checkout", "-b", "feature-trunk") + writeFile(t, clone, "feature.txt", "feature") + gitExec(t, clone, "add", ".") + gitExec(t, clone, "commit", "-m", "feature trunk") + gitExec(t, clone, "push", "origin", "feature-trunk") + staleSHA := gitExec(t, clone, "rev-parse", "origin/feature-trunk") + + other := filepath.Join(t.TempDir(), "other") + gitExec(t, ".", "clone", bare, other) + gitExec(t, other, "push", "origin", "--delete", "feature-trunk") + + restore := withGitDir(t, clone) + defer restore() + + err := FetchBranch("origin", "feature-trunk") + require.Error(t, err) + assert.True(t, errors.Is(err, ErrRemoteBranchNotFound)) + assert.Equal(t, staleSHA, gitExec(t, clone, "rev-parse", "origin/feature-trunk"), + "the stale tracking ref still exists, so callers must trust the fetch result") +} diff --git a/internal/github/client_interface.go b/internal/github/client_interface.go index 7e613814..c1281ed1 100644 --- a/internal/github/client_interface.go +++ b/internal/github/client_interface.go @@ -17,6 +17,11 @@ type ClientOps interface { CreateStack(prNumbers []int) (*RemoteStack, error) AddToStack(stackNumber int, prNumbers []int) (*RemoteStack, error) Unstack(stackNumber int) (*RemoteStack, bool, error) + RepoMergeConfig() (*RepoMergeConfig, error) + MergeStackAsync(prNumber int, method, mergeAction string) (*AsyncMergeResult, error) + GetAsyncMergeResult(prNumber int, uuid string) (*AsyncMergeResult, error) + PRTitles(numbers []int) (map[int]string, error) + BaseBranchUsesMergeQueue(baseRef string) (bool, error) } // Compile-time check that Client satisfies ClientOps. diff --git a/internal/github/merge_async.go b/internal/github/merge_async.go new file mode 100644 index 00000000..8393b2b3 --- /dev/null +++ b/internal/github/merge_async.go @@ -0,0 +1,336 @@ +package github + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/cli/go-gh/v2/pkg/api" + graphql "github.com/cli/shurcooL-graphql" +) + +// Merge method values accepted by the async merge REST API. +const ( + MergeMethodMerge = "merge" + MergeMethodSquash = "squash" + MergeMethodRebase = "rebase" +) + +// Merge action values for the async merge API's merge_action field. "default" +// lets the server choose between a direct merge and the base branch's merge +// queue; "direct_merge" and "merge_queue" force the respective path. Sending an +// explicit action makes the caller's merge-queue detection authoritative: the +// server rejects "merge_queue" on a branch with no queue rather than silently +// merging directly. +const ( + MergeActionDefault = "default" + MergeActionDirectMerge = "direct_merge" + MergeActionMergeQueue = "merge_queue" +) + +// ErrAsyncMergeUnavailable indicates the async merge API is not available for +// the repository (or the token lacks access). Surfaced on a 404 from the submit +// endpoint. +var ErrAsyncMergeUnavailable = errors.New("async stack merge is not available for this repository") + +// RepoMergeConfig describes which merge methods a repository allows, along with +// the viewer's default (last-used) merge method. +type RepoMergeConfig struct { + MergeAllowed bool + SquashAllowed bool + RebaseAllowed bool + // DefaultMethod is the viewer's last-used merge method, or the repository + // default, as one of MergeMethodMerge/MergeMethodSquash/MergeMethodRebase. + DefaultMethod string +} + +// AllowedMethods returns the enabled merge methods in display order +// (merge, squash, rebase). +func (c RepoMergeConfig) AllowedMethods() []string { + var methods []string + if c.MergeAllowed { + methods = append(methods, MergeMethodMerge) + } + if c.SquashAllowed { + methods = append(methods, MergeMethodSquash) + } + if c.RebaseAllowed { + methods = append(methods, MergeMethodRebase) + } + return methods +} + +// Allows reports whether the given merge method is enabled for the repository. +func (c RepoMergeConfig) Allows(method string) bool { + switch method { + case MergeMethodMerge: + return c.MergeAllowed + case MergeMethodSquash: + return c.SquashAllowed + case MergeMethodRebase: + return c.RebaseAllowed + } + return false +} + +// AsyncMergeDetails is the polymorphic "details" object shared by the submit and +// poll responses. Fields are populated based on the current state: a pending +// request carries UUID/MergeMethod/MergeAction/ExpectedHeadSHA, a merged result +// carries SHA, and a failed/not-mergeable result carries only Message. +type AsyncMergeDetails struct { + Message string `json:"message"` + UUID string `json:"uuid"` + MergeMethod string `json:"merge_method"` + MergeAction string `json:"merge_action"` + ExpectedHeadSHA string `json:"expected_head_sha"` + SHA string `json:"sha"` +} + +// AsyncMergeResult is the response body returned by both the submit and poll +// async merge endpoints. Status is one of the AsyncMergeStatus* values: +// "pending" (running in the background), "merged" (merged directly), "enqueued" +// (added to the base branch's merge queue), or "failed". +type AsyncMergeResult struct { + Status string `json:"status"` + Details AsyncMergeDetails `json:"details"` +} + +// Async merge status values returned in the response's "status" field. +const ( + AsyncMergeStatusPending = "pending" + AsyncMergeStatusMerged = "merged" + AsyncMergeStatusEnqueued = "enqueued" + AsyncMergeStatusFailed = "failed" +) + +// IsMerged reports whether the merge completed successfully. +func (r *AsyncMergeResult) IsMerged() bool { + return r != nil && r.Status == AsyncMergeStatusMerged +} + +// IsEnqueued reports whether the stack was added to the base branch's merge +// queue (it will merge once the queue processes it). +func (r *AsyncMergeResult) IsEnqueued() bool { + return r != nil && r.Status == AsyncMergeStatusEnqueued +} + +// IsFailed reports whether the merge was attempted but did not complete. +func (r *AsyncMergeResult) IsFailed() bool { + return r != nil && r.Status == AsyncMergeStatusFailed +} + +// IsPending reports whether the merge is still running in the background. +func (r *AsyncMergeResult) IsPending() bool { + return r != nil && r.Status == AsyncMergeStatusPending +} + +// RepoMergeConfig fetches the repository's allowed merge methods and the +// viewer's default (last-used) merge method. +func (c *Client) RepoMergeConfig() (*RepoMergeConfig, error) { + var query struct { + Repository struct { + MergeCommitAllowed bool `graphql:"mergeCommitAllowed"` + SquashMergeAllowed bool `graphql:"squashMergeAllowed"` + RebaseMergeAllowed bool `graphql:"rebaseMergeAllowed"` + ViewerDefaultMergeMethod string `graphql:"viewerDefaultMergeMethod"` + } `graphql:"repository(owner: $owner, name: $name)"` + } + + variables := map[string]interface{}{ + "owner": graphql.String(c.owner), + "name": graphql.String(c.repo), + } + + if err := c.gql.Query("RepoMergeConfig", &query, variables); err != nil { + return nil, fmt.Errorf("querying repository merge config: %w", err) + } + + r := query.Repository + return &RepoMergeConfig{ + MergeAllowed: r.MergeCommitAllowed, + SquashAllowed: r.SquashMergeAllowed, + RebaseAllowed: r.RebaseMergeAllowed, + DefaultMethod: mergeMethodFromEnum(r.ViewerDefaultMergeMethod), + }, nil +} + +// BaseBranchUsesMergeQueue reports whether the given base branch merges through a +// merge queue, detected via the branch's merge queue object or a MERGE_QUEUE +// repository rule. It is used only to tailor the merge wizard (skipping the +// merge-method step and switching to "enqueue" wording): the async stack merge +// itself always sends merge_action "default", which lets the server route the +// stack to the queue or a direct merge automatically. +func (c *Client) BaseBranchUsesMergeQueue(baseRef string) (bool, error) { + var query struct { + Repository struct { + MergeQueue *struct { + ID string `graphql:"id"` + } `graphql:"mergeQueue(branch: $branch)"` + Ref *struct { + Rules struct { + Nodes []struct { + Type string `graphql:"type"` + } `graphql:"nodes"` + } `graphql:"rules(first: 50)"` + } `graphql:"ref(qualifiedName: $qualified)"` + } `graphql:"repository(owner: $owner, name: $name)"` + } + + variables := map[string]interface{}{ + "owner": graphql.String(c.owner), + "name": graphql.String(c.repo), + "branch": graphql.String(baseRef), + "qualified": graphql.String("refs/heads/" + baseRef), + } + + if err := c.gql.Query("BaseBranchMergeQueue", &query, variables); err != nil { + return false, fmt.Errorf("querying base branch merge queue: %w", err) + } + + r := query.Repository + if r.MergeQueue != nil { + return true, nil + } + if r.Ref != nil { + for _, node := range r.Ref.Rules.Nodes { + if node.Type == "MERGE_QUEUE" { + return true, nil + } + } + } + return false, nil +} + +// MergeStackAsync requests an asynchronous merge of the given pull request. For +// a stacked PR this merges all members of the stack up to and including +// prNumber. A blank method lets the server apply its default. +// +// mergeAction selects the routing: MergeActionDirectMerge or MergeActionMergeQueue +// force the respective path, and MergeActionDefault (also the fallback for an +// empty value) lets the server choose. Sending an explicit action makes the +// caller's merge-queue detection authoritative — the server rejects +// "merge_queue" on a branch with no queue instead of silently merging directly. +// +// On success the returned result is populated for the 200 (already merged) and +// 202 (enqueued for background processing) responses. A 404 returns +// ErrAsyncMergeUnavailable, a 409 (a request already exists) returns a clear +// "already exists" error, and any other non-2xx status is returned as-is. +func (c *Client) MergeStackAsync(prNumber int, method, mergeAction string) (*AsyncMergeResult, error) { + if mergeAction == "" { + mergeAction = MergeActionDefault + } + type reqBody struct { + MergeMethod string `json:"merge_method,omitempty"` + MergeAction string `json:"merge_action"` + } + + body, err := json.Marshal(reqBody{MergeMethod: method, MergeAction: mergeAction}) + if err != nil { + return nil, fmt.Errorf("marshaling request: %w", err) + } + + path := fmt.Sprintf("repos/%s/%s/pulls/%d/merge-async", c.owner, c.repo, prNumber) + var result AsyncMergeResult + if err := c.rest.Put(path, bytes.NewReader(body), &result); err != nil { + return nil, classifyAsyncMergeError(err) + } + return &result, nil +} + +// GetAsyncMergeResult fetches the current result of a previously submitted async +// merge, identified by the UUID returned from MergeStackAsync. A valid lookup +// always returns 200, so the wrapped status/details reflect the merge's progress +// (pending, merged, enqueued, or failed). +func (c *Client) GetAsyncMergeResult(prNumber int, uuid string) (*AsyncMergeResult, error) { + path := fmt.Sprintf("repos/%s/%s/pulls/%d/merge-async/%s", c.owner, c.repo, prNumber, uuid) + var result AsyncMergeResult + if err := c.rest.Get(path, &result); err != nil { + return nil, err + } + return &result, nil +} + +// PRTitles fetches the titles for a set of pull request numbers in a single +// GraphQL query. Missing PRs are simply absent from the result. Best-effort: +// callers may ignore the error and proceed without titles. +func (c *Client) PRTitles(numbers []int) (map[int]string, error) { + titles := make(map[int]string, len(numbers)) + if len(numbers) == 0 { + return titles, nil + } + + // Batch to keep individual queries small for very large stacks. + const batchSize = 50 + for start := 0; start < len(numbers); start += batchSize { + end := start + batchSize + if end > len(numbers) { + end = len(numbers) + } + + var q strings.Builder + q.WriteString("query($owner:String!,$name:String!){repository(owner:$owner,name:$name){") + for i, n := range numbers[start:end] { + fmt.Fprintf(&q, "pr%d:pullRequest(number:%d){number title} ", i, n) + } + q.WriteString("}}") + + var resp struct { + Repository map[string]struct { + Number int `json:"number"` + Title string `json:"title"` + } `json:"repository"` + } + vars := map[string]interface{}{"owner": c.owner, "name": c.repo} + if err := c.gql.Do(q.String(), vars, &resp); err != nil { + return titles, fmt.Errorf("querying pull request titles: %w", err) + } + for _, pr := range resp.Repository { + if pr.Number != 0 { + titles[pr.Number] = pr.Title + } + } + } + return titles, nil +} + +// classifyAsyncMergeError maps a go-gh REST error into a domain error. A 404 +// means async merge isn't available for the repository or token; a 409 means a +// merge request already exists for this stack. Other errors pass through. +// +// Note: the go-gh REST client discards non-2xx response bodies, so the specific +// "details.message" from a 400 (not mergeable) and the existing UUID from a 409 +// aren't recovered here. Those are rare — the in-range PRs are validated open, +// non-draft and non-merged before submitting, and real merge failures (e.g. +// conflicts) surface through the 200 poll body — so status-based handling is +// sufficient. +func classifyAsyncMergeError(err error) error { + var httpErr *api.HTTPError + if errors.As(err, &httpErr) { + switch httpErr.StatusCode { + case http.StatusNotFound: + return ErrAsyncMergeUnavailable + case http.StatusConflict: + return errors.New("a merge request already exists for this stack") + case http.StatusBadRequest: + return errors.New("the stack can no longer be merged as requested; refresh and try again") + } + } + return err +} + +// mergeMethodFromEnum maps a GraphQL PullRequestMergeMethod enum value +// (MERGE/SQUASH/REBASE) to the lowercase REST API value. Unknown values fall +// back to MergeMethodMerge. +func mergeMethodFromEnum(enum string) string { + switch strings.ToUpper(enum) { + case "SQUASH": + return MergeMethodSquash + case "REBASE": + return MergeMethodRebase + default: + return MergeMethodMerge + } +} diff --git a/internal/github/merge_async_test.go b/internal/github/merge_async_test.go new file mode 100644 index 00000000..e0f144bc --- /dev/null +++ b/internal/github/merge_async_test.go @@ -0,0 +1,189 @@ +package github + +import ( + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + "github.com/cli/go-gh/v2/pkg/api" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +type recordedRequest struct { + method string + path string + body string +} + +// testAsyncClient builds a Client whose REST client is backed by a stub +// transport returning the given status and body. When rec is non-nil the +// request's method, path and body are captured for assertions. +func testAsyncClient(t *testing.T, status int, respBody string, rec *recordedRequest) *Client { + t.Helper() + rt := roundTripFunc(func(r *http.Request) (*http.Response, error) { + if rec != nil { + rec.method = r.Method + rec.path = r.URL.Path + if r.Body != nil { + b, _ := io.ReadAll(r.Body) + rec.body = string(b) + } + } + return &http.Response{ + StatusCode: status, + Body: io.NopCloser(strings.NewReader(respBody)), + Header: http.Header{"Content-Type": []string{"application/json"}}, + Request: r, + }, nil + }) + rest, err := api.NewRESTClient(api.ClientOptions{Host: "github.com", AuthToken: "x", Transport: rt}) + require.NoError(t, err) + return &Client{rest: rest, owner: "o", repo: "r"} +} + +func TestMergeStackAsync_Accepted(t *testing.T) { + var rec recordedRequest + body := `{"status":"pending","details":{"message":"Merge request enqueued.","uuid":"u-1","merge_method":"squash","expected_head_sha":"abc"}}` + c := testAsyncClient(t, http.StatusAccepted, body, &rec) + + res, err := c.MergeStackAsync(42, "squash", MergeActionDirectMerge) + require.NoError(t, err) + + assert.Equal(t, http.MethodPut, rec.method) + assert.Equal(t, "/repos/o/r/pulls/42/merge-async", rec.path) + assert.JSONEq(t, `{"merge_method":"squash","merge_action":"direct_merge"}`, rec.body) + + assert.True(t, res.IsPending()) + assert.False(t, res.IsMerged()) + assert.Equal(t, "u-1", res.Details.UUID) + assert.Equal(t, "squash", res.Details.MergeMethod) +} + +func TestMergeStackAsync_AlreadyMerged(t *testing.T) { + body := `{"status":"merged","details":{"message":"Pull request is already merged.","sha":"deadbeef"}}` + res, err := testAsyncClient(t, http.StatusOK, body, nil).MergeStackAsync(42, "merge", MergeActionDirectMerge) + require.NoError(t, err) + assert.True(t, res.IsMerged()) + assert.Equal(t, "deadbeef", res.Details.SHA) +} + +func TestMergeStackAsync_ExistingRequestConflict(t *testing.T) { + // The go-gh REST client discards the 409 body, so we can't recover the + // existing UUID; the request surfaces as a clear "already exists" error. + _, err := testAsyncClient(t, http.StatusConflict, `{"status":"pending","details":{"uuid":"u-2"}}`, nil).MergeStackAsync(42, "merge", MergeActionDirectMerge) + require.Error(t, err) + assert.Contains(t, err.Error(), "already exists") +} + +func TestMergeStackAsync_NotMergeable(t *testing.T) { + // A 400 preflight failure is reported as a clear error (the specific + // details.message isn't recoverable through the REST client). + _, err := testAsyncClient(t, http.StatusBadRequest, `{"status":"failed","details":{"message":"Pull request is closed."}}`, nil).MergeStackAsync(42, "merge", MergeActionDirectMerge) + require.Error(t, err) + assert.Contains(t, err.Error(), "can no longer be merged") +} + +func TestMergeStackAsync_NotAvailable(t *testing.T) { + _, err := testAsyncClient(t, http.StatusNotFound, `{"message":"Not Found"}`, nil).MergeStackAsync(42, "merge", MergeActionDirectMerge) + assert.ErrorIs(t, err, ErrAsyncMergeUnavailable) +} + +func TestMergeStackAsync_ValidationFailed(t *testing.T) { + _, err := testAsyncClient(t, http.StatusUnprocessableEntity, `{"message":"Validation Failed"}`, nil).MergeStackAsync(42, "merge", MergeActionDirectMerge) + require.Error(t, err) + assert.Contains(t, err.Error(), "Validation Failed") +} + +func TestGetAsyncMergeResult_States(t *testing.T) { + tests := []struct { + name string + body string + wantStatus string + }{ + {"pending", `{"status":"pending","details":{"message":"Merge request is in progress.","uuid":"u","merge_method":"merge","merge_action":"default","expected_head_sha":"abc"}}`, AsyncMergeStatusPending}, + {"merged", `{"status":"merged","details":{"message":"Pull request was merged.","sha":"abc"}}`, AsyncMergeStatusMerged}, + {"enqueued", `{"status":"enqueued","details":{"message":"Pull request was added to the merge queue."}}`, AsyncMergeStatusEnqueued}, + {"failed", `{"status":"failed","details":{"message":"Merge conflict."}}`, AsyncMergeStatusFailed}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var rec recordedRequest + res, err := testAsyncClient(t, http.StatusOK, tt.body, &rec).GetAsyncMergeResult(42, "u") + require.NoError(t, err) + assert.Equal(t, http.MethodGet, rec.method) + assert.Equal(t, "/repos/o/r/pulls/42/merge-async/u", rec.path) + assert.Equal(t, tt.wantStatus, res.Status) + }) + } +} + +func TestGetAsyncMergeResult_NotFound(t *testing.T) { + _, err := testAsyncClient(t, http.StatusNotFound, `{"message":"Not Found"}`, nil).GetAsyncMergeResult(42, "missing") + require.Error(t, err) +} + +func TestMergeMethodFromEnum(t *testing.T) { + assert.Equal(t, MergeMethodMerge, mergeMethodFromEnum("MERGE")) + assert.Equal(t, MergeMethodSquash, mergeMethodFromEnum("SQUASH")) + assert.Equal(t, MergeMethodRebase, mergeMethodFromEnum("REBASE")) + assert.Equal(t, MergeMethodMerge, mergeMethodFromEnum("UNKNOWN")) + assert.Equal(t, MergeMethodMerge, mergeMethodFromEnum("")) +} + +func TestRepoMergeConfig_AllowedMethods(t *testing.T) { + c := RepoMergeConfig{MergeAllowed: true, RebaseAllowed: true} + assert.Equal(t, []string{"merge", "rebase"}, c.AllowedMethods()) + assert.True(t, c.Allows("merge")) + assert.False(t, c.Allows("squash")) + assert.True(t, c.Allows("rebase")) + + empty := RepoMergeConfig{} + assert.Empty(t, empty.AllowedMethods()) +} + +func TestAsyncMergeResult_Status(t *testing.T) { + pending := &AsyncMergeResult{Status: AsyncMergeStatusPending} + assert.True(t, pending.IsPending()) + assert.False(t, pending.IsMerged()) + assert.False(t, pending.IsFailed()) + + merged := &AsyncMergeResult{Status: AsyncMergeStatusMerged} + assert.True(t, merged.IsMerged()) + assert.False(t, merged.IsPending()) + + failed := &AsyncMergeResult{Status: AsyncMergeStatusFailed} + assert.True(t, failed.IsFailed()) + assert.False(t, failed.IsMerged()) + + enqueued := &AsyncMergeResult{Status: AsyncMergeStatusEnqueued} + assert.True(t, enqueued.IsEnqueued()) + assert.False(t, enqueued.IsMerged()) + assert.False(t, enqueued.IsPending()) + + var nilRes *AsyncMergeResult + assert.False(t, nilRes.IsPending()) + assert.False(t, nilRes.IsMerged()) + assert.False(t, nilRes.IsEnqueued()) + assert.False(t, nilRes.IsFailed()) +} + +// sanity check that the submit body omits merge_method when empty but always +// sends merge_action. +func TestMergeStackAsync_OmitsEmptyMethod(t *testing.T) { + var rec recordedRequest + _, err := testAsyncClient(t, http.StatusAccepted, `{"status":"pending","details":{"message":"m","uuid":"u","merge_method":"merge","merge_action":"default","expected_head_sha":"x"}}`, &rec).MergeStackAsync(1, "", MergeActionMergeQueue) + require.NoError(t, err) + + var parsed map[string]any + require.NoError(t, json.Unmarshal([]byte(rec.body), &parsed)) + _, hasMethod := parsed["merge_method"] + assert.False(t, hasMethod, "merge_method should be omitted when empty") + assert.Equal(t, "merge_queue", parsed["merge_action"], "the explicit merge_action is always sent") +} diff --git a/internal/github/mock_client.go b/internal/github/mock_client.go index 6c3a4678..a31dd4ef 100644 --- a/internal/github/mock_client.go +++ b/internal/github/mock_client.go @@ -4,19 +4,24 @@ package github // Each field is an optional function that, when set, handles the corresponding // ClientOps method call. When nil, a reasonable default is returned. type MockClient struct { - FindPRForBranchFn func(string) (*PullRequest, error) - FindPRByNumberFn func(int) (*PullRequest, error) - FindPRDetailsForBranchFn func(string) (*PRDetails, error) - CreatePRFn func(string, string, string, string, bool) (*PullRequest, error) - UpdatePRBaseFn func(int, string) error - MarkPRReadyForReviewFn func(string) error - DisableAutoMergeFn func(string) error - ListStacksFn func() ([]RemoteStack, error) - FindStackForPRFn func(int) (*RemoteStack, error) - GetStackFn func(int) (*RemoteStack, error) - CreateStackFn func([]int) (*RemoteStack, error) - AddToStackFn func(int, []int) (*RemoteStack, error) - UnstackFn func(int) (*RemoteStack, bool, error) + FindPRForBranchFn func(string) (*PullRequest, error) + FindPRByNumberFn func(int) (*PullRequest, error) + FindPRDetailsForBranchFn func(string) (*PRDetails, error) + CreatePRFn func(string, string, string, string, bool) (*PullRequest, error) + UpdatePRBaseFn func(int, string) error + MarkPRReadyForReviewFn func(string) error + DisableAutoMergeFn func(string) error + ListStacksFn func() ([]RemoteStack, error) + FindStackForPRFn func(int) (*RemoteStack, error) + GetStackFn func(int) (*RemoteStack, error) + CreateStackFn func([]int) (*RemoteStack, error) + AddToStackFn func(int, []int) (*RemoteStack, error) + UnstackFn func(int) (*RemoteStack, bool, error) + RepoMergeConfigFn func() (*RepoMergeConfig, error) + MergeStackAsyncFn func(int, string, string) (*AsyncMergeResult, error) + GetAsyncMergeResultFn func(int, string) (*AsyncMergeResult, error) + PRTitlesFn func([]int) (map[int]string, error) + BaseBranchUsesMergeQueueFn func(string) (bool, error) } // Compile-time check that MockClient satisfies ClientOps. @@ -112,3 +117,56 @@ func (m *MockClient) Unstack(stackNumber int) (*RemoteStack, bool, error) { } return nil, false, nil } + +func (m *MockClient) RepoMergeConfig() (*RepoMergeConfig, error) { + if m.RepoMergeConfigFn != nil { + return m.RepoMergeConfigFn() + } + return &RepoMergeConfig{ + MergeAllowed: true, + SquashAllowed: true, + RebaseAllowed: true, + DefaultMethod: MergeMethodMerge, + }, nil +} + +func (m *MockClient) MergeStackAsync(prNumber int, method, mergeAction string) (*AsyncMergeResult, error) { + if m.MergeStackAsyncFn != nil { + return m.MergeStackAsyncFn(prNumber, method, mergeAction) + } + return &AsyncMergeResult{ + Status: AsyncMergeStatusPending, + Details: AsyncMergeDetails{ + Message: "Merge request enqueued.", + UUID: "mock-uuid", + MergeMethod: method, + }, + }, nil +} + +func (m *MockClient) GetAsyncMergeResult(prNumber int, uuid string) (*AsyncMergeResult, error) { + if m.GetAsyncMergeResultFn != nil { + return m.GetAsyncMergeResultFn(prNumber, uuid) + } + return &AsyncMergeResult{ + Status: AsyncMergeStatusMerged, + Details: AsyncMergeDetails{ + Message: "Pull request was merged.", + SHA: "mockmergesha", + }, + }, nil +} + +func (m *MockClient) PRTitles(numbers []int) (map[int]string, error) { + if m.PRTitlesFn != nil { + return m.PRTitlesFn(numbers) + } + return map[int]string{}, nil +} + +func (m *MockClient) BaseBranchUsesMergeQueue(baseRef string) (bool, error) { + if m.BaseBranchUsesMergeQueueFn != nil { + return m.BaseBranchUsesMergeQueueFn(baseRef) + } + return false, nil +} diff --git a/internal/modify/apply.go b/internal/modify/apply.go index 6aafc31d..d7621287 100644 --- a/internal/modify/apply.go +++ b/internal/modify/apply.go @@ -584,6 +584,13 @@ func ApplyPlan( } if err := git.RebaseOnto(newBase, oldBase, b.Branch, git.RebaseOpts{}); err != nil { + if git.IsRebaseStartError(err) { + if saveErr := stack.SaveWithLock(gitDir, sf, lock); saveErr != nil { + cfg.Warningf("failed to save stack metadata: %v", saveErr) + } + return nil, nil, fmt.Errorf("could not start rebase of %s onto %s: %w", b.Branch, newBase, err) + } + conflict := &modifyview.ConflictInfo{ Branch: b.Branch, } @@ -815,8 +822,12 @@ func ContinueApply( affectsPRs = true } - // Finish the in-progress git operation (rebase or cherry-pick) - if state.ConflictType == "cherry_pick" { + remainingBranches := state.RemainingBranches + + // Finish the in-progress git operation, or resume at a rebase that was + // previously refused before it could start. + switch state.ConflictType { + case "cherry_pick": if err := git.CherryPickContinue(); err != nil { return fmt.Errorf("cherry-pick continue failed — resolve remaining conflicts and try again: %w", err) } @@ -827,7 +838,7 @@ func ContinueApply( if foldIdx >= 0 && foldIdx < len(s.Branches) { s.Branches = append(s.Branches[:foldIdx], s.Branches[foldIdx+1:]...) } - } else { + case "", "rebase": // Rebase conflict if git.IsRebaseInProgress() { if err := git.RebaseContinue(git.RebaseOpts{}); err != nil { @@ -835,10 +846,14 @@ func ContinueApply( } } cfg.Successf("Rebased %s", state.ConflictBranch) + case "rebase_start": + remainingBranches = append([]string{state.ConflictBranch}, remainingBranches...) + default: + return fmt.Errorf("unknown modify conflict type %q", state.ConflictType) } // Continue cascading rebase for remaining branches - for _, branchName := range state.RemainingBranches { + for _, branchName := range remainingBranches { idx := s.IndexOf(branchName) if idx < 0 { cfg.Warningf("branch %s no longer in stack, skipping", branchName) @@ -878,10 +893,35 @@ func ContinueApply( } if err := git.RebaseOnto(newBase, oldBase, b.Branch, git.RebaseOpts{}); err != nil { + if git.IsRebaseStartError(err) { + remaining := make([]string, 0) + foundCurrent := false + for _, rn := range remainingBranches { + if rn == branchName { + foundCurrent = true + continue + } + if foundCurrent { + remaining = append(remaining, rn) + } + } + state.ConflictBranch = branchName + state.ConflictType = "rebase_start" + state.RemainingBranches = remaining + state.AffectsPRs = affectsPRs + if saveErr := SaveState(gitDir, state); saveErr != nil { + cfg.Warningf("failed to update modify state: %v", saveErr) + } + if saveErr := stack.SaveWithLock(gitDir, sf, lock); saveErr != nil { + cfg.Warningf("failed to save stack metadata: %v", saveErr) + } + return fmt.Errorf("could not start rebase of %s onto %s: %w", b.Branch, newBase, err) + } + // Another conflict — update state and bail remaining := make([]string, 0) foundCurrent := false - for _, rn := range state.RemainingBranches { + for _, rn := range remainingBranches { if rn == branchName { foundCurrent = true continue diff --git a/internal/modify/apply_test.go b/internal/modify/apply_test.go index 0ad6059c..fc805aa0 100644 --- a/internal/modify/apply_test.go +++ b/internal/modify/apply_test.go @@ -2,6 +2,7 @@ package modify import ( "encoding/json" + "errors" "os" "path/filepath" "testing" @@ -1417,6 +1418,82 @@ func TestContinueApply_FoldThenCascadeConflict_DoesNotResurrectFoldedBranch(t *t assert.False(t, StateExists(gitDir), "state should be cleared after successful recovery") } +func TestContinueApply_RebaseStartErrorPersistsRetryState(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "A"}, + {Branch: "B"}, + {Branch: "C"}, + }, + } + + gitDir := t.TempDir() + writeTestStackFile(t, gitDir, s) + + state := &StateFile{ + SchemaVersion: 1, + StackName: "main", + StackIndex: 0, + Phase: PhaseConflict, + ConflictType: "cherry_pick", + ConflictBranch: "B", + FoldBranch: "B", + FoldTarget: "A", + RemainingBranches: []string{"A", "C"}, + OriginalBranch: "A", + OriginalRefs: map[string]string{"A": "sha-main", "C": "sha-A-old"}, + } + require.NoError(t, SaveState(gitDir, state)) + + mock := newApplyMock(gitDir, map[string]string{ + "main": "sha-main", "A": "sha-A", "B": "sha-B", "C": "sha-C", + }) + cherryPickContinues := 0 + mock.CherryPickContinueFn = func() error { + cherryPickContinues++ + return nil + } + cRebases := 0 + mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error { + if branch == "C" { + cRebases++ + if cRebases == 1 { + return &git.RebaseStartError{Err: errors.New("branch is checked out elsewhere")} + } + } + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + defer cfg.Out.Close() + defer cfg.Err.Close() + + err := ContinueApply(cfg, gitDir, noopUpdateBaseSHAs) + require.Error(t, err) + + retryState, err := LoadState(gitDir) + require.NoError(t, err) + require.NotNil(t, retryState) + assert.Equal(t, "rebase_start", retryState.ConflictType) + assert.Equal(t, "C", retryState.ConflictBranch) + assert.Empty(t, retryState.RemainingBranches) + + afterFirst, err := stack.Load(gitDir) + require.NoError(t, err) + assert.Equal(t, -1, afterFirst.Stacks[0].IndexOf("B"), + "completed fold metadata must be saved before waiting to retry the rebase") + + err = ContinueApply(cfg, gitDir, noopUpdateBaseSHAs) + require.NoError(t, err) + assert.Equal(t, 1, cherryPickContinues, "retry must not repeat the completed cherry-pick") + assert.Equal(t, 2, cRebases, "retry must restart the refused rebase") + assert.False(t, StateExists(gitDir)) +} + // ─── Unwind restores renamed branch ───────────────────────────────────────── func TestUnwind_RestoresRenamedBranch(t *testing.T) { diff --git a/internal/tui/mergeview/model.go b/internal/tui/mergeview/model.go new file mode 100644 index 00000000..9d14e850 --- /dev/null +++ b/internal/tui/mergeview/model.go @@ -0,0 +1,463 @@ +package mergeview + +import ( + "errors" + "time" + + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/github/gh-stack/internal/theme" + "github.com/github/gh-stack/internal/tui/shared" +) + +// Model is the Bubble Tea model backing the merge wizard. +type Model struct { + opts Options + + step Step + + // topIndex is the highest selected PR index (the merge high-water mark); + // -1 means nothing selected. Selecting index i implies 0..i are included. + topIndex int + cursor int + + methodCursor int + method string + + spinner spinner.Model + status MergeStatus + + submitted bool + merged bool + enqueued bool + failed bool + cancelled bool + watchStopped bool + message string + err error + + pollInterval time.Duration + + width int + height int + scrollOffset int + usePowerline bool +} + +// New builds a merge wizard model from the given options. +func New(opts Options) Model { + interval := opts.PollInterval + if interval <= 0 { + interval = time.Second + } + + sp := spinner.New() + sp.Spinner = spinner.Dot + sp.Style = lipgloss.NewStyle().Foreground(theme.ColorAccent) + + m := Model{ + opts: opts, + topIndex: len(opts.PRs) - 1, + cursor: len(opts.PRs) - 1, + method: normalizeDefaultMethod(opts), + pollInterval: interval, + spinner: sp, + usePowerline: powerlineEnabled(), + } + // A merge queue picks the merge method from its own configuration, so the + // wizard doesn't choose one and sends a blank method. + if opts.UsesMergeQueue { + m.method = "" + } + m.methodCursor = indexOf(opts.AllowedMethods, m.method) + + if opts.PreselectTopIndex >= 0 && opts.PreselectTopIndex < len(opts.PRs) { + // PR-number mode: the target is fixed, so skip the selection step (and + // the method step too when the base uses a merge queue). + m.topIndex = opts.PreselectTopIndex + m.cursor = opts.PreselectTopIndex + m.step = StepMethod + if opts.UsesMergeQueue { + m.step = StepConfirm + } + } + + return m +} + +// Init implements tea.Model. +func (m Model) Init() tea.Cmd { return nil } + +// Update implements tea.Model. +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.scrollOffset = m.clampScroll() + return m, nil + + case spinner.TickMsg: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + + case submitDoneMsg: + return m.handleSubmitDone(msg) + + case pollTickMsg: + if m.step == StepProgress && !m.done() { + return m, m.pollCmd() + } + return m, nil + + case pollDoneMsg: + return m.handlePollDone(msg) + + case tea.KeyMsg: + return m.handleKey(msg) + } + + return m, nil +} + +func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + key := msg.String() + + if key == "ctrl+c" { + if m.step == StepProgress && m.submitted && !m.done() { + // The merge is running server-side; stop watching without cancelling it. + m.watchStopped = true + return m.finish() + } + m.cancelled = true + return m.finish() + } + + switch m.step { + case StepSelectPRs: + return m.handleSelectKey(key) + case StepMethod: + return m.handleMethodKey(key) + case StepConfirm: + return m.handleConfirmKey(key) + } + return m, nil +} + +func (m Model) handleSelectKey(key string) (tea.Model, tea.Cmd) { + switch key { + case "up", "k": + // The stack renders top-first, so moving "up" goes toward the top of + // the stack (a higher index). + if m.cursor < len(m.opts.PRs)-1 { + m.cursor++ + } + case "down", "j": + if m.cursor > 0 { + m.cursor-- + } + case "shift+up": + // Jump to the top of the stack. + m.cursor = len(m.opts.PRs) - 1 + case "shift+down": + // Jump to the bottom of the stack. + m.cursor = 0 + case " ", "x": + if m.cursor <= m.topIndex { + // Uncheck the cursor and everything above it. + m.topIndex = m.cursor - 1 + } else { + // Fill selection up to and including the cursor. + m.topIndex = m.cursor + } + case "enter", "tab": + if m.topIndex >= 0 { + m.step = m.stepAfterSelect() + } + case "esc", "q": + m.cancelled = true + return m.finish() + } + m.scrollOffset = m.clampScroll() + return m, nil +} + +// stepAfterSelect is the step reached from the PR-selection step: the merge +// method picker normally, or straight to confirmation when the base branch uses +// a merge queue (which chooses the method itself). +func (m Model) stepAfterSelect() Step { + if m.opts.UsesMergeQueue { + return StepConfirm + } + return StepMethod +} + +// maxVisibleItems caps how many pull requests the select step shows at once; the +// rest are reached by scrolling so the picker never takes over the screen. +const maxVisibleItems = 10 + +// visibleItems is the number of pull requests shown in the select window at +// once — capped at maxVisibleItems and shrunk to fit a short terminal (each +// item renders on two lines). When the terminal size is unknown, it still caps +// at maxVisibleItems so the first frame can't overflow a large stack. +func (m Model) visibleItems() int { + n := len(m.opts.PRs) + if m.height <= 0 { + if n > maxVisibleItems { + return maxVisibleItems + } + return n + } + // Reserve lines for the header, scroll indicators, summary, and footer. + const chrome = 11 + avail := (m.height - chrome) / 2 + limit := maxVisibleItems + if avail < limit { + limit = avail + } + if limit < 1 { + limit = 1 + } + if n < limit { + limit = n + } + return limit +} + +// clampScroll returns a scroll offset (in display rows, where row 0 is the top +// of the stack) that keeps the cursor's row visible within the select window. +func (m Model) clampScroll() int { + n := len(m.opts.PRs) + cursorRow := n - 1 - m.cursor + return shared.EnsureVisible(cursorRow, cursorRow+1, m.scrollOffset, m.visibleItems()) +} + +func (m Model) handleMethodKey(key string) (tea.Model, tea.Cmd) { + switch key { + case "up", "k": + if m.methodCursor > 0 { + m.methodCursor-- + } + case "down", "j": + if m.methodCursor < len(m.opts.AllowedMethods)-1 { + m.methodCursor++ + } + case "enter", "tab", " ": + if len(m.opts.AllowedMethods) > 0 { + m.method = m.opts.AllowedMethods[m.methodCursor] + m.step = StepConfirm + } + case "shift+tab": + if m.opts.PreselectTopIndex < 0 { + m.step = StepSelectPRs + } + case "esc", "q": + m.cancelled = true + return m.finish() + } + return m, nil +} + +func (m Model) handleConfirmKey(key string) (tea.Model, tea.Cmd) { + switch key { + case "enter", "y", "Y": + m.step = StepProgress + m.submitted = true + return m, tea.Batch(m.spinner.Tick, m.submitCmd()) + case "shift+tab": + if m.opts.UsesMergeQueue { + // No method step to go back to; return to selection unless the + // target was fixed by PR-number mode. + if m.opts.PreselectTopIndex < 0 { + m.step = StepSelectPRs + } + } else { + m.step = StepMethod + } + case "esc", "q": + m.cancelled = true + return m.finish() + } + return m, nil +} + +func (m Model) handleSubmitDone(msg submitDoneMsg) (tea.Model, tea.Cmd) { + if msg.err != nil { + m.err = msg.err + m.failed = true + m.message = msg.err.Error() + return m.finish() + } + + m.status = msg.status + m.message = msg.status.Message + + switch msg.status.Status { + case StatusMerged: + m.merged = true + return m.finish() + case StatusEnqueued: + m.enqueued = true + return m.finish() + case StatusFailed: + m.failed = true + return m.finish() + default: + // Pending (or an existing request adopted): poll if we have a UUID; + // otherwise this is unexpected, so treat it as a failure. + if msg.status.UUID != "" { + return m, m.pollTickCmd() + } + m.failed = true + return m.finish() + } +} + +func (m Model) handlePollDone(msg pollDoneMsg) (tea.Model, tea.Cmd) { + if msg.err != nil { + m.err = msg.err + m.failed = true + m.message = msg.err.Error() + return m.finish() + } + + m.status = msg.status + m.message = msg.status.Message + + switch msg.status.Status { + case StatusMerged: + m.merged = true + return m.finish() + case StatusEnqueued: + m.enqueued = true + return m.finish() + case StatusFailed: + m.failed = true + return m.finish() + default: + // Still pending: keep polling. + return m, m.pollTickCmd() + } +} + +func (m Model) finish() (tea.Model, tea.Cmd) { + m.step = StepDone + return m, tea.Quit +} + +func (m Model) done() bool { return m.merged || m.enqueued || m.failed || m.step == StepDone } + +// Outcome reports the final result of the wizard for the command layer. +func (m Model) Outcome() Outcome { + o := Outcome{ + Cancelled: m.cancelled, + Submitted: m.submitted, + Merged: m.merged, + Enqueued: m.enqueued, + Failed: m.failed, + WatchStopped: m.watchStopped, + Message: m.message, + TargetPR: m.targetPR(), + Method: m.method, + SHA: m.status.SHA, + Err: m.err, + } + if m.merged || m.enqueued { + o.MergedPRs = m.selectedNumbers() + } + return o +} + +func (m Model) targetPR() int { + if m.topIndex >= 0 && m.topIndex < len(m.opts.PRs) { + return m.opts.PRs[m.topIndex].Number + } + return 0 +} + +func (m Model) selectedNumbers() []int { + if m.topIndex < 0 { + return nil + } + nums := make([]int, 0, m.topIndex+1) + for i := 0; i <= m.topIndex && i < len(m.opts.PRs); i++ { + nums = append(nums, m.opts.PRs[i].Number) + } + return nums +} + +// --- async commands --- + +type submitDoneMsg struct { + status MergeStatus + err error +} + +type pollDoneMsg struct { + status MergeStatus + err error +} + +type pollTickMsg struct{} + +func (m Model) submitCmd() tea.Cmd { + target := m.targetPR() + method := m.method + submit := m.opts.Submit + return func() tea.Msg { + if submit == nil { + return submitDoneMsg{err: errors.New("no submit function configured")} + } + s, err := submit(target, method) + return submitDoneMsg{status: s, err: err} + } +} + +func (m Model) pollCmd() tea.Cmd { + target := m.targetPR() + uuid := m.status.UUID + poll := m.opts.Poll + return func() tea.Msg { + if poll == nil { + return pollDoneMsg{err: errors.New("no poll function configured")} + } + s, err := poll(target, uuid) + return pollDoneMsg{status: s, err: err} + } +} + +func (m Model) pollTickCmd() tea.Cmd { + return tea.Tick(m.pollInterval, func(time.Time) tea.Msg { return pollTickMsg{} }) +} + +// --- helpers --- + +func normalizeDefaultMethod(opts Options) string { + if opts.DefaultMethod != "" && contains(opts.AllowedMethods, opts.DefaultMethod) { + return opts.DefaultMethod + } + if len(opts.AllowedMethods) > 0 { + return opts.AllowedMethods[0] + } + return opts.DefaultMethod +} + +func indexOf(s []string, v string) int { + for i, x := range s { + if x == v { + return i + } + } + return 0 +} + +func contains(s []string, v string) bool { + for _, x := range s { + if x == v { + return true + } + } + return false +} diff --git a/internal/tui/mergeview/model_test.go b/internal/tui/mergeview/model_test.go new file mode 100644 index 00000000..f9c7c5ea --- /dev/null +++ b/internal/tui/mergeview/model_test.go @@ -0,0 +1,457 @@ +package mergeview + +import ( + "errors" + "fmt" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func baseOptions() Options { + return Options{ + PRs: []PRItem{{Number: 1, Title: "a", Branch: "feat-a"}, {Number: 2, Title: "b", Branch: "feat-b"}, {Number: 3, Title: "c", Branch: "feat-c"}}, + BaseRef: "main", + AllowedMethods: []string{"merge", "squash", "rebase"}, + DefaultMethod: "squash", + PreselectTopIndex: -1, + } +} + +func step(m Model, msg tea.Msg) Model { + next, _ := m.Update(msg) + return next.(Model) +} + +func keyType(t tea.KeyType) tea.KeyMsg { return tea.KeyMsg{Type: t} } + +func space() tea.KeyMsg { return tea.KeyMsg{Type: tea.KeySpace} } + +func TestNew_DefaultsSelectAll(t *testing.T) { + m := New(baseOptions()) + assert.Equal(t, StepSelectPRs, m.step) + assert.Equal(t, 2, m.topIndex, "all PRs selected by default") + assert.Equal(t, "squash", m.method) + assert.Equal(t, 1, m.methodCursor) + assert.Equal(t, []int{1, 2, 3}, m.selectedNumbers()) + assert.Equal(t, 3, m.targetPR()) +} + +func TestNew_DefaultMethodFallback(t *testing.T) { + opts := baseOptions() + opts.DefaultMethod = "rebase" + opts.AllowedMethods = []string{"merge", "rebase"} // squash disallowed + m := New(opts) + assert.Equal(t, "rebase", m.method) + + opts.DefaultMethod = "squash" // not allowed -> first allowed + m = New(opts) + assert.Equal(t, "merge", m.method) +} + +func TestNew_PreselectSkipsSelectStep(t *testing.T) { + opts := baseOptions() + opts.PreselectTopIndex = 1 + m := New(opts) + assert.Equal(t, StepMethod, m.step) + assert.Equal(t, 1, m.topIndex) + assert.Equal(t, 2, m.targetPR()) + assert.Equal(t, []int{1, 2}, m.selectedNumbers()) +} + +func TestSelect_CascadeToggle(t *testing.T) { + m := New(baseOptions()) // topIndex=2, cursor=2 + + // Toggling the top (index 2) lowers the water line to include only 1,2. + m = step(m, space()) + assert.Equal(t, []int{1, 2}, m.selectedNumbers()) + + // Move cursor to index 0 (down toward the bottom of the stack) and toggle: + // deselects everything. + m = step(m, keyType(tea.KeyDown)) + m = step(m, keyType(tea.KeyDown)) + assert.Equal(t, 0, m.cursor) + m = step(m, space()) + assert.Empty(t, m.selectedNumbers()) + + // Toggling index 0 again selects only the bottom PR. + m = step(m, space()) + assert.Equal(t, []int{1}, m.selectedNumbers()) + assert.Equal(t, 1, m.targetPR()) +} + +func TestSelect_Viewport(t *testing.T) { + opts := baseOptions() + opts.PRs = nil + for i := 1; i <= 30; i++ { + opts.PRs = append(opts.PRs, PRItem{Number: i, Title: fmt.Sprintf("Title %d", i), Branch: fmt.Sprintf("b%d", i)}) + } + m := New(opts) + + // No size yet: capped at maxVisibleItems so a large stack can't overflow + // the first frame. + assert.Equal(t, 10, m.visibleItems()) + + // A tall terminal caps the window at maxVisibleItems (10). + nm, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 60}) + m = nm.(Model) + assert.Equal(t, 10, m.visibleItems()) + assert.Equal(t, 0, m.scrollOffset) // cursor starts at the top of the stack + + // A short terminal shrinks the window further (each item is two lines). + nm, _ = m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) + m = nm.(Model) + assert.LessOrEqual(t, m.visibleItems(), 10) + assert.Greater(t, m.visibleItems(), 0) + + // Scrolling down keeps the cursor's display row within the window. + for i := 0; i < 20; i++ { + nm, _ := m.Update(tea.KeyMsg{Type: tea.KeyDown}) + m = nm.(Model) + cursorRow := len(m.opts.PRs) - 1 - m.cursor + assert.GreaterOrEqual(t, cursorRow, m.scrollOffset) + assert.Less(t, cursorRow, m.scrollOffset+m.visibleItems()) + } + + // The rendered select view shows at most visibleItems PRs (line 2 of each + // item contains "#N • branch") and a scroll indicator. + view := m.viewSelect() + assert.LessOrEqual(t, strings.Count(view, "•"), m.visibleItems()) + assert.Contains(t, view, "more") +} + +func TestSelect_ArrowDirection(t *testing.T) { + m := New(baseOptions()) // cursor starts at the top of the stack (index 2) + assert.Equal(t, 2, m.cursor) + + // "up" moves toward the top of the stack and is clamped there. + m = step(m, keyType(tea.KeyUp)) + assert.Equal(t, 2, m.cursor) + + // "down" moves toward the bottom of the stack (lower index). + m = step(m, keyType(tea.KeyDown)) + assert.Equal(t, 1, m.cursor) + m = step(m, keyType(tea.KeyUp)) + assert.Equal(t, 2, m.cursor) +} + +func TestTruncate_WideRunes(t *testing.T) { + // ASCII truncates to the requested width with a trailing ellipsis (plus an + // ANSI reset, which has zero display width). + ascii := truncate("abcdef", 3) + assert.True(t, strings.HasPrefix(ascii, "ab…")) + assert.LessOrEqual(t, lipgloss.Width(ascii), 3) + + // Double-width runes must not push the result past the requested display + // width (each CJK rune is two cells). + assert.LessOrEqual(t, lipgloss.Width(truncate("你好世界", 5)), 5) + + // A string that already fits is returned unchanged. + assert.Equal(t, "hi", truncate("hi", 5)) +} + +func TestSelect_AdvanceRequiresSelection(t *testing.T) { + m := New(baseOptions()) + + // Move to the bottom PR and deselect everything. + m = step(m, keyType(tea.KeyDown)) + m = step(m, keyType(tea.KeyDown)) + require.Equal(t, 0, m.cursor) + m = step(m, space()) + require.Empty(t, m.selectedNumbers()) + + // Enter should not advance with nothing selected. + m = step(m, keyType(tea.KeyEnter)) + assert.Equal(t, StepSelectPRs, m.step) + + // Select the bottom PR, then advance. + m = step(m, space()) + m = step(m, keyType(tea.KeyEnter)) + assert.Equal(t, StepMethod, m.step) +} + +func TestMethod_BackWithShiftTab(t *testing.T) { + m := New(baseOptions()) + m = step(m, keyType(tea.KeyEnter)) // -> method + require.Equal(t, StepMethod, m.step) + m = step(m, keyType(tea.KeyShiftTab)) + assert.Equal(t, StepSelectPRs, m.step) +} + +func TestConfirm_BackWithShiftTab(t *testing.T) { + m := New(baseOptions()) + m = step(m, keyType(tea.KeyTab)) // select -> method + m = step(m, keyType(tea.KeyTab)) // method -> confirm + require.Equal(t, StepConfirm, m.step) + m = step(m, keyType(tea.KeyShiftTab)) + assert.Equal(t, StepMethod, m.step) +} + +func mergeQueueOptions() Options { + o := baseOptions() + o.UsesMergeQueue = true + return o +} + +func TestMergeQueue_SkipsMethodStep(t *testing.T) { + m := New(mergeQueueOptions()) + assert.Equal(t, StepSelectPRs, m.step) + assert.Equal(t, "", m.method, "a merge queue picks the method itself") + + // Enter from the selection step goes straight to Confirm, skipping method. + m = step(m, keyType(tea.KeyEnter)) + assert.Equal(t, StepConfirm, m.step) +} + +func TestMergeQueue_ConfirmBackToSelect(t *testing.T) { + m := New(mergeQueueOptions()) + m = step(m, keyType(tea.KeyEnter)) + require.Equal(t, StepConfirm, m.step) + + // shift+tab returns to selection (there is no method step in between). + m = step(m, keyType(tea.KeyShiftTab)) + assert.Equal(t, StepSelectPRs, m.step) +} + +func TestMergeQueue_PreselectStartsAtConfirm(t *testing.T) { + o := mergeQueueOptions() + o.PreselectTopIndex = 1 + m := New(o) + assert.Equal(t, StepConfirm, m.step, "PR-number mode skips both select and method") + assert.Equal(t, []int{1, 2}, m.selectedNumbers()) + + // Confirm is the first step here, so shift+tab has nowhere to go back to. + m = step(m, keyType(tea.KeyShiftTab)) + assert.Equal(t, StepConfirm, m.step) +} + +func TestMergeQueue_ViewWording(t *testing.T) { + m := New(mergeQueueOptions()) + + // The stepper drops the merge-method stage. + assert.NotContains(t, m.stepper(), "Merge Method") + + // The selection summary mentions the merge queue. + assert.Contains(t, m.viewSelect(), "via merge queue") + + // The confirm step uses merge-queue wording and an "enqueue" action hint, + // and shows no merge method. + m = step(m, keyType(tea.KeyEnter)) + require.Equal(t, StepConfirm, m.step) + confirm := m.viewConfirm() + assert.Contains(t, confirm, "merge queue") + assert.Contains(t, confirm, "enqueue") + assert.NotContains(t, confirm, "Create a merge commit") +} + +func TestMethod_SelectAndAdvance(t *testing.T) { + m := New(baseOptions()) + m = step(m, keyType(tea.KeyEnter)) // -> method step + require.Equal(t, StepMethod, m.step) + assert.Equal(t, 1, m.methodCursor) // squash preselected + + m = step(m, keyType(tea.KeyDown)) // rebase + m = step(m, keyType(tea.KeyEnter)) + assert.Equal(t, StepConfirm, m.step) + assert.Equal(t, "rebase", m.method) +} + +func TestMethod_EscCancels(t *testing.T) { + m := New(baseOptions()) + m = step(m, keyType(tea.KeyEnter)) + require.Equal(t, StepMethod, m.step) + m = step(m, keyType(tea.KeyEsc)) + assert.True(t, m.Outcome().Cancelled) +} + +func TestConfirm_SubmitAlreadyMerged(t *testing.T) { + m := New(baseOptions()) + // submitDoneMsg is handled regardless of step; simulate an already-merged + // response. + m = step(m, submitDoneMsg{status: MergeStatus{Status: StatusMerged, Message: "Pull request is already merged.", SHA: "abc1234"}}) + out := m.Outcome() + assert.True(t, out.Merged) + assert.False(t, out.Failed) + assert.Equal(t, []int{1, 2, 3}, out.MergedPRs) +} + +func TestProgress_PendingThenFailed(t *testing.T) { + m := New(baseOptions()) + m.step = StepProgress + m.submitted = true + + m = step(m, submitDoneMsg{status: MergeStatus{Status: StatusPending, UUID: "u1", Message: "enqueued"}}) + assert.False(t, m.done(), "still in progress after queued submit") + + m = step(m, pollDoneMsg{status: MergeStatus{Status: StatusFailed, Message: "Merge conflict."}}) + out := m.Outcome() + assert.True(t, out.Failed) + assert.False(t, out.Merged) + assert.Equal(t, "Merge conflict.", out.Message) +} + +func TestProgress_PendingThenMerged(t *testing.T) { + m := New(baseOptions()) + m.step = StepProgress + m.submitted = true + + m = step(m, submitDoneMsg{status: MergeStatus{Status: StatusPending, UUID: "u1"}}) + m = step(m, pollDoneMsg{status: MergeStatus{Status: StatusMerged, SHA: "deadbee"}}) + out := m.Outcome() + assert.True(t, out.Merged) + assert.Equal(t, []int{1, 2, 3}, out.MergedPRs) +} + +func TestProgress_PendingThenEnqueued(t *testing.T) { + m := New(baseOptions()) + m.step = StepProgress + m.submitted = true + + m = step(m, submitDoneMsg{status: MergeStatus{Status: StatusPending, UUID: "u1"}}) + m = step(m, pollDoneMsg{status: MergeStatus{Status: StatusEnqueued, Message: "Pull request was added to the merge queue."}}) + out := m.Outcome() + assert.True(t, out.Enqueued) + assert.False(t, out.Merged) + assert.False(t, out.Failed) + assert.Equal(t, []int{1, 2, 3}, out.MergedPRs) +} + +func TestSubmit_Enqueued(t *testing.T) { + m := New(baseOptions()) + m.step = StepProgress + m.submitted = true + m = step(m, submitDoneMsg{status: MergeStatus{Status: StatusEnqueued, Message: "Pull request was added to the merge queue."}}) + out := m.Outcome() + assert.True(t, out.Enqueued) + assert.False(t, out.Merged) +} + +func TestSubmit_NotMergeable(t *testing.T) { + m := New(baseOptions()) + m.step = StepProgress + m.submitted = true + m = step(m, submitDoneMsg{status: MergeStatus{Status: StatusFailed, Message: "Pull request is closed."}}) + out := m.Outcome() + assert.True(t, out.Failed) + assert.Equal(t, "Pull request is closed.", out.Message) +} + +func TestSubmit_TransportError(t *testing.T) { + m := New(baseOptions()) + m.step = StepProgress + m = step(m, submitDoneMsg{err: errors.New("boom")}) + out := m.Outcome() + assert.Error(t, out.Err) + assert.True(t, out.Failed) +} + +func TestCancel_FromSelect(t *testing.T) { + m := New(baseOptions()) + m = step(m, keyType(tea.KeyEsc)) + out := m.Outcome() + assert.True(t, out.Cancelled) + assert.False(t, out.Merged) +} + +func TestView_ClearsOnDone(t *testing.T) { + m := New(baseOptions()) + m = step(m, keyType(tea.KeyEsc)) // cancel -> StepDone + require.Equal(t, StepDone, m.step) + assert.Equal(t, "", m.View(), "the done state renders nothing so the inline TUI clears itself") +} + +func TestProgress_WatchStopped(t *testing.T) { + m := New(baseOptions()) + m.step = StepProgress + m.submitted = true + m = step(m, submitDoneMsg{status: MergeStatus{Status: StatusPending, UUID: "u"}}) // in-flight + m = step(m, keyType(tea.KeyCtrlC)) + out := m.Outcome() + assert.True(t, out.WatchStopped) + assert.False(t, out.Cancelled) + assert.False(t, out.Merged) + assert.Equal(t, "", m.View()) +} + +func TestOutcome_SHA(t *testing.T) { + m := New(baseOptions()) + m = step(m, submitDoneMsg{status: MergeStatus{Status: StatusMerged, SHA: "deadbeef"}}) + assert.Equal(t, "deadbeef", m.Outcome().SHA) +} + +func TestView_RendersBannerAndSteps(t *testing.T) { + m := New(baseOptions()) + sel := m.View() + assert.Contains(t, sel, "Merge stack") + assert.Contains(t, sel, "Select PRs") + assert.Contains(t, sel, "Select Merge Method") + assert.Contains(t, sel, "Confirm") + assert.Contains(t, sel, "Will merge 3 PRs into main") + assert.Contains(t, sel, "feat-a") // branch shown on the item's second line + + m = step(m, keyType(tea.KeyTab)) + assert.Contains(t, m.View(), "Squash and merge") // method labels, no subheading + + m = step(m, keyType(tea.KeyTab)) + confirm := m.View() + assert.Contains(t, confirm, "Merge 3 PRs") + assert.Contains(t, confirm, "#1, #2, #3") +} + +func TestBanner_IncludesStackNumber(t *testing.T) { + opts := baseOptions() + opts.StackNumber = 42 + m := New(opts) + assert.Contains(t, m.View(), "Merge stack #42") +} + +func TestProgress_HidesHeader(t *testing.T) { + m := New(baseOptions()) + m.step = StepProgress + v := m.View() + assert.NotContains(t, v, "Merge stack", "header/wizard hidden during progress") + assert.NotContains(t, v, "Select PRs") + assert.Contains(t, v, "Merging") + assert.NotContains(t, v, "…", "no trailing ellipsis on the Merging line") +} + +func TestSelect_JumpTopBottom(t *testing.T) { + opts := baseOptions() + opts.PRs = nil + for i := 1; i <= 6; i++ { + opts.PRs = append(opts.PRs, PRItem{Number: i, Branch: fmt.Sprintf("b%d", i)}) + } + m := New(opts) // cursor starts at the top of the stack (index 5) + require.Equal(t, 5, m.cursor) + + m = step(m, keyType(tea.KeyShiftDown)) // jump to bottom + assert.Equal(t, 0, m.cursor) + + m = step(m, keyType(tea.KeyShiftUp)) // jump to top + assert.Equal(t, 5, m.cursor) +} + +func TestProgressStatus(t *testing.T) { + assert.Equal(t, "Submitting merge request...", progressStatus("")) + assert.Equal(t, "Submitting merge request...", progressStatus(" ")) + assert.Equal(t, "Merge request is in progress...", progressStatus("Merge request is in progress.")) + assert.Equal(t, "Merge request enqueued...", progressStatus("Merge request enqueued.")) +} + +func TestStepper_PowerlineFallback(t *testing.T) { + t.Setenv("GH_STACK_POWERLINE", "0") + assert.NotContains(t, New(baseOptions()).View(), "\ue0b0", "no Powerline glyph in fallback mode") + + t.Setenv("GH_STACK_POWERLINE", "1") + assert.Contains(t, New(baseOptions()).View(), "\ue0b0", "Powerline glyph when enabled") +} + +func TestPRCount(t *testing.T) { + assert.Equal(t, "1 PR", prCount(1)) + assert.Equal(t, "2 PRs", prCount(2)) + assert.Equal(t, "5 PRs", prCount(5)) +} diff --git a/internal/tui/mergeview/types.go b/internal/tui/mergeview/types.go new file mode 100644 index 00000000..8ee0277f --- /dev/null +++ b/internal/tui/mergeview/types.go @@ -0,0 +1,137 @@ +// Package mergeview implements the interactive wizard used by `gh stack merge`. +// +// The wizard walks the user through three selection steps — choosing how far up +// the stack to merge (a bottom-anchored checkbox list), picking the merge +// method, and confirming — then shows a live progress view while the +// asynchronous merge runs on GitHub. Because a stack merge is atomic, the +// progress view reports a single aggregate outcome: all selected PRs merge, or +// none do. +// +// The async merge submit/poll calls are injected as SubmitFunc/PollFunc so the +// wizard stays decoupled from the GitHub client and is easy to test. +package mergeview + +import "time" + +// Step identifies the current stage of the wizard. +type Step int + +const ( + // StepSelectPRs is the bottom-anchored checkbox list choosing how far up + // the stack to merge. + StepSelectPRs Step = iota + // StepMethod is the merge-method picker. + StepMethod + // StepConfirm is the confirmation summary. + StepConfirm + // StepProgress shows the live async merge status. + StepProgress + // StepDone is the terminal state after success, failure, or cancel. + StepDone +) + +// PRItem is a selectable pull request in the merge picker, ordered bottom to top +// of the stack. +type PRItem struct { + Number int + Title string + Branch string +} + +// Status is the async-merge state, mirroring the API's `status` field. +type Status string + +const ( + // StatusPending means the merge is still running in the background. + StatusPending Status = "pending" + // StatusMerged means the merge completed successfully. + StatusMerged Status = "merged" + // StatusEnqueued means the stack was added to the base branch's merge queue. + StatusEnqueued Status = "enqueued" + // StatusFailed means the merge was attempted but did not complete. + StatusFailed Status = "failed" +) + +// MergeStatus is the minimal async-merge result the progress view consumes, +// mapped by the caller from the API response. +type MergeStatus struct { + // Status is the current merge state. + Status Status + // Message is the human-readable status or failure reason. + Message string + // UUID identifies an in-flight merge request, used for polling. + UUID string + // SHA is the resulting merge commit on success. + SHA string +} + +// SubmitFunc submits the async merge for the chosen target PR and method and +// returns the initial status. +type SubmitFunc func(targetPR int, method string) (MergeStatus, error) + +// PollFunc fetches the latest status for an in-flight merge request UUID on the +// given target PR. +type PollFunc func(targetPR int, uuid string) (MergeStatus, error) + +// Options configures the wizard model. +type Options struct { + // PRs are the selectable (open, mergeable) pull requests ordered bottom to + // top of the stack. + PRs []PRItem + // StackNumber is the repo-scoped stack number, shown in the header. + StackNumber int + // BaseRef is the branch the stack merges into (for display). + BaseRef string + // RepoSlug is owner/repo, for display. + RepoSlug string + // AllowedMethods are the repo's enabled merge methods in display order + // (subset of "merge", "squash", "rebase"). + AllowedMethods []string + // DefaultMethod is the method preselected in the picker (the viewer's + // last-used method). + DefaultMethod string + // UsesMergeQueue reports that the stack's base branch merges through a merge + // queue. When set, the wizard skips the merge-method step (the queue picks + // the method), labels the summary "via merge queue", and enqueues instead of + // merging directly. + UsesMergeQueue bool + // PreselectTopIndex, when >= 0, preselects PRs[0..PreselectTopIndex] and + // skips the PR-selection step (PR-number mode). + PreselectTopIndex int + // Submit and Poll perform the async merge; injected by the command. + Submit SubmitFunc + Poll PollFunc + // PollInterval is the delay between status polls. Defaults to one second. + PollInterval time.Duration +} + +// Outcome is the result the command reads back from the finished wizard. +type Outcome struct { + // Cancelled reports the user quit before the merge was submitted. + Cancelled bool + // Submitted reports a merge request was sent to GitHub. + Submitted bool + // Merged reports the merge completed successfully. + Merged bool + // Enqueued reports the stack was added to the base branch's merge queue + // (it will merge once the queue processes it). + Enqueued bool + // Failed reports the merge was attempted but did not complete (conflict, + // rule failure, or not mergeable). + Failed bool + // WatchStopped reports the user stopped watching an in-flight merge (ctrl+c + // during progress); the merge continues on GitHub. + WatchStopped bool + // Message is the final status or failure message. + Message string + // TargetPR is the topmost selected PR (the merge high-water mark). + TargetPR int + // Method is the chosen merge method. + Method string + // MergedPRs are the PR numbers included in the merge. + MergedPRs []int + // SHA is the resulting merge commit on success. + SHA string + // Err is a transport/API error encountered during submit or polling. + Err error +} diff --git a/internal/tui/mergeview/view.go b/internal/tui/mergeview/view.go new file mode 100644 index 00000000..e4dd2efc --- /dev/null +++ b/internal/tui/mergeview/view.go @@ -0,0 +1,446 @@ +package mergeview + +import ( + "fmt" + "os" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/github/gh-stack/internal/theme" +) + +var ( + titleStyle = lipgloss.NewStyle().Foreground(theme.ColorText).Bold(true) + mutedStyle = lipgloss.NewStyle().Foreground(theme.ColorTextMuted) + faintStyle = lipgloss.NewStyle().Foreground(theme.ColorTextFaint) + accentStyle = lipgloss.NewStyle().Foreground(theme.ColorAccent) + numberStyle = lipgloss.NewStyle().Foreground(theme.ColorAccent).Bold(true) + checkedStyle = lipgloss.NewStyle().Foreground(theme.ColorGreen) + textStyle = lipgloss.NewStyle().Foreground(theme.ColorText) + // selectedTitleStyle makes the selected PR's title stand out a touch more + // than the others while staying white/black. + selectedTitleStyle = lipgloss.NewStyle().Foreground(theme.ColorText).Bold(true) + + shortcutKey = lipgloss.NewStyle().Foreground(theme.ColorText) + shortcutLabel = lipgloss.NewStyle().Foreground(theme.ColorTextMuted) +) + +// stepArrow is the Powerline right-triangle separator, rendered in the current +// segment's background color over the next segment's background so the arrow +// blends seamlessly into the shading. +const stepArrow = "\ue0b0" + +// wizardSteps are the selectable stages shown in the top stepper. A merge-queue +// merge has no method to choose, so it uses the shorter two-step sequence. +var wizardSteps = []string{"Select PRs", "Select Merge Method", "Confirm"} +var wizardStepsMergeQueue = []string{"Select PRs", "Confirm"} + +// steps returns the stepper labels for the current mode. +func (m Model) steps() []string { + if m.opts.UsesMergeQueue { + return wizardStepsMergeQueue + } + return wizardSteps +} + +// View implements tea.Model. +func (m Model) View() string { + var s string + switch m.step { + case StepSelectPRs: + s = m.banner() + m.viewSelect() + case StepMethod: + s = m.banner() + m.viewMethod() + case StepConfirm: + s = m.banner() + m.viewConfirm() + case StepProgress: + // Once the merge is submitted, hide the header/wizard and just show + // live progress. + s = m.viewProgress() + default: + // StepDone: render nothing so the inline TUI clears itself on exit; the + // command prints the final outcome. + return "" + } + // Ensure no rendered line exceeds the terminal width; otherwise a line wraps, + // the inline renderer miscounts its height, and repainting (e.g. on resize) + // leaves duplicated header lines behind. + return clampToWidth(s, m.width) +} + +// banner renders the persistent title and wizard stepper shown at the top of +// every step, followed by a single blank line of spacing. +func (m Model) banner() string { + title := "Merge stack" + if m.opts.StackNumber > 0 { + title = fmt.Sprintf("Merge stack #%d", m.opts.StackNumber) + } + return titleStyle.Render(title) + "\n" + m.stepper() + "\n\n" +} + +// stepBg returns the background color for the step at index i given the current +// active step: completed steps are green, the active step is the brightest +// (near-white on dark, near-black on light), and upcoming steps are a dim gray. +func stepBg(i, cur int) lipgloss.TerminalColor { + switch { + case i < cur: + return theme.ColorGreen + case i == cur: + return theme.ColorText + default: + return theme.ColorBorder + } +} + +// stepFg returns the foreground color for the step at index i: dark text on the +// bright/green segments, and a dim muted text on the upcoming gray segments. +func stepFg(i, cur int) lipgloss.TerminalColor { + if i > cur { + return theme.ColorTextMuted + } + return theme.ColorOnFill +} + +func (m Model) stepper() string { + cur := m.wizardIndex() + steps := m.steps() + var b strings.Builder + n := len(steps) + for i, label := range steps { + bg := stepBg(i, cur) + icon := "•" + if i < cur { + icon = "✓" + } + seg := lipgloss.NewStyle().Background(bg).Foreground(stepFg(i, cur)).Bold(i == cur).Padding(0, 1) + b.WriteString(seg.Render(icon + " " + label)) + + if m.usePowerline { + // Powerline separator: the current background color, over the next + // segment's background (or the terminal default after the last step). + arrow := lipgloss.NewStyle().Foreground(bg) + if i < n-1 { + arrow = arrow.Background(stepBg(i+1, cur)) + } + b.WriteString(arrow.Render(stepArrow)) + } + // Fallback: segments abut directly, so their background colors form a + // seamless segmented bar without any Powerline glyph. + } + return b.String() +} + +// powerlineEnabled reports whether the terminal is known to render Powerline +// glyphs (U+E0Bx). Most terminals need a patched/Nerd font, so this defaults to +// off and only opts in for terminals with built-in Powerline glyph support, +// avoiding the missing-glyph box seen in e.g. Apple Terminal. Set +// GH_STACK_POWERLINE=1/0 to override. +func powerlineEnabled() bool { + switch strings.ToLower(os.Getenv("GH_STACK_POWERLINE")) { + case "1", "true", "yes", "on": + return true + case "0", "false", "no", "off": + return false + } + switch os.Getenv("TERM_PROGRAM") { + case "ghostty", "WezTerm": + return true + } + switch os.Getenv("TERM") { + case "xterm-ghostty", "xterm-kitty": + return true + } + return os.Getenv("KITTY_WINDOW_ID") != "" +} + +// wizardIndex maps the current step to its position in the stepper. Progress and +// done are past the last selectable step, so all steps read as complete. When +// the base branch uses a merge queue there is no method step, so confirm is the +// second (index 1) stage. +func (m Model) wizardIndex() int { + if m.opts.UsesMergeQueue { + switch m.step { + case StepSelectPRs: + return 0 + case StepConfirm: + return 1 + default: + return len(m.steps()) + } + } + switch m.step { + case StepSelectPRs: + return 0 + case StepMethod: + return 1 + case StepConfirm: + return 2 + default: + return len(m.steps()) + } +} + +func (m Model) viewSelect() string { + var b strings.Builder + + n := len(m.opts.PRs) + h := m.visibleItems() + start := m.scrollOffset + if start > n-h { + start = n - h + } + if start < 0 { + start = 0 + } + end := start + h + if end > n { + end = n + } + + // Reserve the indicator lines at all times (blank when nothing is hidden) so + // the list doesn't shift as the ↑/↓ hints appear and disappear while scrolling. + if start > 0 { + b.WriteString(faintStyle.Render(fmt.Sprintf(" ↑ %d more", start)) + "\n") + } else { + b.WriteString("\n") + } + // Render top of stack first so the layout matches the CLI. + for r := start; r < end; r++ { + i := n - 1 - r + pr := m.opts.PRs[i] + selected := i <= m.topIndex + + cursorMark := " " + if i == m.cursor { + cursorMark = accentStyle.Render("❯ ") + } + box := mutedStyle.Render("[ ]") + if selected { + box = checkedStyle.Render("[x]") + } + // Title: white/black for all, a touch bolder when selected. + titleField := textStyle + // Number + branch: gray for all, fainter when deselected. + metaField := faintStyle + if selected { + titleField = selectedTitleStyle + metaField = mutedStyle + } + title := pr.Title + if title == "" { + title = pr.Branch + } + b.WriteString(fmt.Sprintf("%s%s %s\n", cursorMark, box, titleField.Render(title))) + b.WriteString(" " + metaField.Render(fmt.Sprintf("#%d • %s", pr.Number, pr.Branch)) + "\n") + } + if end < n { + b.WriteString(faintStyle.Render(fmt.Sprintf(" ↓ %d more", n-end)) + "\n") + } else { + b.WriteString("\n") + } + + b.WriteString("\n") + if m.topIndex >= 0 { + summary := fmt.Sprintf("Will merge %s into %s.", prCount(m.topIndex+1), m.opts.BaseRef) + if m.opts.UsesMergeQueue { + summary = fmt.Sprintf("Will merge %s into %s via merge queue.", prCount(m.topIndex+1), m.opts.BaseRef) + } + b.WriteString(mutedStyle.Render(summary)) + } else { + b.WriteString(faintStyle.Render("Select at least one pull request.")) + } + b.WriteString("\n\n") + b.WriteString(shortcuts( + [2]string{"↑/↓", "move"}, + [2]string{"space", "toggle"}, + [2]string{"tab/enter", "next"}, + [2]string{"esc", "cancel"}, + )) + return b.String() +} + +func (m Model) viewMethod() string { + var b strings.Builder + + for i, method := range m.opts.AllowedMethods { + cursor := " " + if i == m.methodCursor { + cursor = accentStyle.Render("❯ ") + } + radio := "( )" + label := mutedStyle.Render(methodLabel(method)) + if i == m.methodCursor { + radio = checkedStyle.Render("(•)") + label = textStyle.Render(methodLabel(method)) + } + b.WriteString(fmt.Sprintf("%s%s %s\n", cursor, radio, label)) + } + + b.WriteString("\n") + b.WriteString(shortcuts( + [2]string{"↑/↓", "move"}, + [2]string{"tab/enter", "next"}, + [2]string{"shift+tab", "back"}, + [2]string{"esc", "cancel"}, + )) + return b.String() +} + +func (m Model) viewConfirm() string { + var b strings.Builder + nums := m.selectedNumbers() + + if m.opts.UsesMergeQueue { + b.WriteString(fmt.Sprintf("%s into %s via %s.\n", + titleStyle.Render("Merge "+prCount(len(nums))), + accentStyle.Render(m.opts.BaseRef), + accentStyle.Render("merge queue"), + )) + } else { + b.WriteString(fmt.Sprintf("%s into %s with %s.\n", + titleStyle.Render("Merge "+prCount(len(nums))), + accentStyle.Render(m.opts.BaseRef), + accentStyle.Render(methodLabel(m.method)), + )) + } + // Wrap the PR list so a long stack isn't cut off at the screen edge. + listStyle := numberStyle + if m.width > 0 { + listStyle = listStyle.Width(m.width) + } + b.WriteString(listStyle.Render(prNumberList(nums)) + "\n\n") + confirmLabel := "merge" + if m.opts.UsesMergeQueue { + confirmLabel = "enqueue" + } + b.WriteString(shortcuts( + [2]string{"enter", confirmLabel}, + [2]string{"shift+tab", "back"}, + [2]string{"esc", "cancel"}, + )) + return b.String() +} + +func (m Model) viewProgress() string { + var b strings.Builder + nums := m.selectedNumbers() + + if m.opts.UsesMergeQueue { + b.WriteString(fmt.Sprintf("%s Adding %s to the merge queue for %s\n", + m.spinner.View(), + numberStyle.Render(prNumberList(nums)), + accentStyle.Render(m.opts.BaseRef), + )) + } else { + b.WriteString(fmt.Sprintf("%s Merging %s into %s via %s\n", + m.spinner.View(), + numberStyle.Render(prNumberList(nums)), + accentStyle.Render(m.opts.BaseRef), + accentStyle.Render(methodLabel(m.method)), + )) + } + // Always render a status line so it doesn't pop in later and shift the view. + b.WriteString(faintStyle.Render(progressStatus(m.message)) + "\n") + b.WriteString("\n") + b.WriteString(faintStyle.Render("ctrl+c: stop watching (the merge keeps running on GitHub)")) + return b.String() +} + +// progressStatus normalizes an async-merge status message for display: a blank +// message shows an initial "Submitting…" line, and messages end in an ellipsis +// rather than a period. +func progressStatus(msg string) string { + msg = strings.TrimSpace(msg) + if msg == "" { + return "Submitting merge request..." + } + return strings.TrimRight(msg, ". ") + "..." +} + +func shortcuts(entries ...[2]string) string { + parts := make([]string, 0, len(entries)) + for _, e := range entries { + parts = append(parts, shortcutKey.Render(e[0])+" "+shortcutLabel.Render(e[1])) + } + return strings.Join(parts, faintStyle.Render(" · ")) +} + +// prCount renders a pull-request count with correct pluralization: "1 PR" or +// "N PRs". +func prCount(n int) string { + if n == 1 { + return "1 PR" + } + return fmt.Sprintf("%d PRs", n) +} + +func methodLabel(method string) string { + switch method { + case "merge": + return "Create a merge commit" + case "squash": + return "Squash and merge" + case "rebase": + return "Rebase and merge" + default: + return method + } +} + +func prNumberList(nums []int) string { + parts := make([]string, len(nums)) + for i, n := range nums { + parts[i] = fmt.Sprintf("#%d", n) + } + return strings.Join(parts, ", ") +} + +// clampToWidth truncates every line of s to at most width cells so nothing +// wraps. +func clampToWidth(s string, width int) string { + if width <= 0 { + return s + } + lines := strings.Split(s, "\n") + for i, ln := range lines { + if lipgloss.Width(ln) > width { + lines[i] = truncate(ln, width) + } + } + return strings.Join(lines, "\n") +} + +// truncate shortens s to at most width display cells, appending an ellipsis and +// resetting styling. It skips ANSI escape sequences when counting width. +func truncate(s string, width int) string { + if width <= 0 { + return "" + } + if lipgloss.Width(s) <= width { + return s + } + var b strings.Builder + w := 0 + inEscape := false + for _, r := range s { + if r == '\x1b' { + inEscape = true + } + if inEscape { + b.WriteRune(r) + if r == 'm' { + inEscape = false + } + continue + } + rw := lipgloss.Width(string(r)) + if w+rw > width-1 { + b.WriteString("…") + b.WriteString("\x1b[0m") + break + } + b.WriteRune(r) + w += rw + } + return b.String() +} diff --git a/skills/gh-stack/SKILL.md b/skills/gh-stack/SKILL.md index 31554a6f..3f8a60d6 100644 --- a/skills/gh-stack/SKILL.md +++ b/skills/gh-stack/SKILL.md @@ -7,7 +7,7 @@ description: > branch chains, or incremental code review workflows. metadata: author: github - version: "0.0.8" + version: "0.0.9" --- # gh-stack @@ -55,12 +55,13 @@ git config remote.pushDefault origin # if multiple remotes exist (skips remo 1. **Always supply branch names as positional arguments** to `init`, `add`, and `checkout`. Running these commands without arguments triggers interactive prompts. Branch names are used exactly as given — a name is never prefixed or transformed, so `gh stack add refactor/foo` creates a branch named `refactor/foo`. 2. **Always use `--auto` with `gh stack submit`** to auto-generate PR titles. Without `--auto`, `submit` prompts for a title for each new PR. 3. **Always use `--json` with `gh stack view`.** Without `--json`, the command launches an interactive TUI that cannot be operated by agents. There is no other appropriate flag — always pass `--json`. -4. **Use `--remote ` when multiple remotes are configured**, or pre-configure `git config remote.pushDefault origin`. Without this, `push`, `submit`, `sync`, `link`, and `checkout` trigger an interactive remote picker. +4. **Handle multiple remotes.** If more than one remote is configured, pre-configure `git config remote.pushDefault origin`, or pass `--remote ` to the commands that accept it: `push`, `submit`, `sync`, `rebase`, and `link`. `checkout`, `modify`, and `trunk` resolve a remote but have **no `--remote` flag** — they rely on `remote.pushDefault`. With multiple remotes and no configured default, these commands exit with an error in non-interactive mode. 5. **Avoid branches shared across multiple stacks.** If a branch belongs to multiple stacks, commands exit with code 6. Check out a non-shared branch first. 6. **Plan your stack layers by dependency order before writing code.** Foundational changes (models, APIs, shared utilities) go in lower branches; dependent changes (UI, consumers) go in higher branches. Think through the dependency chain before running `gh stack init`. 7. **Use standard `git add` and `git commit` for staging and committing.** This gives you full control over which changes go into each branch. The `-Am` shortcut is available but should not be the default approach—stacked PRs are most effective when each branch contains a deliberate, logical set of changes. 8. **Navigate down the stack when you need to change a lower layer.** If you're working on a frontend branch and realize you need API changes, don't hack around it at the current layer. Navigate to the appropriate branch (`gh stack down`, `gh stack checkout`, or `gh stack bottom`), make and commit the changes there, run `gh stack rebase --upstack`, then navigate back up to continue. 9. **Use `gh stack link` for external tool workflows.** When branches are managed by an external tool (jj, Sapling, etc.), use `gh stack link branch-a branch-b`. `link` does not rely on local tracking state and is intended for API-driven PR and stack management. Provide at least two branches/PRs to create or update a stack, or a stack number followed by the new branches/PRs to append them to the top of an existing stack (e.g. `gh stack link 7 branch-c`). +10. **Use `gh stack merge --yes` to merge stacked PRs.** `gh pr merge` does not work with stacked PRs. In a non-interactive terminal `gh stack merge` runs without prompting and merges the entire stack (bottom to top) atomically; pass `--yes` to be explicit. Scope the merge by passing a pull request number (`gh stack merge 42 --yes` merges everything up to and including PR #42) or a stack number (`gh stack merge 7 --yes`, which needs no local checkout). Choose the method with `--squash`, `--rebase`, `--merge`, or `--merge-method `; without one, the last-used method is used. The merge is all-or-nothing — if any PR can't be merged, none are, and the failure reason is reported. Only basic pull request state is checked before merging (open and not a draft); bypassing merge requirements is not supported for stacks. If the base branch uses a merge queue, the stack is added to the queue instead of merging directly: the queue chooses the merge method (any method you pass is ignored with a warning), and the pull requests are added to the queue together but merge as the queue processes them, so they may land in separate groups rather than all at once. **Never do any of the following — each triggers an interactive prompt or TUI that will hang:** - ❌ `gh stack view` or `gh stack view --short` — always use `gh stack view --json` @@ -68,7 +69,7 @@ git config remote.pushDefault origin # if multiple remotes exist (skips remo - ❌ `gh stack init` without branch arguments — always provide branch names - ❌ `gh stack add` without a branch name — always provide a branch name - ❌ `gh stack checkout` without an argument — always provide a PR number or branch name -- ❌ `gh stack checkout ` when a different local stack already exists on those branches — this triggers an unbypassable conflict resolution prompt; use `gh stack unstack` first to remove the local stack, then retry the checkout +- ❌ `gh stack checkout ` when a different local stack already exists on those branches — this triggers an unbypassable conflict resolution prompt; use `gh stack unstack --local` first to remove the local tracking state (this keeps the stack on GitHub intact), then retry the checkout ## Thinking about stack structure @@ -164,6 +165,10 @@ Small, incidental fixes (e.g., fixing a typo you noticed) can go in the current | Check out by branch (local only) | `gh stack checkout feature-auth` | | Tear down the current stack to restructure it | `gh stack unstack` | | Tear down a specific stack by number | `gh stack unstack 7` | +| Merge the whole current stack | `gh stack merge --yes` | +| Merge a stack by number | `gh stack merge 7 --yes` | +| Merge up to a specific PR | `gh stack merge 42 --yes` | +| Merge with a specific method | `gh stack merge --yes --squash` | --- @@ -390,7 +395,7 @@ echo "$output" | jq '[.branches[] | .isMerged] | all' Use `unstack` to tear down the stack, make structural changes, then re-init: ```bash -# 1. Remove the stack (locally and on GitHub) +# 1. Remove the local tracking and the GitHub stack grouping (PRs are NOT deleted) gh stack unstack # 2. Make structural changes — e.g. delete a branch, reorder, rename @@ -492,7 +497,7 @@ gh stack add -um "Fix auth bug" auth-fix ### Push branches to remote — `gh stack push` -Push all stack branches to the remote. +Push active stack branches to the remote. ``` gh stack push [flags] @@ -512,7 +517,8 @@ gh stack push --remote upstream **Behavior:** -- Pushes all active (non-merged) branches atomically (`--force-with-lease --atomic`) +- Pushes all active (non-merged, non-queued) branches in one non-atomic multi-ref push with explicit per-branch `--force-with-lease` checks +- Some branches may update if another is rejected; fix the rejected branch and rerun the command - Does **not** create or update pull requests — use `gh stack submit` for that **Output (stderr):** @@ -541,7 +547,8 @@ gh stack submit --auto --open **Behavior:** -- Pushes all active (non-merged) branches atomically (`--force-with-lease --atomic`) +- Pushes each active (non-merged, non-queued) branch sequentially with explicit per-branch `--force-with-lease` checks; the overall submit is not atomic +- If a later branch push is rejected, earlier branch pushes and PR updates remain; fix the rejection and rerun the same command - Creates a new PR for each branch that doesn't have one (base set to the first non-merged ancestor branch) - After creating PRs, links them together as a **Stack** on GitHub (requires the repository to have stacks enabled) - If every PR in the stack has already been merged, the stack is complete and can't be extended. `submit` automatically forks your unmerged branches into a **new** stack rooted at the trunk and creates it on GitHub, leaving the merged stack untouched. @@ -591,7 +598,7 @@ When the first argument is a stack number, the remaining arguments are appended | Flag | Description | |------|---------| -| `--base ` | Base branch for the bottom of the stack (default: `main`) | +| `--base ` | Base branch for the bottom of the stack (defaults to the repository's default branch) | | `--open` | Mark new and existing PRs as ready for review | | `--remote ` | Remote to push to (use if multiple remotes exist) | @@ -733,6 +740,7 @@ gh stack view --json "base": "def5678...", "isCurrent": false, "isMerged": true, + "isQueued": false, "needsRebase": false, "pr": { "number": 42, @@ -746,6 +754,7 @@ gh stack view --json "base": "abc1234...", "isCurrent": true, "isMerged": false, + "isQueued": false, "needsRebase": false, "pr": { "number": 43, @@ -763,8 +772,9 @@ Fields per branch: - `base` — parent branch's HEAD SHA at last sync - `isCurrent` — whether this is the checked-out branch - `isMerged` — whether the PR has been merged +- `isQueued` — whether the PR is queued for merge (in a merge queue) - `needsRebase` — whether the base branch is not an ancestor (non-linear history) -- `pr` — PR metadata (omitted if no PR exists). `state` is `"OPEN"` or `"MERGED"`. +- `pr` — PR metadata (omitted if no PR exists). `state` is `"OPEN"`, `"MERGED"`, or `"QUEUED"`. --- @@ -779,6 +789,7 @@ gh stack down # Move down one branch (closer to trunk) gh stack down 2 # Move down two branches gh stack top # Jump to the top of the stack (furthest from trunk) gh stack bottom # Jump to the bottom (first non-merged branch above trunk) +gh stack trunk # Jump to the trunk branch (e.g. main) ``` Navigation clamps to stack bounds. Merged branches are skipped when navigating from active branches. @@ -787,23 +798,29 @@ Navigation clamps to stack bounds. Merged branches are skipped when navigating f ### Check out a stack — `gh stack checkout` -Check out a stack from a pull request number or branch name. **Always provide an argument** — running `gh stack checkout` without arguments triggers an interactive selection menu. +Check out a stack by stack number, pull request number, PR URL, or branch name. **Always provide an argument** — running `gh stack checkout` without arguments triggers an interactive selection menu. ``` -gh stack checkout +gh stack checkout ``` ```bash +# By stack number (the identifier shown in the GitHub stack UI) +gh stack checkout 7 + # By PR number (pulls from GitHub) gh stack checkout 42 +# By PR URL +gh stack checkout https://github.com/owner/repo/pull/42 + # By branch name (local only) gh stack checkout feature-auth ``` -When a PR number is provided (e.g. `123`), the command fetches the stack on GitHub, pulls the branches, and sets up the stack locally. If the stack already exists locally and matches, it switches to the branch. +A bare number is resolved as a **stack number first** (the identifier shown in the GitHub stack UI); if no stack has that number it is tried as a PR number, then a branch name. When a stack or PR number (or PR URL) is provided, the command fetches the stack on GitHub, pulls the branches, and sets up the stack locally. If the stack already exists locally and matches, it switches to the branch. -> **⚠️ Agent warning:** If the local and remote stacks have different branch compositions, this command triggers an interactive conflict-resolution prompt that cannot be bypassed with a flag. To avoid this: run `gh stack unstack` first to remove the conflicting local stack, then retry `gh stack checkout `. +> **⚠️ Agent warning:** If the local and remote stacks have different branch compositions, this command triggers an interactive conflict-resolution prompt that cannot be bypassed with a flag. To avoid this: run `gh stack unstack --local` first to remove the conflicting local tracking state (this keeps the stack on GitHub intact), then retry `gh stack checkout `. When a branch name is provided, the command resolves it against locally tracked stacks only. This is always safe for non-interactive use. @@ -813,6 +830,8 @@ When a branch name is provided, the command resolves it against locally tracked Tear down a stack so you can restructure it — 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. +Unstacking only removes the stack grouping (on GitHub and/or locally); it never deletes the underlying pull requests or branches. + With no argument, the command targets the active stack — the one containing the currently checked out branch — unstacking it on GitHub and removing local tracking. Provide a stack number to unstack a specific stack on GitHub. This works from anywhere in the repository, whether or not the stack is checked out locally — the number is unstacked directly through the GitHub API (like `gh stack link`, no local tracking required). If the stack is also tracked locally, its local tracking is removed as well. @@ -822,7 +841,7 @@ gh stack unstack [] [flags] ``` ```bash -# Tear down the current stack (locally and on GitHub), then rebuild +# Tear down the current stack — removes local tracking and the GitHub grouping (PRs are NOT deleted), then rebuild gh stack unstack gh stack init --base main branch-2 branch-1 branch-3 # reordered @@ -860,13 +879,13 @@ gh stack unstack --local | 6 | Disambiguation required | A branch belongs to multiple stacks. Run `gh stack checkout ` to switch to a non-shared branch first | | 7 | Rebase already in progress | Run `gh stack rebase --continue` (after resolving conflicts) or `gh stack rebase --abort` to start over | | 8 | Stack is locked | Another `gh stack` process is writing the stack file. Wait and retry — the lock times out after 5 seconds | -| 9 | Stacked PRs unavailable | The repository does not have stacked PRs enabled. `submit` will offer to create regular (unstacked) PRs in interactive mode | +| 9 | Stacked PRs unavailable | The repository does not have stacked PRs enabled. Tell the user that stacks must be enabled on the repository first | +| 10 | Modify recovery required | A `gh stack modify` session was interrupted. This skill does not use `modify`, so agents should not produce this; if the repo is left in this state, run `gh stack modify --abort` to restore the pre-modify state | ## Known limitations 1. **Stacks are strictly linear.** Branching stacks (multiple children on a single parent) are not supported. Each branch has exactly one parent and at most one child. If you need parallel workstreams, use separate stacks. 2. **Stack disambiguation cannot be bypassed.** If the current branch is the trunk of multiple stacks, commands error with code 6. Check out a non-shared branch first. -3. **Multiple remotes require `--remote` or config.** If more than one remote is configured, pass `--remote ` or set `remote.pushDefault` in git config before running `push`, `sync`, or `rebase`. -4. **Merging PRs:** Merging Stacked PRs from the CLI is not supported yet. Direct users to open the PR URL in a browser to merge PRs. -5. **Remote stack checkout requires a PR number.** `checkout` with a branch name only works with locally tracked stacks. Use a PR number (e.g. `gh stack checkout 123`) to pull stacks from GitHub. -6. **PR title and body are auto-generated.** There is no flag to set a custom PR title or body during `submit`. The title and body are generated from commit messages plus a footer. Use `gh pr edit` to modify PR title and body after creation. +3. **Multiple remotes require `--remote` or config.** If more than one remote is configured, set `remote.pushDefault` in git config, or pass `--remote ` to the commands that accept it (`push`, `submit`, `sync`, `rebase`, `link`). `checkout`, `modify`, and `trunk` have no `--remote` flag and rely on `remote.pushDefault`. +4. **Remote stack checkout requires a stack or PR number.** `checkout` with a branch name only works with locally tracked stacks. Use a stack number or PR number (e.g. `gh stack checkout 7` or `gh stack checkout 123`) to pull a stack from GitHub. +5. **PR title and body are auto-generated.** There is no flag to set a custom PR title or body during `submit`. The title and body are generated from commit messages plus a footer. Use `gh pr edit` to modify PR title and body after creation.