Merge polecat/Bullet: mq integration land (gt-h5n.5)

This commit is contained in:
Steve Yegge
2025-12-19 12:06:32 -08:00
3 changed files with 559 additions and 1 deletions

View File

@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
@@ -43,6 +44,11 @@ var (
// Status command flags
mqStatusJSON bool
// Integration land flags
mqIntegrationLandForce bool
mqIntegrationLandSkipTests bool
mqIntegrationLandDryRun bool
)
var mqCmd = &cobra.Command{
@@ -155,7 +161,7 @@ branch is landed to main as a single atomic unit.
Commands:
create Create an integration branch for an epic
land Merge integration branch to main (not yet implemented)
land Merge integration branch to main
status Show integration branch status (not yet implemented)`,
}
@@ -180,6 +186,36 @@ Example:
RunE: runMqIntegrationCreate,
}
var mqIntegrationLandCmd = &cobra.Command{
Use: "land <epic-id>",
Short: "Merge integration branch to main",
Long: `Merge an epic's integration branch to main.
Lands all work for an epic by merging its integration branch to main
as a single atomic merge commit.
Actions:
1. Verify all MRs targeting integration/<epic> are merged
2. Verify integration branch exists
3. Merge integration/<epic> to main (--no-ff)
4. Run tests on main
5. Push to origin
6. Delete integration branch
7. Update epic status
Options:
--force Land even if some MRs still open
--skip-tests Skip test run
--dry-run Preview only, make no changes
Examples:
gt mq integration land gt-auth-epic
gt mq integration land gt-auth-epic --dry-run
gt mq integration land gt-auth-epic --force --skip-tests`,
Args: cobra.ExactArgs(1),
RunE: runMqIntegrationLand,
}
func init() {
// Submit flags
mqSubmitCmd.Flags().StringVar(&mqSubmitBranch, "branch", "", "Source branch (default: current branch)")
@@ -214,6 +250,13 @@ func init() {
// Integration branch subcommands
mqIntegrationCmd.AddCommand(mqIntegrationCreateCmd)
// Integration land flags
mqIntegrationLandCmd.Flags().BoolVar(&mqIntegrationLandForce, "force", false, "Land even if some MRs still open")
mqIntegrationLandCmd.Flags().BoolVar(&mqIntegrationLandSkipTests, "skip-tests", false, "Skip test run")
mqIntegrationLandCmd.Flags().BoolVar(&mqIntegrationLandDryRun, "dry-run", false, "Preview only, make no changes")
mqIntegrationCmd.AddCommand(mqIntegrationLandCmd)
mqCmd.AddCommand(mqIntegrationCmd)
rootCmd.AddCommand(mqCmd)
@@ -1142,3 +1185,287 @@ func addIntegrationBranchField(description, branchName string) string {
return strings.Join(newLines, "\n")
}
// runMqIntegrationLand merges an integration branch to main.
func runMqIntegrationLand(cmd *cobra.Command, args []string) error {
epicID := args[0]
// Find workspace
townRoot, err := workspace.FindFromCwdOrError()
if err != nil {
return fmt.Errorf("not in a Gas Town workspace: %w", err)
}
// Find current rig
_, r, err := findCurrentRig(townRoot)
if err != nil {
return err
}
// Initialize beads and git for the rig
bd := beads.New(r.Path)
g := git.NewGit(r.Path)
// Build integration branch name
branchName := "integration/" + epicID
// Show what we're about to do
if mqIntegrationLandDryRun {
fmt.Printf("%s Dry run - no changes will be made\n\n", style.Bold.Render("🔍"))
}
// 1. Verify epic exists
epic, err := bd.Show(epicID)
if err != nil {
if err == beads.ErrNotFound {
return fmt.Errorf("epic '%s' not found", epicID)
}
return fmt.Errorf("fetching epic: %w", err)
}
if epic.Type != "epic" {
return fmt.Errorf("'%s' is a %s, not an epic", epicID, epic.Type)
}
fmt.Printf("Landing integration branch for epic: %s\n", epicID)
fmt.Printf(" Title: %s\n\n", epic.Title)
// 2. Verify integration branch exists
fmt.Printf("Checking integration branch...\n")
exists, err := g.BranchExists(branchName)
if err != nil {
return fmt.Errorf("checking branch existence: %w", err)
}
// Also check remote if local doesn't exist
if !exists {
remoteExists, err := g.RemoteBranchExists("origin", branchName)
if err != nil {
return fmt.Errorf("checking remote branch: %w", err)
}
if !remoteExists {
return fmt.Errorf("integration branch '%s' does not exist (locally or on origin)", branchName)
}
// Fetch and create local tracking branch
fmt.Printf("Fetching integration branch from origin...\n")
if err := g.FetchBranch("origin", branchName); err != nil {
return fmt.Errorf("fetching branch: %w", err)
}
}
fmt.Printf(" %s Branch exists\n", style.Bold.Render("✓"))
// 3. Verify all MRs targeting this integration branch are merged
fmt.Printf("Checking open merge requests...\n")
openMRs, err := findOpenMRsForIntegration(bd, branchName)
if err != nil {
return fmt.Errorf("checking open MRs: %w", err)
}
if len(openMRs) > 0 {
fmt.Printf("\n %s Open merge requests targeting %s:\n", style.Bold.Render("⚠"), branchName)
for _, mr := range openMRs {
fmt.Printf(" - %s: %s\n", mr.ID, mr.Title)
}
fmt.Println()
if !mqIntegrationLandForce {
return fmt.Errorf("cannot land: %d open MRs (use --force to override)", len(openMRs))
}
fmt.Printf(" %s Proceeding anyway (--force)\n", style.Dim.Render("⚠"))
} else {
fmt.Printf(" %s No open MRs targeting integration branch\n", style.Bold.Render("✓"))
}
// Dry run stops here
if mqIntegrationLandDryRun {
fmt.Printf("\n%s Dry run complete. Would perform:\n", style.Bold.Render("🔍"))
fmt.Printf(" 1. Merge %s to main (--no-ff)\n", branchName)
if !mqIntegrationLandSkipTests {
fmt.Printf(" 2. Run tests on main\n")
}
fmt.Printf(" 3. Push main to origin\n")
fmt.Printf(" 4. Delete integration branch (local and remote)\n")
fmt.Printf(" 5. Update epic status to closed\n")
return nil
}
// Ensure working directory is clean
status, err := g.Status()
if err != nil {
return fmt.Errorf("checking git status: %w", err)
}
if !status.Clean {
return fmt.Errorf("working directory is not clean; please commit or stash changes")
}
// Fetch latest
fmt.Printf("Fetching latest from origin...\n")
if err := g.Fetch("origin"); err != nil {
return fmt.Errorf("fetching from origin: %w", err)
}
// 4. Checkout main and merge integration branch
fmt.Printf("Checking out main...\n")
if err := g.Checkout("main"); err != nil {
return fmt.Errorf("checking out main: %w", err)
}
// Pull latest main
if err := g.Pull("origin", "main"); err != nil {
// Non-fatal if pull fails (e.g., first time)
fmt.Printf(" %s\n", style.Dim.Render("(pull from origin/main skipped)"))
}
// Merge with --no-ff
fmt.Printf("Merging %s to main...\n", branchName)
mergeMsg := fmt.Sprintf("Merge %s: %s\n\nEpic: %s", branchName, epic.Title, epicID)
if err := g.MergeNoFF("origin/"+branchName, mergeMsg); err != nil {
// Abort merge on failure
_ = g.AbortMerge()
return fmt.Errorf("merge failed: %w", err)
}
fmt.Printf(" %s Merged successfully\n", style.Bold.Render("✓"))
// 5. Run tests (if configured and not skipped)
if !mqIntegrationLandSkipTests {
testCmd := getTestCommand(r.Path)
if testCmd != "" {
fmt.Printf("Running tests: %s\n", testCmd)
if err := runTestCommand(r.Path, testCmd); err != nil {
// Tests failed - reset main
fmt.Printf(" %s Tests failed, resetting main...\n", style.Bold.Render("✗"))
_ = g.Checkout("main")
resetErr := resetHard(g, "HEAD~1")
if resetErr != nil {
return fmt.Errorf("tests failed and could not reset: %w (test error: %v)", resetErr, err)
}
return fmt.Errorf("tests failed: %w", err)
}
fmt.Printf(" %s Tests passed\n", style.Bold.Render("✓"))
} else {
fmt.Printf(" %s\n", style.Dim.Render("(no test command configured)"))
}
} else {
fmt.Printf(" %s\n", style.Dim.Render("(tests skipped)"))
}
// 6. Push to origin
fmt.Printf("Pushing main to origin...\n")
if err := g.Push("origin", "main", false); err != nil {
// Reset on push failure
resetErr := resetHard(g, "HEAD~1")
if resetErr != nil {
return fmt.Errorf("push failed and could not reset: %w (push error: %v)", resetErr, err)
}
return fmt.Errorf("push failed: %w", err)
}
fmt.Printf(" %s Pushed to origin\n", style.Bold.Render("✓"))
// 7. Delete integration branch
fmt.Printf("Deleting integration branch...\n")
// Delete remote first
if err := g.DeleteRemoteBranch("origin", branchName); err != nil {
fmt.Printf(" %s\n", style.Dim.Render(fmt.Sprintf("(could not delete remote branch: %v)", err)))
} else {
fmt.Printf(" %s Deleted from origin\n", style.Bold.Render("✓"))
}
// Delete local
if err := g.DeleteBranch(branchName, true); err != nil {
fmt.Printf(" %s\n", style.Dim.Render(fmt.Sprintf("(could not delete local branch: %v)", err)))
} else {
fmt.Printf(" %s Deleted locally\n", style.Bold.Render("✓"))
}
// 8. Update epic status
fmt.Printf("Updating epic status...\n")
if err := bd.Close(epicID); err != nil {
fmt.Printf(" %s\n", style.Dim.Render(fmt.Sprintf("(could not close epic: %v)", err)))
} else {
fmt.Printf(" %s Epic closed\n", style.Bold.Render("✓"))
}
// Success output
fmt.Printf("\n%s Successfully landed integration branch\n", style.Bold.Render("✓"))
fmt.Printf(" Epic: %s\n", epicID)
fmt.Printf(" Branch: %s → main\n", branchName)
return nil
}
// findOpenMRsForIntegration finds all open merge requests targeting an integration branch.
func findOpenMRsForIntegration(bd *beads.Beads, targetBranch string) ([]*beads.Issue, error) {
// List all open merge requests
opts := beads.ListOptions{
Type: "merge-request",
Status: "open",
}
allMRs, err := bd.List(opts)
if err != nil {
return nil, err
}
// Filter to those targeting this integration branch
var openMRs []*beads.Issue
for _, mr := range allMRs {
fields := beads.ParseMRFields(mr)
if fields != nil && fields.Target == targetBranch {
openMRs = append(openMRs, mr)
}
}
return openMRs, nil
}
// getTestCommand returns the test command from rig config.
func getTestCommand(rigPath string) string {
configPath := filepath.Join(rigPath, "config.json")
data, err := os.ReadFile(configPath)
if err != nil {
// Try .gastown/config.json as fallback
configPath = filepath.Join(rigPath, ".gastown", "config.json")
data, err = os.ReadFile(configPath)
if err != nil {
return ""
}
}
var rawConfig struct {
MergeQueue struct {
TestCommand string `json:"test_command"`
} `json:"merge_queue"`
TestCommand string `json:"test_command"` // Legacy fallback
}
if err := json.Unmarshal(data, &rawConfig); err != nil {
return ""
}
if rawConfig.MergeQueue.TestCommand != "" {
return rawConfig.MergeQueue.TestCommand
}
return rawConfig.TestCommand
}
// runTestCommand executes a test command in the given directory.
func runTestCommand(workDir, testCmd string) error {
parts := strings.Fields(testCmd)
if len(parts) == 0 {
return nil
}
cmd := exec.Command(parts[0], parts[1:]...)
cmd.Dir = workDir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
// resetHard performs a git reset --hard to the given ref.
func resetHard(g *git.Git, ref string) error {
// We need to use the git package, but it doesn't have a Reset method
// For now, use the internal run method via Checkout workaround
// This is a bit of a hack but works for now
cmd := exec.Command("git", "reset", "--hard", ref)
cmd.Dir = g.WorkDir()
return cmd.Run()
}

214
internal/cmd/mq_test.go Normal file
View File

@@ -0,0 +1,214 @@
package cmd
import (
"testing"
)
func TestAddIntegrationBranchField(t *testing.T) {
tests := []struct {
name string
description string
branchName string
want string
}{
{
name: "empty description",
description: "",
branchName: "integration/gt-epic",
want: "integration_branch: integration/gt-epic",
},
{
name: "simple description",
description: "Epic for authentication",
branchName: "integration/gt-auth",
want: "integration_branch: integration/gt-auth\nEpic for authentication",
},
{
name: "existing integration_branch field",
description: "integration_branch: integration/old-epic\nSome description",
branchName: "integration/new-epic",
want: "integration_branch: integration/new-epic\nSome description",
},
{
name: "multiline description",
description: "Line 1\nLine 2\nLine 3",
branchName: "integration/gt-xyz",
want: "integration_branch: integration/gt-xyz\nLine 1\nLine 2\nLine 3",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := addIntegrationBranchField(tt.description, tt.branchName)
if got != tt.want {
t.Errorf("addIntegrationBranchField() = %q, want %q", got, tt.want)
}
})
}
}
func TestParseBranchName(t *testing.T) {
tests := []struct {
name string
branch string
wantIssue string
wantWorker string
}{
{
name: "polecat branch format",
branch: "polecat/Nux/gt-xyz",
wantIssue: "gt-xyz",
wantWorker: "Nux",
},
{
name: "polecat branch with subtask",
branch: "polecat/Worker/gt-abc.1",
wantIssue: "gt-abc.1",
wantWorker: "Worker",
},
{
name: "simple issue branch",
branch: "gt-xyz",
wantIssue: "gt-xyz",
wantWorker: "",
},
{
name: "feature branch with issue",
branch: "feature/gt-abc-impl",
wantIssue: "gt-abc",
wantWorker: "",
},
{
name: "no issue pattern",
branch: "main",
wantIssue: "",
wantWorker: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
info := parseBranchName(tt.branch)
if info.Issue != tt.wantIssue {
t.Errorf("parseBranchName() Issue = %q, want %q", info.Issue, tt.wantIssue)
}
if info.Worker != tt.wantWorker {
t.Errorf("parseBranchName() Worker = %q, want %q", info.Worker, tt.wantWorker)
}
})
}
}
func TestFormatMRAge(t *testing.T) {
tests := []struct {
name string
createdAt string
wantOk bool // just check it doesn't panic/error
}{
{
name: "RFC3339 format",
createdAt: "2025-01-01T12:00:00Z",
wantOk: true,
},
{
name: "alternative format",
createdAt: "2025-01-01T12:00:00",
wantOk: true,
},
{
name: "invalid format",
createdAt: "not-a-date",
wantOk: true, // returns "?" for invalid
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := formatMRAge(tt.createdAt)
if tt.wantOk && result == "" {
t.Errorf("formatMRAge() returned empty for %s", tt.createdAt)
}
})
}
}
func TestGetDescriptionWithoutMRFields(t *testing.T) {
tests := []struct {
name string
description string
want string
}{
{
name: "empty description",
description: "",
want: "",
},
{
name: "only MR fields",
description: "branch: polecat/Nux/gt-xyz\ntarget: main\nworker: Nux",
want: "",
},
{
name: "mixed content",
description: "branch: polecat/Nux/gt-xyz\nSome custom notes\ntarget: main",
want: "Some custom notes",
},
{
name: "no MR fields",
description: "Just a regular description\nWith multiple lines",
want: "Just a regular description\nWith multiple lines",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := getDescriptionWithoutMRFields(tt.description)
if got != tt.want {
t.Errorf("getDescriptionWithoutMRFields() = %q, want %q", got, tt.want)
}
})
}
}
func TestTruncateString(t *testing.T) {
tests := []struct {
name string
s string
maxLen int
want string
}{
{
name: "short string",
s: "hello",
maxLen: 10,
want: "hello",
},
{
name: "exact length",
s: "hello",
maxLen: 5,
want: "hello",
},
{
name: "needs truncation",
s: "hello world",
maxLen: 8,
want: "hello...",
},
{
name: "very short max",
s: "hello",
maxLen: 3,
want: "hel",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := truncateString(tt.s, tt.maxLen)
if got != tt.want {
t.Errorf("truncateString() = %q, want %q", got, tt.want)
}
})
}
}

View File

@@ -27,6 +27,11 @@ func NewGit(workDir string) *Git {
return &Git{workDir: workDir}
}
// WorkDir returns the working directory for this Git instance.
func (g *Git) WorkDir() string {
return g.workDir
}
// run executes a git command and returns stdout.
func (g *Git) run(args ...string) (string, error) {
cmd := exec.Command("git", args...)
@@ -201,6 +206,18 @@ func (g *Git) Merge(branch string) error {
return err
}
// MergeNoFF merges the given branch with --no-ff flag and a custom message.
func (g *Git) MergeNoFF(branch, message string) error {
_, err := g.run("merge", "--no-ff", "-m", message, branch)
return err
}
// DeleteRemoteBranch deletes a branch on the remote.
func (g *Git) DeleteRemoteBranch(remote, branch string) error {
_, err := g.run("push", remote, "--delete", branch)
return err
}
// Rebase rebases the current branch onto the given ref.
func (g *Git) Rebase(onto string) error {
_, err := g.run("rebase", onto)