polecat: fresh unique branches per run, add gc command (gt-ake0m)

Changed polecat branch model to use unique timestamped branches
(polecat/<name>-<timestamp>) instead of reusing persistent branches.
This prevents JSONL drift issues where stale polecat branches don't
have recently created beads.

Changes:
- Add(): create unique branch, simplified (no reuse logic)
- Recreate(): create fresh branch, old ones left for GC
- loadFromBeads(): read actual branch from git worktree
- CleanupStaleBranches(): remove orphaned polecat branches
- ListBranches(pattern): new git helper for branch enumeration
- gt polecat gc: new command to clean up stale branches

🤖 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-27 17:19:02 -08:00
parent 3aa0ba6e6b
commit 4730ac508f
3 changed files with 189 additions and 48 deletions

View File

@@ -197,8 +197,29 @@ var (
polecatSyncFromMain bool
polecatStatusJSON bool
polecatGitStateJSON bool
polecatGCDryRun bool
)
var polecatGCCmd = &cobra.Command{
Use: "gc <rig>",
Short: "Garbage collect stale polecat branches",
Long: `Garbage collect stale polecat branches in a rig.
Polecats use unique timestamped branches (polecat/<name>-<timestamp>) to
prevent drift issues. Over time, these branches accumulate as polecats
are recreated.
This command removes orphaned branches:
- Branches for polecats that no longer exist
- Old timestamped branches (keeps only the current one per polecat)
Examples:
gt polecat gc gastown
gt polecat gc gastown --dry-run`,
Args: cobra.ExactArgs(1),
RunE: runPolecatGC,
}
var polecatGitStateCmd = &cobra.Command{
Use: "git-state <rig>/<polecat>",
Short: "Show git state for pre-kill verification",
@@ -238,6 +259,9 @@ func init() {
// Git-state flags
polecatGitStateCmd.Flags().BoolVar(&polecatGitStateJSON, "json", false, "Output as JSON")
// GC flags
polecatGCCmd.Flags().BoolVar(&polecatGCDryRun, "dry-run", false, "Show what would be deleted without deleting")
// Add subcommands
polecatCmd.AddCommand(polecatListCmd)
polecatCmd.AddCommand(polecatAddCmd)
@@ -249,6 +273,7 @@ func init() {
polecatCmd.AddCommand(polecatSyncCmd)
polecatCmd.AddCommand(polecatStatusCmd)
polecatCmd.AddCommand(polecatGitStateCmd)
polecatCmd.AddCommand(polecatGCCmd)
rootCmd.AddCommand(polecatCmd)
}
@@ -998,6 +1023,72 @@ func getGitState(worktreePath string) (*GitState, error) {
return state, nil
}
func runPolecatGC(cmd *cobra.Command, args []string) error {
rigName := args[0]
mgr, r, err := getPolecatManager(rigName)
if err != nil {
return err
}
fmt.Printf("Garbage collecting stale polecat branches in %s...\n\n", r.Name)
if polecatGCDryRun {
// Dry run - list branches that would be deleted
repoGit := git.NewGit(r.Path)
// List all polecat branches
branches, err := repoGit.ListBranches("polecat/*")
if err != nil {
return fmt.Errorf("listing branches: %w", err)
}
if len(branches) == 0 {
fmt.Println("No polecat branches found.")
return nil
}
// Get current branches
polecats, err := mgr.List()
if err != nil {
return fmt.Errorf("listing polecats: %w", err)
}
currentBranches := make(map[string]bool)
for _, p := range polecats {
currentBranches[p.Branch] = true
}
// Show what would be deleted
toDelete := 0
for _, branch := range branches {
if !currentBranches[branch] {
fmt.Printf(" Would delete: %s\n", style.Dim.Render(branch))
toDelete++
} else {
fmt.Printf(" Keep (in use): %s\n", style.Success.Render(branch))
}
}
fmt.Printf("\nWould delete %d branch(es), keep %d\n", toDelete, len(branches)-toDelete)
return nil
}
// Actually clean up
deleted, err := mgr.CleanupStaleBranches()
if err != nil {
return fmt.Errorf("cleanup failed: %w", err)
}
if deleted == 0 {
fmt.Println("No stale branches to clean up.")
} else {
fmt.Printf("%s Deleted %d stale branch(es).\n", style.SuccessPrefix, deleted)
}
return nil
}
// splitLines splits a string into non-empty lines.
func splitLines(s string) []string {
var lines []string

View File

@@ -401,6 +401,24 @@ func (g *Git) DeleteBranch(name string, force bool) error {
return err
}
// ListBranches returns all local branches matching a pattern.
// Pattern uses git's pattern matching (e.g., "polecat/*" matches all polecat branches).
// Returns branch names without the refs/heads/ prefix.
func (g *Git) ListBranches(pattern string) ([]string, error) {
args := []string{"branch", "--list", "--format=%(refname:short)"}
if pattern != "" {
args = append(args, pattern)
}
out, err := g.run(args...)
if err != nil {
return nil, err
}
if out == "" {
return nil, nil
}
return strings.Split(out, "\n"), nil
}
// ResetBranch force-updates a branch to point to a ref.
// This is useful for resetting stale polecat branches to main.
func (g *Git) ResetBranch(name, ref string) error {

View File

@@ -116,13 +116,18 @@ func (m *Manager) exists(name string) bool {
// Uses the shared bare repo (.repo.git) if available, otherwise mayor/rig.
// This is much faster than a full clone and shares objects with all worktrees.
// Polecat state is derived from beads assignee field, not state.json.
//
// Branch naming: Each polecat run gets a unique branch (polecat/<name>-<timestamp>).
// This prevents drift issues from stale branches and ensures a clean starting state.
// Old branches are ephemeral and never pushed to origin.
func (m *Manager) Add(name string) (*Polecat, error) {
if m.exists(name) {
return nil, ErrPolecatExists
}
polecatPath := m.polecatDir(name)
branchName := fmt.Sprintf("polecat/%s", name)
// Unique branch per run - prevents drift from stale branches
branchName := fmt.Sprintf("polecat/%s-%d", name, time.Now().UnixMilli())
// Create polecats directory if needed
polecatsDir := filepath.Join(m.rig.Path, "polecats")
@@ -136,24 +141,10 @@ func (m *Manager) Add(name string) (*Polecat, error) {
return nil, fmt.Errorf("finding repo base: %w", err)
}
// Check if branch already exists (e.g., from previous polecat that wasn't cleaned up)
branchExists, err := repoGit.BranchExists(branchName)
if err != nil {
return nil, fmt.Errorf("checking branch existence: %w", err)
}
// Create worktree - reuse existing branch if it exists
if branchExists {
// Branch exists, create worktree using existing branch
if err := repoGit.WorktreeAddExisting(polecatPath, branchName); err != nil {
return nil, fmt.Errorf("creating worktree with existing branch: %w", err)
}
} else {
// Create new branch with worktree
// git worktree add -b polecat/<name> <path>
if err := repoGit.WorktreeAdd(polecatPath, branchName); err != nil {
return nil, fmt.Errorf("creating worktree: %w", err)
}
// Always create fresh branch - unique name guarantees no collision
// git worktree add -b polecat/<name>-<timestamp> <path>
if err := repoGit.WorktreeAdd(polecatPath, branchName); err != nil {
return nil, fmt.Errorf("creating worktree: %w", err)
}
// Set up shared beads: polecat uses rig's .beads via redirect file.
@@ -270,13 +261,15 @@ func (m *Manager) ReleaseName(name string) {
// 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.
//
// Branch naming: Each recreation gets a unique branch (polecat/<name>-<timestamp>).
// Old branches are left for garbage collection - they're never pushed to origin.
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)
polecatGit := git.NewGit(polecatPath)
// Get the repo base (bare repo or mayor/rig)
@@ -307,31 +300,12 @@ func (m *Manager) Recreate(name string, force bool) (*Polecat, error) {
// Fetch latest from origin to ensure we have fresh commits (non-fatal: may be offline)
_ = repoGit.Fetch("origin")
// Delete the old branch so worktree starts fresh from current HEAD
// Non-fatal: branch may not exist (first recreate) or may fail to delete
_ = repoGit.DeleteBranch(branchName, true)
// Check if branch still exists (deletion may have failed or branch was protected)
branchExists, err := repoGit.BranchExists(branchName)
if err != nil {
return nil, fmt.Errorf("checking branch existence: %w", err)
}
// Create worktree - handle both cases like Add() does
if branchExists {
// Branch still exists after deletion attempt - force-reset to origin/main
// This ensures the polecat starts with fresh code, not stale commits
if err := repoGit.ResetBranch(branchName, "origin/main"); err != nil {
return nil, fmt.Errorf("resetting stale branch to origin/main: %w", err)
}
if err := repoGit.WorktreeAddExisting(polecatPath, branchName); err != nil {
return nil, fmt.Errorf("creating worktree with reset branch: %w", err)
}
} else {
// Branch was deleted, create fresh worktree with new branch from HEAD
if err := repoGit.WorktreeAdd(polecatPath, branchName); err != nil {
return nil, fmt.Errorf("creating fresh worktree: %w", err)
}
// Create fresh worktree with unique branch name
// Old branches are left behind - they're ephemeral (never pushed to origin)
// and will be cleaned up by garbage collection
branchName := fmt.Sprintf("polecat/%s-%d", name, time.Now().UnixMilli())
if err := repoGit.WorktreeAdd(polecatPath, branchName); err != nil {
return nil, fmt.Errorf("creating fresh worktree: %w", err)
}
// Set up shared beads
@@ -584,12 +558,19 @@ func (m *Manager) Reset(name string) error {
// We don't interpret issue status (ZFC: Go is transport, not decision-maker).
func (m *Manager) loadFromBeads(name string) (*Polecat, error) {
polecatPath := m.polecatDir(name)
branchName := fmt.Sprintf("polecat/%s", name)
// Get actual branch from worktree (branches are now timestamped)
polecatGit := git.NewGit(polecatPath)
branchName, err := polecatGit.CurrentBranch()
if err != nil {
// Fall back to old format if we can't read the branch
branchName = fmt.Sprintf("polecat/%s", name)
}
// Query beads for assigned issue
assignee := m.assigneeID(name)
issue, err := m.beads.GetAssignedIssue(assignee)
if err != nil {
issue, beadsErr := m.beads.GetAssignedIssue(assignee)
if beadsErr != nil {
// If beads query fails, return basic polecat info
// This allows the system to work even if beads is not available
return &Polecat{
@@ -669,3 +650,54 @@ func (m *Manager) setupSharedBeads(polecatPath string) error {
return nil
}
// CleanupStaleBranches removes orphaned polecat branches that are no longer in use.
// This includes:
// - Branches for polecats that no longer exist
// - Old timestamped branches (keeps only the most recent per polecat name)
// Returns the number of branches deleted.
func (m *Manager) CleanupStaleBranches() (int, error) {
repoGit, err := m.repoBase()
if err != nil {
return 0, fmt.Errorf("finding repo base: %w", err)
}
// List all polecat branches
branches, err := repoGit.ListBranches("polecat/*")
if err != nil {
return 0, fmt.Errorf("listing branches: %w", err)
}
if len(branches) == 0 {
return 0, nil
}
// Get list of existing polecats
polecats, err := m.List()
if err != nil {
return 0, fmt.Errorf("listing polecats: %w", err)
}
// Build set of current polecat branches (from actual polecat objects)
currentBranches := make(map[string]bool)
for _, p := range polecats {
currentBranches[p.Branch] = true
}
// Delete branches not in current set
deleted := 0
for _, branch := range branches {
if currentBranches[branch] {
continue // This branch is in use
}
// Delete orphaned branch
if err := repoGit.DeleteBranch(branch, true); err != nil {
// Log but continue - non-fatal
fmt.Printf("Warning: could not delete branch %s: %v\n", branch, err)
continue
}
deleted++
}
return deleted, nil
}