Polish help text across all agent commands to clarify roles: - crew: persistent workspaces vs ephemeral polecats - deacon: town-level watchdog receiving heartbeats - dog: cross-rig infrastructure workers (cats vs dogs) - mayor: Chief of Staff for cross-rig coordination - nudge: universal synchronous messaging API - polecat: ephemeral one-task workers, self-cleaning - refinery: merge queue serializer per rig - witness: per-rig polecat health monitor Add comprehensive gt nudge documentation to crew template explaining when to use nudge vs mail, common patterns, and target shortcuts. Add orphan-process-cleanup step to deacon patrol formula to clean up claude subagent processes that fail to exit (TTY = "?"). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1390 lines
40 KiB
Go
1390 lines
40 KiB
Go
package cmd
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/spf13/cobra"
|
||
"github.com/steveyegge/gastown/internal/beads"
|
||
"github.com/steveyegge/gastown/internal/git"
|
||
"github.com/steveyegge/gastown/internal/polecat"
|
||
"github.com/steveyegge/gastown/internal/rig"
|
||
"github.com/steveyegge/gastown/internal/runtime"
|
||
"github.com/steveyegge/gastown/internal/style"
|
||
"github.com/steveyegge/gastown/internal/tmux"
|
||
)
|
||
|
||
// Polecat command flags
|
||
var (
|
||
polecatListJSON bool
|
||
polecatListAll bool
|
||
polecatForce bool
|
||
polecatRemoveAll bool
|
||
)
|
||
|
||
var polecatCmd = &cobra.Command{
|
||
Use: "polecat",
|
||
Aliases: []string{"polecats"},
|
||
GroupID: GroupAgents,
|
||
Short: "Manage polecats (ephemeral workers, one task then nuked)",
|
||
RunE: requireSubcommand,
|
||
Long: `Manage polecat lifecycle in rigs.
|
||
|
||
Polecats are EPHEMERAL workers: spawned for one task, nuked when done.
|
||
There is NO idle state. A polecat is either:
|
||
- Working: Actively doing assigned work
|
||
- Stalled: Session crashed mid-work (needs Witness intervention)
|
||
- Zombie: Finished but gt done failed (needs cleanup)
|
||
|
||
Self-cleaning model: When work completes, the polecat runs 'gt done',
|
||
which pushes the branch, submits to the merge queue, and exits. The
|
||
Witness then nukes the sandbox. Polecats don't wait for more work.
|
||
|
||
Session vs sandbox: The Claude session cycles frequently (handoffs,
|
||
compaction). The git worktree (sandbox) persists until nuke. Work
|
||
survives session restarts.
|
||
|
||
Cats build features. Dogs clean up messes.`,
|
||
}
|
||
|
||
var polecatListCmd = &cobra.Command{
|
||
Use: "list [rig]",
|
||
Short: "List polecats in a rig",
|
||
Long: `List polecats in a rig or all rigs.
|
||
|
||
In the transient model, polecats exist only while working. The list shows
|
||
all currently active polecats with their states:
|
||
- working: Actively working on an issue
|
||
- done: Completed work, waiting for cleanup
|
||
- stuck: Needs assistance
|
||
|
||
Examples:
|
||
gt polecat list greenplace
|
||
gt polecat list --all
|
||
gt polecat list greenplace --json`,
|
||
RunE: runPolecatList,
|
||
}
|
||
|
||
var polecatAddCmd = &cobra.Command{
|
||
Use: "add <rig> <name>",
|
||
Short: "Add a new polecat to a rig (DEPRECATED)",
|
||
Deprecated: "use 'gt polecat identity add' instead. This command will be removed in v1.0.",
|
||
Long: `Add a new polecat to a rig.
|
||
|
||
DEPRECATED: Use 'gt polecat identity add' instead. This command will be removed in v1.0.
|
||
|
||
Creates a polecat directory, clones the rig repo, creates a work branch,
|
||
and initializes state.
|
||
|
||
Example:
|
||
gt polecat identity add greenplace Toast # Preferred
|
||
gt polecat add greenplace Toast # Deprecated`,
|
||
Args: cobra.ExactArgs(2),
|
||
RunE: runPolecatAdd,
|
||
}
|
||
|
||
var polecatRemoveCmd = &cobra.Command{
|
||
Use: "remove <rig>/<polecat>... | <rig> --all",
|
||
Short: "Remove polecats from a rig",
|
||
Long: `Remove one or more polecats from a rig.
|
||
|
||
Fails if session is running (stop first).
|
||
Warns if uncommitted changes exist.
|
||
Use --force to bypass checks.
|
||
|
||
Examples:
|
||
gt polecat remove greenplace/Toast
|
||
gt polecat remove greenplace/Toast greenplace/Furiosa
|
||
gt polecat remove greenplace --all
|
||
gt polecat remove greenplace --all --force`,
|
||
Args: cobra.MinimumNArgs(1),
|
||
RunE: runPolecatRemove,
|
||
}
|
||
|
||
var polecatSyncCmd = &cobra.Command{
|
||
Use: "sync <rig>/<polecat>",
|
||
Short: "Sync beads for a polecat",
|
||
Long: `Sync beads for a polecat's worktree.
|
||
|
||
Runs 'bd sync' in the polecat's worktree to push local beads changes
|
||
to the shared sync branch and pull remote changes.
|
||
|
||
Use --all to sync all polecats in a rig.
|
||
Use --from-main to only pull (no push).
|
||
|
||
Examples:
|
||
gt polecat sync greenplace/Toast
|
||
gt polecat sync greenplace --all
|
||
gt polecat sync greenplace/Toast --from-main`,
|
||
Args: cobra.MaximumNArgs(1),
|
||
RunE: runPolecatSync,
|
||
}
|
||
|
||
var polecatStatusCmd = &cobra.Command{
|
||
Use: "status <rig>/<polecat>",
|
||
Short: "Show detailed status for a polecat",
|
||
Long: `Show detailed status for a polecat.
|
||
|
||
Displays comprehensive information including:
|
||
- Current lifecycle state (working, done, stuck, idle)
|
||
- Assigned issue (if any)
|
||
- Session status (running/stopped, attached/detached)
|
||
- Session creation time
|
||
- Last activity time
|
||
|
||
Examples:
|
||
gt polecat status greenplace/Toast
|
||
gt polecat status greenplace/Toast --json`,
|
||
Args: cobra.ExactArgs(1),
|
||
RunE: runPolecatStatus,
|
||
}
|
||
|
||
var (
|
||
polecatSyncAll bool
|
||
polecatSyncFromMain bool
|
||
polecatStatusJSON bool
|
||
polecatGitStateJSON bool
|
||
polecatGCDryRun bool
|
||
polecatNukeAll bool
|
||
polecatNukeDryRun bool
|
||
polecatNukeForce bool
|
||
polecatCheckRecoveryJSON bool
|
||
)
|
||
|
||
var polecatGCCmd = &cobra.Command{
|
||
Use: "gc <rig>",
|
||
Short: "Garbage collect stale polecat branches",
|
||
Long: `Garbage collect stale polecat branches in a rig.
|
||
|
||
Polecats use unique timestamped branches (polecat/<name>-<timestamp>) to
|
||
prevent drift issues. Over time, these branches accumulate when stale
|
||
polecats are repaired.
|
||
|
||
This command removes orphaned branches:
|
||
- Branches for polecats that no longer exist
|
||
- Old timestamped branches (keeps only the current one per polecat)
|
||
|
||
Examples:
|
||
gt polecat gc greenplace
|
||
gt polecat gc greenplace --dry-run`,
|
||
Args: cobra.ExactArgs(1),
|
||
RunE: runPolecatGC,
|
||
}
|
||
|
||
var polecatNukeCmd = &cobra.Command{
|
||
Use: "nuke <rig>/<polecat>... | <rig> --all",
|
||
Short: "Completely destroy a polecat (session, worktree, branch, agent bead)",
|
||
Long: `Completely destroy a polecat and all its artifacts.
|
||
|
||
This is the nuclear option for post-merge cleanup. It:
|
||
1. Kills the Claude session (if running)
|
||
2. Deletes the git worktree (bypassing all safety checks)
|
||
3. Deletes the polecat branch
|
||
4. Closes the agent bead (if exists)
|
||
|
||
SAFETY CHECKS: The command refuses to nuke a polecat if:
|
||
- Worktree has unpushed/uncommitted changes
|
||
- Polecat has an open merge request (MR bead)
|
||
- Polecat has work on its hook
|
||
|
||
Use --force to bypass safety checks (LOSES WORK).
|
||
Use --dry-run to see what would happen and safety check status.
|
||
|
||
Examples:
|
||
gt polecat nuke greenplace/Toast
|
||
gt polecat nuke greenplace/Toast greenplace/Furiosa
|
||
gt polecat nuke greenplace --all
|
||
gt polecat nuke greenplace --all --dry-run
|
||
gt polecat nuke greenplace/Toast --force # bypass safety checks`,
|
||
Args: cobra.MinimumNArgs(1),
|
||
RunE: runPolecatNuke,
|
||
}
|
||
|
||
var polecatGitStateCmd = &cobra.Command{
|
||
Use: "git-state <rig>/<polecat>",
|
||
Short: "Show git state for pre-kill verification",
|
||
Long: `Show git state for a polecat's worktree.
|
||
|
||
Used by the Witness for pre-kill verification to ensure no work is lost.
|
||
Returns whether the worktree is clean (safe to kill) or dirty (needs cleanup).
|
||
|
||
Checks:
|
||
- Working tree: uncommitted changes
|
||
- Unpushed commits: commits ahead of origin/main
|
||
- Stashes: stashed changes
|
||
|
||
Examples:
|
||
gt polecat git-state greenplace/Toast
|
||
gt polecat git-state greenplace/Toast --json`,
|
||
Args: cobra.ExactArgs(1),
|
||
RunE: runPolecatGitState,
|
||
}
|
||
|
||
var polecatCheckRecoveryCmd = &cobra.Command{
|
||
Use: "check-recovery <rig>/<polecat>",
|
||
Short: "Check if polecat needs recovery vs safe to nuke",
|
||
Long: `Check recovery status of a polecat based on cleanup_status in agent bead.
|
||
|
||
Used by the Witness to determine appropriate cleanup action:
|
||
- SAFE_TO_NUKE: cleanup_status is 'clean' - no work at risk
|
||
- NEEDS_RECOVERY: cleanup_status indicates unpushed/uncommitted work
|
||
|
||
This prevents accidental data loss when cleaning up dormant polecats.
|
||
The Witness should escalate NEEDS_RECOVERY cases to the Mayor.
|
||
|
||
Examples:
|
||
gt polecat check-recovery greenplace/Toast
|
||
gt polecat check-recovery greenplace/Toast --json`,
|
||
Args: cobra.ExactArgs(1),
|
||
RunE: runPolecatCheckRecovery,
|
||
}
|
||
|
||
var (
|
||
polecatStaleJSON bool
|
||
polecatStaleThreshold int
|
||
polecatStaleCleanup bool
|
||
)
|
||
|
||
var polecatStaleCmd = &cobra.Command{
|
||
Use: "stale <rig>",
|
||
Short: "Detect stale polecats that may need cleanup",
|
||
Long: `Detect stale polecats in a rig that are candidates for cleanup.
|
||
|
||
A polecat is considered stale if:
|
||
- No active tmux session
|
||
- Way behind main (>threshold commits) OR no agent bead
|
||
- Has no uncommitted work that could be lost
|
||
|
||
The default threshold is 20 commits behind main.
|
||
|
||
Use --cleanup to automatically nuke stale polecats that are safe to remove.
|
||
Use --dry-run with --cleanup to see what would be cleaned.
|
||
|
||
Examples:
|
||
gt polecat stale greenplace
|
||
gt polecat stale greenplace --threshold 50
|
||
gt polecat stale greenplace --json
|
||
gt polecat stale greenplace --cleanup
|
||
gt polecat stale greenplace --cleanup --dry-run`,
|
||
Args: cobra.ExactArgs(1),
|
||
RunE: runPolecatStale,
|
||
}
|
||
|
||
func init() {
|
||
// List flags
|
||
polecatListCmd.Flags().BoolVar(&polecatListJSON, "json", false, "Output as JSON")
|
||
polecatListCmd.Flags().BoolVar(&polecatListAll, "all", false, "List polecats in all rigs")
|
||
|
||
// Remove flags
|
||
polecatRemoveCmd.Flags().BoolVarP(&polecatForce, "force", "f", false, "Force removal, bypassing checks")
|
||
polecatRemoveCmd.Flags().BoolVar(&polecatRemoveAll, "all", false, "Remove all polecats in the rig")
|
||
|
||
// Sync flags
|
||
polecatSyncCmd.Flags().BoolVar(&polecatSyncAll, "all", false, "Sync all polecats in the rig")
|
||
polecatSyncCmd.Flags().BoolVar(&polecatSyncFromMain, "from-main", false, "Pull only, no push")
|
||
|
||
// Status flags
|
||
polecatStatusCmd.Flags().BoolVar(&polecatStatusJSON, "json", false, "Output as JSON")
|
||
|
||
// Git-state flags
|
||
polecatGitStateCmd.Flags().BoolVar(&polecatGitStateJSON, "json", false, "Output as JSON")
|
||
|
||
// GC flags
|
||
polecatGCCmd.Flags().BoolVar(&polecatGCDryRun, "dry-run", false, "Show what would be deleted without deleting")
|
||
|
||
// Nuke flags
|
||
polecatNukeCmd.Flags().BoolVar(&polecatNukeAll, "all", false, "Nuke all polecats in the rig")
|
||
polecatNukeCmd.Flags().BoolVar(&polecatNukeDryRun, "dry-run", false, "Show what would be nuked without doing it")
|
||
polecatNukeCmd.Flags().BoolVarP(&polecatNukeForce, "force", "f", false, "Force nuke, bypassing all safety checks (LOSES WORK)")
|
||
|
||
// Check-recovery flags
|
||
polecatCheckRecoveryCmd.Flags().BoolVar(&polecatCheckRecoveryJSON, "json", false, "Output as JSON")
|
||
|
||
// Stale flags
|
||
polecatStaleCmd.Flags().BoolVar(&polecatStaleJSON, "json", false, "Output as JSON")
|
||
polecatStaleCmd.Flags().IntVar(&polecatStaleThreshold, "threshold", 20, "Commits behind main to consider stale")
|
||
polecatStaleCmd.Flags().BoolVar(&polecatStaleCleanup, "cleanup", false, "Automatically nuke stale polecats")
|
||
|
||
// Add subcommands
|
||
polecatCmd.AddCommand(polecatListCmd)
|
||
polecatCmd.AddCommand(polecatAddCmd)
|
||
polecatCmd.AddCommand(polecatRemoveCmd)
|
||
polecatCmd.AddCommand(polecatSyncCmd)
|
||
polecatCmd.AddCommand(polecatStatusCmd)
|
||
polecatCmd.AddCommand(polecatGitStateCmd)
|
||
polecatCmd.AddCommand(polecatCheckRecoveryCmd)
|
||
polecatCmd.AddCommand(polecatGCCmd)
|
||
polecatCmd.AddCommand(polecatNukeCmd)
|
||
polecatCmd.AddCommand(polecatStaleCmd)
|
||
|
||
rootCmd.AddCommand(polecatCmd)
|
||
}
|
||
|
||
// PolecatListItem represents a polecat in list output.
|
||
type PolecatListItem struct {
|
||
Rig string `json:"rig"`
|
||
Name string `json:"name"`
|
||
State polecat.State `json:"state"`
|
||
Issue string `json:"issue,omitempty"`
|
||
SessionRunning bool `json:"session_running"`
|
||
}
|
||
|
||
// getPolecatManager creates a polecat manager for the given rig.
|
||
func getPolecatManager(rigName string) (*polecat.Manager, *rig.Rig, error) {
|
||
_, r, err := getRig(rigName)
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
|
||
polecatGit := git.NewGit(r.Path)
|
||
t := tmux.NewTmux()
|
||
mgr := polecat.NewManager(r, polecatGit, t)
|
||
|
||
return mgr, r, nil
|
||
}
|
||
|
||
func runPolecatList(cmd *cobra.Command, args []string) error {
|
||
var rigs []*rig.Rig
|
||
|
||
if polecatListAll {
|
||
// List all rigs
|
||
allRigs, _, err := getAllRigs()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
rigs = allRigs
|
||
} else {
|
||
// Need a rig name
|
||
if len(args) < 1 {
|
||
return fmt.Errorf("rig name required (or use --all)")
|
||
}
|
||
_, r, err := getPolecatManager(args[0])
|
||
if err != nil {
|
||
return err
|
||
}
|
||
rigs = []*rig.Rig{r}
|
||
}
|
||
|
||
// Collect polecats from all rigs
|
||
t := tmux.NewTmux()
|
||
var allPolecats []PolecatListItem
|
||
|
||
for _, r := range rigs {
|
||
polecatGit := git.NewGit(r.Path)
|
||
mgr := polecat.NewManager(r, polecatGit, t)
|
||
polecatMgr := polecat.NewSessionManager(t, r)
|
||
|
||
polecats, err := mgr.List()
|
||
if err != nil {
|
||
fmt.Fprintf(os.Stderr, "warning: failed to list polecats in %s: %v\n", r.Name, err)
|
||
continue
|
||
}
|
||
|
||
for _, p := range polecats {
|
||
running, _ := polecatMgr.IsRunning(p.Name)
|
||
allPolecats = append(allPolecats, PolecatListItem{
|
||
Rig: r.Name,
|
||
Name: p.Name,
|
||
State: p.State,
|
||
Issue: p.Issue,
|
||
SessionRunning: running,
|
||
})
|
||
}
|
||
}
|
||
|
||
// Output
|
||
if polecatListJSON {
|
||
enc := json.NewEncoder(os.Stdout)
|
||
enc.SetIndent("", " ")
|
||
return enc.Encode(allPolecats)
|
||
}
|
||
|
||
if len(allPolecats) == 0 {
|
||
fmt.Println("No active polecats found.")
|
||
return nil
|
||
}
|
||
|
||
fmt.Printf("%s\n\n", style.Bold.Render("Active Polecats"))
|
||
for _, p := range allPolecats {
|
||
// Session indicator
|
||
sessionStatus := style.Dim.Render("○")
|
||
if p.SessionRunning {
|
||
sessionStatus = style.Success.Render("●")
|
||
}
|
||
|
||
// Display actual state (no normalization - idle means idle)
|
||
displayState := p.State
|
||
|
||
// State color
|
||
stateStr := string(displayState)
|
||
switch displayState {
|
||
case polecat.StateWorking:
|
||
stateStr = style.Info.Render(stateStr)
|
||
case polecat.StateStuck:
|
||
stateStr = style.Warning.Render(stateStr)
|
||
case polecat.StateDone:
|
||
stateStr = style.Success.Render(stateStr)
|
||
default:
|
||
stateStr = style.Dim.Render(stateStr)
|
||
}
|
||
|
||
fmt.Printf(" %s %s/%s %s\n", sessionStatus, p.Rig, p.Name, stateStr)
|
||
if p.Issue != "" {
|
||
fmt.Printf(" %s\n", style.Dim.Render(p.Issue))
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func runPolecatAdd(cmd *cobra.Command, args []string) error {
|
||
// Emit deprecation warning
|
||
fmt.Fprintf(os.Stderr, "%s 'gt polecat add' is deprecated. Use 'gt polecat identity add' instead.\n",
|
||
style.Warning.Render("Warning:"))
|
||
fmt.Fprintf(os.Stderr, " This command will be removed in v1.0.\n\n")
|
||
|
||
rigName := args[0]
|
||
polecatName := args[1]
|
||
|
||
mgr, _, err := getPolecatManager(rigName)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
fmt.Printf("Adding polecat %s to rig %s...\n", polecatName, rigName)
|
||
|
||
p, err := mgr.Add(polecatName)
|
||
if err != nil {
|
||
return fmt.Errorf("adding polecat: %w", err)
|
||
}
|
||
|
||
fmt.Printf("%s Polecat %s added.\n", style.SuccessPrefix, p.Name)
|
||
fmt.Printf(" %s\n", style.Dim.Render(p.ClonePath))
|
||
fmt.Printf(" Branch: %s\n", style.Dim.Render(p.Branch))
|
||
|
||
return nil
|
||
}
|
||
|
||
func runPolecatRemove(cmd *cobra.Command, args []string) error {
|
||
targets, err := resolvePolecatTargets(args, polecatRemoveAll)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
if len(targets) == 0 {
|
||
fmt.Println("No polecats to remove.")
|
||
return nil
|
||
}
|
||
|
||
// Remove each polecat
|
||
t := tmux.NewTmux()
|
||
var removeErrors []string
|
||
removed := 0
|
||
|
||
for _, p := range targets {
|
||
// Check if session is running
|
||
if !polecatForce {
|
||
polecatMgr := polecat.NewSessionManager(t, p.r)
|
||
running, _ := polecatMgr.IsRunning(p.polecatName)
|
||
if running {
|
||
removeErrors = append(removeErrors, fmt.Sprintf("%s/%s: session is running (stop first or use --force)", p.rigName, p.polecatName))
|
||
continue
|
||
}
|
||
}
|
||
|
||
fmt.Printf("Removing polecat %s/%s...\n", p.rigName, p.polecatName)
|
||
|
||
if err := p.mgr.Remove(p.polecatName, polecatForce); err != nil {
|
||
if errors.Is(err, polecat.ErrHasChanges) {
|
||
removeErrors = append(removeErrors, fmt.Sprintf("%s/%s: has uncommitted changes (use --force)", p.rigName, p.polecatName))
|
||
} else {
|
||
removeErrors = append(removeErrors, fmt.Sprintf("%s/%s: %v", p.rigName, p.polecatName, err))
|
||
}
|
||
continue
|
||
}
|
||
|
||
fmt.Printf(" %s removed\n", style.Success.Render("✓"))
|
||
removed++
|
||
}
|
||
|
||
// Report results
|
||
if len(removeErrors) > 0 {
|
||
fmt.Printf("\n%s Some removals failed:\n", style.Warning.Render("Warning:"))
|
||
for _, e := range removeErrors {
|
||
fmt.Printf(" - %s\n", e)
|
||
}
|
||
}
|
||
|
||
if removed > 0 {
|
||
fmt.Printf("\n%s Removed %d polecat(s).\n", style.SuccessPrefix, removed)
|
||
}
|
||
|
||
if len(removeErrors) > 0 {
|
||
return fmt.Errorf("%d removal(s) failed", len(removeErrors))
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func runPolecatSync(cmd *cobra.Command, args []string) error {
|
||
if len(args) < 1 {
|
||
return fmt.Errorf("rig or rig/polecat address required")
|
||
}
|
||
|
||
// Parse address - could be "rig" or "rig/polecat"
|
||
rigName, polecatName, err := parseAddress(args[0])
|
||
if err != nil {
|
||
// Might just be a rig name
|
||
rigName = args[0]
|
||
polecatName = ""
|
||
}
|
||
|
||
mgr, _, err := getPolecatManager(rigName)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// Get list of polecats to sync
|
||
var polecatsToSync []string
|
||
if polecatSyncAll || polecatName == "" {
|
||
polecats, err := mgr.List()
|
||
if err != nil {
|
||
return fmt.Errorf("listing polecats: %w", err)
|
||
}
|
||
for _, p := range polecats {
|
||
polecatsToSync = append(polecatsToSync, p.Name)
|
||
}
|
||
} else {
|
||
polecatsToSync = []string{polecatName}
|
||
}
|
||
|
||
if len(polecatsToSync) == 0 {
|
||
fmt.Println("No polecats to sync.")
|
||
return nil
|
||
}
|
||
|
||
// Sync each polecat
|
||
var syncErrors []string
|
||
for _, name := range polecatsToSync {
|
||
// Get polecat to get correct clone path (handles old vs new structure)
|
||
p, err := mgr.Get(name)
|
||
if err != nil {
|
||
syncErrors = append(syncErrors, fmt.Sprintf("%s: %v", name, err))
|
||
continue
|
||
}
|
||
|
||
// Check directory exists
|
||
if _, err := os.Stat(p.ClonePath); os.IsNotExist(err) {
|
||
syncErrors = append(syncErrors, fmt.Sprintf("%s: directory not found", name))
|
||
continue
|
||
}
|
||
|
||
// Build sync command
|
||
syncArgs := []string{"sync"}
|
||
if polecatSyncFromMain {
|
||
syncArgs = append(syncArgs, "--from-main")
|
||
}
|
||
|
||
fmt.Printf("Syncing %s/%s...\n", rigName, name)
|
||
|
||
syncCmd := exec.Command("bd", syncArgs...)
|
||
syncCmd.Dir = p.ClonePath
|
||
output, err := syncCmd.CombinedOutput()
|
||
if err != nil {
|
||
syncErrors = append(syncErrors, fmt.Sprintf("%s: %v", name, err))
|
||
if len(output) > 0 {
|
||
fmt.Printf(" %s\n", style.Dim.Render(string(output)))
|
||
}
|
||
} else {
|
||
fmt.Printf(" %s\n", style.Success.Render("✓ synced"))
|
||
}
|
||
}
|
||
|
||
if len(syncErrors) > 0 {
|
||
fmt.Printf("\n%s Some syncs failed:\n", style.Warning.Render("Warning:"))
|
||
for _, e := range syncErrors {
|
||
fmt.Printf(" - %s\n", e)
|
||
}
|
||
return fmt.Errorf("%d sync(s) failed", len(syncErrors))
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// PolecatStatus represents detailed polecat status for JSON output.
|
||
type PolecatStatus struct {
|
||
Rig string `json:"rig"`
|
||
Name string `json:"name"`
|
||
State polecat.State `json:"state"`
|
||
Issue string `json:"issue,omitempty"`
|
||
ClonePath string `json:"clone_path"`
|
||
Branch string `json:"branch"`
|
||
SessionRunning bool `json:"session_running"`
|
||
SessionID string `json:"session_id,omitempty"`
|
||
Attached bool `json:"attached,omitempty"`
|
||
Windows int `json:"windows,omitempty"`
|
||
CreatedAt string `json:"created_at,omitempty"`
|
||
LastActivity string `json:"last_activity,omitempty"`
|
||
}
|
||
|
||
func runPolecatStatus(cmd *cobra.Command, args []string) error {
|
||
rigName, polecatName, err := parseAddress(args[0])
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
mgr, r, err := getPolecatManager(rigName)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// Get polecat info
|
||
p, err := mgr.Get(polecatName)
|
||
if err != nil {
|
||
return fmt.Errorf("polecat '%s' not found in rig '%s'", polecatName, rigName)
|
||
}
|
||
|
||
// Get session info
|
||
t := tmux.NewTmux()
|
||
polecatMgr := polecat.NewSessionManager(t, r)
|
||
sessInfo, err := polecatMgr.Status(polecatName)
|
||
if err != nil {
|
||
// Non-fatal - continue without session info
|
||
sessInfo = &polecat.SessionInfo{
|
||
Polecat: polecatName,
|
||
Running: false,
|
||
}
|
||
}
|
||
|
||
// JSON output
|
||
if polecatStatusJSON {
|
||
status := PolecatStatus{
|
||
Rig: rigName,
|
||
Name: polecatName,
|
||
State: p.State,
|
||
Issue: p.Issue,
|
||
ClonePath: p.ClonePath,
|
||
Branch: p.Branch,
|
||
SessionRunning: sessInfo.Running,
|
||
SessionID: sessInfo.SessionID,
|
||
Attached: sessInfo.Attached,
|
||
Windows: sessInfo.Windows,
|
||
}
|
||
if !sessInfo.Created.IsZero() {
|
||
status.CreatedAt = sessInfo.Created.Format("2006-01-02 15:04:05")
|
||
}
|
||
if !sessInfo.LastActivity.IsZero() {
|
||
status.LastActivity = sessInfo.LastActivity.Format("2006-01-02 15:04:05")
|
||
}
|
||
enc := json.NewEncoder(os.Stdout)
|
||
enc.SetIndent("", " ")
|
||
return enc.Encode(status)
|
||
}
|
||
|
||
// Human-readable output
|
||
fmt.Printf("%s\n\n", style.Bold.Render(fmt.Sprintf("Polecat: %s/%s", rigName, polecatName)))
|
||
|
||
// State with color
|
||
stateStr := string(p.State)
|
||
switch p.State {
|
||
case polecat.StateWorking:
|
||
stateStr = style.Info.Render(stateStr)
|
||
case polecat.StateStuck:
|
||
stateStr = style.Warning.Render(stateStr)
|
||
case polecat.StateDone:
|
||
stateStr = style.Success.Render(stateStr)
|
||
default:
|
||
stateStr = style.Dim.Render(stateStr)
|
||
}
|
||
fmt.Printf(" State: %s\n", stateStr)
|
||
|
||
// Issue
|
||
if p.Issue != "" {
|
||
fmt.Printf(" Issue: %s\n", p.Issue)
|
||
} else {
|
||
fmt.Printf(" Issue: %s\n", style.Dim.Render("(none)"))
|
||
}
|
||
|
||
// Clone path and branch
|
||
fmt.Printf(" Clone: %s\n", style.Dim.Render(p.ClonePath))
|
||
fmt.Printf(" Branch: %s\n", style.Dim.Render(p.Branch))
|
||
|
||
// Session info
|
||
fmt.Println()
|
||
fmt.Printf("%s\n", style.Bold.Render("Session"))
|
||
|
||
if sessInfo.Running {
|
||
fmt.Printf(" Status: %s\n", style.Success.Render("running"))
|
||
fmt.Printf(" Session ID: %s\n", style.Dim.Render(sessInfo.SessionID))
|
||
|
||
if sessInfo.Attached {
|
||
fmt.Printf(" Attached: %s\n", style.Info.Render("yes"))
|
||
} else {
|
||
fmt.Printf(" Attached: %s\n", style.Dim.Render("no"))
|
||
}
|
||
|
||
if sessInfo.Windows > 0 {
|
||
fmt.Printf(" Windows: %d\n", sessInfo.Windows)
|
||
}
|
||
|
||
if !sessInfo.Created.IsZero() {
|
||
fmt.Printf(" Created: %s\n", sessInfo.Created.Format("2006-01-02 15:04:05"))
|
||
}
|
||
|
||
if !sessInfo.LastActivity.IsZero() {
|
||
// Show relative time for activity
|
||
ago := formatActivityTime(sessInfo.LastActivity)
|
||
fmt.Printf(" Last Activity: %s (%s)\n",
|
||
sessInfo.LastActivity.Format("15:04:05"),
|
||
style.Dim.Render(ago))
|
||
}
|
||
} else {
|
||
fmt.Printf(" Status: %s\n", style.Dim.Render("not running"))
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// formatActivityTime returns a human-readable relative time string.
|
||
func formatActivityTime(t time.Time) string {
|
||
d := time.Since(t)
|
||
switch {
|
||
case d < time.Minute:
|
||
return fmt.Sprintf("%d seconds ago", int(d.Seconds()))
|
||
case d < time.Hour:
|
||
return fmt.Sprintf("%d minutes ago", int(d.Minutes()))
|
||
case d < 24*time.Hour:
|
||
return fmt.Sprintf("%d hours ago", int(d.Hours()))
|
||
default:
|
||
return fmt.Sprintf("%d days ago", int(d.Hours()/24))
|
||
}
|
||
}
|
||
|
||
// GitState represents the git state of a polecat's worktree.
|
||
type GitState struct {
|
||
Clean bool `json:"clean"`
|
||
UncommittedFiles []string `json:"uncommitted_files"`
|
||
UnpushedCommits int `json:"unpushed_commits"`
|
||
StashCount int `json:"stash_count"`
|
||
}
|
||
|
||
func runPolecatGitState(cmd *cobra.Command, args []string) error {
|
||
rigName, polecatName, err := parseAddress(args[0])
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
mgr, r, err := getPolecatManager(rigName)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// Verify polecat exists
|
||
p, err := mgr.Get(polecatName)
|
||
if err != nil {
|
||
return fmt.Errorf("polecat '%s' not found in rig '%s'", polecatName, rigName)
|
||
}
|
||
|
||
// Get git state from the polecat's worktree
|
||
state, err := getGitState(p.ClonePath)
|
||
if err != nil {
|
||
return fmt.Errorf("getting git state: %w", err)
|
||
}
|
||
|
||
// JSON output
|
||
if polecatGitStateJSON {
|
||
enc := json.NewEncoder(os.Stdout)
|
||
enc.SetIndent("", " ")
|
||
return enc.Encode(state)
|
||
}
|
||
|
||
// Human-readable output
|
||
fmt.Printf("%s\n\n", style.Bold.Render(fmt.Sprintf("Git State: %s/%s", r.Name, polecatName)))
|
||
|
||
// Working tree status
|
||
if len(state.UncommittedFiles) == 0 {
|
||
fmt.Printf(" Working Tree: %s\n", style.Success.Render("clean"))
|
||
} else {
|
||
fmt.Printf(" Working Tree: %s\n", style.Warning.Render("dirty"))
|
||
fmt.Printf(" Uncommitted: %s\n", style.Warning.Render(fmt.Sprintf("%d files", len(state.UncommittedFiles))))
|
||
for _, f := range state.UncommittedFiles {
|
||
fmt.Printf(" %s\n", style.Dim.Render(f))
|
||
}
|
||
}
|
||
|
||
// Unpushed commits
|
||
if state.UnpushedCommits == 0 {
|
||
fmt.Printf(" Unpushed: %s\n", style.Success.Render("0 commits"))
|
||
} else {
|
||
fmt.Printf(" Unpushed: %s\n", style.Warning.Render(fmt.Sprintf("%d commits ahead", state.UnpushedCommits)))
|
||
}
|
||
|
||
// Stashes
|
||
if state.StashCount == 0 {
|
||
fmt.Printf(" Stashes: %s\n", style.Dim.Render("0"))
|
||
} else {
|
||
fmt.Printf(" Stashes: %s\n", style.Warning.Render(fmt.Sprintf("%d", state.StashCount)))
|
||
}
|
||
|
||
// Verdict
|
||
fmt.Println()
|
||
if state.Clean {
|
||
fmt.Printf(" Verdict: %s\n", style.Success.Render("CLEAN (safe to kill)"))
|
||
} else {
|
||
fmt.Printf(" Verdict: %s\n", style.Error.Render("DIRTY (needs cleanup)"))
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// getGitState checks the git state of a worktree.
|
||
func getGitState(worktreePath string) (*GitState, error) {
|
||
state := &GitState{
|
||
Clean: true,
|
||
UncommittedFiles: []string{},
|
||
}
|
||
|
||
// Check for uncommitted changes (git status --porcelain)
|
||
statusCmd := exec.Command("git", "status", "--porcelain")
|
||
statusCmd.Dir = worktreePath
|
||
output, err := statusCmd.Output()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("git status: %w", err)
|
||
}
|
||
if len(output) > 0 {
|
||
lines := splitLines(string(output))
|
||
for _, line := range lines {
|
||
if line != "" {
|
||
// Extract filename (skip the status prefix)
|
||
if len(line) > 3 {
|
||
state.UncommittedFiles = append(state.UncommittedFiles, line[3:])
|
||
} else {
|
||
state.UncommittedFiles = append(state.UncommittedFiles, line)
|
||
}
|
||
}
|
||
}
|
||
state.Clean = false
|
||
}
|
||
|
||
// Check for unpushed commits (git log origin/main..HEAD)
|
||
// We check commits first, then verify if content differs.
|
||
// After squash merge, commits may differ but content may be identical.
|
||
mainRef := "origin/main"
|
||
logCmd := exec.Command("git", "log", mainRef+"..HEAD", "--oneline")
|
||
logCmd.Dir = worktreePath
|
||
output, err = logCmd.Output()
|
||
if err != nil {
|
||
// origin/main might not exist - try origin/master
|
||
mainRef = "origin/master"
|
||
logCmd = exec.Command("git", "log", mainRef+"..HEAD", "--oneline")
|
||
logCmd.Dir = worktreePath
|
||
output, _ = logCmd.Output() // non-fatal: might be a new repo without remote tracking
|
||
}
|
||
if len(output) > 0 {
|
||
lines := splitLines(string(output))
|
||
count := 0
|
||
for _, line := range lines {
|
||
if line != "" {
|
||
count++
|
||
}
|
||
}
|
||
if count > 0 {
|
||
// Commits exist that aren't on main. But after squash merge,
|
||
// the content may actually be on main with different commit SHAs.
|
||
// Check if there's any actual diff between HEAD and main.
|
||
diffCmd := exec.Command("git", "diff", mainRef, "HEAD", "--quiet")
|
||
diffCmd.Dir = worktreePath
|
||
diffErr := diffCmd.Run()
|
||
if diffErr == nil {
|
||
// Exit code 0 means no diff - content IS on main (squash merged)
|
||
// Don't count these as unpushed
|
||
state.UnpushedCommits = 0
|
||
} else {
|
||
// Exit code 1 means there's a diff - truly unpushed work
|
||
state.UnpushedCommits = count
|
||
state.Clean = false
|
||
}
|
||
}
|
||
}
|
||
|
||
// Check for stashes (git stash list)
|
||
stashCmd := exec.Command("git", "stash", "list")
|
||
stashCmd.Dir = worktreePath
|
||
output, err = stashCmd.Output()
|
||
if err != nil {
|
||
// Ignore stash errors
|
||
output = nil
|
||
}
|
||
if len(output) > 0 {
|
||
lines := splitLines(string(output))
|
||
count := 0
|
||
for _, line := range lines {
|
||
if line != "" {
|
||
count++
|
||
}
|
||
}
|
||
state.StashCount = count
|
||
if count > 0 {
|
||
state.Clean = false
|
||
}
|
||
}
|
||
|
||
return state, nil
|
||
}
|
||
|
||
// RecoveryStatus represents whether a polecat needs recovery or is safe to nuke.
|
||
type RecoveryStatus struct {
|
||
Rig string `json:"rig"`
|
||
Polecat string `json:"polecat"`
|
||
CleanupStatus polecat.CleanupStatus `json:"cleanup_status"`
|
||
NeedsRecovery bool `json:"needs_recovery"`
|
||
Verdict string `json:"verdict"` // SAFE_TO_NUKE or NEEDS_RECOVERY
|
||
Branch string `json:"branch,omitempty"`
|
||
Issue string `json:"issue,omitempty"`
|
||
}
|
||
|
||
func runPolecatCheckRecovery(cmd *cobra.Command, args []string) error {
|
||
rigName, polecatName, err := parseAddress(args[0])
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
mgr, r, err := getPolecatManager(rigName)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// Verify polecat exists and get info
|
||
p, err := mgr.Get(polecatName)
|
||
if err != nil {
|
||
return fmt.Errorf("polecat '%s' not found in rig '%s'", polecatName, rigName)
|
||
}
|
||
|
||
// Get cleanup_status from agent bead
|
||
// We need to read it directly from beads since manager doesn't expose it
|
||
rigPath := r.Path
|
||
bd := beads.New(rigPath)
|
||
agentBeadID := polecatBeadIDForRig(r, rigName, polecatName)
|
||
_, fields, err := bd.GetAgentBead(agentBeadID)
|
||
|
||
status := RecoveryStatus{
|
||
Rig: rigName,
|
||
Polecat: polecatName,
|
||
Branch: p.Branch,
|
||
Issue: p.Issue,
|
||
}
|
||
|
||
if err != nil || fields == nil {
|
||
// No agent bead or no cleanup_status - fall back to git check
|
||
// This handles polecats that haven't self-reported yet
|
||
gitState, gitErr := getGitState(p.ClonePath)
|
||
if gitErr != nil {
|
||
status.CleanupStatus = polecat.CleanupUnknown
|
||
status.NeedsRecovery = true
|
||
status.Verdict = "NEEDS_RECOVERY"
|
||
} else if gitState.Clean {
|
||
status.CleanupStatus = polecat.CleanupClean
|
||
status.NeedsRecovery = false
|
||
status.Verdict = "SAFE_TO_NUKE"
|
||
} else if gitState.UnpushedCommits > 0 {
|
||
status.CleanupStatus = polecat.CleanupUnpushed
|
||
status.NeedsRecovery = true
|
||
status.Verdict = "NEEDS_RECOVERY"
|
||
} else if gitState.StashCount > 0 {
|
||
status.CleanupStatus = polecat.CleanupStash
|
||
status.NeedsRecovery = true
|
||
status.Verdict = "NEEDS_RECOVERY"
|
||
} else {
|
||
status.CleanupStatus = polecat.CleanupUncommitted
|
||
status.NeedsRecovery = true
|
||
status.Verdict = "NEEDS_RECOVERY"
|
||
}
|
||
} else {
|
||
// Use cleanup_status from agent bead
|
||
status.CleanupStatus = polecat.CleanupStatus(fields.CleanupStatus)
|
||
if status.CleanupStatus.IsSafe() {
|
||
status.NeedsRecovery = false
|
||
status.Verdict = "SAFE_TO_NUKE"
|
||
} else {
|
||
// RequiresRecovery covers uncommitted, stash, unpushed
|
||
// Unknown/empty also treated conservatively
|
||
status.NeedsRecovery = true
|
||
status.Verdict = "NEEDS_RECOVERY"
|
||
}
|
||
}
|
||
|
||
// JSON output
|
||
if polecatCheckRecoveryJSON {
|
||
enc := json.NewEncoder(os.Stdout)
|
||
enc.SetIndent("", " ")
|
||
return enc.Encode(status)
|
||
}
|
||
|
||
// Human-readable output
|
||
fmt.Printf("%s\n\n", style.Bold.Render(fmt.Sprintf("Recovery Status: %s/%s", rigName, polecatName)))
|
||
fmt.Printf(" Cleanup Status: %s\n", status.CleanupStatus)
|
||
if status.Branch != "" {
|
||
fmt.Printf(" Branch: %s\n", status.Branch)
|
||
}
|
||
if status.Issue != "" {
|
||
fmt.Printf(" Issue: %s\n", status.Issue)
|
||
}
|
||
fmt.Println()
|
||
|
||
if status.NeedsRecovery {
|
||
fmt.Printf(" Verdict: %s\n", style.Error.Render("NEEDS_RECOVERY"))
|
||
fmt.Println()
|
||
fmt.Printf(" %s This polecat has unpushed/uncommitted work.\n", style.Warning.Render("⚠"))
|
||
fmt.Println(" Escalate to Mayor for recovery before cleanup.")
|
||
} else {
|
||
fmt.Printf(" Verdict: %s\n", style.Success.Render("SAFE_TO_NUKE"))
|
||
fmt.Println()
|
||
fmt.Printf(" %s Safe to nuke - no work at risk.\n", style.Success.Render("✓"))
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func runPolecatGC(cmd *cobra.Command, args []string) error {
|
||
rigName := args[0]
|
||
|
||
mgr, r, err := getPolecatManager(rigName)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
fmt.Printf("Garbage collecting stale polecat branches in %s...\n\n", r.Name)
|
||
|
||
if polecatGCDryRun {
|
||
// Dry run - list branches that would be deleted
|
||
repoGit := git.NewGit(r.Path)
|
||
|
||
// List all polecat branches
|
||
branches, err := repoGit.ListBranches("polecat/*")
|
||
if err != nil {
|
||
return fmt.Errorf("listing branches: %w", err)
|
||
}
|
||
|
||
if len(branches) == 0 {
|
||
fmt.Println("No polecat branches found.")
|
||
return nil
|
||
}
|
||
|
||
// Get current branches
|
||
polecats, err := mgr.List()
|
||
if err != nil {
|
||
return fmt.Errorf("listing polecats: %w", err)
|
||
}
|
||
|
||
currentBranches := make(map[string]bool)
|
||
for _, p := range polecats {
|
||
currentBranches[p.Branch] = true
|
||
}
|
||
|
||
// Show what would be deleted
|
||
toDelete := 0
|
||
for _, branch := range branches {
|
||
if !currentBranches[branch] {
|
||
fmt.Printf(" Would delete: %s\n", style.Dim.Render(branch))
|
||
toDelete++
|
||
} else {
|
||
fmt.Printf(" Keep (in use): %s\n", style.Success.Render(branch))
|
||
}
|
||
}
|
||
|
||
fmt.Printf("\nWould delete %d branch(es), keep %d\n", toDelete, len(branches)-toDelete)
|
||
return nil
|
||
}
|
||
|
||
// Actually clean up
|
||
deleted, err := mgr.CleanupStaleBranches()
|
||
if err != nil {
|
||
return fmt.Errorf("cleanup failed: %w", err)
|
||
}
|
||
|
||
if deleted == 0 {
|
||
fmt.Println("No stale branches to clean up.")
|
||
} else {
|
||
fmt.Printf("%s Deleted %d stale branch(es).\n", style.SuccessPrefix, deleted)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// splitLines splits a string into non-empty lines.
|
||
func splitLines(s string) []string {
|
||
var lines []string
|
||
for _, line := range filepath.SplitList(s) {
|
||
if line != "" {
|
||
lines = append(lines, line)
|
||
}
|
||
}
|
||
// filepath.SplitList doesn't work for newlines, use strings.Split instead
|
||
lines = nil
|
||
for _, line := range strings.Split(s, "\n") {
|
||
lines = append(lines, line)
|
||
}
|
||
return lines
|
||
}
|
||
|
||
func runPolecatNuke(cmd *cobra.Command, args []string) error {
|
||
targets, err := resolvePolecatTargets(args, polecatNukeAll)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
if len(targets) == 0 {
|
||
fmt.Println("No polecats to nuke.")
|
||
return nil
|
||
}
|
||
|
||
// Safety checks: refuse to nuke polecats with active work unless --force is set
|
||
if !polecatNukeForce && !polecatNukeDryRun {
|
||
var blocked []*SafetyCheckResult
|
||
for _, p := range targets {
|
||
result := checkPolecatSafety(p)
|
||
if result.Blocked {
|
||
blocked = append(blocked, result)
|
||
}
|
||
}
|
||
|
||
if len(blocked) > 0 {
|
||
displaySafetyCheckBlocked(blocked)
|
||
return fmt.Errorf("blocked: %d polecat(s) have active work", len(blocked))
|
||
}
|
||
}
|
||
|
||
// Nuke each polecat
|
||
t := tmux.NewTmux()
|
||
var nukeErrors []string
|
||
nuked := 0
|
||
|
||
for _, p := range targets {
|
||
if polecatNukeDryRun {
|
||
fmt.Printf("Would nuke %s/%s:\n", p.rigName, p.polecatName)
|
||
fmt.Printf(" - Kill session: gt-%s-%s\n", p.rigName, p.polecatName)
|
||
fmt.Printf(" - Delete worktree: %s/polecats/%s\n", p.r.Path, p.polecatName)
|
||
fmt.Printf(" - Delete branch (if exists)\n")
|
||
fmt.Printf(" - Close agent bead: %s\n", polecatBeadIDForRig(p.r, p.rigName, p.polecatName))
|
||
|
||
displayDryRunSafetyCheck(p)
|
||
fmt.Println()
|
||
continue
|
||
}
|
||
|
||
if polecatNukeForce {
|
||
fmt.Printf("%s Nuking %s/%s (--force)...\n", style.Warning.Render("⚠"), p.rigName, p.polecatName)
|
||
} else {
|
||
fmt.Printf("Nuking %s/%s...\n", p.rigName, p.polecatName)
|
||
}
|
||
|
||
// Step 1: Kill session (force mode - no graceful shutdown)
|
||
polecatMgr := polecat.NewSessionManager(t, p.r)
|
||
running, _ := polecatMgr.IsRunning(p.polecatName)
|
||
if running {
|
||
if err := polecatMgr.Stop(p.polecatName, true); err != nil {
|
||
fmt.Printf(" %s session kill failed: %v\n", style.Warning.Render("⚠"), err)
|
||
// Continue anyway - worktree removal will still work
|
||
} else {
|
||
fmt.Printf(" %s killed session\n", style.Success.Render("✓"))
|
||
}
|
||
}
|
||
|
||
// Step 2: Get polecat info before deletion (for branch name)
|
||
polecatInfo, err := p.mgr.Get(p.polecatName)
|
||
var branchToDelete string
|
||
if err == nil && polecatInfo != nil {
|
||
branchToDelete = polecatInfo.Branch
|
||
}
|
||
|
||
// Step 3: Delete worktree (nuclear mode - bypass all safety checks)
|
||
if err := p.mgr.RemoveWithOptions(p.polecatName, true, true); err != nil {
|
||
if errors.Is(err, polecat.ErrPolecatNotFound) {
|
||
fmt.Printf(" %s worktree already gone\n", style.Dim.Render("○"))
|
||
} else {
|
||
nukeErrors = append(nukeErrors, fmt.Sprintf("%s/%s: worktree removal failed: %v", p.rigName, p.polecatName, err))
|
||
continue
|
||
}
|
||
} else {
|
||
fmt.Printf(" %s deleted worktree\n", style.Success.Render("✓"))
|
||
}
|
||
|
||
// Step 4: Delete branch (if we know it)
|
||
// Use bare repo if it exists (matches where worktree was created), otherwise mayor/rig
|
||
if branchToDelete != "" {
|
||
var repoGit *git.Git
|
||
bareRepoPath := filepath.Join(p.r.Path, ".repo.git")
|
||
if info, err := os.Stat(bareRepoPath); err == nil && info.IsDir() {
|
||
repoGit = git.NewGitWithDir(bareRepoPath, "")
|
||
} else {
|
||
repoGit = git.NewGit(filepath.Join(p.r.Path, "mayor", "rig"))
|
||
}
|
||
if err := repoGit.DeleteBranch(branchToDelete, true); err != nil {
|
||
// Non-fatal - branch might already be gone
|
||
fmt.Printf(" %s branch delete: %v\n", style.Dim.Render("○"), err)
|
||
} else {
|
||
fmt.Printf(" %s deleted branch %s\n", style.Success.Render("✓"), branchToDelete)
|
||
}
|
||
}
|
||
|
||
// Step 5: Close agent bead (if exists)
|
||
agentBeadID := polecatBeadIDForRig(p.r, p.rigName, p.polecatName)
|
||
closeArgs := []string{"close", agentBeadID, "--reason=nuked"}
|
||
if sessionID := runtime.SessionIDFromEnv(); sessionID != "" {
|
||
closeArgs = append(closeArgs, "--session="+sessionID)
|
||
}
|
||
closeCmd := exec.Command("bd", closeArgs...)
|
||
closeCmd.Dir = filepath.Join(p.r.Path, "mayor", "rig")
|
||
if err := closeCmd.Run(); err != nil {
|
||
// Non-fatal - agent bead might not exist
|
||
fmt.Printf(" %s agent bead not found or already closed\n", style.Dim.Render("○"))
|
||
} else {
|
||
fmt.Printf(" %s closed agent bead %s\n", style.Success.Render("✓"), agentBeadID)
|
||
}
|
||
|
||
nuked++
|
||
}
|
||
|
||
// Report results
|
||
if polecatNukeDryRun {
|
||
fmt.Printf("\n%s Would nuke %d polecat(s).\n", style.Info.Render("ℹ"), len(targets))
|
||
return nil
|
||
}
|
||
|
||
if len(nukeErrors) > 0 {
|
||
fmt.Printf("\n%s Some nukes failed:\n", style.Warning.Render("Warning:"))
|
||
for _, e := range nukeErrors {
|
||
fmt.Printf(" - %s\n", e)
|
||
}
|
||
}
|
||
|
||
if nuked > 0 {
|
||
fmt.Printf("\n%s Nuked %d polecat(s).\n", style.SuccessPrefix, nuked)
|
||
}
|
||
|
||
if len(nukeErrors) > 0 {
|
||
return fmt.Errorf("%d nuke(s) failed", len(nukeErrors))
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func runPolecatStale(cmd *cobra.Command, args []string) error {
|
||
rigName := args[0]
|
||
mgr, r, err := getPolecatManager(rigName)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
fmt.Printf("Detecting stale polecats in %s (threshold: %d commits behind main)...\n\n", r.Name, polecatStaleThreshold)
|
||
|
||
staleInfos, err := mgr.DetectStalePolecats(polecatStaleThreshold)
|
||
if err != nil {
|
||
return fmt.Errorf("detecting stale polecats: %w", err)
|
||
}
|
||
|
||
if len(staleInfos) == 0 {
|
||
fmt.Println("No polecats found.")
|
||
return nil
|
||
}
|
||
|
||
// JSON output
|
||
if polecatStaleJSON {
|
||
return json.NewEncoder(os.Stdout).Encode(staleInfos)
|
||
}
|
||
|
||
// Summary counts
|
||
var staleCount, safeCount int
|
||
for _, info := range staleInfos {
|
||
if info.IsStale {
|
||
staleCount++
|
||
} else {
|
||
safeCount++
|
||
}
|
||
}
|
||
|
||
// Display results
|
||
for _, info := range staleInfos {
|
||
statusIcon := style.Success.Render("●")
|
||
statusText := "active"
|
||
if info.IsStale {
|
||
statusIcon = style.Warning.Render("○")
|
||
statusText = "stale"
|
||
}
|
||
|
||
fmt.Printf("%s %s (%s)\n", statusIcon, style.Bold.Render(info.Name), statusText)
|
||
|
||
// Session status
|
||
if info.HasActiveSession {
|
||
fmt.Printf(" Session: %s\n", style.Success.Render("running"))
|
||
} else {
|
||
fmt.Printf(" Session: %s\n", style.Dim.Render("stopped"))
|
||
}
|
||
|
||
// Commits behind
|
||
if info.CommitsBehind > 0 {
|
||
behindStyle := style.Dim
|
||
if info.CommitsBehind >= polecatStaleThreshold {
|
||
behindStyle = style.Warning
|
||
}
|
||
fmt.Printf(" Behind main: %s\n", behindStyle.Render(fmt.Sprintf("%d commits", info.CommitsBehind)))
|
||
}
|
||
|
||
// Agent state
|
||
if info.AgentState != "" {
|
||
fmt.Printf(" Agent state: %s\n", info.AgentState)
|
||
} else {
|
||
fmt.Printf(" Agent state: %s\n", style.Dim.Render("no bead"))
|
||
}
|
||
|
||
// Uncommitted work
|
||
if info.HasUncommittedWork {
|
||
fmt.Printf(" Uncommitted: %s\n", style.Error.Render("yes"))
|
||
}
|
||
|
||
// Reason
|
||
fmt.Printf(" Reason: %s\n", info.Reason)
|
||
fmt.Println()
|
||
}
|
||
|
||
// Summary
|
||
fmt.Printf("Summary: %d stale, %d active\n", staleCount, safeCount)
|
||
|
||
// Cleanup if requested
|
||
if polecatStaleCleanup && staleCount > 0 {
|
||
fmt.Println()
|
||
if polecatNukeDryRun {
|
||
fmt.Printf("Would clean up %d stale polecat(s):\n", staleCount)
|
||
for _, info := range staleInfos {
|
||
if info.IsStale {
|
||
fmt.Printf(" - %s: %s\n", info.Name, info.Reason)
|
||
}
|
||
}
|
||
} else {
|
||
fmt.Printf("Cleaning up %d stale polecat(s)...\n", staleCount)
|
||
nuked := 0
|
||
for _, info := range staleInfos {
|
||
if !info.IsStale {
|
||
continue
|
||
}
|
||
fmt.Printf(" Nuking %s...", info.Name)
|
||
if err := mgr.RemoveWithOptions(info.Name, true, false); err != nil {
|
||
fmt.Printf(" %s (%v)\n", style.Error.Render("failed"), err)
|
||
} else {
|
||
fmt.Printf(" %s\n", style.Success.Render("done"))
|
||
nuked++
|
||
}
|
||
}
|
||
fmt.Printf("\n%s Nuked %d stale polecat(s).\n", style.SuccessPrefix, nuked)
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|