Skip to main content

grit_lib/
refs.rs

1//! Reference storage — files backend.
2//!
3//! Git stores references as text files under `<git-dir>/refs/` (and
4//! `<git-dir>/packed-refs` for the packed backend).  Each loose ref file
5//! contains either:
6//!
7//! - A 40-character hex SHA-1 followed by a newline, **or**
8//! - The string `"ref: <target>\n"` for symbolic refs.
9//!
10//! `HEAD` is a special case: it is normally a symbolic ref but may also be
11//! detached (pointing directly at a commit hash).
12//!
13//! # Scope
14//!
15//! This module implements the **files backend** only (loose refs + read-only
16//! packed-refs).  The reftable backend is out of scope for v1.
17
18use std::fs;
19use std::io;
20use std::path::Path;
21
22use crate::error::{Error, Result};
23use crate::objects::ObjectId;
24
25/// A symbolic or direct reference.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum Ref {
28    /// Direct reference: stores an [`ObjectId`].
29    Direct(ObjectId),
30    /// Symbolic reference: stores the name of the target ref.
31    Symbolic(String),
32}
33
34/// Read a single reference file from `path`.
35///
36/// # Errors
37///
38/// - [`Error::InvalidRef`] if the file content is not a valid ref.
39/// - [`Error::Io`] on filesystem errors.
40pub fn read_ref_file(path: &Path) -> Result<Ref> {
41    let content = fs::read_to_string(path).map_err(Error::Io)?;
42    let content = content.trim_end_matches('\n');
43    parse_ref_content(content)
44}
45
46/// Parse the content of a ref file (without trailing newline).
47pub(crate) fn parse_ref_content(content: &str) -> Result<Ref> {
48    if let Some(target) = content.strip_prefix("ref: ") {
49        Ok(Ref::Symbolic(target.trim().to_owned()))
50    } else if content.len() == 40 && content.chars().all(|c| c.is_ascii_hexdigit()) {
51        let oid: ObjectId = content.parse()?;
52        Ok(Ref::Direct(oid))
53    } else {
54        Err(Error::InvalidRef(content.to_owned()))
55    }
56}
57
58/// Resolve a reference to its target [`ObjectId`], following symbolic refs.
59///
60/// # Parameters
61///
62/// - `git_dir` — path to the git directory.
63/// - `refname` — reference name (e.g. `"HEAD"`, `"refs/heads/main"`).
64///
65/// # Errors
66///
67/// - [`Error::InvalidRef`] if the ref is malformed or forms a cycle.
68/// - [`Error::ObjectNotFound`] if a symbolic target does not exist.
69pub fn resolve_ref(git_dir: &Path, refname: &str) -> Result<ObjectId> {
70    resolve_ref_depth(git_dir, refname, 0)
71}
72
73/// Internal recursive resolver with cycle detection.
74fn resolve_ref_depth(git_dir: &Path, refname: &str, depth: usize) -> Result<ObjectId> {
75    if depth > 10 {
76        return Err(Error::InvalidRef(format!(
77            "ref symlink too deep: {refname}"
78        )));
79    }
80
81    // First try as a loose ref file
82    let path = git_dir.join(refname);
83    match read_ref_file(&path) {
84        Ok(Ref::Direct(oid)) => return Ok(oid),
85        Ok(Ref::Symbolic(target)) => {
86            return resolve_ref_depth(git_dir, &target, depth + 1);
87        }
88        Err(Error::Io(ref e)) if e.kind() == io::ErrorKind::NotFound => {}
89        Err(e) => return Err(e),
90    }
91
92    // Fall back to packed-refs
93    if let Some(oid) = lookup_packed_ref(git_dir, refname)? {
94        return Ok(oid);
95    }
96
97    Err(Error::InvalidRef(format!("ref not found: {refname}")))
98}
99
100/// Look up a refname in `packed-refs`.
101fn lookup_packed_ref(git_dir: &Path, refname: &str) -> Result<Option<ObjectId>> {
102    let packed = git_dir.join("packed-refs");
103    let content = match fs::read_to_string(&packed) {
104        Ok(c) => c,
105        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
106        Err(e) => return Err(Error::Io(e)),
107    };
108
109    for line in content.lines() {
110        if line.starts_with('#') || line.starts_with('^') {
111            continue;
112        }
113        let mut parts = line.splitn(2, ' ');
114        let hash = parts.next().unwrap_or("");
115        let name = parts.next().unwrap_or("").trim();
116        if name == refname && hash.len() == 40 {
117            let oid: ObjectId = hash.parse()?;
118            return Ok(Some(oid));
119        }
120    }
121    Ok(None)
122}
123
124/// Write a loose ref, creating parent directories as needed.
125///
126/// # Parameters
127///
128/// - `git_dir` — path to the git directory.
129/// - `refname` — reference name (e.g. `"refs/heads/main"`).
130/// - `oid` — the new target object ID.
131///
132/// # Errors
133///
134/// Returns [`Error::Io`] on filesystem errors.
135pub fn write_ref(git_dir: &Path, refname: &str, oid: &ObjectId) -> Result<()> {
136    let path = git_dir.join(refname);
137    if let Some(parent) = path.parent() {
138        fs::create_dir_all(parent)?;
139    }
140    let content = format!("{oid}\n");
141    // Write via lock file for atomicity
142    let lock = path.with_extension("lock");
143    fs::write(&lock, &content)?;
144    fs::rename(&lock, &path)?;
145    Ok(())
146}
147
148/// Delete a loose ref file.
149///
150/// Returns `Ok(())` even if the file did not exist.
151///
152/// # Errors
153///
154/// Returns [`Error::Io`] for errors other than "not found".
155pub fn delete_ref(git_dir: &Path, refname: &str) -> Result<()> {
156    let path = git_dir.join(refname);
157    match fs::remove_file(&path) {
158        Ok(()) => Ok(()),
159        Err(e) if e.kind() == io::ErrorKind::NotFound => {
160            Err(Error::InvalidRef(format!("cannot delete '{refname}': not found")))
161        }
162        Err(e) => Err(Error::Io(e)),
163    }
164}
165
166/// Read the symbolic ref target of `HEAD`.
167///
168/// Returns `None` if HEAD is detached (points directly to a commit hash).
169///
170/// # Errors
171///
172/// Returns [`Error::Io`] or [`Error::InvalidRef`] on failures.
173pub fn read_head(git_dir: &Path) -> Result<Option<String>> {
174    match read_ref_file(&git_dir.join("HEAD"))? {
175        Ref::Symbolic(target) => Ok(Some(target)),
176        Ref::Direct(_) => Ok(None),
177    }
178}
179
180/// Read symbolic target of any loose ref.
181///
182/// Returns `Ok(Some(target))` when `refname` exists and is symbolic,
183/// `Ok(None)` when it is direct or missing.
184pub fn read_symbolic_ref(git_dir: &Path, refname: &str) -> Result<Option<String>> {
185    let path = git_dir.join(refname);
186    match read_ref_file(&path) {
187        Ok(Ref::Symbolic(target)) => Ok(Some(target)),
188        Ok(Ref::Direct(_)) => Ok(None),
189        Err(Error::Io(ref e)) if e.kind() == io::ErrorKind::NotFound => Ok(None),
190        Err(e) => Err(e),
191    }
192}
193
194/// Write a reflog entry.
195///
196/// Appends a line to `<git-dir>/logs/<refname>`.  Creates the log file and
197/// parent directories if they do not exist.
198///
199/// # Parameters
200///
201/// - `git_dir` — path to the git directory.
202/// - `refname` — reference name (e.g. `"refs/heads/main"`).
203/// - `old_oid` — previous OID (use `ObjectId::from_bytes(&[0;20])` for a new ref).
204/// - `new_oid` — new OID.
205/// - `identity` — `"Name <email> <timestamp> <tz>"` formatted string.
206/// - `message` — short log message.
207///
208/// # Errors
209///
210/// Returns [`Error::Io`] on filesystem errors.
211pub fn append_reflog(
212    git_dir: &Path,
213    refname: &str,
214    old_oid: &ObjectId,
215    new_oid: &ObjectId,
216    identity: &str,
217    message: &str,
218) -> Result<()> {
219    let log_path = git_dir.join("logs").join(refname);
220    if let Some(parent) = log_path.parent() {
221        fs::create_dir_all(parent)?;
222    }
223    let line = format!("{old_oid} {new_oid} {identity}\t{message}\n");
224    let mut file = fs::OpenOptions::new()
225        .create(true)
226        .append(true)
227        .open(&log_path)?;
228    use io::Write;
229    file.write_all(line.as_bytes())?;
230    Ok(())
231}
232
233/// List all loose refs under a given prefix (e.g. `"refs/heads/"`).
234///
235/// Returns a sorted list of `(refname, ObjectId)` pairs.
236///
237/// # Errors
238///
239/// Returns [`Error::Io`] on directory traversal errors.
240pub fn list_refs(git_dir: &Path, prefix: &str) -> Result<Vec<(String, ObjectId)>> {
241    let base = git_dir.join(prefix);
242    let mut results = Vec::new();
243    collect_refs(&base, prefix, git_dir, &mut results)?;
244    results.sort_by(|a, b| a.0.cmp(&b.0));
245    Ok(results)
246}
247
248/// List refs matching a glob pattern (e.g. `refs/heads/topic/*`).
249pub fn list_refs_glob(git_dir: &Path, pattern: &str) -> Result<Vec<(String, ObjectId)>> {
250    let glob_pos = pattern.find(|c: char| c == '*' || c == '?' || c == '[');
251    let prefix = match glob_pos {
252        Some(pos) => match pattern[..pos].rfind('/') {
253            Some(slash) => &pattern[..=slash],
254            None => "",
255        },
256        None => pattern,
257    };
258    let all = list_refs(git_dir, prefix)?;
259    let mut results = Vec::new();
260    for (refname, oid) in all {
261        if glob_match(pattern, &refname) {
262            results.push((refname, oid));
263        }
264    }
265    Ok(results)
266}
267
268fn glob_match(pattern: &str, text: &str) -> bool {
269    let pat = pattern.as_bytes();
270    let txt = text.as_bytes();
271    let (mut pi, mut ti) = (0, 0);
272    let (mut star_pi, mut star_ti) = (usize::MAX, 0);
273    while ti < txt.len() {
274        if pi < pat.len() && (pat[pi] == b'?' || pat[pi] == txt[ti]) {
275            pi += 1;
276            ti += 1;
277        } else if pi < pat.len() && pat[pi] == b'*' {
278            star_pi = pi;
279            star_ti = ti;
280            pi += 1;
281        } else if star_pi != usize::MAX {
282            pi = star_pi + 1;
283            star_ti += 1;
284            ti = star_ti;
285        } else {
286            return false;
287        }
288    }
289    while pi < pat.len() && pat[pi] == b'*' {
290        pi += 1;
291    }
292    pi == pat.len()
293}
294
295fn collect_refs(
296    dir: &Path,
297    prefix: &str,
298    git_dir: &Path,
299    out: &mut Vec<(String, ObjectId)>,
300) -> Result<()> {
301    let read = match fs::read_dir(dir) {
302        Ok(r) => r,
303        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
304        Err(e) => return Err(Error::Io(e)),
305    };
306
307    for entry in read {
308        let entry = entry?;
309        let file_type = entry.file_type()?;
310        let name = entry.file_name();
311        let name_str = name.to_string_lossy();
312        let refname = format!("{prefix}{name_str}");
313
314        if file_type.is_dir() {
315            collect_refs(&entry.path(), &format!("{refname}/"), git_dir, out)?;
316        } else if file_type.is_file() {
317            if let Ok(oid) = resolve_ref(git_dir, &refname) {
318                out.push((refname, oid))
319            }
320        }
321    }
322    Ok(())
323}