fix: canonicalize path case on macOS for git worktree operations (GH#880)
bd sync fails with exit status 128 when the daemon is started from a terminal with different path casing than what git has stored. This happens on macOS case-insensitive filesystem when directory names are renamed (e.g., MyProject to myproject) but terminal sessions retain the old casing. The fix uses realpath(1) on macOS to get the true filesystem case when canonicalizing paths: - CanonicalizePath() now calls realpath on macOS - git.GetRepoRoot() canonicalizes repoRoot via canonicalizeCase() - syncbranch.GetRepoRoot() uses utils.CanonicalizePath() This ensures git worktree paths match exactly, preventing the exit status 128 errors from git operations. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> Executed-By: beads/crew/dave Rig: beads Role: crew
This commit is contained in:
committed by
Steve Yegge
parent
fbc93e3de2
commit
7b90678afe
@@ -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).
|
||||
//
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user