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
427 lines
11 KiB
Go
427 lines
11 KiB
Go
package utils
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestCanonicalizePath(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
validate func(t *testing.T, result string)
|
|
}{
|
|
{
|
|
name: "absolute path",
|
|
input: "/tmp/test",
|
|
validate: func(t *testing.T, result string) {
|
|
if !filepath.IsAbs(result) {
|
|
t.Errorf("expected absolute path, got %q", result)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "relative path",
|
|
input: ".",
|
|
validate: func(t *testing.T, result string) {
|
|
if !filepath.IsAbs(result) {
|
|
t.Errorf("expected absolute path, got %q", result)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "current directory",
|
|
input: ".",
|
|
validate: func(t *testing.T, result string) {
|
|
cwd, err := os.Getwd()
|
|
if err != nil {
|
|
t.Fatalf("failed to get cwd: %v", err)
|
|
}
|
|
// Result should be canonical form of current directory
|
|
if !filepath.IsAbs(result) {
|
|
t.Errorf("expected absolute path, got %q", result)
|
|
}
|
|
// The result should be related to cwd (could be same or canonical version)
|
|
if result != cwd {
|
|
// Try to canonicalize cwd to compare
|
|
canonicalCwd, err := filepath.EvalSymlinks(cwd)
|
|
if err == nil && result != canonicalCwd {
|
|
t.Errorf("expected %q or %q, got %q", cwd, canonicalCwd, result)
|
|
}
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "empty path",
|
|
input: "",
|
|
validate: func(t *testing.T, result string) {
|
|
// Empty path should be handled (likely becomes "." then current dir)
|
|
if result == "" {
|
|
t.Error("expected non-empty result for empty input")
|
|
}
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := CanonicalizePath(tt.input)
|
|
tt.validate(t, result)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestFindJSONLInDir tests that FindJSONLInDir correctly prefers issues.jsonl
|
|
// and avoids deletions.jsonl and merge artifacts (bd-tqo fix)
|
|
func TestFindJSONLInDir(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
files []string
|
|
expected string
|
|
}{
|
|
{
|
|
name: "only issues.jsonl",
|
|
files: []string{"issues.jsonl"},
|
|
expected: "issues.jsonl",
|
|
},
|
|
{
|
|
name: "issues.jsonl and deletions.jsonl - prefers issues",
|
|
files: []string{"deletions.jsonl", "issues.jsonl"},
|
|
expected: "issues.jsonl",
|
|
},
|
|
{
|
|
name: "issues.jsonl with merge artifacts - prefers issues",
|
|
files: []string{"beads.base.jsonl", "beads.left.jsonl", "beads.right.jsonl", "issues.jsonl"},
|
|
expected: "issues.jsonl",
|
|
},
|
|
{
|
|
name: "beads.jsonl as legacy fallback",
|
|
files: []string{"beads.jsonl"},
|
|
expected: "beads.jsonl",
|
|
},
|
|
{
|
|
name: "issues.jsonl preferred over beads.jsonl",
|
|
files: []string{"beads.jsonl", "issues.jsonl"},
|
|
expected: "issues.jsonl",
|
|
},
|
|
{
|
|
name: "only deletions.jsonl - returns default issues.jsonl",
|
|
files: []string{"deletions.jsonl"},
|
|
expected: "issues.jsonl",
|
|
},
|
|
{
|
|
name: "only interactions.jsonl - returns default issues.jsonl",
|
|
files: []string{"interactions.jsonl"},
|
|
expected: "issues.jsonl",
|
|
},
|
|
{
|
|
name: "interactions.jsonl with issues.jsonl - prefers issues",
|
|
files: []string{"interactions.jsonl", "issues.jsonl"},
|
|
expected: "issues.jsonl",
|
|
},
|
|
{
|
|
name: "only merge artifacts - returns default issues.jsonl",
|
|
files: []string{"beads.base.jsonl", "beads.left.jsonl", "beads.right.jsonl"},
|
|
expected: "issues.jsonl",
|
|
},
|
|
{
|
|
name: "no files - returns default issues.jsonl",
|
|
files: []string{},
|
|
expected: "issues.jsonl",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
tmpDir, err := os.MkdirTemp("", "bd-findjsonl-test-*")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
// Create test files
|
|
for _, file := range tt.files {
|
|
path := filepath.Join(tmpDir, file)
|
|
if err := os.WriteFile(path, []byte("{}"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
result := FindJSONLInDir(tmpDir)
|
|
got := filepath.Base(result)
|
|
|
|
if got != tt.expected {
|
|
t.Errorf("FindJSONLInDir() = %q, want %q", got, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCanonicalizePathSymlink(t *testing.T) {
|
|
// Create a temporary directory
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create a test file
|
|
testFile := filepath.Join(tmpDir, "test.txt")
|
|
if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil {
|
|
t.Fatalf("failed to create test file: %v", err)
|
|
}
|
|
|
|
// Create a symlink to the temp directory
|
|
symlinkPath := filepath.Join(tmpDir, "link")
|
|
if err := os.Symlink(tmpDir, symlinkPath); err != nil {
|
|
t.Skipf("failed to create symlink (may not be supported): %v", err)
|
|
}
|
|
|
|
// Canonicalize the symlink path
|
|
result := CanonicalizePath(symlinkPath)
|
|
|
|
// The result should be the resolved path (tmpDir), not the symlink
|
|
if result != tmpDir {
|
|
// Try to get canonical form of tmpDir for comparison
|
|
canonicalTmpDir, err := filepath.EvalSymlinks(tmpDir)
|
|
if err != nil {
|
|
t.Fatalf("failed to canonicalize tmpDir: %v", err)
|
|
}
|
|
if result != canonicalTmpDir {
|
|
t.Errorf("expected %q or %q, got %q", tmpDir, canonicalTmpDir, result)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestResolveForWrite(t *testing.T) {
|
|
t.Run("regular file", func(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
file := filepath.Join(tmpDir, "regular.txt")
|
|
if err := os.WriteFile(file, []byte("test"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got, err := ResolveForWrite(file)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got != file {
|
|
t.Errorf("got %q, want %q", got, file)
|
|
}
|
|
})
|
|
|
|
t.Run("symlink", func(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
target := filepath.Join(tmpDir, "target.txt")
|
|
if err := os.WriteFile(target, []byte("test"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
link := filepath.Join(tmpDir, "link.txt")
|
|
if err := os.Symlink(target, link); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got, err := ResolveForWrite(link)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
// Resolve target too - on macOS, /var is symlink to /private/var
|
|
wantTarget, _ := filepath.EvalSymlinks(target)
|
|
if got != wantTarget {
|
|
t.Errorf("got %q, want %q", got, wantTarget)
|
|
}
|
|
})
|
|
|
|
t.Run("non-existent", func(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
newFile := filepath.Join(tmpDir, "new.txt")
|
|
|
|
got, err := ResolveForWrite(newFile)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got != newFile {
|
|
t.Errorf("got %q, want %q", got, newFile)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestFindMoleculesJSONLInDir(t *testing.T) {
|
|
root := t.TempDir()
|
|
molecules := filepath.Join(root, "molecules.jsonl")
|
|
if err := os.WriteFile(molecules, []byte("[]"), 0o644); err != nil {
|
|
t.Fatalf("failed to create molecules.jsonl: %v", err)
|
|
}
|
|
|
|
if got := FindMoleculesJSONLInDir(root); got != molecules {
|
|
t.Fatalf("expected %q, got %q", molecules, got)
|
|
}
|
|
|
|
otherDir := t.TempDir()
|
|
if got := FindMoleculesJSONLInDir(otherDir); got != "" {
|
|
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 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()
|
|
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)
|
|
}
|
|
})
|
|
}
|