Skip to content

Commit 8e7863a

Browse files
committed
Refresh queued PR state when continuing a stack rebase
continueRebase reloads the stack from disk, where the Queued flag is transient (json:"-") and therefore lost, and it only called syncStackPRs after the cascade. So if the initial rebase conflicted on a branch below a queued branch, `gh stack rebase --continue` resumed with that branch seen as active: it rebased the frozen merge-queue branch and rebuilt the downstream branches on a local history that differs from the queued branch. Call syncStackPRs right after resolving the stack — before selecting the base and cascading the remaining branches — mirroring the refresh runRebase already does before its cascade. The queued flag is repopulated, so queued branches stay skipped and downstream branches stay stacked on them. Add TestRebase_Continue_QueuedBranchBelowConflict, which conflicts below a queued branch and asserts the frozen branch is not rebased and the branch above stays stacked on it. Verified to fail without the refresh.
1 parent 50e5514 commit 8e7863a

2 files changed

Lines changed: 82 additions & 0 deletions

File tree

cmd/rebase.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,14 @@ func continueRebase(cfg *config.Config, gitDir string) error {
315315
return fmt.Errorf("no stack found for branch %s", state.OriginalBranch)
316316
}
317317

318+
// Refresh PR state before selecting the base and cascading the remaining
319+
// branches. The queued flag is transient (not persisted), so it was lost
320+
// when the stack was reloaded from disk above. Without this, a queued
321+
// branch in the remaining cascade would be treated as active and its
322+
// frozen merge-queue branch would be rebased. Mirrors the syncStackPRs
323+
// call in runRebase before its cascade.
324+
_ = syncStackPRs(cfg, s)
325+
318326
// The branch that had the conflict is stored in state; fall back to
319327
// looking it up by index for backwards compatibility with older state files.
320328
conflictBranch := state.ConflictBranch

cmd/rebase_test.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -974,6 +974,80 @@ func TestRebase_Continue_RebasesRemainingBranches(t *testing.T) {
974974
assert.Contains(t, checkouts, "b1", "should checkout original branch")
975975
}
976976

977+
// TestRebase_Continue_QueuedBranchBelowConflict verifies that a queued branch is
978+
// still skipped when the cascade resumes via --continue after a conflict below
979+
// it. The Queued flag is transient and lost when continueRebase reloads the
980+
// stack from disk, so it must be refreshed before the remaining cascade — else
981+
// the frozen merge-queue branch would be rebased.
982+
func TestRebase_Continue_QueuedBranchBelowConflict(t *testing.T) {
983+
s := stack.Stack{
984+
Trunk: stack.BranchRef{Branch: "main"},
985+
Branches: []stack.BranchRef{
986+
{Branch: "b1"},
987+
{Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 20}},
988+
{Branch: "b3"},
989+
},
990+
}
991+
992+
tmpDir := t.TempDir()
993+
writeStackFile(t, tmpDir, s)
994+
995+
// State: b1 (below the queued b2) conflicted; b2 and b3 remain.
996+
state := &rebaseState{
997+
CurrentBranchIndex: 0,
998+
ConflictBranch: "b1",
999+
RemainingBranches: []string{"b2", "b3"},
1000+
OriginalBranch: "b3",
1001+
OriginalRefs: map[string]string{
1002+
"main": "main-orig-sha",
1003+
"b1": "sha-b1",
1004+
"b2": "sha-b2",
1005+
"b3": "sha-b3",
1006+
},
1007+
}
1008+
stateData, _ := json.MarshalIndent(state, "", " ")
1009+
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "gh-stack-rebase-state"), stateData, 0644))
1010+
1011+
var rebaseCalls []rebaseCall
1012+
1013+
mock := newRebaseMock(tmpDir, "b1")
1014+
mock.BranchExistsFn = func(name string) bool { return true }
1015+
mock.IsRebaseInProgressFn = func() bool { return true }
1016+
mock.RebaseContinueFn = func(opts git.RebaseOpts) error { return nil }
1017+
mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error {
1018+
rebaseCalls = append(rebaseCalls, rebaseCall{newBase, oldBase, branch})
1019+
return nil
1020+
}
1021+
mock.CheckoutBranchFn = func(string) error { return nil }
1022+
1023+
restore := git.SetOps(mock)
1024+
defer restore()
1025+
1026+
cfg, _, errR := config.NewTestConfig()
1027+
cfg.GitHubClientOverride = queuedPRClient(map[int]string{20: "b2"})
1028+
cmd := RebaseCmd(cfg)
1029+
cmd.SetArgs([]string{"--continue"})
1030+
cmd.SetOut(io.Discard)
1031+
cmd.SetErr(io.Discard)
1032+
err := cmd.Execute()
1033+
1034+
cfg.Err.Close()
1035+
errOut, _ := io.ReadAll(errR)
1036+
output := string(errOut)
1037+
1038+
assert.NoError(t, err)
1039+
assert.Contains(t, output, "Skipping b2")
1040+
assert.Contains(t, output, "queued")
1041+
1042+
// Only b3 is rebased, onto the queued b2. The queued b2 itself must not be
1043+
// rebased (its branch is frozen in the merge queue).
1044+
require.Len(t, rebaseCalls, 1)
1045+
assert.Equal(t, rebaseCall{"b2", "sha-b2", "b3"}, rebaseCalls[0])
1046+
for _, c := range rebaseCalls {
1047+
assert.NotEqual(t, "b2", c.branch, "the frozen queued branch must not be rebased")
1048+
}
1049+
}
1050+
9771051
// TestRebase_Continue_OntoMode verifies the --continue path when UseOnto is
9781052
// set (merged branches upstream). With no remaining branches, only
9791053
// RebaseContinue runs and the state is cleaned up.

0 commit comments

Comments
 (0)