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, PathBuf};
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    // Linked worktrees store user-visible refs in the common git directory.
90    // Enumerate refs from that common directory when present; otherwise use
91    // the provided git_dir directly.
92    let refs_dir_root = resolve_common_git_dir(git_dir).unwrap_or_else(|| git_dir.to_path_buf());
93
94    let mut all_refs: BTreeMap<String, ObjectId> = BTreeMap::new();
95    collect_loose_refs(
96        &refs_dir_root,
97        &refs_dir_root.join("refs"),
98        "refs",
99        &mut all_refs,
100    )?;
101    for (name, oid) in read_packed_refs(&refs_dir_root)? {
102        all_refs.entry(name).or_insert(oid);
103    }
104
105    for (name, oid) in &all_refs {
106        // Branch names should not themselves begin with "refs/".
107        // If such refs exist due to malformed local state, hide them to
108        // match upload-pack style advertised refs.
109        if let Some(branch_tail) = name.strip_prefix("refs/heads/") {
110            if branch_tail.starts_with("refs/") {
111                continue;
112            }
113        }
114
115        if opts.heads && !name.starts_with("refs/heads/") {
116            continue;
117        }
118        if opts.tags && !name.starts_with("refs/tags/") {
119            continue;
120        }
121        if !pattern_matches(name, &opts.patterns) {
122            continue;
123        }
124
125        entries.push(RefEntry {
126            name: name.clone(),
127            oid: *oid,
128            symref_target: None,
129        });
130
131        if !opts.refs_only && name.starts_with("refs/tags/") {
132            if let Some(peeled) = peel_tag(odb, oid) {
133                entries.push(RefEntry {
134                    name: format!("{name}^{{}}"),
135                    oid: peeled,
136                    symref_target: None,
137                });
138            }
139        }
140    }
141
142    Ok(entries)
143}
144
145/// Resolve the common git directory for linked worktrees.
146///
147/// Returns `None` when `git_dir/commondir` is absent or invalid.
148fn resolve_common_git_dir(git_dir: &Path) -> Option<PathBuf> {
149    let raw = fs::read_to_string(git_dir.join("commondir")).ok()?;
150    let rel = raw.trim();
151    if rel.is_empty() {
152        return None;
153    }
154    let candidate = if Path::new(rel).is_absolute() {
155        PathBuf::from(rel)
156    } else {
157        git_dir.join(rel)
158    };
159    candidate.canonicalize().ok()
160}
161
162/// Returns `true` when `refname` matches one of `patterns`, or when `patterns`
163/// is empty (no filtering applied).
164///
165/// A match occurs when:
166/// - `refname == pattern` exactly, **or**
167/// - `refname` ends with `/<pattern>` (suffix component match).
168///
169/// Exposed for callers that need the same rules as `git ls-remote` without
170/// duplicating glob logic (for example protocol v2 `ls-refs` filtering).
171pub fn ref_matches_ls_remote_patterns(refname: &str, patterns: &[String]) -> bool {
172    pattern_matches(refname, patterns)
173}
174
175fn pattern_matches(refname: &str, patterns: &[String]) -> bool {
176    if patterns.is_empty() {
177        return true;
178    }
179    patterns.iter().any(|pat| {
180        if pat.contains('*') || pat.contains('?') {
181            // Glob-style matching: '*' matches any sequence, '?' matches one char
182            glob_match(pat, refname)
183        } else {
184            refname == pat
185                || refname
186                    .strip_suffix(pat.as_str())
187                    .is_some_and(|prefix| prefix.ends_with('/'))
188        }
189    })
190}
191
192/// Simple glob matching supporting `*` (any sequence) and `?` (single char).
193fn glob_match(pattern: &str, text: &str) -> bool {
194    let pat: Vec<char> = pattern.chars().collect();
195    let txt: Vec<char> = text.chars().collect();
196    let (mut pi, mut ti) = (0, 0);
197    let (mut star_pi, mut star_ti) = (usize::MAX, 0);
198    while ti < txt.len() {
199        if pi < pat.len() && (pat[pi] == '?' || pat[pi] == txt[ti]) {
200            pi += 1;
201            ti += 1;
202        } else if pi < pat.len() && pat[pi] == '*' {
203            star_pi = pi;
204            star_ti = ti;
205            pi += 1;
206        } else if star_pi != usize::MAX {
207            pi = star_pi + 1;
208            star_ti += 1;
209            ti = star_ti;
210        } else {
211            return false;
212        }
213    }
214    while pi < pat.len() && pat[pi] == '*' {
215        pi += 1;
216    }
217    pi == pat.len()
218}
219
220/// Recursively collect all loose refs under `path` into `out`.
221///
222/// `relative` is the ref-name prefix corresponding to `path`
223/// (e.g. `"refs"` for `<git-dir>/refs`).
224fn collect_loose_refs(
225    git_dir: &Path,
226    path: &Path,
227    relative: &str,
228    out: &mut BTreeMap<String, ObjectId>,
229) -> Result<()> {
230    let read_dir = match fs::read_dir(path) {
231        Ok(rd) => rd,
232        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
233        Err(e) => return Err(Error::Io(e)),
234    };
235    for entry in read_dir {
236        let entry = entry?;
237        let file_name = entry.file_name().to_string_lossy().to_string();
238        let next_relative = format!("{relative}/{file_name}");
239        let file_type = entry.file_type()?;
240        if file_type.is_dir() {
241            collect_loose_refs(git_dir, &entry.path(), &next_relative, out)?;
242        } else if file_type.is_file() {
243            if let Ok(oid) = crate::refs::resolve_ref(git_dir, &next_relative) {
244                out.insert(next_relative, oid);
245            }
246        }
247    }
248    Ok(())
249}
250
251/// Parse `<git-dir>/packed-refs` and return all `(name, oid)` pairs.
252///
253/// Comment lines (`#`) and peeling lines (`^`) are skipped.
254/// Returns an empty `Vec` when the file does not exist.
255///
256/// # Errors
257///
258/// Returns [`Error::Io`] on read errors other than `NotFound`.
259fn read_packed_refs(git_dir: &Path) -> Result<Vec<(String, ObjectId)>> {
260    let path = git_dir.join("packed-refs");
261    let text = match fs::read_to_string(path) {
262        Ok(t) => t,
263        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
264        Err(e) => return Err(Error::Io(e)),
265    };
266    let mut entries = Vec::new();
267    for line in text.lines() {
268        if line.is_empty() || line.starts_with('#') || line.starts_with('^') {
269            continue;
270        }
271        let mut parts = line.split_whitespace();
272        let Some(oid_str) = parts.next() else {
273            continue;
274        };
275        let Some(name) = parts.next() else {
276            continue;
277        };
278        if let Ok(oid) = oid_str.parse::<ObjectId>() {
279            entries.push((name.to_owned(), oid));
280        }
281    }
282    Ok(entries)
283}
284
285/// Attempt to peel an annotated tag object to the object it points at.
286///
287/// Returns `Some(target_oid)` when `oid` is a tag object that contains an
288/// `object <hex>` header.  Returns `None` for non-tag objects, unreadable
289/// objects, or malformed tag data.
290fn peel_tag(odb: &Odb, oid: &ObjectId) -> Option<ObjectId> {
291    let obj = odb.read(oid).ok()?;
292    if obj.kind != ObjectKind::Tag {
293        return None;
294    }
295    let text = std::str::from_utf8(&obj.data).ok()?;
296    for line in text.lines() {
297        if let Some(target) = line.strip_prefix("object ") {
298            return target.trim().parse::<ObjectId>().ok();
299        }
300    }
301    None
302}
303
304#[cfg(test)]
305mod tests {
306    use super::pattern_matches;
307
308    #[test]
309    fn pattern_matches_empty_allows_all() {
310        assert!(pattern_matches("refs/heads/main", &[]));
311        assert!(pattern_matches("HEAD", &[]));
312    }
313
314    #[test]
315    fn pattern_matches_exact() {
316        let pats = vec!["HEAD".to_owned()];
317        assert!(pattern_matches("HEAD", &pats));
318        assert!(!pattern_matches("refs/heads/main", &pats));
319    }
320
321    #[test]
322    fn pattern_matches_suffix_component() {
323        let pats = vec!["main".to_owned()];
324        assert!(pattern_matches("refs/heads/main", &pats));
325        assert!(!pattern_matches("refs/heads/notmain", &pats));
326        assert!(!pattern_matches("main-branch", &pats));
327    }
328}