Skip to main content

mkit_cli/commands/
revspec.rs

1//! Shared revision-spec resolver (issue #227, parent #226).
2//!
3//! [`resolve_revision`] turns a user-supplied revision string into a
4//! single object [`Hash`](tyalias@mkit_core::Hash). It is the keystone the diff/checkout/
5//! cherry-pick/bisect commands build on so they all accept the same
6//! grammar instead of each one hand-rolling a `hash::from_hex` call.
7//!
8//! Accepted grammar (a deliberately small subset of `git rev-parse`):
9//!
10//! - **Full hash** — exactly 64 hex chars (lower or upper), present in
11//!   the object store.
12//! - **Short hash** — a hex prefix of at least [`MIN_SHORT_HASH`] chars
13//!   that unambiguously names exactly one object in the store. An
14//!   ambiguous prefix (≥ 2 matches) is an error; a prefix matching none
15//!   is an error.
16//! - **Ref name** — a branch (`refs/heads/<name>`), a tag
17//!   (`refs/tags/<name>`), or the literal `HEAD`. Branches win over
18//!   tags on a name collision (matching the precedence the existing
19//!   `checkout` command used).
20//! - **Suffix navigation** — a `<base>` from any of the above followed
21//!   by zero or more `~n` / `^` / `^n` steps walking the commit's
22//!   first-parent chain. `~n` walks `n` first-parents (`~` alone == `~1`);
23//!   `^` / `^n` selects the n-th parent (`^` == `^1`, 1-based). `^0` is
24//!   the commit itself. `HEAD~2`, `main^`, `<hash>~1^2` all parse.
25//!
26//! The resolver intentionally does NOT accept pathspecs, `:/text`
27//! message searches, reflog (`@{n}`) syntax, or ranges — `A..B` ranges
28//! are split by the caller (`diff`) before each end reaches here.
29
30use 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
36/// Minimum length of an accepted short-hash prefix. Shorter prefixes are
37/// rejected as too-ambiguous-by-construction rather than scanned, so a
38/// stray 1–3 char token (e.g. a typo'd ref) fails fast.
39pub const MIN_SHORT_HASH: usize = 4;
40
41/// Errors raised while resolving a revision string.
42#[derive(Debug, thiserror::Error)]
43pub enum RevError {
44    /// The base token matched no ref and no object.
45    #[error("unknown revision '{0}'")]
46    Unknown(String),
47    /// A short-hash prefix matched more than one object.
48    #[error("ambiguous short hash '{0}' matches multiple objects")]
49    Ambiguous(String),
50    /// A `~`/`^` suffix walked off the end of the commit graph.
51    #[error("revision '{spec}': {detail}")]
52    BadSuffix {
53        /// The full spec the user supplied.
54        spec: String,
55        /// What specifically went wrong.
56        detail: String,
57    },
58    /// The base resolved but the suffix navigation hit a non-commit
59    /// object (a tree/blob cannot have parents).
60    #[error("revision '{0}' resolves to a non-commit object; cannot walk parents")]
61    NotACommit(String),
62    /// Underlying ref / store failure.
63    #[error("{0}")]
64    Backend(String),
65}
66
67/// Resolve `spec` to a single object [`Hash`](tyalias@mkit_core::Hash).
68///
69/// See the module docstring for the accepted grammar.
70///
71/// # Errors
72/// Returns [`RevError`] when the spec is unknown, ambiguous, or the
73/// suffix navigation is invalid.
74pub fn resolve_revision(
75    store: &ObjectStore,
76    layout: &RepoLayout,
77    spec: &str,
78) -> Result<Hash, RevError> {
79    // Split the base from any trailing `~`/`^` navigation. The base is
80    // everything up to the first `~` or `^`.
81    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    // Walk the suffix steps left-to-right.
90    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
115/// Resolve the base token (no `~`/`^` suffix) to a hash: ref, then
116/// full/short hash.
117fn resolve_base(store: &ObjectStore, layout: &RepoLayout, base: &str) -> Result<Hash, RevError> {
118    // 1. HEAD.
119    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    // 2. Explicit full ref paths: refs/heads/<b>, refs/tags/<t>,
128    //    refs/remotes/<r>/<b>. These are unambiguous and checked
129    //    before any short-form guessing.
130    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    // 3. Branch (refs/heads), then tag (refs/tags). `read_ref` /
152    //    `read_tag` validate the name, returning `InvalidRefName` for a
153    //    bad ref name — which we treat as "not a ref" and fall through
154    //    to hash parsing (a bare hash is not a valid ref name anyway).
155    // The grammar only, so a branch named before SPEC-REFS §3 bounded
156    // names still resolves.
157    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        // 3b. Remote-tracking short form `<remote>/<branch>` (git's
165        //     resolution order also places this after heads/tags).
166        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    // 4. Full 64-hex hash that exists in the store.
174    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    // 5. Short hex prefix.
184    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
191/// Find the unique object whose hex starts with `prefix`. Errors on
192/// zero matches ([`RevError::Unknown`]) or ≥ 2 matches
193/// ([`RevError::Ambiguous`]).
194fn resolve_short_hash(store: &ObjectStore, prefix: &str) -> Result<Hash, RevError> {
195    let lower = prefix.to_ascii_lowercase();
196    // Object layout: `objects/<2-hex>/<62-hex>`. The first two hex chars
197    // (when present) name the shard directory, so we only scan one
198    // shard. With a 1-char prefix (below MIN_SHORT_HASH, never reached)
199    // we would have to scan 16 shards; the >= MIN_SHORT_HASH gate makes
200    // the 2-char shard slice always available.
201    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
232/// Walk `n` first-parents from `commit`.
233fn 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
255/// Select the `n`-th parent of `commit` (1-based). `n == 0` is the
256/// commit itself.
257fn 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
276/// Read the parent list of a commit-or-remix object.
277fn 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
286/// Parse a leading decimal count from `s`, returning `(count, consumed)`.
287/// An absent count (next char is not a digit, or `s` is empty) yields
288/// `(1, 0)` — matching `git`'s `~` == `~1` and `^` == `^1`.
289fn 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        // Saturate on overflow: a `~99999999999` walk would hit the
295        // history root and error long before the count mattered, but we
296        // must not panic on parse.
297        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    /// Build a throwaway repo with an object store + ref dir.
319    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    /// Write a commit object with the given parents and return its hash.
328    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        // Mine — in memory, hashing only — two blobs whose object hashes
371        // share their first MIN_SHORT_HASH hex chars, then write JUST
372        // those two to the store and query with the shared prefix. A
373        // 4-nibble (16-bit) collision is found within a few hundred
374        // candidates by the birthday bound, and we avoid fsync'ing
375        // thousands of objects to disk. The two serialized payloads
376        // resolve to the two object hashes (the store address is the
377        // BLAKE3 of the serialized bytes).
378        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        // "ffff" almost certainly does not prefix our single commit.
405        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        // `~` alone == `~1`.
444        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        // A 3-char prefix is below MIN_SHORT_HASH and not a valid ref.
492        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}