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
|
polecatSyncFromMain bool
|
||||||
polecatStatusJSON bool
|
polecatStatusJSON bool
|
||||||
polecatGitStateJSON 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{
|
var polecatGitStateCmd = &cobra.Command{
|
||||||
Use: "git-state <rig>/<polecat>",
|
Use: "git-state <rig>/<polecat>",
|
||||||
Short: "Show git state for pre-kill verification",
|
Short: "Show git state for pre-kill verification",
|
||||||
@@ -238,6 +259,9 @@ func init() {
|
|||||||
// Git-state flags
|
// Git-state flags
|
||||||
polecatGitStateCmd.Flags().BoolVar(&polecatGitStateJSON, "json", false, "Output as JSON")
|
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
|
// Add subcommands
|
||||||
polecatCmd.AddCommand(polecatListCmd)
|
polecatCmd.AddCommand(polecatListCmd)
|
||||||
polecatCmd.AddCommand(polecatAddCmd)
|
polecatCmd.AddCommand(polecatAddCmd)
|
||||||
@@ -249,6 +273,7 @@ func init() {
|
|||||||
polecatCmd.AddCommand(polecatSyncCmd)
|
polecatCmd.AddCommand(polecatSyncCmd)
|
||||||
polecatCmd.AddCommand(polecatStatusCmd)
|
polecatCmd.AddCommand(polecatStatusCmd)
|
||||||
polecatCmd.AddCommand(polecatGitStateCmd)
|
polecatCmd.AddCommand(polecatGitStateCmd)
|
||||||
|
polecatCmd.AddCommand(polecatGCCmd)
|
||||||
|
|
||||||
rootCmd.AddCommand(polecatCmd)
|
rootCmd.AddCommand(polecatCmd)
|
||||||
}
|
}
|
||||||
@@ -998,6 +1023,72 @@ func getGitState(worktreePath string) (*GitState, error) {
|
|||||||
return state, nil
|
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.
|
// splitLines splits a string into non-empty lines.
|
||||||
func splitLines(s string) []string {
|
func splitLines(s string) []string {
|
||||||
var lines []string
|
var lines []string
|
||||||
|
|||||||
@@ -401,6 +401,24 @@ func (g *Git) DeleteBranch(name string, force bool) error {
|
|||||||
return err
|
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.
|
// ResetBranch force-updates a branch to point to a ref.
|
||||||
// This is useful for resetting stale polecat branches to main.
|
// This is useful for resetting stale polecat branches to main.
|
||||||
func (g *Git) ResetBranch(name, ref string) error {
|
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.
|
// 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.
|
// 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.
|
// 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) {
|
func (m *Manager) Add(name string) (*Polecat, error) {
|
||||||
if m.exists(name) {
|
if m.exists(name) {
|
||||||
return nil, ErrPolecatExists
|
return nil, ErrPolecatExists
|
||||||
}
|
}
|
||||||
|
|
||||||
polecatPath := m.polecatDir(name)
|
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
|
// Create polecats directory if needed
|
||||||
polecatsDir := filepath.Join(m.rig.Path, "polecats")
|
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)
|
return nil, fmt.Errorf("finding repo base: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if branch already exists (e.g., from previous polecat that wasn't cleaned up)
|
// Always create fresh branch - unique name guarantees no collision
|
||||||
branchExists, err := repoGit.BranchExists(branchName)
|
// git worktree add -b polecat/<name>-<timestamp> <path>
|
||||||
if err != nil {
|
if err := repoGit.WorktreeAdd(polecatPath, branchName); err != nil {
|
||||||
return nil, fmt.Errorf("checking branch existence: %w", err)
|
return nil, fmt.Errorf("creating worktree: %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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up shared beads: polecat uses rig's .beads via redirect file.
|
// 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.
|
// 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.
|
// The name is preserved (not released to pool) since we're recreating immediately.
|
||||||
// force controls whether to bypass uncommitted changes check.
|
// 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) {
|
func (m *Manager) Recreate(name string, force bool) (*Polecat, error) {
|
||||||
if !m.exists(name) {
|
if !m.exists(name) {
|
||||||
return nil, ErrPolecatNotFound
|
return nil, ErrPolecatNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
polecatPath := m.polecatDir(name)
|
polecatPath := m.polecatDir(name)
|
||||||
branchName := fmt.Sprintf("polecat/%s", name)
|
|
||||||
polecatGit := git.NewGit(polecatPath)
|
polecatGit := git.NewGit(polecatPath)
|
||||||
|
|
||||||
// Get the repo base (bare repo or mayor/rig)
|
// 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)
|
// Fetch latest from origin to ensure we have fresh commits (non-fatal: may be offline)
|
||||||
_ = repoGit.Fetch("origin")
|
_ = repoGit.Fetch("origin")
|
||||||
|
|
||||||
// Delete the old branch so worktree starts fresh from current HEAD
|
// Create fresh worktree with unique branch name
|
||||||
// Non-fatal: branch may not exist (first recreate) or may fail to delete
|
// Old branches are left behind - they're ephemeral (never pushed to origin)
|
||||||
_ = repoGit.DeleteBranch(branchName, true)
|
// and will be cleaned up by garbage collection
|
||||||
|
branchName := fmt.Sprintf("polecat/%s-%d", name, time.Now().UnixMilli())
|
||||||
// Check if branch still exists (deletion may have failed or branch was protected)
|
if err := repoGit.WorktreeAdd(polecatPath, branchName); err != nil {
|
||||||
branchExists, err := repoGit.BranchExists(branchName)
|
return nil, fmt.Errorf("creating fresh worktree: %w", err)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up shared beads
|
// 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).
|
// We don't interpret issue status (ZFC: Go is transport, not decision-maker).
|
||||||
func (m *Manager) loadFromBeads(name string) (*Polecat, error) {
|
func (m *Manager) loadFromBeads(name string) (*Polecat, error) {
|
||||||
polecatPath := m.polecatDir(name)
|
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
|
// Query beads for assigned issue
|
||||||
assignee := m.assigneeID(name)
|
assignee := m.assigneeID(name)
|
||||||
issue, err := m.beads.GetAssignedIssue(assignee)
|
issue, beadsErr := m.beads.GetAssignedIssue(assignee)
|
||||||
if err != nil {
|
if beadsErr != nil {
|
||||||
// If beads query fails, return basic polecat info
|
// If beads query fails, return basic polecat info
|
||||||
// This allows the system to work even if beads is not available
|
// This allows the system to work even if beads is not available
|
||||||
return &Polecat{
|
return &Polecat{
|
||||||
@@ -669,3 +650,54 @@ func (m *Manager) setupSharedBeads(polecatPath string) error {
|
|||||||
|
|
||||||
return nil
|
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