Split sync.go into modular files (bd-u2sc.3)
Split the 2203-line sync.go into 5 focused modules: - sync.go (1238 lines) - Main command and git helpers - sync_export.go (170 lines) - Export operations - sync_import.go (132 lines) - Import operations + doSyncFromMain - sync_branch.go (285 lines) - Sync branch and external beads support - sync_check.go (395 lines) - Integrity check operations All tests pass. Remaining files (init.go, show.go, compact.go) still need splitting - tracked for future work. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
977
cmd/bd/sync.go
977
cmd/bd/sync.go
@@ -2,15 +2,11 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
|
||||||
"cmp"
|
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -19,9 +15,7 @@ import (
|
|||||||
"github.com/steveyegge/beads/internal/config"
|
"github.com/steveyegge/beads/internal/config"
|
||||||
"github.com/steveyegge/beads/internal/debug"
|
"github.com/steveyegge/beads/internal/debug"
|
||||||
"github.com/steveyegge/beads/internal/git"
|
"github.com/steveyegge/beads/internal/git"
|
||||||
"github.com/steveyegge/beads/internal/rpc"
|
|
||||||
"github.com/steveyegge/beads/internal/syncbranch"
|
"github.com/steveyegge/beads/internal/syncbranch"
|
||||||
"github.com/steveyegge/beads/internal/types"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var syncCmd = &cobra.Command{
|
var syncCmd = &cobra.Command{
|
||||||
@@ -1189,968 +1183,9 @@ func getDefaultBranchForRemote(ctx context.Context, remote string) string {
|
|||||||
return "main"
|
return "main"
|
||||||
}
|
}
|
||||||
|
|
||||||
// doSyncFromMain performs a one-way sync from the default branch (main/master)
|
// doSyncFromMain function moved to sync_import.go
|
||||||
// Used for ephemeral branches without upstream tracking (gt-ick9)
|
// Export function moved to sync_export.go
|
||||||
// This fetches beads from main and imports them, discarding local beads changes.
|
// Sync branch functions moved to sync_branch.go
|
||||||
// If sync.remote is configured (e.g., "upstream" for fork workflows), uses that remote
|
// Import functions moved to sync_import.go
|
||||||
// instead of "origin" (bd-bx9).
|
// External beads dir functions moved to sync_branch.go
|
||||||
func doSyncFromMain(ctx context.Context, jsonlPath string, renameOnImport bool, dryRun bool, noGitHistory bool) error {
|
// Integrity check types and functions moved to sync_check.go
|
||||||
// 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 including tombstones for sync propagation (bd-rp4o fix)
|
|
||||||
// Tombstones must be exported so they propagate to other clones and prevent resurrection
|
|
||||||
issues, err := store.SearchIssues(ctx, "", types.IssueFilter{IncludeTombstones: true})
|
|
||||||
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
|
|
||||||
slices.SortFunc(issues, func(a, b *types.Issue) int {
|
|
||||||
return cmp.Compare(a.ID, b.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 (error checked implicitly by Rename success)
|
|
||||||
_ = 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 (excluding .beads/ which is expected to be dirty)
|
|
||||||
// bd-7b7h fix: The sync.branch workflow copies JSONL to main working dir without committing,
|
|
||||||
// so .beads/ changes are expected and should not block merge.
|
|
||||||
statusCmd := exec.CommandContext(ctx, "git", "status", "--porcelain", "--", ":!.beads/")
|
|
||||||
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 outside .beads/, please commit or stash them first")
|
|
||||||
}
|
|
||||||
|
|
||||||
// bd-7b7h fix: Restore .beads/ to HEAD state before merge
|
|
||||||
// The uncommitted .beads/ changes came from copyJSONLToMainRepo during bd sync,
|
|
||||||
// which copied them FROM the sync branch. They're redundant with what we're merging.
|
|
||||||
// Discarding them prevents "Your local changes would be overwritten by merge" errors.
|
|
||||||
restoreCmd := exec.CommandContext(ctx, "git", "checkout", "HEAD", "--", ".beads/")
|
|
||||||
if output, err := restoreCmd.CombinedOutput(); err != nil {
|
|
||||||
// Not fatal - .beads/ might not exist in HEAD yet
|
|
||||||
debug.Logf("note: could not restore .beads/ to HEAD: %v (%s)", err, output)
|
|
||||||
}
|
|
||||||
|
|
||||||
if dryRun {
|
|
||||||
fmt.Printf("[DRY RUN] Would merge branch '%s' into '%s'\n", syncBranch, currentBranch)
|
|
||||||
|
|
||||||
// Show what would be merged
|
|
||||||
logCmd := exec.CommandContext(ctx, "git", "log", "--oneline", currentBranch+".."+syncBranch)
|
|
||||||
logOutput, err := logCmd.CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to preview commits: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(strings.TrimSpace(string(logOutput))) > 0 {
|
|
||||||
fmt.Println("\nCommits that would be merged:")
|
|
||||||
fmt.Print(string(logOutput))
|
|
||||||
} else {
|
|
||||||
fmt.Println("\nNo commits to merge (already up to date)")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Perform the merge
|
|
||||||
fmt.Printf("Merging branch '%s' into '%s'...\n", syncBranch, currentBranch)
|
|
||||||
|
|
||||||
mergeCmd := exec.CommandContext(ctx, "git", "merge", "--no-ff", syncBranch, "-m",
|
|
||||||
fmt.Sprintf("Merge %s into %s", syncBranch, currentBranch))
|
|
||||||
mergeOutput, err := mergeCmd.CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
// Check if it's a merge conflict
|
|
||||||
if strings.Contains(string(mergeOutput), "CONFLICT") || strings.Contains(string(mergeOutput), "conflict") {
|
|
||||||
fmt.Fprintf(os.Stderr, "Merge conflict detected:\n%s\n", mergeOutput)
|
|
||||||
fmt.Fprintf(os.Stderr, "\nTo resolve:\n")
|
|
||||||
fmt.Fprintf(os.Stderr, "1. Resolve conflicts in the affected files\n")
|
|
||||||
fmt.Fprintf(os.Stderr, "2. Stage resolved files: git add <files>\n")
|
|
||||||
fmt.Fprintf(os.Stderr, "3. Complete merge: git commit\n")
|
|
||||||
fmt.Fprintf(os.Stderr, "4. After merge commit, run 'bd import' to sync database\n")
|
|
||||||
return fmt.Errorf("merge conflict - see above for resolution steps")
|
|
||||||
}
|
|
||||||
return fmt.Errorf("merge failed: %w\n%s", err, mergeOutput)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Print(string(mergeOutput))
|
|
||||||
fmt.Println("\n✓ Merge complete")
|
|
||||||
|
|
||||||
// Suggest next steps
|
|
||||||
fmt.Println("\nNext steps:")
|
|
||||||
fmt.Println("1. Review the merged changes")
|
|
||||||
fmt.Println("2. Run 'bd sync --import-only' to sync the database with merged JSONL")
|
|
||||||
fmt.Println("3. Run 'bd sync' to push changes to remote")
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// importFromJSONL imports the JSONL file by running the import command
|
|
||||||
// Optional parameters: noGitHistory, protectLeftSnapshot (bd-sync-deletion fix)
|
|
||||||
func importFromJSONL(ctx context.Context, jsonlPath string, renameOnImport bool, opts ...bool) error {
|
|
||||||
// Get current executable path to avoid "./bd" path issues
|
|
||||||
exe, err := os.Executable()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("cannot resolve current executable: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse optional parameters
|
|
||||||
noGitHistory := false
|
|
||||||
protectLeftSnapshot := false
|
|
||||||
if len(opts) > 0 {
|
|
||||||
noGitHistory = opts[0]
|
|
||||||
}
|
|
||||||
if len(opts) > 1 {
|
|
||||||
protectLeftSnapshot = opts[1]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build args for import command
|
|
||||||
// Use --no-daemon to ensure subprocess uses direct mode, avoiding daemon connection issues
|
|
||||||
args := []string{"--no-daemon", "import", "-i", jsonlPath}
|
|
||||||
if renameOnImport {
|
|
||||||
args = append(args, "--rename-on-import")
|
|
||||||
}
|
|
||||||
if noGitHistory {
|
|
||||||
args = append(args, "--no-git-history")
|
|
||||||
}
|
|
||||||
// Add --protect-left-snapshot flag for post-pull imports (bd-sync-deletion fix)
|
|
||||||
if protectLeftSnapshot {
|
|
||||||
args = append(args, "--protect-left-snapshot")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run import command
|
|
||||||
cmd := exec.CommandContext(ctx, exe, args...) // #nosec G204 - bd import command from trusted binary
|
|
||||||
output, err := cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("import failed: %w\n%s", err, output)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show output (import command provides the summary)
|
|
||||||
if len(output) > 0 {
|
|
||||||
fmt.Print(string(output))
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolveNoGitHistoryForFromMain returns the resolved noGitHistory value for sync operations.
|
|
||||||
// When syncing from main (--from-main), noGitHistory is forced to true to prevent creating
|
|
||||||
// incorrect deletion records for locally-created beads that don't exist on main.
|
|
||||||
// See: https://github.com/steveyegge/beads/issues/417
|
|
||||||
func resolveNoGitHistoryForFromMain(fromMain, noGitHistory bool) bool {
|
|
||||||
if fromMain {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return noGitHistory
|
|
||||||
}
|
|
||||||
|
|
||||||
// isExternalBeadsDir checks if the beads directory is in a different git repo than cwd.
|
|
||||||
// This is used to detect when BEADS_DIR points to a separate repository.
|
|
||||||
// Contributed by dand-oss (https://github.com/steveyegge/beads/pull/533)
|
|
||||||
func isExternalBeadsDir(ctx context.Context, beadsDir string) bool {
|
|
||||||
// Get repo root of cwd
|
|
||||||
cwdRepoRoot, err := syncbranch.GetRepoRoot(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return false // Can't determine, assume local
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get repo root of beads dir
|
|
||||||
beadsRepoRoot, err := getRepoRootFromPath(ctx, beadsDir)
|
|
||||||
if err != nil {
|
|
||||||
return false // Can't determine, assume local
|
|
||||||
}
|
|
||||||
|
|
||||||
return cwdRepoRoot != beadsRepoRoot
|
|
||||||
}
|
|
||||||
|
|
||||||
// getRepoRootFromPath returns the git repository root for a given path.
|
|
||||||
// Unlike syncbranch.GetRepoRoot which uses cwd, this allows getting the repo root
|
|
||||||
// for any path.
|
|
||||||
// Contributed by dand-oss (https://github.com/steveyegge/beads/pull/533)
|
|
||||||
func getRepoRootFromPath(ctx context.Context, path string) (string, error) {
|
|
||||||
cmd := exec.CommandContext(ctx, "git", "-C", path, "rev-parse", "--show-toplevel")
|
|
||||||
output, err := cmd.Output()
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to get git root for %s: %w", path, err)
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(string(output)), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// commitToExternalBeadsRepo commits changes directly to an external beads repo.
|
|
||||||
// Used when BEADS_DIR points to a different git repository than cwd.
|
|
||||||
// This bypasses the worktree-based sync which fails when beads dir is external.
|
|
||||||
// Contributed by dand-oss (https://github.com/steveyegge/beads/pull/533)
|
|
||||||
func commitToExternalBeadsRepo(ctx context.Context, beadsDir, message string, push bool) (bool, error) {
|
|
||||||
repoRoot, err := getRepoRootFromPath(ctx, beadsDir)
|
|
||||||
if err != nil {
|
|
||||||
return false, fmt.Errorf("failed to get repo root: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stage beads files (use relative path from repo root)
|
|
||||||
relBeadsDir, err := filepath.Rel(repoRoot, beadsDir)
|
|
||||||
if err != nil {
|
|
||||||
relBeadsDir = beadsDir // Fallback to absolute path
|
|
||||||
}
|
|
||||||
|
|
||||||
addCmd := exec.CommandContext(ctx, "git", "-C", repoRoot, "add", relBeadsDir)
|
|
||||||
if output, err := addCmd.CombinedOutput(); err != nil {
|
|
||||||
return false, fmt.Errorf("git add failed: %w\n%s", err, output)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if there are staged changes
|
|
||||||
diffCmd := exec.CommandContext(ctx, "git", "-C", repoRoot, "diff", "--cached", "--quiet")
|
|
||||||
if diffCmd.Run() == nil {
|
|
||||||
return false, nil // No changes to commit
|
|
||||||
}
|
|
||||||
|
|
||||||
// Commit with config-based author and signing options
|
|
||||||
if message == "" {
|
|
||||||
message = fmt.Sprintf("bd sync: %s", time.Now().Format("2006-01-02 15:04:05"))
|
|
||||||
}
|
|
||||||
commitArgs := buildGitCommitArgs(repoRoot, message)
|
|
||||||
commitCmd := exec.CommandContext(ctx, "git", commitArgs...)
|
|
||||||
if output, err := commitCmd.CombinedOutput(); err != nil {
|
|
||||||
return false, fmt.Errorf("git commit failed: %w\n%s", err, output)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Push if requested
|
|
||||||
if push {
|
|
||||||
pushCmd := exec.CommandContext(ctx, "git", "-C", repoRoot, "push")
|
|
||||||
if pushOutput, err := runGitCmdWithTimeoutMsg(ctx, pushCmd, "git push", 5*time.Second); err != nil {
|
|
||||||
return true, fmt.Errorf("git push failed: %w\n%s", err, pushOutput)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// pullFromExternalBeadsRepo pulls changes in an external beads repo.
|
|
||||||
// Used when BEADS_DIR points to a different git repository than cwd.
|
|
||||||
// Contributed by dand-oss (https://github.com/steveyegge/beads/pull/533)
|
|
||||||
func pullFromExternalBeadsRepo(ctx context.Context, beadsDir string) error {
|
|
||||||
repoRoot, err := getRepoRootFromPath(ctx, beadsDir)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to get repo root: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if remote exists
|
|
||||||
remoteCmd := exec.CommandContext(ctx, "git", "-C", repoRoot, "remote")
|
|
||||||
remoteOutput, err := remoteCmd.Output()
|
|
||||||
if err != nil || len(strings.TrimSpace(string(remoteOutput))) == 0 {
|
|
||||||
return nil // No remote, skip pull
|
|
||||||
}
|
|
||||||
|
|
||||||
pullCmd := exec.CommandContext(ctx, "git", "-C", repoRoot, "pull")
|
|
||||||
if output, err := pullCmd.CombinedOutput(); err != nil {
|
|
||||||
return fmt.Errorf("git pull failed: %w\n%s", err, output)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SyncIntegrityResult contains the results of a pre-sync integrity check.
|
|
||||||
// bd-hlsw.1: Pre-sync integrity check
|
|
||||||
type SyncIntegrityResult struct {
|
|
||||||
ForcedPush *ForcedPushCheck `json:"forced_push,omitempty"`
|
|
||||||
PrefixMismatch *PrefixMismatch `json:"prefix_mismatch,omitempty"`
|
|
||||||
OrphanedChildren *OrphanedChildren `json:"orphaned_children,omitempty"`
|
|
||||||
HasProblems bool `json:"has_problems"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ForcedPushCheck detects if sync branch has diverged from remote.
|
|
||||||
type ForcedPushCheck struct {
|
|
||||||
Detected bool `json:"detected"`
|
|
||||||
LocalRef string `json:"local_ref,omitempty"`
|
|
||||||
RemoteRef string `json:"remote_ref,omitempty"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// PrefixMismatch detects issues with wrong prefix in JSONL.
|
|
||||||
type PrefixMismatch struct {
|
|
||||||
ConfiguredPrefix string `json:"configured_prefix"`
|
|
||||||
MismatchedIDs []string `json:"mismatched_ids,omitempty"`
|
|
||||||
Count int `json:"count"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// OrphanedChildren detects issues with parent that doesn't exist.
|
|
||||||
type OrphanedChildren struct {
|
|
||||||
OrphanedIDs []string `json:"orphaned_ids,omitempty"`
|
|
||||||
Count int `json:"count"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// showSyncIntegrityCheck performs pre-sync integrity checks without modifying state.
|
|
||||||
// bd-hlsw.1: Detects forced pushes, prefix mismatches, and orphaned children.
|
|
||||||
// Exits with code 1 if problems are detected.
|
|
||||||
func showSyncIntegrityCheck(ctx context.Context, jsonlPath string) {
|
|
||||||
fmt.Println("Sync Integrity Check")
|
|
||||||
fmt.Println("====================")
|
|
||||||
|
|
||||||
result := &SyncIntegrityResult{}
|
|
||||||
|
|
||||||
// Check 1: Detect forced pushes on sync branch
|
|
||||||
forcedPush := checkForcedPush(ctx)
|
|
||||||
result.ForcedPush = forcedPush
|
|
||||||
if forcedPush.Detected {
|
|
||||||
result.HasProblems = true
|
|
||||||
}
|
|
||||||
printForcedPushResult(forcedPush)
|
|
||||||
|
|
||||||
// Check 2: Detect prefix mismatches in JSONL
|
|
||||||
prefixMismatch, err := checkPrefixMismatch(ctx, jsonlPath)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Warning: prefix check failed: %v\n", err)
|
|
||||||
} else {
|
|
||||||
result.PrefixMismatch = prefixMismatch
|
|
||||||
if prefixMismatch != nil && prefixMismatch.Count > 0 {
|
|
||||||
result.HasProblems = true
|
|
||||||
}
|
|
||||||
printPrefixMismatchResult(prefixMismatch)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check 3: Detect orphaned children (parent issues that don't exist)
|
|
||||||
orphaned, err := checkOrphanedChildrenInJSONL(jsonlPath)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Warning: orphaned check failed: %v\n", err)
|
|
||||||
} else {
|
|
||||||
result.OrphanedChildren = orphaned
|
|
||||||
if orphaned != nil && orphaned.Count > 0 {
|
|
||||||
result.HasProblems = true
|
|
||||||
}
|
|
||||||
printOrphanedChildrenResult(orphaned)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Summary
|
|
||||||
fmt.Println("\nSummary")
|
|
||||||
fmt.Println("-------")
|
|
||||||
if result.HasProblems {
|
|
||||||
fmt.Println("Problems detected! Review above and consider:")
|
|
||||||
if result.ForcedPush != nil && result.ForcedPush.Detected {
|
|
||||||
fmt.Println(" - Force push: Reset local sync branch or use 'bd sync --from-main'")
|
|
||||||
}
|
|
||||||
if result.PrefixMismatch != nil && result.PrefixMismatch.Count > 0 {
|
|
||||||
fmt.Println(" - Prefix mismatch: Use 'bd import --rename-on-import' to fix")
|
|
||||||
}
|
|
||||||
if result.OrphanedChildren != nil && result.OrphanedChildren.Count > 0 {
|
|
||||||
fmt.Println(" - Orphaned children: Remove parent references or create missing parents")
|
|
||||||
}
|
|
||||||
os.Exit(1)
|
|
||||||
} else {
|
|
||||||
fmt.Println("No problems detected. Safe to sync.")
|
|
||||||
}
|
|
||||||
|
|
||||||
if jsonOutput {
|
|
||||||
data, _ := json.MarshalIndent(result, "", " ")
|
|
||||||
fmt.Println(string(data))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkForcedPush detects if the sync branch has diverged from remote.
|
|
||||||
// This can happen when someone force-pushes to the sync branch.
|
|
||||||
func checkForcedPush(ctx context.Context) *ForcedPushCheck {
|
|
||||||
result := &ForcedPushCheck{
|
|
||||||
Detected: false,
|
|
||||||
Message: "No sync branch configured or no remote",
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get sync branch name
|
|
||||||
if err := ensureStoreActive(); err != nil {
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
syncBranch, _ := syncbranch.Get(ctx, store)
|
|
||||||
if syncBranch == "" {
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if sync branch exists locally
|
|
||||||
checkLocalCmd := exec.CommandContext(ctx, "git", "show-ref", "--verify", "--quiet", "refs/heads/"+syncBranch)
|
|
||||||
if checkLocalCmd.Run() != nil {
|
|
||||||
result.Message = fmt.Sprintf("Sync branch '%s' does not exist locally", syncBranch)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get local ref
|
|
||||||
localRefCmd := exec.CommandContext(ctx, "git", "rev-parse", syncBranch)
|
|
||||||
localRefOutput, err := localRefCmd.Output()
|
|
||||||
if err != nil {
|
|
||||||
result.Message = "Failed to get local sync branch ref"
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
localRef := strings.TrimSpace(string(localRefOutput))
|
|
||||||
result.LocalRef = localRef
|
|
||||||
|
|
||||||
// Check if remote tracking branch exists
|
|
||||||
remote := "origin"
|
|
||||||
if configuredRemote, err := store.GetConfig(ctx, "sync.remote"); err == nil && configuredRemote != "" {
|
|
||||||
remote = configuredRemote
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get remote ref
|
|
||||||
remoteRefCmd := exec.CommandContext(ctx, "git", "rev-parse", remote+"/"+syncBranch)
|
|
||||||
remoteRefOutput, err := remoteRefCmd.Output()
|
|
||||||
if err != nil {
|
|
||||||
result.Message = fmt.Sprintf("Remote tracking branch '%s/%s' does not exist", remote, syncBranch)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
remoteRef := strings.TrimSpace(string(remoteRefOutput))
|
|
||||||
result.RemoteRef = remoteRef
|
|
||||||
|
|
||||||
// If refs match, no divergence
|
|
||||||
if localRef == remoteRef {
|
|
||||||
result.Message = "Sync branch is in sync with remote"
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if local is ahead of remote (normal case)
|
|
||||||
aheadCmd := exec.CommandContext(ctx, "git", "merge-base", "--is-ancestor", remoteRef, localRef)
|
|
||||||
if aheadCmd.Run() == nil {
|
|
||||||
result.Message = "Local sync branch is ahead of remote (normal)"
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if remote is ahead of local (behind, needs pull)
|
|
||||||
behindCmd := exec.CommandContext(ctx, "git", "merge-base", "--is-ancestor", localRef, remoteRef)
|
|
||||||
if behindCmd.Run() == nil {
|
|
||||||
result.Message = "Local sync branch is behind remote (needs pull)"
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// If neither is ancestor, branches have diverged - likely a force push
|
|
||||||
result.Detected = true
|
|
||||||
result.Message = fmt.Sprintf("Sync branch has DIVERGED from remote! Local: %s, Remote: %s. This may indicate a force push on the remote.", localRef[:8], remoteRef[:8])
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func printForcedPushResult(fp *ForcedPushCheck) {
|
|
||||||
fmt.Println("1. Force Push Detection")
|
|
||||||
if fp.Detected {
|
|
||||||
fmt.Printf(" [PROBLEM] %s\n", fp.Message)
|
|
||||||
} else {
|
|
||||||
fmt.Printf(" [OK] %s\n", fp.Message)
|
|
||||||
}
|
|
||||||
fmt.Println()
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkPrefixMismatch detects issues in JSONL that don't match the configured prefix.
|
|
||||||
func checkPrefixMismatch(ctx context.Context, jsonlPath string) (*PrefixMismatch, error) {
|
|
||||||
result := &PrefixMismatch{
|
|
||||||
MismatchedIDs: []string{},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get configured prefix
|
|
||||||
if err := ensureStoreActive(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
prefix, err := store.GetConfig(ctx, "issue_prefix")
|
|
||||||
if err != nil || prefix == "" {
|
|
||||||
prefix = "bd" // Default
|
|
||||||
}
|
|
||||||
result.ConfiguredPrefix = prefix
|
|
||||||
|
|
||||||
// Read JSONL and check each issue's prefix
|
|
||||||
f, err := os.Open(jsonlPath) // #nosec G304 - controlled path
|
|
||||||
if err != nil {
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return result, nil // No JSONL, no mismatches
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("failed to open JSONL: %w", err)
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
|
|
||||||
scanner := bufio.NewScanner(f)
|
|
||||||
scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024)
|
|
||||||
|
|
||||||
for scanner.Scan() {
|
|
||||||
line := scanner.Bytes()
|
|
||||||
if len(bytes.TrimSpace(line)) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
var issue struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(line, &issue); err != nil {
|
|
||||||
continue // Skip malformed lines
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if ID starts with configured prefix
|
|
||||||
if !strings.HasPrefix(issue.ID, prefix+"-") {
|
|
||||||
result.MismatchedIDs = append(result.MismatchedIDs, issue.ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := scanner.Err(); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read JSONL: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
result.Count = len(result.MismatchedIDs)
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func printPrefixMismatchResult(pm *PrefixMismatch) {
|
|
||||||
fmt.Println("2. Prefix Mismatch Check")
|
|
||||||
if pm == nil {
|
|
||||||
fmt.Println(" [SKIP] Could not check prefix")
|
|
||||||
fmt.Println()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf(" Configured prefix: %s\n", pm.ConfiguredPrefix)
|
|
||||||
if pm.Count > 0 {
|
|
||||||
fmt.Printf(" [PROBLEM] Found %d issue(s) with wrong prefix:\n", pm.Count)
|
|
||||||
// Show first 10
|
|
||||||
limit := pm.Count
|
|
||||||
if limit > 10 {
|
|
||||||
limit = 10
|
|
||||||
}
|
|
||||||
for i := 0; i < limit; i++ {
|
|
||||||
fmt.Printf(" - %s\n", pm.MismatchedIDs[i])
|
|
||||||
}
|
|
||||||
if pm.Count > 10 {
|
|
||||||
fmt.Printf(" ... and %d more\n", pm.Count-10)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
fmt.Println(" [OK] All issues have correct prefix")
|
|
||||||
}
|
|
||||||
fmt.Println()
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkOrphanedChildrenInJSONL detects issues with parent references to non-existent issues.
|
|
||||||
func checkOrphanedChildrenInJSONL(jsonlPath string) (*OrphanedChildren, error) {
|
|
||||||
result := &OrphanedChildren{
|
|
||||||
OrphanedIDs: []string{},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read JSONL and build maps of IDs and parent references
|
|
||||||
f, err := os.Open(jsonlPath) // #nosec G304 - controlled path
|
|
||||||
if err != nil {
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("failed to open JSONL: %w", err)
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
|
|
||||||
existingIDs := make(map[string]bool)
|
|
||||||
parentRefs := make(map[string]string) // child ID -> parent ID
|
|
||||||
|
|
||||||
scanner := bufio.NewScanner(f)
|
|
||||||
scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024)
|
|
||||||
|
|
||||||
for scanner.Scan() {
|
|
||||||
line := scanner.Bytes()
|
|
||||||
if len(bytes.TrimSpace(line)) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
var issue struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Parent string `json:"parent,omitempty"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(line, &issue); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip tombstones
|
|
||||||
if issue.Status == string(types.StatusTombstone) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
existingIDs[issue.ID] = true
|
|
||||||
if issue.Parent != "" {
|
|
||||||
parentRefs[issue.ID] = issue.Parent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := scanner.Err(); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read JSONL: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find orphaned children (parent doesn't exist)
|
|
||||||
for childID, parentID := range parentRefs {
|
|
||||||
if !existingIDs[parentID] {
|
|
||||||
result.OrphanedIDs = append(result.OrphanedIDs, fmt.Sprintf("%s (parent: %s)", childID, parentID))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result.Count = len(result.OrphanedIDs)
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// runGitCmdWithTimeoutMsg runs a git command and prints a helpful message if it takes too long.
|
|
||||||
// This helps when git operations hang waiting for credential/browser auth.
|
|
||||||
func runGitCmdWithTimeoutMsg(ctx context.Context, cmd *exec.Cmd, cmdName string, timeoutDelay time.Duration) ([]byte, error) {
|
|
||||||
// Use done channel to cleanly exit goroutine when command completes
|
|
||||||
done := make(chan struct{})
|
|
||||||
go func() {
|
|
||||||
select {
|
|
||||||
case <-time.After(timeoutDelay):
|
|
||||||
fmt.Fprintf(os.Stderr, "⏳ %s is taking longer than expected (possibly waiting for authentication). If this hangs, check for a browser auth prompt or run 'git status' in another terminal.\n", cmdName)
|
|
||||||
case <-done:
|
|
||||||
// Command completed, exit cleanly
|
|
||||||
case <-ctx.Done():
|
|
||||||
// Context canceled, don't print message
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
output, err := cmd.CombinedOutput()
|
|
||||||
close(done)
|
|
||||||
return output, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func printOrphanedChildrenResult(oc *OrphanedChildren) {
|
|
||||||
fmt.Println("3. Orphaned Children Check")
|
|
||||||
if oc == nil {
|
|
||||||
fmt.Println(" [SKIP] Could not check orphaned children")
|
|
||||||
fmt.Println()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if oc.Count > 0 {
|
|
||||||
fmt.Printf(" [PROBLEM] Found %d issue(s) with missing parent:\n", oc.Count)
|
|
||||||
limit := oc.Count
|
|
||||||
if limit > 10 {
|
|
||||||
limit = 10
|
|
||||||
}
|
|
||||||
for i := 0; i < limit; i++ {
|
|
||||||
fmt.Printf(" - %s\n", oc.OrphanedIDs[i])
|
|
||||||
}
|
|
||||||
if oc.Count > 10 {
|
|
||||||
fmt.Printf(" ... and %d more\n", oc.Count-10)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
fmt.Println(" [OK] No orphaned children found")
|
|
||||||
}
|
|
||||||
fmt.Println()
|
|
||||||
}
|
|
||||||
|
|||||||
285
cmd/bd/sync_branch.go
Normal file
285
cmd/bd/sync_branch.go
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/steveyegge/beads/internal/syncbranch"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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 the main branch
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if there are uncommitted changes
|
||||||
|
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("uncommitted changes detected - commit or stash them first")
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Merging sync branch '%s' into '%s'...\n", syncBranch, currentBranch)
|
||||||
|
|
||||||
|
if dryRun {
|
||||||
|
fmt.Println("→ [DRY RUN] Would merge sync branch")
|
||||||
|
// Show what would be merged
|
||||||
|
logCmd := exec.CommandContext(ctx, "git", "log", "--oneline", currentBranch+".."+syncBranch)
|
||||||
|
logOutput, _ := logCmd.CombinedOutput()
|
||||||
|
if len(strings.TrimSpace(string(logOutput))) > 0 {
|
||||||
|
fmt.Println("\nCommits that would be merged:")
|
||||||
|
fmt.Print(string(logOutput))
|
||||||
|
} else {
|
||||||
|
fmt.Println("No commits to merge")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform the merge
|
||||||
|
mergeCmd := exec.CommandContext(ctx, "git", "merge", syncBranch, "-m", fmt.Sprintf("Merge sync branch '%s'", syncBranch))
|
||||||
|
mergeOutput, err := mergeCmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// isExternalBeadsDir checks if the beads directory is in a different git repo than cwd.
|
||||||
|
// This is used to detect when BEADS_DIR points to a separate repository.
|
||||||
|
// Contributed by dand-oss (https://github.com/steveyegge/beads/pull/533)
|
||||||
|
func isExternalBeadsDir(ctx context.Context, beadsDir string) bool {
|
||||||
|
// Get repo root of cwd
|
||||||
|
cwdRepoRoot, err := syncbranch.GetRepoRoot(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return false // Can't determine, assume local
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get repo root of beads dir
|
||||||
|
beadsRepoRoot, err := getRepoRootFromPath(ctx, beadsDir)
|
||||||
|
if err != nil {
|
||||||
|
return false // Can't determine, assume local
|
||||||
|
}
|
||||||
|
|
||||||
|
return cwdRepoRoot != beadsRepoRoot
|
||||||
|
}
|
||||||
|
|
||||||
|
// getRepoRootFromPath returns the git repository root for a given path.
|
||||||
|
// Unlike syncbranch.GetRepoRoot which uses cwd, this allows getting the repo root
|
||||||
|
// for any path.
|
||||||
|
// Contributed by dand-oss (https://github.com/steveyegge/beads/pull/533)
|
||||||
|
func getRepoRootFromPath(ctx context.Context, path string) (string, error) {
|
||||||
|
cmd := exec.CommandContext(ctx, "git", "-C", path, "rev-parse", "--show-toplevel")
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to get git root for %s: %w", path, err)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(output)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// commitToExternalBeadsRepo commits changes directly to an external beads repo.
|
||||||
|
// Used when BEADS_DIR points to a different git repository than cwd.
|
||||||
|
// This bypasses the worktree-based sync which fails when beads dir is external.
|
||||||
|
// Contributed by dand-oss (https://github.com/steveyegge/beads/pull/533)
|
||||||
|
func commitToExternalBeadsRepo(ctx context.Context, beadsDir, message string, push bool) (bool, error) {
|
||||||
|
repoRoot, err := getRepoRootFromPath(ctx, beadsDir)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("failed to get repo root: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stage beads files (use relative path from repo root)
|
||||||
|
relBeadsDir, err := filepath.Rel(repoRoot, beadsDir)
|
||||||
|
if err != nil {
|
||||||
|
relBeadsDir = beadsDir // Fallback to absolute path
|
||||||
|
}
|
||||||
|
|
||||||
|
addCmd := exec.CommandContext(ctx, "git", "-C", repoRoot, "add", relBeadsDir)
|
||||||
|
if output, err := addCmd.CombinedOutput(); err != nil {
|
||||||
|
return false, fmt.Errorf("git add failed: %w\n%s", err, output)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if there are staged changes
|
||||||
|
diffCmd := exec.CommandContext(ctx, "git", "-C", repoRoot, "diff", "--cached", "--quiet")
|
||||||
|
if diffCmd.Run() == nil {
|
||||||
|
return false, nil // No changes to commit
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commit with config-based author and signing options
|
||||||
|
if message == "" {
|
||||||
|
message = fmt.Sprintf("bd sync: %s", time.Now().Format("2006-01-02 15:04:05"))
|
||||||
|
}
|
||||||
|
commitArgs := buildGitCommitArgs(repoRoot, message)
|
||||||
|
commitCmd := exec.CommandContext(ctx, "git", commitArgs...)
|
||||||
|
if output, err := commitCmd.CombinedOutput(); err != nil {
|
||||||
|
return false, fmt.Errorf("git commit failed: %w\n%s", err, output)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push if requested
|
||||||
|
if push {
|
||||||
|
pushCmd := exec.CommandContext(ctx, "git", "-C", repoRoot, "push")
|
||||||
|
if pushOutput, err := runGitCmdWithTimeoutMsg(ctx, pushCmd, "git push", 5*time.Second); err != nil {
|
||||||
|
return true, fmt.Errorf("git push failed: %w\n%s", err, pushOutput)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pullFromExternalBeadsRepo pulls changes in an external beads repo.
|
||||||
|
// Used when BEADS_DIR points to a different git repository than cwd.
|
||||||
|
// Contributed by dand-oss (https://github.com/steveyegge/beads/pull/533)
|
||||||
|
func pullFromExternalBeadsRepo(ctx context.Context, beadsDir string) error {
|
||||||
|
repoRoot, err := getRepoRootFromPath(ctx, beadsDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get repo root: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if remote exists
|
||||||
|
remoteCmd := exec.CommandContext(ctx, "git", "-C", repoRoot, "remote")
|
||||||
|
remoteOutput, err := remoteCmd.Output()
|
||||||
|
if err != nil || len(strings.TrimSpace(string(remoteOutput))) == 0 {
|
||||||
|
return nil // No remote, skip pull
|
||||||
|
}
|
||||||
|
|
||||||
|
pullCmd := exec.CommandContext(ctx, "git", "-C", repoRoot, "pull")
|
||||||
|
if output, err := pullCmd.CombinedOutput(); err != nil {
|
||||||
|
return fmt.Errorf("git pull failed: %w\n%s", err, output)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
395
cmd/bd/sync_check.go
Normal file
395
cmd/bd/sync_check.go
Normal file
@@ -0,0 +1,395 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/steveyegge/beads/internal/syncbranch"
|
||||||
|
"github.com/steveyegge/beads/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SyncIntegrityResult contains the results of a pre-sync integrity check.
|
||||||
|
// bd-hlsw.1: Pre-sync integrity check
|
||||||
|
type SyncIntegrityResult struct {
|
||||||
|
ForcedPush *ForcedPushCheck `json:"forced_push,omitempty"`
|
||||||
|
PrefixMismatch *PrefixMismatch `json:"prefix_mismatch,omitempty"`
|
||||||
|
OrphanedChildren *OrphanedChildren `json:"orphaned_children,omitempty"`
|
||||||
|
HasProblems bool `json:"has_problems"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForcedPushCheck detects if sync branch has diverged from remote.
|
||||||
|
type ForcedPushCheck struct {
|
||||||
|
Detected bool `json:"detected"`
|
||||||
|
LocalRef string `json:"local_ref,omitempty"`
|
||||||
|
RemoteRef string `json:"remote_ref,omitempty"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrefixMismatch detects issues with wrong prefix in JSONL.
|
||||||
|
type PrefixMismatch struct {
|
||||||
|
ConfiguredPrefix string `json:"configured_prefix"`
|
||||||
|
MismatchedIDs []string `json:"mismatched_ids,omitempty"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OrphanedChildren detects issues with parent that doesn't exist.
|
||||||
|
type OrphanedChildren struct {
|
||||||
|
OrphanedIDs []string `json:"orphaned_ids,omitempty"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// showSyncIntegrityCheck performs pre-sync integrity checks without modifying state.
|
||||||
|
// bd-hlsw.1: Detects forced pushes, prefix mismatches, and orphaned children.
|
||||||
|
// Exits with code 1 if problems are detected.
|
||||||
|
func showSyncIntegrityCheck(ctx context.Context, jsonlPath string) {
|
||||||
|
fmt.Println("Sync Integrity Check")
|
||||||
|
fmt.Println("====================")
|
||||||
|
|
||||||
|
result := &SyncIntegrityResult{}
|
||||||
|
|
||||||
|
// Check 1: Detect forced pushes on sync branch
|
||||||
|
forcedPush := checkForcedPush(ctx)
|
||||||
|
result.ForcedPush = forcedPush
|
||||||
|
if forcedPush.Detected {
|
||||||
|
result.HasProblems = true
|
||||||
|
}
|
||||||
|
printForcedPushResult(forcedPush)
|
||||||
|
|
||||||
|
// Check 2: Detect prefix mismatches in JSONL
|
||||||
|
prefixMismatch, err := checkPrefixMismatch(ctx, jsonlPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Warning: prefix check failed: %v\n", err)
|
||||||
|
} else {
|
||||||
|
result.PrefixMismatch = prefixMismatch
|
||||||
|
if prefixMismatch != nil && prefixMismatch.Count > 0 {
|
||||||
|
result.HasProblems = true
|
||||||
|
}
|
||||||
|
printPrefixMismatchResult(prefixMismatch)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check 3: Detect orphaned children (parent issues that don't exist)
|
||||||
|
orphaned, err := checkOrphanedChildrenInJSONL(jsonlPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Warning: orphaned check failed: %v\n", err)
|
||||||
|
} else {
|
||||||
|
result.OrphanedChildren = orphaned
|
||||||
|
if orphaned != nil && orphaned.Count > 0 {
|
||||||
|
result.HasProblems = true
|
||||||
|
}
|
||||||
|
printOrphanedChildrenResult(orphaned)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
fmt.Println("\nSummary")
|
||||||
|
fmt.Println("-------")
|
||||||
|
if result.HasProblems {
|
||||||
|
fmt.Println("Problems detected! Review above and consider:")
|
||||||
|
if result.ForcedPush != nil && result.ForcedPush.Detected {
|
||||||
|
fmt.Println(" - Force push: Reset local sync branch or use 'bd sync --from-main'")
|
||||||
|
}
|
||||||
|
if result.PrefixMismatch != nil && result.PrefixMismatch.Count > 0 {
|
||||||
|
fmt.Println(" - Prefix mismatch: Use 'bd import --rename-on-import' to fix")
|
||||||
|
}
|
||||||
|
if result.OrphanedChildren != nil && result.OrphanedChildren.Count > 0 {
|
||||||
|
fmt.Println(" - Orphaned children: Remove parent references or create missing parents")
|
||||||
|
}
|
||||||
|
os.Exit(1)
|
||||||
|
} else {
|
||||||
|
fmt.Println("No problems detected. Safe to sync.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if jsonOutput {
|
||||||
|
data, _ := json.MarshalIndent(result, "", " ")
|
||||||
|
fmt.Println(string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkForcedPush detects if the sync branch has diverged from remote.
|
||||||
|
// This can happen when someone force-pushes to the sync branch.
|
||||||
|
func checkForcedPush(ctx context.Context) *ForcedPushCheck {
|
||||||
|
result := &ForcedPushCheck{
|
||||||
|
Detected: false,
|
||||||
|
Message: "No sync branch configured or no remote",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get sync branch name
|
||||||
|
if err := ensureStoreActive(); err != nil {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
syncBranch, _ := syncbranch.Get(ctx, store)
|
||||||
|
if syncBranch == "" {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if sync branch exists locally
|
||||||
|
checkLocalCmd := exec.CommandContext(ctx, "git", "show-ref", "--verify", "--quiet", "refs/heads/"+syncBranch)
|
||||||
|
if checkLocalCmd.Run() != nil {
|
||||||
|
result.Message = fmt.Sprintf("Sync branch '%s' does not exist locally", syncBranch)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get local ref
|
||||||
|
localRefCmd := exec.CommandContext(ctx, "git", "rev-parse", syncBranch)
|
||||||
|
localRefOutput, err := localRefCmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
result.Message = "Failed to get local sync branch ref"
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
localRef := strings.TrimSpace(string(localRefOutput))
|
||||||
|
result.LocalRef = localRef
|
||||||
|
|
||||||
|
// Check if remote tracking branch exists
|
||||||
|
remote := "origin"
|
||||||
|
if configuredRemote, err := store.GetConfig(ctx, "sync.remote"); err == nil && configuredRemote != "" {
|
||||||
|
remote = configuredRemote
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get remote ref
|
||||||
|
remoteRefCmd := exec.CommandContext(ctx, "git", "rev-parse", remote+"/"+syncBranch)
|
||||||
|
remoteRefOutput, err := remoteRefCmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
result.Message = fmt.Sprintf("Remote tracking branch '%s/%s' does not exist", remote, syncBranch)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
remoteRef := strings.TrimSpace(string(remoteRefOutput))
|
||||||
|
result.RemoteRef = remoteRef
|
||||||
|
|
||||||
|
// If refs match, no divergence
|
||||||
|
if localRef == remoteRef {
|
||||||
|
result.Message = "Sync branch is in sync with remote"
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if local is ahead of remote (normal case)
|
||||||
|
aheadCmd := exec.CommandContext(ctx, "git", "merge-base", "--is-ancestor", remoteRef, localRef)
|
||||||
|
if aheadCmd.Run() == nil {
|
||||||
|
result.Message = "Local sync branch is ahead of remote (normal)"
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if remote is ahead of local (behind, needs pull)
|
||||||
|
behindCmd := exec.CommandContext(ctx, "git", "merge-base", "--is-ancestor", localRef, remoteRef)
|
||||||
|
if behindCmd.Run() == nil {
|
||||||
|
result.Message = "Local sync branch is behind remote (needs pull)"
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// If neither is ancestor, branches have diverged - likely a force push
|
||||||
|
result.Detected = true
|
||||||
|
result.Message = fmt.Sprintf("Sync branch has DIVERGED from remote! Local: %s, Remote: %s. This may indicate a force push on the remote.", localRef[:8], remoteRef[:8])
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func printForcedPushResult(fp *ForcedPushCheck) {
|
||||||
|
fmt.Println("1. Force Push Detection")
|
||||||
|
if fp.Detected {
|
||||||
|
fmt.Printf(" [PROBLEM] %s\n", fp.Message)
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" [OK] %s\n", fp.Message)
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkPrefixMismatch detects issues in JSONL that don't match the configured prefix.
|
||||||
|
func checkPrefixMismatch(ctx context.Context, jsonlPath string) (*PrefixMismatch, error) {
|
||||||
|
result := &PrefixMismatch{
|
||||||
|
MismatchedIDs: []string{},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get configured prefix
|
||||||
|
if err := ensureStoreActive(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix, err := store.GetConfig(ctx, "issue_prefix")
|
||||||
|
if err != nil || prefix == "" {
|
||||||
|
prefix = "bd" // Default
|
||||||
|
}
|
||||||
|
result.ConfiguredPrefix = prefix
|
||||||
|
|
||||||
|
// Read JSONL and check each issue's prefix
|
||||||
|
f, err := os.Open(jsonlPath) // #nosec G304 - controlled path
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return result, nil // No JSONL, no mismatches
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to open JSONL: %w", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(f)
|
||||||
|
scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024)
|
||||||
|
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Bytes()
|
||||||
|
if len(bytes.TrimSpace(line)) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var issue struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(line, &issue); err != nil {
|
||||||
|
continue // Skip malformed lines
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if ID starts with configured prefix
|
||||||
|
if !strings.HasPrefix(issue.ID, prefix+"-") {
|
||||||
|
result.MismatchedIDs = append(result.MismatchedIDs, issue.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read JSONL: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Count = len(result.MismatchedIDs)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func printPrefixMismatchResult(pm *PrefixMismatch) {
|
||||||
|
fmt.Println("2. Prefix Mismatch Check")
|
||||||
|
if pm == nil {
|
||||||
|
fmt.Println(" [SKIP] Could not check prefix")
|
||||||
|
fmt.Println()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf(" Configured prefix: %s\n", pm.ConfiguredPrefix)
|
||||||
|
if pm.Count > 0 {
|
||||||
|
fmt.Printf(" [PROBLEM] Found %d issue(s) with wrong prefix:\n", pm.Count)
|
||||||
|
// Show first 10
|
||||||
|
limit := pm.Count
|
||||||
|
if limit > 10 {
|
||||||
|
limit = 10
|
||||||
|
}
|
||||||
|
for i := 0; i < limit; i++ {
|
||||||
|
fmt.Printf(" - %s\n", pm.MismatchedIDs[i])
|
||||||
|
}
|
||||||
|
if pm.Count > 10 {
|
||||||
|
fmt.Printf(" ... and %d more\n", pm.Count-10)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Println(" [OK] All issues have correct prefix")
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkOrphanedChildrenInJSONL detects issues with parent references to non-existent issues.
|
||||||
|
func checkOrphanedChildrenInJSONL(jsonlPath string) (*OrphanedChildren, error) {
|
||||||
|
result := &OrphanedChildren{
|
||||||
|
OrphanedIDs: []string{},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read JSONL and build maps of IDs and parent references
|
||||||
|
f, err := os.Open(jsonlPath) // #nosec G304 - controlled path
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to open JSONL: %w", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
existingIDs := make(map[string]bool)
|
||||||
|
parentRefs := make(map[string]string) // child ID -> parent ID
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(f)
|
||||||
|
scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024)
|
||||||
|
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Bytes()
|
||||||
|
if len(bytes.TrimSpace(line)) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var issue struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Parent string `json:"parent,omitempty"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(line, &issue); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip tombstones
|
||||||
|
if issue.Status == string(types.StatusTombstone) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
existingIDs[issue.ID] = true
|
||||||
|
if issue.Parent != "" {
|
||||||
|
parentRefs[issue.ID] = issue.Parent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read JSONL: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find orphaned children (parent doesn't exist)
|
||||||
|
for childID, parentID := range parentRefs {
|
||||||
|
if !existingIDs[parentID] {
|
||||||
|
result.OrphanedIDs = append(result.OrphanedIDs, fmt.Sprintf("%s (parent: %s)", childID, parentID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Count = len(result.OrphanedIDs)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runGitCmdWithTimeoutMsg runs a git command and prints a helpful message if it takes too long.
|
||||||
|
// This helps when git operations hang waiting for credential/browser auth.
|
||||||
|
func runGitCmdWithTimeoutMsg(ctx context.Context, cmd *exec.Cmd, cmdName string, timeoutDelay time.Duration) ([]byte, error) {
|
||||||
|
// Use done channel to cleanly exit goroutine when command completes
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
select {
|
||||||
|
case <-time.After(timeoutDelay):
|
||||||
|
fmt.Fprintf(os.Stderr, "⏳ %s is taking longer than expected (possibly waiting for authentication). If this hangs, check for a browser auth prompt or run 'git status' in another terminal.\n", cmdName)
|
||||||
|
case <-done:
|
||||||
|
// Command completed, exit cleanly
|
||||||
|
case <-ctx.Done():
|
||||||
|
// Context canceled, don't print message
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
close(done)
|
||||||
|
return output, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func printOrphanedChildrenResult(oc *OrphanedChildren) {
|
||||||
|
fmt.Println("3. Orphaned Children Check")
|
||||||
|
if oc == nil {
|
||||||
|
fmt.Println(" [SKIP] Could not check orphaned children")
|
||||||
|
fmt.Println()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if oc.Count > 0 {
|
||||||
|
fmt.Printf(" [PROBLEM] Found %d issue(s) with missing parent:\n", oc.Count)
|
||||||
|
limit := oc.Count
|
||||||
|
if limit > 10 {
|
||||||
|
limit = 10
|
||||||
|
}
|
||||||
|
for i := 0; i < limit; i++ {
|
||||||
|
fmt.Printf(" - %s\n", oc.OrphanedIDs[i])
|
||||||
|
}
|
||||||
|
if oc.Count > 10 {
|
||||||
|
fmt.Printf(" ... and %d more\n", oc.Count-10)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Println(" [OK] No orphaned children found")
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
170
cmd/bd/sync_export.go
Normal file
170
cmd/bd/sync_export.go
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cmp"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/steveyegge/beads/internal/rpc"
|
||||||
|
"github.com/steveyegge/beads/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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 including tombstones for sync propagation (bd-rp4o fix)
|
||||||
|
// Tombstones must be exported so they propagate to other clones and prevent resurrection
|
||||||
|
issues, err := store.SearchIssues(ctx, "", types.IssueFilter{IncludeTombstones: true})
|
||||||
|
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
|
||||||
|
slices.SortFunc(issues, func(a, b *types.Issue) int {
|
||||||
|
return cmp.Compare(a.ID, b.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 (error checked implicitly by Rename success)
|
||||||
|
_ = 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
|
||||||
|
}
|
||||||
132
cmd/bd/sync_import.go
Normal file
132
cmd/bd/sync_import.go
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
)
|
||||||
|
|
||||||
|
// importFromJSONL imports the JSONL file by running the import command
|
||||||
|
// Optional parameters: noGitHistory, protectLeftSnapshot (bd-sync-deletion fix)
|
||||||
|
func importFromJSONL(ctx context.Context, jsonlPath string, renameOnImport bool, opts ...bool) error {
|
||||||
|
// Get current executable path to avoid "./bd" path issues
|
||||||
|
exe, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot resolve current executable: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse optional parameters
|
||||||
|
noGitHistory := false
|
||||||
|
protectLeftSnapshot := false
|
||||||
|
if len(opts) > 0 {
|
||||||
|
noGitHistory = opts[0]
|
||||||
|
}
|
||||||
|
if len(opts) > 1 {
|
||||||
|
protectLeftSnapshot = opts[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build args for import command
|
||||||
|
// Use --no-daemon to ensure subprocess uses direct mode, avoiding daemon connection issues
|
||||||
|
args := []string{"--no-daemon", "import", "-i", jsonlPath}
|
||||||
|
if renameOnImport {
|
||||||
|
args = append(args, "--rename-on-import")
|
||||||
|
}
|
||||||
|
if noGitHistory {
|
||||||
|
args = append(args, "--no-git-history")
|
||||||
|
}
|
||||||
|
// Add --protect-left-snapshot flag for post-pull imports (bd-sync-deletion fix)
|
||||||
|
if protectLeftSnapshot {
|
||||||
|
args = append(args, "--protect-left-snapshot")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run import command
|
||||||
|
cmd := exec.CommandContext(ctx, exe, args...) // #nosec G204 - bd import command from trusted binary
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("import failed: %w\n%s", err, output)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show output (import command provides the summary)
|
||||||
|
if len(output) > 0 {
|
||||||
|
fmt.Print(string(output))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user