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>
941 lines
35 KiB
Go
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
|
|
}
|