Skip to main content

grit_lib/
ls_remote.rs

1//! `ls-remote` — enumerate references from a local repository.
2//!
3//! This module provides the core logic for `grit ls-remote` when targeting a
4//! **local** path.  Network transports are out of scope for v1.
5//!
6//! # Output format
7//!
8//! Each entry is a `(oid, refname)` pair.  HEAD appears first (when included),
9//! followed by all other refs in lexicographic order.  Annotated tags are
10//! optionally followed by a peeled entry whose name ends in `^{}`.
11
12use std::collections::BTreeMap;
13use std::fs;
14use std::io;
15use std::path::Path;
16
17use crate::error::{Error, Result};
18use crate::objects::{ObjectId, ObjectKind};
19use crate::odb::Odb;
20
21/// A single reference entry produced by [`ls_remote`].
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct RefEntry {
24    /// Full reference name, e.g. `refs/heads/main`, `HEAD`, or
25    /// `refs/tags/v1.0^{}` for a peeled tag.
26    pub name: String,
27    /// The object ID the reference resolves to.
28    pub oid: ObjectId,
29    /// Symbolic-ref target for `HEAD` when [`Options::symref`] is set.
30    ///
31    /// `Some("refs/heads/main")` when HEAD is symbolic; `None` otherwise.
32    pub symref_target: Option<String>,
33}
34
35/// Options controlling which references [`ls_remote`] returns.
36#[derive(Debug, Default)]
37pub struct Options {
38    /// Restrict output to `refs/heads/` entries only.
39    pub heads: bool,
40    /// Restrict output to `refs/tags/` entries only.
41    pub tags: bool,
42    /// Exclude pseudo-refs (HEAD) and peeled tag `^{}` entries.
43    pub refs_only: bool,
44    /// Annotate symbolic refs (HEAD) with their `ref: <target>` line.
45    pub symref: bool,
46    /// If non-empty, only return refs matching one of these patterns.
47    ///
48    /// A ref matches when it equals the pattern exactly **or** when its name
49    /// ends with `/<pattern>`.
50    pub patterns: Vec<String>,
51}
52
53/// List references from the repository at `git_dir`.
54///
55/// Returns entries with HEAD first (when not suppressed), then all other refs
56/// sorted lexicographically.  Annotated tags are followed by a peeled entry
57/// (`refs/tags/name^{}`) unless [`Options::refs_only`] is set.
58///
59/// # Parameters
60///
61/// - `git_dir` — path to the `.git` directory or bare repository root.
62/// - `odb` — object database, used to peel annotated tag objects.
63/// - `opts` — filtering and output options.
64///
65/// # Errors
66///
67/// Returns [`Error::Io`] on filesystem errors during ref traversal.
68pub fn ls_remote(git_dir: &Path, odb: &Odb, opts: &Options) -> Result<Vec<RefEntry>> {
69    let mut entries = Vec::new();
70
71    let include_head = !opts.heads && !opts.tags && !opts.refs_only;
72    if include_head {
73        if let Ok(head_oid) = crate::refs::resolve_ref(git_dir, "HEAD") {
74            let symref_target = if opts.symref {
75                crate::refs::read_symbolic_ref(git_dir, "HEAD")?
76            } else {
77                None
78            };
79            if pattern_matches("HEAD", &opts.patterns) {
80                entries.push(RefEntry {
81                    name: "HEAD".to_owned(),
82                    oid: head_oid,
83                    symref_target,
84                });
85            }
86        }
87    }
88
89    let mut all_refs: BTreeMap<String, ObjectId> = BTreeMap::new();
90    collect_loose_refs(git_dir, &git_dir.join("refs"), "refs", &mut all_refs)?;
91    for (name, oid) in read_packed_refs(git_dir)? {
92        all_refs.entry(name).or_insert(oid);
93    }
94
95    for (name, oid) in &all_refs {
96        if opts.heads && !name.starts_with("refs/heads/") {
97            continue;
98        }
99        if opts.tags && !name.starts_with("refs/tags/") {
100            continue;
101        }
102        if !pattern_matches(name, &opts.patterns) {
103            continue;
104        }
105
106        entries.push(RefEntry {
107            name: name.clone(),
108            oid: *oid,
109            symref_target: None,
110        });
111
112        if !opts.refs_only && name.starts_with("refs/tags/") {
113            if let Some(peeled) = peel_tag(odb, oid) {
114                entries.push(RefEntry {
115                    name: format!("{name}^{{}}"),
116                    oid: peeled,
117                    symref_target: None,
118                });
119            }
120        }
121    }
122
123    Ok(entries)
124}
125
126/// Returns `true` when `refname` matches one of `patterns`, or when `patterns`
127/// is empty (no filtering applied).
128///
129/// A match occurs when:
130/// - `refname == pattern` exactly, **or**
131/// - `refname` ends with `/<pattern>` (suffix component match).
132fn pattern_matches(refname: &str, patterns: &[String]) -> bool {
133    if patterns.is_empty() {
134        return true;
135    }
136    patterns.iter().any(|pat| {
137        refname == pat
138            || refname
139                .strip_suffix(pat.as_str())
140                .is_some_and(|prefix| prefix.ends_with('/'))
141    })
142}
143
144/// Recursively collect all loose refs under `path` into `out`.
145///
146/// `relative` is the ref-name prefix corresponding to `path`
147/// (e.g. `"refs"` for `<git-dir>/refs`).
148fn collect_loose_refs(
149    git_dir: &Path,
150    path: &Path,
151    relative: &str,
152    out: &mut BTreeMap<String, ObjectId>,
153) -> Result<()> {
154    let read_dir = match fs::read_dir(path) {
155        Ok(rd) => rd,
156        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
157        Err(e) => return Err(Error::Io(e)),
158    };
159    for entry in read_dir {
160        let entry = entry?;
161        let file_name = entry.file_name().to_string_lossy().to_string();
162        let next_relative = format!("{relative}/{file_name}");
163        let file_type = entry.file_type()?;
164        if file_type.is_dir() {
165            collect_loose_refs(git_dir, &entry.path(), &next_relative, out)?;
166        } else if file_type.is_file() {
167            if let Ok(oid) = crate::refs::resolve_ref(git_dir, &next_relative) {
168                out.insert(next_relative, oid);
169            }
170        }
171    }
172    Ok(())
173}
174
175/// Parse `<git-dir>/packed-refs` and return all `(name, oid)` pairs.
176///
177/// Comment lines (`#`) and peeling lines (`^`) are skipped.
178/// Returns an empty `Vec` when the file does not exist.
179///
180/// # Errors
181///
182/// Returns [`Error::Io`] on read errors other than `NotFound`.
183fn read_packed_refs(git_dir: &Path) -> Result<Vec<(String, ObjectId)>> {
184    let path = git_dir.join("packed-refs");
185    let text = match fs::read_to_string(path) {
186        Ok(t) => t,
187        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
188        Err(e) => return Err(Error::Io(e)),
189    };
190    let mut entries = Vec::new();
191    for line in text.lines() {
192        if line.is_empty() || line.starts_with('#') || line.starts_with('^') {
193            continue;
194        }
195        let mut parts = line.split_whitespace();
196        let Some(oid_str) = parts.next() else {
197            continue;
198        };
199        let Some(name) = parts.next() else {
200            continue;
201        };
202        if let Ok(oid) = oid_str.parse::<ObjectId>() {
203            entries.push((name.to_owned(), oid));
204        }
205    }
206    Ok(entries)
207}
208
209/// Attempt to peel an annotated tag object to the object it points at.
210///
211/// Returns `Some(target_oid)` when `oid` is a tag object that contains an
212/// `object <hex>` header.  Returns `None` for non-tag objects, unreadable
213/// objects, or malformed tag data.
214fn peel_tag(odb: &Odb, oid: &ObjectId) -> Option<ObjectId> {
215    let obj = odb.read(oid).ok()?;
216    if obj.kind != ObjectKind::Tag {
217        return None;
218    }
219    let text = std::str::from_utf8(&obj.data).ok()?;
220    for line in text.lines() {
221        if let Some(target) = line.strip_prefix("object ") {
222            return target.trim().parse::<ObjectId>().ok();
223        }
224    }
225    None
226}
227
228#[cfg(test)]
229mod tests {
230    use super::pattern_matches;
231
232    #[test]
233    fn pattern_matches_empty_allows_all() {
234        assert!(pattern_matches("refs/heads/main", &[]));
235        assert!(pattern_matches("HEAD", &[]));
236    }
237
238    #[test]
239    fn pattern_matches_exact() {
240        let pats = vec!["HEAD".to_owned()];
241        assert!(pattern_matches("HEAD", &pats));
242        assert!(!pattern_matches("refs/heads/main", &pats));
243    }
244
245    #[test]
246    fn pattern_matches_suffix_component() {
247        let pats = vec!["main".to_owned()];
248        assert!(pattern_matches("refs/heads/main", &pats));
249        assert!(!pattern_matches("refs/heads/notmain", &pats));
250        assert!(!pattern_matches("main-branch", &pats));
251    }
252}