mirror of
https://github.com/github/gh-stack.git
synced 2026-09-14 20:26:28 +08:00
851 lines
25 KiB
Go
851 lines
25 KiB
Go
package cmd
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/github/gh-stack/internal/config"
|
|
"github.com/github/gh-stack/internal/git"
|
|
"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) { 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) error {
|
|
allRebaseCalls = append(allRebaseCalls, rebaseCall{newBase: base, oldBase: "", branch: currentCheckedOut})
|
|
return nil
|
|
}
|
|
mock.RebaseOntoFn = func(newBase, oldBase, branch string) 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_SquashMergedBranch_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_SquashMergedBranch_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.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) 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 squash-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.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) 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_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) error { return nil } // b1 succeeds
|
|
mock.RebaseOntoFn = func(newBase, oldBase, branch string) 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) error {
|
|
allRebaseCalls = append(allRebaseCalls, rebaseCall{newBase: base, oldBase: "", branch: currentCheckedOut})
|
|
return nil
|
|
}
|
|
mock.RebaseOntoFn = func(newBase, oldBase, branch string) 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) error {
|
|
allRebaseCalls = append(allRebaseCalls, rebaseCall{newBase: base, oldBase: "", branch: currentCheckedOut})
|
|
return nil
|
|
}
|
|
mock.RebaseOntoFn = func(newBase, oldBase, branch string) 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_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) 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)
|
|
}
|
|
|
|
// 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() error {
|
|
rebaseContinueCalled = true
|
|
return nil
|
|
}
|
|
mock.RebaseOntoFn = func(newBase, oldBase, branch string) 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_OntoMode verifies the --continue path when UseOnto is
|
|
// set (squash-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() 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() error { return nil }
|
|
mock.RebaseOntoFn = func(newBase, oldBase, branch string) 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")
|
|
}
|