Skip to main content

agent_core/
git_program.rs

1//! Which Git program the host executes.
2//!
3//! On macOS `/usr/bin/git` is Apple's `xcrun` shim. Without the Command Line
4//! Tools, executing it opens the "install developer tools" dialog, and
5//! `GIT_TERMINAL_PROMPT=0` does not prevent that. So on macOS Synaps never runs
6//! the shim: it runs a Git by absolute path, or treats Git as unavailable.
7//! Every other platform runs plain `git` from `PATH`, as before.
8//!
9//! On macOS [`git_program`] runs the Git the user's shell runs: the first
10//! `PATH` directory with a `git`, so Homebrew, Nix and MacPorts Git keep
11//! working. Where `PATH` reaches the shim it runs the developer Git the shim
12//! would have run (so a `PATH` with `/usr/bin` first still gets Apple's Git),
13//! or skips the shim when there is no developer Git. When `PATH` has no
14//! usable Git it falls back to [`developer_git`]: the `xcode-select` choice,
15//! then Xcode, then the Command Line Tools. The two follow different rules
16//! because [`developer_git`] stands for what the shim would run, which never
17//! depends on `PATH`.
18use std::ffi::OsStr;
19use std::path::{Component, Path, PathBuf};
20
21/// Apple's `xcrun` shim. Never returned, wherever a candidate points.
22const SHIM: &str = "/usr/bin/git";
23/// Default Xcode developer directory, `xcode-select`'s first default.
24const XCODE: &str = "/Applications/Xcode.app/Contents/Developer";
25/// Default Command Line Tools directory, the last candidate.
26const COMMAND_LINE_TOOLS: &str = "/Library/Developer/CommandLineTools";
27/// Symlink to the developer directory chosen with `xcode-select --switch`.
28#[cfg(target_os = "macos")]
29const SELECT_LINK: &str = "/var/db/xcode_select_link";
30
31/// macOS has no Git: none on `PATH` and no developer Git (Apple's Command
32/// Line Tools are not installed).
33#[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/// The host's Git, for `Command::new`: an absolute path from
48/// [`resolve_path_git`] with this process's `PATH`, falling back to
49/// [`developer_git`], or [`GitUnavailable`]. Never `/usr/bin/git`.
50#[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/// The host's fallback when `PATH` has no usable Git, and what a shim on
60/// `PATH` stands for, for `Command::new`: `<dir>/usr/bin/git` by absolute path
61/// from [`resolve_developer_git`] with this process's `DEVELOPER_DIR` and the
62/// `xcode-select` link, or [`GitUnavailable`]. Never `/usr/bin/git`, never a
63/// `PATH` lookup.
64#[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/// Plain `git`, found on `PATH`, exactly as before (only macOS resolves).
77#[cfg(not(target_os = "macos"))]
78pub fn git_program() -> Result<PathBuf, GitUnavailable> {
79    Ok(PathBuf::from("git"))
80}
81
82/// Plain `git`, found on `PATH`, exactly as before (only macOS resolves).
83#[cfg(not(target_os = "macos"))]
84pub fn developer_git() -> Result<PathBuf, GitUnavailable> {
85    Ok(PathBuf::from("git"))
86}
87
88/// A Git that may be run: following symlinks, an executable regular file that
89/// is not the `/usr/bin/git` shim.
90#[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/// `git` is, or links to, the `/usr/bin/git` shim.
101#[cfg(unix)]
102pub fn is_shim(git: &Path) -> bool {
103    std::fs::canonicalize(git).is_ok_and(|real| real == Path::new(SHIM))
104}
105
106/// The macOS host Git. Pure (the caller supplies `PATH`, the file tests and
107/// the developer Git), so it builds and is tested on every platform.
108///
109/// Walks the absolute directories of `path` in order. The first `<dir>/git`
110/// that `is_git` accepts wins. One that `is_shim` recognises stands for
111/// `developer_git()` (asked at most once): what the shim would run, if there
112/// is one; otherwise the walk goes on. After the walk, `developer_git()`.
113/// Relative and empty entries (the current directory) are skipped, and the
114/// result is never `/usr/bin/git`.
115pub 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    // `None` once asked: if it had found one, the walk would have returned it.
122    let mut developer_git = Some(developer_git);
123    let mut developer = || developer_git.take().and_then(|find| find());
124    // `has_root`, not `is_absolute`: macOS path rules on every test host.
125    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
139/// The macOS developer Git. Pure (the caller supplies `DEVELOPER_DIR`, the
140/// target of `/var/db/xcode_select_link` and the file test), so it builds and
141/// is tested on every platform. Candidate directories, in order:
142///
143/// 1. `developer_dir` (`DEVELOPER_DIR`), if set and absolute;
144/// 2. `select_link_target`, if absolute;
145/// 3. `/Applications/Xcode.app/Contents/Developer`;
146/// 4. `/Library/Developer/CommandLineTools`.
147///
148/// For each, `<dir>/usr/bin/git`, then `<dir>/Contents/Developer/usr/bin/git`
149/// (a directory given as the `.app` bundle, as `xcrun` accepts). The first
150/// that `is_file` accepts wins. A candidate that is set but has no Git falls
151/// through to the next one. Relative candidates and ones with `..` are
152/// skipped, and the result is never `/usr/bin/git`, even if a candidate is
153/// `/`. `None` means there is no developer Git; there is no `PATH` lookup.
154pub 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    // `has_root`, not `is_absolute`: macOS path rules on every test host.
168    .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    /// Resolve with `present` as the only existing regular files.
191    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        // DEVELOPER_DIR=/Applications/Xcode-beta.app, as xcrun accepts.
232        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        // `/Applications/Xcode.app` given as the bundle too.
236        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        // `is_file` claims the shim exists.
265        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        // Nor from PATH, when `is_git` wrongly accepts it.
280        let path = OsStr::new("/usr/bin");
281        assert_eq!(
282            resolve_path_git(Some(path), |_| true, |_| false, || None),
283            None
284        );
285    }
286
287    /// Host resolution over `path` with `present` as the only Gits, `shims` as
288    /// the paths that are the shim and `developer` as the developer Git.
289    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        // `/usr/bin` before Homebrew: Apple's Git, as `git` in a shell runs.
329        let path = ["/usr/bin", "/opt/homebrew/bin"];
330        assert_eq!(
331            host(&path, &[BREW], &[SHIM], Some(TOOLS)),
332            Some(TOOLS.into())
333        );
334        // No developer Git: skip the shim (no dialog), keep walking.
335        assert_eq!(host(&path, &[BREW], &[SHIM], None), Some(BREW.into()));
336        // A link to the shim elsewhere counts as the shim.
337        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    /// A unique temporary directory, removed on drop.
386    #[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(); // a directory, skipped
466        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        // CI's macOS runners ship Xcode: there the answer must be a Git.
496        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        // The host Git is `PATH`'s, when `PATH` has a real Git before the shim.
508        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}