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:
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user