From 69d14e21d8ae7bf15ddaa177b70bc5a1d9d65e94 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Sun, 14 Dec 2025 17:20:46 -0800 Subject: [PATCH] fix(git): find .beads from nested worktrees (GH#509) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When worktrees are nested under the main repo (e.g., /project/.worktrees/feature/), bd now correctly finds .beads/ in the parent repo. The fix simplifies GetMainRepoRoot() to use `git rev-parse --git-common-dir` which always returns the main repo's .git directory, regardless of whether we're in a regular repo, a worktree, or a nested worktree. - Simplified GetMainRepoRoot() implementation - Added tests for nested worktree scenarios - Updated CHANGELOG.md 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- CHANGELOG.md | 8 ++ internal/git/gitdir.go | 62 ++++------- internal/git/worktree_test.go | 196 ++++++++++++++++++++++++++++++++++ 3 files changed, 226 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 774cebe3..712cc304 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Worktree lifecycle management with sparse checkout for sync branches - Automatic detection and user-friendly warnings for worktree conflicts +### Fixed + +- **`bd` now finds `.beads` from nested worktrees** (GH#509) + - When worktrees are nested under the main repo (e.g., `/project/.worktrees/feature/`), + `bd` now correctly finds `.beads/` in the parent repo + - Uses `git rev-parse --git-common-dir` to reliably locate the main repository root + - Works from any subdirectory within the nested worktree + ## [0.29.0] - 2025-12-03 ### Added diff --git a/internal/git/gitdir.go b/internal/git/gitdir.go index a627d86b..1d29b815 100644 --- a/internal/git/gitdir.go +++ b/internal/git/gitdir.go @@ -77,48 +77,30 @@ func IsWorktree() bool { // GetMainRepoRoot returns the main repository root directory. // When in a worktree, this returns the main repository root. // Otherwise, it returns the regular repository root. +// +// For nested worktrees (worktrees located under the main repo, e.g., +// /project/.worktrees/feature/), this correctly returns the main repo +// root (/project/) by using git rev-parse --git-common-dir which always +// points to the main repo's .git directory. (GH#509) func GetMainRepoRoot() (string, error) { - if IsWorktree() { - // In worktree: read .git file to find main repo - gitFileContent := getGitDirNoError("--git-dir") - if gitFileContent == "" { - return "", fmt.Errorf("not a git repository") - } - - // If gitFileContent contains "worktrees", it's a worktree path - // Read the .git file to get the main git dir - if strings.Contains(gitFileContent, "worktrees") { - content, err := exec.Command("cat", ".git").Output() - if err == nil { - line := strings.TrimSpace(string(content)) - if strings.HasPrefix(line, "gitdir: ") { - gitDir := strings.TrimPrefix(line, "gitdir: ") - // Remove /worktrees/* part - if idx := strings.Index(gitDir, "/worktrees/"); idx > 0 { - gitDir = gitDir[:idx] - } - return filepath.Dir(gitDir), nil - } - } - } - - // Fallback: use --git-common-dir with validation - commonDir := getGitDirNoError("--git-common-dir") - if commonDir != "" { - // Validate that commonDir exists - if _, err := exec.Command("test", "-d", commonDir).Output(); err == nil { - return filepath.Dir(commonDir), nil - } - } - - return "", fmt.Errorf("unable to determine main repository root") - } else { - gitDir, err := GetGitDir() - if err != nil { - return "", err - } - return filepath.Dir(gitDir), nil + // Use --git-common-dir which always returns the main repo's .git directory, + // even when running from within a worktree or its subdirectories. + // This is the most reliable method for finding the main repo root. + commonDir := getGitDirNoError("--git-common-dir") + if commonDir == "" { + return "", fmt.Errorf("not a git repository") } + + // Convert to absolute path to handle relative paths correctly + absCommonDir, err := filepath.Abs(commonDir) + if err != nil { + return "", fmt.Errorf("failed to resolve common dir path: %w", err) + } + + // The main repo root is the parent of the .git directory + mainRepoRoot := filepath.Dir(absCommonDir) + + return mainRepoRoot, nil } // getGitDirNoError is a helper that returns empty string on error diff --git a/internal/git/worktree_test.go b/internal/git/worktree_test.go index 7efb48e5..9e046b24 100644 --- a/internal/git/worktree_test.go +++ b/internal/git/worktree_test.go @@ -860,3 +860,199 @@ func TestCountJSONLIssues(t *testing.T) { }) } } + +// TestGetMainRepoRoot tests the GetMainRepoRoot function for various scenarios +func TestGetMainRepoRoot(t *testing.T) { + t.Run("returns correct root for regular repo", func(t *testing.T) { + repoPath, cleanup := setupTestRepo(t) + defer cleanup() + + // Save current dir and change to repo + originalDir, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get current dir: %v", err) + } + defer func() { _ = os.Chdir(originalDir) }() + + if err := os.Chdir(repoPath); err != nil { + t.Fatalf("Failed to chdir to repo: %v", err) + } + + root, err := GetMainRepoRoot() + if err != nil { + t.Fatalf("GetMainRepoRoot failed: %v", err) + } + + // Resolve symlinks for comparison (e.g., /tmp -> /private/tmp on macOS) + expectedRoot, _ := filepath.EvalSymlinks(repoPath) + actualRoot, _ := filepath.EvalSymlinks(root) + + if actualRoot != expectedRoot { + t.Errorf("GetMainRepoRoot() = %s, want %s", actualRoot, expectedRoot) + } + }) + + t.Run("returns main repo root from worktree", func(t *testing.T) { + repoPath, cleanup := setupTestRepo(t) + defer cleanup() + + wm := NewWorktreeManager(repoPath) + worktreePath := filepath.Join(t.TempDir(), "test-worktree") + + if err := wm.CreateBeadsWorktree("test-branch", worktreePath); err != nil { + t.Fatalf("CreateBeadsWorktree failed: %v", err) + } + + // Save current dir and change to worktree + originalDir, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get current dir: %v", err) + } + defer func() { _ = os.Chdir(originalDir) }() + + if err := os.Chdir(worktreePath); err != nil { + t.Fatalf("Failed to chdir to worktree: %v", err) + } + + root, err := GetMainRepoRoot() + if err != nil { + t.Fatalf("GetMainRepoRoot failed: %v", err) + } + + // Resolve symlinks for comparison + expectedRoot, _ := filepath.EvalSymlinks(repoPath) + actualRoot, _ := filepath.EvalSymlinks(root) + + if actualRoot != expectedRoot { + t.Errorf("GetMainRepoRoot() = %s, want %s (main repo)", actualRoot, expectedRoot) + } + }) + + t.Run("returns main repo root from nested worktree (GH#509)", func(t *testing.T) { + repoPath, cleanup := setupTestRepo(t) + defer cleanup() + + // Create a nested worktree directory structure: repo/.worktrees/feature/ + nestedWorktreePath := filepath.Join(repoPath, ".worktrees", "feature-branch") + + wm := NewWorktreeManager(repoPath) + if err := wm.CreateBeadsWorktree("feature-branch", nestedWorktreePath); err != nil { + t.Fatalf("CreateBeadsWorktree failed: %v", err) + } + + // Save current dir and change to nested worktree + originalDir, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get current dir: %v", err) + } + defer func() { _ = os.Chdir(originalDir) }() + + if err := os.Chdir(nestedWorktreePath); err != nil { + t.Fatalf("Failed to chdir to nested worktree: %v", err) + } + + root, err := GetMainRepoRoot() + if err != nil { + t.Fatalf("GetMainRepoRoot failed: %v", err) + } + + // Resolve symlinks for comparison + expectedRoot, _ := filepath.EvalSymlinks(repoPath) + actualRoot, _ := filepath.EvalSymlinks(root) + + if actualRoot != expectedRoot { + t.Errorf("GetMainRepoRoot() = %s, want %s (main repo, not worktree)", actualRoot, expectedRoot) + } + }) + + t.Run("returns main repo root from subdirectory of nested worktree", func(t *testing.T) { + repoPath, cleanup := setupTestRepo(t) + defer cleanup() + + // Create a nested worktree + nestedWorktreePath := filepath.Join(repoPath, ".worktrees", "feature-branch") + + wm := NewWorktreeManager(repoPath) + if err := wm.CreateBeadsWorktree("feature-branch", nestedWorktreePath); err != nil { + t.Fatalf("CreateBeadsWorktree failed: %v", err) + } + + // Create a subdirectory in the worktree + subDir := filepath.Join(nestedWorktreePath, "some", "nested", "dir") + if err := os.MkdirAll(subDir, 0750); err != nil { + t.Fatalf("Failed to create subdir: %v", err) + } + + // Save current dir and change to subdirectory + originalDir, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get current dir: %v", err) + } + defer func() { _ = os.Chdir(originalDir) }() + + if err := os.Chdir(subDir); err != nil { + t.Fatalf("Failed to chdir to subdir: %v", err) + } + + root, err := GetMainRepoRoot() + if err != nil { + t.Fatalf("GetMainRepoRoot failed: %v", err) + } + + // Resolve symlinks for comparison + expectedRoot, _ := filepath.EvalSymlinks(repoPath) + actualRoot, _ := filepath.EvalSymlinks(root) + + if actualRoot != expectedRoot { + t.Errorf("GetMainRepoRoot() = %s, want %s (main repo)", actualRoot, expectedRoot) + } + }) +} + +// TestIsWorktree tests the IsWorktree function +func TestIsWorktree(t *testing.T) { + t.Run("returns false for regular repo", func(t *testing.T) { + repoPath, cleanup := setupTestRepo(t) + defer cleanup() + + originalDir, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get current dir: %v", err) + } + defer func() { _ = os.Chdir(originalDir) }() + + if err := os.Chdir(repoPath); err != nil { + t.Fatalf("Failed to chdir to repo: %v", err) + } + + if IsWorktree() { + t.Error("IsWorktree() should return false for regular repo") + } + }) + + t.Run("returns true for worktree", func(t *testing.T) { + repoPath, cleanup := setupTestRepo(t) + defer cleanup() + + wm := NewWorktreeManager(repoPath) + worktreePath := filepath.Join(t.TempDir(), "test-worktree") + + if err := wm.CreateBeadsWorktree("test-branch", worktreePath); err != nil { + t.Fatalf("CreateBeadsWorktree failed: %v", err) + } + + originalDir, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get current dir: %v", err) + } + defer func() { _ = os.Chdir(originalDir) }() + + if err := os.Chdir(worktreePath); err != nil { + t.Fatalf("Failed to chdir to worktree: %v", err) + } + + if !IsWorktree() { + t.Error("IsWorktree() should return true for worktree") + } + }) +}