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