fix: Polecat lifecycle cleanup - stale worktrees and git tracking

Fixes gt-v07fl: Polecat lifecycle cleanup for stale worktrees and git
tracking conflicts.

Changes:
1. Add .claude/ to .gitignore (prevents untracked file accumulation)
2. Add beads runtime state patterns to .gitignore (prevents future tracking)
3. Remove .beads/ runtime state from git tracking (mq/, issues.jsonl, etc.)
   - Formulas and config remain tracked (needed for go install)
   - Created follow-up gt-mpyuq for formulas refactor
4. Add DetectStalePolecats() to polecat manager for identifying cleanup candidates
5. Add CountCommitsBehind() to git package for staleness detection
6. Add `gt polecat stale <rig>` command for stale polecat detection/cleanup
   - Shows polecats without active sessions
   - Identifies polecats far behind main (configurable threshold)
   - Optional --cleanup flag to auto-nuke stale polecats

The existing `gt polecat gc` command handles branch cleanup.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
nux
2026-01-04 23:43:17 -08:00
committed by Steve Yegge
parent 2141be7672
commit ca71f9b8de
72 changed files with 310 additions and 5959 deletions

View File

@@ -229,6 +229,37 @@ Examples:
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")
@@ -259,6 +290,11 @@ func init() {
// 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)
@@ -269,6 +305,7 @@ func init() {
polecatCmd.AddCommand(polecatCheckRecoveryCmd)
polecatCmd.AddCommand(polecatGCCmd)
polecatCmd.AddCommand(polecatNukeCmd)
polecatCmd.AddCommand(polecatStaleCmd)
rootCmd.AddCommand(polecatCmd)
}
@@ -1461,3 +1498,116 @@ func runPolecatNuke(cmd *cobra.Command, args []string) error {
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
}

View File

@@ -699,6 +699,24 @@ func (g *Git) CommitsAhead(base, branch string) (int, error) {
return count, nil
}
// CountCommitsBehind returns the number of commits that HEAD is behind the given ref.
// For example, CountCommitsBehind("origin/main") returns how many commits
// are on origin/main that are not on the current HEAD.
func (g *Git) CountCommitsBehind(ref string) (int, error) {
out, err := g.run("rev-list", "--count", "HEAD.."+ref)
if err != nil {
return 0, err
}
var count int
_, err = fmt.Sscanf(out, "%d", &count)
if err != nil {
return 0, fmt.Errorf("parsing commit count: %w", err)
}
return count, nil
}
// StashCount returns the number of stashes in the repository.
func (g *Git) StashCount() (int, error) {
out, err := g.run("stash", "list")

View File

@@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"time"
@@ -831,3 +832,131 @@ func (m *Manager) CleanupStaleBranches() (int, error) {
return deleted, nil
}
// StalenessInfo contains details about a polecat's staleness.
type StalenessInfo struct {
Name string
CommitsBehind int // How many commits behind origin/main
HasActiveSession bool // Whether tmux session is running
HasUncommittedWork bool // Whether there's uncommitted or unpushed work
AgentState string // From agent bead (empty if no bead)
IsStale bool // Overall assessment: safe to clean up
Reason string // Why it's considered stale (or not)
}
// DetectStalePolecats identifies polecats that are candidates for cleanup.
// A polecat is considered stale if:
// - No active tmux session AND
// - Either: way behind main (>threshold commits) OR no agent bead/activity
// - Has no uncommitted work that could be lost
//
// threshold: minimum commits behind main to consider "way behind" (e.g., 20)
func (m *Manager) DetectStalePolecats(threshold int) ([]*StalenessInfo, error) {
polecats, err := m.List()
if err != nil {
return nil, fmt.Errorf("listing polecats: %w", err)
}
if len(polecats) == 0 {
return nil, nil
}
// Get default branch from rig config
defaultBranch := "main"
if rigCfg, err := rig.LoadRigConfig(m.rig.Path); err == nil && rigCfg.DefaultBranch != "" {
defaultBranch = rigCfg.DefaultBranch
}
var results []*StalenessInfo
for _, p := range polecats {
info := &StalenessInfo{
Name: p.Name,
}
// Check for active tmux session
// Session name follows pattern: gt-<rig>-<polecat>
sessionName := fmt.Sprintf("gt-%s-%s", m.rig.Name, p.Name)
info.HasActiveSession = checkTmuxSession(sessionName)
// Check how far behind main
polecatGit := git.NewGit(p.ClonePath)
info.CommitsBehind = countCommitsBehind(polecatGit, defaultBranch)
// Check for uncommitted work
status, err := polecatGit.CheckUncommittedWork()
if err == nil && !status.Clean() {
info.HasUncommittedWork = true
}
// Check agent bead state
agentID := m.agentBeadID(p.Name)
_, fields, err := m.beads.GetAgentBead(agentID)
if err == nil && fields != nil {
info.AgentState = fields.AgentState
}
// Determine staleness
info.IsStale, info.Reason = assessStaleness(info, threshold)
results = append(results, info)
}
return results, nil
}
// checkTmuxSession checks if a tmux session exists.
func checkTmuxSession(sessionName string) bool {
// Use has-session command which returns 0 if session exists
cmd := exec.Command("tmux", "has-session", "-t", sessionName) //nolint:gosec // G204: sessionName is constructed internally
return cmd.Run() == nil
}
// countCommitsBehind counts how many commits a worktree is behind origin/<defaultBranch>.
func countCommitsBehind(g *git.Git, defaultBranch string) int {
// Use rev-list to count commits: origin/main..HEAD shows commits ahead,
// HEAD..origin/main shows commits behind
remoteBranch := "origin/" + defaultBranch
count, err := g.CountCommitsBehind(remoteBranch)
if err != nil {
return 0 // Can't determine, assume not behind
}
return count
}
// assessStaleness determines if a polecat should be cleaned up.
func assessStaleness(info *StalenessInfo, threshold int) (bool, string) {
// Never clean up if there's uncommitted work
if info.HasUncommittedWork {
return false, "has uncommitted work"
}
// If session is active, not stale
if info.HasActiveSession {
return false, "session active"
}
// No active session - check other indicators
// If agent reports "running" state but no session, that's suspicious
// but give benefit of doubt (session may have just died)
if info.AgentState == "running" {
return false, "agent reports running (session may be restarting)"
}
// If agent reports "done" or "idle", it's a cleanup candidate
if info.AgentState == "done" || info.AgentState == "idle" {
return true, fmt.Sprintf("agent_state=%s, no active session", info.AgentState)
}
// Way behind main is a strong staleness signal
if info.CommitsBehind >= threshold {
return true, fmt.Sprintf("%d commits behind main, no active session", info.CommitsBehind)
}
// No agent bead and no session - likely abandoned
if info.AgentState == "" {
return true, "no agent bead, no active session"
}
// Default: not enough evidence to consider stale
return false, "insufficient staleness indicators"
}