Merge remote-tracking branch 'origin/polecat/Immortan'
# Conflicts: # internal/refinery/engineer.go # internal/refinery/engineer_test.go
This commit is contained in:
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