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::ident::Signature;
25use crate::objects::{parse_commit, CommitData, ObjectId, ObjectKind, OidHashMap, OidHashSet};
26use crate::odb::Odb;
27use crate::refs::{PackedPeel, PackedRefPeels};
28use crate::repo::Repository;
29use crate::state::{resolve_head_for_repository, HeadState};
30
31/// Decoration category for `git log --decorate` colouring (`color.decorate.*`).
32#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
33#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
34#[cfg_attr(feature = "serde-models", serde(rename_all = "snake_case"))]
35pub enum DecorationKind {
36 /// Local branch (`refs/heads/*`).
37 Branch,
38 /// Remote-tracking branch (`refs/remotes/*`).
39 RemoteBranch,
40 /// Annotated or lightweight tag (`refs/tags/*`).
41 Tag,
42 /// Stash ref (`refs/stash`).
43 Stash,
44 /// Synthetic `HEAD` label when decorating the current tip.
45 Head,
46 /// Replace/graft decoration for rewritten history.
47 Grafted,
48 /// A ref outside the known namespaces (e.g. `refs/hidden/*`, `refs/prefetch/*`).
49 /// Git renders these with `DECORATION_NONE` (reset color) and the full refname.
50 /// Only decorated when an explicit `--decorate-refs[-exclude]` / `log.excludeDecoration`
51 /// filter is active (or `--clear-decorations`), matching `set_default_decoration_filter`.
52 Other,
53}
54
55/// One ref (or synthetic label) attached to a commit for `--decorate` / `%d`.
56#[derive(Clone, Debug)]
57pub struct DecorationItem {
58 /// Full ref name when this came from a real ref (used for `HEAD -> branch` folding).
59 pub refname: Option<String>,
60 /// Short or formatted label shown inside parentheses in `%d` output.
61 pub display: String,
62 /// Category used by the CLI to pick `color.decorate.*` sequences.
63 pub kind: DecorationKind,
64}
65
66/// Per-commit-hex decoration lists, keyed by 40-char commit hex.
67pub type DecorationMap = HashMap<String, Vec<DecorationItem>>;
68
69/// One ref label attached to a commit for structured log/show JSON.
70#[derive(Clone, Debug, PartialEq, Eq)]
71#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
72pub struct CommitRefDecoration {
73 /// Display name (short branch/tag label or full ref when `--decorate=full`).
74 pub name: String,
75 /// Decoration category (`branch`, `tag`, `HEAD`, …).
76 pub kind: DecorationKind,
77}
78
79/// Commit metadata loaded once per oid for porcelain JSON (`log`, `show`, `revs`, …).
80#[derive(Clone, Debug, PartialEq, Eq)]
81#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
82pub struct LoadedCommit {
83 /// Commit object id.
84 pub oid: ObjectId,
85 /// Parent commit ids (empty for root commits).
86 pub parents: Vec<ObjectId>,
87 /// Author identity, parsed with [`Signature::from_header_lossy`] so a malformed
88 /// date reads as the Unix epoch rather than failing the load.
89 pub author: Signature,
90 /// Committer identity, parsed like [`Self::author`].
91 pub committer: Signature,
92 /// Full commit message body (headers excluded).
93 pub message: String,
94 /// Ref decorations pointing at this commit.
95 pub refs: Vec<CommitRefDecoration>,
96 /// Stable change id (stored header or derived from commit id).
97 pub change_id: ChangeId,
98 /// `true` when the id came from a `change-id` commit header.
99 pub change_id_stored: bool,
100}
101
102/// The `--decorate-refs` / `--decorate-refs-exclude` / `log.excludeDecoration`
103/// ref filter. The CLI builds this from clap args + config (`build_decoration_filter`);
104/// the library only applies it.
105#[derive(Default, Clone)]
106pub struct DecorationFilter {
107 /// Ref patterns from `--decorate-refs` (must match to decorate).
108 pub include: Vec<String>,
109 /// Ref patterns from `--decorate-refs-exclude`.
110 pub exclude: Vec<String>,
111 /// Excludes from `log.excludeDecoration` config (same effect as `exclude`,
112 /// but a matching `--decorate-refs` include overrides them, matching Git).
113 pub exclude_config: Vec<String>,
114}
115
116impl DecorationFilter {
117 /// True when no include/exclude patterns are configured.
118 pub fn is_empty(&self) -> bool {
119 self.include.is_empty() && self.exclude.is_empty() && self.exclude_config.is_empty()
120 }
121
122 /// Mirror Git's `ref_filter_match` (log-tree.c). Returns true if `refname`
123 /// (a full ref like `refs/heads/foo`) should be decorated.
124 pub fn matches(&self, refname: &str) -> bool {
125 // `--decorate-refs-exclude` (command line) is checked first.
126 for pat in &self.exclude {
127 if decoration_pattern_matches(pat, refname) {
128 return false;
129 }
130 }
131 // An explicit `--decorate-refs` include is decisive and overrides the
132 // config-based `log.excludeDecoration` exclusions (matching Git's
133 // `ref_filter_match` ordering).
134 if !self.include.is_empty() {
135 for pat in &self.include {
136 if decoration_pattern_matches(pat, refname) {
137 return true;
138 }
139 }
140 return false;
141 }
142 // `log.excludeDecoration` only applies when there is no include list.
143 for pat in &self.exclude_config {
144 if decoration_pattern_matches(pat, refname) {
145 return false;
146 }
147 }
148 true
149 }
150}
151
152/// Normalize a `--decorate-refs[-exclude]` / `log.excludeDecoration` pattern the
153/// way Git's `normalize_glob_ref` (refs.c) does: prepend `refs/` unless it already
154/// starts with `refs/` or is exactly `HEAD`, then strip a single trailing `/`.
155pub fn normalize_glob_ref(pattern: &str) -> String {
156 let mut out = String::new();
157 if !pattern.starts_with("refs/") && pattern != "HEAD" {
158 out.push_str("refs/");
159 }
160 out.push_str(pattern);
161 if out.ends_with('/') {
162 out.pop();
163 }
164 out
165}
166
167/// Match a single (already-normalized) `--decorate-refs[-exclude]` pattern against
168/// a full refname, mirroring Git's `match_ref_pattern` (log-tree.c): glob patterns
169/// use `wildmatch`; non-glob patterns match as a prefix at a component boundary.
170fn decoration_pattern_matches(pattern: &str, refname: &str) -> bool {
171 let has_glob = pattern.bytes().any(|b| matches!(b, b'*' | b'?' | b'['));
172 if has_glob {
173 crate::wildmatch::wildmatch(pattern.as_bytes(), refname.as_bytes(), 0)
174 } else {
175 // `skip_prefix(refname, pattern, &rest) && (!*rest || *rest == '/')`.
176 if let Some(rest) = refname.strip_prefix(pattern) {
177 rest.is_empty() || rest.starts_with('/')
178 } else {
179 false
180 }
181 }
182}
183
184fn replace_ref_base() -> String {
185 let mut base = crate::environment::Environment::empty()
186 .var("GIT_REPLACE_REF_BASE")
187 .unwrap_or_else(|| "refs/replace/".to_owned());
188 if !base.ends_with('/') {
189 base.push('/');
190 }
191 base
192}
193
194fn prepend_decoration(items: &mut Vec<DecorationItem>, item: DecorationItem) {
195 items.insert(0, item);
196}
197
198/// Collect ref decorations from the repository (heads, tags, remotes, stash, replace refs, HEAD).
199///
200/// Order matches Git's `refs_for_each_ref` walk: each ref is **prepended** in ascending ref-name
201/// order, so the final per-commit list matches upstream (e.g. `tag: A1` before `other/main` before
202/// `other/HEAD`).
203pub fn collect_decorations(repo: &Repository, full: bool) -> Result<DecorationMap> {
204 collect_decorations_inner(repo, full, false, &DecorationFilter::default())
205}
206
207/// Load commit metadata for `oids`, attaching decorations from a pre-built [`DecorationMap`].
208///
209/// Reads each commit object at most once. Call [`collect_decorations`] once per command
210/// and reuse the map across all rows.
211///
212/// # Errors
213///
214/// Returns ODB and commit-parse errors when an oid is missing or corrupt.
215pub fn load_commits_with_decorations(
216 repo: &Repository,
217 oids: &[ObjectId],
218 decorations: &DecorationMap,
219) -> Result<Vec<LoadedCommit>> {
220 load_commits_with_decorations_reusing(
221 repo,
222 oids,
223 decorations,
224 &mut OidHashMap::<CommitData>::default(),
225 )
226}
227
228/// Commits a caller already holds, handed out once each so loaders skip the object read.
229///
230/// Implemented for maps of parsed commits (such as
231/// [`RevListResult::commit_data`](crate::revwalk::rev_list::RevListResult::commit_data)) and for
232/// [`WalkedCommits`](crate::revwalk::log_walk::WalkedCommits), which parses on demand.
233pub trait CommitReuse {
234 /// Remove the commit `oid` from this source and return it parsed, or [`None`] when the
235 /// source does not hold it.
236 ///
237 /// # Errors
238 ///
239 /// Returns commit-parse errors for sources that hold unparsed objects.
240 fn take_commit(&mut self, oid: &ObjectId) -> Option<Result<CommitData>>;
241}
242
243impl CommitReuse for OidHashMap<CommitData> {
244 fn take_commit(&mut self, oid: &ObjectId) -> Option<Result<CommitData>> {
245 self.remove(oid).map(Ok)
246 }
247}
248
249impl<T: CommitReuse + ?Sized> CommitReuse for &mut T {
250 fn take_commit(&mut self, oid: &ObjectId) -> Option<Result<CommitData>> {
251 (**self).take_commit(oid)
252 }
253}
254
255/// Like [`load_commits_with_decorations`], but takes commits the caller already holds (for
256/// example [`crate::revwalk::rev_list::RevListResult::commit_data`] or
257/// [`crate::revwalk::log_walk::LogWalkPage::commit_data`]) out of `parsed` instead of reading
258/// them again. Commits missing from `parsed` are read from the object database.
259///
260/// # Parameters
261///
262/// - `repo` - repository to read commits missing from `parsed` from.
263/// - `oids` - commits to load, in output order.
264/// - `decorations` - ref labels from [`collect_decorations`].
265/// - `parsed` - commits by id; entries for `oids` are moved out.
266///
267/// # Errors
268///
269/// Returns ODB and commit-parse errors when an oid is missing or corrupt.
270pub fn load_commits_with_decorations_reusing(
271 repo: &Repository,
272 oids: &[ObjectId],
273 decorations: &DecorationMap,
274 parsed: &mut impl CommitReuse,
275) -> Result<Vec<LoadedCommit>> {
276 let odb = repo.odb();
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 => parse_commit(&odb.read(oid)?.data)?,
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.graph.get_or_init(|| {
619 self.repo
620 .caches()
621 .commit_graph_chain(self.repo.odb().objects_dir())
622 });
623 if graph.as_ref().is_some_and(|g| g.find_commit(oid).is_some()) {
624 return true;
625 }
626 self.repo
627 .odb()
628 .read_info(oid)
629 .is_ok_and(|info| info.kind == ObjectKind::Commit)
630 }
631
632 /// Decoration key for the tip `oid` of `refname`, or `None` when it is not a target.
633 fn commit_hex(&self, refname: &str, oid: &ObjectId) -> Option<String> {
634 let key = self.key(refname, oid)?;
635 if self.targets.is_some_and(|t| !t.contains(&key)) {
636 return None;
637 }
638 Some(key.to_hex())
639 }
640
641 fn key(&self, refname: &str, oid: &ObjectId) -> Option<ObjectId> {
642 match self.packed.as_ref().and_then(|p| p.peeled(refname, oid)) {
643 Some(Some(peeled)) => {
644 if let Some(targets) = self.targets {
645 // Otherwise the key is `peeled` (not a target) or the tag itself.
646 return targets.contains(&peeled).then_some(peeled);
647 }
648 return Some(if self.is_commit(&peeled) {
649 peeled
650 } else {
651 *oid
652 });
653 }
654 Some(None) => return Some(*oid),
655 None => {}
656 }
657 if self.targets.is_some_and(|t| t.contains(oid)) {
658 return Some(*oid);
659 }
660 // Keep tag header/body reads together, but leave the packed-ref-only path alone.
661 self.pack_context
662 .get_or_init(|| self.repo.odb().pack_read_context_guard());
663 Some(
664 self.repo
665 .odb()
666 .peel_to_commit(*oid)
667 .ok()
668 .flatten()
669 .unwrap_or(*oid),
670 )
671 }
672}
673
674/// Index of the branch decoration that should fold into `HEAD -> branch`, if any.
675///
676/// Returns the position of the current-branch [`DecorationItem`] in `items` when a
677/// `HEAD` decoration is actually present (so the CLI can render `HEAD -> branch`),
678/// or `None` when HEAD is detached or was filtered out.
679pub fn current_branch_decoration_index(
680 items: &[DecorationItem],
681 head: &HeadState,
682) -> Option<usize> {
683 let refname = match head {
684 HeadState::Branch { refname, .. } => refname.as_str(),
685 _ => return None,
686 };
687 // Only fold the branch into `HEAD -> branch` when the HEAD decoration is
688 // actually present. If HEAD was filtered out (e.g. by `--decorate-refs`),
689 // the branch must render on its own.
690 if !items.iter().any(|it| it.kind == DecorationKind::Head) {
691 return None;
692 }
693 items
694 .iter()
695 .position(|it| it.kind == DecorationKind::Branch && it.refname.as_deref() == Some(refname))
696}