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

@@ -11,6 +11,7 @@ import (
"github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/git"
"github.com/steveyegge/gastown/internal/polecat"
"github.com/steveyegge/gastown/internal/refinery"
"github.com/steveyegge/gastown/internal/rig"
"github.com/steveyegge/gastown/internal/session"
@@ -91,22 +92,30 @@ This command gracefully shuts down:
- The refinery (if running)
- The witness (if running)
Before shutdown, checks all polecats for uncommitted work:
- Uncommitted changes (modified/untracked files)
- Stashes
- Unpushed commits
Use --force to skip graceful shutdown and kill immediately.
Use --nuclear to bypass ALL safety checks (will lose work!).
Examples:
gt rig shutdown gastown
gt rig shutdown gastown --force`,
gt rig shutdown gastown --force
gt rig shutdown gastown --nuclear # DANGER: loses uncommitted work`,
Args: cobra.ExactArgs(1),
RunE: runRigShutdown,
}
// Flags
var (
rigAddPrefix string
rigAddCrew string
rigResetHandoff bool
rigResetRole string
rigShutdownForce bool
rigAddPrefix string
rigAddCrew string
rigResetHandoff bool
rigResetRole string
rigShutdownForce bool
rigShutdownNuclear bool
)
func init() {
@@ -124,6 +133,7 @@ func init() {
rigResetCmd.Flags().StringVar(&rigResetRole, "role", "", "Role to reset (default: auto-detect from cwd)")
rigShutdownCmd.Flags().BoolVarP(&rigShutdownForce, "force", "f", false, "Force immediate shutdown")
rigShutdownCmd.Flags().BoolVar(&rigShutdownNuclear, "nuclear", false, "DANGER: Bypass ALL safety checks (loses uncommitted work!)")
}
func runRigAdd(cmd *cobra.Command, args []string) error {
@@ -353,6 +363,39 @@ func runRigShutdown(cmd *cobra.Command, args []string) error {
return fmt.Errorf("rig '%s' not found", rigName)
}
// Check all polecats for uncommitted work (unless nuclear)
if !rigShutdownNuclear {
polecatGit := git.NewGit(r.Path)
polecatMgr := polecat.NewManager(r, polecatGit)
polecats, err := polecatMgr.List()
if err == nil && len(polecats) > 0 {
var problemPolecats []struct {
name string
status *git.UncommittedWorkStatus
}
for _, p := range polecats {
pGit := git.NewGit(p.ClonePath)
status, err := pGit.CheckUncommittedWork()
if err == nil && !status.Clean() {
problemPolecats = append(problemPolecats, struct {
name string
status *git.UncommittedWorkStatus
}{p.Name, status})
}
}
if len(problemPolecats) > 0 {
fmt.Printf("\n%s Cannot shutdown - polecats have uncommitted work:\n\n", style.Warning.Render("⚠"))
for _, pp := range problemPolecats {
fmt.Printf(" %s: %s\n", style.Bold.Render(pp.name), pp.status.String())
}
fmt.Printf("\nUse %s to force shutdown (DANGER: will lose work!)\n", style.Bold.Render("--nuclear"))
return fmt.Errorf("refusing to shutdown with uncommitted work")
}
}
}
fmt.Printf("Shutting down rig %s...\n", style.Bold.Render(rigName))
var errors []string

View File

@@ -182,6 +182,29 @@ func runSpawn(cmd *cobra.Command, args []string) error {
return fmt.Errorf("polecat '%s' is already working on %s", polecatName, pc.Issue)
}
// Check for uncommitted work in existing polecat (safety check)
pGit := git.NewGit(pc.ClonePath)
workStatus, err := pGit.CheckUncommittedWork()
if err == nil && !workStatus.Clean() {
fmt.Printf("\n%s Polecat has uncommitted work:\n", style.Warning.Render("⚠"))
if workStatus.HasUncommittedChanges {
fmt.Printf(" • %d uncommitted change(s)\n", len(workStatus.ModifiedFiles)+len(workStatus.UntrackedFiles))
}
if workStatus.StashCount > 0 {
fmt.Printf(" • %d stash(es)\n", workStatus.StashCount)
}
if workStatus.UnpushedCommits > 0 {
fmt.Printf(" • %d unpushed commit(s)\n", workStatus.UnpushedCommits)
}
fmt.Println()
if !spawnForce {
return fmt.Errorf("polecat '%s' has uncommitted work (%s)\nCommit or stash changes before spawning, or use --force to proceed anyway",
polecatName, workStatus.String())
}
fmt.Printf("%s Proceeding with --force (uncommitted work may be lost if polecat is cleaned up)\n",
style.Dim.Render("Warning:"))
}
// Check for unread mail in polecat's inbox (indicates existing unstarted work)
polecatAddress := fmt.Sprintf("%s/%s", rigName, polecatName)
router := mail.NewRouter(r.Path)

View File

@@ -525,3 +525,117 @@ func (g *Git) CommitsAhead(base, branch string) (int, error) {
return count, nil
}
// StashCount returns the number of stashes in the repository.
func (g *Git) StashCount() (int, error) {
out, err := g.run("stash", "list")
if err != nil {
return 0, err
}
if out == "" {
return 0, nil
}
// Count lines in the stash list
lines := strings.Split(out, "\n")
count := 0
for _, line := range lines {
if line != "" {
count++
}
}
return count, nil
}
// UnpushedCommits returns the number of commits that are not pushed to the remote.
// It checks if the current branch has an upstream and counts commits ahead.
// Returns 0 if there is no upstream configured.
func (g *Git) UnpushedCommits() (int, error) {
// Get the upstream branch
upstream, err := g.run("rev-parse", "--abbrev-ref", "@{u}")
if err != nil {
// No upstream configured - this is common for polecat branches
// Check if we can compare against origin/main instead
// If we can't get any reference, return 0 (benefit of the doubt)
return 0, nil
}
// Count commits between upstream and HEAD
out, err := g.run("rev-list", "--count", upstream+"..HEAD")
if err != nil {
return 0, err
}
var count int
_, err = fmt.Sscanf(out, "%d", &count)
if err != nil {
return 0, fmt.Errorf("parsing unpushed count: %w", err)
}
return count, nil
}
// UncommittedWorkStatus contains information about uncommitted work in a repo.
type UncommittedWorkStatus struct {
HasUncommittedChanges bool
StashCount int
UnpushedCommits int
// Details for error messages
ModifiedFiles []string
UntrackedFiles []string
}
// Clean returns true if there is no uncommitted work.
func (s *UncommittedWorkStatus) Clean() bool {
return !s.HasUncommittedChanges && s.StashCount == 0 && s.UnpushedCommits == 0
}
// String returns a human-readable summary of uncommitted work.
func (s *UncommittedWorkStatus) String() string {
var issues []string
if s.HasUncommittedChanges {
issues = append(issues, fmt.Sprintf("%d uncommitted change(s)", len(s.ModifiedFiles)+len(s.UntrackedFiles)))
}
if s.StashCount > 0 {
issues = append(issues, fmt.Sprintf("%d stash(es)", s.StashCount))
}
if s.UnpushedCommits > 0 {
issues = append(issues, fmt.Sprintf("%d unpushed commit(s)", s.UnpushedCommits))
}
if len(issues) == 0 {
return "clean"
}
return strings.Join(issues, ", ")
}
// CheckUncommittedWork performs a comprehensive check for uncommitted work.
func (g *Git) CheckUncommittedWork() (*UncommittedWorkStatus, error) {
status := &UncommittedWorkStatus{}
// Check git status
gitStatus, err := g.Status()
if err != nil {
return nil, fmt.Errorf("checking git status: %w", err)
}
status.HasUncommittedChanges = !gitStatus.Clean
status.ModifiedFiles = append(gitStatus.Modified, gitStatus.Added...)
status.ModifiedFiles = append(status.ModifiedFiles, gitStatus.Deleted...)
status.UntrackedFiles = gitStatus.Untracked
// Check stashes
stashCount, err := g.StashCount()
if err != nil {
return nil, fmt.Errorf("checking stashes: %w", err)
}
status.StashCount = stashCount
// Check unpushed commits
unpushed, err := g.UnpushedCommits()
if err != nil {
return nil, fmt.Errorf("checking unpushed commits: %w", err)
}
status.UnpushedCommits = unpushed
return status, nil
}

View File

@@ -15,11 +15,26 @@ import (
// Common errors
var (
ErrPolecatExists = errors.New("polecat already exists")
ErrPolecatNotFound = errors.New("polecat not found")
ErrHasChanges = errors.New("polecat has uncommitted changes")
ErrPolecatExists = errors.New("polecat already exists")
ErrPolecatNotFound = errors.New("polecat not found")
ErrHasChanges = errors.New("polecat has uncommitted changes")
ErrHasUncommittedWork = errors.New("polecat has uncommitted work")
)
// UncommittedWorkError provides details about uncommitted work.
type UncommittedWorkError struct {
PolecatName string
Status *git.UncommittedWorkStatus
}
func (e *UncommittedWorkError) Error() string {
return fmt.Sprintf("polecat %s has uncommitted work: %s", e.PolecatName, e.Status.String())
}
func (e *UncommittedWorkError) Unwrap() error {
return ErrHasUncommittedWork
}
// Manager handles polecat lifecycle.
type Manager struct {
rig *rig.Rig
@@ -141,8 +156,16 @@ func (m *Manager) Add(name string) (*Polecat, error) {
}
// Remove deletes a polecat worktree.
// If force is true, removes even with uncommitted changes.
// If force is true, removes even with uncommitted changes (but not stashes/unpushed).
// Use nuclear=true to bypass ALL safety checks.
func (m *Manager) Remove(name string, force bool) error {
return m.RemoveWithOptions(name, force, false)
}
// RemoveWithOptions deletes a polecat worktree with explicit control over safety checks.
// force=true: bypass uncommitted changes check (legacy behavior)
// nuclear=true: bypass ALL safety checks including stashes and unpushed commits
func (m *Manager) RemoveWithOptions(name string, force, nuclear bool) error {
if !m.exists(name) {
return ErrPolecatNotFound
}
@@ -150,11 +173,19 @@ func (m *Manager) Remove(name string, force bool) error {
polecatPath := m.polecatDir(name)
polecatGit := git.NewGit(polecatPath)
// Check for uncommitted changes unless force
if !force {
hasChanges, err := polecatGit.HasUncommittedChanges()
if err == nil && hasChanges {
return ErrHasChanges
// Check for uncommitted work unless bypassed
if !nuclear {
status, err := polecatGit.CheckUncommittedWork()
if err == nil && !status.Clean() {
// For backward compatibility: force only bypasses uncommitted changes, not stashes/unpushed
if force {
// Force mode: allow uncommitted changes but still block on stashes/unpushed
if status.StashCount > 0 || status.UnpushedCommits > 0 {
return &UncommittedWorkError{PolecatName: name, Status: status}
}
} else {
return &UncommittedWorkError{PolecatName: name, Status: status}
}
}
}

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)