Skip to content
Closed
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
2 changes: 1 addition & 1 deletion cmd/bd/import.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
20 changes: 20 additions & 0 deletions cmd/bd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 8 additions & 2 deletions cmd/bd/routed.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
61 changes: 61 additions & 0 deletions internal/beads/beads.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ package beads

import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
Expand All @@ -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.
Expand Down
231 changes: 231 additions & 0 deletions internal/beads/beads_test.go
Original file line number Diff line number Diff line change
@@ -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"
)

Expand Down Expand Up @@ -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")
}
}
Loading
Loading