fix(git): find .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. 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
- Worktree lifecycle management with sparse checkout for sync branches
|
||||||
- Automatic detection and user-friendly warnings for worktree conflicts
|
- 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
|
## [0.29.0] - 2025-12-03
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -77,48 +77,30 @@ func IsWorktree() bool {
|
|||||||
// GetMainRepoRoot returns the main repository root directory.
|
// GetMainRepoRoot returns the main repository root directory.
|
||||||
// When in a worktree, this returns the main repository root.
|
// When in a worktree, this returns the main repository root.
|
||||||
// Otherwise, it returns the regular 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) {
|
func GetMainRepoRoot() (string, error) {
|
||||||
if IsWorktree() {
|
// Use --git-common-dir which always returns the main repo's .git directory,
|
||||||
// In worktree: read .git file to find main repo
|
// even when running from within a worktree or its subdirectories.
|
||||||
gitFileContent := getGitDirNoError("--git-dir")
|
// This is the most reliable method for finding the main repo root.
|
||||||
if gitFileContent == "" {
|
commonDir := getGitDirNoError("--git-common-dir")
|
||||||
return "", fmt.Errorf("not a git repository")
|
if commonDir == "" {
|
||||||
}
|
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// getGitDirNoError is a helper that returns empty string on error
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user