Skip to main content

grit_lib/
object_inspect.rs

1//! Revision resolution metadata and typed object contents for display (`resolve` / `cat`).
2//!
3//! [`Repository::resolve`] resolves a user spec to an object id plus kind and size.
4//! [`Repository::read_object_for_display`] loads and parses object bytes for programmatic use.
5//!
6//! ```rust
7//! use grit_lib::object_inspect::ResolveError;
8//! use grit_lib::repo::{InitOptions, Repository};
9//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
10//! let dir = tempfile::tempdir()?;
11//! let repo = Repository::init(dir.path(), &InitOptions::default())?;
12//! match repo.resolve("HEAD") {
13//!     Ok(_resolved) => {}
14//!     Err(ResolveError::UnknownRevision { .. }) => {}
15//!     Err(e) => return Err(e.into()),
16//! }
17//! # Ok(())
18//! # }
19//! ```
20
21use thiserror::Error;
22
23use crate::diagnostics::Warning;
24use crate::error::{Error, Result};
25use crate::objects::odb::store::ObjectStream;
26use crate::objects::{
27    parse_commit, parse_tag, parse_tree, CommitData, Object, ObjectId, ObjectInfo, ObjectKind,
28    TagData, TreeEntry,
29};
30use crate::refs::store::RawRef;
31use crate::refs::{
32    expand_ref_dwim_full_name, shorten_unambiguous, warn_ambiguous_refs_from_config,
33};
34use crate::repo::Repository;
35use crate::rev_parse::{
36    abbreviate_object_id, ambiguous_object_hint_lines, expand_at_minus_to_branch_name,
37    parse_peel_suffix, resolve_revision, resolve_revision_for_verify,
38    resolve_upstream_symbolic_name, split_double_dot_range, split_triple_dot_range,
39    upstream_suffix_info,
40};
41use crate::rev_parse_error::{AmbiguousChangeIdHint, AmbiguousObjectHint, RevParseError};
42
43/// Failure resolving a revision spec for object inspection.
44#[derive(Debug, Error)]
45#[non_exhaustive]
46pub enum ResolveError {
47    /// The spec does not name a known revision or object.
48    #[error("unknown revision '{spec}'")]
49    UnknownRevision {
50        /// User revision string.
51        spec: String,
52    },
53
54    /// A short hex prefix matches more than one object and could not be disambiguated.
55    #[error("ambiguous short object id '{spec}'")]
56    AmbiguousShortObject {
57        /// User revision string.
58        spec: String,
59        /// Candidate objects in Git hint order (type, then hex).
60        candidates: Vec<AmbiguousObjectHint>,
61    },
62
63    /// A change-id prefix matches more than one commit.
64    #[error("ambiguous change id '{spec}'")]
65    AmbiguousChangeId {
66        /// User revision string.
67        spec: String,
68        /// Matching commits (oid, change id, subject).
69        candidates: Vec<AmbiguousChangeIdHint>,
70    },
71
72    /// A path component is missing from the tree named by the revision.
73    #[error("path '{path}' does not exist in '{revision}'")]
74    PathNotInTree {
75        /// Path that is missing from the tree.
76        path: String,
77        /// Revision label for the tree that was searched.
78        revision: String,
79    },
80
81    /// The spec names a revision range or set, not a single object (`git rev-parse --verify`).
82    #[error("revision range or set '{spec}' is not a single object")]
83    RevisionRange {
84        /// User revision string.
85        spec: String,
86    },
87
88    /// The resolved object is not of the expected kind.
89    #[error("object '{spec}' is a {found}, not a {expected}")]
90    WrongObjectKind {
91        /// User revision string.
92        spec: String,
93        /// Kind that was resolved.
94        found: ObjectKind,
95        /// Kind the caller required.
96        expected: ObjectKind,
97    },
98
99    /// Reflog `@{date}` inner text could not be parsed as a date.
100    #[error("invalid reflog date '{date}'")]
101    ReflogDateUnparseable {
102        /// Text inside `@{...}` that was treated as a date selector.
103        date: String,
104    },
105}
106
107impl From<RevParseError> for ResolveError {
108    fn from(value: RevParseError) -> Self {
109        match value {
110            RevParseError::PathNotInRevision { path, revision }
111            | RevParseError::PathOnDiskNotInRevision { path, revision } => {
112                ResolveError::PathNotInTree { path, revision }
113            }
114            RevParseError::InvalidObjectName { name } => {
115                ResolveError::UnknownRevision { spec: name }
116            }
117            RevParseError::AmbiguousChangeId { prefix, candidates } => {
118                ResolveError::AmbiguousChangeId {
119                    spec: prefix,
120                    candidates,
121                }
122            }
123            RevParseError::ReflogDateUnparseable { date } => {
124                ResolveError::ReflogDateUnparseable { date }
125            }
126            other => ResolveError::UnknownRevision {
127                spec: other.to_string(),
128            },
129        }
130    }
131}
132
133/// Full and shortened ref names for a spec (`rev-parse --symbolic-full-name` / `--abbrev-ref`).
134#[derive(Debug, Clone, PartialEq, Eq)]
135pub struct ResolvedRefName {
136    /// Canonical full ref name (for example `refs/heads/main`).
137    pub full: String,
138    /// Shortest unambiguous short name (for example `main`).
139    pub short: String,
140}
141
142/// Result of resolving one revision spec to a concrete object.
143#[derive(Debug, Clone, PartialEq, Eq)]
144pub struct ResolvedObject {
145    /// Resolved object id (after peel suffixes in the spec).
146    pub oid: ObjectId,
147    /// Kind of the resolved object.
148    pub kind: ObjectKind,
149    /// Uncompressed object payload size in bytes.
150    pub size: u64,
151    /// Short ref name when the spec named a ref directly (for example `main`, `v1.0`).
152    pub ref_name: Option<String>,
153    /// Full ref name when the spec named a ref directly (for example `refs/heads/main`).
154    pub full_ref: Option<String>,
155}
156
157/// Parsed object payload for display or serialization.
158#[derive(Debug, Clone)]
159pub enum ObjectContents {
160    /// Raw blob bytes.
161    Blob(Vec<u8>),
162    /// Tree entries in Git sort order.
163    Tree(Vec<TreeEntry>),
164    /// Parsed commit headers and message.
165    Commit(CommitData),
166    /// Parsed annotated tag.
167    Tag(TagData),
168}
169
170/// A branch or tag label for a revision, independent of its display format.
171///
172/// The label describes how a name was supplied, rather than the kind of object
173/// it resolves to. A tag can, for example, point to a blob or another tag.
174#[derive(Debug, Clone)]
175pub enum RevisionLabel {
176    /// A local branch, including the branch named by symbolic `HEAD`.
177    Branch {
178        /// Branch name without `refs/heads/`.
179        name: String,
180    },
181    /// A lightweight or annotated tag.
182    Tag {
183        /// Ref name without `refs/tags/`, which can differ from the tag header.
184        name: String,
185        /// Parsed tag object, or `None` for a lightweight tag.
186        annotation: Option<TagData>,
187    },
188}
189
190impl Repository {
191    /// Classify `spec` as a short local branch or tag name for display.
192    ///
193    /// Symbolic `HEAD` labels its local branch, including an unborn branch;
194    /// detached `HEAD` has no label. Otherwise, short branch names take precedence
195    /// over short tag names. Other inputs return `None` unless they themselves
196    /// name a short branch or tag. This does not interpret revision expressions
197    /// or fully qualified ref names; use [`Self::resolve`] to resolve a revision.
198    ///
199    /// Uses this repository's ref store, including injected backends.
200    /// Annotated tags retain their raw metadata and message, without peeling
201    /// the target or trimming whitespace. Ref lookup failures are treated as
202    /// absent labels, so callers can resolve the revision independently.
203    ///
204    /// # Errors
205    ///
206    /// Returns an object-store error if a named tag's object cannot be read, or
207    /// [`Error::CorruptObject`] if an annotated tag cannot be parsed.
208    #[inline]
209    pub fn revision_label(&self, spec: &str) -> Result<Option<RevisionLabel>> {
210        if spec == "HEAD" {
211            return Ok(crate::refs::read_symbolic_ref_for_repository(self, "HEAD")
212                .ok()
213                .flatten()
214                .and_then(|target| {
215                    target
216                        .strip_prefix("refs/heads/")
217                        .map(|name| RevisionLabel::Branch {
218                            name: name.to_owned(),
219                        })
220                }));
221        }
222
223        if self.refs().resolve(&format!("refs/heads/{spec}")).is_ok() {
224            return Ok(Some(RevisionLabel::Branch {
225                name: spec.to_owned(),
226            }));
227        }
228
229        let Ok(oid) = self.refs().resolve(&format!("refs/tags/{spec}")) else {
230            return Ok(None);
231        };
232        let object = self.odb().read(&oid)?;
233        let annotation = if object.kind == ObjectKind::Tag {
234            Some(parse_tag(&object.data)?)
235        } else {
236            None
237        };
238        Ok(Some(RevisionLabel::Tag {
239            name: spec.to_owned(),
240            annotation,
241        }))
242    }
243
244    /// Resolve `spec` to an object id, kind, and size.
245    ///
246    /// Supports the same revision forms as [`crate::revwalk::rev_parse::resolve_revision`]
247    /// (for example `HEAD~2`, `v1^{tree}`, `main:path`, `@{1}`, abbreviated oids).
248    ///
249    /// # Errors
250    ///
251    /// Returns [`ResolveError::UnknownRevision`] when the spec cannot be resolved,
252    /// [`ResolveError::AmbiguousShortObject`] when a short oid is ambiguous, and
253    /// [`ResolveError::PathNotInTree`] when a `rev:path` path is missing.
254    pub fn resolve(&self, spec: &str) -> std::result::Result<ResolvedObject, ResolveError> {
255        let oid = match resolve_revision(self, spec) {
256            Ok(o) => o,
257            Err(Error::ObjectNotFound(_)) => {
258                return Err(ResolveError::UnknownRevision {
259                    spec: spec.to_owned(),
260                });
261            }
262            Err(Error::RevParse(RevParseError::AmbiguousShortObject { spec: prefix })) => {
263                let candidates =
264                    ambiguous_object_hint_lines(self, &prefix, None).unwrap_or_default();
265                return Err(ResolveError::AmbiguousShortObject {
266                    spec: spec.to_owned(),
267                    candidates,
268                });
269            }
270            Err(Error::RevParse(RevParseError::AmbiguousChangeId { prefix, candidates })) => {
271                return Err(ResolveError::AmbiguousChangeId {
272                    spec: prefix,
273                    candidates,
274                });
275            }
276            Err(Error::RevParse(e)) => return Err(e.into()),
277            Err(_) => {
278                return Err(ResolveError::UnknownRevision {
279                    spec: spec.to_owned(),
280                });
281            }
282        };
283
284        let info = self
285            .read_info_replaced(&oid)
286            .map_err(|_| ResolveError::UnknownRevision {
287                spec: spec.to_owned(),
288            })?;
289        let ref_names = infer_ref_names(self, spec, oid);
290        Ok(ResolvedObject {
291            oid,
292            kind: info.kind,
293            size: info.size,
294            ref_name: ref_names.as_ref().map(|n| n.short.clone()),
295            full_ref: ref_names.map(|n| n.full),
296        })
297    }
298
299    /// Strict single-object resolution for plumbing verify (`git rev-parse --verify`).
300    ///
301    /// Accepts one spec that names one existing object, honoring `^{type}` peels. Rejects
302    /// revision ranges (`A..B`, `A...B`) and revision-set excludes (`^A`). Verifies the object exists
303    /// in the object database (full hex ids must refer to stored objects, not merely parse).
304    ///
305    /// # Errors
306    ///
307    /// Returns [`ResolveError::RevisionRange`] for range or set syntax,
308    /// [`ResolveError::UnknownRevision`] when the spec cannot be resolved or the object is missing,
309    /// [`ResolveError::AmbiguousShortObject`] for ambiguous abbreviated ids, and
310    /// [`ResolveError::WrongObjectKind`] when `expected` is set and the peeled kind differs.
311    pub fn verify_object(
312        &self,
313        spec: &str,
314        expected: Option<ObjectKind>,
315    ) -> std::result::Result<ObjectId, ResolveError> {
316        if split_double_dot_range(spec).is_some()
317            || split_triple_dot_range(spec).is_some()
318            || is_revision_set_exclude_spec(spec)
319        {
320            return Err(ResolveError::RevisionRange {
321                spec: spec.to_owned(),
322            });
323        }
324
325        let oid = match resolve_revision_for_verify(self, spec) {
326            Ok(o) => o,
327            Err(Error::ObjectNotFound(_)) => {
328                return Err(ResolveError::UnknownRevision {
329                    spec: spec.to_owned(),
330                });
331            }
332            Err(Error::RevParse(RevParseError::AmbiguousShortObject { spec: prefix })) => {
333                let candidates =
334                    ambiguous_object_hint_lines(self, &prefix, None).unwrap_or_default();
335                return Err(ResolveError::AmbiguousShortObject {
336                    spec: spec.to_owned(),
337                    candidates,
338                });
339            }
340            Err(Error::RevParse(RevParseError::AmbiguousChangeId { prefix, candidates })) => {
341                return Err(ResolveError::AmbiguousChangeId {
342                    spec: prefix,
343                    candidates,
344                });
345            }
346            Err(Error::RevParse(e)) => return Err(e.into()),
347            Err(_) => {
348                return Err(ResolveError::UnknownRevision {
349                    spec: spec.to_owned(),
350                });
351            }
352        };
353
354        let info = self
355            .read_info_replaced(&oid)
356            .map_err(|_| ResolveError::UnknownRevision {
357                spec: spec.to_owned(),
358            })?;
359
360        if let Some(want) = expected {
361            if info.kind != want {
362                return Err(ResolveError::WrongObjectKind {
363                    spec: spec.to_owned(),
364                    found: info.kind,
365                    expected: want,
366                });
367            }
368        }
369
370        Ok(oid)
371    }
372
373    /// Resolve `spec` to ref names when it names a ref (`rev-parse --abbrev-ref` / `--symbolic-full-name`).
374    ///
375    /// Returns `Ok(None)` when the spec does not name a ref (for example a commit oid or `HEAD^{tree}`).
376    ///
377    /// # Errors
378    ///
379    /// When DWIM finds multiple refs and `core.warnAmbiguousRefs` is enabled, emits
380    /// [`Warning::AmbiguousRefname`] and returns `Ok(None)` (matching `git rev-parse` stdout).
381    /// Other [`RevParseError`] variants cover invalid upstream / `@{-N}` specs.
382    pub fn resolve_ref_name(
383        &self,
384        spec: &str,
385    ) -> std::result::Result<Option<ResolvedRefName>, RevParseError> {
386        let strict = warn_ambiguous_refs_from_config(self);
387        if upstream_suffix_info(spec).is_some() {
388            let full = resolve_upstream_symbolic_name(self, spec).map_err(map_resolve_ref_error)?;
389            let short = shorten_unambiguous(self, &full, strict).map_err(map_resolve_ref_error)?;
390            return Ok(Some(ResolvedRefName { full, short }));
391        }
392        if let Some(branch) =
393            expand_at_minus_to_branch_name(self, spec).map_err(map_resolve_ref_error)?
394        {
395            let full = format!("refs/heads/{branch}");
396            if self.resolve_ref_oid(&full).is_err() {
397                return Ok(None);
398            }
399            let short = shorten_unambiguous(self, &full, strict).map_err(map_resolve_ref_error)?;
400            return Ok(Some(ResolvedRefName { full, short }));
401        }
402        let (count, full) = expand_ref_dwim_full_name(self, spec).map_err(map_resolve_ref_error)?;
403        if count == 0 {
404            if spec == "HEAD" {
405                match self
406                    .refs()
407                    .read_raw("HEAD")
408                    .map_err(map_resolve_ref_error)?
409                {
410                    Some(RawRef::Direct(_)) => {
411                        return Ok(Some(ResolvedRefName {
412                            full: "HEAD".to_owned(),
413                            short: "HEAD".to_owned(),
414                        }));
415                    }
416                    Some(RawRef::Symbolic(target)) if self.resolve_ref_oid(&target).is_err() => {
417                        return Ok(None);
418                    }
419                    Some(RawRef::Symbolic(_)) | None => {}
420                }
421            }
422            return Ok(None);
423        }
424        if count > 1 && strict {
425            self.warn(Warning::AmbiguousRefname {
426                spec: spec.to_owned(),
427            });
428            return Ok(None);
429        }
430        let full = full.ok_or_else(|| RevParseError::AmbiguousRefname {
431            spec: spec.to_owned(),
432        })?;
433        let short = shorten_unambiguous(self, &full, strict).map_err(map_resolve_ref_error)?;
434        Ok(Some(ResolvedRefName { full, short }))
435    }
436
437    /// Read uncompressed object bytes for display, following replace refs when enabled.
438    ///
439    /// # Errors
440    ///
441    /// Returns [`Error::ObjectNotFound`] when the object is missing.
442    pub fn read_object_raw_for_display(&self, oid: &ObjectId) -> Result<Object> {
443        self.read_replaced(oid)
444    }
445
446    /// Object kind and size for display, following replace refs when enabled.
447    ///
448    /// `oid` names the original object. Returns metadata for its replacement when
449    /// replacement is enabled, without loading loose or whole packed blob payloads.
450    ///
451    /// # Errors
452    ///
453    /// Returns [`Error::ObjectNotFound`] when the object is missing.
454    pub fn read_object_info_for_display(&self, oid: &ObjectId) -> Result<ObjectInfo> {
455        self.read_info_replaced(oid)
456    }
457
458    /// Stream an object's payload for display, following replace refs when enabled.
459    ///
460    /// `oid` names the original object and `f` consumes the effective object's stream.
461    /// Returns `Ok(Some(result))` with the callback's result, or `Ok(None)` when the
462    /// object is missing (without calling `f`). Loose and whole packed objects are
463    /// inflated as the callback reads; pack deltas are reconstructed in memory.
464    ///
465    /// # Errors
466    ///
467    /// Propagates backend failures and errors from `f`. Corrupt or truncated payloads
468    /// surface as [`std::io::Error`] values when reading the stream.
469    pub fn with_object_stream_for_display<R>(
470        &self,
471        oid: &ObjectId,
472        f: impl FnOnce(ObjectStream<'_>) -> Result<R>,
473    ) -> Result<Option<R>> {
474        let effective = self.object_id_after_replace(oid);
475        self.odb().with_stream(&effective, f)
476    }
477
478    /// Read and parse the object named by `oid` for display.
479    ///
480    /// # Errors
481    ///
482    /// Returns [`Error::ObjectNotFound`] when the object is missing and
483    /// [`Error::CorruptObject`] when parsing fails.
484    pub fn read_object_for_display(&self, oid: &ObjectId) -> Result<ObjectContents> {
485        let obj = self.read_object_raw_for_display(oid)?;
486        match obj.kind {
487            ObjectKind::Blob => Ok(ObjectContents::Blob(obj.data)),
488            ObjectKind::Tree => Ok(ObjectContents::Tree(parse_tree(&obj.data)?)),
489            ObjectKind::Commit => Ok(ObjectContents::Commit(parse_commit(&obj.data)?)),
490            ObjectKind::Tag => Ok(ObjectContents::Tag(parse_tag(&obj.data)?)),
491        }
492    }
493}
494
495/// Format a tree entry mode for `git cat-file -p` output (six-digit tree modes).
496#[must_use]
497pub fn tree_entry_mode_cat_file_p(mode: u32) -> String {
498    if mode == 0o040000 {
499        "040000".to_owned()
500    } else {
501        format!("{:o}", mode)
502    }
503}
504
505/// Object type label for one tree entry in `git cat-file -p` / `ls-tree` output.
506///
507/// Git derives the type word from the mode bits (`object_type()` in Git's tree-walk code),
508/// not from reading the referenced object.
509#[must_use]
510pub fn tree_entry_cat_file_type(mode: u32) -> &'static str {
511    match mode & 0o170_000 {
512        0o040_000 => "tree",
513        0o160_000 => "commit",
514        _ => "blob",
515    }
516}
517
518/// Format parsed tree entries like `git cat-file -p` (one line per entry).
519#[must_use]
520pub fn format_tree_entries_cat_file_p(entries: &[TreeEntry]) -> Vec<u8> {
521    let mut out = Vec::new();
522    for e in entries {
523        out.extend_from_slice(tree_entry_mode_cat_file_p(e.mode).as_bytes());
524        out.push(b' ');
525        out.extend_from_slice(tree_entry_cat_file_type(e.mode).as_bytes());
526        out.push(b' ');
527        out.extend_from_slice(e.oid.to_hex().as_bytes());
528        out.push(b'\t');
529        out.extend_from_slice(&e.name);
530        out.push(b'\n');
531    }
532    out
533}
534
535/// Format a tree object's payload like `git cat-file -p` (one line per entry).
536///
537/// # Errors
538///
539/// Returns [`Error::CorruptObject`] when `data` is not a valid tree.
540pub fn format_tree_cat_file_p(data: &[u8]) -> Result<Vec<u8>> {
541    let entries = parse_tree(data)?;
542    Ok(format_tree_entries_cat_file_p(&entries))
543}
544
545#[cfg(test)]
546mod tree_entry_cat_file_type_tests {
547    use super::tree_entry_cat_file_type;
548
549    #[test]
550    fn mode_mask_labels_match_git() {
551        assert_eq!(tree_entry_cat_file_type(0o040_000), "tree");
552        assert_eq!(tree_entry_cat_file_type(0o040_755), "tree");
553        assert_eq!(tree_entry_cat_file_type(0o160_000), "commit");
554        assert_eq!(tree_entry_cat_file_type(0o100_644), "blob");
555        assert_eq!(tree_entry_cat_file_type(0o100_755), "blob");
556        assert_eq!(tree_entry_cat_file_type(0o120_000), "blob");
557        assert_eq!(tree_entry_cat_file_type(0o100_664), "blob");
558    }
559}
560
561/// Abbreviate `oid` for human output using repository uniqueness rules.
562pub fn abbreviate_for_display(repo: &Repository, oid: ObjectId, short: bool) -> Result<String> {
563    if short {
564        abbreviate_object_id(repo, oid, 7)
565    } else {
566        Ok(oid.to_hex())
567    }
568}
569
570fn map_resolve_ref_error(err: Error) -> RevParseError {
571    match err {
572        Error::RevParse(e) => e,
573        other => RevParseError::InvalidObjectName {
574            name: other.to_string(),
575        },
576    }
577}
578
579/// Leading `^` revision-set exclude (`^ref`), not parent shorthand (`rev^` / `rev^1`).
580fn is_revision_set_exclude_spec(spec: &str) -> bool {
581    spec.starts_with('^')
582}
583
584fn infer_ref_names(repo: &Repository, spec: &str, oid: ObjectId) -> Option<ResolvedRefName> {
585    let rev_part = spec.split(':').next().unwrap_or(spec);
586    let (base, _) = parse_peel_suffix(rev_part);
587    if base.is_empty() || looks_like_abbrev_hex(base, repo) {
588        return None;
589    }
590    let resolved = match repo.resolve_ref_name(base) {
591        Ok(v) => v?,
592        Err(RevParseError::InvalidObjectName { .. }) => return None,
593        Err(_) => return None,
594    };
595    if repo.resolve_ref_oid(&resolved.full).ok() == Some(oid) {
596        Some(resolved)
597    } else {
598        None
599    }
600}
601
602fn looks_like_abbrev_hex(base: &str, repo: &Repository) -> bool {
603    if base.len() >= repo.odb().hash_algo().hex_len() {
604        return base.chars().all(|c| c.is_ascii_hexdigit());
605    }
606    base.len() >= 4 && base.chars().all(|c| c.is_ascii_hexdigit())
607}
608
609#[cfg(test)]
610mod tests {
611    use super::*;
612
613    #[test]
614    fn resolve_error_from_path_not_in_revision() {
615        let err = ResolveError::from(RevParseError::PathNotInRevision {
616            path: "missing.txt".into(),
617            revision: "HEAD".into(),
618        });
619        assert!(matches!(err, ResolveError::PathNotInTree { .. }));
620    }
621
622    #[test]
623    fn tree_entry_mode_cat_file_p_uses_six_digit_tree_mode() {
624        assert_eq!(tree_entry_mode_cat_file_p(0o040000), "040000");
625        assert_eq!(tree_entry_mode_cat_file_p(0o100644), "100644");
626    }
627    fn label_repo() -> (tempfile::TempDir, Repository, ObjectId) {
628        let dir = tempfile::tempdir().expect("tempdir");
629        let repo = Repository::init(
630            dir.path(),
631            &crate::repo::InitOptions::new().with_initial_branch("main"),
632        )
633        .expect("init");
634        let tree = repo.odb().write(ObjectKind::Tree, b"").expect("tree");
635        let commit = repo.odb().write(ObjectKind::Commit, format!(
636            "tree {tree}\nauthor A <a@example.com> 1 +0000\ncommitter A <a@example.com> 1 +0000\n\ninitial\n"
637        ).as_bytes()).expect("commit");
638        crate::refs::write_ref(repo.git_dir(), "refs/heads/main", &commit).expect("branch");
639        (dir, repo, commit)
640    }
641
642    #[test]
643    fn revision_labels_distinguish_names_from_expressions() {
644        let (_dir, repo, oid) = label_repo();
645        for spec in ["HEAD", "main"] {
646            assert!(matches!(repo.revision_label(spec).expect("label"),
647                Some(RevisionLabel::Branch { name }) if name == "main"));
648        }
649        for spec in [
650            oid.to_hex().as_str(),
651            "HEAD~1",
652            "refs/heads/main",
653            "missing",
654        ] {
655            assert!(
656                repo.revision_label(spec).expect("label").is_none(),
657                "{spec}"
658            );
659        }
660        crate::refs::write_ref(repo.git_dir(), "HEAD", &oid).expect("detach");
661        assert!(repo
662            .revision_label("HEAD")
663            .expect("detached label")
664            .is_none());
665        crate::refs::write_symbolic_ref(repo.git_dir(), "HEAD", "refs/tags/main")
666            .expect("non-branch HEAD");
667        assert!(repo
668            .revision_label("HEAD")
669            .expect("non-branch label")
670            .is_none());
671    }
672
673    #[test]
674    fn revision_label_keeps_unborn_branch() {
675        let dir = tempfile::tempdir().expect("tempdir");
676        let repo = Repository::init(
677            dir.path(),
678            &crate::repo::InitOptions::new().with_initial_branch("new"),
679        )
680        .expect("init");
681        assert!(matches!(repo.revision_label("HEAD").expect("label"),
682            Some(RevisionLabel::Branch { name }) if name == "new"));
683        assert!(repo
684            .revision_label("new")
685            .expect("missing branch")
686            .is_none());
687    }
688
689    #[test]
690    fn revision_label_preserves_tag_metadata_and_branch_precedence() {
691        let (_dir, repo, oid) = label_repo();
692        crate::refs::write_ref(repo.git_dir(), "refs/tags/main", &oid).expect("tag");
693        assert!(matches!(
694            repo.revision_label("main").expect("branch wins"),
695            Some(RevisionLabel::Branch { .. })
696        ));
697        crate::refs::write_ref(repo.git_dir(), "refs/tags/light", &oid).expect("tag");
698        assert!(
699            matches!(repo.revision_label("light").expect("lightweight tag"),
700            Some(RevisionLabel::Tag { name, annotation: None }) if name == "light")
701        );
702
703        let blob = repo
704            .odb()
705            .write(ObjectKind::Blob, b"content")
706            .expect("blob");
707        let tag = repo
708            .odb()
709            .write(
710                ObjectKind::Tag,
711                format!("object {blob}\ntype blob\ntag original\n\nmessage\n\n").as_bytes(),
712            )
713            .expect("tag object");
714        crate::refs::write_ref(repo.git_dir(), "refs/tags/alias", &tag).expect("tag ref");
715        let Some(RevisionLabel::Tag {
716            name,
717            annotation: Some(tag),
718        }) = repo.revision_label("alias").expect("annotated tag")
719        else {
720            panic!("expected annotation");
721        };
722        assert_eq!(name, "alias");
723        assert_eq!(tag.tag, "original");
724        assert_eq!(tag.object, blob);
725        assert_eq!(tag.object_type, "blob");
726        assert_eq!(tag.tagger, None);
727        assert_eq!(tag.message, "message\n\n");
728    }
729
730    #[test]
731    fn revision_label_propagates_tag_object_errors() {
732        let (_dir, repo, _oid) = label_repo();
733        let broken = repo
734            .odb()
735            .write(ObjectKind::Tag, b"invalid")
736            .expect("broken object");
737        crate::refs::write_ref(repo.git_dir(), "refs/tags/broken", &broken).expect("tag ref");
738        assert!(matches!(
739            repo.revision_label("broken"),
740            Err(Error::CorruptObject(_))
741        ));
742        let missing = ObjectId::from_hex("1111111111111111111111111111111111111111").expect("oid");
743        let dangling = crate::refs::store::RefTransaction::new()
744            .update(crate::refs::store::RefUpdate {
745                name: "refs/tags/missing".to_owned(),
746                new_value: Some(RawRef::Direct(missing)),
747                expected: crate::refs::store::Expected::Any,
748                reflog: None,
749                flags: crate::refs::store::RefUpdateFlags {
750                    skip_oid_verification: true,
751                    ..Default::default()
752                },
753            })
754            .expect("txn");
755        repo.refs()
756            .prepare(dangling)
757            .and_then(|prepared| prepared.commit())
758            .expect("dangling tag ref");
759        assert!(matches!(
760            repo.revision_label("missing"),
761            Err(Error::ObjectNotFound(_))
762        ));
763    }
764    #[test]
765    fn revision_labels_use_injected_ref_store() {
766        use crate::refs::store::MemoryRefStore;
767        use std::sync::Arc;
768
769        let (_dir, repo, oid) = label_repo();
770        let refs = Arc::new(MemoryRefStore::new());
771        refs.set_ref("HEAD", RawRef::Symbolic("refs/heads/virtual".into()))
772            .expect("HEAD");
773        refs.set_ref("refs/heads/virtual", RawRef::Direct(oid))
774            .expect("branch");
775        refs.set_ref("refs/tags/virtual-tag", RawRef::Direct(oid))
776            .expect("tag");
777        let repo = repo.with_ref_store(refs);
778        for spec in ["HEAD", "virtual"] {
779            assert!(matches!(repo.revision_label(spec).expect("branch label"),
780                Some(RevisionLabel::Branch { name }) if name == "virtual"));
781        }
782        assert!(
783            matches!(repo.revision_label("virtual-tag").expect("tag label"),
784            Some(RevisionLabel::Tag { name, annotation: None }) if name == "virtual-tag")
785        );
786        assert!(repo
787            .revision_label("main")
788            .expect("disk branch is excluded")
789            .is_none());
790    }
791}