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

@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **Configurable polecat branch naming** - Rigs can now customize polecat branch naming via `polecat_branch_template` configuration. Supports template variables: `{user}`, `{year}`, `{month}`, `{name}`, `{issue}`, `{description}`, `{timestamp}`. Defaults to existing behavior for backward compatibility.
### Fixed
- **Orphan cleanup skips valid tmux sessions** - `gt orphans kill` and automatic orphan cleanup now check for Claude processes belonging to valid Gas Town tmux sessions (gt-*/hq-*) before killing. This prevents false kills of witnesses, refineries, and deacon during startup when they may temporarily show TTY "?"

View File

@@ -89,6 +89,58 @@ Debug routing: `BD_DEBUG_ROUTING=1 bd show <id>`
Process state, PIDs, ephemeral data.
### Rig-Level Configuration
Rigs support layered configuration through:
1. **Wisp layer** (`.beads-wisp/config/`) - transient, local overrides
2. **Rig identity bead labels** - persistent rig settings
3. **Town defaults** (`~/gt/settings/config.json`)
4. **System defaults** - compiled-in fallbacks
#### Polecat Branch Naming
Configure custom branch name templates for polecats:
```bash
# Set via wisp (transient - for testing)
echo '{"polecat_branch_template": "adam/{year}/{month}/{description}"}' > \
~/gt/.beads-wisp/config/myrig.json
# Or set via rig identity bead labels (persistent)
bd update gt-rig-myrig --labels="polecat_branch_template:adam/{year}/{month}/{description}"
```
**Template Variables:**
| Variable | Description | Example |
|----------|-------------|---------|
| `{user}` | From `git config user.name` | `adam` |
| `{year}` | Current year (YY format) | `26` |
| `{month}` | Current month (MM format) | `01` |
| `{name}` | Polecat name | `alpha` |
| `{issue}` | Issue ID without prefix | `123` (from `gt-123`) |
| `{description}` | Sanitized issue title | `fix-auth-bug` |
| `{timestamp}` | Unique timestamp | `1ks7f9a` |
**Default Behavior (backward compatible):**
When `polecat_branch_template` is empty or not set:
- With issue: `polecat/{name}/{issue}@{timestamp}`
- Without issue: `polecat/{name}-{timestamp}`
**Example Configurations:**
```bash
# GitHub enterprise format
"adam/{year}/{month}/{description}"
# Simple feature branches
"feature/{issue}"
# Include polecat name for clarity
"work/{name}/{issue}"
```
## Formula Format
```toml

View File

@@ -558,6 +558,17 @@ func (g *Git) Remotes() ([]string, error) {
return strings.Split(out, "\n"), nil
}
// ConfigGet returns the value of a git config key.
// Returns empty string if the key is not set.
func (g *Git) ConfigGet(key string) (string, error) {
out, err := g.run("config", "--get", key)
if err != nil {
// git config --get returns exit code 1 if key not found
return "", nil
}
return out, nil
}
// Merge merges the given branch into the current branch.
func (g *Git) Merge(branch string) error {
_, err := g.run("merge", branch)

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)
}
}
}
})
}
}

View File

@@ -36,6 +36,7 @@ var SystemDefaults = map[string]interface{}{
"max_polecats": 10,
"priority_adjustment": 0,
"dnd": false,
"polecat_branch_template": "", // Empty = use default behavior (polecat/{name}/...)
}
// StackingKeys defines which keys use stacking semantics (values add up).

View File

@@ -23,22 +23,35 @@ just `gt done`.
### The Self-Cleaning Model
Polecats are **self-cleaning**. When you run `gt done`:
1. Your branch is pushed to origin
2. An MR is created in the Refinery merge queue
3. Your sandbox gets nuked
4. Your session exits
5. **You cease to exist**
1. Syncs beads
2. Nukes your sandbox
3. Exits your session
4. **You cease to exist**
The **Refinery** handles all merging to main. It serializes concurrent work, rebases,
and resolves conflicts. You NEVER push directly to main - that's the Refinery's job.
There is no "idle" state. There is no "waiting for more work". Done means GONE.
**Two workflow types:**
**1. Merge Queue Workflow (gastown, beads repos):**
- Push branch to origin
- `gt done` submits to Refinery merge queue
- Refinery handles merge to main
**2. PR Workflow (longeye and similar repos):**
- Create GitHub PR
- Monitor for CI and review feedback
- Address any issues
- `gt done` when PR is approved and CI green
- Maintainer or Refinery merges the PR
**In both cases, you still run `gt done` at the end.** The difference is WHEN:
- Merge queue: After implementation and tests pass
- PR workflow: After PR is approved and CI is green
**Polecats do NOT:**
- Push directly to main (`git push origin main` - WRONG)
- Create pull requests (you're not an external contributor)
- Wait around to see if their work merges
There is no "idle" state. There is no "waiting for more work". Done means GONE.
The Refinery will handle the merge. You will not be there to see it.
- Merge their own PRs (maintainer or Refinery does this)
- Wait around after running `gt done`
**If you have finished your implementation work, your ONLY next action is:**
```bash
@@ -292,6 +305,49 @@ bd ready # See next step
When all steps are done, the molecule gets squashed automatically when you run `gt done`.
## PR Workflow (for repos that use pull requests)
**Applies to:** longeye and other repos that require code review via GitHub PRs
The **mol-polecat-work** molecule now includes PR creation and monitoring steps for
repos that use PR-based workflows. Here's what's different:
### Old Workflow (Merge Queue)
1. Implement → Test → `gt done` → Refinery merges
### New Workflow (PR-based)
1. Implement → Test
2. **Create PR** (with branch naming: adam/YY/M/description)
3. **Monitor PR** - Check CI and review status
4. **Address feedback** - Fix issues if CI fails or reviewers comment
5. **Verify ready** - Confirm CI green and PR approved
6. **`gt done`** - Mark complete and exit (maintainer will merge)
### Key Differences
**You keep the bead OPEN** during PR review. Don't mark complete until:
- ✓ All CI checks passing
- ✓ Review comments addressed
- ✓ PR approved by reviewer
**You may cycle multiple times** while waiting for review or fixing feedback:
```bash
# If context filling while waiting for review
gt handoff -s "Waiting for PR review" -m "Issue: <issue>
PR: #<number>
Status: <current state>
Next: Continue monitoring"
```
Your next session resumes from the current molecule step.
**Branch naming is critical:**
- Format: `adam/YY/M/<short-description>`
- Example: `adam/26/1/fix-social-media-parser`
- This matches the user convention for the repo
**You do NOT merge the PR** - a maintainer or Refinery does that.
## Before Signaling Done (MANDATORY)
> ⚠️ **CRITICAL**: Work is NOT complete until you run `gt done`