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