Skip to content

Commit 346396d

Browse files
committed
Avoid replaying amended parent commits
Preserve a branch's last valid base when its parent is rewritten, and only use verified ancestor commits as rebase boundaries. Recover previously corrupted metadata from the parent reflog when possible, otherwise stop safely instead of replaying superseded parent commits.
1 parent ed2b46d commit 346396d

7 files changed

Lines changed: 365 additions & 31 deletions

File tree

cmd/rebase_test.go

Lines changed: 247 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ package cmd
22

33
import (
44
"encoding/json"
5+
"errors"
56
"fmt"
67
"io"
78
"os"
9+
"os/exec"
810
"path/filepath"
911
"strings"
1012
"testing"
@@ -242,11 +244,11 @@ func TestRebase_OntoPropagatesToSubsequentBranches(t *testing.T) {
242244
"b4 should rebase --onto b3 with b3's original SHA as oldBase")
243245
}
244246

245-
// TestRebase_StaleOntoOldBase_FallsBackToMergeBase verifies that when a branch
247+
// TestRebase_StaleOntoOldBase_UsesForkPoint verifies that when a branch
246248
// was already rebased past the merged branch's tip (e.g. by a previous run),
247-
// the stale ontoOldBase is detected via IsAncestor and replaced with
248-
// merge-base(newBase, branch) to avoid replaying already-applied commits.
249-
func TestRebase_StaleOntoOldBase_FallsBackToMergeBase(t *testing.T) {
249+
// the stale ontoOldBase is replaced with a reflog fork-point that the branch
250+
// actually contains.
251+
func TestRebase_StaleOntoOldBase_UsesForkPoint(t *testing.T) {
250252
s := stack.Stack{
251253
Trunk: stack.BranchRef{Branch: "main"},
252254
Branches: []stack.BranchRef{
@@ -286,11 +288,11 @@ func TestRebase_StaleOntoOldBase_FallsBackToMergeBase(t *testing.T) {
286288
}
287289
return true, nil
288290
}
289-
mock.MergeBaseFn = func(a, b string) (string, error) {
291+
mock.MergeBaseForkPointFn = func(a, b string) (string, error) {
290292
if a == "main" && b == "b2" {
291-
return "main-b2-mergebase", nil
293+
return "main-b2-forkpoint", nil
292294
}
293-
return "default-mergebase", nil
295+
return "default-forkpoint", nil
294296
}
295297
mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error {
296298
rebaseCalls = append(rebaseCalls, rebaseCall{newBase, oldBase, branch})
@@ -312,9 +314,9 @@ func TestRebase_StaleOntoOldBase_FallsBackToMergeBase(t *testing.T) {
312314
assert.NoError(t, err)
313315
require.Len(t, rebaseCalls, 2)
314316

315-
// b2: stale ontoOldBase detected → falls back to merge-base(main, b2)
316-
assert.Equal(t, rebaseCall{"main", "main-b2-mergebase", "b2"}, rebaseCalls[0],
317-
"b2 should use merge-base as oldBase when ontoOldBase is stale")
317+
// b2: stale ontoOldBase detected → uses fork-point(main, b2)
318+
assert.Equal(t, rebaseCall{"main", "main-b2-forkpoint", "b2"}, rebaseCalls[0],
319+
"b2 should use the reflog fork-point when ontoOldBase is stale")
318320

319321
// b3: b2's SHA is a valid ancestor → uses it directly
320322
assert.Equal(t, rebaseCall{"b2", "b2-on-main-sha", "b3"}, rebaseCalls[1],
@@ -643,7 +645,7 @@ func TestRebase_SkipsMergedBranches(t *testing.T) {
643645
s := stack.Stack{
644646
Trunk: stack.BranchRef{Branch: "main"},
645647
Branches: []stack.BranchRef{
646-
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 42, Merged: true}},
648+
{Branch: "b1", Head: "sha-b1", PullRequest: &stack.PullRequestRef{Number: 42, Merged: true}},
647649
{Branch: "b2"},
648650
},
649651
}
@@ -1963,3 +1965,237 @@ func TestRebase_NoTrunk_ConflictSavesState(t *testing.T) {
19631965
assert.True(t, loaded.NoTrunk,
19641966
"saved rebase state should preserve NoTrunk flag")
19651967
}
1968+
1969+
func TestResolveRebaseOldBase(t *testing.T) {
1970+
t.Run("uses current parent tip when the branch contains it", func(t *testing.T) {
1971+
restore := git.SetOps(&git.MockOps{
1972+
IsAncestorFn: func(ancestor, branch string) (bool, error) {
1973+
return ancestor == "current-parent" && branch == "child", nil
1974+
},
1975+
})
1976+
defer restore()
1977+
1978+
oldBase, err := resolveRebaseOldBase("current-parent", "recorded-base", "parent", "child")
1979+
require.NoError(t, err)
1980+
assert.Equal(t, "current-parent", oldBase)
1981+
})
1982+
1983+
t.Run("uses recorded base after the parent was rewritten", func(t *testing.T) {
1984+
restore := git.SetOps(&git.MockOps{
1985+
IsAncestorFn: func(ancestor, branch string) (bool, error) {
1986+
return ancestor == "recorded-base" && branch == "child", nil
1987+
},
1988+
})
1989+
defer restore()
1990+
1991+
oldBase, err := resolveRebaseOldBase("amended-parent", "recorded-base", "parent", "child")
1992+
require.NoError(t, err)
1993+
assert.Equal(t, "recorded-base", oldBase)
1994+
})
1995+
1996+
t.Run("uses fork point when metadata was already corrupted", func(t *testing.T) {
1997+
restore := git.SetOps(&git.MockOps{
1998+
IsAncestorFn: func(ancestor, branch string) (bool, error) {
1999+
return ancestor == "old-parent" && branch == "child", nil
2000+
},
2001+
MergeBaseForkPointFn: func(ref, branch string) (string, error) {
2002+
return "old-parent", nil
2003+
},
2004+
})
2005+
defer restore()
2006+
2007+
oldBase, err := resolveRebaseOldBase("amended-parent", "amended-parent", "parent", "child")
2008+
require.NoError(t, err)
2009+
assert.Equal(t, "old-parent", oldBase)
2010+
})
2011+
2012+
t.Run("fails when no safe boundary can be recovered", func(t *testing.T) {
2013+
restore := git.SetOps(&git.MockOps{
2014+
IsAncestorFn: func(string, string) (bool, error) { return false, nil },
2015+
MergeBaseForkPointFn: func(string, string) (string, error) {
2016+
return "", errors.New("no fork point")
2017+
},
2018+
})
2019+
defer restore()
2020+
2021+
_, err := resolveRebaseOldBase("amended-parent", "amended-parent", "parent", "child")
2022+
require.Error(t, err)
2023+
assert.Contains(t, err.Error(), "rebase this branch manually")
2024+
})
2025+
}
2026+
2027+
type amendedParentRepo struct {
2028+
dir string
2029+
gitDir string
2030+
oldParent string
2031+
newParent string
2032+
}
2033+
2034+
func issue250Git(t *testing.T, dir string, args ...string) string {
2035+
t.Helper()
2036+
cmd := exec.Command("git", args...)
2037+
cmd.Dir = dir
2038+
cmd.Env = append(os.Environ(),
2039+
"GIT_AUTHOR_NAME=Test",
2040+
"GIT_AUTHOR_EMAIL=test@example.com",
2041+
"GIT_COMMITTER_NAME=Test",
2042+
"GIT_COMMITTER_EMAIL=test@example.com",
2043+
)
2044+
out, err := cmd.CombinedOutput()
2045+
require.NoError(t, err, "git %s:\n%s", strings.Join(args, " "), out)
2046+
return strings.TrimSpace(string(out))
2047+
}
2048+
2049+
func issue250GitMayFail(t *testing.T, dir string, args ...string) error {
2050+
t.Helper()
2051+
cmd := exec.Command("git", args...)
2052+
cmd.Dir = dir
2053+
cmd.Env = append(os.Environ(),
2054+
"GIT_AUTHOR_NAME=Test",
2055+
"GIT_AUTHOR_EMAIL=test@example.com",
2056+
"GIT_COMMITTER_NAME=Test",
2057+
"GIT_COMMITTER_EMAIL=test@example.com",
2058+
)
2059+
return cmd.Run()
2060+
}
2061+
2062+
func issue250WriteFile(t *testing.T, dir, name, content string) {
2063+
t.Helper()
2064+
require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0644))
2065+
}
2066+
2067+
func setupAmendedParentRepo(t *testing.T, corruptBase bool) amendedParentRepo {
2068+
t.Helper()
2069+
remoteDir := filepath.Join(t.TempDir(), "remote.git")
2070+
cloneDir := filepath.Join(t.TempDir(), "clone")
2071+
2072+
issue250Git(t, ".", "-c", "safe.bareRepository=all", "init", "--bare", "-b", "main", remoteDir)
2073+
issue250Git(t, ".", "clone", remoteDir, cloneDir)
2074+
2075+
issue250WriteFile(t, cloneDir, "base.txt", "base\n")
2076+
issue250Git(t, cloneDir, "add", ".")
2077+
issue250Git(t, cloneDir, "commit", "-m", "base")
2078+
issue250Git(t, cloneDir, "push", "-u", "origin", "main")
2079+
mainSHA := issue250Git(t, cloneDir, "rev-parse", "main")
2080+
2081+
issue250Git(t, cloneDir, "checkout", "-b", "parent")
2082+
issue250WriteFile(t, cloneDir, "old-parent.txt", "old parent\n")
2083+
issue250Git(t, cloneDir, "add", ".")
2084+
issue250Git(t, cloneDir, "commit", "-m", "parent old")
2085+
oldParent := issue250Git(t, cloneDir, "rev-parse", "parent")
2086+
issue250Git(t, cloneDir, "push", "-u", "origin", "parent")
2087+
2088+
issue250Git(t, cloneDir, "checkout", "-b", "child")
2089+
issue250WriteFile(t, cloneDir, "child.txt", "child\n")
2090+
issue250Git(t, cloneDir, "add", ".")
2091+
issue250Git(t, cloneDir, "commit", "-m", "child commit")
2092+
childSHA := issue250Git(t, cloneDir, "rev-parse", "child")
2093+
issue250Git(t, cloneDir, "push", "-u", "origin", "child")
2094+
2095+
gitDir := filepath.Join(cloneDir, ".git")
2096+
s := stack.Stack{
2097+
Trunk: stack.BranchRef{Branch: "main", Head: mainSHA},
2098+
Branches: []stack.BranchRef{
2099+
{Branch: "parent", Head: oldParent, Base: mainSHA},
2100+
{Branch: "child", Head: childSHA, Base: oldParent},
2101+
},
2102+
}
2103+
writeStackFile(t, gitDir, s)
2104+
2105+
issue250Git(t, cloneDir, "checkout", "parent")
2106+
issue250Git(t, cloneDir, "rm", "old-parent.txt")
2107+
issue250WriteFile(t, cloneDir, "new-parent.txt", "new parent\n")
2108+
issue250Git(t, cloneDir, "add", ".")
2109+
issue250Git(t, cloneDir, "commit", "--amend", "-m", "parent amended")
2110+
newParent := issue250Git(t, cloneDir, "rev-parse", "parent")
2111+
2112+
if corruptBase {
2113+
issue250Git(t, cloneDir, "push", "--force", "origin", "parent")
2114+
s.Branches[0].Head = newParent
2115+
s.Branches[1].Base = newParent
2116+
writeStackFile(t, gitDir, s)
2117+
}
2118+
issue250Git(t, cloneDir, "checkout", "child")
2119+
2120+
return amendedParentRepo{
2121+
dir: cloneDir,
2122+
gitDir: gitDir,
2123+
oldParent: oldParent,
2124+
newParent: newParent,
2125+
}
2126+
}
2127+
2128+
func issue250TestConfig(t *testing.T) *config.Config {
2129+
t.Helper()
2130+
cfg, outR, errR := config.NewTestConfig()
2131+
cfg.GitHubClientOverride = &github.MockClient{}
2132+
t.Cleanup(func() {
2133+
_ = cfg.Out.Close()
2134+
_ = cfg.Err.Close()
2135+
_ = outR.Close()
2136+
_ = errR.Close()
2137+
})
2138+
return cfg
2139+
}
2140+
2141+
func withIssue250Repo(t *testing.T, dir string) {
2142+
t.Helper()
2143+
originalDir, err := os.Getwd()
2144+
require.NoError(t, err)
2145+
require.NoError(t, os.Chdir(dir))
2146+
t.Cleanup(func() { _ = os.Chdir(originalDir) })
2147+
}
2148+
2149+
func assertIssue250History(t *testing.T, repo amendedParentRepo) {
2150+
t.Helper()
2151+
subjects := strings.Split(issue250Git(t, repo.dir, "log", "--format=%s", "main..child"), "\n")
2152+
assert.Equal(t, []string{"child commit", "parent amended"}, subjects)
2153+
assert.Error(t, issue250GitMayFail(t, repo.dir, "merge-base", "--is-ancestor", repo.oldParent, "child"))
2154+
require.NoError(t, issue250GitMayFail(t, repo.dir, "merge-base", "--is-ancestor", repo.newParent, "child"))
2155+
_, oldErr := os.Stat(filepath.Join(repo.dir, "old-parent.txt"))
2156+
assert.True(t, os.IsNotExist(oldErr))
2157+
_, newErr := os.Stat(filepath.Join(repo.dir, "new-parent.txt"))
2158+
assert.NoError(t, newErr)
2159+
}
2160+
2161+
func TestIntegration_AmendedParentPushThenRebase(t *testing.T) {
2162+
repo := setupAmendedParentRepo(t, false)
2163+
withIssue250Repo(t, repo.dir)
2164+
cfg := issue250TestConfig(t)
2165+
2166+
require.NoError(t, runPush(cfg, &pushOptions{remote: "origin"}))
2167+
2168+
sf, err := stack.Load(repo.gitDir)
2169+
require.NoError(t, err)
2170+
require.Len(t, sf.Stacks, 1)
2171+
assert.Equal(t, repo.oldParent, sf.Stacks[0].Branches[1].Base,
2172+
"push must not replace the child's valid base with an amended parent tip")
2173+
2174+
require.NoError(t, runRebase(cfg, &rebaseOptions{remote: "origin"}))
2175+
assertIssue250History(t, repo)
2176+
}
2177+
2178+
func TestIntegration_AmendedParentRecoversCorruptedBase(t *testing.T) {
2179+
repo := setupAmendedParentRepo(t, true)
2180+
withIssue250Repo(t, repo.dir)
2181+
cfg := issue250TestConfig(t)
2182+
2183+
require.NoError(t, runRebase(cfg, &rebaseOptions{remote: "origin"}))
2184+
assertIssue250History(t, repo)
2185+
}
2186+
2187+
func TestIntegration_AmendedParentWithoutForkPointFailsSafely(t *testing.T) {
2188+
repo := setupAmendedParentRepo(t, true)
2189+
issue250Git(t, repo.dir, "reflog", "expire", "--expire=now", "--all")
2190+
require.Error(t, issue250GitMayFail(t, repo.dir, "merge-base", "--fork-point", "parent", "child"))
2191+
2192+
withIssue250Repo(t, repo.dir)
2193+
cfg := issue250TestConfig(t)
2194+
parentBefore := issue250Git(t, repo.dir, "rev-parse", "parent")
2195+
childBefore := issue250Git(t, repo.dir, "rev-parse", "child")
2196+
2197+
err := runRebase(cfg, &rebaseOptions{remote: "origin"})
2198+
require.Error(t, err)
2199+
assert.Equal(t, parentBefore, issue250Git(t, repo.dir, "rev-parse", "parent"))
2200+
assert.Equal(t, childBefore, issue250Git(t, repo.dir, "rev-parse", "child"))
2201+
}

cmd/sync_test.go

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -495,7 +495,7 @@ func TestSync_RebaseConflict_RestoresAll(t *testing.T) {
495495
return "sha-" + ref, nil
496496
}
497497
mock.IsAncestorFn = func(a, d string) (bool, error) {
498-
return a == "local-sha" && d == "remote-sha", nil
498+
return true, nil
499499
}
500500
mock.UpdateBranchRefFn = func(string, string) error { return nil }
501501
mock.CheckoutBranchFn = func(name string) error {
@@ -840,10 +840,10 @@ func TestSync_QueuedBranch_DownstreamStaysStacked(t *testing.T) {
840840
"queued b1 must not be pushed")
841841
}
842842

843-
// TestSync_StaleOntoOldBase_FallsBackToMergeBase verifies that when a branch
843+
// TestSync_StaleOntoOldBase_UsesForkPoint verifies that when a branch
844844
// was already rebased past the merged branch's tip, sync detects the stale
845-
// ontoOldBase and falls back to merge-base for the correct divergence point.
846-
func TestSync_StaleOntoOldBase_FallsBackToMergeBase(t *testing.T) {
845+
// ontoOldBase and uses a reflog fork-point for the correct divergence point.
846+
func TestSync_StaleOntoOldBase_UsesForkPoint(t *testing.T) {
847847
s := stack.Stack{
848848
Trunk: stack.BranchRef{Branch: "main"},
849849
Branches: []stack.BranchRef{
@@ -889,11 +889,11 @@ func TestSync_StaleOntoOldBase_FallsBackToMergeBase(t *testing.T) {
889889
}
890890
return true, nil
891891
}
892-
mock.MergeBaseFn = func(a, b string) (string, error) {
892+
mock.MergeBaseForkPointFn = func(a, b string) (string, error) {
893893
if a == "main" && b == "b2" {
894-
return "main-b2-mergebase", nil
894+
return "main-b2-forkpoint", nil
895895
}
896-
return "default-mergebase", nil
896+
return "default-forkpoint", nil
897897
}
898898
mock.UpdateBranchRefFn = func(string, string) error { return nil }
899899
mock.CheckoutBranchFn = func(string) error { return nil }
@@ -918,9 +918,9 @@ func TestSync_StaleOntoOldBase_FallsBackToMergeBase(t *testing.T) {
918918
assert.NoError(t, err)
919919
require.Len(t, rebaseOntoCalls, 2)
920920

921-
// b2: stale ontoOldBase → falls back to merge-base(main, b2)
922-
assert.Equal(t, rebaseCall{"main", "main-b2-mergebase", "b2"}, rebaseOntoCalls[0],
923-
"b2 should use merge-base as oldBase when ontoOldBase is stale")
921+
// b2: stale ontoOldBase → uses fork-point(main, b2)
922+
assert.Equal(t, rebaseCall{"main", "main-b2-forkpoint", "b2"}, rebaseOntoCalls[0],
923+
"b2 should use the reflog fork-point when ontoOldBase is stale")
924924

925925
// b3: b2's SHA is a valid ancestor → uses it directly
926926
assert.Equal(t, rebaseCall{"b2", "b2-on-main-sha", "b3"}, rebaseOntoCalls[1],

0 commit comments

Comments
 (0)