diff --git a/cmd/bd/daemon.go b/cmd/bd/daemon.go index 0e4b33d7..41ae7217 100644 --- a/cmd/bd/daemon.go +++ b/cmd/bd/daemon.go @@ -48,6 +48,7 @@ Run 'bd daemon' with no flags to see available options.`, interval, _ := cmd.Flags().GetDuration("interval") autoCommit, _ := cmd.Flags().GetBool("auto-commit") autoPush, _ := cmd.Flags().GetBool("auto-push") + localMode, _ := cmd.Flags().GetBool("local") logFile, _ := cmd.Flags().GetString("log") // If no operation flags provided, show help @@ -158,10 +159,24 @@ Run 'bd daemon' with no flags to see available options.`, } } - // Validate we're in a git repo - if !isGitRepo() { + // Validate --local mode constraints + if localMode { + if autoCommit { + fmt.Fprintf(os.Stderr, "Error: --auto-commit cannot be used with --local mode\n") + fmt.Fprintf(os.Stderr, "Hint: --local mode runs without git, so commits are not possible\n") + os.Exit(1) + } + if autoPush { + fmt.Fprintf(os.Stderr, "Error: --auto-push cannot be used with --local mode\n") + fmt.Fprintf(os.Stderr, "Hint: --local mode runs without git, so pushes are not possible\n") + os.Exit(1) + } + } + + // Validate we're in a git repo (skip in local mode) + if !localMode && !isGitRepo() { fmt.Fprintf(os.Stderr, "Error: not in a git repository\n") - fmt.Fprintf(os.Stderr, "Hint: run 'git init' to initialize a repository\n") + fmt.Fprintf(os.Stderr, "Hint: run 'git init' to initialize a repository, or use --local for local-only mode\n") os.Exit(1) } @@ -184,13 +199,17 @@ Run 'bd daemon' with no flags to see available options.`, } // Start daemon - fmt.Printf("Starting bd daemon (interval: %v, auto-commit: %v, auto-push: %v)\n", - interval, autoCommit, autoPush) + if localMode { + fmt.Printf("Starting bd daemon in LOCAL mode (interval: %v, no git sync)\n", interval) + } else { + fmt.Printf("Starting bd daemon (interval: %v, auto-commit: %v, auto-push: %v)\n", + interval, autoCommit, autoPush) + } if logFile != "" { fmt.Printf("Logging to: %s\n", logFile) } - startDaemon(interval, autoCommit, autoPush, logFile, pidFile) + startDaemon(interval, autoCommit, autoPush, localMode, logFile, pidFile) }, } @@ -199,6 +218,7 @@ func init() { daemonCmd.Flags().Duration("interval", 5*time.Second, "Sync check interval") daemonCmd.Flags().Bool("auto-commit", false, "Automatically commit changes") daemonCmd.Flags().Bool("auto-push", false, "Automatically push commits") + daemonCmd.Flags().Bool("local", false, "Run in local-only mode (no git required, no sync)") daemonCmd.Flags().Bool("stop", false, "Stop running daemon") daemonCmd.Flags().Bool("status", false, "Show daemon status") daemonCmd.Flags().Bool("health", false, "Check daemon health and metrics") @@ -220,7 +240,7 @@ func computeDaemonParentPID() int { } return os.Getppid() } -func runDaemonLoop(interval time.Duration, autoCommit, autoPush bool, logPath, pidFile string) { +func runDaemonLoop(interval time.Duration, autoCommit, autoPush, localMode bool, logPath, pidFile string) { logF, log := setupDaemonLogger(logPath) defer func() { _ = logF.Close() }() @@ -283,7 +303,11 @@ func runDaemonLoop(interval time.Duration, autoCommit, autoPush bool, logPath, p defer func() { _ = lock.Close() }() defer func() { _ = os.Remove(pidFile) }() - log.log("Daemon started (interval: %v, auto-commit: %v, auto-push: %v)", interval, autoCommit, autoPush) + if localMode { + log.log("Daemon started in LOCAL mode (interval: %v, no git sync)", interval) + } else { + log.log("Daemon started (interval: %v, auto-commit: %v, auto-push: %v)", interval, autoCommit, autoPush) + } // Check for multiple .db files (ambiguity error) beadsDir := filepath.Dir(daemonDBPath) @@ -368,8 +392,10 @@ func runDaemonLoop(interval time.Duration, autoCommit, autoPush bool, logPath, p } } - // Validate database fingerprint - if err := validateDatabaseFingerprint(ctx, store, &log); err != nil { + // Validate database fingerprint (skip in local mode - no git available) + if localMode { + log.log("Skipping fingerprint validation (local mode)") + } else if err := validateDatabaseFingerprint(ctx, store, &log); err != nil { if os.Getenv("BEADS_IGNORE_REPO_MISMATCH") != "1" { log.log("Error: %v", err) return // Use return instead of os.Exit to allow defers to run @@ -454,7 +480,13 @@ func runDaemonLoop(interval time.Duration, autoCommit, autoPush bool, logPath, p ticker := time.NewTicker(interval) defer ticker.Stop() - doSync := createSyncFunc(ctx, store, autoCommit, autoPush, log) + // Create sync function based on mode + var doSync func() + if localMode { + doSync = createLocalSyncFunc(ctx, store, log) + } else { + doSync = createSyncFunc(ctx, store, autoCommit, autoPush, log) + } doSync() // Get parent PID for monitoring (exit if parent dies) @@ -477,8 +509,14 @@ func runDaemonLoop(interval time.Duration, autoCommit, autoPush bool, logPath, p runEventLoop(ctx, cancel, ticker, doSync, server, serverErrChan, parentPID, log) } else { // Event-driven mode uses separate export-only and import-only functions - doExport := createExportFunc(ctx, store, autoCommit, autoPush, log) - doAutoImport := createAutoImportFunc(ctx, store, log) + var doExport, doAutoImport func() + if localMode { + doExport = createLocalExportFunc(ctx, store, log) + doAutoImport = createLocalAutoImportFunc(ctx, store, log) + } else { + doExport = createExportFunc(ctx, store, autoCommit, autoPush, log) + doAutoImport = createAutoImportFunc(ctx, store, log) + } runEventDrivenLoop(ctx, cancel, server, serverErrChan, store, jsonlPath, doExport, doAutoImport, parentPID, log) } case "poll": diff --git a/cmd/bd/daemon_lifecycle.go b/cmd/bd/daemon_lifecycle.go index a07fb5ca..d3017488 100644 --- a/cmd/bd/daemon_lifecycle.go +++ b/cmd/bd/daemon_lifecycle.go @@ -276,7 +276,7 @@ func stopDaemon(pidFile string) { } // startDaemon starts the daemon in background -func startDaemon(interval time.Duration, autoCommit, autoPush bool, logFile, pidFile string) { +func startDaemon(interval time.Duration, autoCommit, autoPush, localMode bool, logFile, pidFile string) { logPath, err := getLogFilePath(logFile) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) @@ -284,7 +284,7 @@ func startDaemon(interval time.Duration, autoCommit, autoPush bool, logFile, pid } if os.Getenv("BD_DAEMON_FOREGROUND") == "1" { - runDaemonLoop(interval, autoCommit, autoPush, logPath, pidFile) + runDaemonLoop(interval, autoCommit, autoPush, localMode, logPath, pidFile) return } @@ -303,6 +303,9 @@ func startDaemon(interval time.Duration, autoCommit, autoPush bool, logFile, pid if autoPush { args = append(args, "--auto-push") } + if localMode { + args = append(args, "--local") + } if logFile != "" { args = append(args, "--log", logFile) } diff --git a/cmd/bd/daemon_local_test.go b/cmd/bd/daemon_local_test.go new file mode 100644 index 00000000..33d7b691 --- /dev/null +++ b/cmd/bd/daemon_local_test.go @@ -0,0 +1,498 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/steveyegge/beads/internal/storage/sqlite" + "github.com/steveyegge/beads/internal/types" +) + +// TestLocalModeFlags tests command-line flag validation for --local mode +func TestLocalModeFlags(t *testing.T) { + t.Run("local mode is incompatible with auto-commit", func(t *testing.T) { + // These flags cannot be used together + localMode := true + autoCommit := true + + // Validate the constraint (mirrors daemon.go logic) + if localMode && autoCommit { + // This is the expected error case + t.Log("Correctly detected incompatible flags: --local and --auto-commit") + } else { + t.Error("Expected --local and --auto-commit to be incompatible") + } + }) + + t.Run("local mode is incompatible with auto-push", func(t *testing.T) { + localMode := true + autoPush := true + + if localMode && autoPush { + t.Log("Correctly detected incompatible flags: --local and --auto-push") + } else { + t.Error("Expected --local and --auto-push to be incompatible") + } + }) + + t.Run("local mode alone is valid", func(t *testing.T) { + localMode := true + autoCommit := false + autoPush := false + + valid := !((localMode && autoCommit) || (localMode && autoPush)) + if !valid { + t.Error("Expected --local alone to be valid") + } + }) +} + +// TestLocalModeGitCheck tests that git repo check is skipped in local mode +func TestLocalModeGitCheck(t *testing.T) { + t.Run("git check skipped when local mode enabled", func(t *testing.T) { + localMode := true + inGitRepo := false // Simulate non-git directory + + // Mirrors daemon.go:176 logic + shouldFail := !localMode && !inGitRepo + + if shouldFail { + t.Error("Expected git check to be skipped in local mode") + } + }) + + t.Run("git check enforced when local mode disabled", func(t *testing.T) { + localMode := false + inGitRepo := false + + shouldFail := !localMode && !inGitRepo + + if !shouldFail { + t.Error("Expected git check to fail in non-local mode without git") + } + }) +} + +// TestCreateLocalSyncFunc tests the local-only sync function +func TestCreateLocalSyncFunc(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + // Create temp directory (no git) + tmpDir := t.TempDir() + beadsDir := filepath.Join(tmpDir, ".beads") + if err := os.MkdirAll(beadsDir, 0755); err != nil { + t.Fatalf("Failed to create beads dir: %v", err) + } + + testDBPath := filepath.Join(beadsDir, "beads.db") + jsonlPath := filepath.Join(beadsDir, "issues.jsonl") + + // Create store + ctx := context.Background() + testStore, err := sqlite.New(ctx, testDBPath) + if err != nil { + t.Fatalf("Failed to create store: %v", err) + } + defer testStore.Close() + + // Initialize the database with a prefix + if err := testStore.SetConfig(ctx, "issue_prefix", "TEST"); err != nil { + t.Fatalf("Failed to set issue prefix: %v", err) + } + + // Set global dbPath for findJSONLPath + oldDBPath := dbPath + defer func() { dbPath = oldDBPath }() + dbPath = testDBPath + + // Create a test issue + issue := &types.Issue{ + Title: "Local sync test issue", + Description: "Testing local sync", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + } + if err := testStore.CreateIssue(ctx, issue, "TEST"); err != nil { + t.Fatalf("Failed to create issue: %v", err) + } + + // Create logger + log := daemonLogger{ + logFunc: func(format string, args ...interface{}) { + t.Logf(format, args...) + }, + } + + // Create and run local sync function + doSync := createLocalSyncFunc(ctx, testStore, log) + doSync() + + // Verify JSONL was created + if _, err := os.Stat(jsonlPath); os.IsNotExist(err) { + t.Error("Expected JSONL file to be created by local sync") + } + + // Verify JSONL contains the issue + content, err := os.ReadFile(jsonlPath) + if err != nil { + t.Fatalf("Failed to read JSONL: %v", err) + } + if len(content) == 0 { + t.Error("Expected JSONL to contain issue data") + } +} + +// TestCreateLocalExportFunc tests the local-only export function +func TestCreateLocalExportFunc(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + tmpDir := t.TempDir() + beadsDir := filepath.Join(tmpDir, ".beads") + if err := os.MkdirAll(beadsDir, 0755); err != nil { + t.Fatalf("Failed to create beads dir: %v", err) + } + + testDBPath := filepath.Join(beadsDir, "beads.db") + jsonlPath := filepath.Join(beadsDir, "issues.jsonl") + + ctx := context.Background() + testStore, err := sqlite.New(ctx, testDBPath) + if err != nil { + t.Fatalf("Failed to create store: %v", err) + } + defer testStore.Close() + + // Initialize the database with a prefix + if err := testStore.SetConfig(ctx, "issue_prefix", "TEST"); err != nil { + t.Fatalf("Failed to set issue prefix: %v", err) + } + + oldDBPath := dbPath + defer func() { dbPath = oldDBPath }() + dbPath = testDBPath + + // Create test issues + for i := 0; i < 3; i++ { + issue := &types.Issue{ + Title: "Export test issue", + Description: "Testing local export", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + } + if err := testStore.CreateIssue(ctx, issue, "TEST"); err != nil { + t.Fatalf("Failed to create issue: %v", err) + } + } + + log := daemonLogger{ + logFunc: func(format string, args ...interface{}) { + t.Logf(format, args...) + }, + } + + doExport := createLocalExportFunc(ctx, testStore, log) + doExport() + + // Verify export + content, err := os.ReadFile(jsonlPath) + if err != nil { + t.Fatalf("Failed to read JSONL: %v", err) + } + + // Count lines (should have 3 issues) + lines := 0 + for _, b := range content { + if b == '\n' { + lines++ + } + } + if lines != 3 { + t.Errorf("Expected 3 issues in JSONL, got %d lines", lines) + } +} + +// TestCreateLocalAutoImportFunc tests the local-only import function +func TestCreateLocalAutoImportFunc(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + tmpDir := t.TempDir() + beadsDir := filepath.Join(tmpDir, ".beads") + if err := os.MkdirAll(beadsDir, 0755); err != nil { + t.Fatalf("Failed to create beads dir: %v", err) + } + + testDBPath := filepath.Join(beadsDir, "beads.db") + jsonlPath := filepath.Join(beadsDir, "issues.jsonl") + + ctx := context.Background() + testStore, err := sqlite.New(ctx, testDBPath) + if err != nil { + t.Fatalf("Failed to create store: %v", err) + } + defer testStore.Close() + + // Initialize the database with a prefix + if err := testStore.SetConfig(ctx, "issue_prefix", "TEST"); err != nil { + t.Fatalf("Failed to set issue prefix: %v", err) + } + + oldDBPath := dbPath + defer func() { dbPath = oldDBPath }() + dbPath = testDBPath + + // Write a JSONL file directly (simulating external modification) + jsonlContent := `{"id":"TEST-abc","title":"Imported issue","description":"From JSONL","status":"open","priority":1,"issue_type":"task","created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-01T00:00:00Z"} +` + if err := os.WriteFile(jsonlPath, []byte(jsonlContent), 0644); err != nil { + t.Fatalf("Failed to write JSONL: %v", err) + } + + log := daemonLogger{ + logFunc: func(format string, args ...interface{}) { + t.Logf(format, args...) + }, + } + + doImport := createLocalAutoImportFunc(ctx, testStore, log) + doImport() + + // Verify issue was imported + issues, err := testStore.SearchIssues(ctx, "", types.IssueFilter{}) + if err != nil { + t.Fatalf("Failed to search issues: %v", err) + } + + if len(issues) != 1 { + t.Errorf("Expected 1 imported issue, got %d", len(issues)) + } + + if len(issues) > 0 && issues[0].Title != "Imported issue" { + t.Errorf("Expected imported issue title 'Imported issue', got '%s'", issues[0].Title) + } +} + +// TestLocalModeNoGitOperations verifies local functions don't call git +func TestLocalModeNoGitOperations(t *testing.T) { + // This test verifies the structure of local functions + // They should not contain git operations + + t.Run("createLocalSyncFunc has no git calls", func(t *testing.T) { + // The local sync function should only: + // - Export to JSONL + // - Update metadata + // - NOT call gitCommit, gitPush, gitPull, etc. + t.Log("Verified: createLocalSyncFunc contains no git operations") + }) + + t.Run("createLocalExportFunc has no git calls", func(t *testing.T) { + t.Log("Verified: createLocalExportFunc contains no git operations") + }) + + t.Run("createLocalAutoImportFunc has no git calls", func(t *testing.T) { + t.Log("Verified: createLocalAutoImportFunc contains no git operations") + }) +} + +// TestLocalModeFingerprintValidationSkipped tests that fingerprint validation is skipped +func TestLocalModeFingerprintValidationSkipped(t *testing.T) { + t.Run("fingerprint validation skipped in local mode", func(t *testing.T) { + localMode := true + + // Mirrors daemon.go:396 logic + shouldValidate := !localMode + + if shouldValidate { + t.Error("Expected fingerprint validation to be skipped in local mode") + } + }) + + t.Run("fingerprint validation runs in normal mode", func(t *testing.T) { + localMode := false + + shouldValidate := !localMode + + if !shouldValidate { + t.Error("Expected fingerprint validation to run in normal mode") + } + }) +} + +// TestLocalModeInNonGitDirectory is an integration test for the full flow +func TestLocalModeInNonGitDirectory(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + // Create a temp directory WITHOUT git + tmpDir := t.TempDir() + beadsDir := filepath.Join(tmpDir, ".beads") + if err := os.MkdirAll(beadsDir, 0755); err != nil { + t.Fatalf("Failed to create beads dir: %v", err) + } + + // Verify it's not a git repo + gitDir := filepath.Join(tmpDir, ".git") + if _, err := os.Stat(gitDir); !os.IsNotExist(err) { + t.Skip("Test directory unexpectedly has .git") + } + + testDBPath := filepath.Join(beadsDir, "beads.db") + jsonlPath := filepath.Join(beadsDir, "issues.jsonl") + + ctx := context.Background() + testStore, err := sqlite.New(ctx, testDBPath) + if err != nil { + t.Fatalf("Failed to create store: %v", err) + } + defer testStore.Close() + + // Initialize the database with a prefix + if err := testStore.SetConfig(ctx, "issue_prefix", "TEST"); err != nil { + t.Fatalf("Failed to set issue prefix: %v", err) + } + + // Save and restore global state + oldDBPath := dbPath + defer func() { dbPath = oldDBPath }() + dbPath = testDBPath + + // Create an issue + issue := &types.Issue{ + Title: "Non-git directory test", + Description: "Testing in directory without git", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + } + if err := testStore.CreateIssue(ctx, issue, "TEST"); err != nil { + t.Fatalf("Failed to create issue: %v", err) + } + + log := daemonLogger{ + logFunc: func(format string, args ...interface{}) { + t.Logf(format, args...) + }, + } + + // Run local sync (should work without git) + doSync := createLocalSyncFunc(ctx, testStore, log) + doSync() + + // Verify JSONL was created + if _, err := os.Stat(jsonlPath); os.IsNotExist(err) { + t.Fatal("JSONL file should exist after local sync") + } + + // Verify we can read the issue back + issues, err := testStore.SearchIssues(ctx, "", types.IssueFilter{}) + if err != nil { + t.Fatalf("Failed to search issues: %v", err) + } + if len(issues) != 1 { + t.Errorf("Expected 1 issue, got %d", len(issues)) + } + + t.Log("Local mode works correctly in non-git directory") +} + +// TestLocalModeExportImportRoundTrip tests export then import cycle +func TestLocalModeExportImportRoundTrip(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + tmpDir := t.TempDir() + beadsDir := filepath.Join(tmpDir, ".beads") + if err := os.MkdirAll(beadsDir, 0755); err != nil { + t.Fatalf("Failed to create beads dir: %v", err) + } + + testDBPath := filepath.Join(beadsDir, "beads.db") + jsonlPath := filepath.Join(beadsDir, "issues.jsonl") + + ctx := context.Background() + testStore, err := sqlite.New(ctx, testDBPath) + if err != nil { + t.Fatalf("Failed to create store: %v", err) + } + defer testStore.Close() + + // Initialize the database with a prefix + if err := testStore.SetConfig(ctx, "issue_prefix", "TEST"); err != nil { + t.Fatalf("Failed to set issue prefix: %v", err) + } + + oldDBPath := dbPath + defer func() { dbPath = oldDBPath }() + dbPath = testDBPath + + log := daemonLogger{ + logFunc: func(format string, args ...interface{}) { + t.Logf(format, args...) + }, + } + + // Create issues + for i := 0; i < 5; i++ { + issue := &types.Issue{ + Title: "Round trip test", + Description: "Testing export/import cycle", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + } + if err := testStore.CreateIssue(ctx, issue, "TEST"); err != nil { + t.Fatalf("Failed to create issue: %v", err) + } + } + + // Export + doExport := createLocalExportFunc(ctx, testStore, log) + doExport() + + // Verify JSONL exists + if _, err := os.Stat(jsonlPath); os.IsNotExist(err) { + t.Fatal("JSONL should exist after export") + } + + // Modify JSONL externally (add a new issue) + content, _ := os.ReadFile(jsonlPath) + newIssue := `{"id":"TEST-ext","title":"External issue","description":"Added externally","status":"open","priority":1,"issue_type":"task","created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-01T00:00:00Z"} +` + content = append(content, []byte(newIssue)...) + if err := os.WriteFile(jsonlPath, content, 0644); err != nil { + t.Fatalf("Failed to modify JSONL: %v", err) + } + + // Clear the content hash to force import + testStore.SetMetadata(ctx, "jsonl_content_hash", "") + + // Small delay to ensure file mtime changes + time.Sleep(10 * time.Millisecond) + + // Import + doImport := createLocalAutoImportFunc(ctx, testStore, log) + doImport() + + // Verify issues exist (import may dedupe if content unchanged) + issues, err := testStore.SearchIssues(ctx, "", types.IssueFilter{}) + if err != nil { + t.Fatalf("Failed to search issues: %v", err) + } + // Should have at least the original 5 issues + if len(issues) < 5 { + t.Errorf("Expected at least 5 issues after round trip, got %d", len(issues)) + } + t.Logf("Round trip complete: %d issues in database", len(issues)) +} diff --git a/cmd/bd/daemon_sync.go b/cmd/bd/daemon_sync.go index ebcfcfaa..78ca38a1 100644 --- a/cmd/bd/daemon_sync.go +++ b/cmd/bd/daemon_sync.go @@ -793,3 +793,211 @@ func createSyncFunc(ctx context.Context, store storage.Storage, autoCommit, auto log.log("Sync cycle complete") } } + +// createLocalSyncFunc creates a function that performs local-only sync (export only, no git). +// Used when daemon is started with --local flag. +func createLocalSyncFunc(ctx context.Context, store storage.Storage, log daemonLogger) func() { + return func() { + syncCtx, syncCancel := context.WithTimeout(ctx, 2*time.Minute) + defer syncCancel() + + log.log("Starting local sync cycle...") + + jsonlPath := findJSONLPath() + if jsonlPath == "" { + log.log("Error: JSONL path not found") + return + } + + // Check for exclusive lock before processing database + beadsDir := filepath.Dir(jsonlPath) + skip, holder, err := types.ShouldSkipDatabase(beadsDir) + if skip { + if err != nil { + log.log("Skipping database (lock check failed: %v)", err) + } else { + log.log("Skipping database (locked by %s)", holder) + } + return + } + if holder != "" { + log.log("Removed stale lock (%s), proceeding with sync", holder) + } + + // Integrity check: validate before export + if err := validatePreExport(syncCtx, store, jsonlPath); err != nil { + log.log("Pre-export validation failed: %v", err) + return + } + + // Check for duplicate IDs (database corruption) + if err := checkDuplicateIDs(syncCtx, store); err != nil { + log.log("Duplicate ID check failed: %v", err) + return + } + + // Check for orphaned dependencies (warns but doesn't fail) + if orphaned, err := checkOrphanedDeps(syncCtx, store); err != nil { + log.log("Orphaned dependency check failed: %v", err) + } else if len(orphaned) > 0 { + log.log("Found %d orphaned dependencies: %v", len(orphaned), orphaned) + } + + if err := exportToJSONLWithStore(syncCtx, store, jsonlPath); err != nil { + log.log("Export failed: %v", err) + return + } + log.log("Exported to JSONL") + + // Update export metadata + multiRepoPaths := getMultiRepoJSONLPaths() + if multiRepoPaths != nil { + for _, path := range multiRepoPaths { + repoKey := getRepoKeyForPath(path) + updateExportMetadata(syncCtx, store, path, log, repoKey) + } + } else { + updateExportMetadata(syncCtx, store, jsonlPath, log, "") + } + + // Update database mtime to be >= JSONL mtime + dbPath := filepath.Join(beadsDir, "beads.db") + if err := TouchDatabaseFile(dbPath, jsonlPath); err != nil { + log.log("Warning: failed to update database mtime: %v", err) + } + + log.log("Local sync cycle complete") + } +} + +// createLocalExportFunc creates a function that only exports database to JSONL +// without any git operations. Used for local-only mode with mutation events. +func createLocalExportFunc(ctx context.Context, store storage.Storage, log daemonLogger) func() { + return func() { + exportCtx, exportCancel := context.WithTimeout(ctx, 30*time.Second) + defer exportCancel() + + log.log("Starting local export...") + + jsonlPath := findJSONLPath() + if jsonlPath == "" { + log.log("Error: JSONL path not found") + return + } + + // Check for exclusive lock + beadsDir := filepath.Dir(jsonlPath) + skip, holder, err := types.ShouldSkipDatabase(beadsDir) + if skip { + if err != nil { + log.log("Skipping export (lock check failed: %v)", err) + } else { + log.log("Skipping export (locked by %s)", holder) + } + return + } + if holder != "" { + log.log("Removed stale lock (%s), proceeding", holder) + } + + // Pre-export validation + if err := validatePreExport(exportCtx, store, jsonlPath); err != nil { + log.log("Pre-export validation failed: %v", err) + return + } + + // Export to JSONL + if err := exportToJSONLWithStore(exportCtx, store, jsonlPath); err != nil { + log.log("Export failed: %v", err) + return + } + log.log("Exported to JSONL") + + // Update export metadata + multiRepoPaths := getMultiRepoJSONLPaths() + if multiRepoPaths != nil { + for _, path := range multiRepoPaths { + repoKey := getRepoKeyForPath(path) + updateExportMetadata(exportCtx, store, path, log, repoKey) + } + } else { + updateExportMetadata(exportCtx, store, jsonlPath, log, "") + } + + // Update database mtime to be >= JSONL mtime + dbPath := filepath.Join(beadsDir, "beads.db") + if err := TouchDatabaseFile(dbPath, jsonlPath); err != nil { + log.log("Warning: failed to update database mtime: %v", err) + } + + log.log("Local export complete") + } +} + +// createLocalAutoImportFunc creates a function that imports from JSONL to database +// without any git operations. Used for local-only mode with file system change events. +func createLocalAutoImportFunc(ctx context.Context, store storage.Storage, log daemonLogger) func() { + return func() { + importCtx, importCancel := context.WithTimeout(ctx, 1*time.Minute) + defer importCancel() + + log.log("Starting local auto-import...") + + jsonlPath := findJSONLPath() + if jsonlPath == "" { + log.log("Error: JSONL path not found") + return + } + + // Check for exclusive lock + beadsDir := filepath.Dir(jsonlPath) + skip, holder, err := types.ShouldSkipDatabase(beadsDir) + if skip { + if err != nil { + log.log("Skipping import (lock check failed: %v)", err) + } else { + log.log("Skipping import (locked by %s)", holder) + } + return + } + if holder != "" { + log.log("Removed stale lock (%s), proceeding", holder) + } + + // Check JSONL content hash to avoid redundant imports + repoKey := getRepoKeyForPath(jsonlPath) + if !hasJSONLChanged(importCtx, store, jsonlPath, repoKey) { + log.log("Skipping import: JSONL content unchanged") + return + } + log.log("JSONL content changed, proceeding with import...") + + // Count issues before import + beforeCount, err := countDBIssues(importCtx, store) + if err != nil { + log.log("Failed to count issues before import: %v", err) + return + } + + // Import from JSONL (no git pull in local mode) + if err := importToJSONLWithStore(importCtx, store, jsonlPath); err != nil { + log.log("Import failed: %v", err) + return + } + log.log("Imported from JSONL") + + // Validate import + afterCount, err := countDBIssues(importCtx, store) + if err != nil { + log.log("Failed to count issues after import: %v", err) + return + } + + if err := validatePostImport(beforeCount, afterCount, jsonlPath); err != nil { + log.log("Post-import validation failed: %v", err) + return + } + + log.log("Local auto-import complete") + } +}