Skip to content
Merged
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
14 changes: 10 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,7 @@ Performs a synchronization of the entire stack:
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).
5. **Push** — pushes all branches atomically by default (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.
Expand All @@ -390,6 +390,7 @@ In a non-interactive terminal, a divergence aborts the sync (exit success) witho
|------|-------------|
| `--remote <name>` | Remote to fetch from and push to (defaults to auto-detected remote) |
| `--prune` | Delete local branches for merged PRs |
| `--no-atomic` | Allow partial branch updates |

**Examples:**

Expand All @@ -408,11 +409,14 @@ Push active branches in the current stack to the remote.
gh stack push [flags]
```

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.
Pushes every active branch (excluding merged and queued branches) in one atomic `git push` using explicit per-branch `--force-with-lease` checks. All branch updates succeed or fail together. This command does not create or update pull requests — use `gh stack submit` for that.

All pushing commands (`push`, `submit`, `sync`, and `link`) accept `--no-atomic` to allow partial branch updates. Set `GH_STACK_NO_ATOMIC=1` to opt out by default; any non-empty value disables atomic pushes. An explicit `--no-atomic=false` overrides the environment. Failed atomic pushes never automatically retry non-atomically.

| Flag | Description |
|------|-------------|
| `--remote <name>` | Remote to push to (defaults to auto-detected remote) |
| `--no-atomic` | Allow partial branch updates |

**Examples:**

Expand All @@ -429,7 +433,7 @@ Push all branches and create/update PRs and the stack on GitHub.
gh stack submit [flags]
```

Creates a Stacked PR for every branch in the stack, pushing branches to the remote.
Pushes all active branches together atomically before creating PRs or setting their final bases. For pending reorders, at-risk PRs temporarily target the trunk while branches are pushed. Atomicity applies to Git branch updates, not PR or stack API operations.

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 will be added to the top of the stack.

Expand All @@ -446,6 +450,7 @@ In the editor, new PRs default to ready for review; flip any PR to draft with th
| `--auto` | Skip the editor and use auto-generated PR titles |
| `--open` | Mark new and existing PRs as ready for review |
| `--remote <name>` | Remote to push to (defaults to auto-detected remote) |
| `--no-atomic` | Allow partial branch updates |

**Examples:**

Expand All @@ -465,7 +470,7 @@ gh stack link [flags] <branch-or-pr> <branch-or-pr> [...]

Creates or updates a stack on GitHub from branch names or PR numbers/URLs. This command does not store 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.
Arguments are provided in stack order (bottom to top). Branch arguments are automatically pushed atomically 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.

If the PRs are not yet in a stack, a new stack is created. If some of the PRs are already in a stack, the existing stack is updated to include the new PRs. Existing PRs are never removed from a stack — the update is additive only.

Expand All @@ -474,6 +479,7 @@ If the PRs are not yet in a stack, a new stack is created. If some of the PRs ar
| `--base <branch>` | 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 <name>` | Remote to push to (defaults to auto-detected remote) |
| `--no-atomic` | Allow partial branch updates |

**Examples:**

Expand Down
2 changes: 1 addition & 1 deletion cmd/checkout.go
Original file line number Diff line number Diff line change
Expand Up @@ -477,7 +477,7 @@ func reconcileAndImportRemoteStack(cfg *config.Config, client github.ClientOps,
}
if base := remoteStack.Base.Ref; base != "" && localStack.Trunk.Branch != base {
cfg.Warningf("GitHub retargeted this stack%s from %s to %s", linkedStackLabel(cfg, remoteStack.Number), localStack.Trunk.Branch, base)
cfg.Printf(" Run `%s` to rebase the stack onto %s", cfg.ColorCyan("gh stack sync"), base)
cfg.Printf(" Run `%s` to rebase the stack onto %s", cfg.ColorAccent("gh stack sync"), base)
} else {
cfg.Successf("Local stack matches remote%s", linkedStackLabel(cfg, remoteStack.Number))
}
Expand Down
9 changes: 7 additions & 2 deletions cmd/link.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cmd
import (
"errors"
"fmt"
"os"
"strconv"

"github.com/cli/go-gh/v2/pkg/api"
Expand All @@ -17,6 +18,7 @@ type linkOptions struct {
base string
open bool
remote string
noAtomic bool
baseChanged bool
}

Expand All @@ -40,11 +42,13 @@ command first checks if a PR with that number exists; if not, it
treats the argument as a branch name. PR URLs are always resolved
as pull requests (never as branch names).

Branch arguments are automatically pushed to the remote before
Branch arguments are automatically pushed atomically 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.

Use --no-atomic to allow partial branch updates.

If the PRs are not yet in a stack, a new stack is created. If some of
the PRs are already in a stack, the existing stack is updated to include
the new PRs (existing PRs are never removed).
Expand Down Expand Up @@ -80,6 +84,7 @@ only when it matches an existing 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)")
cmd.Flags().BoolVar(&opts.noAtomic, "no-atomic", os.Getenv("GH_STACK_NO_ATOMIC") != "", "Disable atomic pushes (also set by GH_STACK_NO_ATOMIC)")

return cmd
}
Expand Down Expand Up @@ -466,7 +471,7 @@ func pushBranchArgs(cfg *config.Config, opts *linkOptions, args []string) error
}

cfg.Printf("Pushing %d %s to %s...", len(branches), plural(len(branches), "branch", "branches"), remote)
if err := git.Push(remote, branches, false, true); err != nil {
if err := git.Push(remote, branches, false, !opts.noAtomic); err != nil {
cfg.Errorf("failed to push branches: %s", err)
return ErrSilent
}
Expand Down
3 changes: 3 additions & 0 deletions cmd/link_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1679,6 +1679,7 @@ func TestLink_UpdateDeletedStack_FallsBackToCreate(t *testing.T) {
}

func TestLink_PushesBranchesBeforeResolution(t *testing.T) {
t.Setenv("GH_STACK_NO_ATOMIC", "")
var pushedBranches []string
var pushedRemote string

Expand All @@ -1689,6 +1690,8 @@ func TestLink_PushesBranchesBeforeResolution(t *testing.T) {
PushFn: func(remote string, branches []string, force, atomic bool) error {
pushedRemote = remote
pushedBranches = branches
assert.False(t, force)
assert.True(t, atomic)
return nil
},
})
Expand Down
12 changes: 7 additions & 5 deletions cmd/push.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cmd

import (
"errors"
"os"

"github.com/github/gh-stack/internal/config"
"github.com/github/gh-stack/internal/git"
Expand All @@ -10,7 +11,8 @@ import (
)

type pushOptions struct {
remote string
remote string
noAtomic bool
}

func PushCmd(cfg *config.Config) *cobra.Command {
Expand All @@ -21,9 +23,8 @@ func PushCmd(cfg *config.Config) *cobra.Command {
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.
Uses explicit per-branch --force-with-lease checks and atomic updates: all
branch updates succeed or fail together. Use --no-atomic to allow partial updates.
Merged and queued branches are automatically skipped.`,
Example: ` # Push active stack branches to the default remote
$ gh stack push
Expand All @@ -36,6 +37,7 @@ Merged and queued branches are automatically skipped.`,
}

