diff --git a/README.md b/README.md index f8a5fd3..69302b4 100644 --- a/README.md +++ b/README.md @@ -209,6 +209,7 @@ If a rebase conflict occurs, the operation pauses and prints the conflicted file | `--continue` | Continue the rebase after resolving conflicts | | `--abort` | Abort the rebase and restore all branches to their pre-rebase state | | `--remote ` | Remote to fetch from (defaults to auto-detected remote) | +| `--committer-date-is-author-date` | Preserve commit dates as the same as author dates. Alias: `--preserve-dates` | | Argument | Description | |----------|-------------| @@ -231,6 +232,9 @@ gh stack rebase --continue # Abort rebase and restore everything gh stack rebase --abort + +# Rebase and preserve committer date as author date +gh stack rebase --committer-date-is-author-date ``` ### `gh stack modify` diff --git a/cmd/rebase.go b/cmd/rebase.go index 13415e8..9c27ebc 100644 --- a/cmd/rebase.go +++ b/cmd/rebase.go @@ -16,22 +16,24 @@ import ( ) type rebaseOptions struct { - branch string - downstack bool - upstack bool - cont bool - abort bool - remote string + branch string + downstack bool + upstack bool + cont bool + abort bool + remote string + committerDateIsAuthorDate bool } type rebaseState struct { - CurrentBranchIndex int `json:"currentBranchIndex"` - ConflictBranch string `json:"conflictBranch"` - RemainingBranches []string `json:"remainingBranches"` - OriginalBranch string `json:"originalBranch"` - OriginalRefs map[string]string `json:"originalRefs"` - UseOnto bool `json:"useOnto,omitempty"` - OntoOldBase string `json:"ontoOldBase,omitempty"` + CurrentBranchIndex int `json:"currentBranchIndex"` + ConflictBranch string `json:"conflictBranch"` + RemainingBranches []string `json:"remainingBranches"` + OriginalBranch string `json:"originalBranch"` + OriginalRefs map[string]string `json:"originalRefs"` + UseOnto bool `json:"useOnto,omitempty"` + OntoOldBase string `json:"ontoOldBase,omitempty"` + CommitterDateIsAuthorDate bool `json:"committerDateIsAuthorDate,omitempty"` } const rebaseStateFile = "gh-stack-rebase-state" @@ -74,6 +76,8 @@ layer in its commit history, rebasing if necessary.`, cmd.Flags().BoolVar(&opts.cont, "continue", false, "Continue rebase after resolving conflicts") cmd.Flags().BoolVar(&opts.abort, "abort", false, "Abort rebase and restore all branches") cmd.Flags().StringVar(&opts.remote, "remote", "", "Remote to fetch from (defaults to auto-detected remote)") + cmd.Flags().BoolVar(&opts.committerDateIsAuthorDate, "committer-date-is-author-date", false, "Preserve commit dates as the same as author dates") + cmd.Flags().BoolVar(&opts.committerDateIsAuthorDate, "preserve-dates", false, "Alias for --committer-date-is-author-date") return cmd } @@ -184,26 +188,28 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { } rebaseResult := cascadeRebase(cascadeRebaseOpts{ - Cfg: cfg, - Stack: s, - Branches: branchesToRebase, - StartAbsIdx: startIdx, - OriginalRefs: originalRefs, - NeedsOnto: needsOnto, - OntoOldBase: ontoOldBase, + Cfg: cfg, + Stack: s, + Branches: branchesToRebase, + StartAbsIdx: startIdx, + OriginalRefs: originalRefs, + NeedsOnto: needsOnto, + OntoOldBase: ontoOldBase, + CommitterDateIsAuthorDate: opts.committerDateIsAuthorDate, }) if rebaseResult.Conflicted { cfg.Warningf("Rebasing %s onto %s — conflict", rebaseResult.ConflictBranch, rebaseResult.ConflictBase) state := &rebaseState{ - CurrentBranchIndex: rebaseResult.ConflictIdx, - ConflictBranch: rebaseResult.ConflictBranch, - RemainingBranches: rebaseResult.Remaining, - OriginalBranch: currentBranch, - OriginalRefs: originalRefs, - UseOnto: rebaseResult.NeedsOnto, - OntoOldBase: rebaseResult.OntoOldBase, + CurrentBranchIndex: rebaseResult.ConflictIdx, + ConflictBranch: rebaseResult.ConflictBranch, + RemainingBranches: rebaseResult.Remaining, + OriginalBranch: currentBranch, + OriginalRefs: originalRefs, + UseOnto: rebaseResult.NeedsOnto, + OntoOldBase: rebaseResult.OntoOldBase, + CommitterDateIsAuthorDate: opts.committerDateIsAuthorDate, } if err := saveRebaseState(gitDir, state); err != nil { cfg.Warningf("failed to save rebase state: %s", err) @@ -284,7 +290,8 @@ func continueRebase(cfg *config.Config, gitDir string) error { conflictBranch, s.Branches[len(s.Branches)-1].Branch) if git.IsRebaseInProgress() { - if err := git.RebaseContinue(); err != nil { + rebaseOpts := git.RebaseOpts{CommitterDateIsAuthorDate: state.CommitterDateIsAuthorDate} + if err := git.RebaseContinue(rebaseOpts); err != nil { return fmt.Errorf("rebase continue failed — resolve remaining conflicts and try again: %w", err) } } @@ -324,13 +331,14 @@ func continueRebase(cfg *config.Config, gitDir string) error { } result := cascadeRebase(cascadeRebaseOpts{ - Cfg: cfg, - Stack: s, - Branches: remainingRefs, - StartAbsIdx: startAbsIdx, - OriginalRefs: state.OriginalRefs, - NeedsOnto: state.UseOnto, - OntoOldBase: state.OntoOldBase, + Cfg: cfg, + Stack: s, + Branches: remainingRefs, + StartAbsIdx: startAbsIdx, + OriginalRefs: state.OriginalRefs, + NeedsOnto: state.UseOnto, + OntoOldBase: state.OntoOldBase, + CommitterDateIsAuthorDate: state.CommitterDateIsAuthorDate, }) if result.Conflicted { diff --git a/cmd/rebase_test.go b/cmd/rebase_test.go index 6cca55b..e773bc9 100644 --- a/cmd/rebase_test.go +++ b/cmd/rebase_test.go @@ -2,6 +2,7 @@ package cmd import ( "encoding/json" + "fmt" "io" "os" "path/filepath" @@ -73,11 +74,11 @@ func TestRebase_CascadeRebase(t *testing.T) { currentCheckedOut = name return nil } - mock.RebaseFn = func(base string) error { + 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) error { + mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error { allRebaseCalls = append(allRebaseCalls, rebaseCall{newBase, oldBase, branch}) return nil } @@ -140,7 +141,7 @@ func TestRebase_MergedBranch_UsesOnto(t *testing.T) { } return "default-sha", nil } - mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { + mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error { rebaseCalls = append(rebaseCalls, rebaseCall{newBase, oldBase, branch}) return nil } @@ -205,7 +206,7 @@ func TestRebase_OntoPropagatesToSubsequentBranches(t *testing.T) { } return "default-sha", nil } - mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { + mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error { rebaseCalls = append(rebaseCalls, rebaseCall{newBase, oldBase, branch}) return nil } @@ -290,7 +291,7 @@ func TestRebase_StaleOntoOldBase_FallsBackToMergeBase(t *testing.T) { } return "default-mergebase", nil } - mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { + mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error { rebaseCalls = append(rebaseCalls, rebaseCall{newBase, oldBase, branch}) return nil } @@ -336,8 +337,8 @@ func TestRebase_ConflictSavesState(t *testing.T) { 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 { + 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 } @@ -493,11 +494,11 @@ func TestRebase_DownstackOnly(t *testing.T) { currentCheckedOut = name return nil } - mock.RebaseFn = func(base string) error { + 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) error { + mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error { allRebaseCalls = append(allRebaseCalls, rebaseCall{newBase, oldBase, branch}) return nil } @@ -545,11 +546,11 @@ func TestRebase_UpstackOnly(t *testing.T) { currentCheckedOut = name return nil } - mock.RebaseFn = func(base string) error { + 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) error { + mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error { allRebaseCalls = append(allRebaseCalls, rebaseCall{newBase, oldBase, branch}) return nil } @@ -598,11 +599,11 @@ func TestRebase_UpstackWithMergedBranchBelow(t *testing.T) { return nil } mock.BranchExistsFn = func(name string) bool { return true } - mock.RebaseFn = func(base string) error { + 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) error { + mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error { allRebaseCalls = append(allRebaseCalls, rebaseCall{newBase, oldBase, branch}) return nil } @@ -652,7 +653,7 @@ func TestRebase_SkipsMergedBranches(t *testing.T) { var rebaseCalls []rebaseCall mock := newRebaseMock(tmpDir, "b2") - mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { + mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error { rebaseCalls = append(rebaseCalls, rebaseCall{newBase, oldBase, branch}) return nil } @@ -752,11 +753,11 @@ func TestRebase_Continue_RebasesRemainingBranches(t *testing.T) { mock := newRebaseMock(tmpDir, "b2") mock.IsRebaseInProgressFn = func() bool { return true } - mock.RebaseContinueFn = func() error { + mock.RebaseContinueFn = func(opts git.RebaseOpts) error { rebaseContinueCalled = true return nil } - mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { + mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error { rebaseCalls = append(rebaseCalls, rebaseCall{newBase, oldBase, branch}) return nil } @@ -828,7 +829,7 @@ func TestRebase_Continue_OntoMode(t *testing.T) { mock := newRebaseMock(tmpDir, "b3") mock.IsRebaseInProgressFn = func() bool { return true } - mock.RebaseContinueFn = func() error { + mock.RebaseContinueFn = func(opts git.RebaseOpts) error { rebaseContinueCalled = true return nil } @@ -887,8 +888,8 @@ func TestRebase_Continue_ConflictOnRemaining(t *testing.T) { mock := newRebaseMock(tmpDir, "b2") mock.IsRebaseInProgressFn = func() bool { return true } - mock.RebaseContinueFn = func() error { return nil } - mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { + 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 } @@ -1046,11 +1047,11 @@ func TestRebase_FastForwardsBranchFromRemote(t *testing.T) { return nil } mock.CheckoutBranchFn = func(string) error { return nil } - mock.RebaseFn = func(base string) error { + mock.RebaseFn = func(base string, opts git.RebaseOpts) error { allRebaseCalls = append(allRebaseCalls, rebaseCall{newBase: base}) return nil } - mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { + mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error { allRebaseCalls = append(allRebaseCalls, rebaseCall{newBase, oldBase, branch}) return nil } @@ -1107,7 +1108,7 @@ func TestRebase_BranchAlreadyUpToDate_NoFF(t *testing.T) { return nil } mock.CheckoutBranchFn = func(string) error { return nil } - mock.RebaseFn = func(string) error { return nil } + mock.RebaseFn = func(string, git.RebaseOpts) error { return nil } restore := git.SetOps(mock) defer restore() @@ -1164,7 +1165,7 @@ func TestRebase_BranchDiverged_NoFF(t *testing.T) { return nil } mock.CheckoutBranchFn = func(string) error { return nil } - mock.RebaseFn = func(string) error { return nil } + mock.RebaseFn = func(string, git.RebaseOpts) error { return nil } restore := git.SetOps(mock) defer restore() @@ -1215,7 +1216,7 @@ func TestRebase_SkipsMergedBranchesNotExistingLocally(t *testing.T) { } return shas, nil } - mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { + mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error { rebaseCalls = append(rebaseCalls, rebaseCall{newBase, oldBase, branch}) return nil } @@ -1243,3 +1244,239 @@ func TestRebase_SkipsMergedBranchesNotExistingLocally(t *testing.T) { 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") +} diff --git a/cmd/sync_test.go b/cmd/sync_test.go index 8bc0284..83f797e 100644 --- a/cmd/sync_test.go +++ b/cmd/sync_test.go @@ -71,11 +71,11 @@ func TestSync_TrunkAlreadyUpToDate(t *testing.T) { } return "sha-" + ref, nil } - mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { + mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error { rebaseCalls = append(rebaseCalls, rebaseCall{newBase, oldBase, branch}) return nil } - mock.RebaseFn = func(base string) error { + mock.RebaseFn = func(base string, opts git.RebaseOpts) error { rebaseCalls = append(rebaseCalls, rebaseCall{branch: "rebase-" + base}) return nil } @@ -145,11 +145,11 @@ func TestSync_TrunkUpToDate_StackStale(t *testing.T) { return true, nil } mock.CheckoutBranchFn = func(string) error { return nil } - mock.RebaseFn = func(base string) error { + mock.RebaseFn = func(base string, opts git.RebaseOpts) error { rebaseCalls = append(rebaseCalls, rebaseCall{branch: "(rebase)" + base}) return nil } - mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { + mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error { rebaseCalls = append(rebaseCalls, rebaseCall{newBase, oldBase, branch}) return nil } @@ -228,11 +228,11 @@ func TestSync_TrunkFastForward_TriggersRebase(t *testing.T) { return nil } mock.CheckoutBranchFn = func(string) error { return nil } - mock.RebaseFn = func(base string) error { + mock.RebaseFn = func(base string, opts git.RebaseOpts) error { rebaseCalls = append(rebaseCalls, rebaseCall{branch: "(rebase)" + base}) return nil } - mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { + mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error { rebaseCalls = append(rebaseCalls, rebaseCall{newBase, oldBase, branch}) return nil } @@ -309,8 +309,8 @@ func TestSync_TrunkFastForward_WhenOnTrunk(t *testing.T) { return nil } mock.CheckoutBranchFn = func(string) error { return nil } - mock.RebaseFn = func(string) error { return nil } - mock.RebaseOntoFn = func(string, string, string) error { return nil } + mock.RebaseFn = func(string, git.RebaseOpts) error { return nil } + mock.RebaseOntoFn = func(string, string, string, git.RebaseOpts) error { return nil } restore := git.SetOps(mock) defer restore() @@ -372,7 +372,7 @@ func TestSync_TrunkDiverged(t *testing.T) { // Stack branches have their parent as ancestor return true, nil } - mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { + mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error { rebaseCalls = append(rebaseCalls, rebaseCall{newBase, oldBase, branch}) return nil } @@ -442,8 +442,8 @@ func TestSync_RebaseConflict_RestoresAll(t *testing.T) { currentBranch = name return nil } - mock.RebaseFn = func(string) error { return nil } // b1 succeeds - mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { + 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 fmt.Errorf("conflict") } @@ -509,11 +509,11 @@ func TestSync_NoRebaseWhenTrunkDidntMove(t *testing.T) { mock.RevParseFn = func(ref string) (string, error) { return "same-sha", nil } - mock.RebaseFn = func(string) error { + mock.RebaseFn = func(string, git.RebaseOpts) error { rebaseCount++ return nil } - mock.RebaseOntoFn = func(string, string, string) error { + mock.RebaseOntoFn = func(string, string, string, git.RebaseOpts) error { rebaseOntoCount++ return nil } @@ -563,8 +563,8 @@ func TestSync_PushForceFlagDependsOnRebase(t *testing.T) { mock := newSyncMock(tmpDir, "b1") mock.CheckoutBranchFn = func(string) error { return nil } - mock.RebaseFn = func(string) error { return nil } - mock.RebaseOntoFn = func(string, string, string) error { return nil } + mock.RebaseFn = func(string, git.RebaseOpts) error { return nil } + mock.RebaseOntoFn = func(string, string, string, git.RebaseOpts) error { return nil } if tt.trunkMoved { mock.RevParseFn = func(ref string) (string, error) { @@ -662,7 +662,7 @@ func TestSync_MergedBranch_UsesOnto(t *testing.T) { } mock.UpdateBranchRefFn = func(string, string) error { return nil } mock.CheckoutBranchFn = func(string) error { return nil } - mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { + mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error { rebaseOntoCalls = append(rebaseOntoCalls, rebaseCall{newBase, oldBase, branch}) return nil } @@ -754,7 +754,7 @@ func TestSync_StaleOntoOldBase_FallsBackToMergeBase(t *testing.T) { } mock.UpdateBranchRefFn = func(string, string) error { return nil } mock.CheckoutBranchFn = func(string) error { return nil } - mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { + mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error { rebaseOntoCalls = append(rebaseOntoCalls, rebaseCall{newBase, oldBase, branch}) return nil } @@ -817,8 +817,8 @@ func TestSync_PushFailureAfterRebase(t *testing.T) { } mock.UpdateBranchRefFn = func(string, string) error { return nil } mock.CheckoutBranchFn = func(string) error { return nil } - mock.RebaseFn = func(string) error { return nil } - mock.RebaseOntoFn = func(string, string, string) error { return nil } + mock.RebaseFn = func(string, git.RebaseOpts) error { return nil } + mock.RebaseOntoFn = func(string, string, string, git.RebaseOpts) error { return nil } mock.PushFn = func(remote string, branches []string, force, atomic bool) error { pushCalls = append(pushCalls, pushCall{remote, branches, force, atomic}) return fmt.Errorf("network error: connection refused") @@ -891,11 +891,11 @@ func TestSync_BranchFastForward_TriggersRebase(t *testing.T) { return nil } mock.CheckoutBranchFn = func(string) error { return nil } - mock.RebaseFn = func(base string) error { + mock.RebaseFn = func(base string, opts git.RebaseOpts) error { rebaseCalls = append(rebaseCalls, rebaseCall{branch: "(rebase)" + base}) return nil } - mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { + mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error { rebaseCalls = append(rebaseCalls, rebaseCall{newBase, oldBase, branch}) return nil } @@ -984,11 +984,11 @@ func TestSync_BranchFastForward_WithTrunkUpdate(t *testing.T) { return nil } mock.CheckoutBranchFn = func(string) error { return nil } - mock.RebaseFn = func(base string) error { + mock.RebaseFn = func(base string, opts git.RebaseOpts) error { rebaseCalls2 = append(rebaseCalls2, rebaseCall{branch: "(rebase)" + base}) return nil } - mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { + mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error { rebaseCalls2 = append(rebaseCalls2, rebaseCall{newBase, oldBase, branch}) return nil } @@ -1080,7 +1080,7 @@ func TestSync_MergedBranchDeletedFromRemote(t *testing.T) { } mock.UpdateBranchRefFn = func(string, string) error { return nil } mock.CheckoutBranchFn = func(string) error { return nil } - mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { + mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error { rebaseOntoCalls = append(rebaseOntoCalls, rebaseCall{newBase, oldBase, branch}) return nil } diff --git a/cmd/utils.go b/cmd/utils.go index 05c3906..150498c 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -688,13 +688,14 @@ func fastForwardTrunk(cfg *config.Config, trunk, remote, currentBranch string) b // cascadeRebaseOpts holds parameters for a cascade rebase across a range of // stack branches. type cascadeRebaseOpts struct { - Cfg *config.Config - Stack *stack.Stack - Branches []stack.BranchRef // the range of branches to rebase - StartAbsIdx int // index of Branches[0] in Stack.Branches - OriginalRefs map[string]string - NeedsOnto bool - OntoOldBase string + Cfg *config.Config + Stack *stack.Stack + Branches []stack.BranchRef // the range of branches to rebase + StartAbsIdx int // index of Branches[0] in Stack.Branches + OriginalRefs map[string]string + NeedsOnto bool + OntoOldBase string + CommitterDateIsAuthorDate bool } // cascadeRebaseResult describes the outcome of a cascade rebase. @@ -719,6 +720,7 @@ func cascadeRebase(opts cascadeRebaseOpts) cascadeRebaseResult { ontoOldBase := opts.OntoOldBase originalRefs := opts.OriginalRefs result := cascadeRebaseResult{} + rebaseOpts := git.RebaseOpts{CommitterDateIsAuthorDate: opts.CommitterDateIsAuthorDate} for i, br := range opts.Branches { absIdx := opts.StartAbsIdx + i @@ -763,7 +765,7 @@ func cascadeRebase(opts cascadeRebaseOpts) cascadeRebaseResult { } } - if err := git.RebaseOnto(newBase, actualOldBase, br.Branch); err != nil { + if err := git.RebaseOnto(newBase, actualOldBase, br.Branch, rebaseOpts); err != nil { remaining := make([]string, 0, len(opts.Branches)-i-1) for j := i + 1; j < len(opts.Branches); j++ { remaining = append(remaining, opts.Branches[j].Branch) @@ -786,7 +788,7 @@ func cascadeRebase(opts cascadeRebaseOpts) cascadeRebaseResult { } else { var rebaseErr error if absIdx > 0 { - rebaseErr = git.RebaseOnto(base, originalRefs[base], br.Branch) + rebaseErr = git.RebaseOnto(base, originalRefs[base], br.Branch, rebaseOpts) } else { if err := git.CheckoutBranch(br.Branch); err != nil { remaining := make([]string, 0, len(opts.Branches)-i-1) @@ -802,7 +804,7 @@ func cascadeRebase(opts cascadeRebaseOpts) cascadeRebaseResult { Remaining: remaining, } } - rebaseErr = git.Rebase(base) + rebaseErr = git.Rebase(base, rebaseOpts) } if rebaseErr != nil { diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index 5d08fc3..d918ed6 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -332,6 +332,7 @@ If a rebase conflict occurs, the operation pauses and prints the conflicted file | `--continue` | Continue the rebase after resolving conflicts | | `--abort` | Abort the rebase and restore all branches to their pre-rebase state | | `--remote ` | Remote to fetch from (defaults to auto-detected remote) | +| `--committer-date-is-author-date` | Preserve commit dates as the same as author dates. Alias: `--preserve-dates` | | Argument | Description | |----------|-------------| @@ -354,6 +355,9 @@ gh stack rebase --continue # Abort rebase and restore everything gh stack rebase --abort + +# Rebase and preserve committer date as author date +gh stack rebase --committer-date-is-author-date ``` ### `gh stack push` diff --git a/internal/git/git.go b/internal/git/git.go index 5a2ae52..d03f9c1 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -65,8 +65,13 @@ func runInteractive(args ...string) error { } // rebaseContinueOnce runs a single git rebase --continue without auto-resolve. -func rebaseContinueOnce() error { - cmd := exec.Command("git", "rebase", "--continue") +func rebaseContinueOnce(opts RebaseOpts) error { + args := []string{"rebase"} + if opts.CommitterDateIsAuthorDate { + args = append(args, "--committer-date-is-author-date") + } + args = append(args, "--continue") + cmd := exec.Command("git", args...) cmd.Env = append(os.Environ(), "GIT_EDITOR=true") return cmd.Run() } @@ -75,7 +80,7 @@ func rebaseContinueOnce() error { // from a failed rebase. If so, it auto-continues the rebase (potentially // multiple times for multi-commit rebases). Returns originalErr if any // conflicts remain that need manual resolution. -func tryAutoResolveRebase(originalErr error) error { +func tryAutoResolveRebase(originalErr error, opts RebaseOpts) error { for i := 0; i < 1000; i++ { if !IsRebaseInProgress() { return nil @@ -88,7 +93,7 @@ func tryAutoResolveRebase(originalErr error) error { return originalErr } // Rerere resolved all conflicts — auto-continue. - if rebaseContinueOnce() == nil { + if rebaseContinueOnce(opts) == nil { return nil } // Continue hit another conflicting commit; loop to check @@ -160,8 +165,8 @@ func ResolveRemote(branch string) (string, error) { // Rebase rebases the current branch onto the given base. // If rerere resolves all conflicts automatically, the rebase continues // without user intervention. -func Rebase(base string) error { - return ops.Rebase(base) +func Rebase(base string, opts RebaseOpts) error { + return ops.Rebase(base, opts) } // EnableRerere enables git rerere (reuse recorded resolution) and @@ -194,8 +199,8 @@ func SaveRerereDeclined() error { // which commits have already been applied. // If rerere resolves all conflicts automatically, the rebase continues // without user intervention. -func RebaseOnto(newBase, oldBase, branch string) error { - return ops.RebaseOnto(newBase, oldBase, branch) +func RebaseOnto(newBase, oldBase, branch string, opts RebaseOpts) error { + return ops.RebaseOnto(newBase, oldBase, branch, opts) } // RebaseContinue continues an in-progress rebase. @@ -203,8 +208,8 @@ func RebaseOnto(newBase, oldBase, branch string) error { // for the commit message, which would cause the command to hang. // If rerere resolves subsequent conflicts automatically, the rebase continues // without user intervention. -func RebaseContinue() error { - return ops.RebaseContinue() +func RebaseContinue(opts RebaseOpts) error { + return ops.RebaseContinue(opts) } // RebaseAbort aborts an in-progress rebase. diff --git a/internal/git/gitops.go b/internal/git/gitops.go index 9402fee..462a23f 100644 --- a/internal/git/gitops.go +++ b/internal/git/gitops.go @@ -12,6 +12,11 @@ import ( "time" ) +// RebaseOpts holds optional parameters for git rebase operations. +type RebaseOpts struct { + CommitterDateIsAuthorDate bool +} + // Ops defines the interface for git operations used by commands. // The package-level functions are the default production implementation. // Tests can substitute a mock via SetOps(). @@ -27,13 +32,13 @@ type Ops interface { CreateBranch(name, base string) error Push(remote string, branches []string, force, atomic bool) error ResolveRemote(branch string) (string, error) - Rebase(base string) error + Rebase(base string, opts RebaseOpts) error EnableRerere() error IsRerereEnabled() (bool, error) IsRerereDeclined() (bool, error) SaveRerereDeclined() error - RebaseOnto(newBase, oldBase, branch string) error - RebaseContinue() error + RebaseOnto(newBase, oldBase, branch string, opts RebaseOpts) error + RebaseContinue(opts RebaseOpts) error RebaseAbort() error IsRebaseInProgress() bool ConflictedFiles() ([]string, error) @@ -201,12 +206,17 @@ func (d *defaultOps) ResolveRemote(branch string) (string, error) { return "", fmt.Errorf("no remotes configured") } -func (d *defaultOps) Rebase(base string) error { - err := runSilent("rebase", base) +func (d *defaultOps) Rebase(base string, opts RebaseOpts) error { + args := []string{"rebase"} + if opts.CommitterDateIsAuthorDate { + args = append(args, "--committer-date-is-author-date") + } + args = append(args, base) + err := runSilent(args...) if err == nil { return nil } - return tryAutoResolveRebase(err) + return tryAutoResolveRebase(err, opts) } func (d *defaultOps) EnableRerere() error { @@ -237,20 +247,25 @@ func (d *defaultOps) SaveRerereDeclined() error { return runSilent("config", "gh-stack.rerere-declined", "true") } -func (d *defaultOps) RebaseOnto(newBase, oldBase, branch string) error { - err := runSilent("rebase", "--onto", newBase, oldBase, branch) +func (d *defaultOps) RebaseOnto(newBase, oldBase, branch string, opts RebaseOpts) error { + args := []string{"rebase"} + if opts.CommitterDateIsAuthorDate { + args = append(args, "--committer-date-is-author-date") + } + args = append(args, "--onto", newBase, oldBase, branch) + err := runSilent(args...) if err == nil { return nil } - return tryAutoResolveRebase(err) + return tryAutoResolveRebase(err, opts) } -func (d *defaultOps) RebaseContinue() error { - err := rebaseContinueOnce() +func (d *defaultOps) RebaseContinue(opts RebaseOpts) error { + err := rebaseContinueOnce(opts) if err == nil { return nil } - return tryAutoResolveRebase(err) + return tryAutoResolveRebase(err, opts) } func (d *defaultOps) RebaseAbort() error { diff --git a/internal/git/mock_ops.go b/internal/git/mock_ops.go index c19a001..785e01f 100644 --- a/internal/git/mock_ops.go +++ b/internal/git/mock_ops.go @@ -15,13 +15,13 @@ type MockOps struct { CreateBranchFn func(string, string) error PushFn func(string, []string, bool, bool) error ResolveRemoteFn func(string) (string, error) - RebaseFn func(string) error + RebaseFn func(string, RebaseOpts) error EnableRerereFn func() error IsRerereEnabledFn func() (bool, error) IsRerereDeclinedFn func() (bool, error) SaveRerereDeclinedFn func() error - RebaseOntoFn func(string, string, string) error - RebaseContinueFn func() error + RebaseOntoFn func(string, string, string, RebaseOpts) error + RebaseContinueFn func(RebaseOpts) error RebaseAbortFn func() error IsRebaseInProgressFn func() bool ConflictedFilesFn func() ([]string, error) @@ -132,9 +132,9 @@ func (m *MockOps) ResolveRemote(branch string) (string, error) { return "origin", nil } -func (m *MockOps) Rebase(base string) error { +func (m *MockOps) Rebase(base string, opts RebaseOpts) error { if m.RebaseFn != nil { - return m.RebaseFn(base) + return m.RebaseFn(base, opts) } return nil } @@ -167,16 +167,16 @@ func (m *MockOps) SaveRerereDeclined() error { return nil } -func (m *MockOps) RebaseOnto(newBase, oldBase, branch string) error { +func (m *MockOps) RebaseOnto(newBase, oldBase, branch string, opts RebaseOpts) error { if m.RebaseOntoFn != nil { - return m.RebaseOntoFn(newBase, oldBase, branch) + return m.RebaseOntoFn(newBase, oldBase, branch, opts) } return nil } -func (m *MockOps) RebaseContinue() error { +func (m *MockOps) RebaseContinue(opts RebaseOpts) error { if m.RebaseContinueFn != nil { - return m.RebaseContinueFn() + return m.RebaseContinueFn(opts) } return nil } diff --git a/internal/modify/apply.go b/internal/modify/apply.go index 7075e52..6dfe940 100644 --- a/internal/modify/apply.go +++ b/internal/modify/apply.go @@ -458,7 +458,7 @@ func ApplyPlan( } } - if err := git.RebaseOnto(newBase, oldBase, b.Branch); err != nil { + if err := git.RebaseOnto(newBase, oldBase, b.Branch, git.RebaseOpts{}); err != nil { conflict := &modifyview.ConflictInfo{ Branch: b.Branch, } @@ -575,7 +575,7 @@ func ContinueApply( } else { // Rebase conflict if git.IsRebaseInProgress() { - if err := git.RebaseContinue(); err != nil { + if err := git.RebaseContinue(git.RebaseOpts{}); err != nil { return fmt.Errorf("rebase continue failed — resolve remaining conflicts and try again: %w", err) } } @@ -622,7 +622,7 @@ func ContinueApply( } } - if err := git.RebaseOnto(newBase, oldBase, b.Branch); err != nil { + if err := git.RebaseOnto(newBase, oldBase, b.Branch, git.RebaseOpts{}); err != nil { // Another conflict — update state and bail remaining := make([]string, 0) foundCurrent := false diff --git a/internal/modify/apply_test.go b/internal/modify/apply_test.go index 37699ce..d98b75b 100644 --- a/internal/modify/apply_test.go +++ b/internal/modify/apply_test.go @@ -54,7 +54,7 @@ func newApplyMock(gitDir string, branchSHAs map[string]string) *git.MockOps { IsAncestorFn: func(a, d string) (bool, error) { return false, nil }, MergeBaseFn: func(a, b string) (string, error) { return "merge-base", nil }, CheckoutBranchFn: func(string) error { return nil }, - RebaseOntoFn: func(string, string, string) error { return nil }, + RebaseOntoFn: func(string, string, string, git.RebaseOpts) error { return nil }, IsRebaseInProgressFn: func() bool { return false }, RenameBranchFn: func(string, string) error { return nil }, LogRangeFn: func(base, head string) ([]git.CommitInfo, error) { @@ -235,7 +235,7 @@ func TestApplyPlan_Drop(t *testing.T) { var rebaseCalls []rebaseCall mock := newApplyMock(gitDir, branchSHAs) - mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { + mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error { rebaseCalls = append(rebaseCalls, rebaseCall{newBase, oldBase, branch}) return nil } @@ -379,7 +379,7 @@ func TestApplyPlan_FoldUp(t *testing.T) { cherryPickCalls = append(cherryPickCalls, shas) return nil } - mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { + mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error { rebaseCalls = append(rebaseCalls, rebaseCall{newBase, oldBase, branch}) return nil } @@ -504,7 +504,7 @@ func TestApplyPlan_Reorder(t *testing.T) { var rebaseCalls []rebaseCall mock := newApplyMock(gitDir, branchSHAs) - mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { + mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error { rebaseCalls = append(rebaseCalls, rebaseCall{newBase, oldBase, branch}) return nil } @@ -600,7 +600,7 @@ func TestApplyPlan_MixedDropAndFold(t *testing.T) { } return nil, nil } - mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { + mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error { rebaseCalls = append(rebaseCalls, rebaseCall{newBase, oldBase, branch}) return nil } @@ -667,7 +667,7 @@ func TestApplyPlan_ConflictDuringRebase(t *testing.T) { } mock := newApplyMock(gitDir, branchSHAs) - mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { + mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error { if branch == "B" { return assert.AnError } @@ -810,10 +810,10 @@ func TestContinueApply_MultiStackFindsCorrectStack(t *testing.T) { "main": "sha-main", "A": "sha-A", "B": "sha-B", "C": "sha-C", }) mock.IsRebaseInProgressFn = func() bool { return true } - mock.RebaseContinueFn = func() error { return nil } + mock.RebaseContinueFn = func(opts git.RebaseOpts) error { return nil } var rebasedBranches []string - mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { + mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error { rebasedBranches = append(rebasedBranches, branch) return nil } @@ -960,7 +960,7 @@ func TestApplyPlan_NoChanges(t *testing.T) { } var rebaseCalls int - mock.RebaseOntoFn = func(string, string, string) error { + mock.RebaseOntoFn = func(string, string, string, git.RebaseOpts) error { rebaseCalls++ return nil } @@ -1068,11 +1068,11 @@ func TestContinueApply(t *testing.T) { CurrentBranchFn: func() (string, error) { return "B", nil }, BranchExistsFn: func(string) bool { return true }, IsRebaseInProgressFn: func() bool { return true }, - RebaseContinueFn: func() error { + RebaseContinueFn: func(git.RebaseOpts) error { rebaseContinueCalled = true return nil }, - RebaseOntoFn: func(newBase, oldBase, branch string) error { + RebaseOntoFn: func(newBase, oldBase, branch string, opts git.RebaseOpts) error { rebaseCalls = append(rebaseCalls, rebaseCall{newBase, oldBase, branch}) return nil },