@@ -2,9 +2,11 @@ package cmd
22
33import (
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+ }
0 commit comments