Files
beads/BD-3S8-CHANGES.md
Steve Yegge 875c55c2dc fix(sync): handle diverged histories with content-based merge (bd-3s8)
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>
2025-12-02 18:25:56 -08:00

191 lines
7.3 KiB
Markdown

# 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:
1. **Fetch** (not pull) from remote
2. **Detect divergence** using `git rev-list --left-right --count`
3. **Extract JSONL** from merge base, local HEAD, and remote
4. **Merge content** using bd's 3-way merge algorithm
5. **Reset to remote's history** (adopt their commit graph)
6. **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/behind
- `performContentMerge()` - Extracts and merges JSONL content from base/local/remote
- `performDeletionsMerge()` - Merges deletions.jsonl by union (keeps all deletions)
- `extractJSONLFromCommit()` - Extracts file content from a specific git commit
- `copyJSONLToMainRepo()` - Refactored helper for copying JSONL files
- `preemptiveFetchAndFastForward()` - 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-only` merge
- **Diverged**: Content-based merge (the fix)
- `CommitToSyncBranch()` - Now fetches and fast-forwards before committing
**Enhanced structs:**
- `PullResult` - Added `Merged` and `FastForwarded` fields
### `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 scenarios
- `extractJSONLFromCommit()` - 3 scenarios
- `performContentMerge()` - 2 scenarios
- `performDeletionsMerge()` - 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
1. **Remote branch doesn't exist** - Nothing to pull, return early
2. **No common ancestor** - Use empty base for merge
3. **File doesn't exist in commit** - Use empty content
4. **Deletions.jsonl missing** - Non-fatal, skip deletion merge
5. **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:**
1. No conflict markers in JSONL (shouldn't happen with full auto-resolve)
2. Issue count sanity check - didn't drop to zero unexpectedly
3. 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 --fix` should also run this recovery logic
- But `bd doctor` is for daily/upgrade maintenance, not inner loop
- `bd sync` must handle divergence recovery itself
**The "one nuclear fix" philosophy:**
- `bd sync` should 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:**
1. After successful content merge, auto-push by default
2. One safety check: if issue count dropped by >50% AND there were >5 issues before, log a warning but still push
3. 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=closed` is 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
1. **Already implemented (this PR):**
- Content-based merge for diverged histories
- Pre-emptive fetch before commit
- Deletions.jsonl merge
- Fast-forward detection
2. **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