Skip to content

Commit f7b5600

Browse files
skarimCopilot
andcommitted
Force-push rewritten branches from sync, and stop signing off when it fails
Running `gh stack rebase` and then `gh stack sync` left the stack unpushed. sync derived the force flag purely from whether it had rebased in that same run, so after an earlier rebase it attempted a plain push; the branches had rewritten history, the atomic push was rejected as a whole, and sync still finished with "✓ Stack synced". The flag now also accounts for branches that already diverged from their remote-tracking refs, which is the state any earlier rebase leaves behind. --force-with-lease still protects against overwriting someone else's work. A push that does not land is also no longer reported as a synced stack: the remote does not have the commits, so nothing downstream of the push reflects the local stack. sync now says so and points at `gh stack push`. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: f5959297-80fd-4732-aeae-aa9a4b6a7755
1 parent b3ab7e0 commit f7b5600

2 files changed

Lines changed: 166 additions & 5 deletions

File tree

cmd/stale_trunk_test.go

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -514,3 +514,133 @@ func TestRebase_Autostash_KeepsStashOnConflict(t *testing.T) {
514514
assert.True(t, state.Stashed, "recovery state must record the pending stash")
515515
assert.Equal(t, "main", state.TrunkRef, "recovery must resume against the same trunk ref")
516516
}
517+
518+
// A `gh stack rebase` before `gh stack sync` leaves the branches diverged from
519+
// their remote refs. sync used to derive the force flag purely from whether it
520+
// had rebased in that same run, so the atomic push failed and the stack stayed
521+
// unpushed while sync still reported success.
522+
func TestSync_ForcePushesBranchesRebasedEarlier(t *testing.T) {
523+
tmpDir := t.TempDir()
524+
writeStackFile(t, tmpDir, twoBranchStack())
525+
526+
var pushCalls []pushCall
527+
528+
mock := newSyncMock(tmpDir, "b1")
529+
mock.RevParseFn = func(ref string) (string, error) {
530+
if ref == "main" || ref == "origin/main" {
531+
return "trunk-sha", nil
532+
}
533+
// The local branches carry rewritten history.
534+
if strings.HasPrefix(ref, "origin/") {
535+
return "old-" + strings.TrimPrefix(ref, "origin/"), nil
536+
}
537+
return "new-" + ref, nil
538+
}
539+
// The stack is already correctly stacked, so no rebase happens this run,
540+
// but the remote tips are not contained in the local branches.
541+
mock.IsAncestorFn = func(ancestor, descendant string) (bool, error) {
542+
if strings.HasPrefix(ancestor, "old-") {
543+
return false, nil
544+
}
545+
return true, nil
546+
}
547+
mock.PushFn = func(remote string, branches []string, force, atomic bool) error {
548+
pushCalls = append(pushCalls, pushCall{remote, branches, force, atomic})
549+
return nil
550+
}
551+
552+
restore := git.SetOps(mock)
553+
defer restore()
554+
555+
cfg, _, errR := config.NewTestConfig()
556+
cmd := SyncCmd(cfg)
557+
cmd.SetOut(io.Discard)
558+
cmd.SetErr(io.Discard)
559+
err := cmd.Execute()
560+
561+
cfg.Err.Close()
562+
out, _ := io.ReadAll(errR)
563+
564+
assert.NoError(t, err)
565+
require.Len(t, pushCalls, 1)
566+
assert.True(t, pushCalls[0].force,
567+
"branches rebased before this run still need --force-with-lease")
568+
assert.Contains(t, string(out), "Pushed")
569+
}
570+
571+
// When the push does not land, the stack on the remote does not reflect the
572+
// local one, so sync must not sign off as if it did.
573+
func TestSync_PushFailure_DoesNotReportSuccess(t *testing.T) {
574+
tmpDir := t.TempDir()
575+
writeStackFile(t, tmpDir, twoBranchStack())
576+
577+
mock := newSyncMock(tmpDir, "b1")
578+
mock.PushFn = func(string, []string, bool, bool) error {
579+
return errors.New("remote rejected")
580+
}
581+
582+
restore := git.SetOps(mock)
583+
defer restore()
584+
585+
cfg, _, errR := config.NewTestConfig()
586+
cmd := SyncCmd(cfg)
587+
cmd.SetOut(io.Discard)
588+
cmd.SetErr(io.Discard)
589+
err := cmd.Execute()
590+
591+
cfg.Err.Close()
592+
out, _ := io.ReadAll(errR)
593+
output := string(out)
594+
595+
assert.NoError(t, err, "a failed push is a warning, not a fatal error")
596+
assert.Contains(t, output, "were not pushed")
597+
assert.NotContains(t, output, "Stack synced")
598+
assert.NotContains(t, output, "Branches synced")
599+
}
600+
601+
func TestBranchesNeedForcePush(t *testing.T) {
602+
tests := []struct {
603+
name string
604+
ancestor func(string, string) (bool, error)
605+
revParse func(string) (string, error)
606+
want bool
607+
}{
608+
{
609+
name: "remote tip contained locally needs no force",
610+
ancestor: func(string, string) (bool, error) { return true, nil },
611+
want: false,
612+
},
613+
{
614+
name: "rewritten history needs force",
615+
ancestor: func(string, string) (bool, error) { return false, nil },
616+
want: true,
617+
},
618+
{
619+
name: "a branch with no remote ref is ignored",
620+
revParse: func(string) (string, error) { return "", errors.New("unknown revision") },
621+
ancestor: func(string, string) (bool, error) { return false, nil },
622+
want: false,
623+
},
624+
{
625+
name: "unknown ancestry does not force",
626+
ancestor: func(string, string) (bool, error) { return false, errors.New("boom") },
627+
want: false,
628+
},
629+
}
630+
631+
for _, tt := range tests {
632+
t.Run(tt.name, func(t *testing.T) {
633+
revParse := tt.revParse
634+
if revParse == nil {
635+
revParse = func(ref string) (string, error) { return "sha-" + ref, nil }
636+
}
637+
restore := git.SetOps(&git.MockOps{
638+
RevParseFn: revParse,
639+
IsAncestorFn: tt.ancestor,
640+
})
641+
defer restore()
642+
643+
assert.Equal(t, tt.want, branchesNeedForcePush("origin", []string{"b1", "b2"}))
644+
})
645+
}
646+
}

