mirror of
https://github.com/github/gh-stack.git
synced 2026-09-14 20:26:28 +08:00
b219e96fb5
* Add --committer-date-is-author-date flag to gh stack rebase
Introduce an opt-in `--committer-date-is-author-date` flag (with
`--preserve-dates` alias) for `gh stack rebase`. The flag is passed
through to every underlying `git rebase` invocation in the cascade,
keeping committer dates equal to author dates so that identical content
rebased onto an identical parent produces stable SHAs. This reduces
spurious force-push notifications and noisy review timelines, especially
in deep stacks where bottom branches get re-rebased on every merge.
Git layer changes:
- Add `RebaseOpts` struct with `CommitterDateIsAuthorDate` field to
`internal/git/gitops.go`
- Update `Ops` interface, `defaultOps`, public wrappers, and
`rebaseContinueOnce`/`tryAutoResolveRebase` helpers to accept and
forward the flag
- Update `MockOps` to match the new signatures
Command layer changes:
- Register `--committer-date-is-author-date` and `--preserve-dates`
flags on the cobra command in `cmd/rebase.go`
- Add `CommitterDateIsAuthorDate` to `cascadeRebaseOpts` and thread it
to all `git.Rebase`/`git.RebaseOnto` calls in `cmd/utils.go`
- Persist the flag in `rebaseState` JSON so `--continue` resumes with
the same behavior; pass it to `RebaseContinue` and subsequent cascade
calls
- Update `internal/modify/apply.go` callers to pass zero-value
`RebaseOpts{}`
Tests:
- Update all existing mock signatures in rebase, sync, and modify tests
- Add tests for flag passthrough, `--preserve-dates` alias, state
round-trip, `--continue` flag restoration, and conflict state
persistence
Docs:
- Update flag tables and examples in README.md and
docs/src/content/docs/reference/cli.md
* clearer wording in docs and help text
1483 lines
43 KiB
Go
1483 lines
43 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/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)
|
|
}
|
|
|
|
// 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_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")
|
|
}
|