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