8feb27d44c
After squash merge, polecat branches have different commit SHAs than main even though the content is identical. This was causing false "unpushed commits" warnings during nuke safety checks. Now uses `git diff` to verify if content differs from main, rather than just counting commits ahead. If diff is empty, content is on main (via squash merge) and nuke is safe. (gt-fo9iz) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1639 lines
47 KiB
Go
1639 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 polecatWakeCmd = &cobra.Command{
|
||
Use: "wake <rig>/<polecat>",
|
||
Short: "(Deprecated) Resume a polecat to working state",
|
||
Long: `Resume a polecat to working state.
|
||
|
||
DEPRECATED: In the transient model, polecats are created fresh for each task
|
||
via 'gt sling'. This command is kept for backward compatibility.
|
||
|
||
Transitions: done → working
|
||
|
||
Example:
|
||
gt polecat wake greenplace/Toast`,
|
||
Args: cobra.ExactArgs(1),
|
||
RunE: runPolecatWake,
|
||
}
|
||
|
||
var polecatSleepCmd = &cobra.Command{
|
||
Use: "sleep <rig>/<polecat>",
|
||
Short: "(Deprecated) Mark polecat as done",
|
||
Long: `Mark polecat as done.
|
||
|
||
DEPRECATED: In the transient model, polecats use 'gt handoff' when complete,
|
||
which triggers automatic cleanup by the Witness. This command is kept for
|
||
backward compatibility.
|
||
|
||
Transitions: working → done
|
||
|
||
Example:
|
||
gt polecat sleep greenplace/Toast`,
|
||
Args: cobra.ExactArgs(1),
|
||
RunE: runPolecatSleep,
|
||
}
|
||
|
||
var polecatDoneCmd = &cobra.Command{
|
||
Use: "done <rig>/<polecat>",
|
||
Aliases: []string{"finish"},
|
||
Short: "Mark polecat as done with work and return to idle",
|
||
Long: `Mark polecat as done with work and return to idle.
|
||
|
||
Transitions: working/done/stuck → idle
|
||
Clears the assigned issue.
|
||
Fails if session is running (stop first).
|
||
|
||
Example:
|
||
gt polecat done greenplace/Toast
|
||
gt polecat finish greenplace/Toast`,
|
||
Args: cobra.ExactArgs(1),
|
||
RunE: runPolecatDone,
|
||
}
|
||
|
||
var polecatResetCmd = &cobra.Command{
|
||
Use: "reset <rig>/<polecat>",
|
||
Short: "Force reset polecat to idle state",
|
||
Long: `Force reset polecat to idle state.
|
||
|
||
Transitions: any state → idle
|
||
Clears the assigned issue.
|
||
Use when polecat is stuck in an unexpected state.
|
||
Fails if session is running (stop first).
|
||
|
||
Example:
|
||
gt polecat reset greenplace/Toast`,
|
||
Args: cobra.ExactArgs(1),
|
||
RunE: runPolecatReset,
|
||
}
|
||
|
||
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 as polecats
|
||
are recreated.
|
||
|
||
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,
|
||
}
|
||
|
||
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")
|
||
|
||
// Add subcommands
|
||
polecatCmd.AddCommand(polecatListCmd)
|
||
polecatCmd.AddCommand(polecatAddCmd)
|
||
polecatCmd.AddCommand(polecatRemoveCmd)
|
||
polecatCmd.AddCommand(polecatWakeCmd)
|
||
polecatCmd.AddCommand(polecatSleepCmd)
|
||
polecatCmd.AddCommand(polecatDoneCmd)
|
||
polecatCmd.AddCommand(polecatResetCmd)
|
||
polecatCmd.AddCommand(polecatSyncCmd)
|
||
polecatCmd.AddCommand(polecatStatusCmd)
|
||
polecatCmd.AddCommand(polecatGitStateCmd)
|
||
polecatCmd.AddCommand(polecatCheckRecoveryCmd)
|
||
polecatCmd.AddCommand(polecatGCCmd)
|
||
polecatCmd.AddCommand(polecatNukeCmd)
|
||
|
||
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 runPolecatWake(cmd *cobra.Command, args []string) error {
|
||
fmt.Println(style.Warning.Render("DEPRECATED: Use 'gt sling' to create fresh polecats instead"))
|
||
fmt.Println()
|
||
|
||
rigName, polecatName, err := parseAddress(args[0])
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
mgr, _, err := getPolecatManager(rigName)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
if err := mgr.Wake(polecatName); err != nil {
|
||
return fmt.Errorf("waking polecat: %w", err)
|
||
}
|
||
|
||
fmt.Printf("%s Polecat %s is now working.\n", style.SuccessPrefix, polecatName)
|
||
return nil
|
||
}
|
||
|
||
func runPolecatSleep(cmd *cobra.Command, args []string) error {
|
||
fmt.Println(style.Warning.Render("DEPRECATED: Use 'gt handoff' from within a polecat session instead"))
|
||
fmt.Println()
|
||
|
||
rigName, polecatName, err := parseAddress(args[0])
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
mgr, r, err := getPolecatManager(rigName)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// Check if session is running
|
||
t := tmux.NewTmux()
|
||
sessMgr := session.NewManager(t, r)
|
||
running, _ := sessMgr.IsRunning(polecatName)
|
||
if running {
|
||
return fmt.Errorf("session is running. Use 'gt handoff' from the polecat session, or stop it with: gt session stop %s/%s", rigName, polecatName)
|
||
}
|
||
|
||
if err := mgr.Sleep(polecatName); err != nil {
|
||
return fmt.Errorf("marking polecat as done: %w", err)
|
||
}
|
||
|
||
fmt.Printf("%s Polecat %s is now done.\n", style.SuccessPrefix, polecatName)
|
||
return nil
|
||
}
|
||
|
||
func runPolecatDone(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
|
||
}
|
||
|
||
// Check if session is running
|
||
t := tmux.NewTmux()
|
||
sessMgr := session.NewManager(t, r)
|
||
running, _ := sessMgr.IsRunning(polecatName)
|
||
if running {
|
||
return fmt.Errorf("session is running. Stop it first with: gt session stop %s/%s", rigName, polecatName)
|
||
}
|
||
|
||
if err := mgr.Finish(polecatName); err != nil {
|
||
return fmt.Errorf("finishing polecat: %w", err)
|
||
}
|
||
|
||
fmt.Printf("%s Polecat %s is now idle.\n", style.SuccessPrefix, polecatName)
|
||
return nil
|
||
}
|
||
|
||
func runPolecatReset(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
|
||
}
|
||
|
||
// Check if session is running
|
||
t := tmux.NewTmux()
|
||
sessMgr := session.NewManager(t, r)
|
||
running, _ := sessMgr.IsRunning(polecatName)
|
||
if running {
|
||
return fmt.Errorf("session is running. Stop it first with: gt session stop %s/%s", rigName, polecatName)
|
||
}
|
||
|
||
if err := mgr.Reset(polecatName); err != nil {
|
||
return fmt.Errorf("resetting polecat: %w", err)
|
||
}
|
||
|
||
fmt.Printf("%s Polecat %s has been reset to idle.\n", style.SuccessPrefix, polecatName)
|
||
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 string `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 = "unknown"
|
||
status.NeedsRecovery = true
|
||
status.Verdict = "NEEDS_RECOVERY"
|
||
} else if gitState.Clean {
|
||
status.CleanupStatus = "clean"
|
||
status.NeedsRecovery = false
|
||
status.Verdict = "SAFE_TO_NUKE"
|
||
} else if gitState.UnpushedCommits > 0 {
|
||
status.CleanupStatus = "has_unpushed"
|
||
status.NeedsRecovery = true
|
||
status.Verdict = "NEEDS_RECOVERY"
|
||
} else if gitState.StashCount > 0 {
|
||
status.CleanupStatus = "has_stash"
|
||
status.NeedsRecovery = true
|
||
status.Verdict = "NEEDS_RECOVERY"
|
||
} else {
|
||
status.CleanupStatus = "has_uncommitted"
|
||
status.NeedsRecovery = true
|
||
status.Verdict = "NEEDS_RECOVERY"
|
||
}
|
||
} else {
|
||
// Use cleanup_status from agent bead
|
||
status.CleanupStatus = fields.CleanupStatus
|
||
switch fields.CleanupStatus {
|
||
case "clean":
|
||
status.NeedsRecovery = false
|
||
status.Verdict = "SAFE_TO_NUKE"
|
||
case "has_uncommitted", "has_unpushed", "has_stash":
|
||
status.NeedsRecovery = true
|
||
status.Verdict = "NEEDS_RECOVERY"
|
||
default:
|
||
// Unknown or empty - be conservative
|
||
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
|
||
switch fields.CleanupStatus {
|
||
case "clean":
|
||
// OK
|
||
case "has_unpushed":
|
||
reasons = append(reasons, "has unpushed commits")
|
||
case "has_uncommitted":
|
||
reasons = append(reasons, "has uncommitted changes")
|
||
case "has_stash":
|
||
reasons = append(reasons, "has stashed changes")
|
||
case "unknown", "":
|
||
reasons = append(reasons, "cleanup status unknown")
|
||
default:
|
||
reasons = append(reasons, fmt.Sprintf("cleanup status: %s", fields.CleanupStatus))
|
||
}
|
||
|
||
// Check 3: Work on hook (check both Issue.HookBead from slot and fields.HookBead)
|
||
hookBead := agentIssue.HookBead
|
||
if hookBead == "" {
|
||
hookBead = fields.HookBead
|
||
}
|
||
if hookBead != "" {
|
||
reasons = append(reasons, fmt.Sprintf("has work on hook (%s)", 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 {
|
||
if fields.CleanupStatus == "clean" {
|
||
fmt.Printf(" - Git state: %s\n", style.Success.Render("clean"))
|
||
} else if fields.CleanupStatus != "" {
|
||
fmt.Printf(" - Git state: %s (%s)\n", style.Error.Render("dirty"), fields.CleanupStatus)
|
||
} else {
|
||
fmt.Printf(" - Git state: %s\n", style.Warning.Render("unknown"))
|
||
}
|
||
|
||
hookBead := agentIssue.HookBead
|
||
if hookBead == "" {
|
||
hookBead = fields.HookBead
|
||
}
|
||
if hookBead != "" {
|
||
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
|
||
}
|