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 **per-worktree** `.git` directory of the repository
8/// containing `cwd`. Always returns an absolute path. Errors if `cwd`
9/// isn't inside a git repository.
10///
11/// In a linked worktree this is `.git/worktrees/<name>/`, *not* the
12/// shared main `.git/`. Use this when you want per-worktree state
13/// (HEAD, index, info/, hooks-when-`--worktree`-scoped). For shared
14/// storage (objects, packs, LFS cache, alternates), use
15/// [`git_common_dir`].
16pub fn git_dir(cwd: &Path) -> Result<PathBuf, Error> {
17    run_git(cwd, &["rev-parse", "--absolute-git-dir"]).map(PathBuf::from)
18}
19
20/// Path to the **shared** `.git` directory of the repository containing
21/// `cwd`. Equivalent to [`git_dir`] in repos without linked worktrees;
22/// in worktree-having repos it always returns the main `.git/` rather
23/// than the per-worktree subtree.
24///
25/// Use this for anything stored once per repo: object database,
26/// `objects/info/alternates`, default hooks, and the LFS object cache
27/// at `.git/lfs/`. Mirrors upstream's `git.GitCommonDir()` /
28/// `Configuration.LocalGitStorageDir()`.
29pub fn git_common_dir(cwd: &Path) -> Result<PathBuf, Error> {
30    let raw = run_git(cwd, &["rev-parse", "--git-common-dir"])?;
31    let p = PathBuf::from(&raw);
32    // `--git-common-dir` can return a relative path (`.git` from the
33    // worktree root, `.` from inside the .git dir, sometimes `.git/.`).
34    // Anchor against `cwd` so the result is absolute (matching
35    // `git_dir`'s behavior), and lexically clean any leftover
36    // `CurDir` components so `LocalGitStorageDir` doesn't end up with
37    // a stray `/.`.
38    let absolute = if p.is_absolute() { p } else { cwd.join(p) };
39    Ok(clean_curdir(&absolute))
40}
41
42/// Lexically clean `p` — drop `CurDir` (`.`) components, collapse
43/// `ParentDir` (`..`) by popping the previous component when one
44/// exists. Pure path-string normalization, no I/O. Mirrors Go's
45/// `path/filepath.Clean` for the cases produced by
46/// `git rev-parse --git-common-dir`: `.git`, `./.git`, `.git/.`,
47/// `a/../.git`, etc.
48fn clean_curdir(p: &Path) -> PathBuf {
49    use std::path::Component;
50    let mut out: Vec<Component> = Vec::new();
51    for c in p.components() {
52        match c {
53            Component::CurDir => continue,
54            Component::ParentDir => {
55                // Only collapse when the previous component is a
56                // poppable normal segment. Don't pop a root or
57                // prefix; don't pop another `..` (would change the
58                // meaning when the path *starts* with `..`).
59                let pop_ok = matches!(out.last(), Some(Component::Normal(_)));
60                if pop_ok {
61                    out.pop();
62                } else {
63                    out.push(c);
64                }
65            }
66            other => out.push(other),
67        }
68    }
69    let mut buf = PathBuf::new();
70    for c in &out {
71        buf.push(c.as_os_str());
72    }
73    buf
74}
75
76/// Path to the LFS storage directory (`<common-git-dir>/lfs`). The
77/// directory is not created. Routed through [`git_common_dir`] so a
78/// linked worktree shares the same on-disk LFS object cache as its
79/// main repo — `git lfs prune` from one worktree sees the same 100%
80/// of objects as `git lfs fetch` from another.
81pub fn lfs_dir(cwd: &Path) -> Result<PathBuf, Error> {
82    Ok(git_common_dir(cwd)?.join("lfs"))
83}
84
85/// Path to the working-tree root of the repository containing `cwd`.
86/// Honors `GIT_WORK_TREE`, so this returns the right thing even when
87/// `cwd` is *outside* the work tree (e.g. tests that set both
88/// `GIT_DIR` and `GIT_WORK_TREE` as relative paths from a parent dir).
89/// Errors for bare repos (no work tree) and outside-any-repo callers.
90pub fn work_tree_root(cwd: &Path) -> Result<PathBuf, Error> {
91    run_git(cwd, &["rev-parse", "--show-toplevel"]).map(PathBuf::from)
92}
93
94/// LFS-objects directories belonging to alternate object stores
95/// referenced by this repository. Used to satisfy a `git lfs smudge`
96/// or `git lfs fetch` from a `git clone --shared <source>` checkout
97/// without re-downloading bytes the source already has.
98///
99/// Sources, in order:
100/// 1. `GIT_ALTERNATE_OBJECT_DIRECTORIES` env var (path-list separated).
101/// 2. `<git-dir>/objects/info/alternates` — one object directory per
102///    line; blank lines and `#`-comments skipped.
103///
104/// Each entry names a git *objects* directory (e.g.
105/// `/path/to/source/.git/objects`); the matching LFS-objects
106/// directory lives next to it at `<entry>/../lfs/objects`. Only
107/// directories that actually exist are returned.
108pub fn lfs_alternate_dirs(cwd: &Path) -> Result<Vec<PathBuf>, Error> {
109    let mut dirs: Vec<PathBuf> = Vec::new();
110    let mut push = |objs_dir: &Path| {
111        if let Some(parent) = objs_dir.parent() {
112            let candidate = parent.join("lfs").join("objects");
113            if candidate.is_dir() && !dirs.iter().any(|d| d == &candidate) {
114                dirs.push(candidate);
115            }
116        }
117    };
118
119    if let Some(env) = std::env::var_os("GIT_ALTERNATE_OBJECT_DIRECTORIES") {
120        for raw in std::env::split_paths(&env) {
121            if !raw.as_os_str().is_empty() {
122                push(&raw);
123            }
124        }
125    }
126
127    // `objects/info/alternates` is shared across linked worktrees —
128    // it lives in the common git-dir, not the per-worktree one.
129    let alternates_file = git_common_dir(cwd)?
130        .join("objects")
131        .join("info")
132        .join("alternates");
133    if let Ok(contents) = std::fs::read_to_string(&alternates_file) {
134        for line in contents.lines() {
135            let trimmed = line.trim();
136            if trimmed.is_empty() || trimmed.starts_with('#') {
137                continue;
138            }
139            let raw = unquote_alternate(trimmed);
140            push(Path::new(raw.as_ref()));
141        }
142    }
143
144    Ok(dirs)
145}
146
147/// Strip C-style quotes from one `objects/info/alternates` line and
148/// expand the common escapes (`\\`, `\"`, `\n`, `\t`, `\r`). Git emits
149/// these when an alternate path contains characters that would
150/// otherwise be ambiguous on the line. Returns the input unchanged
151/// when there's no leading quote, so plain paths are still handled.
152fn unquote_alternate(line: &str) -> std::borrow::Cow<'_, str> {
153    if !line.starts_with('"') {
154        return std::borrow::Cow::Borrowed(line);
155    }
156    let Some(end) = line.rfind('"') else {
157        return std::borrow::Cow::Borrowed(line);
158    };
159    if end == 0 {
160        return std::borrow::Cow::Borrowed(line);
161    }
162    let inner = &line[1..end];
163    let mut out = String::with_capacity(inner.len());
164    let mut chars = inner.chars();
165    while let Some(c) = chars.next() {
166        if c != '\\' {
167            out.push(c);
168            continue;
169        }
170        match chars.next() {
171            Some('\\') => out.push('\\'),
172            Some('"') => out.push('"'),
173            Some('n') => out.push('\n'),
174            Some('t') => out.push('\t'),
175            Some('r') => out.push('\r'),
176            // Anything else: emit literally — git supports more
177            // (octal, \xNN), but the alternate-paths use case
178            // basically never needs them.
179            Some(other) => {
180                out.push('\\');
181                out.push(other);
182            }
183            None => out.push('\\'),
184        }
185    }
186    std::borrow::Cow::Owned(out)
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use std::process::Command;
193    use tempfile::TempDir;
194
195    fn init_repo() -> TempDir {
196        // Fail loudly if the test process inherits GIT_DIR / GIT_WORK_TREE.
197        // With those set, `git init <tempdir>` ignores the path arg and
198        // operates on the inherited git-dir instead — so the tempdir
199        // never becomes a real repo and the test silently exercises the
200        // wrong state. The `Justfile` pre-commit recipe strips these
201        // for us; this assertion is the canary if anything else slips.
202        for var in ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"] {
203            assert!(
204                std::env::var_os(var).is_none(),
205                "{var} is set in the test process — git subprocesses will \
206                 ignore the per-test tempdir. Run via `just pre-commit` (which \
207                 strips it) or `env -u {var} cargo test`."
208            );
209        }
210        let tmp = TempDir::new().unwrap();
211        let status = Command::new("git")
212            .args(["init", "--quiet"])
213            .arg(tmp.path())
214            .status()
215            .unwrap();
216        assert!(status.success(), "git init failed");
217        tmp
218    }
219
220    #[test]
221    fn git_dir_is_absolute() {
222        let tmp = init_repo();
223        let dir = git_dir(tmp.path()).unwrap();
224        assert!(dir.is_absolute(), "{dir:?}");
225        assert_eq!(dir.file_name().unwrap(), ".git");
226    }
227
228    #[test]
229    fn lfs_dir_under_git_dir() {
230        let tmp = init_repo();
231        let dir = lfs_dir(tmp.path()).unwrap();
232        assert!(dir.ends_with(".git/lfs"));
233    }
234
235    #[test]
236    fn git_common_dir_matches_git_dir_for_main_worktree() {
237        let tmp = init_repo();
238        // Outside any linked-worktree setup, the two are identical.
239        assert_eq!(
240            git_dir(tmp.path()).unwrap(),
241            git_common_dir(tmp.path()).unwrap()
242        );
243    }
244
245    // Note: the multi-worktree case (verifying that `lfs_dir` from a
246    // linked worktree resolves to the *main* repo's `.git/lfs/`) is
247    // covered end-to-end by the vendored `t-worktree.sh` and
248    // `t-prune-worktree.sh` shell tests. A unit-test version was tried
249    // but flaked under parallel `cargo test` execution because
250    // `git worktree add` touches HOME / global config in ways that
251    // racing threads can perturb. The shell suite runs serially per
252    // file under prove and is the authoritative coverage.
253
254    #[test]
255    fn outside_repo_errors() {
256        let tmp = TempDir::new().unwrap();
257        let err = git_dir(tmp.path()).unwrap_err();
258        assert!(matches!(err, Error::Failed(_)), "got {err:?}");
259    }
260
261    #[test]
262    fn lfs_alternate_dirs_empty_without_alternates_file() {
263        let tmp = init_repo();
264        let dirs = lfs_alternate_dirs(tmp.path()).unwrap();
265        assert!(dirs.is_empty());
266    }
267
268    #[test]
269    fn lfs_alternate_dirs_resolves_via_alternates_file() {
270        let source = init_repo();
271        let lfs_objs = source.path().join(".git/lfs/objects");
272        std::fs::create_dir_all(&lfs_objs).unwrap();
273
274        let target = init_repo();
275        let alt_path = target.path().join(".git/objects/info/alternates");
276        std::fs::create_dir_all(alt_path.parent().unwrap()).unwrap();
277        std::fs::write(
278            &alt_path,
279            format!("{}\n", source.path().join(".git/objects").display()),
280        )
281        .unwrap();
282
283        let dirs = lfs_alternate_dirs(target.path()).unwrap();
284        assert_eq!(dirs, vec![lfs_objs]);
285    }
286
287    #[test]
288    fn lfs_alternate_dirs_skips_blank_and_comment_lines() {
289        let source = init_repo();
290        std::fs::create_dir_all(source.path().join(".git/lfs/objects")).unwrap();
291
292        let target = init_repo();
293        let alt_path = target.path().join(".git/objects/info/alternates");
294        std::fs::create_dir_all(alt_path.parent().unwrap()).unwrap();
295        std::fs::write(
296            &alt_path,
297            format!(
298                "# preamble comment\n\n{}\n",
299                source.path().join(".git/objects").display()
300            ),
301        )
302        .unwrap();
303
304        let dirs = lfs_alternate_dirs(target.path()).unwrap();
305        assert_eq!(dirs.len(), 1);
306    }
307
308    #[test]
309    fn lfs_alternate_dirs_handles_quoted_path() {
310        let source = init_repo();
311        let lfs_objs = source.path().join(".git/lfs/objects");
312        std::fs::create_dir_all(&lfs_objs).unwrap();
313
314        let target = init_repo();
315        let alt_path = target.path().join(".git/objects/info/alternates");
316        std::fs::create_dir_all(alt_path.parent().unwrap()).unwrap();
317        std::fs::write(
318            &alt_path,
319            format!("\"{}\"\n", source.path().join(".git/objects").display()),
320        )
321        .unwrap();
322
323        let dirs = lfs_alternate_dirs(target.path()).unwrap();
324        assert_eq!(dirs, vec![lfs_objs]);
325    }
326
327    #[test]
328    fn unquote_alternate_handles_escapes() {
329        assert_eq!(unquote_alternate("/plain/path"), "/plain/path");
330        assert_eq!(unquote_alternate(r#""/quoted/path""#), "/quoted/path");
331        assert_eq!(unquote_alternate(r#""a\\b""#), "a\\b");
332        assert_eq!(unquote_alternate(r#""a\"b""#), "a\"b");
333        assert_eq!(unquote_alternate(r#""line1\nline2""#), "line1\nline2");
334    }
335
336    #[test]
337    fn lfs_alternate_dirs_skips_alternates_without_lfs_storage() {
338        // A .git that has /objects/ but no /lfs/objects/ — common for
339        // repos that don't use LFS — should be silently skipped.
340        let source = init_repo();
341        // Note: deliberately *not* creating .git/lfs/objects.
342        let target = init_repo();
343        let alt_path = target.path().join(".git/objects/info/alternates");
344        std::fs::create_dir_all(alt_path.parent().unwrap()).unwrap();
345        std::fs::write(
346            &alt_path,
347            format!("{}\n", source.path().join(".git/objects").display()),
348        )
349        .unwrap();
350
351        let dirs = lfs_alternate_dirs(target.path()).unwrap();
352        assert!(dirs.is_empty());
353    }
354
355    #[test]
356    fn lfs_dir_resolves_to_main_repo_from_linked_worktree() {
357        let main = init_repo();
358        let run = |args: &[&str]| {
359            let st = Command::new("git")
360                .arg("-C")
361                .arg(main.path())
362                .args(args)
363                .status()
364                .unwrap();
365            assert!(st.success(), "git {args:?} failed");
366        };
367        run(&["config", "user.email", "t@e"]);
368        run(&["config", "user.name", "t"]);
369        run(&["config", "commit.gpgsign", "false"]);
370        run(&["commit", "--allow-empty", "-q", "-m", "init"]);
371        run(&["branch", "feature"]);
372
373        let wt_holder = TempDir::new().unwrap();
374        let wt_dir = wt_holder.path().join("wt");
375        let status = Command::new("git")
376            .arg("-C")
377            .arg(main.path())
378            .args(["worktree", "add"])
379            .arg(&wt_dir)
380            .arg("feature")
381            .status()
382            .unwrap();
383        assert!(status.success(), "git worktree add failed");
384
385        let main_lfs = lfs_dir(main.path()).unwrap();
386        let wt_lfs = lfs_dir(&wt_dir).unwrap();
387        std::fs::create_dir_all(&main_lfs).unwrap();
388        let canon = |p: PathBuf| std::fs::canonicalize(p).unwrap_or_else(|_| PathBuf::new());
389        assert_eq!(canon(main_lfs), canon(wt_lfs));
390    }
391}