Skip to content

Commit d2b6b2a

Browse files
committed
fast forward active branches where local is behind remote
1 parent 07c2434 commit d2b6b2a

File tree

5 files changed

+448
-4
lines changed

5 files changed

+448
-4
lines changed

cmd/rebase.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,9 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error {
135135
}
136136
}
137137

138+
// Fast-forward stack branches that are behind their remote tracking branch.
139+
fastForwardBranches(cfg, s, remote, currentBranch)
140+
138141
cfg.Printf("Stack detected: %s", s.DisplayChain())
139142

140143
currentIdx := s.IndexOf(currentBranch)

cmd/rebase_test.go

Lines changed: 193 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"io"
66
"os"
77
"path/filepath"
8+
"strings"
89
"testing"
910

1011
"github.com/github/gh-stack/internal/config"
@@ -34,7 +35,13 @@ func newRebaseMock(tmpDir string, currentBranch string) *git.MockOps {
3435
return &git.MockOps{
3536
GitDirFn: func() (string, error) { return tmpDir, nil },
3637
CurrentBranchFn: func() (string, error) { return currentBranch, nil },
37-
RevParseFn: func(ref string) (string, error) { return "sha-" + ref, nil },
38+
RevParseFn: func(ref string) (string, error) {
39+
// Default: origin/<branch> returns same SHA as <branch> (no FF needed)
40+
if strings.HasPrefix(ref, "origin/") {
41+
return "sha-" + strings.TrimPrefix(ref, "origin/"), nil
42+
}
43+
return "sha-" + ref, nil
44+
},
3845
IsAncestorFn: func(a, d string) (bool, error) { return true, nil },
3946
FetchFn: func(string) error { return nil },
4047
EnableRerereFn: func() error { return nil },
@@ -848,3 +855,188 @@ func TestRebase_Abort_WithActiveRebase(t *testing.T) {
848855
// Should return to original branch
849856
assert.Contains(t, checkouts, "b1", "should checkout original branch at end")
850857
}
858+
859+
// TestRebase_FastForwardsBranchFromRemote verifies that when origin/b1 is ahead
860+
// of local b1 (someone pushed a new commit), the branch is fast-forwarded before
861+
// the cascade rebase so downstream branches include the new commits.
862+
func TestRebase_FastForwardsBranchFromRemote(t *testing.T) {
863+
s := stack.Stack{
864+
Trunk: stack.BranchRef{Branch: "main"},
865+
Branches: []stack.BranchRef{
866+
{Branch: "b1"},
867+
{Branch: "b2"},
868+
},
869+
}
870+
871+
tmpDir := t.TempDir()
872+
writeStackFile(t, tmpDir, s)
873+
874+
var allRebaseCalls []rebaseCall
875+
var updateBranchRefCalls []struct{ branch, sha string }
876+
877+
mock := newRebaseMock(tmpDir, "b2")
878+
// b1 is behind origin/b1 (remote has new commit)
879+
mock.RevParseFn = func(ref string) (string, error) {
880+
if ref == "b1" {
881+
return "b1-local-sha", nil
882+
}
883+
if ref == "origin/b1" {
884+
return "b1-remote-sha", nil
885+
}
886+
// trunk and origin/trunk same — trunk already up to date
887+
if ref == "main" || ref == "origin/main" {
888+
return "main-sha", nil
889+
}
890+
if strings.HasPrefix(ref, "origin/") {
891+
return "sha-" + strings.TrimPrefix(ref, "origin/"), nil
892+
}
893+
return "sha-" + ref, nil
894+
}
895+
mock.IsAncestorFn = func(a, d string) (bool, error) {
896+
// b1-local is ancestor of b1-remote → can fast-forward
897+
if a == "b1-local-sha" && d == "b1-remote-sha" {
898+
return true, nil
899+
}
900+
return false, nil
901+
}
902+
mock.UpdateBranchRefFn = func(branch, sha string) error {
903+
updateBranchRefCalls = append(updateBranchRefCalls, struct{ branch, sha string }{branch, sha})
904+
return nil
905+
}
906+
mock.CheckoutBranchFn = func(string) error { return nil }
907+
mock.RebaseFn = func(base string) error {
908+
allRebaseCalls = append(allRebaseCalls, rebaseCall{newBase: base})
909+
return nil
910+
}
911+
mock.RebaseOntoFn = func(newBase, oldBase, branch string) error {
912+
allRebaseCalls = append(allRebaseCalls, rebaseCall{newBase, oldBase, branch})
913+
return nil
914+
}
915+
916+
restore := git.SetOps(mock)
917+
defer restore()
918+
919+
cfg, _, errR := config.NewTestConfig()
920+
cmd := RebaseCmd(cfg)
921+
cmd.SetOut(io.Discard)
922+
cmd.SetErr(io.Discard)
923+
err := cmd.Execute()
924+
925+
cfg.Err.Close()
926+
errOut, _ := io.ReadAll(errR)
927+
output := string(errOut)
928+
929+
assert.NoError(t, err)
930+
931+
// b1 should be fast-forwarded to remote SHA
932+
require.Len(t, updateBranchRefCalls, 1, "should fast-forward b1 via UpdateBranchRef")
933+
assert.Equal(t, "b1", updateBranchRefCalls[0].branch)
934+
assert.Equal(t, "b1-remote-sha", updateBranchRefCalls[0].sha)
935+
936+
assert.Contains(t, output, "Fast-forwarded b1")
937+
938+
// Cascade rebase should still occur
939+
assert.NotEmpty(t, allRebaseCalls, "cascade rebase should still happen")
940+
}
941+
942+
// TestRebase_BranchAlreadyUpToDate_NoFF verifies that when a branch's local
943+
// and remote SHAs match, no fast-forward occurs.
944+
func TestRebase_BranchAlreadyUpToDate_NoFF(t *testing.T) {
945+
s := stack.Stack{
946+
Trunk: stack.BranchRef{Branch: "main"},
947+
Branches: []stack.BranchRef{
948+
{Branch: "b1"},
949+
},
950+
}
951+
952+
tmpDir := t.TempDir()
953+
writeStackFile(t, tmpDir, s)
954+
955+
var updateBranchRefCalls int
956+
var mergeFFCalls int
957+
958+
mock := newRebaseMock(tmpDir, "b1")
959+
// Same SHA for b1 and origin/b1 — already up to date (default mock handles this)
960+
mock.UpdateBranchRefFn = func(string, string) error {
961+
updateBranchRefCalls++
962+
return nil
963+
}
964+
mock.MergeFFFn = func(string) error {
965+
mergeFFCalls++
966+
return nil
967+
}
968+
mock.CheckoutBranchFn = func(string) error { return nil }
969+
mock.RebaseFn = func(string) error { return nil }
970+
971+
restore := git.SetOps(mock)
972+
defer restore()
973+
974+
cfg, _, _ := config.NewTestConfig()
975+
cmd := RebaseCmd(cfg)
976+
cmd.SetOut(io.Discard)
977+
cmd.SetErr(io.Discard)
978+
err := cmd.Execute()
979+
980+
cfg.Out.Close()
981+
cfg.Err.Close()
982+
983+
assert.NoError(t, err)
984+
assert.Equal(t, 0, updateBranchRefCalls, "no UpdateBranchRef for branches already up to date")
985+
assert.Equal(t, 0, mergeFFCalls, "no MergeFF for branches already up to date")
986+
}
987+
988+
// TestRebase_BranchDiverged_NoFF verifies that when local and remote branches
989+
// have diverged (e.g., after a previous local rebase), no fast-forward occurs.
990+
func TestRebase_BranchDiverged_NoFF(t *testing.T) {
991+
s := stack.Stack{
992+
Trunk: stack.BranchRef{Branch: "main"},
993+
Branches: []stack.BranchRef{
994+
{Branch: "b1"},
995+
},
996+
}
997+
998+
tmpDir := t.TempDir()
999+
writeStackFile(t, tmpDir, s)
1000+
1001+
var updateBranchRefCalls int
1002+
1003+
mock := newRebaseMock(tmpDir, "b1")
1004+
// Different SHAs for b1 and origin/b1
1005+
mock.RevParseFn = func(ref string) (string, error) {
1006+
if ref == "b1" {
1007+
return "b1-local-sha", nil
1008+
}
1009+
if ref == "origin/b1" {
1010+
return "b1-remote-sha", nil
1011+
}
1012+
if ref == "main" || ref == "origin/main" {
1013+
return "main-sha", nil
1014+
}
1015+
return "sha-" + ref, nil
1016+
}
1017+
// Neither is ancestor of the other — diverged
1018+
mock.IsAncestorFn = func(a, d string) (bool, error) {
1019+
return false, nil
1020+
}
1021+
mock.UpdateBranchRefFn = func(string, string) error {
1022+
updateBranchRefCalls++
1023+
return nil
1024+
}
1025+
mock.CheckoutBranchFn = func(string) error { return nil }
1026+
mock.RebaseFn = func(string) error { return nil }
1027+
1028+
restore := git.SetOps(mock)
1029+
defer restore()
1030+
1031+
cfg, _, _ := config.NewTestConfig()
1032+
cmd := RebaseCmd(cfg)
1033+
cmd.SetOut(io.Discard)
1034+
cmd.SetErr(io.Discard)
1035+
err := cmd.Execute()
1036+
1037+
cfg.Out.Close()
1038+
cfg.Err.Close()
1039+
1040+
assert.NoError(t, err)
1041+
assert.Equal(t, 0, updateBranchRefCalls, "no FF when branches have diverged")
1042+
}

cmd/sync.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,9 +116,13 @@ func runSync(cfg *config.Config, opts *syncOptions) error {
116116
}
117117
}
118118

119-
// --- Step 3: Cascade rebase (only if trunk moved) ---
119+
// --- Step 2b: Fast-forward stack branches behind their remote tracking branch ---
120+
updatedBranches := fastForwardBranches(cfg, s, remote, currentBranch)
121+
branchesUpdated := len(updatedBranches) > 0
122+
123+
// --- Step 3: Cascade rebase (if trunk or any branch moved) ---
120124
rebased := false
121-
if trunkUpdated {
125+
if trunkUpdated || branchesUpdated {
122126
cfg.Printf("")
123127
cfg.Printf("Rebasing stack ...")
124128

0 commit comments

Comments
 (0)