Initial commit: ClawSync v0.1.0

8-crate pure-Rust workspace for revision-aware HDF5 sync.

## Crates
- clawhdf5-onion: ClawOnion VFD — page-level versioned HDF5 storage,
  binary format, writer/reader, branch DAG, GC, snapshots, provenance
- clawsync-core: BLAKE3, xxHash3, FastCDC (+ SIMD NEON), zstd/lz4
- clawsync-onion: IBLT sketch, Merkle tree differ, packet differ/merger,
  ClawSyncManifest, SyncSelector
- clawsync-hdf5: dataset-level manifest, differ, patcher, wire payload
  reconstruction (apply_received_payloads)
- clawsync-transport: TCP, QUIC (quinn 0.11/TLS 1.3), SyncPeer abstraction,
  length-prefixed rkyv wire protocol (21 SyncMessage variants)
- clawsync-agent: OnionMemory, SyncScheduler, TcpSyncBackend,
  PeerCapabilities negotiation
- clawsync-fs: CDC-based delta sync for any file type; FsSyncClient/Server,
  W=16 pipelining, atomic writes
- clawsync-cli: push/pull/serve/hdf5-sync/serve-hdf5/sync/serve-fs +
  all local management commands; --quic on all network commands

## Key features
- IBLT pre-flight: O(revision count) vs rsync's O(file size)
- W=16 sliding-window push: 13–15x speedup over stop-and-wait at WAN RTT
- Dataset-granular HDF5 sync: only modified datasets transferred
- CDC delta for any file type: insertion-stable chunk boundaries
- Full revision DAG: branch, merge, rollback, export, snapshot, GC
- QUIC transport: TLS 1.3, per-message streams via quinn 0.11

## Tests
~573 passing (default features); ~589 with --features simd-cdc

