From 82d718ee3421ac0176dada435a5b1e3cf7b4973d Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Sun, 21 Dec 2025 10:10:27 -0800 Subject: [PATCH] feat(spawn): always create fresh polecat worktrees MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes gt-9nf: gt spawn should create fresh polecat worktree, never reuse Changes: - Add Recreate() method to polecat manager that removes and recreates worktrees - Modify spawn.go to always recreate existing polecats with fresh worktrees - Preserves safety checks: blocks if polecat is working or has uncommitted work - Use --force to bypass uncommitted work checks This ensures polecats always start with the latest code from the base branch, avoiding stale code, stale beads, and git history pollution. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/cmd/spawn.go | 118 ++++++++++++++++++++---------------- internal/polecat/manager.go | 60 ++++++++++++++++++ 2 files changed, 125 insertions(+), 53 deletions(-) diff --git a/internal/cmd/spawn.go b/internal/cmd/spawn.go index d46065e9..788905dd 100644 --- a/internal/cmd/spawn.go +++ b/internal/cmd/spawn.go @@ -160,65 +160,78 @@ func runSpawn(cmd *cobra.Command, args []string) error { } } - // Check/create polecat - pc, err := polecatMgr.Get(polecatName) - if err != nil { - if err == polecat.ErrPolecatNotFound { - if !spawnCreate { - return fmt.Errorf("polecat '%s' not found (use --create to create)", polecatName) + // Check if polecat exists + existingPolecat, err := polecatMgr.Get(polecatName) + polecatExists := err == nil + + if polecatExists { + // Polecat exists - we'll recreate it fresh after safety checks + + // Check if polecat is currently working (cannot interrupt active work) + if existingPolecat.State == polecat.StateWorking { + return fmt.Errorf("polecat '%s' is already working on %s", polecatName, existingPolecat.Issue) + } + + // Check for uncommitted work (safety check before recreating) + pGit := git.NewGit(existingPolecat.ClonePath) + workStatus, checkErr := pGit.CheckUncommittedWork() + if checkErr == 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)) } - fmt.Printf("Creating polecat %s...\n", polecatName) - pc, err = polecatMgr.Add(polecatName) - if err != nil { - return fmt.Errorf("creating polecat: %w", err) + if workStatus.StashCount > 0 { + fmt.Printf(" • %d stash(es)\n", workStatus.StashCount) } - } else { - return fmt.Errorf("getting polecat: %w", err) + 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 will be lost)\n", + style.Dim.Render("Warning:")) } + + // Check for unread mail (indicates existing unstarted work) + polecatAddress := fmt.Sprintf("%s/%s", rigName, polecatName) + router := mail.NewRouter(r.Path) + mailbox, mailErr := router.GetMailbox(polecatAddress) + if mailErr == nil { + _, unread, _ := mailbox.Count() + if unread > 0 && !spawnForce { + return fmt.Errorf("polecat '%s' has %d unread message(s) in inbox (possible existing work assignment)\nUse --force to override, or let the polecat process its inbox first", + polecatName, unread) + } else if unread > 0 { + fmt.Printf("%s Polecat has %d unread message(s), proceeding with --force\n", + style.Dim.Render("Warning:"), unread) + } + } + + // Recreate the polecat with a fresh worktree (latest code from main) + fmt.Printf("Recreating polecat %s with fresh worktree...\n", polecatName) + if _, err = polecatMgr.Recreate(polecatName, spawnForce); err != nil { + return fmt.Errorf("recreating polecat: %w", err) + } + fmt.Printf("%s Fresh worktree created\n", style.Bold.Render("✓")) + } else if err == polecat.ErrPolecatNotFound { + // Polecat doesn't exist - create new one + if !spawnCreate { + return fmt.Errorf("polecat '%s' not found (use --create to create)", polecatName) + } + fmt.Printf("Creating polecat %s...\n", polecatName) + if _, err = polecatMgr.Add(polecatName); err != nil { + return fmt.Errorf("creating polecat: %w", err) + } + } else { + return fmt.Errorf("getting polecat: %w", err) } - // Check polecat state - if pc.State == polecat.StateWorking { - 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) + // Define polecatAddress and router for later use (mail sending) polecatAddress := fmt.Sprintf("%s/%s", rigName, polecatName) router := mail.NewRouter(r.Path) - mailbox, err := router.GetMailbox(polecatAddress) - if err == nil { - _, unread, _ := mailbox.Count() - if unread > 0 && !spawnForce { - return fmt.Errorf("polecat '%s' has %d unread message(s) in inbox (possible existing work assignment)\nUse --force to override, or let the polecat process its inbox first", - polecatName, unread) - } else if unread > 0 { - fmt.Printf("%s Polecat has %d unread message(s), proceeding with --force\n", - style.Dim.Render("Warning:"), unread) - } - } // Beads operations use rig-level beads (at rig root, not mayor/rig) beadsPath := r.Path @@ -327,7 +340,6 @@ func runSpawn(cmd *cobra.Command, args []string) error { } // Send work assignment mail to polecat inbox (before starting session) - // polecatAddress and router already defined above when checking for unread mail workMsg := buildWorkAssignmentMail(issue, spawnMessage, polecatAddress) fmt.Printf("Sending work assignment to %s inbox...\n", polecatAddress) diff --git a/internal/polecat/manager.go b/internal/polecat/manager.go index dd022862..b375a4f7 100644 --- a/internal/polecat/manager.go +++ b/internal/polecat/manager.go @@ -246,6 +246,66 @@ func (m *Manager) ReleaseName(name string) { _ = m.namePool.Save() } +// Recreate removes an existing polecat and creates a fresh worktree. +// This ensures the polecat starts with the latest code from the base branch. +// The name is preserved (not released to pool) since we're recreating immediately. +// force controls whether to bypass uncommitted changes check. +func (m *Manager) Recreate(name string, force bool) (*Polecat, error) { + if !m.exists(name) { + return nil, ErrPolecatNotFound + } + + polecatPath := m.polecatDir(name) + branchName := fmt.Sprintf("polecat/%s", name) + mayorPath := filepath.Join(m.rig.Path, "mayor", "rig") + mayorGit := git.NewGit(mayorPath) + polecatGit := git.NewGit(polecatPath) + + // Check for uncommitted work unless forced + if !force { + status, err := polecatGit.CheckUncommittedWork() + if err == nil && !status.Clean() { + return nil, &UncommittedWorkError{PolecatName: name, Status: status} + } + } + + // Remove the worktree (use force for git worktree removal) + if err := mayorGit.WorktreeRemove(polecatPath, true); err != nil { + // Fall back to direct removal + if removeErr := os.RemoveAll(polecatPath); removeErr != nil { + return nil, fmt.Errorf("removing polecat dir: %w", removeErr) + } + } + + // Prune stale worktree entries + _ = mayorGit.WorktreePrune() + + // Delete the old branch so worktree starts fresh from current HEAD + _ = mayorGit.DeleteBranch(branchName, true) // force delete + + // Create fresh worktree with new branch from current HEAD + if err := mayorGit.WorktreeAdd(polecatPath, branchName); err != nil { + return nil, fmt.Errorf("creating fresh worktree: %w", err) + } + + // Set up shared beads + if err := m.setupSharedBeads(polecatPath); err != nil { + fmt.Printf("Warning: could not set up shared beads: %v\n", err) + } + + // Return fresh polecat + now := time.Now() + return &Polecat{ + Name: name, + Rig: m.rig.Name, + State: StateIdle, + ClonePath: polecatPath, + Branch: branchName, + CreatedAt: now, + UpdatedAt: now, + }, nil +} + // ReconcilePool syncs pool state with existing polecat directories. // This should be called to recover from crashes or stale state. func (m *Manager) ReconcilePool() {