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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user