From 297c696336c8c63bcd559f6da86cc57f8641fbc1 Mon Sep 17 00:00:00 2001 From: Ryan <2199132+rsnodgrass@users.noreply.github.com> Date: Tue, 23 Dec 2025 13:29:35 -0800 Subject: [PATCH] feat(doctor): add count-based database size check (#724) feat(doctor): add count-based database size check Adds CheckDatabaseSize (Check 29) that warns when closed issues exceed a configurable threshold (default: 5000). The check is informational only - no auto-fix since pruning is destructive. Also improves fix guidance for sync-branch hook compatibility and legacy JSONL filename checks. PR #724 by @rsnodgrass --- cmd/bd/doctor.go | 10 +++++ cmd/bd/doctor/database.go | 89 +++++++++++++++++++++++++++++++++++++++ cmd/bd/doctor/git.go | 2 + cmd/bd/doctor/legacy.go | 2 +- 4 files changed, 102 insertions(+), 1 deletion(-) diff --git a/cmd/bd/doctor.go b/cmd/bd/doctor.go index 5e020566..93ea199c 100644 --- a/cmd/bd/doctor.go +++ b/cmd/bd/doctor.go @@ -422,6 +422,10 @@ func applyFixList(path string, fixes []doctorCheck) { // No auto-fix: compaction requires agent review fmt.Printf(" ⚠ Run 'bd compact --analyze' to review candidates\n") continue + case "Large Database": + // No auto-fix: pruning deletes data, must be user-controlled + fmt.Printf(" ⚠ Run 'bd cleanup --older-than 90' to prune old closed issues\n") + continue default: fmt.Printf(" ⚠ No automatic fix available for %s\n", check.Name) fmt.Printf(" Manual fix: %s\n", check.Fix) @@ -817,6 +821,12 @@ func runDiagnostics(path string) doctorResult { result.Checks = append(result.Checks, compactionCheck) // Info only, not a warning - compaction requires human review + // Check 29: Database size (pruning suggestion) + // Note: This check has no auto-fix - pruning is destructive and user-controlled + sizeCheck := convertDoctorCheck(doctor.CheckDatabaseSize(path)) + result.Checks = append(result.Checks, sizeCheck) + // Don't fail overall check for size warning, just inform + return result } diff --git a/cmd/bd/doctor/database.go b/cmd/bd/doctor/database.go index 674a6c17..56782367 100644 --- a/cmd/bd/doctor/database.go +++ b/cmd/bd/doctor/database.go @@ -620,3 +620,92 @@ func isNoDbModeConfigured(beadsDir string) bool { return cfg.NoDb } + +// CheckDatabaseSize warns when the database has accumulated many closed issues. +// This is purely informational - pruning is NEVER auto-fixed because it +// permanently deletes data. Users must explicitly run 'bd cleanup' to prune. +// +// Config: doctor.suggest_pruning_issue_count (default: 5000, 0 = disabled) +// +// DESIGN NOTE: This check intentionally has NO auto-fix. Unlike other doctor +// checks that fix configuration or sync issues, pruning is destructive and +// irreversible. The user must make an explicit decision to delete their +// closed issue history. We only provide guidance, never action. +func CheckDatabaseSize(path string) DoctorCheck { + beadsDir := filepath.Join(path, ".beads") + + // Get database path + 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) + } + + // If no database, skip this check + if _, err := os.Stat(dbPath); os.IsNotExist(err) { + return DoctorCheck{ + Name: "Large Database", + Status: StatusOK, + Message: "N/A (no database)", + } + } + + // Read threshold from config (default 5000, 0 = disabled) + threshold := 5000 + db, err := sql.Open("sqlite3", "file:"+dbPath+"?mode=ro&_pragma=busy_timeout(30000)") + if err != nil { + return DoctorCheck{ + Name: "Large Database", + Status: StatusOK, + Message: "N/A (unable to open database)", + } + } + defer db.Close() + + // Check for custom threshold in config table + var thresholdStr string + err = db.QueryRow("SELECT value FROM config WHERE key = ?", "doctor.suggest_pruning_issue_count").Scan(&thresholdStr) + if err == nil { + if _, err := fmt.Sscanf(thresholdStr, "%d", &threshold); err != nil { + threshold = 5000 // Reset to default on parse error + } + } + + // If disabled, return OK + if threshold == 0 { + return DoctorCheck{ + Name: "Large Database", + Status: StatusOK, + Message: "Check disabled (threshold = 0)", + } + } + + // Count closed issues + var closedCount int + err = db.QueryRow("SELECT COUNT(*) FROM issues WHERE status = 'closed'").Scan(&closedCount) + if err != nil { + return DoctorCheck{ + Name: "Large Database", + Status: StatusOK, + Message: "N/A (unable to count issues)", + } + } + + // Check against threshold + if closedCount > threshold { + return DoctorCheck{ + Name: "Large Database", + Status: StatusWarning, + Message: fmt.Sprintf("%d closed issues (threshold: %d)", closedCount, threshold), + Detail: "Large number of closed issues may impact performance", + Fix: "Consider running 'bd cleanup --older-than 90' to prune old closed issues", + } + } + + return DoctorCheck{ + Name: "Large Database", + Status: StatusOK, + Message: fmt.Sprintf("%d closed issues (threshold: %d)", closedCount, threshold), + } +} diff --git a/cmd/bd/doctor/git.go b/cmd/bd/doctor/git.go index 99687b7c..ab373ff7 100644 --- a/cmd/bd/doctor/git.go +++ b/cmd/bd/doctor/git.go @@ -145,6 +145,8 @@ func CheckSyncBranchHookCompatibility(path string) DoctorCheck { Status: StatusWarning, Message: "Pre-push hook is not a bd hook", Detail: "Cannot verify sync-branch compatibility with custom hooks", + Fix: "Either run 'bd hooks install --force' to use bd hooks,\n" + + " or ensure your custom hook skips validation when pushing to sync-branch", } } diff --git a/cmd/bd/doctor/legacy.go b/cmd/bd/doctor/legacy.go index 3f5112b9..27b6f985 100644 --- a/cmd/bd/doctor/legacy.go +++ b/cmd/bd/doctor/legacy.go @@ -188,7 +188,7 @@ func CheckLegacyJSONLFilename(repoPath string) DoctorCheck { Detail: "Having multiple JSONL files can cause sync and merge conflicts.\n" + " Only one JSONL file should be used per repository.", Fix: "Determine which file is current and remove the others:\n" + - " 1. Check 'bd stats' to see which file is being used\n" + + " 1. Check .beads/metadata.json for 'jsonl_export' setting\n" + " 2. Verify with 'git log .beads/*.jsonl' to see commit history\n" + " 3. Remove the unused file(s): git rm .beads/.jsonl\n" + " 4. Commit the change",