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:
beads/crew/dave
2026-01-04 13:52:38 -08:00
committed by Steve Yegge
parent fbc93e3de2
commit 7b90678afe
5 changed files with 163 additions and 11 deletions

View File

@@ -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\...).

View File

@@ -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 {

View File

@@ -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.

View File

@@ -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).
//

View File

@@ -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()