Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,7 @@ If a rebase conflict occurs, the operation pauses and prints the conflicted file
|------|-------------|
| `--downstack` | Only rebase branches from trunk to the current branch |
| `--upstack` | Only rebase branches from the current branch to the top |
| `--no-trunk` | Skip trunk — only rebase stack branches onto each other (no fetch, no trunk rebase) |
| `--continue` | Continue the rebase after resolving conflicts |
| `--abort` | Abort the rebase and restore all branches to their pre-rebase state |
| `--remote <name>` | Remote to fetch from (defaults to auto-detected remote) |
Expand All @@ -230,6 +231,9 @@ gh stack rebase --downstack
# Only rebase branches above the current one
gh stack rebase --upstack

# Rebase stack branches without pulling from or rebasing with trunk
gh stack rebase --no-trunk

# After resolving a conflict
gh stack rebase --continue

Expand Down
74 changes: 50 additions & 24 deletions cmd/rebase.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ type rebaseOptions struct {
upstack bool
cont bool
abort bool
noTrunk bool
remote string
committerDateIsAuthorDate bool
}
Expand All @@ -34,6 +35,7 @@ type rebaseState struct {
UseOnto bool `json:"useOnto,omitempty"`
OntoOldBase string `json:"ontoOldBase,omitempty"`
CommitterDateIsAuthorDate bool `json:"committerDateIsAuthorDate,omitempty"`
NoTrunk bool `json:"noTrunk,omitempty"`
}

const rebaseStateFile = "gh-stack-rebase-state"
Expand All @@ -47,7 +49,11 @@ func RebaseCmd(cfg *config.Config) *cobra.Command {
Long: `Pull from remote and do a cascading rebase across the stack.

Ensures that each branch in the stack has the tip of the previous
layer in its commit history, rebasing if necessary.`,
layer in its commit history, rebasing if necessary.

Use --no-trunk to skip fetching and rebasing with the trunk branch.
Only the inter-branch rebases are performed (branch 2 onto branch 1,
branch 3 onto branch 2, etc.).`,
Example: ` # Rebase the entire stack
$ gh stack rebase

Expand All @@ -57,6 +63,9 @@ layer in its commit history, rebasing if necessary.`,
# Only rebase from current branch to the top
$ gh stack rebase --upstack

# Rebase stack branches without pulling from or rebasing with trunk
$ gh stack rebase --no-trunk

# Continue after resolving conflicts
$ gh stack rebase --continue

Expand All @@ -73,6 +82,7 @@ layer in its commit history, rebasing if necessary.`,

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

// Resolve remote for fetch and trunk comparison
remote, err := pickRemote(cfg, currentBranch, opts.remote)
if err != nil {
if !errors.Is(err, errInterrupt) {
cfg.Errorf("%s", err)
if !opts.noTrunk {
// Resolve remote for fetch and trunk comparison
remote, err := pickRemote(cfg, currentBranch, opts.remote)
if err != nil {
if !errors.Is(err, errInterrupt) {
cfg.Errorf("%s", err)
}
return ErrSilent
}
return ErrSilent
}

if err := git.Fetch(remote); err != nil {
cfg.Warningf("Failed to fetch %s: %v", remote, err)
} else {
cfg.Successf("Fetched %s", remote)
}
if err := git.Fetch(remote); err != nil {
cfg.Warningf("Failed to fetch %s: %v", remote, err)
} else {
cfg.Successf("Fetched %s", remote)
}

// Ensure trunk exists locally before fast-forward or cascade rebase.
if err := ensureLocalTrunk(cfg, s.Trunk.Branch, remote); err != nil {
cfg.Errorf("%s", err)
return ErrSilent
}
// Ensure trunk exists locally before fast-forward or cascade rebase.
if err := ensureLocalTrunk(cfg, s.Trunk.Branch, remote); err != nil {
cfg.Errorf("%s", err)
return ErrSilent
}

// Fast-forward trunk so the cascade rebase targets the latest upstream.
fastForwardTrunk(cfg, s.Trunk.Branch, remote, currentBranch)
// Fast-forward trunk so the cascade rebase targets the latest upstream.
fastForwardTrunk(cfg, s.Trunk.Branch, remote, currentBranch)

// Fast-forward stack branches that are behind their remote tracking branch.
fastForwardBranches(cfg, s, remote, currentBranch)
// Fast-forward stack branches that are behind their remote tracking branch.
fastForwardBranches(cfg, s, remote, currentBranch)
}

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

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

// With --no-trunk, skip the first branch (which would rebase onto trunk).
if opts.noTrunk && startIdx < 1 {
startIdx = 1
}

branchesToRebase := s.Branches[startIdx:endIdx]

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

cfg.Printf("%s rebased locally with %s", rangeDesc, s.Trunk.Branch)
if opts.noTrunk {
cfg.Printf("%s rebased locally (without trunk)", rangeDesc)
} else {
cfg.Printf("%s rebased locally with %s", rangeDesc, s.Trunk.Branch)
}
cfg.Printf("To push up your changes, run `%s`",
cfg.ColorCyan("gh stack push"))

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

stack.SaveNonBlocking(gitDir, sf)

cfg.Printf("All branches in stack rebased locally with %s", s.Trunk.Branch)
if state.NoTrunk {
cfg.Printf("All branches in stack rebased locally (without trunk)")
} else {
cfg.Printf("All branches in stack rebased locally with %s", s.Trunk.Branch)
}
cfg.Printf("To push up your changes and open/update the stack of PRs, run `%s`",
cfg.ColorCyan("gh stack submit"))

Expand Down
226 changes: 226 additions & 0 deletions cmd/rebase_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1480,3 +1480,229 @@ func TestRebase_ConflictSavesCommitterDateFlag(t *testing.T) {
assert.True(t, loaded.CommitterDateIsAuthorDate,
"saved rebase state should preserve CommitterDateIsAuthorDate flag")
}

// TestRebase_NoTrunk_SkipsTrunkRebase verifies that --no-trunk skips rebasing
// branch 1 onto trunk but still cascades inter-branch rebases.
func TestRebase_NoTrunk_SkipsTrunkRebase(t *testing.T) {
s := stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "b1"},
{Branch: "b2"},
{Branch: "b3"},
},
}

