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:
@@ -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(_)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user