|
5 | 5 | "io" |
6 | 6 | "os" |
7 | 7 | "path/filepath" |
| 8 | + "strings" |
8 | 9 | "testing" |
9 | 10 |
|
10 | 11 | "github.com/github/gh-stack/internal/config" |
@@ -34,7 +35,13 @@ func newRebaseMock(tmpDir string, currentBranch string) *git.MockOps { |
34 | 35 | return &git.MockOps{ |
35 | 36 | GitDirFn: func() (string, error) { return tmpDir, nil }, |
36 | 37 | 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 | + }, |
38 | 45 | IsAncestorFn: func(a, d string) (bool, error) { return true, nil }, |
39 | 46 | FetchFn: func(string) error { return nil }, |
40 | 47 | EnableRerereFn: func() error { return nil }, |
@@ -848,3 +855,188 @@ func TestRebase_Abort_WithActiveRebase(t *testing.T) { |
848 | 855 | // Should return to original branch |
849 | 856 | assert.Contains(t, checkouts, "b1", "should checkout original branch at end") |
850 | 857 | } |
| 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 | +} |
0 commit comments