Skip to content

Commit 6d8a227

Browse files
committed
feat: add --rebase flag to send command
1 parent 226527a commit 6d8a227

File tree

5 files changed

+133
-1
lines changed

5 files changed

+133
-1
lines changed

cmd/send.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ func init() {
3333
sendCmd.Flags().BoolP("draft", "d", false, "Create PRs as drafts")
3434
sendCmd.Flags().BoolP("existing", "x", false, "Only update PRs that already exist (skip new ones)")
3535
sendCmd.Flags().Bool("no-stack", false, "Send only the tip of each stack as a single PR")
36+
sendCmd.Flags().Bool("rebase", false, "Rebase the stack onto the base branch before sending")
3637
}
3738

3839
// sendOpts holds configuration for the send pipeline.
@@ -46,6 +47,7 @@ type sendOpts struct {
4647
draft bool
4748
existing bool
4849
noStack bool
50+
rebase bool
4951
reviewers []string
5052
revsets []string
5153
}
@@ -83,6 +85,7 @@ func runSend(cmd *cobra.Command, args []string) error {
8385
draft, _ := cmd.Flags().GetBool("draft")
8486
existing, _ := cmd.Flags().GetBool("existing")
8587
noStack, _ := cmd.Flags().GetBool("no-stack")
88+
rebase, _ := cmd.Flags().GetBool("rebase")
8689
w := cmd.OutOrStdout()
8790

8891
revsets := args
@@ -159,6 +162,7 @@ func runSend(cmd *cobra.Command, args []string) error {
159162
draft: draft,
160163
existing: existing,
161164
noStack: noStack,
165+
rebase: rebase,
162166
reviewers: reviewers,
163167
revsets: revsets,
164168
}, w)
@@ -179,6 +183,14 @@ func executeSend(runner jj.Runner, client gh.Service, opts sendOpts, w io.Writer
179183
}
180184
}
181185

186+
// Rebase onto base branch if requested.
187+
if opts.rebase {
188+
_, _ = fmt.Fprintf(w, "Rebasing onto %s...\n", opts.base)
189+
if err := runner.Rebase(opts.revsets, opts.base); err != nil {
190+
return fmt.Errorf("rebasing onto %s: %w", opts.base, err)
191+
}
192+
}
193+
182194
repoFullName := client.Owner() + "/" + client.Repo()
183195

184196
// 2. Resolve stacks.

cmd/send_integration_test.go

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -802,6 +802,80 @@ func TestIntegration_SendNoStack(t *testing.T) {
802802
}
803803
}
804804

805+
func TestIntegration_SendRebase(t *testing.T) {
806+
checkJJ(t)
807+
808+
mock := newMockService()
809+
repoDir, _ := initTestRepoWithRemote(t)
810+
spy := &spyRunner{Runner: jj.NewRunner(repoDir)}
811+
812+
// Create a change on top of main.
813+
writeAndCommit(t, repoDir, "a.go", "package a", "feat: rebase test")
814+
815+
var buf bytes.Buffer
816+
err := executeSend(spy, mock, sendOpts{
817+
base: "main",
818+
remote: "origin",
819+
revsets: []string{"@-"},
820+
rebase: true,
821+
}, &buf)
822+
if err != nil {
823+
t.Fatalf("send --rebase failed: %v\nOutput:\n%s", err, buf.String())
824+
}
825+
826+
output := buf.String()
827+
t.Logf("Output:\n%s", output)
828+
829+
// Verify rebase was called with the correct arguments.
830+
if len(spy.rebaseCalls) != 1 {
831+
t.Fatalf("expected 1 rebase call, got %d", len(spy.rebaseCalls))
832+
}
833+
rc := spy.rebaseCalls[0]
834+
if len(rc.revsets) != 1 || rc.revsets[0] != "@-" {
835+
t.Errorf("expected rebase revsets [@-], got %v", rc.revsets)
836+
}
837+
if rc.destination != "main" {
838+
t.Errorf("expected rebase destination 'main', got %q", rc.destination)
839+
}
840+
841+
// Output should mention rebasing.
842+
if !strings.Contains(output, "Rebasing onto main") {
843+
t.Errorf("expected 'Rebasing onto main' in output, got:\n%s", output)
844+
}
845+
846+
// PR should still be created successfully.
847+
mock.mu.Lock()
848+
defer mock.mu.Unlock()
849+
if len(mock.prs) != 1 {
850+
t.Errorf("expected 1 PR, got %d", len(mock.prs))
851+
}
852+
}
853+
854+
func TestIntegration_SendNoRebaseByDefault(t *testing.T) {
855+
checkJJ(t)
856+
857+
mock := newMockService()
858+
repoDir, _ := initTestRepoWithRemote(t)
859+
spy := &spyRunner{Runner: jj.NewRunner(repoDir)}
860+
861+
writeAndCommit(t, repoDir, "a.go", "package a", "feat: no rebase test")
862+
863+
var buf bytes.Buffer
864+
err := executeSend(spy, mock, sendOpts{
865+
base: "main",
866+
remote: "origin",
867+
revsets: []string{"@-"},
868+
}, &buf)
869+
if err != nil {
870+
t.Fatalf("send failed: %v\nOutput:\n%s", err, buf.String())
871+
}
872+
873+
// Rebase should NOT have been called.
874+
if len(spy.rebaseCalls) != 0 {
875+
t.Errorf("expected 0 rebase calls without --rebase, got %d", len(spy.rebaseCalls))
876+
}
877+
}
878+
805879
func TestIntegration_SendSkipsBehindBookmark(t *testing.T) {
806880
checkJJ(t)
807881

@@ -985,11 +1059,17 @@ func writeFile(t *testing.T, dir, filename, content string) {
9851059
}
9861060
}
9871061

