feat: Add clone-divergence check to bd doctor (gt-wqck)

Adds CloneDivergenceCheck that detects when git clones have drifted
significantly behind origin/main:
- >10 commits behind: WARNING
- >50 commits behind: ERROR (EMERGENCY)

Only checks clones on main branch, since off-main clones are already
caught by BranchCheck. This distinguishes from beads-sync divergence
which is expected behavior.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-26 20:20:34 -08:00
parent 9cd6ce7460
commit eb75f7c58f
2 changed files with 228 additions and 0 deletions

View File

@@ -29,6 +29,10 @@ Cleanup checks (fixable):
- orphan-processes Detect orphaned Claude processes
- wisp-gc Detect and clean abandoned wisps (>1h)
Clone divergence checks:
- persistent-role-branches Detect crew/witness/refinery not on main
- clone-divergence Detect clones significantly behind origin/main
Patrol checks:
- patrol-molecules-exist Verify patrol molecules exist
- patrol-hooks-wired Verify daemon triggers patrols
@@ -75,6 +79,7 @@ func runDoctor(cmd *cobra.Command, args []string) error {
d.Register(doctor.NewWispGCCheck())
d.Register(doctor.NewBranchCheck())
d.Register(doctor.NewBeadsSyncOrphanCheck())
d.Register(doctor.NewCloneDivergenceCheck())
d.Register(doctor.NewIdentityCollisionCheck())
d.Register(doctor.NewLinkedPaneCheck())
d.Register(doctor.NewThemeCheck())

View File

@@ -322,3 +322,226 @@ func (c *BeadsSyncOrphanCheck) findCrewDirs(townRoot string) []string {
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",
},
}
}
// 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
polecatsPath := filepath.Join(rigPath, "polecats")
if polecatEntries, err := os.ReadDir(polecatsPath); err == nil {
for _, polecat := range polecatEntries {
if polecat.IsDir() && !strings.HasPrefix(polecat.Name(), ".") {
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
}