From de75e5181c458f21a64d25c5d8e99c04a1ff7686 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Tue, 16 Dec 2025 01:07:02 -0800 Subject: [PATCH] feat(sync): improve divergence recovery UX (bd-vckm) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When sync branch diverges significantly from remote, provide clear recovery options instead of a confusing rebase conflict error. - Add CheckDivergence() to detect and report sync branch divergence - Add ResetToRemote() to reset local sync branch to remote state - Add --reset-remote and --force-push flags for recovery - Improve error message when rebase fails to include recovery steps 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- cmd/bd/sync.go | 138 +++++++++++++++++++++++++++++ internal/syncbranch/worktree.go | 149 +++++++++++++++++++++++++++++++- 2 files changed, 285 insertions(+), 2 deletions(-) diff --git a/cmd/bd/sync.go b/cmd/bd/sync.go index 5f0df33b..341c4932 100644 --- a/cmd/bd/sync.go +++ b/cmd/bd/sync.go @@ -58,6 +58,9 @@ 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") + // Recovery options (bd-vckm) + resetRemote, _ := cmd.Flags().GetBool("reset-remote") + forcePush, _ := cmd.Flags().GetBool("force-push") // bd-sync-corruption fix: Force direct mode for sync operations. // This prevents stale daemon SQLite connections from corrupting exports. @@ -108,6 +111,15 @@ Use --merge to merge the sync branch back to main branch.`, return } + // Handle recovery options (bd-vckm) + if resetRemote || forcePush { + if err := handleSyncRecovery(ctx, jsonlPath, resetRemote, forcePush, renameOnImport, noGitHistory, dryRun); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + return + } + // If import-only mode, just import and exit if importOnly { if dryRun { @@ -768,6 +780,9 @@ 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") + // Recovery options for diverged sync branches (bd-vckm) + syncCmd.Flags().Bool("reset-remote", false, "Reset local sync branch to remote state (discards local sync changes)") + syncCmd.Flags().Bool("force-push", false, "Force push local sync branch to remote (overwrites remote)") rootCmd.AddCommand(syncCmd) } @@ -1890,6 +1905,129 @@ func resolveNoGitHistoryForFromMain(fromMain, noGitHistory bool) bool { return noGitHistory } +// handleSyncRecovery handles --reset-remote and --force-push recovery options (bd-vckm) +// These are used when the sync branch has diverged significantly from remote. +func handleSyncRecovery(ctx context.Context, jsonlPath string, resetRemote, forcePush, renameOnImport, noGitHistory, dryRun bool) error { + // Check if sync.branch is configured + if err := ensureStoreActive(); err != nil { + return fmt.Errorf("failed to initialize store: %w", err) + } + + syncBranchName, err := syncbranch.Get(ctx, store) + if err != nil { + return fmt.Errorf("failed to get sync branch config: %w", err) + } + + if syncBranchName == "" { + return fmt.Errorf("sync.branch not configured - recovery options only apply to sync branch mode\nRun 'bd config set sync.branch ' to configure") + } + + repoRoot, err := syncbranch.GetRepoRoot(ctx) + if err != nil { + return fmt.Errorf("failed to get repo root: %w", err) + } + + // Check current divergence + divergence, err := syncbranch.CheckDivergence(ctx, repoRoot, syncBranchName) + if err != nil { + return fmt.Errorf("failed to check divergence: %w", err) + } + + fmt.Printf("Sync branch '%s' status:\n", syncBranchName) + fmt.Printf(" Local ahead: %d commits\n", divergence.LocalAhead) + fmt.Printf(" Remote ahead: %d commits\n", divergence.RemoteAhead) + if divergence.IsDiverged { + fmt.Println(" Status: DIVERGED") + } else if divergence.LocalAhead > 0 { + fmt.Println(" Status: Local ahead of remote") + } else if divergence.RemoteAhead > 0 { + fmt.Println(" Status: Remote ahead of local") + } else { + fmt.Println(" Status: In sync") + } + fmt.Println() + + if resetRemote { + if dryRun { + fmt.Println("[DRY RUN] Would reset local sync branch to remote state") + fmt.Printf(" This would discard %d local commit(s)\n", divergence.LocalAhead) + return nil + } + + if divergence.LocalAhead == 0 { + fmt.Println("Nothing to reset - local is not ahead of remote") + return nil + } + + fmt.Printf("Resetting sync branch '%s' to remote state...\n", syncBranchName) + fmt.Printf(" This will discard %d local commit(s)\n", divergence.LocalAhead) + + if err := syncbranch.ResetToRemote(ctx, repoRoot, syncBranchName, jsonlPath); err != nil { + return fmt.Errorf("reset to remote failed: %w", err) + } + + fmt.Println("✓ Reset complete - local sync branch now matches remote") + + // Import the JSONL to update the database + fmt.Println("→ Importing JSONL to update database...") + if err := importFromJSONL(ctx, jsonlPath, renameOnImport, noGitHistory); err != nil { + return fmt.Errorf("import after reset failed: %w", err) + } + fmt.Println("✓ Import complete") + return nil + } + + if forcePush { + if dryRun { + fmt.Println("[DRY RUN] Would force push local sync branch to remote") + fmt.Printf(" This would overwrite %d remote commit(s)\n", divergence.RemoteAhead) + return nil + } + + if divergence.RemoteAhead == 0 && divergence.LocalAhead == 0 { + fmt.Println("Nothing to force push - already in sync") + return nil + } + + fmt.Printf("Force pushing sync branch '%s' to remote...\n", syncBranchName) + if divergence.RemoteAhead > 0 { + fmt.Printf(" This will overwrite %d remote commit(s)\n", divergence.RemoteAhead) + } + + if err := forcePushSyncBranch(ctx, repoRoot, syncBranchName); err != nil { + return fmt.Errorf("force push failed: %w", err) + } + + fmt.Println("✓ Force push complete - remote now matches local") + return nil + } + + return nil +} + +// forcePushSyncBranch force pushes the local sync branch to remote (bd-vckm) +// This is used when you want to overwrite the remote state with local state. +func forcePushSyncBranch(ctx context.Context, repoRoot, syncBranch string) error { + // Worktree path is under .git/beads-worktrees/ + worktreePath := filepath.Join(repoRoot, ".git", "beads-worktrees", syncBranch) + + // Get remote name + remoteCmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "config", "--get", fmt.Sprintf("branch.%s.remote", syncBranch)) + remoteOutput, err := remoteCmd.Output() + remote := "origin" + if err == nil { + remote = strings.TrimSpace(string(remoteOutput)) + } + + // Force push + pushCmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "push", "--force", remote, syncBranch) + if output, err := pushCmd.CombinedOutput(); err != nil { + return fmt.Errorf("git push --force failed: %w\n%s", err, output) + } + + return nil +} + // isExternalBeadsDir checks if the beads directory is in a different git repo than cwd. // This is used to detect when BEADS_DIR points to a separate repository. // Contributed by dand-oss (https://github.com/steveyegge/beads/pull/533) diff --git a/internal/syncbranch/worktree.go b/internal/syncbranch/worktree.go index de272575..2f4bf062 100644 --- a/internal/syncbranch/worktree.go +++ b/internal/syncbranch/worktree.go @@ -24,6 +24,20 @@ type CommitResult struct { Message string // Commit message used } +// DivergenceInfo contains information about sync branch divergence from remote +type DivergenceInfo struct { + LocalAhead int // Number of commits local is ahead of remote + RemoteAhead int // Number of commits remote is ahead of local + Branch string // The sync branch name + Remote string // The remote name (e.g., "origin") + IsDiverged bool // True if both local and remote have commits the other doesn't + IsSignificant bool // True if divergence exceeds threshold (suggests recovery needed) +} + +// SignificantDivergenceThreshold is the number of commits at which divergence is considered significant +// When both local and remote are ahead by at least this many commits, the user should consider recovery options +const SignificantDivergenceThreshold = 5 + // PullResult contains information about a worktree pull operation type PullResult struct { Pulled bool // True if pull was performed @@ -455,6 +469,119 @@ func getDivergence(ctx context.Context, worktreePath, branch, remote string) (in return localAhead, remoteAhead, nil } +// CheckDivergence checks the divergence between local sync branch and remote. +// This should be called before attempting sync operations to detect significant divergence +// that may require user intervention. +// +// Parameters: +// - ctx: Context for cancellation +// - repoRoot: Path to the git repository root +// - syncBranch: Name of the sync branch (e.g., "beads-sync") +// +// Returns DivergenceInfo with details about the divergence, or error if check fails. +func CheckDivergence(ctx context.Context, repoRoot, syncBranch string) (*DivergenceInfo, error) { + info := &DivergenceInfo{ + Branch: syncBranch, + } + + // Worktree path is under .git/beads-worktrees/ + worktreePath := filepath.Join(repoRoot, ".git", "beads-worktrees", syncBranch) + + // Initialize worktree manager + wtMgr := git.NewWorktreeManager(repoRoot) + + // Ensure worktree exists + if err := wtMgr.CreateBeadsWorktree(syncBranch, worktreePath); err != nil { + return nil, fmt.Errorf("failed to create worktree: %w", err) + } + + // Get remote name + remote := getRemoteForBranch(ctx, worktreePath, syncBranch) + info.Remote = remote + + // Fetch from remote to get latest state + fetchCmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "fetch", remote, syncBranch) + if output, err := fetchCmd.CombinedOutput(); err != nil { + // Check if remote branch doesn't exist yet (first sync) + if strings.Contains(string(output), "couldn't find remote ref") { + // Remote branch doesn't exist - no divergence possible + return info, nil + } + return nil, fmt.Errorf("git fetch failed: %w\n%s", err, output) + } + + // Check for divergence + localAhead, remoteAhead, err := getDivergence(ctx, worktreePath, syncBranch, remote) + if err != nil { + return nil, fmt.Errorf("failed to check divergence: %w", err) + } + + info.LocalAhead = localAhead + info.RemoteAhead = remoteAhead + info.IsDiverged = localAhead > 0 && remoteAhead > 0 + + // Significant divergence: both sides have many commits + // This suggests automatic merge may be problematic + if info.IsDiverged && (localAhead >= SignificantDivergenceThreshold || remoteAhead >= SignificantDivergenceThreshold) { + info.IsSignificant = true + } + + return info, nil +} + +// ResetToRemote resets the local sync branch to match the remote state. +// This discards all local commits on the sync branch and adopts the remote's history. +// Use this when the sync branch has diverged significantly and you want to discard local changes. +// +// Parameters: +// - ctx: Context for cancellation +// - repoRoot: Path to the git repository root +// - syncBranch: Name of the sync branch (e.g., "beads-sync") +// - jsonlPath: Path to the JSONL file in the main repo (will be updated with remote content) +// +// Returns error if reset fails. +func ResetToRemote(ctx context.Context, repoRoot, syncBranch, jsonlPath string) error { + // Worktree path is under .git/beads-worktrees/ + worktreePath := filepath.Join(repoRoot, ".git", "beads-worktrees", syncBranch) + + // Initialize worktree manager + wtMgr := git.NewWorktreeManager(repoRoot) + + // Ensure worktree exists + if err := wtMgr.CreateBeadsWorktree(syncBranch, worktreePath); err != nil { + return fmt.Errorf("failed to create worktree: %w", err) + } + + // Get remote name + remote := getRemoteForBranch(ctx, worktreePath, syncBranch) + + // Fetch from remote to get latest state + fetchCmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "fetch", remote, syncBranch) + if output, err := fetchCmd.CombinedOutput(); err != nil { + return fmt.Errorf("git fetch failed: %w\n%s", err, output) + } + + // Reset worktree to remote's state + resetCmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "reset", "--hard", + fmt.Sprintf("%s/%s", remote, syncBranch)) + if output, err := resetCmd.CombinedOutput(); err != nil { + return fmt.Errorf("git reset failed: %w\n%s", err, output) + } + + // Convert absolute path to relative path from repo root + jsonlRelPath, err := filepath.Rel(repoRoot, jsonlPath) + if err != nil { + return fmt.Errorf("failed to get relative JSONL path: %w", err) + } + + // Copy JSONL from worktree to main repo + if err := copyJSONLToMainRepo(worktreePath, jsonlRelPath, jsonlPath); err != nil { + return err + } + + return nil +} + // performContentMerge extracts JSONL from base, local, and remote, then merges content. // Returns the merged JSONL content. func performContentMerge(ctx context.Context, worktreePath, branch, remote, jsonlRelPath string) ([]byte, error) { @@ -741,8 +868,26 @@ func pushFromWorktree(ctx context.Context, worktreePath, branch string) error { if isNonFastForwardError(outputStr) { // Attempt fetch + rebase to get ahead of remote if rebaseErr := fetchAndRebaseInWorktree(ctx, worktreePath, branch, remote); rebaseErr != nil { - // Rebase failed - return original push error with context - return fmt.Errorf("push failed and recovery rebase also failed: push: %w; rebase: %v", lastErr, rebaseErr) + // Rebase failed - provide clear recovery options (bd-vckm) + return fmt.Errorf(`sync branch diverged and automatic recovery failed + +The sync branch '%s' has diverged from remote '%s/%s' and automatic rebase failed. + +Recovery options: + 1. Reset to remote state (discard local sync changes): + bd sync --reset-remote + + 2. Force push local state to remote (overwrites remote): + bd sync --force-push + + 3. Manual recovery in the sync branch worktree: + cd .git/beads-worktrees/%s + git status + # Resolve conflicts manually, then: + bd sync + +Original error: %v +Rebase error: %v`, branch, remote, branch, branch, lastErr, rebaseErr) } // Rebase succeeded - retry push immediately (no backoff needed) continue