Files
gastown/internal/doctor/rig_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

1217 lines
33 KiB
Go

package doctor
import (
"bufio"
"bytes"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/constants"
)
// RigIsGitRepoCheck verifies the rig has a valid mayor/rig git clone.
// Note: The rig directory itself is not a git repo - it contains clones.
type RigIsGitRepoCheck struct {
BaseCheck
}
// NewRigIsGitRepoCheck creates a new rig git repo check.
func NewRigIsGitRepoCheck() *RigIsGitRepoCheck {
return &RigIsGitRepoCheck{
BaseCheck: BaseCheck{
CheckName: "rig-is-git-repo",
CheckDescription: "Verify rig has a valid mayor/rig git clone",
CheckCategory: CategoryRig,
},
}
}
// Run checks if the rig has a valid mayor/rig git clone.
func (c *RigIsGitRepoCheck) Run(ctx *CheckContext) *CheckResult {
rigPath := ctx.RigPath()
if rigPath == "" {
return &CheckResult{
Name: c.Name(),
Status: StatusError,
Message: "No rig specified",
}
}
// Check mayor/rig/ which is the authoritative clone for the rig
mayorRigPath := filepath.Join(rigPath, "mayor", "rig")
gitPath := filepath.Join(mayorRigPath, ".git")
info, err := os.Stat(gitPath)
if os.IsNotExist(err) {
return &CheckResult{
Name: c.Name(),
Status: StatusError,
Message: "No mayor/rig clone found",
Details: []string{fmt.Sprintf("Missing: %s", gitPath)},
FixHint: "Clone the repository to mayor/rig/",
}
}
if err != nil {
return &CheckResult{
Name: c.Name(),
Status: StatusError,
Message: fmt.Sprintf("Cannot access mayor/rig/.git: %v", err),
}
}
// Verify git status works
cmd := exec.Command("git", "-C", mayorRigPath, "status", "--porcelain")
if err := cmd.Run(); err != nil {
return &CheckResult{
Name: c.Name(),
Status: StatusError,
Message: "git status failed on mayor/rig",
Details: []string{fmt.Sprintf("Error: %v", err)},
FixHint: "Check git configuration and repository integrity",
}
}
gitType := "clone"
if info.Mode().IsRegular() {
gitType = "worktree"
}
return &CheckResult{
Name: c.Name(),
Status: StatusOK,
Message: fmt.Sprintf("Valid mayor/rig %s", gitType),
}
}
// GitExcludeConfiguredCheck verifies .git/info/exclude has Gas Town directories.
type GitExcludeConfiguredCheck struct {
FixableCheck
missingEntries []string
excludePath string
}
// NewGitExcludeConfiguredCheck creates a new git exclude check.
func NewGitExcludeConfiguredCheck() *GitExcludeConfiguredCheck {
return &GitExcludeConfiguredCheck{
FixableCheck: FixableCheck{
BaseCheck: BaseCheck{
CheckName: "git-exclude-configured",
CheckDescription: "Check .git/info/exclude has Gas Town directories",
CheckCategory: CategoryRig,
},
},
}
}
// requiredExcludes returns the directories that should be excluded.
func (c *GitExcludeConfiguredCheck) requiredExcludes() []string {
return []string{"polecats/", "witness/", "refinery/", "mayor/"}
}
// Run checks if .git/info/exclude contains required entries.
func (c *GitExcludeConfiguredCheck) Run(ctx *CheckContext) *CheckResult {
rigPath := ctx.RigPath()
if rigPath == "" {
return &CheckResult{
Name: c.Name(),
Status: StatusError,
Message: "No rig specified",
}
}
// Check mayor/rig/ which is the authoritative clone
mayorRigPath := filepath.Join(rigPath, "mayor", "rig")
gitDir := filepath.Join(mayorRigPath, ".git")
info, err := os.Stat(gitDir)
if os.IsNotExist(err) {
return &CheckResult{
Name: c.Name(),
Status: StatusWarning,
Message: "No mayor/rig clone found",
FixHint: "Run rig-is-git-repo check first",
}
}
// If .git is a file (worktree), read the actual git dir
if info.Mode().IsRegular() {
content, err := os.ReadFile(gitDir)
if err != nil {
return &CheckResult{
Name: c.Name(),
Status: StatusError,
Message: fmt.Sprintf("Cannot read .git file: %v", err),
}
}
// Format: "gitdir: /path/to/actual/git/dir"
line := strings.TrimSpace(string(content))
if strings.HasPrefix(line, "gitdir: ") {
gitDir = strings.TrimPrefix(line, "gitdir: ")
// Resolve relative paths
if !filepath.IsAbs(gitDir) {
gitDir = filepath.Join(rigPath, gitDir)
}
}
}
c.excludePath = filepath.Join(gitDir, "info", "exclude")
// Read existing excludes
existing := make(map[string]bool)
if file, err := os.Open(c.excludePath); err == nil {
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line != "" && !strings.HasPrefix(line, "#") {
existing[line] = true
}
}
_ = file.Close() //nolint:gosec // G104: best-effort close
}
// Check for missing entries
c.missingEntries = nil
for _, required := range c.requiredExcludes() {
if !existing[required] {
c.missingEntries = append(c.missingEntries, required)
}
}
if len(c.missingEntries) == 0 {
return &CheckResult{
Name: c.Name(),
Status: StatusOK,
Message: "Git exclude properly configured",
}
}
return &CheckResult{
Name: c.Name(),
Status: StatusWarning,
Message: fmt.Sprintf("%d Gas Town directories not excluded", len(c.missingEntries)),
Details: []string{fmt.Sprintf("Missing: %s", strings.Join(c.missingEntries, ", "))},
FixHint: "Run 'gt doctor --fix' to add missing entries",
}
}
// Fix appends missing entries to .git/info/exclude.
func (c *GitExcludeConfiguredCheck) Fix(ctx *CheckContext) error {
if len(c.missingEntries) == 0 {
return nil
}
// Ensure info directory exists
infoDir := filepath.Dir(c.excludePath)
if err := os.MkdirAll(infoDir, 0755); err != nil {
return fmt.Errorf("failed to create info directory: %w", err)
}
// Append missing entries
f, err := os.OpenFile(c.excludePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
return fmt.Errorf("failed to open exclude file: %w", err)
}
defer f.Close()
// Add a header comment if file is empty or new
info, _ := f.Stat()
if info.Size() == 0 {
if _, err := f.WriteString("# Gas Town directories\n"); err != nil {
return err
}
} else {
// Add newline before new entries
if _, err := f.WriteString("\n# Gas Town directories\n"); err != nil {
return err
}
}
for _, entry := range c.missingEntries {
if _, err := f.WriteString(entry + "\n"); err != nil {
return err
}
}
return nil
}
// HooksPathConfiguredCheck verifies all clones have core.hooksPath set to .githooks.
// This ensures the pre-push hook blocks pushes to invalid branches (no internal PRs).
type HooksPathConfiguredCheck struct {
FixableCheck
unconfiguredClones []string
}
// NewHooksPathConfiguredCheck creates a new hooks path check.
func NewHooksPathConfiguredCheck() *HooksPathConfiguredCheck {
return &HooksPathConfiguredCheck{
FixableCheck: FixableCheck{
BaseCheck: BaseCheck{
CheckName: "hooks-path-configured",
CheckDescription: "Check core.hooksPath is set for all clones",
CheckCategory: CategoryRig,
},
},
}
}
// Run checks if all clones have core.hooksPath configured.
func (c *HooksPathConfiguredCheck) Run(ctx *CheckContext) *CheckResult {
rigPath := ctx.RigPath()
if rigPath == "" {
return &CheckResult{
Name: c.Name(),
Status: StatusError,
Message: "No rig specified",
}
}
c.unconfiguredClones = nil
// Check all clone locations
clonePaths := []string{
filepath.Join(rigPath, "mayor", "rig"),
filepath.Join(rigPath, "refinery", "rig"),
}
// Add crew clones
crewDir := filepath.Join(rigPath, "crew")
if entries, err := os.ReadDir(crewDir); err == nil {
for _, entry := range entries {
if entry.IsDir() {
clonePaths = append(clonePaths, filepath.Join(crewDir, entry.Name()))
}
}
}
// Add polecat clones
polecatDir := filepath.Join(rigPath, "polecats")
if entries, err := os.ReadDir(polecatDir); err == nil {
for _, entry := range entries {
if entry.IsDir() {
clonePaths = append(clonePaths, filepath.Join(polecatDir, entry.Name()))
}
}
}
for _, clonePath := range clonePaths {
// Skip if not a git repo
if _, err := os.Stat(filepath.Join(clonePath, ".git")); os.IsNotExist(err) {
continue
}
// Skip if no .githooks directory exists
if _, err := os.Stat(filepath.Join(clonePath, ".githooks")); os.IsNotExist(err) {
continue
}
// Check core.hooksPath
cmd := exec.Command("git", "-C", clonePath, "config", "--get", "core.hooksPath")
output, err := cmd.Output()
if err != nil || strings.TrimSpace(string(output)) != ".githooks" {
// Get relative path for cleaner output
relPath, _ := filepath.Rel(rigPath, clonePath)
if relPath == "" {
relPath = clonePath
}
c.unconfiguredClones = append(c.unconfiguredClones, clonePath)
}
}
if len(c.unconfiguredClones) == 0 {
return &CheckResult{
Name: c.Name(),
Status: StatusOK,
Message: "All clones have hooks configured",
}
}
// Build details with relative paths
var details []string
for _, clonePath := range c.unconfiguredClones {
relPath, _ := filepath.Rel(rigPath, clonePath)
if relPath == "" {
relPath = clonePath
}
details = append(details, relPath)
}
return &CheckResult{
Name: c.Name(),
Status: StatusWarning,
Message: fmt.Sprintf("%d clone(s) missing hooks configuration", len(c.unconfiguredClones)),
Details: details,
FixHint: "Run 'gt doctor --fix' to configure hooks",
}
}
// Fix configures core.hooksPath for all unconfigured clones.
func (c *HooksPathConfiguredCheck) Fix(ctx *CheckContext) error {
for _, clonePath := range c.unconfiguredClones {
cmd := exec.Command("git", "-C", clonePath, "config", "core.hooksPath", ".githooks")
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to configure hooks for %s: %w", clonePath, err)
}
}
return nil
}
// WitnessExistsCheck verifies the witness directory structure exists.
type WitnessExistsCheck struct {
FixableCheck
rigPath string
needsCreate bool
needsClone bool
needsMail bool
}
// NewWitnessExistsCheck creates a new witness exists check.
func NewWitnessExistsCheck() *WitnessExistsCheck {
return &WitnessExistsCheck{
FixableCheck: FixableCheck{
BaseCheck: BaseCheck{
CheckName: "witness-exists",
CheckDescription: "Verify witness/ directory structure exists",
CheckCategory: CategoryRig,
},
},
}
}
// Run checks if the witness directory structure exists.
func (c *WitnessExistsCheck) Run(ctx *CheckContext) *CheckResult {
c.rigPath = ctx.RigPath()
if c.rigPath == "" {
return &CheckResult{
Name: c.Name(),
Status: StatusError,
Message: "No rig specified",
}
}
witnessDir := filepath.Join(c.rigPath, "witness")
rigClone := filepath.Join(witnessDir, "rig")
mailInbox := filepath.Join(witnessDir, "mail", "inbox.jsonl")
var issues []string
c.needsCreate = false
c.needsClone = false
c.needsMail = false
// Check witness/ directory
if _, err := os.Stat(witnessDir); os.IsNotExist(err) {
issues = append(issues, "Missing: witness/")
c.needsCreate = true
} else {
// Check witness/rig/ clone
rigGit := filepath.Join(rigClone, ".git")
if _, err := os.Stat(rigGit); os.IsNotExist(err) {
issues = append(issues, "Missing: witness/rig/ (git clone)")
c.needsClone = true
}
// Check witness/mail/inbox.jsonl
if _, err := os.Stat(mailInbox); os.IsNotExist(err) {
issues = append(issues, "Missing: witness/mail/inbox.jsonl")
c.needsMail = true
}
}
if len(issues) == 0 {
return &CheckResult{
Name: c.Name(),
Status: StatusOK,
Message: "Witness structure exists",
}
}
return &CheckResult{
Name: c.Name(),
Status: StatusWarning,
Message: "Witness structure incomplete",
Details: issues,
FixHint: "Run 'gt doctor --fix' to create missing structure",
}
}
// Fix creates missing witness structure.
func (c *WitnessExistsCheck) Fix(ctx *CheckContext) error {
witnessDir := filepath.Join(c.rigPath, "witness")
if c.needsCreate {
if err := os.MkdirAll(witnessDir, 0755); err != nil {
return fmt.Errorf("failed to create witness/: %w", err)
}
}
if c.needsMail {
mailDir := filepath.Join(witnessDir, "mail")
if err := os.MkdirAll(mailDir, 0755); err != nil {
return fmt.Errorf("failed to create witness/mail/: %w", err)
}
inboxPath := filepath.Join(mailDir, "inbox.jsonl")
if err := os.WriteFile(inboxPath, []byte{}, 0644); err != nil {
return fmt.Errorf("failed to create inbox.jsonl: %w", err)
}
}
// Note: Cannot auto-fix clone without knowing the repo URL
if c.needsClone {
return fmt.Errorf("cannot auto-create witness/rig/ clone (requires repo URL)")
}
return nil
}
// RefineryExistsCheck verifies the refinery directory structure exists.
type RefineryExistsCheck struct {
FixableCheck
rigPath string
needsCreate bool
needsClone bool
needsMail bool
}
// NewRefineryExistsCheck creates a new refinery exists check.
func NewRefineryExistsCheck() *RefineryExistsCheck {
return &RefineryExistsCheck{
FixableCheck: FixableCheck{
BaseCheck: BaseCheck{
CheckName: "refinery-exists",
CheckDescription: "Verify refinery/ directory structure exists",
CheckCategory: CategoryRig,
},
},
}
}
// Run checks if the refinery directory structure exists.
func (c *RefineryExistsCheck) Run(ctx *CheckContext) *CheckResult {
c.rigPath = ctx.RigPath()
if c.rigPath == "" {
return &CheckResult{
Name: c.Name(),
Status: StatusError,
Message: "No rig specified",
}
}
refineryDir := filepath.Join(c.rigPath, "refinery")
rigClone := filepath.Join(refineryDir, "rig")
mailInbox := filepath.Join(refineryDir, "mail", "inbox.jsonl")
var issues []string
c.needsCreate = false
c.needsClone = false
c.needsMail = false
// Check refinery/ directory
if _, err := os.Stat(refineryDir); os.IsNotExist(err) {
issues = append(issues, "Missing: refinery/")
c.needsCreate = true
} else {
// Check refinery/rig/ clone
rigGit := filepath.Join(rigClone, ".git")
if _, err := os.Stat(rigGit); os.IsNotExist(err) {
issues = append(issues, "Missing: refinery/rig/ (git clone)")
c.needsClone = true
}
// Check refinery/mail/inbox.jsonl
if _, err := os.Stat(mailInbox); os.IsNotExist(err) {
issues = append(issues, "Missing: refinery/mail/inbox.jsonl")
c.needsMail = true
}
}
if len(issues) == 0 {
return &CheckResult{
Name: c.Name(),
Status: StatusOK,
Message: "Refinery structure exists",
}
}
return &CheckResult{
Name: c.Name(),
Status: StatusWarning,
Message: "Refinery structure incomplete",
Details: issues,
FixHint: "Run 'gt doctor --fix' to create missing structure",
}
}
// Fix creates missing refinery structure.
func (c *RefineryExistsCheck) Fix(ctx *CheckContext) error {
refineryDir := filepath.Join(c.rigPath, "refinery")
if c.needsCreate {
if err := os.MkdirAll(refineryDir, 0755); err != nil {
return fmt.Errorf("failed to create refinery/: %w", err)
}
}
if c.needsMail {
mailDir := filepath.Join(refineryDir, "mail")
if err := os.MkdirAll(mailDir, 0755); err != nil {
return fmt.Errorf("failed to create refinery/mail/: %w", err)
}
inboxPath := filepath.Join(mailDir, "inbox.jsonl")
if err := os.WriteFile(inboxPath, []byte{}, 0644); err != nil {
return fmt.Errorf("failed to create inbox.jsonl: %w", err)
}
}
// Note: Cannot auto-fix clone without knowing the repo URL
if c.needsClone {
return fmt.Errorf("cannot auto-create refinery/rig/ clone (requires repo URL)")
}
return nil
}
// MayorCloneExistsCheck verifies the mayor/rig clone exists.
type MayorCloneExistsCheck struct {
FixableCheck
rigPath string
needsCreate bool
needsClone bool
}
// NewMayorCloneExistsCheck creates a new mayor clone check.
func NewMayorCloneExistsCheck() *MayorCloneExistsCheck {
return &MayorCloneExistsCheck{
FixableCheck: FixableCheck{
BaseCheck: BaseCheck{
CheckName: "mayor-clone-exists",
CheckDescription: "Verify mayor/rig/ git clone exists",
CheckCategory: CategoryRig,
},
},
}
}
// Run checks if the mayor/rig clone exists.
func (c *MayorCloneExistsCheck) Run(ctx *CheckContext) *CheckResult {
c.rigPath = ctx.RigPath()
if c.rigPath == "" {
return &CheckResult{
Name: c.Name(),
Status: StatusError,
Message: "No rig specified",
}
}
mayorDir := filepath.Join(c.rigPath, "mayor")
rigClone := filepath.Join(mayorDir, "rig")
var issues []string
c.needsCreate = false
c.needsClone = false
// Check mayor/ directory
if _, err := os.Stat(mayorDir); os.IsNotExist(err) {
issues = append(issues, "Missing: mayor/")
c.needsCreate = true
} else {
// Check mayor/rig/ clone
rigGit := filepath.Join(rigClone, ".git")
if _, err := os.Stat(rigGit); os.IsNotExist(err) {
issues = append(issues, "Missing: mayor/rig/ (git clone)")
c.needsClone = true
}
}
if len(issues) == 0 {
return &CheckResult{
Name: c.Name(),
Status: StatusOK,
Message: "Mayor clone exists",
}
}
return &CheckResult{
Name: c.Name(),
Status: StatusWarning,
Message: "Mayor structure incomplete",
Details: issues,
FixHint: "Run 'gt doctor --fix' to create structure (clone requires repo URL)",
}
}
// Fix creates missing mayor structure.
func (c *MayorCloneExistsCheck) Fix(ctx *CheckContext) error {
mayorDir := filepath.Join(c.rigPath, "mayor")
if c.needsCreate {
if err := os.MkdirAll(mayorDir, 0755); err != nil {
return fmt.Errorf("failed to create mayor/: %w", err)
}
}
// Note: Cannot auto-fix clone without knowing the repo URL
if c.needsClone {
return fmt.Errorf("cannot auto-create mayor/rig/ clone (requires repo URL)")
}
return nil
}
// PolecatClonesValidCheck verifies each polecat directory is a valid clone.
type PolecatClonesValidCheck struct {
BaseCheck
}
// NewPolecatClonesValidCheck creates a new polecat clones check.
func NewPolecatClonesValidCheck() *PolecatClonesValidCheck {
return &PolecatClonesValidCheck{
BaseCheck: BaseCheck{
CheckName: "polecat-clones-valid",
CheckDescription: "Verify polecat directories are valid git clones",
CheckCategory: CategoryRig,
},
}
}
// Run checks if each polecat directory is a valid git clone.
func (c *PolecatClonesValidCheck) Run(ctx *CheckContext) *CheckResult {
rigPath := ctx.RigPath()
if rigPath == "" {
return &CheckResult{
Name: c.Name(),
Status: StatusError,
Message: "No rig specified",
}
}
polecatsDir := filepath.Join(rigPath, "polecats")
entries, err := os.ReadDir(polecatsDir)
if os.IsNotExist(err) {
return &CheckResult{
Name: c.Name(),
Status: StatusOK,
Message: "No polecats/ directory (none deployed)",
}
}
if err != nil {
return &CheckResult{
Name: c.Name(),
Status: StatusError,
Message: fmt.Sprintf("Cannot read polecats/: %v", err),
}
}
var issues []string
var warnings []string
validCount := 0
// Get rig name for new structure path detection
rigName := ctx.RigName
for _, entry := range entries {
if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
continue
}
polecatName := entry.Name()
// Determine worktree path (handle both new and old structures)
// New structure: polecats/<name>/<rigname>/
// Old structure: polecats/<name>/
polecatPath := filepath.Join(polecatsDir, polecatName, rigName)
if _, err := os.Stat(polecatPath); os.IsNotExist(err) {
polecatPath = filepath.Join(polecatsDir, polecatName)
}
// Check if it's a git clone
gitPath := filepath.Join(polecatPath, ".git")
if _, err := os.Stat(gitPath); os.IsNotExist(err) {
issues = append(issues, fmt.Sprintf("%s: not a git clone", polecatName))
continue
}
// Verify git status works and check for uncommitted changes
cmd := exec.Command("git", "-C", polecatPath, "status", "--porcelain")
output, err := cmd.Output()
if err != nil {
issues = append(issues, fmt.Sprintf("%s: git status failed", polecatName))
continue
}
if len(output) > 0 {
warnings = append(warnings, fmt.Sprintf("%s: has uncommitted changes", polecatName))
}
// Check if on a polecat branch
cmd = exec.Command("git", "-C", polecatPath, "branch", "--show-current")
branchOutput, err := cmd.Output()
if err == nil {
branch := strings.TrimSpace(string(branchOutput))
if !strings.HasPrefix(branch, "polecat/") {
warnings = append(warnings, fmt.Sprintf("%s: on branch '%s' (expected polecat/*)", polecatName, branch))
}
}
validCount++
}
if len(issues) > 0 {
return &CheckResult{
Name: c.Name(),
Status: StatusError,
Message: fmt.Sprintf("%d polecat(s) invalid", len(issues)),
Details: append(issues, warnings...),
FixHint: "Cannot auto-fix (data loss risk)",
}
}
if len(warnings) > 0 {
return &CheckResult{
Name: c.Name(),
Status: StatusWarning,
Message: fmt.Sprintf("%d polecat(s) valid, %d warning(s)", validCount, len(warnings)),
Details: warnings,
}
}
if validCount == 0 {
return &CheckResult{
Name: c.Name(),
Status: StatusOK,
Message: "No polecats deployed",
}
}
return &CheckResult{
Name: c.Name(),
Status: StatusOK,
Message: fmt.Sprintf("%d polecat(s) valid", validCount),
}
}
// BeadsConfigValidCheck verifies beads configuration if .beads/ exists.
type BeadsConfigValidCheck struct {
FixableCheck
rigPath string
needsSync bool
}
// NewBeadsConfigValidCheck creates a new beads config check.
func NewBeadsConfigValidCheck() *BeadsConfigValidCheck {
return &BeadsConfigValidCheck{
FixableCheck: FixableCheck{
BaseCheck: BaseCheck{
CheckName: "beads-config-valid",
CheckDescription: "Verify beads configuration if .beads/ exists",
CheckCategory: CategoryRig,
},
},
}
}
// Run checks if beads is properly configured.
func (c *BeadsConfigValidCheck) Run(ctx *CheckContext) *CheckResult {
c.rigPath = ctx.RigPath()
if c.rigPath == "" {
return &CheckResult{
Name: c.Name(),
Status: StatusError,
Message: "No rig specified",
}
}
beadsDir := filepath.Join(c.rigPath, ".beads")
if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
return &CheckResult{
Name: c.Name(),
Status: StatusOK,
Message: "No .beads/ directory (beads not configured)",
}
}
// Check if bd command works
cmd := exec.Command("bd", "stats", "--json")
cmd.Dir = c.rigPath
if err := cmd.Run(); err != nil {
return &CheckResult{
Name: c.Name(),
Status: StatusError,
Message: "bd command failed",
Details: []string{fmt.Sprintf("Error: %v", err)},
FixHint: "Check beads installation and .beads/ configuration",
}
}
// Check sync status
cmd = exec.Command("bd", "sync", "--status")
cmd.Dir = c.rigPath
output, err := cmd.CombinedOutput()
c.needsSync = false
if err != nil {
// sync --status may exit non-zero if out of sync
outputStr := string(output)
if strings.Contains(outputStr, "out of sync") || strings.Contains(outputStr, "behind") {
c.needsSync = true
return &CheckResult{
Name: c.Name(),
Status: StatusWarning,
Message: "Beads out of sync",
Details: []string{strings.TrimSpace(outputStr)},
FixHint: "Run 'gt doctor --fix' or 'bd sync' to synchronize",
}
}
}
return &CheckResult{
Name: c.Name(),
Status: StatusOK,
Message: "Beads configured and in sync",
}
}
// Fix runs bd sync if needed.
func (c *BeadsConfigValidCheck) Fix(ctx *CheckContext) error {
if !c.needsSync {
return nil
}
cmd := exec.Command("bd", "sync")
cmd.Dir = c.rigPath
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("bd sync failed: %s", string(output))
}
return nil
}
// BeadsRedirectCheck verifies that rig-level beads redirect exists for tracked beads.
// When a repo has .beads/ tracked in git (at mayor/rig/.beads), the rig root needs
// a redirect file pointing to that location.
type BeadsRedirectCheck struct {
FixableCheck
}
// NewBeadsRedirectCheck creates a new beads redirect check.
func NewBeadsRedirectCheck() *BeadsRedirectCheck {
return &BeadsRedirectCheck{
FixableCheck: FixableCheck{
BaseCheck: BaseCheck{
CheckName: "beads-redirect",
CheckDescription: "Verify rig-level beads redirect for tracked beads",
CheckCategory: CategoryRig,
},
},
}
}
// Run checks if the rig-level beads redirect exists when needed.
func (c *BeadsRedirectCheck) Run(ctx *CheckContext) *CheckResult {
// Only applies when checking a specific rig
if ctx.RigName == "" {
return &CheckResult{
Name: c.Name(),
Status: StatusOK,
Message: "No rig specified (skipping redirect check)",
}
}
rigPath := ctx.RigPath()
mayorRigBeads := filepath.Join(rigPath, "mayor", "rig", ".beads")
rigBeadsDir := filepath.Join(rigPath, ".beads")
redirectPath := filepath.Join(rigBeadsDir, "redirect")
// Check if this rig has tracked beads (mayor/rig/.beads exists)
if _, err := os.Stat(mayorRigBeads); os.IsNotExist(err) {
// No tracked beads - check if rig/.beads exists (local beads)
if _, err := os.Stat(rigBeadsDir); os.IsNotExist(err) {
return &CheckResult{
Name: c.Name(),
Status: StatusError,
Message: "No .beads directory found at rig root",
Details: []string{
"Beads database not initialized for this rig",
"This prevents issue tracking for this rig",
},
FixHint: "Run 'gt doctor --fix --rig " + ctx.RigName + "' to initialize beads",
}
}
return &CheckResult{
Name: c.Name(),
Status: StatusOK,
Message: "Rig uses local beads (no redirect needed)",
}
}
// Tracked beads exist - check for conflicting local beads
hasLocalData := hasBeadsData(rigBeadsDir)
redirectExists := false
if _, err := os.Stat(redirectPath); err == nil {
redirectExists = true
}
// Case: Local beads directory has actual data (not just redirect)
if hasLocalData && !redirectExists {
return &CheckResult{
Name: c.Name(),
Status: StatusError,
Message: "Conflicting local beads found with tracked beads",
Details: []string{
"Tracked beads exist at: mayor/rig/.beads",
"Local beads with data exist at: .beads/",
"Fix will remove local beads and create redirect to tracked beads",
},
FixHint: "Run 'gt doctor --fix --rig " + ctx.RigName + "' to fix",
}
}
// Case: No redirect file (but no conflicting data)
if !redirectExists {
return &CheckResult{
Name: c.Name(),
Status: StatusError,
Message: "Missing rig-level beads redirect for tracked beads",
Details: []string{
"Tracked beads exist at: mayor/rig/.beads",
"Missing redirect at: .beads/redirect",
"Without this redirect, bd commands from rig root won't find beads",
},
FixHint: "Run 'gt doctor --fix' to create the redirect",
}
}
// Verify redirect points to correct location
content, err := os.ReadFile(redirectPath)
if err != nil {
return &CheckResult{
Name: c.Name(),
Status: StatusWarning,
Message: fmt.Sprintf("Could not read redirect file: %v", err),
}
}
target := strings.TrimSpace(string(content))
if target != "mayor/rig/.beads" {
return &CheckResult{
Name: c.Name(),
Status: StatusError,
Message: fmt.Sprintf("Redirect points to %q, expected mayor/rig/.beads", target),
FixHint: "Run 'gt doctor --fix --rig " + ctx.RigName + "' to correct the redirect",
}
}
return &CheckResult{
Name: c.Name(),
Status: StatusOK,
Message: "Rig-level beads redirect is correctly configured",
}
}
// Fix creates or corrects the rig-level beads redirect, or initializes beads if missing.
func (c *BeadsRedirectCheck) Fix(ctx *CheckContext) error {
if ctx.RigName == "" {
return nil
}
rigPath := ctx.RigPath()
mayorRigBeads := filepath.Join(rigPath, "mayor", "rig", ".beads")
rigBeadsDir := filepath.Join(rigPath, ".beads")
redirectPath := filepath.Join(rigBeadsDir, "redirect")
// Check if tracked beads exist
hasTrackedBeads := true
if _, err := os.Stat(mayorRigBeads); os.IsNotExist(err) {
hasTrackedBeads = false
}
// Check if local beads exist
hasLocalBeads := true
if _, err := os.Stat(rigBeadsDir); os.IsNotExist(err) {
hasLocalBeads = false
}
// Case 1: No beads at all - initialize with bd init
if !hasTrackedBeads && !hasLocalBeads {
// Get the rig's beads prefix from rigs.json (falls back to "gt" if not found)
prefix := config.GetRigPrefix(ctx.TownRoot, ctx.RigName)
// Create .beads directory
if err := os.MkdirAll(rigBeadsDir, 0755); err != nil {
return fmt.Errorf("creating .beads directory: %w", err)
}
// Run bd init with the configured prefix
cmd := exec.Command("bd", "init", "--prefix", prefix)
cmd.Dir = rigPath
if output, err := cmd.CombinedOutput(); err != nil {
// bd might not be installed - create minimal config.yaml
configPath := filepath.Join(rigBeadsDir, "config.yaml")
configContent := fmt.Sprintf("prefix: %s\n", prefix)
if writeErr := os.WriteFile(configPath, []byte(configContent), 0644); writeErr != nil {
return fmt.Errorf("bd init failed (%v) and fallback config creation failed: %w", err, writeErr)
}
// Continue - minimal config created
} else {
_ = output // bd init succeeded
// Configure custom types for Gas Town (beads v0.46.0+)
configCmd := exec.Command("bd", "config", "set", "types.custom", constants.BeadsCustomTypes)
configCmd.Dir = rigPath
_, _ = configCmd.CombinedOutput() // Ignore errors - older beads don't need this
}
return nil
}
// Case 2: Tracked beads exist - create redirect (may need to remove conflicting local beads)
if hasTrackedBeads {
// Check if local beads have conflicting data
if hasLocalBeads && hasBeadsData(rigBeadsDir) {
// Remove conflicting local beads directory
if err := os.RemoveAll(rigBeadsDir); err != nil {
return fmt.Errorf("removing conflicting local beads: %w", err)
}
}
// Create .beads directory if needed
if err := os.MkdirAll(rigBeadsDir, 0755); err != nil {
return fmt.Errorf("creating .beads directory: %w", err)
}
// Write redirect file
if err := os.WriteFile(redirectPath, []byte("mayor/rig/.beads\n"), 0644); err != nil {
return fmt.Errorf("writing redirect file: %w", err)
}
}
return nil
}
// hasBeadsData checks if a beads directory has actual data (issues.jsonl, issues.db, config.yaml)
// as opposed to just being a redirect-only directory.
func hasBeadsData(beadsDir string) bool {
// Check for actual beads data files
dataFiles := []string{"issues.jsonl", "issues.db", "config.yaml"}
for _, f := range dataFiles {
if _, err := os.Stat(filepath.Join(beadsDir, f)); err == nil {
return true
}
}
return false
}
// BareRepoRefspecCheck verifies that the shared bare repo has the correct refspec configured.
// Without this, worktrees created from the bare repo cannot fetch and see origin/* refs.
// See: https://github.com/anthropics/gastown/issues/286
type BareRepoRefspecCheck struct {
FixableCheck
}
// NewBareRepoRefspecCheck creates a new bare repo refspec check.
func NewBareRepoRefspecCheck() *BareRepoRefspecCheck {
return &BareRepoRefspecCheck{
FixableCheck: FixableCheck{
BaseCheck: BaseCheck{
CheckName: "bare-repo-refspec",
CheckDescription: "Verify bare repo has correct refspec for worktrees",
CheckCategory: CategoryRig,
},
},
}
}
// Run checks if the bare repo has the correct remote.origin.fetch refspec.
func (c *BareRepoRefspecCheck) Run(ctx *CheckContext) *CheckResult {
if ctx.RigName == "" {
return &CheckResult{
Name: c.Name(),
Status: StatusOK,
Message: "No rig specified, skipping bare repo check",
}
}
bareRepoPath := filepath.Join(ctx.RigPath(), ".repo.git")
if _, err := os.Stat(bareRepoPath); os.IsNotExist(err) {
// No bare repo - might be using a different architecture
return &CheckResult{
Name: c.Name(),
Status: StatusOK,
Message: "No shared bare repo found (using individual clones)",
}
}
// Check the refspec
cmd := exec.Command("git", "-C", bareRepoPath, "config", "--get", "remote.origin.fetch")
out, err := cmd.Output()
if err != nil {
return &CheckResult{
Name: c.Name(),
Status: StatusError,
Message: "Bare repo missing remote.origin.fetch refspec",
Details: []string{
"Worktrees cannot fetch or see origin/* refs without this config",
"This breaks refinery merge operations and causes stale origin/main",
},
FixHint: "Run 'gt doctor --fix' to configure the refspec",
}
}
refspec := strings.TrimSpace(string(out))
expectedRefspec := "+refs/heads/*:refs/remotes/origin/*"
if refspec != expectedRefspec {
return &CheckResult{
Name: c.Name(),
Status: StatusWarning,
Message: "Bare repo has non-standard refspec",
Details: []string{
fmt.Sprintf("Current: %s", refspec),
fmt.Sprintf("Expected: %s", expectedRefspec),
},
FixHint: "Run 'gt doctor --fix' to update the refspec",
}
}
return &CheckResult{
Name: c.Name(),
Status: StatusOK,
Message: "Bare repo refspec configured correctly",
}
}
// Fix sets the correct refspec on the bare repo.
func (c *BareRepoRefspecCheck) Fix(ctx *CheckContext) error {
if ctx.RigName == "" {
return nil
}
bareRepoPath := filepath.Join(ctx.RigPath(), ".repo.git")
if _, err := os.Stat(bareRepoPath); os.IsNotExist(err) {
return nil // No bare repo to fix
}
cmd := exec.Command("git", "-C", bareRepoPath, "config", "remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("setting refspec: %s", strings.TrimSpace(stderr.String()))
}
return nil
}
// RigChecks returns all rig-level health checks.
func RigChecks() []Check {
return []Check{
NewRigIsGitRepoCheck(),
NewGitExcludeConfiguredCheck(),
NewHooksPathConfiguredCheck(),
NewSparseCheckoutCheck(),
NewBareRepoRefspecCheck(),
NewWitnessExistsCheck(),
NewRefineryExistsCheck(),
NewMayorCloneExistsCheck(),
NewPolecatClonesValidCheck(),
NewBeadsConfigValidCheck(),
NewBeadsRedirectCheck(),
}
}