refactor: Split large cmd/bd files to meet 800-line limit (bd-xtf5)
Split 6 files exceeding 800 lines by extracting cohesive function groups: - show.go (1592→578): extracted show_thread.go, close.go, edit.go, update.go - doctor.go (1295→690): extracted doctor_fix.go, doctor_health.go, doctor_pollution.go - sync.go (1201→749): extracted sync_git.go - compact.go (1199→775): extracted compact_tombstone.go, compact_rpc.go - linear.go (1190→641): extracted linear_sync.go, linear_conflict.go - main.go (1148→800): extracted main_help.go, main_errors.go, main_daemon.go All files now under 800-line acceptance criteria. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
348
cmd/bd/main.go
348
cmd/bd/main.go
@@ -2,16 +2,13 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime/pprof"
|
||||
"runtime/trace"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
@@ -26,40 +23,9 @@ import (
|
||||
"github.com/steveyegge/beads/internal/storage"
|
||||
"github.com/steveyegge/beads/internal/storage/memory"
|
||||
"github.com/steveyegge/beads/internal/storage/sqlite"
|
||||
"github.com/steveyegge/beads/internal/ui"
|
||||
"github.com/steveyegge/beads/internal/utils"
|
||||
)
|
||||
|
||||
// DaemonStatus captures daemon connection state for the current command
|
||||
type DaemonStatus struct {
|
||||
Mode string `json:"mode"` // "daemon" or "direct"
|
||||
Connected bool `json:"connected"`
|
||||
Degraded bool `json:"degraded"`
|
||||
SocketPath string `json:"socket_path,omitempty"`
|
||||
AutoStartEnabled bool `json:"auto_start_enabled"`
|
||||
AutoStartAttempted bool `json:"auto_start_attempted"`
|
||||
AutoStartSucceeded bool `json:"auto_start_succeeded"`
|
||||
FallbackReason string `json:"fallback_reason,omitempty"` // "none","flag_no_daemon","connect_failed","health_failed","auto_start_disabled","auto_start_failed"
|
||||
Detail string `json:"detail,omitempty"` // short diagnostic
|
||||
Health string `json:"health,omitempty"` // "healthy","degraded","unhealthy"
|
||||
}
|
||||
|
||||
// Fallback reason constants
|
||||
const (
|
||||
FallbackNone = "none"
|
||||
FallbackFlagNoDaemon = "flag_no_daemon"
|
||||
FallbackConnectFailed = "connect_failed"
|
||||
FallbackHealthFailed = "health_failed"
|
||||
FallbackWorktreeSafety = "worktree_safety"
|
||||
cmdDaemon = "daemon"
|
||||
cmdImport = "import"
|
||||
statusHealthy = "healthy"
|
||||
FallbackAutoStartDisabled = "auto_start_disabled"
|
||||
FallbackAutoStartFailed = "auto_start_failed"
|
||||
FallbackDaemonUnsupported = "daemon_unsupported"
|
||||
FallbackWispOperation = "wisp_operation"
|
||||
)
|
||||
|
||||
var (
|
||||
dbPath string
|
||||
actor string
|
||||
@@ -101,7 +67,6 @@ var (
|
||||
previousVersion = "" // The last bd version user had (empty = first run or unknown)
|
||||
upgradeAcknowledged = false // Set to true after showing upgrade notification once per session
|
||||
)
|
||||
|
||||
var (
|
||||
noAutoFlush bool
|
||||
noAutoImport bool
|
||||
@@ -117,12 +82,6 @@ var (
|
||||
quietFlag bool // Suppress non-essential output
|
||||
)
|
||||
|
||||
// Command group IDs for help organization
|
||||
const (
|
||||
GroupMaintenance = "maintenance"
|
||||
GroupIntegrations = "integrations"
|
||||
)
|
||||
|
||||
func init() {
|
||||
// Initialize viper configuration
|
||||
if err := config.Initialize(); err != nil {
|
||||
@@ -171,132 +130,6 @@ func init() {
|
||||
rootCmd.SetHelpFunc(colorizedHelpFunc)
|
||||
}
|
||||
|
||||
// colorizedHelpFunc wraps Cobra's default help with semantic coloring
|
||||
// Applies subtle accent color to group headers for visual hierarchy
|
||||
func colorizedHelpFunc(cmd *cobra.Command, args []string) {
|
||||
// Build full help output: Long description + Usage
|
||||
var output strings.Builder
|
||||
|
||||
// Include Long description first (like Cobra's default help)
|
||||
if cmd.Long != "" {
|
||||
output.WriteString(cmd.Long)
|
||||
output.WriteString("\n\n")
|
||||
} else if cmd.Short != "" {
|
||||
output.WriteString(cmd.Short)
|
||||
output.WriteString("\n\n")
|
||||
}
|
||||
|
||||
// Add the usage string which contains commands, flags, etc.
|
||||
output.WriteString(cmd.UsageString())
|
||||
|
||||
// Apply semantic coloring
|
||||
result := colorizeHelpOutput(output.String())
|
||||
fmt.Print(result)
|
||||
}
|
||||
|
||||
// colorizeHelpOutput applies semantic colors to help text
|
||||
// - Group headers get accent color for visual hierarchy
|
||||
// - Section headers (Examples:, Flags:) get accent color
|
||||
// - Command names get subtle styling for scanability
|
||||
// - Flag names get bold styling, types get muted
|
||||
// - Default values get muted styling
|
||||
func colorizeHelpOutput(help string) string {
|
||||
// Match group header lines (e.g., "Working With Issues:")
|
||||
// These are standalone lines ending with ":" and followed by commands
|
||||
groupHeaderRE := regexp.MustCompile(`(?m)^([A-Z][A-Za-z &]+:)\s*$`)
|
||||
|
||||
result := groupHeaderRE.ReplaceAllStringFunc(help, func(match string) string {
|
||||
// Trim whitespace, colorize, then restore
|
||||
trimmed := strings.TrimSpace(match)
|
||||
return ui.RenderAccent(trimmed)
|
||||
})
|
||||
|
||||
// Match section headers in subcommand help (Examples:, Flags:, etc.)
|
||||
sectionHeaderRE := regexp.MustCompile(`(?m)^(Examples|Flags|Usage|Global Flags|Aliases|Available Commands):`)
|
||||
result = sectionHeaderRE.ReplaceAllStringFunc(result, func(match string) string {
|
||||
return ui.RenderAccent(match)
|
||||
})
|
||||
|
||||
// Match command lines: " command Description text"
|
||||
// Commands are indented with 2 spaces, followed by spaces, then description
|
||||
// Pattern matches: indent + command-name (with hyphens) + spacing + description
|
||||
cmdLineRE := regexp.MustCompile(`(?m)^( )([a-z][a-z0-9]*(?:-[a-z0-9]+)*)(\s{2,})(.*)$`)
|
||||
|
||||
result = cmdLineRE.ReplaceAllStringFunc(result, func(match string) string {
|
||||
parts := cmdLineRE.FindStringSubmatch(match)
|
||||
if len(parts) != 5 {
|
||||
return match
|
||||
}
|
||||
indent := parts[1]
|
||||
cmdName := parts[2]
|
||||
spacing := parts[3]
|
||||
description := parts[4]
|
||||
|
||||
// Colorize command references in description (e.g., 'comments add')
|
||||
description = colorizeCommandRefs(description)
|
||||
|
||||
// Highlight entry point hints (e.g., "(start here)")
|
||||
description = highlightEntryPoints(description)
|
||||
|
||||
// Subtle styling on command name for scanability
|
||||
return indent + ui.RenderCommand(cmdName) + spacing + description
|
||||
})
|
||||
|
||||
// Match flag lines: " -f, --file string Description"
|
||||
// Pattern: indent + flags + spacing + optional type + description
|
||||
flagLineRE := regexp.MustCompile(`(?m)^(\s+)(-\w,\s+--[\w-]+|--[\w-]+)(\s+)(string|int|duration|bool)?(\s*.*)$`)
|
||||
result = flagLineRE.ReplaceAllStringFunc(result, func(match string) string {
|
||||
parts := flagLineRE.FindStringSubmatch(match)
|
||||
if len(parts) < 6 {
|
||||
return match
|
||||
}
|
||||
indent := parts[1]
|
||||
flags := parts[2]
|
||||
spacing := parts[3]
|
||||
typeStr := parts[4]
|
||||
desc := parts[5]
|
||||
|
||||
// Mute default values in description
|
||||
desc = muteDefaults(desc)
|
||||
|
||||
if typeStr != "" {
|
||||
return indent + ui.RenderCommand(flags) + spacing + ui.RenderMuted(typeStr) + desc
|
||||
}
|
||||
return indent + ui.RenderCommand(flags) + spacing + desc
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// muteDefaults applies muted styling to default value annotations
|
||||
func muteDefaults(text string) string {
|
||||
defaultRE := regexp.MustCompile(`(\(default[^)]*\))`)
|
||||
return defaultRE.ReplaceAllStringFunc(text, func(match string) string {
|
||||
return ui.RenderMuted(match)
|
||||
})
|
||||
}
|
||||
|
||||
// highlightEntryPoints applies accent styling to entry point hints like "(start here)"
|
||||
func highlightEntryPoints(text string) string {
|
||||
entryRE := regexp.MustCompile(`(\(start here\))`)
|
||||
return entryRE.ReplaceAllStringFunc(text, func(match string) string {
|
||||
return ui.RenderAccent(match)
|
||||
})
|
||||
}
|
||||
|
||||
// colorizeCommandRefs applies command styling to references in text
|
||||
// Matches patterns like 'command name' or 'bd command'
|
||||
func colorizeCommandRefs(text string) string {
|
||||
// Match 'command words' in single quotes (e.g., 'comments add')
|
||||
cmdRefRE := regexp.MustCompile(`'([a-z][a-z0-9 -]+)'`)
|
||||
|
||||
return cmdRefRE.ReplaceAllStringFunc(text, func(match string) string {
|
||||
// Extract the command name without quotes
|
||||
inner := match[1 : len(match)-1]
|
||||
return "'" + ui.RenderCommand(inner) + "'"
|
||||
})
|
||||
}
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "bd",
|
||||
Short: "bd - Dependency-aware issue tracker",
|
||||
@@ -960,189 +793,8 @@ var rootCmd = &cobra.Command{
|
||||
},
|
||||
}
|
||||
|
||||
// getDebounceDuration returns the auto-flush debounce duration
|
||||
// Configurable via config file or BEADS_FLUSH_DEBOUNCE env var (e.g., "500ms", "10s")
|
||||
// Defaults to 5 seconds if not set or invalid
|
||||
|
||||
// signalGasTownActivity writes an activity signal for Gas Town daemon.
|
||||
// This enables exponential backoff based on bd usage detection.
|
||||
// Best-effort: silent on any failure, never affects bd operation.
|
||||
func signalGasTownActivity() {
|
||||
// Determine town root
|
||||
// Priority: GT_ROOT env > detect from cwd path > skip
|
||||
townRoot := os.Getenv("GT_ROOT")
|
||||
if townRoot == "" {
|
||||
// Try to detect from cwd - if under ~/gt/, use that as town root
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
gtRoot := filepath.Join(home, "gt")
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(cwd, gtRoot+string(os.PathSeparator)) {
|
||||
townRoot = gtRoot
|
||||
}
|
||||
}
|
||||
|
||||
if townRoot == "" {
|
||||
return // Not in Gas Town, skip
|
||||
}
|
||||
|
||||
// Ensure daemon directory exists
|
||||
daemonDir := filepath.Join(townRoot, "daemon")
|
||||
if err := os.MkdirAll(daemonDir, 0755); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Build command line from os.Args
|
||||
cmdLine := strings.Join(os.Args, " ")
|
||||
|
||||
// Determine actor (use package-level var if set, else fall back to env)
|
||||
actorName := actor
|
||||
if actorName == "" {
|
||||
if bdActor := os.Getenv("BD_ACTOR"); bdActor != "" {
|
||||
actorName = bdActor
|
||||
} else if user := os.Getenv("USER"); user != "" {
|
||||
actorName = user
|
||||
} else {
|
||||
actorName = "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// Build activity signal
|
||||
activity := struct {
|
||||
LastCommand string `json:"last_command"`
|
||||
Actor string `json:"actor"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
}{
|
||||
LastCommand: cmdLine,
|
||||
Actor: actorName,
|
||||
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
data, err := json.Marshal(activity)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Write atomically (write to temp, rename)
|
||||
activityPath := filepath.Join(daemonDir, "activity.json")
|
||||
tmpPath := activityPath + ".tmp"
|
||||
// nolint:gosec // G306: 0644 is appropriate for a status file
|
||||
if err := os.WriteFile(tmpPath, data, 0644); err != nil {
|
||||
return
|
||||
}
|
||||
_ = os.Rename(tmpPath, activityPath)
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// isFreshCloneError checks if the error is due to a fresh clone scenario
|
||||
// where the database exists but is missing required config (like issue_prefix).
|
||||
// This happens when someone clones a repo with beads but needs to initialize.
|
||||
func isFreshCloneError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
errStr := err.Error()
|
||||
// Check for the specific migration invariant error pattern
|
||||
return strings.Contains(errStr, "post-migration validation failed") &&
|
||||
strings.Contains(errStr, "required config key missing: issue_prefix")
|
||||
}
|
||||
|
||||
// handleFreshCloneError displays a helpful message when a fresh clone is detected
|
||||
// and returns true if the error was handled (so caller should exit).
|
||||
// If not a fresh clone error, returns false and does nothing.
|
||||
func handleFreshCloneError(err error, beadsDir string) bool {
|
||||
if !isFreshCloneError(err) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Look for JSONL file in the .beads directory
|
||||
jsonlPath := ""
|
||||
issueCount := 0
|
||||
|
||||
if beadsDir != "" {
|
||||
// Check for issues.jsonl (canonical) first, then beads.jsonl (legacy)
|
||||
for _, name := range []string{"issues.jsonl", "beads.jsonl"} {
|
||||
candidate := filepath.Join(beadsDir, name)
|
||||
if info, statErr := os.Stat(candidate); statErr == nil && !info.IsDir() {
|
||||
jsonlPath = candidate
|
||||
// Count lines (approximately = issue count)
|
||||
// #nosec G304 -- candidate is constructed from beadsDir which is .beads/
|
||||
if data, readErr := os.ReadFile(candidate); readErr == nil {
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
if strings.TrimSpace(line) != "" {
|
||||
issueCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "Error: Database not initialized\n\n")
|
||||
fmt.Fprintf(os.Stderr, "This appears to be a fresh clone or the database needs initialization.\n")
|
||||
|
||||
if jsonlPath != "" && issueCount > 0 {
|
||||
fmt.Fprintf(os.Stderr, "Found: %s (%d issues)\n\n", jsonlPath, issueCount)
|
||||
fmt.Fprintf(os.Stderr, "To initialize from the JSONL file, run:\n")
|
||||
fmt.Fprintf(os.Stderr, " bd import -i %s\n\n", jsonlPath)
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "\nTo initialize a new database, run:\n")
|
||||
fmt.Fprintf(os.Stderr, " bd init --prefix <your-prefix>\n\n")
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "For more information: bd init --help\n")
|
||||
return true
|
||||
}
|
||||
|
||||
// isWispOperation returns true if the command operates on ephemeral wisps.
|
||||
// Wisp operations auto-bypass the daemon because wisps are local-only
|
||||
// (Ephemeral=true issues are never exported to JSONL).
|
||||
// Detects:
|
||||
// - mol wisp subcommands (create, list, gc, or direct proto invocation)
|
||||
// - mol burn (only operates on wisps)
|
||||
// - mol squash (condenses wisps to digests)
|
||||
// - Commands with ephemeral issue IDs in args (bd-*-eph-*, eph-*)
|
||||
func isWispOperation(cmd *cobra.Command, args []string) bool {
|
||||
cmdName := cmd.Name()
|
||||
|
||||
// Check command hierarchy for wisp subcommands
|
||||
// bd mol wisp → parent is "mol", cmd is "wisp"
|
||||
// bd mol wisp create → parent is "wisp", cmd is "create"
|
||||
if cmd.Parent() != nil {
|
||||
parentName := cmd.Parent().Name()
|
||||
// Direct wisp command or subcommands under wisp
|
||||
if parentName == "wisp" || cmdName == "wisp" {
|
||||
return true
|
||||
}
|
||||
// mol burn and mol squash are wisp-only operations
|
||||
if parentName == "mol" && (cmdName == "burn" || cmdName == "squash") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Check for ephemeral issue IDs in arguments
|
||||
// Ephemeral IDs have "eph" segment: bd-eph-xxx, gt-eph-xxx, eph-xxx
|
||||
for _, arg := range args {
|
||||
// Skip flags
|
||||
if strings.HasPrefix(arg, "-") {
|
||||
continue
|
||||
}
|
||||
// Check for ephemeral prefix patterns
|
||||
if strings.Contains(arg, "-eph-") || strings.HasPrefix(arg, "eph-") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user