diff --git a/cmd/link_test.go b/cmd/link_test.go index 96fd1a6..89c9a20 100644 --- a/cmd/link_test.go +++ b/cmd/link_test.go @@ -1591,6 +1591,8 @@ 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" }, @@ -1598,6 +1600,8 @@ func TestLink_PushesBranchesBeforeResolution(t *testing.T) { PushFn: func(remote string, branches []string, force, atomic bool) error { pushedRemote = remote pushedBranches = branches + pushedForce = force + pushedAtomic = atomic return nil }, }) @@ -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") } diff --git a/cmd/push.go b/cmd/push.go index 34bee03..dbd651a 100644 --- a/cmd/push.go +++ b/cmd/push.go @@ -12,6 +12,7 @@ import ( type pushOptions struct { remote string + atomic bool } func PushCmd(cfg *config.Config) *cobra.Command { @@ -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 { @@ -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 } @@ -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 } diff --git a/cmd/push_test.go b/cmd/push_test.go index 059b003..f235fad 100644 --- a/cmd/push_test.go +++ b/cmd/push_test.go @@ -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"}, diff --git a/cmd/sync.go b/cmd/sync.go index c9aeda7..024180c 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -16,6 +16,7 @@ import ( type syncOptions struct { remote string prune bool + atomic bool } func SyncCmd(cfg *config.Config) *cobra.Command { @@ -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 @@ -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 } @@ -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.", diff --git a/cmd/sync_test.go b/cmd/sync_test.go index ee36498..5ef4714 100644 --- a/cmd/sync_test.go +++ b/cmd/sync_test.go @@ -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 @@ -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() @@ -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") } diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index 33084e3..fb04077 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -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 ` | Remote to fetch from and push to (defaults to auto-detected remote) | | `--prune` | Delete local branches for merged PRs | @@ -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. @@ -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 ``` @@ -402,14 +406,16 @@ gh stack push [flags] | Flag | Description | |------|-------------| +| `--atomic` | Require all branch updates to succeed or fail together (disabled by default) | | `--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. +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 ``` diff --git a/internal/git/gitops_test.go b/internal/git/gitops_test.go index 3d5a5f3..a2bd847 100644 --- a/internal/git/gitops_test.go +++ b/internal/git/gitops_test.go @@ -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) {