When running `bd sync --import-only` from a directory with `.beads/redirect`, the subprocess-based import could fail to update staleness metadata correctly because the subprocess might resolve paths differently than the parent process. The fix uses inline import (calling importIssuesCore directly) instead of spawning a subprocess. This ensures: 1. The same store and dbPath are used throughout 2. Path resolution is consistent with the parent process 3. Staleness metadata is updated correctly in the redirected database 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
224 lines
7.5 KiB
Go
224 lines
7.5 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"time"
|
|
|
|
"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]
|
|
}
|
|
|
|
// 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) 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,
|
|
}
|
|
result, err := importIssuesCore(ctx, dbPath, store, allIssues, opts)
|
|
if err != nil {
|
|
return fmt.Errorf("import failed: %w", err)
|
|
}
|
|
|
|
// 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".
|
|
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) //nolint:gosec // remote and defaultBranch from config
|
|
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/") //nolint:gosec // remote and defaultBranch from config
|
|
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
|
|
}
|