feat: Unified beads redirect for tracked and local beads (#222)
* feat: Beads redirect architecture for tracked and local beads This change implements proper redirect handling so that all rig agents (Witness, Refinery, Crew, Polecats) can work with both: - Tracked beads: .beads/ checked into git at mayor/rig/.beads - Local beads: .beads/ created at rig root during gt rig add Key changes: 1. SetupRedirect now handles tracked beads by skipping redirect chains. The bd CLI doesn't support chains (A→B→C), so worktrees redirect directly to the final destination (mayor/rig/.beads for tracked). 2. ResolveBeadsDir is now used consistently in polecat and refinery managers instead of hardcoded mayor/rig paths. 3. Rig-level agents (witness, refinery) now use rig beads with rig prefix instead of town beads. This follows the architecture where town beads are only for Mayor/Deacon. 4. prime.go simplified to always use ../../.beads for crew redirects, letting rig-level redirect handle tracked vs local routing. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(doctor): Add beads-redirect check for tracked beads When a repo has .beads/ tracked in git (at mayor/rig/.beads), the rig root needs a redirect file pointing to that location. This check: - Detects missing rig-level redirect for tracked beads - Verifies redirect points to correct location (mayor/rig/.beads) - Auto-fixes with 'gt doctor --fix' 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: Handle fileLock.Unlock error in daemon Wrap fileLock.Unlock() return value to satisfy errcheck linter. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -71,15 +71,123 @@ func ResolveBeadsDir(workDir string) string {
|
||||
return beadsDir
|
||||
}
|
||||
|
||||
// Detect redirect chains: check if resolved path also has a redirect
|
||||
resolvedRedirect := filepath.Join(resolved, "redirect")
|
||||
if _, err := os.Stat(resolvedRedirect); err == nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: redirect chain detected: %s -> %s (which also has a redirect)\n", beadsDir, resolved)
|
||||
// Don't follow chains - just return the first resolved path
|
||||
// The target's redirect is likely errant and should be removed
|
||||
// Follow redirect chains (e.g., crew/.beads -> rig/.beads -> mayor/rig/.beads)
|
||||
// This is intentional for the rig-level redirect architecture.
|
||||
// Limit depth to prevent infinite loops from misconfigured redirects.
|
||||
return resolveBeadsDirWithDepth(resolved, 3)
|
||||
}
|
||||
|
||||
// resolveBeadsDirWithDepth follows redirect chains with a depth limit.
|
||||
func resolveBeadsDirWithDepth(beadsDir string, maxDepth int) string {
|
||||
if maxDepth <= 0 {
|
||||
fmt.Fprintf(os.Stderr, "Warning: redirect chain too deep at %s, stopping\n", beadsDir)
|
||||
return beadsDir
|
||||
}
|
||||
|
||||
return resolved
|
||||
redirectPath := filepath.Join(beadsDir, "redirect")
|
||||
data, err := os.ReadFile(redirectPath) //nolint:gosec // G304: path is constructed internally
|
||||
if err != nil {
|
||||
// No redirect, this is the final destination
|
||||
return beadsDir
|
||||
}
|
||||
|
||||
redirectTarget := strings.TrimSpace(string(data))
|
||||
if redirectTarget == "" {
|
||||
return beadsDir
|
||||
}
|
||||
|
||||
// Resolve relative to parent of beadsDir (the workDir)
|
||||
workDir := filepath.Dir(beadsDir)
|
||||
resolved := filepath.Clean(filepath.Join(workDir, redirectTarget))
|
||||
|
||||
// Detect circular redirect
|
||||
if resolved == beadsDir {
|
||||
fmt.Fprintf(os.Stderr, "Warning: circular redirect detected in %s, stopping\n", redirectPath)
|
||||
return beadsDir
|
||||
}
|
||||
|
||||
// Recursively follow
|
||||
return resolveBeadsDirWithDepth(resolved, maxDepth-1)
|
||||
}
|
||||
|
||||
// SetupRedirect creates a .beads/redirect file for a worktree to point to the rig's shared beads.
|
||||
// This is used by crew, polecats, and refinery worktrees to share the rig's beads database.
|
||||
//
|
||||
// Parameters:
|
||||
// - townRoot: the town root directory (e.g., ~/gt)
|
||||
// - worktreePath: the worktree directory (e.g., <rig>/crew/<name> or <rig>/refinery/rig)
|
||||
//
|
||||
// The function:
|
||||
// 1. Computes the relative path from worktree to rig-level .beads
|
||||
// 2. Cleans up any existing .beads/ contents (from tracked branches)
|
||||
// 3. Creates the redirect file
|
||||
//
|
||||
// Safety: This function refuses to create redirects in the canonical beads location
|
||||
// (mayor/rig) to prevent circular redirect chains.
|
||||
func SetupRedirect(townRoot, worktreePath string) error {
|
||||
// Get rig root from worktree path
|
||||
// worktreePath = <town>/<rig>/crew/<name> or <town>/<rig>/refinery/rig etc.
|
||||
relPath, err := filepath.Rel(townRoot, worktreePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("computing relative path: %w", err)
|
||||
}
|
||||
parts := strings.Split(filepath.ToSlash(relPath), "/")
|
||||
if len(parts) < 2 {
|
||||
return fmt.Errorf("invalid worktree path: must be at least 2 levels deep from town root")
|
||||
}
|
||||
|
||||
// Safety check: prevent creating redirect in canonical beads location (mayor/rig)
|
||||
// This would create a circular redirect chain since rig/.beads redirects to mayor/rig/.beads
|
||||
if len(parts) >= 2 && parts[1] == "mayor" {
|
||||
return fmt.Errorf("cannot create redirect in canonical beads location (mayor/rig)")
|
||||
}
|
||||
|
||||
rigRoot := filepath.Join(townRoot, parts[0])
|
||||
rigBeadsPath := filepath.Join(rigRoot, ".beads")
|
||||
|
||||
if _, err := os.Stat(rigBeadsPath); os.IsNotExist(err) {
|
||||
return fmt.Errorf("no rig .beads found at %s", rigBeadsPath)
|
||||
}
|
||||
|
||||
// Clean up any existing .beads/ contents from the branch
|
||||
worktreeBeadsDir := filepath.Join(worktreePath, ".beads")
|
||||
if _, err := os.Stat(worktreeBeadsDir); err == nil {
|
||||
if err := os.RemoveAll(worktreeBeadsDir); err != nil {
|
||||
return fmt.Errorf("cleaning existing .beads dir: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create .beads directory
|
||||
if err := os.MkdirAll(worktreeBeadsDir, 0755); err != nil {
|
||||
return fmt.Errorf("creating .beads dir: %w", err)
|
||||
}
|
||||
|
||||
// Compute relative path from worktree to rig root
|
||||
// e.g., crew/<name> (depth 2) -> ../../.beads
|
||||
// refinery/rig (depth 2) -> ../../.beads
|
||||
depth := len(parts) - 1 // subtract 1 for rig name itself
|
||||
redirectPath := strings.Repeat("../", depth) + ".beads"
|
||||
|
||||
// Check if rig-level beads has a redirect (tracked beads case).
|
||||
// If so, redirect directly to the final destination to avoid chains.
|
||||
// The bd CLI doesn't support redirect chains, so we must skip intermediate hops.
|
||||
rigRedirectPath := filepath.Join(rigBeadsPath, "redirect")
|
||||
if data, err := os.ReadFile(rigRedirectPath); err == nil {
|
||||
rigRedirectTarget := strings.TrimSpace(string(data))
|
||||
if rigRedirectTarget != "" {
|
||||
// Rig has redirect (e.g., "mayor/rig/.beads" for tracked beads).
|
||||
// Redirect worktree directly to the final destination.
|
||||
redirectPath = strings.Repeat("../", depth) + rigRedirectTarget
|
||||
}
|
||||
}
|
||||
|
||||
// Create redirect file
|
||||
redirectFile := filepath.Join(worktreeBeadsDir, "redirect")
|
||||
if err := os.WriteFile(redirectFile, []byte(redirectPath+"\n"), 0644); err != nil {
|
||||
return fmt.Errorf("creating redirect file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Issue represents a beads issue.
|
||||
|
||||
@@ -1502,3 +1502,237 @@ func TestDelegationTerms(t *testing.T) {
|
||||
t.Errorf("parsed.CreditShare = %d, want %d", parsed.CreditShare, terms.CreditShare)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetupRedirect tests the beads redirect setup for worktrees.
|
||||
func TestSetupRedirect(t *testing.T) {
|
||||
t.Run("crew worktree with local beads", func(t *testing.T) {
|
||||
// Setup: town/rig/.beads (local, no redirect)
|
||||
townRoot := t.TempDir()
|
||||
rigRoot := filepath.Join(townRoot, "testrig")
|
||||
rigBeads := filepath.Join(rigRoot, ".beads")
|
||||
crewPath := filepath.Join(rigRoot, "crew", "max")
|
||||
|
||||
// Create rig structure
|
||||
if err := os.MkdirAll(rigBeads, 0755); err != nil {
|
||||
t.Fatalf("mkdir rig beads: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(crewPath, 0755); err != nil {
|
||||
t.Fatalf("mkdir crew: %v", err)
|
||||
}
|
||||
|
||||
// Run SetupRedirect
|
||||
if err := SetupRedirect(townRoot, crewPath); err != nil {
|
||||
t.Fatalf("SetupRedirect failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify redirect was created
|
||||
redirectPath := filepath.Join(crewPath, ".beads", "redirect")
|
||||
content, err := os.ReadFile(redirectPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read redirect: %v", err)
|
||||
}
|
||||
|
||||
want := "../../.beads\n"
|
||||
if string(content) != want {
|
||||
t.Errorf("redirect content = %q, want %q", string(content), want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("crew worktree with tracked beads", func(t *testing.T) {
|
||||
// Setup: town/rig/.beads/redirect -> mayor/rig/.beads (tracked)
|
||||
townRoot := t.TempDir()
|
||||
rigRoot := filepath.Join(townRoot, "testrig")
|
||||
rigBeads := filepath.Join(rigRoot, ".beads")
|
||||
mayorRigBeads := filepath.Join(rigRoot, "mayor", "rig", ".beads")
|
||||
crewPath := filepath.Join(rigRoot, "crew", "max")
|
||||
|
||||
// Create rig structure with tracked beads
|
||||
if err := os.MkdirAll(mayorRigBeads, 0755); err != nil {
|
||||
t.Fatalf("mkdir mayor/rig beads: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(rigBeads, 0755); err != nil {
|
||||
t.Fatalf("mkdir rig beads: %v", err)
|
||||
}
|
||||
// Create rig-level redirect to mayor/rig/.beads
|
||||
if err := os.WriteFile(filepath.Join(rigBeads, "redirect"), []byte("mayor/rig/.beads\n"), 0644); err != nil {
|
||||
t.Fatalf("write rig redirect: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(crewPath, 0755); err != nil {
|
||||
t.Fatalf("mkdir crew: %v", err)
|
||||
}
|
||||
|
||||
// Run SetupRedirect
|
||||
if err := SetupRedirect(townRoot, crewPath); err != nil {
|
||||
t.Fatalf("SetupRedirect failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify redirect goes directly to mayor/rig/.beads (no chain - bd CLI doesn't support chains)
|
||||
redirectPath := filepath.Join(crewPath, ".beads", "redirect")
|
||||
content, err := os.ReadFile(redirectPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read redirect: %v", err)
|
||||
}
|
||||
|
||||
want := "../../mayor/rig/.beads\n"
|
||||
if string(content) != want {
|
||||
t.Errorf("redirect content = %q, want %q", string(content), want)
|
||||
}
|
||||
|
||||
// Verify redirect resolves correctly
|
||||
resolved := ResolveBeadsDir(crewPath)
|
||||
// crew/max -> ../../mayor/rig/.beads (direct, no chain)
|
||||
if resolved != mayorRigBeads {
|
||||
t.Errorf("resolved = %q, want %q", resolved, mayorRigBeads)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("polecat worktree", func(t *testing.T) {
|
||||
townRoot := t.TempDir()
|
||||
rigRoot := filepath.Join(townRoot, "testrig")
|
||||
rigBeads := filepath.Join(rigRoot, ".beads")
|
||||
polecatPath := filepath.Join(rigRoot, "polecats", "worker1")
|
||||
|
||||
if err := os.MkdirAll(rigBeads, 0755); err != nil {
|
||||
t.Fatalf("mkdir rig beads: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(polecatPath, 0755); err != nil {
|
||||
t.Fatalf("mkdir polecat: %v", err)
|
||||
}
|
||||
|
||||
if err := SetupRedirect(townRoot, polecatPath); err != nil {
|
||||
t.Fatalf("SetupRedirect failed: %v", err)
|
||||
}
|
||||
|
||||
redirectPath := filepath.Join(polecatPath, ".beads", "redirect")
|
||||
content, err := os.ReadFile(redirectPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read redirect: %v", err)
|
||||
}
|
||||
|
||||
want := "../../.beads\n"
|
||||
if string(content) != want {
|
||||
t.Errorf("redirect content = %q, want %q", string(content), want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("refinery worktree", func(t *testing.T) {
|
||||
townRoot := t.TempDir()
|
||||
rigRoot := filepath.Join(townRoot, "testrig")
|
||||
rigBeads := filepath.Join(rigRoot, ".beads")
|
||||
refineryPath := filepath.Join(rigRoot, "refinery", "rig")
|
||||
|
||||
if err := os.MkdirAll(rigBeads, 0755); err != nil {
|
||||
t.Fatalf("mkdir rig beads: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(refineryPath, 0755); err != nil {
|
||||
t.Fatalf("mkdir refinery: %v", err)
|
||||
}
|
||||
|
||||
if err := SetupRedirect(townRoot, refineryPath); err != nil {
|
||||
t.Fatalf("SetupRedirect failed: %v", err)
|
||||
}
|
||||
|
||||
redirectPath := filepath.Join(refineryPath, ".beads", "redirect")
|
||||
content, err := os.ReadFile(redirectPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read redirect: %v", err)
|
||||
}
|
||||
|
||||
want := "../../.beads\n"
|
||||
if string(content) != want {
|
||||
t.Errorf("redirect content = %q, want %q", string(content), want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cleans existing tracked beads from worktree", func(t *testing.T) {
|
||||
townRoot := t.TempDir()
|
||||
rigRoot := filepath.Join(townRoot, "testrig")
|
||||
rigBeads := filepath.Join(rigRoot, ".beads")
|
||||
crewPath := filepath.Join(rigRoot, "crew", "max")
|
||||
crewBeads := filepath.Join(crewPath, ".beads")
|
||||
|
||||
if err := os.MkdirAll(rigBeads, 0755); err != nil {
|
||||
t.Fatalf("mkdir rig beads: %v", err)
|
||||
}
|
||||
// Simulate worktree with tracked .beads (has database files)
|
||||
if err := os.MkdirAll(crewBeads, 0755); err != nil {
|
||||
t.Fatalf("mkdir crew beads: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(crewBeads, "beads.db"), []byte("fake db"), 0644); err != nil {
|
||||
t.Fatalf("write fake db: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(crewBeads, "config.yaml"), []byte("prefix: test"), 0644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
if err := SetupRedirect(townRoot, crewPath); err != nil {
|
||||
t.Fatalf("SetupRedirect failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify old files were cleaned up
|
||||
if _, err := os.Stat(filepath.Join(crewBeads, "beads.db")); !os.IsNotExist(err) {
|
||||
t.Error("beads.db should have been removed")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(crewBeads, "config.yaml")); !os.IsNotExist(err) {
|
||||
t.Error("config.yaml should have been removed")
|
||||
}
|
||||
|
||||
// Verify redirect was created
|
||||
redirectPath := filepath.Join(crewBeads, "redirect")
|
||||
if _, err := os.Stat(redirectPath); err != nil {
|
||||
t.Errorf("redirect file should exist: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects mayor/rig canonical location", func(t *testing.T) {
|
||||
townRoot := t.TempDir()
|
||||
rigRoot := filepath.Join(townRoot, "testrig")
|
||||
rigBeads := filepath.Join(rigRoot, ".beads")
|
||||
mayorRigPath := filepath.Join(rigRoot, "mayor", "rig")
|
||||
|
||||
if err := os.MkdirAll(rigBeads, 0755); err != nil {
|
||||
t.Fatalf("mkdir rig beads: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(mayorRigPath, 0755); err != nil {
|
||||
t.Fatalf("mkdir mayor/rig: %v", err)
|
||||
}
|
||||
|
||||
err := SetupRedirect(townRoot, mayorRigPath)
|
||||
if err == nil {
|
||||
t.Error("SetupRedirect should reject mayor/rig location")
|
||||
}
|
||||
if err != nil && !strings.Contains(err.Error(), "canonical") {
|
||||
t.Errorf("error should mention canonical location, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects path too shallow", func(t *testing.T) {
|
||||
townRoot := t.TempDir()
|
||||
rigRoot := filepath.Join(townRoot, "testrig")
|
||||
|
||||
if err := os.MkdirAll(rigRoot, 0755); err != nil {
|
||||
t.Fatalf("mkdir rig: %v", err)
|
||||
}
|
||||
|
||||
err := SetupRedirect(townRoot, rigRoot)
|
||||
if err == nil {
|
||||
t.Error("SetupRedirect should reject rig root (too shallow)")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fails if rig beads missing", func(t *testing.T) {
|
||||
townRoot := t.TempDir()
|
||||
rigRoot := filepath.Join(townRoot, "testrig")
|
||||
crewPath := filepath.Join(rigRoot, "crew", "max")
|
||||
|
||||
// No rig/.beads created
|
||||
if err := os.MkdirAll(crewPath, 0755); err != nil {
|
||||
t.Fatalf("mkdir crew: %v", err)
|
||||
}
|
||||
|
||||
err := SetupRedirect(townRoot, crewPath)
|
||||
if err == nil {
|
||||
t.Error("SetupRedirect should fail if rig .beads missing")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -159,9 +159,9 @@ func TestDoneBeadsInitBothCodePaths(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestDoneRedirectChain verifies behavior with chained redirects.
|
||||
// ResolveBeadsDir follows exactly one level of redirect by design - it does NOT
|
||||
// follow chains transitively. This is intentional: chains typically indicate
|
||||
// misconfiguration (e.g., a redirect file that shouldn't exist).
|
||||
// ResolveBeadsDir follows chains up to depth 3 as a safety net for legacy configs.
|
||||
// SetupRedirect avoids creating chains (bd CLI doesn't support them), but if
|
||||
// chains exist we follow them to the final destination.
|
||||
func TestDoneRedirectChain(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
@@ -189,14 +189,15 @@ func TestDoneRedirectChain(t *testing.T) {
|
||||
t.Fatalf("write worktree redirect: %v", err)
|
||||
}
|
||||
|
||||
// ResolveBeadsDir follows exactly one level - stops at intermediate
|
||||
// (A warning is printed about the chain, but intermediate is returned)
|
||||
// ResolveBeadsDir follows chains up to depth 3 as a safety net.
|
||||
// Note: SetupRedirect avoids creating chains (bd CLI doesn't support them),
|
||||
// but if chains exist from legacy configs, we follow them to the final destination.
|
||||
resolved := beads.ResolveBeadsDir(worktreeDir)
|
||||
|
||||
// Should resolve to intermediate (one level), NOT canonical (two levels)
|
||||
if resolved != intermediateBeadsDir {
|
||||
t.Errorf("ResolveBeadsDir should follow one level only: got %s, want %s",
|
||||
resolved, intermediateBeadsDir)
|
||||
// Should resolve to canonical (follows the full chain)
|
||||
if resolved != canonicalBeadsDir {
|
||||
t.Errorf("ResolveBeadsDir should follow chain to final destination: got %s, want %s",
|
||||
resolved, canonicalBeadsDir)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1309,103 +1309,22 @@ func getAgentBeadID(ctx RoleContext) string {
|
||||
|
||||
// ensureBeadsRedirect ensures the .beads/redirect file exists for worktree-based roles.
|
||||
// This handles cases where git clean or other operations delete the redirect file.
|
||||
//
|
||||
// IMPORTANT: This function includes safety checks to prevent creating redirects in
|
||||
// the canonical beads location (mayor/rig/.beads), which would cause circular redirects.
|
||||
// Uses the shared SetupRedirect helper which handles both tracked and local beads.
|
||||
func ensureBeadsRedirect(ctx RoleContext) {
|
||||
// Only applies to crew and polecat roles (they use shared beads)
|
||||
if ctx.Role != RoleCrew && ctx.Role != RolePolecat {
|
||||
return
|
||||
}
|
||||
|
||||
// Get the rig root (parent of crew/ or polecats/)
|
||||
relPath, err := filepath.Rel(ctx.TownRoot, ctx.WorkDir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
parts := strings.Split(filepath.ToSlash(relPath), "/")
|
||||
if len(parts) < 1 {
|
||||
return
|
||||
}
|
||||
rigRoot := filepath.Join(ctx.TownRoot, parts[0])
|
||||
|
||||
// SAFETY CHECK: Prevent creating redirect in canonical beads location
|
||||
// If workDir is inside mayor/rig/, we should NOT create a redirect there
|
||||
// This prevents circular redirects like mayor/rig/.beads/redirect -> ../../mayor/rig/.beads
|
||||
mayorRigPath := filepath.Join(rigRoot, "mayor", "rig")
|
||||
workDirAbs, _ := filepath.Abs(ctx.WorkDir)
|
||||
mayorRigPathAbs, _ := filepath.Abs(mayorRigPath)
|
||||
if strings.HasPrefix(workDirAbs, mayorRigPathAbs) {
|
||||
// We're inside mayor/rig/ - this is not a polecat/crew worker location
|
||||
// Role detection may be wrong (e.g., GT_ROLE env var mismatch)
|
||||
// Do NOT create a redirect here
|
||||
// Only applies to worktree-based roles that use shared beads
|
||||
if ctx.Role != RoleCrew && ctx.Role != RolePolecat && ctx.Role != RoleRefinery {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if redirect already exists
|
||||
beadsDir := filepath.Join(ctx.WorkDir, ".beads")
|
||||
redirectPath := filepath.Join(beadsDir, "redirect")
|
||||
|
||||
redirectPath := filepath.Join(ctx.WorkDir, ".beads", "redirect")
|
||||
if _, err := os.Stat(redirectPath); err == nil {
|
||||
// Redirect exists, nothing to do
|
||||
return
|
||||
}
|
||||
|
||||
// Determine the correct redirect path based on role and rig structure
|
||||
var redirectContent string
|
||||
|
||||
// Check for shared beads locations in order of preference:
|
||||
// 1. rig/mayor/rig/.beads/ (if mayor rig clone exists)
|
||||
// 2. rig/.beads/ (rig root beads)
|
||||
mayorRigBeads := filepath.Join(rigRoot, "mayor", "rig", ".beads")
|
||||
rigRootBeads := filepath.Join(rigRoot, ".beads")
|
||||
|
||||
if _, err := os.Stat(mayorRigBeads); err == nil {
|
||||
// Use mayor/rig/.beads
|
||||
if ctx.Role == RoleCrew {
|
||||
// crew/<name>/.beads -> ../../mayor/rig/.beads
|
||||
redirectContent = "../../mayor/rig/.beads"
|
||||
} else {
|
||||
// polecats/<name>/.beads -> ../../mayor/rig/.beads
|
||||
redirectContent = "../../mayor/rig/.beads"
|
||||
}
|
||||
} else if _, err := os.Stat(rigRootBeads); err == nil {
|
||||
// Use rig root .beads
|
||||
if ctx.Role == RoleCrew {
|
||||
// crew/<name>/.beads -> ../../.beads
|
||||
redirectContent = "../../.beads"
|
||||
} else {
|
||||
// polecats/<name>/.beads -> ../../.beads
|
||||
redirectContent = "../../.beads"
|
||||
}
|
||||
} else {
|
||||
// No shared beads found, nothing to redirect to
|
||||
return
|
||||
}
|
||||
|
||||
// SAFETY CHECK: Verify the redirect won't be circular
|
||||
// Resolve the redirect target and check it's not the same as our beads dir
|
||||
resolvedTarget := filepath.Join(ctx.WorkDir, redirectContent)
|
||||
resolvedTarget = filepath.Clean(resolvedTarget)
|
||||
if resolvedTarget == beadsDir {
|
||||
// Would create circular redirect - don't do it
|
||||
return
|
||||
}
|
||||
|
||||
// Create .beads directory if needed
|
||||
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||
// Silently fail - not critical
|
||||
return
|
||||
}
|
||||
|
||||
// Write redirect file
|
||||
if err := os.WriteFile(redirectPath, []byte(redirectContent+"\n"), 0644); err != nil {
|
||||
// Silently fail - not critical
|
||||
return
|
||||
}
|
||||
|
||||
// Note: We don't print a message here to avoid cluttering prime output
|
||||
// The redirect is silently restored
|
||||
// Use shared helper - silently ignore errors during prime
|
||||
_ = beads.SetupRedirect(ctx.TownRoot, ctx.WorkDir)
|
||||
}
|
||||
|
||||
// checkPendingEscalations queries for open escalation beads and displays them prominently.
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/gastown/internal/beads"
|
||||
"github.com/steveyegge/gastown/internal/git"
|
||||
"github.com/steveyegge/gastown/internal/rig"
|
||||
"github.com/steveyegge/gastown/internal/util"
|
||||
@@ -381,50 +382,7 @@ type PristineResult struct {
|
||||
|
||||
// setupSharedBeads creates a redirect file so the crew worker uses the rig's shared .beads database.
|
||||
// This eliminates the need for git sync between crew clones - all crew members share one database.
|
||||
//
|
||||
// Structure:
|
||||
//
|
||||
// rig/
|
||||
// mayor/rig/.beads/ <- Shared database (the canonical location)
|
||||
// crew/
|
||||
// <name>/
|
||||
// .beads/
|
||||
// redirect <- Contains "../../mayor/rig/.beads"
|
||||
func (m *Manager) setupSharedBeads(crewPath string) error {
|
||||
// The shared beads database is at rig/mayor/rig/.beads/
|
||||
// Crew clones are at rig/crew/<name>/
|
||||
// So the relative path is ../../mayor/rig/.beads
|
||||
sharedBeadsPath := filepath.Join(m.rig.Path, "mayor", "rig", ".beads")
|
||||
|
||||
// Verify the shared beads exists
|
||||
if _, err := os.Stat(sharedBeadsPath); os.IsNotExist(err) {
|
||||
// Fall back to rig root .beads if mayor/rig doesn't exist
|
||||
sharedBeadsPath = filepath.Join(m.rig.Path, ".beads")
|
||||
if _, err := os.Stat(sharedBeadsPath); os.IsNotExist(err) {
|
||||
return fmt.Errorf("no shared beads database found")
|
||||
}
|
||||
}
|
||||
|
||||
// Create crew's .beads directory
|
||||
crewBeadsDir := filepath.Join(crewPath, ".beads")
|
||||
if err := os.MkdirAll(crewBeadsDir, 0755); err != nil {
|
||||
return fmt.Errorf("creating crew .beads dir: %w", err)
|
||||
}
|
||||
|
||||
// Calculate relative path from crew/.beads/ to shared beads
|
||||
// crew/<name>/.beads/ -> ../../mayor/rig/.beads or ../../.beads
|
||||
var redirectContent string
|
||||
if _, err := os.Stat(filepath.Join(m.rig.Path, "mayor", "rig", ".beads")); err == nil {
|
||||
redirectContent = "../../mayor/rig/.beads\n"
|
||||
} else {
|
||||
redirectContent = "../../.beads\n"
|
||||
}
|
||||
|
||||
// Create redirect file
|
||||
redirectPath := filepath.Join(crewBeadsDir, "redirect")
|
||||
if err := os.WriteFile(redirectPath, []byte(redirectContent), 0644); err != nil {
|
||||
return fmt.Errorf("creating redirect file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
townRoot := filepath.Dir(m.rig.Path)
|
||||
return beads.SetupRedirect(townRoot, crewPath)
|
||||
}
|
||||
|
||||
2
internal/daemon/daemon.go
Normal file → Executable file
2
internal/daemon/daemon.go
Normal file → Executable file
@@ -83,7 +83,7 @@ func (d *Daemon) Run() error {
|
||||
if !locked {
|
||||
return fmt.Errorf("daemon already running (lock held by another process)")
|
||||
}
|
||||
defer fileLock.Unlock()
|
||||
defer func() { _ = fileLock.Unlock() }()
|
||||
|
||||
// Write PID file
|
||||
if err := os.WriteFile(d.config.PidFile, []byte(strconv.Itoa(os.Getpid())), 0644); err != nil {
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/steveyegge/gastown/internal/config"
|
||||
)
|
||||
|
||||
// RigIsGitRepoCheck verifies the rig has a valid mayor/rig git clone.
|
||||
@@ -865,6 +867,214 @@ func (c *BeadsConfigValidCheck) Fix(ctx *CheckContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// BeadsRedirectCheck verifies that rig-level beads redirect exists for tracked beads.
|
||||
// When a repo has .beads/ tracked in git (at mayor/rig/.beads), the rig root needs
|
||||
// a redirect file pointing to that location.
|
||||
type BeadsRedirectCheck struct {
|
||||
FixableCheck
|
||||
}
|
||||
|
||||
// NewBeadsRedirectCheck creates a new beads redirect check.
|
||||
func NewBeadsRedirectCheck() *BeadsRedirectCheck {
|
||||
return &BeadsRedirectCheck{
|
||||
FixableCheck: FixableCheck{
|
||||
BaseCheck: BaseCheck{
|
||||
CheckName: "beads-redirect",
|
||||
CheckDescription: "Verify rig-level beads redirect for tracked beads",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Run checks if the rig-level beads redirect exists when needed.
|
||||
func (c *BeadsRedirectCheck) Run(ctx *CheckContext) *CheckResult {
|
||||
// Only applies when checking a specific rig
|
||||
if ctx.RigName == "" {
|
||||
return &CheckResult{
|
||||
Name: c.Name(),
|
||||
Status: StatusOK,
|
||||
Message: "No rig specified (skipping redirect check)",
|
||||
}
|
||||
}
|
||||
|
||||
rigPath := ctx.RigPath()
|
||||
mayorRigBeads := filepath.Join(rigPath, "mayor", "rig", ".beads")
|
||||
rigBeadsDir := filepath.Join(rigPath, ".beads")
|
||||
redirectPath := filepath.Join(rigBeadsDir, "redirect")
|
||||
|
||||
// Check if this rig has tracked beads (mayor/rig/.beads exists)
|
||||
if _, err := os.Stat(mayorRigBeads); os.IsNotExist(err) {
|
||||
// No tracked beads - check if rig/.beads exists (local beads)
|
||||
if _, err := os.Stat(rigBeadsDir); os.IsNotExist(err) {
|
||||
return &CheckResult{
|
||||
Name: c.Name(),
|
||||
Status: StatusError,
|
||||
Message: "No .beads directory found at rig root",
|
||||
Details: []string{
|
||||
"Beads database not initialized for this rig",
|
||||
"This prevents issue tracking for this rig",
|
||||
},
|
||||
FixHint: "Run 'gt doctor --fix --rig " + ctx.RigName + "' to initialize beads",
|
||||
}
|
||||
}
|
||||
return &CheckResult{
|
||||
Name: c.Name(),
|
||||
Status: StatusOK,
|
||||
Message: "Rig uses local beads (no redirect needed)",
|
||||
}
|
||||
}
|
||||
|
||||
// Tracked beads exist - check for conflicting local beads
|
||||
hasLocalData := hasBeadsData(rigBeadsDir)
|
||||
redirectExists := false
|
||||
if _, err := os.Stat(redirectPath); err == nil {
|
||||
redirectExists = true
|
||||
}
|
||||
|
||||
// Case: Local beads directory has actual data (not just redirect)
|
||||
if hasLocalData && !redirectExists {
|
||||
return &CheckResult{
|
||||
Name: c.Name(),
|
||||
Status: StatusError,
|
||||
Message: "Conflicting local beads found with tracked beads",
|
||||
Details: []string{
|
||||
"Tracked beads exist at: mayor/rig/.beads",
|
||||
"Local beads with data exist at: .beads/",
|
||||
"Fix will remove local beads and create redirect to tracked beads",
|
||||
},
|
||||
FixHint: "Run 'gt doctor --fix --rig " + ctx.RigName + "' to fix",
|
||||
}
|
||||
}
|
||||
|
||||
// Case: No redirect file (but no conflicting data)
|
||||
if !redirectExists {
|
||||
return &CheckResult{
|
||||
Name: c.Name(),
|
||||
Status: StatusError,
|
||||
Message: "Missing rig-level beads redirect for tracked beads",
|
||||
Details: []string{
|
||||
"Tracked beads exist at: mayor/rig/.beads",
|
||||
"Missing redirect at: .beads/redirect",
|
||||
"Without this redirect, bd commands from rig root won't find beads",
|
||||
},
|
||||
FixHint: "Run 'gt doctor --fix' to create the redirect",
|
||||
}
|
||||
}
|
||||
|
||||
// Verify redirect points to correct location
|
||||
content, err := os.ReadFile(redirectPath)
|
||||
if err != nil {
|
||||
return &CheckResult{
|
||||
Name: c.Name(),
|
||||
Status: StatusWarning,
|
||||
Message: fmt.Sprintf("Could not read redirect file: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
target := strings.TrimSpace(string(content))
|
||||
if target != "mayor/rig/.beads" {
|
||||
return &CheckResult{
|
||||
Name: c.Name(),
|
||||
Status: StatusError,
|
||||
Message: fmt.Sprintf("Redirect points to %q, expected mayor/rig/.beads", target),
|
||||
FixHint: "Run 'gt doctor --fix --rig " + ctx.RigName + "' to correct the redirect",
|
||||
}
|
||||
}
|
||||
|
||||
return &CheckResult{
|
||||
Name: c.Name(),
|
||||
Status: StatusOK,
|
||||
Message: "Rig-level beads redirect is correctly configured",
|
||||
}
|
||||
}
|
||||
|
||||
// Fix creates or corrects the rig-level beads redirect, or initializes beads if missing.
|
||||
func (c *BeadsRedirectCheck) Fix(ctx *CheckContext) error {
|
||||
if ctx.RigName == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
rigPath := ctx.RigPath()
|
||||
mayorRigBeads := filepath.Join(rigPath, "mayor", "rig", ".beads")
|
||||
rigBeadsDir := filepath.Join(rigPath, ".beads")
|
||||
redirectPath := filepath.Join(rigBeadsDir, "redirect")
|
||||
|
||||
// Check if tracked beads exist
|
||||
hasTrackedBeads := true
|
||||
if _, err := os.Stat(mayorRigBeads); os.IsNotExist(err) {
|
||||
hasTrackedBeads = false
|
||||
}
|
||||
|
||||
// Check if local beads exist
|
||||
hasLocalBeads := true
|
||||
if _, err := os.Stat(rigBeadsDir); os.IsNotExist(err) {
|
||||
hasLocalBeads = false
|
||||
}
|
||||
|
||||
// Case 1: No beads at all - initialize with bd init
|
||||
if !hasTrackedBeads && !hasLocalBeads {
|
||||
// Get the rig's beads prefix from rigs.json (falls back to "gt" if not found)
|
||||
prefix := config.GetRigPrefix(ctx.TownRoot, ctx.RigName)
|
||||
|
||||
// Create .beads directory
|
||||
if err := os.MkdirAll(rigBeadsDir, 0755); err != nil {
|
||||
return fmt.Errorf("creating .beads directory: %w", err)
|
||||
}
|
||||
|
||||
// Run bd init with the configured prefix
|
||||
cmd := exec.Command("bd", "init", "--prefix", prefix)
|
||||
cmd.Dir = rigPath
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
// bd might not be installed - create minimal config.yaml
|
||||
configPath := filepath.Join(rigBeadsDir, "config.yaml")
|
||||
configContent := fmt.Sprintf("prefix: %s\n", prefix)
|
||||
if writeErr := os.WriteFile(configPath, []byte(configContent), 0644); writeErr != nil {
|
||||
return fmt.Errorf("bd init failed (%v) and fallback config creation failed: %w", err, writeErr)
|
||||
}
|
||||
// Continue - minimal config created
|
||||
} else {
|
||||
_ = output // bd init succeeded
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Case 2: Tracked beads exist - create redirect (may need to remove conflicting local beads)
|
||||
if hasTrackedBeads {
|
||||
// Check if local beads have conflicting data
|
||||
if hasLocalBeads && hasBeadsData(rigBeadsDir) {
|
||||
// Remove conflicting local beads directory
|
||||
if err := os.RemoveAll(rigBeadsDir); err != nil {
|
||||
return fmt.Errorf("removing conflicting local beads: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create .beads directory if needed
|
||||
if err := os.MkdirAll(rigBeadsDir, 0755); err != nil {
|
||||
return fmt.Errorf("creating .beads directory: %w", err)
|
||||
}
|
||||
|
||||
// Write redirect file
|
||||
if err := os.WriteFile(redirectPath, []byte("mayor/rig/.beads\n"), 0644); err != nil {
|
||||
return fmt.Errorf("writing redirect file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// hasBeadsData checks if a beads directory has actual data (issues.jsonl, issues.db, config.yaml)
|
||||
// as opposed to just being a redirect-only directory.
|
||||
func hasBeadsData(beadsDir string) bool {
|
||||
// Check for actual beads data files
|
||||
dataFiles := []string{"issues.jsonl", "issues.db", "config.yaml"}
|
||||
for _, f := range dataFiles {
|
||||
if _, err := os.Stat(filepath.Join(beadsDir, f)); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// RigChecks returns all rig-level health checks.
|
||||
func RigChecks() []Check {
|
||||
return []Check{
|
||||
@@ -876,5 +1086,6 @@ func RigChecks() []Check {
|
||||
NewMayorCloneExistsCheck(),
|
||||
NewPolecatClonesValidCheck(),
|
||||
NewBeadsConfigValidCheck(),
|
||||
NewBeadsRedirectCheck(),
|
||||
}
|
||||
}
|
||||
|
||||
455
internal/doctor/rig_check_test.go
Normal file
455
internal/doctor/rig_check_test.go
Normal file
@@ -0,0 +1,455 @@
|
||||
package doctor
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewBeadsRedirectCheck(t *testing.T) {
|
||||
check := NewBeadsRedirectCheck()
|
||||
|
||||
if check.Name() != "beads-redirect" {
|
||||
t.Errorf("expected name 'beads-redirect', got %q", check.Name())
|
||||
}
|
||||
|
||||
if !check.CanFix() {
|
||||
t.Error("expected CanFix to return true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBeadsRedirectCheck_NoRigSpecified(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
check := NewBeadsRedirectCheck()
|
||||
ctx := &CheckContext{TownRoot: tmpDir, RigName: ""}
|
||||
|
||||
result := check.Run(ctx)
|
||||
|
||||
if result.Status != StatusOK {
|
||||
t.Errorf("expected StatusOK when no rig specified, got %v", result.Status)
|
||||
}
|
||||
if !strings.Contains(result.Message, "skipping") {
|
||||
t.Errorf("expected message about skipping, got %q", result.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBeadsRedirectCheck_NoBeadsAtAll(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
rigName := "testrig"
|
||||
rigDir := filepath.Join(tmpDir, rigName)
|
||||
if err := os.MkdirAll(rigDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
check := NewBeadsRedirectCheck()
|
||||
ctx := &CheckContext{TownRoot: tmpDir, RigName: rigName}
|
||||
|
||||
result := check.Run(ctx)
|
||||
|
||||
if result.Status != StatusError {
|
||||
t.Errorf("expected StatusError when no beads exist (fixable), got %v", result.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBeadsRedirectCheck_LocalBeadsOnly(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
rigName := "testrig"
|
||||
rigDir := filepath.Join(tmpDir, rigName)
|
||||
|
||||
// Create local beads at rig root (no mayor/rig/.beads)
|
||||
localBeads := filepath.Join(rigDir, ".beads")
|
||||
if err := os.MkdirAll(localBeads, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
check := NewBeadsRedirectCheck()
|
||||
ctx := &CheckContext{TownRoot: tmpDir, RigName: rigName}
|
||||
|
||||
result := check.Run(ctx)
|
||||
|
||||
if result.Status != StatusOK {
|
||||
t.Errorf("expected StatusOK for local beads (no redirect needed), got %v", result.Status)
|
||||
}
|
||||
if !strings.Contains(result.Message, "local beads") {
|
||||
t.Errorf("expected message about local beads, got %q", result.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBeadsRedirectCheck_TrackedBeadsMissingRedirect(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
rigName := "testrig"
|
||||
rigDir := filepath.Join(tmpDir, rigName)
|
||||
|
||||
// Create tracked beads at mayor/rig/.beads
|
||||
trackedBeads := filepath.Join(rigDir, "mayor", "rig", ".beads")
|
||||
if err := os.MkdirAll(trackedBeads, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
check := NewBeadsRedirectCheck()
|
||||
ctx := &CheckContext{TownRoot: tmpDir, RigName: rigName}
|
||||
|
||||
result := check.Run(ctx)
|
||||
|
||||
if result.Status != StatusError {
|
||||
t.Errorf("expected StatusError for missing redirect, got %v", result.Status)
|
||||
}
|
||||
if !strings.Contains(result.Message, "Missing") {
|
||||
t.Errorf("expected message about missing redirect, got %q", result.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBeadsRedirectCheck_TrackedBeadsCorrectRedirect(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
rigName := "testrig"
|
||||
rigDir := filepath.Join(tmpDir, rigName)
|
||||
|
||||
// Create tracked beads at mayor/rig/.beads
|
||||
trackedBeads := filepath.Join(rigDir, "mayor", "rig", ".beads")
|
||||
if err := os.MkdirAll(trackedBeads, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create rig-level .beads with correct redirect
|
||||
rigBeads := filepath.Join(rigDir, ".beads")
|
||||
if err := os.MkdirAll(rigBeads, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
redirectPath := filepath.Join(rigBeads, "redirect")
|
||||
if err := os.WriteFile(redirectPath, []byte("mayor/rig/.beads\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
check := NewBeadsRedirectCheck()
|
||||
ctx := &CheckContext{TownRoot: tmpDir, RigName: rigName}
|
||||
|
||||
result := check.Run(ctx)
|
||||
|
||||
if result.Status != StatusOK {
|
||||
t.Errorf("expected StatusOK for correct redirect, got %v", result.Status)
|
||||
}
|
||||
if !strings.Contains(result.Message, "correctly configured") {
|
||||
t.Errorf("expected message about correct config, got %q", result.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBeadsRedirectCheck_TrackedBeadsWrongRedirect(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
rigName := "testrig"
|
||||
rigDir := filepath.Join(tmpDir, rigName)
|
||||
|
||||
// Create tracked beads at mayor/rig/.beads
|
||||
trackedBeads := filepath.Join(rigDir, "mayor", "rig", ".beads")
|
||||
if err := os.MkdirAll(trackedBeads, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create rig-level .beads with wrong redirect
|
||||
rigBeads := filepath.Join(rigDir, ".beads")
|
||||
if err := os.MkdirAll(rigBeads, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
redirectPath := filepath.Join(rigBeads, "redirect")
|
||||
if err := os.WriteFile(redirectPath, []byte("wrong/path\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
check := NewBeadsRedirectCheck()
|
||||
ctx := &CheckContext{TownRoot: tmpDir, RigName: rigName}
|
||||
|
||||
result := check.Run(ctx)
|
||||
|
||||
if result.Status != StatusError {
|
||||
t.Errorf("expected StatusError for wrong redirect (fixable), got %v", result.Status)
|
||||
}
|
||||
if !strings.Contains(result.Message, "wrong/path") {
|
||||
t.Errorf("expected message to contain wrong path, got %q", result.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBeadsRedirectCheck_FixWrongRedirect(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
rigName := "testrig"
|
||||
rigDir := filepath.Join(tmpDir, rigName)
|
||||
|
||||
// Create tracked beads at mayor/rig/.beads
|
||||
trackedBeads := filepath.Join(rigDir, "mayor", "rig", ".beads")
|
||||
if err := os.MkdirAll(trackedBeads, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create rig-level .beads with wrong redirect
|
||||
rigBeads := filepath.Join(rigDir, ".beads")
|
||||
if err := os.MkdirAll(rigBeads, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
redirectPath := filepath.Join(rigBeads, "redirect")
|
||||
if err := os.WriteFile(redirectPath, []byte("wrong/path\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
check := NewBeadsRedirectCheck()
|
||||
ctx := &CheckContext{TownRoot: tmpDir, RigName: rigName}
|
||||
|
||||
// Verify fix is needed
|
||||
result := check.Run(ctx)
|
||||
if result.Status != StatusError {
|
||||
t.Fatalf("expected StatusError before fix, got %v", result.Status)
|
||||
}
|
||||
|
||||
// Apply fix
|
||||
if err := check.Fix(ctx); err != nil {
|
||||
t.Fatalf("Fix failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify redirect was corrected
|
||||
content, err := os.ReadFile(redirectPath)
|
||||
if err != nil {
|
||||
t.Fatalf("redirect file not found: %v", err)
|
||||
}
|
||||
if string(content) != "mayor/rig/.beads\n" {
|
||||
t.Errorf("redirect content = %q, want 'mayor/rig/.beads\\n'", string(content))
|
||||
}
|
||||
|
||||
// Verify check now passes
|
||||
result = check.Run(ctx)
|
||||
if result.Status != StatusOK {
|
||||
t.Errorf("expected StatusOK after fix, got %v", result.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBeadsRedirectCheck_Fix(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
rigName := "testrig"
|
||||
rigDir := filepath.Join(tmpDir, rigName)
|
||||
|
||||
// Create tracked beads at mayor/rig/.beads
|
||||
trackedBeads := filepath.Join(rigDir, "mayor", "rig", ".beads")
|
||||
if err := os.MkdirAll(trackedBeads, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
check := NewBeadsRedirectCheck()
|
||||
ctx := &CheckContext{TownRoot: tmpDir, RigName: rigName}
|
||||
|
||||
// Verify fix is needed
|
||||
result := check.Run(ctx)
|
||||
if result.Status != StatusError {
|
||||
t.Fatalf("expected StatusError before fix, got %v", result.Status)
|
||||
}
|
||||
|
||||
// Apply fix
|
||||
if err := check.Fix(ctx); err != nil {
|
||||
t.Fatalf("Fix failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify redirect file was created
|
||||
redirectPath := filepath.Join(rigDir, ".beads", "redirect")
|
||||
content, err := os.ReadFile(redirectPath)
|
||||
if err != nil {
|
||||
t.Fatalf("redirect file not created: %v", err)
|
||||
}
|
||||
|
||||
expected := "mayor/rig/.beads\n"
|
||||
if string(content) != expected {
|
||||
t.Errorf("redirect content = %q, want %q", string(content), expected)
|
||||
}
|
||||
|
||||
// Verify check now passes
|
||||
result = check.Run(ctx)
|
||||
if result.Status != StatusOK {
|
||||
t.Errorf("expected StatusOK after fix, got %v", result.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBeadsRedirectCheck_FixNoOp_LocalBeads(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
rigName := "testrig"
|
||||
rigDir := filepath.Join(tmpDir, rigName)
|
||||
|
||||
// Create only local beads (no tracked beads)
|
||||
localBeads := filepath.Join(rigDir, ".beads")
|
||||
if err := os.MkdirAll(localBeads, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
check := NewBeadsRedirectCheck()
|
||||
ctx := &CheckContext{TownRoot: tmpDir, RigName: rigName}
|
||||
|
||||
// Fix should be a no-op
|
||||
if err := check.Fix(ctx); err != nil {
|
||||
t.Fatalf("Fix failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify no redirect was created
|
||||
redirectPath := filepath.Join(rigDir, ".beads", "redirect")
|
||||
if _, err := os.Stat(redirectPath); !os.IsNotExist(err) {
|
||||
t.Error("redirect file should not be created for local beads")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBeadsRedirectCheck_FixInitBeads(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
rigName := "testrig"
|
||||
rigDir := filepath.Join(tmpDir, rigName)
|
||||
|
||||
// Create rig directory (no beads at all)
|
||||
if err := os.MkdirAll(rigDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create mayor/rigs.json with prefix for the rig
|
||||
mayorDir := filepath.Join(tmpDir, "mayor")
|
||||
if err := os.MkdirAll(mayorDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rigsJSON := `{
|
||||
"version": 1,
|
||||
"rigs": {
|
||||
"testrig": {
|
||||
"git_url": "https://example.com/test.git",
|
||||
"beads": {
|
||||
"prefix": "tr"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
if err := os.WriteFile(filepath.Join(mayorDir, "rigs.json"), []byte(rigsJSON), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
check := NewBeadsRedirectCheck()
|
||||
ctx := &CheckContext{TownRoot: tmpDir, RigName: rigName}
|
||||
|
||||
// Verify fix is needed
|
||||
result := check.Run(ctx)
|
||||
if result.Status != StatusError {
|
||||
t.Fatalf("expected StatusError before fix, got %v", result.Status)
|
||||
}
|
||||
|
||||
// Apply fix - this will run 'bd init' if available, otherwise create config.yaml
|
||||
if err := check.Fix(ctx); err != nil {
|
||||
t.Fatalf("Fix failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify .beads directory was created
|
||||
beadsDir := filepath.Join(rigDir, ".beads")
|
||||
if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
|
||||
t.Fatal(".beads directory not created")
|
||||
}
|
||||
|
||||
// Verify beads was initialized (either by bd init or fallback)
|
||||
// bd init creates config.yaml, fallback creates config.yaml with prefix
|
||||
configPath := filepath.Join(beadsDir, "config.yaml")
|
||||
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||
t.Fatal("config.yaml not created")
|
||||
}
|
||||
|
||||
// Verify check now passes (local beads exist)
|
||||
result = check.Run(ctx)
|
||||
if result.Status != StatusOK {
|
||||
t.Errorf("expected StatusOK after fix, got %v", result.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBeadsRedirectCheck_ConflictingLocalBeads(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
rigName := "testrig"
|
||||
rigDir := filepath.Join(tmpDir, rigName)
|
||||
|
||||
// Create tracked beads at mayor/rig/.beads
|
||||
trackedBeads := filepath.Join(rigDir, "mayor", "rig", ".beads")
|
||||
if err := os.MkdirAll(trackedBeads, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Add some content to tracked beads
|
||||
if err := os.WriteFile(filepath.Join(trackedBeads, "issues.jsonl"), []byte(`{"id":"tr-1"}`), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create conflicting local beads with actual data
|
||||
localBeads := filepath.Join(rigDir, ".beads")
|
||||
if err := os.MkdirAll(localBeads, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Add data to local beads (this is the conflict)
|
||||
if err := os.WriteFile(filepath.Join(localBeads, "issues.jsonl"), []byte(`{"id":"local-1"}`), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(localBeads, "config.yaml"), []byte("prefix: local\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
check := NewBeadsRedirectCheck()
|
||||
ctx := &CheckContext{TownRoot: tmpDir, RigName: rigName}
|
||||
|
||||
// Check should detect conflicting beads
|
||||
result := check.Run(ctx)
|
||||
if result.Status != StatusError {
|
||||
t.Errorf("expected StatusError for conflicting beads, got %v", result.Status)
|
||||
}
|
||||
if !strings.Contains(result.Message, "Conflicting") {
|
||||
t.Errorf("expected message about conflicting beads, got %q", result.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBeadsRedirectCheck_FixConflictingLocalBeads(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
rigName := "testrig"
|
||||
rigDir := filepath.Join(tmpDir, rigName)
|
||||
|
||||
// Create tracked beads at mayor/rig/.beads
|
||||
trackedBeads := filepath.Join(rigDir, "mayor", "rig", ".beads")
|
||||
if err := os.MkdirAll(trackedBeads, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(trackedBeads, "issues.jsonl"), []byte(`{"id":"tr-1"}`), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create conflicting local beads with actual data
|
||||
localBeads := filepath.Join(rigDir, ".beads")
|
||||
if err := os.MkdirAll(localBeads, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(localBeads, "issues.jsonl"), []byte(`{"id":"local-1"}`), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
check := NewBeadsRedirectCheck()
|
||||
ctx := &CheckContext{TownRoot: tmpDir, RigName: rigName}
|
||||
|
||||
// Verify fix is needed
|
||||
result := check.Run(ctx)
|
||||
if result.Status != StatusError {
|
||||
t.Fatalf("expected StatusError before fix, got %v", result.Status)
|
||||
}
|
||||
|
||||
// Apply fix - should remove conflicting local beads and create redirect
|
||||
if err := check.Fix(ctx); err != nil {
|
||||
t.Fatalf("Fix failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify local issues.jsonl was removed
|
||||
if _, err := os.Stat(filepath.Join(localBeads, "issues.jsonl")); !os.IsNotExist(err) {
|
||||
t.Error("local issues.jsonl should have been removed")
|
||||
}
|
||||
|
||||
// Verify redirect was created
|
||||
redirectPath := filepath.Join(localBeads, "redirect")
|
||||
content, err := os.ReadFile(redirectPath)
|
||||
if err != nil {
|
||||
t.Fatalf("redirect file not created: %v", err)
|
||||
}
|
||||
if string(content) != "mayor/rig/.beads\n" {
|
||||
t.Errorf("redirect content = %q, want 'mayor/rig/.beads\\n'", string(content))
|
||||
}
|
||||
|
||||
// Verify check now passes
|
||||
result = check.Run(ctx)
|
||||
if result.Status != StatusOK {
|
||||
t.Errorf("expected StatusOK after fix, got %v", result.Status)
|
||||
}
|
||||
}
|
||||
@@ -48,12 +48,11 @@ type Manager struct {
|
||||
|
||||
// NewManager creates a new polecat manager.
|
||||
func NewManager(r *rig.Rig, g *git.Git) *Manager {
|
||||
// Always use mayor/rig as the beads path.
|
||||
// This matches routes.jsonl which maps prefixes to <rig>/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")
|
||||
// Use the resolved beads directory to find where bd commands should run.
|
||||
// For tracked beads: rig/.beads/redirect -> mayor/rig/.beads, so use mayor/rig
|
||||
// For local beads: rig/.beads is the database, so use rig root
|
||||
resolvedBeads := beads.ResolveBeadsDir(r.Path)
|
||||
beadsPath := filepath.Dir(resolvedBeads) // Get the directory containing .beads
|
||||
|
||||
// Try to load rig settings for namepool config
|
||||
settingsPath := filepath.Join(r.Path, "settings", "config.json")
|
||||
@@ -721,74 +720,9 @@ func (m *Manager) loadFromBeads(name string) (*Polecat, error) {
|
||||
|
||||
// setupSharedBeads creates a redirect file so the polecat uses the rig's shared .beads database.
|
||||
// This eliminates the need for git sync between polecat clones - all polecats share one database.
|
||||
//
|
||||
// Structure:
|
||||
//
|
||||
// rig/
|
||||
// .beads/ <- Shared database (ensured to exist)
|
||||
// polecats/
|
||||
// <name>/
|
||||
// .beads/
|
||||
// redirect <- Contains "../../.beads" or "../../mayor/rig/.beads"
|
||||
//
|
||||
// IMPORTANT: If the polecat was created from a branch that had .beads/ tracked in git,
|
||||
// those files will be present. We must clean them out and replace with just the redirect.
|
||||
//
|
||||
// The redirect target is conditional: repos with .beads/ tracked in git have their canonical
|
||||
// database at mayor/rig/.beads, while fresh rigs use the database at rig root .beads/.
|
||||
func (m *Manager) setupSharedBeads(polecatPath string) error {
|
||||
// Determine the shared beads location:
|
||||
// - If mayor/rig/.beads exists (source repo has beads tracked in git), use that
|
||||
// - Otherwise fall back to rig/.beads (created by initBeads during gt rig add)
|
||||
// This matches the crew manager's logic for consistency.
|
||||
mayorRigBeads := filepath.Join(m.rig.Path, "mayor", "rig", ".beads")
|
||||
rigRootBeads := filepath.Join(m.rig.Path, ".beads")
|
||||
|
||||
var sharedBeadsPath string
|
||||
var redirectContent string
|
||||
|
||||
if _, err := os.Stat(mayorRigBeads); err == nil {
|
||||
// Source repo has .beads/ tracked - use mayor/rig/.beads
|
||||
sharedBeadsPath = mayorRigBeads
|
||||
redirectContent = "../../mayor/rig/.beads\n"
|
||||
} else {
|
||||
// No beads in source repo - use rig root .beads (from initBeads)
|
||||
sharedBeadsPath = rigRootBeads
|
||||
redirectContent = "../../.beads\n"
|
||||
// Ensure rig root has .beads/ directory
|
||||
if err := os.MkdirAll(rigRootBeads, 0755); err != nil {
|
||||
return fmt.Errorf("creating rig .beads dir: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify shared beads exists
|
||||
if _, err := os.Stat(sharedBeadsPath); os.IsNotExist(err) {
|
||||
return fmt.Errorf("no shared beads database found at %s", sharedBeadsPath)
|
||||
}
|
||||
|
||||
// Clean up any existing .beads/ contents from the branch
|
||||
// This handles the case where the polecat was created from a branch that
|
||||
// had .beads/ tracked (e.g., from previous bd sync operations)
|
||||
polecatBeadsDir := filepath.Join(polecatPath, ".beads")
|
||||
if _, err := os.Stat(polecatBeadsDir); err == nil {
|
||||
// Directory exists - remove it entirely and recreate fresh
|
||||
if err := os.RemoveAll(polecatBeadsDir); err != nil {
|
||||
return fmt.Errorf("cleaning existing .beads dir: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create fresh .beads directory
|
||||
if err := os.MkdirAll(polecatBeadsDir, 0755); err != nil {
|
||||
return fmt.Errorf("creating polecat .beads dir: %w", err)
|
||||
}
|
||||
|
||||
// Create redirect file pointing to the shared beads location
|
||||
redirectPath := filepath.Join(polecatBeadsDir, "redirect")
|
||||
if err := os.WriteFile(redirectPath, []byte(redirectContent), 0644); err != nil {
|
||||
return fmt.Errorf("creating redirect file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
townRoot := filepath.Dir(m.rig.Path)
|
||||
return beads.SetupRedirect(townRoot, polecatPath)
|
||||
}
|
||||
|
||||
// CleanupStaleBranches removes orphaned polecat branches that are no longer in use.
|
||||
|
||||
@@ -179,7 +179,8 @@ func (m *Manager) Start(foreground bool) error {
|
||||
_ = t.SetEnvironment(sessionID, "BD_ACTOR", bdActor)
|
||||
|
||||
// Set beads environment - refinery uses rig-level beads (non-fatal)
|
||||
beadsDir := filepath.Join(m.rig.Path, "mayor", "rig", ".beads")
|
||||
// Use ResolveBeadsDir to handle both tracked (mayor/rig) and local beads
|
||||
beadsDir := beads.ResolveBeadsDir(m.rig.Path)
|
||||
_ = t.SetEnvironment(sessionID, "BEADS_DIR", beadsDir)
|
||||
_ = t.SetEnvironment(sessionID, "BEADS_NO_DAEMON", "1")
|
||||
_ = t.SetEnvironment(sessionID, "BEADS_AGENT_NAME", fmt.Sprintf("%s/refinery", m.rig.Name))
|
||||
|
||||
@@ -367,6 +367,14 @@ func (m *Manager) AddRig(opts AddRigOptions) (*Rig, error) {
|
||||
return nil, fmt.Errorf("creating mayor CLAUDE.md: %w", err)
|
||||
}
|
||||
|
||||
// Initialize beads at rig level BEFORE creating worktrees.
|
||||
// This ensures rig/.beads exists so worktree redirects can point to it.
|
||||
fmt.Printf(" Initializing beads database...\n")
|
||||
if err := m.initBeads(rigPath, opts.BeadsPrefix); err != nil {
|
||||
return nil, fmt.Errorf("initializing beads: %w", err)
|
||||
}
|
||||
fmt.Printf(" ✓ Initialized beads (prefix: %s)\n", opts.BeadsPrefix)
|
||||
|
||||
// Create refinery as worktree from bare repo on default branch.
|
||||
// Refinery needs to see polecat branches (shared .repo.git) and merges them.
|
||||
// Being on the default branch allows direct merge workflow.
|
||||
@@ -379,6 +387,10 @@ func (m *Manager) AddRig(opts AddRigOptions) (*Rig, error) {
|
||||
return nil, fmt.Errorf("creating refinery worktree: %w", err)
|
||||
}
|
||||
fmt.Printf(" ✓ Created refinery worktree\n")
|
||||
// Set up beads redirect for refinery (points to rig-level .beads)
|
||||
if err := beads.SetupRedirect(m.townRoot, refineryRigPath); err != nil {
|
||||
fmt.Printf(" Warning: Could not set up refinery beads redirect: %v\n", err)
|
||||
}
|
||||
// Create refinery CLAUDE.md (overrides any from cloned repo)
|
||||
if err := m.createRoleCLAUDEmd(refineryRigPath, "refinery", opts.Name, ""); err != nil {
|
||||
return nil, fmt.Errorf("creating refinery CLAUDE.md: %w", err)
|
||||
@@ -433,13 +445,6 @@ Use crew for your own workspace. Polecats are for batch work dispatch.
|
||||
return nil, fmt.Errorf("creating polecats dir: %w", err)
|
||||
}
|
||||
|
||||
// Initialize beads at rig level
|
||||
fmt.Printf(" Initializing beads database...\n")
|
||||
if err := m.initBeads(rigPath, opts.BeadsPrefix); err != nil {
|
||||
return nil, fmt.Errorf("initializing beads: %w", err)
|
||||
}
|
||||
fmt.Printf(" ✓ Initialized beads (prefix: %s)\n", opts.BeadsPrefix)
|
||||
|
||||
// Create rig-level agent beads (witness, refinery) in rig beads.
|
||||
// Town-level agents (mayor, deacon) are created by gt install in town beads.
|
||||
if err := m.initAgentBeads(rigPath, opts.Name, opts.BeadsPrefix); err != nil {
|
||||
@@ -508,6 +513,23 @@ func (m *Manager) initBeads(rigPath, prefix string) error {
|
||||
}
|
||||
|
||||
beadsDir := filepath.Join(rigPath, ".beads")
|
||||
mayorRigBeads := filepath.Join(rigPath, "mayor", "rig", ".beads")
|
||||
|
||||
// Check if source repo has tracked .beads/ (cloned into mayor/rig).
|
||||
// If so, create a redirect file instead of a new database.
|
||||
if _, err := os.Stat(mayorRigBeads); err == nil {
|
||||
// Tracked beads exist - create redirect to mayor/rig/.beads
|
||||
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
redirectPath := filepath.Join(beadsDir, "redirect")
|
||||
if err := os.WriteFile(redirectPath, []byte("mayor/rig/.beads\n"), 0644); err != nil {
|
||||
return fmt.Errorf("creating redirect file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// No tracked beads - create local database
|
||||
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -572,7 +594,8 @@ func (m *Manager) initBeads(rigPath, prefix string) error {
|
||||
func (m *Manager) initAgentBeads(rigPath, rigName, prefix string) error {
|
||||
// Rig-level agents go in rig beads with rig prefix (per docs/architecture.md).
|
||||
// Town-level agents (Mayor, Deacon) are created by gt install in town beads.
|
||||
rigBeadsDir := filepath.Join(rigPath, ".beads")
|
||||
// Use ResolveBeadsDir to follow redirect files for tracked beads.
|
||||
rigBeadsDir := beads.ResolveBeadsDir(rigPath)
|
||||
bd := beads.NewWithBeadsDir(rigPath, rigBeadsDir)
|
||||
|
||||
// Define rig-level agents to create
|
||||
|
||||
Reference in New Issue
Block a user