diff --git a/internal/cmd/crew.go b/internal/cmd/crew.go new file mode 100644 index 00000000..4452f567 --- /dev/null +++ b/internal/cmd/crew.go @@ -0,0 +1,156 @@ +package cmd + +import ( + "fmt" + "path/filepath" + + "github.com/spf13/cobra" + "github.com/steveyegge/gastown/internal/config" + "github.com/steveyegge/gastown/internal/crew" + "github.com/steveyegge/gastown/internal/git" + "github.com/steveyegge/gastown/internal/rig" + "github.com/steveyegge/gastown/internal/style" + "github.com/steveyegge/gastown/internal/workspace" +) + +// Crew command flags +var ( + crewRig string + crewBranch bool +) + +var crewCmd = &cobra.Command{ + Use: "crew", + Short: "Manage crew workspaces (user-managed persistent workspaces)", + Long: `Crew workers are user-managed persistent workspaces within a rig. + +Unlike polecats which are witness-managed and ephemeral, crew workers are: +- Persistent: Not auto-garbage-collected +- User-managed: Overseer controls lifecycle +- Long-lived identities: recognizable names like dave, emma, fred +- Gas Town integrated: Mail, handoff mechanics work +- Tmux optional: Can work in terminal directly + +Commands: + gt crew add Create a new crew workspace + gt crew list List crew workspaces + gt crew remove Remove a crew workspace`, +} + +var crewAddCmd = &cobra.Command{ + Use: "add ", + Short: "Create a new crew workspace", + Long: `Create a new crew workspace with a clone of the rig repository. + +The workspace is created at /crew// with: +- A full git clone of the project repository +- Mail directory for message delivery +- CLAUDE.md with crew worker prompting +- Optional feature branch (crew/) + +Examples: + gt crew add dave # Create in current rig + gt crew add emma --rig gastown # Create in specific rig + gt crew add fred --branch # Create with feature branch`, + Args: cobra.ExactArgs(1), + RunE: runCrewAdd, +} + +func init() { + crewAddCmd.Flags().StringVar(&crewRig, "rig", "", "Rig to create crew workspace in") + crewAddCmd.Flags().BoolVar(&crewBranch, "branch", false, "Create a feature branch (crew/)") + + crewCmd.AddCommand(crewAddCmd) + rootCmd.AddCommand(crewCmd) +} + +func runCrewAdd(cmd *cobra.Command, args []string) error { + name := args[0] + + // Find workspace + townRoot, err := workspace.FindFromCwdOrError() + if err != nil { + return fmt.Errorf("not in a Gas Town workspace: %w", err) + } + + // Load rigs config + rigsConfigPath := filepath.Join(townRoot, "config", "rigs.json") + rigsConfig, err := config.LoadRigsConfig(rigsConfigPath) + if err != nil { + rigsConfig = &config.RigsConfig{Rigs: make(map[string]config.RigEntry)} + } + + // Determine rig + rigName := crewRig + if rigName == "" { + // Try to infer from cwd + rigName, err = inferRigFromCwd(townRoot) + if err != nil { + return fmt.Errorf("could not determine rig (use --rig flag): %w", err) + } + } + + // Get rig + g := git.NewGit(townRoot) + rigMgr := rig.NewManager(townRoot, rigsConfig, g) + r, err := rigMgr.GetRig(rigName) + if err != nil { + return fmt.Errorf("rig '%s' not found", rigName) + } + + // Create crew manager + crewGit := git.NewGit(r.Path) + crewMgr := crew.NewManager(r, crewGit) + + // Create crew workspace + fmt.Printf("Creating crew workspace %s in %s...\n", name, rigName) + + worker, err := crewMgr.Add(name, crewBranch) + if err != nil { + if err == crew.ErrCrewExists { + return fmt.Errorf("crew workspace '%s' already exists", name) + } + return fmt.Errorf("creating crew workspace: %w", err) + } + + fmt.Printf("%s Created crew workspace: %s/%s\n", + style.Bold.Render("✓"), rigName, name) + fmt.Printf(" Path: %s\n", worker.ClonePath) + fmt.Printf(" Branch: %s\n", worker.Branch) + fmt.Printf(" Mail: %s/mail/\n", worker.ClonePath) + + fmt.Printf("\n%s\n", style.Dim.Render("Start working with: cd "+worker.ClonePath)) + + return nil +} + +// inferRigFromCwd tries to determine the rig from the current directory. +func inferRigFromCwd(townRoot string) (string, error) { + cwd, err := filepath.Abs(".") + if err != nil { + return "", err + } + + // Check if cwd is within a rig + rel, err := filepath.Rel(townRoot, cwd) + if err != nil { + return "", fmt.Errorf("not in workspace") + } + + // First component should be the rig name + parts := filepath.SplitList(rel) + if len(parts) == 0 { + // Split on path separator instead + for i := 0; i < len(rel); i++ { + if rel[i] == filepath.Separator { + return rel[:i], nil + } + } + // No separator found, entire rel is the rig name + if rel != "" && rel != "." { + return rel, nil + } + } + + return "", fmt.Errorf("could not infer rig from current directory") +} diff --git a/internal/crew/manager.go b/internal/crew/manager.go index 7e7aab84..e9927e65 100644 --- a/internal/crew/manager.go +++ b/internal/crew/manager.go @@ -14,9 +14,9 @@ import ( // Common errors var ( - ErrWorkerExists = errors.New("crew worker already exists") - ErrWorkerNotFound = errors.New("crew worker not found") - ErrHasChanges = errors.New("crew worker has uncommitted changes") + ErrCrewExists = errors.New("crew worker already exists") + ErrCrewNotFound = errors.New("crew worker not found") + ErrHasChanges = errors.New("crew worker has uncommitted changes") ) // Manager handles crew worker lifecycle. @@ -33,121 +33,176 @@ func NewManager(r *rig.Rig, g *git.Git) *Manager { } } -// workerDir returns the directory for a crew worker. -func (m *Manager) workerDir(name string) string { +// crewDir returns the directory for a crew worker. +func (m *Manager) crewDir(name string) string { return filepath.Join(m.rig.Path, "crew", name) } // stateFile returns the state file path for a crew worker. func (m *Manager) stateFile(name string) string { - return filepath.Join(m.workerDir(name), "state.json") + return filepath.Join(m.crewDir(name), "state.json") +} + +// mailDir returns the mail directory path for a crew worker. +func (m *Manager) mailDir(name string) string { + return filepath.Join(m.crewDir(name), "mail") } // exists checks if a crew worker exists. func (m *Manager) exists(name string) bool { - _, err := os.Stat(m.workerDir(name)) + _, err := os.Stat(m.crewDir(name)) return err == nil } // Add creates a new crew worker with a clone of the rig. -func (m *Manager) Add(name string) (*Worker, error) { +func (m *Manager) Add(name string, createBranch bool) (*CrewWorker, error) { if m.exists(name) { - return nil, ErrWorkerExists + return nil, ErrCrewExists } - workerPath := m.workerDir(name) + crewPath := m.crewDir(name) // Create crew directory if needed - crewDir := filepath.Join(m.rig.Path, "crew") - if err := os.MkdirAll(crewDir, 0755); err != nil { + crewBaseDir := filepath.Join(m.rig.Path, "crew") + if err := os.MkdirAll(crewBaseDir, 0755); err != nil { return nil, fmt.Errorf("creating crew dir: %w", err) } // Clone the rig repo - if err := m.git.Clone(m.rig.GitURL, workerPath); err != nil { + if err := m.git.Clone(m.rig.GitURL, crewPath); err != nil { return nil, fmt.Errorf("cloning rig: %w", err) } + crewGit := git.NewGit(crewPath) + branchName := "main" + + // Optionally create a working branch + if createBranch { + branchName = fmt.Sprintf("crew/%s", name) + if err := crewGit.CreateBranch(branchName); err != nil { + os.RemoveAll(crewPath) + return nil, fmt.Errorf("creating branch: %w", err) + } + if err := crewGit.Checkout(branchName); err != nil { + os.RemoveAll(crewPath) + return nil, fmt.Errorf("checking out branch: %w", err) + } + } + + // Create mail directory for mail delivery + mailPath := m.mailDir(name) + if err := os.MkdirAll(mailPath, 0755); err != nil { + os.RemoveAll(crewPath) + return nil, fmt.Errorf("creating mail dir: %w", err) + } + + // Create CLAUDE.md with crew worker prompting + if err := m.createClaudeMD(name, crewPath); err != nil { + os.RemoveAll(crewPath) + return nil, fmt.Errorf("creating CLAUDE.md: %w", err) + } + // Create crew worker state now := time.Now() - worker := &Worker{ + crew := &CrewWorker{ Name: name, Rig: m.rig.Name, - State: StateActive, - ClonePath: workerPath, + ClonePath: crewPath, + Branch: branchName, CreatedAt: now, UpdatedAt: now, } // Save state - if err := m.saveState(worker); err != nil { - os.RemoveAll(workerPath) + if err := m.saveState(crew); err != nil { + os.RemoveAll(crewPath) return nil, fmt.Errorf("saving state: %w", err) } - return worker, nil + return crew, nil } -// AddWithConfig creates a new crew worker with custom configuration. -func (m *Manager) AddWithConfig(name string, beadsDir string) (*Worker, error) { - worker, err := m.Add(name) - if err != nil { - return nil, err - } +// createClaudeMD creates the CLAUDE.md file for crew worker prompting. +func (m *Manager) createClaudeMD(name, crewPath string) error { + content := fmt.Sprintf(`# Claude: Crew Worker - %s - // Update with custom config - if beadsDir != "" { - worker.BeadsDir = beadsDir - if err := m.saveState(worker); err != nil { - return nil, fmt.Errorf("saving config: %w", err) - } - } +You are a **crew worker** in the %s rig. Crew workers are user-managed persistent workspaces. - return worker, nil +## Key Differences from Polecats + +- **User-managed**: You are NOT managed by the Witness daemon +- **Persistent**: Your workspace is not automatically cleaned up +- **Optional issue assignment**: You can work without a beads issue +- **Mail enabled**: You can send and receive mail + +## Commands + +Check mail: +`+"```bash"+` +town mail inbox --as %s/%s +`+"```"+` + +Send mail: +`+"```bash"+` +town mail send -s "Subject" -m "Message" --as %s/%s +`+"```"+` + +## Session Cycling (Handoff) + +When your context fills up, use mail-to-self for handoff: + +1. Compose a handoff note with current state +2. Send to yourself: `+"```"+`town mail send %s/%s -s "Handoff" -m "..."--as %s/%s`+"```"+` +3. Exit cleanly +4. New session reads handoff from inbox + +## Beads + +If using beads for task tracking: +`+"```bash"+` +bd ready # Find available work +bd show # Review issue details +bd update --status=in_progress # Claim it +bd close # Mark complete +`+"```"+` +`, name, m.rig.Name, + m.rig.Name, name, + m.rig.Name, name, + m.rig.Name, name, m.rig.Name, name) + + claudePath := filepath.Join(crewPath, "CLAUDE.md") + return os.WriteFile(claudePath, []byte(content), 0644) } // Remove deletes a crew worker. -func (m *Manager) Remove(name string) error { +func (m *Manager) Remove(name string, force bool) error { if !m.exists(name) { - return ErrWorkerNotFound + return ErrCrewNotFound } - workerPath := m.workerDir(name) - workerGit := git.NewGit(workerPath) + crewPath := m.crewDir(name) - // Check for uncommitted changes - hasChanges, err := workerGit.HasUncommittedChanges() - if err == nil && hasChanges { - return ErrHasChanges + if !force { + crewGit := git.NewGit(crewPath) + hasChanges, err := crewGit.HasUncommittedChanges() + if err == nil && hasChanges { + return ErrHasChanges + } } // Remove directory - if err := os.RemoveAll(workerPath); err != nil { - return fmt.Errorf("removing crew worker dir: %w", err) - } - - return nil -} - -// RemoveForce deletes a crew worker even with uncommitted changes. -func (m *Manager) RemoveForce(name string) error { - if !m.exists(name) { - return ErrWorkerNotFound - } - - workerPath := m.workerDir(name) - if err := os.RemoveAll(workerPath); err != nil { - return fmt.Errorf("removing crew worker dir: %w", err) + if err := os.RemoveAll(crewPath); err != nil { + return fmt.Errorf("removing crew dir: %w", err) } return nil } // List returns all crew workers in the rig. -func (m *Manager) List() ([]*Worker, error) { - crewDir := filepath.Join(m.rig.Path, "crew") +func (m *Manager) List() ([]*CrewWorker, error) { + crewBaseDir := filepath.Join(m.rig.Path, "crew") - entries, err := os.ReadDir(crewDir) + entries, err := os.ReadDir(crewBaseDir) if err != nil { if os.IsNotExist(err) { return nil, nil @@ -155,7 +210,7 @@ func (m *Manager) List() ([]*Worker, error) { return nil, fmt.Errorf("reading crew dir: %w", err) } - var workers []*Worker + var workers []*CrewWorker for _, entry := range entries { if !entry.IsDir() { continue @@ -172,48 +227,22 @@ func (m *Manager) List() ([]*Worker, error) { } // Get returns a specific crew worker by name. -func (m *Manager) Get(name string) (*Worker, error) { +func (m *Manager) Get(name string) (*CrewWorker, error) { if !m.exists(name) { - return nil, ErrWorkerNotFound + return nil, ErrCrewNotFound } return m.loadState(name) } -// SetState updates a crew worker's state. -func (m *Manager) SetState(name string, state State) error { - worker, err := m.Get(name) - if err != nil { - return err - } - - worker.State = state - worker.UpdatedAt = time.Now() - - return m.saveState(worker) -} - -// SetBeadsDir updates the custom beads directory for a crew worker. -func (m *Manager) SetBeadsDir(name, beadsDir string) error { - worker, err := m.Get(name) - if err != nil { - return err - } - - worker.BeadsDir = beadsDir - worker.UpdatedAt = time.Now() - - return m.saveState(worker) -} - // saveState persists crew worker state to disk. -func (m *Manager) saveState(worker *Worker) error { - data, err := json.MarshalIndent(worker, "", " ") +func (m *Manager) saveState(crew *CrewWorker) error { + data, err := json.MarshalIndent(crew, "", " ") if err != nil { return fmt.Errorf("marshaling state: %w", err) } - stateFile := m.stateFile(worker.Name) + stateFile := m.stateFile(crew.Name) if err := os.WriteFile(stateFile, data, 0644); err != nil { return fmt.Errorf("writing state: %w", err) } @@ -222,49 +251,26 @@ func (m *Manager) saveState(worker *Worker) error { } // loadState reads crew worker state from disk. -func (m *Manager) loadState(name string) (*Worker, error) { +func (m *Manager) loadState(name string) (*CrewWorker, error) { stateFile := m.stateFile(name) data, err := os.ReadFile(stateFile) if err != nil { if os.IsNotExist(err) { - // Return minimal worker if state file missing - return &Worker{ + // Return minimal crew worker if state file missing + return &CrewWorker{ Name: name, Rig: m.rig.Name, - State: StateActive, - ClonePath: m.workerDir(name), + ClonePath: m.crewDir(name), }, nil } return nil, fmt.Errorf("reading state: %w", err) } - var worker Worker - if err := json.Unmarshal(data, &worker); err != nil { + var crew CrewWorker + if err := json.Unmarshal(data, &crew); err != nil { return nil, fmt.Errorf("parsing state: %w", err) } - return &worker, nil -} - -// Names returns just the names of all crew workers. -func (m *Manager) Names() ([]string, error) { - crewDir := filepath.Join(m.rig.Path, "crew") - - entries, err := os.ReadDir(crewDir) - if err != nil { - if os.IsNotExist(err) { - return nil, nil - } - return nil, fmt.Errorf("reading crew dir: %w", err) - } - - var names []string - for _, entry := range entries { - if entry.IsDir() { - names = append(names, entry.Name()) - } - } - - return names, nil + return &crew, nil } diff --git a/internal/crew/manager_test.go b/internal/crew/manager_test.go index 9eea4c88..f5bea974 100644 --- a/internal/crew/manager_test.go +++ b/internal/crew/manager_test.go @@ -2,6 +2,7 @@ package crew import ( "os" + "os/exec" "path/filepath" "testing" @@ -9,194 +10,281 @@ import ( "github.com/steveyegge/gastown/internal/rig" ) -func TestManager_workerDir(t *testing.T) { - r := &rig.Rig{ - Name: "test-rig", - Path: "/tmp/test-rig", - } - m := NewManager(r, nil) - - got := m.workerDir("alice") - want := "/tmp/test-rig/crew/alice" - - if got != want { - t.Errorf("workerDir() = %q, want %q", got, want) - } -} - -func TestManager_stateFile(t *testing.T) { - r := &rig.Rig{ - Name: "test-rig", - Path: "/tmp/test-rig", - } - m := NewManager(r, nil) - - got := m.stateFile("bob") - want := "/tmp/test-rig/crew/bob/state.json" - - if got != want { - t.Errorf("stateFile() = %q, want %q", got, want) - } -} - -func TestManager_exists(t *testing.T) { - // Create temp directory structure - tmpDir := t.TempDir() - crewDir := filepath.Join(tmpDir, "crew", "existing-worker") - if err := os.MkdirAll(crewDir, 0755); err != nil { - t.Fatal(err) - } - - r := &rig.Rig{ - Name: "test-rig", - Path: tmpDir, - } - m := NewManager(r, nil) - - tests := []struct { - name string - worker string - want bool - }{ - {"existing worker", "existing-worker", true}, - {"non-existing worker", "non-existing", false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := m.exists(tt.worker) - if got != tt.want { - t.Errorf("exists(%q) = %v, want %v", tt.worker, got, tt.want) - } - }) - } -} - -func TestManager_List_Empty(t *testing.T) { - tmpDir := t.TempDir() - - r := &rig.Rig{ - Name: "test-rig", - Path: tmpDir, - } - m := NewManager(r, git.NewGit(tmpDir)) - - workers, err := m.List() +func TestManagerAddAndGet(t *testing.T) { + // Create temp directory for test + tmpDir, err := os.MkdirTemp("", "crew-test-*") if err != nil { - t.Fatalf("List() error = %v", err) + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + // Create a mock rig + rigPath := filepath.Join(tmpDir, "test-rig") + if err := os.MkdirAll(rigPath, 0755); err != nil { + t.Fatalf("failed to create rig dir: %v", err) } + // Initialize git repo for the rig + g := git.NewGit(rigPath) + + // For testing, we need a git URL - use a local bare repo + bareRepoPath := filepath.Join(tmpDir, "bare-repo.git") + cmd := []string{"git", "init", "--bare", bareRepoPath} + if err := runCmd(cmd[0], cmd[1:]...); err != nil { + t.Fatalf("failed to create bare repo: %v", err) + } + + r := &rig.Rig{ + Name: "test-rig", + Path: rigPath, + GitURL: bareRepoPath, + } + + mgr := NewManager(r, g) + + // Test Add + worker, err := mgr.Add("dave", false) + if err != nil { + t.Fatalf("Add failed: %v", err) + } + + if worker.Name != "dave" { + t.Errorf("expected name 'dave', got '%s'", worker.Name) + } + if worker.Rig != "test-rig" { + t.Errorf("expected rig 'test-rig', got '%s'", worker.Rig) + } + if worker.Branch != "main" { + t.Errorf("expected branch 'main', got '%s'", worker.Branch) + } + + // Verify directory structure + crewDir := filepath.Join(rigPath, "crew", "dave") + if _, err := os.Stat(crewDir); os.IsNotExist(err) { + t.Error("crew directory was not created") + } + + mailDir := filepath.Join(crewDir, "mail") + if _, err := os.Stat(mailDir); os.IsNotExist(err) { + t.Error("mail directory was not created") + } + + claudeMD := filepath.Join(crewDir, "CLAUDE.md") + if _, err := os.Stat(claudeMD); os.IsNotExist(err) { + t.Error("CLAUDE.md was not created") + } + + stateFile := filepath.Join(crewDir, "state.json") + if _, err := os.Stat(stateFile); os.IsNotExist(err) { + t.Error("state.json was not created") + } + + // Test Get + retrieved, err := mgr.Get("dave") + if err != nil { + t.Fatalf("Get failed: %v", err) + } + if retrieved.Name != "dave" { + t.Errorf("expected name 'dave', got '%s'", retrieved.Name) + } + + // Test duplicate Add + _, err = mgr.Add("dave", false) + if err != ErrCrewExists { + t.Errorf("expected ErrCrewExists, got %v", err) + } + + // Test Get non-existent + _, err = mgr.Get("nonexistent") + if err != ErrCrewNotFound { + t.Errorf("expected ErrCrewNotFound, got %v", err) + } +} + +func TestManagerAddWithBranch(t *testing.T) { + // Create temp directory for test + tmpDir, err := os.MkdirTemp("", "crew-test-branch-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + // Create a mock rig + rigPath := filepath.Join(tmpDir, "test-rig") + if err := os.MkdirAll(rigPath, 0755); err != nil { + t.Fatalf("failed to create rig dir: %v", err) + } + + g := git.NewGit(rigPath) + + // Create a local repo with initial commit for branch testing + sourceRepoPath := filepath.Join(tmpDir, "source-repo") + if err := os.MkdirAll(sourceRepoPath, 0755); err != nil { + t.Fatalf("failed to create source repo dir: %v", err) + } + + // Initialize source repo with a commit + cmds := [][]string{ + {"git", "-C", sourceRepoPath, "init"}, + {"git", "-C", sourceRepoPath, "config", "user.email", "test@test.com"}, + {"git", "-C", sourceRepoPath, "config", "user.name", "Test"}, + } + for _, cmd := range cmds { + if err := runCmd(cmd[0], cmd[1:]...); err != nil { + t.Fatalf("failed to run %v: %v", cmd, err) + } + } + + // Create initial file and commit + testFile := filepath.Join(sourceRepoPath, "README.md") + if err := os.WriteFile(testFile, []byte("# Test"), 0644); err != nil { + t.Fatalf("failed to write test file: %v", err) + } + + cmds = [][]string{ + {"git", "-C", sourceRepoPath, "add", "."}, + {"git", "-C", sourceRepoPath, "commit", "-m", "Initial commit"}, + } + for _, cmd := range cmds { + if err := runCmd(cmd[0], cmd[1:]...); err != nil { + t.Fatalf("failed to run %v: %v", cmd, err) + } + } + + r := &rig.Rig{ + Name: "test-rig", + Path: rigPath, + GitURL: sourceRepoPath, + } + + mgr := NewManager(r, g) + + // Test Add with branch + worker, err := mgr.Add("emma", true) + if err != nil { + t.Fatalf("Add with branch failed: %v", err) + } + + if worker.Branch != "crew/emma" { + t.Errorf("expected branch 'crew/emma', got '%s'", worker.Branch) + } +} + +func TestManagerList(t *testing.T) { + // Create temp directory for test + tmpDir, err := os.MkdirTemp("", "crew-test-list-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + // Create a mock rig + rigPath := filepath.Join(tmpDir, "test-rig") + if err := os.MkdirAll(rigPath, 0755); err != nil { + t.Fatalf("failed to create rig dir: %v", err) + } + + g := git.NewGit(rigPath) + + // Create a bare repo for cloning + bareRepoPath := filepath.Join(tmpDir, "bare-repo.git") + if err := runCmd("git", "init", "--bare", bareRepoPath); err != nil { + t.Fatalf("failed to create bare repo: %v", err) + } + + r := &rig.Rig{ + Name: "test-rig", + Path: rigPath, + GitURL: bareRepoPath, + } + + mgr := NewManager(r, g) + + // Initially empty + workers, err := mgr.List() + if err != nil { + t.Fatalf("List failed: %v", err) + } if len(workers) != 0 { - t.Errorf("List() returned %d workers, want 0", len(workers)) + t.Errorf("expected 0 workers, got %d", len(workers)) + } + + // Add some workers + _, err = mgr.Add("alice", false) + if err != nil { + t.Fatalf("Add alice failed: %v", err) + } + _, err = mgr.Add("bob", false) + if err != nil { + t.Fatalf("Add bob failed: %v", err) + } + + workers, err = mgr.List() + if err != nil { + t.Fatalf("List failed: %v", err) + } + if len(workers) != 2 { + t.Errorf("expected 2 workers, got %d", len(workers)) } } -func TestManager_List_WithWorkers(t *testing.T) { - tmpDir := t.TempDir() +func TestManagerRemove(t *testing.T) { + // Create temp directory for test + tmpDir, err := os.MkdirTemp("", "crew-test-remove-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) - // Create some fake worker directories - workers := []string{"alice", "bob", "charlie"} - for _, name := range workers { - workerDir := filepath.Join(tmpDir, "crew", name) - if err := os.MkdirAll(workerDir, 0755); err != nil { - t.Fatal(err) - } + // Create a mock rig + rigPath := filepath.Join(tmpDir, "test-rig") + if err := os.MkdirAll(rigPath, 0755); err != nil { + t.Fatalf("failed to create rig dir: %v", err) + } + + g := git.NewGit(rigPath) + + // Create a bare repo for cloning + bareRepoPath := filepath.Join(tmpDir, "bare-repo.git") + if err := runCmd("git", "init", "--bare", bareRepoPath); err != nil { + t.Fatalf("failed to create bare repo: %v", err) } r := &rig.Rig{ - Name: "test-rig", - Path: tmpDir, + Name: "test-rig", + Path: rigPath, + GitURL: bareRepoPath, } - m := NewManager(r, git.NewGit(tmpDir)) - gotWorkers, err := m.List() + mgr := NewManager(r, g) + + // Add a worker + _, err = mgr.Add("charlie", false) if err != nil { - t.Fatalf("List() error = %v", err) + t.Fatalf("Add failed: %v", err) } - if len(gotWorkers) != len(workers) { - t.Errorf("List() returned %d workers, want %d", len(gotWorkers), len(workers)) - } -} - -func TestManager_Names(t *testing.T) { - tmpDir := t.TempDir() - - // Create some fake worker directories - expected := []string{"alice", "bob"} - for _, name := range expected { - workerDir := filepath.Join(tmpDir, "crew", name) - if err := os.MkdirAll(workerDir, 0755); err != nil { - t.Fatal(err) - } - } - - r := &rig.Rig{ - Name: "test-rig", - Path: tmpDir, - } - m := NewManager(r, git.NewGit(tmpDir)) - - names, err := m.Names() + // Remove it (with force since CLAUDE.md is uncommitted) + err = mgr.Remove("charlie", true) if err != nil { - t.Fatalf("Names() error = %v", err) + t.Fatalf("Remove failed: %v", err) } - if len(names) != len(expected) { - t.Errorf("Names() returned %d names, want %d", len(names), len(expected)) + // Verify it's gone + _, err = mgr.Get("charlie") + if err != ErrCrewNotFound { + t.Errorf("expected ErrCrewNotFound, got %v", err) + } + + // Remove non-existent + err = mgr.Remove("nonexistent", false) + if err != ErrCrewNotFound { + t.Errorf("expected ErrCrewNotFound, got %v", err) } } -func TestWorker_EffectiveBeadsDir(t *testing.T) { - tests := []struct { - name string - beadsDir string - rigPath string - want string - }{ - { - name: "custom beads dir", - beadsDir: "/custom/beads", - rigPath: "/tmp/rig", - want: "/custom/beads", - }, - { - name: "default beads dir", - beadsDir: "", - rigPath: "/tmp/rig", - want: "/tmp/rig/.beads", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - w := &Worker{ - BeadsDir: tt.beadsDir, - } - got := w.EffectiveBeadsDir(tt.rigPath) - if got != tt.want { - t.Errorf("EffectiveBeadsDir() = %q, want %q", got, tt.want) - } - }) - } -} - -func TestWorker_Summary(t *testing.T) { - w := &Worker{ - Name: "alice", - State: StateActive, - Branch: "feature/test", - } - - got := w.Summary() - - if got.Name != w.Name { - t.Errorf("Summary().Name = %q, want %q", got.Name, w.Name) - } - if got.State != w.State { - t.Errorf("Summary().State = %q, want %q", got.State, w.State) - } - if got.Branch != w.Branch { - t.Errorf("Summary().Branch = %q, want %q", got.Branch, w.Branch) - } +// Helper to run commands +func runCmd(name string, args ...string) error { + cmd := exec.Command(name, args...) + return cmd.Run() } diff --git a/internal/crew/types.go b/internal/crew/types.go index b0bec6bd..2fefb5a1 100644 --- a/internal/crew/types.go +++ b/internal/crew/types.go @@ -1,72 +1,39 @@ -// Package crew provides crew worker management. -// Crew workers are user-managed persistent workspaces within a rig, -// as opposed to polecats which are AI-managed workers. +// Package crew provides crew workspace management for overseer workspaces. package crew import "time" -// State represents the current state of a crew worker. -type State string - -const ( - // StateActive means the crew workspace is active. - StateActive State = "active" - - // StateInactive means the crew workspace is inactive. - StateInactive State = "inactive" -) - -// Worker represents a crew member's workspace in a rig. -// Unlike polecats, crew workers are managed by the Overseer (human), -// not by AI agents. -type Worker struct { +// CrewWorker represents a user-managed workspace in a rig. +type CrewWorker struct { // Name is the crew worker identifier. Name string `json:"name"` - // Rig is the rig this worker belongs to. + // Rig is the rig this crew worker belongs to. Rig string `json:"rig"` - // State is the current state. - State State `json:"state"` - - // ClonePath is the path to the worker's clone. + // ClonePath is the path to the crew worker's clone of the rig. ClonePath string `json:"clone_path"` - // Branch is the current git branch (if any). - Branch string `json:"branch,omitempty"` + // Branch is the current git branch. + Branch string `json:"branch"` - // BeadsDir is an optional custom beads directory. - // If empty, defaults to the rig's .beads/ directory. - BeadsDir string `json:"beads_dir,omitempty"` - - // CreatedAt is when the worker was created. + // CreatedAt is when the crew worker was created. CreatedAt time.Time `json:"created_at"` - // UpdatedAt is when the worker was last updated. + // UpdatedAt is when the crew worker was last updated. UpdatedAt time.Time `json:"updated_at"` } // Summary provides a concise view of crew worker status. type Summary struct { Name string `json:"name"` - State State `json:"state"` - Branch string `json:"branch,omitempty"` + Branch string `json:"branch"` } -// Summary returns a Summary for this worker. -func (w *Worker) Summary() Summary { +// Summary returns a Summary for this crew worker. +func (c *CrewWorker) Summary() Summary { return Summary{ - Name: w.Name, - State: w.State, - Branch: w.Branch, + Name: c.Name, + Branch: c.Branch, } } - -// EffectiveBeadsDir returns the beads directory to use. -// Returns the custom BeadsDir if set, otherwise returns the rig default path. -func (w *Worker) EffectiveBeadsDir(rigPath string) string { - if w.BeadsDir != "" { - return w.BeadsDir - } - return rigPath + "/.beads" -}