When checking if a polecat can be nuked, verify that any hooked bead is still active (not closed). If the hooked bead was closed externally, the hook is stale and should not block the nuke. Also shows 'stale' in dry-run output when hook points to a closed bead.
1632 lines
47 KiB
Go
1632 lines
47 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/session"
|
||
"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{"cat", "polecats"},
|
||
GroupID: GroupAgents,
|
||
Short: "Manage polecats in rigs",
|
||
RunE: requireSubcommand,
|
||
Long: `Manage polecat lifecycle in rigs.
|
||
|
||
Polecats are worker agents that operate in their own git worktrees.
|
||
Use the subcommands to add, remove, list, wake, and sleep polecats.`,
|
||
}
|
||
|
||
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",
|
||
Long: `Add a new polecat to a rig.
|
||
|
||
Creates a polecat directory, clones the rig repo, creates a work branch,
|
||
and initializes state.
|
||
|
||
Example:
|
||
gt polecat add greenplace Toast`,
|
||
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)
|
||
mgr := polecat.NewManager(r, polecatGit)
|
||
|
||
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)
|
||
sessMgr := session.NewManager(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, _ := sessMgr.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 {
|
||
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 {
|
||
// Build list of polecats to remove
|
||
type polecatToRemove struct {
|
||
rigName string
|
||
polecatName string
|
||
mgr *polecat.Manager
|
||
r *rig.Rig
|
||
}
|
||
var toRemove []polecatToRemove
|
||
|
||
if polecatRemoveAll {
|
||
// --all flag: first arg is just the rig name
|
||
rigName := args[0]
|
||
// Check if it looks like rig/polecat format
|
||
if _, _, err := parseAddress(rigName); err == nil {
|
||
return fmt.Errorf("with --all, provide just the rig name (e.g., 'gt polecat remove greenplace --all')")
|
||
}
|
||
|
||
mgr, r, err := getPolecatManager(rigName)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
polecats, err := mgr.List()
|
||
if err != nil {
|
||
return fmt.Errorf("listing polecats: %w", err)
|
||
}
|
||
|
||
if len(polecats) == 0 {
|
||
fmt.Println("No polecats to remove.")
|
||
return nil
|
||
}
|
||
|
||
for _, p := range polecats {
|
||
toRemove = append(toRemove, polecatToRemove{
|
||
rigName: rigName,
|
||
polecatName: p.Name,
|
||
mgr: mgr,
|
||
r: r,
|
||
})
|
||
}
|
||
} else {
|
||
// Multiple rig/polecat arguments - require explicit rig/polecat format
|
||
for _, arg := range args {
|
||
// Validate format: must contain "/" to avoid misinterpreting rig names as polecat names
|
||
if !strings.Contains(arg, "/") {
|
||
return fmt.Errorf("invalid address '%s': must be in 'rig/polecat' format (e.g., 'gastown/Toast')", arg)
|
||
}
|
||
|
||
rigName, polecatName, err := parseAddress(arg)
|
||
if err != nil {
|
||
return fmt.Errorf("invalid address '%s': %w", arg, err)
|
||
}
|
||
|
||
mgr, r, err := getPolecatManager(rigName)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
toRemove = append(toRemove, polecatToRemove{
|
||
rigName: rigName,
|
||
polecatName: polecatName,
|
||
mgr: mgr,
|
||
r: r,
|
||
})
|
||
}
|
||
}
|
||
|
||
// Remove each polecat
|
||
t := tmux.NewTmux()
|
||
var removeErrors []string
|
||
removed := 0
|
||
|
||
for _, p := range toRemove {
|
||
// Check if session is running
|
||
if !polecatForce {
|
||
sessMgr := session.NewManager(t, p.r)
|
||
running, _ := sessMgr.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, r, 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 {
|
||
polecatDir := filepath.Join(r.Path, "polecats", name)
|
||
|
||
// Check directory exists
|
||
if _, err := os.Stat(polecatDir); 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 = polecatDir
|
||
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()
|
||
sessMgr := session.NewManager(t, r)
|
||
sessInfo, err := sessMgr.Status(polecatName)
|
||
if err != nil {
|
||
// Non-fatal - continue without session info
|
||
sessInfo = &session.Info{
|
||
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 := beads.PolecatBeadID(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 {
|
||
// Build list of polecats to nuke
|
||
type polecatToNuke struct {
|
||
rigName string
|
||
polecatName string
|
||
mgr *polecat.Manager
|
||
r *rig.Rig
|
||
}
|
||
var toNuke []polecatToNuke
|
||
|
||
if polecatNukeAll {
|
||
// --all flag: first arg is just the rig name
|
||
rigName := args[0]
|
||
// Check if it looks like rig/polecat format
|
||
if _, _, err := parseAddress(rigName); err == nil {
|
||
return fmt.Errorf("with --all, provide just the rig name (e.g., 'gt polecat nuke greenplace --all')")
|
||
}
|
||
|
||
mgr, r, err := getPolecatManager(rigName)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
polecats, err := mgr.List()
|
||
if err != nil {
|
||
return fmt.Errorf("listing polecats: %w", err)
|
||
}
|
||
|
||
if len(polecats) == 0 {
|
||
fmt.Println("No polecats to nuke.")
|
||
return nil
|
||
}
|
||
|
||
for _, p := range polecats {
|
||
toNuke = append(toNuke, polecatToNuke{
|
||
rigName: rigName,
|
||
polecatName: p.Name,
|
||
mgr: mgr,
|
||
r: r,
|
||
})
|
||
}
|
||
} else {
|
||
// Multiple rig/polecat arguments - require explicit rig/polecat format
|
||
for _, arg := range args {
|
||
// Validate format: must contain "/" to avoid misinterpreting rig names as polecat names
|
||
if !strings.Contains(arg, "/") {
|
||
return fmt.Errorf("invalid address '%s': must be in 'rig/polecat' format (e.g., 'gastown/Toast')", arg)
|
||
}
|
||
|
||
rigName, polecatName, err := parseAddress(arg)
|
||
if err != nil {
|
||
return fmt.Errorf("invalid address '%s': %w", arg, err)
|
||
}
|
||
|
||
mgr, r, err := getPolecatManager(rigName)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
toNuke = append(toNuke, polecatToNuke{
|
||
rigName: rigName,
|
||
polecatName: polecatName,
|
||
mgr: mgr,
|
||
r: r,
|
||
})
|
||
}
|
||
}
|
||
|
||
// Safety checks: refuse to nuke polecats with active work unless --force is set
|
||
// Checks:
|
||
// 1. Unpushed commits - worktree has uncommitted/unpushed changes
|
||
// 2. Open MR beads - polecat has open merge requests pending
|
||
// 3. Work on hook - polecat has work assigned to its hook
|
||
if !polecatNukeForce && !polecatNukeDryRun {
|
||
type blockReason struct {
|
||
polecat string
|
||
reasons []string
|
||
}
|
||
var blocked []blockReason
|
||
|
||
for _, p := range toNuke {
|
||
var reasons []string
|
||
|
||
// Get polecat info for branch name
|
||
polecatInfo, infoErr := p.mgr.Get(p.polecatName)
|
||
|
||
// Check 1: Unpushed commits via cleanup_status or git state
|
||
bd := beads.New(p.r.Path)
|
||
agentBeadID := beads.PolecatBeadID(p.rigName, p.polecatName)
|
||
agentIssue, fields, err := bd.GetAgentBead(agentBeadID)
|
||
|
||
if err != nil || fields == nil {
|
||
// No agent bead - fall back to git check
|
||
if infoErr == nil && polecatInfo != nil {
|
||
gitState, gitErr := getGitState(polecatInfo.ClonePath)
|
||
if gitErr != nil {
|
||
reasons = append(reasons, "cannot check git state")
|
||
} else if !gitState.Clean {
|
||
if gitState.UnpushedCommits > 0 {
|
||
reasons = append(reasons, fmt.Sprintf("has %d unpushed commit(s)", gitState.UnpushedCommits))
|
||
} else if len(gitState.UncommittedFiles) > 0 {
|
||
reasons = append(reasons, fmt.Sprintf("has %d uncommitted file(s)", len(gitState.UncommittedFiles)))
|
||
} else if gitState.StashCount > 0 {
|
||
reasons = append(reasons, fmt.Sprintf("has %d stash(es)", gitState.StashCount))
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
// Check cleanup_status from agent bead
|
||
cleanupStatus := polecat.CleanupStatus(fields.CleanupStatus)
|
||
switch cleanupStatus {
|
||
case polecat.CleanupClean:
|
||
// OK
|
||
case polecat.CleanupUnpushed:
|
||
reasons = append(reasons, "has unpushed commits")
|
||
case polecat.CleanupUncommitted:
|
||
reasons = append(reasons, "has uncommitted changes")
|
||
case polecat.CleanupStash:
|
||
reasons = append(reasons, "has stashed changes")
|
||
case polecat.CleanupUnknown, "":
|
||
reasons = append(reasons, "cleanup status unknown")
|
||
default:
|
||
reasons = append(reasons, fmt.Sprintf("cleanup status: %s", cleanupStatus))
|
||
}
|
||
|
||
// Check 3: Work on hook (check both Issue.HookBead from slot and fields.HookBead)
|
||
// Only flag as blocking if the hooked bead is still in an active status.
|
||
// If the hooked bead was closed externally (gt-jc7bq), don't block nuke.
|
||
hookBead := agentIssue.HookBead
|
||
if hookBead == "" {
|
||
hookBead = fields.HookBead
|
||
}
|
||
if hookBead != "" {
|
||
// Check if hooked bead is still active (not closed)
|
||
hookedIssue, err := bd.Show(hookBead)
|
||
if err == nil && hookedIssue != nil {
|
||
// Only block if bead is still active (not closed)
|
||
if hookedIssue.Status != "closed" {
|
||
reasons = append(reasons, fmt.Sprintf("has work on hook (%s)", hookBead))
|
||
}
|
||
// If closed, the hook is stale - don't block nuke
|
||
} else {
|
||
// Can't verify hooked bead - be conservative
|
||
reasons = append(reasons, fmt.Sprintf("has work on hook (%s, unverified)", hookBead))
|
||
}
|
||
}
|
||
}
|
||
|
||
// Check 2: Open MR beads for this branch
|
||
if infoErr == nil && polecatInfo != nil && polecatInfo.Branch != "" {
|
||
mr, mrErr := bd.FindMRForBranch(polecatInfo.Branch)
|
||
if mrErr == nil && mr != nil {
|
||
reasons = append(reasons, fmt.Sprintf("has open MR (%s)", mr.ID))
|
||
}
|
||
}
|
||
|
||
if len(reasons) > 0 {
|
||
blocked = append(blocked, blockReason{
|
||
polecat: fmt.Sprintf("%s/%s", p.rigName, p.polecatName),
|
||
reasons: reasons,
|
||
})
|
||
}
|
||
}
|
||
|
||
if len(blocked) > 0 {
|
||
fmt.Printf("%s Cannot nuke the following polecats:\n\n", style.Error.Render("Error:"))
|
||
var polecatList []string
|
||
for _, b := range blocked {
|
||
fmt.Printf(" %s:\n", style.Bold.Render(b.polecat))
|
||
for _, r := range b.reasons {
|
||
fmt.Printf(" - %s\n", r)
|
||
}
|
||
polecatList = append(polecatList, b.polecat)
|
||
}
|
||
fmt.Println()
|
||
fmt.Println("Safety checks failed. Resolve issues before nuking, or use --force.")
|
||
fmt.Println("Options:")
|
||
fmt.Printf(" 1. Complete work: gt done (from polecat session)\n")
|
||
fmt.Printf(" 2. Push changes: git push (from polecat worktree)\n")
|
||
fmt.Printf(" 3. Escalate: gt mail send mayor/ -s \"RECOVERY_NEEDED\" -m \"...\"\n")
|
||
fmt.Printf(" 4. Force nuke (LOSES WORK): gt polecat nuke --force %s\n", strings.Join(polecatList, " "))
|
||
fmt.Println()
|
||
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 toNuke {
|
||
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", beads.PolecatBeadID(p.rigName, p.polecatName))
|
||
|
||
// Show safety check status in dry-run
|
||
fmt.Printf("\n Safety checks:\n")
|
||
polecatInfo, infoErr := p.mgr.Get(p.polecatName)
|
||
bd := beads.New(p.r.Path)
|
||
agentBeadID := beads.PolecatBeadID(p.rigName, p.polecatName)
|
||
agentIssue, fields, err := bd.GetAgentBead(agentBeadID)
|
||
|
||
// Check 1: Git state
|
||
if err != nil || fields == nil {
|
||
if infoErr == nil && polecatInfo != nil {
|
||
gitState, gitErr := getGitState(polecatInfo.ClonePath)
|
||
if gitErr != nil {
|
||
fmt.Printf(" - Git state: %s\n", style.Warning.Render("cannot check"))
|
||
} else if gitState.Clean {
|
||
fmt.Printf(" - Git state: %s\n", style.Success.Render("clean"))
|
||
} else {
|
||
fmt.Printf(" - Git state: %s\n", style.Error.Render("dirty"))
|
||
}
|
||
} else {
|
||
fmt.Printf(" - Git state: %s\n", style.Dim.Render("unknown (no polecat info)"))
|
||
}
|
||
fmt.Printf(" - Hook: %s\n", style.Dim.Render("unknown (no agent bead)"))
|
||
} else {
|
||
cleanupStatus := polecat.CleanupStatus(fields.CleanupStatus)
|
||
if cleanupStatus.IsSafe() {
|
||
fmt.Printf(" - Git state: %s\n", style.Success.Render("clean"))
|
||
} else if cleanupStatus.RequiresRecovery() {
|
||
fmt.Printf(" - Git state: %s (%s)\n", style.Error.Render("dirty"), cleanupStatus)
|
||
} else {
|
||
fmt.Printf(" - Git state: %s\n", style.Warning.Render("unknown"))
|
||
}
|
||
|
||
hookBead := agentIssue.HookBead
|
||
if hookBead == "" {
|
||
hookBead = fields.HookBead
|
||
}
|
||
if hookBead != "" {
|
||
// Check if hooked bead is still active
|
||
hookedIssue, err := bd.Show(hookBead)
|
||
if err == nil && hookedIssue != nil && hookedIssue.Status == "closed" {
|
||
fmt.Printf(" - Hook: %s (%s, closed - stale)\n", style.Warning.Render("stale"), hookBead)
|
||
} else {
|
||
fmt.Printf(" - Hook: %s (%s)\n", style.Error.Render("has work"), hookBead)
|
||
}
|
||
} else {
|
||
fmt.Printf(" - Hook: %s\n", style.Success.Render("empty"))
|
||
}
|
||
}
|
||
|
||
// Check 2: Open MR
|
||
if infoErr == nil && polecatInfo != nil && polecatInfo.Branch != "" {
|
||
mr, mrErr := bd.FindMRForBranch(polecatInfo.Branch)
|
||
if mrErr == nil && mr != nil {
|
||
fmt.Printf(" - Open MR: %s (%s)\n", style.Error.Render("yes"), mr.ID)
|
||
} else {
|
||
fmt.Printf(" - Open MR: %s\n", style.Success.Render("none"))
|
||
}
|
||
} else {
|
||
fmt.Printf(" - Open MR: %s\n", style.Dim.Render("unknown (no branch info)"))
|
||
}
|
||
|
||
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)
|
||
sessMgr := session.NewManager(t, p.r)
|
||
running, _ := sessMgr.IsRunning(p.polecatName)
|
||
if running {
|
||
if err := sessMgr.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)
|
||
if branchToDelete != "" {
|
||
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 := beads.PolecatBeadID(p.rigName, p.polecatName)
|
||
closeArgs := []string{"close", agentBeadID, "--reason=nuked"}
|
||
if sessionID := os.Getenv("CLAUDE_SESSION_ID"); 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(toNuke))
|
||
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
|
||
}
|