Files
beads/internal/git/worktree_test.go
beads/crew/dave 7b90678afe 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
2026-01-04 13:54:07 -08:00

1349 lines
44 KiB
Go

package git
import (
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
)
// setupTestRepo creates a temporary git repository for testing
func setupTestRepo(t *testing.T) (repoPath string, cleanup func()) {
t.Helper()
tmpDir := t.TempDir()
repoPath = filepath.Join(tmpDir, "test-repo")
// Create repo directory
if err := os.MkdirAll(repoPath, 0750); err != nil {
t.Fatalf("Failed to create test repo directory: %v", err)
}
// Initialize git repo
cmd := exec.Command("git", "init")
cmd.Dir = repoPath
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("Failed to init git repo: %v\nOutput: %s", err, string(output))
}
// Configure git user for commits
cmd = exec.Command("git", "config", "user.email", "test@example.com")
cmd.Dir = repoPath
if err := cmd.Run(); err != nil {
t.Fatalf("Failed to set git user.email: %v", err)
}
cmd = exec.Command("git", "config", "user.name", "Test User")
cmd.Dir = repoPath
if err := cmd.Run(); err != nil {
t.Fatalf("Failed to set git user.name: %v", err)
}
// Create .beads directory and a test file
beadsDir := filepath.Join(repoPath, ".beads")
if err := os.MkdirAll(beadsDir, 0750); err != nil {
t.Fatalf("Failed to create .beads directory: %v", err)
}
testFile := filepath.Join(beadsDir, "test.jsonl")
if err := os.WriteFile(testFile, []byte("test data\n"), 0644); err != nil {
t.Fatalf("Failed to write test file: %v", err)
}
// Create a file outside .beads to test sparse checkout
otherFile := filepath.Join(repoPath, "other.txt")
if err := os.WriteFile(otherFile, []byte("other data\n"), 0644); err != nil {
t.Fatalf("Failed to write other file: %v", err)
}
// Initial commit
cmd = exec.Command("git", "add", ".")
cmd.Dir = repoPath
if err := cmd.Run(); err != nil {
t.Fatalf("Failed to git add: %v", err)
}
cmd = exec.Command("git", "commit", "-m", "Initial commit")
cmd.Dir = repoPath
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("Failed to commit: %v\nOutput: %s", err, string(output))
}
cleanup = func() {
// Cleanup is handled by t.TempDir()
}
return repoPath, cleanup
}
func TestCreateBeadsWorktree(t *testing.T) {
repoPath, cleanup := setupTestRepo(t)
defer cleanup()
wm := NewWorktreeManager(repoPath)
worktreePath := filepath.Join(t.TempDir(), "beads-worktree")
t.Run("creates new branch worktree", func(t *testing.T) {
err := wm.CreateBeadsWorktree("beads-metadata", worktreePath)
if err != nil {
t.Fatalf("CreateBeadsWorktree failed: %v", err)
}
// Verify worktree exists
if _, err := os.Stat(worktreePath); os.IsNotExist(err) {
t.Errorf("Worktree directory was not created")
}
// Verify .git file exists
gitFile := filepath.Join(worktreePath, ".git")
if _, err := os.Stat(gitFile); os.IsNotExist(err) {
t.Errorf("Worktree .git file was not created")
}
// Verify .beads directory exists in worktree
beadsDir := filepath.Join(worktreePath, ".beads")
if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
t.Errorf(".beads directory not found in worktree")
}
// Verify sparse checkout: other.txt should NOT exist
otherFile := filepath.Join(worktreePath, "other.txt")
if _, err := os.Stat(otherFile); err == nil {
t.Errorf("Sparse checkout failed: other.txt should not exist in worktree")
}
})
t.Run("idempotent - calling twice succeeds", func(t *testing.T) {
worktreePath2 := filepath.Join(t.TempDir(), "beads-worktree-idempotent")
// Create once
if err := wm.CreateBeadsWorktree("beads-metadata-idempotent", worktreePath2); err != nil {
t.Fatalf("First CreateBeadsWorktree failed: %v", err)
}
// Create again with same path (should succeed and be a no-op)
if err := wm.CreateBeadsWorktree("beads-metadata-idempotent", worktreePath2); err != nil {
t.Errorf("Second CreateBeadsWorktree failed (should be idempotent): %v", err)
}
// Verify worktree still exists and is valid
if valid, err := wm.isValidWorktree(worktreePath2); err != nil || !valid {
t.Errorf("Worktree should still be valid after idempotent call: valid=%v, err=%v", valid, err)
}
})
}
func TestRemoveBeadsWorktree(t *testing.T) {
repoPath, cleanup := setupTestRepo(t)
defer cleanup()
wm := NewWorktreeManager(repoPath)
worktreePath := filepath.Join(t.TempDir(), "beads-worktree")
// Create worktree first
if err := wm.CreateBeadsWorktree("beads-metadata", worktreePath); err != nil {
t.Fatalf("CreateBeadsWorktree failed: %v", err)
}
// Verify it exists
if _, err := os.Stat(worktreePath); os.IsNotExist(err) {
t.Fatalf("Worktree was not created")
}
// Remove it
if err := wm.RemoveBeadsWorktree(worktreePath); err != nil {
t.Fatalf("RemoveBeadsWorktree failed: %v", err)
}
// Verify it's gone
if _, err := os.Stat(worktreePath); err == nil {
t.Errorf("Worktree directory still exists after removal")
}
}
func TestCheckWorktreeHealth(t *testing.T) {
repoPath, cleanup := setupTestRepo(t)
defer cleanup()
wm := NewWorktreeManager(repoPath)
t.Run("healthy worktree passes check", func(t *testing.T) {
worktreePath := filepath.Join(t.TempDir(), "beads-worktree")
if err := wm.CreateBeadsWorktree("beads-metadata", worktreePath); err != nil {
t.Fatalf("CreateBeadsWorktree failed: %v", err)
}
if err := wm.CheckWorktreeHealth(worktreePath); err != nil {
t.Errorf("CheckWorktreeHealth failed for healthy worktree: %v", err)
}
})
t.Run("non-existent path fails check", func(t *testing.T) {
nonExistentPath := filepath.Join(t.TempDir(), "does-not-exist")
err := wm.CheckWorktreeHealth(nonExistentPath)
if err == nil {
t.Error("CheckWorktreeHealth should fail for non-existent path")
}
if !strings.Contains(err.Error(), "does not exist") {
t.Errorf("Expected 'does not exist' error, got: %v", err)
}
})
t.Run("invalid worktree fails check", func(t *testing.T) {
invalidPath := filepath.Join(t.TempDir(), "invalid-worktree")
if err := os.MkdirAll(invalidPath, 0750); err != nil {
t.Fatalf("Failed to create invalid path: %v", err)
}
err := wm.CheckWorktreeHealth(invalidPath)
if err == nil {
t.Error("CheckWorktreeHealth should fail for invalid worktree")
}
})
}
func TestSyncJSONLToWorktree(t *testing.T) {
repoPath, cleanup := setupTestRepo(t)
defer cleanup()
wm := NewWorktreeManager(repoPath)
worktreePath := filepath.Join(t.TempDir(), "beads-worktree")
// Create worktree
if err := wm.CreateBeadsWorktree("beads-metadata", worktreePath); err != nil {
t.Fatalf("CreateBeadsWorktree failed: %v", err)
}
// Update the JSONL in the main repo
mainJSONL := filepath.Join(repoPath, ".beads", "test.jsonl")
newData := []byte("updated data\n")
if err := os.WriteFile(mainJSONL, newData, 0644); err != nil {
t.Fatalf("Failed to update main JSONL: %v", err)
}
// Sync to worktree
if err := wm.SyncJSONLToWorktree(worktreePath, ".beads/test.jsonl"); err != nil {
t.Fatalf("SyncJSONLToWorktree failed: %v", err)
}
// Verify the data was synced
worktreeJSONL := filepath.Join(worktreePath, ".beads", "test.jsonl")
data, err := os.ReadFile(worktreeJSONL)
if err != nil {
t.Fatalf("Failed to read worktree JSONL: %v", err)
}
if string(data) != string(newData) {
t.Errorf("JSONL data mismatch.\nExpected: %s\nGot: %s", string(newData), string(data))
}
}
func TestBranchExists(t *testing.T) {
repoPath, cleanup := setupTestRepo(t)
defer cleanup()
wm := NewWorktreeManager(repoPath)
t.Run("main branch exists", func(t *testing.T) {
// Get the default branch name (might be 'main' or 'master')
cmd := exec.Command("git", "branch", "--show-current")
cmd.Dir = repoPath
output, err := cmd.Output()
if err != nil {
t.Fatalf("Failed to get current branch: %v", err)
}
currentBranch := strings.TrimSpace(string(output))
exists := wm.branchExists(currentBranch)
if !exists {
t.Errorf("Current branch %s should exist", currentBranch)
}
})
t.Run("non-existent branch returns false", func(t *testing.T) {
exists := wm.branchExists("does-not-exist-branch")
if exists {
t.Error("Non-existent branch should return false")
}
})
}
func TestIsValidWorktree(t *testing.T) {
repoPath, cleanup := setupTestRepo(t)
defer cleanup()
wm := NewWorktreeManager(repoPath)
t.Run("created worktree is valid", func(t *testing.T) {
worktreePath := filepath.Join(t.TempDir(), "beads-worktree")
if err := wm.CreateBeadsWorktree("beads-metadata", worktreePath); err != nil {
t.Fatalf("CreateBeadsWorktree failed: %v", err)
}
valid, err := wm.isValidWorktree(worktreePath)
if err != nil {
t.Fatalf("isValidWorktree failed: %v", err)
}
if !valid {
t.Error("Created worktree should be valid")
}
})
t.Run("non-worktree path is invalid", func(t *testing.T) {
invalidPath := filepath.Join(t.TempDir(), "not-a-worktree")
if err := os.MkdirAll(invalidPath, 0750); err != nil {
t.Fatalf("Failed to create test directory: %v", err)
}
valid, err := wm.isValidWorktree(invalidPath)
if err != nil {
t.Fatalf("isValidWorktree failed: %v", err)
}
if valid {
t.Error("Non-worktree path should be invalid")
}
})
}
func TestSparseCheckoutConfiguration(t *testing.T) {
repoPath, cleanup := setupTestRepo(t)
defer cleanup()
wm := NewWorktreeManager(repoPath)
worktreePath := filepath.Join(t.TempDir(), "beads-worktree")
// Create worktree
if err := wm.CreateBeadsWorktree("beads-metadata", worktreePath); err != nil {
t.Fatalf("CreateBeadsWorktree failed: %v", err)
}
t.Run("sparse checkout includes .beads", func(t *testing.T) {
if err := wm.verifySparseCheckout(worktreePath); err != nil {
t.Errorf("verifySparseCheckout failed: %v", err)
}
})
t.Run("can reconfigure sparse checkout", func(t *testing.T) {
if err := wm.configureSparseCheckout(worktreePath); err != nil {
t.Errorf("configureSparseCheckout failed: %v", err)
}
// Verify it's still correct
if err := wm.verifySparseCheckout(worktreePath); err != nil {
t.Errorf("verifySparseCheckout failed after reconfigure: %v", err)
}
})
}
func TestRemoveBeadsWorktreeManualCleanup(t *testing.T) {
repoPath, cleanup := setupTestRepo(t)
defer cleanup()
wm := NewWorktreeManager(repoPath)
worktreePath := filepath.Join(t.TempDir(), "beads-worktree")
// Create worktree
if err := wm.CreateBeadsWorktree("beads-metadata", worktreePath); err != nil {
t.Fatalf("CreateBeadsWorktree failed: %v", err)
}
// Manually corrupt the worktree to force manual cleanup path
// Remove the .git file which will cause git worktree remove to fail
gitFile := filepath.Join(worktreePath, ".git")
if err := os.Remove(gitFile); err != nil {
t.Fatalf("Failed to remove .git file: %v", err)
}
// Now remove should use the manual cleanup path
err := wm.RemoveBeadsWorktree(worktreePath)
if err != nil {
t.Errorf("RemoveBeadsWorktree should succeed with manual cleanup: %v", err)
}
// Verify directory is gone
if _, err := os.Stat(worktreePath); err == nil {
t.Error("Worktree directory should be removed")
}
}
func TestRemoveBeadsWorktreeNonExistent(t *testing.T) {
repoPath, cleanup := setupTestRepo(t)
defer cleanup()
wm := NewWorktreeManager(repoPath)
nonExistentPath := filepath.Join(t.TempDir(), "does-not-exist")
// Removing a non-existent worktree should succeed (no-op)
err := wm.RemoveBeadsWorktree(nonExistentPath)
if err != nil {
t.Errorf("RemoveBeadsWorktree should succeed for non-existent path: %v", err)
}
}
func TestSyncJSONLToWorktreeErrors(t *testing.T) {
repoPath, cleanup := setupTestRepo(t)
defer cleanup()
wm := NewWorktreeManager(repoPath)
worktreePath := filepath.Join(t.TempDir(), "beads-worktree")
// Create worktree
if err := wm.CreateBeadsWorktree("beads-metadata", worktreePath); err != nil {
t.Fatalf("CreateBeadsWorktree failed: %v", err)
}
t.Run("fails when source file does not exist", func(t *testing.T) {
err := wm.SyncJSONLToWorktree(worktreePath, ".beads/nonexistent.jsonl")
if err == nil {
t.Error("SyncJSONLToWorktree should fail when source file does not exist")
}
if !strings.Contains(err.Error(), "failed to read source JSONL") {
t.Errorf("Expected 'failed to read source JSONL' error, got: %v", err)
}
})
}
func TestCreateBeadsWorktreeWithExistingBranch(t *testing.T) {
repoPath, cleanup := setupTestRepo(t)
defer cleanup()
wm := NewWorktreeManager(repoPath)
// Create a branch first
branchName := "existing-branch"
cmd := exec.Command("git", "branch", branchName)
cmd.Dir = repoPath
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("Failed to create branch: %v\nOutput: %s", err, string(output))
}
// Now create worktree with this existing branch
worktreePath := filepath.Join(t.TempDir(), "beads-worktree")
if err := wm.CreateBeadsWorktree(branchName, worktreePath); err != nil {
t.Fatalf("CreateBeadsWorktree failed with existing branch: %v", err)
}
// Verify worktree was created
if _, err := os.Stat(worktreePath); os.IsNotExist(err) {
t.Error("Worktree directory was not created")
}
// Verify .beads exists
beadsDir := filepath.Join(worktreePath, ".beads")
if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
t.Error(".beads directory not found in worktree")
}
}
func TestCreateBeadsWorktreeInvalidPath(t *testing.T) {
repoPath, cleanup := setupTestRepo(t)
defer cleanup()
wm := NewWorktreeManager(repoPath)
worktreePath := filepath.Join(t.TempDir(), "beads-worktree")
// Create a file where the worktree directory should be (but not a valid worktree)
if err := os.WriteFile(worktreePath, []byte("not a worktree"), 0644); err != nil {
t.Fatalf("Failed to create blocking file: %v", err)
}
// CreateBeadsWorktree should handle this - it should remove the invalid path
err := wm.CreateBeadsWorktree("beads-metadata", worktreePath)
if err != nil {
t.Fatalf("CreateBeadsWorktree should handle invalid path: %v", err)
}
// Verify worktree was created
if _, err := os.Stat(worktreePath); os.IsNotExist(err) {
t.Error("Worktree directory was not created")
}
// Verify it's now a valid worktree (directory, not file)
info, err := os.Stat(worktreePath)
if err != nil {
t.Fatalf("Failed to stat worktree path: %v", err)
}
if !info.IsDir() {
t.Error("Worktree path should be a directory")
}
}
func TestCheckWorktreeHealthWithBrokenSparseCheckout(t *testing.T) {
repoPath, cleanup := setupTestRepo(t)
defer cleanup()
wm := NewWorktreeManager(repoPath)
worktreePath := filepath.Join(t.TempDir(), "beads-worktree")
// Create worktree
if err := wm.CreateBeadsWorktree("beads-metadata", worktreePath); err != nil {
t.Fatalf("CreateBeadsWorktree failed: %v", err)
}
// Read the .git file to find the git directory
gitFile := filepath.Join(worktreePath, ".git")
gitContent, err := os.ReadFile(gitFile)
if err != nil {
t.Fatalf("Failed to read .git file: %v", err)
}
// Parse "gitdir: /path/to/git/dir"
gitDirLine := strings.TrimSpace(string(gitContent))
gitDir := strings.TrimPrefix(gitDirLine, "gitdir: ")
// Corrupt the sparse-checkout file
sparseFile := filepath.Join(gitDir, "info", "sparse-checkout")
if err := os.WriteFile(sparseFile, []byte("invalid\n"), 0644); err != nil {
t.Fatalf("Failed to corrupt sparse-checkout: %v", err)
}
// CheckWorktreeHealth should detect the problem and attempt to fix it
err = wm.CheckWorktreeHealth(worktreePath)
if err != nil {
t.Errorf("CheckWorktreeHealth should repair broken sparse checkout: %v", err)
}
// Verify sparse checkout was repaired
if err := wm.verifySparseCheckout(worktreePath); err != nil {
t.Errorf("Sparse checkout should be repaired: %v", err)
}
}
func TestVerifySparseCheckoutErrors(t *testing.T) {
repoPath, cleanup := setupTestRepo(t)
defer cleanup()
wm := NewWorktreeManager(repoPath)
t.Run("fails with missing .git file", func(t *testing.T) {
invalidPath := filepath.Join(t.TempDir(), "no-git-file")
if err := os.MkdirAll(invalidPath, 0750); err != nil {
t.Fatalf("Failed to create test directory: %v", err)
}
err := wm.verifySparseCheckout(invalidPath)
if err == nil {
t.Error("verifySparseCheckout should fail with missing .git file")
}
})
t.Run("fails with invalid .git file format", func(t *testing.T) {
invalidPath := filepath.Join(t.TempDir(), "invalid-git-file")
if err := os.MkdirAll(invalidPath, 0750); err != nil {
t.Fatalf("Failed to create test directory: %v", err)
}
// Create an invalid .git file (missing "gitdir: " prefix)
gitFile := filepath.Join(invalidPath, ".git")
if err := os.WriteFile(gitFile, []byte("invalid format"), 0644); err != nil {
t.Fatalf("Failed to create invalid .git file: %v", err)
}
err := wm.verifySparseCheckout(invalidPath)
if err == nil {
t.Error("verifySparseCheckout should fail with invalid .git file format")
}
// git sparse-checkout list will fail when .git file is invalid
if !strings.Contains(err.Error(), "failed to list sparse checkout patterns") {
t.Errorf("Expected 'failed to list sparse checkout patterns' error, got: %v", err)
}
})
}
func TestConfigureSparseCheckoutErrors(t *testing.T) {
repoPath, cleanup := setupTestRepo(t)
defer cleanup()
wm := NewWorktreeManager(repoPath)
t.Run("fails with missing .git file", func(t *testing.T) {
invalidPath := filepath.Join(t.TempDir(), "no-git-file")
if err := os.MkdirAll(invalidPath, 0750); err != nil {
t.Fatalf("Failed to create test directory: %v", err)
}
err := wm.configureSparseCheckout(invalidPath)
if err == nil {
t.Error("configureSparseCheckout should fail with missing .git file")
}
})
t.Run("fails with invalid .git file format", func(t *testing.T) {
invalidPath := filepath.Join(t.TempDir(), "invalid-git-file")
if err := os.MkdirAll(invalidPath, 0750); err != nil {
t.Fatalf("Failed to create test directory: %v", err)
}
// Create an invalid .git file
gitFile := filepath.Join(invalidPath, ".git")
if err := os.WriteFile(gitFile, []byte("invalid format"), 0644); err != nil {
t.Fatalf("Failed to create invalid .git file: %v", err)
}
err := wm.configureSparseCheckout(invalidPath)
if err == nil {
t.Error("configureSparseCheckout should fail with invalid .git file format")
}
})
}
// TestSyncJSONLToWorktreeMerge tests the merge behavior when worktree has more issues
// than the local repo (bd-52q fix for GitHub #464)
func TestSyncJSONLToWorktreeMerge(t *testing.T) {
repoPath, cleanup := setupTestRepo(t)
defer cleanup()
wm := NewWorktreeManager(repoPath)
worktreePath := filepath.Join(t.TempDir(), "beads-worktree")
// Create worktree
if err := wm.CreateBeadsWorktree("beads-metadata", worktreePath); err != nil {
t.Fatalf("CreateBeadsWorktree failed: %v", err)
}
t.Run("merges when worktree has more issues than local", func(t *testing.T) {
// Set up: worktree has 3 issues (simulating remote state)
worktreeJSONL := filepath.Join(worktreePath, ".beads", "issues.jsonl")
worktreeData := `{"id":"bd-001","title":"Issue 1","status":"open","created_at":"2025-01-01T00:00:00Z","created_by":"user1"}
{"id":"bd-002","title":"Issue 2","status":"open","created_at":"2025-01-01T00:00:01Z","created_by":"user1"}
{"id":"bd-003","title":"Issue 3","status":"open","created_at":"2025-01-01T00:00:02Z","created_by":"user1"}
`
if err := os.WriteFile(worktreeJSONL, []byte(worktreeData), 0644); err != nil {
t.Fatalf("Failed to write worktree JSONL: %v", err)
}
// Local has only 1 issue (simulating fresh clone that hasn't synced)
mainJSONL := filepath.Join(repoPath, ".beads", "issues.jsonl")
mainData := `{"id":"bd-004","title":"New Issue","status":"open","created_at":"2025-01-02T00:00:00Z","created_by":"user2"}
`
if err := os.WriteFile(mainJSONL, []byte(mainData), 0644); err != nil {
t.Fatalf("Failed to write main JSONL: %v", err)
}
// Sync should MERGE, not overwrite
if err := wm.SyncJSONLToWorktree(worktreePath, ".beads/issues.jsonl"); err != nil {
t.Fatalf("SyncJSONLToWorktree failed: %v", err)
}
// Read the result
resultData, err := os.ReadFile(worktreeJSONL)
if err != nil {
t.Fatalf("Failed to read result JSONL: %v", err)
}
// Should have all 4 issues (3 from worktree + 1 from local)
resultCount := countJSONLIssues(resultData)
if resultCount != 4 {
t.Errorf("Expected 4 issues after merge, got %d\nContent:\n%s", resultCount, string(resultData))
}
// Verify specific issues are present
resultStr := string(resultData)
for _, id := range []string{"bd-001", "bd-002", "bd-003", "bd-004"} {
if !strings.Contains(resultStr, id) {
t.Errorf("Expected issue %s to be in merged result", id)
}
}
})
t.Run("overwrites when local has same or more issues", func(t *testing.T) {
// Set up: worktree has 2 issues
worktreeJSONL := filepath.Join(worktreePath, ".beads", "issues.jsonl")
worktreeData := `{"id":"bd-010","title":"Old 1","status":"open","created_at":"2025-01-01T00:00:00Z","created_by":"user1"}
{"id":"bd-011","title":"Old 2","status":"open","created_at":"2025-01-01T00:00:01Z","created_by":"user1"}
`
if err := os.WriteFile(worktreeJSONL, []byte(worktreeData), 0644); err != nil {
t.Fatalf("Failed to write worktree JSONL: %v", err)
}
// Local has 3 issues (more than worktree)
mainJSONL := filepath.Join(repoPath, ".beads", "issues.jsonl")
mainData := `{"id":"bd-020","title":"New 1","status":"open","created_at":"2025-01-02T00:00:00Z","created_by":"user2"}
{"id":"bd-021","title":"New 2","status":"open","created_at":"2025-01-02T00:00:01Z","created_by":"user2"}
{"id":"bd-022","title":"New 3","status":"open","created_at":"2025-01-02T00:00:02Z","created_by":"user2"}
`
if err := os.WriteFile(mainJSONL, []byte(mainData), 0644); err != nil {
t.Fatalf("Failed to write main JSONL: %v", err)
}
// Sync should OVERWRITE (local is authoritative when it has more)
if err := wm.SyncJSONLToWorktree(worktreePath, ".beads/issues.jsonl"); err != nil {
t.Fatalf("SyncJSONLToWorktree failed: %v", err)
}
// Read the result
resultData, err := os.ReadFile(worktreeJSONL)
if err != nil {
t.Fatalf("Failed to read result JSONL: %v", err)
}
// Should have exactly 3 issues (from local)
resultCount := countJSONLIssues(resultData)
if resultCount != 3 {
t.Errorf("Expected 3 issues after overwrite, got %d", resultCount)
}
// Should have local issues, not worktree issues
resultStr := string(resultData)
if strings.Contains(resultStr, "bd-010") || strings.Contains(resultStr, "bd-011") {
t.Error("Old worktree issues should have been overwritten")
}
if !strings.Contains(resultStr, "bd-020") || !strings.Contains(resultStr, "bd-021") || !strings.Contains(resultStr, "bd-022") {
t.Error("New local issues should be present")
}
})
}
// TestSyncJSONLToWorktree_DeleteMutation is a regression test for the bug where
// intentional deletions via `bd delete` are not synced to the sync branch.
// The issue: SyncJSONLToWorktree uses issue count to decide merge vs overwrite.
// When local has fewer issues (due to deletion), it merges instead of overwrites,
// which re-adds the deleted issue. This test verifies that when forceOverwrite
// is true (indicating an intentional mutation like delete), the local state
// is copied to the worktree without merging.
// GitHub Issue: #XXX (daemon auto-sync delete mutation not reflected in sync branch)
func TestSyncJSONLToWorktree_DeleteMutation(t *testing.T) {
repoPath, cleanup := setupTestRepo(t)
defer cleanup()
wm := NewWorktreeManager(repoPath)
worktreePath := filepath.Join(t.TempDir(), "beads-worktree-delete")
// Create worktree
if err := wm.CreateBeadsWorktree("beads-metadata", worktreePath); err != nil {
t.Fatalf("CreateBeadsWorktree failed: %v", err)
}
t.Run("forceOverwrite=true overwrites even when local has fewer issues", func(t *testing.T) {
// Set up: worktree has 3 issues (simulating sync branch state before delete)
worktreeJSONL := filepath.Join(worktreePath, ".beads", "issues.jsonl")
worktreeData := `{"id":"bd-100","title":"Issue 1","status":"open","created_at":"2025-01-01T00:00:00Z","created_by":"user1"}
{"id":"bd-101","title":"Issue 2","status":"open","created_at":"2025-01-01T00:00:01Z","created_by":"user1"}
{"id":"bd-102","title":"Issue 3 - TO BE DELETED","status":"open","created_at":"2025-01-01T00:00:02Z","created_by":"user1"}
`
if err := os.WriteFile(worktreeJSONL, []byte(worktreeData), 0644); err != nil {
t.Fatalf("Failed to write worktree JSONL: %v", err)
}
// Local has 2 issues (user deleted bd-102 via `bd delete bd-102 --force`)
mainJSONL := filepath.Join(repoPath, ".beads", "issues.jsonl")
mainData := `{"id":"bd-100","title":"Issue 1","status":"open","created_at":"2025-01-01T00:00:00Z","created_by":"user1"}
{"id":"bd-101","title":"Issue 2","status":"open","created_at":"2025-01-01T00:00:01Z","created_by":"user1"}
`
if err := os.WriteFile(mainJSONL, []byte(mainData), 0644); err != nil {
t.Fatalf("Failed to write main JSONL: %v", err)
}
// Sync with forceOverwrite=true (simulating daemon sync after delete mutation)
if err := wm.SyncJSONLToWorktreeWithOptions(worktreePath, ".beads/issues.jsonl", SyncOptions{ForceOverwrite: true}); err != nil {
t.Fatalf("SyncJSONLToWorktreeWithOptions failed: %v", err)
}
// Read the result
resultData, err := os.ReadFile(worktreeJSONL)
if err != nil {
t.Fatalf("Failed to read result JSONL: %v", err)
}
// Should have exactly 2 issues (deleted issue should NOT be re-added)
resultCount := countJSONLIssues(resultData)
if resultCount != 2 {
t.Errorf("Expected 2 issues after delete sync, got %d\nContent:\n%s", resultCount, string(resultData))
}
// Verify deleted issue is NOT present
resultStr := string(resultData)
if strings.Contains(resultStr, "bd-102") {
t.Error("Deleted issue bd-102 should NOT be in synced result (forceOverwrite=true)")
}
// Verify remaining issues are present
if !strings.Contains(resultStr, "bd-100") || !strings.Contains(resultStr, "bd-101") {
t.Error("Remaining issues bd-100 and bd-101 should be present")
}
})
t.Run("forceOverwrite=false merges when local has fewer issues (fresh clone scenario)", func(t *testing.T) {
// Set up: worktree has 3 issues (simulating remote state)
worktreeJSONL := filepath.Join(worktreePath, ".beads", "issues.jsonl")
worktreeData := `{"id":"bd-200","title":"Remote Issue 1","status":"open","created_at":"2025-01-01T00:00:00Z","created_by":"user1"}
{"id":"bd-201","title":"Remote Issue 2","status":"open","created_at":"2025-01-01T00:00:01Z","created_by":"user1"}
{"id":"bd-202","title":"Remote Issue 3","status":"open","created_at":"2025-01-01T00:00:02Z","created_by":"user1"}
`
if err := os.WriteFile(worktreeJSONL, []byte(worktreeData), 0644); err != nil {
t.Fatalf("Failed to write worktree JSONL: %v", err)
}
// Local has 1 issue (fresh clone that hasn't synced yet)
mainJSONL := filepath.Join(repoPath, ".beads", "issues.jsonl")
mainData := `{"id":"bd-203","title":"Local New Issue","status":"open","created_at":"2025-01-02T00:00:00Z","created_by":"user2"}
`
if err := os.WriteFile(mainJSONL, []byte(mainData), 0644); err != nil {
t.Fatalf("Failed to write main JSONL: %v", err)
}
// Sync with forceOverwrite=false (default behavior for non-mutation syncs)
if err := wm.SyncJSONLToWorktreeWithOptions(worktreePath, ".beads/issues.jsonl", SyncOptions{ForceOverwrite: false}); err != nil {
t.Fatalf("SyncJSONLToWorktreeWithOptions failed: %v", err)
}
// Read the result
resultData, err := os.ReadFile(worktreeJSONL)
if err != nil {
t.Fatalf("Failed to read result JSONL: %v", err)
}
// Should have all 4 issues (3 from remote + 1 from local, merged)
resultCount := countJSONLIssues(resultData)
if resultCount != 4 {
t.Errorf("Expected 4 issues after merge, got %d\nContent:\n%s", resultCount, string(resultData))
}
// Verify all issues are present
resultStr := string(resultData)
for _, id := range []string{"bd-200", "bd-201", "bd-202", "bd-203"} {
if !strings.Contains(resultStr, id) {
t.Errorf("Expected issue %s to be in merged result", id)
}
}
})
}
func TestCountJSONLIssues(t *testing.T) {
tests := []struct {
name string
data string
expected int
}{
{
name: "empty file",
data: "",
expected: 0,
},
{
name: "single issue",
data: `{"id":"bd-001","title":"Test"}`,
expected: 1,
},
{
name: "multiple issues",
data: `{"id":"bd-001","title":"Test 1"}
{"id":"bd-002","title":"Test 2"}
{"id":"bd-003","title":"Test 3"}`,
expected: 3,
},
{
name: "with blank lines",
data: `{"id":"bd-001","title":"Test 1"}
{"id":"bd-002","title":"Test 2"}
`,
expected: 2,
},
{
name: "non-JSON lines ignored",
data: "# comment\n{\"id\":\"bd-001\"}\nnot json",
expected: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
count := countJSONLIssues([]byte(tt.data))
if count != tt.expected {
t.Errorf("countJSONLIssues() = %d, want %d", count, tt.expected)
}
})
}
}
// TestGetMainRepoRoot tests the GetMainRepoRoot function for various scenarios
func TestGetMainRepoRoot(t *testing.T) {
t.Run("returns correct root for regular repo", func(t *testing.T) {
ResetCaches() // Reset caches from previous subtests
repoPath, cleanup := setupTestRepo(t)
defer cleanup()
// Save current dir and change to repo
originalDir, err := os.Getwd()
if err != nil {
t.Fatalf("Failed to get current dir: %v", err)
}
defer func() { _ = os.Chdir(originalDir) }()
if err := os.Chdir(repoPath); err != nil {
t.Fatalf("Failed to chdir to repo: %v", err)
}
ResetCaches() // Reset after chdir
root, err := GetMainRepoRoot()
if err != nil {
t.Fatalf("GetMainRepoRoot failed: %v", err)
}
// Resolve symlinks for comparison (e.g., /tmp -> /private/tmp on macOS)
expectedRoot, _ := filepath.EvalSymlinks(repoPath)
actualRoot, _ := filepath.EvalSymlinks(root)
if actualRoot != expectedRoot {
t.Errorf("GetMainRepoRoot() = %s, want %s", actualRoot, expectedRoot)
}
})
t.Run("returns correct root for submodule repo", func(t *testing.T) {
ResetCaches() // Reset caches from previous subtests
superRepoPath, superCleanup := setupTestRepo(t)
defer superCleanup()
submoduleRepoPath, submoduleCleanup := setupTestRepo(t)
defer submoduleCleanup()
addCmd := exec.Command("git", "-c", "protocol.file.allow=always", "submodule", "add", submoduleRepoPath, "core")
addCmd.Dir = superRepoPath
if output, err := addCmd.CombinedOutput(); err != nil {
t.Fatalf("Failed to add submodule: %v\nOutput: %s", err, string(output))
}
commitCmd := exec.Command("git", "commit", "-m", "Add submodule")
commitCmd.Dir = superRepoPath
if output, err := commitCmd.CombinedOutput(); err != nil {
t.Fatalf("Failed to commit submodule: %v\nOutput: %s", err, string(output))
}
submodulePath := filepath.Join(superRepoPath, "core")
// Save current dir and change to submodule
originalDir, err := os.Getwd()
if err != nil {
t.Fatalf("Failed to get current dir: %v", err)
}
defer func() { _ = os.Chdir(originalDir) }()
if err := os.Chdir(submodulePath); err != nil {
t.Fatalf("Failed to chdir to submodule: %v", err)
}
ResetCaches() // Reset after chdir
root, err := GetMainRepoRoot()
if err != nil {
t.Fatalf("GetMainRepoRoot failed: %v", err)
}
// Resolve symlinks for comparison (e.g., /tmp -> /private/tmp on macOS)
expectedRoot, _ := filepath.EvalSymlinks(submodulePath)
actualRoot, _ := filepath.EvalSymlinks(root)
if actualRoot != expectedRoot {
t.Errorf("GetMainRepoRoot() = %s, want %s (submodule repo)", actualRoot, expectedRoot)
}
})
t.Run("returns main repo root from worktree", func(t *testing.T) {
ResetCaches() // Reset caches from previous subtests
repoPath, cleanup := setupTestRepo(t)
defer cleanup()
wm := NewWorktreeManager(repoPath)
worktreePath := filepath.Join(t.TempDir(), "test-worktree")
if err := wm.CreateBeadsWorktree("test-branch", worktreePath); err != nil {
t.Fatalf("CreateBeadsWorktree failed: %v", err)
}
// Save current dir and change to worktree
originalDir, err := os.Getwd()
if err != nil {
t.Fatalf("Failed to get current dir: %v", err)
}
defer func() { _ = os.Chdir(originalDir) }()
if err := os.Chdir(worktreePath); err != nil {
t.Fatalf("Failed to chdir to worktree: %v", err)
}
ResetCaches() // Reset after chdir
root, err := GetMainRepoRoot()
if err != nil {
t.Fatalf("GetMainRepoRoot failed: %v", err)
}
// Resolve symlinks for comparison
expectedRoot, _ := filepath.EvalSymlinks(repoPath)
actualRoot, _ := filepath.EvalSymlinks(root)
if actualRoot != expectedRoot {
t.Errorf("GetMainRepoRoot() = %s, want %s (main repo)", actualRoot, expectedRoot)
}
})
t.Run("returns main repo root from nested worktree (GH#509)", func(t *testing.T) {
ResetCaches() // Reset caches from previous subtests
repoPath, cleanup := setupTestRepo(t)
defer cleanup()
// Create a nested worktree directory structure: repo/.worktrees/feature/
nestedWorktreePath := filepath.Join(repoPath, ".worktrees", "feature-branch")
wm := NewWorktreeManager(repoPath)
if err := wm.CreateBeadsWorktree("feature-branch", nestedWorktreePath); err != nil {
t.Fatalf("CreateBeadsWorktree failed: %v", err)
}
// Save current dir and change to nested worktree
originalDir, err := os.Getwd()
if err != nil {
t.Fatalf("Failed to get current dir: %v", err)
}
defer func() { _ = os.Chdir(originalDir) }()
if err := os.Chdir(nestedWorktreePath); err != nil {
t.Fatalf("Failed to chdir to nested worktree: %v", err)
}
ResetCaches() // Reset after chdir
root, err := GetMainRepoRoot()
if err != nil {
t.Fatalf("GetMainRepoRoot failed: %v", err)
}
// Resolve symlinks for comparison
expectedRoot, _ := filepath.EvalSymlinks(repoPath)
actualRoot, _ := filepath.EvalSymlinks(root)
if actualRoot != expectedRoot {
t.Errorf("GetMainRepoRoot() = %s, want %s (main repo, not worktree)", actualRoot, expectedRoot)
}
})
t.Run("returns main repo root from subdirectory of nested worktree", func(t *testing.T) {
ResetCaches() // Reset caches from previous subtests
repoPath, cleanup := setupTestRepo(t)
defer cleanup()
// Create a nested worktree
nestedWorktreePath := filepath.Join(repoPath, ".worktrees", "feature-branch")
wm := NewWorktreeManager(repoPath)
if err := wm.CreateBeadsWorktree("feature-branch", nestedWorktreePath); err != nil {
t.Fatalf("CreateBeadsWorktree failed: %v", err)
}
// Create a subdirectory in the worktree
subDir := filepath.Join(nestedWorktreePath, "some", "nested", "dir")
if err := os.MkdirAll(subDir, 0750); err != nil {
t.Fatalf("Failed to create subdir: %v", err)
}
// Save current dir and change to subdirectory
originalDir, err := os.Getwd()
if err != nil {
t.Fatalf("Failed to get current dir: %v", err)
}
defer func() { _ = os.Chdir(originalDir) }()
if err := os.Chdir(subDir); err != nil {
t.Fatalf("Failed to chdir to subdir: %v", err)
}
ResetCaches() // Reset after chdir
root, err := GetMainRepoRoot()
if err != nil {
t.Fatalf("GetMainRepoRoot failed: %v", err)
}
// Resolve symlinks for comparison
expectedRoot, _ := filepath.EvalSymlinks(repoPath)
actualRoot, _ := filepath.EvalSymlinks(root)
if actualRoot != expectedRoot {
t.Errorf("GetMainRepoRoot() = %s, want %s (main repo)", actualRoot, expectedRoot)
}
})
}
// TestIsWorktree tests the IsWorktree function
func TestIsWorktree(t *testing.T) {
t.Run("returns false for regular repo", func(t *testing.T) {
ResetCaches() // Reset caches from previous subtests
repoPath, cleanup := setupTestRepo(t)
defer cleanup()
originalDir, err := os.Getwd()
if err != nil {
t.Fatalf("Failed to get current dir: %v", err)
}
defer func() { _ = os.Chdir(originalDir) }()
if err := os.Chdir(repoPath); err != nil {
t.Fatalf("Failed to chdir to repo: %v", err)
}
ResetCaches() // Reset after chdir
if IsWorktree() {
t.Error("IsWorktree() should return false for regular repo")
}
})
t.Run("returns true for worktree", func(t *testing.T) {
ResetCaches() // Reset caches from previous subtests
repoPath, cleanup := setupTestRepo(t)
defer cleanup()
wm := NewWorktreeManager(repoPath)
worktreePath := filepath.Join(t.TempDir(), "test-worktree")
if err := wm.CreateBeadsWorktree("test-branch", worktreePath); err != nil {
t.Fatalf("CreateBeadsWorktree failed: %v", err)
}
originalDir, err := os.Getwd()
if err != nil {
t.Fatalf("Failed to get current dir: %v", err)
}
defer func() { _ = os.Chdir(originalDir) }()
if err := os.Chdir(worktreePath); err != nil {
t.Fatalf("Failed to chdir to worktree: %v", err)
}
ResetCaches() // Reset after chdir to worktree
if !IsWorktree() {
t.Error("IsWorktree() should return true for worktree")
}
})
}
// TestCreateBeadsWorktree_MissingButRegistered tests the issue #609 scenario where
// the worktree directory is deleted but git still has it registered in .git/worktrees/.
// The -f flag on git worktree add should handle this gracefully.
func TestCreateBeadsWorktree_MissingButRegistered(t *testing.T) {
repoPath, cleanup := setupTestRepo(t)
defer cleanup()
wm := NewWorktreeManager(repoPath)
worktreePath := filepath.Join(t.TempDir(), "beads-worktree-gh609")
branch := "beads-metadata-gh609"
// Step 1: Create a worktree
if err := wm.CreateBeadsWorktree(branch, worktreePath); err != nil {
t.Fatalf("Initial CreateBeadsWorktree failed: %v", err)
}
// Verify it exists and is registered
if _, err := os.Stat(worktreePath); os.IsNotExist(err) {
t.Fatal("Worktree was not created")
}
// Step 2: Manually delete the worktree directory (simulating the bug scenario)
// but leave the git registration in .git/worktrees/
if err := os.RemoveAll(worktreePath); err != nil {
t.Fatalf("Failed to remove worktree directory: %v", err)
}
// Verify the directory is gone
if _, err := os.Stat(worktreePath); !os.IsNotExist(err) {
t.Fatal("Worktree directory should not exist after removal")
}
// Verify git still has it registered (this is the "missing but registered" state)
cmd := exec.Command("git", "worktree", "list", "--porcelain")
cmd.Dir = repoPath
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("Failed to list worktrees: %v", err)
}
if !strings.Contains(string(output), "worktree") {
t.Log("Note: git worktree list shows no worktrees - prune may have run")
}
// Step 3: Try to recreate the worktree - this should succeed with -f flag (issue #609 fix)
// Without the fix, this would fail with:
// "fatal: '.git/beads-worktrees/...' is a missing but already registered worktree"
if err := wm.CreateBeadsWorktree(branch, worktreePath); err != nil {
t.Errorf("CreateBeadsWorktree failed for missing-but-registered worktree (issue #609): %v", err)
}
// Verify the worktree was recreated successfully
if _, err := os.Stat(worktreePath); os.IsNotExist(err) {
t.Error("Worktree was not recreated after issue #609 fix")
}
// Verify it's a valid worktree
valid, err := wm.isValidWorktree(worktreePath)
if err != nil || !valid {
t.Errorf("Recreated worktree should be valid: valid=%v, err=%v", valid, err)
}
}
// TestCreateBeadsWorktree_MainRepoSparseCheckoutDisabled tests that creating a worktree
// does not leave core.sparseCheckout enabled on the main repo (GH#886).
// Git 2.38+ enables sparse checkout on the main repo as a side effect of worktree creation,
// which causes confusing "You are in a sparse checkout with 100% of tracked files present"
// message in git status.
func TestCreateBeadsWorktree_MainRepoSparseCheckoutDisabled(t *testing.T) {
repoPath, cleanup := setupTestRepo(t)
defer cleanup()
wm := NewWorktreeManager(repoPath)
worktreePath := filepath.Join(t.TempDir(), "beads-worktree-gh886")
// Verify sparse checkout is not enabled before worktree creation
cmd := exec.Command("git", "config", "--get", "core.sparseCheckout")
cmd.Dir = repoPath
output, _ := cmd.Output()
initialValue := strings.TrimSpace(string(output))
// Empty or "false" are both acceptable initial states
if initialValue == "true" {
t.Log("Note: sparse checkout was already enabled before test")
}
// Create worktree
if err := wm.CreateBeadsWorktree("beads-gh886", worktreePath); err != nil {
t.Fatalf("CreateBeadsWorktree failed: %v", err)
}
// Verify sparse checkout is disabled on main repo after worktree creation
cmd = exec.Command("git", "config", "--get", "core.sparseCheckout")
cmd.Dir = repoPath
output, _ = cmd.Output()
finalValue := strings.TrimSpace(string(output))
// Should be either empty (unset) or "false"
if finalValue == "true" {
t.Errorf("GH#886: Main repo has core.sparseCheckout=true after worktree creation. "+
"This causes confusing git status message. Value should be 'false' or unset, got: %q", finalValue)
}
// Verify that sparse checkout functionality STILL WORKS in the worktree
// (the patterns were applied during checkout, before we disabled the config)
// Check that .beads exists but other.txt does not
beadsDir := filepath.Join(worktreePath, ".beads")
if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
t.Error(".beads directory should exist in worktree (sparse checkout should include it)")
}
otherFile := filepath.Join(worktreePath, "other.txt")
if _, err := os.Stat(otherFile); err == nil {
t.Error("other.txt should NOT exist in worktree (sparse checkout should exclude it)")
}
}
// 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 {
name string
input string
expected string
}{
{
name: "already normalized path",
input: ".beads/issues.jsonl",
expected: ".beads/issues.jsonl",
},
{
name: "worktree prefix - main",
input: "main/.beads/issues.jsonl",
expected: ".beads/issues.jsonl",
},
{
name: "worktree prefix - feature branch",
input: "feat-tts-config/.beads/issues.jsonl",
expected: ".beads/issues.jsonl",
},
{
name: "nested worktree prefix",
input: "worktrees/feature/.beads/issues.jsonl",
expected: ".beads/issues.jsonl",
},
{
name: "metadata file",
input: "main/.beads/metadata.json",
expected: ".beads/metadata.json",
},
{
name: "no .beads in path",
input: "some/other/path.jsonl",
expected: "some/other/path.jsonl",
},
{
name: "only .beads dir (no trailing slash - not normalized)",
input: ".beads",
expected: ".beads",
},
{
name: "empty path",
input: "",
expected: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := NormalizeBeadsRelPath(tt.input)
if result != tt.expected {
t.Errorf("NormalizeBeadsRelPath(%q) = %q, want %q", tt.input, result, tt.expected)
}
})
}
}