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