diff --git a/internal/git/gitdir.go b/internal/git/gitdir.go index e052208c..116fe910 100644 --- a/internal/git/gitdir.go +++ b/internal/git/gitdir.go @@ -4,6 +4,7 @@ import ( "fmt" "os/exec" "path/filepath" + "runtime" "strings" "sync" ) @@ -62,11 +63,17 @@ func initGitContext() { // Derive isWorktree from comparing absolute paths gitCtx.isWorktree = absGitDir != absCommon - // Process repoRoot: normalize Windows paths and resolve symlinks + // Process repoRoot: normalize Windows paths, resolve symlinks, + // and canonicalize case on case-insensitive filesystems (GH#880). + // This is critical for git worktree operations which string-compare paths. repoRoot := NormalizePath(repoRootRaw) if resolved, err := filepath.EvalSymlinks(repoRoot); err == nil { repoRoot = resolved } + // Canonicalize case on macOS/Windows (GH#880) + if canonicalized := canonicalizeCase(repoRoot); canonicalized != "" { + repoRoot = canonicalized + } gitCtx.repoRoot = repoRoot } @@ -173,6 +180,27 @@ func GetRepoRoot() string { return ctx.repoRoot } +// canonicalizeCase resolves a path to its true filesystem case on +// case-insensitive filesystems (macOS/Windows). This is needed because +// git operations string-compare paths exactly - a path with wrong case +// will fail even though it points to the same location. (GH#880) +// +// On macOS, uses realpath(1) which returns the canonical case. +// Returns empty string if resolution fails or isn't needed. +func canonicalizeCase(path string) string { + if runtime.GOOS == "darwin" { + // Use realpath to get canonical path with correct case + cmd := exec.Command("realpath", path) + output, err := cmd.Output() + if err == nil { + return strings.TrimSpace(string(output)) + } + } + // Windows: filepath.EvalSymlinks already handles case + // Linux: case-sensitive, no canonicalization needed + return "" +} + // 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\...). diff --git a/internal/git/worktree_test.go b/internal/git/worktree_test.go index 4f9cecbf..4c2f419d 100644 --- a/internal/git/worktree_test.go +++ b/internal/git/worktree_test.go @@ -4,6 +4,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "strings" "testing" ) @@ -1233,6 +1234,60 @@ func TestCreateBeadsWorktree_MainRepoSparseCheckoutDisabled(t *testing.T) { } } +// TestGetRepoRootCanonicalCase tests that GetRepoRoot returns paths with correct +// filesystem case on case-insensitive filesystems (GH#880) +func TestGetRepoRootCanonicalCase(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("case canonicalization test only runs on macOS") + } + + // Create a repo with mixed-case directory + tmpDir := t.TempDir() + mixedCaseDir := filepath.Join(tmpDir, "TestRepo") + if err := os.MkdirAll(mixedCaseDir, 0755); err != nil { + t.Fatal(err) + } + + // Initialize git repo + cmd := exec.Command("git", "init") + cmd.Dir = mixedCaseDir + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("Failed to init git repo: %v\nOutput: %s", err, string(output)) + } + + // Save cwd and change to the repo using WRONG case + originalDir, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get current dir: %v", err) + } + defer func() { _ = os.Chdir(originalDir) }() + + // Access the repo with lowercase (wrong case) + wrongCasePath := filepath.Join(tmpDir, "testrepo") + + // Verify the wrong case path works on macOS (case-insensitive) + if _, err := os.Stat(wrongCasePath); err != nil { + t.Fatalf("Wrong case path should be accessible on macOS: %v", err) + } + + if err := os.Chdir(wrongCasePath); err != nil { + t.Fatalf("Failed to chdir with wrong case: %v", err) + } + + ResetCaches() // Reset git context cache + + // GetRepoRoot should return the canonical case (TestRepo, not testrepo) + repoRoot := GetRepoRoot() + if repoRoot == "" { + t.Fatal("GetRepoRoot returned empty string") + } + + // The path should end with "TestRepo" (correct case), not "testrepo" + if !strings.HasSuffix(repoRoot, "TestRepo") { + t.Errorf("GetRepoRoot() = %q, want path ending in 'TestRepo' (correct case)", repoRoot) + } +} + // TestNormalizeBeadsRelPath tests path normalization for bare repo worktrees (GH#785, GH#810) func TestNormalizeBeadsRelPath(t *testing.T) { tests := []struct { diff --git a/internal/syncbranch/worktree.go b/internal/syncbranch/worktree.go index d5956b99..7c65ce44 100644 --- a/internal/syncbranch/worktree.go +++ b/internal/syncbranch/worktree.go @@ -14,6 +14,7 @@ import ( "github.com/steveyegge/beads/internal/git" "github.com/steveyegge/beads/internal/merge" + "github.com/steveyegge/beads/internal/utils" ) // CommitResult contains information about a worktree commit operation @@ -968,7 +969,10 @@ func getRemoteForBranch(ctx context.Context, worktreePath, branch string) string // GetRepoRoot returns the git repository root directory // For worktrees, this returns the main repository root (not the worktree root) +// The returned path is canonicalized to fix case on case-insensitive filesystems (GH#880) func GetRepoRoot(ctx context.Context) (string, error) { + var repoRoot string + // Check if .git is a file (worktree) or directory (regular repo) gitPath := ".git" if info, err := os.Stat(gitPath); err == nil { @@ -985,7 +989,7 @@ func GetRepoRoot(ctx context.Context) (string, error) { if idx := strings.Index(gitDir, "/worktrees/"); idx > 0 { gitDir = gitDir[:idx] } - return filepath.Dir(gitDir), nil + repoRoot = filepath.Dir(gitDir) } } else if info.IsDir() { // Regular repo: .git is a directory @@ -993,17 +997,23 @@ func GetRepoRoot(ctx context.Context) (string, error) { if err != nil { return "", err } - return filepath.Dir(absGitPath), nil + repoRoot = filepath.Dir(absGitPath) } } - // Fallback to git command - cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel") - output, err := cmd.Output() - if err != nil { - return "", fmt.Errorf("not a git repository: %w", err) + // Fallback to git command if not determined above + if repoRoot == "" { + cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel") + output, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("not a git repository: %w", err) + } + repoRoot = strings.TrimSpace(string(output)) } - return strings.TrimSpace(string(output)), nil + + // Canonicalize path to fix case on macOS/Windows (GH#880) + // This is critical for git worktree operations which string-compare paths + return utils.CanonicalizePath(repoRoot), nil } // countIssuesInContent counts the number of non-empty lines in JSONL content. diff --git a/internal/utils/path.go b/internal/utils/path.go index a327edfc..9cff8772 100644 --- a/internal/utils/path.go +++ b/internal/utils/path.go @@ -3,6 +3,7 @@ package utils import ( "os" + "os/exec" "path/filepath" "runtime" "strings" @@ -92,13 +93,16 @@ func ResolveForWrite(path string) (string, error) { // CanonicalizePath converts a path to its canonical form by: // 1. Converting to absolute path // 2. Resolving symlinks +// 3. On macOS/Windows, resolving the true filesystem case (GH#880) // -// If either step fails, it falls back to the best available form: +// If any step fails, it falls back to the best available form: +// - If case resolution fails, returns symlink-resolved path // - If symlink resolution fails, returns absolute path // - If absolute path conversion fails, returns original path // // This function is used to ensure consistent path handling across the codebase, -// particularly for BEADS_DIR environment variable processing. +// particularly for BEADS_DIR environment variable processing and git worktree +// paths which require exact case matching. func CanonicalizePath(path string) string { // Try to get absolute path absPath, err := filepath.Abs(path) @@ -114,9 +118,35 @@ func CanonicalizePath(path string) string { return absPath } + // On case-insensitive filesystems, resolve to true filesystem case (GH#880) + // This is critical for git operations which string-compare paths exactly. + if runtime.GOOS == "darwin" || runtime.GOOS == "windows" { + if resolved := resolveCanonicalCase(canonical); resolved != "" { + return resolved + } + } + return canonical } +// resolveCanonicalCase resolves a path to its true filesystem case. +// On macOS, uses realpath(1) to get the canonical case. +// Returns empty string if resolution fails. +func resolveCanonicalCase(path string) string { + if runtime.GOOS == "darwin" { + // Use realpath to get canonical path with correct case + // realpath on macOS returns the true filesystem case + cmd := exec.Command("realpath", path) + output, err := cmd.Output() + if err == nil { + return strings.TrimSpace(string(output)) + } + } + // Windows: filepath.EvalSymlinks already handles case on Windows + // For other systems or if realpath fails, return empty to use fallback + return "" +} + // NormalizePathForComparison returns a normalized path suitable for comparison. // It resolves symlinks and handles case-insensitive filesystems (macOS, Windows). // diff --git a/internal/utils/path_test.go b/internal/utils/path_test.go index eba389bb..70656cd7 100644 --- a/internal/utils/path_test.go +++ b/internal/utils/path_test.go @@ -327,6 +327,35 @@ func TestNormalizePathForComparison(t *testing.T) { }) } +func TestCanonicalizePathCase(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("case canonicalization test only runs on macOS") + } + + // Create a directory with mixed case + tmpDir := t.TempDir() + mixedCaseDir := filepath.Join(tmpDir, "TestCase") + if err := os.MkdirAll(mixedCaseDir, 0755); err != nil { + t.Fatal(err) + } + + // Access via wrong case (lowercase) + wrongCasePath := filepath.Join(tmpDir, "testcase") + + // Verify the wrong case path exists (macOS case-insensitive) + if _, err := os.Stat(wrongCasePath); err != nil { + t.Fatalf("wrong case path should exist on macOS: %v", err) + } + + // CanonicalizePath should return the correct case + result := CanonicalizePath(wrongCasePath) + + // The result should have the correct case "TestCase", not "testcase" + if !strings.HasSuffix(result, "TestCase") { + t.Errorf("CanonicalizePath(%q) = %q, want path ending in 'TestCase'", wrongCasePath, result) + } +} + func TestPathsEqual(t *testing.T) { t.Run("identical paths", func(t *testing.T) { tmpDir := t.TempDir()