GeezerVison is showing github.com — rebase without trunk (#129) · github/gh-stack@e45a88c
File treeExpand file treeCollapse file tree Open diff view settings Expand file treeCollapse file tree Open diff view settings Collapse file Copy file name to clipboardExpand all lines: README.md +4Lines changed: 4 additions & 0 deletions - Display the source diff
- Display the rich diff
| Original file line number | Diff line number | Diff line change |
|---|
@@ -209,6 +209,7 @@ If a rebase conflict occurs, the operation pauses and prints the conflicted file | 209 | 209 | |------|-------------| | 210 | 210 | | `--downstack` | Only rebase branches from trunk to the current branch | | 211 | 211 | | `--upstack` | Only rebase branches from the current branch to the top | | | 212 | +| `--no-trunk` | Skip trunk — only rebase stack branches onto each other (no fetch, no trunk rebase) | | 212 | 213 | | `--continue` | Continue the rebase after resolving conflicts | | 213 | 214 | | `--abort` | Abort the rebase and restore all branches to their pre-rebase state | | 214 | 215 | | `--remote <name>` | Remote to fetch from (defaults to auto-detected remote) | | @@ -230,6 +231,9 @@ gh stack rebase --downstack | 230 | 231 | # Only rebase branches above the current one | 231 | 232 | gh stack rebase --upstack | 232 | 233 |
| | 234 | +# Rebase stack branches without pulling from or rebasing with trunk | | 235 | +gh stack rebase --no-trunk | | 236 | + | 233 | 237 | # After resolving a conflict | 234 | 238 | gh stack rebase --continue | 235 | 239 |
| |
Collapse file Copy file name to clipboardExpand all lines: cmd/rebase.go +50-24Lines changed: 50 additions & 24 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -21,6 +21,7 @@ type rebaseOptions struct { | 21 | 21 | upstack bool | 22 | 22 | cont bool | 23 | 23 | abort bool | | 24 | + noTrunk bool | 24 | 25 | remote string | 25 | 26 | committerDateIsAuthorDate bool | 26 | 27 | } | @@ -34,6 +35,7 @@ type rebaseState struct { | 34 | 35 | UseOnto bool `json:"useOnto,omitempty"` | 35 | 36 | OntoOldBase string `json:"ontoOldBase,omitempty"` | 36 | 37 | CommitterDateIsAuthorDate bool `json:"committerDateIsAuthorDate,omitempty"` | | 38 | + NoTrunk bool `json:"noTrunk,omitempty"` | 37 | 39 | } | 38 | 40 |
| 39 | 41 | const rebaseStateFile = "gh-stack-rebase-state" | @@ -47,7 +49,11 @@ func RebaseCmd(cfg *config.Config) *cobra.Command { | 47 | 49 | Long: `Pull from remote and do a cascading rebase across the stack. | 48 | 50 |
| 49 | 51 | 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.).`, | 51 | 57 | Example: ` # Rebase the entire stack | 52 | 58 | $ gh stack rebase | 53 | 59 |
| @@ -57,6 +63,9 @@ layer in its commit history, rebasing if necessary.`, | 57 | 63 | # Only rebase from current branch to the top | 58 | 64 | $ gh stack rebase --upstack | 59 | 65 |
| | 66 | + # Rebase stack branches without pulling from or rebasing with trunk | | 67 | + $ gh stack rebase --no-trunk | | 68 | +
| 60 | 69 | # Continue after resolving conflicts | 61 | 70 | $ gh stack rebase --continue | 62 | 71 |
| @@ -73,6 +82,7 @@ layer in its commit history, rebasing if necessary.`, | 73 | 82 |
| 74 | 83 | cmd.Flags().BoolVar(&opts.downstack, "downstack", false, "Only rebase branches from trunk to current branch") | 75 | 84 | 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") | 76 | 86 | cmd.Flags().BoolVar(&opts.cont, "continue", false, "Continue rebase after resolving conflicts") | 77 | 87 | cmd.Flags().BoolVar(&opts.abort, "abort", false, "Abort rebase and restore all branches") | 78 | 88 | 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 { | 115 | 125 | return ErrSilent | 116 | 126 | } | 117 | 127 |
| 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 | 123 | 136 | } | 124 | | - return ErrSilent | 125 | | - } | 126 | 137 |
| 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 | + } | 132 | 143 |
| 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 | + } | 138 | 149 |
| 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) | 141 | 152 |
| 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 | + } | 144 | 156 |
| 145 | 157 | cfg.Printf("Stack detected: %s", s.DisplayChain()) | 146 | 158 |
| @@ -163,6 +175,11 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { | 163 | 175 | startIdx = currentIdx | 164 | 176 | } | 165 | 177 |
| | 178 | + // With --no-trunk, skip the first branch (which would rebase onto trunk). | | 179 | + if opts.noTrunk && startIdx < 1 { | | 180 | + startIdx = 1 | | 181 | + } | | 182 | + | 166 | 183 | branchesToRebase := s.Branches[startIdx:endIdx] | 167 | 184 |
| 168 | 185 | if len(branchesToRebase) == 0 { | @@ -224,6 +241,7 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { | 224 | 241 | UseOnto: rebaseResult.NeedsOnto, | 225 | 242 | OntoOldBase: rebaseResult.OntoOldBase, | 226 | 243 | CommitterDateIsAuthorDate: opts.committerDateIsAuthorDate, | | 244 | + NoTrunk: opts.noTrunk, | 227 | 245 | } | 228 | 246 | if err := saveRebaseState(gitDir, state); err != nil { | 229 | 247 | cfg.Warningf("failed to save rebase state: %s", err) | @@ -263,7 +281,11 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { | 263 | 281 | rangeDesc = fmt.Sprintf("All upstack branches from %s", currentBranch) | 264 | 282 | } | 265 | 283 |
| 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 | + } | 267 | 289 | cfg.Printf("To push up your changes, run `%s`", | 268 | 290 | cfg.ColorCyan("gh stack push")) | 269 | 291 |
| @@ -393,7 +415,11 @@ func continueRebase(cfg *config.Config, gitDir string) error { | 393 | 415 |
| 394 | 416 | stack.SaveNonBlocking(gitDir, sf) | 395 | 417 |
| 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 | + } | 397 | 423 | cfg.Printf("To push up your changes and open/update the stack of PRs, run `%s`", | 398 | 424 | cfg.ColorCyan("gh stack submit")) | 399 | 425 |
| |
Collapse file Copy file name to clipboardExpand all lines: cmd/rebase_test.go +226Lines changed: 226 additions & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -1480,3 +1480,229 @@ func TestRebase_ConflictSavesCommitterDateFlag(t *testing.T) { | 1480 | 1480 | assert.True(t, loaded.CommitterDateIsAuthorDate, | 1481 | 1481 | "saved rebase state should preserve CommitterDateIsAuthorDate flag") | 1482 | 1482 | } | | 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 | +} |
|