From 631b067c1c343d94d38508ccc9cdd9c508b708d4 Mon Sep 17 00:00:00 2001 From: wolf Date: Sat, 3 Jan 2026 13:22:52 -0800 Subject: [PATCH] fix(daemon): normalize paths for case-insensitive filesystem comparison (GH#869) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cmd/bd/daemons.go | 10 ++- internal/daemon/discovery.go | 4 +- internal/daemon/registry.go | 7 +- internal/utils/path.go | 43 +++++++++++ internal/utils/path_test.go | 136 +++++++++++++++++++++++++++++++++++ 5 files changed, 194 insertions(+), 6 deletions(-) diff --git a/cmd/bd/daemons.go b/cmd/bd/daemons.go index 97a53b47..a8f969b5 100644 --- a/cmd/bd/daemons.go +++ b/cmd/bd/daemons.go @@ -14,6 +14,7 @@ import ( "github.com/spf13/cobra" "github.com/steveyegge/beads/internal/daemon" + "github.com/steveyegge/beads/internal/utils" ) // JSON response types for daemons commands @@ -180,9 +181,10 @@ Sends shutdown command via RPC, with SIGTERM fallback if RPC fails.`, os.Exit(1) } // Find matching daemon by workspace path or PID + // Use PathsEqual for case-insensitive comparison on macOS/Windows (GH#869) var targetDaemon *daemon.DaemonInfo for _, d := range daemons { - if d.WorkspacePath == target || fmt.Sprintf("%d", d.PID) == target { + if utils.PathsEqual(d.WorkspacePath, target) || fmt.Sprintf("%d", d.PID) == target { targetDaemon = &d break } @@ -232,9 +234,10 @@ Stops the daemon gracefully, then starts a new one.`, os.Exit(1) } // Find the target daemon + // Use PathsEqual for case-insensitive comparison on macOS/Windows (GH#869) var targetDaemon *daemon.DaemonInfo for _, d := range daemons { - if d.WorkspacePath == target || fmt.Sprintf("%d", d.PID) == target { + if utils.PathsEqual(d.WorkspacePath, target) || fmt.Sprintf("%d", d.PID) == target { targetDaemon = &d break } @@ -350,9 +353,10 @@ Supports tail mode (last N lines) and follow mode (like tail -f).`, os.Exit(1) } // Find matching daemon by workspace path or PID + // Use PathsEqual for case-insensitive comparison on macOS/Windows (GH#869) var targetDaemon *daemon.DaemonInfo for _, d := range daemons { - if d.WorkspacePath == target || fmt.Sprintf("%d", d.PID) == target { + if utils.PathsEqual(d.WorkspacePath, target) || fmt.Sprintf("%d", d.PID) == target { targetDaemon = &d break } diff --git a/internal/daemon/discovery.go b/internal/daemon/discovery.go index b41db3e5..45350c47 100644 --- a/internal/daemon/discovery.go +++ b/internal/daemon/discovery.go @@ -10,6 +10,7 @@ import ( "github.com/steveyegge/beads/internal/lockfile" "github.com/steveyegge/beads/internal/rpc" + "github.com/steveyegge/beads/internal/utils" ) // walkWithDepth walks a directory tree with depth limiting @@ -229,7 +230,8 @@ func FindDaemonByWorkspace(workspacePath string) (*DaemonInfo, error) { } for _, daemon := range daemons { - if daemon.WorkspacePath == workspacePath && daemon.Alive { + // Use PathsEqual for case-insensitive comparison on macOS/Windows (GH#869) + if utils.PathsEqual(daemon.WorkspacePath, workspacePath) && daemon.Alive { return &daemon, nil } } diff --git a/internal/daemon/registry.go b/internal/daemon/registry.go index f2e5aa0c..5c64f132 100644 --- a/internal/daemon/registry.go +++ b/internal/daemon/registry.go @@ -9,6 +9,7 @@ import ( "time" "github.com/steveyegge/beads/internal/lockfile" + "github.com/steveyegge/beads/internal/utils" ) // RegistryEntry represents a daemon entry in the registry @@ -179,9 +180,10 @@ func (r *Registry) Register(entry RegistryEntry) error { } // Remove any existing entry for this workspace or PID + // Use PathsEqual for case-insensitive comparison on macOS/Windows (GH#869) filtered := []RegistryEntry{} for _, e := range entries { - if e.WorkspacePath != entry.WorkspacePath && e.PID != entry.PID { + if !utils.PathsEqual(e.WorkspacePath, entry.WorkspacePath) && e.PID != entry.PID { filtered = append(filtered, e) } } @@ -202,9 +204,10 @@ func (r *Registry) Unregister(workspacePath string, pid int) error { } // Filter out entries matching workspace or PID + // Use PathsEqual for case-insensitive comparison on macOS/Windows (GH#869) filtered := []RegistryEntry{} for _, e := range entries { - if e.WorkspacePath != workspacePath && e.PID != pid { + if !utils.PathsEqual(e.WorkspacePath, workspacePath) && e.PID != pid { filtered = append(filtered, e) } } diff --git a/internal/utils/path.go b/internal/utils/path.go index f9ab54ce..a327edfc 100644 --- a/internal/utils/path.go +++ b/internal/utils/path.go @@ -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) +} diff --git a/internal/utils/path_test.go b/internal/utils/path_test.go index df1da201..eba389bb 100644 --- a/internal/utils/path_test.go +++ b/internal/utils/path_test.go @@ -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) + } + }) +}