cmd.Flags().StringVar(&opts.remote, "remote", "", "Remote to push to (defaults to auto-detected remote)")
cmd.Flags().BoolVar(&opts.noAtomic, "no-atomic", os.Getenv("GH_STACK_NO_ATOMIC") != "", "Disable atomic pushes (also set by GH_STACK_NO_ATOMIC)")

return cmd
}
Expand Down Expand Up @@ -108,7 +110,7 @@ func runPush(cfg *config.Config, opts *pushOptions) error {
// remote yet.
_ = git.FetchBranches(remote, activeBranches)
cfg.Printf("Pushing %d %s to %s...", len(activeBranches), plural(len(activeBranches), "branch", "branches"), remote)
if err := git.Push(remote, activeBranches, true, false); err != nil {
if err := git.Push(remote, activeBranches, true, !opts.noAtomic); err != nil {
cfg.Errorf("failed to push: %s", err)
return ErrSilent
}
Expand Down
63 changes: 62 additions & 1 deletion cmd/push_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"github.com/github/gh-stack/internal/git"
"github.com/github/gh-stack/internal/github"
"github.com/github/gh-stack/internal/stack"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
Expand All @@ -24,6 +25,7 @@ func newPushMock(tmpDir string, currentBranch string) *git.MockOps {
}

func TestPush_PushesAllBranches(t *testing.T) {
t.Setenv("GH_STACK_NO_ATOMIC", "")
s := stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
Expand Down Expand Up @@ -62,11 +64,70 @@ func TestPush_PushesAllBranches(t *testing.T) {
assert.Equal(t, "origin", pushCalls[0].remote)
assert.Equal(t, []string{"b1", "b2"}, pushCalls[0].branches)
assert.True(t, pushCalls[0].force)
assert.False(t, pushCalls[0].atomic)
assert.True(t, pushCalls[0].atomic)
Comment thread
skarim marked this conversation as resolved.
assert.Contains(t, output, "Pushed 2 branches")
assert.Contains(t, output, "gh stack submit", "should hint about submit when branches have no PRs")
}

func TestPush_AtomicOptions(t *testing.T) {
for _, command := range []struct {
name string
newCmd func(*config.Config) *cobra.Command
args []string
force bool
wantErr error
}{
{"push", PushCmd, nil, true, ErrSilent},
{"submit", SubmitCmd, []string{"--auto"}, true, ErrSilent},
{"sync", SyncCmd, nil, false, nil},
{"link", LinkCmd, []string{"b1", "b2"}, false, ErrSilent},
} {
for _, tc := range []struct {
name string
env string
args []string
atomic bool
}{
{"default", "", nil, true},
{"flag", "", []string{"--no-atomic"}, false},
{"environment", "1", nil, false},
{"flag overrides environment", "1", []string{"--no-atomic=false"}, true},
} {
t.Run(command.name+"/"+tc.name, func(t *testing.T) {
t.Setenv("GH_STACK_NO_ATOMIC", tc.env)
dir := t.TempDir()
writeStackFile(t, dir, stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}},
})
var pushes []pushCall
mock := newSyncMock(dir, "b1")
mock.RootDirFn = func() (string, error) { return dir, nil }
mock.PushFn = func(remote string, branches []string, force, atomic bool) error {
pushes = append(pushes, pushCall{remote, branches, force, atomic})
return assert.AnError
}
restore := git.SetOps(mock)
defer restore()

cfg, outR, errR := config.NewTestConfig()
cfg.GitHubClientOverride = &github.MockClient{}
cmd := command.newCmd(cfg)
args := append([]string(nil), command.args...)
cmd.SetArgs(append(args, tc.args...))
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
err := cmd.Execute()
_, output := commandOutput(t, cfg, outR, errR)

require.ErrorIs(t, err, command.wantErr, output)
require.Len(t, pushes, 1, "failed pushes must not retry non-atomically")
assert.Equal(t, pushCall{"origin", []string{"b1", "b2"}, command.force, tc.atomic}, pushes[0])
})
}
}
}

