feat(daemon): add --local flag for git-free daemon operation (#433)

* feat(daemon): add --local flag for git-free operation

Add --local mode to the daemon that allows it to run without a git
repository. This decouples the daemon's core functionality (auto-flush
to JSONL, auto-import from JSONL) from git synchronization.

Changes:
- Add --local flag to daemon command
- Skip git repo check when --local is set
- Add validation that --auto-commit and --auto-push cannot be used with --local
- Create local-only sync functions that skip git operations:
  - createLocalSyncFunc: export-only for polling mode
  - createLocalExportFunc: export without git commit/push
  - createLocalAutoImportFunc: import without git pull
- Update startup message to indicate LOCAL mode
- Update event loop to use local functions when in local mode

This enables use cases like:
- Single-machine issue tracking without git
- Auto-flush to JSONL for backup purposes
- Running daemon in environments without git access

Multi-machine sync still requires git (as expected).

* fix(daemon): skip fingerprint validation in local mode

validateDatabaseFingerprint() calls beads.ComputeRepoID() which
executes git commands. This fails in non-git directories even
with --local flag.

Skip fingerprint validation entirely when running in local mode
since there's no git repository to validate against.

* test(daemon): add comprehensive test coverage for --local mode

Add tests for:
- Flag validation (--local incompatible with --auto-commit/--auto-push)
- Git check skip logic in local mode
- createLocalSyncFunc, createLocalExportFunc, createLocalAutoImportFunc
- Fingerprint validation skip in local mode
- Full integration test in non-git directory
- Export/import round-trip test

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Yaakov Nemoy
2025-12-01 17:37:56 -08:00
committed by GitHub
parent 34799a001d
commit f761ba1f3a
4 changed files with 762 additions and 15 deletions

View File

@@ -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":

View File

@@ -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)
}

498
cmd/bd/daemon_local_test.go Normal file
View File

@@ -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))
}

View File

@@ -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")
}
}