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