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>
560 lines
14 KiB
Go
560 lines
14 KiB
Go
package doctor
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
// BranchCheck detects persistent roles (crew, witness, refinery) that are
|
|
// not on the main branch. Long-lived roles should work directly on main
|
|
// to avoid orphaned work and branch decay.
|
|
type BranchCheck struct {
|
|
FixableCheck
|
|
offMainDirs []string // Cached during Run for use in Fix
|
|
}
|
|
|
|
// NewBranchCheck creates a new branch check.
|
|
func NewBranchCheck() *BranchCheck {
|
|
return &BranchCheck{
|
|
FixableCheck: FixableCheck{
|
|
BaseCheck: BaseCheck{
|
|
CheckName: "persistent-role-branches",
|
|
CheckDescription: "Detect persistent roles not on main branch",
|
|
CheckCategory: CategoryCleanup,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// Run checks if persistent role directories are on main branch.
|
|
func (c *BranchCheck) Run(ctx *CheckContext) *CheckResult {
|
|
var offMain []string
|
|
var onMain int
|
|
|
|
// Find all persistent role directories
|
|
dirs := c.findPersistentRoleDirs(ctx.TownRoot)
|
|
|
|
for _, dir := range dirs {
|
|
branch, err := c.getCurrentBranch(dir)
|
|
if err != nil {
|
|
// Skip directories that aren't git repos
|
|
continue
|
|
}
|
|
|
|
if branch == "main" || branch == "master" {
|
|
onMain++
|
|
} else {
|
|
offMain = append(offMain, fmt.Sprintf("%s (on %s)", c.relativePath(ctx.TownRoot, dir), branch))
|
|
}
|
|
}
|
|
|
|
// Cache for Fix
|
|
c.offMainDirs = nil
|
|
for _, dir := range dirs {
|
|
branch, err := c.getCurrentBranch(dir)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if branch != "main" && branch != "master" {
|
|
c.offMainDirs = append(c.offMainDirs, dir)
|
|
}
|
|
}
|
|
|
|
if len(offMain) == 0 {
|
|
if onMain == 0 {
|
|
return &CheckResult{
|
|
Name: c.Name(),
|
|
Status: StatusOK,
|
|
Message: "No persistent role directories found",
|
|
}
|
|
}
|
|
return &CheckResult{
|
|
Name: c.Name(),
|
|
Status: StatusOK,
|
|
Message: fmt.Sprintf("All %d persistent roles on main branch", onMain),
|
|
}
|
|
}
|
|
|
|
return &CheckResult{
|
|
Name: c.Name(),
|
|
Status: StatusWarning,
|
|
Message: fmt.Sprintf("%d persistent role(s) not on main branch", len(offMain)),
|
|
Details: offMain,
|
|
FixHint: "Run 'gt doctor --fix' to switch to main, or manually: git checkout main && git pull",
|
|
}
|
|
}
|
|
|
|
// Fix switches all off-main directories to main branch.
|
|
func (c *BranchCheck) Fix(ctx *CheckContext) error {
|
|
if len(c.offMainDirs) == 0 {
|
|
return nil
|
|
}
|
|
|
|
var lastErr error
|
|
for _, dir := range c.offMainDirs {
|
|
// git checkout main
|
|
cmd := exec.Command("git", "checkout", "main")
|
|
cmd.Dir = dir
|
|
if err := cmd.Run(); err != nil {
|
|
lastErr = fmt.Errorf("%s: %w", dir, err)
|
|
continue
|
|
}
|
|
|
|
// git pull --rebase
|
|
cmd = exec.Command("git", "pull", "--rebase")
|
|
cmd.Dir = dir
|
|
if err := cmd.Run(); err != nil {
|
|
// Pull failure is not fatal, just warn
|
|
continue
|
|
}
|
|
}
|
|
|
|
return lastErr
|
|
}
|
|
|
|
// findPersistentRoleDirs finds all directories that should be on main:
|
|
// - <rig>/crew/*
|
|
// - <rig>/witness/rig (if exists)
|
|
// - <rig>/refinery/rig (if exists)
|
|
func (c *BranchCheck) findPersistentRoleDirs(townRoot string) []string {
|
|
var dirs []string
|
|
|
|
// Find all rigs
|
|
entries, err := os.ReadDir(townRoot)
|
|
if err != nil {
|
|
return dirs
|
|
}
|
|
|
|
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/)
|
|
if !c.isRig(rigPath) {
|
|
continue
|
|
}
|
|
|
|
// Add crew members
|
|
crewPath := filepath.Join(rigPath, "crew")
|
|
if crewEntries, err := os.ReadDir(crewPath); err == nil {
|
|
for _, crew := range crewEntries {
|
|
if crew.IsDir() && !strings.HasPrefix(crew.Name(), ".") {
|
|
dirs = append(dirs, filepath.Join(crewPath, crew.Name()))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add witness/rig if exists
|
|
witnessRig := filepath.Join(rigPath, "witness", "rig")
|
|
if _, err := os.Stat(witnessRig); err == nil {
|
|
dirs = append(dirs, witnessRig)
|
|
}
|
|
|
|
// Add refinery/rig if exists
|
|
refineryRig := filepath.Join(rigPath, "refinery", "rig")
|
|
if _, err := os.Stat(refineryRig); err == nil {
|
|
dirs = append(dirs, refineryRig)
|
|
}
|
|
}
|
|
|
|
return dirs
|
|
}
|
|
|
|
// isRig checks if a directory looks like a rig.
|
|
func (c *BranchCheck) isRig(path string) bool {
|
|
markers := []string{"crew", "polecats", "witness", "refinery"}
|
|
for _, marker := range markers {
|
|
if _, err := os.Stat(filepath.Join(path, marker)); err == nil {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// getCurrentBranch returns the current git branch for a directory.
|
|
func (c *BranchCheck) getCurrentBranch(dir string) (string, error) {
|
|
cmd := exec.Command("git", "branch", "--show-current")
|
|
cmd.Dir = dir
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return strings.TrimSpace(string(out)), nil
|
|
}
|
|
|
|
// relativePath returns path relative to base, or the full path if that fails.
|
|
func (c *BranchCheck) relativePath(base, path string) string {
|
|
rel, err := filepath.Rel(base, path)
|
|
if err != nil {
|
|
return path
|
|
}
|
|
return rel
|
|
}
|
|
|
|
// BeadsSyncOrphanCheck detects code changes on beads-sync branch that weren't
|
|
// merged to main. This catches cases where merges lose code changes.
|
|
type BeadsSyncOrphanCheck struct {
|
|
BaseCheck
|
|
}
|
|
|
|
// NewBeadsSyncOrphanCheck creates a new beads-sync orphan check.
|
|
func NewBeadsSyncOrphanCheck() *BeadsSyncOrphanCheck {
|
|
return &BeadsSyncOrphanCheck{
|
|
BaseCheck: BaseCheck{
|
|
CheckName: "beads-sync-orphans",
|
|
CheckDescription: "Detect orphaned code on beads-sync branch",
|
|
CheckCategory: CategoryCleanup,
|
|
},
|
|
}
|
|
}
|
|
|
|
// Run checks for code differences between main and beads-sync.
|
|
func (c *BeadsSyncOrphanCheck) Run(ctx *CheckContext) *CheckResult {
|
|
// Find the first rig with a crew member (that has beads-sync branch)
|
|
crewDirs := c.findCrewDirs(ctx.TownRoot)
|
|
if len(crewDirs) == 0 {
|
|
return &CheckResult{
|
|
Name: c.Name(),
|
|
Status: StatusOK,
|
|
Message: "No crew directories found",
|
|
}
|
|
}
|
|
|
|
// Use first crew dir to check beads-sync
|
|
crewDir := crewDirs[0]
|
|
|
|
// Check if beads-sync branch exists
|
|
cmd := exec.Command("git", "rev-parse", "--verify", "beads-sync")
|
|
cmd.Dir = crewDir
|
|
if err := cmd.Run(); err != nil {
|
|
return &CheckResult{
|
|
Name: c.Name(),
|
|
Status: StatusOK,
|
|
Message: "No beads-sync branch (single-clone setup)",
|
|
}
|
|
}
|
|
|
|
// Get diff between main and beads-sync, excluding .beads/
|
|
cmd = exec.Command("git", "diff", "--name-only", "main..beads-sync", "--", ".", ":(exclude).beads")
|
|
cmd.Dir = crewDir
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return &CheckResult{
|
|
Name: c.Name(),
|
|
Status: StatusWarning,
|
|
Message: "Could not diff main..beads-sync",
|
|
Details: []string{err.Error()},
|
|
}
|
|
}
|
|
|
|
files := strings.TrimSpace(string(out))
|
|
if files == "" {
|
|
return &CheckResult{
|
|
Name: c.Name(),
|
|
Status: StatusOK,
|
|
Message: "No orphaned code on beads-sync",
|
|
}
|
|
}
|
|
|
|
// Filter to code files only
|
|
var codeFiles []string
|
|
for _, f := range strings.Split(files, "\n") {
|
|
if f == "" {
|
|
continue
|
|
}
|
|
// Check if it's a code file
|
|
if strings.HasSuffix(f, ".go") || strings.HasSuffix(f, ".md") ||
|
|
strings.HasSuffix(f, ".toml") || strings.HasSuffix(f, ".json") ||
|
|
strings.HasSuffix(f, ".yaml") || strings.HasSuffix(f, ".yml") ||
|
|
strings.HasSuffix(f, ".tmpl") {
|
|
codeFiles = append(codeFiles, f)
|
|
}
|
|
}
|
|
|
|
if len(codeFiles) == 0 {
|
|
return &CheckResult{
|
|
Name: c.Name(),
|
|
Status: StatusOK,
|
|
Message: "No orphaned code on beads-sync (only non-code files differ)",
|
|
}
|
|
}
|
|
|
|
return &CheckResult{
|
|
Name: c.Name(),
|
|
Status: StatusWarning,
|
|
Message: fmt.Sprintf("%d file(s) on beads-sync not in main", len(codeFiles)),
|
|
Details: codeFiles,
|
|
FixHint: "Review with: git diff main..beads-sync -- <file>",
|
|
}
|
|
}
|
|
|
|
// findCrewDirs returns crew directories that might have beads-sync.
|
|
func (c *BeadsSyncOrphanCheck) findCrewDirs(townRoot string) []string {
|
|
var dirs []string
|
|
|
|
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
|
|
}
|
|
|
|
crewPath := filepath.Join(townRoot, entry.Name(), "crew")
|
|
if crewEntries, err := os.ReadDir(crewPath); err == nil {
|
|
for _, crew := range crewEntries {
|
|
if crew.IsDir() && !strings.HasPrefix(crew.Name(), ".") {
|
|
dirs = append(dirs, filepath.Join(crewPath, crew.Name()))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return dirs
|
|
}
|
|
|
|
// CloneDivergenceCheck detects when git clones have drifted significantly apart.
|
|
// This is an emergency condition - all clones should be tracking origin/main
|
|
// and staying reasonably in sync. Divergence here is different from beads-sync
|
|
// divergence, which is expected.
|
|
type CloneDivergenceCheck struct {
|
|
BaseCheck
|
|
}
|
|
|
|
// NewCloneDivergenceCheck creates a new clone divergence check.
|
|
func NewCloneDivergenceCheck() *CloneDivergenceCheck {
|
|
return &CloneDivergenceCheck{
|
|
BaseCheck: BaseCheck{
|
|
CheckName: "clone-divergence",
|
|
CheckDescription: "Detect emergency divergence between git clones",
|
|
CheckCategory: CategoryCleanup,
|
|
},
|
|
}
|
|
}
|
|
|
|
// cloneInfo holds information about a single clone.
|
|
type cloneInfo struct {
|
|
path string
|
|
branch string
|
|
headSHA string
|
|
behindBy int // commits behind origin/main
|
|
}
|
|
|
|
// Run checks for significant divergence between clones.
|
|
func (c *CloneDivergenceCheck) Run(ctx *CheckContext) *CheckResult {
|
|
clones := c.findAllClones(ctx.TownRoot)
|
|
if len(clones) == 0 {
|
|
return &CheckResult{
|
|
Name: c.Name(),
|
|
Status: StatusOK,
|
|
Message: "No clones found",
|
|
}
|
|
}
|
|
|
|
// Gather info about each clone
|
|
var infos []cloneInfo
|
|
for _, path := range clones {
|
|
info, err := c.getCloneInfo(path)
|
|
if err != nil {
|
|
continue // Skip problematic clones
|
|
}
|
|
infos = append(infos, info)
|
|
}
|
|
|
|
if len(infos) == 0 {
|
|
return &CheckResult{
|
|
Name: c.Name(),
|
|
Status: StatusOK,
|
|
Message: "No valid git clones found",
|
|
}
|
|
}
|
|
|
|
// Check for clones significantly behind origin/main
|
|
var warnings []string
|
|
var errors []string
|
|
|
|
for _, info := range infos {
|
|
relPath := c.relativePath(ctx.TownRoot, info.path)
|
|
|
|
// Only check clones on main branch (others are caught by BranchCheck)
|
|
if info.branch != "main" && info.branch != "master" {
|
|
continue
|
|
}
|
|
|
|
if info.behindBy > 50 {
|
|
errors = append(errors, fmt.Sprintf("%s: %d commits behind origin/main (EMERGENCY)", relPath, info.behindBy))
|
|
} else if info.behindBy > 10 {
|
|
warnings = append(warnings, fmt.Sprintf("%s: %d commits behind origin/main", relPath, info.behindBy))
|
|
}
|
|
}
|
|
|
|
if len(errors) > 0 {
|
|
return &CheckResult{
|
|
Name: c.Name(),
|
|
Status: StatusError,
|
|
Message: fmt.Sprintf("%d clone(s) critically diverged", len(errors)),
|
|
Details: append(errors, warnings...),
|
|
FixHint: "Run 'git pull --rebase' in affected directories",
|
|
}
|
|
}
|
|
|
|
if len(warnings) > 0 {
|
|
return &CheckResult{
|
|
Name: c.Name(),
|
|
Status: StatusWarning,
|
|
Message: fmt.Sprintf("%d clone(s) behind origin/main", len(warnings)),
|
|
Details: warnings,
|
|
FixHint: "Run 'git pull --rebase' in affected directories",
|
|
}
|
|
}
|
|
|
|
return &CheckResult{
|
|
Name: c.Name(),
|
|
Status: StatusOK,
|
|
Message: fmt.Sprintf("All %d clones in sync with origin/main", len(infos)),
|
|
}
|
|
}
|
|
|
|
// findAllClones finds all git clones in the workspace.
|
|
func (c *CloneDivergenceCheck) findAllClones(townRoot string) []string {
|
|
var clones []string
|
|
|
|
entries, err := os.ReadDir(townRoot)
|
|
if err != nil {
|
|
return clones
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") || entry.Name() == "mayor" || entry.Name() == "docs" {
|
|
continue
|
|
}
|
|
|
|
rigPath := filepath.Join(townRoot, entry.Name())
|
|
|
|
// Check standard clone locations
|
|
locations := []string{
|
|
"mayor/rig",
|
|
"witness/rig",
|
|
"refinery/rig",
|
|
}
|
|
|
|
for _, loc := range locations {
|
|
path := filepath.Join(rigPath, loc)
|
|
if c.isGitRepo(path) {
|
|
clones = append(clones, path)
|
|
}
|
|
}
|
|
|
|
// Add crew members
|
|
crewPath := filepath.Join(rigPath, "crew")
|
|
if crewEntries, err := os.ReadDir(crewPath); err == nil {
|
|
for _, crew := range crewEntries {
|
|
if crew.IsDir() && !strings.HasPrefix(crew.Name(), ".") {
|
|
path := filepath.Join(crewPath, crew.Name())
|
|
if c.isGitRepo(path) {
|
|
clones = append(clones, path)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add polecats (handle both new and old structures)
|
|
// New structure: polecats/<name>/<rigname>/
|
|
// Old structure: polecats/<name>/
|
|
rigName := entry.Name()
|
|
polecatsPath := filepath.Join(rigPath, "polecats")
|
|
if polecatEntries, err := os.ReadDir(polecatsPath); err == nil {
|
|
for _, polecat := range polecatEntries {
|
|
if polecat.IsDir() && !strings.HasPrefix(polecat.Name(), ".") {
|
|
// Try new structure first
|
|
path := filepath.Join(polecatsPath, polecat.Name(), rigName)
|
|
if !c.isGitRepo(path) {
|
|
// Fall back to old structure
|
|
path = filepath.Join(polecatsPath, polecat.Name())
|
|
}
|
|
if c.isGitRepo(path) {
|
|
clones = append(clones, path)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return clones
|
|
}
|
|
|
|
// isGitRepo checks if a directory is a git repository.
|
|
func (c *CloneDivergenceCheck) isGitRepo(path string) bool {
|
|
gitDir := filepath.Join(path, ".git")
|
|
if _, err := os.Stat(gitDir); err == nil {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// getCloneInfo gathers information about a clone.
|
|
func (c *CloneDivergenceCheck) getCloneInfo(path string) (cloneInfo, error) {
|
|
info := cloneInfo{path: path}
|
|
|
|
// Get current branch
|
|
cmd := exec.Command("git", "branch", "--show-current")
|
|
cmd.Dir = path
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return info, err
|
|
}
|
|
info.branch = strings.TrimSpace(string(out))
|
|
|
|
// Get HEAD SHA
|
|
cmd = exec.Command("git", "rev-parse", "HEAD")
|
|
cmd.Dir = path
|
|
out, err = cmd.Output()
|
|
if err != nil {
|
|
return info, err
|
|
}
|
|
info.headSHA = strings.TrimSpace(string(out))
|
|
|
|
// Fetch to make sure we have latest refs (silent, ignore errors)
|
|
cmd = exec.Command("git", "fetch", "--quiet")
|
|
cmd.Dir = path
|
|
_ = cmd.Run()
|
|
|
|
// Count commits behind origin/main
|
|
cmd = exec.Command("git", "rev-list", "--count", "HEAD..origin/main")
|
|
cmd.Dir = path
|
|
out, err = cmd.Output()
|
|
if err != nil {
|
|
// origin/main might not exist, treat as 0 behind
|
|
info.behindBy = 0
|
|
return info, nil
|
|
}
|
|
|
|
var behind int
|
|
_, _ = fmt.Sscanf(strings.TrimSpace(string(out)), "%d", &behind)
|
|
info.behindBy = behind
|
|
|
|
return info, nil
|
|
}
|
|
|
|
// relativePath returns path relative to base.
|
|
func (c *CloneDivergenceCheck) relativePath(base, path string) string {
|
|
rel, err := filepath.Rel(base, path)
|
|
if err != nil {
|
|
return path
|
|
}
|
|
return rel
|
|
}
|