feat: Add configurable polecat branch naming (#825)

feat: Add configurable polecat branch naming

Adds polecat_branch_template configuration for custom branch naming patterns.

Template variables supported:
- {user}: git config user.name  
- {year}/{month}: date (YY/MM format)
- {name}: polecat name
- {issue}: issue ID without prefix
- {description}: sanitized issue title
- {timestamp}: unique timestamp

Maintains backward compatibility - empty template uses existing format.
This commit is contained in:
Adam Zionts
2026-01-21 20:53:12 -08:00
committed by GitHub
parent 0dfb0be368
commit 02390251fc
7 changed files with 353 additions and 37 deletions

View File

@@ -227,6 +227,111 @@ type AddOptions struct {
// Add creates a new polecat as a git worktree from the repo base.
// 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.
// buildBranchName creates a branch name using the configured template or default format.
// Supported template variables:
// - {user}: git config user.name
// - {year}: current year (YY format)
// - {month}: current month (MM format)
// - {name}: polecat name
// - {issue}: issue ID (without prefix)
// - {description}: sanitized issue title
// - {timestamp}: unique timestamp
//
// If no template is configured or template is empty, uses default format:
// - polecat/{name}/{issue}@{timestamp} when issue is available
// - polecat/{name}-{timestamp} otherwise
func (m *Manager) buildBranchName(name, issue string) string {
template := m.rig.GetStringConfig("polecat_branch_template")
// No template configured - use default behavior for backward compatibility
if template == "" {
timestamp := strconv.FormatInt(time.Now().UnixMilli(), 36)
if issue != "" {
return fmt.Sprintf("polecat/%s/%s@%s", name, issue, timestamp)
}
return fmt.Sprintf("polecat/%s-%s", name, timestamp)
}
// Build template variables
vars := make(map[string]string)
// {user} - from git config user.name
if userName, err := m.git.ConfigGet("user.name"); err == nil && userName != "" {
vars["{user}"] = userName
} else {
vars["{user}"] = "unknown"
}
// {year} and {month}
now := time.Now()
vars["{year}"] = now.Format("06") // YY format
vars["{month}"] = now.Format("01") // MM format
// {name}
vars["{name}"] = name
// {timestamp}
vars["{timestamp}"] = strconv.FormatInt(now.UnixMilli(), 36)
// {issue} - issue ID without prefix
if issue != "" {
// Strip prefix (e.g., "gt-123" -> "123")
if idx := strings.Index(issue, "-"); idx >= 0 {
vars["{issue}"] = issue[idx+1:]
} else {
vars["{issue}"] = issue
}
} else {
vars["{issue}"] = ""
}
// {description} - try to get from beads if issue is set
if issue != "" {
if issueData, err := m.beads.Show(issue); err == nil && issueData.Title != "" {
// Sanitize title for branch name: lowercase, replace spaces/special chars with hyphens
desc := strings.ToLower(issueData.Title)
desc = strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
return r
}
return '-'
}, desc)
// Remove consecutive hyphens and trim
desc = strings.Trim(desc, "-")
for strings.Contains(desc, "--") {
desc = strings.ReplaceAll(desc, "--", "-")
}
// Limit length to keep branch names reasonable
if len(desc) > 40 {
desc = desc[:40]
}
vars["{description}"] = desc
} else {
vars["{description}"] = ""
}
} else {
vars["{description}"] = ""
}
// Replace all variables in template
result := template
for key, value := range vars {
result = strings.ReplaceAll(result, key, value)
}
// Clean up any remaining empty segments (e.g., "adam///" -> "adam")
parts := strings.Split(result, "/")
cleanParts := make([]string, 0, len(parts))
for _, part := range parts {
if part != "" {
cleanParts = append(cleanParts, part)
}
}
result = strings.Join(cleanParts, "/")
return result
}
// Polecat state is derived from beads assignee field, not state.json.
//
// Branch naming: Each polecat run gets a unique branch (polecat/<name>-<timestamp>).
@@ -249,18 +354,8 @@ func (m *Manager) AddWithOptions(name string, opts AddOptions) (*Polecat, error)
polecatDir := m.polecatDir(name)
clonePath := filepath.Join(polecatDir, m.rig.Name)
// Branch naming: include issue ID when available for better traceability.
// Format: polecat/<worker>/<issue>@<timestamp> when HookBead is set
// The @timestamp suffix ensures uniqueness if the same issue is re-slung.
// parseBranchName strips the @suffix to extract the issue ID.
timestamp := strconv.FormatInt(time.Now().UnixMilli(), 36)
var branchName string
if opts.HookBead != "" {
branchName = fmt.Sprintf("polecat/%s/%s@%s", name, opts.HookBead, timestamp)
} else {
// Fallback to timestamp format when no issue is known at spawn time
branchName = fmt.Sprintf("polecat/%s-%s", name, timestamp)
}
// Build branch name using configured template or default format
branchName := m.buildBranchName(name, opts.HookBead)
// Create polecat directory (polecats/<name>/)
if err := os.MkdirAll(polecatDir, 0755); err != nil {
@@ -606,14 +701,7 @@ func (m *Manager) RepairWorktreeWithOptions(name string, force bool, opts AddOpt
// Create fresh worktree with unique branch name, starting from origin's default branch
// Old branches are left behind - they're ephemeral (never pushed to origin)
// and will be cleaned up by garbage collection
// Branch naming: include issue ID when available for better traceability.
timestamp := strconv.FormatInt(time.Now().UnixMilli(), 36)
var branchName string
if opts.HookBead != "" {
branchName = fmt.Sprintf("polecat/%s/%s@%s", name, opts.HookBead, timestamp)
} else {
branchName = fmt.Sprintf("polecat/%s-%s", name, timestamp)
}
branchName := m.buildBranchName(name, opts.HookBead)
if err := repoGit.WorktreeAddFromRef(newClonePath, branchName, startPoint); err != nil {
return nil, fmt.Errorf("creating fresh worktree from %s: %w", startPoint, err)
}

View File

@@ -654,3 +654,107 @@ func TestReconcilePoolWith_OrphanDoesNotBlockAllocation(t *testing.T) {
t.Errorf("expected furiosa (orphan freed), got %q", name)
}
}
func TestBuildBranchName(t *testing.T) {
tmpDir := t.TempDir()
// Initialize a git repo for config access
gitCmd := exec.Command("git", "init")
gitCmd.Dir = tmpDir
if err := gitCmd.Run(); err != nil {
t.Fatalf("git init: %v", err)
}
// Set git user.name for testing
configCmd := exec.Command("git", "config", "user.name", "testuser")
configCmd.Dir = tmpDir
if err := configCmd.Run(); err != nil {
t.Fatalf("git config: %v", err)
}
tests := []struct {
name string
template string
issue string
want string
}{
{
name: "default_with_issue",
template: "", // Empty template = default behavior
issue: "gt-123",
want: "polecat/alpha/gt-123@", // timestamp suffix varies
},
{
name: "default_without_issue",
template: "",
issue: "",
want: "polecat/alpha-", // timestamp suffix varies
},
{
name: "custom_template_user_year_month",
template: "{user}/{year}/{month}/fix",
issue: "",
want: "testuser/", // year/month will vary
},
{
name: "custom_template_with_name",
template: "feature/{name}",
issue: "",
want: "feature/alpha",
},
{
name: "custom_template_with_issue",
template: "work/{issue}",
issue: "gt-456",
want: "work/456",
},
{
name: "custom_template_with_timestamp",
template: "feature/{name}-{timestamp}",
issue: "",
want: "feature/alpha-", // timestamp suffix varies
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create rig with test template
r := &rig.Rig{
Name: "test-rig",
Path: tmpDir,
}
// Override system defaults for this test if template is set
if tt.template != "" {
origDefault := rig.SystemDefaults["polecat_branch_template"]
rig.SystemDefaults["polecat_branch_template"] = tt.template
defer func() {
rig.SystemDefaults["polecat_branch_template"] = origDefault
}()
}
g := git.NewGit(tmpDir)
m := NewManager(r, g, nil)
got := m.buildBranchName("alpha", tt.issue)
// For default templates, just check prefix since timestamp varies
if tt.template == "" {
if !strings.HasPrefix(got, tt.want) {
t.Errorf("buildBranchName() = %q, want prefix %q", got, tt.want)
}
} else {
// For custom templates with time-varying fields, check prefix
if strings.Contains(tt.template, "{year}") || strings.Contains(tt.template, "{month}") || strings.Contains(tt.template, "{timestamp}") {
if !strings.HasPrefix(got, tt.want) {
t.Errorf("buildBranchName() = %q, want prefix %q", got, tt.want)
}
} else {
if got != tt.want {
t.Errorf("buildBranchName() = %q, want %q", got, tt.want)
}
}
}
})
}
}