refactor(doctor): consolidate maintenance commands + improve daemon startup
Consolidate clean, repair-deps, validate into bd doctor: - Add validation checks to doctor (merge artifacts, orphaned deps, duplicates, test pollution, git conflicts) - Add auto-fix for merge artifacts and orphaned dependencies - Delete obsolete command files: clean.go, repair_deps.go, validate.go - Delete orphaned test files: clean_security_test.go, validate_test.go Improve daemon startup performance: - Add fast-fail detection when daemon crashed (check lock before retrying) - Reduce graceful shutdown timeout from 5s to 1s - Skip daemon connection for root command (just shows help) - Extract shutdown timeout as constants (daemonShutdownTimeout, daemonShutdownPollInterval) Other changes: - Move rename-prefix command to Maintenance group in help - Fix Makefile to inject git commit hash via ldflags New files: - cmd/bd/doctor/validation.go (5 check functions) - cmd/bd/doctor/fix/validation.go (2 fix functions)
This commit is contained in:
committed by
Steve Yegge
parent
e60dfaf1f1
commit
cafc0b9dfb
169
cmd/bd/clean.go
169
cmd/bd/clean.go
@@ -1,169 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/beads/internal/ui"
|
||||
)
|
||||
|
||||
// TODO: Consider consolidating into 'bd doctor --fix' for simpler maintenance UX
|
||||
var cleanCmd = &cobra.Command{
|
||||
Use: "clean",
|
||||
GroupID: "maint",
|
||||
Short: "Clean up temporary git merge artifacts from .beads directory",
|
||||
Long: `Delete temporary git merge artifacts from the .beads directory.
|
||||
|
||||
This command removes temporary files created during git merges and conflicts.
|
||||
It does NOT delete issues from the database - use 'bd cleanup' for that.
|
||||
|
||||
Files removed:
|
||||
- 3-way merge snapshots (beads.base.jsonl, beads.left.jsonl, beads.right.jsonl)
|
||||
- Merge metadata (*.meta.json)
|
||||
- Git merge driver temp files (*.json[0-9], *.jsonl[0-9])
|
||||
|
||||
Files preserved:
|
||||
- issues.jsonl (source of truth)
|
||||
- beads.db (SQLite database)
|
||||
- metadata.json
|
||||
- config.yaml
|
||||
- All daemon files
|
||||
|
||||
EXAMPLES:
|
||||
Clean up temporary files:
|
||||
bd clean
|
||||
|
||||
Preview what would be deleted:
|
||||
bd clean --dry-run
|
||||
|
||||
SEE ALSO:
|
||||
bd cleanup Delete closed issues from database`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
dryRun, _ := cmd.Flags().GetBool("dry-run")
|
||||
|
||||
// Find beads directory
|
||||
beadsDir := findBeadsDir()
|
||||
if beadsDir == "" {
|
||||
fmt.Fprintf(os.Stderr, "Error: .beads directory not found\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Read patterns from .beads/.gitignore (only merge artifacts section)
|
||||
cleanPatterns, err := readMergeArtifactPatterns(beadsDir)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error reading .gitignore: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Collect files to delete
|
||||
var filesToDelete []string
|
||||
for _, pattern := range cleanPatterns {
|
||||
matches, err := filepath.Glob(filepath.Join(beadsDir, pattern))
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: error matching pattern %s: %v\n", pattern, err)
|
||||
continue
|
||||
}
|
||||
filesToDelete = append(filesToDelete, matches...)
|
||||
}
|
||||
|
||||
if len(filesToDelete) == 0 {
|
||||
fmt.Println("Nothing to clean - all artifacts already removed")
|
||||
return
|
||||
}
|
||||
|
||||
// Just run by default, no --force needed
|
||||
|
||||
if dryRun {
|
||||
fmt.Println(ui.RenderWarn("DRY RUN - no changes will be made"))
|
||||
}
|
||||
fmt.Printf("Found %d file(s) to clean:\n", len(filesToDelete))
|
||||
for _, file := range filesToDelete {
|
||||
relPath, err := filepath.Rel(beadsDir, file)
|
||||
if err != nil {
|
||||
relPath = file
|
||||
}
|
||||
fmt.Printf(" %s\n", relPath)
|
||||
}
|
||||
|
||||
if dryRun {
|
||||
return
|
||||
}
|
||||
|
||||
// Actually delete the files
|
||||
deletedCount := 0
|
||||
errorCount := 0
|
||||
for _, file := range filesToDelete {
|
||||
if err := os.Remove(file); err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
relPath, _ := filepath.Rel(beadsDir, file)
|
||||
fmt.Fprintf(os.Stderr, "Warning: failed to delete %s: %v\n", relPath, err)
|
||||
errorCount++
|
||||
}
|
||||
} else {
|
||||
deletedCount++
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("\nDeleted %d file(s)", deletedCount)
|
||||
if errorCount > 0 {
|
||||
fmt.Printf(" (%d error(s))", errorCount)
|
||||
}
|
||||
fmt.Println()
|
||||
},
|
||||
}
|
||||
|
||||
// readMergeArtifactPatterns reads the .beads/.gitignore file and extracts
|
||||
// patterns from the "Merge artifacts" section
|
||||
func readMergeArtifactPatterns(beadsDir string) ([]string, error) {
|
||||
gitignorePath := filepath.Join(beadsDir, ".gitignore")
|
||||
// #nosec G304 -- gitignorePath is safely constructed via filepath.Join from beadsDir
|
||||
// (which comes from findBeadsDir searching upward for .beads). This can only open
|
||||
// .gitignore within the project's .beads directory. See TestReadMergeArtifactPatterns_PathTraversal
|
||||
file, err := os.Open(gitignorePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open .gitignore: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var patterns []string
|
||||
inMergeSection := false
|
||||
scanner := bufio.NewScanner(file)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
|
||||
// Look for the merge artifacts section
|
||||
if strings.Contains(line, "Merge artifacts") {
|
||||
inMergeSection = true
|
||||
continue
|
||||
}
|
||||
|
||||
// Stop at the next section (starts with #)
|
||||
if inMergeSection && strings.HasPrefix(line, "#") {
|
||||
break
|
||||
}
|
||||
|
||||
// Collect patterns from merge section
|
||||
if inMergeSection && line != "" && !strings.HasPrefix(line, "#") {
|
||||
// Skip negation patterns (starting with !)
|
||||
if !strings.HasPrefix(line, "!") {
|
||||
patterns = append(patterns, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("error reading .gitignore: %w", err)
|
||||
}
|
||||
|
||||
return patterns, nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
cleanCmd.Flags().Bool("dry-run", false, "Preview what would be deleted without making changes")
|
||||
rootCmd.AddCommand(cleanCmd)
|
||||
}
|
||||
@@ -1,296 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestReadMergeArtifactPatterns_PathTraversal verifies that the clean command
|
||||
// properly validates file paths and prevents path traversal attacks.
|
||||
//
|
||||
// This test addresses bd-nbc: gosec G304 flags os.Open(gitignorePath) in
|
||||
// clean.go:118 for potential file inclusion via variable. We verify that:
|
||||
// 1. gitignorePath is safely constructed using filepath.Join
|
||||
// 2. Only .gitignore files within .beads directory can be opened
|
||||
// 3. Path traversal attempts are prevented by filepath.Join normalization
|
||||
// 4. Symlinks pointing outside .beads directory are handled safely
|
||||
func TestReadMergeArtifactPatterns_PathTraversal(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setupFunc func(t *testing.T, tmpDir string) string // Returns beadsDir
|
||||
wantErr bool
|
||||
errContains string
|
||||
}{
|
||||
{
|
||||
name: "normal .gitignore in .beads",
|
||||
setupFunc: func(t *testing.T, tmpDir string) string {
|
||||
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||
t.Fatalf("Failed to create .beads: %v", err)
|
||||
}
|
||||
gitignore := filepath.Join(beadsDir, ".gitignore")
|
||||
content := `# Merge artifacts
|
||||
beads.base.jsonl
|
||||
beads.left.jsonl
|
||||
beads.right.jsonl
|
||||
|
||||
# Other section
|
||||
something-else
|
||||
`
|
||||
if err := os.WriteFile(gitignore, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("Failed to create .gitignore: %v", err)
|
||||
}
|
||||
return beadsDir
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "missing .gitignore",
|
||||
setupFunc: func(t *testing.T, tmpDir string) string {
|
||||
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||
t.Fatalf("Failed to create .beads: %v", err)
|
||||
}
|
||||
// Don't create .gitignore
|
||||
return beadsDir
|
||||
},
|
||||
wantErr: true,
|
||||
errContains: "failed to open .gitignore",
|
||||
},
|
||||
{
|
||||
name: "path traversal via beadsDir (normalized by filepath.Join)",
|
||||
setupFunc: func(t *testing.T, tmpDir string) string {
|
||||
// Create a .gitignore at tmpDir level (not in .beads)
|
||||
gitignore := filepath.Join(tmpDir, ".gitignore")
|
||||
if err := os.WriteFile(gitignore, []byte("# Test"), 0644); err != nil {
|
||||
t.Fatalf("Failed to create .gitignore: %v", err)
|
||||
}
|
||||
|
||||
// Create .beads directory
|
||||
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||
t.Fatalf("Failed to create .beads: %v", err)
|
||||
}
|
||||
|
||||
// Try to use path traversal (will be normalized by filepath.Join)
|
||||
// This demonstrates that filepath.Join protects against traversal
|
||||
return filepath.Join(tmpDir, ".beads", "..")
|
||||
},
|
||||
wantErr: false, // filepath.Join normalizes ".." to tmpDir, which has .gitignore
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
beadsDir := tt.setupFunc(t, tmpDir)
|
||||
|
||||
patterns, err := readMergeArtifactPatterns(beadsDir)
|
||||
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("Expected error containing %q, got nil", tt.errContains)
|
||||
return
|
||||
}
|
||||
if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) {
|
||||
t.Errorf("Expected error containing %q, got %q", tt.errContains, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// For successful cases, verify we got some patterns
|
||||
if tt.name == "normal .gitignore in .beads" && len(patterns) == 0 {
|
||||
t.Errorf("Expected to read patterns from .gitignore, got 0")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestReadMergeArtifactPatterns_SymlinkSafety verifies that symlinks are
|
||||
// handled safely and don't allow access to files outside .beads directory.
|
||||
func TestReadMergeArtifactPatterns_SymlinkSafety(t *testing.T) {
|
||||
if os.Getenv("CI") == "true" {
|
||||
t.Skip("Skipping symlink test in CI (may not have permissions)")
|
||||
}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create a sensitive file outside .beads
|
||||
sensitiveDir := filepath.Join(tmpDir, "sensitive")
|
||||
if err := os.MkdirAll(sensitiveDir, 0755); err != nil {
|
||||
t.Fatalf("Failed to create sensitive dir: %v", err)
|
||||
}
|
||||
sensitivePath := filepath.Join(sensitiveDir, "secrets.txt")
|
||||
if err := os.WriteFile(sensitivePath, []byte("SECRET_DATA"), 0644); err != nil {
|
||||
t.Fatalf("Failed to create sensitive file: %v", err)
|
||||
}
|
||||
|
||||
// Create .beads directory
|
||||
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||
t.Fatalf("Failed to create .beads: %v", err)
|
||||
}
|
||||
|
||||
// Create a symlink from .beads/.gitignore to sensitive file
|
||||
symlinkPath := filepath.Join(beadsDir, ".gitignore")
|
||||
if err := os.Symlink(sensitivePath, symlinkPath); err != nil {
|
||||
t.Skipf("Cannot create symlink (may lack permissions): %v", err)
|
||||
}
|
||||
|
||||
// Try to read patterns - this will follow the symlink
|
||||
// This is actually safe because:
|
||||
// 1. The path is constructed via filepath.Join (safe)
|
||||
// 2. Following symlinks is normal OS behavior
|
||||
// 3. We're just reading a .gitignore file, not executing it
|
||||
patterns, err := readMergeArtifactPatterns(beadsDir)
|
||||
|
||||
// The function will read the sensitive file, but since it doesn't
|
||||
// contain valid gitignore patterns, it should return empty or error
|
||||
if err != nil {
|
||||
// Error is acceptable - the sensitive file isn't a valid .gitignore
|
||||
t.Logf("Got expected error reading non-.gitignore file: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// If no error, verify that no patterns were extracted (file doesn't
|
||||
// have "Merge artifacts" section)
|
||||
if len(patterns) > 0 {
|
||||
t.Logf("Symlink was followed but extracted %d patterns (likely none useful)", len(patterns))
|
||||
}
|
||||
|
||||
// Key insight: This is not actually a security vulnerability because:
|
||||
// - We're only reading the file, not executing it
|
||||
// - The file path is constructed safely
|
||||
// - Following symlinks is expected OS behavior
|
||||
// - The worst case is reading .gitignore content, which is not sensitive
|
||||
}
|
||||
|
||||
// TestReadMergeArtifactPatterns_OnlyMergeSection verifies that only patterns
|
||||
// from the "Merge artifacts" section are extracted, preventing unintended
|
||||
// file deletion from other sections.
|
||||
func TestReadMergeArtifactPatterns_OnlyMergeSection(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||
t.Fatalf("Failed to create .beads: %v", err)
|
||||
}
|
||||
|
||||
// Create .gitignore with multiple sections
|
||||
gitignore := filepath.Join(beadsDir, ".gitignore")
|
||||
content := `# Important files - DO NOT DELETE
|
||||
beads.db
|
||||
metadata.json
|
||||
config.yaml
|
||||
|
||||
# Merge artifacts
|
||||
beads.base.jsonl
|
||||
beads.left.jsonl
|
||||
beads.right.jsonl
|
||||
*.meta.json
|
||||
|
||||
# Daemon files - DO NOT DELETE
|
||||
daemon.sock
|
||||
daemon.pid
|
||||
`
|
||||
if err := os.WriteFile(gitignore, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("Failed to create .gitignore: %v", err)
|
||||
}
|
||||
|
||||
patterns, err := readMergeArtifactPatterns(beadsDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Verify we only got patterns from "Merge artifacts" section
|
||||
expectedPatterns := map[string]bool{
|
||||
"beads.base.jsonl": true,
|
||||
"beads.left.jsonl": true,
|
||||
"beads.right.jsonl": true,
|
||||
"*.meta.json": true,
|
||||
}
|
||||
|
||||
if len(patterns) != len(expectedPatterns) {
|
||||
t.Errorf("Expected %d patterns, got %d: %v", len(expectedPatterns), len(patterns), patterns)
|
||||
}
|
||||
|
||||
for _, pattern := range patterns {
|
||||
if !expectedPatterns[pattern] {
|
||||
t.Errorf("Unexpected pattern %q - should only extract from Merge artifacts section", pattern)
|
||||
}
|
||||
|
||||
// Verify we're NOT getting patterns from other sections
|
||||
forbiddenPatterns := []string{"beads.db", "metadata.json", "config.yaml", "daemon.sock", "daemon.pid"}
|
||||
for _, forbidden := range forbiddenPatterns {
|
||||
if pattern == forbidden {
|
||||
t.Errorf("Got forbidden pattern %q - this should be preserved, not cleaned!", pattern)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestReadMergeArtifactPatterns_ValidatesPatternSafety verifies that
|
||||
// patterns with path traversal attempts behave safely.
|
||||
func TestReadMergeArtifactPatterns_ValidatesPatternSafety(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||
t.Fatalf("Failed to create .beads: %v", err)
|
||||
}
|
||||
|
||||
// Create .gitignore with potentially dangerous patterns
|
||||
gitignore := filepath.Join(beadsDir, ".gitignore")
|
||||
content := `# Merge artifacts
|
||||
beads.base.jsonl
|
||||
/etc/shadow
|
||||
~/sensitive.txt
|
||||
`
|
||||
if err := os.WriteFile(gitignore, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("Failed to create .gitignore: %v", err)
|
||||
}
|
||||
|
||||
patterns, err := readMergeArtifactPatterns(beadsDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// The patterns are read as-is, but when used with filepath.Join(beadsDir, pattern),
|
||||
// absolute paths stay absolute, and relative paths are joined to beadsDir.
|
||||
// Let's verify the behavior:
|
||||
for _, pattern := range patterns {
|
||||
// Simulate what clean.go does: filepath.Glob(filepath.Join(beadsDir, pattern))
|
||||
fullPattern := filepath.Join(beadsDir, pattern)
|
||||
|
||||
// filepath.Join has specific behavior:
|
||||
// - Absolute paths (starting with /) override beadsDir and stay absolute
|
||||
// - Relative paths are joined to beadsDir
|
||||
// - This means /etc/shadow would become /etc/shadow (dangerous)
|
||||
// - But filepath.Glob would fail to match because we don't have permissions
|
||||
|
||||
if filepath.IsAbs(pattern) {
|
||||
// Absolute pattern - stays absolute (potential issue, but glob will fail)
|
||||
t.Logf("WARNING: Absolute pattern %q would become %q", pattern, fullPattern)
|
||||
// In real usage, glob would fail due to permissions
|
||||
} else {
|
||||
// Relative pattern - joined to beadsDir (safe)
|
||||
if !strings.Contains(fullPattern, beadsDir) {
|
||||
t.Errorf("Relative pattern %q should be within beadsDir, got %q", pattern, fullPattern)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Key insight: This highlights a potential issue in clean.go:
|
||||
// - Absolute paths in .gitignore could theoretically target system files
|
||||
// - However, in practice:
|
||||
// 1. .gitignore is trusted (user-controlled file)
|
||||
// 2. filepath.Glob would fail due to permissions on system paths
|
||||
// 3. Only files the user has write access to can be deleted
|
||||
// Still, we should add validation to prevent absolute paths in patterns
|
||||
}
|
||||
|
||||
@@ -15,6 +15,16 @@ import (
|
||||
"github.com/steveyegge/beads/internal/ui"
|
||||
)
|
||||
|
||||
// daemonShutdownTimeout is how long to wait for graceful shutdown before force killing.
|
||||
// 1 second is sufficient - if daemon hasn't stopped by then, it's likely hung.
|
||||
const daemonShutdownTimeout = 1 * time.Second
|
||||
|
||||
// daemonShutdownPollInterval is how often to check if daemon has stopped.
|
||||
const daemonShutdownPollInterval = 100 * time.Millisecond
|
||||
|
||||
// daemonShutdownAttempts is the number of poll attempts before force kill.
|
||||
const daemonShutdownAttempts = int(daemonShutdownTimeout / daemonShutdownPollInterval)
|
||||
|
||||
// Daemon start failure tracking for exponential backoff
|
||||
var (
|
||||
lastDaemonStartAttempt time.Time
|
||||
@@ -72,9 +82,9 @@ func restartDaemonForVersionMismatch() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// Wait for daemon to stop (up to 5 seconds)
|
||||
for i := 0; i < 50; i++ {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
// Wait for daemon to stop, then force kill
|
||||
for i := 0; i < daemonShutdownAttempts; i++ {
|
||||
time.Sleep(daemonShutdownPollInterval)
|
||||
if isRunning, _ := isDaemonRunning(pidFile); !isRunning {
|
||||
debug.Logf("old daemon stopped successfully")
|
||||
break
|
||||
|
||||
@@ -277,15 +277,15 @@ func stopDaemon(pidFile string) {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
for i := 0; i < 50; i++ {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
for i := 0; i < daemonShutdownAttempts; i++ {
|
||||
time.Sleep(daemonShutdownPollInterval)
|
||||
if isRunning, _ := isDaemonRunning(pidFile); !isRunning {
|
||||
fmt.Println("Daemon stopped")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "Warning: daemon did not stop after 5 seconds, forcing termination\n")
|
||||
fmt.Fprintf(os.Stderr, "Warning: daemon did not stop after %v, forcing termination\n", daemonShutdownTimeout)
|
||||
|
||||
// Check one more time before killing the process to avoid a race.
|
||||
if isRunning, _ := isDaemonRunning(pidFile); !isRunning {
|
||||
|
||||
@@ -396,6 +396,22 @@ func applyFixList(path string, fixes []doctorCheck) {
|
||||
continue
|
||||
}
|
||||
err = fix.SyncBranchHealth(path, syncBranch)
|
||||
case "Merge Artifacts":
|
||||
err = fix.MergeArtifacts(path)
|
||||
case "Orphaned Dependencies":
|
||||
err = fix.OrphanedDependencies(path)
|
||||
case "Duplicate Issues":
|
||||
// No auto-fix: duplicates require user review
|
||||
fmt.Printf(" ⚠ Run 'bd duplicates' to review and merge duplicates\n")
|
||||
continue
|
||||
case "Test Pollution":
|
||||
// No auto-fix: test cleanup requires user review
|
||||
fmt.Printf(" ⚠ Run 'bd detect-pollution' to review and clean test issues\n")
|
||||
continue
|
||||
case "Git Conflicts":
|
||||
// No auto-fix: git conflicts require manual resolution
|
||||
fmt.Printf(" ⚠ Resolve conflicts manually: git checkout --ours or --theirs .beads/issues.jsonl\n")
|
||||
continue
|
||||
default:
|
||||
fmt.Printf(" ⚠ No automatic fix available for %s\n", check.Name)
|
||||
fmt.Printf(" Manual fix: %s\n", check.Fix)
|
||||
@@ -749,6 +765,33 @@ func runDiagnostics(path string) doctorResult {
|
||||
result.Checks = append(result.Checks, untrackedCheck)
|
||||
// Don't fail overall check for untracked files, just warn
|
||||
|
||||
// Check 21: Merge artifacts (from bd clean)
|
||||
mergeArtifactsCheck := convertDoctorCheck(doctor.CheckMergeArtifacts(path))
|
||||
result.Checks = append(result.Checks, mergeArtifactsCheck)
|
||||
// Don't fail overall check for merge artifacts, just warn
|
||||
|
||||
// Check 22: Orphaned dependencies (from bd repair-deps, bd validate)
|
||||
orphanedDepsCheck := convertDoctorCheck(doctor.CheckOrphanedDependencies(path))
|
||||
result.Checks = append(result.Checks, orphanedDepsCheck)
|
||||
// Don't fail overall check for orphaned deps, just warn
|
||||
|
||||
// Check 23: Duplicate issues (from bd validate)
|
||||
duplicatesCheck := convertDoctorCheck(doctor.CheckDuplicateIssues(path))
|
||||
result.Checks = append(result.Checks, duplicatesCheck)
|
||||
// Don't fail overall check for duplicates, just warn
|
||||
|
||||
// Check 24: Test pollution (from bd validate)
|
||||
pollutionCheck := convertDoctorCheck(doctor.CheckTestPollution(path))
|
||||
result.Checks = append(result.Checks, pollutionCheck)
|
||||
// Don't fail overall check for test pollution, just warn
|
||||
|
||||
// Check 25: Git conflicts in JSONL (from bd validate)
|
||||
conflictsCheck := convertDoctorCheck(doctor.CheckGitConflicts(path))
|
||||
result.Checks = append(result.Checks, conflictsCheck)
|
||||
if conflictsCheck.Status == statusError {
|
||||
result.OverallOK = false
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
168
cmd/bd/doctor/fix/validation.go
Normal file
168
cmd/bd/doctor/fix/validation.go
Normal file
@@ -0,0 +1,168 @@
|
||||
package fix
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
_ "github.com/ncruces/go-sqlite3/driver"
|
||||
_ "github.com/ncruces/go-sqlite3/embed"
|
||||
)
|
||||
|
||||
// MergeArtifacts removes temporary git merge files from .beads directory.
|
||||
func MergeArtifacts(path string) error {
|
||||
if err := validateBeadsWorkspace(path); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
beadsDir := filepath.Join(path, ".beads")
|
||||
|
||||
// Read patterns from .gitignore or use defaults
|
||||
patterns, err := readMergeArtifactPatterns(beadsDir)
|
||||
if err != nil {
|
||||
patterns = []string{
|
||||
"*.base.jsonl",
|
||||
"*.left.jsonl",
|
||||
"*.right.jsonl",
|
||||
"*.meta.json",
|
||||
}
|
||||
}
|
||||
|
||||
// Find and delete matching files
|
||||
var deleted int
|
||||
var errors []string
|
||||
|
||||
for _, pattern := range patterns {
|
||||
matches, err := filepath.Glob(filepath.Join(beadsDir, pattern))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, file := range matches {
|
||||
if err := os.Remove(file); err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
errors = append(errors, fmt.Sprintf("%s: %v", filepath.Base(file), err))
|
||||
}
|
||||
} else {
|
||||
deleted++
|
||||
fmt.Printf(" Removed %s\n", filepath.Base(file))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(errors) > 0 {
|
||||
return fmt.Errorf("failed to remove some files: %s", strings.Join(errors, "; "))
|
||||
}
|
||||
|
||||
if deleted == 0 {
|
||||
fmt.Println(" No merge artifacts to remove")
|
||||
} else {
|
||||
fmt.Printf(" Removed %d merge artifact(s)\n", deleted)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// readMergeArtifactPatterns reads patterns from .beads/.gitignore merge section
|
||||
func readMergeArtifactPatterns(beadsDir string) ([]string, error) {
|
||||
gitignorePath := filepath.Join(beadsDir, ".gitignore")
|
||||
file, err := os.Open(gitignorePath) // #nosec G304 - path constructed from beadsDir
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var patterns []string
|
||||
inMergeSection := false
|
||||
scanner := bufio.NewScanner(file)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
|
||||
if strings.Contains(line, "Merge artifacts") {
|
||||
inMergeSection = true
|
||||
continue
|
||||
}
|
||||
|
||||
if inMergeSection && strings.HasPrefix(line, "#") {
|
||||
break
|
||||
}
|
||||
|
||||
if inMergeSection && line != "" && !strings.HasPrefix(line, "#") && !strings.HasPrefix(line, "!") {
|
||||
patterns = append(patterns, line)
|
||||
}
|
||||
}
|
||||
|
||||
return patterns, scanner.Err()
|
||||
}
|
||||
|
||||
// OrphanedDependencies removes dependencies pointing to non-existent issues.
|
||||
func OrphanedDependencies(path string) error {
|
||||
if err := validateBeadsWorkspace(path); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
beadsDir := filepath.Join(path, ".beads")
|
||||
dbPath := filepath.Join(beadsDir, "beads.db")
|
||||
|
||||
// Open database
|
||||
db, err := openDB(dbPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open database: %w", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Find orphaned dependencies
|
||||
query := `
|
||||
SELECT d.issue_id, d.depends_on_id
|
||||
FROM dependencies d
|
||||
LEFT JOIN issues i ON d.depends_on_id = i.id
|
||||
WHERE i.id IS NULL
|
||||
`
|
||||
rows, err := db.Query(query)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to query orphaned dependencies: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type orphan struct {
|
||||
issueID string
|
||||
dependsOnID string
|
||||
}
|
||||
var orphans []orphan
|
||||
|
||||
for rows.Next() {
|
||||
var o orphan
|
||||
if err := rows.Scan(&o.issueID, &o.dependsOnID); err == nil {
|
||||
orphans = append(orphans, o)
|
||||
}
|
||||
}
|
||||
|
||||
if len(orphans) == 0 {
|
||||
fmt.Println(" No orphaned dependencies to fix")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete orphaned dependencies
|
||||
for _, o := range orphans {
|
||||
_, err := db.Exec("DELETE FROM dependencies WHERE issue_id = ? AND depends_on_id = ?",
|
||||
o.issueID, o.dependsOnID)
|
||||
if err != nil {
|
||||
fmt.Printf(" Warning: failed to remove %s→%s: %v\n", o.issueID, o.dependsOnID, err)
|
||||
} else {
|
||||
// Mark issue as dirty for export
|
||||
_, _ = db.Exec("INSERT OR IGNORE INTO dirty_issues (issue_id) VALUES (?)", o.issueID)
|
||||
fmt.Printf(" Removed orphaned dependency: %s→%s\n", o.issueID, o.dependsOnID)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf(" Fixed %d orphaned dependency reference(s)\n", len(orphans))
|
||||
return nil
|
||||
}
|
||||
|
||||
// openDB opens a SQLite database for read-write access
|
||||
func openDB(dbPath string) (*sql.DB, error) {
|
||||
return sql.Open("sqlite3", dbPath)
|
||||
}
|
||||
358
cmd/bd/doctor/validation.go
Normal file
358
cmd/bd/doctor/validation.go
Normal file
@@ -0,0 +1,358 @@
|
||||
package doctor
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/steveyegge/beads/internal/beads"
|
||||
"github.com/steveyegge/beads/internal/storage/sqlite"
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
)
|
||||
|
||||
// CheckMergeArtifacts detects temporary git merge files in .beads directory.
|
||||
// These are created during git merges and should be cleaned up.
|
||||
func CheckMergeArtifacts(path string) DoctorCheck {
|
||||
beadsDir := filepath.Join(path, ".beads")
|
||||
|
||||
if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
|
||||
return DoctorCheck{
|
||||
Name: "Merge Artifacts",
|
||||
Status: "ok",
|
||||
Message: "N/A (no .beads directory)",
|
||||
}
|
||||
}
|
||||
|
||||
// Read patterns from .beads/.gitignore (merge artifacts section)
|
||||
patterns, err := readMergeArtifactPatterns(beadsDir)
|
||||
if err != nil {
|
||||
// No .gitignore or can't read it - use default patterns
|
||||
patterns = []string{
|
||||
"*.base.jsonl",
|
||||
"*.left.jsonl",
|
||||
"*.right.jsonl",
|
||||
"*.meta.json",
|
||||
}
|
||||
}
|
||||
|
||||
// Find matching files
|
||||
var artifacts []string
|
||||
for _, pattern := range patterns {
|
||||
matches, err := filepath.Glob(filepath.Join(beadsDir, pattern))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
artifacts = append(artifacts, matches...)
|
||||
}
|
||||
|
||||
if len(artifacts) == 0 {
|
||||
return DoctorCheck{
|
||||
Name: "Merge Artifacts",
|
||||
Status: "ok",
|
||||
Message: "No merge artifacts found",
|
||||
}
|
||||
}
|
||||
|
||||
// Build list of relative paths for display
|
||||
var relPaths []string
|
||||
for _, f := range artifacts {
|
||||
if rel, err := filepath.Rel(beadsDir, f); err == nil {
|
||||
relPaths = append(relPaths, rel)
|
||||
}
|
||||
}
|
||||
|
||||
return DoctorCheck{
|
||||
Name: "Merge Artifacts",
|
||||
Status: "warning",
|
||||
Message: fmt.Sprintf("%d temporary merge file(s) found", len(artifacts)),
|
||||
Detail: strings.Join(relPaths, ", "),
|
||||
Fix: "Run 'bd doctor --fix' to remove merge artifacts",
|
||||
}
|
||||
}
|
||||
|
||||
// readMergeArtifactPatterns reads patterns from .beads/.gitignore merge section
|
||||
func readMergeArtifactPatterns(beadsDir string) ([]string, error) {
|
||||
gitignorePath := filepath.Join(beadsDir, ".gitignore")
|
||||
file, err := os.Open(gitignorePath) // #nosec G304 - path constructed from beadsDir
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var patterns []string
|
||||
inMergeSection := false
|
||||
scanner := bufio.NewScanner(file)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
|
||||
if strings.Contains(line, "Merge artifacts") {
|
||||
inMergeSection = true
|
||||
continue
|
||||
}
|
||||
|
||||
if inMergeSection && strings.HasPrefix(line, "#") {
|
||||
break
|
||||
}
|
||||
|
||||
if inMergeSection && line != "" && !strings.HasPrefix(line, "#") && !strings.HasPrefix(line, "!") {
|
||||
patterns = append(patterns, line)
|
||||
}
|
||||
}
|
||||
|
||||
return patterns, scanner.Err()
|
||||
}
|
||||
|
||||
// CheckOrphanedDependencies detects dependencies pointing to non-existent issues.
|
||||
func CheckOrphanedDependencies(path string) DoctorCheck {
|
||||
beadsDir := filepath.Join(path, ".beads")
|
||||
dbPath := filepath.Join(beadsDir, beads.CanonicalDatabaseName)
|
||||
|
||||
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
||||
return DoctorCheck{
|
||||
Name: "Orphaned Dependencies",
|
||||
Status: "ok",
|
||||
Message: "N/A (no database)",
|
||||
}
|
||||
}
|
||||
|
||||
// Open database read-only
|
||||
db, err := openDBReadOnly(dbPath)
|
||||
if err != nil {
|
||||
return DoctorCheck{
|
||||
Name: "Orphaned Dependencies",
|
||||
Status: "ok",
|
||||
Message: "N/A (unable to open database)",
|
||||
}
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Query for orphaned dependencies
|
||||
query := `
|
||||
SELECT d.issue_id, d.depends_on_id, d.type
|
||||
FROM dependencies d
|
||||
LEFT JOIN issues i ON d.depends_on_id = i.id
|
||||
WHERE i.id IS NULL
|
||||
`
|
||||
rows, err := db.Query(query)
|
||||
if err != nil {
|
||||
return DoctorCheck{
|
||||
Name: "Orphaned Dependencies",
|
||||
Status: "ok",
|
||||
Message: "N/A (query failed)",
|
||||
}
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var orphans []string
|
||||
for rows.Next() {
|
||||
var issueID, dependsOnID, depType string
|
||||
if err := rows.Scan(&issueID, &dependsOnID, &depType); err == nil {
|
||||
orphans = append(orphans, fmt.Sprintf("%s→%s", issueID, dependsOnID))
|
||||
}
|
||||
}
|
||||
|
||||
if len(orphans) == 0 {
|
||||
return DoctorCheck{
|
||||
Name: "Orphaned Dependencies",
|
||||
Status: "ok",
|
||||
Message: "No orphaned dependencies",
|
||||
}
|
||||
}
|
||||
|
||||
detail := strings.Join(orphans, ", ")
|
||||
if len(detail) > 200 {
|
||||
detail = detail[:200] + "..."
|
||||
}
|
||||
|
||||
return DoctorCheck{
|
||||
Name: "Orphaned Dependencies",
|
||||
Status: "warning",
|
||||
Message: fmt.Sprintf("%d orphaned dependency reference(s)", len(orphans)),
|
||||
Detail: detail,
|
||||
Fix: "Run 'bd doctor --fix' to remove orphaned dependencies",
|
||||
}
|
||||
}
|
||||
|
||||
// CheckDuplicateIssues detects issues with identical content.
|
||||
func CheckDuplicateIssues(path string) DoctorCheck {
|
||||
beadsDir := filepath.Join(path, ".beads")
|
||||
dbPath := filepath.Join(beadsDir, beads.CanonicalDatabaseName)
|
||||
|
||||
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
||||
return DoctorCheck{
|
||||
Name: "Duplicate Issues",
|
||||
Status: "ok",
|
||||
Message: "N/A (no database)",
|
||||
}
|
||||
}
|
||||
|
||||
// Open store to use existing duplicate detection
|
||||
store, err := sqlite.New(nil, dbPath)
|
||||
if err != nil {
|
||||
return DoctorCheck{
|
||||
Name: "Duplicate Issues",
|
||||
Status: "ok",
|
||||
Message: "N/A (unable to open database)",
|
||||
}
|
||||
}
|
||||
defer func() { _ = store.Close() }()
|
||||
|
||||
issues, err := store.SearchIssues(nil, "", types.IssueFilter{})
|
||||
if err != nil {
|
||||
return DoctorCheck{
|
||||
Name: "Duplicate Issues",
|
||||
Status: "ok",
|
||||
Message: "N/A (unable to query issues)",
|
||||
}
|
||||
}
|
||||
|
||||
// Find duplicates by title+description hash
|
||||
seen := make(map[string][]string) // hash -> list of IDs
|
||||
for _, issue := range issues {
|
||||
if issue.Status == types.StatusTombstone {
|
||||
continue
|
||||
}
|
||||
key := issue.Title + "|" + issue.Description
|
||||
seen[key] = append(seen[key], issue.ID)
|
||||
}
|
||||
|
||||
var duplicateGroups int
|
||||
var totalDuplicates int
|
||||
for _, ids := range seen {
|
||||
if len(ids) > 1 {
|
||||
duplicateGroups++
|
||||
totalDuplicates += len(ids) - 1 // exclude the canonical one
|
||||
}
|
||||
}
|
||||
|
||||
if duplicateGroups == 0 {
|
||||
return DoctorCheck{
|
||||
Name: "Duplicate Issues",
|
||||
Status: "ok",
|
||||
Message: "No duplicate issues",
|
||||
}
|
||||
}
|
||||
|
||||
return DoctorCheck{
|
||||
Name: "Duplicate Issues",
|
||||
Status: "warning",
|
||||
Message: fmt.Sprintf("%d duplicate issue(s) in %d group(s)", totalDuplicates, duplicateGroups),
|
||||
Detail: "Duplicates cannot be auto-fixed",
|
||||
Fix: "Run 'bd duplicates' to review and merge duplicates",
|
||||
}
|
||||
}
|
||||
|
||||
// CheckTestPollution detects test issues that may have leaked into the database.
|
||||
func CheckTestPollution(path string) DoctorCheck {
|
||||
beadsDir := filepath.Join(path, ".beads")
|
||||
dbPath := filepath.Join(beadsDir, beads.CanonicalDatabaseName)
|
||||
|
||||
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
||||
return DoctorCheck{
|
||||
Name: "Test Pollution",
|
||||
Status: "ok",
|
||||
Message: "N/A (no database)",
|
||||
}
|
||||
}
|
||||
|
||||
db, err := openDBReadOnly(dbPath)
|
||||
if err != nil {
|
||||
return DoctorCheck{
|
||||
Name: "Test Pollution",
|
||||
Status: "ok",
|
||||
Message: "N/A (unable to open database)",
|
||||
}
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Look for common test patterns in titles
|
||||
query := `
|
||||
SELECT COUNT(*) FROM issues
|
||||
WHERE status != 'tombstone'
|
||||
AND (
|
||||
title LIKE 'test-%' OR
|
||||
title LIKE 'Test Issue%' OR
|
||||
title LIKE '%test issue%' OR
|
||||
id LIKE 'test-%'
|
||||
)
|
||||
`
|
||||
var count int
|
||||
if err := db.QueryRow(query).Scan(&count); err != nil {
|
||||
return DoctorCheck{
|
||||
Name: "Test Pollution",
|
||||
Status: "ok",
|
||||
Message: "N/A (query failed)",
|
||||
}
|
||||
}
|
||||
|
||||
if count == 0 {
|
||||
return DoctorCheck{
|
||||
Name: "Test Pollution",
|
||||
Status: "ok",
|
||||
Message: "No test pollution detected",
|
||||
}
|
||||
}
|
||||
|
||||
return DoctorCheck{
|
||||
Name: "Test Pollution",
|
||||
Status: "warning",
|
||||
Message: fmt.Sprintf("%d potential test issue(s) detected", count),
|
||||
Detail: "Test issues may have leaked into production database",
|
||||
Fix: "Run 'bd detect-pollution' to review and clean test issues",
|
||||
}
|
||||
}
|
||||
|
||||
// CheckGitConflicts detects git conflict markers in JSONL file.
|
||||
func CheckGitConflicts(path string) DoctorCheck {
|
||||
beadsDir := filepath.Join(path, ".beads")
|
||||
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
|
||||
|
||||
if _, err := os.Stat(jsonlPath); os.IsNotExist(err) {
|
||||
return DoctorCheck{
|
||||
Name: "Git Conflicts",
|
||||
Status: "ok",
|
||||
Message: "N/A (no JSONL file)",
|
||||
}
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(jsonlPath) // #nosec G304 - path constructed safely
|
||||
if err != nil {
|
||||
return DoctorCheck{
|
||||
Name: "Git Conflicts",
|
||||
Status: "ok",
|
||||
Message: "N/A (unable to read JSONL)",
|
||||
}
|
||||
}
|
||||
|
||||
// Look for conflict markers at start of lines
|
||||
lines := bytes.Split(data, []byte("\n"))
|
||||
var conflictLines []int
|
||||
for i, line := range lines {
|
||||
trimmed := bytes.TrimSpace(line)
|
||||
if bytes.HasPrefix(trimmed, []byte("<<<<<<< ")) ||
|
||||
bytes.Equal(trimmed, []byte("=======")) ||
|
||||
bytes.HasPrefix(trimmed, []byte(">>>>>>> ")) {
|
||||
conflictLines = append(conflictLines, i+1)
|
||||
}
|
||||
}
|
||||
|
||||
if len(conflictLines) == 0 {
|
||||
return DoctorCheck{
|
||||
Name: "Git Conflicts",
|
||||
Status: "ok",
|
||||
Message: "No git conflicts in JSONL",
|
||||
}
|
||||
}
|
||||
|
||||
return DoctorCheck{
|
||||
Name: "Git Conflicts",
|
||||
Status: "error",
|
||||
Message: fmt.Sprintf("Git conflict markers found at %d location(s)", len(conflictLines)),
|
||||
Detail: fmt.Sprintf("Conflict markers at lines: %v", conflictLines),
|
||||
Fix: "Resolve conflicts manually: git checkout --ours or --theirs .beads/issues.jsonl",
|
||||
}
|
||||
}
|
||||
@@ -118,12 +118,24 @@ 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 {
|
||||
fmt.Fprintf(os.Stderr, "Warning: failed to initialize config: %v\n", err)
|
||||
}
|
||||
|
||||
// Add command groups for organized help output
|
||||
rootCmd.AddGroup(
|
||||
&cobra.Group{ID: GroupMaintenance, Title: "Maintenance:"},
|
||||
&cobra.Group{ID: GroupIntegrations, Title: "Integrations & Advanced:"},
|
||||
)
|
||||
|
||||
// Register persistent flags
|
||||
rootCmd.PersistentFlags().StringVar(&dbPath, "db", "", "Database path (default: auto-discover .beads/*.db)")
|
||||
rootCmd.PersistentFlags().StringVar(&actor, "actor", "", "Actor name for audit trail (default: $BD_ACTOR or $USER)")
|
||||
@@ -391,6 +403,11 @@ var rootCmd = &cobra.Command{
|
||||
return
|
||||
}
|
||||
|
||||
// Skip for root command with no subcommand (just shows help)
|
||||
if cmd.Parent() == nil && cmdName == "bd" {
|
||||
return
|
||||
}
|
||||
|
||||
// Also skip for --version flag on root command (cmdName would be "bd")
|
||||
if v, _ := cmd.Flags().GetBool("version"); v {
|
||||
return
|
||||
|
||||
@@ -209,7 +209,7 @@ func findInProgressMolecules(ctx context.Context, s storage.Storage, agent strin
|
||||
}
|
||||
resp, err := daemonClient.List(listArgs)
|
||||
if err == nil {
|
||||
json.Unmarshal(resp.Data, &inProgressIssues)
|
||||
_ = json.Unmarshal(resp.Data, &inProgressIssues)
|
||||
}
|
||||
} else {
|
||||
// Direct query - search for in_progress issues
|
||||
|
||||
@@ -22,7 +22,7 @@ import (
|
||||
|
||||
var renamePrefixCmd = &cobra.Command{
|
||||
Use: "rename-prefix <new-prefix>",
|
||||
GroupID: "advanced",
|
||||
GroupID: GroupMaintenance,
|
||||
Short: "Rename the issue prefix for all issues in the database",
|
||||
Long: `Rename the issue prefix for all issues in the database.
|
||||
This will update all issue IDs and all text references across all fields.
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
// Package main implements the bd CLI dependency repair command.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/beads/internal/storage/sqlite"
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
"github.com/steveyegge/beads/internal/ui"
|
||||
)
|
||||
|
||||
// TODO: Consider consolidating into 'bd doctor --fix' for simpler maintenance UX
|
||||
var repairDepsCmd = &cobra.Command{
|
||||
Use: "repair-deps",
|
||||
GroupID: "maint",
|
||||
Short: "Find and fix orphaned dependency references",
|
||||
Long: `Scans all issues for dependencies pointing to non-existent issues.
|
||||
|
||||
Reports orphaned dependencies and optionally removes them with --fix.
|
||||
Interactive mode with --interactive prompts for each orphan.`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
CheckReadonly("repair-deps")
|
||||
fix, _ := cmd.Flags().GetBool("fix")
|
||||
interactive, _ := cmd.Flags().GetBool("interactive")
|
||||
|
||||
// If daemon is running but doesn't support this command, use direct storage
|
||||
if daemonClient != nil && store == nil {
|
||||
var err error
|
||||
store, err = sqlite.New(rootCtx, dbPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: failed to open database: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer func() { _ = store.Close() }()
|
||||
}
|
||||
|
||||
ctx := rootCtx
|
||||
|
||||
// Get all dependency records
|
||||
allDeps, err := store.GetAllDependencyRecords(ctx)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: failed to get dependencies: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Get all issues to check existence
|
||||
issues, err := store.SearchIssues(ctx, "", types.IssueFilter{})
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: failed to list issues: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Build set of valid issue IDs
|
||||
validIDs := make(map[string]bool)
|
||||
for _, issue := range issues {
|
||||
validIDs[issue.ID] = true
|
||||
}
|
||||
|
||||
// Find orphaned dependencies
|
||||
type orphan struct {
|
||||
issueID string
|
||||
dependsOnID string
|
||||
depType types.DependencyType
|
||||
}
|
||||
var orphans []orphan
|
||||
|
||||
for issueID, deps := range allDeps {
|
||||
if !validIDs[issueID] {
|
||||
// The issue itself doesn't exist, skip (will be cleaned up separately)
|
||||
continue
|
||||
}
|
||||
for _, dep := range deps {
|
||||
if !validIDs[dep.DependsOnID] {
|
||||
orphans = append(orphans, orphan{
|
||||
issueID: dep.IssueID,
|
||||
dependsOnID: dep.DependsOnID,
|
||||
depType: dep.Type,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if jsonOutput {
|
||||
result := map[string]interface{}{
|
||||
"orphans_found": len(orphans),
|
||||
"orphans": []map[string]string{},
|
||||
}
|
||||
if len(orphans) > 0 {
|
||||
orphanList := make([]map[string]string, len(orphans))
|
||||
for i, o := range orphans {
|
||||
orphanList[i] = map[string]string{
|
||||
"issue_id": o.issueID,
|
||||
"depends_on_id": o.dependsOnID,
|
||||
"type": string(o.depType),
|
||||
}
|
||||
}
|
||||
result["orphans"] = orphanList
|
||||
}
|
||||
if fix || interactive {
|
||||
result["fixed"] = len(orphans)
|
||||
}
|
||||
outputJSON(result)
|
||||
return
|
||||
}
|
||||
|
||||
// Report results
|
||||
if len(orphans) == 0 {
|
||||
fmt.Printf("\n%s No orphaned dependencies found\n\n", ui.RenderPass("✓"))
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("\n%s Found %d orphaned dependencies:\n\n", ui.RenderWarn("⚠"), len(orphans))
|
||||
|
||||
for i, o := range orphans {
|
||||
fmt.Printf("%d. %s → %s (%s) [%s does not exist]\n",
|
||||
i+1, o.issueID, o.dependsOnID, o.depType, o.dependsOnID)
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
// Fix if requested
|
||||
if interactive {
|
||||
fixed := 0
|
||||
for _, o := range orphans {
|
||||
fmt.Printf("Remove dependency %s → %s (%s)? [y/N]: ", o.issueID, o.dependsOnID, o.depType)
|
||||
var response string
|
||||
_, _ = fmt.Scanln(&response)
|
||||
if response == "y" || response == "Y" {
|
||||
// Use direct SQL to remove orphaned dependencies
|
||||
// RemoveDependency tries to mark the depends_on issue as dirty, which fails for orphans
|
||||
db := store.UnderlyingDB()
|
||||
_, err := db.ExecContext(ctx, "DELETE FROM dependencies WHERE issue_id = ? AND depends_on_id = ?",
|
||||
o.issueID, o.dependsOnID)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error removing dependency: %v\n", err)
|
||||
} else {
|
||||
// Mark the issue as dirty
|
||||
_, _ = db.ExecContext(ctx, "INSERT OR IGNORE INTO dirty_issues (issue_id) VALUES (?)", o.issueID)
|
||||
fixed++
|
||||
}
|
||||
}
|
||||
}
|
||||
markDirtyAndScheduleFlush()
|
||||
fmt.Printf("\n%s Fixed %d orphaned dependencies\n\n", ui.RenderPass("✓"), fixed)
|
||||
} else if fix {
|
||||
db := store.UnderlyingDB()
|
||||
for _, o := range orphans {
|
||||
// Use direct SQL to remove orphaned dependencies
|
||||
_, err := db.ExecContext(ctx, "DELETE FROM dependencies WHERE issue_id = ? AND depends_on_id = ?",
|
||||
o.issueID, o.dependsOnID)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error removing dependency %s → %s: %v\n",
|
||||
o.issueID, o.dependsOnID, err)
|
||||
} else {
|
||||
// Mark the issue as dirty
|
||||
_, _ = db.ExecContext(ctx, "INSERT OR IGNORE INTO dirty_issues (issue_id) VALUES (?)", o.issueID)
|
||||
}
|
||||
}
|
||||
markDirtyAndScheduleFlush()
|
||||
fmt.Printf("%s Fixed %d orphaned dependencies\n\n", ui.RenderPass("✓"), len(orphans))
|
||||
} else {
|
||||
fmt.Printf("Run with --fix to automatically remove orphaned dependencies\n")
|
||||
fmt.Printf("Run with --interactive to review each dependency\n\n")
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
repairDepsCmd.Flags().Bool("fix", false, "Automatically remove orphaned dependencies")
|
||||
repairDepsCmd.Flags().Bool("interactive", false, "Interactively review each orphaned dependency")
|
||||
repairDepsCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output repair results in JSON format")
|
||||
rootCmd.AddCommand(repairDepsCmd)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/beads/internal/git"
|
||||
@@ -323,12 +324,12 @@ func stopDaemonQuiet(pidFile string) {
|
||||
|
||||
_ = sendStopSignal(process)
|
||||
|
||||
// Wait up to 5 seconds for daemon to stop
|
||||
for i := 0; i < 50; i++ {
|
||||
// Wait for daemon to stop gracefully
|
||||
for i := 0; i < daemonShutdownAttempts; i++ {
|
||||
time.Sleep(daemonShutdownPollInterval)
|
||||
if isRunning, _ := isDaemonRunning(pidFile); !isRunning {
|
||||
return
|
||||
}
|
||||
// Small sleep handled by the check
|
||||
}
|
||||
|
||||
// Force kill if still running
|
||||
|
||||
@@ -1,378 +0,0 @@
|
||||
package main
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
"github.com/steveyegge/beads/internal/ui"
|
||||
)
|
||||
// TODO: Consider consolidating into 'bd doctor --fix' for simpler maintenance UX
|
||||
var validateCmd = &cobra.Command{
|
||||
Use: "validate",
|
||||
GroupID: "maint",
|
||||
Short: "Run comprehensive database health checks",
|
||||
Long: `Run all validation checks to ensure database integrity:
|
||||
- Orphaned dependencies (references to deleted issues)
|
||||
- Duplicate issues (identical content)
|
||||
- Test pollution (leaked test issues)
|
||||
- Git merge conflicts in JSONL
|
||||
Example:
|
||||
bd validate # Run all checks
|
||||
bd validate --fix-all # Auto-fix all issues
|
||||
bd validate --checks=orphans,dupes # Run specific checks
|
||||
bd validate --checks=conflicts # Check for git conflicts
|
||||
bd validate --json # Output in JSON format`,
|
||||
Run: func(cmd *cobra.Command, _ []string) {
|
||||
fixAll, _ := cmd.Flags().GetBool("fix-all")
|
||||
// Block writes in readonly mode (--fix-all modifies data)
|
||||
if fixAll {
|
||||
CheckReadonly("validate --fix-all")
|
||||
}
|
||||
// Check daemon mode - not supported yet (uses direct storage access)
|
||||
if daemonClient != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: validate command not yet supported in daemon mode\n")
|
||||
fmt.Fprintf(os.Stderr, "Use: bd --no-daemon validate\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
checksFlag, _ := cmd.Flags().GetString("checks")
|
||||
jsonOut, _ := cmd.Flags().GetBool("json")
|
||||
ctx := rootCtx
|
||||
|
||||
// Check database freshness before reading (bd-2q6d, bd-c4rq)
|
||||
// Skip check when using daemon (daemon auto-imports on staleness)
|
||||
if daemonClient == nil {
|
||||
if err := ensureDatabaseFresh(ctx); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse and normalize checks
|
||||
checks, err := parseChecks(checksFlag)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "Valid checks: orphans, duplicates, pollution, conflicts\n")
|
||||
os.Exit(2)
|
||||
}
|
||||
// Fetch all issues once for checks that need them
|
||||
var allIssues []*types.Issue
|
||||
needsIssues := false
|
||||
for _, check := range checks {
|
||||
if check == "orphans" || check == "duplicates" || check == "pollution" {
|
||||
needsIssues = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if needsIssues {
|
||||
allIssues, err = store.SearchIssues(ctx, "", types.IssueFilter{})
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error fetching issues: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
results := validationResults{
|
||||
checks: make(map[string]checkResult),
|
||||
checkOrder: checks,
|
||||
}
|
||||
// Run each check
|
||||
for _, check := range checks {
|
||||
switch check {
|
||||
case "orphans":
|
||||
results.checks["orphans"] = validateOrphanedDeps(ctx, allIssues, fixAll)
|
||||
case "duplicates":
|
||||
results.checks["duplicates"] = validateDuplicates(ctx, allIssues, fixAll)
|
||||
case "pollution":
|
||||
results.checks["pollution"] = validatePollution(ctx, allIssues, fixAll)
|
||||
case "conflicts":
|
||||
results.checks["conflicts"] = validateGitConflicts(ctx, fixAll)
|
||||
}
|
||||
}
|
||||
// Output results
|
||||
if jsonOut {
|
||||
outputJSON(results.toJSON())
|
||||
} else {
|
||||
results.print(fixAll)
|
||||
}
|
||||
// Exit with error code if issues found or errors occurred
|
||||
if results.hasFailures() {
|
||||
os.Exit(1)
|
||||
}
|
||||
},
|
||||
}
|
||||
// parseChecks normalizes and validates check names
|
||||
func parseChecks(checksFlag string) ([]string, error) {
|
||||
defaultChecks := []string{"orphans", "duplicates", "pollution", "conflicts"}
|
||||
if checksFlag == "" {
|
||||
return defaultChecks, nil
|
||||
}
|
||||
// Map of synonyms to canonical names
|
||||
synonyms := map[string]string{
|
||||
"dupes": "duplicates",
|
||||
"git-conflicts": "conflicts",
|
||||
}
|
||||
var result []string
|
||||
seen := make(map[string]bool)
|
||||
parts := strings.Split(checksFlag, ",")
|
||||
for _, part := range parts {
|
||||
check := strings.ToLower(strings.TrimSpace(part))
|
||||
if check == "" {
|
||||
continue
|
||||
}
|
||||
// Map synonyms
|
||||
if canonical, ok := synonyms[check]; ok {
|
||||
check = canonical
|
||||
}
|
||||
// Validate
|
||||
valid := false
|
||||
for _, validCheck := range defaultChecks {
|
||||
if check == validCheck {
|
||||
valid = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !valid {
|
||||
return nil, fmt.Errorf("unknown check: %s", part)
|
||||
}
|
||||
// Deduplicate
|
||||
if !seen[check] {
|
||||
seen[check] = true
|
||||
result = append(result, check)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
type checkResult struct {
|
||||
name string
|
||||
issueCount int
|
||||
fixedCount int
|
||||
err error
|
||||
suggestions []string
|
||||
}
|
||||
type validationResults struct {
|
||||
checks map[string]checkResult
|
||||
checkOrder []string
|
||||
}
|
||||
func (r *validationResults) hasFailures() bool {
|
||||
for _, result := range r.checks {
|
||||
if result.err != nil {
|
||||
return true
|
||||
}
|
||||
if result.issueCount > 0 && result.fixedCount < result.issueCount {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
func (r *validationResults) toJSON() map[string]interface{} {
|
||||
output := map[string]interface{}{
|
||||
"checks": map[string]interface{}{},
|
||||
}
|
||||
totalIssues := 0
|
||||
totalFixed := 0
|
||||
hasErrors := false
|
||||
for name, result := range r.checks {
|
||||
var errorStr interface{}
|
||||
if result.err != nil {
|
||||
errorStr = result.err.Error()
|
||||
hasErrors = true
|
||||
}
|
||||
output["checks"].(map[string]interface{})[name] = map[string]interface{}{
|
||||
"issue_count": result.issueCount,
|
||||
"fixed_count": result.fixedCount,
|
||||
"error": errorStr,
|
||||
"failed": result.err != nil,
|
||||
"suggestions": result.suggestions,
|
||||
}
|
||||
totalIssues += result.issueCount
|
||||
totalFixed += result.fixedCount
|
||||
}
|
||||
output["total_issues"] = totalIssues
|
||||
output["total_fixed"] = totalFixed
|
||||
output["healthy"] = !hasErrors && (totalIssues == 0 || totalIssues == totalFixed)
|
||||
return output
|
||||
}
|
||||
func (r *validationResults) print(_ bool) {
|
||||
fmt.Println("\nValidation Results:")
|
||||
fmt.Println("===================")
|
||||
totalIssues := 0
|
||||
totalFixed := 0
|
||||
// Print in deterministic order
|
||||
for _, name := range r.checkOrder {
|
||||
result := r.checks[name]
|
||||
prefix := "✓"
|
||||
var coloredPrefix string
|
||||
if result.err != nil {
|
||||
prefix = "✗"
|
||||
coloredPrefix = ui.RenderFail(prefix)
|
||||
fmt.Printf("%s %s: ERROR - %v\n", coloredPrefix, result.name, result.err)
|
||||
} else if result.issueCount > 0 {
|
||||
prefix = "⚠"
|
||||
coloredPrefix = ui.RenderWarn(prefix)
|
||||
if result.fixedCount > 0 {
|
||||
fmt.Printf("%s %s: %d found, %d fixed\n", coloredPrefix, result.name, result.issueCount, result.fixedCount)
|
||||
} else {
|
||||
fmt.Printf("%s %s: %d found\n", coloredPrefix, result.name, result.issueCount)
|
||||
}
|
||||
} else {
|
||||
coloredPrefix = ui.RenderPass(prefix)
|
||||
fmt.Printf("%s %s: OK\n", coloredPrefix, result.name)
|
||||
}
|
||||
totalIssues += result.issueCount
|
||||
totalFixed += result.fixedCount
|
||||
}
|
||||
fmt.Println()
|
||||
if totalIssues == 0 {
|
||||
fmt.Printf("%s Database is healthy!\n", ui.RenderPass("✓"))
|
||||
} else if totalFixed == totalIssues {
|
||||
fmt.Printf("%s Fixed all %d issues\n", ui.RenderPass("✓"), totalFixed)
|
||||
} else {
|
||||
remaining := totalIssues - totalFixed
|
||||
fmt.Printf("%s Found %d issues", ui.RenderWarn("⚠"), totalIssues)
|
||||
if totalFixed > 0 {
|
||||
fmt.Printf(" (fixed %d, %d remaining)", totalFixed, remaining)
|
||||
}
|
||||
fmt.Println()
|
||||
// Print suggestions
|
||||
fmt.Println("\nRecommendations:")
|
||||
for _, result := range r.checks {
|
||||
for _, suggestion := range result.suggestions {
|
||||
fmt.Printf(" - %s\n", suggestion)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
func validateOrphanedDeps(ctx context.Context, allIssues []*types.Issue, fix bool) checkResult {
|
||||
result := checkResult{name: "orphaned dependencies"}
|
||||
// Build ID existence map
|
||||
existingIDs := make(map[string]bool)
|
||||
for _, issue := range allIssues {
|
||||
existingIDs[issue.ID] = true
|
||||
}
|
||||
// Find orphaned dependencies
|
||||
type orphanedDep struct {
|
||||
issueID string
|
||||
orphanedID string
|
||||
}
|
||||
var orphaned []orphanedDep
|
||||
for _, issue := range allIssues {
|
||||
for _, dep := range issue.Dependencies {
|
||||
if !existingIDs[dep.DependsOnID] {
|
||||
orphaned = append(orphaned, orphanedDep{
|
||||
issueID: issue.ID,
|
||||
orphanedID: dep.DependsOnID,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
result.issueCount = len(orphaned)
|
||||
if fix && len(orphaned) > 0 {
|
||||
// Group by issue
|
||||
orphansByIssue := make(map[string][]string)
|
||||
for _, o := range orphaned {
|
||||
orphansByIssue[o.issueID] = append(orphansByIssue[o.issueID], o.orphanedID)
|
||||
}
|
||||
// Fix each issue
|
||||
for issueID, orphanedIDs := range orphansByIssue {
|
||||
for _, orphanedID := range orphanedIDs {
|
||||
if err := store.RemoveDependency(ctx, issueID, orphanedID, actor); err == nil {
|
||||
result.fixedCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
if result.fixedCount > 0 {
|
||||
markDirtyAndScheduleFlush()
|
||||
}
|
||||
}
|
||||
if result.issueCount > result.fixedCount {
|
||||
result.suggestions = append(result.suggestions, "Run 'bd repair-deps --fix' to remove orphaned dependencies")
|
||||
}
|
||||
return result
|
||||
}
|
||||
func validateDuplicates(_ context.Context, allIssues []*types.Issue, fix bool) checkResult {
|
||||
result := checkResult{name: "duplicates"}
|
||||
// Find duplicates
|
||||
duplicateGroups := findDuplicateGroups(allIssues)
|
||||
// Count total duplicate issues (excluding one canonical per group)
|
||||
for _, group := range duplicateGroups {
|
||||
result.issueCount += len(group) - 1
|
||||
}
|
||||
if fix && len(duplicateGroups) > 0 {
|
||||
// Note: Auto-merge is complex and requires user review
|
||||
// We don't auto-fix duplicates, just report them
|
||||
result.suggestions = append(result.suggestions,
|
||||
fmt.Sprintf("Run 'bd duplicates --auto-merge' to merge %d duplicate groups", len(duplicateGroups)))
|
||||
} else if result.issueCount > 0 {
|
||||
result.suggestions = append(result.suggestions,
|
||||
fmt.Sprintf("Run 'bd duplicates' to review %d duplicate groups", len(duplicateGroups)))
|
||||
}
|
||||
return result
|
||||
}
|
||||
func validatePollution(_ context.Context, allIssues []*types.Issue, fix bool) checkResult {
|
||||
result := checkResult{name: "test pollution"}
|
||||
// Detect pollution
|
||||
polluted := detectTestPollution(allIssues)
|
||||
result.issueCount = len(polluted)
|
||||
if fix && len(polluted) > 0 {
|
||||
// Note: Deleting issues is destructive, we just suggest it
|
||||
result.suggestions = append(result.suggestions,
|
||||
fmt.Sprintf("Run 'bd detect-pollution --clean' to delete %d test issues", len(polluted)))
|
||||
} else if result.issueCount > 0 {
|
||||
result.suggestions = append(result.suggestions,
|
||||
fmt.Sprintf("Run 'bd detect-pollution' to review %d potential test issues", len(polluted)))
|
||||
}
|
||||
return result
|
||||
}
|
||||
func validateGitConflicts(_ context.Context, fix bool) checkResult {
|
||||
result := checkResult{name: "git conflicts"}
|
||||
// Check JSONL file for conflict markers
|
||||
jsonlPath := findJSONLPath()
|
||||
// nolint:gosec // G304: jsonlPath is validated JSONL file from findJSONLPath
|
||||
data, err := os.ReadFile(jsonlPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
// No JSONL file = no conflicts
|
||||
return result
|
||||
}
|
||||
result.err = fmt.Errorf("failed to read JSONL: %w", err)
|
||||
return result
|
||||
}
|
||||
// Look for git conflict markers in raw bytes (before JSON decoding)
|
||||
// This prevents false positives when issue content contains these strings
|
||||
lines := bytes.Split(data, []byte("\n"))
|
||||
var conflictLines []int
|
||||
for i, line := range lines {
|
||||
trimmed := bytes.TrimSpace(line)
|
||||
if bytes.HasPrefix(trimmed, []byte("<<<<<<< ")) ||
|
||||
bytes.Equal(trimmed, []byte("=======")) ||
|
||||
bytes.HasPrefix(trimmed, []byte(">>>>>>> ")) {
|
||||
conflictLines = append(conflictLines, i+1)
|
||||
}
|
||||
}
|
||||
if len(conflictLines) > 0 {
|
||||
result.issueCount = 1 // One conflict situation
|
||||
result.suggestions = append(result.suggestions,
|
||||
fmt.Sprintf("Git conflict markers found in %s at lines: %v", jsonlPath, conflictLines))
|
||||
result.suggestions = append(result.suggestions,
|
||||
"To resolve, choose one version:")
|
||||
result.suggestions = append(result.suggestions,
|
||||
" git checkout --ours .beads/issues.jsonl && bd import -i .beads/issues.jsonl")
|
||||
result.suggestions = append(result.suggestions,
|
||||
" git checkout --theirs .beads/issues.jsonl && bd import -i .beads/issues.jsonl")
|
||||
result.suggestions = append(result.suggestions,
|
||||
"For advanced field-level merging: https://github.com/neongreen/mono/tree/main/beads-merge")
|
||||
}
|
||||
// Can't auto-fix git conflicts
|
||||
if fix && result.issueCount > 0 {
|
||||
result.suggestions = append(result.suggestions,
|
||||
"Note: Git conflicts cannot be auto-fixed with --fix-all")
|
||||
}
|
||||
return result
|
||||
}
|
||||
func init() {
|
||||
validateCmd.Flags().Bool("fix-all", false, "Auto-fix all fixable issues")
|
||||
validateCmd.Flags().String("checks", "", "Comma-separated list of checks (orphans,duplicates,pollution,conflicts)")
|
||||
rootCmd.AddCommand(validateCmd)
|
||||
}
|
||||
@@ -1,345 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
)
|
||||
|
||||
func TestParseChecks(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want []string
|
||||
wantError bool
|
||||
}{
|
||||
{
|
||||
name: "empty returns all defaults",
|
||||
input: "",
|
||||
want: []string{"orphans", "duplicates", "pollution", "conflicts"},
|
||||
},
|
||||
{
|
||||
name: "single check",
|
||||
input: "orphans",
|
||||
want: []string{"orphans"},
|
||||
},
|
||||
{
|
||||
name: "multiple checks",
|
||||
input: "orphans,duplicates",
|
||||
want: []string{"orphans", "duplicates"},
|
||||
},
|
||||
{
|
||||
name: "synonym dupes->duplicates",
|
||||
input: "dupes",
|
||||
want: []string{"duplicates"},
|
||||
},
|
||||
{
|
||||
name: "synonym git-conflicts->conflicts",
|
||||
input: "git-conflicts",
|
||||
want: []string{"conflicts"},
|
||||
},
|
||||
{
|
||||
name: "mixed with whitespace",
|
||||
input: " orphans , duplicates , pollution ",
|
||||
want: []string{"orphans", "duplicates", "pollution"},
|
||||
},
|
||||
{
|
||||
name: "deduplication",
|
||||
input: "orphans,orphans,duplicates",
|
||||
want: []string{"orphans", "duplicates"},
|
||||
},
|
||||
{
|
||||
name: "invalid check",
|
||||
input: "orphans,invalid,duplicates",
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "empty parts ignored",
|
||||
input: "orphans,,duplicates",
|
||||
want: []string{"orphans", "duplicates"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt // capture range variable
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := parseChecks(tt.input)
|
||||
if tt.wantError {
|
||||
if err == nil {
|
||||
t.Errorf("parseChecks(%q) expected error, got nil", tt.input)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("parseChecks(%q) unexpected error: %v", tt.input, err)
|
||||
}
|
||||
if len(got) != len(tt.want) {
|
||||
t.Errorf("parseChecks(%q) length = %d, want %d", tt.input, len(got), len(tt.want))
|
||||
return
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tt.want[i] {
|
||||
t.Errorf("parseChecks(%q)[%d] = %q, want %q", tt.input, i, got[i], tt.want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidationResultsHasFailures(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
checks map[string]checkResult
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "no failures - all clean",
|
||||
checks: map[string]checkResult{
|
||||
"orphans": {issueCount: 0, fixedCount: 0},
|
||||
"dupes": {issueCount: 0, fixedCount: 0},
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "has error",
|
||||
checks: map[string]checkResult{
|
||||
"orphans": {err: os.ErrNotExist},
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "issues found but not all fixed",
|
||||
checks: map[string]checkResult{
|
||||
"orphans": {issueCount: 5, fixedCount: 3},
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "issues found and all fixed",
|
||||
checks: map[string]checkResult{
|
||||
"orphans": {issueCount: 5, fixedCount: 5},
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt // capture range variable
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := &validationResults{checks: tt.checks}
|
||||
got := r.hasFailures()
|
||||
if got != tt.want {
|
||||
t.Errorf("hasFailures() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidationResultsToJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := &validationResults{
|
||||
checks: map[string]checkResult{
|
||||
"orphans": {
|
||||
issueCount: 3,
|
||||
fixedCount: 2,
|
||||
suggestions: []string{"Run bd repair"},
|
||||
},
|
||||
"dupes": {
|
||||
issueCount: 0,
|
||||
fixedCount: 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
output := r.toJSON()
|
||||
|
||||
if output["total_issues"] != 3 {
|
||||
t.Errorf("total_issues = %v, want 3", output["total_issues"])
|
||||
}
|
||||
if output["total_fixed"] != 2 {
|
||||
t.Errorf("total_fixed = %v, want 2", output["total_fixed"])
|
||||
}
|
||||
if output["healthy"] != false {
|
||||
t.Errorf("healthy = %v, want false", output["healthy"])
|
||||
}
|
||||
|
||||
checks := output["checks"].(map[string]interface{})
|
||||
orphans := checks["orphans"].(map[string]interface{})
|
||||
if orphans["issue_count"] != 3 {
|
||||
t.Errorf("orphans issue_count = %v, want 3", orphans["issue_count"])
|
||||
}
|
||||
if orphans["fixed_count"] != 2 {
|
||||
t.Errorf("orphans fixed_count = %v, want 2", orphans["fixed_count"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateOrphanedDeps(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
allIssues := []*types.Issue{
|
||||
{
|
||||
ID: "bd-1",
|
||||
Dependencies: []*types.Dependency{
|
||||
{DependsOnID: "bd-2", Type: types.DepBlocks},
|
||||
{DependsOnID: "bd-999", Type: types.DepBlocks}, // orphaned
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "bd-2",
|
||||
},
|
||||
}
|
||||
|
||||
result := validateOrphanedDeps(ctx, allIssues, false)
|
||||
|
||||
if result.issueCount != 1 {
|
||||
t.Errorf("issueCount = %d, want 1", result.issueCount)
|
||||
}
|
||||
if result.fixedCount != 0 {
|
||||
t.Errorf("fixedCount = %d, want 0 (fix=false)", result.fixedCount)
|
||||
}
|
||||
if len(result.suggestions) == 0 {
|
||||
t.Error("expected suggestions")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDuplicates(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
allIssues := []*types.Issue{
|
||||
{
|
||||
ID: "bd-1",
|
||||
Title: "Same title",
|
||||
},
|
||||
{
|
||||
ID: "bd-2",
|
||||
Title: "Same title",
|
||||
},
|
||||
{
|
||||
ID: "bd-3",
|
||||
Title: "Different",
|
||||
},
|
||||
}
|
||||
|
||||
result := validateDuplicates(ctx, allIssues, false)
|
||||
|
||||
// Should find 1 duplicate (bd-2 is duplicate of bd-1)
|
||||
if result.issueCount != 1 {
|
||||
t.Errorf("issueCount = %d, want 1", result.issueCount)
|
||||
}
|
||||
if len(result.suggestions) == 0 {
|
||||
t.Error("expected suggestions")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePollution(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
allIssues := []*types.Issue{
|
||||
{
|
||||
ID: "test-1",
|
||||
Title: "Test issue",
|
||||
},
|
||||
{
|
||||
ID: "bd-1",
|
||||
Title: "Normal issue",
|
||||
},
|
||||
}
|
||||
|
||||
result := validatePollution(ctx, allIssues, false)
|
||||
|
||||
// Should detect test-1 as pollution
|
||||
if result.issueCount != 1 {
|
||||
t.Errorf("issueCount = %d, want 1", result.issueCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGitConflicts_NoFile(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Create temp dir without JSONL
|
||||
tmpDir := t.TempDir()
|
||||
if err := os.MkdirAll(tmpDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Override dbPath to point to temp dir (file won't exist)
|
||||
originalDBPath := dbPath
|
||||
dbPath = tmpDir + "/nonexistent.db"
|
||||
defer func() { dbPath = originalDBPath }()
|
||||
|
||||
result := validateGitConflicts(ctx, false)
|
||||
|
||||
if result.issueCount != 0 {
|
||||
t.Errorf("issueCount = %d, want 0 (no file)", result.issueCount)
|
||||
}
|
||||
if result.err != nil {
|
||||
t.Errorf("unexpected error: %v", result.err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGitConflicts_WithMarkers(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Create temp JSONL with conflict markers
|
||||
tmpDir := t.TempDir()
|
||||
jsonlPath := tmpDir + "/test.jsonl"
|
||||
content := `{"id":"bd-1"}
|
||||
<<<<<<< HEAD
|
||||
{"id":"bd-2","title":"Version A"}
|
||||
=======
|
||||
{"id":"bd-2","title":"Version B"}
|
||||
>>>>>>> main
|
||||
{"id":"bd-3"}`
|
||||
|
||||
if err := os.WriteFile(jsonlPath, []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Override dbPath to point to temp file
|
||||
originalDBPath := dbPath
|
||||
dbPath = jsonlPath
|
||||
defer func() { dbPath = originalDBPath }()
|
||||
|
||||
result := validateGitConflicts(ctx, false)
|
||||
|
||||
if result.issueCount != 1 {
|
||||
t.Errorf("issueCount = %d, want 1 (conflict found)", result.issueCount)
|
||||
}
|
||||
if len(result.suggestions) == 0 {
|
||||
t.Error("expected suggestions for conflict resolution")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGitConflicts_Clean(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Create temp JSONL without conflicts
|
||||
tmpDir := t.TempDir()
|
||||
jsonlPath := tmpDir + "/test.jsonl"
|
||||
content := `{"id":"bd-1","title":"Normal"}
|
||||
{"id":"bd-2","title":"Also normal"}`
|
||||
|
||||
if err := os.WriteFile(jsonlPath, []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Override dbPath to point to temp file
|
||||
originalDBPath := dbPath
|
||||
dbPath = jsonlPath
|
||||
defer func() { dbPath = originalDBPath }()
|
||||
|
||||
result := validateGitConflicts(ctx, false)
|
||||
|
||||
if result.issueCount != 0 {
|
||||
t.Errorf("issueCount = %d, want 0 (clean file)", result.issueCount)
|
||||
}
|
||||
if result.err != nil {
|
||||
t.Errorf("unexpected error: %v", result.err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user