func TestPush_NoSubmitHintWhenPRsExist(t *testing.T) {
s := stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Expand Down
67 changes: 53 additions & 14 deletions cmd/submit.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cmd
import (
"errors"
"fmt"
"os"
"slices"
"strconv"
"strings"
Expand All @@ -22,9 +23,10 @@ import (
)

type submitOptions struct {
auto bool
open bool
remote string
auto bool
open bool
remote string
noAtomic bool
}

func SubmitCmd(cfg *config.Config) *cobra.Command {
Expand All @@ -46,11 +48,13 @@ 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
1. Pushes all active branches to the remote atomically
2. Creates new PRs for the included branches
3. Updates base branches for existing PRs
4. Creates or updates the stack on GitHub

Use --no-atomic to allow partial branch updates. PR updates are not atomic.

In the editor, new PRs default to ready for review; switch any to draft with the
"CREATE AS" toggle. With --auto, new PRs are created as drafts unless you pass
--open.`,
Expand All @@ -70,6 +74,7 @@ In the editor, new PRs default to ready for review; switch any to draft with the
cmd.Flags().BoolVar(&opts.auto, "auto", false, "Use auto-generated PR titles without prompting")
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)")
cmd.Flags().BoolVar(&opts.noAtomic, "no-atomic", os.Getenv("GH_STACK_NO_ATOMIC") != "", "Disable atomic pushes (also set by GH_STACK_NO_ATOMIC)")

return cmd
}
Expand Down Expand Up @@ -237,23 +242,28 @@ func runSubmit(cfg *config.Config, opts *submitOptions) error {
drafts = collected
}

// Push each branch and create/update its PR in stack order (bottom to top).
// Sequential pushing ensures each branch's base is up-to-date on the
// remote before the next branch is pushed, preventing race conditions.
cfg.Printf("Pushing to %s...", remote)
if pendingModify {
if err := prepareReorderedPRs(cfg, client, s); err != nil {
cfg.Errorf("failed to prepare reordered PRs: %s", err)
return ErrSilent
}
}

if len(activeBranches) > 0 {
cfg.Printf("Pushing to %s...", remote)
if err := git.Push(remote, activeBranches, true, !opts.noAtomic); err != nil {
Comment thread
skarim marked this conversation as resolved.
cfg.Errorf("failed to push: %s", err)
return ErrSilent
}
}

allPRsSubmitted := len(queued) == 0
submittedPRs := make(map[string]int, len(activeBranches))
for i, b := range s.Branches {
if s.Branches[i].IsMerged() || s.Branches[i].IsQueued() {
continue
}

// Push this branch
if err := git.Push(remote, []string{b.Branch}, true, false); err != nil {
cfg.Errorf("failed to push %s: %s", b.Branch, err)
return ErrSilent
}

// Find or create PR, and fix base if needed
baseBranch := s.ActiveBaseBranch(b.Branch)
submitted, err := ensurePR(cfg, client, s, i, baseBranch, opts, templateContent, drafts)
Expand Down Expand Up @@ -304,6 +314,35 @@ func runSubmit(cfg *config.Config, opts *submitOptions) error {
return nil
}

// Reversed PR relationships can cause indirect merges when the new heads are
// pushed. Use the unchanged trunk until ensurePR restores the intended bases.
func prepareReorderedPRs(cfg *config.Config, client github.ClientOps, s *stack.Stack) error {
for i, b := range s.Branches {
if b.IsSkipped() {
continue
}
pr, err := client.FindPRForBranch(b.Branch)
if err != nil {
return fmt.Errorf("looking up PR for %s: %w", b.Branch, err)
}
if pr == nil || s.IndexOf(pr.BaseRefName) <= i {
continue
}
if pr.IsAutoMergeEnabled() {
if err := client.DisableAutoMerge(pr.ID); err != nil {
return fmt.Errorf("disabling auto-merge for PR #%d: %w", pr.Number, err)
}
cfg.Warningf("Disabled auto-merge for PR %s (incompatible with stacked PRs)",
cfg.PRLink(pr.Number, pr.URL))
}
if err := client.UpdatePRBase(pr.Number, s.Trunk.Branch); err != nil {
return fmt.Errorf("temporarily setting base of PR #%d to %s: %w", pr.Number, s.Trunk.Branch, err)
}
cfg.Successf("Temporarily set base of PR %s to %s", cfg.PRLink(pr.Number, pr.URL), s.Trunk.Branch)
}
return nil
}

// collectPRDrafts loads branch display data and runs the interactive submit TUI
// so the user can choose which new branches become PRs and draft each one. It
// returns the per-branch overrides, whether the user cancelled, and any error.
Expand Down
Loading
Loading