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
@@ -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\...).
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user