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