Skip to content

Commit 4dfdc1b

Browse files
julianknutsenclaude
andcommitted
Add wl merge command to land reviewed branches into main
Merges a wl/* branch into main, pushes to upstream+origin, and deletes the branch by default. Supports --keep-branch and --no-push. Detects merge conflicts and aborts cleanly with a helpful message. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d0062ea commit 4dfdc1b

File tree

7 files changed

+287
-1
lines changed

7 files changed

+287
-1
lines changed

cmd/wl/cmd_merge.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"io"
6+
7+
"github.com/spf13/cobra"
8+
"github.com/steveyegge/wasteland/internal/commons"
9+
"github.com/steveyegge/wasteland/internal/style"
10+
)
11+
12+
func newMergeCmd(stdout, stderr io.Writer) *cobra.Command {
13+
var (
14+
noPush bool
15+
keepBranch bool
16+
)
17+
18+
cmd := &cobra.Command{
19+
Use: "merge <branch>",
20+
Short: "Merge a reviewed branch into main",
21+
Long: `Merge a wl/* branch into main after review.
22+
23+
Performs a Dolt merge, pushes main to upstream and origin, and deletes
24+
the branch (unless --keep-branch is set).
25+
26+
Examples:
27+
wl merge wl/my-rig/w-abc123
28+
wl merge wl/my-rig/w-abc123 --keep-branch
29+
wl merge wl/my-rig/w-abc123 --no-push`,
30+
Args: cobra.ExactArgs(1),
31+
RunE: func(cmd *cobra.Command, args []string) error {
32+
return runMerge(cmd, stdout, stderr, args[0], noPush, keepBranch)
33+
},
34+
}
35+
36+
cmd.Flags().BoolVar(&noPush, "no-push", false, "Skip pushing to remotes")
37+
cmd.Flags().BoolVar(&keepBranch, "keep-branch", false, "Don't delete branch after merge")
38+
39+
return cmd
40+
}
41+
42+
func runMerge(cmd *cobra.Command, stdout, _ io.Writer, branch string, noPush, keepBranch bool) error {
43+
cfg, err := resolveWasteland(cmd)
44+
if err != nil {
45+
return fmt.Errorf("loading wasteland config: %w", err)
46+
}
47+
48+
exists, err := commons.BranchExists(cfg.LocalDir, branch)
49+
if err != nil {
50+
return fmt.Errorf("checking branch: %w", err)
51+
}
52+
if !exists {
53+
return fmt.Errorf("branch %q does not exist", branch)
54+
}
55+
56+
if err := commons.MergeBranch(cfg.LocalDir, branch); err != nil {
57+
return err
58+
}
59+
60+
fmt.Fprintf(stdout, "%s Merged %s into main\n", style.Bold.Render("✓"), branch)
61+
62+
if !keepBranch {
63+
if err := commons.DeleteBranch(cfg.LocalDir, branch); err != nil {
64+
fmt.Fprintf(stdout, " warning: failed to delete branch %s: %v\n", branch, err)
65+
} else {
66+
fmt.Fprintf(stdout, " Branch %s deleted\n", branch)
67+
}
68+
}
69+
70+
if !noPush {
71+
_ = commons.PushWithSync(cfg.LocalDir, stdout)
72+
}
73+
74+
return nil
75+
}

cmd/wl/cmd_merge_test.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"testing"
6+
)
7+
8+
func TestMergeRequiresArg(t *testing.T) {
9+
var stdout, stderr bytes.Buffer
10+
root := newRootCmd(&stdout, &stderr)
11+
12+
for _, c := range root.Commands() {
13+
if c.Name() == "merge" {
14+
if err := c.Args(c, []string{}); err == nil {
15+
t.Error("merge should require exactly 1 argument")
16+
}
17+
if err := c.Args(c, []string{"wl/rig/w-abc"}); err != nil {
18+
t.Errorf("merge should accept 1 argument: %v", err)
19+
}
20+
if err := c.Args(c, []string{"a", "b"}); err == nil {
21+
t.Error("merge should reject 2 arguments")
22+
}
23+
return
24+
}
25+
}
26+
t.Fatal("merge command not found")
27+
}

cmd/wl/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ func newRootCmd(stdout, stderr io.Writer) *cobra.Command {
7979
newListCmd(stdout, stderr),
8080
newConfigCmd(stdout, stderr),
8181
newReviewCmd(stdout, stderr),
82+
newMergeCmd(stdout, stderr),
8283
newVersionCmd(stdout),
8384
)
8485
return root

cmd/wl/main_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ func TestSubcommandRegistration(t *testing.T) {
2828
var stdout, stderr bytes.Buffer
2929
root := newRootCmd(&stdout, &stderr)
3030

31-
expected := []string{"join", "post", "claim", "unclaim", "done", "accept", "reject", "update", "delete", "browse", "status", "sync", "leave", "list", "config", "review", "version"}
31+
expected := []string{"join", "post", "claim", "unclaim", "done", "accept", "reject", "update", "delete", "browse", "status", "sync", "leave", "list", "config", "review", "merge", "version"}
3232
for _, name := range expected {
3333
found := false
3434
for _, c := range root.Commands() {

cmd/wl/testdata/errors.txtar

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,14 @@ stderr 'not joined'
146146
! exec wl review wl/some/branch
147147
stderr 'not joined'
148148

149+
# merge with no args.
150+
! exec wl merge
151+
stderr 'accepts 1 arg'
152+
153+
# merge not joined.
154+
! exec wl merge wl/some/branch
155+
stderr 'not joined'
156+
149157
# config get not joined.
150158
! exec wl config get mode
151159
stderr 'not joined'

internal/commons/dolt.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,29 @@ func ListBranches(dbDir, prefix string) ([]string, error) {
174174
return branches, nil
175175
}
176176

177+
// MergeBranch merges a branch into main. If the merge produces conflicts
178+
// it aborts and returns an error. The caller must already be on main.
179+
func MergeBranch(dbDir, branch string) error {
180+
escaped := strings.ReplaceAll(branch, "'", "''")
181+
err := doltSQLScript(dbDir, fmt.Sprintf(
182+
"CALL DOLT_CHECKOUT('main');\nCALL DOLT_MERGE('%s');", escaped,
183+
))
184+
if err != nil {
185+
if strings.Contains(strings.ToLower(err.Error()), "conflict") {
186+
_ = doltSQLScript(dbDir, "CALL DOLT_MERGE('--abort');")
187+
return fmt.Errorf("merge conflict on branch %s: resolve manually or delete the branch", branch)
188+
}
189+
return fmt.Errorf("merging branch %s: %w", branch, err)
190+
}
191+
return nil
192+
}
193+
194+
// DeleteBranch deletes a local branch.
195+
func DeleteBranch(dbDir, branch string) error {
196+
escaped := strings.ReplaceAll(branch, "'", "''")
197+
return doltSQLScript(dbDir, fmt.Sprintf("CALL DOLT_BRANCH('-D', '%s');", escaped))
198+
}
199+
177200
// doltSQLQuery executes a SQL query and returns the raw CSV output.
178201
func doltSQLQuery(dbDir, query string) (string, error) {
179202
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)

test/integration/offline/pr_mode_test.go

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,158 @@ func TestReviewShowsDiff(t *testing.T) {
242242
}
243243
}
244244

245+
func TestMergeBasic(t *testing.T) {
246+
for _, backend := range backends {
247+
t.Run(string(backend), func(t *testing.T) {
248+
env := joinedEnv(t, backend)
249+
dbDir := forkCloneDir(t, env)
250+
251+
// Switch to PR mode and post.
252+
setMode(t, env, upstream, "pr")
253+
254+
stdout, _, err := runWL(t, env, "post",
255+
"--title", "Merge test item",
256+
"--type", "feature",
257+
"--no-push",
258+
)
259+
if err != nil {
260+
t.Fatalf("wl post failed: %v", err)
261+
}
262+
wantedID := extractWantedID(t, stdout)
263+
branch := "wl/" + forkOrg + "/" + wantedID
264+
265+
// Item should NOT be on main yet.
266+
raw := doltSQL(t, dbDir, "SELECT COUNT(*) FROM wanted WHERE id='"+wantedID+"'")
267+
rows := parseCSV(t, raw)
268+
if len(rows) >= 2 && strings.TrimSpace(rows[1][0]) != "0" {
269+
t.Errorf("item should not be on main before merge, got count: %s", rows[1][0])
270+
}
271+
272+
// Merge.
273+
stdout, stderr, err := runWL(t, env, "merge", branch, "--no-push")
274+
if err != nil {
275+
t.Fatalf("wl merge failed: %v\nstdout: %s\nstderr: %s", err, stdout, stderr)
276+
}
277+
if !strings.Contains(stdout, "Merged") {
278+
t.Errorf("expected 'Merged' message, got: %s", stdout)
279+
}
280+
if !strings.Contains(stdout, "deleted") {
281+
t.Errorf("expected branch deletion message, got: %s", stdout)
282+
}
283+
284+
// Item should now be on main.
285+
raw = doltSQL(t, dbDir, "SELECT id, title FROM wanted WHERE id='"+wantedID+"'")
286+
rows = parseCSV(t, raw)
287+
if len(rows) < 2 {
288+
t.Fatalf("wanted item %s not found on main after merge", wantedID)
289+
}
290+
if rows[1][1] != "Merge test item" {
291+
t.Errorf("title = %q, want %q", rows[1][1], "Merge test item")
292+
}
293+
294+
// Branch should be gone.
295+
raw = doltSQL(t, dbDir, "SELECT COUNT(*) FROM dolt_branches WHERE name='"+branch+"'")
296+
rows = parseCSV(t, raw)
297+
if len(rows) >= 2 && strings.TrimSpace(rows[1][0]) != "0" {
298+
t.Errorf("branch %s should be deleted after merge", branch)
299+
}
300+
})
301+
}
302+
}
303+
304+
func TestMergeKeepBranch(t *testing.T) {
305+
for _, backend := range backends {
306+
t.Run(string(backend), func(t *testing.T) {
307+
env := joinedEnv(t, backend)
308+
dbDir := forkCloneDir(t, env)
309+
310+
setMode(t, env, upstream, "pr")
311+
312+
stdout, _, err := runWL(t, env, "post",
313+
"--title", "Keep branch test",
314+
"--type", "bug",
315+
"--no-push",
316+
)
317+
if err != nil {
318+
t.Fatalf("wl post failed: %v", err)
319+
}
320+
wantedID := extractWantedID(t, stdout)
321+
branch := "wl/" + forkOrg + "/" + wantedID
322+
323+
// Merge with --keep-branch.
324+
stdout, stderr, err := runWL(t, env, "merge", branch, "--keep-branch", "--no-push")
325+
if err != nil {
326+
t.Fatalf("wl merge failed: %v\nstdout: %s\nstderr: %s", err, stdout, stderr)
327+
}
328+
329+
// Branch should still exist.
330+
raw := doltSQL(t, dbDir, "SELECT COUNT(*) FROM dolt_branches WHERE name='"+branch+"'")
331+
rows := parseCSV(t, raw)
332+
if len(rows) < 2 || strings.TrimSpace(rows[1][0]) != "1" {
333+
t.Errorf("branch %s should still exist with --keep-branch", branch)
334+
}
335+
})
336+
}
337+
}
338+
339+
func TestMergeNonExistentBranch(t *testing.T) {
340+
for _, backend := range backends {
341+
t.Run(string(backend), func(t *testing.T) {
342+
env := joinedEnv(t, backend)
343+
344+
_, _, err := runWL(t, env, "merge", "wl/fake/w-nonexistent", "--no-push")
345+
if err == nil {
346+
t.Fatal("merge of non-existent branch should fail")
347+
}
348+
})
349+
}
350+
}
351+
352+
func TestMergeFullLifecycle(t *testing.T) {
353+
for _, backend := range backends {
354+
t.Run(string(backend), func(t *testing.T) {
355+
env := joinedEnv(t, backend)
356+
dbDir := forkCloneDir(t, env)
357+
358+
// PR mode: post → review --stat → merge → verify on main.
359+
setMode(t, env, upstream, "pr")
360+
361+
stdout, _, err := runWL(t, env, "post",
362+
"--title", "Full lifecycle PR",
363+
"--type", "feature",
364+
"--no-push",
365+
)
366+
if err != nil {
367+
t.Fatalf("wl post failed: %v", err)
368+
}
369+
wantedID := extractWantedID(t, stdout)
370+
branch := "wl/" + forkOrg + "/" + wantedID
371+
372+
// Review should show diff.
373+
stdout, stderr, err := runWL(t, env, "review", branch, "--stat")
374+
if err != nil {
375+
t.Fatalf("wl review --stat failed: %v\nstdout: %s\nstderr: %s", err, stdout, stderr)
376+
}
377+
if !strings.Contains(stdout, "wanted") {
378+
t.Errorf("review stat should mention 'wanted', got: %s", stdout)
379+
}
380+
381+
// Merge.
382+
stdout, stderr, err = runWL(t, env, "merge", branch, "--no-push")
383+
if err != nil {
384+
t.Fatalf("wl merge failed: %v\nstdout: %s\nstderr: %s", err, stdout, stderr)
385+
}
386+
387+
// Verify on main.
388+
raw := doltSQL(t, dbDir, "SELECT title FROM wanted WHERE id='"+wantedID+"'")
389+
rows := parseCSV(t, raw)
390+
if len(rows) < 2 || rows[1][0] != "Full lifecycle PR" {
391+
t.Errorf("item not found on main after merge")
392+
}
393+
})
394+
}
395+
}
396+
245397
func TestConfigSetGetMode(t *testing.T) {
246398
for _, backend := range backends {
247399
t.Run(string(backend), func(t *testing.T) {

0 commit comments

Comments
 (0)