fix(bd-68e4): make DBJSONLSync bidirectional - export DB when it has more issues

- Modified fix.DBJSONLSync() to detect which direction to sync:
  - If DB > JSONL: run 'bd export' to sync JSONL (DB has newer data)
  - If JSONL > DB: run 'bd sync --import-only' to import (JSONL is source of truth)
  - If equal but different timestamps: use file mtime to decide direction

- Updated CheckDatabaseJSONLSync() error messages to recommend correct fix direction:
  - Shows different guidance based on whether DB or JSONL has more issues

- Added helper functions:
  - countDatabaseIssues() to count issues in SQLite
  - countJSONLIssues() to count issues in JSONL (local, avoids circular import)

- Added tests for countJSONLIssues() with edge cases

Fixes issue where 'bd doctor --fix' would recommend 'bd sync --import-only'
when DB > JSONL, which would be a no-op since JSONL hasn't changed.
This commit is contained in:
matt wilkie
2025-12-21 11:22:37 -07:00
parent fa90c95475
commit 2de4d0facd
4 changed files with 356 additions and 186 deletions

View File

@@ -425,11 +425,25 @@ func CheckDatabaseJSONLSync(path string) DoctorCheck {
// If we found issues, report them
if len(issues) > 0 {
// Provide direction-specific guidance
var fixMsg string
if dbCount > jsonlCount {
fixMsg = "Run 'bd doctor --fix' to automatically export DB to JSONL, or manually run 'bd export'"
} else if jsonlCount > dbCount {
fixMsg = "Run 'bd doctor --fix' to automatically import JSONL to DB, or manually run 'bd sync --import-only'"
} else {
// Equal counts but other issues (like prefix mismatch)
fixMsg = "Run 'bd doctor --fix' to fix automatically, or manually run 'bd sync --import-only' or 'bd export' depending on which has newer data"
}
if strings.Contains(strings.Join(issues, " "), "Prefix mismatch") {
fixMsg = "Run 'bd import -i " + filepath.Base(jsonlPath) + " --rename-on-import' to fix prefixes"
}
return DoctorCheck{
Name: "DB-JSONL Sync",
Status: StatusWarning,
Message: strings.Join(issues, "; "),
Fix: "Run 'bd sync --import-only' to import JSONL updates or 'bd import -i issues.jsonl --rename-on-import' to fix prefixes",
Fix: fixMsg,
}
}

View File

@@ -628,3 +628,83 @@ func TestSyncBranchConfig_InvalidRemoteURL(t *testing.T) {
}
}
func TestCountJSONLIssues(t *testing.T) {
t.Parallel()
t.Run("empty_JSONL", func(t *testing.T) {
dir := setupTestWorkspace(t)
jsonlPath := filepath.Join(dir, ".beads", "issues.jsonl")
// Create empty JSONL
if err := os.WriteFile(jsonlPath, []byte(""), 0644); err != nil {
t.Fatalf("failed to create JSONL: %v", err)
}
count, err := countJSONLIssues(jsonlPath)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if count != 0 {
t.Errorf("expected 0, got %d", count)
}
})
t.Run("valid_issues", func(t *testing.T) {
dir := setupTestWorkspace(t)
jsonlPath := filepath.Join(dir, ".beads", "issues.jsonl")
// Create JSONL with 3 issues
jsonl := []byte(`{"id":"bd-1","title":"First"}
{"id":"bd-2","title":"Second"}
{"id":"bd-3","title":"Third"}
`)
if err := os.WriteFile(jsonlPath, jsonl, 0644); err != nil {
t.Fatalf("failed to create JSONL: %v", err)
}
count, err := countJSONLIssues(jsonlPath)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if count != 3 {
t.Errorf("expected 3, got %d", count)
}
})
t.Run("mixed_valid_and_invalid", func(t *testing.T) {
dir := setupTestWorkspace(t)
jsonlPath := filepath.Join(dir, ".beads", "issues.jsonl")
// Create JSONL with 2 valid and some invalid lines
jsonl := []byte(`{"id":"bd-1","title":"First"}
invalid json line
{"id":"bd-2","title":"Second"}
{"title":"No ID"}
`)
if err := os.WriteFile(jsonlPath, jsonl, 0644); err != nil {
t.Fatalf("failed to create JSONL: %v", err)
}
count, err := countJSONLIssues(jsonlPath)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if count != 2 {
t.Errorf("expected 2, got %d", count)
}
})
t.Run("nonexistent_file", func(t *testing.T) {
dir := setupTestWorkspace(t)
jsonlPath := filepath.Join(dir, ".beads", "nonexistent.jsonl")
count, err := countJSONLIssues(jsonlPath)
if err == nil {
t.Error("expected error for nonexistent file")
}
if count != 0 {
t.Errorf("expected 0, got %d", count)
}
})
}

View File

