From 8738fd74a10f455aee57b5004f3d788d5360c0d4 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Fri, 19 Dec 2025 14:49:09 -0800 Subject: [PATCH] feat(mq): add gt mq integration land command (gt-h5n.5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the 'gt mq integration land ' command that merges an integration branch to main. Features: - Verifies all MRs targeting integration branch are merged (unless --force) - Merges with --no-ff for clear merge commit - Runs tests before push (unless --skip-tests) - Deletes integration branch (local and remote) - Closes epic with merge commit info - Rollback on test/push failure - Dry-run mode (--dry-run) Also adds to git package: - MergeNoFF: merge with --no-ff flag - DeleteRemoteBranch: delete branch on origin - Reset: reset HEAD with optional --hard 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/cmd/mq.go | 298 +++++++++++++++++++++++++++++++++++++++++++- internal/git/git.go | 24 ++++ 2 files changed, 321 insertions(+), 1 deletion(-) diff --git a/internal/cmd/mq.go b/internal/cmd/mq.go index 230b6085..91e8302f 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" @@ -155,7 +156,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 +181,36 @@ Example: RunE: runMqIntegrationCreate, } +// Integration land command flags +var ( + mqIntegrationLandForce bool + mqIntegrationLandSkipTests bool + mqIntegrationLandDryRun bool +) + +var mqIntegrationLandCmd = &cobra.Command{ + Use: "land ", + Short: "Merge integration branch to main", + Long: `Merge an integration branch for an epic to main. + +This command lands an entire epic's work atomically. It: + 1. Verifies all MRs targeting integration/ are merged + 2. Verifies integration branch exists + 3. Merges integration/ to main (--no-ff) + 4. Runs tests on main (configurable) + 5. Pushes to origin + 6. Deletes integration branch + 7. Updates epic status + +Examples: + gt mq integration land gt-auth-epic + gt mq integration land gt-auth-epic --force # Land even if open MRs + gt mq integration land gt-auth-epic --skip-tests # Skip test run + gt mq integration land gt-auth-epic --dry-run # Preview only`, + Args: cobra.ExactArgs(1), + RunE: runMqIntegrationLand, +} + func init() { // Submit flags mqSubmitCmd.Flags().StringVar(&mqSubmitBranch, "branch", "", "Source branch (default: current branch)") @@ -212,8 +243,14 @@ func init() { mqCmd.AddCommand(mqRejectCmd) mqCmd.AddCommand(mqStatusCmd) + // Integration land flags + mqIntegrationLandCmd.Flags().BoolVar(&mqIntegrationLandForce, "force", false, "Land even if some MRs are still open") + mqIntegrationLandCmd.Flags().BoolVar(&mqIntegrationLandSkipTests, "skip-tests", false, "Skip test run") + mqIntegrationLandCmd.Flags().BoolVar(&mqIntegrationLandDryRun, "dry-run", false, "Preview only, don't make changes") + // Integration branch subcommands mqIntegrationCmd.AddCommand(mqIntegrationCreateCmd) + mqIntegrationCmd.AddCommand(mqIntegrationLandCmd) mqCmd.AddCommand(mqIntegrationCmd) rootCmd.AddCommand(mqCmd) @@ -1142,3 +1179,262 @@ 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 for the rig + bd := beads.New(r.Path) + + // 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) + } + + // Build integration branch name + branchName := "integration/" + epicID + + // Initialize git for the rig + g := git.NewGit(r.Path) + + // 2. Verify integration branch exists + exists, err := g.RemoteBranchExists("origin", branchName) + if err != nil { + return fmt.Errorf("checking integration branch: %w", err) + } + if !exists { + return fmt.Errorf("integration branch '%s' does not exist on origin", branchName) + } + + // Fetch latest + fmt.Printf("Fetching latest from origin...\n") + if err := g.Fetch("origin"); err != nil { + return fmt.Errorf("fetching from origin: %w", err) + } + + // 3. Check for open MRs targeting this integration branch + openMRs, err := findOpenMRsForIntegration(bd, branchName) + if err != nil { + return fmt.Errorf("checking open MRs: %w", err) + } + + if len(openMRs) > 0 && !mqIntegrationLandForce { + fmt.Printf("%s Cannot land: %d open MR(s) targeting %s\n\n", + style.Bold.Render("⚠"), len(openMRs), branchName) + for _, mr := range openMRs { + fields := beads.ParseMRFields(mr) + worker := "" + if fields != nil { + worker = fields.Worker + } + fmt.Printf(" - %s: %s (worker: %s)\n", mr.ID, mr.Title, worker) + } + fmt.Printf("\nUse --force to land anyway.\n") + return nil + } + + if len(openMRs) > 0 && mqIntegrationLandForce { + fmt.Printf("%s Forcing land with %d open MR(s)\n\n", + style.Dim.Render("⚠"), len(openMRs)) + } + + // Dry run mode - just show what would happen + if mqIntegrationLandDryRun { + fmt.Printf("\n%s Dry run - would perform:\n", style.Bold.Render("📋")) + fmt.Printf(" 1. Checkout main\n") + fmt.Printf(" 2. Merge %s into main (--no-ff)\n", branchName) + if !mqIntegrationLandSkipTests { + fmt.Printf(" 3. Run tests\n") + } + fmt.Printf(" 4. Push to origin\n") + fmt.Printf(" 5. Delete branch %s (local and remote)\n", branchName) + fmt.Printf(" 6. Close epic %s\n", epicID) + return nil + } + + // Save current branch to restore on failure + origBranch, err := g.CurrentBranch() + if err != nil { + return fmt.Errorf("getting current branch: %w", err) + } + + // 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 has uncommitted changes; commit or stash first") + } + + // 4. Checkout main + fmt.Printf("Checking out main...\n") + if err := g.Checkout("main"); err != nil { + return fmt.Errorf("checkout main: %w", err) + } + + // Pull latest main + fmt.Printf("Pulling latest main...\n") + if err := g.Pull("origin", "main"); err != nil { + _ = g.Checkout(origBranch) + return fmt.Errorf("pulling main: %w", err) + } + + // 5. Merge integration branch to main with --no-ff + mergeMsg := fmt.Sprintf("Merge %s: Land epic %s\n\nMerges all work from integration branch.", branchName, epicID) + fmt.Printf("Merging %s to main...\n", branchName) + if err := g.MergeNoFF("origin/"+branchName, mergeMsg); err != nil { + _ = g.AbortMerge() + _ = g.Checkout(origBranch) + if err == git.ErrMergeConflict { + return fmt.Errorf("merge conflict; resolve manually or rebase the integration branch") + } + return fmt.Errorf("merge failed: %w", err) + } + + // Get the merge commit SHA + mergeCommit, _ := g.Rev("HEAD") + + // 6. Run tests (unless skipped) + if !mqIntegrationLandSkipTests { + fmt.Printf("Running tests...\n") + if err := runRigTests(r.Path); err != nil { + // Rollback the merge + fmt.Printf("%s Tests failed, rolling back merge\n", style.Bold.Render("✗")) + _ = g.Reset("HEAD~1", true) + _ = g.Checkout(origBranch) + return fmt.Errorf("tests failed: %w", err) + } + fmt.Printf("%s Tests passed\n", style.Bold.Render("✓")) + } + + // 7. Push to origin + fmt.Printf("Pushing to origin...\n") + if err := g.Push("origin", "main", false); err != nil { + // Rollback the merge + fmt.Printf("%s Push failed, rolling back merge\n", style.Bold.Render("✗")) + _ = g.Reset("HEAD~1", true) + _ = g.Checkout(origBranch) + return fmt.Errorf("push failed: %w", err) + } + + // 8. Delete integration branch (local and remote) + fmt.Printf("Deleting integration branch...\n") + + // Delete local branch if it exists + localExists, _ := g.BranchExists(branchName) + if localExists { + if err := g.DeleteBranch(branchName, true); err != nil { + fmt.Printf(" %s\n", style.Dim.Render("(warning: could not delete local branch)")) + } + } + + // Delete remote branch + if err := g.DeleteRemoteBranch("origin", branchName); err != nil { + fmt.Printf(" %s\n", style.Dim.Render("(warning: could not delete remote branch)")) + } + + // 9. Update epic status - close it with merge info + if err := bd.CloseWithReason(epicID, fmt.Sprintf("Landed to main via integration branch. Merge commit: %s", mergeCommit)); err != nil { + // Fallback to simple close + if err := bd.Close(epicID); err != nil { + fmt.Printf(" %s\n", style.Dim.Render("(warning: could not close epic)")) + } + } + + // Restore original branch (or stay on main) + if origBranch != branchName && origBranch != "main" { + _ = g.Checkout(origBranch) + } + + // Success output + fmt.Printf("\n%s Landed epic to main\n", style.Bold.Render("✓")) + fmt.Printf(" Epic: %s\n", epicID) + fmt.Printf(" Branch: %s\n", branchName) + fmt.Printf(" Merge commit: %s\n", mergeCommit[:12]) + + return nil +} + +// findOpenMRsForIntegration finds open MRs targeting the given integration branch. +func findOpenMRsForIntegration(bd *beads.Beads, integrationBranch string) ([]*beads.Issue, error) { + // List all open merge-requests + opts := beads.ListOptions{ + Type: "merge-request", + Status: "open", + } + issues, err := bd.List(opts) + if err != nil { + return nil, err + } + + // Filter to those targeting this integration branch + var matching []*beads.Issue + for _, issue := range issues { + fields := beads.ParseMRFields(issue) + if fields != nil && fields.Target == integrationBranch { + matching = append(matching, issue) + } + } + + return matching, nil +} + +// runRigTests runs tests for the rig. Returns an error if tests fail. +func runRigTests(rigPath string) error { + // Try to detect test command from rig config or use defaults + // For now, use common test commands based on what's present + + // Check for go.mod (Go project) + goModPath := filepath.Join(rigPath, "go.mod") + if _, err := os.Stat(goModPath); err == nil { + return runTestCommand(rigPath, "go", "test", "./...") + } + + // Check for package.json (Node.js project) + packagePath := filepath.Join(rigPath, "package.json") + if _, err := os.Stat(packagePath); err == nil { + return runTestCommand(rigPath, "npm", "test") + } + + // Check for Makefile with test target + makefilePath := filepath.Join(rigPath, "Makefile") + if _, err := os.Stat(makefilePath); err == nil { + return runTestCommand(rigPath, "make", "test") + } + + // No test runner found, skip tests + fmt.Printf(" %s\n", style.Dim.Render("(no test runner detected, skipping tests)")) + return nil +} + +// runTestCommand runs a test command in the given directory. +func runTestCommand(dir string, name string, args ...string) error { + cmd := exec.Command(name, args...) + cmd.Dir = dir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + return cmd.Run() +} diff --git a/internal/git/git.go b/internal/git/git.go index 52791bf5..a17c3e64 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -201,6 +201,30 @@ func (g *Git) Merge(branch string) error { return err } +// MergeNoFF merges the given branch with --no-ff (no fast-forward). +// This always creates a merge commit even if fast-forward is possible. +func (g *Git) MergeNoFF(branch, message string) error { + _, err := g.run("merge", "--no-ff", "-m", message, branch) + return err +} + +// DeleteRemoteBranch deletes a branch from the remote. +func (g *Git) DeleteRemoteBranch(remote, branch string) error { + _, err := g.run("push", remote, "--delete", branch) + return err +} + +// Reset resets the current branch to the given ref. +// If hard is true, uses --hard (discards working tree changes). +func (g *Git) Reset(ref string, hard bool) error { + args := []string{"reset", ref} + if hard { + args = []string{"reset", "--hard", ref} + } + _, err := g.run(args...) + return err +} + // Rebase rebases the current branch onto the given ref. func (g *Git) Rebase(onto string) error { _, err := g.run("rebase", onto)