refactor: consolidate duplicate path-finding utilities (bd-74w1, bd-4nqq)

- Add git.GetRepoRoot() with Windows path normalization
- Update beads.findGitRoot() to delegate to git.GetRepoRoot()
- Replace findBeadsDir() with beads.FindBeadsDir() across 8 files
- Remove duplicate findBeadsDir() and findGitRoot() function definitions
- Remove dead test code (TestInfoCommand, TestInfoWithNoDaemon)
- Update tests to work with consolidated APIs

Part of Code Health Review Dec 2025 epic (bd-tggf).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-22 21:15:41 -08:00
parent ca1927bfaa
commit fc0b98730a
10 changed files with 77 additions and 107 deletions

View File

@@ -9,9 +9,9 @@ import (
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"github.com/steveyegge/beads/internal/beads"
"github.com/steveyegge/beads/internal/git"
"github.com/steveyegge/beads/internal/storage"
"github.com/steveyegge/beads/internal/syncbranch"
@@ -69,13 +69,13 @@ func checkAndAutoImport(ctx context.Context, store storage.Storage) bool {
// Returns (issue_count, relative_jsonl_path, git_ref)
func checkGitForIssues() (int, string, string) {
// Try to find .beads directory
beadsDir := findBeadsDir()
beadsDir := beads.FindBeadsDir()
if beadsDir == "" {
return 0, "", ""
}
// Construct relative path from git root
gitRoot := findGitRoot()
gitRoot := git.GetRepoRoot()
if gitRoot == "" {
return 0, "", ""
}
@@ -193,80 +193,7 @@ func getLocalSyncBranch(beadsDir string) string {
return cfg.SyncBranch
}
// findBeadsDir finds the .beads directory in current or parent directories
func findBeadsDir() string {
dir, err := os.Getwd()
if err != nil {
return ""
}
// Check if we're in a git worktree with sparse checkout
// In worktrees, .beads might not exist locally, so we need to resolve to the main repo
if git.IsWorktree() {
mainRepoRoot, err := git.GetMainRepoRoot()
if err == nil && mainRepoRoot != "" {
mainBeadsDir := filepath.Join(mainRepoRoot, ".beads")
if info, err := os.Stat(mainBeadsDir); err == nil && info.IsDir() {
resolved, err := filepath.EvalSymlinks(mainBeadsDir)
if err != nil {
return mainBeadsDir
}
return resolved
}
}
}
for {
beadsDir := filepath.Join(dir, ".beads")
if info, err := os.Stat(beadsDir); err == nil && info.IsDir() {
// Resolve symlinks to get canonical path (fixes macOS /var -> /private/var)
resolved, err := filepath.EvalSymlinks(beadsDir)
if err != nil {
return beadsDir // Fall back to unresolved if EvalSymlinks fails
}
return resolved
}
parent := filepath.Dir(dir)
if parent == dir {
// Reached root
break
}
dir = parent
}
return ""
}
// findGitRoot finds the git repository root
func findGitRoot() string {
cmd := exec.Command("git", "rev-parse", "--show-toplevel")
output, err := cmd.Output()
if err != nil {
return ""
}
root := string(bytes.TrimSpace(output))
// Normalize path for the current OS
// Git on Windows may return paths with forward slashes (C:/Users/...)
// or Unix-style paths (/c/Users/...), convert to native format
if runtime.GOOS == "windows" {
if len(root) > 0 && root[0] == '/' && len(root) >= 3 && root[2] == '/' {
// Convert /c/Users/... to C:\Users\...
root = strings.ToUpper(string(root[1])) + ":" + filepath.FromSlash(root[2:])
} else {
// Convert C:/Users/... to C:\Users\...
root = filepath.FromSlash(root)
}
}
// Resolve symlinks to get canonical path (fixes macOS /var -> /private/var)
resolved, err := filepath.EvalSymlinks(root)
if err != nil {
return root // Fall back to unresolved if EvalSymlinks fails
}
return resolved
}
// importFromGit imports issues from git at the specified ref (bd-0is: supports sync-branch)
func importFromGit(ctx context.Context, dbFilePath string, store storage.Storage, jsonlPath, gitRef string) error {

View File

@@ -7,6 +7,7 @@ import (
"path/filepath"
"testing"
"github.com/steveyegge/beads/internal/beads"
"github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/types"
)
@@ -181,17 +182,21 @@ func TestCheckAndAutoImport_EmptyDatabaseNoGit(t *testing.T) {
}
func TestFindBeadsDir(t *testing.T) {
// Create temp directory with .beads
// Create temp directory with .beads and a valid project file
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatalf("Failed to create .beads dir: %v", err)
}
// Create a config.yaml so beads.FindBeadsDir() recognizes this as a valid project
if err := os.WriteFile(filepath.Join(beadsDir, "config.yaml"), []byte("prefix: test\n"), 0600); err != nil {
t.Fatalf("Failed to create config.yaml: %v", err)
}
// Change to tmpDir
t.Chdir(tmpDir)
found := findBeadsDir()
found := beads.FindBeadsDir()
if found == "" {
t.Error("Expected to find .beads directory")
}
@@ -209,7 +214,7 @@ func TestFindBeadsDir_NotFound(t *testing.T) {
t.Chdir(tmpDir)
found := findBeadsDir()
found := beads.FindBeadsDir()
// findBeadsDir walks up to root, so it might find .beads in parent dirs
// (e.g., user's home directory). Just verify it's not in tmpDir itself.
if found != "" && filepath.Dir(found) == tmpDir {
@@ -224,6 +229,10 @@ func TestFindBeadsDir_ParentDirectory(t *testing.T) {
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatalf("Failed to create .beads dir: %v", err)
}
// Create a config.yaml so beads.FindBeadsDir() recognizes this as a valid project
if err := os.WriteFile(filepath.Join(beadsDir, "config.yaml"), []byte("prefix: test\n"), 0600); err != nil {
t.Fatalf("Failed to create config.yaml: %v", err)
}
subDir := filepath.Join(tmpDir, "subdir")
if err := os.MkdirAll(subDir, 0755); err != nil {
@@ -233,7 +242,7 @@ func TestFindBeadsDir_ParentDirectory(t *testing.T) {
// Change to subdir
t.Chdir(subDir)
found := findBeadsDir()
found := beads.FindBeadsDir()
if found == "" {
t.Error("Expected to find .beads directory in parent")
}

View File

@@ -7,6 +7,7 @@ import (
"strings"
"github.com/steveyegge/beads/internal/debug"
"github.com/steveyegge/beads/internal/git"
)
// ensureForkProtection prevents contributors from accidentally committing
@@ -16,8 +17,8 @@ import (
// .beads/issues.jsonl to .git/info/exclude so it won't be staged.
// This is a per-clone setting that doesn't modify tracked files.
func ensureForkProtection() {
// Find git root (reuses existing findGitRoot from autoimport.go)
gitRoot := findGitRoot()
// Find git root
gitRoot := git.GetRepoRoot()
if gitRoot == "" {
return // Not in a git repo
}

View File

@@ -5,14 +5,6 @@ import (
"testing"
)
func TestInfoCommand(t *testing.T) {
t.Skip("Manual test - bd info command is working, see manual testing")
}
func TestInfoWithNoDaemon(t *testing.T) {
t.Skip("Manual test - bd info --no-daemon command is working, see manual testing")
}
func TestVersionChangesStructure(t *testing.T) {
// Verify versionChanges is properly structured
if len(versionChanges) == 0 {

View File

@@ -14,6 +14,7 @@ import (
"time"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/beads"
"github.com/steveyegge/beads/internal/types"
)
@@ -568,7 +569,7 @@ func findJiraScript(name string) (string, error) {
}
// Check BEADS_DIR or current .beads location
if beadsDir := findBeadsDir(); beadsDir != "" {
if beadsDir := beads.FindBeadsDir(); beadsDir != "" {
repoRoot := filepath.Dir(beadsDir)
locations = append(locations, filepath.Join(repoRoot, "examples", "jira-import", name))
}

View File

@@ -67,7 +67,7 @@ This command:
}
// Find .beads directory
beadsDir := findBeadsDir()
beadsDir := beads.FindBeadsDir()
if beadsDir == "" {
if jsonOutput {
outputJSON(map[string]interface{}{
@@ -729,7 +729,7 @@ func cleanupWALFiles(dbPath string) {
// handleInspect shows migration plan and database state for AI agent analysis
func handleInspect() {
// Find .beads directory
beadsDir := findBeadsDir()
beadsDir := beads.FindBeadsDir()
if beadsDir == "" {
if jsonOutput {
outputJSON(map[string]interface{}{
@@ -927,7 +927,7 @@ func handleToSeparateBranch(branch string, dryRun bool) {
}
// Find .beads directory
beadsDir := findBeadsDir()
beadsDir := beads.FindBeadsDir()
if beadsDir == "" {
if jsonOutput {
outputJSON(map[string]interface{}{

View File

@@ -9,6 +9,7 @@ import (
"time"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/beads"
"github.com/steveyegge/beads/internal/types"
"github.com/steveyegge/beads/internal/ui"
)
@@ -102,7 +103,7 @@ Examples:
}
// Find .beads directory
beadsDir := findBeadsDir()
beadsDir := beads.FindBeadsDir()
if beadsDir == "" {
if jsonOutput {
outputJSON(map[string]interface{}{

View File

@@ -15,6 +15,7 @@ import (
"time"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/beads"
"github.com/steveyegge/beads/internal/config"
"github.com/steveyegge/beads/internal/debug"
"github.com/steveyegge/beads/internal/git"
@@ -863,7 +864,7 @@ func getRepoRootForWorktree(_ context.Context) string {
// gitHasBeadsChanges checks if any tracked files in .beads/ have uncommitted changes
func gitHasBeadsChanges(ctx context.Context) (bool, error) {
// Get the absolute path to .beads directory
beadsDir := findBeadsDir()
beadsDir := beads.FindBeadsDir()
if beadsDir == "" {
return false, fmt.Errorf("no .beads directory found")
}
@@ -961,7 +962,7 @@ func gitCommit(ctx context.Context, filePath string, message string) error {
// to avoid staging gitignored snapshot files that may be tracked. (bd-guc fix)
// Worktree-aware: handles cases where .beads is in the main repo but we're running from a worktree.
func gitCommitBeadsDir(ctx context.Context, message string) error {
beadsDir := findBeadsDir()
beadsDir := beads.FindBeadsDir()
if beadsDir == "" {
return fmt.Errorf("no .beads directory found")
}
@@ -1185,7 +1186,7 @@ func gitPush(ctx context.Context) error {
// This is used after sync when sync.branch is configured to keep the working directory clean.
// The actual beads data lives on the sync branch; the main branch's .beads/ is just a snapshot.
func restoreBeadsDirFromBranch(ctx context.Context) error {
beadsDir := findBeadsDir()
beadsDir := beads.FindBeadsDir()
if beadsDir == "" {
return fmt.Errorf("no .beads directory found")
}

View File

@@ -11,7 +11,6 @@ import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
@@ -442,15 +441,10 @@ type DatabaseInfo struct {
// 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.
// This function delegates to git.GetRepoRoot() which is worktree-aware
// and handles Windows path normalization.
func findGitRoot() string {
cmd := exec.Command("git", "rev-parse", "--show-toplevel")
output, err := cmd.Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(output))
return git.GetRepoRoot()
}
// findDatabaseInTree walks up the directory tree looking for .beads/*.db

View File

@@ -103,6 +103,50 @@ func GetMainRepoRoot() (string, error) {
return mainRepoRoot, nil
}
// GetRepoRoot returns the root directory of the current git repository.
// Returns empty string if not in a git repository.
//
// This function is worktree-aware and handles Windows path normalization
// (Git on Windows may return paths like /c/Users/... or C:/Users/...).
// It also resolves symlinks to get the canonical path.
func GetRepoRoot() string {
cmd := exec.Command("git", "rev-parse", "--show-toplevel")
output, err := cmd.Output()
if err != nil {
return ""
}
root := strings.TrimSpace(string(output))
// Normalize Windows paths from Git
// Git on Windows may return /c/Users/... or C:/Users/...
root = NormalizePath(root)
// Resolve symlinks to get canonical path (fixes macOS /var -> /private/var)
if resolved, err := filepath.EvalSymlinks(root); err == nil {
return resolved
}
return root
}
// NormalizePath converts Git's Windows path formats to native format.
// Git on Windows may return paths like /c/Users/... or C:/Users/...
// This function converts them to native Windows format (C:\Users\...).
// On non-Windows systems, this is a no-op.
func NormalizePath(path string) string {
// Only apply Windows normalization on Windows
if filepath.Separator != '\\' {
return path
}
// Convert /c/Users/... to C:\Users\...
if len(path) >= 3 && path[0] == '/' && path[2] == '/' {
return strings.ToUpper(string(path[1])) + ":" + filepath.FromSlash(path[2:])
}
// Convert C:/Users/... to C:\Users\...
return filepath.FromSlash(path)
}
// getGitDirNoError is a helper that returns empty string on error
// to avoid cluttering code with error handling for simple checks.
func getGitDirNoError(flag string) string {