1use std::path::{Path, PathBuf};
4
5use crate::{Error, run_git};
6
7pub fn git_dir(cwd: &Path) -> Result<PathBuf, Error> {
10 run_git(cwd, &["rev-parse", "--absolute-git-dir"]).map(PathBuf::from)
11}
12
13pub fn lfs_dir(cwd: &Path) -> Result<PathBuf, Error> {
16 Ok(git_dir(cwd)?.join("lfs"))
17}
18
19pub 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
70fn 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 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 let source = init_repo();
231 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}