fix(doctor): UX improvements for diagnostics and daemon (#687)
* fix(doctor): UX improvements for diagnostics and daemon - Add Repo Fingerprint check to detect when database belongs to a different repository (copied .beads dir or git remote URL change) - Add interactive fix for repo fingerprint with options: update repo ID, reinitialize database, or skip - Add visible warning when daemon takes >5s to start, recommending 'bd doctor' for diagnosis - Detect install method (Homebrew vs script) and show only relevant upgrade command - Improve WARNINGS section: - Add icons (⚠ or ✖) next to each item - Color numbers by severity (yellow for warnings, red for errors) - Render entire error lines in red - Sort by severity (errors first) - Fix alignment with checkmarks above - Use heavier fail icon (✖) for better visibility - Add integration and validation tests for doctor fixes * fix(lint): address errcheck and gosec warnings - mol_bond.go: explicitly ignore ephStore.Close() error - beads.go: add nosec for .gitignore file permissions (0644 is standard)
This commit is contained in:
225
cmd/bd/doctor/fix/fix_integration_test.go
Normal file
225
cmd/bd/doctor/fix/fix_integration_test.go
Normal file
@@ -0,0 +1,225 @@
|
||||
//go:build integration
|
||||
|
||||
package fix
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// setupTestGitRepoIntegration creates a temporary git repository with a .beads directory
|
||||
func setupTestGitRepoIntegration(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
beadsDir := filepath.Join(dir, ".beads")
|
||||
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||
t.Fatalf("failed to create .beads directory: %v", err)
|
||||
}
|
||||
|
||||
// Initialize git repo
|
||||
cmd := exec.Command("git", "init")
|
||||
cmd.Dir = dir
|
||||
if err := cmd.Run(); err != nil {
|
||||
t.Fatalf("failed to init git repo: %v", err)
|
||||
}
|
||||
|
||||
// Configure git user for commits
|
||||
cmd = exec.Command("git", "config", "user.email", "test@test.com")
|
||||
cmd.Dir = dir
|
||||
_ = cmd.Run()
|
||||
|
||||
cmd = exec.Command("git", "config", "user.name", "Test User")
|
||||
cmd.Dir = dir
|
||||
_ = cmd.Run()
|
||||
|
||||
return dir
|
||||
}
|
||||
|
||||
// runGitIntegration runs a git command and returns output
|
||||
func runGitIntegration(t *testing.T, dir string, args ...string) string {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Logf("git %v: %s", args, output)
|
||||
}
|
||||
return string(output)
|
||||
}
|
||||
|
||||
// TestSyncBranchHealth_LocalAndRemoteDiverged tests fix when branches diverged
|
||||
func TestSyncBranchHealth_LocalAndRemoteDiverged(t *testing.T) {
|
||||
// Setup bare remote repo
|
||||
remoteDir := t.TempDir()
|
||||
runGitIntegration(t, remoteDir, "init", "--bare")
|
||||
|
||||
// Setup local repo
|
||||
dir := setupTestGitRepoIntegration(t)
|
||||
runGitIntegration(t, dir, "remote", "add", "origin", remoteDir)
|
||||
|
||||
// Create main branch with initial commit
|
||||
testFile := filepath.Join(dir, "test.txt")
|
||||
if err := os.WriteFile(testFile, []byte("main content"), 0600); err != nil {
|
||||
t.Fatalf("failed to create test file: %v", err)
|
||||
}
|
||||
runGitIntegration(t, dir, "add", "test.txt")
|
||||
runGitIntegration(t, dir, "commit", "-m", "initial commit")
|
||||
runGitIntegration(t, dir, "branch", "-M", "main")
|
||||
runGitIntegration(t, dir, "push", "-u", "origin", "main")
|
||||
|
||||
// Create sync branch
|
||||
runGitIntegration(t, dir, "checkout", "-b", "beads-sync")
|
||||
syncFile := filepath.Join(dir, "sync.txt")
|
||||
if err := os.WriteFile(syncFile, []byte("sync content"), 0600); err != nil {
|
||||
t.Fatalf("failed to create sync file: %v", err)
|
||||
}
|
||||
runGitIntegration(t, dir, "add", "sync.txt")
|
||||
runGitIntegration(t, dir, "commit", "-m", "sync commit")
|
||||
runGitIntegration(t, dir, "push", "-u", "origin", "beads-sync")
|
||||
|
||||
// Simulate divergence: update main
|
||||
runGitIntegration(t, dir, "checkout", "main")
|
||||
if err := os.WriteFile(testFile, []byte("updated main content"), 0600); err != nil {
|
||||
t.Fatalf("failed to update test file: %v", err)
|
||||
}
|
||||
runGitIntegration(t, dir, "add", "test.txt")
|
||||
runGitIntegration(t, dir, "commit", "-m", "update main")
|
||||
runGitIntegration(t, dir, "push", "origin", "main")
|
||||
|
||||
// Now beads-sync is behind main - fix it
|
||||
err := SyncBranchHealth(dir, "beads-sync")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Verify beads-sync was reset to main
|
||||
runGitIntegration(t, dir, "checkout", "beads-sync")
|
||||
runGitIntegration(t, dir, "pull", "origin", "beads-sync")
|
||||
|
||||
// Check that beads-sync now has main's content
|
||||
content, err := os.ReadFile(testFile)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read test file: %v", err)
|
||||
}
|
||||
if string(content) != "updated main content" {
|
||||
t.Errorf("expected beads-sync to have main's content, got: %s", content)
|
||||
}
|
||||
|
||||
// Check that sync.txt no longer exists (branch was reset)
|
||||
if _, err := os.Stat(syncFile); !os.IsNotExist(err) {
|
||||
t.Error("sync.txt should not exist after reset to main")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSyncBranchHealth_UncommittedChanges tests fix with uncommitted changes
|
||||
func TestSyncBranchHealth_UncommittedChanges(t *testing.T) {
|
||||
// Setup bare remote repo
|
||||
remoteDir := t.TempDir()
|
||||
runGitIntegration(t, remoteDir, "init", "--bare")
|
||||
|
||||
// Setup local repo
|
||||
dir := setupTestGitRepoIntegration(t)
|
||||
runGitIntegration(t, dir, "remote", "add", "origin", remoteDir)
|
||||
|
||||
// Create main branch with initial commit
|
||||
testFile := filepath.Join(dir, "test.txt")
|
||||
if err := os.WriteFile(testFile, []byte("main content"), 0600); err != nil {
|
||||
t.Fatalf("failed to create test file: %v", err)
|
||||
}
|
||||
runGitIntegration(t, dir, "add", "test.txt")
|
||||
runGitIntegration(t, dir, "commit", "-m", "initial commit")
|
||||
runGitIntegration(t, dir, "branch", "-M", "main")
|
||||
runGitIntegration(t, dir, "push", "-u", "origin", "main")
|
||||
|
||||
// Create sync branch and push it
|
||||
runGitIntegration(t, dir, "checkout", "-b", "beads-sync")
|
||||
runGitIntegration(t, dir, "push", "-u", "origin", "beads-sync")
|
||||
|
||||
// Add uncommitted changes to sync branch
|
||||
dirtyFile := filepath.Join(dir, "dirty.txt")
|
||||
if err := os.WriteFile(dirtyFile, []byte("uncommitted"), 0600); err != nil {
|
||||
t.Fatalf("failed to create dirty file: %v", err)
|
||||
}
|
||||
|
||||
// Checkout main to allow sync branch reset
|
||||
runGitIntegration(t, dir, "checkout", "main")
|
||||
|
||||
// Fix should succeed - it resets the branch, not the working tree
|
||||
err := SyncBranchHealth(dir, "beads-sync")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Verify sync branch was reset
|
||||
output := runGitIntegration(t, dir, "log", "--oneline", "beads-sync")
|
||||
if !strings.Contains(output, "initial commit") {
|
||||
t.Errorf("beads-sync should be reset to main, got log: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSyncBranchHealth_RemoteUnreachable tests fix when remote is unreachable
|
||||
func TestSyncBranchHealth_RemoteUnreachable(t *testing.T) {
|
||||
dir := setupTestGitRepoIntegration(t)
|
||||
|
||||
// Add unreachable remote
|
||||
runGitIntegration(t, dir, "remote", "add", "origin", "https://nonexistent.example.com/repo.git")
|
||||
|
||||
// Create main branch with initial commit
|
||||
testFile := filepath.Join(dir, "test.txt")
|
||||
if err := os.WriteFile(testFile, []byte("main content"), 0600); err != nil {
|
||||
t.Fatalf("failed to create test file: %v", err)
|
||||
}
|
||||
runGitIntegration(t, dir, "add", "test.txt")
|
||||
runGitIntegration(t, dir, "commit", "-m", "initial commit")
|
||||
runGitIntegration(t, dir, "branch", "-M", "main")
|
||||
|
||||
// Create local sync branch
|
||||
runGitIntegration(t, dir, "checkout", "-b", "beads-sync")
|
||||
runGitIntegration(t, dir, "checkout", "main")
|
||||
|
||||
// Fix should fail when trying to fetch
|
||||
err := SyncBranchHealth(dir, "beads-sync")
|
||||
if err == nil {
|
||||
t.Error("expected error when remote is unreachable")
|
||||
}
|
||||
if err != nil && !strings.Contains(err.Error(), "failed to fetch") {
|
||||
t.Errorf("expected fetch error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSyncBranchHealth_CurrentlyOnSyncBranch tests error when on sync branch
|
||||
func TestSyncBranchHealth_CurrentlyOnSyncBranch(t *testing.T) {
|
||||
// Setup bare remote repo
|
||||
remoteDir := t.TempDir()
|
||||
runGitIntegration(t, remoteDir, "init", "--bare")
|
||||
|
||||
// Setup local repo
|
||||
dir := setupTestGitRepoIntegration(t)
|
||||
runGitIntegration(t, dir, "remote", "add", "origin", remoteDir)
|
||||
|
||||
// Create main branch with initial commit
|
||||
testFile := filepath.Join(dir, "test.txt")
|
||||
if err := os.WriteFile(testFile, []byte("main content"), 0600); err != nil {
|
||||
t.Fatalf("failed to create test file: %v", err)
|
||||
}
|
||||
runGitIntegration(t, dir, "add", "test.txt")
|
||||
runGitIntegration(t, dir, "commit", "-m", "initial commit")
|
||||
runGitIntegration(t, dir, "branch", "-M", "main")
|
||||
runGitIntegration(t, dir, "push", "-u", "origin", "main")
|
||||
|
||||
// Create and checkout sync branch
|
||||
runGitIntegration(t, dir, "checkout", "-b", "beads-sync")
|
||||
runGitIntegration(t, dir, "push", "-u", "origin", "beads-sync")
|
||||
|
||||
// Try to fix while on sync branch
|
||||
err := SyncBranchHealth(dir, "beads-sync")
|
||||
if err == nil {
|
||||
t.Error("expected error when currently on sync branch")
|
||||
}
|
||||
if err != nil && !strings.Contains(err.Error(), "currently on beads-sync branch") {
|
||||
t.Errorf("expected 'currently on branch' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
126
cmd/bd/doctor/fix/repo_fingerprint.go
Normal file
126
cmd/bd/doctor/fix/repo_fingerprint.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package fix
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// readLineUnbuffered reads a line from stdin without buffering.
|
||||
// This ensures subprocess stdin isn't consumed by our buffered reader.
|
||||
func readLineUnbuffered() (string, error) {
|
||||
var result []byte
|
||||
buf := make([]byte, 1)
|
||||
for {
|
||||
n, err := os.Stdin.Read(buf)
|
||||
if err != nil {
|
||||
return string(result), err
|
||||
}
|
||||
if n == 1 {
|
||||
c := buf[0] // #nosec G602 -- n==1 guarantees buf has 1 byte
|
||||
if c == '\n' {
|
||||
return string(result), nil
|
||||
}
|
||||
result = append(result, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RepoFingerprint fixes repo fingerprint mismatches by prompting the user
|
||||
// for which action to take. This is interactive because the consequences
|
||||
// differ significantly between options:
|
||||
// 1. Update repo ID (if URL changed or bd upgraded)
|
||||
// 2. Reinitialize database (if wrong database was copied)
|
||||
// 3. Skip (do nothing)
|
||||
func RepoFingerprint(path string) error {
|
||||
// Validate workspace
|
||||
if err := validateBeadsWorkspace(path); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get bd binary path
|
||||
bdBinary, err := getBdBinary()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Prompt user for action
|
||||
fmt.Println("\n Repo fingerprint mismatch detected. Choose an action:")
|
||||
fmt.Println()
|
||||
fmt.Println(" [1] Update repo ID (if git remote URL changed or bd was upgraded)")
|
||||
fmt.Println(" [2] Reinitialize database (if wrong .beads was copied here)")
|
||||
fmt.Println(" [s] Skip (do nothing)")
|
||||
fmt.Println()
|
||||
fmt.Print(" Choice [1/2/s]: ")
|
||||
|
||||
// Read single character without buffering to avoid consuming input meant for subprocesses
|
||||
response, err := readLineUnbuffered()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read input: %w", err)
|
||||
}
|
||||
|
||||
response = strings.TrimSpace(strings.ToLower(response))
|
||||
|
||||
switch response {
|
||||
case "1":
|
||||
// Run bd migrate --update-repo-id
|
||||
fmt.Println(" → Running 'bd migrate --update-repo-id'...")
|
||||
cmd := exec.Command(bdBinary, "migrate", "--update-repo-id") // #nosec G204 -- bdBinary from validated executable path
|
||||
cmd.Dir = path
|
||||
cmd.Stdin = os.Stdin // Allow user to respond to migrate's confirmation prompt
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to update repo ID: %w", err)
|
||||
}
|
||||
return nil
|
||||
|
||||
case "2":
|
||||
// Confirm before destructive action
|
||||
fmt.Print(" ⚠️ This will DELETE .beads/beads.db. Continue? [y/N]: ")
|
||||
confirm, err := readLineUnbuffered()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read confirmation: %w", err)
|
||||
}
|
||||
confirm = strings.TrimSpace(strings.ToLower(confirm))
|
||||
if confirm != "y" && confirm != "yes" {
|
||||
fmt.Println(" → Skipped (canceled)")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Remove database and reinitialize
|
||||
beadsDir := filepath.Join(path, ".beads")
|
||||
dbPath := filepath.Join(beadsDir, "beads.db")
|
||||
|
||||
fmt.Printf(" → Removing %s...\n", dbPath)
|
||||
if err := os.Remove(dbPath); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("failed to remove database: %w", err)
|
||||
}
|
||||
|
||||
// Also remove WAL and SHM files if they exist
|
||||
_ = os.Remove(dbPath + "-wal")
|
||||
_ = os.Remove(dbPath + "-shm")
|
||||
|
||||
fmt.Println(" → Running 'bd init'...")
|
||||
cmd := exec.Command(bdBinary, "init", "--quiet") // #nosec G204 -- bdBinary from validated executable path
|
||||
cmd.Dir = path
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to initialize database: %w", err)
|
||||
}
|
||||
return nil
|
||||
|
||||
case "s", "":
|
||||
fmt.Println(" → Skipped")
|
||||
return nil
|
||||
|
||||
default:
|
||||
fmt.Printf(" → Unrecognized input '%s', skipping\n", response)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
37
cmd/bd/doctor/fix/validation_test.go
Normal file
37
cmd/bd/doctor/fix/validation_test.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package fix
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestFixFunctions_RequireBeadsDir verifies all fix functions properly validate
|
||||
// that a .beads directory exists before attempting fixes.
|
||||
// This replaces 10+ individual "missing .beads directory" subtests.
|
||||
func TestFixFunctions_RequireBeadsDir(t *testing.T) {
|
||||
funcs := []struct {
|
||||
name string
|
||||
fn func(string) error
|
||||
}{
|
||||
{"GitHooks", GitHooks},
|
||||
{"MergeDriver", MergeDriver},
|
||||
{"Daemon", Daemon},
|
||||
{"DBJSONLSync", DBJSONLSync},
|
||||
{"DatabaseVersion", DatabaseVersion},
|
||||
{"SchemaCompatibility", SchemaCompatibility},
|
||||
{"SyncBranchConfig", SyncBranchConfig},
|
||||
{"SyncBranchHealth", func(dir string) error { return SyncBranchHealth(dir, "beads-sync") }},
|
||||
{"UntrackedJSONL", UntrackedJSONL},
|
||||
{"MigrateTombstones", MigrateTombstones},
|
||||
}
|
||||
|
||||
for _, tc := range funcs {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Use a temp directory without .beads
|
||||
dir := t.TempDir()
|
||||
err := tc.fn(dir)
|
||||
if err == nil {
|
||||
t.Errorf("%s should return error for missing .beads directory", tc.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user