diff --git a/internal/cmd/mq.go b/internal/cmd/mq.go new file mode 100644 index 00000000..82ded823 --- /dev/null +++ b/internal/cmd/mq.go @@ -0,0 +1,92 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/steveyegge/gastown/internal/refinery" + "github.com/steveyegge/gastown/internal/style" +) + +// mq command flags +var ( + mqRetryNow bool +) + +var mqCmd = &cobra.Command{ + Use: "mq", + Short: "Merge queue operations", + Long: `Manage the merge queue for a rig. + +The merge queue tracks work from polecats waiting to be merged. +Use these commands to view, retry, and manage merge requests.`, +} + +var mqRetryCmd = &cobra.Command{ + Use: "retry ", + Short: "Retry a failed merge request", + Long: `Retry a failed merge request. + +Resets a failed MR so it can be processed again by the refinery. +The MR must be in a failed state (open with an error). + +Examples: + gt mq retry gastown gt-mr-abc123 + gt mq retry gastown gt-mr-abc123 --now`, + Args: cobra.ExactArgs(2), + RunE: runMQRetry, +} + +func init() { + // Retry flags + mqRetryCmd.Flags().BoolVar(&mqRetryNow, "now", false, "Immediately process instead of waiting for refinery loop") + + // Add subcommands + mqCmd.AddCommand(mqRetryCmd) + + rootCmd.AddCommand(mqCmd) +} + +func runMQRetry(cmd *cobra.Command, args []string) error { + rigName := args[0] + mrID := args[1] + + mgr, _, err := getRefineryManager(rigName) + if err != nil { + return err + } + + // Get the MR first to show info + mr, err := mgr.GetMR(mrID) + if err != nil { + if err == refinery.ErrMRNotFound { + return fmt.Errorf("merge request '%s' not found in rig '%s'", mrID, rigName) + } + return fmt.Errorf("getting merge request: %w", err) + } + + // Show what we're retrying + fmt.Printf("Retrying merge request: %s\n", mrID) + fmt.Printf(" Branch: %s\n", mr.Branch) + fmt.Printf(" Worker: %s\n", mr.Worker) + if mr.Error != "" { + fmt.Printf(" Previous error: %s\n", style.Dim.Render(mr.Error)) + } + + // Perform the retry + if err := mgr.Retry(mrID, mqRetryNow); err != nil { + if err == refinery.ErrMRNotFailed { + return fmt.Errorf("merge request '%s' has not failed (status: %s)", mrID, mr.Status) + } + return fmt.Errorf("retrying merge request: %w", err) + } + + if mqRetryNow { + fmt.Printf("%s Merge request processed\n", style.Bold.Render("✓")) + } else { + fmt.Printf("%s Merge request queued for retry\n", style.Bold.Render("✓")) + fmt.Printf(" %s\n", style.Dim.Render("Will be processed on next refinery cycle")) + } + + return nil +} diff --git a/internal/git/git.go b/internal/git/git.go index fcd9db3a..68123dd3 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -91,6 +91,12 @@ func (g *Git) Fetch(remote string) error { return err } +// FetchBranch fetches a specific branch from the remote. +func (g *Git) FetchBranch(remote, branch string) error { + _, err := g.run("fetch", remote, branch) + return err +} + // Pull pulls from the remote branch. func (g *Git) Pull(remote, branch string) error { _, err := g.run("pull", remote, branch) @@ -207,6 +213,95 @@ func (g *Git) AbortMerge() error { return err } +// CheckConflicts performs a test merge to check if source can be merged into target +// without conflicts. Returns a list of conflicting files, or empty slice if clean. +// The merge is always aborted after checking - no actual changes are made. +// +// The caller must ensure the working directory is clean before calling this. +// After return, the working directory is restored to the target branch. +func (g *Git) CheckConflicts(source, target string) ([]string, error) { + // Checkout the target branch + if err := g.Checkout(target); err != nil { + return nil, fmt.Errorf("checkout target %s: %w", target, err) + } + + // Attempt test merge with --no-commit --no-ff + // We need to capture both stdout and stderr to detect conflicts + _, mergeErr := g.runMergeCheck("merge", "--no-commit", "--no-ff", source) + + if mergeErr != nil { + // Check if there are unmerged files (indicates conflict) + conflicts, err := g.getConflictingFiles() + if err == nil && len(conflicts) > 0 { + // Abort the test merge + g.AbortMerge() + return conflicts, nil + } + + // Check if it's a conflict error from wrapper + if errors.Is(mergeErr, ErrMergeConflict) { + g.AbortMerge() + return conflicts, nil + } + + // Some other merge error + g.AbortMerge() + return nil, mergeErr + } + + // Merge succeeded (no conflicts) - abort the test merge + // Use reset since --abort won't work on successful merge + g.run("reset", "--hard", "HEAD") + return nil, nil +} + +// runMergeCheck runs a git merge command and returns error info from both stdout and stderr. +// This is needed because git merge outputs CONFLICT info to stdout. +func (g *Git) runMergeCheck(args ...string) (string, error) { + cmd := exec.Command("git", args...) + cmd.Dir = g.workDir + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + if err != nil { + // Check stdout for CONFLICT message (git sends it there) + stdoutStr := stdout.String() + if strings.Contains(stdoutStr, "CONFLICT") { + return "", ErrMergeConflict + } + // Fall back to stderr check + return "", g.wrapError(err, stderr.String(), args) + } + + return strings.TrimSpace(stdout.String()), nil +} + +// getConflictingFiles returns the list of files with merge conflicts. +func (g *Git) getConflictingFiles() ([]string, error) { + // git diff --name-only --diff-filter=U shows unmerged files + out, err := g.run("diff", "--name-only", "--diff-filter=U") + if err != nil { + return nil, err + } + + if out == "" { + return nil, nil + } + + files := strings.Split(out, "\n") + // Filter out empty strings + var result []string + for _, f := range files { + if f != "" { + result = append(result, f) + } + } + return result, nil +} + // AbortRebase aborts a rebase in progress. func (g *Git) AbortRebase() error { _, err := g.run("rebase", "--abort") diff --git a/internal/git/git_test.go b/internal/git/git_test.go index 64d772f0..6db55f92 100644 --- a/internal/git/git_test.go +++ b/internal/git/git_test.go @@ -186,3 +186,157 @@ func TestRev(t *testing.T) { t.Errorf("hash length = %d, want 40", len(hash)) } } + +func TestFetchBranch(t *testing.T) { + // Create a "remote" repo + remoteDir := t.TempDir() + cmd := exec.Command("git", "init", "--bare") + cmd.Dir = remoteDir + if err := cmd.Run(); err != nil { + t.Fatalf("git init --bare: %v", err) + } + + // Create a local repo and push to remote + localDir := initTestRepo(t) + g := NewGit(localDir) + + // Add remote + cmd = exec.Command("git", "remote", "add", "origin", remoteDir) + cmd.Dir = localDir + if err := cmd.Run(); err != nil { + t.Fatalf("git remote add: %v", err) + } + + // Push main branch + mainBranch, _ := g.CurrentBranch() + cmd = exec.Command("git", "push", "-u", "origin", mainBranch) + cmd.Dir = localDir + if err := cmd.Run(); err != nil { + t.Fatalf("git push: %v", err) + } + + // Fetch should succeed + if err := g.FetchBranch("origin", mainBranch); err != nil { + t.Errorf("FetchBranch: %v", err) + } +} + +func TestCheckConflicts_NoConflict(t *testing.T) { + dir := initTestRepo(t) + g := NewGit(dir) + mainBranch, _ := g.CurrentBranch() + + // Create feature branch with non-conflicting change + if err := g.CreateBranch("feature"); err != nil { + t.Fatalf("CreateBranch: %v", err) + } + if err := g.Checkout("feature"); err != nil { + t.Fatalf("Checkout feature: %v", err) + } + + // Add a new file (won't conflict with main) + newFile := filepath.Join(dir, "feature.txt") + if err := os.WriteFile(newFile, []byte("feature content"), 0644); err != nil { + t.Fatalf("write file: %v", err) + } + if err := g.Add("feature.txt"); err != nil { + t.Fatalf("Add: %v", err) + } + if err := g.Commit("add feature file"); err != nil { + t.Fatalf("Commit: %v", err) + } + + // Go back to main + if err := g.Checkout(mainBranch); err != nil { + t.Fatalf("Checkout main: %v", err) + } + + // Check for conflicts - should be none + conflicts, err := g.CheckConflicts("feature", mainBranch) + if err != nil { + t.Fatalf("CheckConflicts: %v", err) + } + if len(conflicts) > 0 { + t.Errorf("expected no conflicts, got %v", conflicts) + } + + // Verify we're still on main and clean + branch, _ := g.CurrentBranch() + if branch != mainBranch { + t.Errorf("branch = %q, want %q", branch, mainBranch) + } + status, _ := g.Status() + if !status.Clean { + t.Error("expected clean working directory after CheckConflicts") + } +} + +func TestCheckConflicts_WithConflict(t *testing.T) { + dir := initTestRepo(t) + g := NewGit(dir) + mainBranch, _ := g.CurrentBranch() + + // Create feature branch + if err := g.CreateBranch("feature"); err != nil { + t.Fatalf("CreateBranch: %v", err) + } + if err := g.Checkout("feature"); err != nil { + t.Fatalf("Checkout feature: %v", err) + } + + // Modify README.md on feature branch + readmeFile := filepath.Join(dir, "README.md") + if err := os.WriteFile(readmeFile, []byte("# Feature changes\n"), 0644); err != nil { + t.Fatalf("write file: %v", err) + } + if err := g.Add("README.md"); err != nil { + t.Fatalf("Add: %v", err) + } + if err := g.Commit("modify readme on feature"); err != nil { + t.Fatalf("Commit: %v", err) + } + + // Go back to main and make conflicting change + if err := g.Checkout(mainBranch); err != nil { + t.Fatalf("Checkout main: %v", err) + } + if err := os.WriteFile(readmeFile, []byte("# Main changes\n"), 0644); err != nil { + t.Fatalf("write file: %v", err) + } + if err := g.Add("README.md"); err != nil { + t.Fatalf("Add: %v", err) + } + if err := g.Commit("modify readme on main"); err != nil { + t.Fatalf("Commit: %v", err) + } + + // Check for conflicts - should find README.md + conflicts, err := g.CheckConflicts("feature", mainBranch) + if err != nil { + t.Fatalf("CheckConflicts: %v", err) + } + if len(conflicts) == 0 { + t.Error("expected conflicts, got none") + } + + foundReadme := false + for _, f := range conflicts { + if f == "README.md" { + foundReadme = true + break + } + } + if !foundReadme { + t.Errorf("expected README.md in conflicts, got %v", conflicts) + } + + // Verify we're still on main and clean + branch, _ := g.CurrentBranch() + if branch != mainBranch { + t.Errorf("branch = %q, want %q", branch, mainBranch) + } + status, _ := g.Status() + if !status.Clean { + t.Error("expected clean working directory after CheckConflicts") + } +} diff --git a/internal/refinery/manager.go b/internal/refinery/manager.go index c8ad3971..9e8c2d0e 100644 --- a/internal/refinery/manager.go +++ b/internal/refinery/manager.go @@ -551,6 +551,90 @@ Thank you for your contribution!`, router.Send(msg) } +// ErrMRNotFound is returned when a merge request is not found. +var ErrMRNotFound = errors.New("merge request not found") + +// ErrMRNotFailed is returned when trying to retry an MR that hasn't failed. +var ErrMRNotFailed = errors.New("merge request has not failed") + +// GetMR returns a merge request by ID. +func (m *Manager) GetMR(id string) (*MergeRequest, error) { + ref, err := m.loadState() + if err != nil { + return nil, err + } + + // Check if it's the current MR + if ref.CurrentMR != nil && ref.CurrentMR.ID == id { + return ref.CurrentMR, nil + } + + // Check pending MRs + if ref.PendingMRs != nil { + if mr, ok := ref.PendingMRs[id]; ok { + return mr, nil + } + } + + return nil, ErrMRNotFound +} + +// Retry resets a failed merge request so it can be processed again. +// If processNow is true, immediately processes the MR instead of waiting for the loop. +func (m *Manager) Retry(id string, processNow bool) error { + ref, err := m.loadState() + if err != nil { + return err + } + + // Find the MR + var mr *MergeRequest + if ref.PendingMRs != nil { + mr = ref.PendingMRs[id] + } + if mr == nil { + return ErrMRNotFound + } + + // Verify it's in a failed state (open with an error) + if mr.Status != MROpen || mr.Error == "" { + return ErrMRNotFailed + } + + // Clear the error to mark as ready for retry + mr.Error = "" + + // Save the state + if err := m.saveState(ref); err != nil { + return err + } + + // If --now flag, process immediately + if processNow { + result := m.ProcessMR(mr) + if !result.Success { + return fmt.Errorf("retry failed: %s", result.Error) + } + } + + return nil +} + +// RegisterMR adds a merge request to the pending queue. +func (m *Manager) RegisterMR(mr *MergeRequest) error { + ref, err := m.loadState() + if err != nil { + return err + } + + if ref.PendingMRs == nil { + ref.PendingMRs = make(map[string]*MergeRequest) + } + + ref.PendingMRs[mr.ID] = mr + return m.saveState(ref) +} + // findTownRoot walks up directories to find the town root. func findTownRoot(startPath string) string { path := startPath diff --git a/internal/refinery/manager_test.go b/internal/refinery/manager_test.go new file mode 100644 index 00000000..19dc20d2 --- /dev/null +++ b/internal/refinery/manager_test.go @@ -0,0 +1,172 @@ +package refinery + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/steveyegge/gastown/internal/rig" +) + +func setupTestManager(t *testing.T) (*Manager, string) { + t.Helper() + + // Create temp directory structure + tmpDir := t.TempDir() + rigPath := filepath.Join(tmpDir, "testrig") + if err := os.MkdirAll(filepath.Join(rigPath, ".gastown"), 0755); err != nil { + t.Fatalf("mkdir .gastown: %v", err) + } + + r := &rig.Rig{ + Name: "testrig", + Path: rigPath, + } + + return NewManager(r), rigPath +} + +func TestManager_GetMR(t *testing.T) { + mgr, _ := setupTestManager(t) + + // Create a test MR in the pending queue + mr := &MergeRequest{ + ID: "gt-mr-abc123", + Branch: "polecat/Toast/gt-xyz", + Worker: "Toast", + IssueID: "gt-xyz", + Status: MROpen, + Error: "test failure", + } + + if err := mgr.RegisterMR(mr); err != nil { + t.Fatalf("RegisterMR: %v", err) + } + + t.Run("find existing MR", func(t *testing.T) { + found, err := mgr.GetMR("gt-mr-abc123") + if err != nil { + t.Errorf("GetMR() unexpected error: %v", err) + } + if found == nil { + t.Fatal("GetMR() returned nil") + } + if found.ID != mr.ID { + t.Errorf("GetMR() ID = %s, want %s", found.ID, mr.ID) + } + }) + + t.Run("MR not found", func(t *testing.T) { + _, err := mgr.GetMR("nonexistent-mr") + if err != ErrMRNotFound { + t.Errorf("GetMR() error = %v, want %v", err, ErrMRNotFound) + } + }) +} + +func TestManager_Retry(t *testing.T) { + t.Run("retry failed MR clears error", func(t *testing.T) { + mgr, _ := setupTestManager(t) + + // Create a failed MR + mr := &MergeRequest{ + ID: "gt-mr-failed", + Branch: "polecat/Toast/gt-xyz", + Worker: "Toast", + Status: MROpen, + Error: "merge conflict", + } + + if err := mgr.RegisterMR(mr); err != nil { + t.Fatalf("RegisterMR: %v", err) + } + + // Retry without processing + err := mgr.Retry("gt-mr-failed", false) + if err != nil { + t.Errorf("Retry() unexpected error: %v", err) + } + + // Verify error was cleared + found, _ := mgr.GetMR("gt-mr-failed") + if found.Error != "" { + t.Errorf("Retry() error not cleared, got %s", found.Error) + } + }) + + t.Run("retry non-failed MR fails", func(t *testing.T) { + mgr, _ := setupTestManager(t) + + // Create a successful MR (no error) + mr := &MergeRequest{ + ID: "gt-mr-success", + Branch: "polecat/Toast/gt-abc", + Worker: "Toast", + Status: MROpen, + Error: "", // No error + } + + if err := mgr.RegisterMR(mr); err != nil { + t.Fatalf("RegisterMR: %v", err) + } + + err := mgr.Retry("gt-mr-success", false) + if err != ErrMRNotFailed { + t.Errorf("Retry() error = %v, want %v", err, ErrMRNotFailed) + } + }) + + t.Run("retry nonexistent MR fails", func(t *testing.T) { + mgr, _ := setupTestManager(t) + + err := mgr.Retry("nonexistent", false) + if err != ErrMRNotFound { + t.Errorf("Retry() error = %v, want %v", err, ErrMRNotFound) + } + }) +} + +func TestManager_RegisterMR(t *testing.T) { + mgr, rigPath := setupTestManager(t) + + mr := &MergeRequest{ + ID: "gt-mr-new", + Branch: "polecat/Cheedo/gt-123", + Worker: "Cheedo", + IssueID: "gt-123", + TargetBranch: "main", + CreatedAt: time.Now(), + Status: MROpen, + } + + if err := mgr.RegisterMR(mr); err != nil { + t.Fatalf("RegisterMR: %v", err) + } + + // Verify it was saved to disk + stateFile := filepath.Join(rigPath, ".gastown", "refinery.json") + data, err := os.ReadFile(stateFile) + if err != nil { + t.Fatalf("reading state file: %v", err) + } + + var ref Refinery + if err := json.Unmarshal(data, &ref); err != nil { + t.Fatalf("unmarshal state: %v", err) + } + + if ref.PendingMRs == nil { + t.Fatal("PendingMRs is nil") + } + + saved, ok := ref.PendingMRs["gt-mr-new"] + if !ok { + t.Fatal("MR not found in PendingMRs") + } + + if saved.Worker != "Cheedo" { + t.Errorf("saved MR worker = %s, want Cheedo", saved.Worker) + } +} diff --git a/internal/refinery/types.go b/internal/refinery/types.go index 4aa86c87..6d220a00 100644 --- a/internal/refinery/types.go +++ b/internal/refinery/types.go @@ -38,6 +38,10 @@ type Refinery struct { // CurrentMR is the merge request currently being processed. CurrentMR *MergeRequest `json:"current_mr,omitempty"` + // PendingMRs tracks merge requests that have been submitted. + // Key is the MR ID. + PendingMRs map[string]*MergeRequest `json:"pending_mrs,omitempty"` + // LastMergeAt is when the last successful merge happened. LastMergeAt *time.Time `json:"last_merge_at,omitempty"`