feat: add Git worktree compatibility (PR #478)

Adds comprehensive Git worktree support for beads issue tracking:

Core changes:
- New internal/git/gitdir.go package for worktree detection
- GetGitDir() returns proper .git location (main repo, not worktree)
- Updated all hooks to use git.GetGitDir() instead of local helper
- BeadsDir() now prioritizes main repository's .beads directory

Features:
- Hooks auto-install in main repo when run from worktree
- Shared .beads directory across all worktrees
- Config option no-install-hooks to disable auto-install
- New bd worktree subcommand for diagnostics

Documentation:
- New docs/WORKTREES.md with setup instructions
- Updated CHANGELOG.md and AGENT_INSTRUCTIONS.md

Testing:
- Updated tests to use exported git.GetGitDir()
- Added worktree detection tests

Co-authored-by: Claude <noreply@anthropic.com>
Closes: #478
This commit is contained in:
matt wilkie
2025-12-13 10:40:40 -08:00
committed by Steve Yegge
parent de7b511765
commit e01b7412d9
64 changed files with 1895 additions and 3708 deletions

View File

@@ -16,6 +16,7 @@ import (
"strings"
"github.com/steveyegge/beads/internal/configfile"
"github.com/steveyegge/beads/internal/git"
"github.com/steveyegge/beads/internal/storage"
"github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/types"
@@ -342,6 +343,7 @@ func hasBeadsProjectFiles(beadsDir string) bool {
// Validates that the directory contains actual project files (bd-420).
// Redirect files are supported: if a .beads/redirect file exists, its contents
// are used as the actual .beads directory path.
// For worktrees, prioritizes the main repository's .beads directory (bd-de6).
// This is useful for commands that need to detect beads projects without requiring a database.
func FindBeadsDir() string {
// 1. Check BEADS_DIR environment variable (preferred)
@@ -359,7 +361,26 @@ func FindBeadsDir() string {
}
}
// 2. Search for .beads/ in current directory and ancestors
// 2. For worktrees, check main repository root first (bd-de6)
var mainRepoRoot string
if git.IsWorktree() {
var err error
mainRepoRoot, err = git.GetMainRepoRoot()
if err == nil && mainRepoRoot != "" {
beadsDir := filepath.Join(mainRepoRoot, ".beads")
if info, err := os.Stat(beadsDir); err == nil && info.IsDir() {
// Follow redirect if present
beadsDir = followRedirect(beadsDir)
// Validate directory contains actual project files (bd-420)
if hasBeadsProjectFiles(beadsDir) {
return beadsDir
}
}
}
}
// 3. Search for .beads/ in current directory and ancestors
cwd, err := os.Getwd()
if err != nil {
return ""
@@ -367,6 +388,10 @@ func FindBeadsDir() string {
// Find git root to limit the search (bd-c8x)
gitRoot := findGitRoot()
if git.IsWorktree() && mainRepoRoot != "" {
// For worktrees, extend search boundary to include main repo
gitRoot = mainRepoRoot
}
for dir := cwd; dir != "/" && dir != "."; dir = filepath.Dir(dir) {
beadsDir := filepath.Join(dir, ".beads")
@@ -414,6 +439,9 @@ type DatabaseInfo struct {
// findGitRoot returns the root directory of the current git repository,
// or empty string if not in a git repository. Used to limit directory
// tree walking to within the current git repo (bd-c8x).
//
// This function is worktree-aware and will correctly identify the repository
// root in both regular repositories and git worktrees.
func findGitRoot() string {
cmd := exec.Command("git", "rev-parse", "--show-toplevel")
output, err := cmd.Output()
@@ -425,6 +453,7 @@ func findGitRoot() string {
// findDatabaseInTree walks up the directory tree looking for .beads/*.db
// Stops at the git repository root to avoid finding unrelated databases (bd-c8x).
// For worktrees, searches the main repository root first, then falls back to worktree.
// Prefers config.json, falls back to beads.db, and warns if multiple .db files exist.
// Redirect files are supported: if a .beads/redirect file exists, its contents
// are used as the actual .beads directory path.
@@ -440,10 +469,35 @@ func findDatabaseInTree() string {
dir = resolvedDir
}
// Check if we're in a git worktree
var mainRepoRoot string
if git.IsWorktree() {
// For worktrees, search main repository root first
var err error
mainRepoRoot, err = git.GetMainRepoRoot()
if err == nil && mainRepoRoot != "" {
beadsDir := filepath.Join(mainRepoRoot, ".beads")
if info, err := os.Stat(beadsDir); err == nil && info.IsDir() {
// Follow redirect if present
beadsDir = followRedirect(beadsDir)
// Use helper to find database (with warnings for auto-discovery)
if dbPath := findDatabaseInBeadsDir(beadsDir, true); dbPath != "" {
return dbPath
}
}
}
// If not found in main repo, fall back to worktree search below
}
// Find git root to limit the search (bd-c8x)
gitRoot := findGitRoot()
if git.IsWorktree() && mainRepoRoot != "" {
// For worktrees, extend search boundary to include main repo
gitRoot = mainRepoRoot
}
// Walk up directory tree
// Walk up directory tree (regular repository or worktree fallback)
for {
beadsDir := filepath.Join(dir, ".beads")
if info, err := os.Stat(beadsDir); err == nil && info.IsDir() {

View File

@@ -2,6 +2,7 @@ package beads
import (
"os"
"os/exec"
"path/filepath"
"testing"
)
@@ -782,3 +783,473 @@ func TestFindBeadsDirWithRedirect(t *testing.T) {
t.Errorf("FindBeadsDir() = %q, want %q (via redirect)", result, targetDir)
}
}
// TestFindGitRoot_RegularRepo tests that findGitRoot returns the correct path
// in a regular git repository (not a worktree).
func TestFindGitRoot_RegularRepo(t *testing.T) {
// Create temporary directory for our test repo
tmpDir, err := os.MkdirTemp("", "beads-gitroot-test-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
// Initialize a git repository
repoDir := filepath.Join(tmpDir, "main-repo")
if err := os.MkdirAll(repoDir, 0755); err != nil {
t.Fatal(err)
}
cmd := exec.Command("git", "init")
cmd.Dir = repoDir
if err := cmd.Run(); err != nil {
t.Skipf("git not available: %v", err)
}
// Configure git user for the test repo (required for commits)
cmd = exec.Command("git", "config", "user.email", "test@example.com")
cmd.Dir = repoDir
_ = cmd.Run()
cmd = exec.Command("git", "config", "user.name", "Test User")
cmd.Dir = repoDir
_ = cmd.Run()
// Create a subdirectory and change to it
subDir := filepath.Join(repoDir, "sub", "nested")
if err := os.MkdirAll(subDir, 0755); err != nil {
t.Fatal(err)
}
t.Chdir(subDir)
// findGitRoot should return the repo root
result := findGitRoot()
// Resolve symlinks for comparison (macOS /var -> /private/var)
resultResolved, _ := filepath.EvalSymlinks(result)
repoDirResolved, _ := filepath.EvalSymlinks(repoDir)
if resultResolved != repoDirResolved {
t.Errorf("findGitRoot() = %q, want %q", result, repoDir)
}
}
// TestFindGitRoot_Worktree tests that findGitRoot returns the worktree root
// (not the main repository root) when inside a git worktree. This is critical
// for bd-745 - ensuring database discovery works correctly in worktrees.
func TestFindGitRoot_Worktree(t *testing.T) {
// Create temporary directory for our test
tmpDir, err := os.MkdirTemp("", "beads-worktree-test-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
// Initialize a git repository
mainRepoDir := filepath.Join(tmpDir, "main-repo")
if err := os.MkdirAll(mainRepoDir, 0755); err != nil {
t.Fatal(err)
}
cmd := exec.Command("git", "init")
cmd.Dir = mainRepoDir
if err := cmd.Run(); err != nil {
t.Skipf("git not available: %v", err)
}
// Configure git user for the test repo (required for commits)
cmd = exec.Command("git", "config", "user.email", "test@example.com")
cmd.Dir = mainRepoDir
_ = cmd.Run()
cmd = exec.Command("git", "config", "user.name", "Test User")
cmd.Dir = mainRepoDir
_ = cmd.Run()
// Create an initial commit (required for worktree)
dummyFile := filepath.Join(mainRepoDir, "README.md")
if err := os.WriteFile(dummyFile, []byte("# Test\n"), 0644); err != nil {
t.Fatal(err)
}
cmd = exec.Command("git", "add", "README.md")
cmd.Dir = mainRepoDir
if err := cmd.Run(); err != nil {
t.Fatal(err)
}
cmd = exec.Command("git", "commit", "-m", "Initial commit")
cmd.Dir = mainRepoDir
if err := cmd.Run(); err != nil {
t.Fatalf("git commit failed: %v", err)
}
// Create a worktree
worktreeDir := filepath.Join(tmpDir, "worktree")
cmd = exec.Command("git", "worktree", "add", worktreeDir, "HEAD")
cmd.Dir = mainRepoDir
if err := cmd.Run(); err != nil {
t.Fatalf("git worktree add failed: %v", err)
}
defer func() {
// Clean up worktree
cmd := exec.Command("git", "worktree", "remove", worktreeDir)
cmd.Dir = mainRepoDir
_ = cmd.Run()
}()
// Change to the worktree directory
t.Chdir(worktreeDir)
// findGitRoot should return the WORKTREE root, not the main repo root
result := findGitRoot()
// Resolve symlinks for comparison
resultResolved, _ := filepath.EvalSymlinks(result)
worktreeDirResolved, _ := filepath.EvalSymlinks(worktreeDir)
mainRepoDirResolved, _ := filepath.EvalSymlinks(mainRepoDir)
if resultResolved != worktreeDirResolved {
t.Errorf("findGitRoot() = %q, want worktree %q (not main repo %q)", result, worktreeDir, mainRepoDir)
}
// Additional verification: ensure we're NOT returning the main repo
if resultResolved == mainRepoDirResolved {
t.Errorf("findGitRoot() returned main repo %q instead of worktree %q - worktree detection is broken!", mainRepoDir, worktreeDir)
}
}
// TestFindGitRoot_NotGitRepo tests that findGitRoot returns an empty string
// when not inside a git repository.
func TestFindGitRoot_NotGitRepo(t *testing.T) {
// Create temporary directory that is NOT a git repo
tmpDir, err := os.MkdirTemp("", "beads-nogit-test-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
t.Chdir(tmpDir)
// findGitRoot should return empty string
result := findGitRoot()
if result != "" {
t.Errorf("findGitRoot() = %q, want empty string (not in git repo)", result)
}
}
// TestFindBeadsDir_Worktree tests that FindBeadsDir correctly finds the .beads
// directory within a git worktree, respecting the worktree boundary and not
// searching into the main repository. This is critical for bd-745.
func TestFindBeadsDir_Worktree(t *testing.T) {
// Save original state
originalEnv := os.Getenv("BEADS_DIR")
defer func() {
if originalEnv != "" {
os.Setenv("BEADS_DIR", originalEnv)
} else {
os.Unsetenv("BEADS_DIR")
}
}()
os.Unsetenv("BEADS_DIR")
// Create temporary directory for our test
tmpDir, err := os.MkdirTemp("", "beads-worktree-finddir-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
// Initialize main git repository
mainRepoDir := filepath.Join(tmpDir, "main-repo")
if err := os.MkdirAll(mainRepoDir, 0755); err != nil {
t.Fatal(err)
}
cmd := exec.Command("git", "init")
cmd.Dir = mainRepoDir
if err := cmd.Run(); err != nil {
t.Skipf("git not available: %v", err)
}
// Configure git user
cmd = exec.Command("git", "config", "user.email", "test@example.com")
cmd.Dir = mainRepoDir
_ = cmd.Run()
cmd = exec.Command("git", "config", "user.name", "Test User")
cmd.Dir = mainRepoDir
_ = cmd.Run()
// Create .beads directory in main repo with a database
mainBeadsDir := filepath.Join(mainRepoDir, ".beads")
if err := os.MkdirAll(mainBeadsDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(mainBeadsDir, "beads.db"), []byte{}, 0644); err != nil {
t.Fatal(err)
}
// Create initial commit
if err := os.WriteFile(filepath.Join(mainRepoDir, "README.md"), []byte("# Test\n"), 0644); err != nil {
t.Fatal(err)
}
cmd = exec.Command("git", "add", "-A")
cmd.Dir = mainRepoDir
_ = cmd.Run()
cmd = exec.Command("git", "commit", "-m", "Initial commit")
cmd.Dir = mainRepoDir
if err := cmd.Run(); err != nil {
t.Fatalf("git commit failed: %v", err)
}
// Create a worktree
worktreeDir := filepath.Join(tmpDir, "worktree")
cmd = exec.Command("git", "worktree", "add", worktreeDir, "HEAD")
cmd.Dir = mainRepoDir
if err := cmd.Run(); err != nil {
t.Fatalf("git worktree add failed: %v", err)
}
defer func() {
cmd := exec.Command("git", "worktree", "remove", worktreeDir)
cmd.Dir = mainRepoDir
_ = cmd.Run()
}()
// Create .beads directory in worktree with its own database
worktreeBeadsDir := filepath.Join(worktreeDir, ".beads")
if err := os.MkdirAll(worktreeBeadsDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(worktreeBeadsDir, "beads.db"), []byte{}, 0644); err != nil {
t.Fatal(err)
}
// Change to worktree
t.Chdir(worktreeDir)
// FindBeadsDir should prioritize the main repo's .beads for worktrees (bd-de6)
result := FindBeadsDir()
// Resolve symlinks for comparison
resultResolved, _ := filepath.EvalSymlinks(result)
worktreeBeadsDirResolved, _ := filepath.EvalSymlinks(worktreeBeadsDir)
mainBeadsDirResolved, _ := filepath.EvalSymlinks(mainBeadsDir)
if resultResolved != mainBeadsDirResolved {
t.Errorf("FindBeadsDir() = %q, want main repo .beads %q (prioritized for worktrees)", result, mainBeadsDir)
}
// Verify we're NOT finding the worktree's .beads (should fall back only if main repo has no .beads)
if resultResolved == worktreeBeadsDirResolved {
t.Errorf("FindBeadsDir() returned worktree .beads %q instead of main repo .beads %q - prioritization not working!", worktreeBeadsDir, mainBeadsDir)
}
}
// TestFindDatabasePath_Worktree tests that FindDatabasePath correctly finds the
// shared database in the main repository when accessed from a git worktree. This is the
// key test for bd-745 - worktrees should share the same .beads database.
func TestFindDatabasePath_Worktree(t *testing.T) {
// Save original state
originalEnvDir := os.Getenv("BEADS_DIR")
originalEnvDB := os.Getenv("BEADS_DB")
defer func() {
if originalEnvDir != "" {
os.Setenv("BEADS_DIR", originalEnvDir)
} else {
os.Unsetenv("BEADS_DIR")
}
if originalEnvDB != "" {
os.Setenv("BEADS_DB", originalEnvDB)
} else {
os.Unsetenv("BEADS_DB")
}
}()
os.Unsetenv("BEADS_DIR")
os.Unsetenv("BEADS_DB")
// Create temporary directory for our test
tmpDir, err := os.MkdirTemp("", "beads-worktree-finddb-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
// Initialize main git repository
mainRepoDir := filepath.Join(tmpDir, "main-repo")
if err := os.MkdirAll(mainRepoDir, 0755); err != nil {
t.Fatal(err)
}
cmd := exec.Command("git", "init")
cmd.Dir = mainRepoDir
if err := cmd.Run(); err != nil {
t.Skipf("git not available: %v", err)
}
// Configure git user
cmd = exec.Command("git", "config", "user.email", "test@example.com")
cmd.Dir = mainRepoDir
_ = cmd.Run()
cmd = exec.Command("git", "config", "user.name", "Test User")
cmd.Dir = mainRepoDir
_ = cmd.Run()
// Create .beads directory in main repo with database
mainBeadsDir := filepath.Join(mainRepoDir, ".beads")
if err := os.MkdirAll(mainBeadsDir, 0755); err != nil {
t.Fatal(err)
}
mainDBPath := filepath.Join(mainBeadsDir, "beads.db")
if err := os.WriteFile(mainDBPath, []byte{}, 0644); err != nil {
t.Fatal(err)
}
// Create initial commit
if err := os.WriteFile(filepath.Join(mainRepoDir, "README.md"), []byte("# Test\n"), 0644); err != nil {
t.Fatal(err)
}
cmd = exec.Command("git", "add", "-A")
cmd.Dir = mainRepoDir
_ = cmd.Run()
cmd = exec.Command("git", "commit", "-m", "Initial commit")
cmd.Dir = mainRepoDir
if err := cmd.Run(); err != nil {
t.Fatalf("git commit failed: %v", err)
}
// Create a worktree
worktreeDir := filepath.Join(tmpDir, "worktree")
cmd = exec.Command("git", "worktree", "add", worktreeDir, "HEAD")
cmd.Dir = mainRepoDir
if err := cmd.Run(); err != nil {
t.Fatalf("git worktree add failed: %v", err)
}
defer func() {
cmd := exec.Command("git", "worktree", "remove", worktreeDir)
cmd.Dir = mainRepoDir
_ = cmd.Run()
}()
// Change to worktree subdirectory
worktreeSubDir := filepath.Join(worktreeDir, "sub", "nested")
if err := os.MkdirAll(worktreeSubDir, 0755); err != nil {
t.Fatal(err)
}
t.Chdir(worktreeSubDir)
// FindDatabasePath should find the main repo's shared database
result := FindDatabasePath()
// Resolve symlinks for comparison
resultResolved, _ := filepath.EvalSymlinks(result)
mainDBPathResolved, _ := filepath.EvalSymlinks(mainDBPath)
if resultResolved != mainDBPathResolved {
t.Errorf("FindDatabasePath() = %q, want main repo shared db %q", result, mainDBPath)
}
}
// TestFindDatabasePath_WorktreeNoLocalDB tests that when a worktree does NOT have
// its own .beads directory, FindDatabasePath finds the shared database in the main
// repository. This tests the "shared database" behavior for worktrees.
func TestFindDatabasePath_WorktreeNoLocalDB(t *testing.T) {
// Save original state
originalEnvDir := os.Getenv("BEADS_DIR")
originalEnvDB := os.Getenv("BEADS_DB")
defer func() {
if originalEnvDir != "" {
os.Setenv("BEADS_DIR", originalEnvDir)
} else {
os.Unsetenv("BEADS_DIR")
}
if originalEnvDB != "" {
os.Setenv("BEADS_DB", originalEnvDB)
} else {
os.Unsetenv("BEADS_DB")
}
}()
os.Unsetenv("BEADS_DIR")
os.Unsetenv("BEADS_DB")
// Create temporary directory for our test
tmpDir, err := os.MkdirTemp("", "beads-worktree-nodb-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
// Initialize main git repository
mainRepoDir := filepath.Join(tmpDir, "main-repo")
if err := os.MkdirAll(mainRepoDir, 0755); err != nil {
t.Fatal(err)
}
cmd := exec.Command("git", "init")
cmd.Dir = mainRepoDir
if err := cmd.Run(); err != nil {
t.Skipf("git not available: %v", err)
}
// Configure git user
cmd = exec.Command("git", "config", "user.email", "test@example.com")
cmd.Dir = mainRepoDir
_ = cmd.Run()
cmd = exec.Command("git", "config", "user.name", "Test User")
cmd.Dir = mainRepoDir
_ = cmd.Run()
// Create .beads directory in main repo with database
mainBeadsDir := filepath.Join(mainRepoDir, ".beads")
if err := os.MkdirAll(mainBeadsDir, 0755); err != nil {
t.Fatal(err)
}
mainDBPath := filepath.Join(mainBeadsDir, "beads.db")
if err := os.WriteFile(mainDBPath, []byte{}, 0644); err != nil {
t.Fatal(err)
}
// Create initial commit
if err := os.WriteFile(filepath.Join(mainRepoDir, "README.md"), []byte("# Test\n"), 0644); err != nil {
t.Fatal(err)
}
cmd = exec.Command("git", "add", "-A")
cmd.Dir = mainRepoDir
_ = cmd.Run()
cmd = exec.Command("git", "commit", "-m", "Initial commit")
cmd.Dir = mainRepoDir
if err := cmd.Run(); err != nil {
t.Fatalf("git commit failed: %v", err)
}
// Create a worktree WITHOUT a .beads directory
worktreeDir := filepath.Join(tmpDir, "worktree")
cmd = exec.Command("git", "worktree", "add", worktreeDir, "HEAD")
cmd.Dir = mainRepoDir
if err := cmd.Run(); err != nil {
t.Fatalf("git worktree add failed: %v", err)
}
defer func() {
cmd := exec.Command("git", "worktree", "remove", worktreeDir)
cmd.Dir = mainRepoDir
_ = cmd.Run()
}()
// Note: We do NOT create .beads in the worktree
// The worktree got .beads from the commit, so we need to remove it
worktreeBeadsDir := filepath.Join(worktreeDir, ".beads")
if err := os.RemoveAll(worktreeBeadsDir); err != nil {
// May not exist, that's fine
}
// Change to worktree
t.Chdir(worktreeDir)
// FindDatabasePath should find the main repo's shared database
result := FindDatabasePath()
// Resolve symlinks for comparison
resultResolved, _ := filepath.EvalSymlinks(result)
mainDBPathResolved, _ := filepath.EvalSymlinks(mainDBPath)
if resultResolved != mainDBPathResolved {
t.Errorf("FindDatabasePath() = %q, want main repo shared db %q", result, mainDBPath)
}
}