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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user