Doctor sync issues (#231)
* feat: enhance bd doctor sync detection with count and prefix mismatch checks Improves bd doctor to detect actual database-JSONL sync issues instead of relying only on file modification times: Key improvements: 1. Count detection: Reports when database issue count differs from JSONL (e.g., "Count mismatch: database has 0 issues, JSONL has 61") 2. Prefix detection: Identifies prefix mismatches when majority of JSONL issues use different prefix than database config 3. Error handling: Returns errors from helper functions instead of silent failures, distinguishing "can't open DB" from "counts differ" 4. Query optimization: Single database connection for all checks (reduced from 3 opens to 1) 5. Better error reporting: Shows actual error details when database or JSONL can't be read This addresses the core issue where bd doctor would incorrectly report "Database and JSONL are in sync" when the database was empty but JSONL contained issues (as happened in privacy2 project). Tests: - Added TestCountJSONLIssuesWithMalformedLines to verify malformed JSON handling - Existing doctor tests still pass - countJSONLIssues now returns error to indicate parsing issues 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com> * fix: correct git hooks installation instructions in bd doctor The original message referenced './examples/git-hooks/install.sh' which doesn't exist in user projects. This fix changes the message to point to the actual location in the beads GitHub repository: Before: "Run './examples/git-hooks/install.sh' to install recommended git hooks" After: "See https://github.com/steveyegge/beads/tree/main/examples/git-hooks for installation instructions" This works for any project using bd, not just the beads repository itself. 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com> * feat: add recovery suggestions when database fails but JSONL has issues When bd doctor detects that the database cannot be opened/queried but the JSONL file contains issues, it now suggests the recovery command: Fix: Run 'bd import -i issues.jsonl --rename-on-import' to recover issues from JSONL This addresses the case where: - Database is corrupted or inaccessible - JSONL has all the issues backed up - User needs a clear path to recover The check now: 1. Reads JSONL first (doesn't depend on database) 2. If database fails but JSONL has issues, suggests recovery command 3. If database can be queried, continues with sync checks as before Tested on privacy2 project which has 61 issues in JSONL but inaccessible database. 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com> * fix: support hash-based issue IDs in import rename The import --rename-on-import flag was rejecting valid issue IDs with hash-based suffixes (e.g., privacy-09ea) because the validation only accepted numeric suffixes. Beads now generates and accepts base36-encoded hash IDs, so update the validation to match. Changes: - Update isNumeric() to accept base36 characters (0-9, a-z) - Update tests to reflect hash-based ID support - Add gosec nolint comment for safe file path construction Fixes the error: "cannot rename issue privacy-09ea: non-numeric suffix '09ea'" --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
170
cmd/bd/doctor.go
170
cmd/bd/doctor.go
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -751,13 +752,13 @@ func checkDaemonStatus(path string) doctorCheck {
|
||||
func checkDatabaseJSONLSync(path string) doctorCheck {
|
||||
beadsDir := filepath.Join(path, ".beads")
|
||||
dbPath := filepath.Join(beadsDir, beads.CanonicalDatabaseName)
|
||||
|
||||
|
||||
// Find JSONL file
|
||||
var jsonlPath string
|
||||
for _, name := range []string{"issues.jsonl", "beads.jsonl"} {
|
||||
path := filepath.Join(beadsDir, name)
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
jsonlPath = path
|
||||
testPath := filepath.Join(beadsDir, name)
|
||||
if _, err := os.Stat(testPath); err == nil {
|
||||
jsonlPath = testPath
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -780,7 +781,111 @@ func checkDatabaseJSONLSync(path string) doctorCheck {
|
||||
}
|
||||
}
|
||||
|
||||
// Compare modification times
|
||||
// Try to read JSONL first (doesn't depend on database)
|
||||
jsonlCount, jsonlPrefixes, jsonlErr := countJSONLIssues(jsonlPath)
|
||||
|
||||
// Single database open for all queries (instead of 3 separate opens)
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
// Database can't be opened. If JSONL has issues, suggest recovery.
|
||||
if jsonlErr == nil && jsonlCount > 0 {
|
||||
return doctorCheck{
|
||||
Name: "DB-JSONL Sync",
|
||||
Status: statusWarning,
|
||||
Message: fmt.Sprintf("Database cannot be opened but JSONL contains %d issues", jsonlCount),
|
||||
Detail: err.Error(),
|
||||
Fix: fmt.Sprintf("Run 'bd import -i %s --rename-on-import' to recover issues from JSONL", filepath.Base(jsonlPath)),
|
||||
}
|
||||
}
|
||||
return doctorCheck{
|
||||
Name: "DB-JSONL Sync",
|
||||
Status: statusWarning,
|
||||
Message: "Unable to open database",
|
||||
Detail: err.Error(),
|
||||
}
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Get database count
|
||||
var dbCount int
|
||||
err = db.QueryRow("SELECT COUNT(*) FROM issues").Scan(&dbCount)
|
||||
if err != nil {
|
||||
// Database opened but can't query. If JSONL has issues, suggest recovery.
|
||||
if jsonlErr == nil && jsonlCount > 0 {
|
||||
return doctorCheck{
|
||||
Name: "DB-JSONL Sync",
|
||||
Status: statusWarning,
|
||||
Message: fmt.Sprintf("Database cannot be queried but JSONL contains %d issues", jsonlCount),
|
||||
Detail: err.Error(),
|
||||
Fix: fmt.Sprintf("Run 'bd import -i %s --rename-on-import' to recover issues from JSONL", filepath.Base(jsonlPath)),
|
||||
}
|
||||
}
|
||||
return doctorCheck{
|
||||
Name: "DB-JSONL Sync",
|
||||
Status: statusWarning,
|
||||
Message: "Unable to query database",
|
||||
Detail: err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
// Get database prefix
|
||||
var dbPrefix string
|
||||
err = db.QueryRow("SELECT value FROM config WHERE key = ?", "issue_prefix").Scan(&dbPrefix)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return doctorCheck{
|
||||
Name: "DB-JSONL Sync",
|
||||
Status: statusWarning,
|
||||
Message: "Unable to read database prefix",
|
||||
Detail: err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
// Use JSONL error if we got it earlier
|
||||
if jsonlErr != nil {
|
||||
return doctorCheck{
|
||||
Name: "DB-JSONL Sync",
|
||||
Status: statusWarning,
|
||||
Message: "Unable to read JSONL file",
|
||||
Detail: jsonlErr.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
// Check for issues
|
||||
var issues []string
|
||||
|
||||
// Count mismatch
|
||||
if dbCount != jsonlCount {
|
||||
issues = append(issues, fmt.Sprintf("Count mismatch: database has %d issues, JSONL has %d", dbCount, jsonlCount))
|
||||
}
|
||||
|
||||
// Prefix mismatch (only check most common prefix in JSONL)
|
||||
if dbPrefix != "" && len(jsonlPrefixes) > 0 {
|
||||
var mostCommonPrefix string
|
||||
maxCount := 0
|
||||
for prefix, count := range jsonlPrefixes {
|
||||
if count > maxCount {
|
||||
maxCount = count
|
||||
mostCommonPrefix = prefix
|
||||
}
|
||||
}
|
||||
|
||||
// Only warn if majority of issues have wrong prefix
|
||||
if mostCommonPrefix != dbPrefix && maxCount > jsonlCount/2 {
|
||||
issues = append(issues, fmt.Sprintf("Prefix mismatch: database uses %q but most JSONL issues use %q", dbPrefix, mostCommonPrefix))
|
||||
}
|
||||
}
|
||||
|
||||
// If we found issues, report them
|
||||
if len(issues) > 0 {
|
||||
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",
|
||||
}
|
||||
}
|
||||
|
||||
// Check modification times (only if counts match)
|
||||
dbInfo, err := os.Stat(dbPath)
|
||||
if err != nil {
|
||||
return doctorCheck{
|
||||
@@ -799,7 +904,6 @@ func checkDatabaseJSONLSync(path string) doctorCheck {
|
||||
}
|
||||
}
|
||||
|
||||
// If JSONL is newer, warn about potential sync issue
|
||||
if jsonlInfo.ModTime().After(dbInfo.ModTime()) {
|
||||
timeDiff := jsonlInfo.ModTime().Sub(dbInfo.ModTime())
|
||||
if timeDiff > 30*time.Second {
|
||||
@@ -819,6 +923,54 @@ func checkDatabaseJSONLSync(path string) doctorCheck {
|
||||
}
|
||||
}
|
||||
|
||||
// countJSONLIssues counts issues in the JSONL file and returns the count, prefixes, and any error.
|
||||
func countJSONLIssues(jsonlPath string) (int, map[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, nil, fmt.Errorf("failed to open JSONL file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
count := 0
|
||||
prefixes := make(map[string]int)
|
||||
errorCount := 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 {
|
||||
errorCount++
|
||||
continue
|
||||
}
|
||||
|
||||
if id, ok := issue["id"].(string); ok {
|
||||
count++
|
||||
// Extract prefix (everything before the first dash)
|
||||
parts := strings.SplitN(id, "-", 2)
|
||||
if len(parts) > 0 {
|
||||
prefixes[parts[0]]++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return count, prefixes, fmt.Errorf("failed to read JSONL file: %w", err)
|
||||
}
|
||||
|
||||
if errorCount > 0 {
|
||||
return count, prefixes, fmt.Errorf("skipped %d malformed lines in JSONL", errorCount)
|
||||
}
|
||||
|
||||
return count, prefixes, nil
|
||||
}
|
||||
|
||||
func checkPermissions(path string) doctorCheck {
|
||||
beadsDir := filepath.Join(path, ".beads")
|
||||
|
||||
@@ -1006,13 +1158,15 @@ func checkGitHooks(path string) doctorCheck {
|
||||
}
|
||||
}
|
||||
|
||||
hookInstallMsg := "See https://github.com/steveyegge/beads/tree/main/examples/git-hooks for installation instructions"
|
||||
|
||||
if len(installedHooks) > 0 {
|
||||
return doctorCheck{
|
||||
Name: "Git Hooks",
|
||||
Status: statusWarning,
|
||||
Message: fmt.Sprintf("Missing %d recommended hook(s)", len(missingHooks)),
|
||||
Detail: fmt.Sprintf("Missing: %s", strings.Join(missingHooks, ", ")),
|
||||
Fix: "Run './examples/git-hooks/install.sh' to install recommended git hooks",
|
||||
Fix: hookInstallMsg,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1021,7 +1175,7 @@ func checkGitHooks(path string) doctorCheck {
|
||||
Status: statusWarning,
|
||||
Message: "No recommended git hooks installed",
|
||||
Detail: fmt.Sprintf("Recommended: %s", strings.Join([]string{"pre-commit", "post-merge", "pre-push"}, ", ")),
|
||||
Fix: "Run './examples/git-hooks/install.sh' to install recommended git hooks",
|
||||
Fix: hookInstallMsg,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user