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