Merge branch 'main' into show-rev-in-dev
# Conflicts: # .beads/beads.jsonl
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}()
|
||||
|
||||
|
||||
@@ -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
276
cmd/bd/doctor/perf.go
Normal 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
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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() }
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user