fix(daemon): normalize paths for case-insensitive filesystem comparison (GH#869)

On macOS and Windows, filesystems are typically case-insensitive, so
/Users/foo/Desktop and /Users/foo/desktop refer to the same directory.
The daemon registry and discovery code was doing direct string comparison,
causing path mismatches when the casing differed.

Fix:
- Add NormalizePathForComparison() and PathsEqual() to internal/utils/path.go
- These resolve symlinks and lowercase paths on darwin/windows
- Update all workspace path comparisons in registry.go, discovery.go, and
  daemons.go to use PathsEqual()

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
wolf
2026-01-03 13:22:52 -08:00
committed by Steve Yegge
parent a791991103
commit 631b067c1c
5 changed files with 194 additions and 6 deletions

View File

@@ -4,6 +4,8 @@ package utils
import (
"os"
"path/filepath"
"runtime"
"strings"
)
// FindJSONLInDir finds the JSONL file in the given .beads directory.
@@ -114,3 +116,44 @@ func CanonicalizePath(path string) string {
return canonical
}
// NormalizePathForComparison returns a normalized path suitable for comparison.
// It resolves symlinks and handles case-insensitive filesystems (macOS, Windows).
//
// On case-insensitive filesystems (darwin, windows), the path is lowercased
// to ensure that /Users/foo/Desktop and /Users/foo/desktop compare as equal.
//
// This function should be used whenever comparing workspace paths, not for
// storing or displaying paths (preserve original case for those purposes).
func NormalizePathForComparison(path string) string {
if path == "" {
return ""
}
// Try to get absolute path first
absPath, err := filepath.Abs(path)
if err != nil {
absPath = path
}
// Try to resolve symlinks
canonical, err := filepath.EvalSymlinks(absPath)
if err != nil {
// If symlink resolution fails (e.g., path doesn't exist), use absolute path
canonical = absPath
}
// On case-insensitive filesystems, lowercase for comparison
if runtime.GOOS == "darwin" || runtime.GOOS == "windows" {
canonical = strings.ToLower(canonical)
}
return canonical
}
// PathsEqual compares two paths for equality, handling case-insensitive
// filesystems and symlinks. This is the preferred way to compare workspace
// paths in the daemon registry and discovery code.
func PathsEqual(path1, path2 string) bool {
return NormalizePathForComparison(path1) == NormalizePathForComparison(path2)
}

View File

@@ -3,6 +3,8 @@ package utils
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
@@ -259,3 +261,137 @@ func TestFindMoleculesJSONLInDir(t *testing.T) {
t.Fatalf("expected empty path when file missing, got %q", got)
}
}
func TestNormalizePathForComparison(t *testing.T) {
t.Run("empty path", func(t *testing.T) {
result := NormalizePathForComparison("")
if result != "" {
t.Errorf("expected empty string for empty input, got %q", result)
}
})
t.Run("absolute path", func(t *testing.T) {
tmpDir := t.TempDir()
result := NormalizePathForComparison(tmpDir)
if !filepath.IsAbs(result) {
t.Errorf("expected absolute path, got %q", result)
}
})
t.Run("relative path becomes absolute", func(t *testing.T) {
result := NormalizePathForComparison(".")
if !filepath.IsAbs(result) {
t.Errorf("expected absolute path, got %q", result)
}
})
t.Run("symlink resolution", func(t *testing.T) {
tmpDir := t.TempDir()
// Create a subdirectory
subDir := filepath.Join(tmpDir, "subdir")
if err := os.MkdirAll(subDir, 0755); err != nil {
t.Fatal(err)
}
// Create a symlink to the subdirectory
linkPath := filepath.Join(tmpDir, "link")
if err := os.Symlink(subDir, linkPath); err != nil {
t.Skipf("symlink creation failed: %v", err)
}
normalizedLink := NormalizePathForComparison(linkPath)
normalizedSubdir := NormalizePathForComparison(subDir)
if normalizedLink != normalizedSubdir {
t.Errorf("symlink and target should normalize to same path: %q vs %q", normalizedLink, normalizedSubdir)
}
})
t.Run("case normalization on case-insensitive systems", func(t *testing.T) {
if runtime.GOOS != "darwin" && runtime.GOOS != "windows" {
t.Skip("case normalization only applies to darwin/windows")
}
// On macOS/Windows, different case should normalize to same
tmpDir := t.TempDir()
lowerCase := strings.ToLower(tmpDir)
upperCase := strings.ToUpper(tmpDir)
normalizedLower := NormalizePathForComparison(lowerCase)
normalizedUpper := NormalizePathForComparison(upperCase)
if normalizedLower != normalizedUpper {
t.Errorf("case-insensitive paths should normalize to same value: %q vs %q", normalizedLower, normalizedUpper)
}
})
}
func TestPathsEqual(t *testing.T) {
t.Run("identical paths", func(t *testing.T) {
tmpDir := t.TempDir()
if !PathsEqual(tmpDir, tmpDir) {
t.Error("identical paths should be equal")
}
})
t.Run("empty paths", func(t *testing.T) {
if !PathsEqual("", "") {
t.Error("two empty paths should be equal")
}
})
t.Run("one empty path", func(t *testing.T) {
if PathsEqual("/tmp/foo", "") {
t.Error("non-empty and empty paths should not be equal")
}
})
t.Run("different paths", func(t *testing.T) {
if PathsEqual("/tmp/foo", "/tmp/bar") {
t.Error("different paths should not be equal")
}
})
t.Run("symlink equality", func(t *testing.T) {
tmpDir := t.TempDir()
// Create a subdirectory
subDir := filepath.Join(tmpDir, "subdir")
if err := os.MkdirAll(subDir, 0755); err != nil {
t.Fatal(err)
}
// Create a symlink to the subdirectory
linkPath := filepath.Join(tmpDir, "link")
if err := os.Symlink(subDir, linkPath); err != nil {
t.Skipf("symlink creation failed: %v", err)
}
if !PathsEqual(linkPath, subDir) {
t.Error("symlink and target should be equal")
}
})
t.Run("case-insensitive equality on macOS/Windows (GH#869)", func(t *testing.T) {
if runtime.GOOS != "darwin" && runtime.GOOS != "windows" {
t.Skip("case normalization only applies to darwin/windows")
}
// This is the actual bug from GH#869: Desktop vs desktop
tmpDir := t.TempDir()
// Create a subdirectory with mixed case
mixedCase := filepath.Join(tmpDir, "Desktop")
if err := os.MkdirAll(mixedCase, 0755); err != nil {
t.Fatal(err)
}
// The lowercase version should still refer to the same directory
lowerCase := filepath.Join(tmpDir, "desktop")
if !PathsEqual(mixedCase, lowerCase) {
t.Errorf("paths with different case should be equal on case-insensitive FS: %q vs %q", mixedCase, lowerCase)
}
})
}