1use mkit_core::hash::{self, HEX_LEN, Hash};
31use mkit_core::layout::RepoLayout;
32use mkit_core::object::Object;
33use mkit_core::refs;
34use mkit_core::store::ObjectStore;
35
36pub const MIN_SHORT_HASH: usize = 4;
40
41#[derive(Debug, thiserror::Error)]
43pub enum RevError {
44 #[error("unknown revision '{0}'")]
46 Unknown(String),
47 #[error("ambiguous short hash '{0}' matches multiple objects")]
49 Ambiguous(String),
50 #[error("revision '{spec}': {detail}")]
52 BadSuffix {
53 spec: String,
55 detail: String,
57 },
58 #[error("revision '{0}' resolves to a non-commit object; cannot walk parents")]
61 NotACommit(String),
62 #[error("{0}")]
64 Backend(String),
65}
66
67pub fn resolve_revision(
75 store: &ObjectStore,
76 layout: &RepoLayout,
77 spec: &str,
78) -> Result<Hash, RevError> {
79 let split_at = spec.find(['~', '^']).unwrap_or(spec.len());
82 let (base, suffix) = spec.split_at(split_at);
83 if base.is_empty() {
84 return Err(RevError::Unknown(spec.to_string()));
85 }
86
87 let mut current = resolve_base(store, layout, base)?;
88
89 let mut rest = suffix;
91 while !rest.is_empty() {
92 let bytes = rest.as_bytes();
93 match bytes[0] {
94 b'~' => {
95 let (n, consumed) = parse_count(&rest[1..]);
96 current = walk_first_parent(store, spec, current, n)?;
97 rest = &rest[1 + consumed..];
98 }
99 b'^' => {
100 let (n, consumed) = parse_count(&rest[1..]);
101 current = select_parent(store, spec, current, n)?;
102 rest = &rest[1 + consumed..];
103 }
104 _ => {
105 return Err(RevError::BadSuffix {
106 spec: spec.to_string(),
107 detail: format!("unexpected character in suffix '{rest}'"),
108 });
109 }
110 }
111 }
112 Ok(current)
113}
114
115fn resolve_base(store: &ObjectStore, layout: &RepoLayout, base: &str) -> Result<Hash, RevError> {
118 if base == "HEAD" {
120 return match refs::resolve_head(layout) {
121 Ok(Some(h)) => Ok(h),
122 Ok(None) => Err(RevError::Unknown("HEAD".to_string())),
123 Err(e) => Err(RevError::Backend(format!("resolve HEAD: {e}"))),
124 };
125 }
126
127 if let Some(short) = base.strip_prefix("refs/heads/") {
131 if let Ok(Some(h)) = refs::read_ref(layout, short) {
132 return Ok(h);
133 }
134 return Err(RevError::Unknown(base.to_string()));
135 }
136 if let Some(short) = base.strip_prefix("refs/tags/") {
137 if let Ok(Some(h)) = refs::read_tag(layout, short) {
138 return Ok(h);
139 }
140 return Err(RevError::Unknown(base.to_string()));
141 }
142 if let Some(rest) = base.strip_prefix("refs/remotes/") {
143 if let Some((remote, branch)) = rest.split_once('/')
144 && let Ok(Some(h)) = refs::read_remote_ref(layout, remote, branch)
145 {
146 return Ok(h);
147 }
148 return Err(RevError::Unknown(base.to_string()));
149 }
150
151 if refs::validate_ref_name_grammar(base) {
158 if let Ok(Some(h)) = refs::read_ref(layout, base) {
159 return Ok(h);
160 }
161 if let Ok(Some(h)) = refs::read_tag(layout, base) {
162 return Ok(h);
163 }
164 if let Some((remote, branch)) = base.split_once('/')
167 && let Ok(Some(h)) = refs::read_remote_ref(layout, remote, branch)
168 {
169 return Ok(h);
170 }
171 }
172
173 if base.len() == HEX_LEN
175 && let Ok(h) = hash::from_hex(base)
176 {
177 if store.contains(&h) {
178 return Ok(h);
179 }
180 return Err(RevError::Unknown(base.to_string()));
181 }
182
183 if base.len() >= MIN_SHORT_HASH && base.len() < HEX_LEN && is_hex(base) {
185 return resolve_short_hash(store, base);
186 }
187
188 Err(RevError::Unknown(base.to_string()))
189}
190
191fn resolve_short_hash(store: &ObjectStore, prefix: &str) -> Result<Hash, RevError> {
195 let lower = prefix.to_ascii_lowercase();
196 let (shard, file_prefix) = lower.split_at(2);
202 let shard_dir = store.objects_root().join(shard);
203 let iter = match std::fs::read_dir(&shard_dir) {
204 Ok(i) => i,
205 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
206 return Err(RevError::Unknown(prefix.to_string()));
207 }
208 Err(e) => return Err(RevError::Backend(format!("scan objects: {e}"))),
209 };
210
211 let mut found: Option<Hash> = None;
212 for entry in iter {
213 let entry = entry.map_err(|e| RevError::Backend(format!("scan objects: {e}")))?;
214 let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
215 continue;
216 };
217 if name.len() != HEX_LEN - 2 || !name.starts_with(file_prefix) {
218 continue;
219 }
220 let full = format!("{shard}{name}");
221 let Ok(h) = hash::from_hex(&full) else {
222 continue;
223 };
224 if found.is_some() {
225 return Err(RevError::Ambiguous(prefix.to_string()));
226 }
227 found = Some(h);
228 }
229 found.ok_or_else(|| RevError::Unknown(prefix.to_string()))
230}
231
232fn walk_first_parent(
234 store: &ObjectStore,
235 spec: &str,
236 mut commit: Hash,
237 n: u32,
238) -> Result<Hash, RevError> {
239 for _ in 0..n {
240 let parents = parents_of(store, spec, &commit)?;
241 let Some(first) = parents.first() else {
242 return Err(RevError::BadSuffix {
243 spec: spec.to_string(),
244 detail: format!(
245 "commit {} has no parent (history root reached)",
246 hash::to_hex(&commit)
247 ),
248 });
249 };
250 commit = *first;
251 }
252 Ok(commit)
253}
254
255fn select_parent(store: &ObjectStore, spec: &str, commit: Hash, n: u32) -> Result<Hash, RevError> {
258 if n == 0 {
259 return Ok(commit);
260 }
261 let parents = parents_of(store, spec, &commit)?;
262 let idx = (n - 1) as usize;
263 parents
264 .get(idx)
265 .copied()
266 .ok_or_else(|| RevError::BadSuffix {
267 spec: spec.to_string(),
268 detail: format!(
269 "commit {} has no parent #{n} (only {} parent(s))",
270 hash::to_hex(&commit),
271 parents.len()
272 ),
273 })
274}
275
276fn parents_of(store: &ObjectStore, spec: &str, commit: &Hash) -> Result<Vec<Hash>, RevError> {
278 match store.read_object(commit) {
279 Ok(Object::Commit(c)) => Ok(c.parents),
280 Ok(Object::Remix(r)) => Ok(r.parents),
281 Ok(_) => Err(RevError::NotACommit(spec.to_string())),
282 Err(e) => Err(RevError::Backend(format!("read object: {e}"))),
283 }
284}
285
286fn parse_count(s: &str) -> (u32, usize) {
290 let digits: String = s.chars().take_while(char::is_ascii_digit).collect();
291 if digits.is_empty() {
292 (1, 0)
293 } else {
294 let n = digits.parse::<u32>().unwrap_or(u32::MAX);
298 (n, digits.len())
299 }
300}
301
302fn is_hex(s: &str) -> bool {
303 !s.is_empty() && s.bytes().all(|b| b.is_ascii_hexdigit())
304}
305
306#[cfg(test)]
307mod tests {
308 use super::*;
309 use mkit_core::object::{Commit, Identity, Object};
310 use mkit_core::refs;
311 use mkit_core::serialize;
312 use tempfile::TempDir;
313
314 fn author() -> Identity {
315 Identity::ed25519([0u8; 32])
316 }
317
318 fn fresh_repo() -> (TempDir, ObjectStore, RepoLayout) {
320 let dir = TempDir::new().unwrap();
321 let layout = RepoLayout::single(dir.path());
322 let store = ObjectStore::init(&layout).unwrap();
323 refs::init(&layout).unwrap();
324 (dir, store, layout)
325 }
326
327 fn write_commit(store: &ObjectStore, parents: Vec<Hash>, seed: u8) -> Hash {
329 let commit = Commit::new_unannotated(
330 [seed; 32],
331 parents,
332 author(),
333 [0u8; 32],
334 vec![seed],
335 u64::from(seed),
336 [0u8; 64],
337 );
338 let bytes = serialize::serialize(&Object::Commit(commit)).unwrap();
339 store.write(&bytes).unwrap()
340 }
341
342 #[test]
343 fn resolves_full_hash() {
344 let (_d, store, mkit) = fresh_repo();
345 let c = write_commit(&store, vec![], 1);
346 let hex = hash::to_hex(&c);
347 assert_eq!(resolve_revision(&store, &mkit, &hex).unwrap(), c);
348 }
349
350 #[test]
351 fn full_hash_not_in_store_is_unknown() {
352 let (_d, store, mkit) = fresh_repo();
353 let hex = "ab".repeat(32);
354 let err = resolve_revision(&store, &mkit, &hex).unwrap_err();
355 assert!(matches!(err, RevError::Unknown(_)));
356 }
357
358 #[test]
359 fn resolves_unambiguous_short_hash() {
360 let (_d, store, mkit) = fresh_repo();
361 let c = write_commit(&store, vec![], 7);
362 let hex = hash::to_hex(&c);
363 let short = &hex[..12];
364 assert_eq!(resolve_revision(&store, &mkit, short).unwrap(), c);
365 }
366
367 #[test]
368 fn ambiguous_short_hash_errors() {
369 let (_d, store, mkit) = fresh_repo();
370 let mut seen: std::collections::HashMap<String, Vec<u8>> = std::collections::HashMap::new();
379 let mut pair: Option<(String, Vec<u8>, Vec<u8>)> = None;
380 for i in 0u32..200_000 {
381 let bytes = serialize::serialize(&Object::Blob(mkit_core::object::Blob {
382 data: i.to_le_bytes().to_vec(),
383 }))
384 .unwrap();
385 let h = hash::hash(&bytes);
386 let prefix = hash::to_hex(&h)[..MIN_SHORT_HASH].to_string();
387 if let Some(prev) = seen.get(&prefix) {
388 pair = Some((prefix, prev.clone(), bytes));
389 break;
390 }
391 seen.insert(prefix, bytes);
392 }
393 let (prefix, a, b) = pair.expect("expected a 4-nibble prefix collision");
394 store.write(&a).unwrap();
395 store.write(&b).unwrap();
396 let err = resolve_revision(&store, &mkit, &prefix).unwrap_err();
397 assert!(matches!(err, RevError::Ambiguous(_)), "got {err:?}");
398 }
399
400 #[test]
401 fn short_hash_no_match_is_unknown() {
402 let (_d, store, mkit) = fresh_repo();
403 write_commit(&store, vec![], 3);
404 let err = resolve_revision(&store, &mkit, "ffffffff").unwrap_err();
406 assert!(matches!(err, RevError::Unknown(_)));
407 }
408
409 #[test]
410 fn resolves_branch_ref() {
411 let (_d, store, mkit) = fresh_repo();
412 let c = write_commit(&store, vec![], 5);
413 refs::write_ref(&mkit, "feature", &c).unwrap();
414 assert_eq!(resolve_revision(&store, &mkit, "feature").unwrap(), c);
415 }
416
417 #[test]
418 fn resolves_tag_ref() {
419 let (_d, store, mkit) = fresh_repo();
420 let c = write_commit(&store, vec![], 6);
421 refs::write_tag(&mkit, "v1.0", &c).unwrap();
422 assert_eq!(resolve_revision(&store, &mkit, "v1.0").unwrap(), c);
423 }
424
425 #[test]
426 fn resolves_head() {
427 let (_d, store, mkit) = fresh_repo();
428 let c = write_commit(&store, vec![], 9);
429 refs::write_ref(&mkit, "main", &c).unwrap();
430 assert_eq!(resolve_revision(&store, &mkit, "HEAD").unwrap(), c);
431 }
432
433 #[test]
434 fn resolves_head_tilde_n() {
435 let (_d, store, mkit) = fresh_repo();
436 let root = write_commit(&store, vec![], 1);
437 let mid = write_commit(&store, vec![root], 2);
438 let tip = write_commit(&store, vec![mid], 3);
439 refs::write_ref(&mkit, "main", &tip).unwrap();
440 assert_eq!(resolve_revision(&store, &mkit, "HEAD").unwrap(), tip);
441 assert_eq!(resolve_revision(&store, &mkit, "HEAD~1").unwrap(), mid);
442 assert_eq!(resolve_revision(&store, &mkit, "HEAD~2").unwrap(), root);
443 assert_eq!(resolve_revision(&store, &mkit, "HEAD~").unwrap(), mid);
445 }
446
447 #[test]
448 fn caret_selects_parent() {
449 let (_d, store, mkit) = fresh_repo();
450 let p1 = write_commit(&store, vec![], 1);
451 let p2 = write_commit(&store, vec![], 2);
452 let merge = write_commit(&store, vec![p1, p2], 3);
453 refs::write_ref(&mkit, "main", &merge).unwrap();
454 assert_eq!(resolve_revision(&store, &mkit, "HEAD^").unwrap(), p1);
455 assert_eq!(resolve_revision(&store, &mkit, "HEAD^1").unwrap(), p1);
456 assert_eq!(resolve_revision(&store, &mkit, "HEAD^2").unwrap(), p2);
457 assert_eq!(resolve_revision(&store, &mkit, "HEAD^0").unwrap(), merge);
458 }
459
460 #[test]
461 fn tilde_off_the_end_errors() {
462 let (_d, store, mkit) = fresh_repo();
463 let root = write_commit(&store, vec![], 1);
464 refs::write_ref(&mkit, "main", &root).unwrap();
465 let err = resolve_revision(&store, &mkit, "HEAD~1").unwrap_err();
466 assert!(matches!(err, RevError::BadSuffix { .. }));
467 }
468
469 #[test]
470 fn caret_past_parents_errors() {
471 let (_d, store, mkit) = fresh_repo();
472 let p1 = write_commit(&store, vec![], 1);
473 let c = write_commit(&store, vec![p1], 2);
474 refs::write_ref(&mkit, "main", &c).unwrap();
475 let err = resolve_revision(&store, &mkit, "HEAD^2").unwrap_err();
476 assert!(matches!(err, RevError::BadSuffix { .. }));
477 }
478
479 #[test]
480 fn unknown_ref_errors() {
481 let (_d, store, mkit) = fresh_repo();
482 let err = resolve_revision(&store, &mkit, "nope").unwrap_err();
483 assert!(matches!(err, RevError::Unknown(_)));
484 }
485
486 #[test]
487 fn too_short_prefix_is_unknown() {
488 let (_d, store, mkit) = fresh_repo();
489 let c = write_commit(&store, vec![], 1);
490 let hex = hash::to_hex(&c);
491 let err = resolve_revision(&store, &mkit, &hex[..3]).unwrap_err();
493 assert!(matches!(err, RevError::Unknown(_)));
494 }
495
496 #[test]
497 fn branch_wins_over_tag_on_collision() {
498 let (_d, store, mkit) = fresh_repo();
499 let branch_c = write_commit(&store, vec![], 1);
500 let tag_c = write_commit(&store, vec![], 2);
501 refs::write_ref(&mkit, "dup", &branch_c).unwrap();
502 refs::write_tag(&mkit, "dup", &tag_c).unwrap();
503 assert_eq!(resolve_revision(&store, &mkit, "dup").unwrap(), branch_c);
504 }
505}