fix: bd sync fails in bare repo worktrees (#785)
The bug: In a bare repo + worktrees setup, jsonlRelPath was calculated relative to the project root (which contains all worktrees), resulting in paths like "main/.beads/issues.jsonl". But the sync branch worktree uses sparse checkout for .beads/*, so files are at ".beads/issues.jsonl". This caused copyJSONLToMainRepo to look in the wrong location, silently returning when the file was not found. Fix: Add normalizeBeadsRelPath() to strip leading path components before ".beads", ensuring correct path resolution in both directions: - copyJSONLToMainRepo (worktree -> local) - SyncJSONLToWorktreeWithOptions (local -> worktree) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -181,11 +181,15 @@ func (wm *WorktreeManager) SyncJSONLToWorktree(worktreePath, jsonlRelPath string
|
|||||||
// If ForceOverwrite is false (default), the function uses merge logic to prevent
|
// 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.
|
// data loss when a fresh clone syncs with fewer issues than the remote.
|
||||||
func (wm *WorktreeManager) SyncJSONLToWorktreeWithOptions(worktreePath, jsonlRelPath string, opts SyncOptions) error {
|
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)
|
srcPath := filepath.Join(wm.repoPath, jsonlRelPath)
|
||||||
|
|
||||||
// Destination: worktree JSONL
|
// 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
|
// Ensure destination directory exists
|
||||||
dstDir := filepath.Dir(dstPath)
|
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
|
// isValidWorktree checks if the path is a valid git worktree
|
||||||
func (wm *WorktreeManager) isValidWorktree(worktreePath string) (bool, error) {
|
func (wm *WorktreeManager) isValidWorktree(worktreePath string) (bool, error) {
|
||||||
cmd := exec.Command("git", "worktree", "list", "--porcelain")
|
cmd := exec.Command("git", "worktree", "list", "--porcelain")
|
||||||
|
|||||||
@@ -656,7 +656,12 @@ func extractJSONLFromCommit(ctx context.Context, worktreePath, commit, filePath
|
|||||||
|
|
||||||
// copyJSONLToMainRepo copies JSONL and related files from worktree to main repo.
|
// copyJSONLToMainRepo copies JSONL and related files from worktree to main repo.
|
||||||
func copyJSONLToMainRepo(worktreePath, jsonlRelPath, jsonlPath string) error {
|
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
|
// Check if worktree JSONL exists
|
||||||
if _, err := os.Stat(worktreeJSONLPath); os.IsNotExist(err) {
|
if _, err := os.Stat(worktreeJSONLPath); os.IsNotExist(err) {
|
||||||
@@ -1077,6 +1082,20 @@ func formatVanishedIssues(localIssues, mergedIssues map[string]issueSummary, loc
|
|||||||
return lines
|
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
|
// HasGitRemote checks if any git remote exists
|
||||||
func HasGitRemote(ctx context.Context) bool {
|
func HasGitRemote(ctx context.Context) bool {
|
||||||
cmd := exec.CommandContext(ctx, "git", "remote")
|
cmd := exec.CommandContext(ctx, "git", "remote")
|
||||||
|
|||||||
@@ -179,3 +179,56 @@ func runGitCmd(t *testing.T, dir string, args ...string) {
|
|||||||
t.Fatalf("git %v failed: %v\n%s", args, err, output)
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user