* /{cmd,docs,internal}: autocommit writes in dolt backend
Executed-By: beads/crew/emma
Rig: beads
Role: crew
* /{cmd,internal}: fix commit author
Executed-By: beads/crew/emma
Rig: beads
Role: crew
* /cmd/bd: fix race condition
Executed-By: beads/crew/emma
Rig: beads
Role: crew
---------
Co-authored-by: Test <test@example.com>
261 lines
8.8 KiB
Go
261 lines
8.8 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"github.com/steveyegge/beads/internal/beads"
|
|
"github.com/steveyegge/beads/internal/debug"
|
|
"github.com/steveyegge/beads/internal/types"
|
|
)
|
|
|
|
// 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]
|
|
}
|
|
|
|
// Guardrail: single-process backends (e.g., Dolt) must not spawn a helper `bd import`
|
|
// process while the parent holds an open store. Use inline import instead.
|
|
if singleProcessOnlyBackend() {
|
|
return importFromJSONLInline(ctx, jsonlPath, renameOnImport, noGitHistory, protectLeftSnapshot)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// importFromJSONLInline imports the JSONL file directly without spawning a subprocess.
|
|
// This avoids path resolution issues when running from directories with .beads/redirect.
|
|
// The parent process's store and dbPath are used, ensuring consistent path resolution.
|
|
// (bd-ysal fix)
|
|
func importFromJSONLInline(ctx context.Context, jsonlPath string, renameOnImport bool, _ /* noGitHistory */ bool, protectLeftSnapshot bool) error {
|
|
// Verify we have an active store
|
|
if store == nil {
|
|
return fmt.Errorf("no database store available for inline import")
|
|
}
|
|
|
|
// Read and parse the JSONL file
|
|
// #nosec G304 - jsonlPath is from findJSONLPath() which uses trusted paths
|
|
f, err := os.Open(jsonlPath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to open JSONL file: %w", err)
|
|
}
|
|
defer func() { _ = f.Close() }()
|
|
|
|
var allIssues []*types.Issue
|
|
scanner := bufio.NewScanner(f)
|
|
lineNum := 0
|
|
|
|
for scanner.Scan() {
|
|
lineNum++
|
|
line := scanner.Text()
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
var issue types.Issue
|
|
if err := json.Unmarshal([]byte(line), &issue); err != nil {
|
|
return fmt.Errorf("error parsing line %d: %w", lineNum, err)
|
|
}
|
|
issue.SetDefaults()
|
|
allIssues = append(allIssues, &issue)
|
|
}
|
|
|
|
if err := scanner.Err(); err != nil {
|
|
return fmt.Errorf("error reading JSONL: %w", err)
|
|
}
|
|
|
|
// Import using shared logic
|
|
opts := ImportOptions{
|
|
RenameOnImport: renameOnImport,
|
|
}
|
|
|
|
// GH#865: timestamp-aware protection for post-pull imports (bd-sync-deletion fix).
|
|
// Match `bd import --protect-left-snapshot` behavior.
|
|
if protectLeftSnapshot {
|
|
beadsDir := filepath.Dir(jsonlPath)
|
|
leftSnapshotPath := filepath.Join(beadsDir, "beads.left.jsonl")
|
|
if _, err := os.Stat(leftSnapshotPath); err == nil {
|
|
sm := NewSnapshotManager(jsonlPath)
|
|
leftTimestamps, err := sm.BuildIDToTimestampMap(leftSnapshotPath)
|
|
if err != nil {
|
|
debug.Logf("Warning: failed to read left snapshot: %v", err)
|
|
} else if len(leftTimestamps) > 0 {
|
|
opts.ProtectLocalExportIDs = leftTimestamps
|
|
fmt.Fprintf(os.Stderr, "Protecting %d issue(s) from left snapshot (timestamp-aware)\n", len(leftTimestamps))
|
|
}
|
|
}
|
|
}
|
|
result, err := importIssuesCore(ctx, dbPath, store, allIssues, opts)
|
|
if err != nil {
|
|
return fmt.Errorf("import failed: %w", err)
|
|
}
|
|
|
|
// Record that this command performed a write (for Dolt auto-commit) when the import changed anything.
|
|
if result.Created > 0 || result.Updated > 0 || len(result.IDMapping) > 0 {
|
|
commandDidWrite.Store(true)
|
|
}
|
|
|
|
// Update staleness metadata (same as import.go lines 386-411)
|
|
// This is critical: without this, CheckStaleness will still report stale
|
|
if currentHash, hashErr := computeJSONLHash(jsonlPath); hashErr == nil {
|
|
if err := store.SetMetadata(ctx, "jsonl_content_hash", currentHash); err != nil {
|
|
debug.Logf("Warning: failed to update jsonl_content_hash: %v", err)
|
|
}
|
|
if err := store.SetJSONLFileHash(ctx, currentHash); err != nil {
|
|
debug.Logf("Warning: failed to update jsonl_file_hash: %v", err)
|
|
}
|
|
importTime := time.Now().Format(time.RFC3339Nano)
|
|
if err := store.SetMetadata(ctx, "last_import_time", importTime); err != nil {
|
|
debug.Logf("Warning: failed to update last_import_time: %v", err)
|
|
}
|
|
} else {
|
|
debug.Logf("Warning: failed to compute JSONL hash: %v", hashErr)
|
|
}
|
|
|
|
// Update database mtime
|
|
if err := TouchDatabaseFile(dbPath, jsonlPath); err != nil {
|
|
debug.Logf("Warning: failed to update database mtime: %v", err)
|
|
}
|
|
|
|
// Print summary
|
|
fmt.Fprintf(os.Stderr, "Import complete: %d created, %d updated", result.Created, result.Updated)
|
|
if result.Unchanged > 0 {
|
|
fmt.Fprintf(os.Stderr, ", %d unchanged", result.Unchanged)
|
|
}
|
|
if result.Skipped > 0 {
|
|
fmt.Fprintf(os.Stderr, ", %d skipped", result.Skipped)
|
|
}
|
|
fmt.Fprintf(os.Stderr, "\n")
|
|
|
|
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.
|
|
// 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".
|
|
// GH#1110: Now uses RepoContext to ensure git commands run in beads repo.
|
|
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")
|
|
}
|
|
|
|
// Get RepoContext for beads repo
|
|
rc, err := beads.GetRepoContext()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get repo context: %w", err)
|
|
}
|
|
|
|
// Verify the configured remote exists
|
|
checkRemoteCmd := rc.GitCmd(ctx, "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 := rc.GitCmd(ctx, "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 := rc.GitCmd(ctx, "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
|
|
}
|