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}