tmpDir := t.TempDir()
writeStackFile(t, tmpDir, s)

var allRebaseCalls []rebaseCall
var currentCheckedOut string

mock := newRebaseMock(tmpDir, "b2")
mock.CheckoutBranchFn = func(name string) error {
currentCheckedOut = name
return nil
}
mock.RebaseFn = func(base string, opts git.RebaseOpts) error {
allRebaseCalls = append(allRebaseCalls, rebaseCall{newBase: base, oldBase: "", branch: currentCheckedOut})
return nil
}
mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error {
allRebaseCalls = append(allRebaseCalls, rebaseCall{newBase, oldBase, branch})
return nil
}

restore := git.SetOps(mock)
defer restore()

cfg, _, errR := config.NewTestConfig()
cmd := RebaseCmd(cfg)
cmd.SetArgs([]string{"--no-trunk"})
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
err := cmd.Execute()

cfg.Err.Close()
errOut, _ := io.ReadAll(errR)
output := string(errOut)

assert.NoError(t, err)

// Only b2 onto b1 and b3 onto b2 — no rebase onto trunk (main).
require.Len(t, allRebaseCalls, 2, "should only rebase b2 and b3 (skip b1 onto trunk)")
assert.Equal(t, "b1", allRebaseCalls[0].newBase, "b2 should be rebased onto b1")
assert.Equal(t, "b2", allRebaseCalls[1].newBase, "b3 should be rebased onto b2")

assert.Contains(t, output, "without trunk")
}

// TestRebase_NoTrunk_SkipsFetch verifies that --no-trunk does not call Fetch.
func TestRebase_NoTrunk_SkipsFetch(t *testing.T) {
s := stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "b1"},
{Branch: "b2"},
},
}

tmpDir := t.TempDir()
writeStackFile(t, tmpDir, s)

fetchCalled := false

mock := newRebaseMock(tmpDir, "b1")
mock.CheckoutBranchFn = func(name string) error { return nil }
mock.RebaseFn = func(base string, opts git.RebaseOpts) error { return nil }
mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error { return nil }
mock.FetchFn = func(remote string) error {
fetchCalled = true
return nil
}

restore := git.SetOps(mock)
defer restore()

