Merge branch 'main' into show-rev-in-dev

# Conflicts:
#	.beads/beads.jsonl
This commit is contained in:
matt wilkie
2025-11-16 15:50:09 -07:00
26 changed files with 1645 additions and 79 deletions

View File

@@ -313,6 +313,18 @@ var createCmd = &cobra.Command{
os.Exit(1)
}
// If parent was specified, add parent-child dependency
if parentID != "" {
dep := &types.Dependency{
IssueID: issue.ID,
DependsOnID: parentID,
Type: types.DepParentChild,
}
if err := store.AddDependency(ctx, dep, actor); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to add parent-child dependency %s -> %s: %v\n", issue.ID, parentID, err)
}
}
// Add labels if specified
for _, label := range labels {
if err := store.AddLabel(ctx, issue.ID, label, actor); err != nil {

View File

@@ -54,7 +54,7 @@ func merge3WayAndPruneDeletions(ctx context.Context, store storage.Storage, json
// Ensure temp file cleanup on failure
defer func() {
if fileExists(tmpMerged) {
os.Remove(tmpMerged)
_ = os.Remove(tmpMerged)
}
}()

View File

@@ -46,6 +46,7 @@ type doctorResult struct {
var (
doctorFix bool
perfMode bool
)
var doctorCmd = &cobra.Command{
@@ -68,11 +69,19 @@ This command checks:
- Git hooks (pre-commit, post-merge, pre-push)
- .beads/.gitignore up to date
Performance Mode (--perf):
Run performance diagnostics on your database:
- Times key operations (bd ready, bd list, bd show, etc.)
- Collects system info (OS, arch, SQLite version, database stats)
- Generates CPU profile for analysis
- Outputs shareable report for bug reports
Examples:
bd doctor # Check current directory
bd doctor /path/to/repo # Check specific repository
bd doctor --json # Machine-readable output
bd doctor --fix # Automatically fix issues`,
bd doctor --fix # Automatically fix issues
bd doctor --perf # Performance diagnostics`,
Run: func(cmd *cobra.Command, args []string) {
// Use global jsonOutput set by PersistentPreRun
@@ -89,6 +98,12 @@ Examples:
os.Exit(1)
}
// Run performance diagnostics if --perf flag is set
if perfMode {
doctor.RunPerformanceDiagnostics(absPath)
return
}
// Run diagnostics
result := runDiagnostics(absPath)
@@ -1202,7 +1217,7 @@ func checkGitHooks(path string) doctorCheck {
}
}
hookInstallMsg := "See https://github.com/steveyegge/beads/tree/main/examples/git-hooks for installation instructions"
hookInstallMsg := "Install hooks with 'bd hooks install'. See https://github.com/steveyegge/beads/tree/main/examples/git-hooks for installation instructions"
if len(installedHooks) > 0 {
return doctorCheck{
@@ -1309,4 +1324,5 @@ func checkSchemaCompatibility(path string) doctorCheck {
func init() {
rootCmd.AddCommand(doctorCmd)
doctorCmd.Flags().BoolVar(&perfMode, "perf", false, "Run performance diagnostics and generate CPU profile")
}

276
cmd/bd/doctor/perf.go Normal file
View File

@@ -0,0 +1,276 @@
package doctor
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"runtime"
"runtime/pprof"
"strings"
"time"
"github.com/steveyegge/beads/internal/beads"
)
var cpuProfileFile *os.File
// RunPerformanceDiagnostics runs performance diagnostics and generates a CPU profile
func RunPerformanceDiagnostics(path string) {
fmt.Println("\nBeads Performance Diagnostics")
fmt.Println(strings.Repeat("=", 50))
// Check if .beads directory exists
beadsDir := filepath.Join(path, ".beads")
if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
fmt.Fprintf(os.Stderr, "Error: No .beads/ directory found at %s\n", path)
fmt.Fprintf(os.Stderr, "Run 'bd init' to initialize beads\n")
os.Exit(1)
}
// Get database path
dbPath := filepath.Join(beadsDir, beads.CanonicalDatabaseName)
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
fmt.Fprintf(os.Stderr, "Error: No database found at %s\n", dbPath)
os.Exit(1)
}
// Collect platform info
platformInfo := collectPlatformInfo(dbPath)
fmt.Printf("\nPlatform: %s\n", platformInfo["os_arch"])
fmt.Printf("Go: %s\n", platformInfo["go_version"])
fmt.Printf("SQLite: %s\n", platformInfo["sqlite_version"])
// Collect database stats
dbStats := collectDatabaseStats(dbPath)
fmt.Printf("\nDatabase Statistics:\n")
fmt.Printf(" Total issues: %s\n", dbStats["total_issues"])
fmt.Printf(" Open issues: %s\n", dbStats["open_issues"])
fmt.Printf(" Closed issues: %s\n", dbStats["closed_issues"])
fmt.Printf(" Dependencies: %s\n", dbStats["dependencies"])
fmt.Printf(" Labels: %s\n", dbStats["labels"])
fmt.Printf(" Database size: %s\n", dbStats["db_size"])
// Start CPU profiling
profilePath := fmt.Sprintf("beads-perf-%s.prof", time.Now().Format("2006-01-02-150405"))
if err := startCPUProfile(profilePath); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to start CPU profiling: %v\n", err)
} else {
defer stopCPUProfile()
fmt.Printf("\nCPU profiling enabled: %s\n", profilePath)
}
// Time key operations
fmt.Printf("\nOperation Performance:\n")
// Measure GetReadyWork
readyDuration := measureOperation("bd ready", func() error {
return runReadyWork(dbPath)
})
fmt.Printf(" bd ready %dms\n", readyDuration.Milliseconds())
// Measure SearchIssues (list open)
listDuration := measureOperation("bd list --status=open", func() error {
return runListOpen(dbPath)
})
fmt.Printf(" bd list --status=open %dms\n", listDuration.Milliseconds())
// Measure GetIssue (show random issue)
showDuration := measureOperation("bd show <issue>", func() error {
return runShowRandom(dbPath)
})
if showDuration > 0 {
fmt.Printf(" bd show <random-issue> %dms\n", showDuration.Milliseconds())
}
// Measure SearchIssues with filters
searchDuration := measureOperation("bd list (complex filters)", func() error {
return runComplexSearch(dbPath)
})
fmt.Printf(" bd list (complex filters) %dms\n", searchDuration.Milliseconds())
fmt.Printf("\nProfile saved: %s\n", profilePath)
fmt.Printf("Share this file with bug reports for performance issues.\n\n")
fmt.Printf("View flamegraph:\n")
fmt.Printf(" go tool pprof -http=:8080 %s\n\n", profilePath)
}
func collectPlatformInfo(dbPath string) map[string]string {
info := make(map[string]string)
// OS and architecture
info["os_arch"] = fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH)
// Go version
info["go_version"] = runtime.Version()
// SQLite version
db, err := sql.Open("sqlite3", "file:"+dbPath+"?mode=ro")
if err == nil {
defer db.Close()
var version string
if err := db.QueryRow("SELECT sqlite_version()").Scan(&version); err == nil {
info["sqlite_version"] = version
} else {
info["sqlite_version"] = "unknown"
}
} else {
info["sqlite_version"] = "unknown"
}
return info
}
func collectDatabaseStats(dbPath string) map[string]string {
stats := make(map[string]string)
db, err := sql.Open("sqlite3", "file:"+dbPath+"?mode=ro")
if err != nil {
stats["total_issues"] = "error"
stats["open_issues"] = "error"
stats["closed_issues"] = "error"
stats["dependencies"] = "error"
stats["labels"] = "error"
stats["db_size"] = "error"
return stats
}
defer db.Close()
// Total issues
var total int
if err := db.QueryRow("SELECT COUNT(*) FROM issues").Scan(&total); err == nil {
stats["total_issues"] = fmt.Sprintf("%d", total)
} else {
stats["total_issues"] = "error"
}
// Open issues
var open int
if err := db.QueryRow("SELECT COUNT(*) FROM issues WHERE status != 'closed'").Scan(&open); err == nil {
stats["open_issues"] = fmt.Sprintf("%d", open)
} else {
stats["open_issues"] = "error"
}
// Closed issues
var closed int
if err := db.QueryRow("SELECT COUNT(*) FROM issues WHERE status = 'closed'").Scan(&closed); err == nil {
stats["closed_issues"] = fmt.Sprintf("%d", closed)
} else {
stats["closed_issues"] = "error"
}
// Dependencies
var deps int
if err := db.QueryRow("SELECT COUNT(*) FROM dependencies").Scan(&deps); err == nil {
stats["dependencies"] = fmt.Sprintf("%d", deps)
} else {
stats["dependencies"] = "error"
}
// Labels
var labels int
if err := db.QueryRow("SELECT COUNT(DISTINCT label) FROM labels").Scan(&labels); err == nil {
stats["labels"] = fmt.Sprintf("%d", labels)
} else {
stats["labels"] = "error"
}
// Database file size
if info, err := os.Stat(dbPath); err == nil {
sizeMB := float64(info.Size()) / (1024 * 1024)
stats["db_size"] = fmt.Sprintf("%.2f MB", sizeMB)
} else {
stats["db_size"] = "error"
}
return stats
}
func startCPUProfile(path string) error {
f, err := os.Create(path)
if err != nil {
return err
}
cpuProfileFile = f
return pprof.StartCPUProfile(f)
}
// stopCPUProfile stops CPU profiling and closes the profile file.
// Must be called after pprof.StartCPUProfile() to flush profile data to disk.
func stopCPUProfile() {
pprof.StopCPUProfile()
if cpuProfileFile != nil {
_ = cpuProfileFile.Close() // best effort cleanup
}
}
func measureOperation(name string, op func() error) time.Duration {
start := time.Now()
if err := op(); err != nil {
return 0
}
return time.Since(start)
}
// runQuery executes a read-only database query and returns any error
func runQuery(dbPath string, queryFn func(*sql.DB) error) error {
db, err := sql.Open("sqlite3", "file:"+dbPath+"?mode=ro")
if err != nil {
return err
}
defer db.Close()
return queryFn(db)
}
func runReadyWork(dbPath string) error {
return runQuery(dbPath, func(db *sql.DB) error {
// simplified ready work query (the real one is more complex)
_, err := db.Query(`
SELECT id FROM issues
WHERE status IN ('open', 'in_progress')
AND id NOT IN (
SELECT issue_id FROM dependencies WHERE type = 'blocks'
)
LIMIT 100
`)
return err
})
}
func runListOpen(dbPath string) error {
return runQuery(dbPath, func(db *sql.DB) error {
_, err := db.Query("SELECT id, title, status FROM issues WHERE status != 'closed' LIMIT 100")
return err
})
}
func runShowRandom(dbPath string) error {
return runQuery(dbPath, func(db *sql.DB) error {
// get a random issue
var issueID string
if err := db.QueryRow("SELECT id FROM issues ORDER BY RANDOM() LIMIT 1").Scan(&issueID); err != nil {
return err
}
// get issue details
_, err := db.Query("SELECT * FROM issues WHERE id = ?", issueID)
return err
})
}
func runComplexSearch(dbPath string) error {
return runQuery(dbPath, func(db *sql.DB) error {
// complex query with filters
_, err := db.Query(`
SELECT i.id, i.title, i.status, i.priority
FROM issues i
LEFT JOIN labels l ON i.id = l.issue_id
WHERE i.status IN ('open', 'in_progress')
AND i.priority <= 2
GROUP BY i.id
LIMIT 100
`)
return err
})
}

View File

@@ -545,7 +545,7 @@ func attemptAutoMerge(conflictedPath string) error {
if err != nil {
return fmt.Errorf("failed to create temp directory: %w", err)
}
defer os.RemoveAll(tmpDir)
defer func() { _ = os.RemoveAll(tmpDir) }()
basePath := filepath.Join(tmpDir, "base.jsonl")
leftPath := filepath.Join(tmpDir, "left.jsonl")

View File

@@ -4,6 +4,8 @@ import (
"fmt"
"os"
"path/filepath"
"runtime/pprof"
"runtime/trace"
"slices"
"sync"
"time"
@@ -78,6 +80,9 @@ var (
noAutoImport bool
sandboxMode bool
noDb bool // Use --no-db mode: load from JSONL, write back after each command
profileEnabled bool
profileFile *os.File
traceFile *os.File
)
func init() {
@@ -95,6 +100,7 @@ func init() {
rootCmd.PersistentFlags().BoolVar(&noAutoImport, "no-auto-import", false, "Disable automatic JSONL import when newer than DB")
rootCmd.PersistentFlags().BoolVar(&sandboxMode, "sandbox", false, "Sandbox mode: disables daemon and auto-sync")
rootCmd.PersistentFlags().BoolVar(&noDb, "no-db", false, "Use no-db mode: load from JSONL, no SQLite")
rootCmd.PersistentFlags().BoolVar(&profileEnabled, "profile", false, "Generate CPU profile for performance analysis")
// Add --version flag to root command (same behavior as version subcommand)
rootCmd.Flags().BoolP("version", "v", false, "Print version information")
@@ -141,6 +147,23 @@ var rootCmd = &cobra.Command{
actor = config.GetString("actor")
}
// Performance profiling setup
// When --profile is enabled, force direct mode to capture actual database operations
// rather than just RPC serialization/network overhead. This gives accurate profiles
// of the storage layer, query performance, and business logic.
if profileEnabled {
noDaemon = true
timestamp := time.Now().Format("20060102-150405")
if f, _ := os.Create(fmt.Sprintf("bd-profile-%s-%s.prof", cmd.Name(), timestamp)); f != nil {
profileFile = f
_ = pprof.StartCPUProfile(f)
}
if f, _ := os.Create(fmt.Sprintf("bd-trace-%s-%s.out", cmd.Name(), timestamp)); f != nil {
traceFile = f
_ = trace.Start(f)
}
}
// Skip database initialization for commands that don't need a database
noDbCommands := []string{
cmdDaemon,
@@ -505,6 +528,8 @@ var rootCmd = &cobra.Command{
if store != nil {
_ = store.Close()
}
if profileFile != nil { pprof.StopCPUProfile(); _ = profileFile.Close() }
if traceFile != nil { trace.Stop(); _ = traceFile.Close() }
},
}

View File

@@ -202,7 +202,7 @@ func sendAgentMailRequest(config *AgentMailConfig, method string, params interfa
if err != nil {
return nil, fmt.Errorf("failed to connect to Agent Mail server: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {

View File

@@ -630,7 +630,7 @@ func displayMigrationPlan(plan migrationPlan, dryRun bool) error {
func confirmMigration(plan migrationPlan) bool {
fmt.Printf("\nMigrate %d issues from %s to %s? [y/N] ", len(plan.IssueIDs), plan.From, plan.To)
var response string
fmt.Scanln(&response)
_, _ = fmt.Scanln(&response)
return strings.ToLower(strings.TrimSpace(response)) == "y"
}
@@ -698,6 +698,6 @@ func init() {
migrateIssuesCmd.Flags().Bool("strict", false, "Fail on orphaned dependencies or missing repos")
migrateIssuesCmd.Flags().Bool("yes", false, "Skip confirmation prompt")
migrateIssuesCmd.MarkFlagRequired("from")
migrateIssuesCmd.MarkFlagRequired("to")
_ = migrateIssuesCmd.MarkFlagRequired("from")
_ = migrateIssuesCmd.MarkFlagRequired("to")
}

View File

@@ -607,11 +607,11 @@ Examples:
// Write current value to temp file
if _, err := tmpFile.WriteString(currentValue); err != nil {
tmpFile.Close()
_ = tmpFile.Close()
fmt.Fprintf(os.Stderr, "Error writing to temp file: %v\n", err)
os.Exit(1)
}
tmpFile.Close()
_ = tmpFile.Close()
// Open the editor
editorCmd := exec.Command(editor, tmpPath)

View File

@@ -1,30 +0,0 @@
package main
import (
"testing"
"github.com/steveyegge/beads/internal/types"
)
func TestFormatDependencyType(t *testing.T) {
tests := []struct {
name string
depType types.DependencyType
expected string
}{
{"blocks", types.DepBlocks, "blocks"},
{"related", types.DepRelated, "related"},
{"parent-child", types.DepParentChild, "parent-child"},
{"discovered-from", types.DepDiscoveredFrom, "discovered-from"},
{"unknown", types.DependencyType("unknown"), "unknown"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := formatDependencyType(tt.depType)
if result != tt.expected {
t.Errorf("formatDependencyType(%v) = %v, want %v", tt.depType, result, tt.expected)
}
})
}
}