Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 23 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -316,15 +316,28 @@ Fetch, rebase, push, and sync PR state in a single command.
gh stack sync [flags]
```

Performs a safe, non-interactive synchronization of the entire stack:
Performs a synchronization of the entire stack:

1. **Fetch** — fetches the latest changes from `origin`
2. **Fast-forward trunk** — fast-forwards the trunk branch to match the remote (skips if diverged)
3. **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
4. **Push** — pushes all branches (uses `--force-with-lease` if a rebase occurred)
5. **Sync PRs** — syncs PR state from GitHub and reports the status of each PR
6. **Sync the stack** — links the stack's open PRs into a stack on GitHub, creating the remote stack object if it doesn't exist yet or updating it if it's partially formed. Only happens when two or more PRs exist; sync never opens PRs (use `gh stack submit` for that)
7. **Prune** — in interactive terminals, prompts to delete local branches for merged PRs. Use `--prune` to prune automatically
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).
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).
6. **Sync PRs** — syncs PR state from GitHub and reports the status of each PR.
7. **Sync the stack** — links the stack's open PRs into a stack on GitHub, creating the remote stack object if it doesn't exist yet or updating it if it's partially formed. This only happens when two or more PRs exist; sync never opens PRs (use `gh stack submit` for that).
8. **Prune** — in interactive terminals, prompts to delete local branches for merged PRs. Use `--prune` to prune automatically.

A clean remote-ahead update (PRs added on top of your local stack) is pulled down automatically without prompting, so `sync` is safe to run in automation. Sync only prompts when the stacks have truly diverged.

#### Diverged stacks

When neither stack is a clean prefix of the other — for example, you added a branch locally while separate PRs were added to the same stack on GitHub — sync cannot merge the two automatically. In an interactive terminal it offers three choices:

- **Use the remote stack as the source of truth** — replaces your local stack composition with the remote's, pulling any missing branches. If you were on a branch that the remote stack no longer contains, you're moved to the nearest surviving branch. Requires a clean working state with no uncommitted changes.
- **Delete the stack on GitHub** — deletes the stack object on GitHub and stops the sync. Your PRs and local branches are untouched (only the stack on GitHub is removed); recreate the stack with `gh stack submit` (run `gh stack modify` first if you want to change its structure). This is the way to make GitHub match your local stack, because `submit` — unlike `sync` — also creates PRs for any branches you haven't submitted yet.
- **Cancel** — aborts the sync without pushing branches or updating any PRs.

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 |
|------|-------------|
Expand Down Expand Up @@ -377,6 +390,8 @@ If every PR in the stack has already been merged, that stack is complete and can

In an interactive terminal, `submit` opens a full-screen, mouse- and keyboard-driven editor on a single screen. Every branch without a PR is included by default — deselect any you don't want on the left panel (<kbd>Ctrl</kbd>+<kbd>X</kbd>). Because each PR builds on the branch below it, deselecting a branch also deselects the ones stacked above it, and re-including a branch re-includes the ones below it. Draft each PR's title, description (with a markdown preview and `$EDITOR` escape), and choose ready-for-review or draft on the right, then submit them all at once with <kbd>Ctrl</kbd>+<kbd>S</kbd>. Pass `--auto` (or run in CI) to skip the editor and use auto-generated titles.

If the branches already have open PRs but no stack exists on GitHub, you will have the option to link the PRs into a stack with <kbd>Ctrl</kbd>+<kbd>B</kbd>.

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 |
Expand Down
16 changes: 2 additions & 14 deletions cmd/checkout.go
Original file line number Diff line number Diff line change
Expand Up @@ -491,21 +491,9 @@ func importRemoteStack(
// Skip merged PRs whose branches were deleted from the remote —
// these no longer exist upstream and can't be created locally.
for _, pr := range prs {
branch := pr.HeadRefName
if git.BranchExists(branch) {
continue
}
remoteRef := remote + "/" + branch
if err := git.CreateBranch(branch, remoteRef); err != nil {
if pr.Merged {
cfg.Infof("Skipping merged branch %s", branch)
continue
}
cfg.Errorf("failed to pull branch %s from %s: %v", branch, remoteRef, err)
return nil, ErrSilent
if _, err := ensureLocalBranchFromRemote(cfg, remote, pr); err != nil {
return nil, err
}
_ = git.SetUpstreamTracking(branch, remote)
cfg.Successf("Pulled branch %s", branch)
}

// Build the stack
Expand Down
18 changes: 12 additions & 6 deletions cmd/submit.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ and draft each PR's title, description, and draft state, then submit them all at
once with Ctrl+S. Pass --auto (or run in a non-interactive terminal) to skip the
editor and use auto-generated titles.

If your branches already have open PRs but no stack on GitHub yet (for example,
after deleting the stack) and you deselect every new PR, press Ctrl+B (or click
the "STACK N PRs" button) to link the existing open PRs into a stack.

This command performs several steps:
1. Pushes all branches to the remote
2. Creates new PRs for the included branches
Expand Down Expand Up @@ -202,7 +206,8 @@ func runSubmit(cfg *config.Config, opts *submitOptions) error {
// auto-generated titles and bodies (today's behavior).
var drafts map[string]*submitview.PRDraft
if cfg.IsInteractive() && !opts.auto {
collected, cancelled, tuiErr := collectPRDrafts(cfg, client, s, currentBranch, prDetails, templateContent)
canCreateStack := stacksAvailable && s.ID == ""
collected, cancelled, tuiErr := collectPRDrafts(cfg, client, s, currentBranch, prDetails, templateContent, canCreateStack)
Comment thread
skarim marked this conversation as resolved.
if tuiErr != nil {
cfg.Errorf("failed to run the submit editor: %s", tuiErr)
return ErrSilent
Expand Down Expand Up @@ -263,7 +268,7 @@ func runSubmit(cfg *config.Config, opts *submitOptions) error {
// returns the per-branch overrides, whether the user cancelled, and any error.
// When the stack contains no branches without a PR, it skips the TUI and
// returns nil drafts so the normal push/relink path runs.
func collectPRDrafts(cfg *config.Config, client github.ClientOps, s *stack.Stack, currentBranch string, prDetails map[string]*github.PRDetails, templateContent string) (map[string]*submitview.PRDraft, bool, error) {
func collectPRDrafts(cfg *config.Config, client github.ClientOps, s *stack.Stack, currentBranch string, prDetails map[string]*github.PRDetails, templateContent string, canCreateStack bool) (map[string]*submitview.PRDraft, bool, error) {
// Fill in the real title/description for existing PRs that were synced
// without them (e.g. merged branches) so the read-only cards show API data.
enrichPRContent(client, prDetails)
Expand All @@ -290,10 +295,11 @@ func collectPRDrafts(cfg *config.Config, client github.ClientOps, s *stack.Stack
}

model := submitview.New(submitview.Options{
Nodes: nodes,
Trunk: s.Trunk,
RepoLabel: repoLabel,
Version: Version,
Nodes: nodes,
Trunk: s.Trunk,
RepoLabel: repoLabel,
Version: Version,
CanCreateStack: canCreateStack,
})

// Use cell-motion mouse mode (clicks, drag, and wheel) rather than all-motion.
Expand Down
2 changes: 1 addition & 1 deletion cmd/submit_tui_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ func TestCollectPRDrafts_SkipsWhenNoNewBranches(t *testing.T) {
"b2": {Number: 2, State: "OPEN"},
}

drafts, cancelled, err := collectPRDrafts(cfg, nil, s, "b1", prDetails, "")
drafts, cancelled, err := collectPRDrafts(cfg, nil, s, "b1", prDetails, "", false)
require.NoError(t, err)
assert.False(t, cancelled)
assert.Nil(t, drafts, "no NEW branches means the TUI is skipped and drafts are nil")
Expand Down
52 changes: 46 additions & 6 deletions cmd/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,28 @@ func SyncCmd(cfg *config.Config) *cobra.Command {
Short: "Sync the current stack with the remote",
Long: `Fetch, rebase, push, and sync PR state for the current stack.

