Merge remote-tracking branch 'origin/polecat/Immortan'

# Conflicts:
#	internal/refinery/engineer.go
#	internal/refinery/engineer_test.go
This commit is contained in:
Steve Yegge
2025-12-19 18:27:51 -08:00
6 changed files with 335 additions and 552 deletions

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)
}
}