Files
beads/cmd/bd/sync.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

2003 lines
72 KiB
Go
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/config"
"github.com/steveyegge/beads/internal/configfile"
"github.com/steveyegge/beads/internal/debug"
"github.com/steveyegge/beads/internal/deletions"
"github.com/steveyegge/beads/internal/git"
"github.com/steveyegge/beads/internal/rpc"
"github.com/steveyegge/beads/internal/syncbranch"
"github.com/steveyegge/beads/internal/types"
)
var syncCmd = &cobra.Command{
Use: "sync",
Short: "Synchronize issues with git remote",
Long: `Synchronize issues with git remote in a single operation:
1. Export pending changes to JSONL
2. Commit changes to git
3. Pull from remote (with conflict resolution)
4. Import updated JSONL
5. Push local commits to remote
This command wraps the entire git-based sync workflow for multi-device use.
Use --squash to accumulate changes without committing (reduces commit noise).
Use --flush-only to just export pending changes to JSONL (useful for pre-commit hooks).
Use --import-only to just import from JSONL (useful after git pull).
Use --status to show diff between sync branch and main branch.
Use --merge to merge the sync branch back to main branch.`,
Run: func(cmd *cobra.Command, _ []string) {
CheckReadonly("sync")
ctx := rootCtx
message, _ := cmd.Flags().GetString("message")
dryRun, _ := cmd.Flags().GetBool("dry-run")
noPush, _ := cmd.Flags().GetBool("no-push")
noPull, _ := cmd.Flags().GetBool("no-pull")
renameOnImport, _ := cmd.Flags().GetBool("rename-on-import")
flushOnly, _ := cmd.Flags().GetBool("flush-only")
importOnly, _ := cmd.Flags().GetBool("import-only")
status, _ := cmd.Flags().GetBool("status")
merge, _ := cmd.Flags().GetBool("merge")
fromMain, _ := cmd.Flags().GetBool("from-main")
noGitHistory, _ := cmd.Flags().GetBool("no-git-history")
squash, _ := cmd.Flags().GetBool("squash")
// bd-sync-corruption fix: Force direct mode for sync operations.
// This prevents stale daemon SQLite connections from corrupting exports.
// If the daemon was running but its database file was deleted and recreated
// (e.g., during recovery), the daemon's SQLite connection points to the old
// (deleted) file, causing export to return incomplete/corrupt data.
// Using direct mode ensures we always read from the current database file.
if daemonClient != nil {
debug.Logf("sync: forcing direct mode for consistency")
_ = daemonClient.Close()
daemonClient = nil
}
// Resolve noGitHistory based on fromMain (fixes #417)
noGitHistory = resolveNoGitHistoryForFromMain(fromMain, noGitHistory)
// Find JSONL path
jsonlPath := findJSONLPath()
if jsonlPath == "" {
fmt.Fprintf(os.Stderr, "Error: not in a bd workspace (no .beads directory found)\n")
os.Exit(1)
}
// If status mode, show diff between sync branch and main
if status {
if err := showSyncStatus(ctx); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
return
}
// If merge mode, merge sync branch to main
if merge {
if err := mergeSyncBranch(ctx, dryRun); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
return
}
// If from-main mode, one-way sync from main branch (gt-ick9: ephemeral branch support)
if fromMain {
if err := doSyncFromMain(ctx, jsonlPath, renameOnImport, dryRun, noGitHistory); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
return
}
// If import-only mode, just import and exit
if importOnly {
if dryRun {
fmt.Println("→ [DRY RUN] Would import from JSONL")
} else {
fmt.Println("→ Importing from JSONL...")
if err := importFromJSONL(ctx, jsonlPath, renameOnImport, noGitHistory); err != nil {
fmt.Fprintf(os.Stderr, "Error importing: %v\n", err)
os.Exit(1)
}
fmt.Println("✓ Import complete")
}
return
}
// If flush-only mode, just export and exit
if flushOnly {
if dryRun {
fmt.Println("→ [DRY RUN] Would export pending changes to JSONL")
} else {
if err := exportToJSONL(ctx, jsonlPath); err != nil {
fmt.Fprintf(os.Stderr, "Error exporting: %v\n", err)
os.Exit(1)
}
}
return
}
// If squash mode, export to JSONL but skip git operations (bd-o2e)
// This accumulates changes for a single commit later
if squash {
if dryRun {
fmt.Println("→ [DRY RUN] Would export pending changes to JSONL (squash mode)")
} else {
fmt.Println("→ Exporting pending changes to JSONL (squash mode)...")
if err := exportToJSONL(ctx, jsonlPath); err != nil {
fmt.Fprintf(os.Stderr, "Error exporting: %v\n", err)
os.Exit(1)
}
fmt.Println("✓ Changes accumulated in JSONL")
fmt.Println(" Run 'bd sync' (without --squash) to commit all accumulated changes")
}
return
}
// Check if we're in a git repository
if !isGitRepo() {
fmt.Fprintf(os.Stderr, "Error: not in a git repository\n")
fmt.Fprintf(os.Stderr, "Hint: run 'git init' to initialize a repository\n")
os.Exit(1)
}
// Preflight: check for merge/rebase in progress
if inMerge, err := gitHasUnmergedPaths(); err != nil {
fmt.Fprintf(os.Stderr, "Error checking git state: %v\n", err)
os.Exit(1)
} else if inMerge {
fmt.Fprintf(os.Stderr, "Error: unmerged paths or merge in progress\n")
fmt.Fprintf(os.Stderr, "Hint: resolve conflicts, run 'bd import' if needed, then 'bd sync' again\n")
os.Exit(1)
}
// Preflight: check for upstream tracking
// If no upstream, automatically switch to --from-main mode (gt-ick9: ephemeral branch support)
if !noPull && !gitHasUpstream() {
if hasGitRemote(ctx) {
// Remote exists but no upstream - use from-main mode
fmt.Println("→ No upstream configured, using --from-main mode")
// Force noGitHistory=true for auto-detected from-main mode (fixes #417)
if err := doSyncFromMain(ctx, jsonlPath, renameOnImport, dryRun, true); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
return
}
// If no remote at all, gitPull/gitPush will gracefully skip
}
// Step 1: Export pending changes (but check for stale DB first)
skipExport := false // Track if we should skip export due to ZFC import
if dryRun {
fmt.Println("→ [DRY RUN] Would export pending changes to JSONL")
} else {
// ZFC safety check (bd-l0r, bd-53c): if DB significantly diverges from JSONL,
// force import first to sync with JSONL source of truth.
// After import, skip export to prevent overwriting JSONL (JSONL is source of truth).
//
// bd-53c fix: Added REVERSE ZFC check - if JSONL has MORE issues than DB,
// this indicates the DB is stale and exporting would cause data loss.
// This catches the case where a fresh/stale clone tries to export an
// empty or outdated database over a JSONL with many issues.
if err := ensureStoreActive(); err == nil && store != nil {
dbCount, err := countDBIssuesFast(ctx, store)
if err == nil {
jsonlCount, err := countIssuesInJSONL(jsonlPath)
if err == nil && jsonlCount > 0 {
// Case 1: DB has significantly more issues than JSONL
// (original ZFC check - DB is ahead of JSONL)
if dbCount > jsonlCount {
divergence := float64(dbCount-jsonlCount) / float64(jsonlCount)
if divergence > 0.5 { // >50% more issues in DB than JSONL
fmt.Printf("→ DB has %d issues but JSONL has %d (stale JSONL detected)\n", dbCount, jsonlCount)
fmt.Println("→ Importing JSONL first (ZFC)...")
if err := importFromJSONL(ctx, jsonlPath, renameOnImport, noGitHistory); err != nil {
fmt.Fprintf(os.Stderr, "Error importing (ZFC): %v\n", err)
os.Exit(1)
}
// Skip export after ZFC import - JSONL is source of truth
skipExport = true
fmt.Println("→ Skipping export (JSONL is source of truth after ZFC import)")
}
}
// Case 2 (bd-53c): JSONL has significantly more issues than DB
// This is the DANGEROUS case - exporting would lose issues!
// A stale/empty DB exporting over a populated JSONL causes data loss.
if jsonlCount > dbCount && !skipExport {
divergence := float64(jsonlCount-dbCount) / float64(jsonlCount)
// Use stricter threshold for this dangerous case:
// - Any loss > 20% is suspicious
// - Complete loss (DB empty) is always blocked
if dbCount == 0 || divergence > 0.2 {
fmt.Printf("→ JSONL has %d issues but DB has only %d (stale DB detected - bd-53c)\n", jsonlCount, dbCount)
fmt.Println("→ Importing JSONL first to prevent data loss...")
if err := importFromJSONL(ctx, jsonlPath, renameOnImport, noGitHistory); err != nil {
fmt.Fprintf(os.Stderr, "Error importing (reverse ZFC): %v\n", err)
os.Exit(1)
}
// Skip export after import - JSONL is source of truth
skipExport = true
fmt.Println("→ Skipping export (JSONL is source of truth after reverse ZFC import)")
}
}
}
}
// Case 3 (bd-f2f): JSONL content differs from DB (hash mismatch)
// This catches the case where counts match but STATUS/content differs.
// A stale DB exporting wrong status values over correct JSONL values
// causes corruption that the 3-way merge propagates.
//
// Example: Remote has status=open, stale DB has status=closed (count=5 both)
// Without this check: export writes status=closed → git merge keeps it → corruption
// With this check: detect hash mismatch → import first → get correct status
//
// Note: Auto-import in autoflush.go also checks for hash changes during store
// initialization, so this check may be redundant in most cases. However, it
// provides defense-in-depth for cases where auto-import is disabled or bypassed.
if !skipExport {
repoKey := getRepoKeyForPath(jsonlPath)
if hasJSONLChanged(ctx, store, jsonlPath, repoKey) {
fmt.Println("→ JSONL content differs from last sync (bd-f2f)")
fmt.Println("→ Importing JSONL first to prevent stale DB from overwriting changes...")
if err := importFromJSONL(ctx, jsonlPath, renameOnImport, noGitHistory); err != nil {
fmt.Fprintf(os.Stderr, "Error importing (bd-f2f hash mismatch): %v\n", err)
os.Exit(1)
}
// Don't skip export - we still want to export any remaining local dirty issues
// The import updated DB with JSONL content, and export will write merged state
fmt.Println("→ Import complete, continuing with export of merged state")
}
}
}
if !skipExport {
// Pre-export integrity checks
if err := ensureStoreActive(); err == nil && store != nil {
if err := validatePreExport(ctx, store, jsonlPath); err != nil {
fmt.Fprintf(os.Stderr, "Pre-export validation failed: %v\n", err)
os.Exit(1)
}
if err := checkDuplicateIDs(ctx, store); err != nil {
fmt.Fprintf(os.Stderr, "Database corruption detected: %v\n", err)
os.Exit(1)
}
if orphaned, err := checkOrphanedDeps(ctx, store); err != nil {
fmt.Fprintf(os.Stderr, "Warning: orphaned dependency check failed: %v\n", err)
} else if len(orphaned) > 0 {
fmt.Fprintf(os.Stderr, "Warning: found %d orphaned dependencies: %v\n", len(orphaned), orphaned)
}
}
fmt.Println("→ Exporting pending changes to JSONL...")
if err := exportToJSONL(ctx, jsonlPath); err != nil {
fmt.Fprintf(os.Stderr, "Error exporting: %v\n", err)
os.Exit(1)
}
}
// Capture left snapshot (pre-pull state) for 3-way merge
// This is mandatory for deletion tracking integrity
if err := captureLeftSnapshot(jsonlPath); err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to capture snapshot (required for deletion tracking): %v\n", err)
os.Exit(1)
}
}
// Check if BEADS_DIR points to an external repository (dand-oss fix)
// If so, use direct git operations instead of worktree-based sync
beadsDir := filepath.Dir(jsonlPath)
isExternal := isExternalBeadsDir(ctx, beadsDir)
if isExternal {
// External BEADS_DIR: commit/pull directly to the beads repo
fmt.Println("→ External BEADS_DIR detected, using direct commit...")
// Check for changes in the external beads repo
externalRepoRoot, err := getRepoRootFromPath(ctx, beadsDir)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
// Check if there are changes to commit
relBeadsDir, _ := filepath.Rel(externalRepoRoot, beadsDir)
statusCmd := exec.CommandContext(ctx, "git", "-C", externalRepoRoot, "status", "--porcelain", relBeadsDir)
statusOutput, _ := statusCmd.Output()
externalHasChanges := len(strings.TrimSpace(string(statusOutput))) > 0
if externalHasChanges {
if dryRun {
fmt.Printf("→ [DRY RUN] Would commit changes to external beads repo at %s\n", externalRepoRoot)
} else {
committed, err := commitToExternalBeadsRepo(ctx, beadsDir, message, !noPush)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if committed {
if !noPush {
fmt.Println("✓ Committed and pushed to external beads repo")
} else {
fmt.Println("✓ Committed to external beads repo")
}
}
}
} else {
fmt.Println("→ No changes to commit in external beads repo")
}
if !noPull {
if dryRun {
fmt.Printf("→ [DRY RUN] Would pull from external beads repo at %s\n", externalRepoRoot)
} else {
fmt.Println("→ Pulling from external beads repo...")
if err := pullFromExternalBeadsRepo(ctx, beadsDir); err != nil {
fmt.Fprintf(os.Stderr, "Error pulling: %v\n", err)
os.Exit(1)
}
fmt.Println("✓ Pulled from external beads repo")
// Re-import after pull to update local database
fmt.Println("→ Importing JSONL...")
if err := importFromJSONL(ctx, jsonlPath, renameOnImport, noGitHistory); err != nil {
fmt.Fprintf(os.Stderr, "Error importing: %v\n", err)
os.Exit(1)
}
}
}
fmt.Println("\n✓ Sync complete")
return
}
// Check if sync.branch is configured for worktree-based sync (bd-e3w)
// This allows committing to a separate branch without changing the user's working directory
var syncBranchName string
var repoRoot string
var useSyncBranch bool
if err := ensureStoreActive(); err == nil && store != nil {
syncBranchName, _ = syncbranch.Get(ctx, store)
if syncBranchName != "" && syncbranch.HasGitRemote(ctx) {
// GH#519: Check if sync.branch equals current branch
// If so, we can't use a worktree (git doesn't allow same branch in multiple worktrees)
// Fall back to direct commits on the current branch
if syncbranch.IsSyncBranchSameAsCurrent(ctx, syncBranchName) {
// sync.branch == current branch - use regular commits, not worktree
useSyncBranch = false
} else {
repoRoot, err = syncbranch.GetRepoRoot(ctx)
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: sync.branch configured but failed to get repo root: %v\n", err)
fmt.Fprintf(os.Stderr, "Falling back to current branch commits\n")
} else {
useSyncBranch = true
}
}
}
}
// Step 2: Check if there are changes to commit (check entire .beads/ directory)
hasChanges, err := gitHasBeadsChanges(ctx)
if err != nil {
fmt.Fprintf(os.Stderr, "Error checking git status: %v\n", err)
os.Exit(1)
}
// Track if we already pushed via worktree (to skip Step 5)
pushedViaSyncBranch := false
if hasChanges {
if dryRun {
if useSyncBranch {
fmt.Printf("→ [DRY RUN] Would commit changes to sync branch '%s' via worktree\n", syncBranchName)
} else {
fmt.Println("→ [DRY RUN] Would commit changes to git")
}
} else if useSyncBranch {
// Use worktree to commit to sync branch (bd-e3w)
fmt.Printf("→ Committing changes to sync branch '%s'...\n", syncBranchName)
result, err := syncbranch.CommitToSyncBranch(ctx, repoRoot, syncBranchName, jsonlPath, !noPush)
if err != nil {
fmt.Fprintf(os.Stderr, "Error committing to sync branch: %v\n", err)
os.Exit(1)
}
if result.Committed {
fmt.Printf("✓ Committed to %s\n", syncBranchName)
if result.Pushed {
fmt.Printf("✓ Pushed %s to remote\n", syncBranchName)
pushedViaSyncBranch = true
}
}
} else {
// Regular commit to current branch
fmt.Println("→ Committing changes to git...")
if err := gitCommitBeadsDir(ctx, message); err != nil {
fmt.Fprintf(os.Stderr, "Error committing: %v\n", err)
os.Exit(1)
}
}
} else {
fmt.Println("→ No changes to commit")
}
// Step 3: Pull from remote
// Note: If no upstream, we already handled it above with --from-main mode
if !noPull {
if dryRun {
if useSyncBranch {
fmt.Printf("→ [DRY RUN] Would pull from sync branch '%s' via worktree\n", syncBranchName)
} else {
fmt.Println("→ [DRY RUN] Would pull from remote")
}
} else {
// Execute pull - either via sync branch worktree or regular git pull
if useSyncBranch {
// Pull from sync branch via worktree (bd-e3w)
fmt.Printf("→ Pulling from sync branch '%s'...\n", syncBranchName)
// bd-4u8: Check if confirmation is required for mass deletion
requireMassDeleteConfirmation := config.GetBool("sync.require_confirmation_on_mass_delete")
pullResult, err := syncbranch.PullFromSyncBranch(ctx, repoRoot, syncBranchName, jsonlPath, !noPush, requireMassDeleteConfirmation)
if err != nil {
fmt.Fprintf(os.Stderr, "Error pulling from sync branch: %v\n", err)
os.Exit(1)
}
if pullResult.Pulled {
if pullResult.Merged {
// bd-3s8 fix: divergent histories were merged at content level
fmt.Printf("✓ Merged divergent histories from %s\n", syncBranchName)
// bd-7z4: Print safety warnings from result
for _, warning := range pullResult.SafetyWarnings {
fmt.Fprintln(os.Stderr, warning)
}
// bd-4u8: Handle safety check with confirmation requirement
if pullResult.SafetyCheckTriggered && !pullResult.Pushed {
// bd-dmd: Don't duplicate SafetyCheckDetails - it's already in SafetyWarnings
// Prompt for confirmation
fmt.Fprintf(os.Stderr, "Push these changes to remote? [y/N]: ")
var response string
reader := bufio.NewReader(os.Stdin)
response, _ = reader.ReadString('\n')
response = strings.TrimSpace(strings.ToLower(response))
if response == "y" || response == "yes" {
fmt.Printf("→ Pushing to %s...\n", syncBranchName)
if err := syncbranch.PushSyncBranch(ctx, repoRoot, syncBranchName); err != nil {
fmt.Fprintf(os.Stderr, "Error pushing to sync branch: %v\n", err)
os.Exit(1)
}
fmt.Printf("✓ Pushed merged changes to %s\n", syncBranchName)
pushedViaSyncBranch = true
} else {
fmt.Println("Push canceled. Run 'bd sync' again to retry.")
fmt.Println("If this was unintended, use 'git reflog' on the sync branch to recover.")
}
} else if pullResult.Pushed {
// bd-7ch: auto-push after merge
fmt.Printf("✓ Pushed merged changes to %s\n", syncBranchName)
pushedViaSyncBranch = true
}
} else if pullResult.FastForwarded {
fmt.Printf("✓ Fast-forwarded from %s\n", syncBranchName)
} else {
fmt.Printf("✓ Pulled from %s\n", syncBranchName)
}
}
// JSONL is already copied to main repo by PullFromSyncBranch
} else {
// Check merge driver configuration before pulling
checkMergeDriverConfig()
fmt.Println("→ Pulling from remote...")
err := gitPull(ctx)
if err != nil {
// Check if it's a rebase conflict on beads.jsonl that we can auto-resolve
if isInRebase() && hasJSONLConflict() {
fmt.Println("→ Auto-resolving JSONL merge conflict...")
// Export clean JSONL from DB (database is source of truth)
if exportErr := exportToJSONL(ctx, jsonlPath); exportErr != nil {
fmt.Fprintf(os.Stderr, "Error: failed to export for conflict resolution: %v\n", exportErr)
fmt.Fprintf(os.Stderr, "Hint: resolve conflicts manually and run 'bd import' then 'bd sync' again\n")
os.Exit(1)
}
// Mark conflict as resolved
addCmd := exec.CommandContext(ctx, "git", "add", jsonlPath)
if addErr := addCmd.Run(); addErr != nil {
fmt.Fprintf(os.Stderr, "Error: failed to mark conflict resolved: %v\n", addErr)
fmt.Fprintf(os.Stderr, "Hint: resolve conflicts manually and run 'bd import' then 'bd sync' again\n")
os.Exit(1)
}
// Continue rebase
if continueErr := runGitRebaseContinue(ctx); continueErr != nil {
fmt.Fprintf(os.Stderr, "Error: failed to continue rebase: %v\n", continueErr)
fmt.Fprintf(os.Stderr, "Hint: resolve conflicts manually and run 'bd import' then 'bd sync' again\n")
os.Exit(1)
}
fmt.Println("✓ Auto-resolved JSONL conflict")
} else {
// Not an auto-resolvable conflict, fail with original error
fmt.Fprintf(os.Stderr, "Error pulling: %v\n", err)
// Check if this looks like a merge driver failure
errStr := err.Error()
if strings.Contains(errStr, "merge driver") ||
strings.Contains(errStr, "no such file or directory") ||
strings.Contains(errStr, "MERGE DRIVER INVOKED") {
fmt.Fprintf(os.Stderr, "\nThis may be caused by an incorrect merge driver configuration.\n")
fmt.Fprintf(os.Stderr, "Fix: bd doctor --fix\n\n")
}
fmt.Fprintf(os.Stderr, "Hint: resolve conflicts manually and run 'bd import' then 'bd sync' again\n")
os.Exit(1)
}
}
}
// Import logic - shared for both sync branch and regular pull paths
// Count issues before import for validation
var beforeCount int
if err := ensureStoreActive(); err == nil && store != nil {
beforeCount, err = countDBIssues(ctx, store)
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to count issues before import: %v\n", err)
}
}
// Step 3.5: Perform 3-way merge and prune deletions
if err := ensureStoreActive(); err == nil && store != nil {
if err := applyDeletionsFromMerge(ctx, store, jsonlPath); err != nil {
fmt.Fprintf(os.Stderr, "Error during 3-way merge: %v\n", err)
os.Exit(1)
}
}
// Step 3.6: Sanitize JSONL - remove any resurrected zombies
// Git's 3-way merge may re-add deleted issues to JSONL.
// We must remove them before import to prevent resurrection.
sanitizeResult, err := sanitizeJSONLWithDeletions(jsonlPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to sanitize JSONL: %v\n", err)
// Non-fatal - continue with import
} else {
// bd-3ee1 fix: Log protected issues (local work that would have been incorrectly removed)
if sanitizeResult.ProtectedCount > 0 {
fmt.Printf("→ Protected %d locally exported issue(s) from incorrect sanitization (bd-3ee1)\n", sanitizeResult.ProtectedCount)
for _, id := range sanitizeResult.ProtectedIDs {
fmt.Printf(" - %s (in left snapshot)\n", id)
}
}
if sanitizeResult.RemovedCount > 0 {
fmt.Printf("→ Sanitized JSONL: removed %d deleted issue(s) that were resurrected by git merge\n", sanitizeResult.RemovedCount)
for _, id := range sanitizeResult.RemovedIDs {
fmt.Printf(" - %s\n", id)
}
}
}
// Step 4: Import updated JSONL after pull
// Enable --protect-left-snapshot to prevent git-history-backfill from
// tombstoning issues that were in our local export but got lost during merge (bd-sync-deletion fix)
fmt.Println("→ Importing updated JSONL...")
if err := importFromJSONL(ctx, jsonlPath, renameOnImport, noGitHistory, true); err != nil {
fmt.Fprintf(os.Stderr, "Error importing: %v\n", err)
os.Exit(1)
}
// Validate import didn't cause data loss
if beforeCount > 0 {
if err := ensureStoreActive(); err == nil && store != nil {
afterCount, err := countDBIssues(ctx, store)
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to count issues after import: %v\n", err)
} else {
// Account for expected deletions from sanitize step (bd-tt0 fix)
expectedDeletions := 0
if sanitizeResult != nil {
expectedDeletions = sanitizeResult.RemovedCount
}
if err := validatePostImportWithExpectedDeletions(beforeCount, afterCount, expectedDeletions, jsonlPath); err != nil {
fmt.Fprintf(os.Stderr, "Post-import validation failed: %v\n", err)
os.Exit(1)
}
}
}
}
// Post-pull ZFC check: if skipExport was set by initial ZFC detection,
// or if DB has more issues than JSONL, skip re-export.
// This prevents resurrection of deleted issues when syncing stale clones.
skipReexport := skipExport // Carry forward initial ZFC detection
if !skipReexport {
if err := ensureStoreActive(); err == nil && store != nil {
dbCountPostImport, dbErr := countDBIssuesFast(ctx, store)
jsonlCountPostPull, jsonlErr := countIssuesInJSONL(jsonlPath)
if dbErr == nil && jsonlErr == nil && jsonlCountPostPull > 0 {
// Skip re-export if DB has more issues than JSONL (any amount)
if dbCountPostImport > jsonlCountPostPull {
fmt.Printf("→ DB (%d) has more issues than JSONL (%d) after pull\n",
dbCountPostImport, jsonlCountPostPull)
fmt.Println("→ Trusting JSONL as source of truth (skipping re-export)")
fmt.Println(" Hint: Run 'bd import --delete-missing' to fully sync DB with JSONL")
skipReexport = true
}
}
}
}
// Step 4.5: Check if DB needs re-export (only if DB differs from JSONL)
// This prevents the infinite loop: import → export → commit → dirty again
if !skipReexport {
if err := ensureStoreActive(); err == nil && store != nil {
needsExport, err := dbNeedsExport(ctx, store, jsonlPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to check if export needed: %v\n", err)
// Conservative: assume export needed
needsExport = true
}
if needsExport {
fmt.Println("→ Re-exporting after import to sync DB changes...")
if err := exportToJSONL(ctx, jsonlPath); err != nil {
fmt.Fprintf(os.Stderr, "Error re-exporting after import: %v\n", err)
os.Exit(1)
}
// Step 4.6: Commit the re-export if it created changes
hasPostImportChanges, err := gitHasBeadsChanges(ctx)
if err != nil {
fmt.Fprintf(os.Stderr, "Error checking git status after re-export: %v\n", err)
os.Exit(1)
}
if hasPostImportChanges {
fmt.Println("→ Committing DB changes from import...")
if useSyncBranch {
// Commit to sync branch via worktree (bd-e3w)
result, err := syncbranch.CommitToSyncBranch(ctx, repoRoot, syncBranchName, jsonlPath, !noPush)
if err != nil {
fmt.Fprintf(os.Stderr, "Error committing to sync branch: %v\n", err)
os.Exit(1)
}
if result.Pushed {
pushedViaSyncBranch = true
}
} else {
if err := gitCommitBeadsDir(ctx, "bd sync: apply DB changes after import"); err != nil {
fmt.Fprintf(os.Stderr, "Error committing post-import changes: %v\n", err)
os.Exit(1)
}
}
hasChanges = true // Mark that we have changes to push
}
} else {
fmt.Println("→ DB and JSONL in sync, skipping re-export")
}
}
}
// Update base snapshot after successful import
if err := updateBaseSnapshot(jsonlPath); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to update base snapshot: %v\n", err)
}
}
}
// Step 5: Push to remote (skip if using sync branch - all pushes go via worktree)
// When sync.branch is configured, we don't push the main branch at all.
// The sync branch worktree handles all pushes.
if !noPush && hasChanges && !pushedViaSyncBranch && !useSyncBranch {
if dryRun {
fmt.Println("→ [DRY RUN] Would push to remote")
} else {
fmt.Println("→ Pushing to remote...")
if err := gitPush(ctx); err != nil {
fmt.Fprintf(os.Stderr, "Error pushing: %v\n", err)
fmt.Fprintf(os.Stderr, "Hint: pull may have brought new changes, run 'bd sync' again\n")
os.Exit(1)
}
}
}
if dryRun {
fmt.Println("\n✓ Dry run complete (no changes made)")
} else {
// Clean up temporary snapshot files after successful sync (bd-0io)
// This runs regardless of whether pull was performed
sm := NewSnapshotManager(jsonlPath)
if err := sm.Cleanup(); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to clean up snapshots: %v\n", err)
}
// Auto-compact deletions manifest if enabled and threshold exceeded
if err := maybeAutoCompactDeletions(ctx, jsonlPath); err != nil {
// Non-fatal - just log warning
fmt.Fprintf(os.Stderr, "Warning: auto-compact deletions failed: %v\n", err)
}
// When using sync.branch, restore .beads/ from current branch to keep
// working directory clean. The actual beads data lives on the sync branch,
// and the main branch's .beads/ should match what's committed there.
// This prevents "modified .beads/" showing in git status after sync.
if useSyncBranch {
if err := restoreBeadsDirFromBranch(ctx); err != nil {
// Non-fatal - just means git status will show modified files
debug.Logf("sync: failed to restore .beads/ from branch: %v", err)
}
// Skip final flush in PersistentPostRun - we've already exported to sync branch
// and restored the working directory to match the current branch
skipFinalFlush = true
}
fmt.Println("\n✓ Sync complete")
}
},
}
func init() {
syncCmd.Flags().StringP("message", "m", "", "Commit message (default: auto-generated)")
syncCmd.Flags().Bool("dry-run", false, "Preview sync without making changes")
syncCmd.Flags().Bool("no-push", false, "Skip pushing to remote")
syncCmd.Flags().Bool("no-pull", false, "Skip pulling from remote")
syncCmd.Flags().Bool("rename-on-import", false, "Rename imported issues to match database prefix (updates all references)")
syncCmd.Flags().Bool("flush-only", false, "Only export pending changes to JSONL (skip git operations)")
syncCmd.Flags().Bool("squash", false, "Accumulate changes in JSONL without committing (run 'bd sync' later to commit all)")
syncCmd.Flags().Bool("import-only", false, "Only import from JSONL (skip git operations, useful after git pull)")
syncCmd.Flags().Bool("status", false, "Show diff between sync branch and main branch")
syncCmd.Flags().Bool("merge", false, "Merge sync branch back to main branch")
syncCmd.Flags().Bool("from-main", false, "One-way sync from main branch (for ephemeral branches without upstream)")
syncCmd.Flags().Bool("no-git-history", false, "Skip git history backfill for deletions (use during JSONL filename migrations)")
syncCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output sync statistics in JSON format")
rootCmd.AddCommand(syncCmd)
}
// isGitRepo checks if the current directory is in a git repository
func isGitRepo() bool {
cmd := exec.Command("git", "rev-parse", "--git-dir")
return cmd.Run() == nil
}
// gitHasUnmergedPaths checks for unmerged paths or merge in progress
func gitHasUnmergedPaths() (bool, error) {
cmd := exec.Command("git", "status", "--porcelain")
out, err := cmd.Output()
if err != nil {
return false, fmt.Errorf("git status failed: %w", err)
}
// Check for unmerged status codes (DD, AU, UD, UA, DU, AA, UU)
for _, line := range strings.Split(string(out), "\n") {
if len(line) >= 2 {
s := line[:2]
if s == "DD" || s == "AU" || s == "UD" || s == "UA" || s == "DU" || s == "AA" || s == "UU" {
return true, nil
}
}
}
// Check if MERGE_HEAD exists (merge in progress)
if exec.Command("git", "rev-parse", "-q", "--verify", "MERGE_HEAD").Run() == nil {
return true, nil
}
return false, nil
}
// gitHasUpstream checks if the current branch has an upstream configured
// Uses git config directly for compatibility with Git for Windows
func gitHasUpstream() bool {
// Get current branch name
branchCmd := exec.Command("git", "symbolic-ref", "--short", "HEAD")
branchOutput, err := branchCmd.Output()
if err != nil {
return false
}
branch := strings.TrimSpace(string(branchOutput))
// Check if remote and merge refs are configured
remoteCmd := exec.Command("git", "config", "--get", fmt.Sprintf("branch.%s.remote", branch))
mergeCmd := exec.Command("git", "config", "--get", fmt.Sprintf("branch.%s.merge", branch))
remoteErr := remoteCmd.Run()
mergeErr := mergeCmd.Run()
return remoteErr == nil && mergeErr == nil
}
// gitHasChanges checks if the specified file has uncommitted changes
func gitHasChanges(ctx context.Context, filePath string) (bool, error) {
cmd := exec.CommandContext(ctx, "git", "status", "--porcelain", filePath)
output, err := cmd.Output()
if err != nil {
return false, fmt.Errorf("git status failed: %w", err)
}
return len(strings.TrimSpace(string(output))) > 0, nil
}
// getRepoRootForWorktree returns the main repository root for running git commands
// This is always the main repository root, never the worktree root
func getRepoRootForWorktree(ctx context.Context) string {
repoRoot, err := git.GetMainRepoRoot()
if err != nil {
// Fallback to current directory if GetMainRepoRoot fails
return "."
}
return repoRoot
}
// gitHasBeadsChanges checks if any tracked files in .beads/ have uncommitted changes
func gitHasBeadsChanges(ctx context.Context) (bool, error) {
// Get the absolute path to .beads directory
beadsDir := findBeadsDir()
if beadsDir == "" {
return false, fmt.Errorf("no .beads directory found")
}
// Get the repository root (handles worktrees properly)
repoRoot := getRepoRootForWorktree(ctx)
if repoRoot == "" {
return false, fmt.Errorf("cannot determine repository root")
}
// Compute relative path from repo root to .beads
relPath, err := filepath.Rel(repoRoot, beadsDir)
if err != nil {
// Fall back to absolute path if relative path fails
statusCmd := exec.CommandContext(ctx, "git", "status", "--porcelain", beadsDir)
statusOutput, err := statusCmd.Output()
if err != nil {
return false, fmt.Errorf("git status failed: %w", err)
}
return len(strings.TrimSpace(string(statusOutput))) > 0, nil
}
// Run git status with relative path from repo root
statusCmd := exec.CommandContext(ctx, "git", "-C", repoRoot, "status", "--porcelain", relPath)
statusOutput, err := statusCmd.Output()
if err != nil {
return false, fmt.Errorf("git status failed: %w", err)
}
return len(strings.TrimSpace(string(statusOutput))) > 0, nil
}
// gitCommit commits the specified file (worktree-aware)
func gitCommit(ctx context.Context, filePath string, message string) error {
// Get the repository root (handles worktrees properly)
repoRoot := getRepoRootForWorktree(ctx)
if repoRoot == "" {
return fmt.Errorf("cannot determine repository root")
}
// Make file path relative to repo root for git operations
relPath, err := filepath.Rel(repoRoot, filePath)
if err != nil {
relPath = filePath // Fall back to absolute path
}
// Stage the file from repo root context
addCmd := exec.CommandContext(ctx, "git", "-C", repoRoot, "add", relPath)
if err := addCmd.Run(); err != nil {
return fmt.Errorf("git add failed: %w", err)
}
// Generate message if not provided
if message == "" {
message = fmt.Sprintf("bd sync: %s", time.Now().Format("2006-01-02 15:04:05"))
}
// Commit from repo root context
commitCmd := exec.CommandContext(ctx, "git", "-C", repoRoot, "commit", "-m", message)
output, err := commitCmd.CombinedOutput()
if err != nil {
return fmt.Errorf("git commit failed: %w\n%s", err, output)
}
return nil
}
// gitCommitBeadsDir stages and commits only sync-related files in .beads/ (bd-red fix)
// This ensures bd sync doesn't accidentally commit other staged files.
// Only stages specific sync files (issues.jsonl, deletions.jsonl, metadata.json)
// to avoid staging gitignored snapshot files that may be tracked. (bd-guc fix)
// Worktree-aware: handles cases where .beads is in the main repo but we're running from a worktree.
func gitCommitBeadsDir(ctx context.Context, message string) error {
beadsDir := findBeadsDir()
if beadsDir == "" {
return fmt.Errorf("no .beads directory found")
}
// Get the repository root (handles worktrees properly)
repoRoot := getRepoRootForWorktree(ctx)
if repoRoot == "" {
return fmt.Errorf("cannot determine repository root")
}
// Stage only the specific sync-related files (bd-guc)
// This avoids staging gitignored snapshot files (beads.*.jsonl, *.meta.json)
// that may still be tracked from before they were added to .gitignore
syncFiles := []string{
filepath.Join(beadsDir, "issues.jsonl"),
filepath.Join(beadsDir, "deletions.jsonl"),
filepath.Join(beadsDir, "metadata.json"),
}
// Only add files that exist
var filesToAdd []string
for _, f := range syncFiles {
if _, err := os.Stat(f); err == nil {
// Convert to relative path from repo root for git operations
relPath, err := filepath.Rel(repoRoot, f)
if err != nil {
relPath = f // Fall back to absolute path if relative fails
}
filesToAdd = append(filesToAdd, relPath)
}
}
if len(filesToAdd) == 0 {
return fmt.Errorf("no sync files found to commit")
}
// Stage only the sync files from repo root context (worktree-aware)
args := append([]string{"-C", repoRoot, "add"}, filesToAdd...)
addCmd := exec.CommandContext(ctx, "git", args...)
if err := addCmd.Run(); err != nil {
return fmt.Errorf("git add failed: %w", err)
}
// Generate message if not provided
if message == "" {
message = fmt.Sprintf("bd sync: %s", time.Now().Format("2006-01-02 15:04:05"))
}
// Commit only .beads/ files using -- pathspec (bd-red)
// This prevents accidentally committing other staged files that the user
// may have staged but wasn't ready to commit yet.
// Convert beadsDir to relative path for git commit (worktree-aware)
relBeadsDir, err := filepath.Rel(repoRoot, beadsDir)
if err != nil {
relBeadsDir = beadsDir // Fall back to absolute path if relative fails
}
commitCmd := exec.CommandContext(ctx, "git", "-C", repoRoot, "commit", "-m", message, "--", relBeadsDir)
output, err := commitCmd.CombinedOutput()
if err != nil {
return fmt.Errorf("git commit failed: %w\n%s", err, output)
}
return nil
}
// hasGitRemote checks if a git remote exists in the repository
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
}
// isInRebase checks if we're currently in a git rebase state
func isInRebase() bool {
// Get actual git directory (handles worktrees)
gitDir, err := git.GetGitDir()
if err != nil {
return false
}
// Check for rebase-merge directory (interactive rebase)
rebaseMergePath := filepath.Join(gitDir, "rebase-merge")
if _, err := os.Stat(rebaseMergePath); err == nil {
return true
}
// Check for rebase-apply directory (non-interactive rebase)
rebaseApplyPath := filepath.Join(gitDir, "rebase-apply")
if _, err := os.Stat(rebaseApplyPath); err == nil {
return true
}
return false
}
// hasJSONLConflict checks if the beads JSONL file has a merge conflict
// Returns true only if the JSONL file (issues.jsonl or beads.jsonl) is the only file in conflict
func hasJSONLConflict() bool {
cmd := exec.Command("git", "status", "--porcelain")
out, err := cmd.Output()
if err != nil {
return false
}
var hasJSONLConflict bool
var hasOtherConflict bool
for _, line := range strings.Split(string(out), "\n") {
if len(line) < 3 {
continue
}
// Check for unmerged status codes (UU = both modified, AA = both added, etc.)
status := line[:2]
if status == "UU" || status == "AA" || status == "DD" ||
status == "AU" || status == "UA" || status == "DU" || status == "UD" {
filepath := strings.TrimSpace(line[3:])
// Check for beads JSONL files (issues.jsonl or beads.jsonl in .beads/)
if strings.HasSuffix(filepath, "issues.jsonl") || strings.HasSuffix(filepath, "beads.jsonl") {
hasJSONLConflict = true
} else {
hasOtherConflict = true
}
}
}
// Only return true if ONLY the JSONL file has a conflict
return hasJSONLConflict && !hasOtherConflict
}
// runGitRebaseContinue continues a rebase after resolving conflicts
func runGitRebaseContinue(ctx context.Context) error {
cmd := exec.CommandContext(ctx, "git", "rebase", "--continue")
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("git rebase --continue failed: %w\n%s", err, output)
}
return nil
}
// gitPull pulls from the current branch's upstream
// Returns nil if no remote configured (local-only mode)
func checkMergeDriverConfig() {
// Get current merge driver configuration
cmd := exec.Command("git", "config", "merge.beads.driver")
output, err := cmd.Output()
if err != nil {
// No merge driver configured - this is OK, user may not need it
return
}
currentConfig := strings.TrimSpace(string(output))
// Check if using old incorrect placeholders
if strings.Contains(currentConfig, "%L") || strings.Contains(currentConfig, "%R") {
fmt.Fprintf(os.Stderr, "\n⚠ WARNING: Git merge driver is misconfigured!\n")
fmt.Fprintf(os.Stderr, " Current: %s\n", currentConfig)
fmt.Fprintf(os.Stderr, " Problem: Git only supports %%O (base), %%A (current), %%B (other)\n")
fmt.Fprintf(os.Stderr, " Using %%L/%%R will cause merge failures!\n")
fmt.Fprintf(os.Stderr, "\n Fix now: bd doctor --fix\n")
fmt.Fprintf(os.Stderr, " Or manually: git config merge.beads.driver \"bd merge %%A %%O %%A %%B\"\n\n")
}
}
func gitPull(ctx context.Context) error {
// Check if any remote exists (bd-biwp: support local-only repos)
if !hasGitRemote(ctx) {
return nil // Gracefully skip - local-only mode
}
// Get current branch name
// Use symbolic-ref to work in fresh repos without commits (bd-flil)
branchCmd := exec.CommandContext(ctx, "git", "symbolic-ref", "--short", "HEAD")
branchOutput, err := branchCmd.Output()
if err != nil {
return fmt.Errorf("failed to get current branch: %w", err)
}
branch := strings.TrimSpace(string(branchOutput))
// Get remote name for current branch (usually "origin")
remoteCmd := exec.CommandContext(ctx, "git", "config", "--get", fmt.Sprintf("branch.%s.remote", branch))
remoteOutput, err := remoteCmd.Output()
if err != nil {
// If no remote configured, default to "origin"
remoteOutput = []byte("origin\n")
}
remote := strings.TrimSpace(string(remoteOutput))
// Pull with explicit remote and branch
cmd := exec.CommandContext(ctx, "git", "pull", remote, branch)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("git pull failed: %w\n%s", err, output)
}
return nil
}
// gitPush pushes to the current branch's upstream
// Returns nil if no remote configured (local-only mode)
func gitPush(ctx context.Context) error {
// Check if any remote exists (bd-biwp: support local-only repos)
if !hasGitRemote(ctx) {
return nil // Gracefully skip - local-only mode
}
cmd := exec.CommandContext(ctx, "git", "push")
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("git push failed: %w\n%s", err, output)
}
return nil
}
// restoreBeadsDirFromBranch restores .beads/ directory from the current branch's committed state.
// This is used after sync when sync.branch is configured to keep the working directory clean.
// The actual beads data lives on the sync branch; the main branch's .beads/ is just a snapshot.
func restoreBeadsDirFromBranch(ctx context.Context) error {
beadsDir := findBeadsDir()
if beadsDir == "" {
return fmt.Errorf("no .beads directory found")
}
// Restore .beads/ from HEAD (current branch's committed state)
// Using -- to ensure .beads/ is treated as a path, not a branch name
cmd := exec.CommandContext(ctx, "git", "checkout", "HEAD", "--", beadsDir)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("git checkout failed: %w\n%s", err, output)
}
return nil
}
// getDefaultBranch returns the default branch name (main or master) for origin remote
// Checks remote HEAD first, then falls back to checking if main/master exist
func getDefaultBranch(ctx context.Context) string {
return getDefaultBranchForRemote(ctx, "origin")
}
// getDefaultBranchForRemote returns the default branch name for a specific remote
// Checks remote HEAD first, then falls back to checking if main/master exist
func getDefaultBranchForRemote(ctx context.Context, remote string) string {
// Try to get default branch from remote
cmd := exec.CommandContext(ctx, "git", "symbolic-ref", fmt.Sprintf("refs/remotes/%s/HEAD", remote))
output, err := cmd.Output()
if err == nil {
ref := strings.TrimSpace(string(output))
// Extract branch name from refs/remotes/<remote>/main
prefix := fmt.Sprintf("refs/remotes/%s/", remote)
if strings.HasPrefix(ref, prefix) {
return strings.TrimPrefix(ref, prefix)
}
}
// Fallback: check if <remote>/main exists
if exec.CommandContext(ctx, "git", "rev-parse", "--verify", fmt.Sprintf("%s/main", remote)).Run() == nil {
return "main"
}
// Fallback: check if <remote>/master exists
if exec.CommandContext(ctx, "git", "rev-parse", "--verify", fmt.Sprintf("%s/master", remote)).Run() == nil {
return "master"
}
// Default to main
return "main"
}
// doSyncFromMain performs a one-way sync from the default branch (main/master)
// Used for ephemeral branches without upstream tracking (gt-ick9)
// This fetches beads from main and imports them, discarding local beads changes.
// If sync.remote is configured (e.g., "upstream" for fork workflows), uses that remote
// instead of "origin" (bd-bx9).
func doSyncFromMain(ctx context.Context, jsonlPath string, renameOnImport bool, dryRun bool, noGitHistory bool) error {
// Determine which remote to use (default: origin, but can be configured via sync.remote)
remote := "origin"
if err := ensureStoreActive(); err == nil && store != nil {
if configuredRemote, err := store.GetConfig(ctx, "sync.remote"); err == nil && configuredRemote != "" {
remote = configuredRemote
}
}
if dryRun {
fmt.Println("→ [DRY RUN] Would sync beads from main branch")
fmt.Printf(" 1. Fetch %s main\n", remote)
fmt.Printf(" 2. Checkout .beads/ from %s/main\n", remote)
fmt.Println(" 3. Import JSONL into database")
fmt.Println("\n✓ Dry run complete (no changes made)")
return nil
}
// Check if we're in a git repository
if !isGitRepo() {
return fmt.Errorf("not in a git repository")
}
// Check if remote exists
if !hasGitRemote(ctx) {
return fmt.Errorf("no git remote configured")
}
// Verify the configured remote exists
checkRemoteCmd := exec.CommandContext(ctx, "git", "remote", "get-url", remote)
if err := checkRemoteCmd.Run(); err != nil {
return fmt.Errorf("configured sync.remote '%s' does not exist (run 'git remote add %s <url>')", remote, remote)
}
defaultBranch := getDefaultBranchForRemote(ctx, remote)
// Step 1: Fetch from main
fmt.Printf("→ Fetching from %s/%s...\n", remote, defaultBranch)
fetchCmd := exec.CommandContext(ctx, "git", "fetch", remote, defaultBranch)
if output, err := fetchCmd.CombinedOutput(); err != nil {
return fmt.Errorf("git fetch %s %s failed: %w\n%s", remote, defaultBranch, err, output)
}
// Step 2: Checkout .beads/ directory from main
fmt.Printf("→ Checking out beads from %s/%s...\n", remote, defaultBranch)
checkoutCmd := exec.CommandContext(ctx, "git", "checkout", fmt.Sprintf("%s/%s", remote, defaultBranch), "--", ".beads/")
if output, err := checkoutCmd.CombinedOutput(); err != nil {
return fmt.Errorf("git checkout .beads/ from %s/%s failed: %w\n%s", remote, defaultBranch, err, output)
}
// Step 3: Import JSONL
fmt.Println("→ Importing JSONL...")
if err := importFromJSONL(ctx, jsonlPath, renameOnImport, noGitHistory); err != nil {
return fmt.Errorf("import failed: %w", err)
}
fmt.Println("\n✓ Sync from main complete")
return nil
}
// exportToJSONL exports the database to JSONL format
func exportToJSONL(ctx context.Context, jsonlPath string) error {
// If daemon is running, use RPC
if daemonClient != nil {
exportArgs := &rpc.ExportArgs{
JSONLPath: jsonlPath,
}
resp, err := daemonClient.Export(exportArgs)
if err != nil {
return fmt.Errorf("daemon export failed: %w", err)
}
if !resp.Success {
return fmt.Errorf("daemon export error: %s", resp.Error)
}
return nil
}
// Direct mode: access store directly
// Ensure store is initialized
if err := ensureStoreActive(); err != nil {
return fmt.Errorf("failed to initialize store: %w", err)
}
// Get all issues
issues, err := store.SearchIssues(ctx, "", types.IssueFilter{})
if err != nil {
return fmt.Errorf("failed to get issues: %w", err)
}
// Safety check: prevent exporting empty database over non-empty JSONL
// Note: The main bd-53c protection is the reverse ZFC check earlier in sync.go
// which runs BEFORE export. Here we only block the most catastrophic case (empty DB)
// to allow legitimate deletions.
if len(issues) == 0 {
existingCount, countErr := countIssuesInJSONL(jsonlPath)
if countErr != nil {
// If we can't read the file, it might not exist yet, which is fine
if !os.IsNotExist(countErr) {
fmt.Fprintf(os.Stderr, "Warning: failed to read existing JSONL: %v\n", countErr)
}
} else if existingCount > 0 {
return fmt.Errorf("refusing to export empty database over non-empty JSONL file (database: 0 issues, JSONL: %d issues)", existingCount)
}
}
// Sort by ID for consistent output
sort.Slice(issues, func(i, j int) bool {
return issues[i].ID < issues[j].ID
})
// Populate dependencies for all issues (avoid N+1)
allDeps, err := store.GetAllDependencyRecords(ctx)
if err != nil {
return fmt.Errorf("failed to get dependencies: %w", err)
}
for _, issue := range issues {
issue.Dependencies = allDeps[issue.ID]
}
// Populate labels for all issues
for _, issue := range issues {
labels, err := store.GetLabels(ctx, issue.ID)
if err != nil {
return fmt.Errorf("failed to get labels for %s: %w", issue.ID, err)
}
issue.Labels = labels
}
// Populate comments for all issues
for _, issue := range issues {
comments, err := store.GetIssueComments(ctx, issue.ID)
if err != nil {
return fmt.Errorf("failed to get comments for %s: %w", issue.ID, err)
}
issue.Comments = comments
}
// Create temp file for atomic write
dir := filepath.Dir(jsonlPath)
base := filepath.Base(jsonlPath)
tempFile, err := os.CreateTemp(dir, base+".tmp.*")
if err != nil {
return fmt.Errorf("failed to create temp file: %w", err)
}
tempPath := tempFile.Name()
defer func() {
_ = tempFile.Close()
_ = os.Remove(tempPath)
}()
// Write JSONL
encoder := json.NewEncoder(tempFile)
exportedIDs := make([]string, 0, len(issues))
for _, issue := range issues {
if err := encoder.Encode(issue); err != nil {
return fmt.Errorf("failed to encode issue %s: %w", issue.ID, err)
}
exportedIDs = append(exportedIDs, issue.ID)
}
// Close temp file before rename
_ = tempFile.Close()
// Atomic replace
if err := os.Rename(tempPath, jsonlPath); err != nil {
return fmt.Errorf("failed to replace JSONL file: %w", err)
}
// Set appropriate file permissions (0600: rw-------)
if err := os.Chmod(jsonlPath, 0600); err != nil {
// Non-fatal warning
fmt.Fprintf(os.Stderr, "Warning: failed to set file permissions: %v\n", err)
}
// Clear dirty flags for exported issues
if err := store.ClearDirtyIssuesByID(ctx, exportedIDs); err != nil {
// Non-fatal warning
fmt.Fprintf(os.Stderr, "Warning: failed to clear dirty flags: %v\n", err)
}
// Clear auto-flush state
clearAutoFlushState()
// Update jsonl_content_hash metadata to enable content-based staleness detection (bd-khnb fix)
// After export, database and JSONL are in sync, so update hash to prevent unnecessary auto-import
// Renamed from last_import_hash (bd-39o) - more accurate since updated on both import AND export
if currentHash, err := computeJSONLHash(jsonlPath); err == nil {
if err := store.SetMetadata(ctx, "jsonl_content_hash", currentHash); err != nil {
// Non-fatal warning: Metadata update failures are intentionally non-fatal to prevent blocking
// successful exports. System degrades gracefully to mtime-based staleness detection if metadata
// is unavailable. This ensures export operations always succeed even if metadata storage fails.
fmt.Fprintf(os.Stderr, "Warning: failed to update jsonl_content_hash: %v\n", err)
}
// Use RFC3339Nano for nanosecond precision to avoid race with file mtime (fixes #399)
exportTime := time.Now().Format(time.RFC3339Nano)
if err := store.SetMetadata(ctx, "last_import_time", exportTime); err != nil {
// Non-fatal warning (see above comment about graceful degradation)
fmt.Fprintf(os.Stderr, "Warning: failed to update last_import_time: %v\n", err)
}
// Note: mtime tracking removed in bd-v0y fix (git doesn't preserve mtime)
}
// Update database mtime to be >= JSONL mtime (fixes #278, #301, #321)
// This prevents validatePreExport from incorrectly blocking on next export
beadsDir := filepath.Dir(jsonlPath)
dbPath := filepath.Join(beadsDir, "beads.db")
if err := TouchDatabaseFile(dbPath, jsonlPath); err != nil {
// Non-fatal warning
fmt.Fprintf(os.Stderr, "Warning: failed to update database mtime: %v\n", err)
}
return nil
}
// getCurrentBranch returns the name of the current git branch
// Uses symbolic-ref instead of rev-parse to work in fresh repos without commits (bd-flil)
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
}
// getSyncBranch returns the configured sync branch name
func getSyncBranch(ctx context.Context) (string, error) {
// Ensure store is initialized
if err := ensureStoreActive(); err != nil {
return "", fmt.Errorf("failed to initialize store: %w", err)
}
syncBranch, err := syncbranch.Get(ctx, store)
if err != nil {
return "", fmt.Errorf("failed to get sync branch config: %w", err)
}
if syncBranch == "" {
return "", fmt.Errorf("sync.branch not configured (run 'bd config set sync.branch <branch-name>')")
}
return syncBranch, nil
}
// showSyncStatus shows the diff between sync branch and main branch
func showSyncStatus(ctx context.Context) error {
if !isGitRepo() {
return fmt.Errorf("not in a git repository")
}
currentBranch, err := getCurrentBranch(ctx)
if err != nil {
return err
}
syncBranch, err := getSyncBranch(ctx)
if err != nil {
return err
}
// Check if sync branch exists
checkCmd := exec.CommandContext(ctx, "git", "show-ref", "--verify", "--quiet", "refs/heads/"+syncBranch)
if err := checkCmd.Run(); err != nil {
return fmt.Errorf("sync branch '%s' does not exist", syncBranch)
}
fmt.Printf("Current branch: %s\n", currentBranch)
fmt.Printf("Sync branch: %s\n\n", syncBranch)
// Show commit diff
fmt.Println("Commits in sync branch not in main:")
logCmd := exec.CommandContext(ctx, "git", "log", "--oneline", currentBranch+".."+syncBranch)
logOutput, err := logCmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to get commit log: %w\n%s", err, logOutput)
}
if len(strings.TrimSpace(string(logOutput))) == 0 {
fmt.Println(" (none)")
} else {
fmt.Print(string(logOutput))
}
fmt.Println("\nCommits in main not in sync branch:")
logCmd = exec.CommandContext(ctx, "git", "log", "--oneline", syncBranch+".."+currentBranch)
logOutput, err = logCmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to get commit log: %w\n%s", err, logOutput)
}
if len(strings.TrimSpace(string(logOutput))) == 0 {
fmt.Println(" (none)")
} else {
fmt.Print(string(logOutput))
}
// Show file diff for .beads/issues.jsonl
fmt.Println("\nFile differences in .beads/issues.jsonl:")
diffCmd := exec.CommandContext(ctx, "git", "diff", currentBranch+"..."+syncBranch, "--", ".beads/issues.jsonl")
diffOutput, err := diffCmd.CombinedOutput()
if err != nil {
// diff returns non-zero when there are differences, which is fine
if len(diffOutput) == 0 {
return fmt.Errorf("failed to get diff: %w", err)
}
}
if len(strings.TrimSpace(string(diffOutput))) == 0 {
fmt.Println(" (no differences)")
} else {
fmt.Print(string(diffOutput))
}
return nil
}
// mergeSyncBranch merges the sync branch back to main
func mergeSyncBranch(ctx context.Context, dryRun bool) error {
if !isGitRepo() {
return fmt.Errorf("not in a git repository")
}
currentBranch, err := getCurrentBranch(ctx)
if err != nil {
return err
}
syncBranch, err := getSyncBranch(ctx)
if err != nil {
return err
}
// Check if sync branch exists
checkCmd := exec.CommandContext(ctx, "git", "show-ref", "--verify", "--quiet", "refs/heads/"+syncBranch)
if err := checkCmd.Run(); err != nil {
return fmt.Errorf("sync branch '%s' does not exist", syncBranch)
}
// Verify we're on the main branch (not the sync branch)
if currentBranch == syncBranch {
return fmt.Errorf("cannot merge while on sync branch '%s' (checkout main branch first)", syncBranch)
}
// Check if main branch is clean
statusCmd := exec.CommandContext(ctx, "git", "status", "--porcelain")
statusOutput, err := statusCmd.Output()
if err != nil {
return fmt.Errorf("failed to check git status: %w", err)
}
if len(strings.TrimSpace(string(statusOutput))) > 0 {
return fmt.Errorf("main branch has uncommitted changes, please commit or stash them first")
}
if dryRun {
fmt.Printf("[DRY RUN] Would merge branch '%s' into '%s'\n", syncBranch, currentBranch)
// Show what would be merged
logCmd := exec.CommandContext(ctx, "git", "log", "--oneline", currentBranch+".."+syncBranch)
logOutput, err := logCmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to preview commits: %w", err)
}
if len(strings.TrimSpace(string(logOutput))) > 0 {
fmt.Println("\nCommits that would be merged:")
fmt.Print(string(logOutput))
} else {
fmt.Println("\nNo commits to merge (already up to date)")
}
return nil
}
// Perform the merge
fmt.Printf("Merging branch '%s' into '%s'...\n", syncBranch, currentBranch)
mergeCmd := exec.CommandContext(ctx, "git", "merge", "--no-ff", syncBranch, "-m",
fmt.Sprintf("Merge %s into %s", syncBranch, currentBranch))
mergeOutput, err := mergeCmd.CombinedOutput()
if err != nil {
// Check if it's a merge conflict
if strings.Contains(string(mergeOutput), "CONFLICT") || strings.Contains(string(mergeOutput), "conflict") {
fmt.Fprintf(os.Stderr, "Merge conflict detected:\n%s\n", mergeOutput)
fmt.Fprintf(os.Stderr, "\nTo resolve:\n")
fmt.Fprintf(os.Stderr, "1. Resolve conflicts in the affected files\n")
fmt.Fprintf(os.Stderr, "2. Stage resolved files: git add <files>\n")
fmt.Fprintf(os.Stderr, "3. Complete merge: git commit\n")
fmt.Fprintf(os.Stderr, "4. After merge commit, run 'bd import' to sync database\n")
return fmt.Errorf("merge conflict - see above for resolution steps")
}
return fmt.Errorf("merge failed: %w\n%s", err, mergeOutput)
}
fmt.Print(string(mergeOutput))
fmt.Println("\n✓ Merge complete")
// Suggest next steps
fmt.Println("\nNext steps:")
fmt.Println("1. Review the merged changes")
fmt.Println("2. Run 'bd sync --import-only' to sync the database with merged JSONL")
fmt.Println("3. Run 'bd sync' to push changes to remote")
return nil
}
// importFromJSONL imports the JSONL file by running the import command
// Optional parameters: noGitHistory, protectLeftSnapshot (bd-sync-deletion fix)
func importFromJSONL(ctx context.Context, jsonlPath string, renameOnImport bool, opts ...bool) error {
// Get current executable path to avoid "./bd" path issues
exe, err := os.Executable()
if err != nil {
return fmt.Errorf("cannot resolve current executable: %w", err)
}
// Parse optional parameters
noGitHistory := false
protectLeftSnapshot := false
if len(opts) > 0 {
noGitHistory = opts[0]
}
if len(opts) > 1 {
protectLeftSnapshot = opts[1]
}
// Build args for import command
// Use --no-daemon to ensure subprocess uses direct mode, avoiding daemon connection issues
args := []string{"--no-daemon", "import", "-i", jsonlPath}
if renameOnImport {
args = append(args, "--rename-on-import")
}
if noGitHistory {
args = append(args, "--no-git-history")
}
// Add --protect-left-snapshot flag for post-pull imports (bd-sync-deletion fix)
if protectLeftSnapshot {
args = append(args, "--protect-left-snapshot")
}
// Run import command
cmd := exec.CommandContext(ctx, exe, args...) // #nosec G204 - bd import command from trusted binary
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("import failed: %w\n%s", err, output)
}
// Show output (import command provides the summary)
if len(output) > 0 {
fmt.Print(string(output))
}
return nil
}
// Default configuration values for auto-compact
const (
defaultAutoCompact = false
defaultAutoCompactThreshold = 1000
)
// maybeAutoCompactDeletions checks if auto-compact is enabled and threshold exceeded,
// and if so, prunes the deletions manifest.
func maybeAutoCompactDeletions(ctx context.Context, jsonlPath string) error {
// Ensure store is initialized for config access
if err := ensureStoreActive(); err != nil {
return nil // Can't access config, skip silently
}
// Check if auto-compact is enabled (disabled by default)
autoCompactStr, err := store.GetConfig(ctx, "deletions.auto_compact")
if err != nil || autoCompactStr == "" {
return nil // Not configured, skip
}
autoCompact := autoCompactStr == "true" || autoCompactStr == "1" || autoCompactStr == "yes"
if !autoCompact {
return nil // Disabled, skip
}
// Get threshold (default 1000)
threshold := defaultAutoCompactThreshold
if thresholdStr, err := store.GetConfig(ctx, "deletions.auto_compact_threshold"); err == nil && thresholdStr != "" {
if parsed, err := strconv.Atoi(thresholdStr); err == nil && parsed > 0 {
threshold = parsed
}
}
// Get deletions path
beadsDir := filepath.Dir(jsonlPath)
deletionsPath := deletions.DefaultPath(beadsDir)
// Count current deletions
count, err := deletions.Count(deletionsPath)
if err != nil {
return fmt.Errorf("failed to count deletions: %w", err)
}
// Check if threshold exceeded
if count <= threshold {
return nil // Below threshold, skip
}
// Get retention days (default 7)
retentionDays := configfile.DefaultDeletionsRetentionDays
if retentionStr, err := store.GetConfig(ctx, "deletions.retention_days"); err == nil && retentionStr != "" {
if parsed, err := strconv.Atoi(retentionStr); err == nil && parsed > 0 {
retentionDays = parsed
}
}
// Prune deletions
fmt.Printf("→ Auto-compacting deletions manifest (%d entries > %d threshold)...\n", count, threshold)
result, err := deletions.PruneDeletions(deletionsPath, retentionDays)
if err != nil {
return fmt.Errorf("failed to prune deletions: %w", err)
}
if result.PrunedCount > 0 {
fmt.Printf(" Pruned %d entries older than %d days, kept %d entries\n",
result.PrunedCount, retentionDays, result.KeptCount)
} else {
fmt.Printf(" No entries older than %d days to prune\n", retentionDays)
}
return nil
}
// SanitizeResult contains statistics about the JSONL sanitization operation.
type SanitizeResult struct {
RemovedCount int // Number of issues removed from JSONL
RemovedIDs []string // IDs that were removed
ProtectedCount int // Number of issues protected from removal (bd-3ee1)
ProtectedIDs []string // IDs that were protected
}
// sanitizeJSONLWithDeletions removes non-tombstone issues from the JSONL file
// if they are in the deletions manifest. This prevents zombie resurrection when
// git's 3-way merge re-adds deleted issues to the JSONL during pull.
//
// IMPORTANT (bd-kzxd fix): Tombstones are NOT removed. Tombstones are the proper
// representation of deletions in the JSONL format. Removing them would cause
// the importer to re-create tombstones from deletions.jsonl, leading to
// UNIQUE constraint errors when the tombstone already exists in the database.
//
// IMPORTANT (bd-3ee1 fix): Issues that were in the left snapshot (local export
// before pull) are protected from removal. This prevents newly created issues
// from being incorrectly removed when they happen to have an ID that matches
// an entry in the deletions manifest (possible with hash-based IDs if content
// is similar to a previously deleted issue).
//
// This should be called after git pull but before import.
func sanitizeJSONLWithDeletions(jsonlPath string) (*SanitizeResult, error) {
result := &SanitizeResult{
RemovedIDs: []string{},
ProtectedIDs: []string{},
}
// Get deletions manifest path
beadsDir := filepath.Dir(jsonlPath)
deletionsPath := deletions.DefaultPath(beadsDir)
// Load deletions manifest
loadResult, err := deletions.LoadDeletions(deletionsPath)
if err != nil {
return nil, fmt.Errorf("failed to load deletions manifest: %w", err)
}
// If no deletions, nothing to sanitize
if len(loadResult.Records) == 0 {
return result, nil
}
// bd-3ee1 fix: Load left snapshot to protect locally exported issues
// Issues in the left snapshot were exported before pull and represent
// local work that should not be removed by sanitize
sm := NewSnapshotManager(jsonlPath)
_, leftPath := sm.getSnapshotPaths()
protectedIDs := make(map[string]bool)
if leftIDs, err := sm.buildIDSet(leftPath); err == nil && len(leftIDs) > 0 {
protectedIDs = leftIDs
}
// Read current JSONL
f, err := os.Open(jsonlPath) // #nosec G304 - controlled path
if err != nil {
if os.IsNotExist(err) {
return result, nil // No JSONL file yet
}
return nil, fmt.Errorf("failed to open JSONL: %w", err)
}
var keptLines [][]byte
scanner := bufio.NewScanner(f)
// Allow large lines (up to 10MB for issues with large descriptions)
scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024)
for scanner.Scan() {
line := scanner.Bytes()
if len(bytes.TrimSpace(line)) == 0 {
continue
}
// Extract ID and status to check for tombstones
var issue struct {
ID string `json:"id"`
Status string `json:"status"`
}
if err := json.Unmarshal(line, &issue); err != nil {
// Keep malformed lines (let import handle them)
keptLines = append(keptLines, append([]byte{}, line...))
continue
}
// Check if this ID is in deletions manifest
if _, deleted := loadResult.Records[issue.ID]; deleted {
// bd-kzxd fix: Keep tombstones! They are the proper representation of deletions.
// Only remove non-tombstone issues that were resurrected by git merge.
if issue.Status == string(types.StatusTombstone) {
// Keep the tombstone - it's the authoritative deletion record
keptLines = append(keptLines, append([]byte{}, line...))
} else if protectedIDs[issue.ID] {
// bd-3ee1 fix: Issue was in left snapshot (local export before pull)
// This is local work, not a resurrected zombie - protect it!
keptLines = append(keptLines, append([]byte{}, line...))
result.ProtectedCount++
result.ProtectedIDs = append(result.ProtectedIDs, issue.ID)
} else {
// Remove non-tombstone issue that was resurrected
result.RemovedCount++
result.RemovedIDs = append(result.RemovedIDs, issue.ID)
}
} else {
keptLines = append(keptLines, append([]byte{}, line...))
}
}
if err := scanner.Err(); err != nil {
_ = f.Close()
return nil, fmt.Errorf("failed to read JSONL: %w", err)
}
_ = f.Close()
// If nothing was removed, we're done
if result.RemovedCount == 0 {
return result, nil
}
// Write sanitized JSONL atomically
dir := filepath.Dir(jsonlPath)
base := filepath.Base(jsonlPath)
tempFile, err := os.CreateTemp(dir, base+".sanitize.*")
if err != nil {
return nil, fmt.Errorf("failed to create temp file: %w", err)
}
tempPath := tempFile.Name()
defer func() {
_ = tempFile.Close()
_ = os.Remove(tempPath) // Clean up on error
}()
for _, line := range keptLines {
if _, err := tempFile.Write(line); err != nil {
return nil, fmt.Errorf("failed to write line: %w", err)
}
if _, err := tempFile.Write([]byte("\n")); err != nil {
return nil, fmt.Errorf("failed to write newline: %w", err)
}
}
if err := tempFile.Close(); err != nil {
return nil, fmt.Errorf("failed to close temp file: %w", err)
}
// Atomic replace
if err := os.Rename(tempPath, jsonlPath); err != nil {
return nil, fmt.Errorf("failed to replace JSONL: %w", err)
}
return result, nil
}
// resolveNoGitHistoryForFromMain returns the resolved noGitHistory value for sync operations.
// When syncing from main (--from-main), noGitHistory is forced to true to prevent creating
// incorrect deletion records for locally-created beads that don't exist on main.
// See: https://github.com/steveyegge/beads/issues/417
func resolveNoGitHistoryForFromMain(fromMain, noGitHistory bool) bool {
if fromMain {
return true
}
return noGitHistory
}
// isExternalBeadsDir checks if the beads directory is in a different git repo than cwd.
// This is used to detect when BEADS_DIR points to a separate repository.
// Contributed by dand-oss (https://github.com/steveyegge/beads/pull/533)
func isExternalBeadsDir(ctx context.Context, beadsDir string) bool {
// Get repo root of cwd
cwdRepoRoot, err := syncbranch.GetRepoRoot(ctx)
if err != nil {
return false // Can't determine, assume local
}
// Get repo root of beads dir
beadsRepoRoot, err := getRepoRootFromPath(ctx, beadsDir)
if err != nil {
return false // Can't determine, assume local
}
return cwdRepoRoot != beadsRepoRoot
}
// getRepoRootFromPath returns the git repository root for a given path.
// Unlike syncbranch.GetRepoRoot which uses cwd, this allows getting the repo root
// for any path.
// Contributed by dand-oss (https://github.com/steveyegge/beads/pull/533)
func getRepoRootFromPath(ctx context.Context, path string) (string, error) {
cmd := exec.CommandContext(ctx, "git", "-C", path, "rev-parse", "--show-toplevel")
output, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("failed to get git root for %s: %w", path, err)
}
return strings.TrimSpace(string(output)), nil
}
// commitToExternalBeadsRepo commits changes directly to an external beads repo.
// Used when BEADS_DIR points to a different git repository than cwd.
// This bypasses the worktree-based sync which fails when beads dir is external.
// Contributed by dand-oss (https://github.com/steveyegge/beads/pull/533)
func commitToExternalBeadsRepo(ctx context.Context, beadsDir, message string, push bool) (bool, error) {
repoRoot, err := getRepoRootFromPath(ctx, beadsDir)
if err != nil {
return false, fmt.Errorf("failed to get repo root: %w", err)
}
// Stage beads files (use relative path from repo root)
relBeadsDir, err := filepath.Rel(repoRoot, beadsDir)
if err != nil {
relBeadsDir = beadsDir // Fallback to absolute path
}
addCmd := exec.CommandContext(ctx, "git", "-C", repoRoot, "add", relBeadsDir)
if output, err := addCmd.CombinedOutput(); err != nil {
return false, fmt.Errorf("git add failed: %w\n%s", err, output)
}
// Check if there are staged changes
diffCmd := exec.CommandContext(ctx, "git", "-C", repoRoot, "diff", "--cached", "--quiet")
if diffCmd.Run() == nil {
return false, nil // No changes to commit
}
// Commit
if message == "" {
message = fmt.Sprintf("bd sync: %s", time.Now().Format("2006-01-02 15:04:05"))
}
commitCmd := exec.CommandContext(ctx, "git", "-C", repoRoot, "commit", "-m", message)
if output, err := commitCmd.CombinedOutput(); err != nil {
return false, fmt.Errorf("git commit failed: %w\n%s", err, output)
}
// Push if requested
if push {
pushCmd := exec.CommandContext(ctx, "git", "-C", repoRoot, "push")
if output, err := pushCmd.CombinedOutput(); err != nil {
return true, fmt.Errorf("git push failed: %w\n%s", err, output)
}
}
return true, nil
}
// pullFromExternalBeadsRepo pulls changes in an external beads repo.
// Used when BEADS_DIR points to a different git repository than cwd.
// Contributed by dand-oss (https://github.com/steveyegge/beads/pull/533)
func pullFromExternalBeadsRepo(ctx context.Context, beadsDir string) error {
repoRoot, err := getRepoRootFromPath(ctx, beadsDir)
if err != nil {
return fmt.Errorf("failed to get repo root: %w", err)
}
// Check if remote exists
remoteCmd := exec.CommandContext(ctx, "git", "-C", repoRoot, "remote")
remoteOutput, err := remoteCmd.Output()
if err != nil || len(strings.TrimSpace(string(remoteOutput))) == 0 {
return nil // No remote, skip pull
}
pullCmd := exec.CommandContext(ctx, "git", "-C", repoRoot, "pull")
if output, err := pullCmd.CombinedOutput(); err != nil {
return fmt.Errorf("git pull failed: %w\n%s", err, output)
}
return nil
}