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)
|
||||
}
|
||||
|
||||
@@ -76,8 +76,9 @@ func (c *BeadsDatabaseCheck) Run(ctx *CheckContext) *CheckResult {
|
||||
}
|
||||
|
||||
// Also check rig-level beads if a rig is specified
|
||||
// Follows redirect if present (rig root may redirect to mayor/rig/.beads)
|
||||
if ctx.RigName != "" {
|
||||
rigBeadsDir := filepath.Join(ctx.RigPath(), ".beads")
|
||||
rigBeadsDir := beads.ResolveBeadsDir(ctx.RigPath())
|
||||
if _, err := os.Stat(rigBeadsDir); err == nil {
|
||||
rigDB := filepath.Join(rigBeadsDir, "issues.db")
|
||||
rigJSONL := filepath.Join(rigBeadsDir, "issues.jsonl")
|
||||
@@ -135,9 +136,9 @@ func (c *BeadsDatabaseCheck) Fix(ctx *CheckContext) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Also fix rig-level if specified
|
||||
// Also fix rig-level if specified (follows redirect if present)
|
||||
if ctx.RigName != "" {
|
||||
rigBeadsDir := filepath.Join(ctx.RigPath(), ".beads")
|
||||
rigBeadsDir := beads.ResolveBeadsDir(ctx.RigPath())
|
||||
rigDB := filepath.Join(rigBeadsDir, "issues.db")
|
||||
rigJSONL := filepath.Join(rigBeadsDir, "issues.jsonl")
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/gastown/internal/beads"
|
||||
"github.com/steveyegge/gastown/internal/config"
|
||||
)
|
||||
|
||||
@@ -262,7 +263,10 @@ func (c *PatrolNotStuckCheck) Run(ctx *CheckContext) *CheckResult {
|
||||
var stuckWisps []string
|
||||
for _, rigName := range rigs {
|
||||
// Check main beads database for wisps (issues with Wisp=true)
|
||||
beadsPath := filepath.Join(ctx.TownRoot, rigName, ".beads", "issues.jsonl")
|
||||
// Follows redirect if present (rig root may redirect to mayor/rig/.beads)
|
||||
rigPath := filepath.Join(ctx.TownRoot, rigName)
|
||||
beadsDir := beads.ResolveBeadsDir(rigPath)
|
||||
beadsPath := filepath.Join(beadsDir, "issues.jsonl")
|
||||
stuck := c.checkStuckWisps(beadsPath, rigName)
|
||||
stuckWisps = append(stuckWisps, stuck...)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/gastown/internal/beads"
|
||||
)
|
||||
|
||||
// WispGCCheck detects and cleans orphaned wisps that are older than a threshold.
|
||||
@@ -87,8 +89,9 @@ func (c *WispGCCheck) Run(ctx *CheckContext) *CheckResult {
|
||||
|
||||
// countAbandonedWisps counts wisps older than the threshold in a rig.
|
||||
func (c *WispGCCheck) countAbandonedWisps(rigPath string) int {
|
||||
// Check the beads database for wisps
|
||||
issuesPath := filepath.Join(rigPath, ".beads", "issues.jsonl")
|
||||
// Check the beads database for wisps (follows redirect if present)
|
||||
beadsDir := beads.ResolveBeadsDir(rigPath)
|
||||
issuesPath := filepath.Join(beadsDir, "issues.jsonl")
|
||||
file, err := os.Open(issuesPath)
|
||||
if err != nil {
|
||||
return 0 // No issues file
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/steveyegge/gastown/internal/beads"
|
||||
)
|
||||
|
||||
// Common errors
|
||||
@@ -46,8 +48,9 @@ func NewMailboxBeads(identity, workDir string) *Mailbox {
|
||||
}
|
||||
|
||||
// NewMailboxFromAddress creates a beads-backed mailbox from a GGT address.
|
||||
// Follows .beads/redirect for crew workers and polecats using shared beads.
|
||||
func NewMailboxFromAddress(address, workDir string) *Mailbox {
|
||||
beadsDir := filepath.Join(workDir, ".beads")
|
||||
beadsDir := beads.ResolveBeadsDir(workDir)
|
||||
return &Mailbox{
|
||||
identity: addressToIdentity(address),
|
||||
workDir: workDir,
|
||||
|
||||
Reference in New Issue
Block a user