cmd/sync.go

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,7 @@ func runSync(cfg *config.Config, opts *syncOptions) error {
266266
// --- Step 4: Push ---
267267
cfg.Printf("")
268268
branches := activeBranchNames(s)
269+
pushed := true
269270

270271
if mergedCount := len(s.MergedBranches()); mergedCount > 0 {
271272
cfg.Printf("Skipping %d merged %s", mergedCount, plural(mergedCount, "branch", "branches"))
@@ -277,11 +278,14 @@ func runSync(cfg *config.Config, opts *syncOptions) error {
277278
if len(branches) == 0 {
278279
cfg.Printf("No active branches to push (all merged)")
279280
} else {
280-
// After rebase, force-with-lease is required (history rewritten).
281-
// Without rebase, try a normal push first.
282-
force := rebased
281+
// Rewritten history needs --force-with-lease. That is not only true of
282+
// a rebase this run performed: a `gh stack rebase` beforehand leaves
283+
// exactly the same divergence, and a plain push then fails for the
284+
// whole atomic set.
285+
force := rebased || branchesNeedForcePush(remote, branches)
283286
cfg.Printf("Pushing %d %s to %s...", len(branches), plural(len(branches), "branch", "branches"), remote)
284287
if err := git.Push(remote, branches, force, true); err != nil {
288+
pushed = false
285289
if !force {
286290
cfg.Warningf("Push failed — branches may need force push after rebase")
287291
cfg.Printf(" Run `%s` to push with --force-with-lease.",
@@ -436,9 +440,16 @@ func runSync(cfg *config.Config, opts *syncOptions) error {
436440
}
437441

438442
cfg.Printf("")
439-
if stackSynced {
443+
switch {
444+
case !pushed:
445+
// The branches were rebased and the PR state refreshed, but the remote
446+
// does not have the new commits, so nothing downstream of the push
447+
// actually reflects the local stack yet.
448+
cfg.Warningf("Synced locally, but the branches were not pushed")
449+
cfg.Printf(" Run `%s` to push them.", cfg.ColorCyan("gh stack push"))
450+
case stackSynced:
440451
cfg.Successf("Stack synced")
441-
} else {
452+
default:
442453
// The branches were fetched, rebased, and pushed, but no stack object on
443454
// GitHub was created or updated (no PRs, fewer than two PRs, stacked PRs
444455
// unavailable, or a divergence). Report only what actually happened.
@@ -448,6 +459,26 @@ func runSync(cfg *config.Config, opts *syncOptions) error {
448459
return nil
449460
}
450461

462+
// branchesNeedForcePush reports whether pushing any of the branches would
463+
// rewrite history on the remote, which requires --force-with-lease.
464+
//
465+
// A branch qualifies when its remote-tracking ref is not contained in the local
466+
// branch — the state a rebase leaves behind. Branches with no remote ref yet,
467+
// or whose ancestry cannot be determined, do not qualify: a plain push is the
468+
// safer default and its failure is reported.
469+
func branchesNeedForcePush(remote string, branches []string) bool {
470+
for _, b := range branches {
471+
remoteSHA, err := git.RevParse(remote + "/" + b)
472+
if err != nil {
473+
continue
474+
}
475+
if isAnc, err := git.IsAncestor(remoteSHA, b); err == nil && !isAnc {
476+
return true
477+
}
478+
}
479+
return false
480+
}
481+
451482
// restoreBranches resets each branch to its original SHA, collecting any errors.
452483
func restoreBranches(originalRefs map[string]string) []string {
453484
var errors []string

0 commit comments

Comments
 (0)