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") interval, _ := cmd.Flags().GetDuration("interval")
autoCommit, _ := cmd.Flags().GetBool("auto-commit") autoCommit, _ := cmd.Flags().GetBool("auto-commit")
autoPush, _ := cmd.Flags().GetBool("auto-push") autoPush, _ := cmd.Flags().GetBool("auto-push")
localMode, _ := cmd.Flags().GetBool("local")
logFile, _ := cmd.Flags().GetString("log") logFile, _ := cmd.Flags().GetString("log")
// If no operation flags provided, show help // 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 // Validate --local mode constraints
if !isGitRepo() { 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, "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) os.Exit(1)
} }
@@ -184,13 +199,17 @@ Run 'bd daemon' with no flags to see available options.`,
} }
// Start daemon // Start daemon
fmt.Printf("Starting bd daemon (interval: %v, auto-commit: %v, auto-push: %v)\n", if localMode {
interval, autoCommit, autoPush) 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 != "" { if logFile != "" {
fmt.Printf("Logging to: %s\n", 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().Duration("interval", 5*time.Second, "Sync check interval")
daemonCmd.Flags().Bool("auto-commit", false, "Automatically commit changes") daemonCmd.Flags().Bool("auto-commit", false, "Automatically commit changes")
daemonCmd.Flags().Bool("auto-push", false, "Automatically push commits") 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("stop", false, "Stop running daemon")
daemonCmd.Flags().Bool("status", false, "Show daemon status") daemonCmd.Flags().Bool("status", false, "Show daemon status")
daemonCmd.Flags().Bool("health", false, "Check daemon health and metrics") daemonCmd.Flags().Bool("health", false, "Check daemon health and metrics")
@@ -220,7 +240,7 @@ func computeDaemonParentPID() int {
} }
return os.Getppid() 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) logF, log := setupDaemonLogger(logPath)
defer func() { _ = logF.Close() }() defer func() { _ = logF.Close() }()
@@ -283,7 +303,11 @@ func runDaemonLoop(interval time.Duration, autoCommit, autoPush bool, logPath, p
defer func() { _ = lock.Close() }() defer func() { _ = lock.Close() }()
defer func() { _ = os.Remove(pidFile) }() 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) // Check for multiple .db files (ambiguity error)
beadsDir := filepath.Dir(daemonDBPath) beadsDir := filepath.Dir(daemonDBPath)
@@ -368,8 +392,10 @@ func runDaemonLoop(interval time.Duration, autoCommit, autoPush bool, logPath, p
} }
} }
// Validate database fingerprint // Validate database fingerprint (skip in local mode - no git available)
if err := validateDatabaseFingerprint(ctx, store, &log); err != nil { 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" { if os.Getenv("BEADS_IGNORE_REPO_MISMATCH") != "1" {
log.log("Error: %v", err) log.log("Error: %v", err)
return // Use return instead of os.Exit to allow defers to run 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) ticker := time.NewTicker(interval)
defer ticker.Stop() 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() doSync()
// Get parent PID for monitoring (exit if parent dies) // 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) runEventLoop(ctx, cancel, ticker, doSync, server, serverErrChan, parentPID, log)
} else { } else {
// Event-driven mode uses separate export-only and import-only functions // Event-driven mode uses separate export-only and import-only functions
doExport := createExportFunc(ctx, store, autoCommit, autoPush, log) var doExport, doAutoImport func()
doAutoImport := createAutoImportFunc(ctx, store, log) 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) runEventDrivenLoop(ctx, cancel, server, serverErrChan, store, jsonlPath, doExport, doAutoImport, parentPID, log)
} }
case "poll": case "poll":

View File

@@ -276,7 +276,7 @@ func stopDaemon(pidFile string) {
} }
// startDaemon starts the daemon in background // 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) logPath, err := getLogFilePath(logFile)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err) 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" { if os.Getenv("BD_DAEMON_FOREGROUND") == "1" {
runDaemonLoop(interval, autoCommit, autoPush, logPath, pidFile) runDaemonLoop(interval, autoCommit, autoPush, localMode, logPath, pidFile)
return return
} }
@@ -303,6 +303,9 @@ func startDaemon(interval time.Duration, autoCommit, autoPush bool, logFile, pid
if autoPush { if autoPush {
args = append(args, "--auto-push") args = append(args, "--auto-push")
} }
if localMode {
args = append(args, "--local")
}
if logFile != "" { if logFile != "" {
args = append(args, "--log", 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") 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")
}
}