From a0ecc051b8f5c207113654b8f0667d1751fa075e Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Tue, 16 Dec 2025 13:33:52 -0800 Subject: [PATCH] feat: add rig management package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/rig/manager.go | 204 +++++++++++++++++++++++++++++++++++ internal/rig/manager_test.go | 188 ++++++++++++++++++++++++++++++++ internal/rig/types.go | 59 ++++++++++ 3 files changed, 451 insertions(+) create mode 100644 internal/rig/manager.go create mode 100644 internal/rig/manager_test.go create mode 100644 internal/rig/types.go diff --git a/internal/rig/manager.go b/internal/rig/manager.go new file mode 100644 index 00000000..8fe79bc9 --- /dev/null +++ b/internal/rig/manager.go @@ -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 +} diff --git a/internal/rig/manager_test.go b/internal/rig/manager_test.go new file mode 100644 index 00000000..ef836658 --- /dev/null +++ b/internal/rig/manager_test.go @@ -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") + } +} diff --git a/internal/rig/types.go b/internal/rig/types.go new file mode 100644 index 00000000..897dd266 --- /dev/null +++ b/internal/rig/types.go @@ -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, + } +}