fix: bd sync fails to copy local changes TO beads-sync worktree (#810)
The bug: In a bare repo + worktrees setup, jsonlRelPath was calculated relative to the project root (containing 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 SyncJSONLToWorktreeWithOptions to write to the wrong location (e.g., worktree/main/.beads/ instead of worktree/.beads/), so changes made locally never reached the sync branch worktree. #785 fixed the reverse direction (worktree → local) by adding normalizeBeadsRelPath(), but the local → worktree direction was missed. Fix: - Export NormalizeBeadsRelPath() (uppercase) for cross-package use - Apply normalization in SyncJSONLToWorktreeWithOptions for dstPath - Apply normalization in daemon_sync_branch.go for worktreeJSONLPath in both commit and pull paths - Add unit tests for the normalization function 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -92,7 +92,9 @@ func syncBranchCommitAndPushWithOptions(ctx context.Context, store storage.Stora
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check for changes in worktree
|
// 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)
|
hasChanges, err := gitHasChangesInWorktree(ctx, worktreePath, worktreeJSONLPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("failed to check for changes in worktree: %w", err)
|
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
|
// 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
|
mainJSONLPath := jsonlPath
|
||||||
|
|
||||||
// Check if worktree JSONL exists
|
// Check if worktree JSONL exists
|
||||||
|
|||||||
@@ -185,10 +185,10 @@ func (wm *WorktreeManager) SyncJSONLToWorktreeWithOptions(worktreePath, jsonlRel
|
|||||||
srcPath := filepath.Join(wm.repoPath, jsonlRelPath)
|
srcPath := filepath.Join(wm.repoPath, jsonlRelPath)
|
||||||
|
|
||||||
// Destination: worktree JSONL
|
// 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
|
// worktree name (e.g., "main/.beads/issues.jsonl"). The sync branch uses
|
||||||
// sparse checkout for .beads/* so we normalize to strip leading components.
|
// sparse checkout for .beads/* so we normalize to strip leading components.
|
||||||
normalizedRelPath := normalizeBeadsRelPath(jsonlRelPath)
|
normalizedRelPath := NormalizeBeadsRelPath(jsonlRelPath)
|
||||||
dstPath := filepath.Join(worktreePath, normalizedRelPath)
|
dstPath := filepath.Join(worktreePath, normalizedRelPath)
|
||||||
|
|
||||||
// Ensure destination directory exists
|
// Ensure destination directory exists
|
||||||
@@ -320,22 +320,6 @@ func (wm *WorktreeManager) mergeJSONLFiles(srcData, dstData []byte) ([]byte, err
|
|||||||
return mergedData, nil
|
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
|
// 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")
|
||||||
@@ -437,6 +421,21 @@ func (wm *WorktreeManager) configureSparseCheckout(worktreePath string) error {
|
|||||||
return nil
|
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
|
// verifySparseCheckout checks if sparse checkout is configured correctly
|
||||||
func (wm *WorktreeManager) verifySparseCheckout(worktreePath string) error {
|
func (wm *WorktreeManager) verifySparseCheckout(worktreePath string) error {
|
||||||
// Check if sparse-checkout file exists and contains .beads
|
// Check if sparse-checkout file exists and contains .beads
|
||||||
|
|||||||
@@ -1130,3 +1130,62 @@ func TestCreateBeadsWorktree_MissingButRegistered(t *testing.T) {
|
|||||||
t.Errorf("Recreated worktree should be valid: valid=%v, err=%v", valid, err)
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user