Files
beads/internal/syncbranch/worktree.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

1141 lines
43 KiB
Go

package syncbranch
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"github.com/steveyegge/beads/internal/git"
"github.com/steveyegge/beads/internal/merge"
"github.com/steveyegge/beads/internal/utils"
)
// CommitResult contains information about a worktree commit operation
type CommitResult struct {
Committed bool // True if changes were committed
Pushed bool // True if changes were pushed
Branch string // The sync branch name
Message string // Commit message used
}
// DivergenceInfo contains information about sync branch divergence from remote
type DivergenceInfo struct {
LocalAhead int // Number of commits local is ahead of remote
RemoteAhead int // Number of commits remote is ahead of local
Branch string // The sync branch name
Remote string // The remote name (e.g., "origin")
IsDiverged bool // True if both local and remote have commits the other doesn't
IsSignificant bool // True if divergence exceeds threshold (suggests recovery needed)
}
// SignificantDivergenceThreshold is the number of commits at which divergence is considered significant
// When both local and remote are ahead by at least this many commits, the user should consider recovery options
const SignificantDivergenceThreshold = 5
// PullResult contains information about a worktree pull operation
type PullResult struct {
Pulled bool // True if pull was performed
Branch string // The sync branch name
JSONLPath string // Path to the synced JSONL in main repo
Merged bool // True if divergent histories were merged
FastForwarded bool // True if fast-forward was possible
Pushed bool // True if changes were pushed after merge
// SafetyCheckTriggered indicates mass deletion was detected during merge
// When true, callers should check config option sync.require_confirmation_on_mass_delete
SafetyCheckTriggered bool
// SafetyCheckDetails contains human-readable details about the mass deletion
SafetyCheckDetails string
// SafetyWarnings contains warning messages from the safety check
// Caller should display these to the user as appropriate for their output format
SafetyWarnings []string
}
// CommitToSyncBranch commits JSONL changes to the sync branch using a git worktree.
// This allows committing to a different branch without changing the user's working directory.
//
// IMPORTANT: Before committing, this function now performs a pre-emptive fetch
// and fast-forward if possible. This reduces the likelihood of divergence by ensuring we're
// building on top of the latest remote state when possible.
//
// Parameters:
// - ctx: Context for cancellation
// - repoRoot: Path to the git repository root
// - syncBranch: Name of the sync branch (e.g., "beads-sync")
// - jsonlPath: Absolute path to the JSONL file in the main repo
// - push: If true, push to remote after commit
//
// Returns CommitResult with details about what was done, or error if failed.
func CommitToSyncBranch(ctx context.Context, repoRoot, syncBranch, jsonlPath string, push bool) (*CommitResult, error) {
result := &CommitResult{
Branch: syncBranch,
}
// GH#639: Use git-common-dir for worktree path to support bare repos
worktreePath := getBeadsWorktreePath(ctx, repoRoot, syncBranch)
// Initialize worktree manager
wtMgr := git.NewWorktreeManager(repoRoot)
// Ensure worktree exists and is healthy
// CreateBeadsWorktree performs a full health check internally and
// automatically repairs unhealthy worktrees by removing and recreating them
if err := wtMgr.CreateBeadsWorktree(syncBranch, worktreePath); err != nil {
return nil, fmt.Errorf("failed to create worktree: %w", err)
}
// Get remote name
remote := getRemoteForBranch(ctx, worktreePath, syncBranch)
// Pre-emptive fetch and fast-forward
// This reduces divergence by ensuring we commit on top of latest remote state
if err := preemptiveFetchAndFastForward(ctx, worktreePath, syncBranch, remote); err != nil {
// Non-fatal: if fetch fails (e.g., offline), we can still commit locally
// The divergence will be handled during the next pull
_ = err
}
// Convert absolute path to relative path from repo root
jsonlRelPath, err := filepath.Rel(repoRoot, jsonlPath)
if err != nil {
return nil, fmt.Errorf("failed to get relative JSONL path: %w", err)
}
// Sync JSONL file to worktree
if err := wtMgr.SyncJSONLToWorktree(worktreePath, jsonlRelPath); err != nil {
return nil, fmt.Errorf("failed to sync JSONL to worktree: %w", err)
}
// Also sync other beads files (metadata.json)
beadsDir := filepath.Dir(jsonlPath)
for _, filename := range []string{"metadata.json"} {
srcPath := filepath.Join(beadsDir, filename)
if _, err := os.Stat(srcPath); err == nil {
relPath, err := filepath.Rel(repoRoot, srcPath)
if err == nil {
_ = wtMgr.SyncJSONLToWorktree(worktreePath, relPath) // Best effort
}
}
}
// Check for changes in worktree
worktreeJSONLPath := filepath.Join(worktreePath, jsonlRelPath)
hasChanges, err := hasChangesInWorktree(ctx, worktreePath, worktreeJSONLPath)
if err != nil {
return nil, fmt.Errorf("failed to check for changes in worktree: %w", err)
}
if !hasChanges {
return result, nil // No changes to commit
}
// Commit in worktree
result.Message = fmt.Sprintf("bd sync: %s", time.Now().Format("2006-01-02 15:04:05"))
if err := commitInWorktree(ctx, worktreePath, jsonlRelPath, result.Message); err != nil {
return nil, fmt.Errorf("failed to commit in worktree: %w", err)
}
result.Committed = true
// Push if enabled
if push {
if err := pushFromWorktree(ctx, worktreePath, syncBranch); err != nil {
return nil, fmt.Errorf("failed to push from worktree: %w", err)
}
result.Pushed = true
}
return result, nil
}
// preemptiveFetchAndFastForward fetches from remote and fast-forwards if possible.
// This reduces divergence by keeping the local sync branch up-to-date before committing.
// Returns nil on success, or error if fetch/ff fails (caller should treat as non-fatal).
func preemptiveFetchAndFastForward(ctx context.Context, worktreePath, branch, remote string) error {
// Fetch from remote
fetchCmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "fetch", remote, branch)
if output, err := fetchCmd.CombinedOutput(); err != nil {
// Check if remote branch doesn't exist yet (first sync)
if strings.Contains(string(output), "couldn't find remote ref") {
return nil // Not an error - remote branch doesn't exist yet
}
return fmt.Errorf("fetch failed: %w", err)
}
// Check if we can fast-forward
localAhead, remoteAhead, err := getDivergence(ctx, worktreePath, branch, remote)
if err != nil {
return fmt.Errorf("divergence check failed: %w", err)
}
// If remote has new commits and we have no local commits, fast-forward
if remoteAhead > 0 && localAhead == 0 {
mergeCmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "merge", "--ff-only",
fmt.Sprintf("%s/%s", remote, branch))
if output, err := mergeCmd.CombinedOutput(); err != nil {
return fmt.Errorf("fast-forward failed: %w\n%s", err, output)
}
}
return nil
}
// PullFromSyncBranch pulls changes from the sync branch and copies JSONL to the main repo.
// This fetches remote changes without affecting the user's working directory.
//
// IMPORTANT: This function handles diverged histories gracefully by performing
// a content-based merge instead of relying on git's commit-level merge. When local and remote
// sync branches have diverged:
// 1. Fetch remote changes (don't pull)
// 2. Find the merge base
// 3. Extract JSONL from base, local, and remote
// 4. Perform 3-way content merge using bd's merge algorithm
// 5. Reset to remote's history (adopt remote commit graph)
// 6. Commit merged content on top
//
// IMPORTANT: After successful content merge, auto-pushes to remote by default.
// Includes safety check: warns (but doesn't block) if >50% issues vanished AND >5 existed.
// "Vanished" means removed from issues.jsonl entirely, NOT status=closed.
//
// IMPORTANT: If requireMassDeleteConfirmation is true and the safety check triggers,
// the function will NOT auto-push. Instead, it sets SafetyCheckTriggered=true in the result
// and the caller should prompt for confirmation then call PushSyncBranch.
//
// This ensures sync never fails due to git merge conflicts, as we handle merging at the
// JSONL content level where we have semantic understanding of the data.
//
// Parameters:
// - ctx: Context for cancellation
// - repoRoot: Path to the git repository root
// - syncBranch: Name of the sync branch (e.g., "beads-sync")
// - jsonlPath: Absolute path to the JSONL file in the main repo
// - push: If true, push to remote after merge
// - requireMassDeleteConfirmation: If true and mass deletion detected, skip push
//
// Returns PullResult with details about what was done, or error if failed.
func PullFromSyncBranch(ctx context.Context, repoRoot, syncBranch, jsonlPath string, push bool, requireMassDeleteConfirmation ...bool) (*PullResult, error) {
// Extract optional confirmation requirement parameter
requireConfirmation := false
if len(requireMassDeleteConfirmation) > 0 {
requireConfirmation = requireMassDeleteConfirmation[0]
}
result := &PullResult{
Branch: syncBranch,
JSONLPath: jsonlPath,
}
// GH#639: Use git-common-dir for worktree path to support bare repos
worktreePath := getBeadsWorktreePath(ctx, repoRoot, syncBranch)
// Initialize worktree manager
wtMgr := git.NewWorktreeManager(repoRoot)
// Ensure worktree exists
if err := wtMgr.CreateBeadsWorktree(syncBranch, worktreePath); err != nil {
return nil, fmt.Errorf("failed to create worktree: %w", err)
}
// Get remote name
remote := getRemoteForBranch(ctx, worktreePath, syncBranch)
// Convert absolute path to relative path from repo root
jsonlRelPath, err := filepath.Rel(repoRoot, jsonlPath)
if err != nil {
return nil, fmt.Errorf("failed to get relative JSONL path: %w", err)
}
// Step 1: Fetch from remote (don't pull - we handle merge ourselves)
fetchCmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "fetch", remote, syncBranch)
if output, err := fetchCmd.CombinedOutput(); err != nil {
// Check if remote branch doesn't exist yet (first sync)
if strings.Contains(string(output), "couldn't find remote ref") {
// Remote branch doesn't exist - nothing to pull
result.Pulled = false
return result, nil
}
return nil, fmt.Errorf("git fetch failed in worktree: %w\n%s", err, output)
}
// Step 2: Check for divergence
localAhead, remoteAhead, err := getDivergence(ctx, worktreePath, syncBranch, remote)
if err != nil {
return nil, fmt.Errorf("failed to check divergence: %w", err)
}
// Case 1: Already up to date (remote has nothing new)
if remoteAhead == 0 {
result.Pulled = true
// Still copy JSONL in case worktree has uncommitted changes
if err := copyJSONLToMainRepo(worktreePath, jsonlRelPath, jsonlPath); err != nil {
return nil, err
}
return result, nil
}
// Case 2: Can fast-forward (we have no local commits ahead of remote)
if localAhead == 0 {
// Simple fast-forward merge
mergeCmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "merge", "--ff-only",
fmt.Sprintf("%s/%s", remote, syncBranch))
if output, err := mergeCmd.CombinedOutput(); err != nil {
return nil, fmt.Errorf("git merge --ff-only failed: %w\n%s", err, output)
}
result.Pulled = true
result.FastForwarded = true
// Copy JSONL to main repo
if err := copyJSONLToMainRepo(worktreePath, jsonlRelPath, jsonlPath); err != nil {
return nil, err
}
return result, nil
}
// Case 3: DIVERGED - perform content-based merge
// This is the key fix: instead of git merge (which can fail), we:
// 1. Extract JSONL content from base, local, and remote
// 2. Merge at content level using our 3-way merge algorithm
// 3. Reset to remote's commit history
// 4. Commit merged content on top
// Extract local content before merge for safety check
localContent, extractErr := extractJSONLFromCommit(ctx, worktreePath, "HEAD", jsonlRelPath)
if extractErr != nil {
// Add warning to result so callers can display appropriately
result.SafetyWarnings = append(result.SafetyWarnings,
fmt.Sprintf("⚠️ Warning: Could not extract local content for safety check: %v", extractErr))
}
mergedContent, err := performContentMerge(ctx, worktreePath, syncBranch, remote, jsonlRelPath)
if err != nil {
return nil, fmt.Errorf("content merge failed: %w", err)
}
// Reset worktree to remote's history (adopt their commit graph)
resetCmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "reset", "--hard",
fmt.Sprintf("%s/%s", remote, syncBranch))
if output, err := resetCmd.CombinedOutput(); err != nil {
return nil, fmt.Errorf("git reset failed: %w\n%s", err, output)
}
// Write merged content
worktreeJSONLPath := filepath.Join(worktreePath, jsonlRelPath)
if err := os.MkdirAll(filepath.Dir(worktreeJSONLPath), 0750); err != nil {
return nil, fmt.Errorf("failed to create directory: %w", err)
}
if err := os.WriteFile(worktreeJSONLPath, mergedContent, 0600); err != nil {
return nil, fmt.Errorf("failed to write merged JSONL: %w", err)
}
// Check if merge produced any changes from remote
hasChanges, err := hasChangesInWorktree(ctx, worktreePath, worktreeJSONLPath)
if err != nil {
return nil, fmt.Errorf("failed to check for changes: %w", err)
}
// Commit merged content if there are changes
if hasChanges {
message := fmt.Sprintf("bd sync: merge divergent histories (%d local + %d remote commits)",
localAhead, remoteAhead)
if err := commitInWorktree(ctx, worktreePath, jsonlRelPath, message); err != nil {
return nil, fmt.Errorf("failed to commit merged content: %w", err)
}
}
result.Pulled = true
result.Merged = true
// Copy merged JSONL to main repo
if err := copyJSONLToMainRepo(worktreePath, jsonlRelPath, jsonlPath); err != nil {
return nil, err
}
// Auto-push after successful content merge
if push && hasChanges {
// Safety check: count issues before and after merge to detect mass deletion
localCount := countIssuesInContent(localContent)
mergedCount := countIssuesInContent(mergedContent)
// Track if we should skip push due to safety check requiring confirmation
skipPushForConfirmation := false
// Warn if >50% issues vanished AND >5 existed before
// "Vanished" = removed from JSONL entirely (not status=closed)
if localCount > 5 && mergedCount < localCount {
vanishedPercent := float64(localCount-mergedCount) / float64(localCount) * 100
if vanishedPercent > 50 {
// Set safety check fields for caller to handle confirmation
result.SafetyCheckTriggered = true
result.SafetyCheckDetails = fmt.Sprintf("%.0f%% of issues vanished during merge (%d → %d issues)",
vanishedPercent, localCount, mergedCount)
// Return warnings in result instead of printing directly to stderr
result.SafetyWarnings = append(result.SafetyWarnings,
fmt.Sprintf("⚠️ Warning: %.0f%% of issues vanished during merge (%d → %d issues)",
vanishedPercent, localCount, mergedCount))
// Add forensic info to warnings
localIssues := parseIssuesFromContent(localContent)
mergedIssues := parseIssuesFromContent(mergedContent)
forensicLines := formatVanishedIssues(localIssues, mergedIssues, localCount, mergedCount)
result.SafetyWarnings = append(result.SafetyWarnings, forensicLines...)
// Check if confirmation is required before pushing
if requireConfirmation {
result.SafetyWarnings = append(result.SafetyWarnings,
" Push skipped - confirmation required (sync.require_confirmation_on_mass_delete=true)")
skipPushForConfirmation = true
} else {
result.SafetyWarnings = append(result.SafetyWarnings,
" This may indicate accidental mass deletion. Pushing anyway.",
" If this was unintended, use 'git reflog' on the sync branch to recover.")
}
}
}
// Push unless safety check requires confirmation
if !skipPushForConfirmation {
if err := pushFromWorktree(ctx, worktreePath, syncBranch); err != nil {
return nil, fmt.Errorf("failed to push after merge: %w", err)
}
result.Pushed = true
}
}
return result, nil
}
// getDivergence returns how many commits local is ahead and behind remote.
// Returns (localAhead, remoteAhead, error)
func getDivergence(ctx context.Context, worktreePath, branch, remote string) (int, int, error) {
// Use rev-list to count commits in each direction
// --left-right --count gives us "local\tremote"
cmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "rev-list",
"--left-right", "--count",
fmt.Sprintf("HEAD...%s/%s", remote, branch))
output, err := cmd.Output()
if err != nil {
// If this fails, remote branch might not exist locally yet
// Check if it's a tracking issue
return 0, 0, fmt.Errorf("failed to get divergence: %w", err)
}
// Parse "N\tM" format
parts := strings.Fields(strings.TrimSpace(string(output)))
if len(parts) != 2 {
return 0, 0, fmt.Errorf("unexpected rev-list output: %s", output)
}
localAhead, err := strconv.Atoi(parts[0])
if err != nil {
return 0, 0, fmt.Errorf("failed to parse local ahead count: %w", err)
}
remoteAhead, err := strconv.Atoi(parts[1])
if err != nil {
return 0, 0, fmt.Errorf("failed to parse remote ahead count: %w", err)
}
return localAhead, remoteAhead, nil
}
// CheckDivergence checks the divergence between local sync branch and remote.
// This should be called before attempting sync operations to detect significant divergence
// that may require user intervention.
//
// Parameters:
// - ctx: Context for cancellation
// - repoRoot: Path to the git repository root
// - syncBranch: Name of the sync branch (e.g., "beads-sync")
//
// Returns DivergenceInfo with details about the divergence, or error if check fails.
func CheckDivergence(ctx context.Context, repoRoot, syncBranch string) (*DivergenceInfo, error) {
info := &DivergenceInfo{
Branch: syncBranch,
}
// GH#639: Use git-common-dir for worktree path to support bare repos
worktreePath := getBeadsWorktreePath(ctx, repoRoot, syncBranch)
// Initialize worktree manager
wtMgr := git.NewWorktreeManager(repoRoot)
// Ensure worktree exists
if err := wtMgr.CreateBeadsWorktree(syncBranch, worktreePath); err != nil {
return nil, fmt.Errorf("failed to create worktree: %w", err)
}
// Get remote name
remote := getRemoteForBranch(ctx, worktreePath, syncBranch)
info.Remote = remote
// Fetch from remote to get latest state
fetchCmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "fetch", remote, syncBranch)
if output, err := fetchCmd.CombinedOutput(); err != nil {
// Check if remote branch doesn't exist yet (first sync)
if strings.Contains(string(output), "couldn't find remote ref") {
// Remote branch doesn't exist - no divergence possible
return info, nil
}
return nil, fmt.Errorf("git fetch failed: %w\n%s", err, output)
}
// Check for divergence
localAhead, remoteAhead, err := getDivergence(ctx, worktreePath, syncBranch, remote)
if err != nil {
return nil, fmt.Errorf("failed to check divergence: %w", err)
}
info.LocalAhead = localAhead
info.RemoteAhead = remoteAhead
info.IsDiverged = localAhead > 0 && remoteAhead > 0
// Significant divergence: both sides have many commits
// This suggests automatic merge may be problematic
if info.IsDiverged && (localAhead >= SignificantDivergenceThreshold || remoteAhead >= SignificantDivergenceThreshold) {
info.IsSignificant = true
}
return info, nil
}
// ResetToRemote resets the local sync branch to match the remote state.
// This discards all local commits on the sync branch and adopts the remote's history.
// Use this when the sync branch has diverged significantly and you want to discard local changes.
//
// Parameters:
// - ctx: Context for cancellation
// - repoRoot: Path to the git repository root
// - syncBranch: Name of the sync branch (e.g., "beads-sync")
// - jsonlPath: Path to the JSONL file in the main repo (will be updated with remote content)
//
// Returns error if reset fails.
func ResetToRemote(ctx context.Context, repoRoot, syncBranch, jsonlPath string) error {
// GH#639: Use git-common-dir for worktree path to support bare repos
worktreePath := getBeadsWorktreePath(ctx, repoRoot, syncBranch)
// Initialize worktree manager
wtMgr := git.NewWorktreeManager(repoRoot)
// Ensure worktree exists
if err := wtMgr.CreateBeadsWorktree(syncBranch, worktreePath); err != nil {
return fmt.Errorf("failed to create worktree: %w", err)
}
// Get remote name
remote := getRemoteForBranch(ctx, worktreePath, syncBranch)
// Fetch from remote to get latest state
fetchCmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "fetch", remote, syncBranch)
if output, err := fetchCmd.CombinedOutput(); err != nil {
return fmt.Errorf("git fetch failed: %w\n%s", err, output)
}
// Reset worktree to remote's state
resetCmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "reset", "--hard",
fmt.Sprintf("%s/%s", remote, syncBranch))
if output, err := resetCmd.CombinedOutput(); err != nil {
return fmt.Errorf("git reset failed: %w\n%s", err, output)
}
// Convert absolute path to relative path from repo root
jsonlRelPath, err := filepath.Rel(repoRoot, jsonlPath)
if err != nil {
return fmt.Errorf("failed to get relative JSONL path: %w", err)
}
// Copy JSONL from worktree to main repo
if err := copyJSONLToMainRepo(worktreePath, jsonlRelPath, jsonlPath); err != nil {
return err
}
return nil
}
// performContentMerge extracts JSONL from base, local, and remote, then merges content.
// Returns the merged JSONL content.
func performContentMerge(ctx context.Context, worktreePath, branch, remote, jsonlRelPath string) ([]byte, error) {
// Find merge base
mergeBaseCmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "merge-base",
"HEAD", fmt.Sprintf("%s/%s", remote, branch))
mergeBaseOutput, err := mergeBaseCmd.Output()
if err != nil {
// No common ancestor - treat as empty base
mergeBaseOutput = nil
}
mergeBase := strings.TrimSpace(string(mergeBaseOutput))
// Create temp files for 3-way merge
tmpDir, err := os.MkdirTemp("", "bd-merge-*")
if err != nil {
return nil, fmt.Errorf("failed to create temp dir: %w", err)
}
defer func() { _ = os.RemoveAll(tmpDir) }()
baseFile := filepath.Join(tmpDir, "base.jsonl")
localFile := filepath.Join(tmpDir, "local.jsonl")
remoteFile := filepath.Join(tmpDir, "remote.jsonl")
outputFile := filepath.Join(tmpDir, "merged.jsonl")
// Extract base JSONL (may not exist if this is first divergence)
if mergeBase != "" {
baseContent, err := extractJSONLFromCommit(ctx, worktreePath, mergeBase, jsonlRelPath)
if err != nil {
// Base file might not exist in ancestor - use empty file
baseContent = []byte{}
}
if err := os.WriteFile(baseFile, baseContent, 0600); err != nil {
return nil, fmt.Errorf("failed to write base file: %w", err)
}
} else {
// No merge base - use empty file
if err := os.WriteFile(baseFile, []byte{}, 0600); err != nil {
return nil, fmt.Errorf("failed to write empty base file: %w", err)
}
}
// Extract local JSONL (current HEAD in worktree)
localContent, err := extractJSONLFromCommit(ctx, worktreePath, "HEAD", jsonlRelPath)
if err != nil {
// Local file might not exist - use empty
localContent = []byte{}
}
if err := os.WriteFile(localFile, localContent, 0600); err != nil {
return nil, fmt.Errorf("failed to write local file: %w", err)
}
// Extract remote JSONL
remoteRef := fmt.Sprintf("%s/%s", remote, branch)
remoteContent, err := extractJSONLFromCommit(ctx, worktreePath, remoteRef, jsonlRelPath)
if err != nil {
// Remote file might not exist - use empty
remoteContent = []byte{}
}
if err := os.WriteFile(remoteFile, remoteContent, 0600); err != nil {
return nil, fmt.Errorf("failed to write remote file: %w", err)
}
// Perform 3-way merge using bd's merge algorithm
// The merge function writes to outputFile (first arg) and returns error if conflicts
err = merge.Merge3Way(outputFile, baseFile, localFile, remoteFile, false)
if err != nil {
// Check if it's a conflict error
if strings.Contains(err.Error(), "merge completed with") {
// There were conflicts - this is rare for JSONL since most fields can be
// auto-merged. When it happens, it means both sides changed the same field
// to different values. We fail here rather than writing corrupt JSONL.
return nil, fmt.Errorf("merge conflict: %w (manual resolution required)", err)
}
return nil, fmt.Errorf("3-way merge failed: %w", err)
}
// Read merged result
mergedContent, err := os.ReadFile(outputFile)
if err != nil {
return nil, fmt.Errorf("failed to read merged file: %w", err)
}
return mergedContent, nil
}
// extractJSONLFromCommit extracts a file's content from a specific git commit.
func extractJSONLFromCommit(ctx context.Context, worktreePath, commit, filePath string) ([]byte, error) {
cmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "show",
fmt.Sprintf("%s:%s", commit, filePath))
output, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("failed to extract %s from %s: %w", filePath, commit, err)
}
return output, nil
}
// copyJSONLToMainRepo copies JSONL and related files from worktree to main repo.
func copyJSONLToMainRepo(worktreePath, jsonlRelPath, jsonlPath string) error {
// 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) {
// No JSONL in worktree yet, nothing to sync
return nil
}
// Copy JSONL from worktree to main repo
data, err := os.ReadFile(worktreeJSONLPath)
if err != nil {
return fmt.Errorf("failed to read worktree JSONL: %w", err)
}
if err := os.WriteFile(jsonlPath, data, 0600); err != nil {
return fmt.Errorf("failed to write main JSONL: %w", err)
}
// Also sync other beads files back (metadata.json)
beadsDir := filepath.Dir(jsonlPath)
worktreeBeadsDir := filepath.Dir(worktreeJSONLPath)
for _, filename := range []string{"metadata.json"} {
worktreeSrcPath := filepath.Join(worktreeBeadsDir, filename)
if fileData, err := os.ReadFile(worktreeSrcPath); err == nil {
dstPath := filepath.Join(beadsDir, filename)
_ = os.WriteFile(dstPath, fileData, 0600) // Best effort, match JSONL permissions
}
}
return nil
}
// hasChangesInWorktree checks if there are uncommitted changes in the worktree
func hasChangesInWorktree(ctx context.Context, worktreePath, filePath string) (bool, error) {
// Check the entire .beads directory for changes
beadsDir := filepath.Dir(filePath)
relBeadsDir, err := filepath.Rel(worktreePath, beadsDir)
if err != nil {
// Fallback to checking just the file
relPath, err := filepath.Rel(worktreePath, filePath)
if err != nil {
return false, fmt.Errorf("failed to make path relative: %w", err)
}
cmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "status", "--porcelain", relPath)
output, err := cmd.Output()
if err != nil {
return false, fmt.Errorf("git status failed in worktree: %w", err)
}
return len(strings.TrimSpace(string(output))) > 0, nil
}
cmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "status", "--porcelain", relBeadsDir)
output, err := cmd.Output()
if err != nil {
return false, fmt.Errorf("git status failed in worktree: %w", err)
}
return len(strings.TrimSpace(string(output))) > 0, nil
}
// commitInWorktree stages and commits changes in the worktree
func commitInWorktree(ctx context.Context, worktreePath, jsonlRelPath, message string) error {
// Stage the entire .beads directory
beadsRelDir := filepath.Dir(jsonlRelPath)
addCmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "add", beadsRelDir)
if err := addCmd.Run(); err != nil {
return fmt.Errorf("git add failed in worktree: %w", err)
}
// Commit with --no-verify to skip hooks (pre-commit hook would fail in worktree context)
// The worktree is internal to bd sync, so we don't need to run bd's pre-commit hook
commitCmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "commit", "--no-verify", "-m", message)
output, err := commitCmd.CombinedOutput()
if err != nil {
return fmt.Errorf("git commit failed in worktree: %w\n%s", err, output)
}
return nil
}
// isNonFastForwardError checks if git push output indicates a non-fast-forward rejection
func isNonFastForwardError(output string) bool {
// Git outputs these messages for non-fast-forward rejections
return strings.Contains(output, "non-fast-forward") ||
strings.Contains(output, "fetch first") ||
strings.Contains(output, "rejected") && strings.Contains(output, "behind")
}
// contentMergeRecovery performs a content-level merge when push fails due to divergence.
//
// The problem with git rebase: it replays commits textually, which can resurrect
// tombstones. For example, if remote has a tombstone and local has 'closed',
// the rebase overwrites the tombstone with 'closed'.
//
// This function uses the same content-level merge as PullFromSyncBranch:
// 1. Fetch remote
// 2. Find merge base
// 3. Extract JSONL from base, local, remote
// 4. Run 3-way content merge (respects tombstones)
// 5. Reset to remote, commit merged content
//
// This fixes a sync race where rebase-based divergence recovery resurrects tombstones.
func contentMergeRecovery(ctx context.Context, worktreePath, branch, remote string) error {
// The JSONL is always at .beads/issues.jsonl relative to worktree
jsonlRelPath := filepath.Join(".beads", "issues.jsonl")
// Step 1: Fetch latest from remote
fetchCmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "fetch", remote, branch)
if output, err := fetchCmd.CombinedOutput(); err != nil {
return fmt.Errorf("fetch failed: %w\n%s", err, output)
}
// Step 2: Perform content-level merge (same algorithm as PullFromSyncBranch)
mergedContent, err := performContentMerge(ctx, worktreePath, branch, remote, jsonlRelPath)
if err != nil {
return fmt.Errorf("content merge failed: %w", err)
}
// Step 3: Reset worktree to remote's history (adopt their commit graph)
resetCmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "reset", "--hard",
fmt.Sprintf("%s/%s", remote, branch))
if output, err := resetCmd.CombinedOutput(); err != nil {
return fmt.Errorf("git reset failed: %w\n%s", err, output)
}
// Step 4: Write merged content
worktreeJSONLPath := filepath.Join(worktreePath, jsonlRelPath)
if err := os.MkdirAll(filepath.Dir(worktreeJSONLPath), 0750); err != nil {
return fmt.Errorf("failed to create directory: %w", err)
}
if err := os.WriteFile(worktreeJSONLPath, mergedContent, 0600); err != nil {
return fmt.Errorf("failed to write merged JSONL: %w", err)
}
// Step 5: Check if merge produced any changes from remote
hasChanges, err := hasChangesInWorktree(ctx, worktreePath, worktreeJSONLPath)
if err != nil {
return fmt.Errorf("failed to check for changes: %w", err)
}
// Step 6: Commit merged content if there are changes
if hasChanges {
message := "bd sync: merge divergent histories (content-level recovery)"
if err := commitInWorktree(ctx, worktreePath, jsonlRelPath, message); err != nil {
return fmt.Errorf("failed to commit merged content: %w", err)
}
}
return nil
}
// runCmdWithTimeoutMessage runs a command and prints a helpful message if it takes too long.
// This helps when git operations hang waiting for credential/browser auth.
//
// Parameters:
// - ctx: Context for cancellation
// - timeoutMsg: Message to print when timeout is reached (e.g., "Waiting for Git authentication in browser...")
// - timeoutDelay: Duration to wait before printing message (e.g., 5 seconds)
// - cmd: The command to run
//
// Returns: combined output and error from the command
func runCmdWithTimeoutMessage(ctx context.Context, timeoutMsg string, timeoutDelay time.Duration, cmd *exec.Cmd) ([]byte, error) {
// Use done channel to cleanly exit goroutine when command completes
done := make(chan struct{})
go func() {
select {
case <-time.After(timeoutDelay):
fmt.Fprintf(os.Stderr, "⏳ %s\n", timeoutMsg)
case <-done:
// Command completed, exit cleanly
case <-ctx.Done():
// Context canceled, don't print message
}
}()
output, err := cmd.CombinedOutput()
close(done)
return output, err
}
// pushFromWorktree pushes the sync branch from the worktree with retry logic
// for handling concurrent push conflicts (non-fast-forward errors).
func pushFromWorktree(ctx context.Context, worktreePath, branch string) error {
remote := getRemoteForBranch(ctx, worktreePath, branch)
maxRetries := 5
var lastErr error
for attempt := 0; attempt < maxRetries; attempt++ {
// Push with explicit remote and branch, set upstream if not set
cmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "push", "--set-upstream", remote, branch)
// Set BD_SYNC_IN_PROGRESS so pre-push hook knows to skip checks (GH#532)
// This prevents circular error where hook suggests running bd sync
cmd.Env = append(os.Environ(), "BD_SYNC_IN_PROGRESS=1")
// Run with timeout message in case of hanging auth
output, err := runCmdWithTimeoutMessage(
ctx,
fmt.Sprintf("Git push is waiting (possibly for authentication). If this hangs, check for a browser auth prompt."),
5*time.Second,
cmd,
)
if err == nil {
return nil // Success
}
outputStr := string(output)
lastErr = fmt.Errorf("git push failed from worktree: %w\n%s", err, outputStr)
// Check if this is a non-fast-forward error (concurrent push conflict)
if isNonFastForwardError(outputStr) {
// Use content-level merge instead of git rebase.
// Git rebase is text-level and can resurrect tombstones.
if mergeErr := contentMergeRecovery(ctx, worktreePath, branch, remote); mergeErr != nil {
// Content merge failed - provide clear recovery options
return fmt.Errorf(`sync branch diverged and automatic recovery failed
The sync branch '%s' has diverged from remote '%s/%s' and automatic content merge failed.
Recovery options:
1. Reset to remote state (discard local sync changes):
bd sync --reset-remote
2. Force push local state to remote (overwrites remote):
bd sync --force-push
3. Manual recovery in the sync branch worktree:
cd .git/beads-worktrees/%s
git status
# Resolve conflicts manually, then:
bd sync
Original error: %v
Merge error: %v`, branch, remote, branch, branch, lastErr, mergeErr)
}
// Content merge succeeded - retry push immediately (no backoff needed)
continue
}
// For other errors, use exponential backoff before retry
if attempt < maxRetries-1 {
waitTime := time.Duration(100<<uint(attempt)) * time.Millisecond // 100ms, 200ms, 400ms, 800ms
time.Sleep(waitTime)
}
}
return fmt.Errorf("push failed after %d attempts: %w", maxRetries, lastErr)
}
// PushSyncBranch pushes the sync branch to remote.
// This is used after confirmation when sync.require_confirmation_on_mass_delete is enabled
// and a mass deletion was detected during merge.
//
// Parameters:
// - ctx: Context for cancellation
// - repoRoot: Path to the git repository root
// - syncBranch: Name of the sync branch (e.g., "beads-sync")
//
// Returns error if push fails.
func PushSyncBranch(ctx context.Context, repoRoot, syncBranch string) error {
// Worktree path is under .git/beads-worktrees/<branch>
worktreePath := filepath.Join(repoRoot, ".git", "beads-worktrees", syncBranch)
// Recreate worktree if it was cleaned up, using the same pattern as CommitToSyncBranch
wtMgr := git.NewWorktreeManager(repoRoot)
if err := wtMgr.CreateBeadsWorktree(syncBranch, worktreePath); err != nil {
return fmt.Errorf("failed to ensure worktree exists: %w", err)
}
return pushFromWorktree(ctx, worktreePath, syncBranch)
}
// getBeadsWorktreePath returns the path where beads worktrees should be stored.
// GH#639: Uses git rev-parse --git-common-dir to correctly handle bare repos and worktrees.
// For regular repos, this is typically .git/beads-worktrees/<branch>.
// For bare repos or worktrees of bare repos, this uses the common git directory.
func getBeadsWorktreePath(ctx context.Context, repoRoot, syncBranch string) string {
// Try to get the git common directory using git's native API
// This handles all cases: regular repos, worktrees, bare repos
cmd := exec.CommandContext(ctx, "git", "-C", repoRoot, "rev-parse", "--git-common-dir")
output, err := cmd.Output()
if err == nil {
gitCommonDir := strings.TrimSpace(string(output))
// Make path absolute if it's relative
if !filepath.IsAbs(gitCommonDir) {
gitCommonDir = filepath.Join(repoRoot, gitCommonDir)
}
return filepath.Join(gitCommonDir, "beads-worktrees", syncBranch)
}
// Fallback to legacy behavior for compatibility
return filepath.Join(repoRoot, ".git", "beads-worktrees", syncBranch)
}
// getRemoteForBranch gets the remote name for a branch, defaulting to "origin"
func getRemoteForBranch(ctx context.Context, worktreePath, branch string) string {
remoteCmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "config", "--get", fmt.Sprintf("branch.%s.remote", branch))
remoteOutput, err := remoteCmd.Output()
if err != nil {
return "origin" // Default
}
return strings.TrimSpace(string(remoteOutput))
}
// GetRepoRoot returns the git repository root directory
// For worktrees, this returns the main repository root (not the worktree root)
// The returned path is canonicalized to fix case on case-insensitive filesystems (GH#880)
func GetRepoRoot(ctx context.Context) (string, error) {
var repoRoot string
// Check if .git is a file (worktree) or directory (regular repo)
gitPath := ".git"
if info, err := os.Stat(gitPath); err == nil {
if info.Mode().IsRegular() {
// Worktree: read .git file
content, err := os.ReadFile(gitPath)
if err != nil {
return "", fmt.Errorf("failed to read .git file: %w", err)
}
line := strings.TrimSpace(string(content))
if strings.HasPrefix(line, "gitdir: ") {
gitDir := strings.TrimPrefix(line, "gitdir: ")
// Remove /worktrees/* part
if idx := strings.Index(gitDir, "/worktrees/"); idx > 0 {
gitDir = gitDir[:idx]
}
repoRoot = filepath.Dir(gitDir)
}
} else if info.IsDir() {
// Regular repo: .git is a directory
absGitPath, err := filepath.Abs(gitPath)
if err != nil {
return "", err
}
repoRoot = filepath.Dir(absGitPath)
}
}
// Fallback to git command if not determined above
if repoRoot == "" {
cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
output, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("not a git repository: %w", err)
}
repoRoot = strings.TrimSpace(string(output))
}
// Canonicalize path to fix case on macOS/Windows (GH#880)
// This is critical for git worktree operations which string-compare paths
return utils.CanonicalizePath(repoRoot), nil
}
// countIssuesInContent counts the number of non-empty lines in JSONL content.
// Each non-empty line represents one issue. Used for safety checks.
func countIssuesInContent(content []byte) int {
if len(content) == 0 {
return 0
}
count := 0
for _, line := range strings.Split(string(content), "\n") {
if strings.TrimSpace(line) != "" {
count++
}
}
return count
}
// issueSummary holds minimal issue info for forensic logging
type issueSummary struct {
ID string `json:"id"`
Title string `json:"title"`
}
// parseIssuesFromContent extracts issue IDs and titles from JSONL content.
// Used for forensic logging of vanished issues.
func parseIssuesFromContent(content []byte) map[string]issueSummary {
result := make(map[string]issueSummary)
if len(content) == 0 {
return result
}
for _, line := range strings.Split(string(content), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
var summary issueSummary
if err := json.Unmarshal([]byte(line), &summary); err != nil {
continue // Skip malformed lines
}
if summary.ID != "" {
result[summary.ID] = summary
}
}
return result
}
// formatVanishedIssues returns forensic info lines when issues vanish during merge (bd-lsa, bd-7z4).
// Returns string slices for caller to display as appropriate for their output format.
func formatVanishedIssues(localIssues, mergedIssues map[string]issueSummary, localCount, mergedCount int) []string {
var lines []string
timestamp := time.Now().Format("2006-01-02 15:04:05 MST")
lines = append(lines, fmt.Sprintf("\n📋 Mass deletion forensic log [%s]", timestamp))
lines = append(lines, fmt.Sprintf(" Before merge: %d issues", localCount))
lines = append(lines, fmt.Sprintf(" After merge: %d issues", mergedCount))
lines = append(lines, " Vanished issues:")
// Collect vanished IDs first, then sort for deterministic output
var vanishedIDs []string
for id := range localIssues {
if _, exists := mergedIssues[id]; !exists {
vanishedIDs = append(vanishedIDs, id)
}
}
sort.Strings(vanishedIDs)
for _, id := range vanishedIDs {
title := localIssues[id].Title
if len(title) > 60 {
title = title[:57] + "..."
}
lines = append(lines, fmt.Sprintf(" - %s: %s", id, title))
}
lines = append(lines, fmt.Sprintf(" Total vanished: %d\n", len(vanishedIDs)))
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)
// 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
}
// HasGitRemote checks if any git remote exists
func HasGitRemote(ctx context.Context) bool {
cmd := exec.CommandContext(ctx, "git", "remote")
output, err := cmd.Output()
if err != nil {
return false
}
return len(strings.TrimSpace(string(output))) > 0
}
// GetCurrentBranch returns the name of the current git branch
func GetCurrentBranch(ctx context.Context) (string, error) {
cmd := exec.CommandContext(ctx, "git", "symbolic-ref", "--short", "HEAD")
output, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("failed to get current branch: %w", err)
}
return strings.TrimSpace(string(output)), nil
}
// IsSyncBranchSameAsCurrent returns true if the sync branch is the same as the current branch.
// This is used to detect the case where we can't use a worktree because the branch is already
// checked out. In this case, we should commit directly to the current branch instead.
// See: https://github.com/steveyegge/beads/issues/519
func IsSyncBranchSameAsCurrent(ctx context.Context, syncBranch string) bool {
currentBranch, err := GetCurrentBranch(ctx)
if err != nil {
return false
}
return currentBranch == syncBranch
}