1use std::path::{Path, PathBuf};
4
5use crate::{Error, run_git};
6
7pub fn git_dir(cwd: &Path) -> Result<PathBuf, Error> {
17 run_git(cwd, &["rev-parse", "--absolute-git-dir"]).map(PathBuf::from)
18}
19
20pub 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 let absolute = if p.is_absolute() { p } else { cwd.join(p) };
39 Ok(clean_curdir(&absolute))
40}
41
42fn 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 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
76pub fn lfs_dir(cwd: &Path) -> Result<PathBuf, Error> {
82 Ok(git_common_dir(cwd)?.join("lfs"))
83}
84
85pub fn work_tree_root(cwd: &Path) -> Result<PathBuf, Error> {
91 run_git(cwd, &["rev-parse", "--show-toplevel"]).map(PathBuf::from)
92}
93
94pub 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 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
147fn 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 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 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 assert_eq!(
240 git_dir(tmp.path()).unwrap(),
241 git_common_dir(tmp.path()).unwrap()
242 );
243 }
244
245 #[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 let source = init_repo();
341 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}