Skip to content

Commit 73ea782

Browse files
committed
flag for rebasing without trunk
1 parent d42a3c2 commit 73ea782

2 files changed

Lines changed: 276 additions & 24 deletions

File tree

cmd/rebase.go

Lines changed: 50 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ type rebaseOptions struct {
2121
upstack bool
2222
cont bool
2323
abort bool
24+
noTrunk bool
2425
remote string
2526
committerDateIsAuthorDate bool
2627
}
@@ -34,6 +35,7 @@ type rebaseState struct {
3435
UseOnto bool `json:"useOnto,omitempty"`
3536
OntoOldBase string `json:"ontoOldBase,omitempty"`
3637
CommitterDateIsAuthorDate bool `json:"committerDateIsAuthorDate,omitempty"`
38+
NoTrunk bool `json:"noTrunk,omitempty"`
3739
}
3840

3941
const rebaseStateFile = "gh-stack-rebase-state"
@@ -47,7 +49,11 @@ func RebaseCmd(cfg *config.Config) *cobra.Command {
4749
Long: `Pull from remote and do a cascading rebase across the stack.
4850
4951
Ensures that each branch in the stack has the tip of the previous
50-
layer in its commit history, rebasing if necessary.`,
52+
layer in its commit history, rebasing if necessary.
53+
54+
Use --no-trunk to skip fetching and rebasing with the trunk branch.
55+
Only the inter-branch rebases are performed (branch 2 onto branch 1,
56+
branch 3 onto branch 2, etc.).`,
5157
Example: ` # Rebase the entire stack
5258
$ gh stack rebase
5359
@@ -57,6 +63,9 @@ layer in its commit history, rebasing if necessary.`,
5763
# Only rebase from current branch to the top
5864
$ gh stack rebase --upstack
5965
66+
# Rebase stack branches without pulling from or rebasing with trunk
67+
$ gh stack rebase --no-trunk
68+
6069
# Continue after resolving conflicts
6170
$ gh stack rebase --continue
6271
@@ -73,6 +82,7 @@ layer in its commit history, rebasing if necessary.`,
7382

7483
cmd.Flags().BoolVar(&opts.downstack, "downstack", false, "Only rebase branches from trunk to current branch")
7584
cmd.Flags().BoolVar(&opts.upstack, "upstack", false, "Only rebase branches from current branch to top")
85+
cmd.Flags().BoolVar(&opts.noTrunk, "no-trunk", false, "Skip trunk — only rebase stack branches onto each other")
7686
cmd.Flags().BoolVar(&opts.cont, "continue", false, "Continue rebase after resolving conflicts")
7787
cmd.Flags().BoolVar(&opts.abort, "abort", false, "Abort rebase and restore all branches")
7888
cmd.Flags().StringVar(&opts.remote, "remote", "", "Remote to fetch from (defaults to auto-detected remote)")
@@ -115,32 +125,34 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error {
115125
return ErrSilent
116126
}
117127

118-
// Resolve remote for fetch and trunk comparison
119-
remote, err := pickRemote(cfg, currentBranch, opts.remote)
120-
if err != nil {
121-
if !errors.Is(err, errInterrupt) {
122-
cfg.Errorf("%s", err)
128+
if !opts.noTrunk {
129+
// Resolve remote for fetch and trunk comparison
130+
remote, err := pickRemote(cfg, currentBranch, opts.remote)
131+
if err != nil {
132+
if !errors.Is(err, errInterrupt) {
133+
cfg.Errorf("%s", err)
134+
}
135+
return ErrSilent
123136
}
124-
return ErrSilent
125-
}
126137

127-
if err := git.Fetch(remote); err != nil {
128-
cfg.Warningf("Failed to fetch %s: %v", remote, err)
129-
} else {
130-
cfg.Successf("Fetched %s", remote)
131-
}
138+
if err := git.Fetch(remote); err != nil {
139+
cfg.Warningf("Failed to fetch %s: %v", remote, err)
140+
} else {
141+
cfg.Successf("Fetched %s", remote)
142+
}
132143

133-
// Ensure trunk exists locally before fast-forward or cascade rebase.
134-
if err := ensureLocalTrunk(cfg, s.Trunk.Branch, remote); err != nil {
135-
cfg.Errorf("%s", err)
136-
return ErrSilent
137-
}
144+
// Ensure trunk exists locally before fast-forward or cascade rebase.
145+
if err := ensureLocalTrunk(cfg, s.Trunk.Branch, remote); err != nil {
146+
cfg.Errorf("%s", err)
147+
return ErrSilent
148+
}
138149

139-
// Fast-forward trunk so the cascade rebase targets the latest upstream.
140-
fastForwardTrunk(cfg, s.Trunk.Branch, remote, currentBranch)
150+
// Fast-forward trunk so the cascade rebase targets the latest upstream.
151+
fastForwardTrunk(cfg, s.Trunk.Branch, remote, currentBranch)
141152

142-
// Fast-forward stack branches that are behind their remote tracking branch.
143-
fastForwardBranches(cfg, s, remote, currentBranch)
153+
// Fast-forward stack branches that are behind their remote tracking branch.
154+
fastForwardBranches(cfg, s, remote, currentBranch)
155+
}
144156

145157
cfg.Printf("Stack detected: %s", s.DisplayChain())
146158

@@ -163,6 +175,11 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error {
163175
startIdx = currentIdx
164176
}
165177

178+
// With --no-trunk, skip the first branch (which would rebase onto trunk).
179+
if opts.noTrunk && startIdx < 1 {
180+
startIdx = 1
181+
}
182+
166183
branchesToRebase := s.Branches[startIdx:endIdx]
167184

168185
if len(branchesToRebase) == 0 {
@@ -224,6 +241,7 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error {
224241
UseOnto: rebaseResult.NeedsOnto,
225242
OntoOldBase: rebaseResult.OntoOldBase,
226243
CommitterDateIsAuthorDate: opts.committerDateIsAuthorDate,
244+
NoTrunk: opts.noTrunk,
227245
}
228246
if err := saveRebaseState(gitDir, state); err != nil {
229247
cfg.Warningf("failed to save rebase state: %s", err)
@@ -263,7 +281,11 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error {
263281
rangeDesc = fmt.Sprintf("All upstack branches from %s", currentBranch)
264282
}
265283

266-
cfg.Printf("%s rebased locally with %s", rangeDesc, s.Trunk.Branch)
284+
if opts.noTrunk {
285+
cfg.Printf("%s rebased locally (without trunk)", rangeDesc)
286+
} else {
287+
cfg.Printf("%s rebased locally with %s", rangeDesc, s.Trunk.Branch)
288+
}
267289
cfg.Printf("To push up your changes, run `%s`",
268290
cfg.ColorCyan("gh stack push"))
269291

@@ -393,7 +415,11 @@ func continueRebase(cfg *config.Config, gitDir string) error {
393415

394416
stack.SaveNonBlocking(gitDir, sf)
395417

396-
cfg.Printf("All branches in stack rebased locally with %s", s.Trunk.Branch)
418+
if state.NoTrunk {
419+
cfg.Printf("All branches in stack rebased locally (without trunk)")
420+
} else {
421+
cfg.Printf("All branches in stack rebased locally with %s", s.Trunk.Branch)
422+
}
397423
cfg.Printf("To push up your changes and open/update the stack of PRs, run `%s`",
398424
cfg.ColorCyan("gh stack submit"))
399425

cmd/rebase_test.go

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1480,3 +1480,229 @@ func TestRebase_ConflictSavesCommitterDateFlag(t *testing.T) {
14801480
assert.True(t, loaded.CommitterDateIsAuthorDate,
14811481
"saved rebase state should preserve CommitterDateIsAuthorDate flag")
14821482
}
1483+
1484+
// TestRebase_NoTrunk_SkipsTrunkRebase verifies that --no-trunk skips rebasing
1485+
// branch 1 onto trunk but still cascades inter-branch rebases.
1486+
func TestRebase_NoTrunk_SkipsTrunkRebase(t *testing.T) {
1487+
s := stack.Stack{
1488+
Trunk: stack.BranchRef{Branch: "main"},
1489+
Branches: []stack.BranchRef{
1490+
{Branch: "b1"},
1491+
{Branch: "b2"},
1492+
{Branch: "b3"},
1493+
},
1494+
}
1495+
1496+
tmpDir := t.TempDir()
1497+
writeStackFile(t, tmpDir, s)
1498+
1499+
var allRebaseCalls []rebaseCall
1500+
var currentCheckedOut string
1501+
1502+
mock := newRebaseMock(tmpDir, "b2")
1503+
mock.CheckoutBranchFn = func(name string) error {
1504+
currentCheckedOut = name
1505+
return nil
1506+
}
1507+
mock.RebaseFn = func(base string, opts git.RebaseOpts) error {
1508+
allRebaseCalls = append(allRebaseCalls, rebaseCall{newBase: base, oldBase: "", branch: currentCheckedOut})
1509+
return nil
1510+
}
1511+
mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error {
1512+
allRebaseCalls = append(allRebaseCalls, rebaseCall{newBase, oldBase, branch})
1513+
return nil
1514+
}
1515+
1516+
restore := git.SetOps(mock)
1517+
defer restore()
1518+
1519+
cfg, _, errR := config.NewTestConfig()
1520+
cmd := RebaseCmd(cfg)
1521+
cmd.SetArgs([]string{"--no-trunk"})
1522+
cmd.SetOut(io.Discard)
1523+
cmd.SetErr(io.Discard)
1524+
err := cmd.Execute()
1525+
1526+
cfg.Err.Close()
1527+
errOut, _ := io.ReadAll(errR)
1528+
output := string(errOut)
1529+
1530+
assert.NoError(t, err)
1531+
1532+
// Only b2 onto b1 and b3 onto b2 — no rebase onto trunk (main).
1533+
require.Len(t, allRebaseCalls, 2, "should only rebase b2 and b3 (skip b1 onto trunk)")
1534+
assert.Equal(t, "b1", allRebaseCalls[0].newBase, "b2 should be rebased onto b1")
1535+
assert.Equal(t, "b2", allRebaseCalls[1].newBase, "b3 should be rebased onto b2")
1536+
1537+
assert.Contains(t, output, "without trunk")
1538+
}
1539+
1540+
// TestRebase_NoTrunk_SkipsFetch verifies that --no-trunk does not call Fetch.
1541+
func TestRebase_NoTrunk_SkipsFetch(t *testing.T) {
1542+
s := stack.Stack{
1543+
Trunk: stack.BranchRef{Branch: "main"},
1544+
Branches: []stack.BranchRef{
1545+
{Branch: "b1"},
1546+
{Branch: "b2"},
1547+
},
1548+
}
1549+
1550+
tmpDir := t.TempDir()
1551+
writeStackFile(t, tmpDir, s)
1552+
1553+
fetchCalled := false
1554+
1555+
mock := newRebaseMock(tmpDir, "b1")
1556+
mock.CheckoutBranchFn = func(name string) error { return nil }
1557+
mock.RebaseFn = func(base string, opts git.RebaseOpts) error { return nil }
1558+
mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error { return nil }
1559+
mock.FetchFn = func(remote string) error {
1560+
fetchCalled = true
1561+
return nil
1562+
}
1563+
1564+
restore := git.SetOps(mock)
1565+
defer restore()
1566+
1567+
cfg, _, _ := config.NewTestConfig()
1568+
cmd := RebaseCmd(cfg)
1569+
cmd.SetArgs([]string{"--no-trunk"})
1570+
cmd.SetOut(io.Discard)
1571+
cmd.SetErr(io.Discard)
1572+
err := cmd.Execute()
1573+
1574+
cfg.Out.Close()
1575+
cfg.Err.Close()
1576+
1577+
assert.NoError(t, err)
1578+
assert.False(t, fetchCalled, "Fetch should not be called with --no-trunk")
1579+
}
1580+
1581+
// TestRebase_NoTrunk_SingleBranch verifies that --no-trunk with a single-branch
1582+
// stack has no branches to rebase (since branch 1 onto trunk is skipped).
1583+
func TestRebase_NoTrunk_SingleBranch(t *testing.T) {
1584+
s := stack.Stack{
1585+
Trunk: stack.BranchRef{Branch: "main"},
1586+
Branches: []stack.BranchRef{
1587+
{Branch: "b1"},
1588+
},
1589+
}
1590+
1591+
tmpDir := t.TempDir()
1592+
writeStackFile(t, tmpDir, s)
1593+
1594+
mock := newRebaseMock(tmpDir, "b1")
1595+
mock.CheckoutBranchFn = func(name string) error { return nil }
1596+
1597+
restore := git.SetOps(mock)
1598+
defer restore()
1599+
1600+
cfg, _, errR := config.NewTestConfig()
1601+
cmd := RebaseCmd(cfg)
1602+
cmd.SetArgs([]string{"--no-trunk"})
1603+
cmd.SetOut(io.Discard)
1604+
cmd.SetErr(io.Discard)
1605+
err := cmd.Execute()
1606+
1607+
cfg.Err.Close()
1608+
errOut, _ := io.ReadAll(errR)
1609+
output := string(errOut)
1610+
1611+
assert.NoError(t, err)
1612+
assert.Contains(t, output, "No branches to rebase")
1613+
}
1614+
1615+
// TestRebase_NoTrunk_WithUpstack verifies --no-trunk combined with --upstack
1616+
// when the current branch is above index 0. The --no-trunk should not change
1617+
// behavior since --upstack already starts from a non-trunk branch.
1618+
func TestRebase_NoTrunk_WithUpstack(t *testing.T) {
1619+
s := stack.Stack{
1620+
Trunk: stack.BranchRef{Branch: "main"},
1621+
Branches: []stack.BranchRef{
1622+
{Branch: "b1"},
1623+
{Branch: "b2"},
1624+
{Branch: "b3"},
1625+
},
1626+
}
1627+
1628+
tmpDir := t.TempDir()
1629+
writeStackFile(t, tmpDir, s)
1630+
1631+
var allRebaseCalls []rebaseCall
1632+
var currentCheckedOut string
1633+
1634+
mock := newRebaseMock(tmpDir, "b2")
1635+
mock.CheckoutBranchFn = func(name string) error {
1636+
currentCheckedOut = name
1637+
return nil
1638+
}
1639+
mock.RebaseFn = func(base string, opts git.RebaseOpts) error {
1640+
allRebaseCalls = append(allRebaseCalls, rebaseCall{newBase: base, oldBase: "", branch: currentCheckedOut})
1641+
return nil
1642+
}
1643+
mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error {
1644+
allRebaseCalls = append(allRebaseCalls, rebaseCall{newBase, oldBase, branch})
1645+
return nil
1646+
}
1647+
1648+
restore := git.SetOps(mock)
1649+
defer restore()
1650+
1651+
cfg, _, _ := config.NewTestConfig()
1652+
cmd := RebaseCmd(cfg)
1653+
cmd.SetArgs([]string{"--no-trunk", "--upstack"})
1654+
cmd.SetOut(io.Discard)
1655+
cmd.SetErr(io.Discard)
1656+
err := cmd.Execute()
1657+
1658+
cfg.Out.Close()
1659+
cfg.Err.Close()
1660+
1661+
assert.NoError(t, err)
1662+
// --upstack from b2 = [b2, b3], --no-trunk doesn't change this since startIdx is already 1
1663+
require.Len(t, allRebaseCalls, 2, "upstack should rebase b2 and b3")
1664+
assert.Equal(t, "b1", allRebaseCalls[0].newBase, "b2 should be rebased onto b1")
1665+
assert.Equal(t, "b2", allRebaseCalls[1].newBase, "b3 should be rebased onto b2")
1666+
}
1667+
1668+
// TestRebase_NoTrunk_ConflictSavesState verifies that --no-trunk persists the
1669+
// NoTrunk flag in the rebase state when a conflict occurs.
1670+
func TestRebase_NoTrunk_ConflictSavesState(t *testing.T) {
1671+
s := stack.Stack{
1672+
Trunk: stack.BranchRef{Branch: "main"},
1673+
Branches: []stack.BranchRef{
1674+
{Branch: "b1"},
1675+
{Branch: "b2"},
1676+
{Branch: "b3"},
1677+
},
1678+
}
1679+
1680+
tmpDir := t.TempDir()
1681+
writeStackFile(t, tmpDir, s)
1682+
1683+
mock := newRebaseMock(tmpDir, "b2")
1684+
mock.CheckoutBranchFn = func(name string) error { return nil }
1685+
mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error {
1686+
if branch == "b2" {
1687+
return fmt.Errorf("conflict")
1688+
}
1689+
return nil
1690+
}
1691+
mock.ConflictedFilesFn = func() ([]string, error) { return nil, nil }
1692+
1693+
restore := git.SetOps(mock)
1694+
defer restore()
1695+
1696+
cfg, _, _ := config.NewTestConfig()
1697+
cmd := RebaseCmd(cfg)
1698+
cmd.SetArgs([]string{"--no-trunk"})
1699+
cmd.SetOut(io.Discard)
1700+
cmd.SetErr(io.Discard)
1701+
_ = cmd.Execute()
1702+
1703+
// Load the saved state and verify the NoTrunk flag is persisted.
1704+
loaded, err := loadRebaseState(tmpDir)
1705+
require.NoError(t, err)
1706+
assert.True(t, loaded.NoTrunk,
1707+
"saved rebase state should preserve NoTrunk flag")
1708+
}

0 commit comments

Comments
 (0)