From e3b6091931d4d05ded742d1cdc26e4c009ac98c6 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Tue, 16 Dec 2025 01:13:23 -0800 Subject: [PATCH] feat(sync): add --check flag for pre-sync integrity checks (bd-hlsw.1) Add bd sync --check command that performs pre-sync integrity checks without modifying state: 1. Force push detection: Detects when sync branch has diverged from remote, indicating a potential force push 2. Prefix mismatch detection: Scans JSONL for issues that don't match the configured prefix 3. Orphaned children detection: Finds issues with parent references to non-existent issues Outputs diagnostic with actionable suggestions for each problem found. Exits with code 1 if any problems are detected. Implements bd-hlsw.1: Pre-sync integrity check --- cmd/bd/sync.go | 370 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 370 insertions(+) diff --git a/cmd/bd/sync.go b/cmd/bd/sync.go index 1aad4b82..479002d4 100644 --- a/cmd/bd/sync.go +++ b/cmd/bd/sync.go @@ -58,6 +58,7 @@ Use --merge to merge the sync branch back to main branch.`, fromMain, _ := cmd.Flags().GetBool("from-main") noGitHistory, _ := cmd.Flags().GetBool("no-git-history") squash, _ := cmd.Flags().GetBool("squash") + checkIntegrity, _ := cmd.Flags().GetBool("check") // bd-sync-corruption fix: Force direct mode for sync operations. // This prevents stale daemon SQLite connections from corrupting exports. @@ -90,6 +91,15 @@ Use --merge to merge the sync branch back to main branch.`, return } + // If check mode, run pre-sync integrity checks (bd-hlsw.1) + if checkIntegrity { + if err := showSyncIntegrityCheck(ctx, jsonlPath); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + return + } + // If merge mode, merge sync branch to main if merge { if err := mergeSyncBranch(ctx, dryRun); err != nil { @@ -794,6 +804,7 @@ func init() { syncCmd.Flags().Bool("from-main", false, "One-way sync from main branch (for ephemeral branches without upstream)") syncCmd.Flags().Bool("no-git-history", false, "Skip git history backfill for deletions (use during JSONL filename migrations)") syncCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output sync statistics in JSON format") + syncCmd.Flags().Bool("check", false, "Pre-sync integrity check: detect forced pushes, prefix mismatches, and orphaned issues") rootCmd.AddCommand(syncCmd) } @@ -2018,3 +2029,362 @@ func pullFromExternalBeadsRepo(ctx context.Context, beadsDir string) error { return nil } + +// SyncIntegrityResult contains the results of a pre-sync integrity check. +// bd-hlsw.1: Pre-sync integrity check +type SyncIntegrityResult struct { + ForcedPush *ForcedPushCheck `json:"forced_push,omitempty"` + PrefixMismatch *PrefixMismatch `json:"prefix_mismatch,omitempty"` + OrphanedChildren *OrphanedChildren `json:"orphaned_children,omitempty"` + HasProblems bool `json:"has_problems"` +} + +// ForcedPushCheck detects if sync branch has diverged from remote. +type ForcedPushCheck struct { + Detected bool `json:"detected"` + LocalRef string `json:"local_ref,omitempty"` + RemoteRef string `json:"remote_ref,omitempty"` + Message string `json:"message"` +} + +// PrefixMismatch detects issues with wrong prefix in JSONL. +type PrefixMismatch struct { + ConfiguredPrefix string `json:"configured_prefix"` + MismatchedIDs []string `json:"mismatched_ids,omitempty"` + Count int `json:"count"` +} + +// OrphanedChildren detects issues with parent that doesn't exist. +type OrphanedChildren struct { + OrphanedIDs []string `json:"orphaned_ids,omitempty"` + Count int `json:"count"` +} + +// showSyncIntegrityCheck performs pre-sync integrity checks without modifying state. +// bd-hlsw.1: Detects forced pushes, prefix mismatches, and orphaned children. +func showSyncIntegrityCheck(ctx context.Context, jsonlPath string) error { + fmt.Println("Sync Integrity Check") + fmt.Println("====================\n") + + result := &SyncIntegrityResult{} + + // Check 1: Detect forced pushes on sync branch + forcedPush := checkForcedPush(ctx) + result.ForcedPush = forcedPush + if forcedPush.Detected { + result.HasProblems = true + } + printForcedPushResult(forcedPush) + + // Check 2: Detect prefix mismatches in JSONL + prefixMismatch, err := checkPrefixMismatch(ctx, jsonlPath) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: prefix check failed: %v\n", err) + } else { + result.PrefixMismatch = prefixMismatch + if prefixMismatch != nil && prefixMismatch.Count > 0 { + result.HasProblems = true + } + printPrefixMismatchResult(prefixMismatch) + } + + // Check 3: Detect orphaned children (parent issues that don't exist) + orphaned, err := checkOrphanedChildrenInJSONL(jsonlPath) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: orphaned check failed: %v\n", err) + } else { + result.OrphanedChildren = orphaned + if orphaned != nil && orphaned.Count > 0 { + result.HasProblems = true + } + printOrphanedChildrenResult(orphaned) + } + + // Summary + fmt.Println("\nSummary") + fmt.Println("-------") + if result.HasProblems { + fmt.Println("Problems detected! Review above and consider:") + if result.ForcedPush != nil && result.ForcedPush.Detected { + fmt.Println(" - Force push: Reset local sync branch or use 'bd sync --from-main'") + } + if result.PrefixMismatch != nil && result.PrefixMismatch.Count > 0 { + fmt.Println(" - Prefix mismatch: Use 'bd import --rename-on-import' to fix") + } + if result.OrphanedChildren != nil && result.OrphanedChildren.Count > 0 { + fmt.Println(" - Orphaned children: Remove parent references or create missing parents") + } + os.Exit(1) + } else { + fmt.Println("No problems detected. Safe to sync.") + } + + if jsonOutput { + data, _ := json.MarshalIndent(result, "", " ") + fmt.Println(string(data)) + } + + return nil +} + +// checkForcedPush detects if the sync branch has diverged from remote. +// This can happen when someone force-pushes to the sync branch. +func checkForcedPush(ctx context.Context) *ForcedPushCheck { + result := &ForcedPushCheck{ + Detected: false, + Message: "No sync branch configured or no remote", + } + + // Get sync branch name + if err := ensureStoreActive(); err != nil { + return result + } + + syncBranch, _ := syncbranch.Get(ctx, store) + if syncBranch == "" { + return result + } + + // Check if sync branch exists locally + checkLocalCmd := exec.CommandContext(ctx, "git", "show-ref", "--verify", "--quiet", "refs/heads/"+syncBranch) + if checkLocalCmd.Run() != nil { + result.Message = fmt.Sprintf("Sync branch '%s' does not exist locally", syncBranch) + return result + } + + // Get local ref + localRefCmd := exec.CommandContext(ctx, "git", "rev-parse", syncBranch) + localRefOutput, err := localRefCmd.Output() + if err != nil { + result.Message = "Failed to get local sync branch ref" + return result + } + localRef := strings.TrimSpace(string(localRefOutput)) + result.LocalRef = localRef + + // Check if remote tracking branch exists + remote := "origin" + if configuredRemote, err := store.GetConfig(ctx, "sync.remote"); err == nil && configuredRemote != "" { + remote = configuredRemote + } + + // Get remote ref + remoteRefCmd := exec.CommandContext(ctx, "git", "rev-parse", remote+"/"+syncBranch) + remoteRefOutput, err := remoteRefCmd.Output() + if err != nil { + result.Message = fmt.Sprintf("Remote tracking branch '%s/%s' does not exist", remote, syncBranch) + return result + } + remoteRef := strings.TrimSpace(string(remoteRefOutput)) + result.RemoteRef = remoteRef + + // If refs match, no divergence + if localRef == remoteRef { + result.Message = "Sync branch is in sync with remote" + return result + } + + // Check if local is ahead of remote (normal case) + aheadCmd := exec.CommandContext(ctx, "git", "merge-base", "--is-ancestor", remoteRef, localRef) + if aheadCmd.Run() == nil { + result.Message = "Local sync branch is ahead of remote (normal)" + return result + } + + // Check if remote is ahead of local (behind, needs pull) + behindCmd := exec.CommandContext(ctx, "git", "merge-base", "--is-ancestor", localRef, remoteRef) + if behindCmd.Run() == nil { + result.Message = "Local sync branch is behind remote (needs pull)" + return result + } + + // If neither is ancestor, branches have diverged - likely a force push + result.Detected = true + result.Message = fmt.Sprintf("Sync branch has DIVERGED from remote! Local: %s, Remote: %s. This may indicate a force push on the remote.", localRef[:8], remoteRef[:8]) + + return result +} + +func printForcedPushResult(fp *ForcedPushCheck) { + fmt.Println("1. Force Push Detection") + if fp.Detected { + fmt.Printf(" [PROBLEM] %s\n", fp.Message) + } else { + fmt.Printf(" [OK] %s\n", fp.Message) + } + fmt.Println() +} + +// checkPrefixMismatch detects issues in JSONL that don't match the configured prefix. +func checkPrefixMismatch(ctx context.Context, jsonlPath string) (*PrefixMismatch, error) { + result := &PrefixMismatch{ + MismatchedIDs: []string{}, + } + + // Get configured prefix + if err := ensureStoreActive(); err != nil { + return nil, err + } + + prefix, err := store.GetConfig(ctx, "issue_prefix") + if err != nil || prefix == "" { + prefix = "bd" // Default + } + result.ConfiguredPrefix = prefix + + // Read JSONL and check each issue's prefix + f, err := os.Open(jsonlPath) // #nosec G304 - controlled path + if err != nil { + if os.IsNotExist(err) { + return result, nil // No JSONL, no mismatches + } + return nil, fmt.Errorf("failed to open JSONL: %w", err) + } + defer f.Close() + + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024) + + for scanner.Scan() { + line := scanner.Bytes() + if len(bytes.TrimSpace(line)) == 0 { + continue + } + + var issue struct { + ID string `json:"id"` + } + if err := json.Unmarshal(line, &issue); err != nil { + continue // Skip malformed lines + } + + // Check if ID starts with configured prefix + if !strings.HasPrefix(issue.ID, prefix+"-") { + result.MismatchedIDs = append(result.MismatchedIDs, issue.ID) + } + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("failed to read JSONL: %w", err) + } + + result.Count = len(result.MismatchedIDs) + return result, nil +} + +func printPrefixMismatchResult(pm *PrefixMismatch) { + fmt.Println("2. Prefix Mismatch Check") + if pm == nil { + fmt.Println(" [SKIP] Could not check prefix") + fmt.Println() + return + } + + fmt.Printf(" Configured prefix: %s\n", pm.ConfiguredPrefix) + if pm.Count > 0 { + fmt.Printf(" [PROBLEM] Found %d issue(s) with wrong prefix:\n", pm.Count) + // Show first 10 + limit := pm.Count + if limit > 10 { + limit = 10 + } + for i := 0; i < limit; i++ { + fmt.Printf(" - %s\n", pm.MismatchedIDs[i]) + } + if pm.Count > 10 { + fmt.Printf(" ... and %d more\n", pm.Count-10) + } + } else { + fmt.Println(" [OK] All issues have correct prefix") + } + fmt.Println() +} + +// checkOrphanedChildrenInJSONL detects issues with parent references to non-existent issues. +func checkOrphanedChildrenInJSONL(jsonlPath string) (*OrphanedChildren, error) { + result := &OrphanedChildren{ + OrphanedIDs: []string{}, + } + + // Read JSONL and build maps of IDs and parent references + f, err := os.Open(jsonlPath) // #nosec G304 - controlled path + if err != nil { + if os.IsNotExist(err) { + return result, nil + } + return nil, fmt.Errorf("failed to open JSONL: %w", err) + } + defer f.Close() + + existingIDs := make(map[string]bool) + parentRefs := make(map[string]string) // child ID -> parent ID + + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024) + + for scanner.Scan() { + line := scanner.Bytes() + if len(bytes.TrimSpace(line)) == 0 { + continue + } + + var issue struct { + ID string `json:"id"` + Parent string `json:"parent,omitempty"` + Status string `json:"status"` + } + if err := json.Unmarshal(line, &issue); err != nil { + continue + } + + // Skip tombstones + if issue.Status == string(types.StatusTombstone) { + continue + } + + existingIDs[issue.ID] = true + if issue.Parent != "" { + parentRefs[issue.ID] = issue.Parent + } + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("failed to read JSONL: %w", err) + } + + // Find orphaned children (parent doesn't exist) + for childID, parentID := range parentRefs { + if !existingIDs[parentID] { + result.OrphanedIDs = append(result.OrphanedIDs, fmt.Sprintf("%s (parent: %s)", childID, parentID)) + } + } + + result.Count = len(result.OrphanedIDs) + return result, nil +} + +func printOrphanedChildrenResult(oc *OrphanedChildren) { + fmt.Println("3. Orphaned Children Check") + if oc == nil { + fmt.Println(" [SKIP] Could not check orphaned children") + fmt.Println() + return + } + + if oc.Count > 0 { + fmt.Printf(" [PROBLEM] Found %d issue(s) with missing parent:\n", oc.Count) + limit := oc.Count + if limit > 10 { + limit = 10 + } + for i := 0; i < limit; i++ { + fmt.Printf(" - %s\n", oc.OrphanedIDs[i]) + } + if oc.Count > 10 { + fmt.Printf(" ... and %d more\n", oc.Count-10) + } + } else { + fmt.Println(" [OK] No orphaned children found") + } + fmt.Println() +}