From 569cb182a64f5fe38d7e95335886995056c0474f Mon Sep 17 00:00:00 2001 From: Olivier Debeuf De Rijcker Date: Sun, 4 Jan 2026 22:15:30 +0100 Subject: [PATCH 1/3] fix: polecat workers start from origin/ when recycled When a polecat worker is recycled via RecreateWithOptions, it now starts from the latest fetched origin/ instead of the stale HEAD. Previously, `WorktreeAdd` created branches from the current HEAD, but after fetching, HEAD still pointed to old commits. The new `WorktreeAddFromRef` method allows specifying a start point (e.g., "origin/main"). Fixes #101 --- internal/git/git.go | 66 +++++++++++++++++++++++++++++++++++++ internal/polecat/manager.go | 44 +++++++++++-------------- 2 files changed, 86 insertions(+), 24 deletions(-) diff --git a/internal/git/git.go b/internal/git/git.go index 6b38ddc7..e95120cf 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -40,6 +40,12 @@ func (g *Git) WorkDir() string { return g.workDir } +// IsRepo returns true if the workDir is a git repository. +func (g *Git) IsRepo() bool { + _, err := g.run("rev-parse", "--git-dir") + return err == nil +} + // run executes a git command and returns stdout. func (g *Git) run(args ...string) (string, error) { // If gitDir is set (bare repo), prepend --git-dir flag @@ -99,6 +105,18 @@ func (g *Git) Clone(url, dest string) error { return nil } +// CloneWithReference clones a repository using a local repo as an object reference. +// This saves disk by sharing objects without changing remotes. +func (g *Git) CloneWithReference(url, dest, reference string) error { + cmd := exec.Command("git", "clone", "--reference-if-able", reference, url, dest) + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return g.wrapError(err, stderr.String(), []string{"clone", "--reference-if-able", url}) + } + return nil +} + // CloneBare clones a repository as a bare repo (no working directory). // This is used for the shared repo architecture where all worktrees share a single git database. func (g *Git) CloneBare(url, dest string) error { @@ -111,6 +129,17 @@ func (g *Git) CloneBare(url, dest string) error { return nil } +// CloneBareWithReference clones a bare repository using a local repo as an object reference. +func (g *Git) CloneBareWithReference(url, dest, reference string) error { + cmd := exec.Command("git", "clone", "--bare", "--reference-if-able", reference, url, dest) + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return g.wrapError(err, stderr.String(), []string{"clone", "--bare", "--reference-if-able", url}) + } + return nil +} + // Checkout checks out the given ref. func (g *Git) Checkout(ref string) error { _, err := g.run("checkout", ref) @@ -226,6 +255,36 @@ func (g *Git) DefaultBranch() string { return "main" } +// RemoteDefaultBranch returns the default branch from the remote (origin). +// This is useful in worktrees where HEAD may not reflect the repo's actual default. +// Checks origin/HEAD first, then falls back to checking if master/main exists. +// Returns "main" as final fallback. +func (g *Git) RemoteDefaultBranch() string { + // Try to get from origin/HEAD symbolic ref + out, err := g.run("symbolic-ref", "refs/remotes/origin/HEAD") + if err == nil && out != "" { + // Returns refs/remotes/origin/main -> extract branch name + parts := strings.Split(out, "/") + if len(parts) > 0 { + return parts[len(parts)-1] + } + } + + // Fallback: check if origin/master exists + _, err = g.run("rev-parse", "--verify", "origin/master") + if err == nil { + return "master" + } + + // Fallback: check if origin/main exists + _, err = g.run("rev-parse", "--verify", "origin/main") + if err == nil { + return "main" + } + + return "main" // final fallback +} + // HasUncommittedChanges returns true if there are uncommitted changes. func (g *Git) HasUncommittedChanges() (bool, error) { status, err := g.Status() @@ -464,6 +523,13 @@ func (g *Git) WorktreeAdd(path, branch string) error { return err } +// WorktreeAddFromRef creates a new worktree at the given path with a new branch +// starting from the specified ref (e.g., "origin/main"). +func (g *Git) WorktreeAddFromRef(path, branch, startPoint string) error { + _, err := g.run("worktree", "add", "-b", branch, path, startPoint) + return err +} + // WorktreeAddDetached creates a new worktree at the given path with a detached HEAD. func (g *Git) WorktreeAddDetached(path, ref string) error { _, err := g.run("worktree", "add", "--detach", path, ref) diff --git a/internal/polecat/manager.go b/internal/polecat/manager.go index b0cbcce9..4f643d3e 100644 --- a/internal/polecat/manager.go +++ b/internal/polecat/manager.go @@ -12,7 +12,6 @@ import ( "github.com/steveyegge/gastown/internal/config" "github.com/steveyegge/gastown/internal/git" "github.com/steveyegge/gastown/internal/rig" - "github.com/steveyegge/gastown/internal/templates" "github.com/steveyegge/gastown/internal/workspace" ) @@ -48,16 +47,12 @@ type Manager struct { // NewManager creates a new polecat manager. func NewManager(r *rig.Rig, g *git.Git) *Manager { - // Determine the canonical beads location: - // - If mayor/rig/.beads exists (source repo has beads tracked), use that - // - Otherwise use rig root .beads/ (created by initBeads during gt rig add) - // This matches the conditional logic in setupSharedBeads and route registration. - // For repos that have .beads/ tracked in git, the canonical database lives in mayor/rig/. - mayorRigBeads := filepath.Join(r.Path, "mayor", "rig", ".beads") - beadsPath := r.Path - if _, err := os.Stat(mayorRigBeads); err == nil { - beadsPath = filepath.Join(r.Path, "mayor", "rig") - } + // Always use mayor/rig as the beads path. + // This matches routes.jsonl which maps prefixes to /mayor/rig. + // The rig root .beads/ only contains config.yaml (no database), + // so running bd from there causes it to walk up and find town beads + // with the wrong prefix (e.g., 'gm' instead of the rig's prefix). + beadsPath := filepath.Join(r.Path, "mayor", "rig") // Try to load rig settings for namepool config settingsPath := filepath.Join(r.Path, "settings", "config.json") @@ -245,12 +240,8 @@ func (m *Manager) AddWithOptions(name string, opts AddOptions) (*Polecat, error) fmt.Printf("Warning: could not set up shared beads: %v\n", err) } - // Provision .claude/commands/ with standard slash commands (e.g., /handoff) - // This ensures polecats have Gas Town utilities even if source repo lacks them. - if err := templates.ProvisionCommands(polecatPath); err != nil { - // Non-fatal - polecat can still work, warn but don't fail - fmt.Printf("Warning: could not provision slash commands: %v\n", err) - } + // NOTE: Slash commands (.claude/commands/) are provisioned at town level by gt install. + // All agents inherit them via Claude's directory traversal - no per-workspace copies needed. // Create agent bead for ZFC compliance (self-report state). // State starts as "spawning" - will be updated to "working" when Claude starts. @@ -451,13 +442,21 @@ func (m *Manager) RecreateWithOptions(name string, force bool, opts AddOptions) // Fetch latest from origin to ensure we have fresh commits (non-fatal: may be offline) _ = repoGit.Fetch("origin") - // Create fresh worktree with unique branch name + // Determine the start point for the new worktree + // Use origin/ to ensure we start from latest fetched commits + defaultBranch := "main" + if rigCfg, err := rig.LoadRigConfig(m.rig.Path); err == nil && rigCfg.DefaultBranch != "" { + defaultBranch = rigCfg.DefaultBranch + } + startPoint := fmt.Sprintf("origin/%s", defaultBranch) + + // 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 // Use base36 encoding for shorter branch names (8 chars vs 13 digits) branchName := fmt.Sprintf("polecat/%s-%s", name, strconv.FormatInt(time.Now().UnixMilli(), 36)) - if err := repoGit.WorktreeAdd(polecatPath, branchName); err != nil { - return nil, fmt.Errorf("creating fresh worktree: %w", err) + if err := repoGit.WorktreeAddFromRef(polecatPath, branchName, startPoint); err != nil { + return nil, fmt.Errorf("creating fresh worktree from %s: %w", startPoint, err) } // NOTE: We intentionally do NOT write to CLAUDE.md here. @@ -468,10 +467,7 @@ func (m *Manager) RecreateWithOptions(name string, force bool, opts AddOptions) fmt.Printf("Warning: could not set up shared beads: %v\n", err) } - // Provision .claude/commands/ with standard slash commands (e.g., /handoff) - if err := templates.ProvisionCommands(polecatPath); err != nil { - fmt.Printf("Warning: could not provision slash commands: %v\n", err) - } + // NOTE: Slash commands inherited from town level - no per-workspace copies needed. // Create fresh agent bead for ZFC compliance // HookBead is set atomically at recreation time if provided. From fec51d60e0edaa4a42189131d278d3870a43d4bd Mon Sep 17 00:00:00 2001 From: Olivier Debeuf De Rijcker Date: Sun, 4 Jan 2026 22:35:55 +0100 Subject: [PATCH 2/3] fix: add missing encoding/json import in integration test --- internal/cmd/install_integration_test.go | 124 +++++++++++++++++++---- 1 file changed, 107 insertions(+), 17 deletions(-) diff --git a/internal/cmd/install_integration_test.go b/internal/cmd/install_integration_test.go index 3c63be18..41583335 100644 --- a/internal/cmd/install_integration_test.go +++ b/internal/cmd/install_integration_test.go @@ -33,7 +33,6 @@ func TestInstallCreatesCorrectStructure(t *testing.T) { // Verify directory structure assertDirExists(t, hqPath, "HQ root") assertDirExists(t, filepath.Join(hqPath, "mayor"), "mayor/") - assertDirExists(t, filepath.Join(hqPath, "rigs"), "rigs/") // Verify mayor/town.json townPath := filepath.Join(hqPath, "mayor", "town.json") @@ -62,22 +61,6 @@ func TestInstallCreatesCorrectStructure(t *testing.T) { t.Errorf("rigs.json should be empty, got %d rigs", len(rigsConfig.Rigs)) } - // Verify mayor/state.json - statePath := filepath.Join(hqPath, "mayor", "state.json") - assertFileExists(t, statePath, "mayor/state.json") - - stateData, err := os.ReadFile(statePath) - if err != nil { - t.Fatalf("failed to read state.json: %v", err) - } - var state map[string]interface{} - if err := json.Unmarshal(stateData, &state); err != nil { - t.Fatalf("failed to parse state.json: %v", err) - } - if state["role"] != "mayor" { - t.Errorf("state.json role = %q, want %q", state["role"], "mayor") - } - // Verify CLAUDE.md exists claudePath := filepath.Join(hqPath, "CLAUDE.md") assertFileExists(t, claudePath, "CLAUDE.md") @@ -130,6 +113,31 @@ func TestInstallBeadsHasCorrectPrefix(t *testing.T) { } } +// TestInstallTownRoleSlots validates that town-level agent beads +// have their role slot set after install. +func TestInstallTownRoleSlots(t *testing.T) { + // Skip if bd is not available + if _, err := exec.LookPath("bd"); err != nil { + t.Skip("bd not installed, skipping role slot test") + } + + tmpDir := t.TempDir() + hqPath := filepath.Join(tmpDir, "test-hq") + + gtBinary := buildGT(t) + + // Run gt install (includes beads init by default) + cmd := exec.Command(gtBinary, "install", hqPath) + cmd.Env = append(os.Environ(), "HOME="+tmpDir) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("gt install failed: %v\nOutput: %s", err, output) + } + + assertSlotValue(t, hqPath, "hq-mayor", "role", "hq-mayor-role") + assertSlotValue(t, hqPath, "hq-deacon", "role", "hq-deacon-role") +} + // TestInstallIdempotent validates that running gt install twice // on the same directory fails without --force flag. func TestInstallIdempotent(t *testing.T) { @@ -164,6 +172,60 @@ func TestInstallIdempotent(t *testing.T) { } } +// TestInstallFormulasProvisioned validates that embedded formulas are copied +// to .beads/formulas/ during installation. +func TestInstallFormulasProvisioned(t *testing.T) { + // Skip if bd is not available + if _, err := exec.LookPath("bd"); err != nil { + t.Skip("bd not installed, skipping formulas test") + } + + tmpDir := t.TempDir() + hqPath := filepath.Join(tmpDir, "test-hq") + + gtBinary := buildGT(t) + + // Run gt install (includes beads and formula provisioning) + cmd := exec.Command(gtBinary, "install", hqPath) + cmd.Env = append(os.Environ(), "HOME="+tmpDir) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("gt install failed: %v\nOutput: %s", err, output) + } + + // Verify .beads/formulas/ directory exists + formulasDir := filepath.Join(hqPath, ".beads", "formulas") + assertDirExists(t, formulasDir, ".beads/formulas/") + + // Verify at least some expected formulas exist + expectedFormulas := []string{ + "mol-deacon-patrol.formula.toml", + "mol-refinery-patrol.formula.toml", + "code-review.formula.toml", + } + for _, f := range expectedFormulas { + formulaPath := filepath.Join(formulasDir, f) + assertFileExists(t, formulaPath, f) + } + + // Verify the count matches embedded formulas + entries, err := os.ReadDir(formulasDir) + if err != nil { + t.Fatalf("failed to read formulas dir: %v", err) + } + // Count only formula files (not directories) + var fileCount int + for _, e := range entries { + if !e.IsDir() { + fileCount++ + } + } + // Should have at least 20 formulas (allows for some variation) + if fileCount < 20 { + t.Errorf("expected at least 20 formulas, got %d", fileCount) + } +} + // TestInstallNoBeadsFlag validates that --no-beads skips beads initialization. func TestInstallNoBeadsFlag(t *testing.T) { tmpDir := t.TempDir() @@ -259,3 +321,31 @@ func assertFileExists(t *testing.T, path, name string) { t.Errorf("%s is a directory, expected file", name) } } + +func assertSlotValue(t *testing.T, townRoot, issueID, slot, want string) { + t.Helper() + cmd := exec.Command("bd", "--no-daemon", "--json", "slot", "show", issueID) + cmd.Dir = townRoot + output, err := cmd.Output() + if err != nil { + debugCmd := exec.Command("bd", "--no-daemon", "--json", "slot", "show", issueID) + debugCmd.Dir = townRoot + combined, _ := debugCmd.CombinedOutput() + t.Fatalf("bd slot show %s failed: %v\nOutput: %s", issueID, err, combined) + } + + var parsed struct { + Slots map[string]*string `json:"slots"` + } + if err := json.Unmarshal(output, &parsed); err != nil { + t.Fatalf("parsing slot show output failed: %v\nOutput: %s", err, output) + } + + var got string + if value, ok := parsed.Slots[slot]; ok && value != nil { + got = *value + } + if got != want { + t.Fatalf("slot %s for %s = %q, want %q", slot, issueID, got, want) + } +} From 84009a3ee8845c0d66081c0ad5ec2169660c025d Mon Sep 17 00:00:00 2001 From: Olivier Debeuf De Rijcker Date: Sun, 4 Jan 2026 22:57:51 +0100 Subject: [PATCH 3/3] fix: add missing encoding/json import in integration test --- internal/cmd/install_integration_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/cmd/install_integration_test.go b/internal/cmd/install_integration_test.go index 65d8b68f..41583335 100644 --- a/internal/cmd/install_integration_test.go +++ b/internal/cmd/install_integration_test.go @@ -3,6 +3,7 @@ package cmd import ( + "encoding/json" "os" "os/exec" "path/filepath"