feat(sync): improve divergence recovery UX (bd-vckm)
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 <noreply@anthropic.com>
This commit is contained in:
138
cmd/bd/sync.go
138
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 <branch-name>' 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/<branch>
|
||||
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)
|
||||
|
||||
@@ -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/<branch>
|
||||
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/<branch>
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user