bd sync: 2025-12-23 13:49:07

This commit is contained in:
Steve Yegge
2025-12-23 13:49:07 -08:00
parent 37ec967619
commit 7b671662aa
28 changed files with 1192 additions and 5622 deletions

View File

@@ -7,6 +7,7 @@ import (
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"time"
@@ -52,7 +53,6 @@ var (
doctorInteractive bool // bd-3xl: per-fix confirmation mode
doctorDryRun bool // bd-a5z: preview fixes without applying
doctorOutput string // bd-9cc: export diagnostics to file
doctorVerbose bool // bd-4qfb: show all checks including passed
perfMode bool
checkHealthMode bool
)
@@ -422,10 +422,6 @@ 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)
@@ -821,12 +817,6 @@ 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
}
@@ -868,118 +858,136 @@ func exportDiagnostics(result doctorResult, outputPath string) error {
}
func printDiagnostics(result doctorResult) {
// Count checks by status and collect into categories
var passCount, warnCount, failCount int
var errors, warnings []doctorCheck
passedByCategory := make(map[string][]doctorCheck)
for _, check := range result.Checks {
switch check.Status {
case statusOK:
passCount++
cat := check.Category
if cat == "" {
cat = "Other"
}
passedByCategory[cat] = append(passedByCategory[cat], check)
case statusWarning:
warnCount++
warnings = append(warnings, check)
case statusError:
failCount++
errors = append(errors, check)
}
}
// Print header with version and summary at TOP
// Print header with version
fmt.Printf("\nbd doctor v%s\n\n", result.CLIVersion)
fmt.Printf("Summary: %d checks passed, %d warnings, %d errors\n", passCount, warnCount, failCount)
// Print errors section (always shown if any)
if failCount > 0 {
fmt.Println()
fmt.Println(ui.RenderSeparator())
fmt.Printf("%s Errors (%d)\n", ui.RenderFailIcon(), failCount)
fmt.Println(ui.RenderSeparator())
fmt.Println()
// Group checks by category
checksByCategory := make(map[string][]doctorCheck)
for _, check := range result.Checks {
cat := check.Category
if cat == "" {
cat = "Other"
}
checksByCategory[cat] = append(checksByCategory[cat], check)
}
for _, check := range errors {
fmt.Printf("[%s] %s\n", check.Name, check.Message)
// Track counts
var passCount, warnCount, failCount int
var warnings []doctorCheck
// Print checks by category in defined order
for _, category := range doctor.CategoryOrder {
checks, exists := checksByCategory[category]
if !exists || len(checks) == 0 {
continue
}
// Print category header
fmt.Println(ui.RenderCategory(category))
// Print each check in this category
for _, check := range checks {
// Determine status icon
var statusIcon string
switch check.Status {
case statusOK:
statusIcon = ui.RenderPassIcon()
passCount++
case statusWarning:
statusIcon = ui.RenderWarnIcon()
warnCount++
warnings = append(warnings, check)
case statusError:
statusIcon = ui.RenderFailIcon()
failCount++
warnings = append(warnings, check)
}
// Print check line: icon + name + message
fmt.Printf(" %s %s", statusIcon, check.Name)
if check.Message != "" {
fmt.Printf("%s", ui.RenderMuted(" "+check.Message))
}
fmt.Println()
// Print detail if present (indented)
if check.Detail != "" {
fmt.Printf(" %s\n", check.Detail)
fmt.Printf(" %s%s\n", ui.MutedStyle.Render(ui.TreeLast), ui.RenderMuted(check.Detail))
}
}
fmt.Println()
}
// Print any checks without a category
if otherChecks, exists := checksByCategory["Other"]; exists && len(otherChecks) > 0 {
fmt.Println(ui.RenderCategory("Other"))
for _, check := range otherChecks {
var statusIcon string
switch check.Status {
case statusOK:
statusIcon = ui.RenderPassIcon()
passCount++
case statusWarning:
statusIcon = ui.RenderWarnIcon()
warnCount++
warnings = append(warnings, check)
case statusError:
statusIcon = ui.RenderFailIcon()
failCount++
warnings = append(warnings, check)
}
fmt.Printf(" %s %s", statusIcon, check.Name)
if check.Message != "" {
fmt.Printf("%s", ui.RenderMuted(" "+check.Message))
}
fmt.Println()
if check.Detail != "" {
fmt.Printf(" %s%s\n", ui.MutedStyle.Render(ui.TreeLast), ui.RenderMuted(check.Detail))
}
}
fmt.Println()
}
// Print summary line
fmt.Println(ui.RenderSeparator())
summary := fmt.Sprintf("%s %d passed %s %d warnings %s %d failed",
ui.RenderPassIcon(), passCount,
ui.RenderWarnIcon(), warnCount,
ui.RenderFailIcon(), failCount,
)
fmt.Println(summary)
// Print warnings/errors section with fixes
if len(warnings) > 0 {
fmt.Println()
fmt.Println(ui.RenderWarn(ui.IconWarn + " WARNINGS"))
// Sort by severity: errors first, then warnings
slices.SortStableFunc(warnings, func(a, b doctorCheck) int {
// Errors (statusError) come before warnings (statusWarning)
if a.Status == statusError && b.Status != statusError {
return -1
}
if a.Status != statusError && b.Status == statusError {
return 1
}
return 0 // maintain original order within same severity
})
for i, check := range warnings {
// Show numbered items with icon and color based on status
// Errors get entire line in red, warnings just the number in yellow
line := fmt.Sprintf("%s: %s", check.Name, check.Message)
if check.Status == statusError {
fmt.Printf(" %s %s %s\n", ui.RenderFailIcon(), ui.RenderFail(fmt.Sprintf("%d.", i+1)), ui.RenderFail(line))
} else {
fmt.Printf(" %s %s %s\n", ui.RenderWarnIcon(), ui.RenderWarn(fmt.Sprintf("%d.", i+1)), line)
}
if check.Fix != "" {
fmt.Printf(" Fix: %s\n", check.Fix)
fmt.Printf(" %s%s\n", ui.MutedStyle.Render(ui.TreeLast), check.Fix)
}
fmt.Println()
}
}
// Print warnings section (always shown if any)
if warnCount > 0 {
fmt.Println(ui.RenderSeparator())
fmt.Printf("%s Warnings (%d)\n", ui.RenderWarnIcon(), warnCount)
fmt.Println(ui.RenderSeparator())
fmt.Println()
for _, check := range warnings {
fmt.Printf("[%s] %s\n", check.Name, check.Message)
if check.Detail != "" {
fmt.Printf(" %s\n", check.Detail)
}
if check.Fix != "" {
fmt.Printf(" Fix: %s\n", check.Fix)
}
fmt.Println()
}
}
// Print passed section
if passCount > 0 {
fmt.Println(ui.RenderSeparator())
if doctorVerbose {
// Verbose mode: show all passed checks grouped by category
fmt.Printf("%s Passed (%d)\n", ui.RenderPassIcon(), passCount)
fmt.Println(ui.RenderSeparator())
fmt.Println()
for _, category := range doctor.CategoryOrder {
checks, exists := passedByCategory[category]
if !exists || len(checks) == 0 {
continue
}
fmt.Printf(" %s\n", category)
for _, check := range checks {
fmt.Printf(" %s %s", ui.RenderPassIcon(), check.Name)
if check.Message != "" {
fmt.Printf(" %s", ui.RenderMuted(check.Message))
}
fmt.Println()
}
fmt.Println()
}
// Print "Other" category if exists
if otherChecks, exists := passedByCategory["Other"]; exists && len(otherChecks) > 0 {
fmt.Printf(" %s\n", "Other")
for _, check := range otherChecks {
fmt.Printf(" %s %s", ui.RenderPassIcon(), check.Name)
if check.Message != "" {
fmt.Printf(" %s", ui.RenderMuted(check.Message))
}
fmt.Println()
}
fmt.Println()
}
} else {
// Default mode: collapsed summary
fmt.Printf("%s Passed (%d) %s\n", ui.RenderPassIcon(), passCount, ui.RenderMuted("[use --verbose to show details]"))
fmt.Println(ui.RenderSeparator())
}
}
// Final status message
if failCount == 0 && warnCount == 0 {
} else {
fmt.Println()
fmt.Printf("%s\n", ui.RenderPass("✓ All checks passed"))
}
@@ -990,5 +998,4 @@ func init() {
doctorCmd.Flags().BoolVar(&perfMode, "perf", false, "Run performance diagnostics and generate CPU profile")
doctorCmd.Flags().BoolVar(&checkHealthMode, "check-health", false, "Quick health check for git hooks (silent on success)")
doctorCmd.Flags().StringVarP(&doctorOutput, "output", "o", "", "Export diagnostics to JSON file (bd-9cc)")
doctorCmd.Flags().BoolVarP(&doctorVerbose, "verbose", "v", false, "Show all checks including passed (bd-4qfb)")
}

View File

@@ -620,92 +620,3 @@ 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),
}
}

