diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a862fbb..1e0f8ad2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 "?" diff --git a/docs/reference.md b/docs/reference.md index d9c6b676..882b18b3 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -89,6 +89,58 @@ Debug routing: `BD_DEBUG_ROUTING=1 bd show ` 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 diff --git a/internal/git/git.go b/internal/git/git.go index 8ab9b523..7778626f 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -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) diff --git a/internal/polecat/manager.go b/internal/polecat/manager.go index ea10f686..daba8fb7 100644 --- a/internal/polecat/manager.go +++ b/internal/polecat/manager.go @@ -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/-). @@ -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//@ 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//) 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) } diff --git a/internal/polecat/manager_test.go b/internal/polecat/manager_test.go index 72676f6e..225962c2 100644 --- a/internal/polecat/manager_test.go +++ b/internal/polecat/manager_test.go @@ -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) + } + } + } + }) + } +} diff --git a/internal/rig/config.go b/internal/rig/config.go index 8c209e89..ec58cc0e 100644 --- a/internal/rig/config.go +++ b/internal/rig/config.go @@ -31,11 +31,12 @@ type ConfigResult struct { // SystemDefaults contains compiled-in default values. // These are the fallback when no other layer provides a value. var SystemDefaults = map[string]interface{}{ - "status": "operational", - "auto_restart": true, - "max_polecats": 10, - "priority_adjustment": 0, - "dnd": false, + "status": "operational", + "auto_restart": true, + "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). diff --git a/internal/templates/roles/polecat.md.tmpl b/internal/templates/roles/polecat.md.tmpl index c3eaf05f..5beefe84 100644 --- a/internal/templates/roles/polecat.md.tmpl +++ b/internal/templates/roles/polecat.md.tmpl @@ -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: +PR: # +Status: +Next: Continue monitoring" +``` + +Your next session resumes from the current molecule step. + +**Branch naming is critical:** +- Format: `adam/YY/M/` +- 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`