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>
570 lines
14 KiB
Go
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
|
|
}
|