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:
@@ -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
|
||||
|
||||
159
internal/doctor/beads_check.go
Normal file
159
internal/doctor/beads_check.go
Normal 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
|
||||
}
|
||||
101
internal/doctor/beads_check_test.go
Normal file
101
internal/doctor/beads_check_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user