Skip to content
Open
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
28 changes: 28 additions & 0 deletions internal/beads/beads.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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() {
Expand Down
354 changes: 354 additions & 0 deletions internal/beads/cwd_priority_test.go
Original file line number Diff line number Diff line change
@@ -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] != ".."))
}
Loading