feat: add rig management package
Types: - Rig: managed repository with polecats, witness, refinery, mayor - RigSummary: concise rig overview - Manager: rig discovery, loading, creation Manager operations: - DiscoverRigs: load all registered rigs - GetRig: get specific rig by name - RigExists, ListRigNames: query helpers - AddRig: clone and register new rig - RemoveRig: unregister rig (keeps files) Rig structure follows docs/architecture.md: - polecats/, refinery/rig/, witness/rig/, mayor/rig/ Closes gt-u1j.5 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
204
internal/rig/manager.go
Normal file
204
internal/rig/manager.go
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
package rig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/steveyegge/gastown/internal/config"
|
||||||
|
"github.com/steveyegge/gastown/internal/git"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Common errors
|
||||||
|
var (
|
||||||
|
ErrRigNotFound = errors.New("rig not found")
|
||||||
|
ErrRigExists = errors.New("rig already exists")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Manager handles rig discovery, loading, and creation.
|
||||||
|
type Manager struct {
|
||||||
|
townRoot string
|
||||||
|
config *config.RigsConfig
|
||||||
|
git *git.Git
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewManager creates a new rig manager.
|
||||||
|
func NewManager(townRoot string, rigsConfig *config.RigsConfig, g *git.Git) *Manager {
|
||||||
|
return &Manager{
|
||||||
|
townRoot: townRoot,
|
||||||
|
config: rigsConfig,
|
||||||
|
git: g,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DiscoverRigs returns all rigs registered in the workspace.
|
||||||
|
func (m *Manager) DiscoverRigs() ([]*Rig, error) {
|
||||||
|
var rigs []*Rig
|
||||||
|
|
||||||
|
for name, entry := range m.config.Rigs {
|
||||||
|
rig, err := m.loadRig(name, entry)
|
||||||
|
if err != nil {
|
||||||
|
// Log error but continue with other rigs
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rigs = append(rigs, rig)
|
||||||
|
}
|
||||||
|
|
||||||
|
return rigs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRig returns a specific rig by name.
|
||||||
|
func (m *Manager) GetRig(name string) (*Rig, error) {
|
||||||
|
entry, ok := m.config.Rigs[name]
|
||||||
|
if !ok {
|
||||||
|
return nil, ErrRigNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return m.loadRig(name, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RigExists checks if a rig is registered.
|
||||||
|
func (m *Manager) RigExists(name string) bool {
|
||||||
|
_, ok := m.config.Rigs[name]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadRig loads rig details from the filesystem.
|
||||||
|
func (m *Manager) loadRig(name string, entry config.RigEntry) (*Rig, error) {
|
||||||
|
rigPath := filepath.Join(m.townRoot, name)
|
||||||
|
|
||||||
|
// Verify directory exists
|
||||||
|
info, err := os.Stat(rigPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("rig directory: %w", err)
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
return nil, fmt.Errorf("not a directory: %s", rigPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
rig := &Rig{
|
||||||
|
Name: name,
|
||||||
|
Path: rigPath,
|
||||||
|
GitURL: entry.GitURL,
|
||||||
|
Config: entry.BeadsConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan for polecats
|
||||||
|
polecatsDir := filepath.Join(rigPath, "polecats")
|
||||||
|
if entries, err := os.ReadDir(polecatsDir); err == nil {
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() {
|
||||||
|
rig.Polecats = append(rig.Polecats, e.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for witness
|
||||||
|
witnessPath := filepath.Join(rigPath, "witness", "rig")
|
||||||
|
if _, err := os.Stat(witnessPath); err == nil {
|
||||||
|
rig.HasWitness = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for refinery
|
||||||
|
refineryPath := filepath.Join(rigPath, "refinery", "rig")
|
||||||
|
if _, err := os.Stat(refineryPath); err == nil {
|
||||||
|
rig.HasRefinery = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for mayor clone
|
||||||
|
mayorPath := filepath.Join(rigPath, "mayor", "rig")
|
||||||
|
if _, err := os.Stat(mayorPath); err == nil {
|
||||||
|
rig.HasMayor = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return rig, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddRig clones a repository and registers it as a rig.
|
||||||
|
func (m *Manager) AddRig(name, gitURL string) (*Rig, error) {
|
||||||
|
if m.RigExists(name) {
|
||||||
|
return nil, ErrRigExists
|
||||||
|
}
|
||||||
|
|
||||||
|
rigPath := filepath.Join(m.townRoot, name)
|
||||||
|
|
||||||
|
// Check if directory already exists
|
||||||
|
if _, err := os.Stat(rigPath); err == nil {
|
||||||
|
return nil, fmt.Errorf("directory already exists: %s", rigPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone repository
|
||||||
|
if err := m.git.Clone(gitURL, rigPath); err != nil {
|
||||||
|
return nil, fmt.Errorf("cloning repository: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create agent directories
|
||||||
|
if err := m.createAgentDirs(rigPath); err != nil {
|
||||||
|
// Cleanup on failure
|
||||||
|
os.RemoveAll(rigPath)
|
||||||
|
return nil, fmt.Errorf("creating agent directories: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update git exclude
|
||||||
|
if err := m.updateGitExclude(rigPath); err != nil {
|
||||||
|
// Non-fatal, continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register in config
|
||||||
|
m.config.Rigs[name] = config.RigEntry{
|
||||||
|
GitURL: gitURL,
|
||||||
|
}
|
||||||
|
|
||||||
|
return m.loadRig(name, m.config.Rigs[name])
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveRig unregisters a rig (does not delete files).
|
||||||
|
func (m *Manager) RemoveRig(name string) error {
|
||||||
|
if !m.RigExists(name) {
|
||||||
|
return ErrRigNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(m.config.Rigs, name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createAgentDirs creates the standard agent directory structure.
|
||||||
|
func (m *Manager) createAgentDirs(rigPath string) error {
|
||||||
|
for _, dir := range AgentDirs {
|
||||||
|
dirPath := filepath.Join(rigPath, dir)
|
||||||
|
if err := os.MkdirAll(dirPath, 0755); err != nil {
|
||||||
|
return fmt.Errorf("creating %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateGitExclude adds agent directories to .git/info/exclude.
|
||||||
|
func (m *Manager) updateGitExclude(rigPath string) error {
|
||||||
|
excludePath := filepath.Join(rigPath, ".git", "info", "exclude")
|
||||||
|
|
||||||
|
// Read existing content
|
||||||
|
content, err := os.ReadFile(excludePath)
|
||||||
|
if err != nil && !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append agent dirs
|
||||||
|
additions := "\n# Gas Town agent directories\n"
|
||||||
|
for _, dir := range AgentDirs {
|
||||||
|
additions += dir + "/\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write back
|
||||||
|
return os.WriteFile(excludePath, append(content, []byte(additions)...), 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRigNames returns the names of all registered rigs.
|
||||||
|
func (m *Manager) ListRigNames() []string {
|
||||||
|
names := make([]string, 0, len(m.config.Rigs))
|
||||||
|
for name := range m.config.Rigs {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
188
internal/rig/manager_test.go
Normal file
188
internal/rig/manager_test.go
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
package rig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/steveyegge/gastown/internal/config"
|
||||||
|
"github.com/steveyegge/gastown/internal/git"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupTestTown(t *testing.T) (string, *config.RigsConfig) {
|
||||||
|
t.Helper()
|
||||||
|
root := t.TempDir()
|
||||||
|
|
||||||
|
rigsConfig := &config.RigsConfig{
|
||||||
|
Version: 1,
|
||||||
|
Rigs: make(map[string]config.RigEntry),
|
||||||
|
}
|
||||||
|
|
||||||
|
return root, rigsConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
func createTestRig(t *testing.T, root, name string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
rigPath := filepath.Join(root, name)
|
||||||
|
if err := os.MkdirAll(rigPath, 0755); err != nil {
|
||||||
|
t.Fatalf("mkdir rig: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create agent dirs
|
||||||
|
for _, dir := range AgentDirs {
|
||||||
|
dirPath := filepath.Join(rigPath, dir)
|
||||||
|
if err := os.MkdirAll(dirPath, 0755); err != nil {
|
||||||
|
t.Fatalf("mkdir %s: %v", dir, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create some polecats
|
||||||
|
polecatsDir := filepath.Join(rigPath, "polecats")
|
||||||
|
for _, polecat := range []string{"Toast", "Cheedo"} {
|
||||||
|
if err := os.MkdirAll(filepath.Join(polecatsDir, polecat), 0755); err != nil {
|
||||||
|
t.Fatalf("mkdir polecat: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiscoverRigs(t *testing.T) {
|
||||||
|
root, rigsConfig := setupTestTown(t)
|
||||||
|
|
||||||
|
// Create test rig
|
||||||
|
createTestRig(t, root, "gastown")
|
||||||
|
rigsConfig.Rigs["gastown"] = config.RigEntry{
|
||||||
|
GitURL: "git@github.com:test/gastown.git",
|
||||||
|
}
|
||||||
|
|
||||||
|
manager := NewManager(root, rigsConfig, git.NewGit(root))
|
||||||
|
|
||||||
|
rigs, err := manager.DiscoverRigs()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DiscoverRigs: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(rigs) != 1 {
|
||||||
|
t.Errorf("rigs count = %d, want 1", len(rigs))
|
||||||
|
}
|
||||||
|
|
||||||
|
rig := rigs[0]
|
||||||
|
if rig.Name != "gastown" {
|
||||||
|
t.Errorf("Name = %q, want gastown", rig.Name)
|
||||||
|
}
|
||||||
|
if len(rig.Polecats) != 2 {
|
||||||
|
t.Errorf("Polecats count = %d, want 2", len(rig.Polecats))
|
||||||
|
}
|
||||||
|
if !rig.HasWitness {
|
||||||
|
t.Error("expected HasWitness = true")
|
||||||
|
}
|
||||||
|
if !rig.HasRefinery {
|
||||||
|
t.Error("expected HasRefinery = true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetRig(t *testing.T) {
|
||||||
|
root, rigsConfig := setupTestTown(t)
|
||||||
|
|
||||||
|
createTestRig(t, root, "test-rig")
|
||||||
|
rigsConfig.Rigs["test-rig"] = config.RigEntry{
|
||||||
|
GitURL: "git@github.com:test/test-rig.git",
|
||||||
|
}
|
||||||
|
|
||||||
|
manager := NewManager(root, rigsConfig, git.NewGit(root))
|
||||||
|
|
||||||
|
rig, err := manager.GetRig("test-rig")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetRig: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rig.Name != "test-rig" {
|
||||||
|
t.Errorf("Name = %q, want test-rig", rig.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetRigNotFound(t *testing.T) {
|
||||||
|
root, rigsConfig := setupTestTown(t)
|
||||||
|
manager := NewManager(root, rigsConfig, git.NewGit(root))
|
||||||
|
|
||||||
|
_, err := manager.GetRig("nonexistent")
|
||||||
|
if err != ErrRigNotFound {
|
||||||
|
t.Errorf("GetRig = %v, want ErrRigNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRigExists(t *testing.T) {
|
||||||
|
root, rigsConfig := setupTestTown(t)
|
||||||
|
rigsConfig.Rigs["exists"] = config.RigEntry{}
|
||||||
|
|
||||||
|
manager := NewManager(root, rigsConfig, git.NewGit(root))
|
||||||
|
|
||||||
|
if !manager.RigExists("exists") {
|
||||||
|
t.Error("expected RigExists = true for existing rig")
|
||||||
|
}
|
||||||
|
if manager.RigExists("nonexistent") {
|
||||||
|
t.Error("expected RigExists = false for nonexistent rig")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoveRig(t *testing.T) {
|
||||||
|
root, rigsConfig := setupTestTown(t)
|
||||||
|
rigsConfig.Rigs["to-remove"] = config.RigEntry{}
|
||||||
|
|
||||||
|
manager := NewManager(root, rigsConfig, git.NewGit(root))
|
||||||
|
|
||||||
|
if err := manager.RemoveRig("to-remove"); err != nil {
|
||||||
|
t.Fatalf("RemoveRig: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if manager.RigExists("to-remove") {
|
||||||
|
t.Error("rig should not exist after removal")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoveRigNotFound(t *testing.T) {
|
||||||
|
root, rigsConfig := setupTestTown(t)
|
||||||
|
manager := NewManager(root, rigsConfig, git.NewGit(root))
|
||||||
|
|
||||||
|
err := manager.RemoveRig("nonexistent")
|
||||||
|
if err != ErrRigNotFound {
|
||||||
|
t.Errorf("RemoveRig = %v, want ErrRigNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListRigNames(t *testing.T) {
|
||||||
|
root, rigsConfig := setupTestTown(t)
|
||||||
|
rigsConfig.Rigs["rig1"] = config.RigEntry{}
|
||||||
|
rigsConfig.Rigs["rig2"] = config.RigEntry{}
|
||||||
|
|
||||||
|
manager := NewManager(root, rigsConfig, git.NewGit(root))
|
||||||
|
|
||||||
|
names := manager.ListRigNames()
|
||||||
|
if len(names) != 2 {
|
||||||
|
t.Errorf("names count = %d, want 2", len(names))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRigSummary(t *testing.T) {
|
||||||
|
rig := &Rig{
|
||||||
|
Name: "test",
|
||||||
|
Polecats: []string{"a", "b", "c"},
|
||||||
|
HasWitness: true,
|
||||||
|
HasRefinery: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
summary := rig.Summary()
|
||||||
|
|
||||||
|
if summary.Name != "test" {
|
||||||
|
t.Errorf("Name = %q, want test", summary.Name)
|
||||||
|
}
|
||||||
|
if summary.PolecatCount != 3 {
|
||||||
|
t.Errorf("PolecatCount = %d, want 3", summary.PolecatCount)
|
||||||
|
}
|
||||||
|
if !summary.HasWitness {
|
||||||
|
t.Error("expected HasWitness = true")
|
||||||
|
}
|
||||||
|
if summary.HasRefinery {
|
||||||
|
t.Error("expected HasRefinery = false")
|
||||||
|
}
|
||||||
|
}
|
||||||
59
internal/rig/types.go
Normal file
59
internal/rig/types.go
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
// Package rig provides rig management functionality.
|
||||||
|
package rig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/steveyegge/gastown/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Rig represents a managed repository in the workspace.
|
||||||
|
type Rig struct {
|
||||||
|
// Name is the rig identifier (directory name).
|
||||||
|
Name string `json:"name"`
|
||||||
|
|
||||||
|
// Path is the absolute path to the rig directory.
|
||||||
|
Path string `json:"path"`
|
||||||
|
|
||||||
|
// GitURL is the remote repository URL.
|
||||||
|
GitURL string `json:"git_url"`
|
||||||
|
|
||||||
|
// Config is the rig-level configuration.
|
||||||
|
Config *config.BeadsConfig `json:"config,omitempty"`
|
||||||
|
|
||||||
|
// Polecats is the list of polecat names in this rig.
|
||||||
|
Polecats []string `json:"polecats,omitempty"`
|
||||||
|
|
||||||
|
// HasWitness indicates if the rig has a witness agent.
|
||||||
|
HasWitness bool `json:"has_witness"`
|
||||||
|
|
||||||
|
// HasRefinery indicates if the rig has a refinery agent.
|
||||||
|
HasRefinery bool `json:"has_refinery"`
|
||||||
|
|
||||||
|
// HasMayor indicates if the rig has a mayor clone.
|
||||||
|
HasMayor bool `json:"has_mayor"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentDirs are the standard agent directories in a rig.
|
||||||
|
var AgentDirs = []string{
|
||||||
|
"polecats",
|
||||||
|
"refinery/rig",
|
||||||
|
"witness/rig",
|
||||||
|
"mayor/rig",
|
||||||
|
}
|
||||||
|
|
||||||
|
// RigSummary provides a concise overview of a rig.
|
||||||
|
type RigSummary struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
PolecatCount int `json:"polecat_count"`
|
||||||
|
HasWitness bool `json:"has_witness"`
|
||||||
|
HasRefinery bool `json:"has_refinery"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary returns a RigSummary for this rig.
|
||||||
|
func (r *Rig) Summary() RigSummary {
|
||||||
|
return RigSummary{
|
||||||
|
Name: r.Name,
|
||||||
|
PolecatCount: len(r.Polecats),
|
||||||
|
HasWitness: r.HasWitness,
|
||||||
|
HasRefinery: r.HasRefinery,
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user