988-
// spyRunner wraps a real Runner and records remotes passed to GitFetch/GitPush.
1062+
// spyRunner wraps a real Runner and records remotes passed to GitFetch/GitPush/Rebase.
9891063
type spyRunner struct {
9901064
jj.Runner
9911065
fetchRemotes []string
9921066
pushRemote string
1067+
rebaseCalls []rebaseCall
1068+
}
1069+
1070+
type rebaseCall struct {
1071+
revsets []string
1072+
destination string
9931073
}
9941074

9951075
func (s *spyRunner) GitFetch(remote string) error {
@@ -1002,6 +1082,11 @@ func (s *spyRunner) GitPush(bookmarks []string, allowNew bool, remote string) er
10021082
return s.Runner.GitPush(bookmarks, allowNew, remote)
10031083
}
10041084

1085+
func (s *spyRunner) Rebase(revsets []string, destination string) error {
1086+
s.rebaseCalls = append(s.rebaseCalls, rebaseCall{revsets: revsets, destination: destination})
1087+
return s.Runner.Rebase(revsets, destination)
1088+
}
1089+
10051090
// --- Test helpers ---
10061091

10071092
func checkJJ(t *testing.T) {

docs/reference.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
| `--draft` | `-d` | | Create PRs as drafts |
2121
| `--existing` | `-x` | | Only update PRs that already exist (skip new ones) |
2222
| `--no-stack` | | | Send only the tip of each stack as a single PR |
23+
| `--rebase` | | | Rebase the stack onto the base branch before sending |
2324

2425
## Revsets
2526

@@ -44,6 +45,18 @@ branches to your fork.
4445
jip send --upstream upstream
4546
```
4647

48+
## Rebasing before send (`--rebase`)
49+
50+
Use `--rebase` to rebase the stack onto the base branch before pushing. This
51+
ensures PRs don't contain stale diffs when the base branch has moved forward.
52+
53+
```bash
54+
jip send --rebase
55+
```
56+
57+
This is equivalent to running `jj rebase` manually before `jip send`, but
58+
saves a step.
59+
4760
## Single PR for a stack (`--no-stack`)
4861

4962
By default, jip creates one PR per commit. Use `--no-stack` to bundle an

docs/workflows.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,9 @@ jj rebase -o main
125125

126126
# Update all PRs (jip posts comments showing what changed)
127127
jip s prm vu
128+
129+
# Or combine rebase + send in one step
130+
jip s --rebase prm vu
128131
```
129132

130133
The reformat PR is already merged, so jip skips it. The bugfix PR now targets
@@ -140,6 +143,9 @@ jj git fetch
140143
jj rebase -o main
141144
# Update all existing (-x/--existing) PRs for changes that are descendants of main
142145
jip s -x main::
146+
147+
# Or combine rebase + send in one step
148+
jip s -x --rebase main::
143149
```
144150

145151
Now only the feature PR remains, targeting `main` directly. The stack

internal/jj/runner.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ type Runner interface {
5555

5656
// Interdiff returns the diff between two revisions using jj interdiff --git.
5757
Interdiff(from, to string) (string, error)
58+
59+
// Rebase rebases the given revsets onto the destination revision.
60+
Rebase(revsets []string, destination string) error
5861
}
5962

6063
// NewRunner creates a Runner that executes jj in the given repository directory.
@@ -166,6 +169,19 @@ func (r *realRunner) Interdiff(from, to string) (string, error) {
166169
return string(out), nil
167170
}
168171

172+
func (r *realRunner) Rebase(revsets []string, destination string) error {
173+
args := []string{"rebase", "-R", r.repoDir, "-d", destination}
174+
for _, rev := range revsets {
175+
args = append(args, "-b", rev)
176+
}
177+
cmd := exec.Command("jj", args...)
178+
out, err := cmd.CombinedOutput()
179+
if err != nil {
180+
return fmt.Errorf("jj rebase: %w\n%s", err, strings.TrimSpace(string(out)))
181+
}
182+
return nil
183+
}
184+
169185
// ParseRemoteList parses the output of jj git remote list into a map
170186
// of remote name → URL.
171187
func ParseRemoteList(data []byte) map[string]string {

0 commit comments

Comments
 (0)