Files
gastown/internal/doctor/beads_check.go
Steve Yegge debe47cc62 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>
2025-12-19 16:06:12 -08:00

160 lines
4.5 KiB
Go

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
}