Files
github__gh-stack/cmd/rebase_test.go
Sameen Karim 623b7e6fbf Fix rebase treating queued PRs as merged (#173)
* Don't treat queued PRs as merged when rebasing the stack

gh stack rebase and gh stack sync share cascadeRebase, which skipped
branches via IsSkipped() (merged or queued) and then switched to a
`git rebase --onto` that drops the skipped branch's commits from every
downstream branch. That is right for a merged PR — its commits are
already in trunk — but wrong for a queued PR: its commits only exist on
its own branch, which is frozen in the merge queue, so the branches
above it were rebased onto trunk and lost work they depend on.

Handle the two cases separately. A merged branch still activates --onto
so its commits are dropped. A queued branch is still skipped (its branch
is frozen and is not rebased or pushed), but onto mode is reset so
downstream branches rebase normally onto the queued branch, keeping its
commits underneath. The --onto target search, the runRebase --onto seed,
and the continueRebase display base now key on IsMerged() instead of
IsSkipped(), so a queued predecessor no longer forces downstream
branches onto trunk. gh stack sync is fixed through the same shared
helper.

Add rebase coverage for a queued branch mid-stack, a merged branch below
a queued branch, and --upstack above a queued branch, plus a sync test
that also asserts the queued branch is excluded from the push. The
transient queued state is injected through the GitHub mock's merge-queue
entry.

* 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.
2026-07-15 12:07:43 -04:00

1966 lines
58 KiB
Go

package cmd
import (
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/github/gh-stack/internal/config"
"github.com/github/gh-stack/internal/git"
"github.com/github/gh-stack/internal/github"
"github.com/github/gh-stack/internal/stack"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// rebaseCall records arguments passed to RebaseOnto or Rebase.
type rebaseCall struct {
newBase string
oldBase string
branch string
}
// resetCall records arguments passed to CheckoutBranch + ResetHard.
type resetCall struct {
branch string
sha string
}
// newRebaseMock creates a MockOps pre-configured for rebase tests.
// It returns stable SHAs based on ref name, tracks checkout, and allows
// callers to override specific function fields after creation.
func newRebaseMock(tmpDir string, currentBranch string) *git.MockOps {
return &git.MockOps{
GitDirFn: func() (string, error) { return tmpDir, nil },
CurrentBranchFn: func() (string, error) { return currentBranch, nil },
RevParseFn: func(ref string) (string, error) {
// Default: origin/<branch> returns same SHA as <branch> (no FF needed)
if strings.HasPrefix(ref, "origin/") {
return "sha-" + strings.TrimPrefix(ref, "origin/"), nil
}
return "sha-" + ref, nil
},
IsAncestorFn: func(a, d string) (bool, error) { return true, nil },
FetchFn: func(string) error { return nil },
EnableRerereFn: func() error { return nil },
IsRebaseInProgressFn: func() bool { return false },
}
}
// TestRebase_CascadeRebase verifies that a stack [b1, b2, b3] with all active
// branches triggers the correct cascade: b1 rebased onto trunk, b2 onto b1,
// b3 onto b2.
func TestRebase_CascadeRebase(t *testing.T) {
s := stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "b1"},
{Branch: "b2"},
{Branch: "b3"},
},
}
tmpDir := t.TempDir()
writeStackFile(t, tmpDir, s)
var allRebaseCalls []rebaseCall
var currentCheckedOut string
mock := newRebaseMock(tmpDir, "b2")
mock.CheckoutBranchFn = func(name string) error {
currentCheckedOut = name
return nil
}
mock.RebaseFn = func(base string, opts git.RebaseOpts) error {
allRebaseCalls = append(allRebaseCalls, rebaseCall{newBase: base, oldBase: "", branch: currentCheckedOut})
return nil
}
mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error {
allRebaseCalls = append(allRebaseCalls, rebaseCall{newBase, oldBase, branch})
return nil
}
restore := git.SetOps(mock)
defer restore()
cfg, _, errR := config.NewTestConfig()
cmd := RebaseCmd(cfg)
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
err := cmd.Execute()
cfg.Err.Close()
errOut, _ := io.ReadAll(errR)
output := string(errOut)
assert.NoError(t, err)
// All branches should be rebased in order: b1 onto main, b2 onto b1, b3 onto b2
require.Len(t, allRebaseCalls, 3)
assert.Equal(t, "main", allRebaseCalls[0].newBase, "b1 should be rebased onto trunk")
assert.Equal(t, "b1", allRebaseCalls[1].newBase, "b2 should be rebased onto b1")
assert.Equal(t, "b2", allRebaseCalls[2].newBase, "b3 should be rebased onto b2")
assert.Contains(t, output, "rebased locally")
}
// TestRebase_MergedBranch_UsesOnto verifies that when b1 has a merged PR,
// it is skipped and b2 uses RebaseOnto with trunk as newBase and b1's original
// SHA as oldBase. b3 also uses --onto (propagation).
func TestRebase_MergedBranch_UsesOnto(t *testing.T) {
s := stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10, Merged: true}},
{Branch: "b2"},
{Branch: "b3"},
},
}
tmpDir := t.TempDir()
writeStackFile(t, tmpDir, s)
var rebaseCalls []rebaseCall
// Use explicit SHAs so assertions are self-documenting
branchSHAs := map[string]string{
"main": "main-sha-aaa",
"b1": "b1-orig-sha",
"b2": "b2-orig-sha",
"b3": "b3-orig-sha",
}
mock := newRebaseMock(tmpDir, "b2")
mock.BranchExistsFn = func(name string) bool { return true }
mock.RevParseFn = func(ref string) (string, error) {
if sha, ok := branchSHAs[ref]; ok {
return sha, nil
}
return "default-sha", nil
}
mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error {
rebaseCalls = append(rebaseCalls, rebaseCall{newBase, oldBase, branch})
return nil
}
restore := git.SetOps(mock)
defer restore()
cfg, _, errR := config.NewTestConfig()
cmd := RebaseCmd(cfg)
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
err := cmd.Execute()
cfg.Err.Close()
errOut, _ := io.ReadAll(errR)
output := string(errOut)
assert.NoError(t, err)
assert.Contains(t, output, "Skipping b1")
// b2: onto trunk, oldBase = b1's original SHA
// b3: onto b2, oldBase = b2's original SHA (propagation)
require.Len(t, rebaseCalls, 2)
assert.Equal(t, rebaseCall{"main", "b1-orig-sha", "b2"}, rebaseCalls[0],
"b2 should rebase --onto main using b1's original SHA as oldBase")
assert.Equal(t, rebaseCall{"b2", "b2-orig-sha", "b3"}, rebaseCalls[1],
"b3 should propagate --onto mode with b2's original SHA as oldBase")
}
// TestRebase_OntoPropagatesToSubsequentBranches verifies that when multiple
// branches are merged, --onto propagates correctly through the chain.
func TestRebase_OntoPropagatesToSubsequentBranches(t *testing.T) {
s := stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10, Merged: true}},
{Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 11, Merged: true}},
{Branch: "b3"},
{Branch: "b4"},
},
}
tmpDir := t.TempDir()
writeStackFile(t, tmpDir, s)
var rebaseCalls []rebaseCall
// Use explicit SHAs so assertions are self-documenting
branchSHAs := map[string]string{
"main": "main-sha-aaa",
"b1": "b1-orig-sha",
"b2": "b2-orig-sha",
"b3": "b3-orig-sha",
"b4": "b4-orig-sha",
}
mock := newRebaseMock(tmpDir, "b3")
mock.BranchExistsFn = func(name string) bool { return true }
mock.RevParseFn = func(ref string) (string, error) {
if sha, ok := branchSHAs[ref]; ok {
return sha, nil
}
return "default-sha", nil
}
mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error {
rebaseCalls = append(rebaseCalls, rebaseCall{newBase, oldBase, branch})
return nil
}
restore := git.SetOps(mock)
defer restore()
cfg, _, errR := config.NewTestConfig()
cmd := RebaseCmd(cfg)
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
err := cmd.Execute()
cfg.Err.Close()
errOut, _ := io.ReadAll(errR)
output := string(errOut)
assert.NoError(t, err)
assert.Contains(t, output, "Skipping b1")
assert.Contains(t, output, "Skipping b2")
// b1 merged → ontoOldBase = b1-orig-sha
// b2 merged → ontoOldBase = b2-orig-sha
// b3: first non-merged ancestor search finds none → newBase = trunk
// RebaseOnto("main", "b2-orig-sha", "b3")
// b4: first non-merged ancestor = b3 → newBase = b3
// RebaseOnto("b3", "b3-orig-sha", "b4")
require.Len(t, rebaseCalls, 2)
assert.Equal(t, rebaseCall{"main", "b2-orig-sha", "b3"}, rebaseCalls[0],
"b3 should rebase --onto main with b2's SHA as oldBase")
assert.Equal(t, rebaseCall{"b3", "b3-orig-sha", "b4"}, rebaseCalls[1],
"b4 should rebase --onto b3 with b3's original SHA as oldBase")
}
// TestRebase_StaleOntoOldBase_FallsBackToMergeBase verifies that when a branch
// was already rebased past the merged branch's tip (e.g. by a previous run),
// the stale ontoOldBase is detected via IsAncestor and replaced with
// merge-base(newBase, branch) to avoid replaying already-applied commits.
func TestRebase_StaleOntoOldBase_FallsBackToMergeBase(t *testing.T) {
s := stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10, Merged: true}},
{Branch: "b2"},
{Branch: "b3"},
},
}
tmpDir := t.TempDir()
writeStackFile(t, tmpDir, s)
var rebaseCalls []rebaseCall
// b1's local ref is the stale pre-squash tip from before a previous rebase.
// b2 was already rebased onto main by a previous run, so b1's old tip
// is NOT an ancestor of b2.
branchSHAs := map[string]string{
"main": "main-sha",
"b1": "b1-stale-presquash-sha",
"b2": "b2-on-main-sha",
"b3": "b3-on-b2-sha",
}
mock := newRebaseMock(tmpDir, "b2")
mock.BranchExistsFn = func(name string) bool { return true }
mock.RevParseFn = func(ref string) (string, error) {
if sha, ok := branchSHAs[ref]; ok {
return sha, nil
}
return "default-sha", nil
}
mock.IsAncestorFn = func(ancestor, descendant string) (bool, error) {
// b1's stale SHA is NOT an ancestor of b2 (b2 was already rebased onto main)
if ancestor == "b1-stale-presquash-sha" {
return false, nil
}
return true, nil
}
mock.MergeBaseFn = func(a, b string) (string, error) {
if a == "main" && b == "b2" {
return "main-b2-mergebase", nil
}
return "default-mergebase", nil
}
mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error {
rebaseCalls = append(rebaseCalls, rebaseCall{newBase, oldBase, branch})
return nil
}
restore := git.SetOps(mock)
defer restore()
cfg, _, _ := config.NewTestConfig()
cmd := RebaseCmd(cfg)
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
err := cmd.Execute()
cfg.Out.Close()
cfg.Err.Close()
assert.NoError(t, err)
require.Len(t, rebaseCalls, 2)
// b2: stale ontoOldBase detected → falls back to merge-base(main, b2)
assert.Equal(t, rebaseCall{"main", "main-b2-mergebase", "b2"}, rebaseCalls[0],
"b2 should use merge-base as oldBase when ontoOldBase is stale")
// b3: b2's SHA is a valid ancestor → uses it directly
assert.Equal(t, rebaseCall{"b2", "b2-on-main-sha", "b3"}, rebaseCalls[1],
"b3 should use b2's original SHA as oldBase (not stale)")
}
// TestRebase_ConflictSavesState verifies that when a rebase conflict occurs,
// the state is saved with the conflict branch and remaining branches.
func TestRebase_ConflictSavesState(t *testing.T) {
s := stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "b1"},
{Branch: "b2"},
{Branch: "b3"},
},
}
tmpDir := t.TempDir()
writeStackFile(t, tmpDir, s)
mock := newRebaseMock(tmpDir, "b1")
mock.CheckoutBranchFn = func(string) error { return nil }
mock.RebaseFn = func(string, git.RebaseOpts) error { return nil } // b1 succeeds
mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error {
if branch == "b2" {
return assert.AnError // conflict on b2
}
return nil
}
mock.ConflictedFilesFn = func() ([]string, error) { return nil, nil }
restore := git.SetOps(mock)
defer restore()
cfg, _, errR := config.NewTestConfig()
cmd := RebaseCmd(cfg)
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
err := cmd.Execute()
cfg.Err.Close()
errOut, _ := io.ReadAll(errR)
output := string(errOut)
assert.Error(t, err)
assert.ErrorIs(t, err, ErrConflict)
assert.Contains(t, output, "--continue")
// Verify state file was saved
stateData, readErr := os.ReadFile(filepath.Join(tmpDir, "gh-stack-rebase-state"))
require.NoError(t, readErr, "rebase state file should be saved")
var state rebaseState
require.NoError(t, json.Unmarshal(stateData, &state))
assert.Equal(t, "b2", state.ConflictBranch)
assert.Equal(t, []string{"b3"}, state.RemainingBranches)
assert.Equal(t, "b1", state.OriginalBranch)
assert.Contains(t, state.OriginalRefs, "b1")
assert.Contains(t, state.OriginalRefs, "b2")
assert.Contains(t, state.OriginalRefs, "b3")
}
// TestRebase_Continue_NoState verifies that --continue without a state file
// produces a "no rebase in progress" message.
func TestRebase_Continue_NoState(t *testing.T) {
tmpDir := t.TempDir()
mock := newRebaseMock(tmpDir, "b1")
restore := git.SetOps(mock)
defer restore()
cfg, _, errR := config.NewTestConfig()
cmd := RebaseCmd(cfg)
cmd.SetArgs([]string{"--continue"})
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
err := cmd.Execute()
cfg.Err.Close()
errOut, _ := io.ReadAll(errR)
output := string(errOut)
assert.ErrorIs(t, err, ErrSilent)
assert.Contains(t, output, "no rebase in progress")
}
// TestRebase_Abort_RestoresBranches verifies that --abort restores all branches
// to their original SHAs and removes the state file.
func TestRebase_Abort_RestoresBranches(t *testing.T) {
tmpDir := t.TempDir()
// Pre-create rebase state
state := &rebaseState{
CurrentBranchIndex: 1,
ConflictBranch: "b2",
RemainingBranches: []string{"b3"},
OriginalBranch: "b1",
OriginalRefs: map[string]string{
"b1": "orig-sha-b1",
"b2": "orig-sha-b2",
"b3": "orig-sha-b3",
},
}
stateData, _ := json.MarshalIndent(state, "", " ")
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "gh-stack-rebase-state"), stateData, 0644))
var resets []resetCall
var checkouts []string
currentBranch := "b2" // simulating we're on the conflict branch
mock := newRebaseMock(tmpDir, currentBranch)
mock.CheckoutBranchFn = func(name string) error {
checkouts = append(checkouts, name)
currentBranch = name
return nil
}
mock.ResetHardFn = func(ref string) error {
resets = append(resets, resetCall{currentBranch, ref})
return nil
}
restore := git.SetOps(mock)
defer restore()
cfg, _, errR := config.NewTestConfig()
cmd := RebaseCmd(cfg)
cmd.SetArgs([]string{"--abort"})
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
err := cmd.Execute()
cfg.Err.Close()
errOut, _ := io.ReadAll(errR)
output := string(errOut)
assert.NoError(t, err)
assert.Contains(t, output, "Rebase aborted and branches restored")
// Verify each branch was reset to its original SHA.
// Map iteration order is non-deterministic, so collect into a map.
resetMap := make(map[string]string)
for _, r := range resets {
resetMap[r.branch] = r.sha
}
assert.Equal(t, "orig-sha-b1", resetMap["b1"])
assert.Equal(t, "orig-sha-b2", resetMap["b2"])
assert.Equal(t, "orig-sha-b3", resetMap["b3"])
// State file should be removed
_, err = os.Stat(filepath.Join(tmpDir, "gh-stack-rebase-state"))
assert.True(t, os.IsNotExist(err), "state file should be removed after abort")
// Should return to original branch
assert.Contains(t, checkouts, "b1", "should checkout original branch at end")
}
// TestRebase_DownstackOnly verifies that --downstack only rebases branches
// from trunk to the current branch (inclusive).
func TestRebase_DownstackOnly(t *testing.T) {
s := stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "b1"},
{Branch: "b2"},
{Branch: "b3"},
},
}
tmpDir := t.TempDir()
writeStackFile(t, tmpDir, s)
var allRebaseCalls []rebaseCall
var currentCheckedOut string
mock := newRebaseMock(tmpDir, "b2")
mock.CheckoutBranchFn = func(name string) error {
currentCheckedOut = name
return nil
}
mock.RebaseFn = func(base string, opts git.RebaseOpts) error {
allRebaseCalls = append(allRebaseCalls, rebaseCall{newBase: base, oldBase: "", branch: currentCheckedOut})
return nil
}
mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error {
allRebaseCalls = append(allRebaseCalls, rebaseCall{newBase, oldBase, branch})
return nil
}
restore := git.SetOps(mock)
defer restore()
cfg, _, _ := config.NewTestConfig()
cmd := RebaseCmd(cfg)
cmd.SetArgs([]string{"--downstack"})
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
err := cmd.Execute()
cfg.Out.Close()
cfg.Err.Close()
assert.NoError(t, err)
// b2 is at index 1, so downstack = [b1, b2] (indices 0..1)
require.Len(t, allRebaseCalls, 2, "downstack should rebase b1 and b2 only")
assert.Equal(t, "main", allRebaseCalls[0].newBase, "b1 should be rebased onto trunk")
assert.Equal(t, "b1", allRebaseCalls[1].newBase, "b2 should be rebased onto b1")
}
// TestRebase_UpstackOnly verifies that --upstack only rebases branches
// from the current branch to the top.
func TestRebase_UpstackOnly(t *testing.T) {
s := stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "b1"},
{Branch: "b2"},
{Branch: "b3"},
},
}
tmpDir := t.TempDir()
writeStackFile(t, tmpDir, s)
var allRebaseCalls []rebaseCall
var currentCheckedOut string
mock := newRebaseMock(tmpDir, "b2")
mock.CheckoutBranchFn = func(name string) error {
currentCheckedOut = name
return nil
}
mock.RebaseFn = func(base string, opts git.RebaseOpts) error {
allRebaseCalls = append(allRebaseCalls, rebaseCall{newBase: base, oldBase: "", branch: currentCheckedOut})
return nil
}
mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error {
allRebaseCalls = append(allRebaseCalls, rebaseCall{newBase, oldBase, branch})
return nil
}
restore := git.SetOps(mock)
defer restore()
cfg, _, _ := config.NewTestConfig()
cmd := RebaseCmd(cfg)
cmd.SetArgs([]string{"--upstack"})
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
err := cmd.Execute()
cfg.Out.Close()
cfg.Err.Close()
assert.NoError(t, err)
// b2 is at index 1, upstack = [b2, b3] (indices 1..2)
require.Len(t, allRebaseCalls, 2, "upstack should rebase b2 and b3")
assert.Equal(t, "b1", allRebaseCalls[0].newBase, "b2 should be rebased onto b1")
assert.Equal(t, "b2", allRebaseCalls[1].newBase, "b3 should be rebased onto b2")
}
// TestRebase_UpstackWithMergedBranchBelow verifies that --upstack pre-seeds
// --onto state when a merged branch exists immediately below the rebase range.
func TestRebase_UpstackWithMergedBranchBelow(t *testing.T) {
s := stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10, Merged: true}},
{Branch: "b2"},
{Branch: "b3"},
},
}
tmpDir := t.TempDir()
writeStackFile(t, tmpDir, s)
var allRebaseCalls []rebaseCall
var currentCheckedOut string
mock := newRebaseMock(tmpDir, "b2")
mock.CheckoutBranchFn = func(name string) error {
currentCheckedOut = name
return nil
}
mock.BranchExistsFn = func(name string) bool { return true }
mock.RebaseFn = func(base string, opts git.RebaseOpts) error {
allRebaseCalls = append(allRebaseCalls, rebaseCall{newBase: base, oldBase: "", branch: currentCheckedOut})
return nil
}
mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error {
allRebaseCalls = append(allRebaseCalls, rebaseCall{newBase, oldBase, branch})
return nil
}
restore := git.SetOps(mock)
defer restore()
cfg, _, _ := config.NewTestConfig()
cmd := RebaseCmd(cfg)
cmd.SetArgs([]string{"--upstack"})
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
err := cmd.Execute()
cfg.Out.Close()
cfg.Err.Close()
assert.NoError(t, err)
// b2 is at index 1, upstack = [b2, b3]. b1 is merged below.
// b2 should use --onto because b1 was merged.
require.Len(t, allRebaseCalls, 2, "upstack should rebase b2 and b3")
// b2: --onto rebase with b1's old SHA as old base
assert.Equal(t, "main", allRebaseCalls[0].newBase, "b2 should be rebased onto main (first non-merged ancestor)")
assert.Equal(t, "sha-b1", allRebaseCalls[0].oldBase, "b2 should use b1's original SHA as old base")
assert.Equal(t, "b2", allRebaseCalls[0].branch, "b2 should be the branch being rebased")
// b3: --onto continues to propagate
assert.Equal(t, "b2", allRebaseCalls[1].newBase, "b3 should be rebased onto b2")
assert.NotEmpty(t, allRebaseCalls[1].oldBase, "b3 should also use --onto")
}
// TestRebase_SkipsMergedBranches verifies that merged branches are skipped
// with an appropriate message.
func TestRebase_SkipsMergedBranches(t *testing.T) {
s := stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 42, Merged: true}},
{Branch: "b2"},
},
}
tmpDir := t.TempDir()
writeStackFile(t, tmpDir, s)
var rebaseCalls []rebaseCall
mock := newRebaseMock(tmpDir, "b2")
mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error {
rebaseCalls = append(rebaseCalls, rebaseCall{newBase, oldBase, branch})
return nil
}
restore := git.SetOps(mock)
defer restore()
cfg, _, errR := config.NewTestConfig()
cmd := RebaseCmd(cfg)
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
err := cmd.Execute()
cfg.Err.Close()
errOut, _ := io.ReadAll(errR)
output := string(errOut)
assert.NoError(t, err)
assert.Contains(t, output, "Skipping b1")
assert.Contains(t, output, "PR #42 merged")
// Only b2 should be rebased
require.Len(t, rebaseCalls, 1)
assert.Equal(t, "b2", rebaseCalls[0].branch)
}
// queuedPRClient returns a MockClient whose FindPRByNumber reports the given PR
// numbers as queued (in a merge queue, open, not merged) and finds no PR by
// branch name. Used to drive the transient Queued state through syncStackPRs in
// rebase/sync tests.
func queuedPRClient(headByNumber map[int]string) *github.MockClient {
return &github.MockClient{
FindPRByNumberFn: func(n int) (*github.PullRequest, error) {
head, ok := headByNumber[n]
if !ok {
return nil, nil
}
return &github.PullRequest{
Number: n,
HeadRefName: head,
State: "OPEN",
Merged: false,
MergeQueueEntry: &github.MergeQueueEntry{ID: fmt.Sprintf("MQ_%d", n)},
}, nil
},
FindPRForBranchFn: func(string) (*github.PullRequest, error) { return nil, nil },
}
}
// TestRebase_QueuedBranch_DownstreamStaysStacked verifies the #144 fix: a queued
// PR is NOT treated as merged. Its branch is skipped (frozen in the merge queue),
// but downstream branches stay stacked on top of it — they rebase onto the queued
// branch, not --onto trunk with the queued commits dropped.
func TestRebase_QueuedBranch_DownstreamStaysStacked(t *testing.T) {
s := stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10}},
{Branch: "b2"},
{Branch: "b3"},
},
}
tmpDir := t.TempDir()
writeStackFile(t, tmpDir, s)
var rebaseCalls []rebaseCall
mock := newRebaseMock(tmpDir, "b2")
mock.BranchExistsFn = func(name string) bool { return true }
mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error {
rebaseCalls = append(rebaseCalls, rebaseCall{newBase, oldBase, branch})
return nil
}
restore := git.SetOps(mock)
defer restore()
cfg, _, errR := config.NewTestConfig()
cfg.GitHubClientOverride = queuedPRClient(map[int]string{10: "b1"})
cmd := RebaseCmd(cfg)
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
err := cmd.Execute()
cfg.Err.Close()
errOut, _ := io.ReadAll(errR)
output := string(errOut)
assert.NoError(t, err)
assert.Contains(t, output, "Skipping b1")
assert.Contains(t, output, "queued")
assert.NotContains(t, output, "adjusted for merged PR",
"queued branches must not trigger the merged --onto path")
// b2 stays stacked on the queued b1 (not rebased --onto main); b3 onto b2.
require.Len(t, rebaseCalls, 2)
assert.Equal(t, rebaseCall{"b1", "sha-b1", "b2"}, rebaseCalls[0],
"b2 should rebase onto the queued branch b1, keeping its commits")
assert.Equal(t, rebaseCall{"b2", "sha-b2", "b3"}, rebaseCalls[1],
"b3 should rebase onto b2")
}
// TestRebase_MergedBelowQueued_KeepsStackedOnQueued verifies that when a merged
// branch sits below a queued branch, the branch above the queued one stays
// stacked on the queued branch. The queued branch is frozen and still carries the
// merged branch's commits, so downstream cannot drop them via --onto.
func TestRebase_MergedBelowQueued_KeepsStackedOnQueued(t *testing.T) {
s := stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10, Merged: true}},
{Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 11}},
{Branch: "b3"},
},
}
tmpDir := t.TempDir()
writeStackFile(t, tmpDir, s)
var rebaseCalls []rebaseCall
mock := newRebaseMock(tmpDir, "b3")
mock.BranchExistsFn = func(name string) bool { return true }
mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error {
rebaseCalls = append(rebaseCalls, rebaseCall{newBase, oldBase, branch})
return nil
}
restore := git.SetOps(mock)
defer restore()
cfg, _, errR := config.NewTestConfig()
cfg.GitHubClientOverride = queuedPRClient(map[int]string{11: "b2"})
cmd := RebaseCmd(cfg)
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
err := cmd.Execute()
cfg.Err.Close()
errOut, _ := io.ReadAll(errR)
output := string(errOut)
assert.NoError(t, err)
assert.Contains(t, output, "Skipping b1")
assert.Contains(t, output, "PR #10 merged")
assert.Contains(t, output, "Skipping b2")
assert.Contains(t, output, "queued")
// b1 merged and b2 queued are both skipped. b3 stays stacked on the queued
// b2 — it must NOT be rebased --onto main (which would drop b2's + b1's
// commits while b2 is frozen).
require.Len(t, rebaseCalls, 1)
assert.Equal(t, rebaseCall{"b2", "sha-b2", "b3"}, rebaseCalls[0],
"b3 should rebase onto the queued b2, not --onto main")
assert.NotContains(t, output, "adjusted for merged PR")
}
// TestRebase_UpstackAboveQueuedBranch verifies the onto-seed fix: with --upstack
// starting just above a queued branch, the first in-range branch rebases normally
// onto the queued predecessor rather than dropping its commits via --onto.
func TestRebase_UpstackAboveQueuedBranch(t *testing.T) {
s := stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10}},
{Branch: "b2"},
{Branch: "b3"},
},
}
tmpDir := t.TempDir()
writeStackFile(t, tmpDir, s)
var rebaseCalls []rebaseCall
mock := newRebaseMock(tmpDir, "b2")
mock.BranchExistsFn = func(name string) bool { return true }
mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error {
rebaseCalls = append(rebaseCalls, rebaseCall{newBase, oldBase, branch})
return nil
}
restore := git.SetOps(mock)
defer restore()
cfg, _, errR := config.NewTestConfig()
cfg.GitHubClientOverride = queuedPRClient(map[int]string{10: "b1"})
cmd := RebaseCmd(cfg)
cmd.SetArgs([]string{"--upstack"})
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
err := cmd.Execute()
cfg.Err.Close()
errOut, _ := io.ReadAll(errR)
output := string(errOut)
assert.NoError(t, err)
// upstack from b2 = [b2, b3]; b1 (queued) is below the range.
require.Len(t, rebaseCalls, 2)
assert.Equal(t, rebaseCall{"b1", "sha-b1", "b2"}, rebaseCalls[0],
"b2 should rebase onto the queued predecessor b1, not --onto main")
assert.Equal(t, rebaseCall{"b2", "sha-b2", "b3"}, rebaseCalls[1],
"b3 should rebase onto b2")
assert.NotContains(t, output, "adjusted for merged PR")
}
// TestRebase_StateRoundTrip verifies that rebase state can be saved and loaded
// back with all fields preserved, including the --onto fields.
func TestRebase_StateRoundTrip(t *testing.T) {
tmpDir := t.TempDir()
original := &rebaseState{
CurrentBranchIndex: 2,
ConflictBranch: "feature-b",
RemainingBranches: []string{"feature-c", "feature-d"},
OriginalBranch: "feature-a",
OriginalRefs: map[string]string{
"feature-a": "aaa111",
"feature-b": "bbb222",
"feature-c": "ccc333",
"feature-d": "ddd444",
},
UseOnto: true,
OntoOldBase: "bbb222",
}
err := saveRebaseState(tmpDir, original)
require.NoError(t, err)
loaded, err := loadRebaseState(tmpDir)
require.NoError(t, err)
assert.Equal(t, original.CurrentBranchIndex, loaded.CurrentBranchIndex)
assert.Equal(t, original.ConflictBranch, loaded.ConflictBranch)
assert.Equal(t, original.RemainingBranches, loaded.RemainingBranches)
assert.Equal(t, original.OriginalBranch, loaded.OriginalBranch)
assert.Equal(t, original.OriginalRefs, loaded.OriginalRefs)
assert.Equal(t, original.UseOnto, loaded.UseOnto)
assert.Equal(t, original.OntoOldBase, loaded.OntoOldBase)
}
// TestRebase_Continue_RebasesRemainingBranches verifies the --continue success
// path: RebaseContinue is called, remaining branches are rebased via RebaseOnto,
// the state file is cleaned up, and the original branch is restored.
func TestRebase_Continue_RebasesRemainingBranches(t *testing.T) {
s := stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "b1"},
{Branch: "b2"},
{Branch: "b3"},
},
}
tmpDir := t.TempDir()
writeStackFile(t, tmpDir, s)
// State: b2 had a conflict (index 1), b3 remains to be rebased.
state := &rebaseState{
CurrentBranchIndex: 1,
ConflictBranch: "b2",
RemainingBranches: []string{"b3"},
OriginalBranch: "b1",
OriginalRefs: map[string]string{
"main": "main-orig-sha",
"b1": "b1-orig-sha",
"b2": "b2-orig-sha",
"b3": "b3-orig-sha",
},
}
stateData, _ := json.MarshalIndent(state, "", " ")
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "gh-stack-rebase-state"), stateData, 0644))
var rebaseContinueCalled bool
var rebaseCalls []rebaseCall
var checkouts []string
mock := newRebaseMock(tmpDir, "b2")
mock.IsRebaseInProgressFn = func() bool { return true }
mock.RebaseContinueFn = func(opts git.RebaseOpts) error {
rebaseContinueCalled = true
return nil
}
mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error {
rebaseCalls = append(rebaseCalls, rebaseCall{newBase, oldBase, branch})
return nil
}
mock.CheckoutBranchFn = func(name string) error {
checkouts = append(checkouts, name)
return nil
}
restore := git.SetOps(mock)
defer restore()
cfg, _, _ := config.NewTestConfig()
cmd := RebaseCmd(cfg)
cmd.SetArgs([]string{"--continue"})
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
err := cmd.Execute()
assert.NoError(t, err)
assert.True(t, rebaseContinueCalled, "RebaseContinue should be called")
// b3 is at idx 2 (idx > 0, not UseOnto) → RebaseOnto(base=b2, originalRefs[b2], b3)
require.Len(t, rebaseCalls, 1)
assert.Equal(t, rebaseCall{"b2", "b2-orig-sha", "b3"}, rebaseCalls[0])
// State file should be removed after success
_, statErr := os.Stat(filepath.Join(tmpDir, "gh-stack-rebase-state"))
assert.True(t, os.IsNotExist(statErr), "state file should be removed after success")
// Original branch should be checked out at the end
assert.Contains(t, checkouts, "b1", "should checkout original branch")
}
// TestRebase_Continue_QueuedBranchBelowConflict verifies that a queued branch is
// still skipped when the cascade resumes via --continue after a conflict below
// it. The Queued flag is transient and lost when continueRebase reloads the
// stack from disk, so it must be refreshed before the remaining cascade — else
// the frozen merge-queue branch would be rebased.
func TestRebase_Continue_QueuedBranchBelowConflict(t *testing.T) {
s := stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "b1"},
{Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 20}},
{Branch: "b3"},
},
}
tmpDir := t.TempDir()
writeStackFile(t, tmpDir, s)
// State: b1 (below the queued b2) conflicted; b2 and b3 remain.
state := &rebaseState{
CurrentBranchIndex: 0,
ConflictBranch: "b1",
RemainingBranches: []string{"b2", "b3"},
OriginalBranch: "b3",
OriginalRefs: map[string]string{
"main": "main-orig-sha",
"b1": "sha-b1",
"b2": "sha-b2",
"b3": "sha-b3",
},
}
stateData, _ := json.MarshalIndent(state, "", " ")
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "gh-stack-rebase-state"), stateData, 0644))
var rebaseCalls []rebaseCall
mock := newRebaseMock(tmpDir, "b1")
mock.BranchExistsFn = func(name string) bool { return true }
mock.IsRebaseInProgressFn = func() bool { return true }
mock.RebaseContinueFn = func(opts git.RebaseOpts) error { return nil }
mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error {
rebaseCalls = append(rebaseCalls, rebaseCall{newBase, oldBase, branch})
return nil
}
mock.CheckoutBranchFn = func(string) error { return nil }
restore := git.SetOps(mock)
defer restore()
cfg, _, errR := config.NewTestConfig()
cfg.GitHubClientOverride = queuedPRClient(map[int]string{20: "b2"})
cmd := RebaseCmd(cfg)
cmd.SetArgs([]string{"--continue"})
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
err := cmd.Execute()
cfg.Err.Close()
errOut, _ := io.ReadAll(errR)
output := string(errOut)
assert.NoError(t, err)
assert.Contains(t, output, "Skipping b2")
assert.Contains(t, output, "queued")
// Only b3 is rebased, onto the queued b2. The queued b2 itself must not be
// rebased (its branch is frozen in the merge queue).
require.Len(t, rebaseCalls, 1)
assert.Equal(t, rebaseCall{"b2", "sha-b2", "b3"}, rebaseCalls[0])
for _, c := range rebaseCalls {
assert.NotEqual(t, "b2", c.branch, "the frozen queued branch must not be rebased")
}
}
// TestRebase_Continue_OntoMode verifies the --continue path when UseOnto is
// set (merged branches upstream). With no remaining branches, only
// RebaseContinue runs and the state is cleaned up.
func TestRebase_Continue_OntoMode(t *testing.T) {
s := stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10, Merged: true}},
{Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 11, Merged: true}},
{Branch: "b3"},
},
}
tmpDir := t.TempDir()
writeStackFile(t, tmpDir, s)
// b3 was the conflict branch; no remaining branches after it.
state := &rebaseState{
CurrentBranchIndex: 2,
ConflictBranch: "b3",
RemainingBranches: []string{},
OriginalBranch: "b1",
OriginalRefs: map[string]string{
"main": "sha-main",
"b1": "sha-b1",
"b2": "sha-b2",
"b3": "sha-b3",
},
UseOnto: true,
OntoOldBase: "sha-b2",
}
stateData, _ := json.MarshalIndent(state, "", " ")
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "gh-stack-rebase-state"), stateData, 0644))
var rebaseContinueCalled bool
mock := newRebaseMock(tmpDir, "b3")
mock.IsRebaseInProgressFn = func() bool { return true }
mock.RebaseContinueFn = func(opts git.RebaseOpts) error {
rebaseContinueCalled = true
return nil
}
mock.CheckoutBranchFn = func(string) error { return nil }
restore := git.SetOps(mock)
defer restore()
cfg, _, _ := config.NewTestConfig()
cmd := RebaseCmd(cfg)
cmd.SetArgs([]string{"--continue"})
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
err := cmd.Execute()
assert.NoError(t, err)
assert.True(t, rebaseContinueCalled, "RebaseContinue should be called")
// State file should be removed after success
_, statErr := os.Stat(filepath.Join(tmpDir, "gh-stack-rebase-state"))
assert.True(t, os.IsNotExist(statErr), "state file should be removed after success")
}
// TestRebase_Continue_ConflictOnRemaining verifies that when --continue
// successfully resolves the first conflict but hits a new conflict on a
// remaining branch, the state is updated and ErrConflict is returned.
func TestRebase_Continue_ConflictOnRemaining(t *testing.T) {
s := stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "b1"},
{Branch: "b2"},
{Branch: "b3"},
{Branch: "b4"},
},
}
tmpDir := t.TempDir()
writeStackFile(t, tmpDir, s)
state := &rebaseState{
CurrentBranchIndex: 1,
ConflictBranch: "b2",
RemainingBranches: []string{"b3", "b4"},
OriginalBranch: "b1",
OriginalRefs: map[string]string{
"main": "sha-main",
"b1": "sha-b1",
"b2": "sha-b2",
"b3": "sha-b3",
"b4": "sha-b4",
},
}
stateData, _ := json.MarshalIndent(state, "", " ")
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "gh-stack-rebase-state"), stateData, 0644))
mock := newRebaseMock(tmpDir, "b2")
mock.IsRebaseInProgressFn = func() bool { return true }
mock.RebaseContinueFn = func(opts git.RebaseOpts) error { return nil }
mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error {
if branch == "b3" {
return assert.AnError // conflict on b3
}
return nil
}
mock.ConflictedFilesFn = func() ([]string, error) { return nil, nil }
mock.CheckoutBranchFn = func(string) error { return nil }
restore := git.SetOps(mock)
defer restore()
cfg, _, errR := config.NewTestConfig()
cmd := RebaseCmd(cfg)
cmd.SetArgs([]string{"--continue"})
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
err := cmd.Execute()
cfg.Err.Close()
errOut, _ := io.ReadAll(errR)
output := string(errOut)
assert.Error(t, err)
assert.ErrorIs(t, err, ErrConflict)
assert.Contains(t, output, "--continue")
// State file should still exist with updated conflict info
updatedData, readErr := os.ReadFile(filepath.Join(tmpDir, "gh-stack-rebase-state"))
require.NoError(t, readErr, "state file should still exist after new conflict")
var updatedState rebaseState
require.NoError(t, json.Unmarshal(updatedData, &updatedState))
assert.Equal(t, "b3", updatedState.ConflictBranch)
assert.Equal(t, []string{"b4"}, updatedState.RemainingBranches)
}
// TestRebase_Abort_WithActiveRebase verifies that --abort calls RebaseAbort
// when a git rebase is in progress, restores branches, and cleans up the state.
func TestRebase_Abort_WithActiveRebase(t *testing.T) {
tmpDir := t.TempDir()
state := &rebaseState{
CurrentBranchIndex: 1,
ConflictBranch: "b2",
RemainingBranches: []string{},
OriginalBranch: "b1",
OriginalRefs: map[string]string{
"b1": "orig-sha-b1",
"b2": "orig-sha-b2",
},
}
stateData, _ := json.MarshalIndent(state, "", " ")
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "gh-stack-rebase-state"), stateData, 0644))
var rebaseAbortCalled bool
var resets []resetCall
var checkouts []string
currentBranch := "b2"
mock := newRebaseMock(tmpDir, currentBranch)
mock.IsRebaseInProgressFn = func() bool { return true }
mock.RebaseAbortFn = func() error {
rebaseAbortCalled = true
return nil
}
mock.CheckoutBranchFn = func(name string) error {
checkouts = append(checkouts, name)
currentBranch = name
return nil
}
mock.ResetHardFn = func(ref string) error {
resets = append(resets, resetCall{currentBranch, ref})
return nil
}
restore := git.SetOps(mock)
defer restore()
cfg, _, errR := config.NewTestConfig()
cmd := RebaseCmd(cfg)
cmd.SetArgs([]string{"--abort"})
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
err := cmd.Execute()
cfg.Err.Close()
errOut, _ := io.ReadAll(errR)
output := string(errOut)
assert.NoError(t, err)
assert.True(t, rebaseAbortCalled, "RebaseAbort should be called when rebase is in progress")
assert.Contains(t, output, "Rebase aborted and branches restored")
// Verify branches restored to original SHAs
resetMap := make(map[string]string)
for _, r := range resets {
resetMap[r.branch] = r.sha
}
assert.Equal(t, "orig-sha-b1", resetMap["b1"])
assert.Equal(t, "orig-sha-b2", resetMap["b2"])
// State file should be removed
_, statErr := os.Stat(filepath.Join(tmpDir, "gh-stack-rebase-state"))
assert.True(t, os.IsNotExist(statErr), "state file should be removed after abort")
// Should return to original branch
assert.Contains(t, checkouts, "b1", "should checkout original branch at end")
}
// TestRebase_FastForwardsBranchFromRemote verifies that when origin/b1 is ahead
// of local b1 (someone pushed a new commit), the branch is fast-forwarded before
// the cascade rebase so downstream branches include the new commits.
func TestRebase_FastForwardsBranchFromRemote(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 allRebaseCalls []rebaseCall
var updateBranchRefCalls []struct{ branch, sha string }
mock := newRebaseMock(tmpDir, "b2")
// b1 is behind origin/b1 (remote has new commit)
mock.RevParseFn = func(ref string) (string, error) {
if ref == "b1" {
return "b1-local-sha", nil
}
if ref == "origin/b1" {
return "b1-remote-sha", nil
}
// trunk and origin/trunk same — trunk already up to date
if ref == "main" || ref == "origin/main" {
return "main-sha", nil
}
if strings.HasPrefix(ref, "origin/") {
return "sha-" + strings.TrimPrefix(ref, "origin/"), nil
}
return "sha-" + ref, nil
}
mock.IsAncestorFn = func(a, d string) (bool, error) {
// b1-local is ancestor of b1-remote → can fast-forward
if a == "b1-local-sha" && d == "b1-remote-sha" {
return true, nil
}
return false, nil
}
mock.UpdateBranchRefFn = func(branch, sha string) error {
updateBranchRefCalls = append(updateBranchRefCalls, struct{ branch, sha string }{branch, sha})
return nil
}
mock.CheckoutBranchFn = func(string) error { return nil }
mock.RebaseFn = func(base string, opts git.RebaseOpts) error {
allRebaseCalls = append(allRebaseCalls, rebaseCall{newBase: base})
return nil
}
mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error {
allRebaseCalls = append(allRebaseCalls, rebaseCall{newBase, oldBase, branch})
return nil
}
restore := git.SetOps(mock)
defer restore()
cfg, _, errR := config.NewTestConfig()
cmd := RebaseCmd(cfg)
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
err := cmd.Execute()
cfg.Err.Close()
errOut, _ := io.ReadAll(errR)
output := string(errOut)
assert.NoError(t, err)
// b1 should be fast-forwarded to remote SHA
require.Len(t, updateBranchRefCalls, 1, "should fast-forward b1 via UpdateBranchRef")
assert.Equal(t, "b1", updateBranchRefCalls[0].branch)
assert.Equal(t, "b1-remote-sha", updateBranchRefCalls[0].sha)
assert.Contains(t, output, "Fast-forwarded b1")
// Cascade rebase should still occur
assert.NotEmpty(t, allRebaseCalls, "cascade rebase should still happen")
}
// TestRebase_BranchAlreadyUpToDate_NoFF verifies that when a branch's local
// and remote SHAs match, no fast-forward occurs.
func TestRebase_BranchAlreadyUpToDate_NoFF(t *testing.T) {
s := stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "b1"},
},
}
tmpDir := t.TempDir()
writeStackFile(t, tmpDir, s)
var updateBranchRefCalls int
var mergeFFCalls int
mock := newRebaseMock(tmpDir, "b1")
// Same SHA for b1 and origin/b1 — already up to date (default mock handles this)
mock.UpdateBranchRefFn = func(string, string) error {
updateBranchRefCalls++
return nil
}
mock.MergeFFFn = func(string) error {
mergeFFCalls++
return nil
}
mock.CheckoutBranchFn = func(string) error { return nil }
mock.RebaseFn = func(string, git.RebaseOpts) error { return nil }
restore := git.SetOps(mock)
defer restore()
cfg, _, _ := config.NewTestConfig()
cmd := RebaseCmd(cfg)
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
err := cmd.Execute()
cfg.Out.Close()
cfg.Err.Close()
assert.NoError(t, err)
assert.Equal(t, 0, updateBranchRefCalls, "no UpdateBranchRef for branches already up to date")
assert.Equal(t, 0, mergeFFCalls, "no MergeFF for branches already up to date")
}
// TestRebase_BranchDiverged_NoFF verifies that when local and remote branches
// have diverged (e.g., after a previous local rebase), no fast-forward occurs.
func TestRebase_BranchDiverged_NoFF(t *testing.T) {
s := stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "b1"},
},
}
tmpDir := t.TempDir()
writeStackFile(t, tmpDir, s)
var updateBranchRefCalls int
mock := newRebaseMock(tmpDir, "b1")
// Different SHAs for b1 and origin/b1
mock.RevParseFn = func(ref string) (string, error) {
if ref == "b1" {
return "b1-local-sha", nil
}
if ref == "origin/b1" {
return "b1-remote-sha", nil
}
if ref == "main" || ref == "origin/main" {
return "main-sha", nil
}
return "sha-" + ref, nil
}
// Neither is ancestor of the other — diverged
mock.IsAncestorFn = func(a, d string) (bool, error) {
return false, nil
}
mock.UpdateBranchRefFn = func(string, string) error {
updateBranchRefCalls++
return nil
}
mock.CheckoutBranchFn = func(string) error { return nil }
mock.RebaseFn = func(string, git.RebaseOpts) error { return nil }
restore := git.SetOps(mock)
defer restore()
cfg, _, _ := config.NewTestConfig()
cmd := RebaseCmd(cfg)
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
err := cmd.Execute()
cfg.Out.Close()
cfg.Err.Close()
assert.NoError(t, err)
assert.Equal(t, 0, updateBranchRefCalls, "no FF when branches have diverged")
}
func TestRebase_SkipsMergedBranchesNotExistingLocally(t *testing.T) {
// Simulates a stack where b1 is merged and its branch was auto-deleted
// from the remote, so it doesn't exist locally. The stored Head SHA is
// used as ontoOldBase for the next branch's --onto rebase.
s := stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "b1", Head: "b1-stored-head-sha", PullRequest: &stack.PullRequestRef{Number: 42, Merged: true}},
{Branch: "b2"},
},
}
tmpDir := t.TempDir()
writeStackFile(t, tmpDir, s)
var rebaseCalls []rebaseCall
mock := newRebaseMock(tmpDir, "b2")
mock.BranchExistsFn = func(name string) bool {
// b1 does not exist locally (deleted from remote after merge)
return name != "b1"
}
mock.RevParseMultiFn = func(refs []string) ([]string, error) {
// Only resolve refs that exist — b1 should not be in the list
shas := make([]string, len(refs))
for i, r := range refs {
if r == "b1" {
t.Fatalf("RevParseMulti should not be called with non-existent branch b1")
}
shas[i] = "sha-" + r
}
return shas, nil
}
mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error {
rebaseCalls = append(rebaseCalls, rebaseCall{newBase, oldBase, branch})
return nil
}
restore := git.SetOps(mock)
defer restore()
cfg, _, errR := config.NewTestConfig()
cmd := RebaseCmd(cfg)
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
err := cmd.Execute()
cfg.Err.Close()
errOut, _ := io.ReadAll(errR)
output := string(errOut)
assert.NoError(t, err)
assert.Contains(t, output, "Skipping b1")
// Only b2 should be rebased, and the rebase should use b1's stored
// Head SHA as oldBase so `git rebase --onto` receives valid arguments.
require.Len(t, rebaseCalls, 1)
assert.Equal(t, "b2", rebaseCalls[0].branch)
assert.Equal(t, "main", rebaseCalls[0].newBase)
assert.Equal(t, "b1-stored-head-sha", rebaseCalls[0].oldBase)
}
// TestRebase_CommitterDateIsAuthorDate verifies that when
// --committer-date-is-author-date is passed, it is forwarded to all rebase
// calls in the cascade.
func TestRebase_CommitterDateIsAuthorDate(t *testing.T) {
s := stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "b1"},
{Branch: "b2"},
{Branch: "b3"},
},
}
tmpDir := t.TempDir()
writeStackFile(t, tmpDir, s)
var receivedOpts []git.RebaseOpts
var currentCheckedOut string
mock := newRebaseMock(tmpDir, "b2")
mock.CheckoutBranchFn = func(name string) error {
currentCheckedOut = name
return nil
}
mock.RebaseFn = func(base string, opts git.RebaseOpts) error {
receivedOpts = append(receivedOpts, opts)
_ = currentCheckedOut
return nil
}
mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error {
receivedOpts = append(receivedOpts, opts)
return nil
}
restore := git.SetOps(mock)
defer restore()
cfg, _, errR := config.NewTestConfig()
cmd := RebaseCmd(cfg)
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SetArgs([]string{"--committer-date-is-author-date"})
err := cmd.Execute()
cfg.Err.Close()
errOut, _ := io.ReadAll(errR)
output := string(errOut)
assert.NoError(t, err)
assert.Contains(t, output, "rebased locally")
// All 3 rebase calls should have CommitterDateIsAuthorDate set.
require.Len(t, receivedOpts, 3)
for i, opts := range receivedOpts {
assert.True(t, opts.CommitterDateIsAuthorDate,
"rebase call %d should have CommitterDateIsAuthorDate=true", i)
}
}
// TestRebase_PreserveDatesAlias verifies that --preserve-dates is an alias
// for --committer-date-is-author-date.
func TestRebase_PreserveDatesAlias(t *testing.T) {
s := stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "b1"},
},
}
tmpDir := t.TempDir()
writeStackFile(t, tmpDir, s)
var receivedOpts []git.RebaseOpts
mock := newRebaseMock(tmpDir, "b1")
mock.CheckoutBranchFn = func(string) error { return nil }
mock.RebaseFn = func(base string, opts git.RebaseOpts) error {
receivedOpts = append(receivedOpts, opts)
return nil
}
restore := git.SetOps(mock)
defer restore()
cfg, _, _ := config.NewTestConfig()
cmd := RebaseCmd(cfg)
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SetArgs([]string{"--preserve-dates"})
err := cmd.Execute()
assert.NoError(t, err)
require.Len(t, receivedOpts, 1)
assert.True(t, receivedOpts[0].CommitterDateIsAuthorDate,
"--preserve-dates should set CommitterDateIsAuthorDate=true")
}
// TestRebase_StateRoundTrip_CommitterDateIsAuthorDate verifies that
// CommitterDateIsAuthorDate is persisted and restored in rebase state.
func TestRebase_StateRoundTrip_CommitterDateIsAuthorDate(t *testing.T) {
tmpDir := t.TempDir()
original := &rebaseState{
CurrentBranchIndex: 1,
ConflictBranch: "b2",
RemainingBranches: []string{"b3"},
OriginalBranch: "b1",
OriginalRefs: map[string]string{
"b1": "sha-b1",
"b2": "sha-b2",
"b3": "sha-b3",
},
CommitterDateIsAuthorDate: true,
}
err := saveRebaseState(tmpDir, original)
require.NoError(t, err)
loaded, err := loadRebaseState(tmpDir)
require.NoError(t, err)
assert.Equal(t, true, loaded.CommitterDateIsAuthorDate)
}
// TestRebase_Continue_PreservesCommitterDateFlag verifies that --continue
// restores the committer-date-is-author-date flag from saved state and
// passes it to subsequent rebase calls.
func TestRebase_Continue_PreservesCommitterDateFlag(t *testing.T) {
s := stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "b1"},
{Branch: "b2"},
{Branch: "b3"},
},
}
tmpDir := t.TempDir()
writeStackFile(t, tmpDir, s)
// State: b2 had a conflict, b3 remains. Flag was set.
state := &rebaseState{
CurrentBranchIndex: 1,
ConflictBranch: "b2",
RemainingBranches: []string{"b3"},
OriginalBranch: "b1",
OriginalRefs: map[string]string{
"main": "main-orig-sha",
"b1": "b1-orig-sha",
"b2": "b2-orig-sha",
"b3": "b3-orig-sha",
},
CommitterDateIsAuthorDate: true,
}
require.NoError(t, saveRebaseState(tmpDir, state))
var continueCalled bool
var continueOpts git.RebaseOpts
var rebaseOntoOpts []git.RebaseOpts
mock := newRebaseMock(tmpDir, "b2")
mock.CheckoutBranchFn = func(string) error { return nil }
mock.IsRebaseInProgressFn = func() bool { return continueCalled == false }
mock.RebaseContinueFn = func(opts git.RebaseOpts) error {
continueCalled = true
continueOpts = opts
return nil
}
mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error {
rebaseOntoOpts = append(rebaseOntoOpts, opts)
return nil
}
restore := git.SetOps(mock)
defer restore()
cfg, _, _ := config.NewTestConfig()
cmd := RebaseCmd(cfg)
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SetArgs([]string{"--continue"})
err := cmd.Execute()
assert.NoError(t, err)
assert.True(t, continueCalled)
assert.True(t, continueOpts.CommitterDateIsAuthorDate,
"RebaseContinue should receive CommitterDateIsAuthorDate=true from saved state")
require.Len(t, rebaseOntoOpts, 1)
assert.True(t, rebaseOntoOpts[0].CommitterDateIsAuthorDate,
"remaining cascade rebase should receive CommitterDateIsAuthorDate=true from saved state")
}
// TestRebase_ConflictSavesCommitterDateFlag verifies that when a conflict
// occurs with --committer-date-is-author-date active, the flag is persisted
// in the saved state.
func TestRebase_ConflictSavesCommitterDateFlag(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)
mock := newRebaseMock(tmpDir, "b1")
mock.CheckoutBranchFn = func(string) error { return nil }
mock.RebaseFn = func(base string, opts git.RebaseOpts) error {
return nil // b1 succeeds
}
mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error {
if branch == "b2" {
return fmt.Errorf("conflict")
}
return nil
}
restore := git.SetOps(mock)
defer restore()
cfg, _, _ := config.NewTestConfig()
cmd := RebaseCmd(cfg)
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SetArgs([]string{"--committer-date-is-author-date"})
_ = cmd.Execute()
// Load the saved state and verify the flag is persisted.
loaded, err := loadRebaseState(tmpDir)
require.NoError(t, err)
assert.True(t, loaded.CommitterDateIsAuthorDate,
"saved rebase state should preserve CommitterDateIsAuthorDate flag")
}
// TestRebase_NoTrunk_SkipsTrunkRebase verifies that --no-trunk skips rebasing
// branch 1 onto trunk but still cascades inter-branch rebases.
func TestRebase_NoTrunk_SkipsTrunkRebase(t *testing.T) {
s := stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "b1"},
{Branch: "b2"},
{Branch: "b3"},
},
}
tmpDir := t.TempDir()
writeStackFile(t, tmpDir, s)
var allRebaseCalls []rebaseCall
var currentCheckedOut string
mock := newRebaseMock(tmpDir, "b2")
mock.CheckoutBranchFn = func(name string) error {
currentCheckedOut = name
return nil
}
mock.RebaseFn = func(base string, opts git.RebaseOpts) error {
allRebaseCalls = append(allRebaseCalls, rebaseCall{newBase: base, oldBase: "", branch: currentCheckedOut})
return nil
}
mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error {
allRebaseCalls = append(allRebaseCalls, rebaseCall{newBase, oldBase, branch})
return nil
}
restore := git.SetOps(mock)
defer restore()
cfg, _, errR := config.NewTestConfig()
cmd := RebaseCmd(cfg)
cmd.SetArgs([]string{"--no-trunk"})
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
err := cmd.Execute()
cfg.Err.Close()
errOut, _ := io.ReadAll(errR)
output := string(errOut)
assert.NoError(t, err)
// Only b2 onto b1 and b3 onto b2 — no rebase onto trunk (main).
require.Len(t, allRebaseCalls, 2, "should only rebase b2 and b3 (skip b1 onto trunk)")
assert.Equal(t, "b1", allRebaseCalls[0].newBase, "b2 should be rebased onto b1")
assert.Equal(t, "b2", allRebaseCalls[1].newBase, "b3 should be rebased onto b2")
assert.Contains(t, output, "without trunk")
}
// TestRebase_NoTrunk_SkipsFetch verifies that --no-trunk does not call Fetch.
func TestRebase_NoTrunk_SkipsFetch(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)
fetchCalled := false
mock := newRebaseMock(tmpDir, "b1")
mock.CheckoutBranchFn = func(name string) error { return nil }
mock.RebaseFn = func(base string, opts git.RebaseOpts) error { return nil }
mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error { return nil }
mock.FetchFn = func(remote string) error {
fetchCalled = true
return nil
}
restore := git.SetOps(mock)
defer restore()
cfg, _, _ := config.NewTestConfig()
cmd := RebaseCmd(cfg)
cmd.SetArgs([]string{"--no-trunk"})
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
err := cmd.Execute()
cfg.Out.Close()
cfg.Err.Close()
assert.NoError(t, err)
assert.False(t, fetchCalled, "Fetch should not be called with --no-trunk")
}
// TestRebase_NoTrunk_SingleBranch verifies that --no-trunk with a single-branch
// stack has no branches to rebase (since branch 1 onto trunk is skipped).
func TestRebase_NoTrunk_SingleBranch(t *testing.T) {
s := stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "b1"},
},
}
tmpDir := t.TempDir()
writeStackFile(t, tmpDir, s)
mock := newRebaseMock(tmpDir, "b1")
mock.CheckoutBranchFn = func(name string) error { return nil }
restore := git.SetOps(mock)
defer restore()
cfg, _, errR := config.NewTestConfig()
cmd := RebaseCmd(cfg)
cmd.SetArgs([]string{"--no-trunk"})
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
err := cmd.Execute()
cfg.Err.Close()
errOut, _ := io.ReadAll(errR)
output := string(errOut)
assert.NoError(t, err)
assert.Contains(t, output, "No branches to rebase")
}
// TestRebase_NoTrunk_WithUpstack verifies --no-trunk combined with --upstack
// when the current branch is above index 0. The --no-trunk should not change
// behavior since --upstack already starts from a non-trunk branch.
func TestRebase_NoTrunk_WithUpstack(t *testing.T) {
s := stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "b1"},
{Branch: "b2"},
{Branch: "b3"},
},
}
tmpDir := t.TempDir()
writeStackFile(t, tmpDir, s)
var allRebaseCalls []rebaseCall
var currentCheckedOut string
mock := newRebaseMock(tmpDir, "b2")
mock.CheckoutBranchFn = func(name string) error {
currentCheckedOut = name
return nil
}
mock.RebaseFn = func(base string, opts git.RebaseOpts) error {
allRebaseCalls = append(allRebaseCalls, rebaseCall{newBase: base, oldBase: "", branch: currentCheckedOut})
return nil
}
mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error {
allRebaseCalls = append(allRebaseCalls, rebaseCall{newBase, oldBase, branch})
return nil
}
restore := git.SetOps(mock)
defer restore()
cfg, _, _ := config.NewTestConfig()
cmd := RebaseCmd(cfg)
cmd.SetArgs([]string{"--no-trunk", "--upstack"})
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
err := cmd.Execute()
cfg.Out.Close()
cfg.Err.Close()
assert.NoError(t, err)
// --upstack from b2 = [b2, b3], --no-trunk doesn't change this since startIdx is already 1
require.Len(t, allRebaseCalls, 2, "upstack should rebase b2 and b3")
assert.Equal(t, "b1", allRebaseCalls[0].newBase, "b2 should be rebased onto b1")
assert.Equal(t, "b2", allRebaseCalls[1].newBase, "b3 should be rebased onto b2")
}
// TestRebase_NoTrunk_ConflictSavesState verifies that --no-trunk persists the
// NoTrunk flag in the rebase state when a conflict occurs.
func TestRebase_NoTrunk_ConflictSavesState(t *testing.T) {
s := stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "b1"},
{Branch: "b2"},
{Branch: "b3"},
},
}
tmpDir := t.TempDir()
writeStackFile(t, tmpDir, s)
mock := newRebaseMock(tmpDir, "b2")
mock.CheckoutBranchFn = func(name string) error { return nil }
mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error {
if branch == "b2" {
return fmt.Errorf("conflict")
}
return nil
}
mock.ConflictedFilesFn = func() ([]string, error) { return nil, nil }
restore := git.SetOps(mock)
defer restore()
cfg, _, _ := config.NewTestConfig()
cmd := RebaseCmd(cfg)
cmd.SetArgs([]string{"--no-trunk"})
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
_ = cmd.Execute()
// Load the saved state and verify the NoTrunk flag is persisted.
loaded, err := loadRebaseState(tmpDir)
require.NoError(t, err)
assert.True(t, loaded.NoTrunk,
"saved rebase state should preserve NoTrunk flag")
}