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