use std::collections::HashSet;
use std::fs;
use super::{
BranchRefError, BranchRefRecord, BranchRefStore, BranchShardRef, encode_record, ref_file_name,
};
use crate::branch::persist::{fail_next_parent_dir_sync, push_bytes, push_u64};
use crate::tree::Hash;
fn hash(byte: u8) -> Hash {
Hash::from_bytes([byte; 32])
}
fn new_branch(name: &str, created: u64, shards: &[(usize, u8)]) -> BranchRefRecord {
BranchRefRecord {
name: name.to_owned(),
created,
kind: crate::branch::BranchKind::Work,
namespace_lineage: None,
seq: 0,
timestamp: created,
shards: shards
.iter()
.map(|&(shard_id, anchor)| BranchShardRef {
shard_id,
fork_anchor: hash(anchor),
head: hash(anchor),
})
.collect(),
parents: Vec::new(),
}
}
fn tempdir() -> Result<tempfile::TempDir, BranchRefError> {
tempfile::tempdir().map_err(BranchRefError::Io)
}
fn encode_legacy_unkinded_record(record: &BranchRefRecord) -> Vec<u8> {
let mut bytes = b"HBR1".to_vec();
push_bytes(&mut bytes, record.name.as_bytes());
push_u64(&mut bytes, record.created);
push_u64(&mut bytes, record.seq);
push_u64(&mut bytes, record.timestamp);
push_u64(&mut bytes, record.shards.len() as u64);
for shard in &record.shards {
push_u64(&mut bytes, shard.shard_id as u64);
bytes.extend_from_slice(shard.fork_anchor.as_bytes());
bytes.extend_from_slice(shard.head.as_bytes());
}
push_u64(&mut bytes, record.parents.len() as u64);
for parent in &record.parents {
bytes.extend_from_slice(parent.as_bytes());
}
bytes
}
#[test]
fn hbr1_round_trip_survives_reopen() -> Result<(), BranchRefError> {
let dir = tempdir()?;
let mut record = new_branch("sessions/alpha", 1_111, &[(0, 1), (3, 2), (7, 3)]);
record.seq = 4;
record.timestamp = 2_222;
record.shards[1].head = hash(9);
record.parents = vec![hash(4), hash(5)];
{
let mut store = BranchRefStore::open(dir.path())?;
store.create(record.clone())?;
}
let reopened = BranchRefStore::open(dir.path())?;
assert_eq!(reopened.get("sessions/alpha"), Some(&record));
assert_eq!(reopened.list().count(), 1);
Ok(())
}
#[test]
fn legacy_hbr1_decodes_as_work_without_lineage() -> Result<(), BranchRefError> {
let dir = tempdir()?;
let legacy = new_branch("legacy-work", 5, &[(0, 1)]);
fs::write(
dir.path().join(ref_file_name(&legacy.name)),
encode_legacy_unkinded_record(&legacy),
)?;
let mut opened = BranchRefStore::open(dir.path())?;
let record = opened
.get("legacy-work")
.ok_or_else(|| BranchRefError::BranchRemoved("legacy-work".to_owned()))?;
assert_eq!(record.kind, crate::branch::BranchKind::Work);
assert_eq!(record.namespace_lineage, None);
assert_eq!(record.shards, legacy.shards);
opened.advance(
"legacy-work",
legacy.created,
0,
&[(0, hash(2))],
vec![hash(1)],
6,
)?;
drop(opened);
let reopened = BranchRefStore::open(dir.path())?;
let advanced = reopened
.get("legacy-work")
.ok_or_else(|| BranchRefError::BranchRemoved("legacy-work".to_owned()))?;
assert_eq!(advanced.kind, crate::branch::BranchKind::Work);
assert_eq!(advanced.seq, 1);
assert_eq!(advanced.shards[0].head, hash(2));
Ok(())
}
#[test]
fn create_duplicate_name_is_typed_error() -> Result<(), BranchRefError> {
let dir = tempdir()?;
let mut store = BranchRefStore::open(dir.path())?;
store.create(new_branch("dup", 10, &[(0, 1)]))?;
let result = store.create(new_branch("dup", 20, &[(0, 2)]));
assert!(matches!(result, Err(BranchRefError::DuplicateBranch(name)) if name == "dup"));
assert!(matches!(store.get("dup"), Some(record) if record.created == 10));
Ok(())
}
#[test]
fn create_detects_on_disk_duplicate_via_noclobber() -> Result<(), BranchRefError> {
let dir = tempdir()?;
let mut store = BranchRefStore::open(dir.path())?;
let intruder = new_branch("shadow", 30, &[(0, 3)]);
fs::write(
dir.path().join(ref_file_name("shadow")),
encode_record(&intruder),
)?;
let result = store.create(new_branch("shadow", 40, &[(0, 4)]));
assert!(matches!(result, Err(BranchRefError::DuplicateBranch(name)) if name == "shadow"));
Ok(())
}
#[test]
fn create_name_hash_collision_is_typed_error() -> Result<(), BranchRefError> {
let dir = tempdir()?;
let mut store = BranchRefStore::open(dir.path())?;
let occupant = new_branch("occupant", 50, &[(0, 5)]);
fs::write(
dir.path().join(ref_file_name("victim")),
encode_record(&occupant),
)?;
let result = store.create(new_branch("victim", 60, &[(0, 6)]));
assert!(matches!(
result,
Err(BranchRefError::NameHashCollision { requested, existing })
if requested == "victim" && existing == "occupant"
));
Ok(())
}
#[test]
fn create_rejects_duplicate_shard_ids() -> Result<(), BranchRefError> {
let dir = tempdir()?;
let mut store = BranchRefStore::open(dir.path())?;
let result = store.create(new_branch("twice", 70, &[(1, 1), (1, 2)]));
assert!(matches!(
result,
Err(BranchRefError::DuplicateShard { name, shard_id }) if name == "twice" && shard_id == 1
));
assert!(store.get("twice").is_none());
Ok(())
}
#[test]
fn advance_bumps_seq_and_stale_seq_is_typed_error() -> Result<(), BranchRefError> {
let dir = tempdir()?;
let mut store = BranchRefStore::open(dir.path())?;
store.create(new_branch("work", 100, &[(0, 1)]))?;
let seq = store.advance("work", 100, 0, &[(0, hash(2))], vec![hash(1)], 150)?;
assert_eq!(seq, 1);
let stale = store.advance("work", 100, 0, &[(0, hash(3))], vec![hash(2)], 160);
assert!(matches!(
stale,
Err(BranchRefError::StaleSeq { name, expected: 0, found: 1 }) if name == "work"
));
assert!(
matches!(store.get("work"), Some(record) if record.seq == 1 && record.shards[0].head == hash(2))
);
Ok(())
}
#[test]
fn advance_after_recreate_is_generation_mismatch() -> Result<(), BranchRefError> {
let dir = tempdir()?;
let mut store = BranchRefStore::open(dir.path())?;
store.create(new_branch("lease", 200, &[(0, 1)]))?;
store.remove("lease")?;
store.create(new_branch("lease", 300, &[(0, 2)]))?;
let result = store.advance("lease", 200, 0, &[(0, hash(9))], vec![hash(1)], 350);
assert!(matches!(
result,
Err(BranchRefError::BranchGenerationMismatch {
name,
expected_created: 200,
found_created: 300,
}) if name == "lease"
));
assert!(matches!(store.get("lease"), Some(record) if record.shards[0].head == hash(2)));
Ok(())
}
#[test]
fn advance_after_remove_is_branch_removed() -> Result<(), BranchRefError> {
let dir = tempdir()?;
let mut store = BranchRefStore::open(dir.path())?;
store.create(new_branch("gone", 400, &[(0, 1)]))?;
store.remove("gone")?;
let result = store.advance("gone", 400, 0, &[(0, hash(2))], vec![hash(1)], 450);
assert!(matches!(result, Err(BranchRefError::BranchRemoved(name)) if name == "gone"));
Ok(())
}
#[test]
fn advance_preserves_fork_anchor_verbatim() -> Result<(), BranchRefError> {
let dir = tempdir()?;
let mut store = BranchRefStore::open(dir.path())?;
store.create(new_branch("anchored", 500, &[(0, 1), (2, 2)]))?;
store.advance("anchored", 500, 0, &[(0, hash(7))], vec![hash(1)], 550)?;
store.advance("anchored", 500, 1, &[(0, hash(8))], vec![hash(7)], 560)?;
let reopened = BranchRefStore::open(dir.path())?;
let record = reopened
.get("anchored")
.ok_or_else(|| BranchRefError::BranchRemoved("anchored".to_owned()))?;
assert_eq!(record.shards[0].fork_anchor, hash(1));
assert_eq!(record.shards[0].head, hash(8));
assert_eq!(record.shards[1].fork_anchor, hash(2));
assert_eq!(record.shards[1].head, hash(2));
assert_eq!(record.parents, vec![hash(7)]);
Ok(())
}
#[test]
fn advance_unknown_shard_is_typed_error() -> Result<(), BranchRefError> {
let dir = tempdir()?;
let mut store = BranchRefStore::open(dir.path())?;
store.create(new_branch("narrow", 600, &[(0, 1)]))?;
let result = store.advance("narrow", 600, 0, &[(5, hash(2))], vec![hash(1)], 650);
assert!(matches!(
result,
Err(BranchRefError::UnknownShard { name, shard_id: 5 }) if name == "narrow"
));
assert!(matches!(store.get("narrow"), Some(record) if record.seq == 0));
Ok(())
}
#[test]
fn open_fails_loud_on_corrupt_record() -> Result<(), BranchRefError> {
let dir = tempdir()?;
{
let mut store = BranchRefStore::open(dir.path())?;
store.create(new_branch("healthy", 700, &[(0, 1)]))?;
}
fs::write(dir.path().join(ref_file_name("rotten")), b"bit rot")?;
assert!(matches!(
BranchRefStore::open(dir.path()),
Err(BranchRefError::Corrupt(_))
));
Ok(())
}
#[test]
fn open_fails_loud_on_misfiled_record() -> Result<(), BranchRefError> {
let dir = tempdir()?;
fs::write(
dir.path().join(ref_file_name("expected")),
encode_record(&new_branch("actual", 800, &[(0, 1)])),
)?;
assert!(matches!(
BranchRefStore::open(dir.path()),
Err(BranchRefError::Corrupt(_))
));
Ok(())
}
#[test]
fn open_sweeps_only_pinned_prefix_temp_files() -> Result<(), BranchRefError> {
let dir = tempdir()?;
let orphaned = dir.path().join(".branch-a1b2c3.tmp");
let foreign = dir.path().join(".other-a1b2c3.tmp");
fs::write(&orphaned, b"torn write leftovers")?;
fs::write(&foreign, b"someone else's file")?;
let store = BranchRefStore::open(dir.path())?;
assert!(!orphaned.exists());
assert!(foreign.exists());
assert_eq!(store.list().count(), 0);
Ok(())
}
#[test]
fn protected_roots_is_union_of_anchors_and_heads() -> Result<(), BranchRefError> {
let dir = tempdir()?;
let mut store = BranchRefStore::open(dir.path())?;
store.create(new_branch("one", 900, &[(0, 1)]))?;
store.create(new_branch("two", 910, &[(0, 2), (1, 3)]))?;
store.advance("one", 900, 0, &[(0, hash(4))], vec![hash(1)], 950)?;
let expected: HashSet<Hash> = [hash(1), hash(2), hash(3), hash(4)].into_iter().collect();
assert_eq!(store.protected_roots(), expected);
Ok(())
}
#[test]
fn remove_returns_record_and_survives_reopen() -> Result<(), BranchRefError> {
let dir = tempdir()?;
{
let mut store = BranchRefStore::open(dir.path())?;
store.create(new_branch("keep", 1_000, &[(0, 1)]))?;
store.create(new_branch("drop", 1_010, &[(0, 2)]))?;
let removed = store.remove("drop")?;
assert!(matches!(removed, Some(record) if record.name == "drop"));
assert!(store.get("drop").is_none());
}
let reopened = BranchRefStore::open(dir.path())?;
assert!(reopened.get("drop").is_none());
assert!(reopened.get("keep").is_some());
Ok(())
}
#[test]
fn remove_unknown_name_is_none() -> Result<(), BranchRefError> {
let dir = tempdir()?;
let mut store = BranchRefStore::open(dir.path())?;
assert!(matches!(store.remove("never-existed"), Ok(None)));
Ok(())
}
#[test]
fn unfenced_advance_adopts_replacement_into_map() -> Result<(), BranchRefError> {
let dir = tempdir()?;
let mut store = BranchRefStore::open(dir.path())?;
store.create(new_branch("layers/base", 1_000, &[(0, 1)]))?;
fail_next_parent_dir_sync();
let result = store.advance(
"layers/base",
1_000,
0,
&[(0, hash(7))],
vec![hash(1)],
2_000,
);
assert!(
matches!(result, Err(BranchRefError::Io(ref error)) if error.to_string().contains("injected")),
"the unfenced install must still surface its error"
);
let record = store
.get("layers/base")
.ok_or_else(|| BranchRefError::Corrupt("record missing after unfenced install".into()))?;
assert_eq!(record.seq, 1);
assert_eq!(record.shards[0].head, hash(7));
assert!(store.protected_roots().contains(&hash(7)));
drop(store);
let reopened = BranchRefStore::open(dir.path())?;
let record = reopened
.get("layers/base")
.ok_or_else(|| BranchRefError::Corrupt("record missing after reopen".into()))?;
assert_eq!(record.seq, 1);
assert_eq!(record.shards[0].head, hash(7));
Ok(())
}
#[test]
fn unfenced_create_adopts_record_into_map() -> Result<(), BranchRefError> {
let dir = tempdir()?;
let mut store = BranchRefStore::open(dir.path())?;
fail_next_parent_dir_sync();
let result = store.create(new_branch("layers/loss", 1_000, &[(0, 3)]));
assert!(
matches!(result, Err(BranchRefError::Io(ref error)) if error.to_string().contains("injected")),
"the unfenced create must still surface its error"
);
assert!(store.protected_roots().contains(&hash(3)));
drop(store);
let reopened = BranchRefStore::open(dir.path())?;
assert!(reopened.get("layers/loss").is_some());
Ok(())
}