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:
Julian Knutsen
2026-01-06 12:59:37 -08:00
committed by GitHub
parent 16fb45bb2a
commit 9d7dcde1e2
11 changed files with 1075 additions and 231 deletions

View File

@@ -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.

View File

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