Skip to content

Commit 68ce60c

Browse files
authored
Merge Stacked PRs with merge command (#307)
* merge cmd * Refine the merge TUI and simplify the async-merge client Follow-up polish for `gh stack merge` (the command itself landed in the previous commit). These changes refine the interactive wizard, enrich the PR picker, and replace the merge client's bespoke HTTP handling with the standard go-gh REST client. Wizard and stepper: - Redesign the top stepper as a segmented bar: completed steps are green, the active step is the brightest, and upcoming steps are dimmed. Steps are separated by a Powerline arrow that blends into the shading, with a graceful fallback to abutting segments on terminals that lack the glyph (e.g. Apple Terminal). Set GH_STACK_POWERLINE=1/0 to override detection. - Show the stack number in the header ("Merge stack #123"). - Hide the header and stepper once the merge is submitted so the live progress view stands on its own. PR picker: - Render each pull request on two lines: the title (white/black, a touch bolder when selected) above its "#number • branch" (gray, fainter when deselected). Titles are fetched in one batched GraphQL query (PRTitles) and fall back to the branch name. - Scroll long stacks in a fixed 10-item window with persistent "N more" indicators, so the list no longer jumps as those hints appear and disappear. Add shift+up / shift+down to jump to the top or bottom. Progress and outcome: - Always render a status line ("Submitting merge request...") so it does not pop in later and shift the view, and normalize messages to end in an ellipsis. - Print the final result from the command layer rather than the TUI: a success line that includes the merge commit SHA ("Merged #1, #2 into main (abc1234)"), an atomic-rollback note on failure, a distinct message when the user stops watching an in-flight merge, and "Cancelled operation, nothing merged" on cancel. - Clamp every rendered line to the terminal width so resizing no longer leaves duplicated header lines behind, and make truncation ANSI-aware. Async-merge client: - Use the go-gh REST client (c.rest.Put / c.rest.Get) for both the submit and poll endpoints, removing the bespoke http.Client, base-URL helper, and manual response decoding. The REST client discards non-2xx bodies, but that only costs the rare 400 message and 409 UUID: real merge failures still surface through the 200 poll body, and the in-range PRs are validated open, non-draft, and non-merged before submitting. - Add classifyAsyncMergeError to map status codes to clear errors (404 unavailable, 409 already exists, 400 no longer mergeable) and drop the now-unused AsyncMergeResult.StatusCode field. Rework the client tests to drive the REST client through a stub http.RoundTripper. * warn merge queue unsupported * update for new status field from api * merge cmd docs * more helpful error msgs * update to support merge queue * addressing review comments * hide merge method step for merge queue * set merge action explicitly * address review comments to clarify docs on merge/api behavior
1 parent fded736 commit 68ce60c

18 files changed

Lines changed: 3560 additions & 23 deletions

File tree

.github/copilot-instructions.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ No Makefile, no code generation, no external linter config. Standard Go toolchai
1717

1818
- `cmd/`: One Cobra command per file. Each exports `<Name>Cmd(cfg *config.Config)` with logic in `run<Name>()`.
1919
- `internal/git/`: `Ops` interface (52 methods) wrapping git CLI. `MockOps` for tests. Package-level functions delegate to swappable `ops` variable.
20-
- `internal/github/`: `ClientOps` interface (13 methods) for GitHub API. `MockClient` for tests. Stack operations use the public Stacks REST API (`/repos/{owner}/{repo}/stacks`).
20+
- `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.
2121
- `internal/config/`: `Config` struct passed to all commands. Holds I/O, colors, and test hooks (`SelectFn`, `ConfirmFn`, `InputFn`, `GitHubClientOverride`).
2222
- `internal/stack/`: Stack file (`.git/gh-stack`, JSON) management with file locking.
2323
- `internal/tui/`: bubbletea views (`stackview`, `modifyview`).

AGENTS.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ internal/
3535
gitops.go # Ops interface (52 methods)
3636
mock_ops.go # MockOps. Each method has a corresponding *Fn field.
3737
github/ # github.ClientOps interface + real Client
38-
client_interface.go # ClientOps interface (13 methods)
38+
client_interface.go # ClientOps interface (18 methods)
3939
mock_client.go # MockClient. Uses function-pointer fields for testing.
4040
stack/ # stack file (.git/gh-stack) management, JSON schema, locking
4141
schema.json # JSON Schema for the stack file format
@@ -57,7 +57,7 @@ skills/ # AI agent skill definition (SKILL.md)
5757
| Group | Commands |
5858
|-------|----------|
5959
| Stack management | `init`, `add`, `view`, `checkout`, `modify`, `unstack` |
60-
| Remote operations | `submit`, `sync`, `rebase`, `push`, `link` |
60+
| Remote operations | `submit`, `sync`, `rebase`, `push`, `link`, `merge` |
6161
| Navigation | `switch`, `up`, `down`, `top`, `bottom`, `trunk` |
6262
| Utilities | `alias`, `feedback` |
6363

@@ -109,7 +109,7 @@ if errors.As(err, &exitErr) { ... }
109109
### Key interfaces
110110

111111
- **`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.
112-
- **`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.
112+
- **`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.
113113
- **`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`).
114114

115115
### Stack file

README.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,46 @@ gh stack link 42 43 feature-auth feature-ui
439439
gh stack link --base develop --open feat-a feat-b feat-c
440440
```
441441

442+
### `gh stack merge`
443+
444+
Merge one or multiple stacked PRs at once.
445+
446+
```
447+
gh stack merge [<stack-number> | <pr-number>]
448+
```
449+
450+
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.
451+
452+
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.
453+
454+
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.
455+
456+
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.
457+
458+
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.
459+
460+
| Flag | Description |
461+
|------|-------------|
462+
| `--merge-method <method>` | Merge method to use: `merge`, `squash`, or `rebase` |
463+
| `--merge` / `--squash` / `--rebase` | Shorthands for the corresponding merge method |
464+
| `-y, --yes` | Merge without prompting for confirmation |
465+
466+
**Examples:**
467+
468+
```sh
469+
# Merge the current stack (interactive picker)
470+
gh stack merge
471+
472+
# Merge a stack you don't have checked out, by stack number
473+
gh stack merge 7
474+
475+
# Merge everything up to and including PR #42
476+
gh stack merge 42
477+
478+
# Merge the whole current stack without prompting, squashing
479+
gh stack merge --yes --squash
480+
```
481+
442482
### `gh stack view`
443483

444484
View the current stack.

0 commit comments

Comments
 (0)