From 92ccacffd993ea29a4b2291b429b33ce0d9ae240 Mon Sep 17 00:00:00 2001 From: David Van Couvering Date: Sun, 25 Jan 2026 18:08:34 -0800 Subject: [PATCH] feat(sling): add --no-merge flag to skip merge queue (#939) When contributing to upstream repos or wanting human review before merge, the --no-merge flag keeps polecat work on a feature branch instead of auto-merging to main. Changes: - Add --no-merge flag to gt sling command - Store no_merge field in bead's AttachmentFields - Modify gt done to skip merge queue when no_merge is set - Push branch and mail dispatcher "READY_FOR_REVIEW" instead - Add tests for field parsing and sling flag storage Closes: gt-p7b8 Co-authored-by: Claude Opus 4.5 --- internal/beads/beads_test.go | 74 ++++++++++++++++++++++++ internal/beads/fields.go | 10 ++++ internal/cmd/done.go | 32 +++++++++++ internal/cmd/sling.go | 11 ++++ internal/cmd/sling_helpers.go | 47 +++++++++++++++ internal/cmd/sling_test.go | 104 ++++++++++++++++++++++++++++++++++ 6 files changed, 278 insertions(+) diff --git a/internal/beads/beads_test.go b/internal/beads/beads_test.go index 103b68e4..433a4f83 100644 --- a/internal/beads/beads_test.go +++ b/internal/beads/beads_test.go @@ -903,6 +903,80 @@ func TestAttachmentFieldsRoundTrip(t *testing.T) { } } +// TestNoMergeField tests the no_merge field in AttachmentFields. +// The no_merge flag tells gt done to skip the merge queue and keep work on a feature branch. +func TestNoMergeField(t *testing.T) { + t.Run("parse no_merge true", func(t *testing.T) { + issue := &Issue{Description: "no_merge: true\ndispatched_by: mayor"} + fields := ParseAttachmentFields(issue) + if fields == nil { + t.Fatal("ParseAttachmentFields() = nil") + } + if !fields.NoMerge { + t.Error("NoMerge should be true") + } + if fields.DispatchedBy != "mayor" { + t.Errorf("DispatchedBy = %q, want 'mayor'", fields.DispatchedBy) + } + }) + + t.Run("parse no_merge false", func(t *testing.T) { + issue := &Issue{Description: "no_merge: false\ndispatched_by: crew"} + fields := ParseAttachmentFields(issue) + if fields == nil { + t.Fatal("ParseAttachmentFields() = nil") + } + if fields.NoMerge { + t.Error("NoMerge should be false") + } + }) + + t.Run("parse no-merge alternate format", func(t *testing.T) { + issue := &Issue{Description: "no-merge: true"} + fields := ParseAttachmentFields(issue) + if fields == nil { + t.Fatal("ParseAttachmentFields() = nil") + } + if !fields.NoMerge { + t.Error("NoMerge should be true with hyphen format") + } + }) + + t.Run("format no_merge", func(t *testing.T) { + fields := &AttachmentFields{ + NoMerge: true, + DispatchedBy: "mayor", + } + got := FormatAttachmentFields(fields) + if !strings.Contains(got, "no_merge: true") { + t.Errorf("FormatAttachmentFields() missing no_merge, got:\n%s", got) + } + if !strings.Contains(got, "dispatched_by: mayor") { + t.Errorf("FormatAttachmentFields() missing dispatched_by, got:\n%s", got) + } + }) + + t.Run("round-trip with no_merge", func(t *testing.T) { + original := &AttachmentFields{ + AttachedMolecule: "mol-test", + AttachedAt: "2026-01-24T12:00:00Z", + DispatchedBy: "gastown/crew/max", + NoMerge: true, + } + + formatted := FormatAttachmentFields(original) + issue := &Issue{Description: formatted} + parsed := ParseAttachmentFields(issue) + + if parsed == nil { + t.Fatal("round-trip parse returned nil") + } + if *parsed != *original { + t.Errorf("round-trip mismatch:\ngot %+v\nwant %+v", parsed, original) + } + }) +} + // TestResolveBeadsDir tests the redirect following logic. func TestResolveBeadsDir(t *testing.T) { // Create temp directory structure diff --git a/internal/beads/fields.go b/internal/beads/fields.go index 454e3e6b..6013134b 100644 --- a/internal/beads/fields.go +++ b/internal/beads/fields.go @@ -21,6 +21,7 @@ type AttachmentFields struct { AttachedAt string // ISO 8601 timestamp when attached AttachedArgs string // Natural language args passed via gt sling --args (no-tmux mode) DispatchedBy string // Agent ID that dispatched this work (for completion notification) + NoMerge bool // If true, gt done skips merge queue (for upstream PRs/human review) } // ParseAttachmentFields extracts attachment fields from an issue's description. @@ -65,6 +66,9 @@ func ParseAttachmentFields(issue *Issue) *AttachmentFields { case "dispatched_by", "dispatched-by", "dispatchedby": fields.DispatchedBy = value hasFields = true + case "no_merge", "no-merge", "nomerge": + fields.NoMerge = strings.ToLower(value) == "true" + hasFields = true } } @@ -95,6 +99,9 @@ func FormatAttachmentFields(fields *AttachmentFields) string { if fields.DispatchedBy != "" { lines = append(lines, "dispatched_by: "+fields.DispatchedBy) } + if fields.NoMerge { + lines = append(lines, "no_merge: true") + } return strings.Join(lines, "\n") } @@ -117,6 +124,9 @@ func SetAttachmentFields(issue *Issue, fields *AttachmentFields) string { "dispatched_by": true, "dispatched-by": true, "dispatchedby": true, + "no_merge": true, + "no-merge": true, + "nomerge": true, } // Collect non-attachment lines from existing description diff --git a/internal/cmd/done.go b/internal/cmd/done.go index 8d0c7694..c8657197 100644 --- a/internal/cmd/done.go +++ b/internal/cmd/done.go @@ -310,6 +310,38 @@ func runDone(cmd *cobra.Command, args []string) error { // Initialize beads bd := beads.New(beads.ResolveBeadsDir(cwd)) + // Check for no_merge flag - if set, skip merge queue and notify for review + sourceIssueForNoMerge, err := bd.Show(issueID) + if err == nil { + attachmentFields := beads.ParseAttachmentFields(sourceIssueForNoMerge) + if attachmentFields != nil && attachmentFields.NoMerge { + fmt.Printf("%s No-merge mode: skipping merge queue\n", style.Bold.Render("→")) + fmt.Printf(" Branch: %s\n", branch) + fmt.Printf(" Issue: %s\n", issueID) + fmt.Println() + fmt.Printf("%s\n", style.Dim.Render("Work stays on feature branch for human review.")) + + // Mail dispatcher with READY_FOR_REVIEW + if dispatcher := attachmentFields.DispatchedBy; dispatcher != "" { + townRouter := mail.NewRouter(townRoot) + reviewMsg := &mail.Message{ + To: dispatcher, + From: detectSender(), + Subject: fmt.Sprintf("READY_FOR_REVIEW: %s", issueID), + Body: fmt.Sprintf("Branch: %s\nIssue: %s\nReady for review.", branch, issueID), + } + if err := townRouter.Send(reviewMsg); err != nil { + style.PrintWarning("could not notify dispatcher: %v", err) + } else { + fmt.Printf("%s Dispatcher notified: READY_FOR_REVIEW\n", style.Bold.Render("✓")) + } + } + + // Skip MR creation, go to witness notification + goto notifyWitness + } + } + // Determine target branch (auto-detect integration branch if applicable) target := defaultBranch autoTarget, err := detectIntegrationBranch(bd, g, issueID) diff --git a/internal/cmd/sling.go b/internal/cmd/sling.go index d399a221..451e0739 100644 --- a/internal/cmd/sling.go +++ b/internal/cmd/sling.go @@ -96,6 +96,7 @@ var ( slingAccount string // --account: Claude Code account handle to use slingAgent string // --agent: override runtime agent for this sling/spawn slingNoConvoy bool // --no-convoy: skip auto-convoy creation + slingNoMerge bool // --no-merge: skip merge queue on completion (for upstream PRs/human review) ) func init() { @@ -113,6 +114,7 @@ func init() { slingCmd.Flags().StringVar(&slingAgent, "agent", "", "Override agent/runtime for this sling (e.g., claude, gemini, codex, or custom alias)") slingCmd.Flags().BoolVar(&slingNoConvoy, "no-convoy", false, "Skip auto-convoy creation for single-issue sling") slingCmd.Flags().BoolVar(&slingHookRawBead, "hook-raw-bead", false, "Hook raw bead without default formula (expert mode)") + slingCmd.Flags().BoolVar(&slingNoMerge, "no-merge", false, "Skip merge queue on completion (keep work on feature branch for review)") rootCmd.AddCommand(slingCmd) } @@ -494,6 +496,15 @@ func runSling(cmd *cobra.Command, args []string) error { } } + // Store no_merge flag in bead (skips merge queue on completion) + if slingNoMerge { + if err := storeNoMergeInBead(beadID, true); err != nil { + fmt.Printf("%s Could not store no_merge in bead: %v\n", style.Dim.Render("Warning:"), err) + } else { + fmt.Printf("%s No-merge mode enabled (work stays on feature branch)\n", style.Bold.Render("✓")) + } + } + // Record the attached molecule in the BASE bead's description. // This field points to the wisp (compound root) and enables: // - gt hook/gt prime: follow attached_molecule to show molecule steps diff --git a/internal/cmd/sling_helpers.go b/internal/cmd/sling_helpers.go index d17d306c..d9c441cd 100644 --- a/internal/cmd/sling_helpers.go +++ b/internal/cmd/sling_helpers.go @@ -235,6 +235,53 @@ func storeAttachedMoleculeInBead(beadID, moleculeID string) error { return nil } +// storeNoMergeInBead sets the no_merge field in a bead's description. +// When set, gt done will skip the merge queue and keep work on the feature branch. +// This is useful for upstream contributions or when human review is needed before merge. +func storeNoMergeInBead(beadID string, noMerge bool) error { + if !noMerge { + return nil + } + + // Get the bead to preserve existing description content + showCmd := exec.Command("bd", "show", beadID, "--json") + out, err := showCmd.Output() + if err != nil { + return fmt.Errorf("fetching bead: %w", err) + } + + // Parse the bead + var issues []beads.Issue + if err := json.Unmarshal(out, &issues); err != nil { + return fmt.Errorf("parsing bead: %w", err) + } + if len(issues) == 0 { + return fmt.Errorf("bead not found") + } + issue := &issues[0] + + // Get or create attachment fields + fields := beads.ParseAttachmentFields(issue) + if fields == nil { + fields = &beads.AttachmentFields{} + } + + // Set the no_merge flag + fields.NoMerge = true + + // Update the description + newDesc := beads.SetAttachmentFields(issue, fields) + + // Update the bead + updateCmd := exec.Command("bd", "update", beadID, "--description="+newDesc) + updateCmd.Stderr = os.Stderr + if err := updateCmd.Run(); err != nil { + return fmt.Errorf("updating bead description: %w", err) + } + + return nil +} + // injectStartPrompt sends a prompt to the target pane to start working. // Uses the reliable nudge pattern: literal mode + 500ms debounce + separate Enter. func injectStartPrompt(pane, beadID, subject, args string) error { diff --git a/internal/cmd/sling_test.go b/internal/cmd/sling_test.go index d705c68c..11a25608 100644 --- a/internal/cmd/sling_test.go +++ b/internal/cmd/sling_test.go @@ -1034,3 +1034,107 @@ exit /b 0 "Log output:\n%s\nAttached log:\n%s", string(logBytes), attachedLog) } } + +// TestSlingNoMergeFlag verifies that gt sling --no-merge stores the no_merge flag +// in the bead's description. This flag tells gt done to skip the merge queue +// and keep work on the feature branch for human review. +func TestSlingNoMergeFlag(t *testing.T) { + townRoot := t.TempDir() + + // Minimal workspace marker so workspace.FindFromCwd() succeeds. + if err := os.MkdirAll(filepath.Join(townRoot, "mayor", "rig"), 0755); err != nil { + t.Fatalf("mkdir mayor/rig: %v", err) + } + + // Create stub bd that logs update commands + binDir := filepath.Join(townRoot, "bin") + if err := os.MkdirAll(binDir, 0755); err != nil { + t.Fatalf("mkdir binDir: %v", err) + } + logPath := filepath.Join(townRoot, "bd.log") + bdScript := `#!/bin/sh +set -e +echo "ARGS:$*" >> "${BD_LOG}" +if [ "$1" = "--no-daemon" ]; then + shift +fi +cmd="$1" +shift || true +case "$cmd" in + show) + echo '[{"title":"Test issue","status":"open","assignee":"","description":""}]' + ;; + update) + exit 0 + ;; +esac +exit 0 +` + bdScriptWindows := `@echo off +setlocal enableextensions +echo ARGS:%*>>"%BD_LOG%" +set "cmd=%1" +if "%cmd%"=="--no-daemon" set "cmd=%2" +if "%cmd%"=="show" ( + echo [{"title":"Test issue","status":"open","assignee":"","description":""}] + exit /b 0 +) +if "%cmd%"=="update" exit /b 0 +exit /b 0 +` + _ = writeBDStub(t, binDir, bdScript, bdScriptWindows) + + t.Setenv("BD_LOG", logPath) + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv(EnvGTRole, "mayor") + t.Setenv("GT_CREW", "") + t.Setenv("GT_POLECAT", "") + t.Setenv("TMUX_PANE", "") + t.Setenv("GT_TEST_NO_NUDGE", "1") + + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + t.Cleanup(func() { _ = os.Chdir(cwd) }) + if err := os.Chdir(filepath.Join(townRoot, "mayor", "rig")); err != nil { + t.Fatalf("chdir: %v", err) + } + + // Save and restore global flags + prevDryRun := slingDryRun + prevNoConvoy := slingNoConvoy + prevNoMerge := slingNoMerge + t.Cleanup(func() { + slingDryRun = prevDryRun + slingNoConvoy = prevNoConvoy + slingNoMerge = prevNoMerge + }) + + slingDryRun = false + slingNoConvoy = true + slingNoMerge = true // This is what we're testing + + if err := runSling(nil, []string{"gt-test123"}); err != nil { + t.Fatalf("runSling: %v", err) + } + + logBytes, err := os.ReadFile(logPath) + if err != nil { + t.Fatalf("read bd log: %v", err) + } + + // Look for update command that includes no_merge in description + logLines := strings.Split(string(logBytes), "\n") + foundNoMerge := false + for _, line := range logLines { + if strings.Contains(line, "update") && strings.Contains(line, "no_merge") { + foundNoMerge = true + break + } + } + + if !foundNoMerge { + t.Errorf("--no-merge flag not stored in bead description\nLog:\n%s", string(logBytes)) + } +}