feat(polecat): refuse to lose uncommitted work during cleanup (gt-8v8)

Add comprehensive uncommitted work checks before any polecat cleanup:
- Check for uncommitted changes (modified/untracked files)
- Check for stashes
- Check for unpushed commits

Affected commands:
- gt polecat remove: now refuses if uncommitted work exists
- gt rig shutdown: checks all polecats before shutdown
- Witness cleanup: refuses to clean polecats with uncommitted work
- gt spawn: warns if spawning to polecat with uncommitted work

Safety model:
- --force: bypasses uncommitted changes check only
- --nuclear: bypasses ALL safety checks (will lose work)

New git helpers:
- StashCount(): count stashes in repo
- UnpushedCommits(): count commits not pushed to upstream
- CheckUncommittedWork(): comprehensive work status check

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-20 15:55:05 -08:00
parent 2a9f31a02d
commit 4f21002132
5 changed files with 262 additions and 22 deletions

View File

@@ -554,9 +554,13 @@ func extractPolecatName(body string) string {
}
// cleanupPolecat performs the full cleanup sequence for an ephemeral polecat.
// 1. Kill session
// 2. Remove worktree
// 3. Delete branch
// 1. Check for uncommitted work (stubbornly refuses to lose work)
// 2. Kill session
// 3. Remove worktree
// 4. Delete branch
//
// If the polecat has uncommitted work (changes, stashes, or unpushed commits),
// the cleanup is aborted and an error is returned. The Witness will retry later.
func (m *Manager) cleanupPolecat(polecatName string) error {
fmt.Printf(" Cleaning up polecat %s...\n", polecatName)
@@ -566,7 +570,31 @@ func (m *Manager) cleanupPolecat(polecatName string) error {
polecatGit := git.NewGit(m.rig.Path)
polecatMgr := polecat.NewManager(m.rig, polecatGit)
// 1. Kill session
// Get polecat path for git check
polecatPath := filepath.Join(m.rig.Path, "polecats", polecatName)
// 1. Check for uncommitted work BEFORE doing anything destructive
pGit := git.NewGit(polecatPath)
status, err := pGit.CheckUncommittedWork()
if err != nil {
// If we can't check (e.g., not a git repo), log warning but continue
fmt.Printf(" Warning: could not check uncommitted work: %v\n", err)
} else if !status.Clean() {
// REFUSE to clean up - this is the key safety feature
fmt.Printf(" REFUSING to cleanup - polecat has uncommitted work:\n")
if status.HasUncommittedChanges {
fmt.Printf(" • %d uncommitted change(s)\n", len(status.ModifiedFiles)+len(status.UntrackedFiles))
}
if status.StashCount > 0 {
fmt.Printf(" • %d stash(es)\n", status.StashCount)
}
if status.UnpushedCommits > 0 {
fmt.Printf(" • %d unpushed commit(s)\n", status.UnpushedCommits)
}
return fmt.Errorf("polecat %s has uncommitted work: %s", polecatName, status.String())
}
// 2. Kill session
running, err := sessMgr.IsRunning(polecatName)
if err == nil && running {
fmt.Printf(" Killing session...\n")
@@ -575,16 +603,17 @@ func (m *Manager) cleanupPolecat(polecatName string) error {
}
}
// 2. Remove worktree (this also removes the directory)
// 3. Remove worktree (this also removes the directory)
// Use force=true since we've already verified no uncommitted work
fmt.Printf(" Removing worktree...\n")
if err := polecatMgr.Remove(polecatName, true); err != nil {
if err := polecatMgr.RemoveWithOptions(polecatName, true, true); err != nil {
// Only error if polecat actually exists
if !errors.Is(err, polecat.ErrPolecatNotFound) {
return fmt.Errorf("removing worktree: %w", err)
}
}
// 3. Delete branch from mayor's clone
// 4. Delete branch from mayor's clone
branchName := fmt.Sprintf("polecat/%s", polecatName)
mayorPath := filepath.Join(m.rig.Path, "mayor", "rig")
mayorGit := git.NewGit(mayorPath)