feat(spawn): always create fresh polecat worktrees
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 <noreply@anthropic.com>
This commit is contained in:
@@ -160,32 +160,22 @@ func runSpawn(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check/create polecat
|
// Check if polecat exists
|
||||||
pc, err := polecatMgr.Get(polecatName)
|
existingPolecat, err := polecatMgr.Get(polecatName)
|
||||||
if err != nil {
|
polecatExists := err == nil
|
||||||
if err == polecat.ErrPolecatNotFound {
|
|
||||||
if !spawnCreate {
|
if polecatExists {
|
||||||
return fmt.Errorf("polecat '%s' not found (use --create to create)", polecatName)
|
// Polecat exists - we'll recreate it fresh after safety checks
|
||||||
}
|
|
||||||
fmt.Printf("Creating polecat %s...\n", polecatName)
|
// Check if polecat is currently working (cannot interrupt active work)
|
||||||
pc, err = polecatMgr.Add(polecatName)
|
if existingPolecat.State == polecat.StateWorking {
|
||||||
if err != nil {
|
return fmt.Errorf("polecat '%s' is already working on %s", polecatName, existingPolecat.Issue)
|
||||||
return fmt.Errorf("creating polecat: %w", err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return fmt.Errorf("getting polecat: %w", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check polecat state
|
// Check for uncommitted work (safety check before recreating)
|
||||||
if pc.State == polecat.StateWorking {
|
pGit := git.NewGit(existingPolecat.ClonePath)
|
||||||
return fmt.Errorf("polecat '%s' is already working on %s", polecatName, pc.Issue)
|
workStatus, checkErr := pGit.CheckUncommittedWork()
|
||||||
}
|
if checkErr == nil && !workStatus.Clean() {
|
||||||
|
|
||||||
// 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("⚠"))
|
fmt.Printf("\n%s Polecat has uncommitted work:\n", style.Warning.Render("⚠"))
|
||||||
if workStatus.HasUncommittedChanges {
|
if workStatus.HasUncommittedChanges {
|
||||||
fmt.Printf(" • %d uncommitted change(s)\n", len(workStatus.ModifiedFiles)+len(workStatus.UntrackedFiles))
|
fmt.Printf(" • %d uncommitted change(s)\n", len(workStatus.ModifiedFiles)+len(workStatus.UntrackedFiles))
|
||||||
@@ -201,15 +191,15 @@ func runSpawn(cmd *cobra.Command, args []string) error {
|
|||||||
return fmt.Errorf("polecat '%s' has uncommitted work (%s)\nCommit or stash changes before spawning, or use --force to proceed anyway",
|
return fmt.Errorf("polecat '%s' has uncommitted work (%s)\nCommit or stash changes before spawning, or use --force to proceed anyway",
|
||||||
polecatName, workStatus.String())
|
polecatName, workStatus.String())
|
||||||
}
|
}
|
||||||
fmt.Printf("%s Proceeding with --force (uncommitted work may be lost if polecat is cleaned up)\n",
|
fmt.Printf("%s Proceeding with --force (uncommitted work will be lost)\n",
|
||||||
style.Dim.Render("Warning:"))
|
style.Dim.Render("Warning:"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for unread mail in polecat's inbox (indicates existing unstarted work)
|
// Check for unread mail (indicates existing unstarted work)
|
||||||
polecatAddress := fmt.Sprintf("%s/%s", rigName, polecatName)
|
polecatAddress := fmt.Sprintf("%s/%s", rigName, polecatName)
|
||||||
router := mail.NewRouter(r.Path)
|
router := mail.NewRouter(r.Path)
|
||||||
mailbox, err := router.GetMailbox(polecatAddress)
|
mailbox, mailErr := router.GetMailbox(polecatAddress)
|
||||||
if err == nil {
|
if mailErr == nil {
|
||||||
_, unread, _ := mailbox.Count()
|
_, unread, _ := mailbox.Count()
|
||||||
if unread > 0 && !spawnForce {
|
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",
|
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",
|
||||||
@@ -220,6 +210,29 @@ func runSpawn(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define polecatAddress and router for later use (mail sending)
|
||||||
|
polecatAddress := fmt.Sprintf("%s/%s", rigName, polecatName)
|
||||||
|
router := mail.NewRouter(r.Path)
|
||||||
|
|
||||||
// Beads operations use rig-level beads (at rig root, not mayor/rig)
|
// Beads operations use rig-level beads (at rig root, not mayor/rig)
|
||||||
beadsPath := r.Path
|
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)
|
// 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)
|
workMsg := buildWorkAssignmentMail(issue, spawnMessage, polecatAddress)
|
||||||
|
|
||||||
fmt.Printf("Sending work assignment to %s inbox...\n", polecatAddress)
|
fmt.Printf("Sending work assignment to %s inbox...\n", polecatAddress)
|
||||||
|
|||||||
@@ -246,6 +246,66 @@ func (m *Manager) ReleaseName(name string) {
|
|||||||
_ = m.namePool.Save()
|
_ = 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.
|
// ReconcilePool syncs pool state with existing polecat directories.
|
||||||
// This should be called to recover from crashes or stale state.
|
// This should be called to recover from crashes or stale state.
|
||||||
func (m *Manager) ReconcilePool() {
|
func (m *Manager) ReconcilePool() {
|
||||||
|
|||||||
Reference in New Issue
Block a user