Skip to main content

git_lfs_git/
path.rs

1//! Repository path discovery.
2
3use std::path::{Path, PathBuf};
4
5use crate::{Error, run_git};
6
7/// Path to the `.git` directory of the repository containing `cwd`. Always
8/// returns an absolute path. Errors if `cwd` isn't inside a git repository.
9pub fn git_dir(cwd: &Path) -> Result<PathBuf, Error> {
10    run_git(cwd, &["rev-parse", "--absolute-git-dir"]).map(PathBuf::from)
11}
12
13/// Path to the LFS storage directory for the repository (`<git-dir>/lfs`).
14/// The directory is not created.
15pub fn lfs_dir(cwd: &Path) -> Result<PathBuf, Error> {
16    Ok(git_dir(cwd)?.join("lfs"))
17}
18
19/// Path to the working-tree root of the repository containing `cwd`.
20/// Honors `GIT_WORK_TREE`, so this returns the right thing even when
21/// `cwd` is *outside* the work tree (e.g. tests that set both
22/// `GIT_DIR` and `GIT_WORK_TREE` as relative paths from a parent dir).
23/// Errors for bare repos (no work tree) and outside-any-repo callers.
24pub fn work_tree_root(cwd: &Path) -> Result<PathBuf, Error> {
25    run_git(cwd, &["rev-parse", "--show-toplevel"]).map(PathBuf::from)
26}
27
28/// LFS-objects directories belonging to alternate object stores
29/// referenced by this repository. Used to satisfy a `git lfs smudge`
30/// or `git lfs fetch` from a `git clone --shared <source>` checkout
31/// without re-downloading bytes the source already has.
32///
33/// Sources, in order:
34/// 1. `GIT_ALTERNATE_OBJECT_DIRECTORIES` env var (path-list separated).
35/// 2. `<git-dir>/objects/info/alternates` — one object directory per
36///    line; blank lines and `#`-comments skipped.
37///
38/// Each entry names a git *objects* directory (e.g.
39/// `/path/to/source/.git/objects`); the matching LFS-objects
40/// directory lives next to it at `<entry>/../lfs/objects`. Only
41/// directories that actually exist are returned.
42pub fn lfs_alternate_dirs(cwd: &Path) -> Result<Vec<PathBuf>, Error> {
43    let mut dirs: Vec<PathBuf> = Vec::new();
44    let mut push = |objs_dir: &Path| {
45        if let Some(parent) = objs_dir.parent() {
46            let candidate = parent.join("lfs").join("objects");
47            if candidate.is_dir() && !dirs.iter().any(|d| d == &candidate) {
48                dirs.push(candidate);
49            }
50        }
51    };
52
53    if let Some(env) = std::env::var_os("GIT_ALTERNATE_OBJECT_DIRECTORIES") {
54        for raw in std::env::split_paths(&env) {
55            if !raw.as_os_str().is_empty() {
56                push(&raw);
57            }
58        }
59    }
60
61    let alternates_file = git_dir(cwd)?
62        .join("objects")
63        .join("info")
64        .join("alternates");
65    if let Ok(contents) = std::fs::read_to_string(&alternates_file) {
66        for line in contents.lines() {
67            let trimmed = line.trim();
68            if trimmed.is_empty() || trimmed.starts_with('#') {
69                continue;
70            }
71            let raw = unquote_alternate(trimmed);
72            push(Path::new(raw.as_ref()));
73        }
74    }
75
76    Ok(dirs)
77}
78
79/// Strip C-style quotes from one `objects/info/alternates` line and
80/// expand the common escapes (`\\`, `\"`, `\n`, `\t`, `\r`). Git emits
81/// these when an alternate path contains characters that would
82/// otherwise be ambiguous on the line. Returns the input unchanged
83/// when there's no leading quote, so plain paths are still handled.
84fn unquote_alternate(line: &str) -> std::borrow::Cow<'_, str> {
85    if !line.starts_with('"') {
86        return std::borrow::Cow::Borrowed(line);
87    }
88    let Some(end) = line.rfind('"') else {
89        return std::borrow::Cow::Borrowed(line);
90    };
91    if end == 0 {
92        return std::borrow::Cow::Borrowed(line);
93    }
94    let inner = &line[1..end];
95    let mut out = String::with_capacity(inner.len());
96    let mut chars = inner.chars();
97    while let Some(c) = chars.next() {
98        if c != '\\' {
99            out.push(c);
100            continue;
101        }
102        match chars.next() {
103            Some('\\') => out.push('\\'),
104            Some('"') => out.push('"'),
105            Some('n') => out.push('\n'),
106            Some('t') => out.push('\t'),
107            Some('r') => out.push('\r'),
108            // Anything else: emit literally — git supports more
109            // (octal, \xNN), but the alternate-paths use case
110            // basically never needs them.
111            Some(other) => {
112                out.push('\\');
113                out.push(other);
114            }
115            None => out.push('\\'),
116        }
117    }
118    std::borrow::Cow::Owned(out)
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124    use std::process::Command;
125    use tempfile::TempDir;
126
127    fn init_repo() -> TempDir {
128        let tmp = TempDir::new().unwrap();
129        let status = Command::new("git")
130            .args(["init", "--quiet"])
131            .arg(tmp.path())
132            .status()
133            .unwrap();
134        assert!(status.success(), "git init failed");
135        tmp
136    }
137
138    #[test]
139    fn git_dir_is_absolute() {
140        let tmp = init_repo();
141        let dir = git_dir(tmp.path()).unwrap();
142        assert!(dir.is_absolute(), "{dir:?}");
143        assert_eq!(dir.file_name().unwrap(), ".git");
144    }
145
146    #[test]
147    fn lfs_dir_under_git_dir() {
148        let tmp = init_repo();
149        let dir = lfs_dir(tmp.path()).unwrap();
150        assert!(dir.ends_with(".git/lfs"));
151    }
152
153    #[test]
154    fn outside_repo_errors() {
155        let tmp = TempDir::new().unwrap();
156        let err = git_dir(tmp.path()).unwrap_err();
157        assert!(matches!(err, Error::Failed(_)), "got {err:?}");
158    }
159
160    #[test]
161    fn lfs_alternate_dirs_empty_without_alternates_file() {
162        let tmp = init_repo();
163        let dirs = lfs_alternate_dirs(tmp.path()).unwrap();
164        assert!(dirs.is_empty());
165    }
166
167    #[test]
168    fn lfs_alternate_dirs_resolves_via_alternates_file() {
169        let source = init_repo();
170        let lfs_objs = source.path().join(".git/lfs/objects");
171        std::fs::create_dir_all(&lfs_objs).unwrap();
172
173        let target = init_repo();
174        let alt_path = target.path().join(".git/objects/info/alternates");
175        std::fs::create_dir_all(alt_path.parent().unwrap()).unwrap();
176        std::fs::write(
177            &alt_path,
178            format!("{}\n", source.path().join(".git/objects").display()),
179        )
180        .unwrap();
181
182        let dirs = lfs_alternate_dirs(target.path()).unwrap();
183        assert_eq!(dirs, vec![lfs_objs]);
184    }
185
186    #[test]
187    fn lfs_alternate_dirs_skips_blank_and_comment_lines() {
188        let source = init_repo();
189        std::fs::create_dir_all(source.path().join(".git/lfs/objects")).unwrap();
190
191        let target = init_repo();
192        let alt_path = target.path().join(".git/objects/info/alternates");
193        std::fs::create_dir_all(alt_path.parent().unwrap()).unwrap();
194        std::fs::write(
195            &alt_path,
196            format!(
197                "# preamble comment\n\n{}\n",
198                source.path().join(".git/objects").display()
199            ),
200        )
201        .unwrap();
202
203        let dirs = lfs_alternate_dirs(target.path()).unwrap();
204        assert_eq!(dirs.len(), 1);
205    }
206
207    #[test]
208    fn lfs_alternate_dirs_handles_quoted_path() {
209        let source = init_repo();
210        let lfs_objs = source.path().join(".git/lfs/objects");
211        std::fs::create_dir_all(&lfs_objs).unwrap();
212
213        let target = init_repo();
214        let alt_path = target.path().join(".git/objects/info/alternates");
215        std::fs::create_dir_all(alt_path.parent().unwrap()).unwrap();
216        std::fs::write(
217            &alt_path,
218            format!("\"{}\"\n", source.path().join(".git/objects").display()),
219        )
220        .unwrap();
221
222        let dirs = lfs_alternate_dirs(target.path()).unwrap();
223        assert_eq!(dirs, vec![lfs_objs]);
224    }
225
226    #[test]
227    fn unquote_alternate_handles_escapes() {
228        assert_eq!(unquote_alternate("/plain/path"), "/plain/path");
229        assert_eq!(unquote_alternate(r#""/quoted/path""#), "/quoted/path");
230        assert_eq!(unquote_alternate(r#""a\\b""#), "a\\b");
231        assert_eq!(unquote_alternate(r#""a\"b""#), "a\"b");
232        assert_eq!(unquote_alternate(r#""line1\nline2""#), "line1\nline2");
233    }
234
235    #[test]
236    fn lfs_alternate_dirs_skips_alternates_without_lfs_storage() {
237        // A .git that has /objects/ but no /lfs/objects/ — common for
238        // repos that don't use LFS — should be silently skipped.
239        let source = init_repo();
240        // Note: deliberately *not* creating .git/lfs/objects.
241        let target = init_repo();
242        let alt_path = target.path().join(".git/objects/info/alternates");
243        std::fs::create_dir_all(alt_path.parent().unwrap()).unwrap();
244        std::fs::write(
245            &alt_path,
246            format!("{}\n", source.path().join(".git/objects").display()),
247        )
248        .unwrap();
249
250        let dirs = lfs_alternate_dirs(target.path()).unwrap();
251        assert!(dirs.is_empty());
252    }
253}