View File

@@ -145,8 +145,6 @@ 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",
}
}

View File

@@ -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 .beads/metadata.json for 'jsonl_export' setting\n" +
" 1. Check 'bd stats' to see which file is being used\n" +
" 2. Verify with 'git log .beads/*.jsonl' to see commit history\n" +
" 3. Remove the unused file(s): git rm .beads/<unused>.jsonl\n" +
" 4. Commit the change",

View File

@@ -26,14 +26,9 @@ Examples:
bd search "database" --label backend --limit 10
bd search --query "performance" --assignee alice
bd search "bd-5q" # Search by partial ID
bd search "security" --priority 1 # Exact priority match
bd search "security" --priority-min 0 --priority-max 2 # Priority range
bd search "security" --priority-min 0 --priority-max 2
bd search "bug" --created-after 2025-01-01
bd search "refactor" --updated-after 2025-01-01 --priority-min 1
bd search "bug" --desc-contains "authentication" # Search in description
bd search "" --empty-description # Issues without description
bd search "" --no-assignee # Unassigned issues
bd search "" --no-labels # Issues without labels
bd search "bug" --sort priority
bd search "task" --sort created --reverse`,
Run: func(cmd *cobra.Command, args []string) {
@@ -46,31 +41,9 @@ Examples:
query = queryFlag
}
// Check if any filter flags are set (allows empty query with filters)
hasFilters := cmd.Flags().Changed("status") ||
cmd.Flags().Changed("priority") ||
cmd.Flags().Changed("assignee") ||
cmd.Flags().Changed("type") ||
cmd.Flags().Changed("label") ||
cmd.Flags().Changed("label-any") ||
cmd.Flags().Changed("created-after") ||
cmd.Flags().Changed("created-before") ||
cmd.Flags().Changed("updated-after") ||
cmd.Flags().Changed("updated-before") ||
cmd.Flags().Changed("closed-after") ||
cmd.Flags().Changed("closed-before") ||
cmd.Flags().Changed("priority-min") ||
cmd.Flags().Changed("priority-max") ||
cmd.Flags().Changed("title-contains") ||
cmd.Flags().Changed("desc-contains") ||
cmd.Flags().Changed("notes-contains") ||
cmd.Flags().Changed("empty-description") ||
cmd.Flags().Changed("no-assignee") ||
cmd.Flags().Changed("no-labels")
// If no query and no filters provided, show help
if query == "" && !hasFilters {
fmt.Fprintf(os.Stderr, "Error: search query or filter is required\n")
// If no query provided, show help
if query == "" {
fmt.Fprintf(os.Stderr, "Error: search query is required\n")
if err := cmd.Help(); err != nil {
fmt.Fprintf(os.Stderr, "Error displaying help: %v\n", err)
}
@@ -88,11 +61,6 @@ Examples:
sortBy, _ := cmd.Flags().GetString("sort")
reverse, _ := cmd.Flags().GetBool("reverse")
// Pattern matching flags
titleContains, _ := cmd.Flags().GetString("title-contains")
descContains, _ := cmd.Flags().GetString("desc-contains")
notesContains, _ := cmd.Flags().GetString("notes-contains")
// Date range flags
createdAfter, _ := cmd.Flags().GetString("created-after")
createdBefore, _ := cmd.Flags().GetString("created-before")
@@ -101,11 +69,6 @@ Examples:
closedAfter, _ := cmd.Flags().GetString("closed-after")
closedBefore, _ := cmd.Flags().GetString("closed-before")
// Empty/null check flags
emptyDesc, _ := cmd.Flags().GetBool("empty-description")
noAssignee, _ := cmd.Flags().GetBool("no-assignee")
noLabels, _ := cmd.Flags().GetBool("no-labels")
// Priority range flags
priorityMinStr, _ := cmd.Flags().GetString("priority-min")
priorityMaxStr, _ := cmd.Flags().GetString("priority-max")
@@ -141,39 +104,6 @@ Examples:
filter.LabelsAny = labelsAny
}
// Exact priority match (use Changed() to properly handle P0)
if cmd.Flags().Changed("priority") {
priorityStr, _ := cmd.Flags().GetString("priority")
priority, err := validation.ValidatePriority(priorityStr)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
filter.Priority = &priority
}
// Pattern matching
if titleContains != "" {
filter.TitleContains = titleContains
}
if descContains != "" {
filter.DescriptionContains = descContains
}
if notesContains != "" {
filter.NotesContains = notesContains
}
// Empty/null checks
if emptyDesc {
filter.EmptyDescription = true
}
if noAssignee {
filter.NoAssignee = true
}
if noLabels {
filter.NoLabels = true
}
// Date ranges
if createdAfter != "" {
t, err := parseTimeFlag(createdAfter)
@@ -270,21 +200,6 @@ Examples:
listArgs.LabelsAny = labelsAny
}
// Exact priority match
if filter.Priority != nil {
listArgs.Priority = filter.Priority
}
// Pattern matching
listArgs.TitleContains = titleContains
listArgs.DescriptionContains = descContains
listArgs.NotesContains = notesContains
// Empty/null checks
listArgs.EmptyDescription = filter.EmptyDescription
listArgs.NoAssignee = filter.NoAssignee
listArgs.NoLabels = filter.NoLabels
// Date ranges
if filter.CreatedAfter != nil {
listArgs.CreatedAfter = filter.CreatedAfter.Format(time.RFC3339)
@@ -457,7 +372,6 @@ func outputSearchResults(issues []*types.Issue, query string, longFormat bool) {
func init() {
searchCmd.Flags().String("query", "", "Search query (alternative to positional argument)")
searchCmd.Flags().StringP("status", "s", "", "Filter by status (open, in_progress, blocked, deferred, closed)")
registerPriorityFlag(searchCmd, "")
searchCmd.Flags().StringP("assignee", "a", "", "Filter by assignee")
searchCmd.Flags().StringP("type", "t", "", "Filter by type (bug, feature, task, epic, chore, merge-request, molecule, gate)")
searchCmd.Flags().StringSliceP("label", "l", []string{}, "Filter by labels (AND: must have ALL)")
@@ -467,11 +381,6 @@ func init() {
searchCmd.Flags().String("sort", "", "Sort by field: priority, created, updated, closed, status, id, title, type, assignee")
searchCmd.Flags().BoolP("reverse", "r", false, "Reverse sort order")
// Pattern matching flags
searchCmd.Flags().String("title-contains", "", "Filter by title substring (case-insensitive)")
searchCmd.Flags().String("desc-contains", "", "Filter by description substring (case-insensitive)")
searchCmd.Flags().String("notes-contains", "", "Filter by notes substring (case-insensitive)")
// Date range flags
searchCmd.Flags().String("created-after", "", "Filter issues created after date (YYYY-MM-DD or RFC3339)")
searchCmd.Flags().String("created-before", "", "Filter issues created before date (YYYY-MM-DD or RFC3339)")
@@ -480,11 +389,6 @@ func init() {
searchCmd.Flags().String("closed-after", "", "Filter issues closed after date (YYYY-MM-DD or RFC3339)")
searchCmd.Flags().String("closed-before", "", "Filter issues closed before date (YYYY-MM-DD or RFC3339)")
// Empty/null check flags
searchCmd.Flags().Bool("empty-description", false, "Filter issues with empty or missing description")
searchCmd.Flags().Bool("no-assignee", false, "Filter issues with no assignee")
searchCmd.Flags().Bool("no-labels", false, "Filter issues with no labels")
// Priority range flags
searchCmd.Flags().String("priority-min", "", "Filter by minimum priority (inclusive, 0-4 or P0-P4)")
searchCmd.Flags().String("priority-max", "", "Filter by maximum priority (inclusive, 0-4 or P0-P4)")

View File

@@ -972,10 +972,6 @@ var closeCmd = &cobra.Command{
Run: func(cmd *cobra.Command, args []string) {
CheckReadonly("close")
reason, _ := cmd.Flags().GetString("reason")
// Check --resolution alias if --reason not provided
if reason == "" {
reason, _ = cmd.Flags().GetString("resolution")
}
if reason == "" {
reason = "Closed"
}
@@ -1057,8 +1053,6 @@ var closeCmd = &cobra.Command{
if hookRunner != nil {
hookRunner.Run(hooks.EventClose, &issue)
}
// Run config-based close hooks (bd-g4b4)
hooks.RunConfigCloseHooks(ctx, &issue)
if jsonOutput {
closedIssues = append(closedIssues, &issue)
}
@@ -1111,12 +1105,8 @@ var closeCmd = &cobra.Command{
// Run close hook (bd-kwro.8)
closedIssue, _ := store.GetIssue(ctx, id)
if closedIssue != nil {
if hookRunner != nil {
hookRunner.Run(hooks.EventClose, closedIssue)
}
// Run config-based close hooks (bd-g4b4)
hooks.RunConfigCloseHooks(ctx, closedIssue)
if closedIssue != nil && hookRunner != nil {
hookRunner.Run(hooks.EventClose, closedIssue)
}
if jsonOutput {
@@ -1421,8 +1411,6 @@ func init() {
rootCmd.AddCommand(editCmd)
closeCmd.Flags().StringP("reason", "r", "", "Reason for closing")
closeCmd.Flags().String("resolution", "", "Alias for --reason (Jira CLI convention)")
_ = closeCmd.Flags().MarkHidden("resolution") // Hidden alias for agent/CLI ergonomics
closeCmd.Flags().Bool("json", false, "Output JSON format")
closeCmd.Flags().BoolP("force", "f", false, "Force close pinned issues")
closeCmd.Flags().Bool("continue", false, "Auto-advance to next step in molecule")

View File

@@ -1,16 +0,0 @@
# Test bd close --resolution alias (GH#721)
# Jira CLI convention: --resolution instead of --reason
bd init --prefix test
# Create issue
bd create 'Issue to close with resolution'
cp stdout issue.txt
exec sh -c 'grep -oE "test-[a-z0-9]+" issue.txt > issue_id.txt'
# Close using --resolution alias
exec sh -c 'bd close $(cat issue_id.txt) --resolution "Fixed via resolution alias"'
stdout 'Closed test-'
# Verify close_reason is set correctly
exec sh -c 'bd show $(cat issue_id.txt) --json'
stdout 'Fixed via resolution alias'