diff --git a/cmd/bd/doctor.go b/cmd/bd/doctor.go index 4e18dd19..6beaa69a 100644 --- a/cmd/bd/doctor.go +++ b/cmd/bd/doctor.go @@ -572,6 +572,11 @@ func runDiagnostics(path string) doctorResult { result.Checks = append(result.Checks, persistentMolCheck) // Don't fail overall check for persistent mol issues, just warn + // Check 26c: Legacy merge queue files (gastown mrqueue remnants) + staleMQFilesCheck := convertDoctorCheck(doctor.CheckStaleMQFiles(path)) + result.Checks = append(result.Checks, staleMQFilesCheck) + // Don't fail overall check for legacy MQ files, just warn + // Check 27: Expired tombstones (maintenance) tombstonesExpiredCheck := convertDoctorCheck(doctor.CheckExpiredTombstones(path)) result.Checks = append(result.Checks, tombstonesExpiredCheck) diff --git a/cmd/bd/doctor/maintenance.go b/cmd/bd/doctor/maintenance.go index 9145454a..33111aab 100644 --- a/cmd/bd/doctor/maintenance.go +++ b/cmd/bd/doctor/maintenance.go @@ -392,3 +392,56 @@ func CheckPersistentMolIssues(path string) DoctorCheck { Category: CategoryMaintenance, } } + +// CheckStaleMQFiles detects legacy .beads/mq/*.json files from gastown. +// These files are LOCAL ONLY (not committed) and represent stale merge queue +// entries from the old mrqueue implementation. They are safe to delete since +// gt done already creates merge-request wisps in beads. +func CheckStaleMQFiles(path string) DoctorCheck { + beadsDir := resolveBeadsDir(filepath.Join(path, ".beads")) + mqDir := filepath.Join(beadsDir, "mq") + + if _, err := os.Stat(mqDir); os.IsNotExist(err) { + return DoctorCheck{ + Name: "Legacy MQ Files", + Status: StatusOK, + Message: "No legacy merge queue files", + Category: CategoryMaintenance, + } + } + + files, err := filepath.Glob(filepath.Join(mqDir, "*.json")) + if err != nil || len(files) == 0 { + return DoctorCheck{ + Name: "Legacy MQ Files", + Status: StatusOK, + Message: "No legacy merge queue files", + Category: CategoryMaintenance, + } + } + + return DoctorCheck{ + Name: "Legacy MQ Files", + Status: StatusWarning, + Message: fmt.Sprintf("%d stale .beads/mq/*.json file(s)", len(files)), + Detail: "Legacy gastown merge queue files (local only, safe to delete)", + Fix: "Run 'bd doctor --fix' to delete, or 'rm -rf .beads/mq/'", + Category: CategoryMaintenance, + } +} + +// FixStaleMQFiles removes the legacy .beads/mq/ directory and all its contents. +func FixStaleMQFiles(path string) error { + beadsDir := resolveBeadsDir(filepath.Join(path, ".beads")) + mqDir := filepath.Join(beadsDir, "mq") + + if _, err := os.Stat(mqDir); os.IsNotExist(err) { + return nil // Nothing to do + } + + if err := os.RemoveAll(mqDir); err != nil { + return fmt.Errorf("failed to remove %s: %w", mqDir, err) + } + + return nil +} diff --git a/cmd/bd/doctor/maintenance_test.go b/cmd/bd/doctor/maintenance_test.go index 35f20baf..8aeb6aeb 100644 --- a/cmd/bd/doctor/maintenance_test.go +++ b/cmd/bd/doctor/maintenance_test.go @@ -91,3 +91,124 @@ func TestCheckCompactionCandidates_NoDatabase(t *testing.T) { t.Errorf("expected category 'Maintenance', got %q", check.Category) } } + +func TestCheckStaleMQFiles_NoMQDirectory(t *testing.T) { + // Create temp directory with .beads but no mq subdirectory + tmpDir := t.TempDir() + beadsDir := filepath.Join(tmpDir, ".beads") + if err := os.MkdirAll(beadsDir, 0755); err != nil { + t.Fatalf("failed to create .beads dir: %v", err) + } + + check := CheckStaleMQFiles(tmpDir) + + if check.Name != "Legacy MQ Files" { + t.Errorf("expected name 'Legacy MQ Files', got %q", check.Name) + } + if check.Status != StatusOK { + t.Errorf("expected status OK, got %q", check.Status) + } + if check.Message != "No legacy merge queue files" { + t.Errorf("expected message about no legacy files, got %q", check.Message) + } + if check.Category != CategoryMaintenance { + t.Errorf("expected category 'Maintenance', got %q", check.Category) + } +} + +func TestCheckStaleMQFiles_EmptyMQDirectory(t *testing.T) { + // Create temp directory with .beads/mq but no JSON files + tmpDir := t.TempDir() + mqDir := filepath.Join(tmpDir, ".beads", "mq") + if err := os.MkdirAll(mqDir, 0755); err != nil { + t.Fatalf("failed to create .beads/mq dir: %v", err) + } + + check := CheckStaleMQFiles(tmpDir) + + if check.Status != StatusOK { + t.Errorf("expected status OK for empty mq dir, got %q", check.Status) + } +} + +func TestCheckStaleMQFiles_WithJSONFiles(t *testing.T) { + // Create temp directory with .beads/mq containing JSON files + tmpDir := t.TempDir() + mqDir := filepath.Join(tmpDir, ".beads", "mq") + if err := os.MkdirAll(mqDir, 0755); err != nil { + t.Fatalf("failed to create .beads/mq dir: %v", err) + } + + // Create some stale MQ files + for _, name := range []string{"mr-abc123.json", "mr-def456.json"} { + path := filepath.Join(mqDir, name) + if err := os.WriteFile(path, []byte(`{"id":"test"}`), 0644); err != nil { + t.Fatalf("failed to create %s: %v", name, err) + } + } + + check := CheckStaleMQFiles(tmpDir) + + if check.Name != "Legacy MQ Files" { + t.Errorf("expected name 'Legacy MQ Files', got %q", check.Name) + } + if check.Status != StatusWarning { + t.Errorf("expected status Warning for mq dir with files, got %q", check.Status) + } + if check.Message != "2 stale .beads/mq/*.json file(s)" { + t.Errorf("expected message about 2 stale files, got %q", check.Message) + } + if check.Fix == "" { + t.Error("expected fix message to be present") + } +} + +func TestFixStaleMQFiles_RemovesDirectory(t *testing.T) { + // Create temp directory with .beads/mq containing JSON files + tmpDir := t.TempDir() + mqDir := filepath.Join(tmpDir, ".beads", "mq") + if err := os.MkdirAll(mqDir, 0755); err != nil { + t.Fatalf("failed to create .beads/mq dir: %v", err) + } + + // Create a stale MQ file + path := filepath.Join(mqDir, "mr-abc123.json") + if err := os.WriteFile(path, []byte(`{"id":"test"}`), 0644); err != nil { + t.Fatalf("failed to create file: %v", err) + } + + // Verify directory exists before fix + if _, err := os.Stat(mqDir); os.IsNotExist(err) { + t.Fatal("mq directory should exist before fix") + } + + // Apply the fix + if err := FixStaleMQFiles(tmpDir); err != nil { + t.Fatalf("FixStaleMQFiles failed: %v", err) + } + + // Verify directory is removed after fix + if _, err := os.Stat(mqDir); !os.IsNotExist(err) { + t.Error("mq directory should not exist after fix") + } + + // Verify check now passes + check := CheckStaleMQFiles(tmpDir) + if check.Status != StatusOK { + t.Errorf("expected status OK after fix, got %q", check.Status) + } +} + +func TestFixStaleMQFiles_NoDirectory(t *testing.T) { + // Create temp directory with .beads but no mq subdirectory + tmpDir := t.TempDir() + beadsDir := filepath.Join(tmpDir, ".beads") + if err := os.MkdirAll(beadsDir, 0755); err != nil { + t.Fatalf("failed to create .beads dir: %v", err) + } + + // Fix should succeed even if directory doesn't exist + if err := FixStaleMQFiles(tmpDir); err != nil { + t.Fatalf("FixStaleMQFiles should not fail when directory doesn't exist: %v", err) + } +} diff --git a/cmd/bd/doctor_fix.go b/cmd/bd/doctor_fix.go index 14873406..4e888188 100644 --- a/cmd/bd/doctor_fix.go +++ b/cmd/bd/doctor_fix.go @@ -310,6 +310,8 @@ func applyFixList(path string, fixes []doctorCheck) { // No auto-fix: pruning deletes data, must be user-controlled fmt.Printf(" ⚠ Run 'bd cleanup --older-than 90' to prune old closed issues\n") continue + case "Legacy MQ Files": + err = doctor.FixStaleMQFiles(path) default: fmt.Printf(" ⚠ No automatic fix available for %s\n", check.Name) fmt.Printf(" Manual fix: %s\n", check.Fix)