When sync.branch is configured, the main branch's .beads/ directory was showing as modified after every sync, even though the data was correctly synced to the sync branch worktree. This happened because: 1. Export writes to main's .beads/ 2. Files are copied to worktree and committed there 3. But main's .beads/ now differs from what's committed on main Fix: After sync completes, restore .beads/ from HEAD to keep the working directory clean. The actual beads data lives on the sync branch; the main branch's .beads/ is just a snapshot that should match what's committed.
1639 lines
58 KiB
Go
1639 lines
58 KiB
Go
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/configfile"
|
||
"github.com/steveyegge/beads/internal/debug"
|
||
"github.com/steveyegge/beads/internal/deletions"
|
||
"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) {
|
||
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 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) {
|
||
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)
|
||
pullResult, err := syncbranch.PullFromSyncBranch(ctx, repoRoot, syncBranchName, jsonlPath)
|
||
if err != nil {
|
||
fmt.Fprintf(os.Stderr, "Error pulling from sync branch: %v\n", err)
|
||
os.Exit(1)
|
||
}
|
||
if pullResult.Pulled {
|
||
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 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
|
||
fmt.Println("→ Importing updated JSONL...")
|
||
if err := importFromJSONL(ctx, jsonlPath, renameOnImport, noGitHistory); 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 already pushed via sync branch worktree)
|
||
if !noPush && hasChanges && !pushedViaSyncBranch {
|
||
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)
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
// gitHasBeadsChanges checks if any tracked files in .beads/ have uncommitted changes
|
||
func gitHasBeadsChanges(ctx context.Context) (bool, error) {
|
||
beadsDir := findBeadsDir()
|
||
if beadsDir == "" {
|
||
return false, fmt.Errorf("no .beads directory found")
|
||
}
|
||
cmd := exec.CommandContext(ctx, "git", "status", "--porcelain", beadsDir)
|
||
output, err := cmd.Output()
|
||
if err != nil {
|
||
return false, fmt.Errorf("git status failed: %w", err)
|
||
}
|
||
return len(strings.TrimSpace(string(output))) > 0, nil
|
||
}
|
||
|
||
// gitCommit commits the specified file
|
||
func gitCommit(ctx context.Context, filePath string, message string) error {
|
||
// Stage the file
|
||
addCmd := exec.CommandContext(ctx, "git", "add", filePath)
|
||
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
|
||
commitCmd := exec.CommandContext(ctx, "git", "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)
|
||
func gitCommitBeadsDir(ctx context.Context, message string) error {
|
||
beadsDir := findBeadsDir()
|
||
if beadsDir == "" {
|
||
return fmt.Errorf("no .beads directory found")
|
||
}
|
||
|
||
// 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 {
|
||
filesToAdd = append(filesToAdd, f)
|
||
}
|
||
}
|
||
|
||
if len(filesToAdd) == 0 {
|
||
return fmt.Errorf("no sync files found to commit")
|
||
}
|
||
|
||
// Stage only the sync files
|
||
args := append([]string{"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.
|
||
commitCmd := exec.CommandContext(ctx, "git", "commit", "-m", message, "--", beadsDir)
|
||
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 {
|
||
// Check for rebase-merge directory (interactive rebase)
|
||
if _, err := os.Stat(".git/rebase-merge"); err == nil {
|
||
return true
|
||
}
|
||
// Check for rebase-apply directory (non-interactive rebase)
|
||
if _, err := os.Stat(".git/rebase-apply"); 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
|
||
func importFromJSONL(ctx context.Context, jsonlPath string, renameOnImport bool, noGitHistory ...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)
|
||
}
|
||
|
||
// 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")
|
||
}
|
||
// Handle optional noGitHistory parameter
|
||
if len(noGitHistory) > 0 && noGitHistory[0] {
|
||
args = append(args, "--no-git-history")
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// sanitizeJSONLWithDeletions removes any issues from the JSONL file that are
|
||
// in the deletions manifest. This prevents zombie resurrection when git's
|
||
// 3-way merge re-adds deleted issues to the JSONL during pull.
|
||
//
|
||
// This should be called after git pull but before import.
|
||
func sanitizeJSONLWithDeletions(jsonlPath string) (*SanitizeResult, error) {
|
||
result := &SanitizeResult{
|
||
RemovedIDs: []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
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// Quick extraction of ID without full unmarshal
|
||
// Look for "id":"..." pattern
|
||
var issue struct {
|
||
ID string `json:"id"`
|
||
}
|
||
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 {
|
||
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
|
||
}
|