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