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:
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user