diff --git a/cmd/bd/doctor/backend.go b/cmd/bd/doctor/backend.go index bf8bec5917..274d248587 100644 --- a/cmd/bd/doctor/backend.go +++ b/cmd/bd/doctor/backend.go @@ -5,11 +5,14 @@ import ( "os/exec" "path/filepath" "strings" + "sync" "github.com/steveyegge/beads/internal/configfile" "github.com/steveyegge/beads/internal/utils" ) +var resolveBeadsDirCache sync.Map + // getBackendAndBeadsDir resolves the effective .beads directory (following redirects) // and returns the configured storage backend ("dolt" by default). func getBackendAndBeadsDir(repoPath string) (backend string, beadsDir string) { @@ -23,10 +26,17 @@ func getBackendAndBeadsDir(repoPath string) (backend string, beadsDir string) { } func ResolveBeadsDirForRepo(repoPath string) string { - return resolveDoctorBeadsDir(repoPath) + cacheKey := utils.CanonicalizePath(repoPath) + if resolved, ok := resolveBeadsDirCache.Load(cacheKey); ok { + return resolved.(string) + } + + resolved := resolveBeadsDirForRepoUncached(repoPath) + resolveBeadsDirCache.Store(cacheKey, resolved) + return resolved } -func resolveDoctorBeadsDir(repoPath string) string { +func resolveBeadsDirForRepoUncached(repoPath string) string { localBeadsDir := filepath.Join(repoPath, ".beads") if info, err := os.Stat(localBeadsDir); err == nil && info.IsDir() { return resolveBeadsDir(localBeadsDir) @@ -39,6 +49,10 @@ func resolveDoctorBeadsDir(repoPath string) string { return resolveBeadsDir(localBeadsDir) } +func clearResolveBeadsDirCache() { + resolveBeadsDirCache = sync.Map{} +} + func worktreeFallbackBeadsDir(repoPath string) string { cmd := exec.Command("git", "-C", repoPath, "rev-parse", "--git-dir", "--git-common-dir") output, err := cmd.Output() diff --git a/cmd/bd/doctor/bare_parent_fallback_test.go b/cmd/bd/doctor/bare_parent_fallback_test.go index 976260c110..59b6812ff1 100644 --- a/cmd/bd/doctor/bare_parent_fallback_test.go +++ b/cmd/bd/doctor/bare_parent_fallback_test.go @@ -11,6 +11,9 @@ import ( ) func TestResolveBeadsDirForRepo_BareParentWorktreeFallback(t *testing.T) { + clearResolveBeadsDirCache() + t.Cleanup(clearResolveBeadsDirCache) + bareDir, featureWorktreeDir := setupBareParentWorktreeForDoctorTest(t) bareBeadsDir := filepath.Join(bareDir, ".beads") if err := os.MkdirAll(bareBeadsDir, 0o750); err != nil { @@ -23,7 +26,66 @@ func TestResolveBeadsDirForRepo_BareParentWorktreeFallback(t *testing.T) { } } +func TestResolveBeadsDirForRepo_CachesFallbackResult(t *testing.T) { + clearResolveBeadsDirCache() + t.Cleanup(clearResolveBeadsDirCache) + + tmpDir := t.TempDir() + repoPath := filepath.Join(tmpDir, "feature") + bareDir := filepath.Join(tmpDir, "repo.git") + bareBeadsDir := filepath.Join(bareDir, ".beads") + gitBinDir := filepath.Join(tmpDir, "bin") + gitLogPath := filepath.Join(tmpDir, "git.log") + gitScriptPath := filepath.Join(gitBinDir, "git") + + for _, dir := range []string{repoPath, bareBeadsDir, gitBinDir} { + if err := os.MkdirAll(dir, 0o750); err != nil { + t.Fatal(err) + } + } + + gitScript := strings.Join([]string{ + "#!/bin/sh", + "printf 'called\n' >> \"$FAKE_GIT_LOG\"", + "printf '%s\\n%s\\n' \"$FAKE_GIT_DIR\" \"$FAKE_GIT_COMMON_DIR\"", + "", + }, "\n") + if err := os.WriteFile(gitScriptPath, []byte(gitScript), 0o750); err != nil { + t.Fatal(err) + } + + t.Setenv("PATH", gitBinDir) + t.Setenv("FAKE_GIT_LOG", gitLogPath) + t.Setenv("FAKE_GIT_DIR", filepath.Join(bareDir, "worktrees", "feature")) + t.Setenv("FAKE_GIT_COMMON_DIR", bareDir) + + first := ResolveBeadsDirForRepo(repoPath) + if first != utils.CanonicalizePath(bareBeadsDir) { + t.Fatalf("first ResolveBeadsDirForRepo() = %q, want %q", first, utils.CanonicalizePath(bareBeadsDir)) + } + + if err := os.Remove(gitScriptPath); err != nil { + t.Fatal(err) + } + + second := ResolveBeadsDirForRepo(repoPath) + if second != first { + t.Fatalf("second ResolveBeadsDirForRepo() = %q, want cached %q", second, first) + } + + logData, err := os.ReadFile(gitLogPath) + if err != nil { + t.Fatal(err) + } + if calls := strings.Count(string(logData), "called\n"); calls != 1 { + t.Fatalf("git fallback call count = %d, want 1", calls) + } +} + func TestCheckMetadataVersionTracking_BareParentWorktreeFallback(t *testing.T) { + clearResolveBeadsDirCache() + t.Cleanup(clearResolveBeadsDirCache) + bareDir, featureWorktreeDir := setupBareParentWorktreeForDoctorTest(t) bareBeadsDir := filepath.Join(bareDir, ".beads") if err := os.MkdirAll(bareBeadsDir, 0o750); err != nil { @@ -40,6 +102,9 @@ func TestCheckMetadataVersionTracking_BareParentWorktreeFallback(t *testing.T) { } func TestCheckLockHealth_BareParentWorktreeFallback(t *testing.T) { + clearResolveBeadsDirCache() + t.Cleanup(clearResolveBeadsDirCache) + bareDir, featureWorktreeDir := setupBareParentWorktreeForDoctorTest(t) bareBeadsDir := filepath.Join(bareDir, ".beads") if err := os.MkdirAll(filepath.Join(bareBeadsDir, "dolt"), 0o750); err != nil { @@ -56,6 +121,9 @@ func TestCheckLockHealth_BareParentWorktreeFallback(t *testing.T) { } func TestCheckDoltLocks_BareParentWorktreeFallback(t *testing.T) { + clearResolveBeadsDirCache() + t.Cleanup(clearResolveBeadsDirCache) + bareDir, featureWorktreeDir := setupBareParentWorktreeForDoctorTest(t) bareBeadsDir := filepath.Join(bareDir, ".beads") if err := os.MkdirAll(bareBeadsDir, 0o750); err != nil { diff --git a/default.nix b/default.nix index 414d84fcfd..d5555150bb 100644 --- a/default.nix +++ b/default.nix @@ -16,7 +16,7 @@ buildGoModule { doCheck = false; # Go module dependencies hash - if build fails with hash mismatch, update with the "got:" value - vendorHash = "sha256-1BJsEPP5SYZFGCWHLn532IUKlzcGDg5nhrqGWylEHgY="; + vendorHash = "sha256-wcFAvGoDR9IYckWRMqPqCgPSUKmoYYyYg0dfNGDI6Go="; # Relax go.mod version for Nix: nixpkgs Go may lag behind the latest # patch release, and GOTOOLCHAIN=auto can't download in the Nix sandbox.