feat: add integration branch management for swarms
- CreateIntegrationBranch: create from BaseCommit, push to origin - MergeToIntegration: merge worker branch with conflict detection - LandToMain: merge integration to target branch - CleanupBranches: remove all swarm branches after landing - Helper functions for branch naming conventions Closes gt-kmn.3 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
208
internal/swarm/integration.go
Normal file
208
internal/swarm/integration.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
106
internal/swarm/integration_test.go
Normal file
106
internal/swarm/integration_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user