cfg, _, _ := config.NewTestConfig()
cmd := RebaseCmd(cfg)
cmd.SetArgs([]string{"--no-trunk"})
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
err := cmd.Execute()

cfg.Out.Close()
cfg.Err.Close()

assert.NoError(t, err)
assert.False(t, fetchCalled, "Fetch should not be called with --no-trunk")
}

// TestRebase_NoTrunk_SingleBranch verifies that --no-trunk with a single-branch
// stack has no branches to rebase (since branch 1 onto trunk is skipped).
func TestRebase_NoTrunk_SingleBranch(t *testing.T) {
s := stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "b1"},
},
}

tmpDir := t.TempDir()
writeStackFile(t, tmpDir, s)

mock := newRebaseMock(tmpDir, "b1")
mock.CheckoutBranchFn = func(name string) error { return nil }

restore := git.SetOps(mock)
defer restore()

cfg, _, errR := config.NewTestConfig()
cmd := RebaseCmd(cfg)
cmd.SetArgs([]string{"--no-trunk"})
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
err := cmd.Execute()

cfg.Err.Close()
errOut, _ := io.ReadAll(errR)
output := string(errOut)

assert.NoError(t, err)
assert.Contains(t, output, "No branches to rebase")
}

// TestRebase_NoTrunk_WithUpstack verifies --no-trunk combined with --upstack
// when the current branch is above index 0. The --no-trunk should not change
// behavior since --upstack already starts from a non-trunk branch.
func TestRebase_NoTrunk_WithUpstack(t *testing.T) {
s := stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "b1"},
{Branch: "b2"},
{Branch: "b3"},
},
}

tmpDir := t.TempDir()
writeStackFile(t, tmpDir, s)

var allRebaseCalls []rebaseCall
var currentCheckedOut string

mock := newRebaseMock(tmpDir, "b2")
mock.CheckoutBranchFn = func(name string) error {
currentCheckedOut = name
return nil
}
mock.RebaseFn = func(base string, opts git.RebaseOpts) error {
allRebaseCalls = append(allRebaseCalls, rebaseCall{newBase: base, oldBase: "", branch: currentCheckedOut})
return nil
}
mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error {
allRebaseCalls = append(allRebaseCalls, rebaseCall{newBase, oldBase, branch})
return nil
}

restore := git.SetOps(mock)
defer restore()

cfg, _, _ := config.NewTestConfig()
cmd := RebaseCmd(cfg)
cmd.SetArgs([]string{"--no-trunk", "--upstack"})
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
err := cmd.Execute()

cfg.Out.Close()
cfg.Err.Close()

assert.NoError(t, err)
// --upstack from b2 = [b2, b3], --no-trunk doesn't change this since startIdx is already 1
require.Len(t, allRebaseCalls, 2, "upstack should rebase b2 and b3")
assert.Equal(t, "b1", allRebaseCalls[0].newBase, "b2 should be rebased onto b1")
assert.Equal(t, "b2", allRebaseCalls[1].newBase, "b3 should be rebased onto b2")
}

// TestRebase_NoTrunk_ConflictSavesState verifies that --no-trunk persists the
// NoTrunk flag in the rebase state when a conflict occurs.
func TestRebase_NoTrunk_ConflictSavesState(t *testing.T) {
s := stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "b1"},
{Branch: "b2"},
{Branch: "b3"},
},
}

tmpDir := t.TempDir()
writeStackFile(t, tmpDir, s)

mock := newRebaseMock(tmpDir, "b2")
mock.CheckoutBranchFn = func(name string) error { return nil }
mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error {
if branch == "b2" {
return fmt.Errorf("conflict")
}
return nil
}
mock.ConflictedFilesFn = func() ([]string, error) { return nil, nil }

restore := git.SetOps(mock)
defer restore()

cfg, _, _ := config.NewTestConfig()
cmd := RebaseCmd(cfg)
cmd.SetArgs([]string{"--no-trunk"})
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
_ = cmd.Execute()

// Load the saved state and verify the NoTrunk flag is persisted.
loaded, err := loadRebaseState(tmpDir)
require.NoError(t, err)
assert.True(t, loaded.NoTrunk,
"saved rebase state should preserve NoTrunk flag")
}
Loading