diff --git a/cmd/bd/explicit_db_nodb_test.go b/cmd/bd/explicit_db_nodb_test.go index baf816a7ab..c33e6977ec 100644 --- a/cmd/bd/explicit_db_nodb_test.go +++ b/cmd/bd/explicit_db_nodb_test.go @@ -3,6 +3,7 @@ package main import ( + "context" "encoding/json" "os" "os/exec" @@ -11,6 +12,7 @@ import ( "strconv" "strings" "testing" + "time" "github.com/steveyegge/beads/internal/configfile" ) @@ -113,6 +115,11 @@ func writeProjectConfig(t *testing.T, beadsDir string, syncRemote string, port i )) } +func writeIssuePrefixConfig(t *testing.T, beadsDir, prefix string) { + t.Helper() + writeFile(t, filepath.Join(beadsDir, "config.yaml"), []byte("issue-prefix: "+prefix+"\n")) +} + // evalPath resolves symlinks in a path for consistent comparison. // On macOS, t.TempDir() returns /var/folders/... but binaries resolve // it to /private/var/folders/..., causing string comparison failures. @@ -139,7 +146,10 @@ func decodeJSONOutput(t *testing.T, out []byte, target any) { func runBDCommand(t *testing.T, binPath, dir string, extraEnv []string, args ...string) []byte { t.Helper() - cmd := exec.Command(binPath, args...) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, binPath, args...) cmd.Dir = dir cmd.Env = append(os.Environ(), "HOME="+t.TempDir(), @@ -155,11 +165,44 @@ func runBDCommand(t *testing.T, binPath, dir string, extraEnv []string, args ... cmd.Env = append(cmd.Env, extraEnv...) out, err := cmd.CombinedOutput() if err != nil { + if ctx.Err() == context.DeadlineExceeded { + t.Fatalf("%v timed out after %s\n%s", args, 10*time.Second, out) + } t.Fatalf("%v failed: %v\n%s", args, err, out) } return out } +type whereExpectation struct { + beadsDir string + database string + prefix string + omitPrefix bool +} + +func assertWhereOutput(t *testing.T, out []byte, want whereExpectation) { + t.Helper() + + var got map[string]any + decodeJSONOutput(t, out, &got) + + if evalPath(t, got["path"].(string)) != evalPath(t, want.beadsDir) { + t.Fatalf("path = %v, want %s", got["path"], want.beadsDir) + } + if evalPath(t, got["database_path"].(string)) != evalPath(t, want.database) { + t.Fatalf("database_path = %v, want %s", got["database_path"], want.database) + } + if want.omitPrefix { + if _, ok := got["prefix"]; ok { + t.Fatalf("prefix = %v, want omitted when only server metadata is available", got["prefix"]) + } + return + } + if got["prefix"] != want.prefix { + t.Fatalf("prefix = %v, want %s", got["prefix"], want.prefix) + } +} + func TestContextUsesExplicitDBFlagForNoDBCommand(t *testing.T) { binPath := buildBDUnderTest(t) root := t.TempDir() @@ -186,6 +229,24 @@ func TestContextUsesExplicitDBFlagForNoDBCommand(t *testing.T) { } } +func TestWhereUsesExplicitDBFlagForNoDBCommand(t *testing.T) { + binPath := buildBDUnderTest(t) + root := t.TempDir() + repoA := filepath.Join(root, "repo-a") + repoB := filepath.Join(root, "repo-b") + beadsDirA := writeServerRepo(t, repoA, "repo_a_db", "10.0.0.1", "origin-a", 3311) + beadsDirB := writeServerRepo(t, repoB, "repo_b_db", "10.0.0.2", "origin-b", 3312) + writeIssuePrefixConfig(t, beadsDirA, "repo-a") + writeIssuePrefixConfig(t, beadsDirB, "repo-b") + + out := runBDCommand(t, binPath, repoA, nil, "--db", filepath.Join(beadsDirB, "dolt"), "where", "--json") + assertWhereOutput(t, out, whereExpectation{ + beadsDir: beadsDirB, + database: filepath.Join(beadsDirB, "dolt"), + prefix: "repo-b", + }) +} + func TestDoltShowUsesExplicitDBFlagForNoDBCommand(t *testing.T) { binPath := buildBDUnderTest(t) root := t.TempDir() @@ -229,6 +290,23 @@ func TestContextUsesBEADSDBForNoDBCommand(t *testing.T) { } } +func TestWhereUsesBEADSDBForNoDBCommand(t *testing.T) { + binPath := buildBDUnderTest(t) + root := t.TempDir() + repoA := filepath.Join(root, "repo-a") + repoB := filepath.Join(root, "repo-b") + writeServerRepo(t, repoA, "repo_a_db", "10.0.0.1", "origin-a", 3311) + beadsDirB := writeServerRepo(t, repoB, "repo_b_db", "10.0.0.2", "origin-b", 3312) + writeIssuePrefixConfig(t, beadsDirB, "repo-b") + + out := runBDCommand(t, binPath, repoA, []string{"BEADS_DB=" + filepath.Join(beadsDirB, "dolt")}, "where", "--json") + assertWhereOutput(t, out, whereExpectation{ + beadsDir: beadsDirB, + database: filepath.Join(beadsDirB, "dolt"), + prefix: "repo-b", + }) +} + func TestContextUsesBEADSDBDirectoryForNoDBCommand(t *testing.T) { binPath := buildBDUnderTest(t) root := t.TempDir() @@ -272,6 +350,23 @@ func TestContextUsesExplicitDBFlagForExternalDoltDataDir(t *testing.T) { } } +func TestWhereUsesExplicitDBFlagForExternalDoltDataDir(t *testing.T) { + binPath := buildBDUnderTest(t) + root := t.TempDir() + repoA := filepath.Join(root, "repo-a") + repoB := filepath.Join(root, "repo-b") + writeServerRepo(t, repoA, "repo_a_db", "10.0.0.1", "origin-a", 3311) + beadsDirB := writeServerRepoWithDataDir(t, repoB, "repo_b_db", "10.0.0.2", "origin-b", 3312, "../external-dolt") + writeIssuePrefixConfig(t, beadsDirB, "repo-b") + + out := runBDCommand(t, binPath, repoA, nil, "--db", filepath.Join(beadsDirB, "../external-dolt"), "where", "--json") + assertWhereOutput(t, out, whereExpectation{ + beadsDir: beadsDirB, + database: filepath.Join(beadsDirB, "../external-dolt"), + prefix: "repo-b", + }) +} + func TestContextExplicitDBFlagOverridesBEADSDBForNoDBCommand(t *testing.T) { binPath := buildBDUnderTest(t) root := t.TempDir() @@ -294,6 +389,65 @@ func TestContextExplicitDBFlagOverridesBEADSDBForNoDBCommand(t *testing.T) { } } +func TestWhereExplicitDBFlagOverridesBEADSDBForNoDBCommand(t *testing.T) { + binPath := buildBDUnderTest(t) + root := t.TempDir() + repoA := filepath.Join(root, "repo-a") + repoB := filepath.Join(root, "repo-b") + repoC := filepath.Join(root, "repo-c") + writeServerRepo(t, repoA, "repo_a_db", "10.0.0.1", "origin-a", 3311) + beadsDirB := writeServerRepo(t, repoB, "repo_b_db", "10.0.0.2", "origin-b", 3312) + beadsDirC := writeServerRepo(t, repoC, "repo_c_db", "10.0.0.3", "origin-c", 3313) + writeIssuePrefixConfig(t, beadsDirB, "repo-b") + writeIssuePrefixConfig(t, beadsDirC, "repo-c") + + out := runBDCommand(t, binPath, repoA, []string{"BEADS_DB=" + filepath.Join(beadsDirC, "dolt")}, "--db", filepath.Join(beadsDirB, "dolt"), "where", "--json") + assertWhereOutput(t, out, whereExpectation{ + beadsDir: beadsDirB, + database: filepath.Join(beadsDirB, "dolt"), + prefix: "repo-b", + }) +} + +func TestWhereUsesExplicitDBFlagForMetadataOnlyServerRepo(t *testing.T) { + binPath := buildBDUnderTest(t) + root := t.TempDir() + repoA := filepath.Join(root, "repo-a") + repoB := filepath.Join(root, "repo-b") + writeServerRepo(t, repoA, "repo_a_db", "10.0.0.1", "origin-a", 3311) + beadsDirB := writeServerRepo(t, repoB, "repo_b_db", "10.0.0.2", "origin-b", 3312) + + out := runBDCommand(t, binPath, repoA, nil, "--db", filepath.Join(beadsDirB, "dolt"), "where", "--json") + assertWhereOutput(t, out, whereExpectation{ + beadsDir: beadsDirB, + database: filepath.Join(beadsDirB, "dolt"), + omitPrefix: true, + }) +} + +func TestWhereBEADSDBOverridesBDDBForNoDBCommand(t *testing.T) { + binPath := buildBDUnderTest(t) + root := t.TempDir() + repoA := filepath.Join(root, "repo-a") + repoB := filepath.Join(root, "repo-b") + repoC := filepath.Join(root, "repo-c") + writeServerRepo(t, repoA, "repo_a_db", "10.0.0.1", "origin-a", 3311) + beadsDirB := writeServerRepo(t, repoB, "repo_b_db", "10.0.0.2", "origin-b", 3312) + beadsDirC := writeServerRepo(t, repoC, "repo_c_db", "10.0.0.3", "origin-c", 3313) + writeIssuePrefixConfig(t, beadsDirB, "repo-b") + writeIssuePrefixConfig(t, beadsDirC, "repo-c") + + out := runBDCommand(t, binPath, repoA, []string{ + "BEADS_DB=" + filepath.Join(beadsDirB, "dolt"), + "BD_DB=" + filepath.Join(beadsDirC, "dolt"), + }, "where", "--json") + assertWhereOutput(t, out, whereExpectation{ + beadsDir: beadsDirB, + database: filepath.Join(beadsDirB, "dolt"), + prefix: "repo-b", + }) +} + func TestContextBEADSDBOverridesBDDBForNoDBCommand(t *testing.T) { binPath := buildBDUnderTest(t) root := t.TempDir() diff --git a/cmd/bd/init.go b/cmd/bd/init.go index f09cb917a2..775ddad696 100644 --- a/cmd/bd/init.go +++ b/cmd/bd/init.go @@ -449,13 +449,13 @@ Non-interactive mode (--non-interactive or BD_NON_INTERACTIVE=1): if existingCfg, _ := configfile.Load(beadsDir); existingCfg != nil && existingCfg.DoltDatabase != "" { dbName = existingCfg.DoltDatabase } else if prefix != "" { - // Sanitize hyphens and dots to underscores for SQL-idiomatic database names. - // Dots are invalid in Dolt/MySQL identifiers (e.g. from ".claude" directories). + // Sanitize hyphens to underscores for SQL-idiomatic database names. + // Dots were already normalized in prefix above so issue_prefix and + // DoltDatabase stay in lockstep. // Must match the sanitization applied to metadata.json DoltDatabase // field (line below), otherwise init creates a database with one name // but metadata.json records a different name, causing reopens to fail. dbName = strings.ReplaceAll(prefix, "-", "_") - dbName = strings.ReplaceAll(dbName, ".", "_") } else { dbName = "beads" } @@ -715,12 +715,12 @@ Non-interactive mode (--non-interactive or BD_NON_INTERACTIVE=1): if database != "" { cfg.DoltDatabase = database } else if cfg.DoltDatabase == "" && prefix != "" { - // Sanitize hyphens and dots to underscores for SQL-idiomatic names (GH#2142). - // Must match the sanitization applied to dbName above (lines 430-431), + // Sanitize hyphens to underscores for SQL-idiomatic names (GH#2142). + // Dots were already normalized in prefix above. + // Must match the sanitization applied to dbName above, // otherwise init creates a database with one name but metadata.json // records a different name, causing reopens to fail. cfg.DoltDatabase = strings.ReplaceAll(prefix, "-", "_") - cfg.DoltDatabase = strings.ReplaceAll(cfg.DoltDatabase, ".", "_") } // Persist the connection mode matching this build. diff --git a/cmd/bd/init_embedded_test.go b/cmd/bd/init_embedded_test.go index ffe8633aa2..81ef11344d 100644 --- a/cmd/bd/init_embedded_test.go +++ b/cmd/bd/init_embedded_test.go @@ -673,6 +673,32 @@ func TestEmbeddedInit(t *testing.T) { t.Errorf("issue_prefix: got %q, want %q", val, "GPUPolynomials_jl") } }) + + t.Run("config_dot_prefix_sanitized", func(t *testing.T) { + dir := t.TempDir() + initGitRepoAt(t, dir) + beadsDir := filepath.Join(dir, ".beads") + if err := os.MkdirAll(beadsDir, 0o750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(beadsDir, "config.yaml"), []byte("issue-prefix: GPUPolynomials.jl\n"), 0o644); err != nil { + t.Fatal(err) + } + + runBDInit(t, bd, dir) + + cfg, err := configfile.Load(beadsDir) + if err != nil { + t.Fatalf("failed to load metadata.json: %v", err) + } + const want = "GPUPolynomials_jl" + if cfg.DoltDatabase != want { + t.Errorf("DoltDatabase: got %q, want %q", cfg.DoltDatabase, want) + } + if val := readBack(t, beadsDir, want, "issue_prefix", false); val != want { + t.Errorf("issue_prefix: got %q, want %q", val, want) + } + }) } // TestEmbeddedInitConcurrent verifies the exclusive flock prevents concurrent diff --git a/cmd/bd/main.go b/cmd/bd/main.go index 90d2c97ba9..0c57847db4 100644 --- a/cmd/bd/main.go +++ b/cmd/bd/main.go @@ -237,7 +237,7 @@ func isSelectedNoDBCommand(cmd *cobra.Command) bool { if cmd == nil { return false } - if cmd.Name() == "context" { + if cmd.Name() == "context" || cmd.Name() == "where" { return true } if cmd.Parent() == nil || cmd.Parent().Name() != "dolt" { diff --git a/cmd/bd/where.go b/cmd/bd/where.go index 5dde0fbe6b..b321ce5a44 100644 --- a/cmd/bd/where.go +++ b/cmd/bd/where.go @@ -7,6 +7,9 @@ import ( "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/storage" "github.com/steveyegge/beads/internal/utils" ) @@ -34,8 +37,11 @@ Examples: Run: func(cmd *cobra.Command, args []string) { result := WhereResult{} - // Find the beads directory (this follows redirects) - beadsDir := beads.FindBeadsDir() + if selected := selectedNoDBBeadsDir(); selected != "" { + prepareSelectedNoDBContext(selected) + } + + beadsDir := resolveWhereBeadsDir() if beadsDir == "" { if jsonOutput { outputJSON(map[string]string{ @@ -60,22 +66,24 @@ Examples: } // Find the database path - dbPath := beads.FindDatabasePath() + dbPath := resolveWhereDatabasePath() if dbPath != "" { result.DatabasePath = dbPath + } - // Try to get the prefix from the database if we have a store - if store != nil { - ctx := rootCtx - if prefix, err := store.GetConfig(ctx, "issue_prefix"); err == nil && prefix != "" { + // Prefer YAML when available, otherwise do a scoped read-only reopen + // using the already-resolved dbPath so we can preserve prefix output + // without paying the old worktree-discovery cost. + if prefix := config.GetString("issue-prefix"); prefix != "" { + result.Prefix = prefix + } else if dbPath != "" && shouldReadWherePrefixFromStore(beadsDir) { + _ = withStorage(getRootContext(), nil, dbPath, func(currentStore storage.DoltStorage) error { + prefix, err := currentStore.GetConfig(getRootContext(), "issue_prefix") + if err == nil && prefix != "" { result.Prefix = prefix } - } - } - - // If we don't have the prefix from DB, try to detect it from JSONL - if result.Prefix == "" { - result.Prefix = detectPrefixFromDir(beadsDir) + return nil + }) } // Output results @@ -96,6 +104,33 @@ Examples: }, } +func resolveWhereBeadsDir() string { + if selected := selectedNoDBBeadsDir(); selected != "" { + return selected + } + + return beads.FindBeadsDir() +} + +func resolveWhereDatabasePath() string { + return beads.FindDatabasePath() +} + +func shouldReadWherePrefixFromStore(beadsDir string) bool { + if beadsDir == "" { + return false + } + + cfg, err := configfile.Load(beadsDir) + if err != nil || cfg == nil { + return true + } + + // `bd where` should be able to report selected server-mode metadata without + // requiring a live Dolt server just to recover issue_prefix. + return !cfg.IsDoltServerMode() +} + // findOriginalBeadsDir walks up from cwd looking for a .beads directory with a redirect file // Returns the original .beads path if found, empty string otherwise func findOriginalBeadsDir() string { @@ -145,12 +180,6 @@ func findOriginalBeadsDir() string { return "" } -// detectPrefixFromDir tries to detect the issue prefix from files in the beads directory. -// Returns empty string if prefix cannot be determined. -func detectPrefixFromDir(_ string) string { - return "" -} - func init() { rootCmd.AddCommand(whereCmd) } diff --git a/cmd/bd/where_cgo_test.go b/cmd/bd/where_cgo_test.go new file mode 100644 index 0000000000..42535eedce --- /dev/null +++ b/cmd/bd/where_cgo_test.go @@ -0,0 +1,120 @@ +//go:build cgo + +package main + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/steveyegge/beads/internal/configfile" + "github.com/steveyegge/beads/internal/storage" + "github.com/steveyegge/beads/internal/storage/embeddeddolt" + "github.com/steveyegge/beads/internal/utils" +) + +func TestWhereCommand_ReadsPrefixFromEmbeddedStore(t *testing.T) { + saveAndRestoreGlobals(t) + ensureCleanGlobalState(t) + initConfigForTest(t) + + originalCmdCtx := cmdCtx + originalJSONOutput := jsonOutput + originalRootCtx := rootCtx + defer func() { + cmdCtx = originalCmdCtx + jsonOutput = originalJSONOutput + rootCtx = originalRootCtx + }() + + resetCommandContext() + + repoDir := t.TempDir() + beadsDir := filepath.Join(repoDir, ".beads") + if err := os.MkdirAll(beadsDir, 0o755); err != nil { + t.Fatalf("mkdir beads dir: %v", err) + } + + cfg := &configfile.Config{ + Database: "dolt", + Backend: configfile.BackendDolt, + DoltMode: configfile.DoltModeEmbedded, + DoltDatabase: "embedcfg", + } + if err := cfg.Save(beadsDir); err != nil { + t.Fatalf("save metadata: %v", err) + } + + store, err := embeddeddolt.New(context.Background(), beadsDir, "embedcfg", "main") + if err != nil { + t.Fatalf("embeddeddolt.New: %v", err) + } + if err := store.SetConfig(context.Background(), "issue_prefix", "storeprefix"); err != nil { + _ = store.Close() + t.Fatalf("SetConfig(issue_prefix): %v", err) + } + if err := store.Close(); err != nil { + t.Fatalf("Close(): %v", err) + } + + dbDir := filepath.Join(beadsDir, "dolt") + t.Setenv("BEADS_DIR", "") + t.Setenv("BEADS_DB", dbDir) + t.Setenv("BD_DB", "") + t.Setenv("BEADS_DOLT_SERVER_MODE", "") + t.Setenv("BEADS_DOLT_SHARED_SERVER", "") + + dbFlag := rootCmd.PersistentFlags().Lookup("db") + originalFlagValue := dbFlag.Value.String() + originalFlagChanged := dbFlag.Changed + if err := dbFlag.Value.Set(""); err != nil { + t.Fatalf("reset db flag: %v", err) + } + dbFlag.Changed = false + t.Cleanup(func() { + _ = dbFlag.Value.Set(originalFlagValue) + dbFlag.Changed = originalFlagChanged + }) + + jsonOutput = true + rootCtx = context.Background() + + if err := withStorage(rootCtx, nil, dbDir, func(currentStore storage.DoltStorage) error { + prefix, err := currentStore.GetConfig(rootCtx, "issue_prefix") + if err != nil { + return err + } + if prefix != "storeprefix" { + t.Fatalf("precheck issue_prefix = %q, want %q", prefix, "storeprefix") + } + return nil + }); err != nil { + t.Fatalf("withStorage precheck: %v", err) + } + + output := captureStdout(t, func() error { + whereCmd.Run(whereCmd, nil) + return nil + }) + + var result WhereResult + if err := json.Unmarshal([]byte(output), &result); err != nil { + t.Fatalf("json.Unmarshal(%q): %v", output, err) + } + + if !utils.PathsEqual(result.Path, beadsDir) { + t.Fatalf("Path = %q, want %q", result.Path, beadsDir) + } + if result.DatabasePath == "" { + t.Fatal("DatabasePath = empty, want resolved dolt path") + } + base := filepath.Base(result.DatabasePath) + if base != "dolt" && base != "embeddeddolt" { + t.Fatalf("DatabasePath = %q, want dolt-style basename", result.DatabasePath) + } + if result.Prefix != "storeprefix" { + t.Fatalf("Prefix = %q, want %q", result.Prefix, "storeprefix") + } +} diff --git a/cmd/bd/where_embedded_test.go b/cmd/bd/where_embedded_test.go index 732cbe707b..a97e8e05f1 100644 --- a/cmd/bd/where_embedded_test.go +++ b/cmd/bd/where_embedded_test.go @@ -132,6 +132,11 @@ func TestEmbeddedWhere(t *testing.T) { t.Errorf("expected .beads in path: %v", path) } } + if prefix, ok := m["prefix"]; !ok { + t.Fatalf("expected prefix in where --json output: %s", out) + } else if p, ok := prefix.(string); !ok || p != "tw" { + t.Fatalf("expected prefix %q in where --json output, got %#v", "tw", prefix) + } }) } diff --git a/cmd/bd/where_test.go b/cmd/bd/where_test.go new file mode 100644 index 0000000000..b88d9237a6 --- /dev/null +++ b/cmd/bd/where_test.go @@ -0,0 +1,327 @@ +package main + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/spf13/cobra" + "github.com/steveyegge/beads/internal/configfile" + "github.com/steveyegge/beads/internal/utils" +) + +func TestResolveWhereBeadsDir_FallsBackToFindBeadsDir(t *testing.T) { + saveAndRestoreGlobals(t) + ensureCleanGlobalState(t) + + originalCmdCtx := cmdCtx + defer func() { + cmdCtx = originalCmdCtx + }() + + resetCommandContext() + + repoDir := t.TempDir() + beadsDir := filepath.Join(repoDir, ".beads") + if err := os.MkdirAll(beadsDir, 0o755); err != nil { + t.Fatalf("mkdir beads dir: %v", err) + } + if err := os.WriteFile(filepath.Join(beadsDir, "config.yaml"), []byte("issue-prefix: fallback\n"), 0o644); err != nil { + t.Fatalf("write config.yaml: %v", err) + } + + originalWD, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + if err := os.Chdir(repoDir); err != nil { + t.Fatalf("chdir(%q): %v", repoDir, err) + } + t.Cleanup(func() { + _ = os.Chdir(originalWD) + }) + + t.Setenv("BEADS_DIR", "") + t.Setenv("BEADS_DB", "") + t.Setenv("BD_DB", "") + setDBPath("") + + dbFlag := rootCmd.PersistentFlags().Lookup("db") + originalFlagValue := dbFlag.Value.String() + originalFlagChanged := dbFlag.Changed + if err := dbFlag.Value.Set(""); err != nil { + t.Fatalf("reset db flag: %v", err) + } + dbFlag.Changed = false + t.Cleanup(func() { + _ = dbFlag.Value.Set(originalFlagValue) + dbFlag.Changed = originalFlagChanged + }) + + if got := resolveWhereBeadsDir(); !utils.PathsEqual(got, beadsDir) { + t.Fatalf("resolveWhereBeadsDir() = %q, want %q", got, beadsDir) + } +} + +func TestResolveWhereBeadsDir_ReturnsEmptyWithoutWorkspace(t *testing.T) { + saveAndRestoreGlobals(t) + ensureCleanGlobalState(t) + + originalCmdCtx := cmdCtx + defer func() { + cmdCtx = originalCmdCtx + }() + + resetCommandContext() + + workspace := t.TempDir() + originalWD, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + if err := os.Chdir(workspace); err != nil { + t.Fatalf("chdir(%q): %v", workspace, err) + } + t.Cleanup(func() { + _ = os.Chdir(originalWD) + }) + + t.Setenv("BEADS_DIR", "") + t.Setenv("BEADS_DB", "") + t.Setenv("BD_DB", "") + setDBPath("") + + dbFlag := rootCmd.PersistentFlags().Lookup("db") + originalFlagValue := dbFlag.Value.String() + originalFlagChanged := dbFlag.Changed + if err := dbFlag.Value.Set(""); err != nil { + t.Fatalf("reset db flag: %v", err) + } + dbFlag.Changed = false + t.Cleanup(func() { + _ = dbFlag.Value.Set(originalFlagValue) + dbFlag.Changed = originalFlagChanged + }) + + if got := resolveWhereBeadsDir(); got != "" { + t.Fatalf("resolveWhereBeadsDir() = %q, want empty", got) + } +} + +func TestResolveWhereBeadsDir_UsesInitializedDBPath(t *testing.T) { + originalDBPath := dbPath + originalCmdCtx := cmdCtx + defer func() { + dbPath = originalDBPath + cmdCtx = originalCmdCtx + }() + + resetCommandContext() + + repoDir := t.TempDir() + beadsDir := filepath.Join(repoDir, ".beads") + dbDir := filepath.Join(beadsDir, "dolt") + if err := os.MkdirAll(dbDir, 0o755); err != nil { + t.Fatalf("mkdir db dir: %v", err) + } + + cfg := &configfile.Config{ + Database: "dolt", + Backend: configfile.BackendDolt, + } + if err := cfg.Save(beadsDir); err != nil { + t.Fatalf("save metadata: %v", err) + } + + cwd := t.TempDir() + originalWD, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + if err := os.Chdir(cwd); err != nil { + t.Fatalf("chdir: %v", err) + } + t.Cleanup(func() { + _ = os.Chdir(originalWD) + }) + + dbPath = dbDir + + if got := resolveWhereBeadsDir(); !utils.PathsEqual(got, beadsDir) { + t.Fatalf("resolveWhereBeadsDir() = %q, want %q", got, beadsDir) + } +} + +func TestResolveWhereDatabasePath_PrefersInitializedDBPath(t *testing.T) { + originalDBPath := dbPath + originalCmdCtx := cmdCtx + defer func() { + dbPath = originalDBPath + cmdCtx = originalCmdCtx + }() + + resetCommandContext() + + repoDir := t.TempDir() + beadsDir := filepath.Join(repoDir, ".beads") + dbDir := filepath.Join(beadsDir, "dolt") + if err := os.MkdirAll(dbDir, 0o755); err != nil { + t.Fatalf("mkdir db dir: %v", err) + } + + cfg := &configfile.Config{ + Database: "dolt", + Backend: configfile.BackendDolt, + } + if err := cfg.Save(beadsDir); err != nil { + t.Fatalf("save metadata: %v", err) + } + + dbPath = dbDir + t.Setenv("BEADS_DIR", "") + + prepareSelectedNoDBContext(beadsDir) + + if got := resolveWhereDatabasePath(); !utils.PathsEqual(got, dbDir) { + t.Fatalf("resolveWhereDatabasePath() = %q, want %q", got, dbDir) + } +} + +func TestIsSelectedNoDBCommand_Where(t *testing.T) { + cmd := &cobra.Command{Use: "where"} + + if !isSelectedNoDBCommand(cmd) { + t.Fatal("isSelectedNoDBCommand(where) = false, want true") + } +} + +func TestShouldReadWherePrefixFromStore(t *testing.T) { + t.Setenv("BEADS_DOLT_SERVER_MODE", "") + t.Setenv("BEADS_DOLT_SHARED_SERVER", "") + + t.Run("empty beads dir", func(t *testing.T) { + if got := shouldReadWherePrefixFromStore(""); got { + t.Fatal("shouldReadWherePrefixFromStore(\"\") = true, want false") + } + }) + + t.Run("missing metadata", func(t *testing.T) { + beadsDir := filepath.Join(t.TempDir(), ".beads") + if err := os.MkdirAll(beadsDir, 0o755); err != nil { + t.Fatalf("mkdir beads dir: %v", err) + } + if got := shouldReadWherePrefixFromStore(beadsDir); !got { + t.Fatal("shouldReadWherePrefixFromStore(missing metadata) = false, want true") + } + }) + + t.Run("server mode metadata", func(t *testing.T) { + beadsDir := filepath.Join(t.TempDir(), ".beads") + if err := os.MkdirAll(beadsDir, 0o755); err != nil { + t.Fatalf("mkdir beads dir: %v", err) + } + cfg := &configfile.Config{ + Backend: configfile.BackendDolt, + DoltMode: configfile.DoltModeServer, + } + if err := cfg.Save(beadsDir); err != nil { + t.Fatalf("save metadata: %v", err) + } + if got := shouldReadWherePrefixFromStore(beadsDir); got { + t.Fatal("shouldReadWherePrefixFromStore(server mode) = true, want false") + } + }) + + t.Run("embedded mode metadata", func(t *testing.T) { + beadsDir := filepath.Join(t.TempDir(), ".beads") + if err := os.MkdirAll(beadsDir, 0o755); err != nil { + t.Fatalf("mkdir beads dir: %v", err) + } + cfg := &configfile.Config{ + Backend: configfile.BackendDolt, + DoltMode: configfile.DoltModeEmbedded, + } + if err := cfg.Save(beadsDir); err != nil { + t.Fatalf("save metadata: %v", err) + } + if got := shouldReadWherePrefixFromStore(beadsDir); !got { + t.Fatal("shouldReadWherePrefixFromStore(embedded mode) = false, want true") + } + }) +} + +func TestWhereCommand_UsesConfigPrefixFromSelectedDB(t *testing.T) { + saveAndRestoreGlobals(t) + ensureCleanGlobalState(t) + initConfigForTest(t) + + originalCmdCtx := cmdCtx + originalJSONOutput := jsonOutput + originalRootCtx := rootCtx + defer func() { + cmdCtx = originalCmdCtx + jsonOutput = originalJSONOutput + rootCtx = originalRootCtx + }() + + resetCommandContext() + + repoDir := t.TempDir() + beadsDir := filepath.Join(repoDir, ".beads") + dbDir := filepath.Join(beadsDir, "dolt") + if err := os.MkdirAll(dbDir, 0o755); err != nil { + t.Fatalf("mkdir db dir: %v", err) + } + + cfg := &configfile.Config{ + Database: "dolt", + Backend: configfile.BackendDolt, + } + if err := cfg.Save(beadsDir); err != nil { + t.Fatalf("save metadata: %v", err) + } + if err := os.WriteFile(filepath.Join(beadsDir, "config.yaml"), []byte("issue-prefix: yamlprefix\n"), 0o644); err != nil { + t.Fatalf("write config.yaml: %v", err) + } + + t.Setenv("BEADS_DIR", "") + t.Setenv("BEADS_DB", dbDir) + t.Setenv("BD_DB", "") + + dbFlag := rootCmd.PersistentFlags().Lookup("db") + originalFlagValue := dbFlag.Value.String() + originalFlagChanged := dbFlag.Changed + if err := dbFlag.Value.Set(""); err != nil { + t.Fatalf("reset db flag: %v", err) + } + dbFlag.Changed = false + t.Cleanup(func() { + _ = dbFlag.Value.Set(originalFlagValue) + dbFlag.Changed = originalFlagChanged + }) + + jsonOutput = true + rootCtx = context.Background() + + output := captureStdout(t, func() error { + whereCmd.Run(whereCmd, nil) + return nil + }) + + var result WhereResult + if err := json.Unmarshal([]byte(output), &result); err != nil { + t.Fatalf("json.Unmarshal(%q): %v", output, err) + } + + if !utils.PathsEqual(result.Path, beadsDir) { + t.Fatalf("Path = %q, want %q", result.Path, beadsDir) + } + if !utils.PathsEqual(result.DatabasePath, dbDir) { + t.Fatalf("DatabasePath = %q, want %q", result.DatabasePath, dbDir) + } + if result.Prefix != "yamlprefix" { + t.Fatalf("Prefix = %q, want %q", result.Prefix, "yamlprefix") + } +}