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", 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 gastown gt polecat list --all gt polecat list gastown --json`, RunE: runPolecatList, } var polecatAddCmd = &cobra.Command{ Use: "add ", 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 gastown Toast`, Args: cobra.ExactArgs(2), RunE: runPolecatAdd, } var polecatRemoveCmd = &cobra.Command{ Use: "remove /... | --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 gastown/Toast gt polecat remove gastown/Toast gastown/Furiosa gt polecat remove gastown --all gt polecat remove gastown --all --force`, Args: cobra.MinimumNArgs(1), RunE: runPolecatRemove, } var polecatWakeCmd = &cobra.Command{ Use: "wake /", 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 gastown/Toast`, Args: cobra.ExactArgs(1), RunE: runPolecatWake, } var polecatSleepCmd = &cobra.Command{ Use: "sleep /", 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 gastown/Toast`, Args: cobra.ExactArgs(1), RunE: runPolecatSleep, } var polecatDoneCmd = &cobra.Command{ Use: "done /", 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 gastown/Toast gt polecat finish gastown/Toast`, Args: cobra.ExactArgs(1), RunE: runPolecatDone, } var polecatResetCmd = &cobra.Command{ Use: "reset /", 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 gastown/Toast`, Args: cobra.ExactArgs(1), RunE: runPolecatReset, } var polecatSyncCmd = &cobra.Command{ Use: "sync /", 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 gastown/Toast gt polecat sync gastown --all gt polecat sync gastown/Toast --from-main`, Args: cobra.MaximumNArgs(1), RunE: runPolecatSync, } var polecatStatusCmd = &cobra.Command{ Use: "status /", 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 gastown/Toast gt polecat status gastown/Toast --json`, Args: cobra.ExactArgs(1), RunE: runPolecatStatus, } var ( polecatSyncAll bool polecatSyncFromMain bool polecatStatusJSON bool polecatGitStateJSON bool polecatGCDryRun bool polecatNukeAll bool polecatNukeDryRun bool ) var polecatGCCmd = &cobra.Command{ Use: "gc ", Short: "Garbage collect stale polecat branches", Long: `Garbage collect stale polecat branches in a rig. Polecats use unique timestamped branches (polecat/-) 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 gastown gt polecat gc gastown --dry-run`, Args: cobra.ExactArgs(1), RunE: runPolecatGC, } var polecatNukeCmd = &cobra.Command{ Use: "nuke /... | --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) Use this after the Refinery has merged the polecat's work. Examples: gt polecat nuke gastown/Toast gt polecat nuke gastown/Toast gastown/Furiosa gt polecat nuke gastown --all gt polecat nuke gastown --all --dry-run`, Args: cobra.MinimumNArgs(1), RunE: runPolecatNuke, } var polecatGitStateCmd = &cobra.Command{ Use: "git-state /", 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 gastown/Toast gt polecat git-state gastown/Toast --json`, Args: cobra.ExactArgs(1), RunE: runPolecatGitState, } 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") // 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(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("●") } // Normalize state for display (legacy idle/active → working) displayState := p.State if p.State == polecat.StateIdle || p.State == polecat.StateActive { displayState = polecat.StateWorking } // 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 gastown --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 for _, arg := range args { 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) logCmd := exec.Command("git", "log", "origin/main..HEAD", "--oneline") logCmd.Dir = worktreePath output, err = logCmd.Output() if err != nil { // origin/main might not exist - try origin/master logCmd = exec.Command("git", "log", "origin/master..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++ } } state.UnpushedCommits = count if count > 0 { 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 } 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 gastown --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 for _, arg := range args { 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, }) } } // 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)) continue } 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) closeCmd := exec.Command("bd", "close", agentBeadID, "--reason=nuked") 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 }