Files
beads/internal/syncbranch/worktree.go
Steve Yegge 17389d0eb4 fix(sync): handle sync.branch == current branch (GH#519)
When sync.branch is configured to the same branch as the current branch,
git worktree creation fails because the same branch cannot be checked out
in multiple locations.

This fix detects when sync.branch equals the current branch and falls back
to direct commits on the current branch instead of using the worktree-based
approach.

Changes:
- Add IsSyncBranchSameAsCurrent() helper in syncbranch package
- Add GetCurrentBranch() helper function
- Update sync.go to detect this case and skip worktree operations
- Add unit tests for the new functionality

Closes #519

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-14 17:20:47 -08:00

941 lines
35 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
}
// 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,
}
// Worktree path is under .git/beads-worktrees/<branch>
worktreePath := filepath.Join(repoRoot, ".git", "beads-worktrees", 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 (deletions.jsonl, metadata.json)
beadsDir := filepath.Dir(jsonlPath)
for _, filename := range []string{"deletions.jsonl", "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,
}
// Worktree path is under .git/beads-worktrees/<branch>
worktreePath := filepath.Join(repoRoot, ".git", "beads-worktrees", 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)
}
// Also merge deletions.jsonl if it exists
beadsRelDir := filepath.Dir(jsonlRelPath)
deletionsRelPath := filepath.Join(beadsRelDir, "deletions.jsonl")
mergedDeletions, deletionsErr := performDeletionsMerge(ctx, worktreePath, syncBranch, remote, deletionsRelPath)
// deletionsErr is non-fatal - file might not exist
// 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)
}
// Write merged deletions if we have them
if deletionsErr == nil && len(mergedDeletions) > 0 {
deletionsPath := filepath.Join(worktreePath, deletionsRelPath)
if err := os.WriteFile(deletionsPath, mergedDeletions, 0600); err != nil {
// Non-fatal - deletions are supplementary
_ = 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
}
// 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
}
// performDeletionsMerge merges deletions.jsonl from local and remote.
// Deletions are merged by union - we keep all deletion records from both sides.
// This ensures that if either side deleted an issue, it stays deleted.
func performDeletionsMerge(ctx context.Context, worktreePath, branch, remote, deletionsRelPath string) ([]byte, error) {
// Extract local deletions
localDeletions, localErr := extractJSONLFromCommit(ctx, worktreePath, "HEAD", deletionsRelPath)
// Extract remote deletions
remoteRef := fmt.Sprintf("%s/%s", remote, branch)
remoteDeletions, remoteErr := extractJSONLFromCommit(ctx, worktreePath, remoteRef, deletionsRelPath)
// If neither exists, nothing to merge
if localErr != nil && remoteErr != nil {
return nil, fmt.Errorf("no deletions files to merge")
}
// If only one exists, use that
if localErr != nil {
return remoteDeletions, nil
}
if remoteErr != nil {
return localDeletions, nil
}
// Both exist - merge by taking union of lines (deduplicated)
// Each line in deletions.jsonl is a JSON object with an "id" field
seen := make(map[string]bool)
var merged []byte
// Process local deletions
for _, line := range strings.Split(string(localDeletions), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
if !seen[line] {
seen[line] = true
merged = append(merged, []byte(line+"\n")...)
}
}
// Process remote deletions
for _, line := range strings.Split(string(remoteDeletions), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
if !seen[line] {
seen[line] = true
merged = append(merged, []byte(line+"\n")...)
}
}
return merged, 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 (deletions.jsonl, metadata.json)
beadsDir := filepath.Dir(jsonlPath)
worktreeBeadsDir := filepath.Dir(worktreeJSONLPath)
for _, filename := range []string{"deletions.jsonl", "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)
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 - return original push error with context
return fmt.Errorf("push failed and recovery rebase also failed: push: %w; rebase: %v", 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)
}
// 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
}