Files
gastown/internal/doctor/crew_check.go
Ryan Snodgrass e1f2bb8b4b feat(ui): import comprehensive UX system from beads
Import beads' UX design system into gastown:

- Add internal/ui/ package with Ayu theme colors and semantic styling
  - styles.go: AdaptiveColor definitions for light/dark mode
  - terminal.go: TTY detection, NO_COLOR/CLICOLOR support
  - markdown.go: Glamour rendering with agent mode bypass
  - pager.go: Smart paging with GT_PAGER support

- Add colorized help output (internal/cmd/help.go)
  - Group headers in accent color
  - Command names styled for scannability
  - Flag types and defaults muted

- Add gt thanks command (internal/cmd/thanks.go)
  - Contributor display with same logic as bd thanks
  - Styled with Ayu theme colors

- Update gt doctor to match bd doctor UX
  - Category grouping (Core, Infrastructure, Rig, Patrol, etc.)
  - Semantic icons (✓ ⚠ ✖) with Ayu colors
  - Tree connectors for detail lines
  - Summary line with pass/warn/fail counts
  - Warnings section at end with numbered issues

- Migrate existing styles to use ui package
  - internal/style/style.go uses ui.ColorPass etc.
  - internal/tui/feed/styles.go uses ui package colors

Co-Authored-By: SageOx <ox@sageox.ai>
2026-01-09 22:46:06 -08:00

367 lines
9.1 KiB
Go

