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}