Files
beads/cmd/bd/sync.go
Steve Yegge ab61b0956b feat(dolt): auto-commit write commands and set explicit commit authors (#1270)
Adds Dolt auto-commit functionality for write commands and sets explicit commit authors.

Includes fix for race condition in commandDidWrite (converted to atomic.Bool).

Original PR: #1267 by @coffeegoddd
Co-authored-by: Dustin Brown <dustin@dolthub.com>
2026-01-22 20:52:20 -08:00

1283 lines
43 KiB
Go

package main
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/gofrs/flock"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/beads"
"github.com/steveyegge/beads/internal/config"
"github.com/steveyegge/beads/internal/debug"
"github.com/steveyegge/beads/internal/storage"
"github.com/steveyegge/beads/internal/syncbranch"
)
// SyncBranchContext holds sync-branch configuration detected from the store.
// This consolidates the repeated pattern of checking for sync-branch config.
type SyncBranchContext struct {
Branch string // Sync branch name, empty if not configured
RepoRoot string // Git repository root path
}
// IsConfigured returns true if a sync branch is configured.
func (s *SyncBranchContext) IsConfigured() bool {
return s.Branch != ""
}
// getSyncBranchContext detects sync-branch configuration from the store.
// Returns a context with empty Branch if not configured or on error.
func getSyncBranchContext(ctx context.Context) *SyncBranchContext {
sbc := &SyncBranchContext{}
if err := ensureStoreActive(); err != nil || store == nil {
return sbc
}
if sb, _ := syncbranch.Get(ctx, store); sb != "" {
sbc.Branch = sb
if rc, err := beads.GetRepoContext(); err == nil {
sbc.RepoRoot = rc.RepoRoot
}
}
return sbc
}
// commitAndPushBeads commits and pushes .beads changes using the appropriate method.
// When sync-branch is configured, uses worktree-based commit/push.
// Otherwise, uses standard git commit/push on the current branch.
func commitAndPushBeads(ctx context.Context, sbc *SyncBranchContext, jsonlPath string, noPush bool, message string) error {
if sbc.IsConfigured() {
fmt.Printf("→ Committing to sync branch '%s'...\n", sbc.Branch)
commitResult, err := syncbranch.CommitToSyncBranch(ctx, sbc.RepoRoot, sbc.Branch, jsonlPath, !noPush)
if err != nil {
return fmt.Errorf("committing to sync branch: %w", err)
}
if commitResult.Committed {
fmt.Printf(" Committed: %s\n", commitResult.Message)
if commitResult.Pushed {
fmt.Println(" Pushed to remote")
}
} else {
fmt.Println("→ No changes to commit")
}
return nil
}
// Standard git workflow
hasChanges, err := gitHasBeadsChanges(ctx)
if err != nil {
return fmt.Errorf("checking git status: %w", err)
}
if hasChanges {
fmt.Println("→ Committing changes...")
if err := gitCommitBeadsDir(ctx, message); err != nil {
return fmt.Errorf("committing: %w", err)
}
} else {
fmt.Println("→ No changes to commit")
}
// Push to remote
if !noPush && hasChanges {
fmt.Println("→ Pushing to remote...")
if err := gitPush(ctx, ""); err != nil {
return fmt.Errorf("pushing: %w", err)
}
}
return nil
}
var syncCmd = &cobra.Command{
Use: "sync",
GroupID: "sync",
Short: "Export database to JSONL (sync with git)",
Long: `Export database to JSONL for git synchronization.
By default, exports the current database state to JSONL.
Does NOT stage or commit - that's the user's job.
Commands:
bd sync Export to JSONL (prep for push)
bd sync --import Import from JSONL (after pull)
bd sync --status Show sync state
bd sync --resolve Resolve conflicts (uses configured strategy)
bd sync --force Force full export/import (skip incremental)
bd sync --full Full sync: pull → merge → export → commit → push (legacy)
Conflict Resolution:
bd sync --resolve Use configured conflict.strategy
bd sync --resolve --ours Keep local versions
bd sync --resolve --theirs Keep remote versions
bd sync --resolve --manual Interactive resolution with prompts
The --manual flag shows a diff for each conflict and prompts you to choose:
l/local - Keep local version
r/remote - Keep remote version
m/merge - Auto-merge (LWW for scalars, union for collections)
s/skip - Skip (keep local, conflict remains for later)
a/all - Accept auto-merge for all remaining conflicts
q/quit - Quit and skip all remaining conflicts
d/diff - Show full JSON diff
The --full flag provides the legacy full sync behavior for backwards compatibility.`,
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")
importFlag, _ := cmd.Flags().GetBool("import")
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")
checkIntegrity, _ := cmd.Flags().GetBool("check")
acceptRebase, _ := cmd.Flags().GetBool("accept-rebase")
fullSync, _ := cmd.Flags().GetBool("full")
resolve, _ := cmd.Flags().GetBool("resolve")
resolveOurs, _ := cmd.Flags().GetBool("ours")
resolveTheirs, _ := cmd.Flags().GetBool("theirs")
resolveManual, _ := cmd.Flags().GetBool("manual")
forceFlag, _ := cmd.Flags().GetBool("force")
// --import is shorthand for --import-only
if importFlag {
importOnly = true
}
// If --no-push not explicitly set, check no-push config
if !cmd.Flags().Changed("no-push") {
noPush = config.GetBool("no-push")
}
// 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.
//
// GH#984: Must use fallbackToDirectMode() instead of just closing daemon.
// When connected to daemon, PersistentPreRun skips store initialization.
// Just closing daemon leaves store=nil, causing "no database store available"
// errors in post-checkout hook's `bd sync --import-only`.
if daemonClient != nil {
debug.Logf("sync: forcing direct mode for consistency")
if err := fallbackToDirectMode("sync requires direct database access"); err != nil {
FatalError("failed to initialize direct mode: %v", err)
}
}
// Initialize local store after daemon disconnect.
// When daemon was connected, PersistentPreRun returns early without initializing
// the store global. Commands like --import-only need the store, so we must
// initialize it here after closing the daemon connection.
if err := ensureStoreActive(); err != nil {
FatalError("failed to initialize store: %v", err)
}
// Resolve noGitHistory based on fromMain (fixes #417)
noGitHistory = resolveNoGitHistoryForFromMain(fromMain, noGitHistory)
// Handle --set-mode flag
setMode, _ := cmd.Flags().GetString("set-mode")
if setMode != "" {
if err := SetSyncMode(ctx, store, setMode); err != nil {
FatalError("failed to set sync mode: %v", err)
}
fmt.Printf("✓ Sync mode set to: %s (%s)\n", setMode, SyncModeDescription(setMode))
return
}
// Find JSONL path
jsonlPath := findJSONLPath()
if jsonlPath == "" {
FatalError("not in a bd workspace (no .beads directory found)")
}
// If status mode, show sync state (new format per spec)
if status {
if err := showSyncStateStatus(ctx, jsonlPath); err != nil {
FatalError("%v", err)
}
return
}
// If resolve mode, resolve conflicts
if resolve {
strategy := config.GetConflictStrategy() // use configured default
if resolveOurs {
strategy = config.ConflictStrategyOurs
} else if resolveTheirs {
strategy = config.ConflictStrategyTheirs
} else if resolveManual {
strategy = config.ConflictStrategyManual
}
if err := resolveSyncConflicts(ctx, jsonlPath, strategy, dryRun); err != nil {
FatalError("%v", err)
}
return
}
// If check mode, run pre-sync integrity checks
if checkIntegrity {
showSyncIntegrityCheck(ctx, jsonlPath)
return
}
// If merge mode, merge sync branch to main
if merge {
if err := mergeSyncBranch(ctx, dryRun); err != nil {
FatalError("%v", err)
}
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 {
FatalError("%v", err)
}
return
}
// If import-only mode, just import and exit
// Use inline import to avoid subprocess path resolution issues with .beads/redirect (bd-ysal)
if importOnly {
if dryRun {
fmt.Println("→ [DRY RUN] Would import from JSONL")
} else {
fmt.Println("→ Importing from JSONL...")
if err := importFromJSONLInline(ctx, jsonlPath, renameOnImport, noGitHistory, false); err != nil {
FatalError("importing: %v", err)
}
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 {
FatalError("exporting: %v", err)
}
}
return
}
// If squash mode, export to JSONL but skip git operations
// 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 {
FatalError("exporting: %v", err)
}
fmt.Println("✓ Changes accumulated in JSONL")
fmt.Println(" Run 'bd sync' (without --squash) to commit all accumulated changes")
}
return
}
// DEFAULT BEHAVIOR: Export to JSONL only (per spec)
// Does NOT stage or commit - that's the user's job.
// Use --full for legacy full sync behavior (pull → merge → export → commit → push)
if !fullSync {
if err := doExportSync(ctx, jsonlPath, forceFlag, dryRun); err != nil {
FatalError("%v", err)
}
return
}
// FULL SYNC MODE (--full flag): Legacy behavior
// Pull → Merge → Export → Commit → Push
// Check if we're in a git repository
if !isGitRepo() {
FatalErrorWithHint("not in a git repository", "run 'git init' to initialize a repository")
}
// Preflight: check for merge/rebase in progress
if inMerge, err := gitHasUnmergedPaths(); err != nil {
FatalError("checking git state: %v", err)
} else if inMerge {
FatalErrorWithHint("unmerged paths or merge in progress", "resolve conflicts, run 'bd import' if needed, then 'bd sync' again")
}
// GH#885: Preflight check for uncommitted JSONL changes
// This detects when a previous sync exported but failed before commit,
// leaving the JSONL in an inconsistent state across worktrees.
if hasUncommitted, err := gitHasUncommittedBeadsChanges(ctx); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to check for uncommitted changes: %v\n", err)
} else if hasUncommitted {
fmt.Println("→ Detected uncommitted JSONL changes (possible incomplete sync)")
fmt.Println("→ Re-exporting from database to reconcile state...")
// Force a fresh export to ensure JSONL matches current DB state
if err := exportToJSONL(ctx, jsonlPath); err != nil {
FatalError("re-exporting to reconcile state: %v", err)
}
fmt.Println("✓ State reconciled")
}
// GH#638: Check sync.branch BEFORE upstream check
// When sync.branch is configured, we should use worktree-based sync even if
// the current branch has no upstream (e.g., detached HEAD in jj, git worktrees)
sbc := getSyncBranchContext(ctx)
// GH#1166: Block sync if currently on the sync branch
// This must happen BEFORE worktree operations - after entering a worktree,
// GetCurrentBranch() would return the worktree's branch, not the original.
if sbc.IsConfigured() {
if syncbranch.IsSyncBranchSameAsCurrent(ctx, sbc.Branch) {
FatalError("Cannot sync to '%s': it's your current branch. "+
"Checkout a different branch first, or use a dedicated sync branch like 'beads-sync'.",
sbc.Branch)
}
}
// bd-wayc3: Check for redirect + sync-branch incompatibility
// Redirect and sync-branch are mutually exclusive:
// - Redirect says: "My database is in another repo (I am a client)"
// - Sync-branch says: "I own my database and sync it myself via worktree"
// When redirect is active, the sync-branch worktree operations fail because
// the beads files are in a different git repo than the current working directory.
redirectInfo := beads.GetRedirectInfo()
if redirectInfo.IsRedirected {
if sbc.IsConfigured() {
fmt.Printf("⚠️ Redirect active (-> %s), skipping sync-branch operations\n", redirectInfo.TargetDir)
fmt.Println(" Hint: Redirected clones should not have sync-branch configured")
fmt.Println(" The owner of the target .beads directory handles sync-branch")
} else {
fmt.Printf("→ Redirect active (-> %s)\n", redirectInfo.TargetDir)
}
// For redirected clones, just do import/export - skip all git operations
// The target repo's owner (e.g., mayor) handles git commit/push via sync-branch
if dryRun {
fmt.Println("→ [DRY RUN] Would export to JSONL (redirected clone, git operations skipped)")
fmt.Println("✓ Dry run complete (no changes made)")
} else {
fmt.Println("→ Exporting to JSONL (redirected clone, skipping git operations)...")
if err := exportToJSONL(ctx, jsonlPath); err != nil {
FatalError("exporting: %v", err)
}
fmt.Println("✓ Export complete (target repo owner handles git sync)")
}
return
}
// Preflight: check for upstream tracking
// If no upstream, automatically switch to --from-main mode (gt-ick9: ephemeral branch support)
// GH#638: Skip this fallback if sync.branch is explicitly configured
if !noPull && !gitHasUpstream() && !sbc.IsConfigured() {
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 {
FatalError("%v", err)
}
return
}
// If no remote at all, gitPull/gitPush will gracefully skip
}
// Pull-first sync: Pull → Merge → Export → Commit → Push
// This eliminates the export-before-pull data loss pattern (#911) by
// seeing remote changes before exporting local state.
if err := doPullFirstSync(ctx, jsonlPath, renameOnImport, noGitHistory, dryRun, noPush, noPull, message, acceptRebase, sbc); err != nil {
FatalError("%v", err)
}
},
}
// doPullFirstSync implements the pull-first sync flow:
// Pull → Merge → Export → Commit → Push
//
// This eliminates the export-before-pull data loss pattern (#911) by
// seeing remote changes before exporting local state.
//
// The 3-way merge uses:
// - Base state: Last successful sync (.beads/sync_base.jsonl)
// - Local state: Current database contents
// - Remote state: JSONL after git pull
//
// When noPull is true, skips the pull/merge steps and just does:
// Export → Commit → Push
func doPullFirstSync(ctx context.Context, jsonlPath string, renameOnImport, noGitHistory, dryRun, noPush, noPull bool, message string, acceptRebase bool, sbc *SyncBranchContext) error {
beadsDir := filepath.Dir(jsonlPath)
_ = acceptRebase // Reserved for future sync branch force-push detection
if dryRun {
if noPull {
fmt.Println("→ [DRY RUN] Would export pending changes to JSONL")
fmt.Println("→ [DRY RUN] Would commit changes")
if !noPush {
fmt.Println("→ [DRY RUN] Would push to remote")
}
} else {
fmt.Println("→ [DRY RUN] Would pull from remote")
fmt.Println("→ [DRY RUN] Would load base state from sync_base.jsonl")
fmt.Println("→ [DRY RUN] Would merge base, local, and remote issues (3-way)")
fmt.Println("→ [DRY RUN] Would export merged state to JSONL")
fmt.Println("→ [DRY RUN] Would update sync_base.jsonl")
fmt.Println("→ [DRY RUN] Would commit and push changes")
}
fmt.Println("\n✓ Dry run complete (no changes made)")
return nil
}
// If noPull, use simplified export-only flow
if noPull {
return doExportOnlySync(ctx, jsonlPath, noPush, message)
}
// Step 1: Load local state from DB BEFORE pulling
// This captures the current DB state before remote changes arrive
if err := ensureStoreActive(); err != nil {
return fmt.Errorf("activating store: %w", err)
}
localIssues, err := store.SearchIssues(ctx, "", beads.IssueFilter{IncludeTombstones: true})
if err != nil {
return fmt.Errorf("loading local issues: %w", err)
}
fmt.Printf("→ Loaded %d local issues from database\n", len(localIssues))
// Acquire exclusive lock to prevent concurrent sync corruption
lockPath := filepath.Join(beadsDir, ".sync.lock")
lock := flock.New(lockPath)
locked, err := lock.TryLock()
if err != nil {
return fmt.Errorf("acquiring sync lock: %w", err)
}
if !locked {
return fmt.Errorf("another sync is in progress")
}
defer func() { _ = lock.Unlock() }()
// Step 2: Load base state (last successful sync)
fmt.Println("→ Loading base state...")
baseIssues, err := loadBaseState(beadsDir)
if err != nil {
return fmt.Errorf("loading base state: %w", err)
}
if baseIssues == nil {
fmt.Println(" No base state found (first sync)")
} else {
fmt.Printf(" Loaded %d issues from base state\n", len(baseIssues))
}
// Step 3: Pull from remote
// Mode-specific pull behavior:
// - dolt-native/belt-and-suspenders with Dolt remote: Pull from Dolt
// - sync.branch configured: Pull from sync branch via worktree
// - Default (git-portable): Normal git pull
syncMode := GetSyncMode(ctx, store)
shouldUseDolt := ShouldUseDoltRemote(ctx, store)
if shouldUseDolt {
// Try Dolt pull for dolt-native and belt-and-suspenders modes
rs, ok := storage.AsRemote(store)
if ok {
fmt.Println("→ Pulling from Dolt remote...")
if err := rs.Pull(ctx); err != nil {
// Don't fail if no remote configured
if strings.Contains(err.Error(), "remote") {
fmt.Println("⚠ No Dolt remote configured, skipping Dolt pull")
} else {
return fmt.Errorf("dolt pull failed: %w", err)
}
} else {
fmt.Println("✓ Pulled from Dolt remote")
}
} else if syncMode == SyncModeDoltNative {
return fmt.Errorf("dolt-native sync mode requires Dolt backend")
}
// For belt-and-suspenders, continue with git pull even if Dolt pull failed
}
// Git-based pull (for git-portable, belt-and-suspenders, or when Dolt not available)
if ShouldExportJSONL(ctx, store) {
if sbc.IsConfigured() {
fmt.Printf("→ Pulling from sync branch '%s'...\n", sbc.Branch)
pullResult, err := syncbranch.PullFromSyncBranch(ctx, sbc.RepoRoot, sbc.Branch, jsonlPath, false)
if err != nil {
return fmt.Errorf("pulling from sync branch: %w", err)
}
// Display any safety warnings from the pull
for _, warning := range pullResult.SafetyWarnings {
fmt.Fprintln(os.Stderr, warning)
}
if pullResult.Merged {
fmt.Println(" Merged divergent sync branch histories")
} else if pullResult.FastForwarded {
fmt.Println(" Fast-forwarded to remote")
}
} else {
fmt.Println("→ Pulling from remote...")
if err := gitPull(ctx, ""); err != nil {
return fmt.Errorf("pulling: %w", err)
}
}
}
// For dolt-native mode, we're done after pulling from Dolt remote
// Dolt handles merging internally, no JSONL workflow needed
if syncMode == SyncModeDoltNative {
fmt.Println("\n✓ Sync complete (dolt-native mode)")
return nil
}
// Step 4: Load remote state from JSONL (after pull)
remoteIssues, err := loadIssuesFromJSONL(jsonlPath)
if err != nil {
return fmt.Errorf("loading remote issues from JSONL: %w", err)
}
fmt.Printf(" Loaded %d remote issues from JSONL\n", len(remoteIssues))
// Step 5: Perform 3-way merge
fmt.Println("→ Merging base, local, and remote issues (3-way)...")
mergeResult := MergeIssues(baseIssues, localIssues, remoteIssues)
// Report merge results
localCount, remoteCount, sameCount := 0, 0, 0
for _, strategy := range mergeResult.Strategy {
switch strategy {
case StrategyLocal:
localCount++
case StrategyRemote:
remoteCount++
case StrategySame:
sameCount++
}
}
fmt.Printf(" Merged: %d issues total\n", len(mergeResult.Merged))
fmt.Printf(" Local wins: %d, Remote wins: %d, Same: %d, Conflicts (LWW): %d\n",
localCount, remoteCount, sameCount, mergeResult.Conflicts)
// Display manual conflicts that need user resolution
if len(mergeResult.ManualConflicts) > 0 {
displayManualConflicts(mergeResult.ManualConflicts)
}
// Step 6: Import merged state to DB
// First, write merged result to JSONL so import can read it
fmt.Println("→ Writing merged state to JSONL...")
if err := writeMergedStateToJSONL(jsonlPath, mergeResult.Merged); err != nil {
return fmt.Errorf("writing merged state: %w", err)
}
fmt.Println("→ Importing merged state to database...")
if err := importFromJSONL(ctx, jsonlPath, renameOnImport, noGitHistory); err != nil {
return fmt.Errorf("importing merged state: %w", err)
}
// Step 7: Export from DB to JSONL (ensures DB is source of truth)
fmt.Println("→ Exporting from database to JSONL...")
if err := exportToJSONL(ctx, jsonlPath); err != nil {
return fmt.Errorf("exporting: %w", err)
}
// Step 8 & 9: Commit and push changes
if err := commitAndPushBeads(ctx, sbc, jsonlPath, noPush, message); err != nil {
return err
}
// Step 10: Update base state for next sync (after successful push)
// Base state only updates after confirmed push to ensure consistency
fmt.Println("→ Updating base state...")
// Reload from exported JSONL to capture any normalization from import/export cycle
finalIssues, err := loadIssuesFromJSONL(jsonlPath)
if err != nil {
return fmt.Errorf("reloading final state: %w", err)
}
if err := saveBaseState(beadsDir, finalIssues); err != nil {
return fmt.Errorf("saving base state: %w", err)
}
fmt.Printf(" Saved %d issues to base state\n", len(finalIssues))
// Step 11: Clear sync state on successful sync
if bd := beads.FindBeadsDir(); bd != "" {
_ = ClearSyncState(bd)
}
fmt.Println("\n✓ Sync complete")
return nil
}
// doExportOnlySync handles the --no-pull case: just export, commit, and push
func doExportOnlySync(ctx context.Context, jsonlPath string, noPush bool, message string) error {
beadsDir := filepath.Dir(jsonlPath)
// Acquire exclusive lock to prevent concurrent sync corruption
lockPath := filepath.Join(beadsDir, ".sync.lock")
lock := flock.New(lockPath)
locked, err := lock.TryLock()
if err != nil {
return fmt.Errorf("acquiring sync lock: %w", err)
}
if !locked {
return fmt.Errorf("another sync is in progress")
}
defer func() { _ = lock.Unlock() }()
// Pre-export integrity checks
if err := ensureStoreActive(); err == nil && store != nil {
if err := validatePreExport(ctx, store, jsonlPath); err != nil {
return fmt.Errorf("pre-export validation failed: %w", err)
}
if err := checkDuplicateIDs(ctx, store); err != nil {
return fmt.Errorf("database corruption detected: %w", err)
}
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)
}
}
// Template validation before export
if err := validateOpenIssuesForSync(ctx); err != nil {
return err
}
// GH#1173: Detect sync-branch configuration and use appropriate commit method
sbc := getSyncBranchContext(ctx)
fmt.Println("→ Exporting pending changes to JSONL...")
if err := exportToJSONL(ctx, jsonlPath); err != nil {
return fmt.Errorf("exporting: %w", err)
}
// Commit and push using the appropriate method (sync-branch worktree or regular git)
if err := commitAndPushBeads(ctx, sbc, jsonlPath, noPush, message); err != nil {
return err
}
// Clear sync state on successful sync
if bd := beads.FindBeadsDir(); bd != "" {
_ = ClearSyncState(bd)
}
fmt.Println("\n✓ Sync complete")
return nil
}
// writeMergedStateToJSONL writes merged issues to JSONL file
func writeMergedStateToJSONL(path string, issues []*beads.Issue) error {
tempPath := path + ".tmp"
file, err := os.Create(tempPath) //nolint:gosec // path is trusted internal beads path
if err != nil {
return err
}
encoder := json.NewEncoder(file)
encoder.SetEscapeHTML(false)
for _, issue := range issues {
if err := encoder.Encode(issue); err != nil {
_ = file.Close() // Best-effort cleanup
_ = os.Remove(tempPath)
return err
}
}
if err := file.Close(); err != nil {
_ = os.Remove(tempPath) // Best-effort cleanup
return err
}
return os.Rename(tempPath, path)
}
// doExportSync exports the current database state based on sync mode.
// - git-portable, realtime: Export to JSONL
// - dolt-native: Commit and push to Dolt remote (skip JSONL)
// - belt-and-suspenders: Both JSONL export and Dolt push
// Does NOT stage or commit to git - that's the user's job.
func doExportSync(ctx context.Context, jsonlPath string, force, dryRun bool) error {
if err := ensureStoreActive(); err != nil {
return fmt.Errorf("failed to initialize store: %w", err)
}
syncMode := GetSyncMode(ctx, store)
shouldExportJSONL := ShouldExportJSONL(ctx, store)
shouldUseDolt := ShouldUseDoltRemote(ctx, store)
if dryRun {
if shouldExportJSONL {
fmt.Println("→ [DRY RUN] Would export database to JSONL")
}
if shouldUseDolt {
fmt.Println("→ [DRY RUN] Would commit and push to Dolt remote")
}
return nil
}
// Handle Dolt remote operations for dolt-native and belt-and-suspenders modes
if shouldUseDolt {
rs, ok := storage.AsRemote(store)
if !ok {
if syncMode == SyncModeDoltNative {
return fmt.Errorf("dolt-native sync mode requires Dolt backend (current backend doesn't support remote operations)")
}
// belt-and-suspenders: warn but continue with JSONL
fmt.Println("⚠ Dolt remote not available, falling back to JSONL-only")
} else {
fmt.Println("→ Committing to Dolt...")
// We are explicitly creating a Dolt commit inside sync; avoid redundant auto-commit in PersistentPostRun.
commandDidExplicitDoltCommit = true
if err := rs.Commit(ctx, "bd sync: auto-commit"); err != nil {
// Ignore "nothing to commit" errors
if !strings.Contains(err.Error(), "nothing to commit") {
return fmt.Errorf("dolt commit failed: %w", err)
}
}
fmt.Println("→ Pushing to Dolt remote...")
if err := rs.Push(ctx); err != nil {
// Don't fail if no remote configured
if !strings.Contains(err.Error(), "remote") {
return fmt.Errorf("dolt push failed: %w", err)
}
fmt.Println("⚠ No Dolt remote configured, skipping push")
} else {
fmt.Println("✓ Pushed to Dolt remote")
}
}
}
// Export to JSONL for git-portable, realtime, and belt-and-suspenders modes
if shouldExportJSONL {
fmt.Println("Exporting beads to JSONL...")
// Get count of dirty (changed) issues for incremental tracking
var changedCount int
if !force {
dirtyIDs, err := store.GetDirtyIssues(ctx)
if err != nil {
debug.Logf("warning: failed to get dirty issues: %v", err)
} else {
changedCount = len(dirtyIDs)
}
}
// Export to JSONL (uses incremental export for large repos)
result, err := exportToJSONLIncrementalDeferred(ctx, jsonlPath)
if err != nil {
return fmt.Errorf("exporting: %w", err)
}
// Finalize export (update metadata)
finalizeExport(ctx, result)
// Report results
totalCount := 0
if result != nil {
totalCount = len(result.ExportedIDs)
}
if changedCount > 0 && !force {
fmt.Printf("✓ Exported %d issues (%d changed since last sync)\n", totalCount, changedCount)
} else {
fmt.Printf("✓ Exported %d issues\n", totalCount)
}
fmt.Printf("✓ %s updated\n", jsonlPath)
}
return nil
}
// showSyncStateStatus shows the current sync state per the spec.
// Output format:
//
// Sync mode: git-portable
// Last export: 2026-01-16 10:30:00 (commit abc123)
// Pending changes: 3 issues modified since last export
// Import branch: none
// Conflicts: none
func showSyncStateStatus(ctx context.Context, jsonlPath string) error {
if err := ensureStoreActive(); err != nil {
return fmt.Errorf("failed to initialize store: %w", err)
}
beadsDir := filepath.Dir(jsonlPath)
// Sync mode (from config)
syncCfg := config.GetSyncConfig()
fmt.Printf("Sync mode: %s (%s)\n", syncCfg.Mode, SyncModeDescription(string(syncCfg.Mode)))
fmt.Printf(" Export on: %s, Import on: %s\n", syncCfg.ExportOn, syncCfg.ImportOn)
// Conflict strategy
conflictCfg := config.GetConflictConfig()
fmt.Printf("Conflict strategy: %s\n", conflictCfg.Strategy)
// Federation config (if set)
fedCfg := config.GetFederationConfig()
if fedCfg.Remote != "" {
fmt.Printf("Federation remote: %s\n", fedCfg.Remote)
if fedCfg.Sovereignty != "" {
fmt.Printf(" Sovereignty: %s\n", fedCfg.Sovereignty)
}
}
// Last export time
lastExport, err := store.GetMetadata(ctx, "last_import_time")
if err != nil || lastExport == "" {
fmt.Println("Last export: never")
} else {
// Try to parse and format nicely
t, err := time.Parse(time.RFC3339Nano, lastExport)
if err != nil {
fmt.Printf("Last export: %s\n", lastExport)
} else {
// Try to get the last commit hash for the JSONL file
commitHash := getLastJSONLCommitHash(ctx, jsonlPath)
if commitHash != "" {
fmt.Printf("Last export: %s (commit %s)\n", t.Format("2006-01-02 15:04:05"), commitHash[:7])
} else {
fmt.Printf("Last export: %s\n", t.Format("2006-01-02 15:04:05"))
}
}
}
// Pending changes (dirty issues)
dirtyIDs, err := store.GetDirtyIssues(ctx)
if err != nil {
fmt.Println("Pending changes: unknown (error getting dirty issues)")
} else if len(dirtyIDs) == 0 {
fmt.Println("Pending changes: none")
} else {
fmt.Printf("Pending changes: %d issues modified since last export\n", len(dirtyIDs))
}
// Import branch (sync branch status)
syncBranch, _ := syncbranch.Get(ctx, store)
if syncBranch == "" {
fmt.Println("Import branch: none")
} else {
fmt.Printf("Import branch: %s\n", syncBranch)
}
// Conflicts - check for sync conflict state file
syncConflictPath := filepath.Join(beadsDir, "sync_conflicts.json")
if _, err := os.Stat(syncConflictPath); err == nil {
conflictState, err := LoadSyncConflictState(beadsDir)
if err != nil {
fmt.Println("Conflicts: unknown (error reading sync state)")
} else if len(conflictState.Conflicts) > 0 {
fmt.Printf("Conflicts: %d unresolved\n", len(conflictState.Conflicts))
for _, c := range conflictState.Conflicts {
fmt.Printf(" - %s: %s\n", c.IssueID, c.Reason)
}
} else {
fmt.Println("Conflicts: none")
}
} else {
fmt.Println("Conflicts: none")
}
return nil
}
// getLastJSONLCommitHash returns the short commit hash of the last commit
// that touched the JSONL file, or empty string if unknown.
func getLastJSONLCommitHash(ctx context.Context, jsonlPath string) string {
rc, err := beads.GetRepoContext()
if err != nil {
return ""
}
cmd := rc.GitCmd(ctx, "log", "-1", "--format=%h", "--", jsonlPath)
output, err := cmd.Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(output))
}
// SyncConflictState tracks pending sync conflicts.
type SyncConflictState struct {
Conflicts []SyncConflictRecord `json:"conflicts,omitempty"`
}
// SyncConflictRecord represents a conflict detected during sync.
type SyncConflictRecord struct {
IssueID string `json:"issue_id"`
Reason string `json:"reason"`
LocalVersion string `json:"local_version,omitempty"`
RemoteVersion string `json:"remote_version,omitempty"`
Strategy string `json:"strategy,omitempty"` // how it was resolved
}
// LoadSyncConflictState loads the sync conflict state from disk.
func LoadSyncConflictState(beadsDir string) (*SyncConflictState, error) {
path := filepath.Join(beadsDir, "sync_conflicts.json")
// #nosec G304 -- path is derived from the workspace .beads directory
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return &SyncConflictState{}, nil
}
return nil, err
}
var state SyncConflictState
if err := json.Unmarshal(data, &state); err != nil {
return nil, err
}
return &state, nil
}
// SaveSyncConflictState saves the sync conflict state to disk.
func SaveSyncConflictState(beadsDir string, state *SyncConflictState) error {
path := filepath.Join(beadsDir, "sync_conflicts.json")
data, err := json.MarshalIndent(state, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0600)
}
// ClearSyncConflictState removes the sync conflict state file.
func ClearSyncConflictState(beadsDir string) error {
path := filepath.Join(beadsDir, "sync_conflicts.json")
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
// resolveSyncConflicts resolves pending sync conflicts using the specified strategy.
// Strategies:
// - "newest": Keep whichever version has the newer updated_at timestamp (default)
// - "ours": Keep local version
// - "theirs": Keep remote version
// - "manual": Interactive resolution with user prompts
func resolveSyncConflicts(ctx context.Context, jsonlPath string, strategy config.ConflictStrategy, dryRun bool) error {
beadsDir := filepath.Dir(jsonlPath)
conflictState, err := LoadSyncConflictState(beadsDir)
if err != nil {
return fmt.Errorf("loading sync conflicts: %w", err)
}
if len(conflictState.Conflicts) == 0 {
fmt.Println("No conflicts to resolve")
return nil
}
if dryRun {
fmt.Printf("→ [DRY RUN] Would resolve %d conflicts using '%s' strategy\n", len(conflictState.Conflicts), strategy)
for _, c := range conflictState.Conflicts {
fmt.Printf(" - %s: %s\n", c.IssueID, c.Reason)
}
return nil
}
fmt.Printf("Resolving conflicts using '%s' strategy...\n", strategy)
// Load base, local, and remote states for merge
baseIssues, err := loadBaseState(beadsDir)
if err != nil {
return fmt.Errorf("loading base state: %w", err)
}
if err := ensureStoreActive(); err != nil {
return fmt.Errorf("initializing store: %w", err)
}
localIssues, err := store.SearchIssues(ctx, "", beads.IssueFilter{IncludeTombstones: true})
if err != nil {
return fmt.Errorf("loading local issues: %w", err)
}
remoteIssues, err := loadIssuesFromJSONL(jsonlPath)
if err != nil {
return fmt.Errorf("loading remote issues: %w", err)
}
// Build maps for quick lookup
baseMap := make(map[string]*beads.Issue)
for _, issue := range baseIssues {
baseMap[issue.ID] = issue
}
localMap := make(map[string]*beads.Issue)
for _, issue := range localIssues {
localMap[issue.ID] = issue
}
remoteMap := make(map[string]*beads.Issue)
for _, issue := range remoteIssues {
remoteMap[issue.ID] = issue
}
// Handle manual strategy with interactive resolution
if strategy == config.ConflictStrategyManual {
return resolveSyncConflictsManually(ctx, jsonlPath, beadsDir, conflictState, baseMap, localMap, remoteMap)
}
resolved := 0
for _, conflict := range conflictState.Conflicts {
local := localMap[conflict.IssueID]
remote := remoteMap[conflict.IssueID]
var winner string
switch strategy {
case config.ConflictStrategyOurs:
winner = "local"
case config.ConflictStrategyTheirs:
winner = "remote"
case config.ConflictStrategyNewest:
fallthrough
default:
// Compare updated_at timestamps
if local != nil && remote != nil {
if local.UpdatedAt.After(remote.UpdatedAt) {
winner = "local"
} else {
winner = "remote"
}
} else if local != nil {
winner = "local"
} else {
winner = "remote"
}
}
fmt.Printf("✓ %s: kept %s", conflict.IssueID, winner)
if strategy == config.ConflictStrategyNewest {
fmt.Print(" (newer)")
}
fmt.Println()
resolved++
}
// Clear conflicts after resolution
if err := ClearSyncConflictState(beadsDir); err != nil {
return fmt.Errorf("clearing conflict state: %w", err)
}
// Re-run merge with the resolved conflicts
mergeResult := MergeIssues(baseIssues, localIssues, remoteIssues)
// Display any remaining manual conflicts
if len(mergeResult.ManualConflicts) > 0 {
displayManualConflicts(mergeResult.ManualConflicts)
}
// Write merged state
if err := writeMergedStateToJSONL(jsonlPath, mergeResult.Merged); err != nil {
return fmt.Errorf("writing merged state: %w", err)
}
// Import to database
if err := importFromJSONLInline(ctx, jsonlPath, false, false, false); err != nil {
return fmt.Errorf("importing merged state: %w", err)
}
// Export to ensure consistency
if err := exportToJSONL(ctx, jsonlPath); err != nil {
return fmt.Errorf("exporting: %w", err)
}
// Update base state
finalIssues, err := loadIssuesFromJSONL(jsonlPath)
if err != nil {
return fmt.Errorf("reloading final state: %w", err)
}
if err := saveBaseState(beadsDir, finalIssues); err != nil {
return fmt.Errorf("saving base state: %w", err)
}
fmt.Printf("✓ Merge complete (%d conflicts resolved)\n", resolved)
return nil
}
// resolveSyncConflictsManually handles manual conflict resolution with interactive prompts.
func resolveSyncConflictsManually(ctx context.Context, jsonlPath, beadsDir string, conflictState *SyncConflictState,
baseMap, localMap, remoteMap map[string]*beads.Issue) error {
// Build interactive conflicts list
var interactiveConflicts []InteractiveConflict
for _, c := range conflictState.Conflicts {
interactiveConflicts = append(interactiveConflicts, InteractiveConflict{
IssueID: c.IssueID,
Local: localMap[c.IssueID],
Remote: remoteMap[c.IssueID],
Base: baseMap[c.IssueID],
})
}
// Run interactive resolution
resolvedIssues, skipped, err := resolveConflictsInteractively(interactiveConflicts)
if err != nil {
return fmt.Errorf("interactive resolution: %w", err)
}
if skipped > 0 {
fmt.Printf("\n⚠ %d conflict(s) skipped - will remain unresolved\n", skipped)
}
if len(resolvedIssues) == 0 && skipped == len(conflictState.Conflicts) {
fmt.Println("No conflicts were resolved")
return nil
}
// Build the merged issue list:
// 1. Start with issues that weren't in conflict
// 2. Add the resolved issues
conflictIDSet := make(map[string]bool)
for _, c := range conflictState.Conflicts {
conflictIDSet[c.IssueID] = true
}
// Build resolved issue map for quick lookup
resolvedMap := make(map[string]*beads.Issue)
for _, issue := range resolvedIssues {
if issue != nil {
resolvedMap[issue.ID] = issue
}
}
// Collect all unique IDs from base, local, remote
allIDSet := make(map[string]bool)
for id := range baseMap {
allIDSet[id] = true
}
for id := range localMap {
allIDSet[id] = true
}
for id := range remoteMap {
allIDSet[id] = true
}
// Build final merged list
var mergedIssues []*beads.Issue
for id := range allIDSet {
if conflictIDSet[id] {
// This was a conflict
if resolved, ok := resolvedMap[id]; ok {
// User resolved this conflict - use their choice
mergedIssues = append(mergedIssues, resolved)
} else {
// Skipped - keep local version in output, conflict remains for later
if local := localMap[id]; local != nil {
mergedIssues = append(mergedIssues, local)
}
}
} else {
// Not a conflict - use standard 3-way merge logic
local := localMap[id]
remote := remoteMap[id]
base := baseMap[id]
merged, _, _ := MergeIssue(base, local, remote)
if merged != nil {
mergedIssues = append(mergedIssues, merged)
}
}
}
// Clear resolved conflicts (keep skipped ones)
if skipped == 0 {
if err := ClearSyncConflictState(beadsDir); err != nil {
return fmt.Errorf("clearing conflict state: %w", err)
}
} else {
// Update conflict state to only keep skipped conflicts
var remaining []SyncConflictRecord
for _, c := range conflictState.Conflicts {
if _, resolved := resolvedMap[c.IssueID]; !resolved {
remaining = append(remaining, c)
}
}
conflictState.Conflicts = remaining
if err := SaveSyncConflictState(beadsDir, conflictState); err != nil {
return fmt.Errorf("saving updated conflict state: %w", err)
}
}
// Write merged state
if err := writeMergedStateToJSONL(jsonlPath, mergedIssues); err != nil {
return fmt.Errorf("writing merged state: %w", err)
}
// Import to database
if err := importFromJSONLInline(ctx, jsonlPath, false, false, false); err != nil {
return fmt.Errorf("importing merged state: %w", err)
}
// Export to ensure consistency
if err := exportToJSONL(ctx, jsonlPath); err != nil {
return fmt.Errorf("exporting: %w", err)
}
// Update base state
finalIssues, err := loadIssuesFromJSONL(jsonlPath)
if err != nil {
return fmt.Errorf("reloading final state: %w", err)
}
if err := saveBaseState(beadsDir, finalIssues); err != nil {
return fmt.Errorf("saving base state: %w", err)
}
resolvedCount := len(resolvedIssues)
fmt.Printf("\n✓ Manual resolution complete (%d resolved, %d skipped)\n", resolvedCount, skipped)
return nil
}
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("import", false, "Import from JSONL (shorthand for --import-only)")
syncCmd.Flags().Bool("status", false, "Show sync state (pending changes, last export, conflicts)")
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")
syncCmd.Flags().Bool("check", false, "Pre-sync integrity check: detect forced pushes, prefix mismatches, and orphaned issues")
syncCmd.Flags().Bool("accept-rebase", false, "Accept remote sync branch history (use when force-push detected)")
syncCmd.Flags().Bool("full", false, "Full sync: pull → merge → export → commit → push (legacy behavior)")
syncCmd.Flags().Bool("resolve", false, "Resolve pending sync conflicts")
syncCmd.Flags().Bool("ours", false, "Use 'ours' strategy for conflict resolution (with --resolve)")
syncCmd.Flags().Bool("theirs", false, "Use 'theirs' strategy for conflict resolution (with --resolve)")
syncCmd.Flags().Bool("manual", false, "Use interactive manual resolution for conflicts (with --resolve)")
syncCmd.Flags().Bool("force", false, "Force full export/import (skip incremental optimization)")
syncCmd.Flags().String("set-mode", "", "Set sync mode (git-portable, realtime, dolt-native, belt-and-suspenders)")
rootCmd.AddCommand(syncCmd)
}
// Git helper functions moved to sync_git.go
// doSyncFromMain function moved to sync_import.go
// Export function moved to sync_export.go
// Sync branch functions moved to sync_branch.go
// Import functions moved to sync_import.go
// External beads dir functions moved to sync_branch.go
// Integrity check types and functions moved to sync_check.go