Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 10 additions & 22 deletions cmd/bd/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.
Expand Down
140 changes: 140 additions & 0 deletions cmd/bd/create_routing_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package main

import (
"context"
"os"
"path/filepath"
"testing"

Expand Down Expand Up @@ -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)
}
}
142 changes: 125 additions & 17 deletions internal/routing/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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-".
Expand Down Expand Up @@ -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 <townRoot>/.beads/routes.jsonl.
Expand All @@ -342,40 +435,55 @@ 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., <town>/.beads -> <town>/olympus/.beads), currentBeadsDir
// will be the resolved path (e.g., <town>/olympus/.beads) and walking up
// from there would find <town>/olympus as the town root instead of <town>.
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))
}
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
Expand Down
Loading