* feat: enhance bd doctor sync detection with count and prefix mismatch checks Improves bd doctor to detect actual database-JSONL sync issues instead of relying only on file modification times: Key improvements: 1. Count detection: Reports when database issue count differs from JSONL (e.g., "Count mismatch: database has 0 issues, JSONL has 61") 2. Prefix detection: Identifies prefix mismatches when majority of JSONL issues use different prefix than database config 3. Error handling: Returns errors from helper functions instead of silent failures, distinguishing "can't open DB" from "counts differ" 4. Query optimization: Single database connection for all checks (reduced from 3 opens to 1) 5. Better error reporting: Shows actual error details when database or JSONL can't be read This addresses the core issue where bd doctor would incorrectly report "Database and JSONL are in sync" when the database was empty but JSONL contained issues (as happened in privacy2 project). Tests: - Added TestCountJSONLIssuesWithMalformedLines to verify malformed JSON handling - Existing doctor tests still pass - countJSONLIssues now returns error to indicate parsing issues 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com> * fix: correct git hooks installation instructions in bd doctor The original message referenced './examples/git-hooks/install.sh' which doesn't exist in user projects. This fix changes the message to point to the actual location in the beads GitHub repository: Before: "Run './examples/git-hooks/install.sh' to install recommended git hooks" After: "See https://github.com/steveyegge/beads/tree/main/examples/git-hooks for installation instructions" This works for any project using bd, not just the beads repository itself. 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com> * feat: add recovery suggestions when database fails but JSONL has issues When bd doctor detects that the database cannot be opened/queried but the JSONL file contains issues, it now suggests the recovery command: Fix: Run 'bd import -i issues.jsonl --rename-on-import' to recover issues from JSONL This addresses the case where: - Database is corrupted or inaccessible - JSONL has all the issues backed up - User needs a clear path to recover The check now: 1. Reads JSONL first (doesn't depend on database) 2. If database fails but JSONL has issues, suggests recovery command 3. If database can be queried, continues with sync checks as before Tested on privacy2 project which has 61 issues in JSONL but inaccessible database. 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com> * fix: support hash-based issue IDs in import rename The import --rename-on-import flag was rejecting valid issue IDs with hash-based suffixes (e.g., privacy-09ea) because the validation only accepted numeric suffixes. Beads now generates and accepts base36-encoded hash IDs, so update the validation to match. Changes: - Update isNumeric() to accept base36 characters (0-9, a-z) - Update tests to reflect hash-based ID support - Add gosec nolint comment for safe file path construction Fixes the error: "cannot rename issue privacy-09ea: non-numeric suffix '09ea'" --------- Co-authored-by: Claude <noreply@anthropic.com>
499 lines
12 KiB
Go
499 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestDoctorNoBeadsDir(t *testing.T) {
|
|
// Create temporary directory
|
|
tmpDir := t.TempDir()
|
|
|
|
// Run diagnostics
|
|
result := runDiagnostics(tmpDir)
|
|
|
|
// Should fail overall
|
|
if result.OverallOK {
|
|
t.Error("Expected OverallOK to be false when .beads/ directory is missing")
|
|
}
|
|
|
|
// Check installation check failed
|
|
if len(result.Checks) == 0 {
|
|
t.Fatal("Expected at least one check")
|
|
}
|
|
|
|
installCheck := result.Checks[0]
|
|
if installCheck.Name != "Installation" {
|
|
t.Errorf("Expected first check to be Installation, got %s", installCheck.Name)
|
|
}
|
|
if installCheck.Status != "error" {
|
|
t.Errorf("Expected Installation status to be error, got %s", installCheck.Status)
|
|
}
|
|
if installCheck.Fix == "" {
|
|
t.Error("Expected Installation check to have a fix")
|
|
}
|
|
}
|
|
|
|
func TestDoctorWithBeadsDir(t *testing.T) {
|
|
// Create temporary directory with .beads
|
|
tmpDir := t.TempDir()
|
|
beadsDir := filepath.Join(tmpDir, ".beads")
|
|
if err := os.Mkdir(beadsDir, 0750); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Run diagnostics
|
|
result := runDiagnostics(tmpDir)
|
|
|
|
// Should have installation check passing
|
|
if len(result.Checks) == 0 {
|
|
t.Fatal("Expected at least one check")
|
|
}
|
|
|
|
installCheck := result.Checks[0]
|
|
if installCheck.Name != "Installation" {
|
|
t.Errorf("Expected first check to be Installation, got %s", installCheck.Name)
|
|
}
|
|
if installCheck.Status != "ok" {
|
|
t.Errorf("Expected Installation status to be ok, got %s", installCheck.Status)
|
|
}
|
|
}
|
|
|
|
func TestDoctorJSONOutput(t *testing.T) {
|
|
// Create temporary directory
|
|
tmpDir := t.TempDir()
|
|
|
|
// Run diagnostics
|
|
result := runDiagnostics(tmpDir)
|
|
|
|
// Marshal to JSON to verify structure
|
|
jsonBytes, err := json.Marshal(result)
|
|
if err != nil {
|
|
t.Fatalf("Failed to marshal result to JSON: %v", err)
|
|
}
|
|
|
|
// Unmarshal back to verify structure
|
|
var decoded doctorResult
|
|
if err := json.Unmarshal(jsonBytes, &decoded); err != nil {
|
|
t.Fatalf("Failed to unmarshal JSON: %v", err)
|
|
}
|
|
|
|
// Verify key fields
|
|
if decoded.Path != result.Path {
|
|
t.Errorf("Path mismatch: %s != %s", decoded.Path, result.Path)
|
|
}
|
|
if decoded.CLIVersion != result.CLIVersion {
|
|
t.Errorf("CLIVersion mismatch: %s != %s", decoded.CLIVersion, result.CLIVersion)
|
|
}
|
|
if decoded.OverallOK != result.OverallOK {
|
|
t.Errorf("OverallOK mismatch: %v != %v", decoded.OverallOK, result.OverallOK)
|
|
}
|
|
if len(decoded.Checks) != len(result.Checks) {
|
|
t.Errorf("Checks length mismatch: %d != %d", len(decoded.Checks), len(result.Checks))
|
|
}
|
|
}
|
|
|
|
// Note: isHashID is tested in migrate_hash_ids_test.go
|
|
|
|
func TestCheckInstallation(t *testing.T) {
|
|
// Test with missing .beads directory
|
|
tmpDir := t.TempDir()
|
|
check := checkInstallation(tmpDir)
|
|
|
|
if check.Status != statusError {
|
|
t.Errorf("Expected error status, got %s", check.Status)
|
|
}
|
|
if check.Fix == "" {
|
|
t.Error("Expected fix to be provided")
|
|
}
|
|
|
|
// Test with existing .beads directory
|
|
beadsDir := filepath.Join(tmpDir, ".beads")
|
|
if err := os.Mkdir(beadsDir, 0750); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
check = checkInstallation(tmpDir)
|
|
if check.Status != statusOK {
|
|
t.Errorf("Expected ok status, got %s", check.Status)
|
|
}
|
|
}
|
|
|
|
func TestCheckDatabaseVersionJSONLMode(t *testing.T) {
|
|
// Create temporary directory with .beads but no database
|
|
tmpDir := t.TempDir()
|
|
beadsDir := filepath.Join(tmpDir, ".beads")
|
|
if err := os.Mkdir(beadsDir, 0750); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Create empty issues.jsonl to simulate --no-db mode
|
|
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
|
|
if err := os.WriteFile(jsonlPath, []byte{}, 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
check := checkDatabaseVersion(tmpDir)
|
|
|
|
if check.Status != statusOK {
|
|
t.Errorf("Expected ok status for JSONL mode, got %s", check.Status)
|
|
}
|
|
if check.Message != "JSONL-only mode" {
|
|
t.Errorf("Expected JSONL-only mode message, got %s", check.Message)
|
|
}
|
|
if check.Detail == "" {
|
|
t.Error("Expected detail field to be set for JSONL mode")
|
|
}
|
|
}
|
|
|
|
func TestCompareVersions(t *testing.T) {
|
|
tests := []struct {
|
|
v1 string
|
|
v2 string
|
|
expected int
|
|
}{
|
|
{"0.20.1", "0.20.1", 0}, // Equal
|
|
{"0.20.1", "0.20.0", 1}, // v1 > v2
|
|
{"0.20.0", "0.20.1", -1}, // v1 < v2
|
|
{"0.10.0", "0.9.9", 1}, // Major.minor comparison
|
|
{"1.0.0", "0.99.99", 1}, // Major version difference
|
|
{"0.20.1", "0.3.0", 1}, // String comparison would fail this
|
|
{"1.2", "1.2.0", 0}, // Different length, equal
|
|
{"1.2.1", "1.2", 1}, // Different length, v1 > v2
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
result := compareVersions(tc.v1, tc.v2)
|
|
if result != tc.expected {
|
|
t.Errorf("compareVersions(%q, %q) = %d, expected %d", tc.v1, tc.v2, result, tc.expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCheckMultipleDatabases(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
dbFiles []string
|
|
expectedStatus string
|
|
expectWarning bool
|
|
}{
|
|
{
|
|
name: "no databases",
|
|
dbFiles: []string{},
|
|
expectedStatus: statusOK,
|
|
expectWarning: false,
|
|
},
|
|
{
|
|
name: "single database",
|
|
dbFiles: []string{"beads.db"},
|
|
expectedStatus: statusOK,
|
|
expectWarning: false,
|
|
},
|
|
{
|
|
name: "multiple databases",
|
|
dbFiles: []string{"beads.db", "old.db"},
|
|
expectedStatus: statusWarning,
|
|
expectWarning: true,
|
|
},
|
|
{
|
|
name: "backup files ignored",
|
|
dbFiles: []string{"beads.db", "beads.backup.db"},
|
|
expectedStatus: statusOK,
|
|
expectWarning: false,
|
|
},
|
|
{
|
|
name: "vc.db ignored",
|
|
dbFiles: []string{"beads.db", "vc.db"},
|
|
expectedStatus: statusOK,
|
|
expectWarning: false,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
beadsDir := filepath.Join(tmpDir, ".beads")
|
|
if err := os.Mkdir(beadsDir, 0750); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Create test database files
|
|
for _, dbFile := range tc.dbFiles {
|
|
path := filepath.Join(beadsDir, dbFile)
|
|
if err := os.WriteFile(path, []byte{}, 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
check := checkMultipleDatabases(tmpDir)
|
|
|
|
if check.Status != tc.expectedStatus {
|
|
t.Errorf("Expected status %s, got %s", tc.expectedStatus, check.Status)
|
|
}
|
|
|
|
if tc.expectWarning && check.Fix == "" {
|
|
t.Error("Expected fix message for warning status")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCheckMultipleJSONLFiles(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
jsonlFiles []string
|
|
expectedStatus string
|
|
expectWarning bool
|
|
}{
|
|
{
|
|
name: "no JSONL files",
|
|
jsonlFiles: []string{},
|
|
expectedStatus: statusOK,
|
|
expectWarning: false,
|
|
},
|
|
{
|
|
name: "single issues.jsonl",
|
|
jsonlFiles: []string{"issues.jsonl"},
|
|
expectedStatus: statusOK,
|
|
expectWarning: false,
|
|
},
|
|
{
|
|
name: "single beads.jsonl",
|
|
jsonlFiles: []string{"beads.jsonl"},
|
|
expectedStatus: statusOK,
|
|
expectWarning: false,
|
|
},
|
|
{
|
|
name: "both JSONL files",
|
|
jsonlFiles: []string{"issues.jsonl", "beads.jsonl"},
|
|
expectedStatus: statusWarning,
|
|
expectWarning: true,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
beadsDir := filepath.Join(tmpDir, ".beads")
|
|
if err := os.Mkdir(beadsDir, 0750); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Create test JSONL files
|
|
for _, jsonlFile := range tc.jsonlFiles {
|
|
path := filepath.Join(beadsDir, jsonlFile)
|
|
if err := os.WriteFile(path, []byte{}, 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
check := checkMultipleJSONLFiles(tmpDir)
|
|
|
|
if check.Status != tc.expectedStatus {
|
|
t.Errorf("Expected status %s, got %s", tc.expectedStatus, check.Status)
|
|
}
|
|
|
|
if tc.expectWarning && check.Fix == "" {
|
|
t.Error("Expected fix message for warning status")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCheckPermissions(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
beadsDir := filepath.Join(tmpDir, ".beads")
|
|
if err := os.Mkdir(beadsDir, 0750); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
check := checkPermissions(tmpDir)
|
|
|
|
if check.Status != statusOK {
|
|
t.Errorf("Expected ok status for writable directory, got %s: %s", check.Status, check.Message)
|
|
}
|
|
}
|
|
|
|
func TestCheckDatabaseJSONLSync(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
hasDB bool
|
|
hasJSONL bool
|
|
expectedStatus string
|
|
}{
|
|
{
|
|
name: "no database",
|
|
hasDB: false,
|
|
hasJSONL: true,
|
|
expectedStatus: statusOK,
|
|
},
|
|
{
|
|
name: "no JSONL",
|
|
hasDB: true,
|
|
hasJSONL: false,
|
|
expectedStatus: statusOK,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
beadsDir := filepath.Join(tmpDir, ".beads")
|
|
if err := os.Mkdir(beadsDir, 0750); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if tc.hasDB {
|
|
dbPath := filepath.Join(beadsDir, "beads.db")
|
|
// Skip database creation tests due to SQLite driver registration in tests
|
|
// The real doctor command works fine with actual databases
|
|
if tc.hasJSONL {
|
|
t.Skip("Database creation in tests requires complex driver setup")
|
|
}
|
|
// For no-JSONL case, just create an empty file
|
|
if err := os.WriteFile(dbPath, []byte{}, 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
if tc.hasJSONL {
|
|
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
|
|
if err := os.WriteFile(jsonlPath, []byte{}, 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
check := checkDatabaseJSONLSync(tmpDir)
|
|
|
|
if check.Status != tc.expectedStatus {
|
|
t.Errorf("Expected status %s, got %s", tc.expectedStatus, check.Status)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
|
|
func TestCountJSONLIssuesWithMalformedLines(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
beadsDir := filepath.Join(tmpDir, ".beads")
|
|
if err := os.Mkdir(beadsDir, 0750); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Create JSONL file with mixed valid and invalid JSON
|
|
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
|
|
jsonlContent := `{"id":"test-001","title":"Valid 1"}
|
|
invalid json line here
|
|
{"id":"test-002","title":"Valid 2"}
|
|
{"broken": incomplete
|
|
{"id":"test-003","title":"Valid 3"}
|
|
`
|
|
if err := os.WriteFile(jsonlPath, []byte(jsonlContent), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
count, prefixes, err := countJSONLIssues(jsonlPath)
|
|
|
|
// Should count valid issues (3)
|
|
if count != 3 {
|
|
t.Errorf("Expected 3 issues, got %d", count)
|
|
}
|
|
|
|
// Should have 1 error for malformed lines
|
|
if err == nil {
|
|
t.Error("Expected error for malformed lines, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "skipped") {
|
|
t.Errorf("Expected error about skipped lines, got: %v", err)
|
|
}
|
|
|
|
// Should have extracted prefix
|
|
if prefixes["test"] != 3 {
|
|
t.Errorf("Expected 3 'test' prefixes, got %d", prefixes["test"])
|
|
}
|
|
}
|
|
func TestCheckGitHooks(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
hasGitDir bool
|
|
installedHooks []string
|
|
expectedStatus string
|
|
expectWarning bool
|
|
}{
|
|
{
|
|
name: "not a git repository",
|
|
hasGitDir: false,
|
|
installedHooks: []string{},
|
|
expectedStatus: statusOK,
|
|
expectWarning: false,
|
|
},
|
|
{
|
|
name: "all hooks installed",
|
|
hasGitDir: true,
|
|
installedHooks: []string{"pre-commit", "post-merge", "pre-push"},
|
|
expectedStatus: statusOK,
|
|
expectWarning: false,
|
|
},
|
|
{
|
|
name: "no hooks installed",
|
|
hasGitDir: true,
|
|
installedHooks: []string{},
|
|
expectedStatus: statusWarning,
|
|
expectWarning: true,
|
|
},
|
|
{
|
|
name: "some hooks installed",
|
|
hasGitDir: true,
|
|
installedHooks: []string{"pre-commit"},
|
|
expectedStatus: statusWarning,
|
|
expectWarning: true,
|
|
},
|
|
{
|
|
name: "partial hooks installed",
|
|
hasGitDir: true,
|
|
installedHooks: []string{"pre-commit", "post-merge"},
|
|
expectedStatus: statusWarning,
|
|
expectWarning: true,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
if tc.hasGitDir {
|
|
gitDir := filepath.Join(tmpDir, ".git")
|
|
hooksDir := filepath.Join(gitDir, "hooks")
|
|
if err := os.MkdirAll(hooksDir, 0750); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Create installed hooks
|
|
for _, hookName := range tc.installedHooks {
|
|
hookPath := filepath.Join(hooksDir, hookName)
|
|
if err := os.WriteFile(hookPath, []byte("#!/bin/sh\n"), 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
check := checkGitHooks(tmpDir)
|
|
|
|
if check.Status != tc.expectedStatus {
|
|
t.Errorf("Expected status %s, got %s", tc.expectedStatus, check.Status)
|
|
}
|
|
|
|
if tc.expectWarning && check.Fix == "" {
|
|
t.Error("Expected fix message for warning status")
|
|
}
|
|
|
|
if !tc.expectWarning && check.Fix != "" && tc.hasGitDir {
|
|
t.Error("Expected no fix message for non-warning status")
|
|
}
|
|
})
|
|
}
|
|
}
|