diff --git a/internal/swarm/integration.go b/internal/swarm/integration.go new file mode 100644 index 00000000..7dd854ae --- /dev/null +++ b/internal/swarm/integration.go @@ -0,0 +1,208 @@ +package swarm + +import ( + "bytes" + "errors" + "fmt" + "os/exec" + "strings" +) + +// Integration branch errors +var ( + ErrBranchExists = errors.New("branch already exists") + ErrBranchNotFound = errors.New("branch not found") + ErrMergeConflict = errors.New("merge conflict") + ErrNotOnIntegration = errors.New("not on integration branch") +) + +// CreateIntegrationBranch creates the integration branch for a swarm. +// The branch is created from the swarm's BaseCommit and pushed to origin. +func (m *Manager) CreateIntegrationBranch(swarmID string) error { + swarm, ok := m.swarms[swarmID] + if !ok { + return ErrSwarmNotFound + } + + branchName := swarm.Integration + + // Check if branch already exists + if m.branchExists(branchName) { + return ErrBranchExists + } + + // Create branch from BaseCommit + if err := m.gitRun("checkout", "-b", branchName, swarm.BaseCommit); err != nil { + return fmt.Errorf("creating branch: %w", err) + } + + // Push to origin + if err := m.gitRun("push", "-u", "origin", branchName); err != nil { + // Non-fatal - may not have remote + } + + return nil +} + +// MergeToIntegration merges a worker branch into the integration branch. +// Returns ErrMergeConflict if the merge has conflicts. +func (m *Manager) MergeToIntegration(swarmID, workerBranch string) error { + swarm, ok := m.swarms[swarmID] + if !ok { + return ErrSwarmNotFound + } + + // Ensure we're on the integration branch + currentBranch, err := m.getCurrentBranch() + if err != nil { + return fmt.Errorf("getting current branch: %w", err) + } + if currentBranch != swarm.Integration { + if err := m.gitRun("checkout", swarm.Integration); err != nil { + return fmt.Errorf("checking out integration: %w", err) + } + } + + // Fetch the worker branch + if err := m.gitRun("fetch", "origin", workerBranch); err != nil { + // May not exist on remote, try local + } + + // Attempt merge + err = m.gitRun("merge", "--no-ff", "-m", + fmt.Sprintf("Merge %s into %s", workerBranch, swarm.Integration), + workerBranch) + if err != nil { + // Check if it's a merge conflict + if strings.Contains(err.Error(), "CONFLICT") || + strings.Contains(err.Error(), "Merge conflict") { + return ErrMergeConflict + } + return fmt.Errorf("merging: %w", err) + } + + return nil +} + +// AbortMerge aborts an in-progress merge. +func (m *Manager) AbortMerge() error { + return m.gitRun("merge", "--abort") +} + +// LandToMain merges the integration branch to the target branch (usually main). +func (m *Manager) LandToMain(swarmID string) error { + swarm, ok := m.swarms[swarmID] + if !ok { + return ErrSwarmNotFound + } + + // Checkout target branch + if err := m.gitRun("checkout", swarm.TargetBranch); err != nil { + return fmt.Errorf("checking out %s: %w", swarm.TargetBranch, err) + } + + // Pull latest + m.gitRun("pull", "origin", swarm.TargetBranch) // Ignore errors + + // Merge integration branch + err := m.gitRun("merge", "--no-ff", "-m", + fmt.Sprintf("Land swarm %s", swarmID), + swarm.Integration) + if err != nil { + if strings.Contains(err.Error(), "CONFLICT") { + return ErrMergeConflict + } + return fmt.Errorf("merging to %s: %w", swarm.TargetBranch, err) + } + + // Push + if err := m.gitRun("push", "origin", swarm.TargetBranch); err != nil { + return fmt.Errorf("pushing: %w", err) + } + + return nil +} + +// CleanupBranches removes all branches associated with a swarm. +func (m *Manager) CleanupBranches(swarmID string) error { + swarm, ok := m.swarms[swarmID] + if !ok { + return ErrSwarmNotFound + } + + var lastErr error + + // Delete integration branch locally + if err := m.gitRun("branch", "-D", swarm.Integration); err != nil { + lastErr = err + } + + // Delete integration branch remotely + m.gitRun("push", "origin", "--delete", swarm.Integration) // Ignore errors + + // Delete worker branches + for _, task := range swarm.Tasks { + if task.Branch != "" { + // Local delete + m.gitRun("branch", "-D", task.Branch) + // Remote delete + m.gitRun("push", "origin", "--delete", task.Branch) + } + } + + return lastErr +} + +// GetIntegrationBranch returns the integration branch name for a swarm. +func (m *Manager) GetIntegrationBranch(swarmID string) (string, error) { + swarm, ok := m.swarms[swarmID] + if !ok { + return "", ErrSwarmNotFound + } + return swarm.Integration, nil +} + +// GetWorkerBranch generates the branch name for a worker on a task. +func (m *Manager) GetWorkerBranch(swarmID, worker, taskID string) string { + return fmt.Sprintf("%s/%s/%s", swarmID, worker, taskID) +} + +// branchExists checks if a branch exists locally. +func (m *Manager) branchExists(branch string) bool { + err := m.gitRun("show-ref", "--verify", "--quiet", "refs/heads/"+branch) + return err == nil +} + +// getCurrentBranch returns the current branch name. +func (m *Manager) getCurrentBranch() (string, error) { + cmd := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD") + cmd.Dir = m.workDir + + var stdout bytes.Buffer + cmd.Stdout = &stdout + + if err := cmd.Run(); err != nil { + return "", err + } + + return strings.TrimSpace(stdout.String()), nil +} + +// gitRun executes a git command. +func (m *Manager) gitRun(args ...string) error { + cmd := exec.Command("git", args...) + cmd.Dir = m.workDir + + var stderr bytes.Buffer + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + errMsg := strings.TrimSpace(stderr.String()) + if errMsg != "" { + return fmt.Errorf("%s: %s", args[0], errMsg) + } + return fmt.Errorf("%s: %w", args[0], err) + } + + return nil +} diff --git a/internal/swarm/integration_test.go b/internal/swarm/integration_test.go new file mode 100644 index 00000000..571f0d60 --- /dev/null +++ b/internal/swarm/integration_test.go @@ -0,0 +1,106 @@ +package swarm + +import ( + "testing" + + "github.com/steveyegge/gastown/internal/rig" +) + +func TestGetIntegrationBranch(t *testing.T) { + r := &rig.Rig{ + Name: "test-rig", + Path: "/tmp/test-rig", + } + m := NewManager(r) + + swarm, _ := m.Create("epic-1", []string{"Toast"}, "main") + + branch, err := m.GetIntegrationBranch(swarm.ID) + if err != nil { + t.Fatalf("GetIntegrationBranch failed: %v", err) + } + + expected := "swarm/epic-1" + if branch != expected { + t.Errorf("branch = %q, want %q", branch, expected) + } +} + +func TestGetIntegrationBranchNotFound(t *testing.T) { + r := &rig.Rig{ + Name: "test-rig", + Path: "/tmp/test-rig", + } + m := NewManager(r) + + _, err := m.GetIntegrationBranch("nonexistent") + if err != ErrSwarmNotFound { + t.Errorf("GetIntegrationBranch = %v, want ErrSwarmNotFound", err) + } +} + +func TestGetWorkerBranch(t *testing.T) { + r := &rig.Rig{ + Name: "test-rig", + Path: "/tmp/test-rig", + } + m := NewManager(r) + + branch := m.GetWorkerBranch("sw-1", "Toast", "task-123") + expected := "sw-1/Toast/task-123" + if branch != expected { + t.Errorf("branch = %q, want %q", branch, expected) + } +} + +func TestCreateIntegrationBranchSwarmNotFound(t *testing.T) { + r := &rig.Rig{ + Name: "test-rig", + Path: "/tmp/test-rig", + } + m := NewManager(r) + + err := m.CreateIntegrationBranch("nonexistent") + if err != ErrSwarmNotFound { + t.Errorf("CreateIntegrationBranch = %v, want ErrSwarmNotFound", err) + } +} + +func TestMergeToIntegrationSwarmNotFound(t *testing.T) { + r := &rig.Rig{ + Name: "test-rig", + Path: "/tmp/test-rig", + } + m := NewManager(r) + + err := m.MergeToIntegration("nonexistent", "branch") + if err != ErrSwarmNotFound { + t.Errorf("MergeToIntegration = %v, want ErrSwarmNotFound", err) + } +} + +func TestLandToMainSwarmNotFound(t *testing.T) { + r := &rig.Rig{ + Name: "test-rig", + Path: "/tmp/test-rig", + } + m := NewManager(r) + + err := m.LandToMain("nonexistent") + if err != ErrSwarmNotFound { + t.Errorf("LandToMain = %v, want ErrSwarmNotFound", err) + } +} + +func TestCleanupBranchesSwarmNotFound(t *testing.T) { + r := &rig.Rig{ + Name: "test-rig", + Path: "/tmp/test-rig", + } + m := NewManager(r) + + err := m.CleanupBranches("nonexistent") + if err != ErrSwarmNotFound { + t.Errorf("CleanupBranches = %v, want ErrSwarmNotFound", err) + } +}