diff --git a/cmd/bd/import.go b/cmd/bd/import.go index 0c0c298e32..1f57cdd4dd 100644 --- a/cmd/bd/import.go +++ b/cmd/bd/import.go @@ -26,7 +26,7 @@ EXAMPLES: bd import backup.jsonl # Import from a specific file bd import --dry-run # Show what would be imported`, GroupID: "sync", - RunE: runImport, + RunE: runImport, } var ( diff --git a/cmd/bd/main.go b/cmd/bd/main.go index 7588a14841..1bdc24758e 100644 --- a/cmd/bd/main.go +++ b/cmd/bd/main.go @@ -412,6 +412,26 @@ var rootCmd = &cobra.Command{ } } + // Capture redirect info BEFORE FindDatabasePath() follows the redirect. + // When .beads/redirect points to a shared directory with a different + // dolt_database, the source's database name would be lost. Capture it + // early and set BEADS_DOLT_SERVER_DATABASE so all store opens use it. + redirectInfo := beads.GetRedirectInfo() + var sourceDoltDatabase string + if redirectInfo.IsRedirected && redirectInfo.LocalDir != "" { + rInfo := beads.ResolveRedirect(redirectInfo.LocalDir) + sourceDoltDatabase = rInfo.SourceDatabase + } + // Set env var early so ALL store opens (main + routed) use the correct + // database. Redirects may resolve to a shared .beads dir that serves + // multiple databases; the env var ensures the right one is selected. + if sourceDoltDatabase != "" && os.Getenv("BEADS_DOLT_SERVER_DATABASE") == "" { + _ = os.Setenv("BEADS_DOLT_SERVER_DATABASE", sourceDoltDatabase) + if os.Getenv("BD_DEBUG_ROUTING") != "" { + fmt.Fprintf(os.Stderr, "[routing] Preserved source dolt_database %q across redirect\n", sourceDoltDatabase) + } + } + // Initialize database path if dbPath == "" { // Use public API to find database (same logic as extensions) diff --git a/cmd/bd/routed.go b/cmd/bd/routed.go index 8558eee7e9..ecd385b443 100644 --- a/cmd/bd/routed.go +++ b/cmd/bd/routed.go @@ -78,7 +78,10 @@ func resolveAndGetIssueWithRouting(ctx context.Context, localStore *dolt.DoltSto // Check if this ID routes to a different beads directory. beadsDir := filepath.Dir(dbPath) targetDir, routed, routeErr := routing.ResolveBeadsDirForID(ctx, id, beadsDir) - routesDifferently := routeErr == nil && routed && targetDir != beadsDir + // Redirects may resolve back to the same .beads directory but with a different + // dolt_database. ResolveBeadsDirForID sets BEADS_DOLT_SERVER_DATABASE when a + // redirect changes the database, so we check for that too. + routesDifferently := routeErr == nil && routed && (targetDir != beadsDir || os.Getenv("BEADS_DOLT_SERVER_DATABASE") != "") // When routing says this ID belongs to a different database, go directly // to the routed store. Checking the local store first would risk finding @@ -204,7 +207,10 @@ func getIssueWithRouting(ctx context.Context, localStore *dolt.DoltStore, id str // Check if this ID routes to a different beads directory. beadsDir := filepath.Dir(dbPath) targetDir, routed, routeErr := routing.ResolveBeadsDirForID(ctx, id, beadsDir) - routesDifferently := routeErr == nil && routed && targetDir != beadsDir + // Redirects may resolve back to the same .beads directory but with a different + // dolt_database. ResolveBeadsDirForID sets BEADS_DOLT_SERVER_DATABASE when a + // redirect changes the database, so we check for that too. + routesDifferently := routeErr == nil && routed && (targetDir != beadsDir || os.Getenv("BEADS_DOLT_SERVER_DATABASE") != "") if routesDifferently { routedStore, err := dolt.NewFromConfig(ctx, targetDir) diff --git a/internal/beads/beads.go b/internal/beads/beads.go index a50f6f66cc..c045d6ea42 100644 --- a/internal/beads/beads.go +++ b/internal/beads/beads.go @@ -9,6 +9,7 @@ package beads import ( "context" + "encoding/json" "fmt" "os" "os/exec" @@ -29,6 +30,66 @@ const CanonicalDatabaseName = "beads.db" // RedirectFileName is the name of the file that redirects to another .beads directory const RedirectFileName = "redirect" +// SourceDatabaseInfo contains the dolt_database name from a source .beads/metadata.json, +// preserved across a redirect so that the source directory's database identity is not +// lost when the redirect target has a different dolt_database. +// +// When a .beads/redirect points to a shared .beads directory that serves multiple +// databases, the source's metadata.json may specify a different dolt_database than +// the target's. This struct captures the source database name so callers can +// restore it after redirect resolution. +type SourceDatabaseInfo struct { + // SourceDir is the original .beads directory (before redirect) + SourceDir string + // TargetDir is the resolved .beads directory (after redirect) + TargetDir string + // WasRedirected is true if a redirect was followed + WasRedirected bool + // SourceDatabase is dolt_database from the source metadata.json (raw field, + // NOT the env-var-aware GetDoltDatabase()). Empty if no source metadata exists + // or the source has no dolt_database configured. + SourceDatabase string +} + +// ResolveRedirect follows a .beads/redirect file and captures the source directory's +// dolt_database from metadata.json BEFORE following the redirect. This preserves +// the source database identity across redirects. +// +// The env var BEADS_DOLT_SERVER_DATABASE still takes highest priority (handled by +// GetDoltDatabase() in callers). This function only captures the raw config field +// so callers can use it as an override when the env var is not set. +// +// Returns SourceDatabaseInfo with WasRedirected=true if a redirect was followed, +// and SourceDatabase set to the source's dolt_database (if any). +func ResolveRedirect(beadsDir string) SourceDatabaseInfo { + info := SourceDatabaseInfo{ + SourceDir: beadsDir, + TargetDir: beadsDir, + } + + // Read source metadata.json directly (NOT via configfile.Load which may trigger + // Dolt connections or recursive FollowRedirect calls causing deadlocks). + // We only need the raw dolt_database field. + metadataPath := filepath.Join(beadsDir, "metadata.json") + if data, err := os.ReadFile(metadataPath); err == nil { + var raw struct { + DoltDatabase string `json:"dolt_database"` + } + if json.Unmarshal(data, &raw) == nil { + info.SourceDatabase = raw.DoltDatabase + } + } + + // Follow redirect + resolved := FollowRedirect(beadsDir) + if resolved != beadsDir { + info.WasRedirected = true + info.TargetDir = resolved + } + + return info +} + // FollowRedirect checks if a .beads directory contains a redirect file and follows it. // If a redirect file exists, it returns the target .beads directory path. // If no redirect exists or there's an error, it returns the original path unchanged. diff --git a/internal/beads/beads_test.go b/internal/beads/beads_test.go index 989a21e7ba..0e2b8caf33 100644 --- a/internal/beads/beads_test.go +++ b/internal/beads/beads_test.go @@ -1,12 +1,14 @@ package beads import ( + "encoding/json" "os" "os/exec" "path/filepath" "strings" "testing" + "github.com/steveyegge/beads/internal/configfile" "github.com/steveyegge/beads/internal/git" ) @@ -1813,3 +1815,232 @@ func TestFindDatabasePath_WorktreeNoLocalDB(t *testing.T) { t.Errorf("FindDatabasePath() = %q, want main repo shared db %q", result, mainDoltDir) } } + +// writeMetadataJSON writes a metadata.json file to the given .beads directory. +func writeMetadataJSON(t *testing.T, beadsDir string, cfg *configfile.Config) { + t.Helper() + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + t.Fatalf("failed to marshal metadata.json: %v", err) + } + if err := os.WriteFile(filepath.Join(beadsDir, "metadata.json"), data, 0644); err != nil { + t.Fatalf("failed to write metadata.json: %v", err) + } +} + +// TestResolveRedirect_PreservesSourceDatabase tests that ResolveRedirect captures +// the source rig's dolt_database from metadata.json before following a redirect. +// When a source directory has a redirect to a shared directory with a different +// dolt_database, the source database name must be preserved. +func TestResolveRedirect_PreservesSourceDatabase(t *testing.T) { + tmpDir := t.TempDir() + // Resolve symlinks for macOS /private/var path consistency + tmpDir, _ = filepath.EvalSymlinks(tmpDir) + + // Create source .beads with dolt_database: "lola" + sourceDir := filepath.Join(tmpDir, "lola", ".beads") + if err := os.MkdirAll(sourceDir, 0755); err != nil { + t.Fatal(err) + } + writeMetadataJSON(t, sourceDir, &configfile.Config{ + Database: "beads.db", + DoltMode: "server", + DoltDatabase: "lola", + }) + + // Create target (shared) .beads with dolt_database: "hq" + targetDir := filepath.Join(tmpDir, "town", ".beads") + if err := os.MkdirAll(targetDir, 0755); err != nil { + t.Fatal(err) + } + writeMetadataJSON(t, targetDir, &configfile.Config{ + Database: "beads.db", + DoltMode: "server", + DoltDatabase: "hq", + }) + + // Write redirect from source to target + if err := os.WriteFile(filepath.Join(sourceDir, "redirect"), []byte(targetDir+"\n"), 0644); err != nil { + t.Fatal(err) + } + + info := ResolveRedirect(sourceDir) + + // Verify redirect was followed + if !info.WasRedirected { + t.Error("expected WasRedirected=true") + } + + // Verify source database is preserved + if info.SourceDatabase != "lola" { + t.Errorf("SourceDatabase = %q, want %q", info.SourceDatabase, "lola") + } + + // Verify source and target dirs are correct + sourceResolved, _ := filepath.EvalSymlinks(sourceDir) + targetResolved, _ := filepath.EvalSymlinks(targetDir) + infoSourceResolved, _ := filepath.EvalSymlinks(info.SourceDir) + infoTargetResolved, _ := filepath.EvalSymlinks(info.TargetDir) + + if infoSourceResolved != sourceResolved { + t.Errorf("SourceDir = %q, want %q", info.SourceDir, sourceDir) + } + if infoTargetResolved != targetResolved { + t.Errorf("TargetDir = %q, want %q", info.TargetDir, targetDir) + } +} + +// TestResolveRedirect_NoRedirect tests that ResolveRedirect works correctly when +// there is no redirect file (source and target are the same). +func TestResolveRedirect_NoRedirect(t *testing.T) { + tmpDir := t.TempDir() + tmpDir, _ = filepath.EvalSymlinks(tmpDir) + + beadsDir := filepath.Join(tmpDir, ".beads") + if err := os.MkdirAll(beadsDir, 0755); err != nil { + t.Fatal(err) + } + writeMetadataJSON(t, beadsDir, &configfile.Config{ + Database: "beads.db", + DoltDatabase: "mydb", + }) + + info := ResolveRedirect(beadsDir) + + if info.WasRedirected { + t.Error("expected WasRedirected=false when no redirect file exists") + } + if info.SourceDatabase != "mydb" { + t.Errorf("SourceDatabase = %q, want %q", info.SourceDatabase, "mydb") + } + + beadsDirResolved, _ := filepath.EvalSymlinks(beadsDir) + infoSourceResolved, _ := filepath.EvalSymlinks(info.SourceDir) + infoTargetResolved, _ := filepath.EvalSymlinks(info.TargetDir) + + if infoSourceResolved != beadsDirResolved { + t.Errorf("SourceDir = %q, want %q", info.SourceDir, beadsDir) + } + if infoTargetResolved != beadsDirResolved { + t.Errorf("TargetDir = %q, want same as source %q when no redirect", info.TargetDir, beadsDir) + } +} + +// TestResolveRedirect_NoSourceMetadata tests that ResolveRedirect handles a source +// directory with no metadata.json (SourceDatabase should be empty). +func TestResolveRedirect_NoSourceMetadata(t *testing.T) { + tmpDir := t.TempDir() + tmpDir, _ = filepath.EvalSymlinks(tmpDir) + + sourceDir := filepath.Join(tmpDir, "rig", ".beads") + if err := os.MkdirAll(sourceDir, 0755); err != nil { + t.Fatal(err) + } + // No metadata.json in source + + targetDir := filepath.Join(tmpDir, "town", ".beads") + if err := os.MkdirAll(targetDir, 0755); err != nil { + t.Fatal(err) + } + writeMetadataJSON(t, targetDir, &configfile.Config{ + Database: "beads.db", + DoltDatabase: "hq", + }) + + // Write redirect from source to target + if err := os.WriteFile(filepath.Join(sourceDir, "redirect"), []byte(targetDir+"\n"), 0644); err != nil { + t.Fatal(err) + } + + info := ResolveRedirect(sourceDir) + + if !info.WasRedirected { + t.Error("expected WasRedirected=true") + } + if info.SourceDatabase != "" { + t.Errorf("SourceDatabase = %q, want empty string when no source metadata", info.SourceDatabase) + } +} + +// TestResolveRedirect_SourceHasNoDoltDatabase tests that ResolveRedirect handles +// a source whose metadata.json exists but has no dolt_database field. +func TestResolveRedirect_SourceHasNoDoltDatabase(t *testing.T) { + tmpDir := t.TempDir() + tmpDir, _ = filepath.EvalSymlinks(tmpDir) + + sourceDir := filepath.Join(tmpDir, "rig", ".beads") + if err := os.MkdirAll(sourceDir, 0755); err != nil { + t.Fatal(err) + } + // Source metadata has no dolt_database + writeMetadataJSON(t, sourceDir, &configfile.Config{ + Database: "beads.db", + }) + + targetDir := filepath.Join(tmpDir, "town", ".beads") + if err := os.MkdirAll(targetDir, 0755); err != nil { + t.Fatal(err) + } + writeMetadataJSON(t, targetDir, &configfile.Config{ + Database: "beads.db", + DoltDatabase: "hq", + }) + + if err := os.WriteFile(filepath.Join(sourceDir, "redirect"), []byte(targetDir+"\n"), 0644); err != nil { + t.Fatal(err) + } + + info := ResolveRedirect(sourceDir) + + if !info.WasRedirected { + t.Error("expected WasRedirected=true") + } + // No dolt_database in source => SourceDatabase should be empty + // This means the target's dolt_database will be used (no override) + if info.SourceDatabase != "" { + t.Errorf("SourceDatabase = %q, want empty string when source has no dolt_database", info.SourceDatabase) + } +} + +// TestResolveRedirect_SourceDatabaseAvailableForRouting tests that ResolveRedirect +// captures the source database so callers (like routing code) can set the env var. +// The env var is NOT set by FollowRedirect itself (that caused hangs from circular +// configfile.Load calls). Instead, routing callers use ResolveRedirect and set +// BEADS_DOLT_SERVER_DATABASE explicitly. +func TestResolveRedirect_SourceDatabaseAvailableForRouting(t *testing.T) { + tmpDir := t.TempDir() + tmpDir, _ = filepath.EvalSymlinks(tmpDir) + + // Create source .beads with dolt_database: "lola" + sourceDir := filepath.Join(tmpDir, "lola", ".beads") + if err := os.MkdirAll(sourceDir, 0755); err != nil { + t.Fatal(err) + } + writeMetadataJSON(t, sourceDir, &configfile.Config{ + DoltDatabase: "lola", + }) + + // Create target (shared) .beads with dolt_database: "hq" + targetDir := filepath.Join(tmpDir, "town", ".beads") + if err := os.MkdirAll(targetDir, 0755); err != nil { + t.Fatal(err) + } + writeMetadataJSON(t, targetDir, &configfile.Config{ + DoltDatabase: "hq", + }) + + // Write redirect + if err := os.WriteFile(filepath.Join(sourceDir, "redirect"), []byte(targetDir+"\n"), 0644); err != nil { + t.Fatal(err) + } + + info := ResolveRedirect(sourceDir) + + // Source database should be available for routing callers to use + if info.SourceDatabase != "lola" { + t.Errorf("SourceDatabase = %q, want %q", info.SourceDatabase, "lola") + } + if !info.WasRedirected { + t.Error("expected WasRedirected=true") + } +} diff --git a/internal/routing/routes.go b/internal/routing/routes.go index 4eb46623ae..5e4580a196 100644 --- a/internal/routing/routes.go +++ b/internal/routing/routes.go @@ -181,8 +181,15 @@ func ResolveBeadsDirForRig(rigOrPrefix, currentBeadsDir string) (beadsDir string targetPath = filepath.Join(townRoot, route.Path, ".beads") } - // Follow redirect if present - targetPath = beads.FollowRedirect(targetPath) + // Follow redirect, preserving source dolt_database + rInfo := beads.ResolveRedirect(targetPath) + targetPath = rInfo.TargetDir + if rInfo.WasRedirected && rInfo.SourceDatabase != "" && os.Getenv("BEADS_DOLT_SERVER_DATABASE") == "" { + _ = os.Setenv("BEADS_DOLT_SERVER_DATABASE", rInfo.SourceDatabase) + if os.Getenv("BD_DEBUG_ROUTING") != "" { + fmt.Fprintf(os.Stderr, "[routing] Preserved source dolt_database %q across redirect for rig %q\n", rInfo.SourceDatabase, rigOrPrefix) + } + } // Verify the target exists if info, statErr := os.Stat(targetPath); statErr != nil || !info.IsDir() { @@ -231,6 +238,10 @@ func ResolveToExternalRef(id, beadsDir string) string { // It first checks the local beads directory, then consults routes.jsonl for prefix-based routing. // If routes.jsonl is not found locally, it searches up to the town root. // +// When a redirect is followed, the source directory's dolt_database is preserved via +// BEADS_DOLT_SERVER_DATABASE env var so GetDoltDatabase() picks it up automatically. +// This prevents the redirect target's database from overriding the source's. +// // Parameters: // - ctx: context for database operations // - id: the issue ID to look up @@ -259,8 +270,15 @@ func ResolveBeadsDirForID(ctx context.Context, id, currentBeadsDir string) (stri targetPath = filepath.Join(townRoot, route.Path, ".beads") } - // Follow redirect if present - targetPath = beads.FollowRedirect(targetPath) + // Follow redirect, preserving source dolt_database + rInfo := beads.ResolveRedirect(targetPath) + targetPath = rInfo.TargetDir + if rInfo.WasRedirected && rInfo.SourceDatabase != "" && os.Getenv("BEADS_DOLT_SERVER_DATABASE") == "" { + _ = os.Setenv("BEADS_DOLT_SERVER_DATABASE", rInfo.SourceDatabase) + if os.Getenv("BD_DEBUG_ROUTING") != "" { + fmt.Fprintf(os.Stderr, "[routing] Preserved source dolt_database %q across redirect for %s\n", rInfo.SourceDatabase, id) + } + } // Verify the target exists if info, err := os.Stat(targetPath); err == nil && info.IsDir() { diff --git a/internal/storage/dolt/store.go b/internal/storage/dolt/store.go index 02417fe8b4..3e9f22d4af 100644 --- a/internal/storage/dolt/store.go +++ b/internal/storage/dolt/store.go @@ -1844,7 +1844,6 @@ func (s *DoltStore) tryAutoResolveMetadataConflicts(ctx context.Context, tx *sql return true, nil } - // Branch creates a new branch func (s *DoltStore) Branch(ctx context.Context, name string) (retErr error) { ctx, span := doltTracer.Start(ctx, "dolt.branch",