use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use crate::atomic::{write_atomic, write_create_new};
use crate::hash::{HASH_LEN, HEX_LEN, Hash, to_hex};
pub const REFS_DIR: &str = "refs";
pub const HEADS_DIR: &str = "refs/heads";
pub const TAGS_DIR: &str = "refs/tags";
pub const REMOTES_DIR: &str = "refs/remotes";
pub const HEAD_FILE: &str = "HEAD";
pub const SHALLOW_FILE: &str = "shallow";
const HEAD_REF_PREFIX: &str = "ref: refs/heads/";
const HEAD_MAX_BYTES: u64 = 4 * 1024;
const REF_FILE_MAX_BYTES: u64 = 128;
const SHALLOW_MAX_BYTES: u64 = 1024 * 1024;
#[derive(Debug, thiserror::Error)]
pub enum RefError {
#[error("invalid ref name '{0}'")]
InvalidRefName(String),
#[error("invalid ref content for '{0}'")]
InvalidRef(String),
#[error("HEAD is not a valid symbolic-ref or detached-hash file")]
InvalidHead,
#[error("HEAD is not present")]
NoHead,
#[error("ref '{0}' did not satisfy CAS condition")]
Conflict(String),
#[error("ref '{0}' not found")]
NotFound(String),
#[error("cannot delete the current branch '{0}'")]
CurrentBranch(String),
#[error(transparent)]
Io(#[from] io::Error),
}
pub type RefResult<T> = Result<T, RefError>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RefWriteCondition {
Any,
Missing,
Match(Hash),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Head {
Branch(String),
Detached(Hash),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Ref {
pub name: String,
pub hash: Option<Hash>,
}
#[must_use]
pub fn validate_ref_name(name: &str) -> bool {
if name.is_empty() {
return false;
}
if name.starts_with('/') {
return false;
}
let mut last_part: &str = "";
for part in name.split('/') {
if part.is_empty() {
return false;
}
if part == "." || part == ".." {
return false;
}
let bytes = part.as_bytes();
if bytes.len() >= 5 && &bytes[bytes.len() - 5..] == b".lock" {
return false;
}
for &c in part.as_bytes() {
if c == 0 || c == b'\\' {
return false;
}
let allowed = c.is_ascii_alphanumeric() || c == b'.' || c == b'_' || c == b'-';
if !allowed {
return false;
}
}
last_part = part;
}
if last_part == "HEAD" {
return false;
}
true
}
#[must_use]
pub fn validate_ref_prefix(prefix: &str) -> bool {
if prefix.is_empty() {
return true;
}
let trimmed = prefix.trim_end_matches('/');
if trimmed.is_empty() {
return false;
}
validate_ref_name(trimmed)
}
#[must_use]
pub fn encode_ref_wire(h: &Hash) -> [u8; 65] {
let hex = to_hex(h);
let bytes = hex.as_bytes();
let mut out = [0u8; 65];
out[..HEX_LEN].copy_from_slice(bytes);
out[HEX_LEN] = b'\n';
out
}
#[must_use]
pub fn decode_ref_wire(data: &[u8]) -> Option<Hash> {
let s = core::str::from_utf8(data).ok()?;
let trimmed = s.trim_end_matches(['\n', '\r', ' ', '\t']);
if trimmed.len() != HEX_LEN {
return None;
}
parse_lowercase_hash(trimmed.as_bytes())
}
fn parse_lowercase_hash(bytes: &[u8]) -> Option<Hash> {
if bytes.len() != HEX_LEN {
return None;
}
let mut out = [0u8; HASH_LEN];
for i in 0..HASH_LEN {
let hi = lowercase_nibble(bytes[i * 2])?;
let lo = lowercase_nibble(bytes[i * 2 + 1])?;
out[i] = (hi << 4) | lo;
}
Some(out)
}
fn lowercase_nibble(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(10 + (b - b'a')),
_ => None,
}
}
pub fn init(mkit_dir: &Path) -> RefResult<()> {
fs::create_dir_all(mkit_dir.join(REFS_DIR))?;
fs::create_dir_all(mkit_dir.join(HEADS_DIR))?;
fs::create_dir_all(mkit_dir.join(TAGS_DIR))?;
fs::create_dir_all(mkit_dir.join(REMOTES_DIR))?;
let head_path = mkit_dir.join(HEAD_FILE);
if !head_path.exists() {
let body = format!("{HEAD_REF_PREFIX}main\n");
write_atomic(&head_path, body.as_bytes(), false)?;
}
Ok(())
}
pub fn read_head(mkit_dir: &Path) -> RefResult<Head> {
let path = mkit_dir.join(HEAD_FILE);
let meta = match fs::metadata(&path) {
Ok(m) => m,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Err(RefError::NoHead),
Err(e) => return Err(RefError::Io(e)),
};
if meta.len() > HEAD_MAX_BYTES {
return Err(RefError::InvalidHead);
}
let raw = fs::read(&path)?;
let s = core::str::from_utf8(&raw).map_err(|_| RefError::InvalidHead)?;
let trimmed = s.trim_end_matches(['\n', '\r', ' ', '\t']);
if let Some(branch) = trimmed.strip_prefix(HEAD_REF_PREFIX) {
if !validate_ref_name(branch) {
return Err(RefError::InvalidHead);
}
return Ok(Head::Branch(branch.to_string()));
}
if trimmed.len() == HEX_LEN {
let h = parse_lowercase_hash(trimmed.as_bytes()).ok_or(RefError::InvalidHead)?;
return Ok(Head::Detached(h));
}
Err(RefError::InvalidHead)
}
pub fn write_head_branch(mkit_dir: &Path, branch: &str) -> RefResult<()> {
if !validate_ref_name(branch) {
return Err(RefError::InvalidRefName(branch.to_string()));
}
let body = format!("{HEAD_REF_PREFIX}{branch}\n");
write_atomic(&mkit_dir.join(HEAD_FILE), body.as_bytes(), false)?;
Ok(())
}
pub fn write_head_detached(mkit_dir: &Path, h: &Hash) -> RefResult<()> {
let wire = encode_ref_wire(h);
write_atomic(&mkit_dir.join(HEAD_FILE), &wire, false)?;
Ok(())
}
pub fn resolve_head(mkit_dir: &Path) -> RefResult<Option<Hash>> {
let head = match read_head(mkit_dir) {
Ok(h) => h,
Err(RefError::NoHead) => return Ok(None),
Err(e) => return Err(e),
};
match head {
Head::Branch(name) => read_ref(mkit_dir, &name),
Head::Detached(h) => Ok(Some(h)),
}
}
pub fn update_head(mkit_dir: &Path, commit_hash: &Hash) -> RefResult<()> {
let head = read_head(mkit_dir)?;
match head {
Head::Branch(name) => write_ref(mkit_dir, &name, commit_hash),
Head::Detached(_) => write_head_detached(mkit_dir, commit_hash),
}
}
pub fn read_ref(mkit_dir: &Path, branch: &str) -> RefResult<Option<Hash>> {
if !validate_ref_name(branch) {
return Err(RefError::InvalidRefName(branch.to_string()));
}
read_ref_under(mkit_dir, HEADS_DIR, branch)
}
pub fn write_ref(mkit_dir: &Path, branch: &str, h: &Hash) -> RefResult<()> {
update_ref(mkit_dir, branch, RefWriteCondition::Any, h)
}
pub fn update_ref(
mkit_dir: &Path,
branch: &str,
condition: RefWriteCondition,
h: &Hash,
) -> RefResult<()> {
if !validate_ref_name(branch) {
return Err(RefError::InvalidRefName(branch.to_string()));
}
let path = ref_path(mkit_dir, HEADS_DIR, branch);
let wire = encode_ref_wire(h);
cas_write(&path, &wire, branch, condition)
}
#[cfg(feature = "history-mmr")]
pub fn update_ref_with_history<X: crate::protocol::async_shim::Executor + 'static>(
mkit_dir: &Path,
branch: &str,
condition: RefWriteCondition,
hash: &Hash,
history: &mut crate::history::CommitHistory<X>,
) -> RefResult<()> {
let Some(history_dir) = history.mkit_dir() else {
return Err(RefError::InvalidRef(format!(
"{branch}: update_ref_with_history requires a journaled CommitHistory (open_at)"
)));
};
if history_dir != mkit_dir {
return Err(RefError::InvalidRef(format!(
"{branch}: CommitHistory's mkit_dir does not match the ref's mkit_dir"
)));
}
if history.branch() != Some(branch) {
return Err(RefError::InvalidRef(format!(
"{branch}: CommitHistory was opened for a different branch ({:?})",
history.branch()
)));
}
let _lock =
crate::repo_lock::acquire_default(mkit_dir, "refs-history.lock").map_err(|e| match e {
crate::repo_lock::LockError::Io(io) => RefError::Io(io),
other => RefError::InvalidRef(format!("{branch}: lock acquisition: {other}")),
})?;
update_ref(mkit_dir, branch, condition, hash)?;
history
.append(hash)
.map_err(|e| RefError::InvalidRef(format!("{branch}: history append: {e}")))?;
Ok(())
}
pub fn delete_ref(mkit_dir: &Path, branch: &str) -> RefResult<()> {
if !validate_ref_name(branch) {
return Err(RefError::InvalidRefName(branch.to_string()));
}
let path = ref_path(mkit_dir, HEADS_DIR, branch);
match fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::NotFound => {
Err(RefError::NotFound(branch.to_string()))
}
Err(e) => Err(RefError::Io(e)),
}
}
pub fn delete_ref_safe(mkit_dir: &Path, branch: &str) -> RefResult<()> {
match read_head(mkit_dir) {
Ok(Head::Branch(current)) if current == branch => {
Err(RefError::CurrentBranch(branch.to_string()))
}
_ => delete_ref(mkit_dir, branch),
}
}
pub fn list_refs(mkit_dir: &Path) -> RefResult<Vec<Ref>> {
list_refs_under(mkit_dir, HEADS_DIR)
}
pub fn read_remote_ref(mkit_dir: &Path, remote: &str, branch: &str) -> RefResult<Option<Hash>> {
validate_remote_and_branch(remote, branch)?;
read_ref_under(mkit_dir, &remote_ref_dir(remote), branch)
}
pub fn write_remote_ref(mkit_dir: &Path, remote: &str, branch: &str, h: &Hash) -> RefResult<()> {
validate_remote_and_branch(remote, branch)?;
let path = ref_path(mkit_dir, &remote_ref_dir(remote), branch);
let wire = encode_ref_wire(h);
cas_write(&path, &wire, branch, RefWriteCondition::Any)
}
pub fn delete_remote_ref(mkit_dir: &Path, remote: &str, branch: &str) -> RefResult<()> {
validate_remote_and_branch(remote, branch)?;
let path = ref_path(mkit_dir, &remote_ref_dir(remote), branch);
match fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::NotFound => {
Err(RefError::NotFound(format!("{remote}/{branch}")))
}
Err(e) => Err(RefError::Io(e)),
}
}
pub fn list_remote_refs(mkit_dir: &Path, remote: &str) -> RefResult<Vec<Ref>> {
if !validate_ref_name(remote) {
return Err(RefError::InvalidRefName(remote.to_string()));
}
list_refs_under(mkit_dir, &remote_ref_dir(remote))
}
pub fn list_remote_names(mkit_dir: &Path) -> RefResult<Vec<String>> {
let dir = mkit_dir.join(REMOTES_DIR);
let entries = match fs::read_dir(&dir) {
Ok(e) => e,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(RefError::Io(e)),
};
let mut names = Vec::new();
for entry in entries {
let entry = entry.map_err(RefError::Io)?;
if !entry.file_type().map_err(RefError::Io)?.is_dir() {
continue;
}
if let Some(name) = entry.file_name().to_str()
&& validate_ref_name(name)
{
names.push(name.to_owned());
}
}
names.sort();
Ok(names)
}
pub fn read_tag(mkit_dir: &Path, name: &str) -> RefResult<Option<Hash>> {
if !validate_ref_name(name) {
return Err(RefError::InvalidRefName(name.to_string()));
}
read_ref_under(mkit_dir, TAGS_DIR, name)
}
pub fn write_tag(mkit_dir: &Path, name: &str, h: &Hash) -> RefResult<()> {
update_tag(mkit_dir, name, RefWriteCondition::Any, h)
}
pub fn update_tag(
mkit_dir: &Path,
name: &str,
condition: RefWriteCondition,
h: &Hash,
) -> RefResult<()> {
if !validate_ref_name(name) {
return Err(RefError::InvalidRefName(name.to_string()));
}
let path = ref_path(mkit_dir, TAGS_DIR, name);
let wire = encode_ref_wire(h);
cas_write(&path, &wire, name, condition)
}
pub fn delete_tag(mkit_dir: &Path, name: &str) -> RefResult<()> {
if !validate_ref_name(name) {
return Err(RefError::InvalidRefName(name.to_string()));
}
let path = ref_path(mkit_dir, TAGS_DIR, name);
match fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Err(RefError::NotFound(name.to_string())),
Err(e) => Err(RefError::Io(e)),
}
}
pub fn list_tags(mkit_dir: &Path) -> RefResult<Vec<Ref>> {
list_refs_under(mkit_dir, TAGS_DIR)
}
pub fn load_shallow_boundaries(mkit_dir: &Path) -> RefResult<Option<Vec<Hash>>> {
let path = mkit_dir.join(SHALLOW_FILE);
let meta = match fs::metadata(&path) {
Ok(m) => m,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(RefError::Io(e)),
};
if meta.len() == 0 {
return Ok(None);
}
if meta.len() > SHALLOW_MAX_BYTES {
return Err(RefError::InvalidRef("shallow file too large".to_string()));
}
let bytes = fs::read(&path)?;
let s = core::str::from_utf8(&bytes).map_err(|_| RefError::InvalidHead)?;
let mut out = Vec::new();
for line in s.split('\n') {
let trimmed = line.trim_end_matches(['\r', ' ', '\t']);
if trimmed.len() != HEX_LEN {
continue;
}
if let Some(h) = parse_lowercase_hash(trimmed.as_bytes()) {
out.push(h);
}
}
if out.is_empty() {
return Ok(None);
}
Ok(Some(out))
}
pub fn write_shallow_boundaries(mkit_dir: &Path, boundaries: &[Hash]) -> RefResult<()> {
let path = mkit_dir.join(SHALLOW_FILE);
if boundaries.is_empty() {
match fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(RefError::Io(e)),
}
} else {
let mut out = Vec::with_capacity(boundaries.len() * 65);
for h in boundaries {
out.extend_from_slice(&encode_ref_wire(h));
}
write_atomic(&path, &out, true)?;
Ok(())
}
}
fn ref_path(mkit_dir: &Path, sub_dir: &str, name: &str) -> PathBuf {
let mut path = mkit_dir.join(sub_dir);
for segment in name.split('/') {
path.push(segment);
}
path
}
fn remote_ref_dir(remote: &str) -> String {
format!("{REMOTES_DIR}/{remote}")
}
fn validate_remote_and_branch(remote: &str, branch: &str) -> RefResult<()> {
if !validate_ref_name(remote) {
return Err(RefError::InvalidRefName(remote.to_string()));
}
if !validate_ref_name(branch) {
return Err(RefError::InvalidRefName(branch.to_string()));
}
Ok(())
}
fn read_ref_under(mkit_dir: &Path, sub_dir: &str, name: &str) -> RefResult<Option<Hash>> {
let path = ref_path(mkit_dir, sub_dir, name);
let meta = match fs::metadata(&path) {
Ok(m) => m,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(RefError::Io(e)),
};
if meta.len() > REF_FILE_MAX_BYTES {
return Err(RefError::InvalidRef(name.to_string()));
}
let bytes = fs::read(&path)?;
let h = decode_ref_wire(&bytes).ok_or_else(|| RefError::InvalidRef(name.to_string()))?;
Ok(Some(h))
}
fn cas_write(
path: &Path,
wire: &[u8; 65],
name_for_err: &str,
condition: RefWriteCondition,
) -> RefResult<()> {
match condition {
RefWriteCondition::Any => {
write_atomic(path, wire, true)?;
Ok(())
}
RefWriteCondition::Missing => {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let created = write_create_new(path, wire, true)?;
if !created {
return Err(RefError::Conflict(name_for_err.to_string()));
}
Ok(())
}
RefWriteCondition::Match(expected) => {
let current = match fs::read(path) {
Ok(b) => Some(
decode_ref_wire(&b)
.ok_or_else(|| RefError::InvalidRef(name_for_err.to_string()))?,
),
Err(e) if e.kind() == io::ErrorKind::NotFound => None,
Err(e) => return Err(RefError::Io(e)),
};
if current != Some(expected) {
return Err(RefError::Conflict(name_for_err.to_string()));
}
write_atomic(path, wire, true)?;
Ok(())
}
}
}
fn list_refs_under(mkit_dir: &Path, sub_dir: &str) -> RefResult<Vec<Ref>> {
let root = mkit_dir.join(sub_dir);
let mut out = Vec::new();
if !root.is_dir() {
return Ok(out);
}
collect_refs(&root, "", &mut out, 0)?;
out.sort_by(|a, b| a.name.cmp(&b.name));
Ok(out)
}
const MAX_REF_DEPTH: usize = 32;
fn collect_refs(root: &Path, prefix: &str, out: &mut Vec<Ref>, depth: usize) -> RefResult<()> {
if depth > MAX_REF_DEPTH {
return Ok(());
}
let dir_path = if prefix.is_empty() {
root.to_path_buf()
} else {
root.join(prefix)
};
let iter = match fs::read_dir(&dir_path) {
Ok(i) => i,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(RefError::Io(e)),
};
for entry in iter {
let entry = entry?;
let file_name = match entry.file_name().to_str() {
Some(s) => s.to_string(),
None => continue, };
let child_name = if prefix.is_empty() {
file_name.clone()
} else {
format!("{prefix}/{file_name}")
};
let ft = entry.file_type()?;
if ft.is_dir() {
collect_refs(root, &child_name, out, depth + 1)?;
continue;
}
if !ft.is_file() {
continue;
}
if !validate_ref_name(&child_name) {
continue;
}
let Ok(bytes) = fs::read(entry.path()) else {
continue;
};
let hash = decode_ref_wire(&bytes);
out.push(Ref {
name: child_name,
hash,
});
}
Ok(())
}
#[doc(hidden)]
#[must_use]
pub fn _hash_from_lowercase_hex_for_tests(s: &str) -> Option<Hash> {
parse_lowercase_hash(s.as_bytes())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::hash;
use tempfile::TempDir;
fn fresh_repo() -> (TempDir, PathBuf) {
let dir = TempDir::new().unwrap();
let mkit_dir = dir.path().join(".mkit");
fs::create_dir_all(&mkit_dir).unwrap();
init(&mkit_dir).unwrap();
(dir, mkit_dir)
}
fn h(seed: &str) -> Hash {
hash::hash(seed.as_bytes())
}
#[test]
fn validate_accepts_simple_names() {
assert!(validate_ref_name("main"));
assert!(validate_ref_name("feat/v1.0-beta"));
assert!(validate_ref_name("release/2024_09"));
}
#[test]
fn validate_rejects_empty() {
assert!(!validate_ref_name(""));
}
#[test]
fn validate_rejects_leading_slash() {
assert!(!validate_ref_name("/main"));
}
#[test]
fn validate_rejects_dotdot_segment() {
assert!(!validate_ref_name("feat/.."));
assert!(!validate_ref_name("../escape"));
assert!(!validate_ref_name("feat/./topic"));
}
#[test]
fn validate_rejects_double_slash() {
assert!(!validate_ref_name("refs//heads/main"));
assert!(!validate_ref_name("main/"));
}
#[test]
fn validate_rejects_disallowed_bytes() {
assert!(!validate_ref_name("main@v1"));
assert!(!validate_ref_name("feat\\branch"));
assert!(!validate_ref_name("with space"));
}
#[test]
fn validate_rejects_lock_suffix() {
assert!(!validate_ref_name("refs/heads/main.lock"));
}
#[test]
fn validate_rejects_head_final_segment() {
assert!(!validate_ref_name("refs/heads/HEAD"));
assert!(!validate_ref_name("HEAD"));
}
#[test]
fn validate_accepts_main_regression() {
assert!(validate_ref_name("refs/heads/main"));
}
#[test]
fn validate_accepts_non_lock_suffix_regression() {
assert!(validate_ref_name("refs/heads/lockfile"));
}
#[test]
fn validate_accepts_headless_regression() {
assert!(validate_ref_name("refs/heads/HEADless"));
}
#[test]
fn validate_prefix() {
assert!(validate_ref_prefix(""));
assert!(validate_ref_prefix("refs/heads/"));
assert!(validate_ref_prefix("refs/heads"));
assert!(!validate_ref_prefix("refs//heads/"));
assert!(!validate_ref_prefix("/"));
}
#[test]
fn wire_round_trip() {
let original = h("test-ref");
let wire = encode_ref_wire(&original);
assert_eq!(wire.len(), 65);
assert_eq!(wire[64], b'\n');
let parsed = decode_ref_wire(&wire).unwrap();
assert_eq!(parsed, original);
}
#[test]
fn wire_rejects_uppercase() {
let original = h("test-ref");
let mut wire = encode_ref_wire(&original);
let mut flipped = false;
for b in &mut wire[..HEX_LEN] {
if (b'a'..=b'f').contains(b) {
*b -= b'a' - b'A';
flipped = true;
break;
}
}
assert!(flipped, "test fixture should contain at least one a-f");
assert!(decode_ref_wire(&wire).is_none());
}
#[test]
fn wire_rejects_short_input() {
let bad = b"deadbeef\n";
assert!(decode_ref_wire(bad).is_none());
}
#[test]
fn wire_rejects_non_hex() {
let mut wire = encode_ref_wire(&h("x"));
wire[1] = b'g';
assert!(decode_ref_wire(&wire).is_none());
}
#[test]
fn wire_tolerates_trailing_cr() {
let original = h("eol");
let mut buf = encode_ref_wire(&original).to_vec();
buf.insert(64, b'\r');
let parsed = decode_ref_wire(&buf).unwrap();
assert_eq!(parsed, original);
}
#[test]
fn init_writes_default_head() {
let (_dir, mkit) = fresh_repo();
let head = read_head(&mkit).unwrap();
assert_eq!(head, Head::Branch("main".to_string()));
}
#[test]
fn write_and_read_branch_ref() {
let (_dir, mkit) = fresh_repo();
let commit = h("commit1");
write_ref(&mkit, "main", &commit).unwrap();
let read = read_ref(&mkit, "main").unwrap();
assert_eq!(read, Some(commit));
}
#[test]
fn resolve_head_with_no_commits_returns_none() {
let (_dir, mkit) = fresh_repo();
assert_eq!(resolve_head(&mkit).unwrap(), None);
}
#[test]
fn resolve_head_after_commit() {
let (_dir, mkit) = fresh_repo();
let commit = h("commit1");
write_ref(&mkit, "main", &commit).unwrap();
assert_eq!(resolve_head(&mkit).unwrap(), Some(commit));
}
#[test]
fn update_head_updates_current_branch() {
let (_dir, mkit) = fresh_repo();
let h1 = h("c1");
update_head(&mkit, &h1).unwrap();
assert_eq!(resolve_head(&mkit).unwrap(), Some(h1));
let h2 = h("c2");
update_head(&mkit, &h2).unwrap();
assert_eq!(resolve_head(&mkit).unwrap(), Some(h2));
}
#[test]
fn detached_head_round_trip() {
let dir = TempDir::new().unwrap();
let mkit = dir.path().join(".mkit");
fs::create_dir_all(&mkit).unwrap();
let commit = h("detached");
write_head_detached(&mkit, &commit).unwrap();
match read_head(&mkit).unwrap() {
Head::Detached(got) => assert_eq!(got, commit),
other @ Head::Branch(_) => panic!("expected detached, got {other:?}"),
}
assert_eq!(resolve_head(&mkit).unwrap(), Some(commit));
}
#[test]
fn nonexistent_branch_returns_none() {
let (_dir, mkit) = fresh_repo();
assert_eq!(read_ref(&mkit, "nonexistent").unwrap(), None);
}
#[test]
fn list_refs_empty() {
let (_dir, mkit) = fresh_repo();
let refs = list_refs(&mkit).unwrap();
assert!(refs.is_empty());
}
#[test]
fn list_refs_sorted() {
let (_dir, mkit) = fresh_repo();
write_ref(&mkit, "main", &h("m")).unwrap();
write_ref(&mkit, "dev", &h("d")).unwrap();
let refs = list_refs(&mkit).unwrap();
assert_eq!(refs.len(), 2);
assert_eq!(refs[0].name, "dev");
assert_eq!(refs[1].name, "main");
}
#[test]
fn nested_refs_listed_recursively() {
let (_dir, mkit) = fresh_repo();
write_ref(&mkit, "feature/deep/topic", &h("nested")).unwrap();
let refs = list_refs(&mkit).unwrap();
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].name, "feature/deep/topic");
}
#[test]
fn delete_ref_basic() {
let (_dir, mkit) = fresh_repo();
write_ref(&mkit, "feature", &h("f")).unwrap();
delete_ref(&mkit, "feature").unwrap();
assert_eq!(read_ref(&mkit, "feature").unwrap(), None);
}
#[test]
fn delete_nonexistent_ref_errors() {
let (_dir, mkit) = fresh_repo();
let err = delete_ref(&mkit, "nope").unwrap_err();
assert!(matches!(err, RefError::NotFound(_)));
}
#[test]
fn refuse_delete_current_branch() {
let (_dir, mkit) = fresh_repo();
write_ref(&mkit, "main", &h("m")).unwrap();
let err = delete_ref_safe(&mkit, "main").unwrap_err();
assert!(matches!(err, RefError::CurrentBranch(_)));
}
#[test]
fn cas_any_clobbers() {
let (_dir, mkit) = fresh_repo();
update_ref(&mkit, "main", RefWriteCondition::Any, &h("a")).unwrap();
update_ref(&mkit, "main", RefWriteCondition::Any, &h("b")).unwrap();
assert_eq!(read_ref(&mkit, "main").unwrap(), Some(h("b")));
}
#[test]
fn cas_missing_succeeds_when_absent() {
let (_dir, mkit) = fresh_repo();
update_ref(&mkit, "main", RefWriteCondition::Missing, &h("a")).unwrap();
assert_eq!(read_ref(&mkit, "main").unwrap(), Some(h("a")));
}
#[test]
fn cas_missing_fails_when_present() {
let (_dir, mkit) = fresh_repo();
write_ref(&mkit, "main", &h("a")).unwrap();
let err = update_ref(&mkit, "main", RefWriteCondition::Missing, &h("b")).unwrap_err();
assert!(matches!(err, RefError::Conflict(_)));
}
#[test]
fn cas_match_succeeds_on_correct_hash() {
let (_dir, mkit) = fresh_repo();
write_ref(&mkit, "main", &h("a")).unwrap();
update_ref(&mkit, "main", RefWriteCondition::Match(h("a")), &h("b")).unwrap();
assert_eq!(read_ref(&mkit, "main").unwrap(), Some(h("b")));
}
#[test]
fn cas_match_fails_on_wrong_hash() {
let (_dir, mkit) = fresh_repo();
write_ref(&mkit, "main", &h("a")).unwrap();
let err = update_ref(&mkit, "main", RefWriteCondition::Match(h("z")), &h("b")).unwrap_err();
assert!(matches!(err, RefError::Conflict(_)));
}
#[test]
fn cas_match_fails_on_missing_ref() {
let (_dir, mkit) = fresh_repo();
let err = update_ref(&mkit, "main", RefWriteCondition::Match(h("a")), &h("b")).unwrap_err();
assert!(matches!(err, RefError::Conflict(_)));
}
#[test]
fn write_rejects_invalid_branch_name() {
let (_dir, mkit) = fresh_repo();
let err = write_ref(&mkit, "../escape", &h("x")).unwrap_err();
assert!(matches!(err, RefError::InvalidRefName(_)));
let err = write_head_branch(&mkit, "bad//branch").unwrap_err();
assert!(matches!(err, RefError::InvalidRefName(_)));
}
#[test]
fn write_and_read_tag() {
let (_dir, mkit) = fresh_repo();
let commit = h("v1.0");
write_tag(&mkit, "v1.0", &commit).unwrap();
assert_eq!(read_tag(&mkit, "v1.0").unwrap(), Some(commit));
}
#[test]
fn list_tags_sorted() {
let (_dir, mkit) = fresh_repo();
write_tag(&mkit, "v2.0", &h("v2")).unwrap();
write_tag(&mkit, "v1.0", &h("v1")).unwrap();
write_tag(&mkit, "alpha", &h("a")).unwrap();
let tags = list_tags(&mkit).unwrap();
assert_eq!(
tags.iter().map(|r| r.name.as_str()).collect::<Vec<_>>(),
vec!["alpha", "v1.0", "v2.0"]
);
}
#[test]
fn tag_and_branch_same_name_independent() {
let (_dir, mkit) = fresh_repo();
let tag = h("tag");
let branch = h("branch");
write_tag(&mkit, "main", &tag).unwrap();
write_ref(&mkit, "main", &branch).unwrap();
assert_eq!(read_tag(&mkit, "main").unwrap(), Some(tag));
assert_eq!(read_ref(&mkit, "main").unwrap(), Some(branch));
}
#[test]
fn delete_tag_basic() {
let (_dir, mkit) = fresh_repo();
write_tag(&mkit, "release", &h("r")).unwrap();
delete_tag(&mkit, "release").unwrap();
assert_eq!(read_tag(&mkit, "release").unwrap(), None);
}
#[test]
fn delete_nonexistent_tag_errors() {
let (_dir, mkit) = fresh_repo();
let err = delete_tag(&mkit, "missing").unwrap_err();
assert!(matches!(err, RefError::NotFound(_)));
}
#[test]
fn load_shallow_returns_none_when_missing() {
let (_dir, mkit) = fresh_repo();
assert_eq!(load_shallow_boundaries(&mkit).unwrap(), None);
}
#[test]
fn write_and_load_shallow_round_trip() {
let (_dir, mkit) = fresh_repo();
let bs = vec![h("b1"), h("b2"), h("b3")];
write_shallow_boundaries(&mkit, &bs).unwrap();
let loaded = load_shallow_boundaries(&mkit).unwrap().unwrap();
assert_eq!(loaded.len(), 3);
for b in &bs {
assert!(loaded.contains(b));
}
}
#[test]
fn write_empty_shallow_removes_file() {
let (_dir, mkit) = fresh_repo();
write_shallow_boundaries(&mkit, &[h("x")]).unwrap();
assert!(load_shallow_boundaries(&mkit).unwrap().is_some());
write_shallow_boundaries(&mkit, &[]).unwrap();
assert_eq!(load_shallow_boundaries(&mkit).unwrap(), None);
}
#[test]
fn load_shallow_skips_invalid_lines() {
let (_dir, mkit) = fresh_repo();
let path = mkit.join(SHALLOW_FILE);
let valid = h("ok");
let valid_hex = to_hex(&valid);
let mut content = String::new();
content.push_str("short\n");
content.push_str(&valid_hex);
content.push('\n');
content.push_str(&"z".repeat(64));
content.push('\n');
std::fs::write(&path, content).unwrap();
let loaded = load_shallow_boundaries(&mkit).unwrap().unwrap();
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0], valid);
}
#[cfg(feature = "history-mmr")]
mod history_coupling {
use super::*;
use crate::history::{CommitHistory, TokioExecutor};
use std::sync::Arc;
#[test]
fn update_ref_with_history_appends_to_journal_under_lock() {
let (_dir, mkit) = fresh_repo();
let exec = Arc::new(TokioExecutor::new().unwrap());
let mut hist = CommitHistory::open_at(exec.clone(), &mkit, "main").unwrap();
let c1 = h("c1");
let c2 = h("c2");
update_ref_with_history(&mkit, "main", RefWriteCondition::Any, &c1, &mut hist).unwrap();
update_ref_with_history(&mkit, "main", RefWriteCondition::Match(c1), &c2, &mut hist)
.unwrap();
assert_eq!(read_ref(&mkit, "main").unwrap(), Some(c2));
assert_eq!(hist.len(), 2, "two appends → two leaves in the MMR");
}
#[test]
fn update_ref_with_history_rejects_mem_history() {
let (_dir, mkit) = fresh_repo();
let mut mem_hist = CommitHistory::open();
let err = update_ref_with_history(
&mkit,
"main",
RefWriteCondition::Any,
&h("x"),
&mut mem_hist,
)
.unwrap_err();
assert!(matches!(err, RefError::InvalidRef(_)));
}
#[test]
fn update_ref_with_history_rejects_branch_mismatch() {
let (_dir, mkit) = fresh_repo();
let exec = Arc::new(TokioExecutor::new().unwrap());
let mut hist = CommitHistory::open_at(exec, &mkit, "main").unwrap();
let err = update_ref_with_history(
&mkit,
"feature",
RefWriteCondition::Any,
&h("x"),
&mut hist,
)
.unwrap_err();
assert!(matches!(err, RefError::InvalidRef(_)));
}
#[test]
fn update_ref_with_history_cas_failure_does_not_append() {
let (_dir, mkit) = fresh_repo();
let exec = Arc::new(TokioExecutor::new().unwrap());
let mut hist = CommitHistory::open_at(exec, &mkit, "main").unwrap();
write_ref(&mkit, "main", &h("existing")).unwrap();
let err = update_ref_with_history(
&mkit,
"main",
RefWriteCondition::Missing,
&h("new"),
&mut hist,
)
.unwrap_err();
assert!(matches!(err, RefError::Conflict(_)));
assert_eq!(
hist.len(),
0,
"CAS failure must NOT have appended to history"
);
}
}
}