diff --git a/internal/git/worktree.go b/internal/git/worktree.go index 9d432003..754ec92d 100644 --- a/internal/git/worktree.go +++ b/internal/git/worktree.go @@ -181,11 +181,15 @@ func (wm *WorktreeManager) SyncJSONLToWorktree(worktreePath, jsonlRelPath string // If ForceOverwrite is false (default), the function uses merge logic to prevent // data loss when a fresh clone syncs with fewer issues than the remote. func (wm *WorktreeManager) SyncJSONLToWorktreeWithOptions(worktreePath, jsonlRelPath string, opts SyncOptions) error { - // Source: main repo JSONL + // Source: main repo JSONL (use the full path as provided) srcPath := filepath.Join(wm.repoPath, jsonlRelPath) // Destination: worktree JSONL - dstPath := filepath.Join(worktreePath, jsonlRelPath) + // GH#785: 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) + dstPath := filepath.Join(worktreePath, normalizedRelPath) // Ensure destination directory exists dstDir := filepath.Dir(dstPath) @@ -317,6 +321,20 @@ func (wm *WorktreeManager) mergeJSONLFiles(srcData, dstData []byte) ([]byte, err } +// 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) + 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") diff --git a/internal/syncbranch/worktree.go b/internal/syncbranch/worktree.go index 918d3a1d..7ab233fe 100644 --- a/internal/syncbranch/worktree.go +++ b/internal/syncbranch/worktree.go @@ -656,7 +656,12 @@ func extractJSONLFromCommit(ctx context.Context, worktreePath, commit, filePath // copyJSONLToMainRepo copies JSONL and related files from worktree to main repo. func copyJSONLToMainRepo(worktreePath, jsonlRelPath, jsonlPath string) error { - worktreeJSONLPath := filepath.Join(worktreePath, jsonlRelPath) + // GH#785: Handle bare repo worktrees where jsonlRelPath might include the + // worktree name (e.g., "main/.beads/issues.jsonl" instead of ".beads/issues.jsonl"). + // The sync branch uses sparse checkout for .beads/* so we normalize the path + // to strip any leading components before .beads. + normalizedRelPath := normalizeBeadsRelPath(jsonlRelPath) + worktreeJSONLPath := filepath.Join(worktreePath, normalizedRelPath) // Check if worktree JSONL exists if _, err := os.Stat(worktreeJSONLPath); os.IsNotExist(err) { @@ -1077,6 +1082,20 @@ func formatVanishedIssues(localIssues, mergedIssues map[string]issueSummary, loc return lines } +// 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) + if idx := strings.Index(normalized, ".beads"); idx > 0 { + // Strip leading path components before .beads + return filepath.FromSlash(normalized[idx:]) + } + return relPath +} + // HasGitRemote checks if any git remote exists func HasGitRemote(ctx context.Context) bool { cmd := exec.CommandContext(ctx, "git", "remote") diff --git a/internal/syncbranch/worktree_path_test.go b/internal/syncbranch/worktree_path_test.go index 5938b891..77d973de 100644 --- a/internal/syncbranch/worktree_path_test.go +++ b/internal/syncbranch/worktree_path_test.go @@ -179,3 +179,56 @@ func runGitCmd(t *testing.T, dir string, args ...string) { t.Fatalf("git %v failed: %v\n%s", args, err, output) } } + +// TestNormalizeBeadsRelPath tests path normalization for bare repo worktrees. +// This is the regression test for GH#785. +func TestNormalizeBeadsRelPath(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "normal repo path unchanged", + input: ".beads/issues.jsonl", + expected: ".beads/issues.jsonl", + }, + { + name: "bare repo worktree strips leading component", + input: "main/.beads/issues.jsonl", + expected: ".beads/issues.jsonl", + }, + { + name: "bare repo worktree with deeper path", + input: "worktrees/feature-branch/.beads/issues.jsonl", + expected: ".beads/issues.jsonl", + }, + { + name: "metadata file also works", + input: "main/.beads/metadata.json", + expected: ".beads/metadata.json", + }, + { + name: "path with no .beads unchanged", + input: "some/other/path.txt", + expected: "some/other/path.txt", + }, + { + name: ".beads at start unchanged", + input: ".beads/subdir/file.jsonl", + expected: ".beads/subdir/file.jsonl", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Normalize to forward slashes for comparison + result := normalizeBeadsRelPath(tt.input) + // Convert expected to platform path for comparison + expectedPlatform := filepath.FromSlash(tt.expected) + if result != expectedPlatform { + t.Errorf("normalizeBeadsRelPath(%q) = %q, want %q", tt.input, result, expectedPlatform) + } + }) + } +}