Skip to main content

grit_lib/refs/
listing.rs

1//! Shared ref listing: namespace selection, glob patterns, sort keys, and limits.
2//!
3//! Used by branch/tag list, `for-each-ref`-style queries, and in-memory remote ref
4//! lists.
5//!
6//! - **Local lists** (`query_refs`, `query_listings_from_pairs`): patterns follow
7//!   `git for-each-ref` / `git branch --list` on the **short** ref name (suffix after
8//!   `refs/heads/`, `refs/tags/`, etc.), and a trailing `/` selects a directory prefix.
9//! - **Remote advertisements** ([`pattern_matches_ls_remote_refname`]): patterns follow
10//!   `git ls-remote` tail-match rules on the **full** ref name (`main` matches
11//!   `refs/heads/main`; `refs/tags/v1*` also matches `refs/tags/v1^{}`).
12//!
13//! ```rust
14//! use grit_lib::refs::listing::{query_refs, RefNamespace, RefQuery, RefQueryOptions, SortKey};
15//! use grit_lib::repo::{InitOptions, Repository};
16//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
17//! let dir = tempfile::tempdir()?;
18//! let repo = Repository::init(dir.path(), &InitOptions::default())?;
19//! let query = RefQuery {
20//!     namespaces: vec![RefNamespace::Heads],
21//!     ..RefQuery::default()
22//! };
23//! let _branches = query_refs(&repo, &query, &RefQueryOptions::default())?;
24//! # Ok(())
25//! # }
26//! ```
27
28use std::cmp::Ordering;
29use std::collections::BTreeMap;
30use std::ops::ControlFlow;
31
32use thiserror::Error;
33
34use crate::error::Error;
35use crate::ident::committer_unix_seconds_for_ordering_bytes;
36use crate::objects::{ObjectId, ObjectKind};
37use crate::odb::Odb;
38use crate::refs::query_reachability::{self, ReachRow};
39use crate::refs::store::{RefEntry, RefStore};
40use crate::refs::version_sort::compare_refname_versions;
41use crate::refs::PackedRefPeels;
42use crate::repo::Repository;
43use crate::wildmatch::{wildmatch, WM_CASEFOLD};
44
45/// Which ref namespace to include in a [`RefQuery`].
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub enum RefNamespace {
48    /// `refs/heads/*`
49    Heads,
50    /// `refs/remotes/*`
51    Remotes,
52    /// `refs/tags/*`
53    Tags,
54    /// Any ref whose full name starts with this prefix (for example `refs/notes/`).
55    Custom(String),
56}
57
58impl RefNamespace {
59    fn prefix(&self) -> &str {
60        match self {
61            Self::Heads => "refs/heads/",
62            Self::Remotes => "refs/remotes/",
63            Self::Tags => "refs/tags/",
64            Self::Custom(p) => p.as_str(),
65        }
66    }
67}
68
69/// Primary sort key for [`RefQuery`].
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
71pub enum SortKey {
72    /// Full ref name (`refname`), byte order.
73    #[default]
74    Name,
75    /// `version:refname` — [`compare_version_names`] on the full ref name, then refname tie-break.
76    Version,
77    /// `creatordate` — tagger or committer epoch from object headers.
78    Date,
79}
80
81/// Sort direction and key for ref listing.
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub struct RefSort {
84    /// Which field to sort by.
85    pub key: SortKey,
86    /// When true, reverse the primary ordering (Git `--reverse` / leading `-` on `--sort`).
87    pub reverse: bool,
88}
89
90impl Default for RefSort {
91    fn default() -> Self {
92        Self {
93            key: SortKey::Name,
94            reverse: false,
95        }
96    }
97}
98
99/// Filters and ordering for a ref listing query.
100#[derive(Debug, Clone, PartialEq, Eq, Default)]
101pub struct RefQuery {
102    /// Namespaces to scan (union). When empty, defaults to [`RefNamespace::Heads`].
103    pub namespaces: Vec<RefNamespace>,
104    /// Glob patterns matched against the short ref name; empty means no pattern filter.
105    pub patterns: Vec<String>,
106    /// Sort key and direction.
107    pub sort: RefSort,
108    /// Maximum number of refs to return after sorting (`None` = unlimited).
109    pub limit: Option<usize>,
110    /// Keep refs whose peeled commit contains every listed commit (`--contains`).
111    pub contains: Vec<ObjectId>,
112    /// Exclude refs whose peeled commit contains any listed commit (`--no-contains`).
113    pub no_contains: Vec<ObjectId>,
114    /// Keep branch tips merged into every listed commit (`--merged`).
115    pub merged: Vec<ObjectId>,
116    /// Exclude branch tips merged into any listed commit (`--no-merged`).
117    pub no_merged: Vec<ObjectId>,
118    /// Keep refs whose oid or tag peel chain matches any listed object (`--points-at`).
119    pub points_at: Vec<ObjectId>,
120}
121
122/// Class of ref for display and JSON (`head`, `remote`, `tag`, …).
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub enum RefKind {
125    /// Local branch (`refs/heads/`).
126    Head,
127    /// Remote-tracking branch (`refs/remotes/`).
128    Remote,
129    /// Tag (`refs/tags/`).
130    Tag,
131    /// Any other ref namespace.
132    Other,
133}
134
135/// One ref returned from [`References::query`](super::handle::References::query) or
136/// [`query_listings_from_pairs`].
137#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct RefListing {
139    /// Full ref name (for example `refs/tags/v1.0`).
140    pub full_name: String,
141    /// Short name within its namespace (for example `v1.0` for tags).
142    pub short_name: String,
143    /// Object id stored at the ref (annotated tag object id for annotated tags).
144    pub oid: ObjectId,
145    /// Peeled object id when the ref store or ODB provides one (often a commit).
146    pub peeled: Option<ObjectId>,
147    /// Ref classification derived from `full_name`.
148    pub kind: RefKind,
149}
150
151/// Extra inputs for pattern matching and version sort (explicit config, no env reads).
152#[derive(Debug, Clone, Default)]
153pub struct RefQueryOptions {
154    /// `versionsort.suffix` values in priority order (Git multi-valued config).
155    pub versionsort_suffixes: Vec<String>,
156    /// When true, patterns use [`WM_CASEFOLD`] like `core.ignorecase`.
157    pub ignore_case: bool,
158}
159
160/// Errors from ref query operations.
161#[derive(Debug, Error)]
162pub enum RefQueryError {
163    /// Ref store iteration or resolution failed.
164    #[error("ref store: {0}")]
165    Store(#[from] Error),
166    /// Creator-date sort was requested but no object database was supplied.
167    #[error("creator-date sort requires an object database")]
168    MissingObjectDatabase,
169    /// Filter object id is not present in the object database.
170    #[error("object {0} not found")]
171    MissingObject(ObjectId),
172    /// `--contains` / `--merged` target does not resolve to a commit.
173    #[error("object {0} is not a commit")]
174    NotCommit(ObjectId),
175}
176
177type QueryResult<T> = std::result::Result<T, RefQueryError>;
178
179#[derive(Clone)]
180struct CollectedRef {
181    full_name: String,
182    short_name: String,
183    oid: ObjectId,
184    peeled: Option<ObjectId>,
185    kind: RefKind,
186    creator_date: i64,
187}
188
189/// Classify `full_name` into a [`RefKind`].
190#[must_use]
191pub fn ref_kind_from_name(full_name: &str) -> RefKind {
192    if full_name.starts_with("refs/heads/") {
193        RefKind::Head
194    } else if full_name.starts_with("refs/remotes/") {
195        RefKind::Remote
196    } else if full_name.starts_with("refs/tags/") {
197        RefKind::Tag
198    } else {
199        RefKind::Other
200    }
201}
202
203/// Strip the namespace prefix for `full_name` given the query namespace used to collect it.
204#[must_use]
205pub fn short_name_in_namespace(full_name: &str, namespace: &RefNamespace) -> String {
206    let prefix = namespace.prefix();
207    full_name
208        .strip_prefix(prefix)
209        .unwrap_or(full_name)
210        .to_owned()
211}
212
213/// Whether `refname` matches `pattern` using `git ls-remote` tail-match rules.
214#[must_use]
215pub fn pattern_matches_ls_remote_refname(refname: &str, pattern: &str) -> bool {
216    if pattern.is_empty() {
217        return false;
218    }
219    let path = format!("/{refname}");
220    let full = format!("*/{pattern}");
221    ls_remote_glob_match(&full, &path)
222}
223
224/// Returns `true` when `refname` matches one of `patterns`.
225///
226/// An empty `patterns` slice means no pattern filter (all ref names pass). An explicit
227/// empty-string pattern (`patterns == [""]`) matches nothing, like `git ls-remote`.
228#[must_use]
229pub fn patterns_match_ls_remote(refname: &str, patterns: &[String]) -> bool {
230    if patterns.is_empty() {
231        return true;
232    }
233    patterns
234        .iter()
235        .any(|p| pattern_matches_ls_remote_refname(refname, p))
236}
237
238fn ls_remote_glob_match(pattern: &str, text: &str) -> bool {
239    let pat: Vec<char> = pattern.chars().collect();
240    let txt: Vec<char> = text.chars().collect();
241    let (mut pi, mut ti) = (0, 0);
242    let (mut star_pi, mut star_ti) = (usize::MAX, 0);
243    while ti < txt.len() {
244        if pi < pat.len() && (pat[pi] == '?' || pat[pi] == txt[ti]) {
245            pi += 1;
246            ti += 1;
247        } else if pi < pat.len() && pat[pi] == '*' {
248            star_pi = pi;
249            star_ti = ti;
250            pi += 1;
251        } else if star_pi != usize::MAX {
252            pi = star_pi + 1;
253            star_ti += 1;
254            ti = star_ti;
255        } else {
256            return false;
257        }
258    }
259    while pi < pat.len() && pat[pi] == '*' {
260        pi += 1;
261    }
262    pi == pat.len()
263}
264
265/// Whether `short_name` matches `pattern` (Git `for-each-ref` / branch list rules).
266#[must_use]
267pub fn pattern_matches_short_name(short_name: &str, pattern: &str, ignore_case: bool) -> bool {
268    if pattern.is_empty() {
269        return true;
270    }
271    let mut flags = 0u32;
272    if ignore_case {
273        flags |= WM_CASEFOLD;
274    }
275
276    if pattern.ends_with('/') {
277        let base = pattern.trim_end_matches('/');
278        if ascii_eq(short_name, base, ignore_case)
279            || ascii_starts_with(short_name, pattern, ignore_case)
280        {
281            return true;
282        }
283    }
284
285    if wildmatch(pattern.as_bytes(), short_name.as_bytes(), flags) {
286        return true;
287    }
288
289    // Literal prefix without glob metacharacters (Git `match_name_as_path` on short names).
290    if !pattern.contains(['*', '?', '[', '\\']) {
291        if ascii_eq(short_name, pattern, ignore_case) {
292            return true;
293        }
294        if ascii_starts_with_path_segment(short_name, pattern, ignore_case) {
295            return true;
296        }
297    }
298    false
299}
300
301fn ascii_eq(left: &str, right: &str, ignore_case: bool) -> bool {
302    ascii_eq_bytes(left.as_bytes(), right.as_bytes(), ignore_case)
303}
304
305fn ascii_eq_bytes(left: &[u8], right: &[u8], ignore_case: bool) -> bool {
306    if !ignore_case {
307        return left == right;
308    }
309    left.len() == right.len()
310        && left
311            .iter()
312            .zip(right.iter())
313            .all(|(a, b)| a.eq_ignore_ascii_case(b))
314}
315
316fn ascii_starts_with(name: &str, prefix: &str, ignore_case: bool) -> bool {
317    ascii_starts_with_bytes(name.as_bytes(), prefix.as_bytes(), ignore_case)
318}
319
320fn ascii_starts_with_bytes(name: &[u8], prefix: &[u8], ignore_case: bool) -> bool {
321    if name.len() < prefix.len() {
322        return false;
323    }
324    if !ignore_case {
325        return name.starts_with(prefix);
326    }
327    name[..prefix.len()]
328        .iter()
329        .zip(prefix.iter())
330        .all(|(a, b)| a.eq_ignore_ascii_case(b))
331}
332
333fn ascii_starts_with_path_segment(name: &str, pattern: &str, ignore_case: bool) -> bool {
334    let name = name.as_bytes();
335    let pattern = pattern.as_bytes();
336    if name.len() < pattern.len() + 1 || name[pattern.len()] != b'/' {
337        return false;
338    }
339    ascii_starts_with_bytes(name, pattern, ignore_case)
340}
341
342/// Compare two ref names using Git's `version:refname` / `versioncmp` ordering.
343///
344/// `versionsort_suffixes` is the configured `versionsort.suffix` list (may be empty).
345#[must_use]
346pub fn compare_version_names(a: &str, b: &str, versionsort_suffixes: &[String]) -> Ordering {
347    compare_refname_versions(a, b, versionsort_suffixes)
348}
349
350/// Extract the epoch from a Git identity line (`Name <email> <epoch> <tz>`).
351#[must_use]
352pub fn epoch_from_identity(ident: &str) -> i64 {
353    let parts: Vec<&str> = ident.rsplitn(3, ' ').collect();
354    if parts.len() >= 2 {
355        parts[1].parse().unwrap_or(0)
356    } else {
357        0
358    }
359}
360
361/// Creator date for a ref target: tagger date for tag objects, committer date for commits.
362///
363/// Reads only the object header region (stops at the first blank line). Returns `0` when
364/// unknown.
365#[must_use]
366pub fn creator_date_from_bytes(kind: ObjectKind, data: &[u8]) -> i64 {
367    let header = object_header_bytes(data);
368    match kind {
369        ObjectKind::Tag => {
370            if let Some(line) = find_header_value(header, b"tagger") {
371                committer_unix_seconds_for_ordering_bytes(line)
372            } else {
373                0
374            }
375        }
376        ObjectKind::Commit => {
377            if let Some(line) = find_header_value(header, b"committer") {
378                committer_unix_seconds_for_ordering_bytes(line)
379            } else {
380                0
381            }
382        }
383        _ => 0,
384    }
385}
386
387/// Creator date for `oid` in `odb` (see [`creator_date_from_bytes`]).
388///
389/// # Errors
390///
391/// Propagates object read failures.
392pub fn creator_date(odb: &Odb, oid: &ObjectId) -> crate::error::Result<i64> {
393    let obj = odb.read(oid)?;
394    Ok(creator_date_from_bytes(obj.kind, &obj.data))
395}
396
397/// Run `query` over an in-memory iterator of `(full_name, oid)` pairs.
398///
399/// `peeled` is optional per entry; pass `None` when unknown. For creator-date sort,
400/// `odb` must be `Some`.
401///
402/// # Errors
403///
404/// Returns [`RefQueryError::MissingObjectDatabase`] when sort key is [`SortKey::Date`] and
405/// `odb` is `None`. Propagates object read errors as [`RefQueryError::Store`].
406pub fn query_listings_from_pairs<I>(
407    entries: I,
408    query: &RefQuery,
409    options: &RefQueryOptions,
410    odb: Option<&Odb>,
411) -> QueryResult<Vec<RefListing>>
412where
413    I: IntoIterator<Item = (String, ObjectId, Option<ObjectId>)>,
414{
415    let namespaces = effective_namespaces(query);
416    let mut collected = BTreeMap::new();
417    for (full_name, oid, peeled) in entries {
418        if !namespaces
419            .iter()
420            .any(|ns| full_name.starts_with(ns.prefix()))
421        {
422            continue;
423        }
424        let short_name = short_name_for_query(&full_name, &namespaces);
425        if !patterns_match(&short_name, &query.patterns, options.ignore_case) {
426            continue;
427        }
428        let kind = ref_kind_from_name(&full_name);
429        collected.entry(full_name.clone()).or_insert(CollectedRef {
430            full_name,
431            short_name,
432            oid,
433            peeled,
434            kind,
435            creator_date: 0,
436        });
437    }
438    finish_query(
439        collected.into_values().collect(),
440        query,
441        options,
442        odb,
443        None,
444        PeelTips::Always,
445    )
446}
447
448/// Collect refs from `store` under each namespace in `query`, then filter, sort, and limit.
449///
450/// # Errors
451///
452/// Propagates ref-store and object-read failures.
453pub fn query_listings_on_store(
454    store: &dyn RefStore,
455    query: &RefQuery,
456    options: &RefQueryOptions,
457    odb: Option<&Odb>,
458) -> QueryResult<Vec<RefListing>> {
459    let namespaces = effective_namespaces(query);
460    let mut collected = Vec::new();
461    for ns in &namespaces {
462        let prefix = ns.prefix();
463        store.for_each_ref(prefix, &mut |entry: &RefEntry| {
464            insert_collected_ref(&mut collected, entry, store, &namespaces, query, options);
465            ControlFlow::Continue(())
466        })?;
467    }
468    finish_query(
469        dedup_collected(collected),
470        query,
471        options,
472        odb,
473        None,
474        PeelTips::Always,
475    )
476}
477
478/// Whether [`finish_query`] fills [`RefListing::peeled`] for refs the store did not peel.
479#[derive(Debug, Clone, Copy, PartialEq, Eq)]
480pub(crate) enum PeelTips {
481    /// Peel every listed ref (reads each tip's object header unless `packed-refs` says).
482    Always,
483    /// Peel only when a reachability or `points_at` filter needs it; otherwise listings carry
484    /// whatever peel the ref store reported.
485    WhenFiltering,
486}
487
488/// Query refs on `repo` (respects linked-worktree visibility and injected ref stores).
489///
490/// Finite, name-ordered `contains` queries without other reachability or object filters
491/// evaluate tips in result order and stop after `limit` matches. Ancestry results are
492/// shared across evaluated tips. Targets are validated even when the limit is zero.
493/// Unlimited queries, other sorts, and combined filters use a shared batch walk.
494///
495/// # Errors
496///
497/// Propagates ref-store and object-read failures.
498pub fn query_refs(
499    repo: &Repository,
500    query: &RefQuery,
501    options: &RefQueryOptions,
502) -> QueryResult<Vec<RefListing>> {
503    query_refs_peeling(repo, query, options, PeelTips::Always)
504}
505
506/// [`query_refs`] for callers that do not read [`RefListing::peeled`], which spares an
507/// object read per listed ref when no filter needs peeled tips.
508///
509/// # Errors
510///
511/// Same as [`query_refs`].
512pub(crate) fn query_refs_peeling(
513    repo: &Repository,
514    query: &RefQuery,
515    options: &RefQueryOptions,
516    peel: PeelTips,
517) -> QueryResult<Vec<RefListing>> {
518    let git_dir = repo.git_dir();
519    let store = repo.refs();
520    let namespaces = effective_namespaces(query);
521    let linked_worktree = crate::worktree_ref::is_linked_worktree_git_dir(git_dir);
522    let early_limit = early_ref_collection_limit(query, options);
523    let mut collected = Vec::new();
524    for ns in &namespaces {
525        let prefix = ns.prefix();
526        store.for_each_ref(prefix, &mut |entry: &RefEntry| {
527            if linked_worktree
528                && !crate::worktree_ref::ref_visible_from_worktree(git_dir, &entry.name)
529            {
530                return ControlFlow::Continue(());
531            }
532            insert_collected_ref(&mut collected, entry, store, &namespaces, query, options);
533            if early_limit.is_some_and(|limit| collected.len() >= limit) {
534                return ControlFlow::Break(());
535            }
536            ControlFlow::Continue(())
537        })?;
538        if early_limit.is_some_and(|limit| collected.len() >= limit) {
539            break;
540        }
541    }
542    finish_query(
543        dedup_collected(collected),
544        query,
545        options,
546        Some(repo.odb()),
547        Some(repo),
548        peel,
549    )
550}
551
552/// Sort `collected` by full name and keep the first entry for each name.
553///
554/// Stores list each prefix in name order, so for a single namespace this is a linear pass.
555fn dedup_collected(mut collected: Vec<CollectedRef>) -> Vec<CollectedRef> {
556    collected.sort_by(|a, b| a.full_name.cmp(&b.full_name));
557    collected.dedup_by(|later, first| later.full_name == first.full_name);
558    collected
559}
560
561fn insert_collected_ref(
562    collected: &mut Vec<CollectedRef>,
563    entry: &RefEntry,
564    store: &dyn RefStore,
565    namespaces: &[RefNamespace],
566    query: &RefQuery,
567    options: &RefQueryOptions,
568) {
569    let resolved = entry
570        .peeled
571        .or_else(|| match &entry.value {
572            crate::refs::store::RawRef::Direct(oid) => Some(*oid),
573            crate::refs::store::RawRef::Symbolic(target) => store.resolve(target).ok(),
574        })
575        .or_else(|| store.resolve(&entry.name).ok());
576    let Some(resolved) = resolved else {
577        return;
578    };
579    let short_name = short_name_for_query(&entry.name, namespaces);
580    if !patterns_match(&short_name, &query.patterns, options.ignore_case) {
581        return;
582    }
583    let direct_oid = match &entry.value {
584        crate::refs::store::RawRef::Direct(oid) => *oid,
585        crate::refs::store::RawRef::Symbolic(_) => resolved,
586    };
587    let peeled = match &entry.value {
588        crate::refs::store::RawRef::Direct(_) => entry.peeled,
589        crate::refs::store::RawRef::Symbolic(_) => None,
590    };
591    let kind = ref_kind_from_name(&entry.name);
592    collected.push(CollectedRef {
593        full_name: entry.name.clone(),
594        short_name,
595        oid: direct_oid,
596        peeled,
597        kind,
598        creator_date: 0,
599    });
600}
601
602fn effective_namespaces(query: &RefQuery) -> Vec<RefNamespace> {
603    if query.namespaces.is_empty() {
604        vec![RefNamespace::Heads]
605    } else {
606        query.namespaces.clone()
607    }
608}
609
610fn short_name_for_query(full_name: &str, namespaces: &[RefNamespace]) -> String {
611    let mut best = 0usize;
612    for ns in namespaces {
613        let prefix = ns.prefix();
614        if full_name.starts_with(prefix) && prefix.len() > best {
615            best = prefix.len();
616        }
617    }
618    if best > 0 {
619        full_name[best..].to_owned()
620    } else {
621        short_name_for_kind(full_name, ref_kind_from_name(full_name))
622    }
623}
624
625fn short_name_for_kind(full_name: &str, kind: RefKind) -> String {
626    match kind {
627        RefKind::Head => full_name.strip_prefix("refs/heads/").unwrap_or(full_name),
628        RefKind::Remote => full_name.strip_prefix("refs/remotes/").unwrap_or(full_name),
629        RefKind::Tag => full_name.strip_prefix("refs/tags/").unwrap_or(full_name),
630        RefKind::Other => full_name.strip_prefix("refs/").unwrap_or(full_name),
631    }
632    .to_owned()
633}
634
635fn patterns_match(short_name: &str, patterns: &[String], ignore_case: bool) -> bool {
636    if patterns.is_empty() {
637        return true;
638    }
639    patterns
640        .iter()
641        .any(|p| pattern_matches_short_name(short_name, p, ignore_case))
642}
643
644fn query_has_reachability(query: &RefQuery) -> bool {
645    !query.contains.is_empty()
646        || !query.no_contains.is_empty()
647        || !query.merged.is_empty()
648        || !query.no_merged.is_empty()
649        || !query.points_at.is_empty()
650}
651
652/// When refs arrive in name order from the store, stop collecting once enough match.
653fn early_ref_collection_limit(query: &RefQuery, options: &RefQueryOptions) -> Option<usize> {
654    let limit = query.limit?;
655    if query.sort.key != SortKey::Name || query.sort.reverse {
656        return None;
657    }
658    if !query.patterns.is_empty() || options.ignore_case {
659        return None;
660    }
661    if query_has_reachability(query) {
662        return None;
663    }
664    if effective_namespaces(query).len() != 1 {
665        return None;
666    }
667    Some(limit)
668}
669
670fn finish_query(
671    mut collected: Vec<CollectedRef>,
672    query: &RefQuery,
673    options: &RefQueryOptions,
674    odb: Option<&Odb>,
675    repo: Option<&Repository>,
676    peel: PeelTips,
677) -> QueryResult<Vec<RefListing>> {
678    if query_has_reachability(query) {
679        // Filtering peels the tips it judges, so only the refs it keeps without having
680        // peeled them are peeled afterwards; each tip's header is read once.
681        let repo = repo.ok_or(RefQueryError::MissingObjectDatabase)?;
682        let packed = packed_peels_for(repo, &collected);
683        let mut rows: Vec<ReachRow> = collected
684            .iter()
685            .map(|c| ReachRow {
686                oid: c.oid,
687                peeled: c
688                    .peeled
689                    .map(Some)
690                    .or_else(|| packed.as_ref().and_then(|p| p.peeled(&c.full_name, &c.oid))),
691            })
692            .collect();
693        let keep = query_reachability::filter_listings_by_reachability(repo, query, &mut rows)?;
694        let mut verdicts = keep.into_iter().zip(rows);
695        collected.retain_mut(|entry| {
696            let Some((keep, row)) = verdicts.next() else {
697                return false;
698            };
699            if keep {
700                match row.peeled {
701                    Some(peeled) => entry.peeled = peeled,
702                    None => fill_peeled(repo.odb(), None, entry),
703                }
704            }
705            keep
706        });
707    } else if let Some(odb) = odb.filter(|_| peel == PeelTips::Always) {
708        let packed = repo.and_then(|r| packed_peels_for(r, &collected));
709        for entry in &mut collected {
710            fill_peeled(odb, packed.as_ref(), entry);
711        }
712    }
713    if matches!(query.sort.key, SortKey::Date) {
714        let odb = odb.ok_or(RefQueryError::MissingObjectDatabase)?;
715        for entry in &mut collected {
716            entry.creator_date = creator_date(odb, &entry.oid).unwrap_or(0);
717        }
718    }
719    let suffixes = &options.versionsort_suffixes;
720    collected.sort_by(|a, b| {
721        let primary = compare_entries(a, b, query.sort.key, suffixes);
722        let primary = if query.sort.reverse {
723            primary.reverse()
724        } else {
725            primary
726        };
727        if primary != Ordering::Equal {
728            return primary;
729        }
730        a.full_name.cmp(&b.full_name)
731    });
732    if let Some(limit) = query.limit {
733        collected.truncate(limit);
734    }
735    Ok(collected
736        .into_iter()
737        .map(|c| RefListing {
738            full_name: c.full_name,
739            short_name: c.short_name,
740            oid: c.oid,
741            peeled: c.peeled,
742            kind: c.kind,
743        })
744        .collect())
745}
746
747/// Fewest refs to peel for which indexing `packed-refs` beats reading each object header.
748///
749/// Loading the peel lines indexes the whole file, which in a repository with many packed
750/// refs costs more than a handful of object-header reads.
751pub(crate) const PACKED_PEELS_MIN_REFS: usize = 64;
752
753/// `packed-refs` peel lines for `collected`, when enough refs lack a peel to pay for them.
754///
755/// A malformed `packed-refs` only loses the shortcut; peeling falls back to the objects.
756fn packed_peels_for(repo: &Repository, collected: &[CollectedRef]) -> Option<PackedRefPeels> {
757    let to_peel = collected.iter().filter(|c| c.peeled.is_none()).count();
758    if to_peel < PACKED_PEELS_MIN_REFS {
759        return None;
760    }
761    PackedRefPeels::for_store(repo.refs(), repo.git_dir()).ok()
762}
763
764/// Set `entry.peeled` for annotated tags, preferring `packed-refs` peel lines over
765/// reading the tag object.
766fn fill_peeled(odb: &Odb, packed: Option<&PackedRefPeels>, entry: &mut CollectedRef) {
767    if entry.peeled.is_some() {
768        return;
769    }
770    if let Some(known) = packed.and_then(|p| p.peeled(&entry.full_name, &entry.oid)) {
771        entry.peeled = known;
772        return;
773    }
774    entry.peeled = odb.peel_tag_target(entry.oid).ok().flatten();
775}
776
777fn compare_entries(
778    a: &CollectedRef,
779    b: &CollectedRef,
780    key: SortKey,
781    suffixes: &[String],
782) -> Ordering {
783    match key {
784        SortKey::Name => a.full_name.cmp(&b.full_name),
785        SortKey::Version => compare_version_names(&a.full_name, &b.full_name, suffixes),
786        SortKey::Date => a.creator_date.cmp(&b.creator_date),
787    }
788}
789
790fn object_header_bytes(data: &[u8]) -> &[u8] {
791    if let Some(pos) = data.windows(2).position(|w| w == b"\n\n") {
792        &data[..pos]
793    } else {
794        data
795    }
796}
797
798fn find_header_value<'a>(header: &'a [u8], name: &[u8]) -> Option<&'a [u8]> {
799    let mut idx = 0;
800    while idx < header.len() {
801        let line_end = header[idx..]
802            .iter()
803            .position(|&b| b == b'\n')
804            .map(|p| idx + p)
805            .unwrap_or(header.len());
806        let line = &header[idx..line_end];
807        if line.is_empty() {
808            break;
809        }
810        if line.len() > name.len() + 1 && line.starts_with(name) && line[name.len()] == b' ' {
811            return Some(&line[name.len() + 1..]);
812        }
813        idx = line_end.saturating_add(1);
814    }
815    None
816}
817
818/// Build [`RefQueryOptions`] from repository config (`versionsort.suffix`, `core.ignorecase`).
819///
820/// # Errors
821///
822/// Propagates config load failures from [`Repository::config`](crate::repo::Repository::config).
823pub fn ref_query_options_for_repo(repo: &Repository) -> crate::error::Result<RefQueryOptions> {
824    let config = repo.config()?;
825    let versionsort_suffixes = config.get_all("versionsort.suffix");
826    let ignore_case = config
827        .get_bool("core.ignorecase")
828        .and_then(|r| r.ok())
829        .unwrap_or(false);
830    Ok(RefQueryOptions {
831        versionsort_suffixes,
832        ignore_case,
833    })
834}
835
836#[cfg(test)]
837mod tests {
838    #![allow(clippy::unwrap_used)]
839
840    use super::*;
841
842    #[test]
843    fn version_order_matches_git_tag_examples() {
844        assert_eq!(
845            compare_version_names("refs/tags/foo1.3", "refs/tags/foo1.10", &[]),
846            Ordering::Less
847        );
848        assert_eq!(
849            compare_version_names("refs/tags/foo1.10", "refs/tags/foo1.6", &[]),
850            Ordering::Greater
851        );
852    }
853
854    #[test]
855    fn pattern_prefix_slash_and_glob() {
856        assert!(pattern_matches_short_name("feature/x", "feature/", false));
857        assert!(pattern_matches_short_name("v1.9", "v1.*", false));
858        assert!(pattern_matches_short_name("release-rc1", "*rc*", false));
859        assert!(!pattern_matches_short_name("other", "feature/", false));
860    }
861
862    #[test]
863    fn versionsort_suffix_orders_prerelease() {
864        let suffixes = vec!["-rc".to_owned()];
865        assert_eq!(
866            compare_version_names("v1.0", "v1.0-rc1", &suffixes),
867            Ordering::Greater
868        );
869    }
870
871    #[test]
872    fn utf8_names_with_suffix_config_do_not_panic_in_compare() {
873        let suffixes = vec!["x".to_owned()];
874        let _ = compare_version_names("refs/tags/vα1", "refs/tags/vα2", &suffixes);
875    }
876
877    #[test]
878    fn ignore_case_prefix_directory_match() {
879        assert!(pattern_matches_short_name("Feature/x", "feature/", true));
880    }
881
882    #[test]
883    fn ignore_case_prefix_does_not_panic_on_utf8_short_names() {
884        assert!(!pattern_matches_short_name("€x", "a", true));
885    }
886
887    #[test]
888    fn ls_remote_empty_string_pattern_matches_nothing() {
889        assert!(!pattern_matches_ls_remote_refname("refs/heads/main", ""));
890        assert!(!patterns_match_ls_remote(
891            "refs/heads/main",
892            &[String::new()]
893        ));
894        assert!(patterns_match_ls_remote("refs/heads/main", &[]));
895    }
896
897    #[test]
898    fn ls_remote_pattern_tail_component() {
899        assert!(pattern_matches_ls_remote_refname("refs/heads/main", "main"));
900        assert!(!pattern_matches_ls_remote_refname(
901            "refs/heads/topic",
902            "main"
903        ));
904        assert!(pattern_matches_ls_remote_refname(
905            "refs/tags/v1^{}",
906            "refs/tags/v1*"
907        ));
908        assert!(!pattern_matches_ls_remote_refname("refs/tags/v1^{}", "v1"));
909    }
910
911    #[test]
912    fn peel_tips_skips_peeling_only_without_filters() {
913        use crate::objects::{serialize_commit, serialize_tag, CommitData, ObjectKind, TagData};
914
915        let dir = tempfile::tempdir().unwrap();
916        let repo = crate::repo::init_repository(
917            dir.path(),
918            false,
919            "main",
920            None,
921            crate::RefStorageFormat::Files,
922        )
923        .unwrap();
924        let tree = repo.odb().write(ObjectKind::Tree, b"").unwrap();
925        let commit = repo
926            .odb()
927            .write(
928                ObjectKind::Commit,
929                &serialize_commit(&CommitData {
930                    tree,
931                    parents: Vec::new(),
932                    author: "T <t@example.com> 1 +0000".into(),
933                    committer: "T <t@example.com> 1 +0000".into(),
934                    author_raw: Vec::new(),
935                    committer_raw: Vec::new(),
936                    encoding: None,
937                    message: "m\n".into(),
938                    raw_message: None,
939                    extra_headers: Vec::new(),
940                }),
941            )
942            .unwrap();
943        let tag = repo
944            .odb()
945            .write(
946                ObjectKind::Tag,
947                &serialize_tag(&TagData {
948                    object: commit,
949                    object_type: "commit".into(),
950                    tag: "t".into(),
951                    tagger: Some("T <t@example.com> 1 +0000".into()),
952                    extra_headers: Vec::new(),
953                    message: "t\n".into(),
954                }),
955            )
956            .unwrap();
957        crate::refs::write_ref(repo.git_dir(), "refs/heads/main", &commit).unwrap();
958        crate::refs::write_ref(repo.git_dir(), "refs/tags/tagged", &tag).unwrap();
959
960        let options = RefQueryOptions::default();
961        let peeled = |query: &RefQuery, peel: PeelTips| -> Vec<(String, Option<ObjectId>)> {
962            query_refs_peeling(&repo, query, &options, peel)
963                .unwrap()
964                .into_iter()
965                .map(|l| (l.short_name, l.peeled))
966                .collect()
967        };
968        let all = RefQuery {
969            namespaces: vec![RefNamespace::Heads, RefNamespace::Tags],
970            ..RefQuery::default()
971        };
972        let both_peeled = vec![
973            ("main".to_owned(), None),
974            ("tagged".to_owned(), Some(commit)),
975        ];
976        assert_eq!(peeled(&all, PeelTips::Always), both_peeled);
977        assert_eq!(
978            query_refs(&repo, &all, &options)
979                .unwrap()
980                .into_iter()
981                .map(|l| (l.short_name, l.peeled))
982                .collect::<Vec<_>>(),
983            both_peeled
984        );
985        assert_eq!(
986            peeled(&all, PeelTips::WhenFiltering),
987            vec![("main".to_owned(), None), ("tagged".to_owned(), None)]
988        );
989
990        let points_at = RefQuery {
991            points_at: vec![commit],
992            ..all.clone()
993        };
994        assert_eq!(peeled(&points_at, PeelTips::WhenFiltering), both_peeled);
995        let contains = RefQuery {
996            contains: vec![commit],
997            ..all.clone()
998        };
999        assert_eq!(peeled(&contains, PeelTips::WhenFiltering), both_peeled);
1000    }
1001
1002    #[test]
1003    fn limit_one_with_multiple_namespaces_uses_global_name_order() {
1004        let dir = tempfile::tempdir().unwrap();
1005        let repo = crate::repo::init_repository(
1006            dir.path(),
1007            false,
1008            "main",
1009            None,
1010            crate::RefStorageFormat::Files,
1011        )
1012        .unwrap();
1013        let tree = repo
1014            .odb()
1015            .write(crate::objects::ObjectKind::Tree, b"")
1016            .unwrap();
1017        let commit = repo
1018            .odb()
1019            .write(
1020                crate::objects::ObjectKind::Commit,
1021                &crate::objects::serialize_commit(&crate::objects::CommitData {
1022                    tree,
1023                    parents: Vec::new(),
1024                    author: "T <t@example.com> 1 +0000".into(),
1025                    committer: "T <t@example.com> 1 +0000".into(),
1026                    author_raw: Vec::new(),
1027                    committer_raw: Vec::new(),
1028                    encoding: None,
1029                    message: "m\n".into(),
1030                    raw_message: None,
1031                    extra_headers: Vec::new(),
1032                }),
1033            )
1034            .unwrap();
1035        crate::refs::write_ref(repo.git_dir(), "refs/heads/a", &commit).unwrap();
1036        crate::refs::write_ref(repo.git_dir(), "refs/tags/z", &commit).unwrap();
1037
1038        let query = RefQuery {
1039            namespaces: vec![RefNamespace::Tags, RefNamespace::Heads],
1040            limit: Some(1),
1041            sort: RefSort {
1042                key: SortKey::Name,
1043                reverse: false,
1044            },
1045            ..RefQuery::default()
1046        };
1047        let names: Vec<String> = query_refs(&repo, &query, &RefQueryOptions::default())
1048            .unwrap()
1049            .into_iter()
1050            .map(|l| l.full_name)
1051            .collect();
1052        assert_eq!(names, vec!["refs/heads/a".to_owned()]);
1053    }
1054}