Merge gt-cik.2-furiosa: add gt crew add command
This commit is contained in:
156
internal/cmd/crew.go
Normal file
156
internal/cmd/crew.go
Normal file
@@ -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 <name> Create a new crew workspace
|
||||
gt crew list List crew workspaces
|
||||
gt crew remove <name> Remove a crew workspace`,
|
||||
}
|
||||
|
||||
var crewAddCmd = &cobra.Command{
|
||||
Use: "add <name>",
|
||||
Short: "Create a new crew workspace",
|
||||
Long: `Create a new crew workspace with a clone of the rig repository.
|
||||
|
||||
The workspace is created at <rig>/crew/<name>/ with:
|
||||
- A full git clone of the project repository
|
||||
- Mail directory for message delivery
|
||||
- CLAUDE.md with crew worker prompting
|
||||
- Optional feature branch (crew/<name>)
|
||||
|
||||
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/<name>)")
|
||||
|
||||
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")
|
||||
}
|
||||
@@ -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 <recipient> -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 <id> # Review issue details
|
||||
bd update <id> --status=in_progress # Claim it
|
||||
bd close <id> # 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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user