feat(sync): implement bd sync command specification

Changes the default bd sync behavior to export-only per the spec:
- bd sync: Export to JSONL (no staging/committing)
- bd sync --import: Import from JSONL (shorthand for --import-only)
- bd sync --status: Show sync state (mode, last export, pending changes, conflicts)
- bd sync --resolve: Resolve conflicts with strategies (newest/ours/theirs)
- bd sync --force: Force full export/import (skip incremental)
- bd sync --full: Full sync (legacy pull→merge→export→commit→push behavior)

The new --status output format shows:
- Sync mode: git-portable
- Last export: timestamp (commit hash)
- Pending changes: N issues modified since last export
- Import branch: name or none
- Conflicts: count or none

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
onyx
2026-01-17 01:54:50 -08:00
committed by gastown/crew/dennis
parent ca24c17af8
commit a7cd9136d8

View File

@@ -6,6 +6,8 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/gofrs/flock"
"github.com/spf13/cobra"
@@ -18,23 +20,21 @@ import (
var syncCmd = &cobra.Command{
Use: "sync",
GroupID: "sync",
Short: "Synchronize issues with git remote",
Long: `Synchronize issues with git remote:
1. Pull from remote (fetch + merge)
2. Merge local and remote issues (3-way merge with LWW)
3. Export merged state to JSONL
4. Commit changes to git
5. Push to remote
Short: "Export database to JSONL (sync with git)",
Long: `Export database to JSONL for git synchronization.
The 3-way merge algorithm prevents data loss during concurrent edits
by comparing base state with both local and remote changes.
By default, exports the current database state to JSONL.
Does NOT stage or commit - that's the user's job.
Use --no-pull to skip pulling (just export, commit, push).
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.`,
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
bd sync --force Force full export/import (skip incremental)
bd sync --full Full sync: pull → merge → export → commit → push (legacy)
The --full flag provides the legacy full sync behavior for backwards compatibility.`,
Run: func(cmd *cobra.Command, _ []string) {
CheckReadonly("sync")
ctx := rootCtx
@@ -46,6 +46,7 @@ Use --merge to merge the sync branch back to main branch.`,
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")
@@ -53,6 +54,16 @@ Use --merge to merge the sync branch back to main branch.`,
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")
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") {
@@ -94,9 +105,23 @@ Use --merge to merge the sync branch back to main branch.`,
FatalError("not in a bd workspace (no .beads directory found)")
}
// If status mode, show diff between sync branch and main
// If status mode, show sync state (new format per spec)
if status {
if err := showSyncStatus(ctx); err != nil {
if err := showSyncStateStatus(ctx, jsonlPath); err != nil {
FatalError("%v", err)
}
return
}
// If resolve mode, resolve conflicts
if resolve {
strategy := "newest" // default
if resolveOurs {
strategy = "ours"
} else if resolveTheirs {
strategy = "theirs"
}
if err := resolveSyncConflicts(ctx, jsonlPath, strategy, dryRun); err != nil {
FatalError("%v", err)
}
return
@@ -167,6 +192,19 @@ Use --merge to merge the sync branch back to main branch.`,
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")
@@ -577,6 +615,333 @@ func writeMergedStateToJSONL(path string, issues []*beads.Issue) error {
return os.Rename(tempPath, path)
}
// doExportSync exports the current database state to JSONL.
// This is the new default behavior for bd sync (per spec).
// Does NOT stage or commit - 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)
}
if dryRun {
fmt.Println("→ [DRY RUN] Would export database to JSONL")
return nil
}
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
result, err := exportToJSONLDeferred(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
fmt.Println("Sync mode: git-portable")
// 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")
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
func resolveSyncConflicts(ctx context.Context, jsonlPath string, strategy string, 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
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
}
resolved := 0
for _, conflict := range conflictState.Conflicts {
local := localMap[conflict.IssueID]
remote := remoteMap[conflict.IssueID]
var winner string
switch strategy {
case "ours":
winner = "local"
case "theirs":
winner = "remote"
case "newest":
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 == "newest" {
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)
// 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); 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
}
func init() {
syncCmd.Flags().StringP("message", "m", "", "Commit message (default: auto-generated)")
syncCmd.Flags().Bool("dry-run", false, "Preview sync without making changes")
@@ -586,13 +951,19 @@ func init() {
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("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("force", false, "Force full export/import (skip incremental optimization)")
rootCmd.AddCommand(syncCmd)
}