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    /// When set together with [`Options::symref`], resolve symref targets for
47    /// **all** symbolic refs, not just `HEAD`.
48    ///
49    /// Mirrors protocol v2 `ls-refs`, where every symbolic ref carries a
50    /// `symref-target`. Protocol v0 only advertises the `HEAD` symref via a
51    /// capability, so it leaves this `false`.
52    pub all_symrefs: bool,
53    /// If non-empty, only return refs matching one of these patterns.
54    ///
55    /// A ref matches when it equals the pattern exactly **or** when its name
56    /// ends with `/<pattern>`.
57    pub patterns: Vec<String>,
58}
59
60/// List references from the repository at `git_dir`.
61///
62/// Returns entries with HEAD first (when not suppressed), then all other refs
63/// sorted lexicographically.  Annotated tags are followed by a peeled entry
64/// (`refs/tags/name^{}`) unless [`Options::refs_only`] is set.
65///
66/// # Parameters
67///
68/// - `git_dir` — path to the `.git` directory or bare repository root.
69/// - `odb` — object database, used to peel annotated tag objects.
70/// - `opts` — filtering and output options.
71///
72/// # Errors
73///
74/// Returns [`Error::Io`] on filesystem errors during ref traversal.
75pub fn ls_remote(git_dir: &Path, odb: &Odb, opts: &Options) -> Result<Vec<RefEntry>> {
76    let mut entries = Vec::new();
77
78    let include_head = !opts.heads && !opts.tags && !opts.refs_only;
79    if include_head {
80        if let Ok(head_oid) = crate::refs::resolve_ref(git_dir, "HEAD") {
81            let symref_target = if opts.symref {
82                crate::refs::read_symbolic_ref(git_dir, "HEAD")?
83            } else {
84                None
85            };
86            if pattern_matches("HEAD", &opts.patterns) {
87                entries.push(RefEntry {
88                    name: "HEAD".to_owned(),
89                    oid: head_oid,
90                    symref_target,
91                });
92            }
93        }
94    }
95
96    // Linked worktrees store user-visible refs in the common git directory.
97    // Enumerate refs from that common directory when present; otherwise use
98    // the provided git_dir directly.
99    let refs_dir_root = resolve_common_git_dir(git_dir).unwrap_or_else(|| git_dir.to_path_buf());
100
101    let mut all_refs: BTreeMap<String, ObjectId> = BTreeMap::new();
102    collect_loose_refs(
103        &refs_dir_root,
104        &refs_dir_root.join("refs"),
105        "refs",
106        &mut all_refs,
107    )?;
108    for (name, oid) in read_packed_refs(&refs_dir_root)? {
109        all_refs.entry(name).or_insert(oid);
110    }
111
112    for (name, oid) in &all_refs {
113        // Branch names should not themselves begin with "refs/".
114        // If such refs exist due to malformed local state, hide them to
115        // match upload-pack style advertised refs.
116        if let Some(branch_tail) = name.strip_prefix("refs/heads/") {
117            if branch_tail.starts_with("refs/") {
118                continue;
119            }
120        }
121
122        // `--branches`/`--tags` form a union, not an intersection: a ref is
123        // kept when it matches *any* requested category. With neither flag set
124        // every ref is allowed (matching git's `check_ref`).
125        if opts.heads || opts.tags {
126            let is_branch = opts.heads && name.starts_with("refs/heads/");
127            let is_tag = opts.tags && name.starts_with("refs/tags/");
128            if !is_branch && !is_tag {
129                continue;
130            }
131        }
132        if !pattern_matches(name, &opts.patterns) {
133            continue;
134        }
135
136        let symref_target = if opts.symref && opts.all_symrefs {
137            crate::refs::read_symbolic_ref(&refs_dir_root, name)
138                .ok()
139                .flatten()
140        } else {
141            None
142        };
143
144        entries.push(RefEntry {
145            name: name.clone(),
146            oid: *oid,
147            symref_target,
148        });
149
150        if !opts.refs_only && name.starts_with("refs/tags/") {
151            if let Some(peeled) = peel_tag(odb, oid) {
152                entries.push(RefEntry {
153                    name: format!("{name}^{{}}"),
154                    oid: peeled,
155                    symref_target: None,
156                });
157            }
158        }
159    }
160
161    Ok(entries)
162}
163
164/// Resolve the common git directory for linked worktrees.
165///
166/// Returns `None` when `git_dir/commondir` is absent or invalid.
167fn resolve_common_git_dir(git_dir: &Path) -> Option<PathBuf> {
168    let raw = fs::read_to_string(git_dir.join("commondir")).ok()?;
169    let rel = raw.trim();
170    if rel.is_empty() {
171        return None;
172    }
173    let candidate = if Path::new(rel).is_absolute() {
174        PathBuf::from(rel)
175    } else {
176        git_dir.join(rel)
177    };
178    candidate.canonicalize().ok()
179}
180
181/// Returns `true` when `refname` matches one of `patterns`, or when `patterns`
182/// is empty (no filtering applied).
183///
184/// A match occurs when:
185/// - `refname == pattern` exactly, **or**
186/// - `refname` ends with `/<pattern>` (suffix component match).
187///
188/// Exposed for callers that need the same rules as `git ls-remote` without
189/// duplicating glob logic (for example protocol v2 `ls-refs` filtering).
190pub fn ref_matches_ls_remote_patterns(refname: &str, patterns: &[String]) -> bool {
191    pattern_matches(refname, patterns)
192}
193
194fn pattern_matches(refname: &str, patterns: &[String]) -> bool {
195    if patterns.is_empty() {
196        return true;
197    }
198    // Mirror git's `tail_match`: each user pattern is matched as `*/<pattern>`
199    // against `/<refname>` with plain wildmatch semantics (`*` spans `/`).
200    let path = format!("/{refname}");
201    patterns.iter().any(|pat| {
202        let full = format!("*/{pat}");
203        glob_match(&full, &path)
204    })
205}
206
207/// Simple glob matching supporting `*` (any sequence) and `?` (single char).
208fn glob_match(pattern: &str, text: &str) -> bool {
209    let pat: Vec<char> = pattern.chars().collect();
210    let txt: Vec<char> = text.chars().collect();
211    let (mut pi, mut ti) = (0, 0);
212    let (mut star_pi, mut star_ti) = (usize::MAX, 0);
213    while ti < txt.len() {
214        if pi < pat.len() && (pat[pi] == '?' || pat[pi] == txt[ti]) {
215            pi += 1;
216            ti += 1;
217        } else if pi < pat.len() && pat[pi] == '*' {
218            star_pi = pi;
219            star_ti = ti;
220            pi += 1;
221        } else if star_pi != usize::MAX {
222            pi = star_pi + 1;
223            star_ti += 1;
224            ti = star_ti;
225        } else {
226            return false;
227        }
228    }
229    while pi < pat.len() && pat[pi] == '*' {
230        pi += 1;
231    }
232    pi == pat.len()
233}
234
235/// Recursively collect all loose refs under `path` into `out`.
236///
237/// `relative` is the ref-name prefix corresponding to `path`
238/// (e.g. `"refs"` for `<git-dir>/refs`).
239fn collect_loose_refs(
240    git_dir: &Path,
241    path: &Path,
242    relative: &str,
243    out: &mut BTreeMap<String, ObjectId>,
244) -> Result<()> {
245    let read_dir = match fs::read_dir(path) {
246        Ok(rd) => rd,
247        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
248        Err(e) => return Err(Error::Io(e)),
249    };
250    for entry in read_dir {
251        let entry = entry?;
252        let file_name = entry.file_name().to_string_lossy().to_string();
253        let next_relative = format!("{relative}/{file_name}");
254        let file_type = entry.file_type()?;
255        if file_type.is_dir() {
256            collect_loose_refs(git_dir, &entry.path(), &next_relative, out)?;
257        } else if file_type.is_file() {
258            if let Ok(oid) = crate::refs::resolve_ref(git_dir, &next_relative) {
259                out.insert(next_relative, oid);
260            }
261        }
262    }
263    Ok(())
264}
265
266/// Parse `<git-dir>/packed-refs` and return all `(name, oid)` pairs.
267///
268/// Comment lines (`#`) and peeling lines (`^`) are skipped.
269/// Returns an empty `Vec` when the file does not exist.
270///
271/// # Errors
272///
273/// Returns [`Error::Io`] on read errors other than `NotFound`.
274fn read_packed_refs(git_dir: &Path) -> Result<Vec<(String, ObjectId)>> {
275    let path = git_dir.join("packed-refs");
276    let text = match fs::read_to_string(path) {
277        Ok(t) => t,
278        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
279        Err(e) => return Err(Error::Io(e)),
280    };
281    let mut entries = Vec::new();
282    for line in text.lines() {
283        if line.is_empty() || line.starts_with('#') || line.starts_with('^') {
284            continue;
285        }
286        let mut parts = line.split_whitespace();
287        let Some(oid_str) = parts.next() else {
288            continue;
289        };
290        let Some(name) = parts.next() else {
291            continue;
292        };
293        if let Ok(oid) = oid_str.parse::<ObjectId>() {
294            entries.push((name.to_owned(), oid));
295        }
296    }
297    Ok(entries)
298}
299
300/// Attempt to peel an annotated tag object to the object it points at.
301///
302/// Returns `Some(target_oid)` when `oid` is a tag object that contains an
303/// `object <hex>` header.  Returns `None` for non-tag objects, unreadable
304/// objects, or malformed tag data.
305fn peel_tag(odb: &Odb, oid: &ObjectId) -> Option<ObjectId> {
306    let obj = odb.read(oid).ok()?;
307    if obj.kind != ObjectKind::Tag {
308        return None;
309    }
310    let text = std::str::from_utf8(&obj.data).ok()?;
311    for line in text.lines() {
312        if let Some(target) = line.strip_prefix("object ") {
313            return target.trim().parse::<ObjectId>().ok();
314        }
315    }
316    None
317}
318
319#[cfg(test)]
320mod tests {
321    use super::pattern_matches;
322
323    #[test]
324    fn pattern_matches_empty_allows_all() {
325        assert!(pattern_matches("refs/heads/main", &[]));
326        assert!(pattern_matches("HEAD", &[]));
327    }
328
329    #[test]
330    fn pattern_matches_exact() {
331        let pats = vec!["HEAD".to_owned()];
332        assert!(pattern_matches("HEAD", &pats));
333        assert!(!pattern_matches("refs/heads/main", &pats));
334    }
335
336    #[test]
337    fn pattern_matches_suffix_component() {
338        let pats = vec!["main".to_owned()];
339        assert!(pattern_matches("refs/heads/main", &pats));
340        assert!(!pattern_matches("refs/heads/notmain", &pats));
341        assert!(!pattern_matches("main-branch", &pats));
342    }
343}