1use std::ffi::OsStr;
32use std::path::{Component, Path, PathBuf};
33
34const SHIM: &str = "/usr/bin/git";
36const XCODE: &str = "/Applications/Xcode.app/Contents/Developer";
38const COMMAND_LINE_TOOLS: &str = "/Library/Developer/CommandLineTools";
40#[cfg(target_os = "macos")]
42const SELECT_LINK: &str = "/var/db/xcode_select_link";
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub struct GitUnavailable;
48
49impl std::fmt::Display for GitUnavailable {
50 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51 f.write_str(
52 "git unavailable: run `xcode-select --install` \
53 (no git on PATH and Apple's Command Line Tools are not installed)",
54 )
55 }
56}
57
58impl std::error::Error for GitUnavailable {}
59
60#[cfg(target_os = "macos")]
64pub fn git_program() -> Result<PathBuf, GitUnavailable> {
65 let path = std::env::var_os("PATH");
66 resolve_path_git(path.as_deref(), is_real_git, is_shim, || {
67 developer_git().ok()
68 })
69 .ok_or(GitUnavailable)
70}
71
72#[cfg(target_os = "macos")]
78pub fn developer_git() -> Result<PathBuf, GitUnavailable> {
79 let developer_dir = std::env::var_os("DEVELOPER_DIR");
80 let select_link = std::fs::read_link(SELECT_LINK).ok();
81 resolve_developer_git(
82 developer_dir.as_deref(),
83 select_link.as_deref(),
84 is_real_git,
85 )
86 .ok_or(GitUnavailable)
87}
88
89#[cfg(not(target_os = "macos"))]
91pub fn git_program() -> Result<PathBuf, GitUnavailable> {
92 Ok(PathBuf::from("git"))
93}
94
95#[cfg(not(target_os = "macos"))]
97pub fn developer_git() -> Result<PathBuf, GitUnavailable> {
98 Ok(PathBuf::from("git"))
99}
100
101#[cfg(unix)]
104pub fn is_real_git(git: &Path) -> bool {
105 use std::os::unix::fs::PermissionsExt;
106 std::fs::canonicalize(git).is_ok_and(|real| {
107 real != Path::new(SHIM)
108 && std::fs::metadata(&real)
109 .is_ok_and(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0)
110 })
111}
112
113#[cfg(unix)]
115pub fn is_shim(git: &Path) -> bool {
116 std::fs::canonicalize(git).is_ok_and(|real| real == Path::new(SHIM))
117}
118
119pub fn resolve_path_git(
129 path: Option<&OsStr>,
130 is_git: impl Fn(&Path) -> bool,
131 is_shim: impl Fn(&Path) -> bool,
132 developer_git: impl FnOnce() -> Option<PathBuf>,
133) -> Option<PathBuf> {
134 let mut developer_git = Some(developer_git);
136 let mut developer = || developer_git.take().and_then(|find| find());
137 for dir in std::env::split_paths(path.unwrap_or_default()).filter(|dir| dir.has_root()) {
139 let git = dir.join("git");
140 if git != Path::new(SHIM) && is_git(&git) {
141 return Some(git);
142 }
143 if is_shim(&git) {
144 if let Some(git) = developer() {
145 return Some(git);
146 }
147 }
148 }
149 developer()
150}
151
152pub fn resolve_developer_git(
168 developer_dir: Option<&OsStr>,
169 select_link_target: Option<&Path>,
170 is_file: impl Fn(&Path) -> bool,
171) -> Option<PathBuf> {
172 [
173 developer_dir.map(Path::new),
174 select_link_target,
175 Some(Path::new(XCODE)),
176 Some(Path::new(COMMAND_LINE_TOOLS)),
177 ]
178 .into_iter()
179 .flatten()
180 .filter(|dir| dir.has_root() && !dir.components().any(|c| c == Component::ParentDir))
182 .flat_map(|dir| {
183 [
184 dir.join("usr/bin/git"),
185 dir.join("Contents/Developer/usr/bin/git"),
186 ]
187 })
188 .find(|git| git != Path::new(SHIM) && is_file(git))
189}
190
191#[cfg(test)]
192mod tests {
193 use super::*;
194
195 const BETA_APP: &str = "/Applications/Xcode-beta.app";
196 const BETA: &str = "/Applications/Xcode-beta.app/Contents/Developer";
197 const SELECTED: &str = "/Applications/Xcode-15.app/Contents/Developer";
198
199 fn git(dir: &str) -> PathBuf {
200 Path::new(dir).join("usr/bin/git")
201 }
202
203 fn resolve(dev: Option<&str>, link: Option<&str>, present: &[PathBuf]) -> Option<PathBuf> {
205 resolve_developer_git(dev.map(OsStr::new), link.map(Path::new), |p| {
206 present.iter().any(|g| g == p)
207 })
208 }
209
210 #[test]
211 fn developer_dir_then_select_link_then_xcode_then_command_line_tools() {
212 let all = [
213 git(BETA),
214 git(SELECTED),
215 git(XCODE),
216 git(COMMAND_LINE_TOOLS),
217 ];
218 assert_eq!(resolve(Some(BETA), Some(SELECTED), &all), Some(git(BETA)));
219 assert_eq!(resolve(None, Some(SELECTED), &all), Some(git(SELECTED)));
220 assert_eq!(resolve(None, None, &all), Some(git(XCODE)));
221 let tools = PathBuf::from("/Library/Developer/CommandLineTools/usr/bin/git");
222 assert_eq!(resolve(None, None, &all[3..]), Some(tools));
223 }
224
225 #[test]
226 fn a_candidate_without_git_falls_through() {
227 let rest = [git(SELECTED), git(XCODE), git(COMMAND_LINE_TOOLS)];
228 assert_eq!(
229 resolve(Some(BETA), Some(SELECTED), &rest),
230 Some(git(SELECTED))
231 );
232 assert_eq!(
233 resolve(Some(BETA), Some(SELECTED), &rest[1..]),
234 Some(git(XCODE))
235 );
236 assert_eq!(
237 resolve(Some(BETA), Some(SELECTED), &rest[2..]),
238 Some(git(COMMAND_LINE_TOOLS))
239 );
240 }
241
242 #[test]
243 fn an_app_bundle_candidate_uses_its_contents_developer() {
244 let present = [git(BETA), git(COMMAND_LINE_TOOLS)];
246 assert_eq!(resolve(Some(BETA_APP), None, &present), Some(git(BETA)));
247 assert_eq!(resolve(None, Some(BETA_APP), &present), Some(git(BETA)));
248 assert_eq!(
250 resolve(Some("/Applications/Xcode.app"), None, &[git(XCODE)]),
251 Some(git(XCODE))
252 );
253 }
254
255 #[test]
256 fn relative_or_parent_candidates_are_ignored() {
257 let skipped = ["", "Developer", "Xcode.app/Contents/Developer", "/opt/../x"];
258 let mut present = skipped.map(git).to_vec();
259 present.extend(skipped.map(|dir| git(&format!("{dir}/Contents/Developer"))));
260 let relative = Some("Developer");
261 assert_eq!(resolve(relative, relative, &present), None);
262 present.push(git(COMMAND_LINE_TOOLS));
263 for dir in skipped {
264 let found = resolve(Some(dir), Some(dir), &present);
265 assert_eq!(found, Some(git(COMMAND_LINE_TOOLS)), "{dir:?}");
266 }
267 }
268
269 #[test]
270 fn no_developer_git_is_unavailable() {
271 assert_eq!(resolve(None, None, &[]), None);
272 assert_eq!(resolve(Some(BETA), Some(SELECTED), &[]), None);
273 }
274
275 #[test]
276 fn never_the_usr_bin_git_shim() {
277 let present = [git("/"), git(COMMAND_LINE_TOOLS)];
279 assert_eq!(present[0], Path::new(SHIM));
280 for dir in ["/", "/.", "/usr/..", "/usr/bin/.."] {
281 assert_eq!(
282 resolve(Some(dir), Some(dir), &present),
283 Some(git(COMMAND_LINE_TOOLS))
284 );
285 assert_eq!(
286 resolve(Some(dir), Some(dir), &present[..1]),
287 None,
288 "{dir:?}"
289 );
290 }
291 assert_eq!(resolve(None, None, &present[..1]), None);
292 let path = OsStr::new("/usr/bin");
294 assert_eq!(
295 resolve_path_git(Some(path), |_| true, |_| false, || None),
296 None
297 );
298 }
299
300 fn host(
303 path: &[&str],
304 present: &[&str],
305 shims: &[&str],
306 developer: Option<&str>,
307 ) -> Option<PathBuf> {
308 let path = std::env::join_paths(path).unwrap();
309 resolve_path_git(
310 Some(&path),
311 |git| present.iter().any(|p| git == Path::new(p)),
312 |git| shims.iter().any(|p| git == Path::new(p)),
313 || developer.map(PathBuf::from),
314 )
315 }
316
317 const BREW: &str = "/opt/homebrew/bin/git";
318 const NIX: &str = "/run/current-system/sw/bin/git";
319 const TOOLS: &str = "/Library/Developer/CommandLineTools/usr/bin/git";
320
321 #[test]
322 fn host_runs_the_first_git_on_path() {
323 let path = [
324 "/opt/homebrew/bin",
325 "/run/current-system/sw/bin",
326 "/usr/bin",
327 ];
328 assert_eq!(
329 host(&path, &[BREW, NIX], &[SHIM], Some(TOOLS)),
330 Some(BREW.into())
331 );
332 assert_eq!(host(&path, &[NIX], &[SHIM], Some(TOOLS)), Some(NIX.into()));
333 assert_eq!(
334 host(&path[1..], &[BREW, NIX], &[SHIM], Some(TOOLS)),
335 Some(NIX.into())
336 );
337 }
338
339 #[test]
340 fn host_shim_on_path_stands_for_the_developer_git() {
341 let path = ["/usr/bin", "/opt/homebrew/bin"];
343 assert_eq!(
344 host(&path, &[BREW], &[SHIM], Some(TOOLS)),
345 Some(TOOLS.into())
346 );
347 assert_eq!(host(&path, &[BREW], &[SHIM], None), Some(BREW.into()));
349 let link = "/usr/local/bin/git";
351 let path = ["/usr/local/bin", "/opt/homebrew/bin"];
352 assert_eq!(
353 host(&path, &[BREW], &[link], Some(TOOLS)),
354 Some(TOOLS.into())
355 );
356 assert_eq!(host(&path, &[BREW], &[link], None), Some(BREW.into()));
357 }
358
359 #[test]
360 fn host_falls_back_to_the_developer_git() {
361 assert_eq!(
362 host(&["/opt/homebrew/bin"], &[], &[], Some(TOOLS)),
363 Some(TOOLS.into())
364 );
365 assert_eq!(host(&[], &[], &[], Some(TOOLS)), Some(TOOLS.into()));
366 assert_eq!(
367 resolve_path_git(None, |_| true, |_| true, || Some(TOOLS.into())),
368 Some(TOOLS.into())
369 );
370 assert_eq!(host(&["/usr/bin"], &[], &[SHIM], None), None);
371 assert_eq!(host(&[], &[], &[], None), None);
372 }
373
374 #[test]
375 fn host_asks_for_the_developer_git_at_most_once() {
376 let asked = std::cell::Cell::new(0);
377 let path = std::env::join_paths(["/usr/bin", "/usr/bin", "/opt/homebrew/bin"]).unwrap();
378 let found = resolve_path_git(
379 Some(&path),
380 |_| false,
381 |git| git == Path::new(SHIM),
382 || {
383 asked.set(asked.get() + 1);
384 None
385 },
386 );
387 assert_eq!((found, asked.get()), (None, 1));
388 }
389
390 #[test]
391 fn host_skips_relative_and_empty_path_entries() {
392 let path = ["", ".", "bin", "/opt/homebrew/bin"];
393 let present = ["git", "./git", "bin/git", BREW];
394 assert_eq!(host(&path, &present, &[], None), Some(BREW.into()));
395 assert_eq!(host(&path[..3], &present, &[], None), None);
396 }
397
398 #[cfg(unix)]
401 struct TempDir(PathBuf);
402
403 #[cfg(unix)]
404 impl TempDir {
405 fn new() -> Self {
406 use std::sync::atomic::{AtomicUsize, Ordering};
407 static NEXT: AtomicUsize = AtomicUsize::new(0);
408 let name = format!(
409 "synaps-git-program-{}-{}",
410 std::process::id(),
411 NEXT.fetch_add(1, Ordering::Relaxed)
412 );
413 let dir = std::env::temp_dir().join(name);
414 std::fs::create_dir(&dir).unwrap();
415 Self(dir.canonicalize().unwrap())
416 }
417
418 fn file(&self, name: &str, mode: u32) -> PathBuf {
419 use std::os::unix::fs::PermissionsExt;
420 let path = self.0.join(name);
421 std::fs::write(&path, b"#!/bin/sh\n").unwrap();
422 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)).unwrap();
423 path
424 }
425 }
426
427 #[cfg(unix)]
428 impl Drop for TempDir {
429 fn drop(&mut self) {
430 let _ = std::fs::remove_dir_all(&self.0);
431 }
432 }
433
434 #[cfg(unix)]
435 #[test]
436 fn real_git_is_an_executable_regular_file() {
437 use std::os::unix::fs::symlink;
438 let tmp = TempDir::new();
439 let real = tmp.file("git", 0o755);
440 assert!(is_real_git(&real));
441 assert!(is_real_git(&tmp.file("owner-only", 0o700)));
442 let link = tmp.0.join("link");
443 symlink(&real, &link).unwrap();
444 assert!(is_real_git(&link), "a link to a real Git, like Homebrew's");
445
446 assert!(!is_real_git(&tmp.0), "a directory");
447 let dir = tmp.0.join("dir");
448 std::fs::create_dir(&dir).unwrap();
449 assert!(!is_real_git(&dir), "a directory named for Git");
450 let dangling = tmp.0.join("dangling");
451 symlink(tmp.0.join("missing"), &dangling).unwrap();
452 assert!(!is_real_git(&dangling), "a dangling link");
453 assert!(!is_real_git(&tmp.0.join("missing")), "nothing");
454 assert!(!is_real_git(&tmp.file("plain", 0o644)), "not executable");
455 assert!(!is_shim(&real) && !is_shim(&dangling));
456 }
457
458 #[cfg(unix)]
459 #[test]
460 fn real_git_is_never_the_shim() {
461 use std::os::unix::fs::symlink;
462 let shim = Path::new(SHIM);
463 if std::fs::canonicalize(shim).ok().as_deref() != Some(shim) {
464 eprintln!("skipped: no /usr/bin/git on this host");
465 return;
466 }
467 let tmp = TempDir::new();
468 let link = tmp.0.join("git");
469 symlink(shim, &link).unwrap();
470 assert!(!is_real_git(shim) && !is_real_git(&link));
471 assert!(is_shim(shim) && is_shim(&link));
472 }
473
474 #[cfg(unix)]
475 #[test]
476 fn host_resolution_on_the_real_file_system() {
477 let (brew, empty) = (TempDir::new(), TempDir::new());
478 let git = brew.file("git", 0o755);
479 std::fs::create_dir(empty.0.join("git")).unwrap(); let tools = PathBuf::from(TOOLS);
481 let resolve = |path: &[&Path], developer: Option<&PathBuf>| {
482 let path = std::env::join_paths(path).unwrap();
483 resolve_path_git(Some(&path), is_real_git, is_shim, || developer.cloned())
484 };
485 assert_eq!(
486 resolve(&[&empty.0, &brew.0], Some(&tools)),
487 Some(git.clone())
488 );
489 assert_eq!(resolve(&[&empty.0], Some(&tools)), Some(tools.clone()));
490 if is_shim(Path::new(SHIM)) {
491 let usr_bin = Path::new("/usr/bin");
492 assert_eq!(resolve(&[usr_bin, &brew.0], Some(&tools)), Some(tools));
493 assert_eq!(resolve(&[usr_bin, &brew.0], None), Some(git));
494 }
495 }
496
497 #[cfg(not(target_os = "macos"))]
498 #[test]
499 fn other_platforms_run_git_from_path() {
500 assert_eq!(git_program(), Ok(PathBuf::from("git")));
501 assert_eq!(developer_git(), Ok(PathBuf::from("git")));
502 }
503
504 #[cfg(target_os = "macos")]
505 #[test]
506 fn macos_runs_an_absolute_git_never_the_shim() {
507 let host = git_program();
508 let developer = developer_git();
509 if std::env::var_os("CI").is_some() {
511 assert!(developer.is_ok(), "no developer git on a macOS CI runner");
512 assert!(host.is_ok(), "no git on a macOS CI runner");
513 }
514 for git in [host, developer].into_iter().flatten() {
515 assert!(git.is_absolute() && git != Path::new(SHIM), "{git:?}");
516 assert!(is_real_git(&git), "{git:?}");
517 }
518 if let Ok(git) = developer_git() {
519 assert!(git.ends_with("usr/bin/git"), "{git:?}");
520 }
521 let path = std::env::var_os("PATH").unwrap_or_default();
523 let first = std::env::split_paths(&path)
524 .filter(|dir| dir.is_absolute())
525 .map(|dir| dir.join("git"))
526 .find(|git| is_real_git(git) || is_shim(git));
527 if let Some(first) = first.filter(|git| is_real_git(git)) {
528 assert_eq!(git_program(), Ok(first));
529 }
530 }
531}