Enterprise teams can now customize integration branch names to match
their conventions (e.g., username/TICKET-123/feature-name).
- Add integration_branch_template to MergeQueueConfig
- Add --branch CLI override for gt mq integration create
- Support {epic}, {prefix}, {user} template variables
- Validate branch names for git-safe characters
- Store actual branch name in epic metadata at create time
- Read stored branch name in land/status (fallback for old epics)
Also fixes unrelated build error in polecat/manager.go (polecatPath
variable was undefined).
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
754 lines
23 KiB
Go
754 lines
23 KiB
Go
package cmd
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/spf13/cobra"
|
|
"github.com/steveyegge/gastown/internal/beads"
|
|
"github.com/steveyegge/gastown/internal/config"
|
|
"github.com/steveyegge/gastown/internal/git"
|
|
"github.com/steveyegge/gastown/internal/style"
|
|
"github.com/steveyegge/gastown/internal/workspace"
|
|
)
|
|
|
|
// Integration branch template constants
|
|
const defaultIntegrationBranchTemplate = "integration/{epic}"
|
|
|
|
// invalidBranchCharsRegex matches characters that are invalid in git branch names.
|
|
// Git branch names cannot contain: ~ ^ : \ space, .., @{, or end with .lock
|
|
var invalidBranchCharsRegex = regexp.MustCompile(`[~^:\s\\]|\.\.|\.\.|@\{`)
|
|
|
|
// buildIntegrationBranchName expands an integration branch template with variables.
|
|
// Variables supported:
|
|
// - {epic}: Full epic ID (e.g., "RA-123")
|
|
// - {prefix}: Epic prefix before first hyphen (e.g., "RA")
|
|
// - {user}: Git user.name (e.g., "klauern")
|
|
//
|
|
// If template is empty, uses defaultIntegrationBranchTemplate.
|
|
func buildIntegrationBranchName(template, epicID string) string {
|
|
if template == "" {
|
|
template = defaultIntegrationBranchTemplate
|
|
}
|
|
|
|
result := template
|
|
result = strings.ReplaceAll(result, "{epic}", epicID)
|
|
result = strings.ReplaceAll(result, "{prefix}", extractEpicPrefix(epicID))
|
|
|
|
// Git user (optional - leaves placeholder if not available)
|
|
if user := getGitUserName(); user != "" {
|
|
result = strings.ReplaceAll(result, "{user}", user)
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// extractEpicPrefix extracts the prefix from an epic ID (before the first hyphen).
|
|
// Examples: "RA-123" -> "RA", "PROJ-456" -> "PROJ", "abc" -> "abc"
|
|
func extractEpicPrefix(epicID string) string {
|
|
if idx := strings.Index(epicID, "-"); idx > 0 {
|
|
return epicID[:idx]
|
|
}
|
|
return epicID
|
|
}
|
|
|
|
// getGitUserName returns the git user.name config value, or empty if not set.
|
|
func getGitUserName() string {
|
|
cmd := exec.Command("git", "config", "user.name")
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(string(out))
|
|
}
|
|
|
|
// validateBranchName checks if a branch name is valid for git.
|
|
// Returns an error if the branch name contains invalid characters.
|
|
func validateBranchName(branchName string) error {
|
|
if branchName == "" {
|
|
return fmt.Errorf("branch name cannot be empty")
|
|
}
|
|
|
|
// Check for invalid characters
|
|
if invalidBranchCharsRegex.MatchString(branchName) {
|
|
return fmt.Errorf("branch name %q contains invalid characters (~ ^ : \\ space, .., or @{)", branchName)
|
|
}
|
|
|
|
// Check for .lock suffix
|
|
if strings.HasSuffix(branchName, ".lock") {
|
|
return fmt.Errorf("branch name %q cannot end with .lock", branchName)
|
|
}
|
|
|
|
// Check for leading/trailing slashes or dots
|
|
if strings.HasPrefix(branchName, "/") || strings.HasSuffix(branchName, "/") {
|
|
return fmt.Errorf("branch name %q cannot start or end with /", branchName)
|
|
}
|
|
if strings.HasPrefix(branchName, ".") || strings.HasSuffix(branchName, ".") {
|
|
return fmt.Errorf("branch name %q cannot start or end with .", branchName)
|
|
}
|
|
|
|
// Check for consecutive slashes
|
|
if strings.Contains(branchName, "//") {
|
|
return fmt.Errorf("branch name %q cannot contain consecutive slashes", branchName)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// getIntegrationBranchField extracts the integration_branch field from an epic's description.
|
|
// Returns empty string if the field is not found.
|
|
func getIntegrationBranchField(description string) string {
|
|
if description == "" {
|
|
return ""
|
|
}
|
|
|
|
lines := strings.Split(description, "\n")
|
|
for _, line := range lines {
|
|
trimmed := strings.TrimSpace(line)
|
|
if strings.HasPrefix(strings.ToLower(trimmed), "integration_branch:") {
|
|
value := strings.TrimPrefix(trimmed, "integration_branch:")
|
|
value = strings.TrimPrefix(value, "Integration_branch:")
|
|
value = strings.TrimPrefix(value, "INTEGRATION_BRANCH:")
|
|
// Handle case variations
|
|
for _, prefix := range []string{"integration_branch:", "Integration_branch:", "INTEGRATION_BRANCH:"} {
|
|
if strings.HasPrefix(trimmed, prefix) {
|
|
value = strings.TrimPrefix(trimmed, prefix)
|
|
break
|
|
}
|
|
}
|
|
// Re-parse properly - the prefix removal above is messy
|
|
parts := strings.SplitN(trimmed, ":", 2)
|
|
if len(parts) == 2 {
|
|
return strings.TrimSpace(parts[1])
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// getIntegrationBranchTemplate returns the integration branch template to use.
|
|
// Priority: CLI flag > rig config > default
|
|
func getIntegrationBranchTemplate(rigPath, cliOverride string) string {
|
|
if cliOverride != "" {
|
|
return cliOverride
|
|
}
|
|
|
|
// Try to load rig settings
|
|
settingsPath := filepath.Join(rigPath, "settings", "config.json")
|
|
settings, err := config.LoadRigSettings(settingsPath)
|
|
if err != nil {
|
|
return defaultIntegrationBranchTemplate
|
|
}
|
|
|
|
if settings.MergeQueue != nil && settings.MergeQueue.IntegrationBranchTemplate != "" {
|
|
return settings.MergeQueue.IntegrationBranchTemplate
|
|
}
|
|
|
|
return defaultIntegrationBranchTemplate
|
|
}
|
|
|
|
// IntegrationStatusOutput is the JSON output structure for integration status.
|
|
type IntegrationStatusOutput struct {
|
|
Epic string `json:"epic"`
|
|
Branch string `json:"branch"`
|
|
Created string `json:"created,omitempty"`
|
|
AheadOfMain int `json:"ahead_of_main"`
|
|
MergedMRs []IntegrationStatusMRSummary `json:"merged_mrs"`
|
|
PendingMRs []IntegrationStatusMRSummary `json:"pending_mrs"`
|
|
}
|
|
|
|
// IntegrationStatusMRSummary represents a merge request in the integration status output.
|
|
type IntegrationStatusMRSummary struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
Status string `json:"status,omitempty"`
|
|
}
|
|
|
|
// 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 from template
|
|
template := getIntegrationBranchTemplate(r.Path, mqIntegrationCreateBranch)
|
|
branchName := buildIntegrationBranchName(template, epicID)
|
|
|
|
// Validate the branch name
|
|
if err := validateBranchName(branchName); err != nil {
|
|
return fmt.Errorf("invalid branch name: %w", err)
|
|
}
|
|
|
|
// 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 (best-effort cleanup)
|
|
_ = 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")
|
|
}
|
|
|
|
// 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)
|
|
|
|
// 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)
|
|
}
|
|
|
|
// Get integration branch name from epic metadata (stored at create time)
|
|
// Fall back to default template for backward compatibility with old epics
|
|
branchName := getIntegrationBranchField(epic.Description)
|
|
if branchName == "" {
|
|
branchName = buildIntegrationBranchName(defaultIntegrationBranchTemplate, epicID)
|
|
}
|
|
|
|
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 (best-effort cleanup)
|
|
_ = 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") // best-effort: need to be on main to reset
|
|
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
|
|
}
|
|
|
|
return filterMRsByTarget(allMRs, targetBranch), nil
|
|
}
|
|
|
|
// filterMRsByTarget filters merge requests to those targeting a specific branch.
|
|
func filterMRsByTarget(mrs []*beads.Issue, targetBranch string) []*beads.Issue {
|
|
var result []*beads.Issue
|
|
for _, mr := range mrs {
|
|
fields := beads.ParseMRFields(mr)
|
|
if fields != nil && fields.Target == targetBranch {
|
|
result = append(result, mr)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// getTestCommand returns the test command from rig settings.
|
|
func getTestCommand(rigPath string) string {
|
|
settingsPath := filepath.Join(rigPath, "settings", "config.json")
|
|
settings, err := config.LoadRigSettings(settingsPath)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
if settings.MergeQueue != nil && settings.MergeQueue.TestCommand != "" {
|
|
return settings.MergeQueue.TestCommand
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// 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()
|
|
}
|
|
|
|
// runMqIntegrationStatus shows the status of an integration branch for an epic.
|
|
func runMqIntegrationStatus(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)
|
|
|
|
// Fetch epic to get stored branch name
|
|
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)
|
|
}
|
|
|
|
// Get integration branch name from epic metadata (stored at create time)
|
|
// Fall back to default template for backward compatibility with old epics
|
|
branchName := getIntegrationBranchField(epic.Description)
|
|
if branchName == "" {
|
|
branchName = buildIntegrationBranchName(defaultIntegrationBranchTemplate, epicID)
|
|
}
|
|
|
|
// Initialize git for the rig
|
|
g := git.NewGit(r.Path)
|
|
|
|
// Fetch from origin to ensure we have latest refs
|
|
if err := g.Fetch("origin"); err != nil {
|
|
// Non-fatal, continue with local data
|
|
}
|
|
|
|
// Check if integration branch exists (locally or remotely)
|
|
localExists, _ := g.BranchExists(branchName)
|
|
remoteExists, _ := g.RemoteBranchExists("origin", branchName)
|
|
|
|
if !localExists && !remoteExists {
|
|
return fmt.Errorf("integration branch '%s' does not exist", branchName)
|
|
}
|
|
|
|
// Determine which ref to use for comparison
|
|
ref := branchName
|
|
if !localExists && remoteExists {
|
|
ref = "origin/" + branchName
|
|
}
|
|
|
|
// Get branch creation date
|
|
createdDate, err := g.BranchCreatedDate(ref)
|
|
if err != nil {
|
|
createdDate = "" // Non-fatal
|
|
}
|
|
|
|
// Get commits ahead of main
|
|
aheadCount, err := g.CommitsAhead("main", ref)
|
|
if err != nil {
|
|
aheadCount = 0 // Non-fatal
|
|
}
|
|
|
|
// Query for MRs targeting this integration branch (use resolved name)
|
|
targetBranch := branchName
|
|
|
|
// Get all merge-request issues
|
|
allMRs, err := bd.List(beads.ListOptions{
|
|
Type: "merge-request",
|
|
Status: "", // all statuses
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("querying merge requests: %w", err)
|
|
}
|
|
|
|
// Filter by target branch and separate into merged/pending
|
|
var mergedMRs, pendingMRs []*beads.Issue
|
|
for _, mr := range allMRs {
|
|
fields := beads.ParseMRFields(mr)
|
|
if fields == nil || fields.Target != targetBranch {
|
|
continue
|
|
}
|
|
|
|
if mr.Status == "closed" {
|
|
mergedMRs = append(mergedMRs, mr)
|
|
} else {
|
|
pendingMRs = append(pendingMRs, mr)
|
|
}
|
|
}
|
|
|
|
// Build output structure
|
|
output := IntegrationStatusOutput{
|
|
Epic: epicID,
|
|
Branch: branchName,
|
|
Created: createdDate,
|
|
AheadOfMain: aheadCount,
|
|
MergedMRs: make([]IntegrationStatusMRSummary, 0, len(mergedMRs)),
|
|
PendingMRs: make([]IntegrationStatusMRSummary, 0, len(pendingMRs)),
|
|
}
|
|
|
|
for _, mr := range mergedMRs {
|
|
// Extract the title without "Merge: " prefix for cleaner display
|
|
title := strings.TrimPrefix(mr.Title, "Merge: ")
|
|
output.MergedMRs = append(output.MergedMRs, IntegrationStatusMRSummary{
|
|
ID: mr.ID,
|
|
Title: title,
|
|
})
|
|
}
|
|
|
|
for _, mr := range pendingMRs {
|
|
title := strings.TrimPrefix(mr.Title, "Merge: ")
|
|
output.PendingMRs = append(output.PendingMRs, IntegrationStatusMRSummary{
|
|
ID: mr.ID,
|
|
Title: title,
|
|
Status: mr.Status,
|
|
})
|
|
}
|
|
|
|
// JSON output
|
|
if mqIntegrationStatusJSON {
|
|
enc := json.NewEncoder(os.Stdout)
|
|
enc.SetIndent("", " ")
|
|
return enc.Encode(output)
|
|
}
|
|
|
|
// Human-readable output
|
|
return printIntegrationStatus(&output)
|
|
}
|
|
|
|
// printIntegrationStatus prints the integration status in human-readable format.
|
|
func printIntegrationStatus(output *IntegrationStatusOutput) error {
|
|
fmt.Printf("Integration: %s\n", style.Bold.Render(output.Branch))
|
|
if output.Created != "" {
|
|
fmt.Printf("Created: %s\n", output.Created)
|
|
}
|
|
fmt.Printf("Ahead of main: %d commits\n", output.AheadOfMain)
|
|
|
|
// Merged MRs
|
|
fmt.Printf("\nMerged MRs (%d):\n", len(output.MergedMRs))
|
|
if len(output.MergedMRs) == 0 {
|
|
fmt.Printf(" %s\n", style.Dim.Render("(none)"))
|
|
} else {
|
|
for _, mr := range output.MergedMRs {
|
|
fmt.Printf(" %-12s %s\n", mr.ID, mr.Title)
|
|
}
|
|
}
|
|
|
|
// Pending MRs
|
|
fmt.Printf("\nPending MRs (%d):\n", len(output.PendingMRs))
|
|
if len(output.PendingMRs) == 0 {
|
|
fmt.Printf(" %s\n", style.Dim.Render("(none)"))
|
|
} else {
|
|
for _, mr := range output.PendingMRs {
|
|
statusInfo := ""
|
|
if mr.Status != "" && mr.Status != "open" {
|
|
statusInfo = fmt.Sprintf(" (%s)", mr.Status)
|
|
}
|
|
fmt.Printf(" %-12s %s%s\n", mr.ID, mr.Title, style.Dim.Render(statusInfo))
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|