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 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 => repo.read_commit_with_replace(oid)?,
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
619 .graph
620 .get_or_init(|| self.repo.odb().commit_graph_chain());
621 if graph.as_ref().is_some_and(|g| g.find_commit(oid).is_some()) {
622 return true;
623 }
624 self.repo
625 .odb()
626 .read_info(oid)
627 .is_ok_and(|info| info.kind == ObjectKind::Commit)
628 }
629
630 /// Decoration key for the tip `oid` of `refname`, or `None` when it is not a target.
631 fn commit_hex(&self, refname: &str, oid: &ObjectId) -> Option<String> {
632 let key = self.key(refname, oid)?;
633 if self.targets.is_some_and(|t| !t.contains(&key)) {
634 return None;
635 }
636 Some(key.to_hex())
637 }
638
639 fn key(&self, refname: &str, oid: &ObjectId) -> Option<ObjectId> {
640 match self.packed.as_ref().and_then(|p| p.peeled(refname, oid)) {
641 Some(Some(peeled)) => {
642 if let Some(targets) = self.targets {
643 // Otherwise the key is `peeled` (not a target) or the tag itself.
644 return targets.contains(&peeled).then_some(peeled);
645 }
646 return Some(if self.is_commit(&peeled) {
647 peeled
648 } else {
649 *oid
650 });
651 }
652 Some(None) => return Some(*oid),
653 None => {}
654 }
655 if self.targets.is_some_and(|t| t.contains(oid)) {
656 return Some(*oid);
657 }
658 // Keep tag header/body reads together, but leave the packed-ref-only path alone.
659 self.pack_context
660 .get_or_init(|| self.repo.odb().pack_read_context_guard());
661 Some(
662 self.repo
663 .odb()
664 .peel_to_commit(*oid)
665 .ok()
666 .flatten()
667 .unwrap_or(*oid),
668 )
669 }
670}
671
672/// Index of the branch decoration that should fold into `HEAD -> branch`, if any.
673///
674/// Returns the position of the current-branch [`DecorationItem`] in `items` when a
675/// `HEAD` decoration is actually present (so the CLI can render `HEAD -> branch`),
676/// or `None` when HEAD is detached or was filtered out.
677pub fn current_branch_decoration_index(
678 items: &[DecorationItem],
679 head: &HeadState,
680) -> Option<usize> {
681 let refname = match head {
682 HeadState::Branch { refname, .. } => refname.as_str(),
683 _ => return None,
684 };
685 // Only fold the branch into `HEAD -> branch` when the HEAD decoration is
686 // actually present. If HEAD was filtered out (e.g. by `--decorate-refs`),
687 // the branch must render on its own.
688 if !items.iter().any(|it| it.kind == DecorationKind::Head) {
689 return None;
690 }
691 items
692 .iter()
693 .position(|it| it.kind == DecorationKind::Branch && it.refname.as_deref() == Some(refname))
694}