fix(sync): handle redirect + sync-branch incompatibility (bd-wayc3)

When a crew worker's .beads/ is redirected to another repo, bd sync
now detects this and skips all git operations (sync-branch worktree
manipulation). Instead, it just exports to JSONL and lets the target
repo's owner handle the git sync.

Changes:
- sync.go: Detect redirect early, skip git operations when active
- beads.go: Update GetRedirectInfo() to check git repo even when
  BEADS_DIR is pre-set (findLocalBdsDirInRepo helper)
- validation.go: Add doctor check for redirect + sync-branch conflict
- doctor.go: Register new check, remove undefined CheckMisclassifiedWisps

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Executed-By: beads/crew/dave
Rig: beads
Role: crew
This commit is contained in:
beads/crew/dave
2026-01-14 20:36:30 -08:00
committed by Steve Yegge
parent ff67b88ea9
commit 3298c45e4b
5 changed files with 1416 additions and 1281 deletions

File diff suppressed because one or more lines are too long

View File

@@ -465,7 +465,12 @@ func runDiagnostics(path string) doctorResult {
result.Checks = append(result.Checks, vestigialWorktreesCheck)
// Don't fail overall check for vestigial worktrees, just warn
// Check 14f: last-touched file tracking (runtime state shouldn't be committed)
// Check 14f: redirect + sync-branch conflict (bd-wayc3)
redirectSyncBranchCheck := convertDoctorCheck(doctor.CheckRedirectSyncBranchConflict(path))
result.Checks = append(result.Checks, redirectSyncBranchCheck)
// Don't fail overall check for redirect+sync-branch conflict, just warn
// Check 14g: last-touched file tracking (runtime state shouldn't be committed)
lastTouchedTrackingCheck := convertWithCategory(doctor.CheckLastTouchedNotTracked(), doctor.CategoryGit)
result.Checks = append(result.Checks, lastTouchedTrackingCheck)
// Don't fail overall check for last-touched tracking, just warn
@@ -577,10 +582,9 @@ func runDiagnostics(path string) doctorResult {
result.Checks = append(result.Checks, staleMQFilesCheck)
// Don't fail overall check for legacy MQ files, just warn
// Check 26d: Misclassified wisps (wisp-patterned IDs without ephemeral flag)
misclassifiedWispsCheck := convertDoctorCheck(doctor.CheckMisclassifiedWisps(path))
result.Checks = append(result.Checks, misclassifiedWispsCheck)
// Don't fail overall check for misclassified wisps, just warn
// Note: Check 26d (misclassified wisps) was referenced but never implemented.
// The commit f703237c added importer-based auto-detection instead.
// Removing the undefined reference to fix build.
// Check 27: Expired tombstones (maintenance)
tombstonesExpiredCheck := convertDoctorCheck(doctor.CheckExpiredTombstones(path))

View File

@@ -391,6 +391,79 @@ func CheckChildParentDependencies(path string) DoctorCheck {
}
}
// CheckRedirectSyncBranchConflict detects when both redirect and sync-branch are configured.
// This is a configuration error: redirect means "my database is elsewhere (I'm a client)",
// while sync-branch means "I own my database and sync it myself". These are mutually exclusive.
// bd-wayc3: Added to detect incompatible configuration before sync fails.
func CheckRedirectSyncBranchConflict(path string) DoctorCheck {
beadsDir := filepath.Join(path, ".beads")
// Check if redirect file exists
redirectFile := filepath.Join(beadsDir, beads.RedirectFileName)
if _, err := os.Stat(redirectFile); os.IsNotExist(err) {
return DoctorCheck{
Name: "Redirect + Sync-Branch",
Status: StatusOK,
Message: "No redirect configured",
Category: CategoryData,
}
}
// Redirect exists - check if sync-branch is also configured
// Read config.yaml directly since we need to check the local config, not the resolved one
configPath := filepath.Join(beadsDir, "config.yaml")
data, err := os.ReadFile(configPath) // #nosec G304 - path constructed safely
if err != nil {
// No config file - no conflict possible
return DoctorCheck{
Name: "Redirect + Sync-Branch",
Status: StatusOK,
Message: "Redirect active (no local config)",
Category: CategoryData,
}
}
// Parse sync-branch from config.yaml (simple line-based parsing)
// Handles: sync-branch: value, sync-branch: "value", sync-branch: 'value'
// Also handles trailing comments: sync-branch: value # comment
configStr := string(data)
for _, line := range strings.Split(configStr, "\n") {
line = strings.TrimSpace(line)
// Skip comments
if strings.HasPrefix(line, "#") {
continue
}
if strings.HasPrefix(line, "sync-branch:") {
value := strings.TrimPrefix(line, "sync-branch:")
// Remove trailing comment if present
if idx := strings.Index(value, "#"); idx != -1 {
value = value[:idx]
}
value = strings.TrimSpace(value)
// Remove quotes if present
value = strings.Trim(value, `"'`)
if value != "" {
// Found both redirect and sync-branch - conflict!
return DoctorCheck{
Name: "Redirect + Sync-Branch",
Status: StatusWarning,
Message: fmt.Sprintf("Redirect active but sync-branch=%q configured", value),
Detail: "Redirect and sync-branch are mutually exclusive. Redirected clones should not have sync-branch.",
Fix: "Remove sync-branch from config.yaml (set to empty string or delete the line)",
Category: CategoryData,
}
}
}
}
return DoctorCheck{
Name: "Redirect + Sync-Branch",
Status: StatusOK,
Message: "Redirect active (no sync-branch conflict)",
Category: CategoryData,
}
}
// CheckGitConflicts detects git conflict markers in JSONL file.
func CheckGitConflicts(path string) DoctorCheck {
// Follow redirect to resolve actual beads directory (bd-tvus fix)

View File

@@ -208,6 +208,36 @@ Use --merge to merge the sync branch back to main branch.`,
}
hasSyncBranchConfig := syncBranchName != ""
// bd-wayc3: Check for redirect + sync-branch incompatibility
// Redirect and sync-branch are mutually exclusive:
// - Redirect says: "My database is in another repo (I am a client)"
// - Sync-branch says: "I own my database and sync it myself via worktree"
// When redirect is active, the sync-branch worktree operations fail because
// the beads files are in a different git repo than the current working directory.
redirectInfo := beads.GetRedirectInfo()
if redirectInfo.IsRedirected {
if hasSyncBranchConfig {
fmt.Printf("⚠️ Redirect active (-> %s), skipping sync-branch operations\n", redirectInfo.TargetDir)
fmt.Println(" Hint: Redirected clones should not have sync-branch configured")
fmt.Println(" The owner of the target .beads directory handles sync-branch")
} else {
fmt.Printf("→ Redirect active (-> %s)\n", redirectInfo.TargetDir)
}
// For redirected clones, just do import/export - skip all git operations
// The target repo's owner (e.g., mayor) handles git commit/push via sync-branch
if dryRun {
fmt.Println("→ [DRY RUN] Would export to JSONL (redirected clone, git operations skipped)")
fmt.Println("✓ Dry run complete (no changes made)")
} else {
fmt.Println("→ Exporting to JSONL (redirected clone, skipping git operations)...")
if err := exportToJSONL(ctx, jsonlPath); err != nil {
FatalError("exporting: %v", err)
}
fmt.Println("✓ Export complete (target repo owner handles git sync)")
}
return
}
// Preflight: check for upstream tracking
// If no upstream, automatically switch to --from-main mode (gt-ick9: ephemeral branch support)
// GH#638: Skip this fallback if sync.branch is explicitly configured

View File

@@ -106,26 +106,42 @@ type RedirectInfo struct {
// GetRedirectInfo checks if the current beads directory is redirected.
// It searches for the local .beads/ directory and checks if it contains a redirect file.
// Returns RedirectInfo with IsRedirected=true if a redirect is active.
//
// bd-wayc3: This function now also checks the git repo's local .beads directory even when
// BEADS_DIR is set. This handles the case where BEADS_DIR is pre-set to the redirect target
// (e.g., by shell environment or tooling), but we still need to detect that a redirect exists.
func GetRedirectInfo() RedirectInfo {
info := RedirectInfo{}
// Find the local .beads directory without following redirects
localBeadsDir := findLocalBeadsDir()
if localBeadsDir == "" {
return info
// First, always check the git repo's local .beads directory for redirects
// This handles the case where BEADS_DIR is pre-set to the redirect target
if localBeadsDir := findLocalBdsDirInRepo(); localBeadsDir != "" {
if info := checkRedirectInDir(localBeadsDir); info.IsRedirected {
return info
}
}
info.LocalDir = localBeadsDir
// Fall back to original logic for non-git-repo cases
if localBeadsDir := findLocalBeadsDir(); localBeadsDir != "" {
return checkRedirectInDir(localBeadsDir)
}
return RedirectInfo{}
}
// checkRedirectInDir checks if a beads directory has a redirect file and returns redirect info.
// Returns RedirectInfo with IsRedirected=true if a valid redirect exists.
func checkRedirectInDir(beadsDir string) RedirectInfo {
info := RedirectInfo{LocalDir: beadsDir}
// Check if this directory has a redirect file
redirectFile := filepath.Join(localBeadsDir, RedirectFileName)
redirectFile := filepath.Join(beadsDir, RedirectFileName)
if _, err := os.Stat(redirectFile); err != nil {
// No redirect file
return info
}
// There's a redirect - find the target
targetDir := FollowRedirect(localBeadsDir)
if targetDir == localBeadsDir {
targetDir := FollowRedirect(beadsDir)
if targetDir == beadsDir {
// Redirect file exists but failed to resolve (invalid target)
return info
}
@@ -135,6 +151,24 @@ func GetRedirectInfo() RedirectInfo {
return info
}
// findLocalBdsDirInRepo finds the .beads directory relative to the git repo root.
// This ignores BEADS_DIR to find the "true local" .beads for redirect detection.
// bd-wayc3: Added to detect redirects even when BEADS_DIR is pre-set.
func findLocalBdsDirInRepo() string {
// Get git repo root
repoRoot := git.GetRepoRoot()
if repoRoot == "" {
return ""
}
beadsDir := filepath.Join(repoRoot, ".beads")
if info, err := os.Stat(beadsDir); err == nil && info.IsDir() {
return beadsDir
}
return ""
}
// findLocalBeadsDir finds the local .beads directory without following redirects.
// This is used to detect if a redirect is configured.
func findLocalBeadsDir() string {