package doctor import ( "bufio" "fmt" "os" "path/filepath" "strings" "github.com/steveyegge/gastown/internal/constants" ) // SettingsCheck verifies each rig has a settings/ directory. type SettingsCheck struct { FixableCheck missingSettings []string // Cached during Run for use in Fix } // NewSettingsCheck creates a new settings directory check. func NewSettingsCheck() *SettingsCheck { return &SettingsCheck{ FixableCheck: FixableCheck{ BaseCheck: BaseCheck{ CheckName: "rig-settings", CheckDescription: "Check that rigs have settings/ directory", }, }, } } // Run checks if all rigs have a settings/ directory. func (c *SettingsCheck) Run(ctx *CheckContext) *CheckResult { rigs := c.findRigs(ctx.TownRoot) if len(rigs) == 0 { return &CheckResult{ Name: c.Name(), Status: StatusOK, Message: "No rigs found", } } var missing []string var ok int for _, rig := range rigs { settingsPath := constants.RigSettingsPath(rig) if _, err := os.Stat(settingsPath); os.IsNotExist(err) { relPath, _ := filepath.Rel(ctx.TownRoot, rig) missing = append(missing, relPath) } else { ok++ } } // Cache for Fix c.missingSettings = nil for _, rig := range rigs { settingsPath := constants.RigSettingsPath(rig) if _, err := os.Stat(settingsPath); os.IsNotExist(err) { c.missingSettings = append(c.missingSettings, settingsPath) } } if len(missing) == 0 { return &CheckResult{ Name: c.Name(), Status: StatusOK, Message: fmt.Sprintf("All %d rig(s) have settings/ directory", ok), } } details := make([]string, len(missing)) for i, m := range missing { details[i] = fmt.Sprintf("Missing: %s/settings/", m) } return &CheckResult{ Name: c.Name(), Status: StatusWarning, Message: fmt.Sprintf("%d rig(s) missing settings/ directory", len(missing)), Details: details, FixHint: "Run 'gt doctor --fix' to create missing directories", } } // Fix creates missing settings/ directories. func (c *SettingsCheck) Fix(ctx *CheckContext) error { for _, path := range c.missingSettings { if err := os.MkdirAll(path, 0755); err != nil { return fmt.Errorf("failed to create %s: %w", path, err) } } return nil } // RuntimeGitignoreCheck verifies .runtime/ is gitignored at town and rig levels. type RuntimeGitignoreCheck struct { BaseCheck } // NewRuntimeGitignoreCheck creates a new runtime gitignore check. func NewRuntimeGitignoreCheck() *RuntimeGitignoreCheck { return &RuntimeGitignoreCheck{ BaseCheck: BaseCheck{ CheckName: "runtime-gitignore", CheckDescription: "Check that .runtime/ directories are gitignored", }, } } // Run checks if .runtime/ is properly gitignored. func (c *RuntimeGitignoreCheck) Run(ctx *CheckContext) *CheckResult { var issues []string // Check town-level .gitignore townGitignore := filepath.Join(ctx.TownRoot, ".gitignore") if !c.containsPattern(townGitignore, ".runtime") { issues = append(issues, "Town .gitignore missing .runtime/ pattern") } // Check each rig's .gitignore (in their git worktrees) rigs := c.findRigs(ctx.TownRoot) for _, rig := range rigs { // Check crew members crewPath := filepath.Join(rig, "crew") if crewEntries, err := os.ReadDir(crewPath); err == nil { for _, crew := range crewEntries { if crew.IsDir() && !strings.HasPrefix(crew.Name(), ".") { crewGitignore := filepath.Join(crewPath, crew.Name(), ".gitignore") if !c.containsPattern(crewGitignore, ".runtime") { relPath, _ := filepath.Rel(ctx.TownRoot, filepath.Join(crewPath, crew.Name())) issues = append(issues, fmt.Sprintf("%s .gitignore missing .runtime/ pattern", relPath)) } } } } } if len(issues) == 0 { return &CheckResult{ Name: c.Name(), Status: StatusOK, Message: ".runtime/ properly gitignored", } } return &CheckResult{ Name: c.Name(), Status: StatusWarning, Message: fmt.Sprintf("%d location(s) missing .runtime gitignore", len(issues)), Details: issues, FixHint: "Add '.runtime/' to .gitignore files", } } // containsPattern checks if a gitignore file contains a pattern. func (c *RuntimeGitignoreCheck) containsPattern(gitignorePath, pattern string) bool { file, err := os.Open(gitignorePath) if err != nil { return false // File doesn't exist } defer file.Close() scanner := bufio.NewScanner(file) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) // Check for pattern match (with or without trailing slash, with or without glob prefix) // Accept: .runtime, .runtime/, /.runtime, /.runtime/, **/.runtime, **/.runtime/ if line == pattern || line == pattern+"/" || line == "/"+pattern || line == "/"+pattern+"/" || line == "**/"+pattern || line == "**/"+pattern+"/" { return true } } return false } // findRigs returns rig directories within the town. func (c *RuntimeGitignoreCheck) findRigs(townRoot string) []string { return findAllRigs(townRoot) } // LegacyGastownCheck warns if old .gastown/ directories still exist. type LegacyGastownCheck struct { FixableCheck legacyDirs []string // Cached during Run for use in Fix } // NewLegacyGastownCheck creates a new legacy gastown check. func NewLegacyGastownCheck() *LegacyGastownCheck { return &LegacyGastownCheck{ FixableCheck: FixableCheck{ BaseCheck: BaseCheck{ CheckName: "legacy-gastown", CheckDescription: "Check for old .gastown/ directories that should be migrated", }, }, } } // Run checks for legacy .gastown/ directories. func (c *LegacyGastownCheck) Run(ctx *CheckContext) *CheckResult { var found []string // Check town-level .gastown/ townGastown := filepath.Join(ctx.TownRoot, ".gastown") if info, err := os.Stat(townGastown); err == nil && info.IsDir() { found = append(found, ".gastown/ (town root)") } // Check each rig for .gastown/ rigs := c.findRigs(ctx.TownRoot) for _, rig := range rigs { rigGastown := filepath.Join(rig, ".gastown") if info, err := os.Stat(rigGastown); err == nil && info.IsDir() { relPath, _ := filepath.Rel(ctx.TownRoot, rig) found = append(found, fmt.Sprintf("%s/.gastown/", relPath)) } } // Cache for Fix c.legacyDirs = nil if info, err := os.Stat(townGastown); err == nil && info.IsDir() { c.legacyDirs = append(c.legacyDirs, townGastown) } for _, rig := range rigs { rigGastown := filepath.Join(rig, ".gastown") if info, err := os.Stat(rigGastown); err == nil && info.IsDir() { c.legacyDirs = append(c.legacyDirs, rigGastown) } } if len(found) == 0 { return &CheckResult{ Name: c.Name(), Status: StatusOK, Message: "No legacy .gastown/ directories found", } } return &CheckResult{ Name: c.Name(), Status: StatusWarning, Message: fmt.Sprintf("%d legacy .gastown/ directory(ies) found", len(found)), Details: found, FixHint: "Run 'gt doctor --fix' to remove after verifying migration is complete", } } // Fix removes legacy .gastown/ directories. func (c *LegacyGastownCheck) Fix(ctx *CheckContext) error { for _, dir := range c.legacyDirs { if err := os.RemoveAll(dir); err != nil { return fmt.Errorf("failed to remove %s: %w", dir, err) } } return nil } // findRigs returns rig directories within the town. func (c *LegacyGastownCheck) findRigs(townRoot string) []string { return findAllRigs(townRoot) } // findRigs returns rig directories within the town. func (c *SettingsCheck) findRigs(townRoot string) []string { return findAllRigs(townRoot) } // findAllRigs is a shared helper that returns all rig directories within a town. func findAllRigs(townRoot string) []string { var rigs []string entries, err := os.ReadDir(townRoot) if err != nil { return rigs } for _, entry := range entries { if !entry.IsDir() { continue } // Skip non-rig directories name := entry.Name() if name == "mayor" || name == ".beads" || strings.HasPrefix(name, ".") { continue } rigPath := filepath.Join(townRoot, name) // Check if this looks like a rig (has crew/, polecats/, witness/, or refinery/) markers := []string{"crew", "polecats", "witness", "refinery"} for _, marker := range markers { if _, err := os.Stat(filepath.Join(rigPath, marker)); err == nil { rigs = append(rigs, rigPath) break } } } return rigs }