fix(import): Update database mtime after import to prevent bd doctor false warnings (#263)
When 'bd sync --import-only' completes, it imports JSONL changes into the database but doesn't update the database file's modification time. This causes 'bd doctor' to incorrectly warn that 'JSONL is newer than database' even when they're in sync. Root cause: SQLite in WAL mode writes to beads.db-wal; the main beads.db mtime often doesn't change until a checkpoint. bd doctor compares JSONL mtime to beads.db mtime, so it can misfire without an mtime bump. The fix adds touchDatabaseFile() that: - Only runs when import actually made changes (not dry-run, not unchanged) - Sets DB mtime to max(JSONL mtime, now) + 1ns to handle clock skew - Is best-effort (logs warning on failure, doesn't fail import) - Includes tests for basic touch and clock skew scenarios Fixes: bd-g3ey
This commit is contained in:
@@ -10,6 +10,7 @@ import (
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/beads/internal/debug"
|
||||
@@ -301,6 +302,15 @@ NOTE: Import requires direct database access and does not work with daemon mode.
|
||||
flushToJSONL()
|
||||
}
|
||||
|
||||
// Update database mtime to reflect it's now in sync with JSONL
|
||||
// This prevents bd doctor from incorrectly warning that JSONL is newer
|
||||
// Only touch if actual changes were made (not dry-run, not unchanged-only)
|
||||
if result.Created > 0 || result.Updated > 0 || len(result.IDMapping) > 0 {
|
||||
if err := touchDatabaseFile(dbPath, input); 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 {
|
||||
@@ -364,6 +374,33 @@ NOTE: Import requires direct database access and does not work with daemon mode.
|
||||
},
|
||||
}
|
||||
|
||||
// touchDatabaseFile updates the modification time of the database file.
|
||||
// This is used after import to ensure the database appears "in sync" with JSONL,
|
||||
// preventing bd doctor from incorrectly warning that JSONL is newer.
|
||||
//
|
||||
// In SQLite WAL mode, writes go to beads.db-wal and beads.db mtime may not update
|
||||
// until a checkpoint. Since bd doctor compares JSONL mtime to beads.db mtime only,
|
||||
// we need to explicitly touch the DB file after import.
|
||||
//
|
||||
// The function sets DB mtime to max(JSONL mtime, now) + 1ns to handle clock skew.
|
||||
// If jsonlPath is empty or can't be read, falls back to time.Now().
|
||||
func touchDatabaseFile(dbPath, jsonlPath string) error {
|
||||
targetTime := time.Now()
|
||||
|
||||
// If we have the JSONL path, use max(JSONL mtime, now) to handle clock skew
|
||||
if jsonlPath != "" {
|
||||
if info, err := os.Stat(jsonlPath); err == nil {
|
||||
jsonlTime := info.ModTime()
|
||||
if jsonlTime.After(targetTime) {
|
||||
targetTime = jsonlTime.Add(time.Nanosecond)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Best-effort touch - don't fail import if this doesn't work
|
||||
return os.Chtimes(dbPath, targetTime, targetTime)
|
||||
}
|
||||
|
||||
// checkUncommittedChanges detects if the JSONL file has uncommitted changes
|
||||
// and warns the user if the working tree differs from git HEAD
|
||||
func checkUncommittedChanges(filePath string, result *ImportResult) {
|
||||
|
||||
88
cmd/bd/import_mtime_test.go
Normal file
88
cmd/bd/import_mtime_test.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestTouchDatabaseFile verifies the touchDatabaseFile helper function
|
||||
func TestTouchDatabaseFile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
testFile := filepath.Join(tmpDir, "test.db")
|
||||
|
||||
// Create a test file
|
||||
if err := os.WriteFile(testFile, []byte("test"), 0600); err != nil {
|
||||
t.Fatalf("Failed to create test file: %v", err)
|
||||
}
|
||||
|
||||
// Get initial mtime
|
||||
infoBefore, err := os.Stat(testFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to stat file: %v", err)
|
||||
}
|
||||
|
||||
// Wait a bit to ensure mtime difference (1s for filesystems with coarse resolution)
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
// Touch the file
|
||||
if err := touchDatabaseFile(testFile, ""); err != nil {
|
||||
t.Fatalf("touchDatabaseFile failed: %v", err)
|
||||
}
|
||||
|
||||
// Get new mtime
|
||||
infoAfter, err := os.Stat(testFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to stat file after touch: %v", err)
|
||||
}
|
||||
|
||||
// Verify mtime was updated
|
||||
if !infoAfter.ModTime().After(infoBefore.ModTime()) {
|
||||
t.Errorf("File mtime should be updated after touch")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTouchDatabaseFileWithClockSkew verifies handling of future JSONL timestamps
|
||||
func TestTouchDatabaseFileWithClockSkew(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
dbFile := filepath.Join(tmpDir, "test.db")
|
||||
jsonlFile := filepath.Join(tmpDir, "issues.jsonl")
|
||||
|
||||
// Create test files
|
||||
if err := os.WriteFile(dbFile, []byte("db"), 0600); err != nil {
|
||||
t.Fatalf("Failed to create db file: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(jsonlFile, []byte("jsonl"), 0600); err != nil {
|
||||
t.Fatalf("Failed to create jsonl file: %v", err)
|
||||
}
|
||||
|
||||
// Set JSONL mtime to 1 hour in the future (simulating clock skew)
|
||||
futureTime := time.Now().Add(1 * time.Hour)
|
||||
if err := os.Chtimes(jsonlFile, futureTime, futureTime); err != nil {
|
||||
t.Fatalf("Failed to set future mtime: %v", err)
|
||||
}
|
||||
|
||||
// Touch the DB file with JSONL path
|
||||
if err := touchDatabaseFile(dbFile, jsonlFile); err != nil {
|
||||
t.Fatalf("touchDatabaseFile failed: %v", err)
|
||||
}
|
||||
|
||||
// Get DB mtime
|
||||
dbInfo, err := os.Stat(dbFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to stat db file: %v", err)
|
||||
}
|
||||
|
||||
jsonlInfo, err := os.Stat(jsonlFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to stat jsonl file: %v", err)
|
||||
}
|
||||
|
||||
// Verify DB mtime is at least as new as JSONL mtime
|
||||
// (should be JSONL mtime + 1ns to handle clock skew)
|
||||
if dbInfo.ModTime().Before(jsonlInfo.ModTime()) {
|
||||
t.Errorf("DB mtime should be >= JSONL mtime when JSONL is in future")
|
||||
t.Errorf("DB mtime: %v, JSONL mtime: %v", dbInfo.ModTime(), jsonlInfo.ModTime())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user