diff --git a/cmd/bd/create.go b/cmd/bd/create.go index 4b46f1a759..62d29d9bd6 100644 --- a/cmd/bd/create.go +++ b/cmd/bd/create.go @@ -11,6 +11,7 @@ import ( "time" "github.com/spf13/cobra" + "github.com/steveyegge/beads/internal/beads" "github.com/steveyegge/beads/internal/config" "github.com/steveyegge/beads/internal/debug" "github.com/steveyegge/beads/internal/hooks" @@ -977,33 +978,20 @@ func createInRig(cmd *cobra.Command, rigName, explicitID, title, description, is } // findTownBeadsDir finds the town-level .beads directory (where routes.jsonl lives). -// It walks up from the current directory looking for a .beads directory with routes.jsonl. +// It follows the active beads directory for this command so nested towns do not +// override the authoritative routing context. func findTownBeadsDir() (string, error) { - // Start from current directory and walk up - dir, err := os.Getwd() - if err != nil { - return "", err + currentBeadsDir := resolveCommandBeadsDir(dbPath) + if currentBeadsDir == "" { + currentBeadsDir = beads.FindBeadsDir() } - for { - beadsDir := filepath.Join(dir, ".beads") - routesFile := filepath.Join(beadsDir, routing.RoutesFileName) - - // Check if this .beads directory has routes.jsonl - if _, err := os.Stat(routesFile); err == nil { - return beadsDir, nil - } - - // Move up one directory - parent := filepath.Dir(dir) - if parent == dir { - // Reached filesystem root - break - } - dir = parent + townBeadsDir := routing.ResolveTownBeadsDir(currentBeadsDir) + if townBeadsDir == "" { + return "", fmt.Errorf("no routes.jsonl found for current beads directory") } - return "", fmt.Errorf("no routes.jsonl found in any parent .beads directory") + return townBeadsDir, nil } // formatTimeForRPC converts a *time.Time to RFC3339 string for RPC calls. diff --git a/cmd/bd/create_routing_test.go b/cmd/bd/create_routing_test.go index 0aa6809538..414a7ed04f 100644 --- a/cmd/bd/create_routing_test.go +++ b/cmd/bd/create_routing_test.go @@ -4,6 +4,7 @@ package main import ( "context" + "os" "path/filepath" "testing" @@ -46,3 +47,142 @@ func TestGetRoutingConfigValue_YAMLPrecedence(t *testing.T) { t.Fatalf("getRoutingConfigValue() = %q, want %q", got, "maintainer") } } + +func TestFindTownBeadsDir_PrefersCurrentBeadsDirOverNestedTownCWD(t *testing.T) { + outerTownDir := t.TempDir() + + outerMayorDir := filepath.Join(outerTownDir, "mayor") + if err := os.MkdirAll(outerMayorDir, 0750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(outerMayorDir, "town.json"), []byte(`{}`), 0600); err != nil { + t.Fatal(err) + } + + outerBeadsDir := filepath.Join(outerTownDir, ".beads") + if err := os.MkdirAll(outerBeadsDir, 0750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(outerBeadsDir, "routes.jsonl"), []byte(`{"prefix":"gt-","path":"rigs/outer"} +`), 0600); err != nil { + t.Fatal(err) + } + + outerRigBeadsDir := filepath.Join(outerTownDir, "outer-worktree", ".beads") + if err := os.MkdirAll(outerRigBeadsDir, 0750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(outerRigBeadsDir, "metadata.json"), []byte(`{"backend":"dolt","dolt_database":"outer"}`), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(outerRigBeadsDir, "config.yaml"), []byte("backend: dolt\n"), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(outerRigBeadsDir, "dolt.db"), []byte{}, 0600); err != nil { + t.Fatal(err) + } + + if err := os.WriteFile(filepath.Join(outerBeadsDir, "routes.jsonl"), []byte(`{"prefix":"gt-","path":"rigs/outer"} +`), 0600); err != nil { + t.Fatal(err) + } + + innerTownDir := filepath.Join(outerTownDir, "nested-town") + innerMayorDir := filepath.Join(innerTownDir, "mayor") + if err := os.MkdirAll(innerMayorDir, 0750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(innerMayorDir, "town.json"), []byte(`{}`), 0600); err != nil { + t.Fatal(err) + } + + innerBeadsDir := filepath.Join(innerTownDir, ".beads") + if err := os.MkdirAll(innerBeadsDir, 0750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(innerBeadsDir, "routes.jsonl"), []byte(`{"prefix":"gt-","path":"rigs/inner"} +`), 0600); err != nil { + t.Fatal(err) + } + + workerDir := filepath.Join(innerTownDir, "crew", "worker") + if err := os.MkdirAll(workerDir, 0750); err != nil { + t.Fatal(err) + } + t.Chdir(workerDir) + t.Setenv("BEADS_DIR", outerRigBeadsDir) + + oldDBPath := dbPath + dbPath = "" + t.Cleanup(func() { dbPath = oldDBPath }) + + got, err := findTownBeadsDir() + if err != nil { + t.Fatalf("findTownBeadsDir() error = %v", err) + } + if got != outerBeadsDir { + t.Fatalf("findTownBeadsDir() = %s, want %s", got, outerBeadsDir) + } +} + +func TestFindTownBeadsDir_PrefersExplicitDBPathOverBEADSDir(t *testing.T) { + outerTownDir := t.TempDir() + + outerMayorDir := filepath.Join(outerTownDir, "mayor") + if err := os.MkdirAll(outerMayorDir, 0750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(outerMayorDir, "town.json"), []byte(`{}`), 0600); err != nil { + t.Fatal(err) + } + + outerBeadsDir := filepath.Join(outerTownDir, ".beads") + if err := os.MkdirAll(outerBeadsDir, 0750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(outerBeadsDir, "routes.jsonl"), []byte(`{"prefix":"gt-","path":"rigs/outer"} +`), 0600); err != nil { + t.Fatal(err) + } + + outerRigBeadsDir := filepath.Join(outerTownDir, "outer-worktree", ".beads") + outerDBPath := filepath.Join(outerRigBeadsDir, "dolt") + writeTestMetadata(t, outerDBPath, "outer_db") + + innerTownDir := filepath.Join(outerTownDir, "nested-town") + innerMayorDir := filepath.Join(innerTownDir, "mayor") + if err := os.MkdirAll(innerMayorDir, 0750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(innerMayorDir, "town.json"), []byte(`{}`), 0600); err != nil { + t.Fatal(err) + } + + innerBeadsDir := filepath.Join(innerTownDir, ".beads") + if err := os.MkdirAll(innerBeadsDir, 0750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(innerBeadsDir, "routes.jsonl"), []byte(`{"prefix":"gt-","path":"rigs/inner"} +`), 0600); err != nil { + t.Fatal(err) + } + + workerDir := filepath.Join(innerTownDir, "crew", "worker") + if err := os.MkdirAll(workerDir, 0750); err != nil { + t.Fatal(err) + } + t.Chdir(workerDir) + t.Setenv("BEADS_DIR", innerBeadsDir) + + oldDBPath := dbPath + dbPath = outerDBPath + t.Cleanup(func() { dbPath = oldDBPath }) + + got, err := findTownBeadsDir() + if err != nil { + t.Fatalf("findTownBeadsDir() error = %v", err) + } + if got != outerBeadsDir { + t.Fatalf("findTownBeadsDir() = %s, want %s", got, outerBeadsDir) + } +} diff --git a/internal/routing/routes.go b/internal/routing/routes.go index a2045a3211..e8957c5f60 100644 --- a/internal/routing/routes.go +++ b/internal/routing/routes.go @@ -12,6 +12,7 @@ import ( "github.com/steveyegge/beads/internal/beads" "github.com/steveyegge/beads/internal/storage" "github.com/steveyegge/beads/internal/types" + "github.com/steveyegge/beads/internal/utils" ) // RoutesFileName is the name of the routes configuration file @@ -66,6 +67,30 @@ func LoadTownRoutes(beadsDir string) ([]Route, error) { return routes, nil } +// ResolveTownBeadsDir returns the authoritative routes-bearing .beads directory +// for the current routing context. +func ResolveTownBeadsDir(currentBeadsDir string) string { + if currentBeadsDir != "" { + routes, err := LoadRoutes(currentBeadsDir) + if err == nil && len(routes) > 0 { + return currentBeadsDir + } + } + + _, townRoot := findTownRoutes(currentBeadsDir) + if townRoot == "" { + return "" + } + + townBeadsDir := filepath.Join(townRoot, ".beads") + routes, err := LoadRoutes(townBeadsDir) + if err != nil || len(routes) == 0 { + return "" + } + + return townBeadsDir +} + // ExtractPrefix extracts the prefix from an issue ID. // For "gt-abc123", returns "gt-". // For "bd-abc123", returns "bd-". @@ -329,6 +354,74 @@ func findTownRootFromCWD() string { return findTownRoot(cwd) } +func canonicalizeRoutingBeadsDir(beadsDir string) string { + if beadsDir == "" { + return "" + } + return utils.NormalizePathForComparison(beads.ResolveRedirect(beadsDir).TargetDir) +} + +func findTownRootForBeadsDirFromCWD(currentBeadsDir string) string { + targetBeadsDir := canonicalizeRoutingBeadsDir(currentBeadsDir) + if targetBeadsDir == "" { + return "" + } + + cwd, err := os.Getwd() + if err != nil { + return "" + } + + current := cwd + for { + if _, err := os.Stat(filepath.Join(current, "mayor", "town.json")); err == nil { + candidateBeadsDir := filepath.Join(current, ".beads") + if info, statErr := os.Stat(candidateBeadsDir); statErr == nil && info.IsDir() { + if canonicalizeRoutingBeadsDir(candidateBeadsDir) == targetBeadsDir { + return current + } + } + } + + parent := filepath.Dir(current) + if parent == current { + return "" + } + current = parent + } +} + +func findNearestTownRoutesFromPath(startDir string) ([]Route, string) { + if startDir == "" { + return nil, "" + } + + current := startDir + for { + if _, err := os.Stat(filepath.Join(current, "mayor", "town.json")); err == nil { + townBeadsDir := filepath.Join(current, ".beads") + routes, loadErr := LoadRoutes(townBeadsDir) + if loadErr == nil && len(routes) > 0 { + return routes, current + } + } + + parent := filepath.Dir(current) + if parent == current { + return nil, "" + } + current = parent + } +} + +func findNearestTownRoutesFromCWD() ([]Route, string) { + cwd, err := os.Getwd() + if err != nil { + return nil, "" + } + return findNearestTownRoutesFromPath(cwd) +} + // findTownRoutes searches for routes.jsonl at the town level. // It walks up from currentBeadsDir to find the town root, then loads routes // from /.beads/routes.jsonl. @@ -342,18 +435,25 @@ 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 { - // 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., /.beads -> /olympus/.beads), currentBeadsDir - // will be the resolved path (e.g., /olympus/.beads) and walking up - // from there would find /olympus as the town root instead of . - townRoot := findTownRootFromCWD() + // When routes come from currentBeadsDir, the town root must belong to that + // same .beads owner. In nested town layouts, blindly using the nearest CWD + // town can pair outer routes with an inner mayor/town.json and route to the + // wrong tree. + townRoot := findTownRootForBeadsDirFromCWD(currentBeadsDir) 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) + fmt.Fprintf(os.Stderr, "[routing] findTownRoutes: found routes in %s, townRoot=%s (matched route source)\n", currentBeadsDir, townRoot) + } + return routes, townRoot + } + + if townRoot = findTownRoot(filepath.Dir(currentBeadsDir)); townRoot != "" { + if os.Getenv("BD_DEBUG_ROUTING") != "" { + fmt.Fprintf(os.Stderr, "[routing] findTownRoutes: found routes in %s, townRoot=%s (via currentBeadsDir ancestry)\n", currentBeadsDir, townRoot) } return routes, townRoot } + // Fallback to parent dir if not in a town structure (for non-orchestrator 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)) @@ -361,21 +461,29 @@ func findTownRoutes(currentBeadsDir string) ([]Route, string) { return routes, filepath.Dir(currentBeadsDir) } - // Walk up from CWD to find town root - townRoot := findTownRootFromCWD() - if townRoot == "" { - return nil, "" // Not in a town + if currentBeadsDir != "" { + routes, townRoot := findNearestTownRoutesFromPath(filepath.Dir(currentBeadsDir)) + if len(routes) > 0 && townRoot != "" { + if os.Getenv("BD_DEBUG_ROUTING") != "" { + fmt.Fprintf(os.Stderr, "[routing] findTownRoutes: loaded routes from %s, townRoot=%s (via currentBeadsDir ancestry)\n", filepath.Join(townRoot, ".beads"), townRoot) + } + return routes, townRoot + } + + if os.Getenv("BD_DEBUG_ROUTING") != "" { + fmt.Fprintf(os.Stderr, "[routing] findTownRoutes: no routes found for authoritative beads dir %s\n", currentBeadsDir) + } + return nil, "" } - // Load routes from town beads - townBeadsDir := filepath.Join(townRoot, ".beads") - routes, err = LoadRoutes(townBeadsDir) - if err != nil || len(routes) == 0 { - return nil, "" // No town routes + // Walk up town roots from CWD until we find one that actually has routes. + routes, townRoot := findNearestTownRoutesFromCWD() + if len(routes) == 0 || townRoot == "" { + return nil, "" // Not in a town } if os.Getenv("BD_DEBUG_ROUTING") != "" { - fmt.Fprintf(os.Stderr, "[routing] findTownRoutes: loaded routes from %s, townRoot=%s\n", townBeadsDir, townRoot) + fmt.Fprintf(os.Stderr, "[routing] findTownRoutes: loaded routes from %s, townRoot=%s\n", filepath.Join(townRoot, ".beads"), townRoot) } return routes, townRoot diff --git a/internal/routing/routing_test.go b/internal/routing/routing_test.go index 109b8b6709..ec4d8a2528 100644 --- a/internal/routing/routing_test.go +++ b/internal/routing/routing_test.go @@ -738,3 +738,289 @@ func TestResolveBeadsDirForID_FollowsRelativeRedirectFromRigRoot(t *testing.T) { t.Errorf("ResolveBeadsDirForID() should resolve redirect relative to rig root:\n got: %s\n want: %s", resolvedDir, actualBeadsDir) } } + +func TestResolveTownBeadsDir_PrefersCurrentBeadsDirOverNestedTownCWD(t *testing.T) { + tmpDir := t.TempDir() + + outerMayorDir := filepath.Join(tmpDir, "mayor") + if err := os.MkdirAll(outerMayorDir, 0750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(outerMayorDir, "town.json"), []byte(`{}`), 0600); err != nil { + t.Fatal(err) + } + + outerTownBeadsDir := filepath.Join(tmpDir, ".beads") + if err := os.MkdirAll(outerTownBeadsDir, 0750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(outerTownBeadsDir, "routes.jsonl"), []byte(`{"prefix":"gt-","path":"rigs/outer"} +`), 0600); err != nil { + t.Fatal(err) + } + + outerRigBeadsDir := filepath.Join(tmpDir, "outer-worktree", ".beads") + if err := os.MkdirAll(outerRigBeadsDir, 0750); err != nil { + t.Fatal(err) + } + + innerTownDir := filepath.Join(tmpDir, "nested-town") + innerMayorDir := filepath.Join(innerTownDir, "mayor") + if err := os.MkdirAll(innerMayorDir, 0750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(innerMayorDir, "town.json"), []byte(`{}`), 0600); err != nil { + t.Fatal(err) + } + + innerBeadsDir := filepath.Join(innerTownDir, ".beads") + if err := os.MkdirAll(innerBeadsDir, 0750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(innerBeadsDir, "routes.jsonl"), []byte(`{"prefix":"gt-","path":"rigs/inner"} +`), 0600); err != nil { + t.Fatal(err) + } + + workerDir := filepath.Join(innerTownDir, "crew", "worker") + if err := os.MkdirAll(workerDir, 0750); err != nil { + t.Fatal(err) + } + t.Chdir(workerDir) + + if got := ResolveTownBeadsDir(outerRigBeadsDir); got != outerTownBeadsDir { + t.Fatalf("ResolveTownBeadsDir() = %s, want %s", got, outerTownBeadsDir) + } +} + +func setupNestedTownRoutingFixture(t *testing.T) (string, string) { + t.Helper() + + tmpDir := t.TempDir() + + tmpDir, err := filepath.EvalSymlinks(tmpDir) + if err != nil { + t.Fatal(err) + } + + outerTownBeadsDir := filepath.Join(tmpDir, ".beads") + if err := os.MkdirAll(outerTownBeadsDir, 0750); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(tmpDir, "mayor"), 0750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(tmpDir, "mayor", "town.json"), []byte(`{"name":"outer-town"}`), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(outerTownBeadsDir, "routes.jsonl"), []byte(`{"prefix":"crom-","path":"crom"}`+"\n"), 0600); err != nil { + t.Fatal(err) + } + + outerTarget := filepath.Join(tmpDir, "crom", ".beads") + if err := os.MkdirAll(outerTarget, 0750); err != nil { + t.Fatal(err) + } + + outerRigBeadsDir := filepath.Join(tmpDir, "outer-worktree", ".beads") + if err := os.MkdirAll(outerRigBeadsDir, 0750); err != nil { + t.Fatal(err) + } + + nestedTownRoot := filepath.Join(tmpDir, "beads") + if err := os.MkdirAll(filepath.Join(nestedTownRoot, "mayor"), 0750); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(nestedTownRoot, ".beads"), 0750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(nestedTownRoot, "mayor", "town.json"), []byte(`{"name":"inner-town"}`), 0600); err != nil { + t.Fatal(err) + } + + innerTarget := filepath.Join(nestedTownRoot, "crom", ".beads") + if err := os.MkdirAll(innerTarget, 0750); err != nil { + t.Fatal(err) + } + + worktreeDir := filepath.Join(nestedTownRoot, "polecats", "obsidian", "beads") + if err := os.MkdirAll(worktreeDir, 0750); err != nil { + t.Fatal(err) + } + t.Chdir(worktreeDir) + + return outerRigBeadsDir, outerTarget +} + +func TestResolveBeadsDirForRig_UsesOuterTownRootFromNestedTownCWD(t *testing.T) { + outerBeadsDir, outerTarget := setupNestedTownRoutingFixture(t) + + resolvedDir, prefix, err := ResolveBeadsDirForRig("crom-", outerBeadsDir) + if err != nil { + t.Fatalf("ResolveBeadsDirForRig() error = %v", err) + } + if prefix != "crom-" { + t.Fatalf("ResolveBeadsDirForRig() prefix = %q, want %q", prefix, "crom-") + } + + resolvedResolved, _ := filepath.EvalSymlinks(resolvedDir) + outerResolved, _ := filepath.EvalSymlinks(outerTarget) + if resolvedResolved != outerResolved { + t.Errorf("ResolveBeadsDirForRig() should use outer town root:\n got: %s\n want: %s", resolvedDir, outerTarget) + } +} + +func TestResolveBeadsDirForID_UsesOuterTownRootFromNestedTownCWD(t *testing.T) { + outerBeadsDir, outerTarget := setupNestedTownRoutingFixture(t) + + ctx := context.Background() + resolvedDir, routed, err := ResolveBeadsDirForID(ctx, "crom-abc123", outerBeadsDir) + if err != nil { + t.Fatalf("ResolveBeadsDirForID() error = %v", err) + } + if !routed { + t.Fatal("ResolveBeadsDirForID() routed = false, want true") + } + + resolvedResolved, _ := filepath.EvalSymlinks(resolvedDir) + outerResolved, _ := filepath.EvalSymlinks(outerTarget) + if resolvedResolved != outerResolved { + t.Errorf("ResolveBeadsDirForID() should use outer town root:\n got: %s\n want: %s", resolvedDir, outerTarget) + } +} + +func TestResolveBeadsDirForID_DoesNotUseCWDRoutesWhenCurrentBeadsDirIsAuthoritative(t *testing.T) { + tmpDir := t.TempDir() + + authoritativeBeadsDir := filepath.Join(tmpDir, "explicit", ".beads") + if err := os.MkdirAll(authoritativeBeadsDir, 0750); err != nil { + t.Fatal(err) + } + + nestedTownDir := filepath.Join(tmpDir, "nested-town") + if err := os.MkdirAll(filepath.Join(nestedTownDir, "mayor"), 0750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(nestedTownDir, "mayor", "town.json"), []byte(`{"name":"inner-town"}`), 0600); err != nil { + t.Fatal(err) + } + innerBeadsDir := filepath.Join(nestedTownDir, ".beads") + if err := os.MkdirAll(innerBeadsDir, 0750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(innerBeadsDir, "routes.jsonl"), []byte(`{"prefix":"crom-","path":"crom"}`+"\n"), 0600); err != nil { + t.Fatal(err) + } + innerTarget := filepath.Join(nestedTownDir, "crom", ".beads") + if err := os.MkdirAll(innerTarget, 0750); err != nil { + t.Fatal(err) + } + + workerDir := filepath.Join(nestedTownDir, "crew", "worker") + if err := os.MkdirAll(workerDir, 0750); err != nil { + t.Fatal(err) + } + t.Chdir(workerDir) + + ctx := context.Background() + resolvedDir, routed, err := ResolveBeadsDirForID(ctx, "crom-abc123", authoritativeBeadsDir) + if err != nil { + t.Fatalf("ResolveBeadsDirForID() error = %v", err) + } + if routed { + t.Fatal("ResolveBeadsDirForID() routed = true, want false") + } + if resolvedDir != authoritativeBeadsDir { + t.Fatalf("ResolveBeadsDirForID() = %s, want %s", resolvedDir, authoritativeBeadsDir) + } + if got := ResolveTownBeadsDir(authoritativeBeadsDir); got != "" { + t.Fatalf("ResolveTownBeadsDir() = %s, want empty string", got) + } + if innerTarget == resolvedDir { + t.Fatal("ResolveBeadsDirForID() should not drift to CWD town routes") + } +} + +func TestResolveBeadsDirForID_UsesOuterTownRootWithNestedTownAndRedirectedRouteSource(t *testing.T) { + tmpDir := t.TempDir() + + tmpDir, err := filepath.EvalSymlinks(tmpDir) + if err != nil { + t.Fatal(err) + } + + if err := os.MkdirAll(filepath.Join(tmpDir, "mayor"), 0750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(tmpDir, "mayor", "town.json"), []byte(`{"name":"outer-town"}`), 0600); err != nil { + t.Fatal(err) + } + + routerBeadsDir := filepath.Join(tmpDir, "router", ".beads") + if err := os.MkdirAll(routerBeadsDir, 0750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(routerBeadsDir, "routes.jsonl"), []byte(`{"prefix":"crom-","path":"crom"}`+"\n"), 0600); err != nil { + t.Fatal(err) + } + + if err := os.Symlink(routerBeadsDir, filepath.Join(tmpDir, ".beads")); err != nil { + t.Skip("Cannot create symlinks on this system") + } + + outerStubBeadsDir := filepath.Join(tmpDir, "crom", ".beads") + if err := os.MkdirAll(outerStubBeadsDir, 0750); err != nil { + t.Fatal(err) + } + outerActualTarget := filepath.Join(tmpDir, "crom", "mayor", "rig", ".beads") + if err := os.MkdirAll(outerActualTarget, 0750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(outerStubBeadsDir, "redirect"), []byte("mayor/rig/.beads\n"), 0600); err != nil { + t.Fatal(err) + } + + nestedTownDir := filepath.Join(tmpDir, "beads") + if err := os.MkdirAll(filepath.Join(nestedTownDir, "mayor"), 0750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(nestedTownDir, "mayor", "town.json"), []byte(`{"name":"inner-town"}`), 0600); err != nil { + t.Fatal(err) + } + innerBeadsDir := filepath.Join(nestedTownDir, ".beads") + if err := os.MkdirAll(innerBeadsDir, 0750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(innerBeadsDir, "routes.jsonl"), []byte(`{"prefix":"crom-","path":"crom"}`+"\n"), 0600); err != nil { + t.Fatal(err) + } + innerTarget := filepath.Join(nestedTownDir, "crom", ".beads") + if err := os.MkdirAll(innerTarget, 0750); err != nil { + t.Fatal(err) + } + + worktreeDir := filepath.Join(nestedTownDir, "polecats", "obsidian", "beads") + if err := os.MkdirAll(worktreeDir, 0750); err != nil { + t.Fatal(err) + } + t.Chdir(worktreeDir) + + ctx := context.Background() + resolvedDir, routed, err := ResolveBeadsDirForID(ctx, "crom-abc123", routerBeadsDir) + if err != nil { + t.Fatalf("ResolveBeadsDirForID() error = %v", err) + } + if !routed { + t.Fatal("ResolveBeadsDirForID() routed = false, want true") + } + + resolvedResolved, _ := filepath.EvalSymlinks(resolvedDir) + outerResolved, _ := filepath.EvalSymlinks(outerActualTarget) + innerResolved, _ := filepath.EvalSymlinks(innerTarget) + if resolvedResolved != outerResolved { + t.Fatalf("ResolveBeadsDirForID() should use outer redirected target:\n got: %s\n want: %s", resolvedDir, outerActualTarget) + } + if resolvedResolved == innerResolved { + t.Fatal("ResolveBeadsDirForID() drifted to nested CWD town root") + } +}