diff --git a/cmd/bd/daemon_sync_branch.go b/cmd/bd/daemon_sync_branch.go index cd508653..bf8e2dd5 100644 --- a/cmd/bd/daemon_sync_branch.go +++ b/cmd/bd/daemon_sync_branch.go @@ -92,7 +92,9 @@ func syncBranchCommitAndPushWithOptions(ctx context.Context, store storage.Stora } // Check for changes in worktree - worktreeJSONLPath := filepath.Join(worktreePath, jsonlRelPath) + // GH#810: Normalize path for bare repo worktrees + normalizedRelPath := git.NormalizeBeadsRelPath(jsonlRelPath) + worktreeJSONLPath := filepath.Join(worktreePath, normalizedRelPath) hasChanges, err := gitHasChangesInWorktree(ctx, worktreePath, worktreeJSONLPath) if err != nil { return false, fmt.Errorf("failed to check for changes in worktree: %w", err) @@ -304,7 +306,9 @@ func syncBranchPull(ctx context.Context, store storage.Storage, log daemonLogger } // Copy JSONL back to main repo - worktreeJSONLPath := filepath.Join(worktreePath, jsonlRelPath) + // GH#810: Normalize path for bare repo worktrees + normalizedRelPath := git.NormalizeBeadsRelPath(jsonlRelPath) + worktreeJSONLPath := filepath.Join(worktreePath, normalizedRelPath) mainJSONLPath := jsonlPath // Check if worktree JSONL exists diff --git a/internal/git/worktree.go b/internal/git/worktree.go index bd0e011f..d5f0164c 100644 --- a/internal/git/worktree.go +++ b/internal/git/worktree.go @@ -185,10 +185,10 @@ func (wm *WorktreeManager) SyncJSONLToWorktreeWithOptions(worktreePath, jsonlRel srcPath := filepath.Join(wm.repoPath, jsonlRelPath) // Destination: worktree JSONL - // GH#785: Handle bare repo worktrees where jsonlRelPath might include the + // GH#785, GH#810: Handle bare repo worktrees where jsonlRelPath might include the // worktree name (e.g., "main/.beads/issues.jsonl"). The sync branch uses // sparse checkout for .beads/* so we normalize to strip leading components. - normalizedRelPath := normalizeBeadsRelPath(jsonlRelPath) + normalizedRelPath := NormalizeBeadsRelPath(jsonlRelPath) dstPath := filepath.Join(worktreePath, normalizedRelPath) // Ensure destination directory exists @@ -320,22 +320,6 @@ func (wm *WorktreeManager) mergeJSONLFiles(srcData, dstData []byte) ([]byte, err return mergedData, nil } - -// normalizeBeadsRelPath strips any leading path components before .beads/. -// This handles bare repo worktrees where the relative path includes the worktree -// name (e.g., "main/.beads/issues.jsonl" -> ".beads/issues.jsonl"). -// GH#785: Fix for sync failing across worktrees in bare repo setup. -func normalizeBeadsRelPath(relPath string) string { - // Use filepath.ToSlash for consistent handling across platforms - normalized := filepath.ToSlash(relPath) - // Look for ".beads/" to ensure we match the directory, not a prefix like ".beads-backup" - if idx := strings.Index(normalized, ".beads/"); idx > 0 { - // Strip leading path components before .beads - return filepath.FromSlash(normalized[idx:]) - } - return relPath -} - // isValidWorktree checks if the path is a valid git worktree func (wm *WorktreeManager) isValidWorktree(worktreePath string) (bool, error) { cmd := exec.Command("git", "worktree", "list", "--porcelain") @@ -437,6 +421,21 @@ func (wm *WorktreeManager) configureSparseCheckout(worktreePath string) error { return nil } +// NormalizeBeadsRelPath strips any leading path components before .beads/. +// This handles bare repo worktrees where the relative path includes the worktree +// name (e.g., "main/.beads/issues.jsonl" -> ".beads/issues.jsonl"). +// GH#785, GH#810: Fix for sync failing across worktrees in bare repo setup. +func NormalizeBeadsRelPath(relPath string) string { + // Use filepath.ToSlash for consistent handling across platforms + normalized := filepath.ToSlash(relPath) + // Look for ".beads/" to ensure we match the directory, not a prefix like ".beads-backup" + if idx := strings.Index(normalized, ".beads/"); idx > 0 { + // Strip leading path components before .beads + return filepath.FromSlash(normalized[idx:]) + } + return relPath +} + // verifySparseCheckout checks if sparse checkout is configured correctly func (wm *WorktreeManager) verifySparseCheckout(worktreePath string) error { // Check if sparse-checkout file exists and contains .beads diff --git a/internal/git/worktree_test.go b/internal/git/worktree_test.go index 544c628f..1cc37d72 100644 --- a/internal/git/worktree_test.go +++ b/internal/git/worktree_test.go @@ -1130,3 +1130,62 @@ func TestCreateBeadsWorktree_MissingButRegistered(t *testing.T) { t.Errorf("Recreated worktree should be valid: valid=%v, err=%v", valid, err) } } + +// 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) + } + }) + } +}