When multiple clones commit to beads-sync branch and histories diverge, git merge would fail. This replaces git's commit-level merge with a content-based merge that extracts JSONL from base/local/remote and merges at the semantic level. Key changes: - Add divergence detection using git rev-list --left-right - Extract JSONL content from specific commits for 3-way merge - Reset to remote's history then commit merged content on top - Pre-emptive fetch before commit to reduce divergence likelihood - Deletions.jsonl merged by union (keeps all deletions) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
7.3 KiB
BD-3S8: Multi-Clone Sync Fix
Problem
When multiple clones of a repository both commit to the beads-sync branch and one tries to pull, git's merge would fail due to diverged histories. This made multi-clone workflows unreliable.
Solution
Replace git's commit-level merge with a content-based merge that handles divergence gracefully:
- Fetch (not pull) from remote
- Detect divergence using
git rev-list --left-right --count - Extract JSONL from merge base, local HEAD, and remote
- Merge content using bd's 3-way merge algorithm
- Reset to remote's history (adopt their commit graph)
- Commit merged content on top
This ensures sync never fails due to git merge conflicts - we handle merging at the JSONL content level where we have semantic understanding of the data.
Changes
internal/syncbranch/worktree.go
New functions:
getDivergence()- Detects how many commits local/remote are ahead/behindperformContentMerge()- Extracts and merges JSONL content from base/local/remoteperformDeletionsMerge()- Merges deletions.jsonl by union (keeps all deletions)extractJSONLFromCommit()- Extracts file content from a specific git commitcopyJSONLToMainRepo()- Refactored helper for copying JSONL filespreemptiveFetchAndFastForward()- Reduces divergence by fetching before commit
Modified functions:
PullFromSyncBranch()- Now handles three cases:- Already up-to-date: Remote has nothing new
- Fast-forward: Simple
--ff-onlymerge - Diverged: Content-based merge (the fix)
CommitToSyncBranch()- Now fetches and fast-forwards before committing
Enhanced structs:
PullResult- AddedMergedandFastForwardedfields
cmd/bd/sync.go
- Updated output messages to show merge type (fast-forward vs merged divergent histories)
internal/syncbranch/worktree_divergence_test.go (new file)
Test coverage for:
getDivergence()- 4 scenariosextractJSONLFromCommit()- 3 scenariosperformContentMerge()- 2 scenariosperformDeletionsMerge()- 2 scenarios
How It Works
Clone A commits and pushes: origin/beads-sync = A -- B -- C
Clone B commits locally: local beads-sync = A -- B -- D
When Clone B syncs:
1. Fetch: gets C from origin
2. Detect divergence: local ahead 1, remote ahead 1
3. Find merge base: B
4. Extract: base=B's JSONL, local=D's JSONL, remote=C's JSONL
5. Content merge: merge JSONL using 3-way algorithm
6. Reset to origin: beads-sync = A -- B -- C
7. Commit merged: beads-sync = A -- B -- C -- M (merged content)
8. Push: no conflict, linear history
Merge Rules
The 3-way merge uses these rules (from internal/merge/merge.go):
- New issues: Added from both sides
- Deleted issues: Deletion wins over modification
- Modified issues: Field-level merge
status: "closed" always wins over "open"updated_at: Takes the max (latest)closed_at: Only set if status is "closed"dependencies: Union of both sides- Other fields: Standard 3-way merge
Edge Cases Handled
- Remote branch doesn't exist - Nothing to pull, return early
- No common ancestor - Use empty base for merge
- File doesn't exist in commit - Use empty content
- Deletions.jsonl missing - Non-fatal, skip deletion merge
- True conflicts - Currently fails with error (manual resolution required)
Future Improvements
1. Auto-Resolve All Conflicts (No Manual Resolution Required)
Currently, true conflicts (both sides changed same field to different values) fail the sync. This should be changed to auto-resolve deterministically:
| Field | Auto-Resolution Strategy |
|---|---|
updated_at |
Already handled - takes max (latest) |
closed_at |
Already handled - takes max (latest) |
status |
Already handled - "closed" wins |
Priority |
Take higher priority (lower number = more urgent) |
IssueType |
Take left (local wins) |
Notes |
Concatenate both with separator (preserves all contributions) |
Title |
Take from side with latest updated_at on the issue |
Description |
Take from side with latest updated_at on the issue |
With this strategy, no conflicts ever require manual resolution - there's always a deterministic auto-resolution. The merge driver becomes fully automatic.
2. Auto-Push After Merge (Default Behavior)
Users shouldn't need to review merge diffs on beads metadata. The goal is "one command that just works":
bd sync # Should handle everything, including push
Proposed behavior:
- After successful content merge, auto-push by default
- Only hold off on push when unsafe conditions detected
Safety checks before auto-push:
- No conflict markers in JSONL (shouldn't happen with full auto-resolve)
- Issue count sanity check - didn't drop to zero unexpectedly
- Reasonable deletion threshold - didn't delete > N% of issues in one sync
The deletions manifest problem:
- In multi-clone environments, deletions from one clone propagate to others
- This is correct behavior, but can feel like "corruption" when unexpected
- Swarms legitimately close/delete all issues sometimes
- Hard to distinguish "swarm finished all work" from "corruption"
Proposed safeguards:
- Track whether issues were closed (status change) vs deleted (removed from JSONL)
- Closing all issues = legitimate (swarm finished)
- Deleting all issues when there were many = suspicious, pause for confirmation
- Config option:
sync.auto_push(default: true, can set to false for paranoid mode)
Integration with bd doctor:
bd doctor --fixshould also run this recovery logic- But
bd doctoris for daily/upgrade maintenance, not inner loop bd syncmust handle divergence recovery itself
The "one nuclear fix" philosophy:
bd syncshould just work 99.9% of the time- Auto-resolve all conflicts
- Auto-push when safe
- Only fail/pause when genuinely dangerous (mass deletion detected)
3. V1 Implementation Plan
Keep it simple for the first iteration:
Auto-push behavior:
- After successful content merge, auto-push by default
- One safety check: if issue count dropped by >50% AND there were >5 issues before, log a warning but still push
- Config option
sync.require_confirmation_on_mass_delete(default: false) for paranoid users who want to be prompted
Rationale:
- Logging gives forensics if something goes wrong
- Doesn't block the happy path (99.9% of syncs)
- Users who've been burned can enable confirmation mode
- We can tighten safeguards later based on real-world feedback
What "mass deletion" means:
- Issues that vanished from
issues.jsonl(not just closed) status=closedis fine - swarm finished legitimately- Issues disappearing entirely is suspicious
Future safeguards (not v1):
- Tombstone TTL: Ignore deletions older than N days
- Deletion rate limit: Pause if deletions.jsonl suddenly has 100+ new entries
- Protected issues: Certain issues can't be deleted via sync
Summary of Work Items
-
Already implemented (this PR):
- Content-based merge for diverged histories
- Pre-emptive fetch before commit
- Deletions.jsonl merge
- Fast-forward detection
-
Still to implement:
- Auto-resolve all field conflicts (no manual resolution)
- Auto-push after merge with safety check
- Mass deletion warning/logging
- Config option for confirmation mode