Files
gastown/internal/polecat/manager.go
Steve Yegge 9ebc733623 fix: stop writing Gas Town context to polecat CLAUDE.md
Same fix as crew: polecat manager was overwriting project CLAUDE.md with
Gas Town polecat context. Gas Town context is now injected ephemerally via
SessionStart hook (gt prime) only.

Removed installCLAUDETemplate function and associated tests.
2025-12-30 12:01:27 -08:00

825 lines
26 KiB
Go

package polecat
import (
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"time"
"github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/git"
"github.com/steveyegge/gastown/internal/rig"
)
// Common errors
var (
ErrPolecatExists = errors.New("polecat already exists")
ErrPolecatNotFound = errors.New("polecat not found")
ErrHasChanges = errors.New("polecat has uncommitted changes")
ErrHasUncommittedWork = errors.New("polecat has uncommitted work")
)
// UncommittedWorkError provides details about uncommitted work.
type UncommittedWorkError struct {
PolecatName string
Status *git.UncommittedWorkStatus
}
func (e *UncommittedWorkError) Error() string {
return fmt.Sprintf("polecat %s has uncommitted work: %s", e.PolecatName, e.Status.String())
}
func (e *UncommittedWorkError) Unwrap() error {
return ErrHasUncommittedWork
}
// Manager handles polecat lifecycle.
type Manager struct {
rig *rig.Rig
git *git.Git
beads *beads.Beads
namePool *NamePool
}
// NewManager creates a new polecat manager.
func NewManager(r *rig.Rig, g *git.Git) *Manager {
// Use the rig root for beads operations (rig-level beads at .beads/)
rigPath := r.Path
// Try to load rig settings for namepool config
settingsPath := filepath.Join(r.Path, "settings", "config.json")
var pool *NamePool
settings, err := config.LoadRigSettings(settingsPath)
if err == nil && settings.Namepool != nil {
// Use configured namepool settings
pool = NewNamePoolWithConfig(
r.Path,
r.Name,
settings.Namepool.Style,
settings.Namepool.Names,
settings.Namepool.MaxBeforeNumbering,
)
} else {
// Use defaults
pool = NewNamePool(r.Path, r.Name)
}
_ = pool.Load() // non-fatal: state file may not exist for new rigs
return &Manager{
rig: r,
git: g,
beads: beads.New(rigPath),
namePool: pool,
}
}
// assigneeID returns the beads assignee identifier for a polecat.
// Format: "rig/polecatName" (e.g., "gastown/Toast")
func (m *Manager) assigneeID(name string) string {
return fmt.Sprintf("%s/%s", m.rig.Name, name)
}
// agentBeadID returns the agent bead ID for a polecat.
// Format: "gt-<rig>-polecat-<name>" (e.g., "gt-gastown-polecat-Toast")
func (m *Manager) agentBeadID(name string) string {
return beads.PolecatBeadID(m.rig.Name, name)
}
// getCleanupStatusFromBead reads the cleanup_status from the polecat's agent bead.
// Returns empty string if the bead doesn't exist or has no cleanup_status.
// ZFC #10: This is the ZFC-compliant way to check if removal is safe.
func (m *Manager) getCleanupStatusFromBead(name string) string {
agentID := m.agentBeadID(name)
_, fields, err := m.beads.GetAgentBead(agentID)
if err != nil || fields == nil {
return ""
}
return fields.CleanupStatus
}
// checkCleanupStatus validates the cleanup status against removal safety rules.
// Returns an error if removal should be blocked based on the status.
// force=true: allow has_uncommitted, block has_stash and has_unpushed
// force=false: block all non-clean statuses
func (m *Manager) checkCleanupStatus(name, cleanupStatus string, force bool) error {
switch cleanupStatus {
case "clean":
return nil
case "has_uncommitted":
if force {
return nil // force bypasses uncommitted changes
}
return &UncommittedWorkError{
PolecatName: name,
Status: &git.UncommittedWorkStatus{HasUncommittedChanges: true},
}
case "has_stash":
return &UncommittedWorkError{
PolecatName: name,
Status: &git.UncommittedWorkStatus{StashCount: 1},
}
case "has_unpushed":
return &UncommittedWorkError{
PolecatName: name,
Status: &git.UncommittedWorkStatus{UnpushedCommits: 1},
}
default:
// Unknown status - be conservative and block
return &UncommittedWorkError{
PolecatName: name,
Status: &git.UncommittedWorkStatus{HasUncommittedChanges: true},
}
}
}
// repoBase returns the git directory and Git object to use for worktree operations.
// Prefers the shared bare repo (.repo.git) if it exists, otherwise falls back to mayor/rig.
// The bare repo architecture allows all worktrees (refinery, polecats) to share branch visibility.
func (m *Manager) repoBase() (*git.Git, error) {
// First check for shared bare repo (new architecture)
bareRepoPath := filepath.Join(m.rig.Path, ".repo.git")
if info, err := os.Stat(bareRepoPath); err == nil && info.IsDir() {
// Bare repo exists - use it
return git.NewGitWithDir(bareRepoPath, ""), nil
}
// Fall back to mayor/rig (legacy architecture)
mayorPath := filepath.Join(m.rig.Path, "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
}
// polecatDir returns the directory for a polecat.
func (m *Manager) polecatDir(name string) string {
return filepath.Join(m.rig.Path, "polecats", name)
}
// exists checks if a polecat exists.
func (m *Manager) exists(name string) bool {
_, err := os.Stat(m.polecatDir(name))
return err == nil
}
// Add creates a new polecat as a git worktree from the repo base.
// Uses the shared bare repo (.repo.git) if available, otherwise mayor/rig.
// This is much faster than a full clone and shares objects with all worktrees.
// Polecat state is derived from beads assignee field, not state.json.
//
// Branch naming: Each polecat run gets a unique branch (polecat/<name>-<timestamp>).
// This prevents drift issues from stale branches and ensures a clean starting state.
// Old branches are ephemeral and never pushed to origin.
func (m *Manager) Add(name string) (*Polecat, error) {
if m.exists(name) {
return nil, ErrPolecatExists
}
polecatPath := m.polecatDir(name)
// Unique branch per run - prevents drift from stale branches
// Use base36 encoding for shorter branch names (8 chars vs 13 digits)
branchName := fmt.Sprintf("polecat/%s-%s", name, strconv.FormatInt(time.Now().UnixMilli(), 36))
// Create polecats directory if needed
polecatsDir := filepath.Join(m.rig.Path, "polecats")
if err := os.MkdirAll(polecatsDir, 0755); err != nil {
return nil, fmt.Errorf("creating polecats dir: %w", err)
}
// Get the repo base (bare repo or mayor/rig)
repoGit, err := m.repoBase()
if err != nil {
return nil, fmt.Errorf("finding repo base: %w", err)
}
// Always create fresh branch - unique name guarantees no collision
// git worktree add -b polecat/<name>-<timestamp> <path>
if err := repoGit.WorktreeAdd(polecatPath, branchName); err != nil {
return nil, fmt.Errorf("creating worktree: %w", err)
}
// NOTE: We intentionally do NOT write to CLAUDE.md here.
// Gas Town context is injected ephemerally via SessionStart hook (gt prime).
// Writing to CLAUDE.md would overwrite project instructions and could leak
// Gas Town internals into the project repo if merged.
// Set up shared beads: polecat uses rig's .beads via redirect file.
// This eliminates git sync overhead - all polecats share one database.
if err := m.setupSharedBeads(polecatPath); err != nil {
// Non-fatal - polecat can still work with local beads
// Log warning but don't fail the spawn
fmt.Printf("Warning: could not set up shared beads: %v\n", err)
}
// Create agent bead for ZFC compliance (self-report state).
// State starts as "spawning" - will be updated to "working" when Claude starts.
agentID := m.agentBeadID(name)
_, err = m.beads.CreateAgentBead(agentID, agentID, &beads.AgentFields{
RoleType: "polecat",
Rig: m.rig.Name,
AgentState: "spawning",
RoleBead: "gt-polecat-role",
})
if err != nil {
// Non-fatal - log warning but continue
fmt.Printf("Warning: could not create agent bead: %v\n", err)
}
// Return polecat with derived state (no issue assigned yet = idle)
// State is derived from beads, not stored in state.json
now := time.Now()
polecat := &Polecat{
Name: name,
Rig: m.rig.Name,
State: StateIdle, // No issue assigned yet
ClonePath: polecatPath,
Branch: branchName,
CreatedAt: now,
UpdatedAt: now,
}
return polecat, nil
}
// Remove deletes a polecat worktree.
// If force is true, removes even with uncommitted changes (but not stashes/unpushed).
// Use nuclear=true to bypass ALL safety checks.
func (m *Manager) Remove(name string, force bool) error {
return m.RemoveWithOptions(name, force, false)
}
// RemoveWithOptions deletes a polecat worktree with explicit control over safety checks.
// force=true: bypass uncommitted changes check (legacy behavior)
// nuclear=true: bypass ALL safety checks including stashes and unpushed commits
//
// ZFC #10: Uses cleanup_status from agent bead if available (polecat self-report),
// falls back to git check for backward compatibility.
func (m *Manager) RemoveWithOptions(name string, force, nuclear bool) error {
if !m.exists(name) {
return ErrPolecatNotFound
}
polecatPath := m.polecatDir(name)
// Check for uncommitted work unless bypassed
if !nuclear {
// ZFC #10: First try to read cleanup_status from agent bead
// This is the ZFC-compliant path - trust what the polecat reported
cleanupStatus := m.getCleanupStatusFromBead(name)
if cleanupStatus != "" && cleanupStatus != "unknown" {
// ZFC path: Use polecat's self-reported status
if err := m.checkCleanupStatus(name, cleanupStatus, force); err != nil {
return err
}
} else {
// Fallback path: Check git directly (for polecats that haven't reported yet)
polecatGit := git.NewGit(polecatPath)
status, err := polecatGit.CheckUncommittedWork()
if err == nil && !status.Clean() {
// For backward compatibility: force only bypasses uncommitted changes, not stashes/unpushed
if force {
// Force mode: allow uncommitted changes but still block on stashes/unpushed
if status.StashCount > 0 || status.UnpushedCommits > 0 {
return &UncommittedWorkError{PolecatName: name, Status: status}
}
} else {
return &UncommittedWorkError{PolecatName: name, Status: status}
}
}
}
}
// Get repo base to remove the worktree properly
repoGit, err := m.repoBase()
if err != nil {
// Fall back to direct removal if repo base not found
return os.RemoveAll(polecatPath)
}
// Try to remove as a worktree first (use force flag for worktree removal too)
if err := repoGit.WorktreeRemove(polecatPath, force); err != nil {
// Fall back to direct removal if worktree removal fails
// (e.g., if this is an old-style clone, not a worktree)
if removeErr := os.RemoveAll(polecatPath); removeErr != nil {
return fmt.Errorf("removing polecat dir: %w", removeErr)
}
}
// Prune any stale worktree entries (non-fatal: cleanup only)
_ = repoGit.WorktreePrune()
// Release name back to pool if it's a pooled name (non-fatal: state file update)
m.namePool.Release(name)
_ = m.namePool.Save()
// Delete agent bead (non-fatal: may not exist or beads may not be available)
agentID := m.agentBeadID(name)
if err := m.beads.DeleteAgentBead(agentID); err != nil {
// Only log if not "not found" - it's ok if it doesn't exist
if !errors.Is(err, beads.ErrNotFound) {
fmt.Printf("Warning: could not delete agent bead %s: %v\n", agentID, err)
}
}
return nil
}
// AllocateName allocates a name from the name pool.
// Returns a pooled name (polecat-01 through polecat-50) if available,
// otherwise returns an overflow name (rigname-N).
func (m *Manager) AllocateName() (string, error) {
// First reconcile pool with existing polecats to handle stale state
m.ReconcilePool()
name, err := m.namePool.Allocate()
if err != nil {
return "", err
}
if err := m.namePool.Save(); err != nil {
return "", fmt.Errorf("saving pool state: %w", err)
}
return name, nil
}
// ReleaseName releases a name back to the pool.
// This is called when a polecat is removed.
func (m *Manager) ReleaseName(name string) {
m.namePool.Release(name)
_ = m.namePool.Save() // non-fatal: state file update
}
// Recreate removes an existing polecat and creates a fresh worktree.
// This ensures the polecat starts with the latest code from the base branch.
// The name is preserved (not released to pool) since we're recreating immediately.
// force controls whether to bypass uncommitted changes check.
//
// Branch naming: Each recreation gets a unique branch (polecat/<name>-<timestamp>).
// Old branches are left for garbage collection - they're never pushed to origin.
func (m *Manager) Recreate(name string, force bool) (*Polecat, error) {
if !m.exists(name) {
return nil, ErrPolecatNotFound
}
polecatPath := m.polecatDir(name)
polecatGit := git.NewGit(polecatPath)
// Get the repo base (bare repo or mayor/rig)
repoGit, err := m.repoBase()
if err != nil {
return nil, fmt.Errorf("finding repo base: %w", err)
}
// Check for uncommitted work unless forced
if !force {
status, err := polecatGit.CheckUncommittedWork()
if err == nil && !status.Clean() {
return nil, &UncommittedWorkError{PolecatName: name, Status: status}
}
}
// Delete old agent bead before recreation (non-fatal)
agentID := m.agentBeadID(name)
if err := m.beads.DeleteAgentBead(agentID); err != nil {
if !errors.Is(err, beads.ErrNotFound) {
fmt.Printf("Warning: could not delete old agent bead %s: %v\n", agentID, err)
}
}
// Remove the worktree (use force for git worktree removal)
if err := repoGit.WorktreeRemove(polecatPath, true); err != nil {
// Fall back to direct removal
if removeErr := os.RemoveAll(polecatPath); removeErr != nil {
return nil, fmt.Errorf("removing polecat dir: %w", removeErr)
}
}
// Prune stale worktree entries (non-fatal: cleanup only)
_ = repoGit.WorktreePrune()
// Fetch latest from origin to ensure we have fresh commits (non-fatal: may be offline)
_ = repoGit.Fetch("origin")
// Create fresh worktree with unique branch name
// Old branches are left behind - they're ephemeral (never pushed to origin)
// and will be cleaned up by garbage collection
// Use base36 encoding for shorter branch names (8 chars vs 13 digits)
branchName := fmt.Sprintf("polecat/%s-%s", name, strconv.FormatInt(time.Now().UnixMilli(), 36))
if err := repoGit.WorktreeAdd(polecatPath, branchName); err != nil {
return nil, fmt.Errorf("creating fresh worktree: %w", err)
}
// NOTE: We intentionally do NOT write to CLAUDE.md here.
// Gas Town context is injected ephemerally via SessionStart hook (gt prime).
// Set up shared beads
if err := m.setupSharedBeads(polecatPath); err != nil {
fmt.Printf("Warning: could not set up shared beads: %v\n", err)
}
// Create fresh agent bead for ZFC compliance
_, err = m.beads.CreateAgentBead(agentID, agentID, &beads.AgentFields{
RoleType: "polecat",
Rig: m.rig.Name,
AgentState: "spawning",
RoleBead: "gt-polecat-role",
})
if err != nil {
fmt.Printf("Warning: could not create agent bead: %v\n", err)
}
// Return fresh polecat
now := time.Now()
return &Polecat{
Name: name,
Rig: m.rig.Name,
State: StateIdle,
ClonePath: polecatPath,
Branch: branchName,
CreatedAt: now,
UpdatedAt: now,
}, nil
}
// ReconcilePool syncs pool state with existing polecat directories.
// This should be called to recover from crashes or stale state.
func (m *Manager) ReconcilePool() {
polecats, err := m.List()
if err != nil {
return
}
var names []string
for _, p := range polecats {
names = append(names, p.Name)
}
m.namePool.Reconcile(names)
_ = m.namePool.Save() // non-fatal: state file update
}
// PoolStatus returns information about the name pool.
func (m *Manager) PoolStatus() (active int, names []string) {
return m.namePool.ActiveCount(), m.namePool.ActiveNames()
}
// List returns all polecats in the rig.
func (m *Manager) List() ([]*Polecat, error) {
polecatsDir := filepath.Join(m.rig.Path, "polecats")
entries, err := os.ReadDir(polecatsDir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("reading polecats dir: %w", err)
}
var polecats []*Polecat
for _, entry := range entries {
if !entry.IsDir() {
continue
}
polecat, err := m.Get(entry.Name())
if err != nil {
continue // Skip invalid polecats
}
polecats = append(polecats, polecat)
}
return polecats, nil
}
// Get returns a specific polecat by name.
// State is derived from beads assignee field:
// - If an issue is assigned to this polecat and is open/in_progress: StateWorking
// - If an issue is assigned but closed: StateDone
// - If no issue assigned: StateIdle
func (m *Manager) Get(name string) (*Polecat, error) {
if !m.exists(name) {
return nil, ErrPolecatNotFound
}
return m.loadFromBeads(name)
}
// SetState updates a polecat's state.
// In the beads model, state is derived from issue status:
// - StateWorking/StateActive: issue status set to in_progress
// - StateDone/StateIdle: assignee cleared from issue
// - StateStuck: issue status set to blocked (if supported)
// If beads is not available, this is a no-op.
func (m *Manager) SetState(name string, state State) error {
if !m.exists(name) {
return ErrPolecatNotFound
}
// Find the issue assigned to this polecat
assignee := m.assigneeID(name)
issue, err := m.beads.GetAssignedIssue(assignee)
if err != nil {
// If beads is not available, treat as no-op (state can't be changed)
return nil
}
switch state {
case StateWorking, StateActive:
// Set issue to in_progress if there is one
if issue != nil {
status := "in_progress"
if err := m.beads.Update(issue.ID, beads.UpdateOptions{Status: &status}); err != nil {
return fmt.Errorf("setting issue status: %w", err)
}
}
case StateDone, StateIdle:
// Clear assignment when done/idle
if issue != nil {
empty := ""
if err := m.beads.Update(issue.ID, beads.UpdateOptions{Assignee: &empty}); err != nil {
return fmt.Errorf("clearing assignee: %w", err)
}
}
case StateStuck:
// Mark issue as blocked if supported, otherwise just note in issue
if issue != nil {
// For now, just keep the assignment - the issue's blocked_by would indicate stuck
// We could add a status="blocked" here if beads supports it
}
}
return nil
}
// AssignIssue assigns an issue to a polecat by setting the issue's assignee in beads.
func (m *Manager) AssignIssue(name, issue string) error {
if !m.exists(name) {
return ErrPolecatNotFound
}
// Set the issue's assignee to this polecat
assignee := m.assigneeID(name)
status := "in_progress"
if err := m.beads.Update(issue, beads.UpdateOptions{
Assignee: &assignee,
Status: &status,
}); err != nil {
return fmt.Errorf("setting issue assignee: %w", err)
}
return nil
}
// ClearIssue removes the issue assignment from a polecat.
// In the transient model, this transitions to Done state for cleanup.
// This clears the assignee from the currently assigned issue in beads.
// If beads is not available, this is a no-op.
func (m *Manager) ClearIssue(name string) error {
if !m.exists(name) {
return ErrPolecatNotFound
}
// Find the issue assigned to this polecat
assignee := m.assigneeID(name)
issue, err := m.beads.GetAssignedIssue(assignee)
if err != nil {
// If beads is not available, treat as no-op
return nil
}
if issue == nil {
// No issue assigned, nothing to clear
return nil
}
// Clear the assignee from the issue
empty := ""
if err := m.beads.Update(issue.ID, beads.UpdateOptions{
Assignee: &empty,
}); err != nil {
return fmt.Errorf("clearing issue assignee: %w", err)
}
return nil
}
// Wake transitions a polecat from idle to active.
// Deprecated: In the transient model, polecats start in working state.
// This method is kept for backward compatibility with existing polecats.
func (m *Manager) Wake(name string) error {
polecat, err := m.Get(name)
if err != nil {
return err
}
// Accept both idle and done states for legacy compatibility
if polecat.State != StateIdle && polecat.State != StateDone {
return fmt.Errorf("polecat is not idle (state: %s)", polecat.State)
}
return m.SetState(name, StateWorking)
}
// Sleep transitions a polecat from active to idle.
// Deprecated: In the transient model, polecats are deleted when done.
// This method is kept for backward compatibility.
func (m *Manager) Sleep(name string) error {
polecat, err := m.Get(name)
if err != nil {
return err
}
// Accept working state as well for legacy compatibility
if polecat.State != StateActive && polecat.State != StateWorking {
return fmt.Errorf("polecat is not active (state: %s)", polecat.State)
}
return m.SetState(name, StateDone)
}
// Finish transitions a polecat from working/done/stuck to idle and clears the issue.
// This clears the assignee from any assigned issue.
func (m *Manager) Finish(name string) error {
polecat, err := m.Get(name)
if err != nil {
return err
}
// Only allow finishing from working-related states
switch polecat.State {
case StateWorking, StateDone, StateStuck:
// OK to finish
default:
return fmt.Errorf("polecat is not in a finishing state (state: %s)", polecat.State)
}
// Clear the issue assignment
return m.ClearIssue(name)
}
// Reset forces a polecat to idle state regardless of current state.
// This clears the assignee from any assigned issue.
func (m *Manager) Reset(name string) error {
if !m.exists(name) {
return ErrPolecatNotFound
}
// Clear the issue assignment
return m.ClearIssue(name)
}
// loadFromBeads gets polecat info from beads assignee field.
// State is simple: issue assigned → working, no issue → idle.
// We don't interpret issue status (ZFC: Go is transport, not decision-maker).
func (m *Manager) loadFromBeads(name string) (*Polecat, error) {
polecatPath := m.polecatDir(name)
// Get actual branch from worktree (branches are now timestamped)
polecatGit := git.NewGit(polecatPath)
branchName, err := polecatGit.CurrentBranch()
if err != nil {
// Fall back to old format if we can't read the branch
branchName = fmt.Sprintf("polecat/%s", name)
}
// Query beads for assigned issue
assignee := m.assigneeID(name)
issue, beadsErr := m.beads.GetAssignedIssue(assignee)
if beadsErr != nil {
// If beads query fails, return basic polecat info
// This allows the system to work even if beads is not available
return &Polecat{
Name: name,
Rig: m.rig.Name,
State: StateIdle,
ClonePath: polecatPath,
Branch: branchName,
}, nil
}
// Simple rule: has issue = working, no issue = idle
// We don't interpret issue.Status - that's for Claude to decide
state := StateIdle
issueID := ""
if issue != nil {
issueID = issue.ID
state = StateWorking
}
return &Polecat{
Name: name,
Rig: m.rig.Name,
State: state,
ClonePath: polecatPath,
Branch: branchName,
Issue: issueID,
}, nil
}
// setupSharedBeads creates a redirect file so the polecat uses the rig's shared .beads database.
// This eliminates the need for git sync between polecat clones - all polecats share one database.
//
// Structure:
//
// rig/
// .beads/ <- Shared database (ensured to exist)
// polecats/
// <name>/
// .beads/
// redirect <- Contains "../../.beads"
//
// IMPORTANT: If the polecat was created from a branch that had .beads/ tracked in git,
// those files will be present. We must clean them out and replace with just the redirect.
func (m *Manager) setupSharedBeads(polecatPath string) error {
// Ensure rig root has .beads/ directory
rigBeadsDir := filepath.Join(m.rig.Path, ".beads")
if err := os.MkdirAll(rigBeadsDir, 0755); err != nil {
return fmt.Errorf("creating rig .beads dir: %w", err)
}
// Clean up any existing .beads/ contents from the branch
// This handles the case where the polecat was created from a branch that
// had .beads/ tracked (e.g., from previous bd sync operations)
polecatBeadsDir := filepath.Join(polecatPath, ".beads")
if _, err := os.Stat(polecatBeadsDir); err == nil {
// Directory exists - remove it entirely and recreate fresh
if err := os.RemoveAll(polecatBeadsDir); err != nil {
return fmt.Errorf("cleaning existing .beads dir: %w", err)
}
}
// Create fresh .beads directory
if err := os.MkdirAll(polecatBeadsDir, 0755); err != nil {
return fmt.Errorf("creating polecat .beads dir: %w", err)
}
// Create redirect file pointing to mayor/rig/.beads (the canonical beads location)
// Path is relative from polecats/<name>/.beads/ to mayor/rig/.beads/
// We go directly to mayor/rig/.beads, not through rig root, to match crew workers
redirectPath := filepath.Join(polecatBeadsDir, "redirect")
redirectContent := "../../mayor/rig/.beads\n"
if err := os.WriteFile(redirectPath, []byte(redirectContent), 0644); err != nil {
return fmt.Errorf("creating redirect file: %w", err)
}
return nil
}
// CleanupStaleBranches removes orphaned polecat branches that are no longer in use.
// This includes:
// - Branches for polecats that no longer exist
// - Old timestamped branches (keeps only the most recent per polecat name)
// Returns the number of branches deleted.
func (m *Manager) CleanupStaleBranches() (int, error) {
repoGit, err := m.repoBase()
if err != nil {
return 0, fmt.Errorf("finding repo base: %w", err)
}
// List all polecat branches
branches, err := repoGit.ListBranches("polecat/*")
if err != nil {
return 0, fmt.Errorf("listing branches: %w", err)
}
if len(branches) == 0 {
return 0, nil
}
// Get list of existing polecats
polecats, err := m.List()
if err != nil {
return 0, fmt.Errorf("listing polecats: %w", err)
}
// Build set of current polecat branches (from actual polecat objects)
currentBranches := make(map[string]bool)
for _, p := range polecats {
currentBranches[p.Branch] = true
}
// Delete branches not in current set
deleted := 0
for _, branch := range branches {
if currentBranches[branch] {
continue // This branch is in use
}
// Delete orphaned branch
if err := repoGit.DeleteBranch(branch, true); err != nil {
// Log but continue - non-fatal
fmt.Printf("Warning: could not delete branch %s: %v\n", branch, err)
continue
}
deleted++
}
return deleted, nil
}