use mkit_core::hash::{self, HEX_LEN, Hash};
use mkit_core::layout::RepoLayout;
use mkit_core::object::Object;
use mkit_core::refs;
use mkit_core::store::ObjectStore;
pub const MIN_SHORT_HASH: usize = 4;
#[derive(Debug, thiserror::Error)]
pub enum RevError {
#[error("unknown revision '{0}'")]
Unknown(String),
#[error("ambiguous short hash '{0}' matches multiple objects")]
Ambiguous(String),
#[error("revision '{spec}': {detail}")]
BadSuffix {
spec: String,
detail: String,
},
#[error("revision '{0}' resolves to a non-commit object; cannot walk parents")]
NotACommit(String),
#[error("{0}")]
Backend(String),
}
pub fn resolve_revision(
store: &ObjectStore,
layout: &RepoLayout,
spec: &str,
) -> Result<Hash, RevError> {
let split_at = spec.find(['~', '^']).unwrap_or(spec.len());
let (base, suffix) = spec.split_at(split_at);
if base.is_empty() {
return Err(RevError::Unknown(spec.to_string()));
}
let mut current = resolve_base(store, layout, base)?;
let mut rest = suffix;
while !rest.is_empty() {
let bytes = rest.as_bytes();
match bytes[0] {
b'~' => {
let (n, consumed) = parse_count(&rest[1..]);
current = walk_first_parent(store, spec, current, n)?;
rest = &rest[1 + consumed..];
}
b'^' => {
let (n, consumed) = parse_count(&rest[1..]);
current = select_parent(store, spec, current, n)?;
rest = &rest[1 + consumed..];
}
_ => {
return Err(RevError::BadSuffix {
spec: spec.to_string(),
detail: format!("unexpected character in suffix '{rest}'"),
});
}
}
}
Ok(current)
}
fn resolve_base(store: &ObjectStore, layout: &RepoLayout, base: &str) -> Result<Hash, RevError> {
if base == "HEAD" {
return match refs::resolve_head(layout) {
Ok(Some(h)) => Ok(h),
Ok(None) => Err(RevError::Unknown("HEAD".to_string())),
Err(e) => Err(RevError::Backend(format!("resolve HEAD: {e}"))),
};
}
if let Some(short) = base.strip_prefix("refs/heads/") {
if let Ok(Some(h)) = refs::read_ref(layout, short) {
return Ok(h);
}
return Err(RevError::Unknown(base.to_string()));
}
if let Some(short) = base.strip_prefix("refs/tags/") {
if let Ok(Some(h)) = refs::read_tag(layout, short) {
return Ok(h);
}
return Err(RevError::Unknown(base.to_string()));
}
if let Some(rest) = base.strip_prefix("refs/remotes/") {
if let Some((remote, branch)) = rest.split_once('/')
&& let Ok(Some(h)) = refs::read_remote_ref(layout, remote, branch)
{
return Ok(h);
}
return Err(RevError::Unknown(base.to_string()));
}
if refs::validate_ref_name(base) {
if let Ok(Some(h)) = refs::read_ref(layout, base) {
return Ok(h);
}
if let Ok(Some(h)) = refs::read_tag(layout, base) {
return Ok(h);
}
if let Some((remote, branch)) = base.split_once('/')
&& let Ok(Some(h)) = refs::read_remote_ref(layout, remote, branch)
{
return Ok(h);
}
}
if base.len() == HEX_LEN
&& let Ok(h) = hash::from_hex(base)
{
if store.contains(&h) {
return Ok(h);
}
return Err(RevError::Unknown(base.to_string()));
}
if base.len() >= MIN_SHORT_HASH && base.len() < HEX_LEN && is_hex(base) {
return resolve_short_hash(store, base);
}
Err(RevError::Unknown(base.to_string()))
}
fn resolve_short_hash(store: &ObjectStore, prefix: &str) -> Result<Hash, RevError> {
let lower = prefix.to_ascii_lowercase();
let (shard, file_prefix) = lower.split_at(2);
let shard_dir = store.objects_root().join(shard);
let iter = match std::fs::read_dir(&shard_dir) {
Ok(i) => i,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Err(RevError::Unknown(prefix.to_string()));
}
Err(e) => return Err(RevError::Backend(format!("scan objects: {e}"))),
};
let mut found: Option<Hash> = None;
for entry in iter {
let entry = entry.map_err(|e| RevError::Backend(format!("scan objects: {e}")))?;
let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
continue;
};
if name.len() != HEX_LEN - 2 || !name.starts_with(file_prefix) {
continue;
}
let full = format!("{shard}{name}");
let Ok(h) = hash::from_hex(&full) else {
continue;
};
if found.is_some() {
return Err(RevError::Ambiguous(prefix.to_string()));
}
found = Some(h);
}
found.ok_or_else(|| RevError::Unknown(prefix.to_string()))
}
fn walk_first_parent(
store: &ObjectStore,
spec: &str,
mut commit: Hash,
n: u32,
) -> Result<Hash, RevError> {
for _ in 0..n {
let parents = parents_of(store, spec, &commit)?;
let Some(first) = parents.first() else {
return Err(RevError::BadSuffix {
spec: spec.to_string(),
detail: format!(
"commit {} has no parent (history root reached)",
hash::to_hex(&commit)
),
});
};
commit = *first;
}
Ok(commit)
}
fn select_parent(store: &ObjectStore, spec: &str, commit: Hash, n: u32) -> Result<Hash, RevError> {
if n == 0 {
return Ok(commit);
}
let parents = parents_of(store, spec, &commit)?;
let idx = (n - 1) as usize;
parents
.get(idx)
.copied()
.ok_or_else(|| RevError::BadSuffix {
spec: spec.to_string(),
detail: format!(
"commit {} has no parent #{n} (only {} parent(s))",
hash::to_hex(&commit),
parents.len()
),
})
}
fn parents_of(store: &ObjectStore, spec: &str, commit: &Hash) -> Result<Vec<Hash>, RevError> {
match store.read_object(commit) {
Ok(Object::Commit(c)) => Ok(c.parents),
Ok(Object::Remix(r)) => Ok(r.parents),
Ok(_) => Err(RevError::NotACommit(spec.to_string())),
Err(e) => Err(RevError::Backend(format!("read object: {e}"))),
}
}
fn parse_count(s: &str) -> (u32, usize) {
let digits: String = s.chars().take_while(char::is_ascii_digit).collect();
if digits.is_empty() {
(1, 0)
} else {
let n = digits.parse::<u32>().unwrap_or(u32::MAX);
(n, digits.len())
}
}
fn is_hex(s: &str) -> bool {
!s.is_empty() && s.bytes().all(|b| b.is_ascii_hexdigit())
}
#[cfg(test)]
mod tests {
use super::*;
use mkit_core::object::{Commit, Identity, Object};
use mkit_core::refs;
use mkit_core::serialize;
use tempfile::TempDir;
fn author() -> Identity {
Identity::ed25519([0u8; 32])
}
fn fresh_repo() -> (TempDir, ObjectStore, RepoLayout) {
let dir = TempDir::new().unwrap();
let layout = RepoLayout::single(dir.path());
let store = ObjectStore::init(&layout).unwrap();
refs::init(&layout).unwrap();
(dir, store, layout)
}
fn write_commit(store: &ObjectStore, parents: Vec<Hash>, seed: u8) -> Hash {
let commit = Commit::new_unannotated(
[seed; 32],
parents,
author(),
[0u8; 32],
vec![seed],
u64::from(seed),
[0u8; 64],
);
let bytes = serialize::serialize(&Object::Commit(commit)).unwrap();
store.write(&bytes).unwrap()
}
#[test]
fn resolves_full_hash() {
let (_d, store, mkit) = fresh_repo();
let c = write_commit(&store, vec![], 1);
let hex = hash::to_hex(&c);
assert_eq!(resolve_revision(&store, &mkit, &hex).unwrap(), c);
}
#[test]
fn full_hash_not_in_store_is_unknown() {
let (_d, store, mkit) = fresh_repo();
let hex = "ab".repeat(32);
let err = resolve_revision(&store, &mkit, &hex).unwrap_err();
assert!(matches!(err, RevError::Unknown(_)));
}
#[test]
fn resolves_unambiguous_short_hash() {
let (_d, store, mkit) = fresh_repo();
let c = write_commit(&store, vec![], 7);
let hex = hash::to_hex(&c);
let short = &hex[..12];
assert_eq!(resolve_revision(&store, &mkit, short).unwrap(), c);
}
#[test]
fn ambiguous_short_hash_errors() {
let (_d, store, mkit) = fresh_repo();
let mut seen: std::collections::HashMap<String, Vec<u8>> = std::collections::HashMap::new();
let mut pair: Option<(String, Vec<u8>, Vec<u8>)> = None;
for i in 0u32..200_000 {
let bytes = serialize::serialize(&Object::Blob(mkit_core::object::Blob {
data: i.to_le_bytes().to_vec(),
}))
.unwrap();
let h = hash::hash(&bytes);
let prefix = hash::to_hex(&h)[..MIN_SHORT_HASH].to_string();
if let Some(prev) = seen.get(&prefix) {
pair = Some((prefix, prev.clone(), bytes));
break;
}
seen.insert(prefix, bytes);
}
let (prefix, a, b) = pair.expect("expected a 4-nibble prefix collision");
store.write(&a).unwrap();
store.write(&b).unwrap();
let err = resolve_revision(&store, &mkit, &prefix).unwrap_err();
assert!(matches!(err, RevError::Ambiguous(_)), "got {err:?}");
}
#[test]
fn short_hash_no_match_is_unknown() {
let (_d, store, mkit) = fresh_repo();
write_commit(&store, vec![], 3);
let err = resolve_revision(&store, &mkit, "ffffffff").unwrap_err();
assert!(matches!(err, RevError::Unknown(_)));
}
#[test]
fn resolves_branch_ref() {
let (_d, store, mkit) = fresh_repo();
let c = write_commit(&store, vec![], 5);
refs::write_ref(&mkit, "feature", &c).unwrap();
assert_eq!(resolve_revision(&store, &mkit, "feature").unwrap(), c);
}
#[test]
fn resolves_tag_ref() {
let (_d, store, mkit) = fresh_repo();
let c = write_commit(&store, vec![], 6);
refs::write_tag(&mkit, "v1.0", &c).unwrap();
assert_eq!(resolve_revision(&store, &mkit, "v1.0").unwrap(), c);
}
#[test]
fn resolves_head() {
let (_d, store, mkit) = fresh_repo();
let c = write_commit(&store, vec![], 9);
refs::write_ref(&mkit, "main", &c).unwrap();
assert_eq!(resolve_revision(&store, &mkit, "HEAD").unwrap(), c);
}
#[test]
fn resolves_head_tilde_n() {
let (_d, store, mkit) = fresh_repo();
let root = write_commit(&store, vec![], 1);
let mid = write_commit(&store, vec![root], 2);
let tip = write_commit(&store, vec![mid], 3);
refs::write_ref(&mkit, "main", &tip).unwrap();
assert_eq!(resolve_revision(&store, &mkit, "HEAD").unwrap(), tip);
assert_eq!(resolve_revision(&store, &mkit, "HEAD~1").unwrap(), mid);
assert_eq!(resolve_revision(&store, &mkit, "HEAD~2").unwrap(), root);
assert_eq!(resolve_revision(&store, &mkit, "HEAD~").unwrap(), mid);
}
#[test]
fn caret_selects_parent() {
let (_d, store, mkit) = fresh_repo();
let p1 = write_commit(&store, vec![], 1);
let p2 = write_commit(&store, vec![], 2);
let merge = write_commit(&store, vec![p1, p2], 3);
refs::write_ref(&mkit, "main", &merge).unwrap();
assert_eq!(resolve_revision(&store, &mkit, "HEAD^").unwrap(), p1);
assert_eq!(resolve_revision(&store, &mkit, "HEAD^1").unwrap(), p1);
assert_eq!(resolve_revision(&store, &mkit, "HEAD^2").unwrap(), p2);
assert_eq!(resolve_revision(&store, &mkit, "HEAD^0").unwrap(), merge);
}
#[test]
fn tilde_off_the_end_errors() {
let (_d, store, mkit) = fresh_repo();
let root = write_commit(&store, vec![], 1);
refs::write_ref(&mkit, "main", &root).unwrap();
let err = resolve_revision(&store, &mkit, "HEAD~1").unwrap_err();
assert!(matches!(err, RevError::BadSuffix { .. }));
}
#[test]
fn caret_past_parents_errors() {
let (_d, store, mkit) = fresh_repo();
let p1 = write_commit(&store, vec![], 1);
let c = write_commit(&store, vec![p1], 2);
refs::write_ref(&mkit, "main", &c).unwrap();
let err = resolve_revision(&store, &mkit, "HEAD^2").unwrap_err();
assert!(matches!(err, RevError::BadSuffix { .. }));
}
#[test]
fn unknown_ref_errors() {
let (_d, store, mkit) = fresh_repo();
let err = resolve_revision(&store, &mkit, "nope").unwrap_err();
assert!(matches!(err, RevError::Unknown(_)));
}
#[test]
fn too_short_prefix_is_unknown() {
let (_d, store, mkit) = fresh_repo();
let c = write_commit(&store, vec![], 1);
let hex = hash::to_hex(&c);
let err = resolve_revision(&store, &mkit, &hex[..3]).unwrap_err();
assert!(matches!(err, RevError::Unknown(_)));
}
#[test]
fn branch_wins_over_tag_on_collision() {
let (_d, store, mkit) = fresh_repo();
let branch_c = write_commit(&store, vec![], 1);
let tag_c = write_commit(&store, vec![], 2);
refs::write_ref(&mkit, "dup", &branch_c).unwrap();
refs::write_tag(&mkit, "dup", &tag_c).unwrap();
assert_eq!(resolve_revision(&store, &mkit, "dup").unwrap(), branch_c);
}
}