fix(doctor): add beads database check to detect empty issues.db

Add a new doctor check that detects when issues.db is empty but
issues.jsonl has content. This situation can cause "table issues has
no column named pinned" errors when running bd mail send.

The check:
- Detects empty database file alongside non-empty JSONL
- Can auto-fix by deleting the empty database and triggering rebuild
- Works for both town-level and rig-level beads

Run 'gt doctor --fix' to automatically fix this issue.

Fixes gt-bxi8

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-19 16:06:12 -08:00
parent 349205eda1
commit debe47cc62
4 changed files with 459 additions and 183 deletions

View File

@@ -53,16 +53,7 @@ func runDoctor(cmd *cobra.Command, args []string) error {
d := doctor.NewDoctor()
// Register built-in checks
// Note: Town-level checks are registered in gt-f9x.5
// Rig-level checks are registered in gt-f9x.6
// For now, we just have the framework ready
// If no checks registered, inform user
if len(d.Checks()) == 0 {
fmt.Println("No health checks registered yet.")
fmt.Println("Town-level and rig-level checks will be added in future updates.")
return nil
}
d.Register(doctor.NewBeadsDatabaseCheck())
// Run checks
var report *doctor.Report

View File

@@ -0,0 +1,159 @@
package doctor
import (
"bytes"
"os"
"os/exec"
"path/filepath"
)
// BeadsDatabaseCheck verifies that the beads database is properly initialized.
// It detects when issues.db is empty or missing critical columns, and can
// auto-fix by triggering a re-import from the JSONL file.
type BeadsDatabaseCheck struct {
FixableCheck
}
// NewBeadsDatabaseCheck creates a new beads database check.
func NewBeadsDatabaseCheck() *BeadsDatabaseCheck {
return &BeadsDatabaseCheck{
FixableCheck: FixableCheck{
BaseCheck: BaseCheck{
CheckName: "beads-database",
CheckDescription: "Verify beads database is properly initialized",
},
},
}
}
// Run checks if the beads database is properly initialized.
func (c *BeadsDatabaseCheck) Run(ctx *CheckContext) *CheckResult {
// Check town-level beads
beadsDir := filepath.Join(ctx.TownRoot, ".beads")
if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
return &CheckResult{
Name: c.Name(),
Status: StatusWarning,
Message: "No .beads directory found at town root",
FixHint: "Run 'bd init' to initialize beads",
}
}
// Check if issues.db exists and has content
issuesDB := filepath.Join(beadsDir, "issues.db")
issuesJSONL := filepath.Join(beadsDir, "issues.jsonl")
dbInfo, dbErr := os.Stat(issuesDB)
jsonlInfo, jsonlErr := os.Stat(issuesJSONL)
// If no database file, that's OK - beads will create it
if os.IsNotExist(dbErr) {
return &CheckResult{
Name: c.Name(),
Status: StatusOK,
Message: "No issues.db file (will be created on first use)",
}
}
// If database file is empty but JSONL has content, this is the bug
if dbErr == nil && dbInfo.Size() == 0 {
if jsonlErr == nil && jsonlInfo.Size() > 0 {
return &CheckResult{
Name: c.Name(),
Status: StatusError,
Message: "issues.db is empty but issues.jsonl has content",
Details: []string{
"This can cause 'table issues has no column named pinned' errors",
"The database needs to be rebuilt from the JSONL file",
},
FixHint: "Run 'gt doctor --fix' or delete issues.db and run 'bd sync --from-main'",
}
}
}
// Also check rig-level beads if a rig is specified
if ctx.RigName != "" {
rigBeadsDir := filepath.Join(ctx.RigPath(), ".beads")
if _, err := os.Stat(rigBeadsDir); err == nil {
rigDB := filepath.Join(rigBeadsDir, "issues.db")
rigJSONL := filepath.Join(rigBeadsDir, "issues.jsonl")
rigDBInfo, rigDBErr := os.Stat(rigDB)
rigJSONLInfo, rigJSONLErr := os.Stat(rigJSONL)
if rigDBErr == nil && rigDBInfo.Size() == 0 {
if rigJSONLErr == nil && rigJSONLInfo.Size() > 0 {
return &CheckResult{
Name: c.Name(),
Status: StatusError,
Message: "Rig issues.db is empty but issues.jsonl has content",
Details: []string{
"Rig: " + ctx.RigName,
"This can cause 'table issues has no column named pinned' errors",
},
FixHint: "Run 'gt doctor --fix' or delete the rig's issues.db",
}
}
}
}
}
return &CheckResult{
Name: c.Name(),
Status: StatusOK,
Message: "Beads database is properly initialized",
}
}
// Fix attempts to rebuild the database from JSONL.
func (c *BeadsDatabaseCheck) Fix(ctx *CheckContext) error {
beadsDir := filepath.Join(ctx.TownRoot, ".beads")
issuesDB := filepath.Join(beadsDir, "issues.db")
issuesJSONL := filepath.Join(beadsDir, "issues.jsonl")
// Check if we need to fix town-level database
dbInfo, dbErr := os.Stat(issuesDB)
jsonlInfo, jsonlErr := os.Stat(issuesJSONL)
if dbErr == nil && dbInfo.Size() == 0 && jsonlErr == nil && jsonlInfo.Size() > 0 {
// Delete the empty database file
if err := os.Remove(issuesDB); err != nil {
return err
}
// Run bd sync to rebuild from JSONL
cmd := exec.Command("bd", "sync", "--from-main")
cmd.Dir = ctx.TownRoot
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return err
}
}
// Also fix rig-level if specified
if ctx.RigName != "" {
rigBeadsDir := filepath.Join(ctx.RigPath(), ".beads")
rigDB := filepath.Join(rigBeadsDir, "issues.db")
rigJSONL := filepath.Join(rigBeadsDir, "issues.jsonl")
rigDBInfo, rigDBErr := os.Stat(rigDB)
rigJSONLInfo, rigJSONLErr := os.Stat(rigJSONL)
if rigDBErr == nil && rigDBInfo.Size() == 0 && rigJSONLErr == nil && rigJSONLInfo.Size() > 0 {
if err := os.Remove(rigDB); err != nil {
return err
}
cmd := exec.Command("bd", "sync", "--from-main")
cmd.Dir = ctx.RigPath()
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return err
}
}
}
return nil
}

