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 <noreply@anthropic.com>
This commit is contained in:
David Van Couvering
2026-01-25 18:08:34 -08:00
committed by GitHub
parent a86c7d954f
commit 92ccacffd9
6 changed files with 278 additions and 0 deletions

View File

@@ -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. // TestResolveBeadsDir tests the redirect following logic.
func TestResolveBeadsDir(t *testing.T) { func TestResolveBeadsDir(t *testing.T) {
// Create temp directory structure // Create temp directory structure

View File

@@ -21,6 +21,7 @@ type AttachmentFields struct {
AttachedAt string // ISO 8601 timestamp when attached AttachedAt string // ISO 8601 timestamp when attached
AttachedArgs string // Natural language args passed via gt sling --args (no-tmux mode) AttachedArgs string // Natural language args passed via gt sling --args (no-tmux mode)
DispatchedBy string // Agent ID that dispatched this work (for completion notification) 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. // ParseAttachmentFields extracts attachment fields from an issue's description.
@@ -65,6 +66,9 @@ func ParseAttachmentFields(issue *Issue) *AttachmentFields {
case "dispatched_by", "dispatched-by", "dispatchedby": case "dispatched_by", "dispatched-by", "dispatchedby":
fields.DispatchedBy = value fields.DispatchedBy = value
hasFields = true 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 != "" { if fields.DispatchedBy != "" {
lines = append(lines, "dispatched_by: "+fields.DispatchedBy) lines = append(lines, "dispatched_by: "+fields.DispatchedBy)
} }
if fields.NoMerge {
lines = append(lines, "no_merge: true")
}
return strings.Join(lines, "\n") return strings.Join(lines, "\n")
} }
@@ -117,6 +124,9 @@ func SetAttachmentFields(issue *Issue, fields *AttachmentFields) string {
"dispatched_by": true, "dispatched_by": true,
"dispatched-by": true, "dispatched-by": true,
"dispatchedby": true, "dispatchedby": true,
"no_merge": true,
"no-merge": true,
"nomerge": true,
} }
// Collect non-attachment lines from existing description // Collect non-attachment lines from existing description

View File

@@ -310,6 +310,38 @@ func runDone(cmd *cobra.Command, args []string) error {
// Initialize beads // Initialize beads
bd := beads.New(beads.ResolveBeadsDir(cwd)) 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) // Determine target branch (auto-detect integration branch if applicable)
target := defaultBranch target := defaultBranch
autoTarget, err := detectIntegrationBranch(bd, g, issueID) autoTarget, err := detectIntegrationBranch(bd, g, issueID)

View File

@@ -96,6 +96,7 @@ var (
slingAccount string // --account: Claude Code account handle to use slingAccount string // --account: Claude Code account handle to use
slingAgent string // --agent: override runtime agent for this sling/spawn slingAgent string // --agent: override runtime agent for this sling/spawn
slingNoConvoy bool // --no-convoy: skip auto-convoy creation slingNoConvoy bool // --no-convoy: skip auto-convoy creation
slingNoMerge bool // --no-merge: skip merge queue on completion (for upstream PRs/human review)
) )
func init() { 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().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(&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(&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) 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. // Record the attached molecule in the BASE bead's description.
// This field points to the wisp (compound root) and enables: // This field points to the wisp (compound root) and enables:
// - gt hook/gt prime: follow attached_molecule to show molecule steps // - gt hook/gt prime: follow attached_molecule to show molecule steps

View File

@@ -235,6 +235,53 @@ func storeAttachedMoleculeInBead(beadID, moleculeID string) error {
return nil 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. // injectStartPrompt sends a prompt to the target pane to start working.
// Uses the reliable nudge pattern: literal mode + 500ms debounce + separate Enter. // Uses the reliable nudge pattern: literal mode + 500ms debounce + separate Enter.
func injectStartPrompt(pane, beadID, subject, args string) error { func injectStartPrompt(pane, beadID, subject, args string) error {

View File

@@ -1034,3 +1034,107 @@ exit /b 0
"Log output:\n%s\nAttached log:\n%s", string(logBytes), attachedLog) "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))
}
}