diff --git a/internal/cmd/rig.go b/internal/cmd/rig.go index ebcfbcaf..b5277626 100644 --- a/internal/cmd/rig.go +++ b/internal/cmd/rig.go @@ -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 diff --git a/internal/cmd/spawn.go b/internal/cmd/spawn.go index 8c0d43be..d46065e9 100644 --- a/internal/cmd/spawn.go +++ b/internal/cmd/spawn.go @@ -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) diff --git a/internal/git/git.go b/internal/git/git.go index d598d136..458c7d4c 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -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 +} diff --git a/internal/polecat/manager.go b/internal/polecat/manager.go index 00786e0a..74a271e0 100644 --- a/internal/polecat/manager.go +++ b/internal/polecat/manager.go @@ -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} + } } } diff --git a/internal/witness/manager.go b/internal/witness/manager.go index 5b2cc43d..403aec07 100644 --- a/internal/witness/manager.go +++ b/internal/witness/manager.go @@ -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)