Skip to content

Commit 1775047

Browse files
skarimCopilot
andcommitted
Keep the recorded base when a parent moves out of band
Amending a commit on a lower branch, pushing, and then rebasing replayed the old version of that commit into every branch above it — a conflict at best, a duplicated commit at worst (#250, #193). The same defect made a squash-merged bottom PR conflict against the branches above its direct child (#309). Root cause: `updateBaseSHAs` unconditionally recorded the parent's *current* tip as each child's base, even when the child had never been rebased onto it. `gh stack push` calls it, so the recipe "amend, push, rebase" corrupted the metadata before the rebase ever ran: after init: b2.base = <b1 original tip> correct after push: b2.base = <b1 amended tip> b2 does not contain this `branches[].base` is the `git rebase --onto <newBase> <upstream>` boundary for the next cascade, so recording a commit the branch does not contain makes git fall back to a merge base and replay the parent's superseded commits. `updateBaseSHAs` now only advances a base when the parent's tip really is in the branch's history, so the record keeps describing where the branch actually sits. `Head` is still always updated — it is the branch's own tip. `cascadeRebase` gains `resolveOntoOldBase`, which picks the latest boundary the branch genuinely contains: the parent's current tip, else the recorded metadata base, else a merge base. It replaces the ad-hoc staleness guard that existed only on the merged-PR path, and now covers the plain path too, which had none. Also fixes a regression from the previous commit: a trunk that was never pushed (a stack based on a local integration branch) was treated like a trunk that had been deleted upstream and failed outright. The two are now distinguished by whether the trunk has an upstream configured — a tracked trunk that has disappeared is still a hard error, an untracked one is used as-is. Verified against real git: amend at the bottom, in the middle, and at two depths at once; extra commits; a dropped commit force-pushed; squash-merge with three branches above it; and each combined with a trunk that moved, diverged, or was locked by another worktree. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: f5959297-80fd-4732-aeae-aa9a4b6a7755
1 parent 700d9cf commit 1775047

8 files changed

Lines changed: 480 additions & 30 deletions

File tree

cmd/onto_base_test.go

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
package cmd
2+
3+
import (
4+
"errors"
5+
"testing"
6+
7+
"github.com/github/gh-stack/internal/git"
8+
"github.com/github/gh-stack/internal/stack"
9+
"github.com/stretchr/testify/assert"
10+
)
11+
12+
// ancestryMock builds an IsAncestor func from an explicit linear history per
13+
// branch: history[branch] lists the SHAs that branch contains, oldest first.
14+
func ancestryMock(history map[string][]string) func(string, string) (bool, error) {
15+
index := func(list []string, sha string) int {
16+
for i, s := range list {
17+
if s == sha {
18+
return i
19+
}
20+
}
21+
return -1
22+
}
23+
return func(ancestor, descendant string) (bool, error) {
24+
// descendant may be a branch name or a SHA within some branch.
25+
for branch, list := range history {
26+
if branch != descendant && index(list, descendant) < 0 {
27+
continue
28+
}
29+
end := len(list)
30+
if i := index(list, descendant); i >= 0 {
31+
end = i + 1
32+
}
33+
if index(list[:end], ancestor) >= 0 {
34+
return true, nil
35+
}
36+
}
37+
return false, nil
38+
}
39+
}
40+
41+
func TestResolveOntoOldBase(t *testing.T) {
42+
// b2 contains: trunk -> a-old -> b2-own
43+
// The parent (b1) has since been amended to a-new, which b2 does NOT have.
44+
history := map[string][]string{
45+
"b2": {"trunk", "a-old", "b2-own"},
46+
"b1": {"trunk", "a-new"},
47+
}
48+
49+
t.Run("recorded upstream is used when the branch contains it", func(t *testing.T) {
50+
restore := git.SetOps(&git.MockOps{IsAncestorFn: ancestryMock(history)})
51+
defer restore()
52+
53+
got := resolveOntoOldBase("a-old", "a-old", "b1", "b2")
54+
assert.Equal(t, "a-old", got)
55+
})
56+
57+
// The #250 case: the parent was amended, so its current tip is not in the
58+
// branch's history. Falling back to a merge base would replay the parent's
59+
// superseded commit; the recorded metadata base is the correct boundary.
60+
t.Run("falls back to the metadata base when the parent was amended", func(t *testing.T) {
61+
restore := git.SetOps(&git.MockOps{
62+
IsAncestorFn: ancestryMock(history),
63+
MergeBaseFn: func(a, b string) (string, error) { return "trunk", nil },
64+
})
65+
defer restore()
66+
67+
got := resolveOntoOldBase("a-new", "a-old", "b1", "b2")
68+
assert.Equal(t, "a-old", got,
69+
"should replay only b2's own commits, not the parent's superseded one")
70+
})
71+
72+
t.Run("prefers the latest usable boundary", func(t *testing.T) {
73+
restore := git.SetOps(&git.MockOps{
74+
IsAncestorFn: ancestryMock(history),
75+
MergeBaseFn: func(a, b string) (string, error) { return "trunk", nil },
76+
})
77+
defer restore()
78+
79+
// Both "trunk" and "a-old" are ancestors of b2; a-old replays fewer commits.
80+
got := resolveOntoOldBase("a-new", "a-old", "b1", "b2")
81+
assert.Equal(t, "a-old", got)
82+
})
83+
84+
t.Run("falls back to a merge base when no metadata base is recorded", func(t *testing.T) {
85+
restore := git.SetOps(&git.MockOps{
86+
IsAncestorFn: ancestryMock(history),
87+
MergeBaseFn: func(a, b string) (string, error) { return "trunk", nil },
88+
})
89+
defer restore()
90+
91+
got := resolveOntoOldBase("a-new", "", "b1", "b2")
92+
assert.Equal(t, "trunk", got)
93+
})
94+
95+
t.Run("returns the recorded value when nothing is usable", func(t *testing.T) {
96+
restore := git.SetOps(&git.MockOps{
97+
IsAncestorFn: func(string, string) (bool, error) { return false, nil },
98+
MergeBaseFn: func(string, string) (string, error) { return "", errors.New("no merge base") },
99+
})
100+
defer restore()
101+
102+
got := resolveOntoOldBase("a-new", "unrelated", "b1", "b2")
103+
assert.Equal(t, "a-new", got)
104+
})
105+
106+
t.Run("ignores an unusable metadata base", func(t *testing.T) {
107+
restore := git.SetOps(&git.MockOps{
108+
IsAncestorFn: ancestryMock(history),
109+
MergeBaseFn: func(a, b string) (string, error) { return "trunk", nil },
110+
})
111+
defer restore()
112+
113+
// A metadata base from an unrelated history must not be trusted.
114+
got := resolveOntoOldBase("a-new", "bogus", "b1", "b2")
115+
assert.Equal(t, "trunk", got)
116+
})
117+
}
118+
119+
func TestUpdateBaseSHAs(t *testing.T) {
120+
newStack := func(b2Base string) *stack.Stack {
121+
return &stack.Stack{
122+
Trunk: stack.BranchRef{Branch: "main"},
123+
Branches: []stack.BranchRef{
124+
{Branch: "b1"},
125+
{Branch: "b2", Base: b2Base},
126+
},
127+
}
128+
}
129+
130+
t.Run("advances the base when the branch really is stacked on the parent", func(t *testing.T) {
131+
restore := git.SetOps(&git.MockOps{
132+
RevParseFn: func(ref string) (string, error) { return "sha-" + ref, nil },
133+
IsAncestorFn: func(string, string) (bool, error) { return true, nil },
134+
})
135+
defer restore()
136+
137+
s := newStack("stale-base")
138+
updateBaseSHAs(s)
139+
assert.Equal(t, "sha-b1", s.Branches[1].Base)
140+
assert.Equal(t, "sha-b2", s.Branches[1].Head)
141+
})
142+
143+
// The root cause of #250: `gh stack push` recorded the parent's amended tip
144+
// as the child's base even though the child had not been rebased onto it,
145+
// which made the next cascade replay the parent's superseded commits.
146+
t.Run("keeps the recorded base when the parent moved out of band", func(t *testing.T) {
147+
restore := git.SetOps(&git.MockOps{
148+
RevParseFn: func(ref string) (string, error) { return "sha-" + ref, nil },
149+
IsAncestorFn: func(ancestor, descendant string) (bool, error) {
150+
// b2 does not contain b1's amended tip.
151+
if ancestor == "sha-b1" && descendant == "b2" {
152+
return false, nil
153+
}
154+
return true, nil
155+
},
156+
})
157+
defer restore()
158+
159+
s := newStack("b1-original-tip")
160+
updateBaseSHAs(s)
161+
assert.Equal(t, "b1-original-tip", s.Branches[1].Base,
162+
"the base must keep describing where b2 actually sits")
163+
assert.Equal(t, "sha-b2", s.Branches[1].Head, "head is always the branch's own tip")
164+
})
165+
166+
t.Run("records a base when none was known yet", func(t *testing.T) {
167+
restore := git.SetOps(&git.MockOps{
168+
RevParseFn: func(ref string) (string, error) { return "sha-" + ref, nil },
169+
IsAncestorFn: func(string, string) (bool, error) {
170+
return false, nil
171+
},
172+
})
173+
defer restore()
174+
175+
s := newStack("")
176+
updateBaseSHAs(s)
177+
assert.Equal(t, "sha-b1", s.Branches[1].Base,
178+
"with nothing recorded there is no truth to preserve")
179+
})
180+
181+
t.Run("keeps the recorded base when ancestry cannot be determined", func(t *testing.T) {
182+
restore := git.SetOps(&git.MockOps{
183+
RevParseFn: func(ref string) (string, error) { return "sha-" + ref, nil },
184+
IsAncestorFn: func(string, string) (bool, error) { return false, errors.New("boom") },
185+
})
186+
defer restore()
187+
188+
s := newStack("known-base")
189+
updateBaseSHAs(s)
190+
assert.Equal(t, "known-base", s.Branches[1].Base)
191+
})
192+
193+
t.Run("skips merged branches", func(t *testing.T) {
194+
restore := git.SetOps(&git.MockOps{
195+
RevParseFn: func(ref string) (string, error) { return "sha-" + ref, nil },
196+
IsAncestorFn: func(string, string) (bool, error) { return true, nil },
197+
})
198+
defer restore()
199+
200+
s := &stack.Stack{
201+
Trunk: stack.BranchRef{Branch: "main"},
202+
Branches: []stack.BranchRef{
203+
{Branch: "b1", Base: "frozen", PullRequest: &stack.PullRequestRef{Number: 1, Merged: true}},
204+
{Branch: "b2"},
205+
},
206+
}
207+
updateBaseSHAs(s)
208+
assert.Equal(t, "frozen", s.Branches[0].Base, "a merged branch's base is left alone")
209+
assert.Equal(t, "sha-main", s.Branches[1].Base, "b2 is measured against trunk")
210+
})
211+
}

cmd/rebase_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,9 @@ func newRebaseMock(tmpDir string, currentBranch string) *git.MockOps {
4444
}
4545
return "sha-" + ref, nil
4646
},
47-
IsAncestorFn: func(a, d string) (bool, error) { return true, nil },
48-
FetchFn: func(string) error { return nil },
49-
EnableRerereFn: func() error { return nil },
47+
IsAncestorFn: func(a, d string) (bool, error) { return true, nil },
48+
FetchFn: func(string) error { return nil },
49+
EnableRerereFn: func() error { return nil },
5050
IsRebaseInProgressFn: func() bool { return false },
5151
}
5252
}