View File

@@ -0,0 +1,101 @@
package doctor
import (
"os"
"path/filepath"
"testing"
)
func TestNewBeadsDatabaseCheck(t *testing.T) {
check := NewBeadsDatabaseCheck()
if check.Name() != "beads-database" {
t.Errorf("expected name 'beads-database', got %q", check.Name())
}
if !check.CanFix() {
t.Error("expected CanFix to return true")
}
}
func TestBeadsDatabaseCheck_NoBeadsDir(t *testing.T) {
tmpDir := t.TempDir()
check := NewBeadsDatabaseCheck()
ctx := &CheckContext{TownRoot: tmpDir}
result := check.Run(ctx)
if result.Status != StatusWarning {
t.Errorf("expected StatusWarning, got %v", result.Status)
}
}
func TestBeadsDatabaseCheck_NoDatabase(t *testing.T) {
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatal(err)
}
check := NewBeadsDatabaseCheck()
ctx := &CheckContext{TownRoot: tmpDir}
result := check.Run(ctx)
if result.Status != StatusOK {
t.Errorf("expected StatusOK, got %v", result.Status)
}
}
func TestBeadsDatabaseCheck_EmptyDatabase(t *testing.T) {
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatal(err)
}
// Create empty database
dbPath := filepath.Join(beadsDir, "issues.db")
if err := os.WriteFile(dbPath, []byte{}, 0644); err != nil {
t.Fatal(err)
}
// Create JSONL with content
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
if err := os.WriteFile(jsonlPath, []byte(`{"id":"test-1","title":"Test"}`), 0644); err != nil {
t.Fatal(err)
}
check := NewBeadsDatabaseCheck()
ctx := &CheckContext{TownRoot: tmpDir}
result := check.Run(ctx)
if result.Status != StatusError {
t.Errorf("expected StatusError for empty db with content in jsonl, got %v", result.Status)
}
}
func TestBeadsDatabaseCheck_PopulatedDatabase(t *testing.T) {
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatal(err)
}
// Create database with content
dbPath := filepath.Join(beadsDir, "issues.db")
if err := os.WriteFile(dbPath, []byte("SQLite format 3"), 0644); err != nil {
t.Fatal(err)
}
check := NewBeadsDatabaseCheck()
ctx := &CheckContext{TownRoot: tmpDir}
result := check.Run(ctx)
if result.Status != StatusOK {
t.Errorf("expected StatusOK for populated db, got %v", result.Status)
}
}