From d183e8e43349ba61f90a9e23e74d6d42b2e51f48 Mon Sep 17 00:00:00 2001 From: Christopher Hiller Date: Mon, 25 May 2026 19:43:04 -0700 Subject: [PATCH 1/8] feat: batch push branches This changes the behavior of `gh stack submit` so that it pushes all branches at once, atomically. This improves performance. --- AGENTS.md | 4 + README.md | 2 +- cmd/submit.go | 14 ++-- e2e/submit_test.go | 41 ++++++++++ internal/git/git.go | 17 +++- internal/git/git_test.go | 163 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 232 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e851ac8..8fa1463 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,3 +65,7 @@ Uses `go-gh` library (not subprocess calls to `gh`): ### Git Operations `internal/git` uses `safeexec.LookPath("git")` to find git securely (prevents PATH injection on Windows). The path is cached with `sync.Once`. + +## Generated Files + +- `CHANGELOG.md` is automatically generated by Release Please and should never be edited manually. diff --git a/README.md b/README.md index 16f9e04..a604bce 100644 --- a/README.md +++ b/README.md @@ -268,7 +268,7 @@ Restack, push, and create/update PRs for the entire stack. This is the primary workflow command. By default it processes **every tracked branch** in parent-before-child order. It performs three phases: 1. **Restack**: Rebase affected branches onto their parents -2. **Push**: Force-push all affected branches (using `--force-with-lease`) +2. **Push**: Force-push all affected branches in a single atomic `git push` (`--force-with-lease --atomic`); if any ref is rejected the push fails immediately and no refs are updated 3. **PR**: Create PRs for branches without them; update PR bases for existing PRs PRs targeting non-trunk branches are created as drafts. When a PR's base changes to trunk (after its parent merges), you'll be prompted to mark it ready for review. diff --git a/cmd/submit.go b/cmd/submit.go index 444ac84..1929080 100644 --- a/cmd/submit.go +++ b/cmd/submit.go @@ -285,6 +285,7 @@ func doSubmitPushAndPR(g *git.Git, cfg *config.Config, root *tree.Node, branches // Phase 2: Push branches that will participate in PRs (or all if --skip-prs). fmt.Println(s.Bold("\n=== Phase 2: Push ===")) + var toPush []string for _, b := range branches { var d *prDecision if !opts.PushOnly { @@ -298,12 +299,13 @@ func doSubmitPushAndPR(g *git.Git, cfg *config.Config, root *tree.Node, branches if opts.DryRun { fmt.Printf("%s Would push %s -> origin/%s (forced)\n", s.Muted("dry-run:"), s.Branch(b.Name), s.Branch(b.Name)) } else { - fmt.Printf("Pushing %s -> origin/%s (forced)... ", s.Branch(b.Name), s.Branch(b.Name)) - if err := g.Push(b.Name, true); err != nil { - fmt.Println(s.Error("failed")) - return fmt.Errorf("failed to push %s: %w", b.Name, err) - } - fmt.Println(s.Success("ok")) + toPush = append(toPush, b.Name) + } + } + if !opts.DryRun && len(toPush) > 0 { + fmt.Printf("Pushing %s to origin (force-with-lease, atomic)...\n", strings.Join(toPush, ", ")) + if err := g.PushMany(toPush, true); err != nil { + return fmt.Errorf("push failed: %w", err) } } diff --git a/e2e/submit_test.go b/e2e/submit_test.go index 3225d07..43db415 100644 --- a/e2e/submit_test.go +++ b/e2e/submit_test.go @@ -491,3 +491,44 @@ func TestSubmitFromTrunkFallback(t *testing.T) { t.Error("should not push trunk branch") } } + +// TestSubmitBatchPush verifies that a multi-branch submit advances all remote +// refs in one atomic push and emits the expected summary line. +func TestSubmitBatchPush(t *testing.T) { + env := NewTestEnvWithRemote(t) + env.MustRun("init") + + // Build a 3-branch stack: main -> feat-a -> feat-b -> feat-c + env.MustRun("create", "feat-a") + tipA := env.CreateCommit("a work") + + env.MustRun("create", "feat-b") + tipB := env.CreateCommit("b work") + + env.MustRun("create", "feat-c") + tipC := env.CreateCommit("c work") + + result := env.MustRun("submit", "--skip-prs", "--yes") + + // Output should mention the batch push summary + if !strings.Contains(result.Stdout, "Pushing") { + t.Error("expected batch push summary line in output") + } + if !strings.Contains(result.Stdout, "atomic") { + t.Error("expected 'atomic' in push summary line") + } + + // All three remote refs must match local tips + remoteA := env.GitRemote("rev-parse", "refs/heads/feat-a") + if remoteA != tipA { + t.Errorf("remote feat-a = %s, want %s", remoteA, tipA) + } + remoteB := env.GitRemote("rev-parse", "refs/heads/feat-b") + if remoteB != tipB { + t.Errorf("remote feat-b = %s, want %s", remoteB, tipB) + } + remoteC := env.GitRemote("rev-parse", "refs/heads/feat-c") + if remoteC != tipC { + t.Errorf("remote feat-c = %s, want %s", remoteC, tipC) + } +} diff --git a/internal/git/git.go b/internal/git/git.go index 17c9efe..78dd32e 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -175,9 +175,22 @@ func (g *Git) Commit(message string) error { // Push force-pushes a branch to origin with lease. func (g *Git) Push(branch string, force bool) error { - args := []string{"push", "origin", branch} + return g.PushMany([]string{branch}, force) +} + +// PushMany pushes multiple branches to origin in a single invocation. +// When force is true, --force-with-lease and --atomic are added so that the +// push is all-or-nothing: if any ref is rejected (e.g. lease conflict), none +// of the refs are updated on the remote. +// Returns nil immediately when branches is empty. +func (g *Git) PushMany(branches []string, force bool) error { + if len(branches) == 0 { + return nil + } + args := []string{"push", "origin"} + args = append(args, branches...) if force { - args = append(args, "--force-with-lease") + args = append(args, "--force-with-lease", "--atomic") } return g.runInteractive(args...) } diff --git a/internal/git/git_test.go b/internal/git/git_test.go index d68b2e9..ed72545 100644 --- a/internal/git/git_test.go +++ b/internal/git/git_test.go @@ -904,3 +904,166 @@ func TestRebaseNoUpdateRefsPreservesBookmark(t *testing.T) { t.Errorf("expected bookmark to be preserved with --no-update-refs, but it moved from %s to %s", bookmarkBefore, bookmarkAfter) } } + +// setupRepoWithRemote creates a local repo + bare remote and returns (localDir, remoteDir, Git). +// The trunk branch is pushed to the remote and the remote is set as "origin". +func setupRepoWithRemote(t *testing.T) (dir, remoteDir string, g *git.Git, trunk string) { + t.Helper() + dir = t.TempDir() + + run := func(args ...string) string { + cmd := exec.Command("git", args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v failed: %v\n%s", args, err, out) + } + return strings.TrimSpace(string(out)) + } + + run("init", "-b", "main") + run("config", "user.email", "test@test.com") + run("config", "user.name", "Test") + os.WriteFile(filepath.Join(dir, "root"), []byte("root"), 0644) + run("add", ".") + run("commit", "-m", "root") + + remoteDir = t.TempDir() + exec.Command("git", "clone", "--bare", dir, remoteDir).Run() //nolint:errcheck + run("remote", "add", "origin", remoteDir) + run("push", "-u", "origin", "main") + + g = git.New(dir) + trunk = "main" + return dir, remoteDir, g, trunk +} + +// remoteRef returns the SHA that a ref points to on the bare remote, or "" on error. +func remoteRef(t *testing.T, remoteDir, branch string) string { + t.Helper() + out, err := exec.Command("git", "-C", remoteDir, "rev-parse", "refs/heads/"+branch).Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +func TestPushManyHappyPath(t *testing.T) { + dir, remoteDir, g, _ := setupRepoWithRemote(t) + + run := func(args ...string) { + cmd := exec.Command("git", args...) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v failed: %v\n%s", args, err, out) + } + } + + // Create feat-a + run("checkout", "-b", "feat-a") + os.WriteFile(filepath.Join(dir, "a"), []byte("a"), 0644) + run("add", ".") + run("commit", "-m", "a") + tipA, _ := g.GetTip("feat-a") + + // Create feat-b on top + run("checkout", "-b", "feat-b") + os.WriteFile(filepath.Join(dir, "b"), []byte("b"), 0644) + run("add", ".") + run("commit", "-m", "b") + tipB, _ := g.GetTip("feat-b") + + // Both branches are local-only at this point; PushMany should create them on remote. + if err := g.PushMany([]string{"feat-a", "feat-b"}, false); err != nil { + t.Fatalf("PushMany failed: %v", err) + } + + if got := remoteRef(t, remoteDir, "feat-a"); got != tipA { + t.Errorf("remote feat-a = %s, want %s", got, tipA) + } + if got := remoteRef(t, remoteDir, "feat-b"); got != tipB { + t.Errorf("remote feat-b = %s, want %s", got, tipB) + } +} + +func TestPushManyEmpty(t *testing.T) { + _, _, g, _ := setupRepoWithRemote(t) + // Should be a no-op with no error. + if err := g.PushMany(nil, true); err != nil { + t.Fatalf("PushMany(nil) returned unexpected error: %v", err) + } + if err := g.PushMany([]string{}, true); err != nil { + t.Fatalf("PushMany([]) returned unexpected error: %v", err) + } +} + +// TestPushManyAtomicRejection verifies that when one branch fails --force-with-lease, +// the --atomic flag prevents the other branch from being updated on the remote. +func TestPushManyAtomicRejection(t *testing.T) { + dir, remoteDir, g, _ := setupRepoWithRemote(t) + + run := func(args ...string) { + cmd := exec.Command("git", args...) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v failed: %v\n%s", args, err, out) + } + } + + // Create and push feat-a + run("checkout", "-b", "feat-a") + os.WriteFile(filepath.Join(dir, "a"), []byte("a"), 0644) + run("add", ".") + run("commit", "-m", "a") + run("push", "origin", "feat-a") + + // Create feat-b (will be pushed clean — no prior remote ref) + run("checkout", "main") + run("checkout", "-b", "feat-b") + os.WriteFile(filepath.Join(dir, "b"), []byte("b"), 0644) + run("add", ".") + run("commit", "-m", "b") + tipBLocal, _ := g.GetTip("feat-b") + run("push", "origin", "feat-b") + + // Diverge the remote's feat-a by adding a commit there directly. + // This will cause the force-with-lease for feat-a to be rejected. + exec.Command("git", "-C", remoteDir, "config", "--local", "receive.denyNonFastForwards", "false").Run() //nolint:errcheck + os.WriteFile(filepath.Join(remoteDir, "diverged"), []byte("diverged"), 0644) + exec.Command("git", "-C", remoteDir, "update-ref", "refs/heads/feat-a", + // Point feat-a on remote to a newly-created orphan commit so the lease check fails. + // Easiest: use fast-import to write a new root commit. + // Simpler approach: just push a new commit from a temp clone. + "HEAD").Run() //nolint:errcheck + + // Simpler divergence: commit directly to feat-a on the remote via a temp clone + tmp := t.TempDir() + exec.Command("git", "clone", remoteDir, tmp).Run() //nolint:errcheck + os.WriteFile(filepath.Join(tmp, "remote-diverge"), []byte("x"), 0644) + exec.Command("git", "-C", tmp, "config", "user.email", "t@t.com").Run() //nolint:errcheck + exec.Command("git", "-C", tmp, "config", "user.name", "T").Run() //nolint:errcheck + exec.Command("git", "-C", tmp, "checkout", "feat-a").Run() //nolint:errcheck + exec.Command("git", "-C", tmp, "add", ".").Run() //nolint:errcheck + exec.Command("git", "-C", tmp, "commit", "-m", "diverge").Run() //nolint:errcheck + exec.Command("git", "-C", tmp, "push", "origin", "feat-a").Run() //nolint:errcheck + + // Add a new commit to feat-b locally (so we're trying to advance it) + run("checkout", "feat-b") + os.WriteFile(filepath.Join(dir, "b2"), []byte("b2"), 0644) + run("add", ".") + run("commit", "-m", "b2") + tipBNew, _ := g.GetTip("feat-b") + _ = tipBLocal // original tip before the new commit + + // PushMany should fail because feat-a's lease is broken. + err := g.PushMany([]string{"feat-a", "feat-b"}, true) + if err == nil { + t.Fatal("expected PushMany to fail due to lease rejection on feat-a, but it succeeded") + } + + // Due to --atomic, feat-b must NOT have advanced on the remote. + remoteB := remoteRef(t, remoteDir, "feat-b") + if remoteB == tipBNew { + t.Errorf("remote feat-b advanced to %s despite atomic failure — --atomic is not working", tipBNew) + } +} From a0f96998a311e6b61f4eeb5687df4614bd101fd2 Mon Sep 17 00:00:00 2001 From: Christopher Hiller Date: Mon, 25 May 2026 19:50:26 -0700 Subject: [PATCH 2/8] docs(git): clarify Push doc comment The Push doc comment said "force-pushes ... with lease" but the function takes a `force` flag and only adds `--force-with-lease` when true. Update the comment to describe both branches honestly. Per PR #125 review. --- internal/git/git.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/git/git.go b/internal/git/git.go index 78dd32e..fc68e9d 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -173,7 +173,9 @@ func (g *Git) Commit(message string) error { return g.runSilent("commit", "-m", message) } -// Push force-pushes a branch to origin with lease. +// Push pushes a branch to origin. When force is true, --force-with-lease is +// used; when false, the push is a normal fast-forward push (and will fail if +// the remote has diverged). func (g *Git) Push(branch string, force bool) error { return g.PushMany([]string{branch}, force) } From e37a54fbab38656888a07538f53d56f4393bcbbe Mon Sep 17 00:00:00 2001 From: Christopher Hiller Date: Mon, 25 May 2026 19:50:50 -0700 Subject: [PATCH 3/8] fix(git): guard PushMany against dash-prefixed branch names Insert a `--` end-of-options marker between the flag list and the refspec list so a branch starting with `-` is never parsed as a git option. Per PR #125 review. --- internal/git/git.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/internal/git/git.go b/internal/git/git.go index fc68e9d..58e3665 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -185,15 +185,19 @@ func (g *Git) Push(branch string, force bool) error { // push is all-or-nothing: if any ref is rejected (e.g. lease conflict), none // of the refs are updated on the remote. // Returns nil immediately when branches is empty. +// +// All flags are placed before a `--` end-of-options marker so refspecs that +// happen to start with `-` are never misinterpreted as git options. func (g *Git) PushMany(branches []string, force bool) error { if len(branches) == 0 { return nil } - args := []string{"push", "origin"} - args = append(args, branches...) + args := []string{"push"} if force { args = append(args, "--force-with-lease", "--atomic") } + args = append(args, "origin", "--") + args = append(args, branches...) return g.runInteractive(args...) } From 145341d7cd9f6a26f6c3cfbe80dde8ca5c02eaf6 Mon Sep 17 00:00:00 2001 From: Christopher Hiller Date: Mon, 25 May 2026 19:51:15 -0700 Subject: [PATCH 4/8] style(submit): format branch names in push summary line Other submit output ("Skipping push for ...") formats branch names via `s.Branch(...)`; the batched push summary should do the same so colors and emphasis stay consistent across the phase. Per PR #125 review. --- cmd/submit.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cmd/submit.go b/cmd/submit.go index 1929080..dc744b7 100644 --- a/cmd/submit.go +++ b/cmd/submit.go @@ -303,7 +303,11 @@ func doSubmitPushAndPR(g *git.Git, cfg *config.Config, root *tree.Node, branches } } if !opts.DryRun && len(toPush) > 0 { - fmt.Printf("Pushing %s to origin (force-with-lease, atomic)...\n", strings.Join(toPush, ", ")) + styled := make([]string, len(toPush)) + for i, name := range toPush { + styled[i] = s.Branch(name) + } + fmt.Printf("Pushing %s to origin (force-with-lease, atomic)...\n", strings.Join(styled, ", ")) if err := g.PushMany(toPush, true); err != nil { return fmt.Errorf("push failed: %w", err) } From 10ba92cd3423fa6a245192174828367430c90404 Mon Sep 17 00:00:00 2001 From: Christopher Hiller Date: Mon, 25 May 2026 19:51:29 -0700 Subject: [PATCH 5/8] fix(submit): include refs in batched push error message The previous `push failed: %w` wrapping dropped the list of branches being pushed, making failures harder to diagnose at a glance. Include the branch names alongside git's underlying error. Per PR #125 review. --- cmd/submit.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/submit.go b/cmd/submit.go index dc744b7..409414d 100644 --- a/cmd/submit.go +++ b/cmd/submit.go @@ -309,7 +309,7 @@ func doSubmitPushAndPR(g *git.Git, cfg *config.Config, root *tree.Node, branches } fmt.Printf("Pushing %s to origin (force-with-lease, atomic)...\n", strings.Join(styled, ", ")) if err := g.PushMany(toPush, true); err != nil { - return fmt.Errorf("push failed: %w", err) + return fmt.Errorf("failed to push branches [%s]: %w", strings.Join(toPush, ", "), err) } } From aa9a7caff9496144b7749e00eb7ea81b0dcc4bd6 Mon Sep 17 00:00:00 2001 From: Christopher Hiller Date: Mon, 25 May 2026 19:51:40 -0700 Subject: [PATCH 6/8] docs(git): correct setupRepoWithRemote return-value comment Helper returns four values (localDir, remoteDir, *Git, trunk) but the comment only listed three. Sync the comment with the signature. Per PR #125 review. --- internal/git/git_test.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/git/git_test.go b/internal/git/git_test.go index ed72545..47cd1f9 100644 --- a/internal/git/git_test.go +++ b/internal/git/git_test.go @@ -905,8 +905,9 @@ func TestRebaseNoUpdateRefsPreservesBookmark(t *testing.T) { } } -// setupRepoWithRemote creates a local repo + bare remote and returns (localDir, remoteDir, Git). -// The trunk branch is pushed to the remote and the remote is set as "origin". +// setupRepoWithRemote creates a local repo + bare remote and returns +// (localDir, remoteDir, *Git, trunk). The trunk branch is pushed to the +// remote and the remote is set as "origin". func setupRepoWithRemote(t *testing.T) (dir, remoteDir string, g *git.Git, trunk string) { t.Helper() dir = t.TempDir() From b00eded51fb356e0fc82b2ffb46b49dfb1d2225f Mon Sep 17 00:00:00 2001 From: Christopher Hiller Date: Mon, 25 May 2026 19:52:12 -0700 Subject: [PATCH 7/8] test(git): drop dead pre-divergence block in atomic-rejection test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first block wrote a file into the bare remote and ran `update-ref ... HEAD` with all errors ignored. It did nothing useful — the temp-clone push that follows is what actually creates the divergence — and was confusing. Remove it and tighten the remaining setup so errors are surfaced via `t.Fatalf` instead of being swallowed. Per PR #125 review. --- internal/git/git_test.go | 39 +++++++++++++++++++++------------------ 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/internal/git/git_test.go b/internal/git/git_test.go index 47cd1f9..f04c877 100644 --- a/internal/git/git_test.go +++ b/internal/git/git_test.go @@ -1027,26 +1027,29 @@ func TestPushManyAtomicRejection(t *testing.T) { tipBLocal, _ := g.GetTip("feat-b") run("push", "origin", "feat-b") - // Diverge the remote's feat-a by adding a commit there directly. - // This will cause the force-with-lease for feat-a to be rejected. - exec.Command("git", "-C", remoteDir, "config", "--local", "receive.denyNonFastForwards", "false").Run() //nolint:errcheck - os.WriteFile(filepath.Join(remoteDir, "diverged"), []byte("diverged"), 0644) - exec.Command("git", "-C", remoteDir, "update-ref", "refs/heads/feat-a", - // Point feat-a on remote to a newly-created orphan commit so the lease check fails. - // Easiest: use fast-import to write a new root commit. - // Simpler approach: just push a new commit from a temp clone. - "HEAD").Run() //nolint:errcheck - - // Simpler divergence: commit directly to feat-a on the remote via a temp clone + // Diverge feat-a on the remote by committing & pushing through a temp + // clone. This advances refs/heads/feat-a on the bare remote out from + // under our local clone, so our next force-with-lease for feat-a will + // see a stale lease and be rejected. tmp := t.TempDir() - exec.Command("git", "clone", remoteDir, tmp).Run() //nolint:errcheck + cloneTmp := exec.Command("git", "clone", remoteDir, tmp) + if out, err := cloneTmp.CombinedOutput(); err != nil { + t.Fatalf("git clone (divergence) failed: %v\n%s", err, out) + } + tmpRun := func(args ...string) { + cmd := exec.Command("git", args...) + cmd.Dir = tmp + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v (divergence) failed: %v\n%s", args, err, out) + } + } + tmpRun("config", "user.email", "t@t.com") + tmpRun("config", "user.name", "T") + tmpRun("checkout", "feat-a") os.WriteFile(filepath.Join(tmp, "remote-diverge"), []byte("x"), 0644) - exec.Command("git", "-C", tmp, "config", "user.email", "t@t.com").Run() //nolint:errcheck - exec.Command("git", "-C", tmp, "config", "user.name", "T").Run() //nolint:errcheck - exec.Command("git", "-C", tmp, "checkout", "feat-a").Run() //nolint:errcheck - exec.Command("git", "-C", tmp, "add", ".").Run() //nolint:errcheck - exec.Command("git", "-C", tmp, "commit", "-m", "diverge").Run() //nolint:errcheck - exec.Command("git", "-C", tmp, "push", "origin", "feat-a").Run() //nolint:errcheck + tmpRun("add", ".") + tmpRun("commit", "-m", "diverge") + tmpRun("push", "origin", "feat-a") // Add a new commit to feat-b locally (so we're trying to advance it) run("checkout", "feat-b") From d923bcf4095eb86c7bea83cfff13bf5d2cfc8a98 Mon Sep 17 00:00:00 2001 From: Christopher Hiller Date: Mon, 25 May 2026 19:52:40 -0700 Subject: [PATCH 8/8] test(git): assert strict feat-b equality after atomic rejection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous check (`remoteB != tipBNew`) could pass for the wrong reason — e.g. `rev-parse` returning `""` on error would silently satisfy it. Use the captured pre-push tip (`tipBLocal`) for an exact equality check, and fail loudly if the remote SHA can't be read at all. Per PR #125 review. --- internal/git/git_test.go | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/internal/git/git_test.go b/internal/git/git_test.go index f04c877..165c950 100644 --- a/internal/git/git_test.go +++ b/internal/git/git_test.go @@ -1056,8 +1056,6 @@ func TestPushManyAtomicRejection(t *testing.T) { os.WriteFile(filepath.Join(dir, "b2"), []byte("b2"), 0644) run("add", ".") run("commit", "-m", "b2") - tipBNew, _ := g.GetTip("feat-b") - _ = tipBLocal // original tip before the new commit // PushMany should fail because feat-a's lease is broken. err := g.PushMany([]string{"feat-a", "feat-b"}, true) @@ -1065,9 +1063,14 @@ func TestPushManyAtomicRejection(t *testing.T) { t.Fatal("expected PushMany to fail due to lease rejection on feat-a, but it succeeded") } - // Due to --atomic, feat-b must NOT have advanced on the remote. + // Due to --atomic, feat-b must remain at exactly its pre-push value on + // the remote. Assert strict equality (and fail loudly if the remote ref + // cannot be read) so a missing/blank rev-parse can't masquerade as success. remoteB := remoteRef(t, remoteDir, "feat-b") - if remoteB == tipBNew { - t.Errorf("remote feat-b advanced to %s despite atomic failure — --atomic is not working", tipBNew) + if remoteB == "" { + t.Fatal("could not read remote feat-b SHA after PushMany failure") + } + if remoteB != tipBLocal { + t.Errorf("remote feat-b = %s, want unchanged %s — --atomic did not hold", remoteB, tipBLocal) } }