From 73ea782cd79a583c933b0b57ca3dcdeee8e9b6da Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Mon, 15 Jun 2026 10:50:45 -0400 Subject: [PATCH 1/2] flag for rebasing without trunk --- cmd/rebase.go | 74 ++++++++++----- cmd/rebase_test.go | 226 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 276 insertions(+), 24 deletions(-) diff --git a/cmd/rebase.go b/cmd/rebase.go index f1acea5..d0c2814 100644 --- a/cmd/rebase.go +++ b/cmd/rebase.go @@ -21,6 +21,7 @@ type rebaseOptions struct { upstack bool cont bool abort bool + noTrunk bool remote string committerDateIsAuthorDate bool } @@ -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" @@ -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 @@ -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 @@ -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)") @@ -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()) @@ -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 { @@ -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) @@ -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")) @@ -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")) diff --git a/cmd/rebase_test.go b/cmd/rebase_test.go index e773bc9..611f2b4 100644 --- a/cmd/rebase_test.go +++ b/cmd/rebase_test.go @@ -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") +} From 5b307cde4e85e47646a9403d079206e38f495f0b Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Mon, 15 Jun 2026 10:52:16 -0400 Subject: [PATCH 2/2] update docs with new rebase flag --- README.md | 4 ++++ docs/src/content/docs/guides/workflows.md | 3 +++ docs/src/content/docs/reference/cli.md | 4 ++++ skills/gh-stack/SKILL.md | 9 ++++++++- 4 files changed, 19 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index cc20ec5..627a3c9 100644 --- a/README.md +++ b/README.md @@ -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 ` | Remote to fetch from (defaults to auto-detected remote) | @@ -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 diff --git a/docs/src/content/docs/guides/workflows.md b/docs/src/content/docs/guides/workflows.md index a1285b1..6f4c0b2 100644 --- a/docs/src/content/docs/guides/workflows.md +++ b/docs/src/content/docs/guides/workflows.md @@ -155,6 +155,9 @@ gh stack rebase --downstack # Only rebase from the current branch up to the top gh stack rebase --upstack + +# Rebase stack branches without pulling from or rebasing with trunk +gh stack rebase --no-trunk ``` After rebasing, push the updated branches: diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index dab458f..3855fcb 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -334,6 +334,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 ` | Remote to fetch from (defaults to auto-detected remote) | @@ -355,6 +356,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 diff --git a/skills/gh-stack/SKILL.md b/skills/gh-stack/SKILL.md index 3ad4a35..27f26d2 100644 --- a/skills/gh-stack/SKILL.md +++ b/skills/gh-stack/SKILL.md @@ -7,7 +7,7 @@ description: > branch chains, or incremental code review workflows. metadata: author: github - version: "0.0.5" + version: "0.0.6" --- # gh-stack @@ -155,6 +155,7 @@ Small, incidental fixes (e.g., fixing a typo you noticed) can go in the current | Sync and prune merged branches | `gh stack sync --prune` | | Rebase entire stack | `gh stack rebase` | | Rebase upstack only | `gh stack rebase --upstack` | +| Rebase without trunk | `gh stack rebase --no-trunk` | | Continue after conflict | `gh stack rebase --continue` | | Abort rebase | `gh stack rebase --abort` | | View stack details (JSON) | `gh stack view --json` | @@ -663,6 +664,9 @@ gh stack rebase --downstack # Rebase only branches from current branch to top gh stack rebase --upstack +# Rebase stack branches without pulling from or rebasing with trunk +gh stack rebase --no-trunk + # After resolving a conflict: stage files with `git add`, then: gh stack rebase --continue @@ -674,6 +678,7 @@ gh stack rebase --abort |------|-------------| | `--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 after resolving conflicts | | `--abort` | Abort and restore all branches | | `--remote ` | Remote to fetch from (use if multiple remotes exist) | @@ -688,6 +693,8 @@ gh stack rebase --abort **Rerere (conflict memory):** `git rerere` is enabled by `init` so previously resolved conflicts are auto-resolved in future rebases. +**No-trunk mode:** Use `--no-trunk` to skip fetching from the remote and rebasing with the trunk branch. Only inter-branch rebases are performed (branch 2 onto branch 1, branch 3 onto branch 2, etc.). Useful when you only need to align stack branches with each other without pulling upstream changes. + --- ### View the stack — `gh stack view`