feat(mq): add gt mq integration land command (gt-h5n.5)

Implements the 'gt mq integration land <epic>' 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 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-19 14:49:09 -08:00
parent 9a55153450
commit 8738fd74a1
2 changed files with 321 additions and 1 deletions

View File

@@ -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 <epic-id>",
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/<epic> are merged
2. Verifies integration branch exists
3. Merges integration/<epic> 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()
}

View File

@@ -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)