fix: gt commands follow .beads/redirect for shared beads (gt-ln5af)

Added ResolveBeadsDir() helper that follows .beads/redirect files,
enabling crew workers and polecats to properly access shared beads.

Updated callers:
- mailbox.go: NewMailboxFromAddress follows redirect
- catalog.go: LoadCatalog follows redirect at all levels
- doctor checks: beads_check, patrol_check, wisp_check follow redirect

Also added comprehensive tests for the redirect resolution logic.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-27 21:34:22 -08:00
parent f443cd6637
commit 879018f35d
7 changed files with 153 additions and 13 deletions
+39
View File
@@ -21,6 +21,45 @@ var (
ErrNotFound = errors.New("issue not found")
)
// ResolveBeadsDir returns the actual beads directory, following any redirect.
// If workDir/.beads/redirect exists, it reads the redirect path and resolves it
// relative to workDir (not the .beads directory). Otherwise, returns workDir/.beads.
//
// This is essential for crew workers and polecats that use shared beads via redirect.
// The redirect file contains a relative path like "../../mayor/rig/.beads".
//
// Example: if we're at crew/max/ and .beads/redirect contains "../../mayor/rig/.beads",
// the redirect is resolved from crew/max/ (not crew/max/.beads/), giving us
// mayor/rig/.beads at the rig root level.
func ResolveBeadsDir(workDir string) string {
beadsDir := filepath.Join(workDir, ".beads")
redirectPath := filepath.Join(beadsDir, "redirect")
// Check for redirect file
data, err := os.ReadFile(redirectPath)
if err != nil {
// No redirect, use local .beads
return beadsDir
}
// Read and clean the redirect path
redirectTarget := strings.TrimSpace(string(data))
if redirectTarget == "" {
return beadsDir
}
// Resolve relative to workDir (the redirect is written from the perspective
// of being inside workDir, not inside workDir/.beads)
// e.g., redirect contains "../../mayor/rig/.beads"
// from crew/max/, this resolves to mayor/rig/.beads
resolved := filepath.Join(workDir, redirectTarget)
// Clean the path to resolve .. components
resolved = filepath.Clean(resolved)
return resolved
}
// Issue represents a beads issue.
type Issue struct {
ID string `json:"id"`
+86
View File
@@ -881,3 +881,89 @@ func TestAttachmentFieldsRoundTrip(t *testing.T) {
t.Errorf("round-trip mismatch:\ngot %+v\nwant %+v", parsed, original)
}
}
// TestResolveBeadsDir tests the redirect following logic.
func TestResolveBeadsDir(t *testing.T) {
// Create temp directory structure
tmpDir, err := os.MkdirTemp("", "beads-redirect-test-*")
if err != nil {
t.Fatal(err)
}
defer func() { _ = os.RemoveAll(tmpDir) }()
t.Run("no redirect", func(t *testing.T) {
// Create a simple .beads directory without redirect
workDir := filepath.Join(tmpDir, "no-redirect")
beadsDir := filepath.Join(workDir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatal(err)
}
got := ResolveBeadsDir(workDir)
want := beadsDir
if got != want {
t.Errorf("ResolveBeadsDir() = %q, want %q", got, want)
}
})
t.Run("with redirect", func(t *testing.T) {
// Create structure like: crew/max/.beads/redirect -> ../../mayor/rig/.beads
workDir := filepath.Join(tmpDir, "crew", "max")
localBeadsDir := filepath.Join(workDir, ".beads")
targetBeadsDir := filepath.Join(tmpDir, "mayor", "rig", ".beads")
// Create both directories
if err := os.MkdirAll(localBeadsDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(targetBeadsDir, 0755); err != nil {
t.Fatal(err)
}
// Create redirect file
redirectPath := filepath.Join(localBeadsDir, "redirect")
if err := os.WriteFile(redirectPath, []byte("../../mayor/rig/.beads\n"), 0644); err != nil {
t.Fatal(err)
}
got := ResolveBeadsDir(workDir)
want := targetBeadsDir
if got != want {
t.Errorf("ResolveBeadsDir() = %q, want %q", got, want)
}
})
t.Run("no beads directory", func(t *testing.T) {
// Directory with no .beads at all
workDir := filepath.Join(tmpDir, "empty")
if err := os.MkdirAll(workDir, 0755); err != nil {
t.Fatal(err)
}
got := ResolveBeadsDir(workDir)
want := filepath.Join(workDir, ".beads")
if got != want {
t.Errorf("ResolveBeadsDir() = %q, want %q", got, want)
}
})
t.Run("empty redirect file", func(t *testing.T) {
// Redirect file exists but is empty - should fall back to local
workDir := filepath.Join(tmpDir, "empty-redirect")
beadsDir := filepath.Join(workDir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatal(err)
}
redirectPath := filepath.Join(beadsDir, "redirect")
if err := os.WriteFile(redirectPath, []byte(" \n"), 0644); err != nil {
t.Fatal(err)
}
got := ResolveBeadsDir(workDir)
want := beadsDir
if got != want {
t.Errorf("ResolveBeadsDir() = %q, want %q", got, want)
}
})
}
+10 -6
View File
@@ -46,28 +46,32 @@ func NewMoleculeCatalog() *MoleculeCatalog {
// - projectPath: Path to the project directory. Empty to skip project-level.
//
// Molecules are loaded from town, rig, and project levels (no builtin molecules).
// Each level follows .beads/redirect if present (for shared beads support).
func LoadCatalog(townRoot, rigPath, projectPath string) (*MoleculeCatalog, error) {
catalog := NewMoleculeCatalog()
// 1. Load town-level molecules
// 1. Load town-level molecules (follows redirect if present)
if townRoot != "" {
townMolsPath := filepath.Join(townRoot, ".beads", "molecules.jsonl")
townBeadsDir := ResolveBeadsDir(townRoot)
townMolsPath := filepath.Join(townBeadsDir, "molecules.jsonl")
if err := catalog.LoadFromFile(townMolsPath, "town"); err != nil && !os.IsNotExist(err) {
return nil, fmt.Errorf("loading town molecules: %w", err)
}
}
// 2. Load rig-level molecules
// 2. Load rig-level molecules (follows redirect if present)
if rigPath != "" {
rigMolsPath := filepath.Join(rigPath, ".beads", "molecules.jsonl")
rigBeadsDir := ResolveBeadsDir(rigPath)
rigMolsPath := filepath.Join(rigBeadsDir, "molecules.jsonl")
if err := catalog.LoadFromFile(rigMolsPath, "rig"); err != nil && !os.IsNotExist(err) {
return nil, fmt.Errorf("loading rig molecules: %w", err)
}
}
// 3. Load project-level molecules
// 3. Load project-level molecules (follows redirect if present)
if projectPath != "" {
projectMolsPath := filepath.Join(projectPath, ".beads", "molecules.jsonl")
projectBeadsDir := ResolveBeadsDir(projectPath)
projectMolsPath := filepath.Join(projectBeadsDir, "molecules.jsonl")
if err := catalog.LoadFromFile(projectMolsPath, "project"); err != nil && !os.IsNotExist(err) {
return nil, fmt.Errorf("loading project molecules: %w", err)
}