From 2a99a63939bbfd0b07d4628a09909d675816a795 Mon Sep 17 00:00:00 2001 From: julianknutsen Date: Wed, 25 Mar 2026 07:38:46 +0100 Subject: [PATCH] fix: check cwd for .beads/ before git-worktree resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a subdirectory has its own .beads/ inside a git worktree that also has .beads/ at its root, the git-worktree check (step 2b) finds the root's .beads/ before the cwd walk (step 3) can discover the local one. This causes incorrect database connections when orchestrators like Gas City manage multiple "rigs" (project subdirectories) that each have their own beads database, nested inside a parent git repo that also tracks beads. Fix: Add a cwd check before git-worktree resolution in both FindBeadsDir() and findDatabaseInTree(). If cwd has a valid .beads/ directory (with project files or a database), return it immediately. Priority order is now: 1. BEADS_DIR env var (unchanged) 1b. cwd .beads/ (NEW — most-local directory wins) 2. Git worktree root .beads/ (unchanged) 3. Walk up from cwd (unchanged) This preserves all existing behavior for single-project repos, worktrees, and redirects. The cwd check only fires when cwd literally contains .beads/ with valid project files. Tests added: - TestFindBeadsDir_CwdPriority: rig .beads/ wins over root .beads/ - TestFindDatabasePath_CwdPriority: same for database discovery - TestFindBeadsDir_CwdWithoutBeads_FallsBackToWalk: normal fallback - TestFindBeadsDir_CwdBeadsDirWithRedirect: redirect in cwd followed - TestFindBeadsDir_BEADS_DIR_StillTakesPriority: env var still wins - TestFindBeadsDir_CwdEmptyBeadsDir_SkipsToCwdWalk: empty dir skipped --- internal/beads/beads.go | 28 +++ internal/beads/cwd_priority_test.go | 354 ++++++++++++++++++++++++++++ 2 files changed, 382 insertions(+) create mode 100644 internal/beads/cwd_priority_test.go diff --git a/internal/beads/beads.go b/internal/beads/beads.go index 87e976bf86..21b3518638 100644 --- a/internal/beads/beads.go +++ b/internal/beads/beads.go @@ -563,6 +563,21 @@ func FindBeadsDir() string { } } + // 1b. Check cwd for .beads/ before git-worktree resolution. + // When cwd is a subdirectory (e.g. a rig) that has its own .beads/ + // inside a git worktree that also has .beads/ at its root, step 2b + // would grab the worktree root's .beads/ first. This check ensures + // the most-local .beads/ wins. (GH#XXXX) + if cwd, err := os.Getwd(); err == nil { + cwdBeadsDir := filepath.Join(cwd, ".beads") + if info, err := os.Stat(cwdBeadsDir); err == nil && info.IsDir() { + cwdBeadsDir = FollowRedirect(cwdBeadsDir) + if hasBeadsProjectFiles(cwdBeadsDir) { + return cwdBeadsDir + } + } + } + // 2. For worktrees, check worktree-local redirect first, then own .beads, then main repo var mainRepoRoot string if git.IsWorktree() { @@ -735,6 +750,19 @@ func findDatabaseInTree() string { dir = resolvedDir } + // Check cwd first — a subdirectory with its own .beads/ takes priority + // over the git worktree root's .beads/. Same rationale as FindBeadsDir + // step 1b. (GH#XXXX) + { + cwdBeadsDir := filepath.Join(dir, ".beads") + if info, err := os.Stat(cwdBeadsDir); err == nil && info.IsDir() { + cwdBeadsDir = FollowRedirect(cwdBeadsDir) + if dbPath := findDatabaseInBeadsDir(cwdBeadsDir, true); dbPath != "" { + return dbPath + } + } + } + // Check if we're in a git worktree var mainRepoRoot string if git.IsWorktree() { diff --git a/internal/beads/cwd_priority_test.go b/internal/beads/cwd_priority_test.go new file mode 100644 index 0000000000..51b6a8a1d1 --- /dev/null +++ b/internal/beads/cwd_priority_test.go @@ -0,0 +1,354 @@ +package beads + +import ( + "os" + "os/exec" + "path/filepath" + "testing" +) + +// TestFindBeadsDir_CwdPriority verifies that a .beads/ directory in cwd takes +// priority over a .beads/ directory at the git worktree root. +// +// Scenario: A "rig" subdirectory has its own .beads/ inside a git worktree +// that also has .beads/ at its root. Before this fix, step 2b +// (git.GetRepoRoot → check .beads/) fired before the cwd walk, grabbing +// the worktree root's .beads/ instead of the rig's local one. +func TestFindBeadsDir_CwdPriority(t *testing.T) { + // Save and restore env + origBeadsDir := os.Getenv("BEADS_DIR") + t.Cleanup(func() { + if origBeadsDir != "" { + os.Setenv("BEADS_DIR", origBeadsDir) + } else { + os.Unsetenv("BEADS_DIR") + } + }) + os.Unsetenv("BEADS_DIR") + + tmpDir := t.TempDir() + + // Create a git repo (simulating the worktree root) + cmd := exec.Command("git", "init", tmpDir) + if err := cmd.Run(); err != nil { + t.Skipf("git not available: %v", err) + } + + // Create root-level .beads/ with project files (the "wrong" one) + rootBeadsDir := filepath.Join(tmpDir, ".beads") + if err := os.MkdirAll(rootBeadsDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(rootBeadsDir, "metadata.json"), []byte(`{"backend":"dolt","dolt_database":"root_db"}`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(rootBeadsDir, "config.yaml"), []byte("issue_prefix: root\n"), 0o644); err != nil { + t.Fatal(err) + } + + // Create a rig subdirectory with its own .beads/ (the "right" one) + rigDir := filepath.Join(tmpDir, "my-rig") + rigBeadsDir := filepath.Join(rigDir, ".beads") + if err := os.MkdirAll(rigBeadsDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(rigBeadsDir, "metadata.json"), []byte(`{"backend":"dolt","dolt_database":"rig_db"}`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(rigBeadsDir, "config.yaml"), []byte("issue_prefix: rig\n"), 0o644); err != nil { + t.Fatal(err) + } + + // cd into the rig directory + t.Chdir(rigDir) + + result := FindBeadsDir() + + resultResolved, _ := filepath.EvalSymlinks(result) + expectedResolved, _ := filepath.EvalSymlinks(rigBeadsDir) + if resultResolved != expectedResolved { + t.Errorf("FindBeadsDir() = %q, want %q (rig's .beads should win over root's)", result, rigBeadsDir) + } +} + +// TestFindDatabasePath_CwdPriority verifies FindDatabasePath (the database +// discovery path) also prefers cwd's .beads/ over the git worktree root's. +func TestFindDatabasePath_CwdPriority(t *testing.T) { + origBeadsDir := os.Getenv("BEADS_DIR") + origBeadsDB := os.Getenv("BEADS_DB") + t.Cleanup(func() { + if origBeadsDir != "" { + os.Setenv("BEADS_DIR", origBeadsDir) + } else { + os.Unsetenv("BEADS_DIR") + } + if origBeadsDB != "" { + os.Setenv("BEADS_DB", origBeadsDB) + } else { + os.Unsetenv("BEADS_DB") + } + }) + os.Unsetenv("BEADS_DIR") + os.Unsetenv("BEADS_DB") + + tmpDir := t.TempDir() + + // Create a git repo + cmd := exec.Command("git", "init", tmpDir) + if err := cmd.Run(); err != nil { + t.Skipf("git not available: %v", err) + } + + // Create root-level .beads/ with a dolt dir (the "wrong" one) + rootBeadsDir := filepath.Join(tmpDir, ".beads") + rootDoltDir := filepath.Join(rootBeadsDir, "dolt") + if err := os.MkdirAll(rootDoltDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(rootBeadsDir, "metadata.json"), []byte(`{"backend":"dolt","dolt_database":"root_db"}`), 0o644); err != nil { + t.Fatal(err) + } + + // Create rig subdirectory with its own .beads/ and dolt dir + rigDir := filepath.Join(tmpDir, "my-rig") + rigBeadsDir := filepath.Join(rigDir, ".beads") + rigDoltDir := filepath.Join(rigBeadsDir, "dolt") + if err := os.MkdirAll(rigDoltDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(rigBeadsDir, "metadata.json"), []byte(`{"backend":"dolt","dolt_database":"rig_db"}`), 0o644); err != nil { + t.Fatal(err) + } + + // cd into the rig directory + t.Chdir(rigDir) + + result := FindDatabasePath() + + // The database path should be under the rig's .beads/, not the root's + if result == "" { + t.Fatal("FindDatabasePath() returned empty, expected rig's database path") + } + + resultResolved, _ := filepath.EvalSymlinks(result) + if !isUnder(resultResolved, rigDir) { + t.Errorf("FindDatabasePath() = %q, want path under %q (rig's .beads should win)", result, rigDir) + } + if isUnder(resultResolved, rootBeadsDir) { + t.Errorf("FindDatabasePath() = %q, should NOT be under root's .beads %q", result, rootBeadsDir) + } +} + +// TestFindBeadsDir_CwdWithoutBeads_FallsBackToWalk verifies that when cwd +// has no .beads/, the normal walk-up behavior still works. +func TestFindBeadsDir_CwdWithoutBeads_FallsBackToWalk(t *testing.T) { + origBeadsDir := os.Getenv("BEADS_DIR") + t.Cleanup(func() { + if origBeadsDir != "" { + os.Setenv("BEADS_DIR", origBeadsDir) + } else { + os.Unsetenv("BEADS_DIR") + } + }) + os.Unsetenv("BEADS_DIR") + + tmpDir := t.TempDir() + + // Create a git repo + cmd := exec.Command("git", "init", tmpDir) + if err := cmd.Run(); err != nil { + t.Skipf("git not available: %v", err) + } + + // Create root-level .beads/ only (no rig-level .beads/) + rootBeadsDir := filepath.Join(tmpDir, ".beads") + if err := os.MkdirAll(rootBeadsDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(rootBeadsDir, "metadata.json"), []byte(`{"backend":"dolt"}`), 0o644); err != nil { + t.Fatal(err) + } + + // Create a subdirectory WITHOUT .beads/ + subDir := filepath.Join(tmpDir, "some", "deep", "subdir") + if err := os.MkdirAll(subDir, 0o755); err != nil { + t.Fatal(err) + } + + t.Chdir(subDir) + + result := FindBeadsDir() + + resultResolved, _ := filepath.EvalSymlinks(result) + expectedResolved, _ := filepath.EvalSymlinks(rootBeadsDir) + if resultResolved != expectedResolved { + t.Errorf("FindBeadsDir() = %q, want %q (should fall back to root when cwd has no .beads/)", result, rootBeadsDir) + } +} + +// TestFindBeadsDir_CwdBeadsDirWithRedirect verifies that cwd's .beads/ +// redirect is followed when the cwd check fires. +func TestFindBeadsDir_CwdBeadsDirWithRedirect(t *testing.T) { + origBeadsDir := os.Getenv("BEADS_DIR") + t.Cleanup(func() { + if origBeadsDir != "" { + os.Setenv("BEADS_DIR", origBeadsDir) + } else { + os.Unsetenv("BEADS_DIR") + } + }) + os.Unsetenv("BEADS_DIR") + + tmpDir := t.TempDir() + + // Create a git repo + cmd := exec.Command("git", "init", tmpDir) + if err := cmd.Run(); err != nil { + t.Skipf("git not available: %v", err) + } + + // Create root-level .beads/ + rootBeadsDir := filepath.Join(tmpDir, ".beads") + if err := os.MkdirAll(rootBeadsDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(rootBeadsDir, "metadata.json"), []byte(`{"backend":"dolt","dolt_database":"root_db"}`), 0o644); err != nil { + t.Fatal(err) + } + + // Create a redirect target + targetBeadsDir := filepath.Join(tmpDir, "shared-beads") + if err := os.MkdirAll(targetBeadsDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(targetBeadsDir, "metadata.json"), []byte(`{"backend":"dolt","dolt_database":"shared_db"}`), 0o644); err != nil { + t.Fatal(err) + } + + // Create rig subdirectory with .beads/ that has a redirect + rigDir := filepath.Join(tmpDir, "my-rig") + rigBeadsDir := filepath.Join(rigDir, ".beads") + if err := os.MkdirAll(rigBeadsDir, 0o755); err != nil { + t.Fatal(err) + } + // Write redirect file pointing to the shared target + if err := os.WriteFile(filepath.Join(rigBeadsDir, "redirect"), []byte(targetBeadsDir), 0o644); err != nil { + t.Fatal(err) + } + + t.Chdir(rigDir) + + result := FindBeadsDir() + + resultResolved, _ := filepath.EvalSymlinks(result) + expectedResolved, _ := filepath.EvalSymlinks(targetBeadsDir) + if resultResolved != expectedResolved { + t.Errorf("FindBeadsDir() = %q, want %q (cwd .beads/ redirect should be followed)", result, targetBeadsDir) + } +} + +// TestFindBeadsDir_BEADS_DIR_StillTakesPriority verifies that BEADS_DIR env +// var still takes priority over the cwd check. +func TestFindBeadsDir_BEADS_DIR_StillTakesPriority(t *testing.T) { + origBeadsDir := os.Getenv("BEADS_DIR") + t.Cleanup(func() { + if origBeadsDir != "" { + os.Setenv("BEADS_DIR", origBeadsDir) + } else { + os.Unsetenv("BEADS_DIR") + } + }) + + tmpDir := t.TempDir() + + // Create an explicit BEADS_DIR target + explicitBeadsDir := filepath.Join(tmpDir, "explicit-beads") + if err := os.MkdirAll(explicitBeadsDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(explicitBeadsDir, "metadata.json"), []byte(`{"backend":"dolt","dolt_database":"explicit_db"}`), 0o644); err != nil { + t.Fatal(err) + } + os.Setenv("BEADS_DIR", explicitBeadsDir) + + // Create cwd with its own .beads/ + cwdDir := filepath.Join(tmpDir, "cwd-project") + cwdBeadsDir := filepath.Join(cwdDir, ".beads") + if err := os.MkdirAll(cwdBeadsDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(cwdBeadsDir, "metadata.json"), []byte(`{"backend":"dolt","dolt_database":"cwd_db"}`), 0o644); err != nil { + t.Fatal(err) + } + + t.Chdir(cwdDir) + + result := FindBeadsDir() + + resultResolved, _ := filepath.EvalSymlinks(result) + expectedResolved, _ := filepath.EvalSymlinks(explicitBeadsDir) + if resultResolved != expectedResolved { + t.Errorf("FindBeadsDir() = %q, want %q (BEADS_DIR should still take priority over cwd)", result, explicitBeadsDir) + } +} + +// TestFindBeadsDir_CwdEmptyBeadsDir_SkipsToCwdWalk verifies that when cwd +// has a .beads/ directory without any project files, it's skipped and the +// normal walk-up behavior continues. +func TestFindBeadsDir_CwdEmptyBeadsDir_SkipsToCwdWalk(t *testing.T) { + origBeadsDir := os.Getenv("BEADS_DIR") + t.Cleanup(func() { + if origBeadsDir != "" { + os.Setenv("BEADS_DIR", origBeadsDir) + } else { + os.Unsetenv("BEADS_DIR") + } + }) + os.Unsetenv("BEADS_DIR") + + tmpDir := t.TempDir() + + // Create a git repo + cmd := exec.Command("git", "init", tmpDir) + if err := cmd.Run(); err != nil { + t.Skipf("git not available: %v", err) + } + + // Create root-level .beads/ with project files + rootBeadsDir := filepath.Join(tmpDir, ".beads") + if err := os.MkdirAll(rootBeadsDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(rootBeadsDir, "metadata.json"), []byte(`{"backend":"dolt"}`), 0o644); err != nil { + t.Fatal(err) + } + + // Create rig with empty .beads/ (no project files) + rigDir := filepath.Join(tmpDir, "empty-rig") + rigBeadsDir := filepath.Join(rigDir, ".beads") + if err := os.MkdirAll(rigBeadsDir, 0o755); err != nil { + t.Fatal(err) + } + // No metadata.json, no config.yaml, no dolt/ — empty dir + + t.Chdir(rigDir) + + result := FindBeadsDir() + + // Should fall through to the root's .beads/ since rig's is empty + resultResolved, _ := filepath.EvalSymlinks(result) + expectedResolved, _ := filepath.EvalSymlinks(rootBeadsDir) + if resultResolved != expectedResolved { + t.Errorf("FindBeadsDir() = %q, want %q (empty cwd .beads/ should be skipped)", result, rootBeadsDir) + } +} + +// isUnder returns true if child is under parent in the directory tree. +func isUnder(child, parent string) bool { + rel, err := filepath.Rel(parent, child) + if err != nil { + return false + } + // rel should not start with ".." (going up) and should not be absolute + return !filepath.IsAbs(rel) && (rel == "." || (len(rel) >= 2 && rel[:2] != "..")) +}