Skip to main content

grit_lib/porcelain/
log.rs

1//! `git log` ref decoration as structured data.
2//!
3//! `git log --decorate` / the `%d` and `%(decorate:…)` pretty placeholders attach
4//! the refs that point at each commit (branches, tags, remote-tracking branches,
5//! the stash, replace refs, and `HEAD`). This module computes that attachment as a
6//! **colour-free [`DecorationMap`]** — for every commit hex, the ordered list of
7//! [`DecorationItem`]s pointing at it — exactly the way Git's `log-tree.c` builds
8//! its decoration list.
9//!
10//! Following the [library/CLI contract](crate::porcelain), the library produces
11//! only the structured model: which ref decorates which commit, its display name,
12//! and its [`DecorationKind`]. The `grit` binary owns everything presentational —
13//! mapping a `DecorationKind` to a `color.decorate.*` sequence, folding
14//! `HEAD -> branch`, the parentheses/separators of `%d`, and reading the clap args
15//! that build the [`DecorationFilter`]. The bulk of `grit log` (rev-walk, pretty
16//! formatting, the pager, graph drawing) still lives in
17//! `grit/src/commands/log.rs`; this module is the first colour-free slice of it.
18
19use std::collections::{HashMap, HashSet};
20
21use crate::change_id::{resolved_change_id, ChangeId};
22use crate::config::{parse_bool, ConfigSet};
23use crate::error::Result;
24use crate::ident::Signature;
25use crate::objects::{parse_commit, CommitData, ObjectId, ObjectKind, OidHashMap, OidHashSet};
26use crate::odb::Odb;
27use crate::refs::{PackedPeel, PackedRefPeels};
28use crate::repo::Repository;
29use crate::state::{resolve_head_for_repository, HeadState};
30
31/// Decoration category for `git log --decorate` colouring (`color.decorate.*`).
32#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
33#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
34#[cfg_attr(feature = "serde-models", serde(rename_all = "snake_case"))]
35pub enum DecorationKind {
36    /// Local branch (`refs/heads/*`).
37    Branch,
38    /// Remote-tracking branch (`refs/remotes/*`).
39    RemoteBranch,
40    /// Annotated or lightweight tag (`refs/tags/*`).
41    Tag,
42    /// Stash ref (`refs/stash`).
43    Stash,
44    /// Synthetic `HEAD` label when decorating the current tip.
45    Head,
46    /// Replace/graft decoration for rewritten history.
47    Grafted,
48    /// A ref outside the known namespaces (e.g. `refs/hidden/*`, `refs/prefetch/*`).
49    /// Git renders these with `DECORATION_NONE` (reset color) and the full refname.
50    /// Only decorated when an explicit `--decorate-refs[-exclude]` / `log.excludeDecoration`
51    /// filter is active (or `--clear-decorations`), matching `set_default_decoration_filter`.
52    Other,
53}
54
55/// One ref (or synthetic label) attached to a commit for `--decorate` / `%d`.
56#[derive(Clone, Debug)]
57pub struct DecorationItem {
58    /// Full ref name when this came from a real ref (used for `HEAD -> branch` folding).
59    pub refname: Option<String>,
60    /// Short or formatted label shown inside parentheses in `%d` output.
61    pub display: String,
62    /// Category used by the CLI to pick `color.decorate.*` sequences.
63    pub kind: DecorationKind,
64}
65
66/// Per-commit-hex decoration lists, keyed by 40-char commit hex.
67pub type DecorationMap = HashMap<String, Vec<DecorationItem>>;
68
69/// One ref label attached to a commit for structured log/show JSON.
70#[derive(Clone, Debug, PartialEq, Eq)]
71#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
72pub struct CommitRefDecoration {
73    /// Display name (short branch/tag label or full ref when `--decorate=full`).
74    pub name: String,
75    /// Decoration category (`branch`, `tag`, `HEAD`, …).
76    pub kind: DecorationKind,
77}
78
79/// Commit metadata loaded once per oid for porcelain JSON (`log`, `show`, `revs`, …).
80#[derive(Clone, Debug, PartialEq, Eq)]
81#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
82pub struct LoadedCommit {
83    /// Commit object id.
84    pub oid: ObjectId,
85    /// Parent commit ids (empty for root commits).
86    pub parents: Vec<ObjectId>,
87    /// Author identity, parsed with [`Signature::from_header_lossy`] so a malformed
88    /// date reads as the Unix epoch rather than failing the load.
89    pub author: Signature,
90    /// Committer identity, parsed like [`Self::author`].
91    pub committer: Signature,
92    /// Full commit message body (headers excluded).
93    pub message: String,
94    /// Ref decorations pointing at this commit.
95    pub refs: Vec<CommitRefDecoration>,
96    /// Stable change id (stored header or derived from commit id).
97    pub change_id: ChangeId,
98    /// `true` when the id came from a `change-id` commit header.
99    pub change_id_stored: bool,
100}
101
102/// The `--decorate-refs` / `--decorate-refs-exclude` / `log.excludeDecoration`
103/// ref filter. The CLI builds this from clap args + config (`build_decoration_filter`);
104/// the library only applies it.
105#[derive(Default, Clone)]
106pub struct DecorationFilter {
107    /// Ref patterns from `--decorate-refs` (must match to decorate).
108    pub include: Vec<String>,
109    /// Ref patterns from `--decorate-refs-exclude`.
110    pub exclude: Vec<String>,
111    /// Excludes from `log.excludeDecoration` config (same effect as `exclude`,
112    /// but a matching `--decorate-refs` include overrides them, matching Git).
113    pub exclude_config: Vec<String>,
114}
115
116impl DecorationFilter {
117    /// True when no include/exclude patterns are configured.
118    pub fn is_empty(&self) -> bool {
119        self.include.is_empty() && self.exclude.is_empty() && self.exclude_config.is_empty()
120    }
121
122    /// Mirror Git's `ref_filter_match` (log-tree.c). Returns true if `refname`
123    /// (a full ref like `refs/heads/foo`) should be decorated.
124    pub fn matches(&self, refname: &str) -> bool {
125        // `--decorate-refs-exclude` (command line) is checked first.
126        for pat in &self.exclude {
127            if decoration_pattern_matches(pat, refname) {
128                return false;
129            }
130        }
131        // An explicit `--decorate-refs` include is decisive and overrides the
132        // config-based `log.excludeDecoration` exclusions (matching Git's
133        // `ref_filter_match` ordering).
134        if !self.include.is_empty() {
135            for pat in &self.include {
136                if decoration_pattern_matches(pat, refname) {
137                    return true;
138                }
139            }
140            return false;
141        }
142        // `log.excludeDecoration` only applies when there is no include list.
143        for pat in &self.exclude_config {
144            if decoration_pattern_matches(pat, refname) {
145                return false;
146            }
147        }
148        true
149    }
150}
151
152/// Normalize a `--decorate-refs[-exclude]` / `log.excludeDecoration` pattern the
153/// way Git's `normalize_glob_ref` (refs.c) does: prepend `refs/` unless it already
154/// starts with `refs/` or is exactly `HEAD`, then strip a single trailing `/`.
155pub fn normalize_glob_ref(pattern: &str) -> String {
156    let mut out = String::new();
157    if !pattern.starts_with("refs/") && pattern != "HEAD" {
158        out.push_str("refs/");
159    }
160    out.push_str(pattern);
161    if out.ends_with('/') {
162        out.pop();
163    }
164    out
165}
166
167/// Match a single (already-normalized) `--decorate-refs[-exclude]` pattern against
168/// a full refname, mirroring Git's `match_ref_pattern` (log-tree.c): glob patterns
169/// use `wildmatch`; non-glob patterns match as a prefix at a component boundary.
170fn decoration_pattern_matches(pattern: &str, refname: &str) -> bool {
171    let has_glob = pattern.bytes().any(|b| matches!(b, b'*' | b'?' | b'['));
172    if has_glob {
173        crate::wildmatch::wildmatch(pattern.as_bytes(), refname.as_bytes(), 0)
174    } else {
175        // `skip_prefix(refname, pattern, &rest) && (!*rest || *rest == '/')`.
176        if let Some(rest) = refname.strip_prefix(pattern) {
177            rest.is_empty() || rest.starts_with('/')
178        } else {
179            false
180        }
181    }
182}
183
184fn replace_ref_base() -> String {
185    let mut base = crate::environment::Environment::empty()
186        .var("GIT_REPLACE_REF_BASE")
187        .unwrap_or_else(|| "refs/replace/".to_owned());
188    if !base.ends_with('/') {
189        base.push('/');
190    }
191    base
192}
193
194fn prepend_decoration(items: &mut Vec<DecorationItem>, item: DecorationItem) {
195    items.insert(0, item);
196}
197
198/// Collect ref decorations from the repository (heads, tags, remotes, stash, replace refs, HEAD).
199///
200/// Order matches Git's `refs_for_each_ref` walk: each ref is **prepended** in ascending ref-name
201/// order, so the final per-commit list matches upstream (e.g. `tag: A1` before `other/main` before
202/// `other/HEAD`).
203pub fn collect_decorations(repo: &Repository, full: bool) -> Result<DecorationMap> {
204    collect_decorations_inner(repo, full, false, &DecorationFilter::default())
205}
206
207/// Load commit metadata for `oids`, attaching decorations from a pre-built [`DecorationMap`].
208///
209/// Reads each commit object at most once. Call [`collect_decorations`] once per command
210/// and reuse the map across all rows.
211///
212/// # Errors
213///
214/// Returns ODB and commit-parse errors when an oid is missing or corrupt.
215pub fn load_commits_with_decorations(
216    repo: &Repository,
217    oids: &[ObjectId],
218    decorations: &DecorationMap,
219) -> Result<Vec<LoadedCommit>> {
220    load_commits_with_decorations_reusing(
221        repo,
222        oids,
223        decorations,
224        &mut OidHashMap::<CommitData>::default(),
225    )
226}
227
228/// Commits a caller already holds, handed out once each so loaders skip the object read.
229///
230/// Implemented for maps of parsed commits (such as
231/// [`RevListResult::commit_data`](crate::revwalk::rev_list::RevListResult::commit_data)) and for
232/// [`WalkedCommits`](crate::revwalk::log_walk::WalkedCommits), which parses on demand.
233pub trait CommitReuse {
234    /// Remove the commit `oid` from this source and return it parsed, or [`None`] when the
235    /// source does not hold it.
236    ///
237    /// # Errors
238    ///
239    /// Returns commit-parse errors for sources that hold unparsed objects.
240    fn take_commit(&mut self, oid: &ObjectId) -> Option<Result<CommitData>>;
241}
242
243impl CommitReuse for OidHashMap<CommitData> {
244    fn take_commit(&mut self, oid: &ObjectId) -> Option<Result<CommitData>> {
245        self.remove(oid).map(Ok)
246    }
247}
248
249impl<T: CommitReuse + ?Sized> CommitReuse for &mut T {
250    fn take_commit(&mut self, oid: &ObjectId) -> Option<Result<CommitData>> {
251        (**self).take_commit(oid)
252    }
253}
254
255/// Like [`load_commits_with_decorations`], but takes commits the caller already holds (for
256/// example [`crate::revwalk::rev_list::RevListResult::commit_data`] or
257/// [`crate::revwalk::log_walk::LogWalkPage::commit_data`]) out of `parsed` instead of reading
258/// them again. Commits missing from `parsed` are read from the object database.
259///
260/// # Parameters
261///
262/// - `repo` - repository to read commits missing from `parsed` from.
263/// - `oids` - commits to load, in output order.
264/// - `decorations` - ref labels from [`collect_decorations`].
265/// - `parsed` - commits by id; entries for `oids` are moved out.
266///
267/// # Errors
268///
269/// Returns ODB and commit-parse errors when an oid is missing or corrupt.
270pub fn load_commits_with_decorations_reusing(
271    repo: &Repository,
272    oids: &[ObjectId],
273    decorations: &DecorationMap,
274    parsed: &mut impl CommitReuse,
275) -> Result<Vec<LoadedCommit>> {
276    let odb = repo.odb();
277    let mut out = Vec::with_capacity(oids.len());
278    for oid in oids {
279        let data = match parsed.take_commit(oid) {
280            Some(data) => data?,
281            None => parse_commit(&odb.read(oid)?.data)?,
282        };
283        let resolved = resolved_change_id(oid, &data);
284        let hex = oid.to_hex();
285        let refs = decorations
286            .get(&hex)
287            .map(|items| {
288                items
289                    .iter()
290                    .map(|item| CommitRefDecoration {
291                        name: item.display.clone(),
292                        kind: item.kind,
293                    })
294                    .collect()
295            })
296            .unwrap_or_default();
297        out.push(LoadedCommit {
298            oid: *oid,
299            parents: data.parents,
300            author: Signature::from_header_lossy(&data.author),
301            committer: Signature::from_header_lossy(&data.committer),
302            message: data.message,
303            refs,
304            change_id: resolved.id,
305            change_id_stored: resolved.stored,
306        });
307    }
308    Ok(out)
309}
310
311/// Like [`collect_decorations`], but only for the given commits.
312///
313/// The result equals [`collect_decorations`] restricted to the keys of `commits`. Refs
314/// whose `packed-refs` peel line names one of them need no object reads, and refs
315/// pointing elsewhere are skipped instead of peeled, so a command that shows a few
316/// commits does not pay for every tag in the repository.
317///
318/// # Parameters
319///
320/// - `full`: show full ref names (`refs/heads/main`) instead of short ones.
321/// - `commits`: commit ids whose decorations are wanted.
322///
323/// # Errors
324///
325/// Returns ref listing, `HEAD` resolution, and object read errors, like
326/// [`collect_decorations`].
327pub fn collect_decorations_for_commits(
328    repo: &Repository,
329    full: bool,
330    commits: &[ObjectId],
331) -> Result<DecorationMap> {
332    let targets: OidHashSet = commits.iter().copied().collect();
333    collect_decorations_impl(
334        repo,
335        full,
336        false,
337        &DecorationFilter::default(),
338        Some(&targets),
339    )
340}
341
342/// Like [`collect_decorations`], but `clear_decorations` removes the default ref
343/// filter so refs that are normally hidden (e.g. `refs/notes/*`) also get
344/// decorated (matches `git log --clear-decorations`).
345pub fn collect_decorations_inner(
346    repo: &Repository,
347    full: bool,
348    clear_decorations: bool,
349    filter: &DecorationFilter,
350) -> Result<DecorationMap> {
351    collect_decorations_impl(repo, full, clear_decorations, filter, None)
352}
353
354/// Shared body of the decoration collectors; `targets` limits the result to those commits.
355fn collect_decorations_impl(
356    repo: &Repository,
357    full: bool,
358    clear_decorations: bool,
359    filter: &DecorationFilter,
360    targets: Option<&OidHashSet>,
361) -> Result<DecorationMap> {
362    let mut map: DecorationMap = HashMap::new();
363    let git_dir = &repo.git_dir();
364
365    let head = resolve_head_for_repository(repo)?;
366    let hide_remote_update_noise = repo
367        .config()
368        .unwrap_or_else(|_| std::sync::Arc::new(ConfigSet::new()))
369        .get("grit.submoduleUpdateRemoteDecorations")
370        .as_deref()
371        .and_then(|value| parse_bool(value).ok())
372        .unwrap_or(false);
373    let rep_base = replace_ref_base();
374
375    let mut all_refs = match targets {
376        _ if !repo.refs_on_disk() => crate::refs::list_refs_for_repository(repo, "refs/")?,
377        // Packed refs whose recorded peel already rules out every target are skipped while
378        // reading `packed-refs`; the loop below would drop them anyway.
379        Some(targets) => {
380            crate::refs::list_refs_filtering_packed(git_dir, "refs/", &|name, oid, peel| {
381                name.starts_with(&rep_base)
382                    || match peel {
383                        PackedPeel::Peeled(peeled) => targets.contains(&peeled),
384                        PackedPeel::NotTag => targets.contains(oid),
385                        PackedPeel::Unknown => true,
386                    }
387            })?
388        }
389        None => crate::refs::list_refs(git_dir, "refs/")?,
390    };
391    all_refs.sort_by(|a, b| a.0.cmp(&b.0));
392    let peeler = RefTipPeeler::new(repo, targets);
393
394    for (refname, oid) in all_refs {
395        // Apply `--decorate-refs` / `--decorate-refs-exclude` / `log.excludeDecoration`.
396        if !filter.is_empty() && !refname.starts_with(&rep_base) && !filter.matches(&refname) {
397            continue;
398        }
399        if refname.starts_with(&rep_base) {
400            let Some(rest) = refname.strip_prefix(&rep_base) else {
401                continue;
402            };
403            let rest = rest.trim();
404            if rest.len() != 40 {
405                continue;
406            }
407            let Ok(replaced) = rest.parse::<ObjectId>() else {
408                continue;
409            };
410            if targets.is_some_and(|t| !t.contains(&replaced)) {
411                continue;
412            }
413            prepend_decoration(
414                map.entry(rest.to_owned()).or_default(),
415                DecorationItem {
416                    refname: None,
417                    display: "replaced".to_owned(),
418                    kind: DecorationKind::Grafted,
419                },
420            );
421            continue;
422        }
423
424        if refname == "refs/stash" || refname.starts_with("refs/stash/") {
425            let Some(hex) = peeler.commit_hex(&refname, &oid) else {
426                continue;
427            };
428            prepend_decoration(
429                map.entry(hex).or_default(),
430                DecorationItem {
431                    refname: Some("refs/stash".to_string()),
432                    display: "refs/stash".to_owned(),
433                    kind: DecorationKind::Stash,
434                },
435            );
436            continue;
437        }
438
439        if let Some(rest) = refname.strip_prefix("refs/heads/") {
440            let Some(hex) = peeler.commit_hex(&refname, &oid) else {
441                continue;
442            };
443            let display = if full {
444                refname.clone()
445            } else {
446                rest.to_owned()
447            };
448            prepend_decoration(
449                map.entry(hex).or_default(),
450                DecorationItem {
451                    refname: Some(refname.clone()),
452                    display,
453                    kind: DecorationKind::Branch,
454                },
455            );
456            continue;
457        }
458
459        if let Some(rest) = refname.strip_prefix("refs/tags/") {
460            let Some(peeled) = peeler.commit_hex(&refname, &oid) else {
461                continue;
462            };
463            let display = if full {
464                refname.clone()
465            } else {
466                rest.to_owned()
467            };
468            prepend_decoration(
469                map.entry(peeled).or_default(),
470                DecorationItem {
471                    refname: Some(refname.clone()),
472                    display,
473                    kind: DecorationKind::Tag,
474                },
475            );
476            continue;
477        }
478
479        if let Some(rest) = refname.strip_prefix("refs/remotes/") {
480            let Some(peeled) = peeler.commit_hex(&refname, &oid) else {
481                continue;
482            };
483            let display = if full {
484                refname.clone()
485            } else {
486                rest.to_owned()
487            };
488            prepend_decoration(
489                map.entry(peeled).or_default(),
490                DecorationItem {
491                    refname: Some(refname.clone()),
492                    display,
493                    kind: DecorationKind::RemoteBranch,
494                },
495            );
496            continue;
497        }
498
499        // Refs outside the known namespaces (e.g. `refs/hidden/*`, `refs/prefetch/*`,
500        // `refs/notes/commits`). Git's `set_default_decoration_filter` (builtin/log.c)
501        // only restricts decorations to the known namespaces when NO `--decorate-refs`,
502        // `--decorate-refs-exclude`, or `log.excludeDecoration` is given. As soon as any
503        // such filter is active (or `--clear-decorations` is set), the default include
504        // list is dropped and every ref that passes the filter is decorated with its full
505        // name and `DECORATION_NONE` (reset) color (log-tree.c `add_ref_decoration`).
506        if clear_decorations || !filter.is_empty() {
507            let Some(peeled) = peeler.commit_hex(&refname, &oid) else {
508                continue;
509            };
510            prepend_decoration(
511                map.entry(peeled).or_default(),
512                DecorationItem {
513                    refname: Some(refname.clone()),
514                    display: refname.clone(),
515                    kind: DecorationKind::Other,
516                },
517            );
518        }
519    }
520
521    if let Some(oid) = head
522        .oid()
523        .filter(|oid| targets.is_none_or(|t| t.contains(*oid)))
524    {
525        if filter.is_empty() || filter.matches("HEAD") {
526            let hex = oid.to_hex();
527            prepend_decoration(
528                map.entry(hex).or_default(),
529                DecorationItem {
530                    refname: Some("HEAD".to_string()),
531                    display: "HEAD".to_owned(),
532                    kind: DecorationKind::Head,
533                },
534            );
535        }
536    }
537
538    for items in map.values_mut() {
539        // Dedup truly identical decorations (same refname). A tag and a branch
540        // that happen to share a short name (e.g. `tag: reach` and `reach`) are
541        // distinct and both shown, matching Git.
542        let mut seen = HashSet::new();
543        items.retain(|it| seen.insert((it.kind, it.display.clone(), it.refname.clone())));
544        if hide_remote_update_noise {
545            let branch_names: HashSet<String> = items
546                .iter()
547                .filter(|it| it.kind == DecorationKind::Branch)
548                .map(|it| it.display.clone())
549                .collect();
550            if !branch_names.is_empty() {
551                let hide_detached_head = !matches!(head, HeadState::Branch { .. });
552                items.retain(|it| {
553                    if hide_detached_head && it.kind == DecorationKind::Head {
554                        return false;
555                    }
556                    if it.kind == DecorationKind::RemoteBranch {
557                        let short_remote = it
558                            .display
559                            .split_once('/')
560                            .map(|(_, branch)| branch)
561                            .unwrap_or(it.display.as_str());
562                        return !branch_names.contains(short_remote) && short_remote != "HEAD";
563                    }
564                    true
565                });
566            }
567        }
568    }
569
570    Ok(map)
571}
572
573/// Peel an object (possibly a tag) down to a commit and return its hex.
574///
575/// Returns `None` when `hex` is not an object id, the object is missing or unreadable, or it
576/// peels to a tree or blob. Typed callers should use
577/// [`Odb::peel_to_commit`](crate::odb::Odb::peel_to_commit) directly.
578pub fn peel_to_commit_hex(odb: &Odb, hex: &str) -> Option<String> {
579    let oid: ObjectId = hex.parse().ok()?;
580    odb.peel_to_commit(oid)
581        .ok()
582        .flatten()
583        .map(|commit| commit.to_hex())
584}
585
586/// Computes decoration keys for ref tips: the commit a tip peels to, or the tip itself
587/// when it does not peel to a commit.
588///
589/// When `packed-refs` records a ref's peeled value the tag objects are not read. The
590/// peeled object is confirmed to be a commit through the commit-graph when it lists the
591/// object, and otherwise from the object header.
592///
593/// With `targets`, only keys among those commits are wanted: a tip whose recorded peel
594/// is a target is a commit by definition, and other tips are skipped without reads
595/// whenever that already decides the answer.
596struct RefTipPeeler<'a> {
597    repo: &'a Repository,
598    packed: Option<PackedRefPeels>,
599    graph: std::cell::OnceCell<Option<std::sync::Arc<crate::commit_graph_file::CommitGraphChain>>>,
600    targets: Option<&'a OidHashSet>,
601    pack_context: std::cell::OnceCell<crate::pack_store::PackReadContextGuard>,
602}
603
604impl<'a> RefTipPeeler<'a> {
605    fn new(repo: &'a Repository, targets: Option<&'a OidHashSet>) -> Self {
606        // A malformed `packed-refs` only loses the shortcut; peeling falls back to the objects.
607        let packed = PackedRefPeels::for_store(repo.refs(), repo.git_dir()).ok();
608        Self {
609            repo,
610            packed,
611            graph: std::cell::OnceCell::new(),
612            targets,
613            pack_context: std::cell::OnceCell::new(),
614        }
615    }
616
617    fn is_commit(&self, oid: &ObjectId) -> bool {
618        let graph = self.graph.get_or_init(|| {
619            self.repo
620                .caches()
621                .commit_graph_chain(self.repo.odb().objects_dir())
622        });
623        if graph.as_ref().is_some_and(|g| g.find_commit(oid).is_some()) {
624            return true;
625        }
626        self.repo
627            .odb()
628            .read_info(oid)
629            .is_ok_and(|info| info.kind == ObjectKind::Commit)
630    }
631
632    /// Decoration key for the tip `oid` of `refname`, or `None` when it is not a target.
633    fn commit_hex(&self, refname: &str, oid: &ObjectId) -> Option<String> {
634        let key = self.key(refname, oid)?;
635        if self.targets.is_some_and(|t| !t.contains(&key)) {
636            return None;
637        }
638        Some(key.to_hex())
639    }
640
641    fn key(&self, refname: &str, oid: &ObjectId) -> Option<ObjectId> {
642        match self.packed.as_ref().and_then(|p| p.peeled(refname, oid)) {
643            Some(Some(peeled)) => {
644                if let Some(targets) = self.targets {
645                    // Otherwise the key is `peeled` (not a target) or the tag itself.
646                    return targets.contains(&peeled).then_some(peeled);
647                }
648                return Some(if self.is_commit(&peeled) {
649                    peeled
650                } else {
651                    *oid
652                });
653            }
654            Some(None) => return Some(*oid),
655            None => {}
656        }
657        if self.targets.is_some_and(|t| t.contains(oid)) {
658            return Some(*oid);
659        }
660        // Keep tag header/body reads together, but leave the packed-ref-only path alone.
661        self.pack_context
662            .get_or_init(|| self.repo.odb().pack_read_context_guard());
663        Some(
664            self.repo
665                .odb()
666                .peel_to_commit(*oid)
667                .ok()
668                .flatten()
669                .unwrap_or(*oid),
670        )
671    }
672}
673
674/// Index of the branch decoration that should fold into `HEAD -> branch`, if any.
675///
676/// Returns the position of the current-branch [`DecorationItem`] in `items` when a
677/// `HEAD` decoration is actually present (so the CLI can render `HEAD -> branch`),
678/// or `None` when HEAD is detached or was filtered out.
679pub fn current_branch_decoration_index(
680    items: &[DecorationItem],
681    head: &HeadState,
682) -> Option<usize> {
683    let refname = match head {
684        HeadState::Branch { refname, .. } => refname.as_str(),
685        _ => return None,
686    };
687    // Only fold the branch into `HEAD -> branch` when the HEAD decoration is
688    // actually present. If HEAD was filtered out (e.g. by `--decorate-refs`),
689    // the branch must render on its own.
690    if !items.iter().any(|it| it.kind == DecorationKind::Head) {
691        return None;
692    }
693    items
694        .iter()
695        .position(|it| it.kind == DecorationKind::Branch && it.refname.as_deref() == Some(refname))
696}