fix(routing): handle symlinked .beads directories correctly (#1112)
When .beads is a symlink (e.g., ~/gt/.beads -> ~/gt/olympus/.beads), findTownRoutes() incorrectly used filepath.Dir() on the resolved symlink path to determine the town root. This caused route resolution to fail because the town root would be ~/gt/olympus instead of ~/gt, making routes point to non-existent directories. The fix adds findTownRootFromCWD() which walks up from the current working directory instead of the beads directory path. This finds the correct town root regardless of symlink resolution. Changes: - Add findTownRootFromCWD() function - Update findTownRoutes() to use CWD-based town root detection - Add fallback to filepath.Dir() for non-Gas Town repos - Add debug logging (BD_DEBUG_ROUTING=1) - Add comprehensive test case Test: go test ./internal/routing/...
This commit is contained in:
@@ -291,20 +291,52 @@ func findTownRoot(startDir string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// findTownRootFromCWD walks up from the current working directory looking for a town root.
|
||||
// This is used to handle symlinked .beads directories correctly.
|
||||
// By starting from CWD instead of the beads directory path, we find the correct
|
||||
// town root even when .beads is a symlink that points elsewhere.
|
||||
func findTownRootFromCWD() string {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return findTownRoot(cwd)
|
||||
}
|
||||
|
||||
// findTownRoutes searches for routes.jsonl at the town level.
|
||||
// It walks up from currentBeadsDir to find the town root, then loads routes
|
||||
// from <townRoot>/.beads/routes.jsonl.
|
||||
// Returns (routes, townRoot). Returns nil routes if not in an orchestrator town or no routes found.
|
||||
//
|
||||
// IMPORTANT: This function handles symlinked .beads directories correctly.
|
||||
// When .beads is a symlink (e.g., ~/gt/.beads -> ~/gt/olympus/.beads), we must
|
||||
// use findTownRoot() starting from CWD to determine the actual town root rather
|
||||
// than starting from currentBeadsDir, which may be the resolved symlink path.
|
||||
func findTownRoutes(currentBeadsDir string) ([]Route, string) {
|
||||
// First try the current beads dir (works if we're already at town level)
|
||||
routes, err := LoadRoutes(currentBeadsDir)
|
||||
if err == nil && len(routes) > 0 {
|
||||
// Return the parent of the beads dir as "town root" for path resolution
|
||||
// Use findTownRoot() starting from CWD to determine the actual town root.
|
||||
// We must NOT use currentBeadsDir as the starting point because if .beads
|
||||
// is a symlink (e.g., ~/gt/.beads -> ~/gt/olympus/.beads), currentBeadsDir
|
||||
// will be the resolved path (e.g., ~/gt/olympus/.beads) and walking up
|
||||
// from there would find ~/gt/olympus as the town root instead of ~/gt.
|
||||
townRoot := findTownRootFromCWD()
|
||||
if townRoot != "" {
|
||||
if os.Getenv("BD_DEBUG_ROUTING") != "" {
|
||||
fmt.Fprintf(os.Stderr, "[routing] findTownRoutes: found routes in %s, townRoot=%s (via findTownRootFromCWD)\n", currentBeadsDir, townRoot)
|
||||
}
|
||||
return routes, townRoot
|
||||
}
|
||||
// Fallback to parent dir if not in a town structure (for non-Gas Town repos)
|
||||
if os.Getenv("BD_DEBUG_ROUTING") != "" {
|
||||
fmt.Fprintf(os.Stderr, "[routing] findTownRoutes: found routes in %s, townRoot=%s (fallback to parent dir)\n", currentBeadsDir, filepath.Dir(currentBeadsDir))
|
||||
}
|
||||
return routes, filepath.Dir(currentBeadsDir)
|
||||
}
|
||||
|
||||
// Walk up to find town root
|
||||
townRoot := findTownRoot(currentBeadsDir)
|
||||
// Walk up from CWD to find town root
|
||||
townRoot := findTownRootFromCWD()
|
||||
if townRoot == "" {
|
||||
return nil, "" // Not in a town
|
||||
}
|
||||
@@ -316,6 +348,10 @@ func findTownRoutes(currentBeadsDir string) ([]Route, string) {
|
||||
return nil, "" // No town routes
|
||||
}
|
||||
|
||||
if os.Getenv("BD_DEBUG_ROUTING") != "" {
|
||||
fmt.Fprintf(os.Stderr, "[routing] findTownRoutes: loaded routes from %s, townRoot=%s\n", townBeadsDir, townRoot)
|
||||
}
|
||||
|
||||
return routes, townRoot
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ package routing
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
@@ -286,3 +288,106 @@ func TestDetectUserRole_DefaultContributor(t *testing.T) {
|
||||
t.Fatalf("expected %s, got %s", Contributor, role)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFindTownRoutes_SymlinkedBeadsDir verifies that findTownRoutes correctly
|
||||
// handles symlinked .beads directories by using findTownRootFromCWD() instead of
|
||||
// walking up from the beadsDir path.
|
||||
//
|
||||
// Scenario: ~/gt/.beads is a symlink to ~/gt/olympus/.beads
|
||||
// Before fix: walking up from ~/gt/olympus/.beads finds ~/gt/olympus (WRONG)
|
||||
// After fix: findTownRootFromCWD() walks up from CWD to find mayor/town.json at ~/gt
|
||||
func TestFindTownRoutes_SymlinkedBeadsDir(t *testing.T) {
|
||||
// Create temporary directory structure simulating Gas Town:
|
||||
// tmpDir/
|
||||
// mayor/
|
||||
// town.json <- town root marker
|
||||
// olympus/ <- actual beads storage
|
||||
// .beads/
|
||||
// routes.jsonl
|
||||
// .beads -> olympus/.beads <- symlink
|
||||
// daedalus/
|
||||
// mayor/
|
||||
// rig/
|
||||
// .beads/ <- target rig
|
||||
tmpDir, err := os.MkdirTemp("", "routing-symlink-test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Resolve symlinks in tmpDir (macOS /var -> /private/var)
|
||||
tmpDir, err = filepath.EvalSymlinks(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create mayor/town.json to mark town root
|
||||
mayorDir := filepath.Join(tmpDir, "mayor")
|
||||
if err := os.MkdirAll(mayorDir, 0750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
townJSON := filepath.Join(mayorDir, "town.json")
|
||||
if err := os.WriteFile(townJSON, []byte(`{"name": "test-town"}`), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create olympus/.beads with routes.jsonl
|
||||
olympusBeadsDir := filepath.Join(tmpDir, "olympus", ".beads")
|
||||
if err := os.MkdirAll(olympusBeadsDir, 0750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
routesContent := `{"prefix": "gt-", "path": "daedalus/mayor/rig"}
|
||||
`
|
||||
routesPath := filepath.Join(olympusBeadsDir, "routes.jsonl")
|
||||
if err := os.WriteFile(routesPath, []byte(routesContent), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create daedalus/mayor/rig/.beads as target rig
|
||||
daedalusBeadsDir := filepath.Join(tmpDir, "daedalus", "mayor", "rig", ".beads")
|
||||
if err := os.MkdirAll(daedalusBeadsDir, 0750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Create metadata.json so the rig is recognized as valid
|
||||
if err := os.WriteFile(filepath.Join(daedalusBeadsDir, "metadata.json"), []byte(`{}`), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create symlink: tmpDir/.beads -> olympus/.beads
|
||||
symlinkPath := filepath.Join(tmpDir, ".beads")
|
||||
if err := os.Symlink(olympusBeadsDir, symlinkPath); err != nil {
|
||||
t.Skip("Cannot create symlinks on this system (may require admin on Windows)")
|
||||
}
|
||||
|
||||
// Change to the town root directory - this simulates the user running bd from ~/gt
|
||||
// The fix uses findTownRootFromCWD() which needs CWD to be inside the town
|
||||
t.Chdir(tmpDir)
|
||||
|
||||
// Simulate what happens when FindBeadsDir() returns the resolved symlink path
|
||||
// (this is what CanonicalizePath does)
|
||||
resolvedBeadsDir := olympusBeadsDir // This is what would be passed to findTownRoutes
|
||||
|
||||
// Call findTownRoutes with the resolved symlink path
|
||||
routes, townRoot := findTownRoutes(resolvedBeadsDir)
|
||||
|
||||
// Verify we got the routes
|
||||
if len(routes) == 0 {
|
||||
t.Fatal("findTownRoutes returned no routes")
|
||||
}
|
||||
|
||||
// Verify the town root is correct (should be tmpDir, NOT tmpDir/olympus)
|
||||
if townRoot != tmpDir {
|
||||
t.Errorf("findTownRoutes returned wrong townRoot:\n got: %s\n want: %s", townRoot, tmpDir)
|
||||
}
|
||||
|
||||
// Verify route resolution works - the route should resolve to the correct path
|
||||
expectedRigPath := filepath.Join(tmpDir, "daedalus", "mayor", "rig", ".beads")
|
||||
for _, route := range routes {
|
||||
if route.Prefix == "gt-" {
|
||||
actualPath := filepath.Join(townRoot, route.Path, ".beads")
|
||||
if actualPath != expectedRigPath {
|
||||
t.Errorf("Route resolution failed:\n got: %s\n want: %s", actualPath, expectedRigPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user