diff --git a/cmd/bd/context_binding_integration_test.go b/cmd/bd/context_binding_integration_test.go new file mode 100644 index 0000000000..3ae4afe9b2 --- /dev/null +++ b/cmd/bd/context_binding_integration_test.go @@ -0,0 +1,143 @@ +//go:build cgo + +package main + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/steveyegge/beads/internal/configfile" + "github.com/steveyegge/beads/internal/storage/dolt" + "github.com/steveyegge/beads/internal/types" +) + +func filteredEnvForContextBinding(keys ...string) []string { + strip := make(map[string]struct{}, len(keys)) + for _, key := range keys { + strip[key+"="] = struct{}{} + } + + env := os.Environ() + filtered := make([]string, 0, len(env)) + for _, entry := range env { + if strings.HasPrefix(entry, "BEADS_") || strings.HasPrefix(entry, "BD_") { + continue + } + trim := false + for prefix := range strip { + if strings.HasPrefix(entry, prefix) { + trim = true + break + } + } + if !trim { + filtered = append(filtered, entry) + } + } + return filtered +} + +func TestListExplicitDBPathRebindsTargetContext(t *testing.T) { + if testDoltServerPort == 0 { + t.Skip("Dolt test server not available, skipping") + } + + tmpDir := t.TempDir() + callerRepo := filepath.Join(tmpDir, "caller") + callerBeadsDir := filepath.Join(callerRepo, ".beads") + writeTestConfigYAML(t, callerBeadsDir, "dolt.auto-commit: invalid\nactor: caller-actor\n") + if err := os.WriteFile(filepath.Join(callerBeadsDir, ".env"), []byte("BEADS_DOLT_SERVER_PORT=1\n"), 0o600); err != nil { + t.Fatalf("write caller .env: %v", err) + } + + targetRepo := filepath.Join(tmpDir, "target") + targetBeadsDir := filepath.Join(targetRepo, ".beads") + writeTestConfigYAML(t, targetBeadsDir, "dolt.auto-commit: off\nactor: target-actor\n") + database := uniqueTestDBName(t) + if err := (&configfile.Config{ + Backend: configfile.BackendDolt, + DoltMode: configfile.DoltModeServer, + DoltServerHost: "127.0.0.1", + DoltServerPort: testDoltServerPort, + DoltDatabase: database, + }).Save(targetBeadsDir); err != nil { + t.Fatalf("save target metadata: %v", err) + } + + ctx := context.Background() + testStore, err := dolt.New(ctx, &dolt.Config{ + Path: filepath.Join(targetBeadsDir, "dolt"), + BeadsDir: targetBeadsDir, + ServerHost: "127.0.0.1", + ServerPort: testDoltServerPort, + Database: database, + CreateIfMissing: true, + }) + if err != nil { + t.Fatalf("create test store: %v", err) + } + defer func() { + _ = testStore.Close() + dropTestDatabase(database, testDoltServerPort) + }() + if err := testStore.SetConfig(ctx, "issue_prefix", "ctx"); err != nil { + t.Fatalf("set issue_prefix: %v", err) + } + now := time.Now() + nowIssue := &types.Issue{ + ID: "ctx-1", + Title: "Context binding proof", + Description: "Proves explicit --db commands use the target workspace config", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + CreatedAt: now, + UpdatedAt: now, + } + if err := testStore.CreateIssue(ctx, nowIssue, "test-user"); err != nil { + t.Fatalf("create issue: %v", err) + } + + binPath := filepath.Join(t.TempDir(), "bd-under-test") + packageDir, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + buildCmd := exec.Command("go", "build", "-o", binPath, ".") + buildCmd.Dir = packageDir + buildOut, err := buildCmd.CombinedOutput() + if err != nil { + t.Fatalf("go build failed: %v\n%s", err, buildOut) + } + + listCmd := exec.Command(binPath, "list", "--db", filepath.Join(targetBeadsDir, "dolt"), "--json") + listCmd.Dir = callerRepo + listCmd.Env = append(filteredEnvForContextBinding("BEADS_DIR", "BEADS_DB", "BD_DB", "BEADS_DOLT_SERVER_PORT", "BEADS_DOLT_SERVER_DATABASE"), + "HOME="+t.TempDir(), + "XDG_CONFIG_HOME="+t.TempDir(), + "BEADS_TEST_MODE=1", + "BEADS_DIR="+callerBeadsDir, + "BEADS_DB=", + ) + output, err := listCmd.CombinedOutput() + if err != nil { + t.Fatalf("bd list failed: %v\n%s", err, output) + } + if !strings.Contains(string(output), "Context binding proof") { + t.Fatalf("expected list output to include target issue\n%s", output) + } + + if _, err := os.Stat(filepath.Join(callerBeadsDir, localVersionFile)); err == nil { + t.Fatalf("caller workspace unexpectedly created %s", filepath.Join(callerBeadsDir, localVersionFile)) + } else if !os.IsNotExist(err) { + t.Fatalf("stat caller %s: %v", localVersionFile, err) + } + if _, err := os.Stat(filepath.Join(targetBeadsDir, localVersionFile)); err != nil { + t.Fatalf("target workspace should create %s: %v", localVersionFile, err) + } +} diff --git a/cmd/bd/context_binding_test.go b/cmd/bd/context_binding_test.go new file mode 100644 index 0000000000..9302203a46 --- /dev/null +++ b/cmd/bd/context_binding_test.go @@ -0,0 +1,187 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + + "github.com/steveyegge/beads/internal/config" + "github.com/steveyegge/beads/internal/configfile" + "github.com/steveyegge/beads/internal/doltserver" +) + +func writeTestConfigYAML(t *testing.T, beadsDir, contents string) { + t.Helper() + if err := os.MkdirAll(beadsDir, 0o700); err != nil { + t.Fatalf("mkdir beads dir: %v", err) + } + if err := os.WriteFile(filepath.Join(beadsDir, "config.yaml"), []byte(contents), 0o600); err != nil { + t.Fatalf("write config.yaml: %v", err) + } +} + +type flagSnapshot struct { + value string + changed bool +} + +func snapshotRootFlagState() map[string]flagSnapshot { + state := map[string]flagSnapshot{} + for _, name := range []string{"db", "json", "format", "readonly", "actor", "dolt-auto-commit"} { + flag := rootCmd.PersistentFlags().Lookup(name) + if flag == nil { + continue + } + state[name] = flagSnapshot{value: flag.Value.String(), changed: flag.Changed} + } + return state +} + +func restoreRootFlagState(t *testing.T, state map[string]flagSnapshot) { + t.Helper() + for name, snapshot := range state { + flag := rootCmd.PersistentFlags().Lookup(name) + if flag == nil { + continue + } + if err := flag.Value.Set(snapshot.value); err != nil { + t.Fatalf("restore %s flag: %v", name, err) + } + flag.Changed = snapshot.changed + } +} + +func TestPrepareSelectedCommandContext_RebindsTargetConfig(t *testing.T) { + t.Setenv("BEADS_DOLT_SERVER_DATABASE", "") + t.Setenv("BEADS_DOLT_SERVER_PORT", "") + + callerDir := t.TempDir() + callerBeadsDir := filepath.Join(callerDir, ".beads") + writeTestConfigYAML(t, callerBeadsDir, "actor: caller-actor\ndolt.auto-start: true\ndolt.port: 1111\ndolt.auto-commit: on\n") + + targetDir := t.TempDir() + targetBeadsDir := filepath.Join(targetDir, ".beads") + writeTestConfigYAML(t, targetBeadsDir, "actor: target-actor\ndolt.auto-start: false\ndolt.port: 4242\ndolt.auto-commit: batch\njson: true\nreadonly: true\n") + if err := (&configfile.Config{ + Backend: configfile.BackendDolt, + DoltMode: configfile.DoltModeServer, + }).Save(targetBeadsDir); err != nil { + t.Fatalf("save target metadata: %v", err) + } + + t.Setenv("BEADS_DIR", callerBeadsDir) + config.ResetForTesting() + t.Cleanup(config.ResetForTesting) + if err := config.Initialize(); err != nil { + t.Fatalf("config.Initialize: %v", err) + } + + oldServerMode := serverMode + oldJSONOutput := jsonOutput + oldReadonlyMode := readonlyMode + oldActor := actor + oldDoltAutoCommit := doltAutoCommit + flagState := snapshotRootFlagState() + t.Cleanup(func() { + serverMode = oldServerMode + jsonOutput = oldJSONOutput + readonlyMode = oldReadonlyMode + actor = oldActor + doltAutoCommit = oldDoltAutoCommit + restoreRootFlagState(t, flagState) + }) + + serverMode = false + jsonOutput = false + readonlyMode = false + actor = "" + doltAutoCommit = "" + for _, name := range []string{"json", "format", "readonly", "actor", "dolt-auto-commit"} { + if flag := rootCmd.PersistentFlags().Lookup(name); flag != nil { + flag.Changed = false + } + } + + prepareSelectedCommandContext(targetBeadsDir, false) + refreshBoundCommandConfig(rootCmd) + + if got := os.Getenv("BEADS_DIR"); got != targetBeadsDir { + t.Fatalf("BEADS_DIR = %q, want %q", got, targetBeadsDir) + } + if !serverMode { + t.Fatal("serverMode should be true after rebinding to target metadata") + } + if !jsonOutput { + t.Fatal("jsonOutput should be rebound from target config") + } + if !readonlyMode { + t.Fatal("readonlyMode should be rebound from target config") + } + if actor != "target-actor" { + t.Fatalf("actor = %q, want %q", actor, "target-actor") + } + if doltAutoCommit != "batch" { + t.Fatalf("doltAutoCommit = %q, want %q", doltAutoCommit, "batch") + } + if !doltserver.IsAutoStartDisabled() { + t.Fatal("IsAutoStartDisabled should honor target config after rebinding") + } + if got := doltserver.DefaultConfig(targetBeadsDir).Port; got != 4242 { + t.Fatalf("DefaultConfig(target).Port = %d, want %d", got, 4242) + } +} + +func TestPrepareSelectedCommandContext_DoesNotMergeCallerConfigForUnsetKeys(t *testing.T) { + t.Setenv("BEADS_DOLT_SERVER_DATABASE", "") + t.Setenv("BEADS_DOLT_SERVER_PORT", "") + + root := t.TempDir() + callerDir := filepath.Join(root, "caller") + callerBeadsDir := filepath.Join(callerDir, ".beads") + writeTestConfigYAML(t, callerBeadsDir, "readonly: true\njson: true\n") + + targetDir := filepath.Join(root, "target") + targetBeadsDir := filepath.Join(targetDir, ".beads") + writeTestConfigYAML(t, targetBeadsDir, "actor: target-actor\n") + + t.Chdir(callerDir) + t.Setenv("BEADS_DIR", callerBeadsDir) + config.ResetForTesting() + t.Cleanup(config.ResetForTesting) + if err := config.Initialize(); err != nil { + t.Fatalf("config.Initialize: %v", err) + } + + oldJSONOutput := jsonOutput + oldReadonlyMode := readonlyMode + oldActor := actor + flagState := snapshotRootFlagState() + t.Cleanup(func() { + jsonOutput = oldJSONOutput + readonlyMode = oldReadonlyMode + actor = oldActor + restoreRootFlagState(t, flagState) + }) + + jsonOutput = false + readonlyMode = false + actor = "" + for _, name := range []string{"json", "format", "readonly", "actor"} { + if flag := rootCmd.PersistentFlags().Lookup(name); flag != nil { + flag.Changed = false + } + } + + prepareSelectedCommandContext(targetBeadsDir, false) + refreshBoundCommandConfig(rootCmd) + + if readonlyMode { + t.Fatal("readonlyMode should stay false when target config leaves readonly unset") + } + if jsonOutput { + t.Fatal("jsonOutput should stay false when target config leaves json unset") + } + if actor != "target-actor" { + t.Fatalf("actor = %q, want %q", actor, "target-actor") + } +} diff --git a/cmd/bd/context_cmd.go b/cmd/bd/context_cmd.go index 1c4a04a1b3..e2f3dc3c4c 100644 --- a/cmd/bd/context_cmd.go +++ b/cmd/bd/context_cmd.go @@ -51,7 +51,7 @@ Examples: } // Resolve repo context (works without DB open) - if selected := selectedNoDBBeadsDir(); selected != "" { + if selected := selectedNoDBBeadsDir(cmd); selected != "" { prepareSelectedNoDBContext(selected) } diff --git a/cmd/bd/doctor_context_test.go b/cmd/bd/doctor_context_test.go new file mode 100644 index 0000000000..9b7ba801c6 --- /dev/null +++ b/cmd/bd/doctor_context_test.go @@ -0,0 +1,205 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + + "github.com/steveyegge/beads/internal/beads" + "github.com/steveyegge/beads/internal/config" + "github.com/steveyegge/beads/internal/configfile" + "github.com/steveyegge/beads/internal/utils" +) + +func savePersistentPreRunState(t *testing.T) { + t.Helper() + + oldServerMode := serverMode + oldCmdCtx := cmdCtx + oldDBPath := dbPath + oldActor := actor + oldJSONOutput := jsonOutput + oldReadonlyMode := readonlyMode + oldDoltAutoCommit := doltAutoCommit + flagState := snapshotRootFlagState() + t.Cleanup(func() { + serverMode = oldServerMode + cmdCtx = oldCmdCtx + dbPath = oldDBPath + actor = oldActor + jsonOutput = oldJSONOutput + readonlyMode = oldReadonlyMode + doltAutoCommit = oldDoltAutoCommit + restoreRootFlagState(t, flagState) + }) + + serverMode = false + cmdCtx = nil + dbPath = "" + actor = "" + jsonOutput = false + readonlyMode = false + doltAutoCommit = "" +} + +func writeMetadataConfig(t *testing.T, beadsDir string, doltMode string, database string) { + t.Helper() + + if err := (&configfile.Config{ + Backend: configfile.BackendDolt, + DoltMode: doltMode, + DoltDatabase: database, + }).Save(beadsDir); err != nil { + t.Fatalf("save metadata: %v", err) + } +} + +func TestDoctorPersistentPreRunLoadsServerModeForNoDBCommand(t *testing.T) { + repoDir := t.TempDir() + beadsDir := filepath.Join(repoDir, ".beads") + writeTestConfigYAML(t, beadsDir, "") + writeMetadataConfig(t, beadsDir, configfile.DoltModeServer, "doctor_ctx_test") + + t.Chdir(repoDir) + t.Setenv("BEADS_DIR", beadsDir) + t.Setenv("BEADS_DOLT_SHARED_SERVER", "") + t.Setenv("BEADS_DOLT_SERVER_DATABASE", "") + t.Setenv("BEADS_DOLT_SERVER_PORT", "") + + config.ResetForTesting() + t.Cleanup(config.ResetForTesting) + savePersistentPreRunState(t) + + if rootCmd.PersistentPreRun == nil { + t.Fatal("rootCmd.PersistentPreRun must be set") + } + rootCmd.PersistentPreRun(doctorCmd, nil) + + if !serverMode { + t.Fatal("doctor should load server mode before the no-store early return") + } +} + +func TestDoctorPersistentPreRunUsesExplicitDBTarget(t *testing.T) { + callerRepo := filepath.Join(t.TempDir(), "caller") + callerBeadsDir := filepath.Join(callerRepo, ".beads") + writeTestConfigYAML(t, callerBeadsDir, "") + writeMetadataConfig(t, callerBeadsDir, configfile.DoltModeEmbedded, "caller_ctx_test") + + targetRepo := filepath.Join(t.TempDir(), "target") + targetBeadsDir := filepath.Join(targetRepo, ".beads") + writeTestConfigYAML(t, targetBeadsDir, "") + writeMetadataConfig(t, targetBeadsDir, configfile.DoltModeServer, "target_ctx_test") + + t.Chdir(callerRepo) + t.Setenv("BEADS_DIR", callerBeadsDir) + t.Setenv("BEADS_DOLT_SHARED_SERVER", "") + t.Setenv("BEADS_DOLT_SERVER_DATABASE", "") + t.Setenv("BEADS_DOLT_SERVER_PORT", "") + + config.ResetForTesting() + t.Cleanup(config.ResetForTesting) + savePersistentPreRunState(t) + + targetDBPath := filepath.Join(targetBeadsDir, "dolt") + dbPath = targetDBPath + if flag := rootCmd.PersistentFlags().Lookup("db"); flag != nil { + flag.Changed = true + } + + rootCmd.PersistentPreRun(doctorCmd, nil) + + if got := os.Getenv("BEADS_DIR"); got != targetBeadsDir { + t.Fatalf("BEADS_DIR = %q, want %q", got, targetBeadsDir) + } + if !serverMode { + t.Fatal("doctor should use the explicit target repo's server mode") + } +} + +func TestBootstrapPersistentPreRunUsesExplicitDBTarget(t *testing.T) { + callerRepo := filepath.Join(t.TempDir(), "caller") + callerBeadsDir := filepath.Join(callerRepo, ".beads") + writeTestConfigYAML(t, callerBeadsDir, "") + writeMetadataConfig(t, callerBeadsDir, configfile.DoltModeEmbedded, "caller_bootstrap_test") + + targetRepo := filepath.Join(t.TempDir(), "target") + targetBeadsDir := filepath.Join(targetRepo, ".beads") + writeTestConfigYAML(t, targetBeadsDir, "") + writeMetadataConfig(t, targetBeadsDir, configfile.DoltModeServer, "target_bootstrap_test") + + t.Chdir(callerRepo) + t.Setenv("BEADS_DIR", callerBeadsDir) + t.Setenv("BEADS_DOLT_SHARED_SERVER", "") + t.Setenv("BEADS_DOLT_SERVER_DATABASE", "") + t.Setenv("BEADS_DOLT_SERVER_PORT", "") + + config.ResetForTesting() + t.Cleanup(config.ResetForTesting) + savePersistentPreRunState(t) + + targetDBPath := filepath.Join(targetBeadsDir, "dolt") + dbPath = targetDBPath + if flag := rootCmd.PersistentFlags().Lookup("db"); flag != nil { + flag.Changed = true + } + + rootCmd.PersistentPreRun(bootstrapCmd, nil) + + if got := os.Getenv("BEADS_DIR"); got != targetBeadsDir { + t.Fatalf("BEADS_DIR = %q, want %q", got, targetBeadsDir) + } +} + +func TestLoadSelectionEnvironmentUsesAmbientEnvFileForBEADSDB(t *testing.T) { + callerRepo := filepath.Join(t.TempDir(), "caller") + callerBeadsDir := filepath.Join(callerRepo, ".beads") + writeTestConfigYAML(t, callerBeadsDir, "") + + targetRepo := filepath.Join(t.TempDir(), "target") + targetBeadsDir := filepath.Join(targetRepo, ".beads") + writeTestConfigYAML(t, targetBeadsDir, "") + targetDBPath := filepath.Join(targetBeadsDir, "dolt") + if err := os.MkdirAll(targetDBPath, 0o700); err != nil { + t.Fatalf("mkdir target db dir: %v", err) + } + if err := os.WriteFile(filepath.Join(callerBeadsDir, ".env"), []byte("BEADS_DB="+targetDBPath+"\n"), 0o600); err != nil { + t.Fatalf("write caller .env: %v", err) + } + + t.Chdir(callerRepo) + t.Setenv("BEADS_DIR", "") + t.Setenv("BEADS_DB", "") + t.Setenv("BD_DB", "") + + loadSelectionEnvironment() + + if got := os.Getenv("BEADS_DB"); utils.CanonicalizePath(got) != utils.CanonicalizePath(targetDBPath) { + t.Fatalf("BEADS_DB = %q, want %q", got, targetDBPath) + } + if got := beads.FindDatabasePath(); utils.CanonicalizePath(got) != utils.CanonicalizePath(targetDBPath) { + t.Fatalf("FindDatabasePath() = %q, want %q", got, targetDBPath) + } +} + +func TestSelectedDoltBeadsDirUsesReboundBEADSDir(t *testing.T) { + callerRepo := filepath.Join(t.TempDir(), "caller") + callerBeadsDir := filepath.Join(callerRepo, ".beads") + writeTestConfigYAML(t, callerBeadsDir, "") + + targetRepo := filepath.Join(t.TempDir(), "target") + targetBeadsDir := filepath.Join(targetRepo, ".beads") + writeTestConfigYAML(t, targetBeadsDir, "") + targetDBPath := filepath.Join(targetBeadsDir, "dolt") + if err := os.MkdirAll(targetDBPath, 0o700); err != nil { + t.Fatalf("mkdir target db dir: %v", err) + } + + t.Chdir(callerRepo) + t.Setenv("BEADS_DIR", targetBeadsDir) + t.Setenv("BEADS_DB", filepath.Join(callerBeadsDir, "dolt")) + + if got := selectedDoltBeadsDir(); got != targetBeadsDir { + t.Fatalf("selectedDoltBeadsDir() = %q, want %q", got, targetBeadsDir) + } +} diff --git a/cmd/bd/dolt.go b/cmd/bd/dolt.go index 6741f6e82a..5e52df79a9 100644 --- a/cmd/bd/dolt.go +++ b/cmd/bd/dolt.go @@ -14,6 +14,7 @@ import ( "time" "github.com/spf13/cobra" + "github.com/steveyegge/beads/internal/beads" "github.com/steveyegge/beads/internal/config" "github.com/steveyegge/beads/internal/configfile" "github.com/steveyegge/beads/internal/doltserver" @@ -1023,7 +1024,7 @@ func init() { } func selectedDoltBeadsDir() string { - beadsDir := selectedNoDBBeadsDir() + beadsDir := beads.FindBeadsDir() if beadsDir == "" { return "" } diff --git a/cmd/bd/init.go b/cmd/bd/init.go index bfc14b170c..c58d965c78 100644 --- a/cmd/bd/init.go +++ b/cmd/bd/init.go @@ -1287,9 +1287,10 @@ func checkExistingBeadsDataAt(beadsDir string, prefix string) error { // Check for existing Dolt database if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.GetBackend() == configfile.BackendDolt { // Embedded mode stores databases under `.beads/embeddeddolt//`. - // Treat any present embedded DB as "already initialized" (guard against - // accidental re-init / data loss). - if isEmbeddedMode() { + // Use the target workspace metadata rather than ambient process state so + // init guards remain deterministic even when another test or earlier + // command has rebound global server-mode state. + if !cfg.IsDoltServerMode() { embeddedRoot := filepath.Join(beadsDir, "embeddeddolt") entries, err := os.ReadDir(embeddedRoot) if err != nil { diff --git a/cmd/bd/init_guard_test.go b/cmd/bd/init_guard_test.go index c67b703a3d..d3c51bbb14 100644 --- a/cmd/bd/init_guard_test.go +++ b/cmd/bd/init_guard_test.go @@ -279,6 +279,39 @@ func TestInitGuard_FreshCloneWithMetadataJSON(t *testing.T) { } }) + t.Run("embedded_metadata_ignores_ambient_shared_server_mode", func(t *testing.T) { + t.Setenv("BEADS_DOLT_SHARED_SERVER", "1") + + tmpDir := t.TempDir() + beadsDir := filepath.Join(tmpDir, ".beads") + if err := os.MkdirAll(beadsDir, 0755); err != nil { + t.Fatal(err) + } + + metadata := map[string]interface{}{ + "database": "dolt", + "backend": "dolt", + "dolt_mode": "embedded", + } + data, _ := json.Marshal(metadata) + if err := os.WriteFile(filepath.Join(beadsDir, "metadata.json"), data, 0644); err != nil { + t.Fatal(err) + } + + dbDir := filepath.Join(beadsDir, "embeddeddolt", "beads", ".dolt") + if err := os.MkdirAll(dbDir, 0755); err != nil { + t.Fatal(err) + } + + err := checkExistingBeadsDataAt(beadsDir, "test") + if err == nil { + t.Error("existing embedded database should still block init when shared server mode is enabled elsewhere") + } + if err != nil && !strings.Contains(err.Error(), "already initialized") { + t.Errorf("expected 'already initialized' message, got: %v", err) + } + }) + t.Run("no_metadata_json_allows_init", func(t *testing.T) { tmpDir := t.TempDir() beadsDir := filepath.Join(tmpDir, ".beads") diff --git a/cmd/bd/main.go b/cmd/bd/main.go index 90d2c97ba9..59ebaed06f 100644 --- a/cmd/bd/main.go +++ b/cmd/bd/main.go @@ -135,6 +135,42 @@ func loadBeadsEnvFile(beadsDir string) { _ = gotenv.Load(envFile) } +// loadBeadsSelectionEnvFile loads only the selector keys needed for early +// workspace/database discovery. Unlike loadBeadsEnvFile, this intentionally +// limits itself to BEADS_DIR / BEADS_DB / BD_DB so caller credentials and +// runtime knobs do not leak into explicit-target commands before rebinding. +func loadBeadsSelectionEnvFile(beadsDir string) { + if beadsDir == "" { + return + } + envFile := filepath.Join(beadsDir, ".env") + pairs, err := gotenv.Read(envFile) + if err != nil { + return + } + for _, key := range []string{"BEADS_DIR", "BEADS_DB", "BD_DB"} { + if os.Getenv(key) != "" { + continue + } + if value, ok := pairs[key]; ok && strings.TrimSpace(value) != "" { + _ = os.Setenv(key, value) + } + } +} + +// loadSelectionEnvironment loads only the selector keys required to discover +// the target workspace/database before the store-init path runs. This preserves +// historical support for .beads/.env files that route commands via BEADS_DB or +// BEADS_DIR without importing the caller workspace's broader runtime settings. +func loadSelectionEnvironment() { + if os.Getenv("BEADS_DIR") != "" || os.Getenv("BEADS_DB") != "" || os.Getenv("BD_DB") != "" { + return + } + if beadsDir := beads.FindBeadsDir(); beadsDir != "" { + loadBeadsSelectionEnvFile(beadsDir) + } +} + // loadEnvironment runs the lightweight, always-needed environment setup that // must happen before the noDbCommands early return. This ensures commands like // "bd doctor --server" pick up per-project Dolt credentials from .beads/.env. @@ -176,11 +212,10 @@ func repairSharedServerEmbeddedMismatch(beadsDir string, cfg *configfile.Config) } } -// loadServerModeFromConfig loads the storage mode (embedded vs server) from -// metadata.json so that isEmbeddedMode() returns the correct value. Called -// for commands that skip full DB init but still need to know the mode. -func loadServerModeFromConfig() { - beadsDir := beads.FindBeadsDir() +// loadServerModeFromBeadsDir loads the storage mode (embedded vs server) from +// the given beads directory's metadata.json so that isEmbeddedMode() returns +// the correct value. +func loadServerModeFromBeadsDir(beadsDir string) { if beadsDir == "" { return } @@ -200,6 +235,13 @@ func loadServerModeFromConfig() { } } +// loadServerModeFromConfig loads the storage mode (embedded vs server) from +// metadata.json so that isEmbeddedMode() returns the correct value. Called +// for commands that skip full DB init but still need to know the mode. +func loadServerModeFromConfig() { + loadServerModeFromBeadsDir(beads.FindBeadsDir()) +} + func preserveRedirectSourceDatabase(beadsDir string) { if beadsDir == "" || os.Getenv("BEADS_DOLT_SERVER_DATABASE") != "" { return @@ -214,41 +256,35 @@ func preserveRedirectSourceDatabase(beadsDir string) { } } -func selectedNoDBBeadsDir() string { - selectedDBPath := "" - if rootCmd.PersistentFlags().Changed("db") && dbPath != "" { - selectedDBPath = dbPath +func selectedNoDBBeadsDir(cmd *cobra.Command) string { + if cmd != nil && cmd.Root() != nil && cmd.Root().PersistentFlags().Changed("db") && dbPath != "" { + if selectedBeadsDir := resolveCommandBeadsDir(dbPath); selectedBeadsDir != "" { + return selectedBeadsDir + } + } else if cmd != nil && cmd.PersistentFlags().Changed("db") && dbPath != "" { + if selectedBeadsDir := resolveCommandBeadsDir(dbPath); selectedBeadsDir != "" { + return selectedBeadsDir + } } else if envDB := os.Getenv("BEADS_DB"); envDB != "" { - selectedDBPath = envDB + if selectedBeadsDir := resolveCommandBeadsDir(envDB); selectedBeadsDir != "" { + return selectedBeadsDir + } } else if envDB := os.Getenv("BD_DB"); envDB != "" { - selectedDBPath = envDB - } else { - selectedDBPath = dbPath - } - if selectedDBPath != "" { - if selectedBeadsDir := resolveCommandBeadsDir(selectedDBPath); selectedBeadsDir != "" { + if selectedBeadsDir := resolveCommandBeadsDir(envDB); selectedBeadsDir != "" { return selectedBeadsDir } } - return beads.FindBeadsDir() -} - -func isSelectedNoDBCommand(cmd *cobra.Command) bool { - if cmd == nil { - return false - } - if cmd.Name() == "context" { - return true - } - if cmd.Parent() == nil || cmd.Parent().Name() != "dolt" { - return false + if os.Getenv("BEADS_DIR") != "" { + if selectedBeadsDir := beads.FindBeadsDir(); selectedBeadsDir != "" { + return selectedBeadsDir + } } - switch cmd.Name() { - case "push", "pull", "commit": - return false - default: - return true + if dbPath != "" { + if selectedBeadsDir := resolveCommandBeadsDir(dbPath); selectedBeadsDir != "" { + return selectedBeadsDir + } } + return beads.FindBeadsDir() } // configCommandCanRunWithoutStore returns true for config subcommands whose Run @@ -287,16 +323,50 @@ func configCommandCanRunWithoutStore(cmd *cobra.Command, args []string) bool { } } -func prepareSelectedNoDBContext(beadsDir string) { +func prepareSelectedCommandContext(beadsDir string, loadEnv bool) { if beadsDir == "" { return } _ = os.Setenv("BEADS_DIR", beadsDir) - loadBeadsEnvFile(beadsDir) + if loadEnv { + loadBeadsEnvFile(beadsDir) + } preserveRedirectSourceDatabase(beadsDir) if err := config.Initialize(); err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to reinitialize config for selected beads dir: %v\n", err) } + config.CheckBeadsDirPermissions(beadsDir) + loadServerModeFromBeadsDir(beadsDir) +} + +func prepareSelectedNoDBContext(beadsDir string) { + prepareSelectedCommandContext(beadsDir, true) +} + +// refreshBoundCommandConfig reapplies config-backed defaults after the command +// context has been rebound to a resolved target beads directory. This keeps +// explicit flags authoritative while letting rerouted/explicit-db commands use +// the target repo's config rather than the caller's config. +func refreshBoundCommandConfig(cmd *cobra.Command) { + if cmd == nil { + return + } + root := cmd.Root() + if root == nil { + root = cmd + } + if !root.PersistentFlags().Changed("json") && !root.PersistentFlags().Changed("format") { + jsonOutput = config.GetBool("json") + } + if !root.PersistentFlags().Changed("readonly") { + readonlyMode = config.GetBool("readonly") + } + if !root.PersistentFlags().Changed("actor") { + actor = config.GetString("actor") + } + if !root.PersistentFlags().Changed("dolt-auto-commit") { + doltAutoCommit = config.GetString("dolt.auto-commit") + } } // resolveCommandBeadsDir maps a discovered Dolt data path back to the owning @@ -476,6 +546,8 @@ var rootCmd = &cobra.Command{ FatalError("%v", err) } + loadSelectionEnvironment() + // Apply viper configuration if flags weren't explicitly set // Priority: flags > viper (config file + env vars) > defaults // Do this BEFORE early-return so init/version/help respect config @@ -510,7 +582,8 @@ var rootCmd = &cobra.Command{ WasSet bool }{readonlyMode, true} } - if !cmd.Root().PersistentFlags().Changed("db") && dbPath == "" { + if !cmd.Root().PersistentFlags().Changed("db") && dbPath == "" && + os.Getenv("BEADS_DB") == "" && os.Getenv("BD_DB") == "" && os.Getenv("BEADS_DIR") == "" { dbPath = config.GetString("db") } else if cmd.Root().PersistentFlags().Changed("db") { flagOverrides["db"] = struct { @@ -543,22 +616,6 @@ var rootCmd = &cobra.Command{ } } - // Validate Dolt auto-commit mode early so all commands fail fast on invalid config. - if _, err := getDoltAutoCommitMode(); err != nil { - FatalError("%v", err) - } - - // GH#2677: Load .beads/.env before the noDbCommands early return so that - // commands like "bd doctor --server" pick up per-project Dolt credentials. - if !isSelectedNoDBCommand(cmd) { - loadEnvironment() - } - - // Load storage mode (embedded vs server) early so that isEmbeddedMode() - // returns the correct value for all commands, including those that skip - // full DB initialization (e.g., bd dolt status, bd doctor, bd bootstrap). - loadServerModeFromConfig() - // GH#1093: Check noDbCommands BEFORE expensive operations // to avoid spawning git subprocesses for simple commands // like "bd version" that don't need database access. @@ -601,6 +658,7 @@ var rootCmd = &cobra.Command{ // Check both the command name and parent command name for subcommands cmdName := cmd.Name() isSubcommand := cmd.Parent() != nil && cmd.Parent().Name() != "bd" + skipsStoreInit := false if cmd.Parent() != nil { parentName := cmd.Parent().Name() if parentName == "dolt" && slices.Contains(needsStoreDoltSubcommands, cmdName) { @@ -608,22 +666,42 @@ var rootCmd = &cobra.Command{ } else if slices.Contains(needsStoreDoltGrandchildren, parentName) { // GH#2224: dolt remote add/list/remove need the store — fall through to init } else if slices.Contains(noDbCommands, parentName) { - return + skipsStoreInit = true } } // Only skip for top-level commands in noDbCommands, not subcommands // that happen to share names (e.g., "bd backup init" vs "bd init"). if slices.Contains(noDbCommands, cmdName) && !isSubcommand { - return + skipsStoreInit = true } // Skip for root command with no subcommand (just shows help) if cmd.Parent() == nil && cmdName == cmd.Use { - return + skipsStoreInit = true } // Also skip for --version flag on root command (cmdName would be "bd") if v, _ := cmd.Flags().GetBool("version"); v { + skipsStoreInit = true + } + + // Commands that skip store initialization still need early config/env + // setup before they inspect server mode or per-project Dolt settings. + // Rebind them to the selected workspace so explicit --db / BEADS_DB + // targets behave consistently across doctor/bootstrap/context/dolt. + if skipsStoreInit { + prepareSelectedNoDBContext(selectedNoDBBeadsDir(cmd)) + refreshBoundCommandConfig(cmd) + if beadsDir := os.Getenv("BEADS_DIR"); beadsDir == "" { + loadEnvironment() + loadServerModeFromConfig() + } + if _, err := getDoltAutoCommitMode(); err != nil { + FatalError("%v", err) + } + } + + if skipsStoreInit { return } @@ -652,7 +730,9 @@ var rootCmd = &cobra.Command{ // 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. - preserveRedirectSourceDatabase(beads.GetRedirectInfo().LocalDir) + if dbPath == "" { + preserveRedirectSourceDatabase(beads.GetRedirectInfo().LocalDir) + } // Initialize database path if dbPath == "" { @@ -692,6 +772,13 @@ var rootCmd = &cobra.Command{ } } + beadsDir := resolveCommandBeadsDir(dbPath) + prepareSelectedCommandContext(beadsDir, true) + refreshBoundCommandConfig(cmd) + if _, err := getDoltAutoCommitMode(); err != nil { + FatalError("%v", err) + } + // Set actor for audit trail actor = getActorWithGit() // Attach actor to the command span now that we have it. @@ -713,7 +800,6 @@ var rootCmd = &cobra.Command{ // opens its own store connection, writes the version metadata, commits it, // and closes BEFORE the main store is opened. This ensures bd doctor and // read-only commands see the correct version after a CLI upgrade. - beadsDir := resolveCommandBeadsDir(dbPath) autoMigrateOnVersionBump(beadsDir) diff --git a/internal/config/config.go b/internal/config/config.go index f57c96a348..1b54693727 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -57,6 +57,11 @@ func Initialize() error { } // 1. Project: walk up from CWD to find .beads/config.yaml + beadsDirEnv := strings.TrimSpace(os.Getenv("BEADS_DIR")) + beadsEnvConfigPath := "" + if beadsDirEnv != "" { + beadsEnvConfigPath = filepath.Clean(filepath.Join(beadsDirEnv, "config.yaml")) + } cwd, err := os.Getwd() if err == nil { // In the beads repo, `.beads/config.yaml` is tracked and may set non-default config values. @@ -82,6 +87,12 @@ func Initialize() error { beadsDir := filepath.Join(dir, ".beads") p := filepath.Join(beadsDir, "config.yaml") if _, err := os.Stat(p); err == nil { + // When BEADS_DIR points at a different runtime workspace, do not + // merge the caller repo's config underneath it. That leaks caller + // settings like readonly/json/actor into explicit-target commands. + if beadsEnvConfigPath != "" && filepath.Clean(p) != beadsEnvConfigPath { + break + } if ignoreRepoConfig && moduleRoot != "" { // Only ignore the repo-local config (moduleRoot/.beads/config.yaml). wantIgnore := filepath.Clean(p) == filepath.Clean(filepath.Join(moduleRoot, ".beads", "config.yaml")) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 355a8ed2fa..808e9af4a7 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1369,3 +1369,43 @@ func TestGetStringFromDir(t *testing.T) { } }) } + +func TestInitialize_ExternalBEADSDirDoesNotMergeCallerProjectConfig(t *testing.T) { + restore := envSnapshot(t) + defer restore() + + callerRepo := filepath.Join(t.TempDir(), "caller") + callerBeadsDir := filepath.Join(callerRepo, ".beads") + if err := os.MkdirAll(callerBeadsDir, 0o755); err != nil { + t.Fatalf("failed to create caller .beads: %v", err) + } + if err := os.WriteFile(filepath.Join(callerBeadsDir, "config.yaml"), []byte("readonly: true\njson: true\n"), 0o600); err != nil { + t.Fatalf("failed to write caller config: %v", err) + } + + targetBeadsDir := filepath.Join(t.TempDir(), "target", ".beads") + if err := os.MkdirAll(targetBeadsDir, 0o755); err != nil { + t.Fatalf("failed to create target .beads: %v", err) + } + if err := os.WriteFile(filepath.Join(targetBeadsDir, "config.yaml"), []byte("actor: target-user\n"), 0o600); err != nil { + t.Fatalf("failed to write target config: %v", err) + } + + t.Chdir(callerRepo) + t.Setenv("BEADS_DIR", targetBeadsDir) + + ResetForTesting() + if err := Initialize(); err != nil { + t.Fatalf("Initialize() returned error: %v", err) + } + + if got := GetString("actor"); got != "target-user" { + t.Fatalf("GetString(actor) = %q, want %q", got, "target-user") + } + if got := GetBool("readonly"); got { + t.Fatalf("GetBool(readonly) = %v, want false", got) + } + if got := GetBool("json"); got { + t.Fatalf("GetBool(json) = %v, want false", got) + } +}