This command performs a safe, non-interactive synchronization:
This command performs a safe synchronization:

1. Fetches the latest changes from the remote
2. Fast-forwards the trunk branch to match the remote
3. Cascade-rebases stack branches onto their updated parents
4. Pushes all branches atomically (using --force-with-lease --atomic)
5. Syncs PR state from GitHub
6. Links the stack's open PRs into a stack on GitHub (creating or updating
2. Reconciles the stack on GitHub with your local stack: pulls down
branches for any PRs added to the stack on GitHub, or prompts you to
resolve a divergence in an interactive terminal
3. Fast-forwards the trunk branch to match the remote
4. Cascade-rebases stack branches onto their updated parents
5. Pushes all branches atomically (using --force-with-lease --atomic)
6. Syncs PR state from GitHub
7. Links the stack's open PRs into a stack on GitHub (creating or updating
the remote stack object) when two or more PRs exist

If PRs have been added to the stack on GitHub, their branches are pulled
down and appended to your local stack so it mirrors the remote. A clean
"remote is ahead" update happens automatically without prompting. If the
local and remote stacks have diverged, sync prompts (in an interactive
terminal) to use the remote as the source of truth, delete the stack on
GitHub and recreate it later with sync/submit, or cancel. Cancelling — or a
divergence in a non-interactive terminal — aborts the sync without pushing
branches or updating PRs.

If a rebase conflict is detected, all branches are restored to their
original state and you are advised to run "gh stack rebase" to resolve
conflicts interactively.
Expand Down Expand Up @@ -99,6 +111,34 @@ func runSync(cfg *config.Config, opts *syncOptions) error {
_ = git.FetchBranches(remote, fetchTargets)
cfg.Successf("Fetched latest changes from %s", remote)

// --- Step 1b: Reconcile remote-ahead stack changes ---
// Pull in branches for PRs that were added to the stack on GitHub, or
// resolve a divergence, before rebasing and pushing so pulled branches
// participate in the normal flow. Best-effort for stacks tracked on the
// remote; a no-op otherwise.
reconcileRes, err := reconcileRemoteStack(cfg, sf, s, currentBranch, gitDir, remote)
if err != nil {
if errors.Is(err, errInterrupt) {
return ErrSilent
}
return err
}
if reconcileRes.stack != nil {
s = reconcileRes.stack
}
if reconcileRes.stop {
// The reconcile step resolved the situation and there is nothing more to
// do (the user cancelled or deleted the remote stack, or a divergence was
// detected non-interactively). The resolving path already reported the
// outcome, so just exit successfully.
return nil
}
// Reconciling "use remote as source of truth" may have moved us off a
// branch that is no longer in the stack, so re-read the current branch.
if cb, cbErr := git.CurrentBranch(); cbErr == nil {
currentBranch = cb
}

// --- Step 2: Fast-forward trunk ---
trunk := s.Trunk.Branch
trunkUpdated := fastForwardTrunk(cfg, trunk, remote, currentBranch)
Expand Down
Loading
Loading