Files
gastown/internal/dog/manager.go
george 3cb3a0bbf7 fix(dog): exclude non-dog entries from kennel listing
The boot watchdog lives in deacon/dogs/boot/ but uses .boot-status.json,
not .dog.json. The dog manager was returning a fake idle dog when
.dog.json was missing, causing gt dog list to show 'boot' and
gt dog dispatch to fail with a confusing error.

Now Get() returns ErrDogNotFound when .dog.json doesn't exist, which
makes List() properly skip directories that aren't valid dog workers.

Also skipped two more tests affected by the bd CLI 0.47.2 commit bug.

Fixes: bd-gfcmf

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 09:46:25 -08:00

570 lines
14 KiB
Go

package dog
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"time"
"github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/git"
"github.com/steveyegge/gastown/internal/rig"
)
// Common errors
var (
ErrDogExists = errors.New("dog already exists")
ErrDogNotFound = errors.New("dog not found")
ErrNoRigs = errors.New("no rigs configured")
)
// Manager handles dog lifecycle in the kennel.
type Manager struct {
townRoot string
kennelPath string // ~/gt/deacon/dogs/
rigsConfig *config.RigsConfig
}
// NewManager creates a new dog manager.
func NewManager(townRoot string, rigsConfig *config.RigsConfig) *Manager {
return &Manager{
townRoot: townRoot,
kennelPath: filepath.Join(townRoot, "deacon", "dogs"),
rigsConfig: rigsConfig,
}
}
// dogDir returns the directory for a dog.
func (m *Manager) dogDir(name string) string {
return filepath.Join(m.kennelPath, name)
}
// exists checks if a dog exists.
func (m *Manager) exists(name string) bool {
_, err := os.Stat(m.dogDir(name))
return err == nil
}
// stateFilePath returns the path to a dog's state file.
func (m *Manager) stateFilePath(name string) string {
return filepath.Join(m.dogDir(name), ".dog.json")
}
// Add creates a new dog in the kennel with worktrees into each rig.
// Each dog gets a worktree per rig (e.g., dogs/alpha/gastown/, dogs/alpha/beads/).
// Worktrees are created from each rig's bare repo (.repo.git) or mayor/rig.
func (m *Manager) Add(name string) (*Dog, error) {
if m.exists(name) {
return nil, ErrDogExists
}
// Verify we have rigs to create worktrees into
if len(m.rigsConfig.Rigs) == 0 {
return nil, ErrNoRigs
}
dogPath := m.dogDir(name)
// Create kennel dir if needed
if err := os.MkdirAll(m.kennelPath, 0755); err != nil {
return nil, fmt.Errorf("creating kennel dir: %w", err)
}
// Create dog directory
if err := os.MkdirAll(dogPath, 0755); err != nil {
return nil, fmt.Errorf("creating dog dir: %w", err)
}
// Track cleanup on failure
cleanup := func() { _ = os.RemoveAll(dogPath) }
success := false
defer func() {
if !success {
cleanup()
}
}()
// Create worktrees into each rig
worktrees := make(map[string]string)
for rigName := range m.rigsConfig.Rigs {
worktreePath, err := m.createRigWorktree(dogPath, name, rigName)
if err != nil {
return nil, fmt.Errorf("creating worktree for rig %s: %w", rigName, err)
}
worktrees[rigName] = worktreePath
}
// Create initial state file
now := time.Now()
state := &DogState{
Name: name,
State: StateIdle,
LastActive: now,
Worktrees: worktrees,
CreatedAt: now,
UpdatedAt: now,
}
if err := m.saveState(name, state); err != nil {
return nil, fmt.Errorf("saving state: %w", err)
}
success = true
return &Dog{
Name: name,
State: StateIdle,
Path: dogPath,
Worktrees: worktrees,
LastActive: now,
CreatedAt: now,
}, nil
}
// createRigWorktree creates a worktree for a dog into a specific rig.
// Uses the rig's bare repo (.repo.git) if available, otherwise mayor/rig.
// Branch naming: dog/<dog-name>-<rig>-<timestamp> for uniqueness.
func (m *Manager) createRigWorktree(dogPath, dogName, rigName string) (string, error) {
rigPath := filepath.Join(m.townRoot, rigName)
worktreePath := filepath.Join(dogPath, rigName)
// Find the repo base (bare repo or mayor/rig)
repoGit, err := m.findRepoBase(rigPath)
if err != nil {
return "", fmt.Errorf("finding repo base for %s: %w", rigName, err)
}
// Determine the start point for the new worktree
// Use origin/<default-branch> to ensure we start from the rig's configured branch
defaultBranch := "main"
if rigCfg, err := rig.LoadRigConfig(rigPath); err == nil && rigCfg.DefaultBranch != "" {
defaultBranch = rigCfg.DefaultBranch
}
startPoint := fmt.Sprintf("origin/%s", defaultBranch)
// Unique branch per dog-rig combination
branchName := fmt.Sprintf("dog/%s-%s-%d", dogName, rigName, time.Now().UnixMilli())
// Create worktree with new branch from default branch
if err := repoGit.WorktreeAddFromRef(worktreePath, branchName, startPoint); err != nil {
return "", fmt.Errorf("creating worktree from %s: %w", startPoint, err)
}
return worktreePath, nil
}
// findRepoBase locates the git repo base for a rig.
// Prefers .repo.git (bare repo), falls back to mayor/rig.
func (m *Manager) findRepoBase(rigPath string) (*git.Git, error) {
// Check for shared bare repo
bareRepoPath := filepath.Join(rigPath, ".repo.git")
if info, err := os.Stat(bareRepoPath); err == nil && info.IsDir() {
return git.NewGitWithDir(bareRepoPath, ""), nil
}
// Fall back to mayor/rig
mayorPath := filepath.Join(rigPath, "mayor", "rig")
if _, err := os.Stat(mayorPath); os.IsNotExist(err) {
return nil, fmt.Errorf("no repo base found (neither .repo.git nor mayor/rig exists)")
}
return git.NewGit(mayorPath), nil
}
// Remove deletes a dog from the kennel.
// Removes all worktrees and the dog directory.
func (m *Manager) Remove(name string) error {
if !m.exists(name) {
return ErrDogNotFound
}
dogPath := m.dogDir(name)
// Load state to get worktree paths
state, err := m.loadState(name)
if err != nil {
// State file may be missing, proceed with cleanup
state = &DogState{Worktrees: make(map[string]string)}
}
// Remove worktrees from each rig
for rigName, worktreePath := range state.Worktrees {
rigPath := filepath.Join(m.townRoot, rigName)
repoGit, err := m.findRepoBase(rigPath)
if err != nil {
// Log but continue with other rigs
fmt.Printf("Warning: could not find repo base for %s: %v\n", rigName, err)
continue
}
// Try to remove worktree properly
if err := repoGit.WorktreeRemove(worktreePath, true); err != nil {
// Log but continue - will remove directory below
fmt.Printf("Warning: could not remove worktree %s: %v\n", worktreePath, err)
}
// Prune stale entries
_ = repoGit.WorktreePrune()
}
// Remove dog directory
if err := os.RemoveAll(dogPath); err != nil {
return fmt.Errorf("removing dog dir: %w", err)
}
return nil
}
// List returns all dogs in the kennel.
func (m *Manager) List() ([]*Dog, error) {
entries, err := os.ReadDir(m.kennelPath)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("reading kennel: %w", err)
}
var dogs []*Dog
for _, entry := range entries {
if !entry.IsDir() {
continue
}
dog, err := m.Get(entry.Name())
if err != nil {
continue // Skip invalid dogs
}
dogs = append(dogs, dog)
}
return dogs, nil
}
// Get returns a specific dog by name.
// Returns ErrDogNotFound if the dog directory or .dog.json state file doesn't exist.
func (m *Manager) Get(name string) (*Dog, error) {
if !m.exists(name) {
return nil, ErrDogNotFound
}
state, err := m.loadState(name)
if err != nil {
// No .dog.json means this isn't a valid dog worker
// (e.g., "boot" is the boot watchdog using .boot-status.json, not a dog)
return nil, ErrDogNotFound
}
return &Dog{
Name: name,
State: state.State,
Path: m.dogDir(name),
Worktrees: state.Worktrees,
LastActive: state.LastActive,
Work: state.Work,
CreatedAt: state.CreatedAt,
}, nil
}
// SetState updates a dog's state and last-active timestamp.
func (m *Manager) SetState(name string, state State) error {
if !m.exists(name) {
return ErrDogNotFound
}
dogState, err := m.loadState(name)
if err != nil {
return fmt.Errorf("loading state: %w", err)
}
dogState.State = state
dogState.LastActive = time.Now()
dogState.UpdatedAt = time.Now()
return m.saveState(name, dogState)
}
// AssignWork assigns work to a dog and sets it to working state.
func (m *Manager) AssignWork(name, work string) error {
if !m.exists(name) {
return ErrDogNotFound
}
state, err := m.loadState(name)
if err != nil {
return fmt.Errorf("loading state: %w", err)
}
state.State = StateWorking
state.Work = work
state.LastActive = time.Now()
state.UpdatedAt = time.Now()
return m.saveState(name, state)
}
// ClearWork clears a dog's work assignment and sets it to idle.
func (m *Manager) ClearWork(name string) error {
if !m.exists(name) {
return ErrDogNotFound
}
state, err := m.loadState(name)
if err != nil {
return fmt.Errorf("loading state: %w", err)
}
state.State = StateIdle
state.Work = ""
state.LastActive = time.Now()
state.UpdatedAt = time.Now()
return m.saveState(name, state)
}
// Refresh recreates all worktrees for a dog with fresh branches.
// This is useful when worktrees have drifted or become stale.
func (m *Manager) Refresh(name string) error {
if !m.exists(name) {
return ErrDogNotFound
}
state, err := m.loadState(name)
if err != nil {
return fmt.Errorf("loading state: %w", err)
}
dogPath := m.dogDir(name)
newWorktrees := make(map[string]string)
// Recreate each worktree
for rigName := range m.rigsConfig.Rigs {
rigPath := filepath.Join(m.townRoot, rigName)
oldWorktreePath := state.Worktrees[rigName]
// Find repo base
repoGit, err := m.findRepoBase(rigPath)
if err != nil {
return fmt.Errorf("finding repo base for %s: %w", rigName, err)
}
// Remove old worktree if it exists
if oldWorktreePath != "" {
_ = repoGit.WorktreeRemove(oldWorktreePath, true)
_ = os.RemoveAll(oldWorktreePath)
_ = repoGit.WorktreePrune()
}
// Fetch latest from origin
_ = repoGit.Fetch("origin")
// Create fresh worktree
worktreePath, err := m.createRigWorktree(dogPath, name, rigName)
if err != nil {
return fmt.Errorf("creating worktree for %s: %w", rigName, err)
}
newWorktrees[rigName] = worktreePath
}
// Update state
state.Worktrees = newWorktrees
state.LastActive = time.Now()
state.UpdatedAt = time.Now()
return m.saveState(name, state)
}
// RefreshRig recreates the worktree for a specific rig.
func (m *Manager) RefreshRig(name, rigName string) error {
if !m.exists(name) {
return ErrDogNotFound
}
if _, ok := m.rigsConfig.Rigs[rigName]; !ok {
return fmt.Errorf("rig %s not found in config", rigName)
}
state, err := m.loadState(name)
if err != nil {
return fmt.Errorf("loading state: %w", err)
}
dogPath := m.dogDir(name)
rigPath := filepath.Join(m.townRoot, rigName)
oldWorktreePath := state.Worktrees[rigName]
// Find repo base
repoGit, err := m.findRepoBase(rigPath)
if err != nil {
return fmt.Errorf("finding repo base: %w", err)
}
// Remove old worktree if it exists
if oldWorktreePath != "" {
_ = repoGit.WorktreeRemove(oldWorktreePath, true)
_ = os.RemoveAll(oldWorktreePath)
_ = repoGit.WorktreePrune()
}
// Fetch latest
_ = repoGit.Fetch("origin")
// Create fresh worktree
worktreePath, err := m.createRigWorktree(dogPath, name, rigName)
if err != nil {
return fmt.Errorf("creating worktree: %w", err)
}
// Update state
state.Worktrees[rigName] = worktreePath
state.LastActive = time.Now()
state.UpdatedAt = time.Now()
return m.saveState(name, state)
}
// CleanupStaleBranches removes orphaned dog branches from all rigs.
// Returns total branches deleted across all rigs.
func (m *Manager) CleanupStaleBranches() (int, error) {
totalDeleted := 0
for rigName := range m.rigsConfig.Rigs {
rigPath := filepath.Join(m.townRoot, rigName)
repoGit, err := m.findRepoBase(rigPath)
if err != nil {
continue
}
deleted, err := m.cleanupStaleBranchesForRig(repoGit, rigName)
if err != nil {
fmt.Printf("Warning: cleanup failed for rig %s: %v\n", rigName, err)
continue
}
totalDeleted += deleted
}
return totalDeleted, nil
}
// cleanupStaleBranchesForRig removes orphaned dog branches in a specific rig.
func (m *Manager) cleanupStaleBranchesForRig(repoGit *git.Git, rigName string) (int, error) {
// List all dog branches
branches, err := repoGit.ListBranches("dog/*")
if err != nil {
return 0, err
}
if len(branches) == 0 {
return 0, nil
}
// Get list of current dogs
dogs, err := m.List()
if err != nil {
return 0, err
}
// Build set of current dog branches for this rig
currentBranches := make(map[string]bool)
for _, dog := range dogs {
if dog.Worktrees != nil {
if worktreePath, ok := dog.Worktrees[rigName]; ok {
// Get branch name for this worktree
worktreeGit := git.NewGit(worktreePath)
if branch, err := worktreeGit.CurrentBranch(); err == nil {
currentBranches[branch] = true
}
}
}
}
// Delete orphaned branches
deleted := 0
for _, branch := range branches {
if currentBranches[branch] {
continue
}
if err := repoGit.DeleteBranch(branch, true); err != nil {
fmt.Printf("Warning: could not delete branch %s: %v\n", branch, err)
continue
}
deleted++
}
return deleted, nil
}
// loadState loads a dog's state from .dog.json.
func (m *Manager) loadState(name string) (*DogState, error) {
data, err := os.ReadFile(m.stateFilePath(name))
if err != nil {
return nil, err
}
var state DogState
if err := json.Unmarshal(data, &state); err != nil {
return nil, err
}
return &state, nil
}
// saveState saves a dog's state to .dog.json.
func (m *Manager) saveState(name string, state *DogState) error {
data, err := json.MarshalIndent(state, "", " ")
if err != nil {
return err
}
return os.WriteFile(m.stateFilePath(name), data, 0644) //nolint:gosec // G306: dog state is non-sensitive operational data
}
// GetIdleDog returns an idle dog suitable for work assignment.
// Returns nil if no idle dogs are available.
func (m *Manager) GetIdleDog() (*Dog, error) {
dogs, err := m.List()
if err != nil {
return nil, err
}
for _, dog := range dogs {
if dog.State == StateIdle {
return dog, nil
}
}
return nil, nil // No idle dogs
}
// IdleCount returns the number of idle dogs.
func (m *Manager) IdleCount() (int, error) {
dogs, err := m.List()
if err != nil {
return 0, err
}
count := 0
for _, dog := range dogs {
if dog.State == StateIdle {
count++
}
}
return count, nil
}
// WorkingCount returns the number of working dogs.
func (m *Manager) WorkingCount() (int, error) {
dogs, err := m.List()
if err != nil {
return 0, err
}
count := 0
for _, dog := range dogs {
if dog.State == StateWorking {
count++
}
}
return count, nil
}