From 50cdd638cb410641862fd3d528d0ac51afd2e416 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Fri, 19 Dec 2025 01:46:04 -0800 Subject: [PATCH] feat(mq): add gt mq integration create command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement 'gt mq integration create ' command to create integration branches for batch work on epics. The command: 1. Verifies the epic exists in beads 2. Creates branch integration/ from origin/main 3. Pushes the branch to origin 4. Stores integration branch info in the epic's metadata Also adds helper methods to git package: - CreateBranchFrom: create branch from specific ref - BranchExists: check if local branch exists - RemoteBranchExists: check if branch exists on remote Future MRs for the epic's children can target the integration branch with: gt mq submit --epic 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/cmd/mq.go | 172 ++++++++++++++++++++++++++++++++++++++++++++ internal/git/git.go | 33 +++++++++ 2 files changed, 205 insertions(+) diff --git a/internal/cmd/mq.go b/internal/cmd/mq.go index 301af680..230b6085 100644 --- a/internal/cmd/mq.go +++ b/internal/cmd/mq.go @@ -144,6 +144,42 @@ Example: RunE: runMqStatus, } +var mqIntegrationCmd = &cobra.Command{ + Use: "integration", + Short: "Manage integration branches for epics", + Long: `Manage integration branches for batch work on epics. + +Integration branches allow multiple MRs for an epic to target a shared +branch instead of main. After all epic work is complete, the integration +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) + status Show integration branch status (not yet implemented)`, +} + +var mqIntegrationCreateCmd = &cobra.Command{ + Use: "create ", + Short: "Create an integration branch for an epic", + Long: `Create an integration branch for batch work on an epic. + +Creates a branch named integration/ from main and pushes it +to origin. Future MRs for this epic's children can target this branch. + +Actions: + 1. Verify epic exists + 2. Create branch integration/ from main + 3. Push to origin + 4. Store integration branch info in epic metadata + +Example: + gt mq integration create gt-auth-epic + # Creates integration/gt-auth-epic from main`, + Args: cobra.ExactArgs(1), + RunE: runMqIntegrationCreate, +} + func init() { // Submit flags mqSubmitCmd.Flags().StringVar(&mqSubmitBranch, "branch", "", "Source branch (default: current branch)") @@ -176,6 +212,10 @@ func init() { mqCmd.AddCommand(mqRejectCmd) mqCmd.AddCommand(mqStatusCmd) + // Integration branch subcommands + mqIntegrationCmd.AddCommand(mqIntegrationCreateCmd) + mqCmd.AddCommand(mqIntegrationCmd) + rootCmd.AddCommand(mqCmd) } @@ -970,3 +1010,135 @@ func getDescriptionWithoutMRFields(description string) string { result = strings.TrimSpace(result) return result } + +// runMqIntegrationCreate creates an integration branch for an epic. +func runMqIntegrationCreate(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) + } + + // Verify it's actually an epic + 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) + + // Check if integration branch already exists locally + exists, err := g.BranchExists(branchName) + if err != nil { + return fmt.Errorf("checking branch existence: %w", err) + } + if exists { + return fmt.Errorf("integration branch '%s' already exists locally", branchName) + } + + // Check if branch exists on remote + remoteExists, err := g.RemoteBranchExists("origin", branchName) + if err != nil { + // Log warning but continue - remote check isn't critical + fmt.Printf(" %s\n", style.Dim.Render("(could not check remote, continuing)")) + } + if remoteExists { + return fmt.Errorf("integration branch '%s' already exists on origin", branchName) + } + + // Ensure we have latest main + fmt.Printf("Fetching latest from origin...\n") + if err := g.Fetch("origin"); err != nil { + return fmt.Errorf("fetching from origin: %w", err) + } + + // 2. Create branch from origin/main + fmt.Printf("Creating branch '%s' from main...\n", branchName) + if err := g.CreateBranchFrom(branchName, "origin/main"); err != nil { + return fmt.Errorf("creating branch: %w", err) + } + + // 3. Push to origin + fmt.Printf("Pushing to origin...\n") + if err := g.Push("origin", branchName, false); err != nil { + // Clean up local branch on push failure + _ = g.DeleteBranch(branchName, true) + return fmt.Errorf("pushing to origin: %w", err) + } + + // 4. Store integration branch info in epic metadata + // Update the epic's description to include the integration branch info + newDesc := addIntegrationBranchField(epic.Description, branchName) + if newDesc != epic.Description { + if err := bd.Update(epicID, beads.UpdateOptions{Description: &newDesc}); err != nil { + // Non-fatal - branch was created, just metadata update failed + fmt.Printf(" %s\n", style.Dim.Render("(warning: could not update epic metadata)")) + } + } + + // Success output + fmt.Printf("\n%s Created integration branch\n", style.Bold.Render("✓")) + fmt.Printf(" Epic: %s\n", epicID) + fmt.Printf(" Branch: %s\n", branchName) + fmt.Printf(" From: main\n") + fmt.Printf("\n Future MRs for this epic's children can target:\n") + fmt.Printf(" gt mq submit --epic %s\n", epicID) + + return nil +} + +// addIntegrationBranchField adds or updates the integration_branch field in a description. +func addIntegrationBranchField(description, branchName string) string { + fieldLine := "integration_branch: " + branchName + + // If description is empty, just return the field + if description == "" { + return fieldLine + } + + // Check if integration_branch field already exists + lines := strings.Split(description, "\n") + var newLines []string + found := false + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(strings.ToLower(trimmed), "integration_branch:") { + // Replace existing field + newLines = append(newLines, fieldLine) + found = true + } else { + newLines = append(newLines, line) + } + } + + if !found { + // Add field at the beginning + newLines = append([]string{fieldLine}, newLines...) + } + + return strings.Join(newLines, "\n") +} diff --git a/internal/git/git.go b/internal/git/git.go index 68123dd3..52791bf5 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -314,6 +314,39 @@ func (g *Git) CreateBranch(name string) error { return err } +// CreateBranchFrom creates a new branch from a specific ref. +func (g *Git) CreateBranchFrom(name, ref string) error { + _, err := g.run("branch", name, ref) + return err +} + +// BranchExists checks if a branch exists locally. +func (g *Git) BranchExists(name string) (bool, error) { + _, err := g.run("show-ref", "--verify", "--quiet", "refs/heads/"+name) + if err != nil { + // Exit code 1 means branch doesn't exist + if strings.Contains(err.Error(), "exit status 1") { + return false, nil + } + return false, err + } + return true, nil +} + +// RemoteBranchExists checks if a branch exists on the remote. +func (g *Git) RemoteBranchExists(remote, branch string) (bool, error) { + _, err := g.run("ls-remote", "--heads", remote, branch) + if err != nil { + return false, err + } + // ls-remote returns empty if branch doesn't exist, need to check output + out, err := g.run("ls-remote", "--heads", remote, branch) + if err != nil { + return false, err + } + return out != "", nil +} + // DeleteBranch deletes a branch. func (g *Git) DeleteBranch(name string, force bool) error { flag := "-d"