From 18191f5e54d2fbc09db0d9324aa48a4e1523a761 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Tue, 2 Dec 2025 23:51:20 -0800 Subject: [PATCH] feat(doctor): detect and fix stale sync branches (bd-6rf) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 'Sync Branch Health' check that detects: 1. Local sync branch diverged from remote (after force-push reset) 2. Sync branch significantly behind main on source files (20+ commits, 50+ files) Add --fix support that: - Handles worktree case (resets within worktree) - Handles regular branch case (deletes and recreates from main) - Pushes the reset branch to remote This helps contributors whose local beads-sync becomes orphaned after someone else resets the branch. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- cmd/bd/doctor.go | 172 +++++++++++++++++++++++++++++++ cmd/bd/doctor/fix/sync_branch.go | 108 +++++++++++++++++++ 2 files changed, 280 insertions(+) diff --git a/cmd/bd/doctor.go b/cmd/bd/doctor.go index f269750b..8035f0bd 100644 --- a/cmd/bd/doctor.go +++ b/cmd/bd/doctor.go @@ -224,6 +224,14 @@ func applyFixes(result doctorResult) { err = fix.HydrateDeletionsManifest(result.Path) case "Untracked Files": err = fix.UntrackedJSONL(result.Path) + case "Sync Branch Health": + // Get sync branch from config + syncBranch := syncbranch.GetFromYAML() + if syncBranch == "" { + fmt.Printf(" ⚠ No sync branch configured in config.yaml\n") + continue + } + err = fix.SyncBranchHealth(result.Path, syncBranch) default: fmt.Printf(" ⚠ No automatic fix available for %s\n", check.Name) fmt.Printf(" Manual fix: %s\n", check.Fix) @@ -596,6 +604,11 @@ func runDiagnostics(path string) doctorResult { result.Checks = append(result.Checks, syncBranchCheck) // Don't fail overall check for missing sync.branch, just warn + // Check 17a: Sync branch health (bd-6rf) + syncBranchHealthCheck := checkSyncBranchHealth(path) + result.Checks = append(result.Checks, syncBranchHealthCheck) + // Don't fail overall check for sync branch health, just warn + // Check 18: Deletions manifest (prevents zombie resurrection) deletionsCheck := checkDeletionsManifest(path) result.Checks = append(result.Checks, deletionsCheck) @@ -2102,6 +2115,165 @@ func checkSyncBranchConfig(path string) doctorCheck { } } +// checkSyncBranchHealth detects when the sync branch has diverged from main +// or from the remote sync branch (after a force-push reset). +// bd-6rf: Detect and fix stale beads-sync branch +func checkSyncBranchHealth(path string) doctorCheck { + // Skip if not in a git repo + gitDir := filepath.Join(path, ".git") + if _, err := os.Stat(gitDir); os.IsNotExist(err) { + return doctorCheck{ + Name: "Sync Branch Health", + Status: statusOK, + Message: "N/A (not a git repository)", + } + } + + // Get configured sync branch + syncBranch := syncbranch.GetFromYAML() + if syncBranch == "" { + return doctorCheck{ + Name: "Sync Branch Health", + Status: statusOK, + Message: "N/A (no sync branch configured)", + } + } + + // Check if local sync branch exists + cmd := exec.Command("git", "rev-parse", "--verify", syncBranch) + cmd.Dir = path + if err := cmd.Run(); err != nil { + // Local branch doesn't exist - that's fine, bd sync will create it + return doctorCheck{ + Name: "Sync Branch Health", + Status: statusOK, + Message: fmt.Sprintf("N/A (local %s branch not created yet)", syncBranch), + } + } + + // Check if remote sync branch exists + remote := "origin" + remoteBranch := fmt.Sprintf("%s/%s", remote, syncBranch) + cmd = exec.Command("git", "rev-parse", "--verify", remoteBranch) + cmd.Dir = path + if err := cmd.Run(); err != nil { + // Remote branch doesn't exist - that's fine + return doctorCheck{ + Name: "Sync Branch Health", + Status: statusOK, + Message: fmt.Sprintf("N/A (remote %s not found)", remoteBranch), + } + } + + // Check 1: Is local sync branch diverged from remote? (after force-push) + // If they have no common ancestor in recent history, the remote was likely force-pushed + cmd = exec.Command("git", "merge-base", syncBranch, remoteBranch) + cmd.Dir = path + mergeBaseOutput, err := cmd.Output() + if err != nil { + // No common ancestor - branches have completely diverged + return doctorCheck{ + Name: "Sync Branch Health", + Status: statusWarning, + Message: fmt.Sprintf("Local %s diverged from remote", syncBranch), + Detail: "The remote sync branch was likely reset/force-pushed. Your local branch has orphaned history.", + Fix: fmt.Sprintf("Reset local branch: git branch -D %s (it will be recreated on next bd sync)", syncBranch), + } + } + + // Check if local is behind remote (needs to fast-forward) + mergeBase := strings.TrimSpace(string(mergeBaseOutput)) + cmd = exec.Command("git", "rev-parse", syncBranch) + cmd.Dir = path + localHead, _ := cmd.Output() + localHeadStr := strings.TrimSpace(string(localHead)) + + cmd = exec.Command("git", "rev-parse", remoteBranch) + cmd.Dir = path + remoteHead, _ := cmd.Output() + remoteHeadStr := strings.TrimSpace(string(remoteHead)) + + // If merge base equals local but not remote, local is behind + if mergeBase == localHeadStr && mergeBase != remoteHeadStr { + // Count how far behind + cmd = exec.Command("git", "rev-list", "--count", fmt.Sprintf("%s..%s", syncBranch, remoteBranch)) + cmd.Dir = path + countOutput, _ := cmd.Output() + behindCount := strings.TrimSpace(string(countOutput)) + + return doctorCheck{ + Name: "Sync Branch Health", + Status: statusOK, + Message: fmt.Sprintf("Local %s is %s commits behind remote (will sync)", syncBranch, behindCount), + } + } + + // Check 2: Is sync branch far behind main on source files? + // Get the main branch name + mainBranch := "main" + cmd = exec.Command("git", "rev-parse", "--verify", "main") + cmd.Dir = path + if err := cmd.Run(); err != nil { + // Try "master" as fallback + cmd = exec.Command("git", "rev-parse", "--verify", "master") + cmd.Dir = path + if err := cmd.Run(); err != nil { + // Can't determine main branch + return doctorCheck{ + Name: "Sync Branch Health", + Status: statusOK, + Message: "OK", + } + } + mainBranch = "master" + } + + // Count commits main is ahead of sync branch + cmd = exec.Command("git", "rev-list", "--count", fmt.Sprintf("%s..%s", syncBranch, mainBranch)) + cmd.Dir = path + aheadOutput, err := cmd.Output() + if err != nil { + return doctorCheck{ + Name: "Sync Branch Health", + Status: statusOK, + Message: "OK", + } + } + aheadCount := strings.TrimSpace(string(aheadOutput)) + + // Check if there are non-.beads/ file differences (stale source code) + cmd = exec.Command("git", "diff", "--name-only", fmt.Sprintf("%s..%s", syncBranch, mainBranch), "--", ":(exclude).beads/") + cmd.Dir = path + diffOutput, _ := cmd.Output() + diffFiles := strings.TrimSpace(string(diffOutput)) + + if diffFiles != "" && aheadCount != "0" { + // Count the number of different files + fileCount := len(strings.Split(diffFiles, "\n")) + // Parse ahead count as int for comparison + aheadCountInt := 0 + fmt.Sscanf(aheadCount, "%d", &aheadCountInt) + + // Only warn if significantly behind (20+ commits AND 50+ source files) + // Small drift is normal between bd sync operations + if fileCount > 50 && aheadCountInt > 20 { + return doctorCheck{ + Name: "Sync Branch Health", + Status: statusWarning, + Message: fmt.Sprintf("Sync branch %s commits behind %s on source files", aheadCount, mainBranch), + Detail: fmt.Sprintf("%d source files differ between %s and %s. The sync branch has stale code.", fileCount, syncBranch, mainBranch), + Fix: fmt.Sprintf("Reset sync branch: git branch -f %s %s && git push --force-with-lease origin %s", syncBranch, mainBranch, syncBranch), + } + } + } + + return doctorCheck{ + Name: "Sync Branch Health", + Status: statusOK, + Message: "OK", + } +} + func checkDeletionsManifest(path string) doctorCheck { beadsDir := filepath.Join(path, ".beads") diff --git a/cmd/bd/doctor/fix/sync_branch.go b/cmd/bd/doctor/fix/sync_branch.go index b7058203..06a2388d 100644 --- a/cmd/bd/doctor/fix/sync_branch.go +++ b/cmd/bd/doctor/fix/sync_branch.go @@ -42,3 +42,111 @@ func SyncBranchConfig(path string) error { fmt.Printf(" Set sync.branch = %s\n", currentBranch) return nil } + +// SyncBranchHealth fixes a stale or diverged sync branch by resetting it to main. +// This handles two cases: +// 1. Local sync branch diverged from remote (after force-push) +// 2. Sync branch far behind main on source files +// +// bd-6rf: Detect and fix stale beads-sync branch +func SyncBranchHealth(path, syncBranch string) error { + if err := validateBeadsWorkspace(path); err != nil { + return err + } + + // Determine main branch + mainBranch := "main" + cmd := exec.Command("git", "rev-parse", "--verify", "main") + cmd.Dir = path + if err := cmd.Run(); err != nil { + cmd = exec.Command("git", "rev-parse", "--verify", "master") + cmd.Dir = path + if err := cmd.Run(); err != nil { + return fmt.Errorf("cannot determine main branch (neither main nor master exists)") + } + mainBranch = "master" + } + + // Check if there's a worktree for this branch + worktreePath := "" + cmd = exec.Command("git", "worktree", "list", "--porcelain") + cmd.Dir = path + output, err := cmd.Output() + if err == nil { + lines := strings.Split(string(output), "\n") + for i, line := range lines { + if strings.HasPrefix(line, "worktree ") { + wt := strings.TrimPrefix(line, "worktree ") + // Check if next line has the branch + if i+2 < len(lines) && strings.Contains(lines[i+2], syncBranch) { + worktreePath = wt + break + } + } + } + } + + // If worktree exists, reset within it + if worktreePath != "" { + fmt.Printf(" Resetting sync branch in worktree: %s\n", worktreePath) + cmd = exec.Command("git", "fetch", "origin", mainBranch) + cmd.Dir = worktreePath + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to fetch: %w\n%s", err, out) + } + + cmd = exec.Command("git", "reset", "--hard", fmt.Sprintf("origin/%s", mainBranch)) + cmd.Dir = worktreePath + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to reset worktree: %w\n%s", err, out) + } + + // Push the reset branch + cmd = exec.Command("git", "push", "--force-with-lease", "origin", syncBranch) + cmd.Dir = worktreePath + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to push: %w\n%s", err, out) + } + + fmt.Printf(" ✓ Reset %s to %s and pushed\n", syncBranch, mainBranch) + return nil + } + + // No worktree - reset the branch directly + // First, make sure we're not on the sync branch + cmd = exec.Command("git", "symbolic-ref", "--short", "HEAD") + cmd.Dir = path + currentBranchOutput, err := cmd.Output() + if err == nil && strings.TrimSpace(string(currentBranchOutput)) == syncBranch { + return fmt.Errorf("currently on %s branch - checkout a different branch first", syncBranch) + } + + // Delete and recreate the branch from main + fmt.Printf(" Deleting local %s branch...\n", syncBranch) + cmd = exec.Command("git", "branch", "-D", syncBranch) + cmd.Dir = path + _ = cmd.Run() // Ignore error if branch doesn't exist + + // Fetch latest and recreate + cmd = exec.Command("git", "fetch", "origin", mainBranch) + cmd.Dir = path + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to fetch: %w\n%s", err, out) + } + + cmd = exec.Command("git", "branch", syncBranch, fmt.Sprintf("origin/%s", mainBranch)) + cmd.Dir = path + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to create branch: %w\n%s", err, out) + } + + // Push the new branch + cmd = exec.Command("git", "push", "--force-with-lease", "origin", syncBranch) + cmd.Dir = path + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to push: %w\n%s", err, out) + } + + fmt.Printf(" ✓ Recreated %s from %s and pushed\n", syncBranch, mainBranch) + return nil +}