Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 8 additions & 6 deletions cmd/submit.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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, ", "))
Comment thread
boneskull marked this conversation as resolved.
Outdated
if err := g.PushMany(toPush, true); err != nil {
return fmt.Errorf("push failed: %w", err)
Comment thread
boneskull marked this conversation as resolved.
Outdated
}
}

Expand Down
41 changes: 41 additions & 0 deletions e2e/submit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
17 changes: 15 additions & 2 deletions internal/git/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,9 +175,22 @@ func (g *Git) Commit(message string) error {

// Push force-pushes a branch to origin with lease.
Comment thread
boneskull marked this conversation as resolved.
Outdated
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")
}
Comment thread
boneskull marked this conversation as resolved.
Outdated
return g.runInteractive(args...)
}
Expand Down
163 changes: 163 additions & 0 deletions internal/git/git_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Comment thread
boneskull marked this conversation as resolved.
Outdated
// 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
Comment thread
boneskull marked this conversation as resolved.
Outdated

// 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)
}
Comment thread
boneskull marked this conversation as resolved.
Outdated
}
Loading