## Performance (Apple Silicon)
- Reconstruct rev=100: 68 µs (target ≤ 1 ms)
- BLAKE3 Rayon 1 MB: 10.3 GiB/s (target ≥ 5 GB/s)
- GC 500 revisions: 20.6 µs (target ≤ 2 s)
- W=16 vs W=1 at 5 ms RTT: 14.8x speedup
- No-op pre-flight at 16 MB: 4 ms vs rsync 35 ms (7.8x)

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
osobh
2026-04-04 18:41:22 -05:00
co-authored by Claude Sonnet 4.6
commit 260e15f5b6
102 changed files with 30098 additions and 0 deletions
+805
View File
@@ -0,0 +1,805 @@
//! Branch management: fork, merge, lifecycle operations on the DAG.
use std::collections::{BTreeMap, BTreeSet};
use crate::compress::decompress_page;
use crate::error::OnionError;
use crate::format::{BranchEntry, Codec, NO_PARENT};
use crate::writer::OnionFile;
/// Caller-supplied dataset-level merge resolver.
///
/// Receives `(dataset_path, target_bytes, source_bytes)` and returns the merged bytes.
pub type DatasetResolver = dyn Fn(&str, &[u8], &[u8]) -> Vec<u8>;
/// Merge strategy used when merging one branch into another.
pub enum MergeStrategy {
/// Page-level last-write-wins: the source branch's pages replace
/// the target branch's pages wherever they conflict.
LatestWins,
/// Dataset-level merge with a caller-supplied resolver function.
///
/// The resolver receives `(dataset_path, target_bytes, source_bytes)`
/// and returns the merged bytes.
DatasetLevel(Box<DatasetResolver>),
/// Three-way merge: find common ancestor, diff both sides against it.
ThreeWay,
}
/// Public summary of a branch.
#[derive(Debug, Clone)]
pub struct BranchInfo {
pub id: u32,
pub name: String,
pub head_rev: u64,
pub fork_rev: u64,
}
impl OnionFile {
// ── Fork ─────────────────────────────────────────────────────────────────
/// Create a new named branch forked from the current HEAD of `source_branch`.
///
/// Returns the new branch ID.
pub fn create_branch(
&mut self,
name: &str,
source_branch: &str,
) -> Result<u32, OnionError> {
if self.branch_by_name(name).is_some() {
return Err(OnionError::BranchExists(name.to_string()));
}
let source = self
.branch_by_name(source_branch)
.ok_or_else(|| OnionError::BranchNotFound(source_branch.to_string()))?;
let fork_rev = source.head_rev;
let new_id = self.branches.len() as u32;
let name_off = self.annotations.push(name);
let created_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs_f64())
.unwrap_or(0.0);
let entry = BranchEntry {
id: new_id,
_pad_id: [0u8; 4],
name_off,
head_rev: fork_rev, // new branch starts at same HEAD as source
fork_rev,
created_at,
};
self.branches.push(entry);
self.header.branch_count = self.branches.len() as u32;
Ok(new_id)
}
/// Register a branch received from a remote peer during sync.
///
/// If a branch with this ID already exists, returns its ID without
/// modification (idempotent). Used by the merger when applying packets
/// from branches the local file hasn't seen before.
pub fn ensure_branch_id(&mut self, branch_id: u32, fork_rev: u64) -> u32 {
if self.branch_by_id(branch_id).is_some() {
return branch_id;
}
let name = format!("branch-{branch_id}");
let name_off = self.annotations.push(&name);
let created_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs_f64())
.unwrap_or(0.0);
let entry = BranchEntry {
id: branch_id,
_pad_id: [0u8; 4],
name_off,
head_rev: fork_rev,
fork_rev,
created_at,
};
self.branches.push(entry);
self.header.branch_count = self.branches.len() as u32;
branch_id
}
// ── Merge ────────────────────────────────────────────────────────────────
/// Merge `source_branch` into `target_branch` using the given strategy.
///
/// Produces a new revision on `target_branch` and returns its revision number.
/// `LatestWins` is the only strategy fully implemented in Phase 1;
/// `DatasetLevel` and `ThreeWay` are scaffolded for Phase 3.
pub fn merge_into(
&mut self,
source_branch: &str,
target_branch: &str,
strategy: MergeStrategy,
) -> Result<u64, OnionError> {
let source_id = self
.branch_by_name(source_branch)
.ok_or_else(|| OnionError::BranchNotFound(source_branch.to_string()))?
.id;
let target_id = self
.branch_by_name(target_branch)
.ok_or_else(|| OnionError::BranchNotFound(target_branch.to_string()))?
.id;
match strategy {
MergeStrategy::LatestWins => self.merge_latest_wins(source_id, target_id),
MergeStrategy::DatasetLevel(resolver) => {
self.merge_dataset_level(source_id, target_id, resolver.as_ref())
}
MergeStrategy::ThreeWay => self.merge_three_way(source_id, target_id),
}
}
fn merge_latest_wins(
&mut self,
source_id: u32,
target_id: u32,
) -> Result<u64, OnionError> {
let fork_rev = self
.branches
.iter()
.find(|b| b.id == source_id)
.map(|b| b.fork_rev)
.unwrap_or(NO_PARENT);
// Revisions on the source branch after the fork point
let source_revs: Vec<u64> = self
.index
.branch_revisions(source_id)
.filter(|e| fork_rev == NO_PARENT || e.revision > fork_rev)
.map(|e| e.revision)
.collect();
if source_revs.is_empty() {
return Err(OnionError::Malformed(
"source branch has no new revisions to merge".to_string(),
));
}
// Decompress & collect pages in revision order — later writes win.
let mut merged: BTreeMap<u64, Vec<u8>> = BTreeMap::new();
for rev in &source_revs {
for (h5_off, page_bytes) in self.revision_pages(*rev)? {
merged.insert(h5_off, page_bytes);
}
}
// Commit the merged pages as a new revision on the target branch.
let mut session = self.begin_session(Some(target_id))?;
for (h5_off, bytes) in &merged {
session.record_page(*h5_off, bytes);
}
let annotation = format!("merge branch {source_id}{target_id} [latest-wins]");
self.commit_session(session, Some(&annotation))
}
fn merge_dataset_level(
&mut self,
source_id: u32,
target_id: u32,
resolver: &DatasetResolver,
) -> Result<u64, OnionError> {
let fork_rev = self
.branches
.iter()
.find(|b| b.id == source_id)
.map(|b| b.fork_rev)
.unwrap_or(NO_PARENT);
let source_revs: Vec<u64> = self
.index
.branch_revisions(source_id)
.filter(|e| fork_rev == NO_PARENT || e.revision > fork_rev)
.map(|e| e.revision)
.collect();
if source_revs.is_empty() {
return Err(OnionError::Malformed(
"source branch has no new revisions to merge".to_string(),
));
}
// Collect source delta (latest write per offset)
let mut source_delta: BTreeMap<u64, Vec<u8>> = BTreeMap::new();
for rev in &source_revs {
for (h5_off, bytes) in self.revision_pages(*rev)? {
source_delta.insert(h5_off, bytes);
}
}
// For each changed offset, find the target branch's current page
// (most recent write on target for that offset), then call the resolver.
let mut merged: BTreeMap<u64, Vec<u8>> = BTreeMap::new();
for (h5_off, source_bytes) in &source_delta {
let target_bytes = self
.branch_latest_page(target_id, *h5_off)
.unwrap_or_else(|| vec![0u8; source_bytes.len()]);
// Use the h5_offset as the "dataset path" key (real impl would map offsets to HDF5 paths)
let path = format!("page@{h5_off}");
let result = resolver(&path, &target_bytes, source_bytes);
merged.insert(*h5_off, result);
}
let mut session = self.begin_session(Some(target_id))?;
for (h5_off, bytes) in &merged {
session.record_page(*h5_off, bytes);
}
let annotation = format!("merge branch {source_id}{target_id} [dataset-level]");
self.commit_session(session, Some(&annotation))
}
fn merge_three_way(
&mut self,
source_id: u32,
target_id: u32,
) -> Result<u64, OnionError> {
let source_head = self
.index
.branch_head(source_id)
.ok_or_else(|| OnionError::Malformed(format!("source branch {source_id} has no revisions")))?
.revision;
let target_head = self
.index
.branch_head(target_id)
.ok_or_else(|| OnionError::Malformed(format!("target branch {target_id} has no revisions")))?
.revision;
// Find common ancestor of the two branch HEADs
let ancestor_rev = self
.index
.common_ancestor(source_head, target_head)
.ok_or_else(|| {
let src_name = self.branches.iter().find(|b| b.id == source_id)
.and_then(|b| self.annotations.get(b.name_off))
.unwrap_or("?").to_owned();
let tgt_name = self.branches.iter().find(|b| b.id == target_id)
.and_then(|b| self.annotations.get(b.name_off))
.unwrap_or("?").to_owned();
OnionError::NoCommonAncestor { a: src_name, b: tgt_name }
})?;
// Collect source delta since ancestor (latest write per offset)
let source_revs: Vec<u64> = self
.index
.branch_revisions(source_id)
.filter(|e| e.revision > ancestor_rev)
.map(|e| e.revision)
.collect();
let mut source_delta: BTreeMap<u64, Vec<u8>> = BTreeMap::new();
for rev in &source_revs {
for (h5_off, bytes) in self.revision_pages(*rev)? {
source_delta.insert(h5_off, bytes);
}
}
// Collect target delta since ancestor (latest write per offset)
let target_revs: Vec<u64> = self
.index
.branch_revisions(target_id)
.filter(|e| e.revision > ancestor_rev)
.map(|e| e.revision)
.collect();
let mut target_delta: BTreeMap<u64, Vec<u8>> = BTreeMap::new();
for rev in &target_revs {
for (h5_off, bytes) in self.revision_pages(*rev)? {
target_delta.insert(h5_off, bytes);
}
}
if source_delta.is_empty() {
return Err(OnionError::Malformed(
"three-way merge: source has no new changes since the common ancestor".to_string(),
));
}
// Union of all changed page offsets across both deltas
let all_offsets: BTreeSet<u64> = source_delta
.keys()
.chain(target_delta.keys())
.copied()
.collect();
let mut merged: BTreeMap<u64, Vec<u8>> = BTreeMap::new();
for offset in &all_offsets {
let result = match (source_delta.get(offset), target_delta.get(offset)) {
// Only source changed this page → use source
(Some(s), None) => s.clone(),
// Only target changed this page → use target (already on target, no-op)
(None, Some(_t)) => continue,
// Both changed → source wins (last-write-wins for conflicts)
(Some(s), Some(_t)) => s.clone(),
(None, None) => unreachable!(),
};
merged.insert(*offset, result);
}
if merged.is_empty() {
return Err(OnionError::Malformed(
"three-way merge: no source changes to apply (target already has all changes)".to_string(),
));
}
let mut session = self.begin_session(Some(target_id))?;
for (h5_off, bytes) in &merged {
session.record_page(*h5_off, bytes);
}
let annotation = format!(
"3-way merge {source_id}{target_id} [ancestor rev {ancestor_rev}]"
);
self.commit_session(session, Some(&annotation))
}
// ── Internal helpers ─────────────────────────────────────────────────────
/// Return the most recent page bytes written on `branch_id` at `h5_offset`,
/// or `None` if that branch has never written to that offset.
fn branch_latest_page(&self, branch_id: u32, h5_offset: u64) -> Option<Vec<u8>> {
let revs: Vec<u64> = self
.index
.branch_revisions(branch_id)
.map(|e| e.revision)
.collect();
for rev in revs.iter().rev() {
if let Some(table) = self.page_tables.get(*rev as usize) {
if let Some(pt) = table.iter().find(|pt| pt.h5_offset == h5_offset) {
let codec = Codec::from_u8(pt.codec).ok()?;
let start = pt.data_offset as usize;
let end = start + pt.data_size as usize;
if end > self.page_data.len() {
return None;
}
let compressed = &self.page_data[start..end];
return decompress_page(compressed, codec, pt.orig_size).ok();
}
}
}
None
}
// ── Lifecycle ────────────────────────────────────────────────────────────
/// List all branches with their current HEAD revision.
pub fn list_branches(&self) -> Vec<BranchInfo> {
self.branches
.iter()
.map(|b| BranchInfo {
id: b.id,
name: self
.annotations
.get(b.name_off)
.unwrap_or("?")
.to_owned(),
head_rev: b.head_rev,
fork_rev: b.fork_rev,
})
.collect()
}
/// Return revision entries for a named branch in chronological order.
pub fn branch_history(&self, name: &str) -> Result<Vec<&crate::format::RevisionEntry>, OnionError> {
let branch = self
.branch_by_name(name)
.ok_or_else(|| OnionError::BranchNotFound(name.to_string()))?;
Ok(self.index.branch_revisions(branch.id).collect())
}
/// Rename a branch.
pub fn rename_branch(&mut self, from: &str, to: &str) -> Result<(), OnionError> {
if self.branch_by_name(to).is_some() {
return Err(OnionError::BranchExists(to.to_string()));
}
let id = self
.branch_by_name(from)
.ok_or_else(|| OnionError::BranchNotFound(from.to_string()))?
.id;
let new_name_off = self.annotations.push(to);
self.branches
.iter_mut()
.find(|b| b.id == id)
.unwrap()
.name_off = new_name_off;
Ok(())
}
/// Delete a branch. The `main` branch (id 0) cannot be deleted.
pub fn delete_branch(&mut self, name: &str) -> Result<(), OnionError> {
let branch = self
.branch_by_name(name)
.ok_or_else(|| OnionError::BranchNotFound(name.to_string()))?;
if branch.id == crate::format::BRANCH_MAIN {
return Err(OnionError::Malformed("cannot delete the main branch".into()));
}
let id = branch.id;
self.branches.retain(|b| b.id != id);
self.header.branch_count = self.branches.len() as u32;
Ok(())
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Tests
// ─────────────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use crate::format::BRANCH_MAIN;
use tempfile::NamedTempFile;
fn tmp_h5() -> std::path::PathBuf {
let f = NamedTempFile::new().unwrap();
let path = f.path().with_extension("h5");
std::fs::write(&path, b"\x89HDF\r\n\x1a\n").unwrap();
path
}
#[test]
fn list_branches_initial() {
let h5 = tmp_h5();
let onion = OnionFile::create(&h5, 4096).unwrap();
let branches = onion.list_branches();
assert_eq!(branches.len(), 1);
assert_eq!(branches[0].name, "main");
assert_eq!(branches[0].id, BRANCH_MAIN);
}
#[test]
fn create_branch_from_main() {
let h5 = tmp_h5();
let mut onion = OnionFile::create(&h5, 4096).unwrap();
// commit something on main first
let mut s = onion.begin_session(None).unwrap();
s.record_page(0, &vec![0u8; 4096]);
onion.commit_session(s, None).unwrap();
let id = onion.create_branch("experiment", "main").unwrap();
assert_eq!(id, 1);
let branches = onion.list_branches();
assert_eq!(branches.len(), 2);
assert!(branches.iter().any(|b| b.name == "experiment"));
}
#[test]
fn create_duplicate_branch_errors() {
let h5 = tmp_h5();
let mut onion = OnionFile::create(&h5, 4096).unwrap();
let mut s = onion.begin_session(None).unwrap();
s.record_page(0, &vec![0u8; 4096]);
onion.commit_session(s, None).unwrap();
onion.create_branch("feat", "main").unwrap();
let err = onion.create_branch("feat", "main").unwrap_err();
assert!(matches!(err, OnionError::BranchExists(_)));
}
#[test]
fn create_branch_from_nonexistent_errors() {
let h5 = tmp_h5();
let mut onion = OnionFile::create(&h5, 4096).unwrap();
let err = onion.create_branch("feat", "no-such-branch").unwrap_err();
assert!(matches!(err, OnionError::BranchNotFound(_)));
}
#[test]
fn branch_history_empty_branch() {
let h5 = tmp_h5();
let mut onion = OnionFile::create(&h5, 4096).unwrap();
let mut s = onion.begin_session(None).unwrap();
s.record_page(0, &vec![0u8; 4096]);
onion.commit_session(s, None).unwrap();
onion.create_branch("feat", "main").unwrap();
let history = onion.branch_history("feat").unwrap();
// "feat" branches from main, so no new revisions on it yet
assert_eq!(history.len(), 0);
}
#[test]
fn branch_history_with_commits() {
let h5 = tmp_h5();
let mut onion = OnionFile::create(&h5, 4096).unwrap();
// main: rev 0
let mut s = onion.begin_session(None).unwrap();
s.record_page(0, &vec![0u8; 4096]);
onion.commit_session(s, None).unwrap();
// create feat branch
let feat_id = onion.create_branch("feat", "main").unwrap();
// commit on feat
let mut sf = onion.begin_session(Some(feat_id)).unwrap();
sf.record_page(4096, &vec![0xFFu8; 4096]);
onion.commit_session(sf, Some("feat commit")).unwrap();
let history = onion.branch_history("feat").unwrap();
assert_eq!(history.len(), 1);
assert_eq!(history[0].branch_id, feat_id);
}
#[test]
fn rename_branch_succeeds() {
let h5 = tmp_h5();
let mut onion = OnionFile::create(&h5, 4096).unwrap();
let mut s = onion.begin_session(None).unwrap();
s.record_page(0, &vec![0u8; 4096]);
onion.commit_session(s, None).unwrap();
onion.create_branch("old-name", "main").unwrap();
onion.rename_branch("old-name", "new-name").unwrap();
assert!(onion.branch_by_name("new-name").is_some());
assert!(onion.branch_by_name("old-name").is_none());
}
#[test]
fn rename_to_existing_name_errors() {
let h5 = tmp_h5();
let mut onion = OnionFile::create(&h5, 4096).unwrap();
let mut s = onion.begin_session(None).unwrap();
s.record_page(0, &vec![0u8; 4096]);
onion.commit_session(s, None).unwrap();
onion.create_branch("a", "main").unwrap();
onion.create_branch("b", "main").unwrap();
let err = onion.rename_branch("a", "b").unwrap_err();
assert!(matches!(err, OnionError::BranchExists(_)));
}
#[test]
fn delete_branch_removes_it() {
let h5 = tmp_h5();
let mut onion = OnionFile::create(&h5, 4096).unwrap();
let mut s = onion.begin_session(None).unwrap();
s.record_page(0, &vec![0u8; 4096]);
onion.commit_session(s, None).unwrap();
onion.create_branch("temp", "main").unwrap();
onion.delete_branch("temp").unwrap();
assert!(onion.branch_by_name("temp").is_none());
assert_eq!(onion.list_branches().len(), 1);
}
#[test]
fn delete_main_branch_errors() {
let h5 = tmp_h5();
let mut onion = OnionFile::create(&h5, 4096).unwrap();
let err = onion.delete_branch("main").unwrap_err();
assert!(matches!(err, OnionError::Malformed(_)));
}
#[test]
fn delete_nonexistent_branch_errors() {
let h5 = tmp_h5();
let mut onion = OnionFile::create(&h5, 4096).unwrap();
let err = onion.delete_branch("ghost").unwrap_err();
assert!(matches!(err, OnionError::BranchNotFound(_)));
}
// ── Merge: LatestWins ────────────────────────────────────────────────────
/// Helper: main has rev 0 (page 0 = AA), feat forks and writes rev 1
/// (page 0 = BB). After merge, main HEAD should have page 0 = BB.
fn make_fork_scenario() -> (std::path::PathBuf, OnionFile, u32) {
let h5 = tmp_h5();
let mut onion = OnionFile::create(&h5, 4096).unwrap();
// main rev 0
let mut s = onion.begin_session(None).unwrap();
s.record_page(0, &vec![0xAAu8; 4096]);
onion.commit_session(s, Some("main r0")).unwrap();
// fork feat from main
let feat_id = onion.create_branch("feat", "main").unwrap();
// feat rev 1: overwrite page 0
let mut sf = onion.begin_session(Some(feat_id)).unwrap();
sf.record_page(0, &vec![0xBBu8; 4096]);
onion.commit_session(sf, Some("feat r1")).unwrap();
(h5, onion, feat_id)
}
#[test]
fn merge_latest_wins_produces_new_revision() {
let (_h5, mut onion, _feat_id) = make_fork_scenario();
let pre_count = onion.revision_count();
onion.merge_into("feat", "main", MergeStrategy::LatestWins).unwrap();
assert_eq!(onion.revision_count(), pre_count + 1);
}
#[test]
fn merge_latest_wins_page_content_correct() {
let (h5, mut onion, _feat_id) = make_fork_scenario();
onion.merge_into("feat", "main", MergeStrategy::LatestWins).unwrap();
onion.flush().unwrap();
// Reload from disk and reconstruct main HEAD
let onion2 = OnionFile::open(&h5).unwrap();
let main_head = onion2.revision_count() - 1;
let base = std::fs::read(&h5).unwrap();
let state = onion2.reconstruct_revision(main_head, &base).unwrap();
// Page 0 should now be BB (from feat)
assert!(state[0..4096].iter().all(|&b| b == 0xBB),
"page 0 should be BB after latest-wins merge");
}
#[test]
fn merge_latest_wins_multiple_source_revisions() {
let h5 = tmp_h5();
let mut onion = OnionFile::create(&h5, 4096).unwrap();
// main: rev 0
let mut s = onion.begin_session(None).unwrap();
s.record_page(0, &vec![0x00u8; 4096]);
onion.commit_session(s, None).unwrap();
let feat_id = onion.create_branch("feat", "main").unwrap();
// feat: revs 1 and 2 (two writes on same page — rev 2 must win)
let mut s1 = onion.begin_session(Some(feat_id)).unwrap();
s1.record_page(0, &vec![0x11u8; 4096]);
onion.commit_session(s1, None).unwrap();
let mut s2 = onion.begin_session(Some(feat_id)).unwrap();
s2.record_page(0, &vec![0x22u8; 4096]);
onion.commit_session(s2, None).unwrap();
onion.merge_into("feat", "main", MergeStrategy::LatestWins).unwrap();
onion.flush().unwrap();
let onion2 = OnionFile::open(&h5).unwrap();
let head = onion2.revision_count() - 1;
let base = std::fs::read(&h5).unwrap();
let state = onion2.reconstruct_revision(head, &base).unwrap();
assert!(state[0..4096].iter().all(|&b| b == 0x22),
"latest write (0x22) must win in LatestWins merge");
}
#[test]
fn merge_source_with_no_new_revisions_errors() {
let h5 = tmp_h5();
let mut onion = OnionFile::create(&h5, 4096).unwrap();
let mut s = onion.begin_session(None).unwrap();
s.record_page(0, &vec![0u8; 4096]);
onion.commit_session(s, None).unwrap();
// fork but make no commits on feat
onion.create_branch("feat", "main").unwrap();
let err = onion.merge_into("feat", "main", MergeStrategy::LatestWins).unwrap_err();
assert!(matches!(err, OnionError::Malformed(_)));
}
// ── Merge: DatasetLevel ──────────────────────────────────────────────────
#[test]
fn merge_dataset_level_resolver_called() {
let (_h5, mut onion, _) = make_fork_scenario();
let called = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let called2 = called.clone();
let resolver = move |_path: &str, _target: &[u8], source: &[u8]| -> Vec<u8> {
called2.store(true, std::sync::atomic::Ordering::SeqCst);
source.to_vec() // just return source
};
onion
.merge_into("feat", "main", MergeStrategy::DatasetLevel(Box::new(resolver)))
.unwrap();
assert!(called.load(std::sync::atomic::Ordering::SeqCst), "resolver must be called");
}
#[test]
fn merge_dataset_level_resolver_controls_output() {
let (h5, mut onion, _feat_id) = make_fork_scenario();
// Resolver always returns 0xCC regardless of inputs
let resolver = |_path: &str, _target: &[u8], _source: &[u8]| -> Vec<u8> {
vec![0xCCu8; 4096]
};
onion
.merge_into("feat", "main", MergeStrategy::DatasetLevel(Box::new(resolver)))
.unwrap();
onion.flush().unwrap();
let onion2 = OnionFile::open(&h5).unwrap();
let head = onion2.revision_count() - 1;
let base = std::fs::read(&h5).unwrap();
let state = onion2.reconstruct_revision(head, &base).unwrap();
assert!(state[0..4096].iter().all(|&b| b == 0xCC),
"resolver output 0xCC should be in merged state");
}
// ── Merge: ThreeWay ──────────────────────────────────────────────────────
#[test]
fn merge_three_way_only_source_change_applied() {
// main: 0(AA) → 1(CC on page 4096)
// feat: forked at rev 0, writes rev 1(BB on page 0)
// 3-way merge feat → main:
// - page 0: only feat changed it (vs ancestor rev 0) → use feat (BB)
// - page 4096: only main changed it → skip (already on main)
let h5 = tmp_h5();
let mut onion = OnionFile::create(&h5, 4096).unwrap();
// main rev 0: page 0 = AA
let mut s0 = onion.begin_session(None).unwrap();
s0.record_page(0, &vec![0xAAu8; 4096]);
onion.commit_session(s0, None).unwrap();
let feat_id = onion.create_branch("feat", "main").unwrap();
// main rev 1: writes page 4096 = CC (diverges from feat)
let mut sm = onion.begin_session(None).unwrap();
sm.record_page(4096, &vec![0xCCu8; 4096]);
onion.commit_session(sm, None).unwrap();
// feat rev 2: writes page 0 = BB
let mut sf = onion.begin_session(Some(feat_id)).unwrap();
sf.record_page(0, &vec![0xBBu8; 4096]);
onion.commit_session(sf, None).unwrap();
onion.merge_into("feat", "main", MergeStrategy::ThreeWay).unwrap();
onion.flush().unwrap();
let onion2 = OnionFile::open(&h5).unwrap();
let head = onion2.revision_count() - 1;
let base = std::fs::read(&h5).unwrap();
let state = onion2.reconstruct_revision(head, &base).unwrap();
assert!(state[0..4096].iter().all(|&b| b == 0xBB),
"page 0 should be BB (from feat)");
assert!(state[4096..8192].iter().all(|&b| b == 0xCC),
"page 4096 should stay CC (from main — not overwritten by 3-way)");
}
#[test]
fn merge_three_way_conflict_source_wins() {
// Both branches write to page 0 after the fork → source (feat) should win.
let h5 = tmp_h5();
let mut onion = OnionFile::create(&h5, 4096).unwrap();
let mut s0 = onion.begin_session(None).unwrap();
s0.record_page(0, &vec![0x00u8; 4096]);
onion.commit_session(s0, None).unwrap();
let feat_id = onion.create_branch("feat", "main").unwrap();
// main writes BB (target change)
let mut sm = onion.begin_session(None).unwrap();
sm.record_page(0, &vec![0xBBu8; 4096]);
onion.commit_session(sm, None).unwrap();
// feat writes CC (source change)
let mut sf = onion.begin_session(Some(feat_id)).unwrap();
sf.record_page(0, &vec![0xCCu8; 4096]);
onion.commit_session(sf, None).unwrap();
onion.merge_into("feat", "main", MergeStrategy::ThreeWay).unwrap();
onion.flush().unwrap();
let onion2 = OnionFile::open(&h5).unwrap();
let head = onion2.revision_count() - 1;
let base = std::fs::read(&h5).unwrap();
let state = onion2.reconstruct_revision(head, &base).unwrap();
assert!(state[0..4096].iter().all(|&b| b == 0xCC),
"on conflict, source (CC) should win");
}
#[test]
fn merge_nonexistent_source_errors() {
let h5 = tmp_h5();
let mut onion = OnionFile::create(&h5, 4096).unwrap();
let err = onion.merge_into("no-such", "main", MergeStrategy::LatestWins).unwrap_err();
assert!(matches!(err, OnionError::BranchNotFound(_)));
}
#[test]
fn merge_nonexistent_target_errors() {
let h5 = tmp_h5();
let mut onion = OnionFile::create(&h5, 4096).unwrap();
let mut s = onion.begin_session(None).unwrap();
s.record_page(0, &vec![0u8; 4096]);
onion.commit_session(s, None).unwrap();
onion.create_branch("feat", "main").unwrap();
let err = onion.merge_into("feat", "no-target", MergeStrategy::LatestWins).unwrap_err();
assert!(matches!(err, OnionError::BranchNotFound(_)));
}
}