* fix(beads): align agent bead prefixes and force multi-hyphen IDs * fix(checkpoint): treat threshold as stale at boundary
271 lines
9.1 KiB
Go
271 lines
9.1 KiB
Go
package cmd
|
|
|
|
import (
|
|
"fmt"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/steveyegge/gastown/internal/beads"
|
|
"github.com/steveyegge/gastown/internal/polecat"
|
|
"github.com/steveyegge/gastown/internal/rig"
|
|
"github.com/steveyegge/gastown/internal/style"
|
|
)
|
|
|
|
// polecatTarget represents a polecat to operate on.
|
|
type polecatTarget struct {
|
|
rigName string
|
|
polecatName string
|
|
mgr *polecat.Manager
|
|
r *rig.Rig
|
|
}
|
|
|
|
// resolvePolecatTargets builds a list of polecats from command args.
|
|
// If useAll is true, the first arg is treated as a rig name and all polecats in it are returned.
|
|
// Otherwise, args are parsed as rig/polecat addresses.
|
|
func resolvePolecatTargets(args []string, useAll bool) ([]polecatTarget, error) {
|
|
var targets []polecatTarget
|
|
|
|
if useAll {
|
|
// --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 nil, fmt.Errorf("with --all, provide just the rig name (e.g., 'gt polecat <cmd> %s --all')", strings.Split(rigName, "/")[0])
|
|
}
|
|
|
|
mgr, r, err := getPolecatManager(rigName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
polecats, err := mgr.List()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("listing polecats: %w", err)
|
|
}
|
|
|
|
for _, p := range polecats {
|
|
targets = append(targets, polecatTarget{
|
|
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 nil, 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 nil, fmt.Errorf("invalid address '%s': %w", arg, err)
|
|
}
|
|
|
|
mgr, r, err := getPolecatManager(rigName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
targets = append(targets, polecatTarget{
|
|
rigName: rigName,
|
|
polecatName: polecatName,
|
|
mgr: mgr,
|
|
r: r,
|
|
})
|
|
}
|
|
}
|
|
|
|
return targets, nil
|
|
}
|
|
|
|
// SafetyCheckResult holds the result of safety checks for a polecat.
|
|
type SafetyCheckResult struct {
|
|
Polecat string
|
|
Blocked bool
|
|
Reasons []string
|
|
CleanupStatus polecat.CleanupStatus
|
|
HookBead string
|
|
HookStale bool // true if hooked bead is closed
|
|
OpenMR string
|
|
GitState *GitState
|
|
}
|
|
|
|
// checkPolecatSafety performs safety checks before destructive operations.
|
|
// Returns nil if the polecat is safe to operate on, or a SafetyCheckResult with reasons if blocked.
|
|
func checkPolecatSafety(target polecatTarget) *SafetyCheckResult {
|
|
result := &SafetyCheckResult{
|
|
Polecat: fmt.Sprintf("%s/%s", target.rigName, target.polecatName),
|
|
}
|
|
|
|
// Get polecat info for branch name
|
|
polecatInfo, infoErr := target.mgr.Get(target.polecatName)
|
|
|
|
// Check 1: Unpushed commits via cleanup_status or git state
|
|
bd := beads.New(target.r.Path)
|
|
agentBeadID := polecatBeadIDForRig(target.r, target.rigName, target.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)
|
|
result.GitState = gitState
|
|
if gitErr != nil {
|
|
result.Reasons = append(result.Reasons, "cannot check git state")
|
|
} else if !gitState.Clean {
|
|
if gitState.UnpushedCommits > 0 {
|
|
result.Reasons = append(result.Reasons, fmt.Sprintf("has %d unpushed commit(s)", gitState.UnpushedCommits))
|
|
} else if len(gitState.UncommittedFiles) > 0 {
|
|
result.Reasons = append(result.Reasons, fmt.Sprintf("has %d uncommitted file(s)", len(gitState.UncommittedFiles)))
|
|
} else if gitState.StashCount > 0 {
|
|
result.Reasons = append(result.Reasons, fmt.Sprintf("has %d stash(es)", gitState.StashCount))
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// Check cleanup_status from agent bead
|
|
result.CleanupStatus = polecat.CleanupStatus(fields.CleanupStatus)
|
|
switch result.CleanupStatus {
|
|
case polecat.CleanupClean:
|
|
// OK
|
|
case polecat.CleanupUnpushed:
|
|
result.Reasons = append(result.Reasons, "has unpushed commits")
|
|
case polecat.CleanupUncommitted:
|
|
result.Reasons = append(result.Reasons, "has uncommitted changes")
|
|
case polecat.CleanupStash:
|
|
result.Reasons = append(result.Reasons, "has stashed changes")
|
|
case polecat.CleanupUnknown, "":
|
|
result.Reasons = append(result.Reasons, "cleanup status unknown")
|
|
default:
|
|
result.Reasons = append(result.Reasons, fmt.Sprintf("cleanup status: %s", result.CleanupStatus))
|
|
}
|
|
|
|
// Check 3: Work on hook
|
|
hookBead := agentIssue.HookBead
|
|
if hookBead == "" {
|
|
hookBead = fields.HookBead
|
|
}
|
|
if hookBead != "" {
|
|
result.HookBead = hookBead
|
|
// Check if hooked bead is still active (not closed)
|
|
hookedIssue, err := bd.Show(hookBead)
|
|
if err == nil && hookedIssue != nil {
|
|
if hookedIssue.Status != "closed" {
|
|
result.Reasons = append(result.Reasons, fmt.Sprintf("has work on hook (%s)", hookBead))
|
|
} else {
|
|
result.HookStale = true
|
|
}
|
|
} else {
|
|
result.Reasons = append(result.Reasons, fmt.Sprintf("has work on hook (%s, unverified)", hookBead))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check 2: Open MR beads for this branch
|
|
if infoErr == nil && polecatInfo != nil && polecatInfo.Branch != "" {
|
|
mr, mrErr := bd.FindMRForBranch(polecatInfo.Branch)
|
|
if mrErr == nil && mr != nil {
|
|
result.OpenMR = mr.ID
|
|
result.Reasons = append(result.Reasons, fmt.Sprintf("has open MR (%s)", mr.ID))
|
|
}
|
|
}
|
|
|
|
result.Blocked = len(result.Reasons) > 0
|
|
return result
|
|
}
|
|
|
|
func rigPrefix(r *rig.Rig) string {
|
|
townRoot := filepath.Dir(r.Path)
|
|
return beads.GetPrefixForRig(townRoot, r.Name)
|
|
}
|
|
|
|
func polecatBeadIDForRig(r *rig.Rig, rigName, polecatName string) string {
|
|
return beads.PolecatBeadIDWithPrefix(rigPrefix(r), rigName, polecatName)
|
|
}
|
|
|
|
// displaySafetyCheckBlocked prints blocked polecats and guidance.
|
|
func displaySafetyCheckBlocked(blocked []*SafetyCheckResult) {
|
|
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()
|
|
}
|
|
|
|
// displayDryRunSafetyCheck shows safety check status for dry-run mode.
|
|
func displayDryRunSafetyCheck(target polecatTarget) {
|
|
fmt.Printf("\n Safety checks:\n")
|
|
polecatInfo, infoErr := target.mgr.Get(target.polecatName)
|
|
bd := beads.New(target.r.Path)
|
|
agentBeadID := polecatBeadIDForRig(target.r, target.rigName, target.polecatName)
|
|
agentIssue, fields, err := bd.GetAgentBead(agentBeadID)
|
|
|
|
// Check 1: Git state
|
|
if err != nil || fields == nil {
|
|
if infoErr == nil && polecatInfo != nil {
|
|
gitState, gitErr := getGitState(polecatInfo.ClonePath)
|
|
if gitErr != nil {
|
|
fmt.Printf(" - Git state: %s\n", style.Warning.Render("cannot check"))
|
|
} else if gitState.Clean {
|
|
fmt.Printf(" - Git state: %s\n", style.Success.Render("clean"))
|
|
} else {
|
|
fmt.Printf(" - Git state: %s\n", style.Error.Render("dirty"))
|
|
}
|
|
} else {
|
|
fmt.Printf(" - Git state: %s\n", style.Dim.Render("unknown (no polecat info)"))
|
|
}
|
|
fmt.Printf(" - Hook: %s\n", style.Dim.Render("unknown (no agent bead)"))
|
|
} else {
|
|
cleanupStatus := polecat.CleanupStatus(fields.CleanupStatus)
|
|
if cleanupStatus.IsSafe() {
|
|
fmt.Printf(" - Git state: %s\n", style.Success.Render("clean"))
|
|
} else if cleanupStatus.RequiresRecovery() {
|
|
fmt.Printf(" - Git state: %s (%s)\n", style.Error.Render("dirty"), cleanupStatus)
|
|
} else {
|
|
fmt.Printf(" - Git state: %s\n", style.Warning.Render("unknown"))
|
|
}
|
|
|
|
hookBead := agentIssue.HookBead
|
|
if hookBead == "" {
|
|
hookBead = fields.HookBead
|
|
}
|
|
if hookBead != "" {
|
|
hookedIssue, err := bd.Show(hookBead)
|
|
if err == nil && hookedIssue != nil && hookedIssue.Status == "closed" {
|
|
fmt.Printf(" - Hook: %s (%s, closed - stale)\n", style.Warning.Render("stale"), hookBead)
|
|
} else {
|
|
fmt.Printf(" - Hook: %s (%s)\n", style.Error.Render("has work"), hookBead)
|
|
}
|
|
} else {
|
|
fmt.Printf(" - Hook: %s\n", style.Success.Render("empty"))
|
|
}
|
|
}
|
|
|
|
// Check 2: Open MR
|
|
if infoErr == nil && polecatInfo != nil && polecatInfo.Branch != "" {
|
|
mr, mrErr := bd.FindMRForBranch(polecatInfo.Branch)
|
|
if mrErr == nil && mr != nil {
|
|
fmt.Printf(" - Open MR: %s (%s)\n", style.Error.Render("yes"), mr.ID)
|
|
} else {
|
|
fmt.Printf(" - Open MR: %s\n", style.Success.Render("none"))
|
|
}
|
|
} else {
|
|
fmt.Printf(" - Open MR: %s\n", style.Dim.Render("unknown (no branch info)"))
|
|
}
|
|
}
|