From 7de003a18ba9d978de2159a3c3004f8f513c7e6f Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Fri, 19 Dec 2025 12:01:42 -0800 Subject: [PATCH] feat(mq): implement integration land command (gt-h5n.5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 'gt mq integration land ' command that: - Verifies all MRs targeting integration/ are merged - Verifies integration branch exists - Merges integration/ to main (--no-ff) - Runs tests on main (if configured) - Pushes to origin - Deletes integration branch (local and remote) - Updates epic status to closed Options: - --force: land even if some MRs still open - --skip-tests: skip test run - --dry-run: preview only Also adds: - MergeNoFF() and DeleteRemoteBranch() to git package - WorkDir() accessor for git.Git - Unit tests for mq helper functions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/cmd/mq.go | 329 +++++++++++++++++++++++++++++++++++++++- internal/cmd/mq_test.go | 214 ++++++++++++++++++++++++++ internal/git/git.go | 17 +++ 3 files changed, 559 insertions(+), 1 deletion(-) create mode 100644 internal/cmd/mq_test.go diff --git a/internal/cmd/mq.go b/internal/cmd/mq.go index 230b6085..725546fa 100644 --- a/internal/cmd/mq.go +++ b/internal/cmd/mq.go @@ -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 ", + 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/ are merged + 2. Verify integration branch exists + 3. Merge integration/ 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() +} diff --git a/internal/cmd/mq_test.go b/internal/cmd/mq_test.go new file mode 100644 index 00000000..2e530afc --- /dev/null +++ b/internal/cmd/mq_test.go @@ -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) + } + }) + } +} diff --git a/internal/git/git.go b/internal/git/git.go index 52791bf5..60eb6f9a 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -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)