cmd/stale_trunk_test.go

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -303,9 +303,10 @@ func TestRebase_RemoteQualifiedTrunk_IsNormalized(t *testing.T) {
303303
assert.NotContains(t, string(out), "origin/origin/main")
304304
}
305305

306-
// Issue #225 shape: the trunk branch was merged and deleted on the remote, so
307-
// there is nothing to bring the stack up to date with. That must be reported
308-
// rather than silently rebasing onto a stale local trunk.
306+
// Issue #225 shape: the trunk branch was tracked on the remote and has since
307+
// been merged and deleted, so there is nothing to bring the stack up to date
308+
// with. That must be reported rather than silently rebasing onto a stale local
309+
// trunk.
309310
func TestRebase_TrunkMissingOnRemote_Fails(t *testing.T) {
310311
tmpDir := t.TempDir()
311312
writeStackFile(t, tmpDir, twoBranchStack())
@@ -314,6 +315,7 @@ func TestRebase_TrunkMissingOnRemote_Fails(t *testing.T) {
314315

315316
mock := newRebaseMock(tmpDir, "b2")
316317
mock.BranchExistsFn = func(string) bool { return true }
318+
mock.UpstreamRemoteFn = func(string) (string, error) { return "origin", nil }
317319
mock.RevParseFn = func(ref string) (string, error) {
318320
if ref == "origin/main" {
319321
return "", errors.New("unknown revision")
@@ -338,10 +340,57 @@ func TestRebase_TrunkMissingOnRemote_Fails(t *testing.T) {
338340

339341
assert.ErrorIs(t, err, ErrSilent)
340342
assert.Zero(t, rebaseCalls, "nothing should be rebased onto a stale trunk")
341-
assert.Contains(t, output, "does not exist on origin")
343+
assert.Contains(t, output, "no longer exists on origin")
342344
assert.Contains(t, output, "--no-trunk")
343345
}
344346

347+
// A trunk that was never pushed is a local integration branch, not an orphaned
348+
// stack: the cascade must still run against it.
349+
func TestRebase_LocalOnlyTrunk_StillRebases(t *testing.T) {
350+
s := twoBranchStack()
351+
s.Trunk.Branch = "integration"
352+
353+
tmpDir := t.TempDir()
354+
writeStackFile(t, tmpDir, s)
355+
356+
var rebaseBases []string
357+
358+
mock := newRebaseMock(tmpDir, "b2")
359+
mock.BranchExistsFn = func(string) bool { return true }
360+
mock.UpstreamRemoteFn = func(string) (string, error) { return "", nil }
361+
mock.RevParseFn = func(ref string) (string, error) {
362+
if strings.HasPrefix(ref, "origin/") {
363+
return "", errors.New("unknown revision")
364+
}
365+
return "sha-" + ref, nil
366+
}
367+
mock.CheckoutBranchFn = func(string) error { return nil }
368+
mock.RebaseFn = func(base string, _ git.RebaseOpts) error {
369+
rebaseBases = append(rebaseBases, base)
370+
return nil
371+
}
372+
mock.RebaseOntoFn = func(newBase, _, _ string, _ git.RebaseOpts) error {
373+
rebaseBases = append(rebaseBases, newBase)
374+
return nil
375+
}
376+
377+
restore := git.SetOps(mock)
378+
defer restore()
379+
380+
cfg, _, errR := config.NewTestConfig()
381+
cmd := RebaseCmd(cfg)
382+
cmd.SetOut(io.Discard)
383+
cmd.SetErr(io.Discard)
384+
err := cmd.Execute()
385+
386+
cfg.Err.Close()
387+
out, _ := io.ReadAll(errR)
388+
389+
assert.NoError(t, err)
390+
assert.Equal(t, []string{"integration", "b1"}, rebaseBases)
391+
assert.Contains(t, string(out), "only exists locally")
392+
}
393+
345394
// --no-trunk still works without a reachable remote trunk.
346395
func TestRebase_NoTrunk_SkipsTrunkResolution(t *testing.T) {
347396
tmpDir := t.TempDir()

cmd/trunk_target_test.go

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package cmd
33
import (
44
"errors"
55
"io"
6+
"strings"
67
"testing"
78

89
"github.com/github/gh-stack/internal/config"
@@ -223,9 +224,11 @@ func TestResolveTrunkTarget(t *testing.T) {
223224
assert.False(t, target.Detached)
224225
})
225226

226-
// Issue #225 shape: the trunk branch was merged and deleted on the remote.
227-
t.Run("fails when the trunk no longer exists on the remote", func(t *testing.T) {
227+
// Issue #225 shape: the trunk branch was tracked on the remote and has since
228+
// been merged and deleted, orphaning the stack.
229+
t.Run("fails when a tracked trunk no longer exists on the remote", func(t *testing.T) {
228230
mock := trunkTargetMock("local", "")
231+
mock.UpstreamRemoteFn = func(string) (string, error) { return "origin", nil }
229232
restore := git.SetOps(mock)
230233
defer restore()
231234

@@ -237,10 +240,55 @@ func TestResolveTrunkTarget(t *testing.T) {
237240

238241
cfg.Err.Close()
239242
out, _ := io.ReadAll(errR)
240-
assert.Contains(t, string(out), "does not exist on origin")
243+
assert.Contains(t, string(out), "no longer exists on origin")
241244
assert.Contains(t, string(out), "--no-trunk")
242245
})
243246

247+
// A stack based on a local integration branch that was never pushed: the
248+
// local branch is the source of truth, so the cascade must still run.
249+
t.Run("uses a local-only trunk that was never pushed", func(t *testing.T) {
250+
restore := git.SetOps(&git.MockOps{
251+
BranchExistsFn: func(string) bool { return true },
252+
UpstreamRemoteFn: func(string) (string, error) { return "", nil },
253+
RevParseFn: func(ref string) (string, error) {
254+
if strings.HasPrefix(ref, "origin/") {
255+
return "", errors.New("unknown revision")
256+
}
257+
return "sha-" + ref, nil
258+
},
259+
})
260+
defer restore()
261+
262+
cfg, _, errR := config.NewTestConfig()
263+
s := &stack.Stack{Trunk: stack.BranchRef{Branch: "integration"}}
264+
265+
target, err := resolveTrunkTarget(cfg, s, "origin", "b1")
266+
require.NoError(t, err)
267+
assert.Equal(t, "integration", target.Ref)
268+
assert.False(t, target.Detached)
269+
270+
cfg.Err.Close()
271+
out, _ := io.ReadAll(errR)
272+
assert.Contains(t, string(out), "only exists locally")
273+
})
274+
275+
t.Run("fails when the trunk exists neither locally nor remotely", func(t *testing.T) {
276+
mock := trunkTargetMock("local", "")
277+
mock.BranchExistsFn = func(string) bool { return false }
278+
restore := git.SetOps(mock)
279+
defer restore()
280+
281+
cfg, _, errR := config.NewTestConfig()
282+
s := &stack.Stack{Trunk: stack.BranchRef{Branch: "main"}}
283+
284+
_, err := resolveTrunkTarget(cfg, s, "origin", "b1")
285+
assert.ErrorIs(t, err, ErrSilent)
286+
287+
cfg.Err.Close()
288+
out, _ := io.ReadAll(errR)
289+
assert.Contains(t, string(out), "neither locally nor on origin")
290+
})
291+
244292
// Issue #176: `gh stack init --base origin/main` records a remote-qualified
245293
// trunk, which used to be re-qualified into "origin/origin/main".
246294
t.Run("normalizes a remote-qualified trunk", func(t *testing.T) {

0 commit comments

Comments
 (0)