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
6 changes: 6 additions & 0 deletions cmd/link_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1591,13 +1591,17 @@ func TestLink_UpdateDeletedStack_FallsBackToCreate(t *testing.T) {
func TestLink_PushesBranchesBeforeResolution(t *testing.T) {
var pushedBranches []string
var pushedRemote string
var pushedForce bool
var pushedAtomic bool

restore := git.SetOps(&git.MockOps{
BranchExistsFn: func(name string) bool { return name == "feat-a" || name == "feat-b" },
ResolveRemoteFn: func(string) (string, error) { return "origin", nil },
PushFn: func(remote string, branches []string, force, atomic bool) error {
pushedRemote = remote
pushedBranches = branches
pushedForce = force
pushedAtomic = atomic
return nil
},
})
Expand Down Expand Up @@ -1633,6 +1637,8 @@ func TestLink_PushesBranchesBeforeResolution(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, "origin", pushedRemote)
assert.Equal(t, []string{"feat-a", "feat-b"}, pushedBranches)
assert.False(t, pushedForce)
assert.True(t, pushedAtomic)
assert.Contains(t, output, "Pushing 2 branches")
}

Expand Down
13 changes: 9 additions & 4 deletions cmd/push.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (

type pushOptions struct {
remote string
atomic bool
}

func PushCmd(cfg *config.Config) *cobra.Command {
Expand All @@ -22,13 +23,16 @@ 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. By default, updates are not
atomic: a branch may update even if another branch is rejected. Use --atomic
to require all branch updates to succeed or fail together.
Merged and queued branches are automatically skipped.`,
Example: ` # Push active stack branches to the default remote
$ gh stack push

# Push all active branches atomically
$ gh stack push --atomic

# Push to a specific remote
$ gh stack push --remote upstream`,
RunE: func(cmd *cobra.Command, args []string) error {
Expand All @@ -37,6 +41,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.atomic, "atomic", false, "Require all branch updates to succeed or fail together (default: disabled)")

return cmd
}
Expand Down Expand Up @@ -107,7 +112,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.atomic); err != nil {
cfg.Errorf("failed to push: %s", err)
return ErrSilent
}
Expand Down
36 changes: 36 additions & 0 deletions cmd/push_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,42 @@ func TestPush_PushesAllBranches(t *testing.T) {
assert.Contains(t, output, "gh stack submit", "should hint about submit when branches have no PRs")
}

func TestPush_Atomic(t *testing.T) {
s := stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "b1"},
{Branch: "b2"},
},
}

tmpDir := t.TempDir()
writeStackFile(t, tmpDir, s)

var pushCalls []pushCall
mock := newPushMock(tmpDir, "b1")
mock.PushFn = func(remote string, branches []string, force, atomic bool) error {
pushCalls = append(pushCalls, pushCall{remote, branches, force, atomic})
return nil
}

restore := git.SetOps(mock)
defer restore()

cfg, _, _ := config.NewTestConfig()
cfg.GitHubClientOverride = &github.MockClient{}
cmd := PushCmd(cfg)
cmd.SetArgs([]string{"--atomic"})
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)

require.NoError(t, cmd.Execute())
require.Len(t, pushCalls, 1)
assert.Equal(t, []string{"b1", "b2"}, pushCalls[0].branches)
assert.True(t, pushCalls[0].force)
assert.True(t, pushCalls[0].atomic)
}

func TestPush_NoSubmitHintWhenPRsExist(t *testing.T) {
s := stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Expand Down
9 changes: 7 additions & 2 deletions cmd/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
type syncOptions struct {
remote string
prune bool
atomic bool
}

func SyncCmd(cfg *config.Config) *cobra.Command {
Expand All @@ -34,11 +35,14 @@ This command performs a safe synchronization:
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)
5. Pushes all branches atomically by default
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

Atomic sync uses --atomic and, after a rebase, --force-with-lease. Use
--atomic=false to allow partial branch updates.

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
Expand Down Expand Up @@ -70,6 +74,7 @@ the first active branch in the stack, or the trunk if all are merged.`,

cmd.Flags().StringVar(&opts.remote, "remote", "", "Remote to fetch from and push to (defaults to auto-detected remote)")
cmd.Flags().BoolVar(&opts.prune, "prune", false, "Delete local branches for merged PRs")
cmd.Flags().BoolVar(&opts.atomic, "atomic", true, "Require all branch updates to succeed or fail together")

return cmd
}
Expand Down Expand Up @@ -239,7 +244,7 @@ func runSync(cfg *config.Config, opts *syncOptions) error {
// Without rebase, try a normal push first.
force := rebased
cfg.Printf("Pushing %d %s to %s...", len(branches), plural(len(branches), "branch", "branches"), remote)
if err := git.Push(remote, branches, force, true); err != nil {
if err := git.Push(remote, branches, force, opts.atomic); err != nil {
if !force {
cfg.Warningf("Push failed — branches may need force push after rebase")
cfg.Printf(" Run `%s` to push with --force-with-lease.",
Expand Down
50 changes: 50 additions & 0 deletions cmd/sync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,54 @@ func TestSync_TrunkAlreadyUpToDate(t *testing.T) {
// Push should happen without force
require.Len(t, pushCalls, 1)
assert.False(t, pushCalls[0].force, "push should not use force when no rebase occurred")
assert.True(t, pushCalls[0].atomic, "sync should push atomically by default")
}

func TestSync_AtomicFlag(t *testing.T) {
tests := []struct {
name string
args []string
wantAtomic bool
}{
{name: "explicit atomic", args: []string{"--atomic"}, wantAtomic: true},
{name: "atomic disabled", args: []string{"--atomic=false"}, wantAtomic: false},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "b1"},
{Branch: "b2"},
},
}

tmpDir := t.TempDir()
writeStackFile(t, tmpDir, s)

var pushCalls []pushCall
mock := newSyncMock(tmpDir, "b1")
mock.PushFn = func(remote string, branches []string, force, atomic bool) error {
pushCalls = append(pushCalls, pushCall{remote, branches, force, atomic})
return nil
}

restore := git.SetOps(mock)
defer restore()

cfg, _, _ := config.NewTestConfig()
cmd := SyncCmd(cfg)
cmd.SetArgs(tt.args)
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)

require.NoError(t, cmd.Execute())
require.Len(t, pushCalls, 1)
assert.Equal(t, []string{"b1", "b2"}, pushCalls[0].branches)
assert.Equal(t, tt.wantAtomic, pushCalls[0].atomic)
})
}
}

// TestSync_TrunkUpToDate_StackStale verifies that when trunk is already up to
Expand Down Expand Up @@ -251,6 +299,7 @@ func TestSync_TrunkFastForward_TriggersRebase(t *testing.T) {

cfg, _, errR := config.NewTestConfig()
cmd := SyncCmd(cfg)
cmd.SetArgs([]string{"--atomic=false"})
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
err := cmd.Execute()
Expand All @@ -273,6 +322,7 @@ func TestSync_TrunkFastForward_TriggersRebase(t *testing.T) {

// Push should use force-with-lease after rebase
require.Len(t, pushCalls, 1)
assert.False(t, pushCalls[0].atomic, "atomic option should apply to force pushes")
assert.True(t, pushCalls[0].force, "push should use force-with-lease after rebase")
}

Expand Down
10 changes: 8 additions & 2 deletions docs/src/content/docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,7 @@ gh stack sync [flags]

| Flag | Description |
|------|-------------|
| `--atomic` | Require all branch updates to succeed or fail together (enabled by default; use `--atomic=false` to disable) |
| `--remote <name>` | Remote to fetch from and push to (defaults to auto-detected remote) |
| `--prune` | Delete local branches for merged PRs |

Expand All @@ -313,7 +314,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** 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). Use `--atomic=false` to allow branches whose updates succeed to proceed when another branch is rejected.
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 @@ -335,6 +336,9 @@ In a non-interactive terminal, a divergence aborts the sync (exit success) witho
```sh
gh stack sync

# Explicitly allow partial branch updates
gh stack sync --atomic=false

# Sync and automatically prune merged branches
gh stack sync --prune
```
Expand Down Expand Up @@ -402,14 +406,16 @@ gh stack push [flags]

| Flag | Description |
|------|-------------|
| `--atomic` | Require all branch updates to succeed or fail together (disabled by default) |
| `--remote <name>` | 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.
Pushes every active branch (excluding merged and queued branches) in one `git push` using explicit per-branch `--force-with-lease` checks. By default, the update is not atomic: branches whose leases pass may update even if another branch is rejected. Use `--atomic` to make the multi-ref push all-or-nothing. This command does not create or update pull requests — use `gh stack submit` for that.

**Examples:**

```sh
gh stack push
gh stack push --atomic
gh stack push --remote upstream
```

Expand Down
52 changes: 52 additions & 0 deletions internal/git/gitops_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,58 @@ func TestIntegration_Push_RemoteAdvancedByOther(t *testing.T) {
assert.NotEqual(t, otherSHA, finalRemoteSHA, "remote should have advanced past original other SHA")
}

func TestIntegration_Push_AtomicRejectsAllRefs(t *testing.T) {
bareDir, cloneDir := setupBareAndClone(t)
restore := withGitDir(t, cloneDir)
defer restore()

d := &defaultOps{}

gitExec(t, cloneDir, "checkout", "-b", "b1")
writeFile(t, cloneDir, "b1.txt", "v1")
gitExec(t, cloneDir, "add", ".")
gitExec(t, cloneDir, "commit", "-m", "b1 initial")
gitExec(t, cloneDir, "push", "origin", "b1")

gitExec(t, cloneDir, "checkout", "-b", "b2")
writeFile(t, cloneDir, "b2.txt", "v1")
gitExec(t, cloneDir, "add", ".")
gitExec(t, cloneDir, "commit", "-m", "b2 initial")
gitExec(t, cloneDir, "push", "origin", "b2")

require.NoError(t, d.FetchBranches("origin", []string{"b1", "b2"}))
remoteB1Before := remoteBranchSHA(t, bareDir, "b1")

gitExec(t, cloneDir, "checkout", "b1")
writeFile(t, cloneDir, "b1.txt", "local update")
gitExec(t, cloneDir, "add", ".")
gitExec(t, cloneDir, "commit", "-m", "b1 local update")
localB1 := gitExec(t, cloneDir, "rev-parse", "b1")
require.NotEqual(t, remoteB1Before, localB1)

gitExec(t, cloneDir, "checkout", "b2")
writeFile(t, cloneDir, "b2.txt", "local update")
gitExec(t, cloneDir, "add", ".")
gitExec(t, cloneDir, "commit", "-m", "b2 local update")

otherClone := filepath.Join(t.TempDir(), "other")
gitExec(t, ".", "clone", bareDir, otherClone)
gitExec(t, otherClone, "checkout", "b2")
writeFile(t, otherClone, "b2.txt", "competing update")
gitExec(t, otherClone, "add", ".")
gitExec(t, otherClone, "commit", "-m", "b2 competing update")
gitExec(t, otherClone, "push", "origin", "b2")
competingB2 := remoteBranchSHA(t, bareDir, "b2")

err := d.Push("origin", []string{"b1", "b2"}, true, true)
require.Error(t, err, "atomic push should fail when one branch has a stale lease")

assert.Equal(t, remoteB1Before, remoteBranchSHA(t, bareDir, "b1"),
"valid branch must not update when another ref is rejected")
assert.Equal(t, competingB2, remoteBranchSHA(t, bareDir, "b2"),
"rejected branch must preserve the competing remote update")
}

// Test 4: Brand-new branch, absent on remote.
// Push should create the branch via empty-expect lease.
func TestIntegration_Push_NewBranchAbsentOnRemote(t *testing.T) {
Expand Down