gt polecat nuke: Add safety checks for unpushed commits, open MRs, work on hook

The command now refuses to nuke a polecat unless it passes safety checks:
1. Worktree has no unpushed/uncommitted changes
2. Polecat has no open merge request beads
3. Polecat has no work on its hook

Use --force to bypass these checks (loses work).
Use --dry-run to see safety check status before nuking.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
gastown/polecats/nux
2025-12-30 23:03:04 -08:00
committed by Steve Yegge
parent 172d5f7402
commit 1cbe638e4f

View File

@@ -237,13 +237,20 @@ This is the nuclear option for post-merge cleanup. It:
3. Deletes the polecat branch
4. Closes the agent bead (if exists)
Use this after the Refinery has merged the polecat's work.
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 --all --dry-run
gt polecat nuke greenplace/Toast --force # bypass safety checks`,
Args: cobra.MinimumNArgs(1),
RunE: runPolecatNuke,
}
@@ -312,7 +319,7 @@ func init() {
// 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 even if polecat has unpushed work")
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")
@@ -1341,55 +1348,107 @@ func runPolecatNuke(cmd *cobra.Command, args []string) error {
}
}
// Check recovery status for each polecat unless --force is set
// This prevents accidental data loss when nuking polecats with unpushed work
// 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 {
var needsRecovery []string
type blockReason struct {
polecat string
reasons []string
}
var blocked []blockReason
for _, p := range toNuke {
// Check cleanup_status from agent bead
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)
_, fields, err := bd.GetAgentBead(agentBeadID)
agentIssue, fields, err := bd.GetAgentBead(agentBeadID)
var recoveryNeeded bool
if err != nil || fields == nil {
// No agent bead - fall back to git check
polecatInfo, infoErr := p.mgr.Get(p.polecatName)
if infoErr == nil && polecatInfo != nil {
gitState, gitErr := getGitState(polecatInfo.ClonePath)
if gitErr != nil || !gitState.Clean {
recoveryNeeded = true
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":
recoveryNeeded = false
case "has_uncommitted", "has_unpushed", "has_stash", "unknown", "":
recoveryNeeded = true
// 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:
recoveryNeeded = true
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))
}
}
if recoveryNeeded {
needsRecovery = append(needsRecovery, fmt.Sprintf("%s/%s", p.rigName, p.polecatName))
// 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(needsRecovery) > 0 {
fmt.Printf("%s The following polecats have unpushed/uncommitted work:\n", style.Error.Render("Error:"))
for _, pc := range needsRecovery {
fmt.Printf(" - %s\n", pc)
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("These polecats NEED RECOVERY before cleanup.")
fmt.Println("Safety checks failed. Resolve issues before nuking, or use --force.")
fmt.Println("Options:")
fmt.Printf(" 1. Escalate to Mayor: gt mail send mayor/ -s \"RECOVERY_NEEDED\" -m \"...\"\n")
fmt.Printf(" 2. Force nuke (LOSES WORK): gt polecat nuke --force %s\n", strings.Join(needsRecovery, " "))
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) need recovery", len(needsRecovery))
return fmt.Errorf("blocked: %d polecat(s) have active work", len(blocked))
}
}
@@ -1405,6 +1464,62 @@ func runPolecatNuke(cmd *cobra.Command, args []string) error {
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
}