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 work_tree_root(cwd: &Path) -> Result<PathBuf, Error> {
25 run_git(cwd, &["rev-parse", "--show-toplevel"]).map(PathBuf::from)
26}
27
28pub 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
79fn 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 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 let source = init_repo();
240 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}