@@ -1,13 +1,25 @@
package fix
import (
"bufio"
"database/sql"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
_ "github.com/ncruces/go-sqlite3/driver"
_ "github.com/ncruces/go-sqlite3/embed"
"github.com/steveyegge/beads/internal/beads"
"github.com/steveyegge/beads/internal/configfile"
)
// DBJSONLSync fixes database-JSONL sync issues by running bd sync --import-only
// DBJSONLSync fixes database-JSONL sync issues by running the appropriate sync command.
// It detects which has more data and runs the correct direction:
// - If DB > JSONL: Run 'bd export' (DB→JSONL)
// - If JSONL > DB: Run 'bd sync --import-only' (JSONL→DB)
// - If equal but timestamps differ: Use file mtime to decide
func DBJSONLSync(path string) error {
// Validate workspace
if err := validateBeadsWorkspace(path); err != nil {
@@ -16,26 +28,72 @@ func DBJSONLSync(path string) error {
beadsDir := filepath.Join(path, ".beads")
// Get database path (same logic as doctor package)
var dbPath string
if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.Database != "" {
dbPath = cfg.DatabasePath(beadsDir)
} else {
dbPath = filepath.Join(beadsDir, beads.CanonicalDatabaseName)
}
// Find JSONL file
var jsonlPath string
issuesJSONL := filepath.Join(beadsDir, "issues.jsonl")
beadsJSONL := filepath.Join(beadsDir, "beads.jsonl")
if _, err := os.Stat(issuesJSONL); err == nil {
jsonlPath = issuesJSONL
} else if _, err := os.Stat(beadsJSONL); err == nil {
jsonlPath = beadsJSONL
}
// Check if both database and JSONL exist
dbPath := filepath.Join(beadsDir, "beads.db")
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
beadsJSONLPath := filepath.Join(beadsDir, "beads.jsonl")
hasDB := false
if _, err := os.Stat(dbPath); err == nil {
hasDB = true
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
return nil // No database, nothing to sync
}
if jsonlPath == "" {
return nil // No JSONL, nothing to sync
}
hasJSONL := false
if _, err := os.Stat(jsonlPath); err == nil {
hasJSONL = true
} else if _, err := os.Stat(beadsJSONLPath); err == nil {
hasJSONL = true
// Count issues in both
dbCount, err := countDatabaseIssues(dbPath)
if err != nil {
return fmt.Errorf("failed to count database issues: %w", err)
}
if !hasDB || !hasJSONL {
// Nothing to sync
return nil
jsonlCount, err := countJSONLIssues(jsonlPath)
if err != nil {
return fmt.Errorf("failed to count JSONL issues: %w", err)
}
// Determine sync direction
var syncDirection string
if dbCount == jsonlCount {
// Counts are equal, use file modification times to decide
dbInfo, err := os.Stat(dbPath)
if err != nil {
return fmt.Errorf("failed to stat database: %w", err)
}
jsonlInfo, err := os.Stat(jsonlPath)
if err != nil {
return fmt.Errorf("failed to stat JSONL: %w", err)
}
if dbInfo.ModTime().After(jsonlInfo.ModTime()) {
// DB was modified after JSONL → export to update JSONL
syncDirection = "export"
} else {
// JSONL was modified after DB → import to update DB
syncDirection = "import"
}
} else if dbCount > jsonlCount {
// DB has more issues → export to sync JSONL
syncDirection = "export"
} else {
// JSONL has more issues → import to sync DB
syncDirection = "import"
}
// Get bd binary path
@@ -44,9 +102,15 @@ func DBJSONLSync(path string) error {
return err
}
// Run bd sync --import-only to import JSONL updates
cmd := exec.Command(bdBinary, "sync", "--import-only") // #nosec G204 -- bdBinary from validated executable path
cmd.Dir = path // Set working directory without changing process dir
// Run the appropriate sync command
var cmd *exec.Cmd
if syncDirection == "export" {
cmd = exec.Command(bdBinary, "export") // #nosec G204 -- bdBinary from validated executable path
} else {
cmd = exec.Command(bdBinary, "sync", "--import-only") // #nosec G204 -- bdBinary from validated executable path
}
cmd.Dir = path // Set working directory without changing process dir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
@@ -56,3 +120,56 @@ func DBJSONLSync(path string) error {
return nil
}
// countDatabaseIssues counts the number of issues in the database.
func countDatabaseIssues(dbPath string) (int, error) {
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
return 0, fmt.Errorf("failed to open database: %w", err)
}
defer db.Close()
var count int
err = db.QueryRow("SELECT COUNT(*) FROM issues").Scan(&count)
if err != nil {
return 0, fmt.Errorf("failed to query database: %w", err)
}
return count, nil
}
// countJSONLIssues counts the number of valid issues in a JSONL file.
// Returns only the count (doesn't need prefixes for sync direction decision).
func countJSONLIssues(jsonlPath string) (int, error) {
// jsonlPath is safe: constructed from filepath.Join(beadsDir, hardcoded name)
file, err := os.Open(jsonlPath) //nolint:gosec
if err != nil {
return 0, fmt.Errorf("failed to open JSONL file: %w", err)
}
defer file.Close()
count := 0
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Bytes()
if len(line) == 0 {
continue
}
// Parse JSON to get the ID
var issue map[string]interface{}
if err := json.Unmarshal(line, &issue); err != nil {
continue // Skip malformed lines
}
if id, ok := issue["id"].(string); ok && id != "" {
count++
}
}
if err := scanner.Err(); err != nil {
return count, fmt.Errorf("failed to read JSONL file: %w", err)
}
return count, nil
}