package doctor
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
// CrewStateCheck validates crew worker state.json files for completeness.
// Empty or incomplete state.json files cause "can't find pane/session" errors.
type CrewStateCheck struct {
FixableCheck
invalidCrews []invalidCrew // Cached during Run for use in Fix
}
type invalidCrew struct {
path string
stateFile string
rigName string
crewName string
issue string
}
// NewCrewStateCheck creates a new crew state check.
func NewCrewStateCheck() *CrewStateCheck {
return &CrewStateCheck{
FixableCheck: FixableCheck{
BaseCheck: BaseCheck{
CheckName: "crew-state",
CheckDescription: "Validate crew worker state.json files",
CheckCategory: CategoryCleanup,
},
},
}
}
// Run checks all crew state.json files for completeness.
func (c *CrewStateCheck) Run(ctx *CheckContext) *CheckResult {
c.invalidCrews = nil
crewDirs := c.findAllCrewDirs(ctx.TownRoot)
if len(crewDirs) == 0 {
return &CheckResult{
Name: c.Name(),
Status: StatusOK,
Message: "No crew workspaces found",
}
}
var validCount int
var details []string
for _, cd := range crewDirs {
stateFile := filepath.Join(cd.path, "state.json")
// Check if state.json exists
data, err := os.ReadFile(stateFile)
if err != nil {
if os.IsNotExist(err) {
// Missing state file is OK - code will use defaults
validCount++
continue
}
// Other errors are problems
issue := fmt.Sprintf("cannot read state.json: %v", err)
c.invalidCrews = append(c.invalidCrews, invalidCrew{
path: cd.path,
stateFile: stateFile,
rigName: cd.rigName,
crewName: cd.crewName,
issue: issue,
})
details = append(details, fmt.Sprintf("%s/%s: %s", cd.rigName, cd.crewName, issue))
continue
}
// Parse state.json
var state struct {
Name string `json:"name"`
Rig string `json:"rig"`
ClonePath string `json:"clone_path"`
}
if err := json.Unmarshal(data, &state); err != nil {
issue := "invalid JSON in state.json"
c.invalidCrews = append(c.invalidCrews, invalidCrew{
path: cd.path,
stateFile: stateFile,
rigName: cd.rigName,
crewName: cd.crewName,
issue: issue,
})
details = append(details, fmt.Sprintf("%s/%s: %s", cd.rigName, cd.crewName, issue))
continue
}
// Check for empty/incomplete state
var issues []string
if state.Name == "" {
issues = append(issues, "missing name")
}
if state.Rig == "" {
issues = append(issues, "missing rig")
}
if state.ClonePath == "" {
issues = append(issues, "missing clone_path")
}
if len(issues) > 0 {
issue := strings.Join(issues, ", ")
c.invalidCrews = append(c.invalidCrews, invalidCrew{
path: cd.path,
stateFile: stateFile,
rigName: cd.rigName,
crewName: cd.crewName,
issue: issue,
})
details = append(details, fmt.Sprintf("%s/%s: %s", cd.rigName, cd.crewName, issue))
} else {
validCount++
}
}
if len(c.invalidCrews) == 0 {
return &CheckResult{
Name: c.Name(),
Status: StatusOK,
Message: fmt.Sprintf("All %d crew state files valid", validCount),
}
}
return &CheckResult{
Name: c.Name(),
Status: StatusWarning,
Message: fmt.Sprintf("%d crew workspace(s) with invalid state.json", len(c.invalidCrews)),
Details: details,
FixHint: "Run 'gt doctor --fix' to regenerate state files",
}
}
// Fix regenerates invalid state.json files with correct values.
func (c *CrewStateCheck) Fix(ctx *CheckContext) error {
if len(c.invalidCrews) == 0 {
return nil
}
var lastErr error
for _, ic := range c.invalidCrews {
state := map[string]interface{}{
"name": ic.crewName,
"rig": ic.rigName,
"clone_path": ic.path,
"branch": "main",
"created_at": time.Now().Format(time.RFC3339),
"updated_at": time.Now().Format(time.RFC3339),
}
data, err := json.MarshalIndent(state, "", " ")
if err != nil {
lastErr = fmt.Errorf("%s/%s: %w", ic.rigName, ic.crewName, err)
continue
}
if err := os.WriteFile(ic.stateFile, data, 0644); err != nil {
lastErr = fmt.Errorf("%s/%s: %w", ic.rigName, ic.crewName, err)
continue
}
}
return lastErr
}
type crewDir struct {
path string
rigName string
crewName string
}
// findAllCrewDirs finds all crew directories in the workspace.
func (c *CrewStateCheck) findAllCrewDirs(townRoot string) []crewDir {
var dirs []crewDir
entries, err := os.ReadDir(townRoot)
if err != nil {
return dirs
}
for _, entry := range entries {
if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") || entry.Name() == "mayor" {
continue
}
rigName := entry.Name()
crewPath := filepath.Join(townRoot, rigName, "crew")
crewEntries, err := os.ReadDir(crewPath)
if err != nil {
continue
}
for _, crew := range crewEntries {
if !crew.IsDir() || strings.HasPrefix(crew.Name(), ".") {
continue
}
dirs = append(dirs, crewDir{
path: filepath.Join(crewPath, crew.Name()),
rigName: rigName,
crewName: crew.Name(),
})
}
}
return dirs
}
// CrewWorktreeCheck detects stale cross-rig worktrees in crew directories.
// Cross-rig worktrees are created by `gt worktree <rig>` and live in crew/
// with names like `<source-rig>-<crewname>`. They should be cleaned up when
// no longer needed to avoid confusion with regular crew workspaces.
type CrewWorktreeCheck struct {
FixableCheck
staleWorktrees []staleWorktree
}
type staleWorktree struct {
path string
rigName string
name string
sourceRig string
crewName string
}
// NewCrewWorktreeCheck creates a new crew worktree check.
func NewCrewWorktreeCheck() *CrewWorktreeCheck {
return &CrewWorktreeCheck{
FixableCheck: FixableCheck{
BaseCheck: BaseCheck{
CheckName: "crew-worktrees",
CheckDescription: "Detect stale cross-rig worktrees in crew directories",
CheckCategory: CategoryCleanup,
},
},
}
}
// Run checks for cross-rig worktrees that may need cleanup.
func (c *CrewWorktreeCheck) Run(ctx *CheckContext) *CheckResult {
c.staleWorktrees = nil
worktrees := c.findCrewWorktrees(ctx.TownRoot)
if len(worktrees) == 0 {
return &CheckResult{
Name: c.Name(),
Status: StatusOK,
Message: "No cross-rig worktrees in crew directories",
}
}
c.staleWorktrees = worktrees
var details []string
for _, wt := range worktrees {
details = append(details, fmt.Sprintf("%s/crew/%s (from %s/crew/%s)",
wt.rigName, wt.name, wt.sourceRig, wt.crewName))
}
return &CheckResult{
Name: c.Name(),
Status: StatusWarning,
Message: fmt.Sprintf("%d cross-rig worktree(s) in crew directories", len(worktrees)),
Details: details,
FixHint: "Run 'gt doctor --fix' to remove, or use 'gt crew remove <name> --purge'",
}
}
// Fix removes stale cross-rig worktrees.
func (c *CrewWorktreeCheck) Fix(ctx *CheckContext) error {
if len(c.staleWorktrees) == 0 {
return nil
}
var lastErr error
for _, wt := range c.staleWorktrees {
// Use git worktree remove to properly clean up
mayorRigPath := filepath.Join(ctx.TownRoot, wt.rigName, "mayor", "rig")
removeCmd := exec.Command("git", "worktree", "remove", "--force", wt.path)
removeCmd.Dir = mayorRigPath
if output, err := removeCmd.CombinedOutput(); err != nil {
lastErr = fmt.Errorf("%s/crew/%s: %v (%s)", wt.rigName, wt.name, err, strings.TrimSpace(string(output)))
}
}
return lastErr
}
// findCrewWorktrees finds cross-rig worktrees in crew directories.
// These are worktrees with hyphenated names (e.g., "beads-dave") that
// indicate they were created via `gt worktree` for cross-rig work.
func (c *CrewWorktreeCheck) findCrewWorktrees(townRoot string) []staleWorktree {
var worktrees []staleWorktree
entries, err := os.ReadDir(townRoot)
if err != nil {
return worktrees
}
for _, entry := range entries {
if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") || entry.Name() == "mayor" {
continue
}
rigName := entry.Name()
crewPath := filepath.Join(townRoot, rigName, "crew")
crewEntries, err := os.ReadDir(crewPath)
if err != nil {
continue
}
for _, crew := range crewEntries {
if !crew.IsDir() || strings.HasPrefix(crew.Name(), ".") {
continue
}
name := crew.Name()
path := filepath.Join(crewPath, name)
// Check if it's a worktree (has .git file, not directory)
gitPath := filepath.Join(path, ".git")
info, err := os.Stat(gitPath)
if err != nil || info.IsDir() {
// Not a worktree (regular clone or error)
continue
}
// Check for hyphenated name pattern: <source-rig>-<crewname>
// This indicates a cross-rig worktree created by `gt worktree`
parts := strings.SplitN(name, "-", 2)
if len(parts) != 2 {
// Not a cross-rig worktree pattern
continue
}
sourceRig := parts[0]
crewName := parts[1]
// Verify the source rig exists (sanity check)
sourceRigPath := filepath.Join(townRoot, sourceRig)
if _, err := os.Stat(sourceRigPath); os.IsNotExist(err) {
// Source rig doesn't exist - definitely stale
}
worktrees = append(worktrees, staleWorktree{
path: path,
rigName: rigName,
name: name,
sourceRig: sourceRig,
crewName: crewName,
})
}
}
return worktrees
}