Files
beads/internal/syncbranch/worktree.go
Charles P. Cross 81aa301649 fix(syncbranch): support bare repos and worktrees with git-common-dir (#641)
* fix(syncbranch): support bare repos and worktrees with git-common-dir

Replace hardcoded .git/beads-worktrees/ path with dynamic detection using
git rev-parse --git-common-dir. This correctly handles:

- Regular repositories (.git is a directory)
- Git worktrees (.git is a file pointing elsewhere)
- Bare repositories (no .git directory, repo IS the git dir)
- Worktrees of bare repositories

The new getBeadsWorktreePath() helper uses git's native API to find the
shared git directory, ensuring beads worktrees are created in the correct
location regardless of repository structure.

Updated functions:
- CommitToSyncBranch
- PullFromSyncBranch
- CheckDivergence
- ResetToRemote

Fixes #639

* test(syncbranch): add regression tests for getBeadsWorktreePath

Add comprehensive tests for the worktree path calculation to ensure proper
handling of various repository structures:

- Regular repos: uses .git/beads-worktrees path
- Bare repos: uses <bare-repo>/beads-worktrees (no .git subdirectory)
- Worktrees: uses main repo's .git/beads-worktrees (git-common-dir)
- Fallback: legacy behavior when git command fails
- Relative paths: ensures absolute path conversion

These tests ensure the fix for GH#639 doesn't regress.

---------

Co-authored-by: Charles P. Cross <cpdata@users.noreply.github.com>
2025-12-19 17:52:40 -08:00

1040 lines
39 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"
)
// 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 (bd-7ch)
// SafetyCheckTriggered indicates mass deletion was detected during merge (bd-4u8)
// When true, callers should check config option sync.require_confirmation_on_mass_delete
SafetyCheckTriggered bool
// SafetyCheckDetails contains human-readable details about the mass deletion (bd-4u8)
SafetyCheckDetails string
// SafetyWarnings contains warning messages from the safety check (bd-7z4)
// 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 (bd-3s8 fix): 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
if err := wtMgr.CreateBeadsWorktree(syncBranch, worktreePath); err != nil {
return nil, fmt.Errorf("failed to create worktree: %w", err)
}
// Check worktree health and repair if needed
if err := wtMgr.CheckWorktreeHealth(worktreePath); err != nil {
// Try to recreate worktree
if err := wtMgr.RemoveBeadsWorktree(worktreePath); err != nil {
// Log but continue - removal might fail but recreation might work
_ = err
}
if err := wtMgr.CreateBeadsWorktree(syncBranch, worktreePath); err != nil {
return nil, fmt.Errorf("failed to recreate worktree after health check: %w", err)
}
}
// Get remote name
remote := getRemoteForBranch(ctx, worktreePath, syncBranch)
// Pre-emptive fetch and fast-forward (bd-3s8 fix)
// 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 (bd-3s8 fix): 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 (bd-7ch): 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 (bd-4u8): 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 (bd-7ch)
// - requireMassDeleteConfirmation: If true and mass deletion detected, skip push (bd-4u8)
//
// 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) {
// bd-4u8: 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 (bd-3s8 fix)
// 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
// bd-7ch: Extract local content before merge for safety check
localContent, extractErr := extractJSONLFromCommit(ctx, worktreePath, "HEAD", jsonlRelPath)
if extractErr != nil {
// bd-feh: Add warning to result so callers can display appropriately (bd-dtm fix)
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
}
// bd-7ch: 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 {
// bd-4u8: 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)
// bd-7z4: 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))
// bd-lsa: Add forensic info to warnings
localIssues := parseIssuesFromContent(localContent)
mergedIssues := parseIssuesFromContent(mergedContent)
forensicLines := formatVanishedIssues(localIssues, mergedIssues, localCount, mergedCount)
result.SafetyWarnings = append(result.SafetyWarnings, forensicLines...)
// bd-4u8: 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 {
worktreeJSONLPath := filepath.Join(worktreePath, jsonlRelPath)
// 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")
}
// fetchAndRebaseInWorktree fetches remote and rebases local commits on top
func fetchAndRebaseInWorktree(ctx context.Context, worktreePath, branch, remote string) error {
// 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)
}
// Rebase local commits on top of remote
rebaseCmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "rebase", fmt.Sprintf("%s/%s", remote, branch))
if output, err := rebaseCmd.CombinedOutput(); err != nil {
// Abort the failed rebase to leave worktree in clean state
abortCmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "rebase", "--abort")
_ = abortCmd.Run() // Best effort
return fmt.Errorf("rebase failed: %w\n%s", err, output)
}
return nil
}
// 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")
output, err := cmd.CombinedOutput()
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) {
// Attempt fetch + rebase to get ahead of remote
if rebaseErr := fetchAndRebaseInWorktree(ctx, worktreePath, branch, remote); rebaseErr != nil {
// Rebase failed - provide clear recovery options (bd-vckm)
return fmt.Errorf(`sync branch diverged and automatic recovery failed
The sync branch '%s' has diverged from remote '%s/%s' and automatic rebase 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
Rebase error: %v`, branch, remote, branch, branch, lastErr, rebaseErr)
}
// Rebase 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. (bd-4u8)
// 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)
// bd-k2n: 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)
func GetRepoRoot(ctx context.Context) (string, error) {
// 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]
}
return filepath.Dir(gitDir), nil
}
} else if info.IsDir() {
// Regular repo: .git is a directory
absGitPath, err := filepath.Abs(gitPath)
if err != nil {
return "", err
}
return filepath.Dir(absGitPath), nil
}
}
// Fallback to git command
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)
}
return strings.TrimSpace(string(output)), nil
}
// countIssuesInContent counts the number of non-empty lines in JSONL content.
// Each non-empty line represents one issue. Used for safety checks (bd-7ch).
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 (bd-lsa)
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 (bd-lsa).
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:")
// bd-ciu: 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
}
// 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
}