Skip to main content

agent_core/
git_program.rs

1//! Which Git program to execute. Compiled into the host and, by path, into the
2//! independently built Axel memory service.
3//!
4//! On macOS `/usr/bin/git` is Apple's `xcrun` shim. Without the Command Line
5//! Tools, executing it opens the "install developer tools" dialog, and
6//! `GIT_TERMINAL_PROMPT=0` does not prevent that. So on macOS Synaps never runs
7//! the shim: it runs a Git by absolute path, or treats Git as unavailable.
8//! Every other platform runs plain `git` from `PATH`, as before.
9//!
10//! On macOS the host and the service deliberately follow different rules.
11//! Do not unify them:
12//!
13//! - The host ([`git_program`]) runs the Git the user's shell runs: the first
14//!   `PATH` directory with a `git`, so Homebrew, Nix and MacPorts Git keep
15//!   working. Where `PATH` reaches the shim it runs the developer Git the shim
16//!   would have run (so a `PATH` with `/usr/bin` first still gets Apple's Git),
17//!   or skips the shim when there is no developer Git. With no Git on `PATH`
18//!   at all it falls back to the developer Git.
19//! - The Axel service ([`developer_git`]) never consults `PATH`. The host
20//!   spawns it with an empty environment (`env_clear`: no `PATH`, no
21//!   `DEVELOPER_DIR`), so nothing the host inherits picks the program the
22//!   verifier runs. It runs the developer Git the shim would choose in an
23//!   empty environment, as `xcrun` did for it before: the `xcode-select`
24//!   choice, then Xcode, then the Command Line Tools.
25//!
26//! So the two can run different Gits (say Homebrew's and Apple's). Both only
27//! run read-only `rev-parse` (Git 2.31 or newer), whose paths do not depend on
28//! the Git build. A service Git that cannot read the repository (too old for
29//! its format, say) makes the service fail closed with no scope, never yield a
30//! different identity.
31use std::ffi::OsStr;
32use std::path::{Component, Path, PathBuf};
33
34/// Apple's `xcrun` shim. Never returned, wherever a candidate points.
35const SHIM: &str = "/usr/bin/git";
36/// Default Xcode developer directory, `xcode-select`'s first default.
37const XCODE: &str = "/Applications/Xcode.app/Contents/Developer";
38/// Default Command Line Tools directory, the last candidate.
39const COMMAND_LINE_TOOLS: &str = "/Library/Developer/CommandLineTools";
40/// Symlink to the developer directory chosen with `xcode-select --switch`.
41#[cfg(target_os = "macos")]
42const SELECT_LINK: &str = "/var/db/xcode_select_link";
43
44/// macOS has no Git: none on `PATH` and no developer Git (Apple's Command
45/// Line Tools are not installed).
46#[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/// The host's Git, for `Command::new`: an absolute path from
61/// [`resolve_path_git`] with this process's `PATH`, falling back to
62/// [`developer_git`], or [`GitUnavailable`]. Never `/usr/bin/git`.
63#[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/// The Axel service's Git (and the host's fallback), for `Command::new`:
73/// `<dir>/usr/bin/git` by absolute path from [`resolve_developer_git`] with
74/// this process's `DEVELOPER_DIR` (never set in the service, whose environment
75/// is empty) and the `xcode-select` link, or [`GitUnavailable`]. Never
76/// `/usr/bin/git`, never a `PATH` lookup.
77#[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/// Plain `git`, found on `PATH`, exactly as before (only macOS resolves).
90#[cfg(not(target_os = "macos"))]
91pub fn git_program() -> Result<PathBuf, GitUnavailable> {
92    Ok(PathBuf::from("git"))
93}
94
95/// Plain `git`, found on `PATH`, exactly as before (only macOS resolves).
96#[cfg(not(target_os = "macos"))]
97pub fn developer_git() -> Result<PathBuf, GitUnavailable> {
98    Ok(PathBuf::from("git"))
99}
100
101/// A Git that may be run: following symlinks, an executable regular file that
102/// is not the `/usr/bin/git` shim.
103#[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/// `git` is, or links to, the `/usr/bin/git` shim.
114#[cfg(unix)]
115pub fn is_shim(git: &Path) -> bool {
116    std::fs::canonicalize(git).is_ok_and(|real| real == Path::new(SHIM))
117}
118
119/// The macOS host Git. Pure (the caller supplies `PATH`, the file tests and
120/// the developer Git), so it builds and is tested on every platform.
121///
122/// Walks the absolute directories of `path` in order. The first `<dir>/git`
123/// that `is_git` accepts wins. One that `is_shim` recognises stands for
124/// `developer_git()` (asked at most once): what the shim would run, if there
125/// is one; otherwise the walk goes on. After the walk, `developer_git()`.
126/// Relative and empty entries (the current directory) are skipped, and the
127/// result is never `/usr/bin/git`.
128pub 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    // `None` once asked: if it had found one, the walk would have returned it.
135    let mut developer_git = Some(developer_git);
136    let mut developer = || developer_git.take().and_then(|find| find());
137    // `has_root`, not `is_absolute`: macOS path rules on every test host.
138    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
152/// The macOS developer Git. Pure (the caller supplies `DEVELOPER_DIR`, the
153/// target of `/var/db/xcode_select_link` and the file test), so it builds and
154/// is tested on every platform. Candidate directories, in order:
155///
156/// 1. `developer_dir` (`DEVELOPER_DIR`), if set and absolute;
157/// 2. `select_link_target`, if absolute;
158/// 3. `/Applications/Xcode.app/Contents/Developer`;
159/// 4. `/Library/Developer/CommandLineTools`.
160///
161/// For each, `<dir>/usr/bin/git`, then `<dir>/Contents/Developer/usr/bin/git`
162/// (a directory given as the `.app` bundle, as `xcrun` accepts). The first
163/// that `is_file` accepts wins. A candidate that is set but has no Git falls
164/// through to the next one. Relative candidates and ones with `..` are
165/// skipped, and the result is never `/usr/bin/git`, even if a candidate is
166/// `/`. `None` means there is no developer Git; there is no `PATH` lookup.
167pub 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    // `has_root`, not `is_absolute`: macOS path rules on every test host.
181    .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    /// Resolve with `present` as the only existing regular files.
204    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        // DEVELOPER_DIR=/Applications/Xcode-beta.app, as xcrun accepts.
245        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        // `/Applications/Xcode.app` given as the bundle too.
249        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        // `is_file` claims the shim exists.
278        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        // Nor from PATH, when `is_git` wrongly accepts it.
293        let path = OsStr::new("/usr/bin");
294        assert_eq!(
295            resolve_path_git(Some(path), |_| true, |_| false, || None),
296            None
297        );
298    }
299
300    /// Host resolution over `path` with `present` as the only Gits, `shims` as
301    /// the paths that are the shim and `developer` as the developer Git.
302    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        // `/usr/bin` before Homebrew: Apple's Git, as `git` in a shell runs.
342        let path = ["/usr/bin", "/opt/homebrew/bin"];
343        assert_eq!(
344            host(&path, &[BREW], &[SHIM], Some(TOOLS)),
345            Some(TOOLS.into())
346        );
347        // No developer Git: skip the shim (no dialog), keep walking.
348        assert_eq!(host(&path, &[BREW], &[SHIM], None), Some(BREW.into()));
349        // A link to the shim elsewhere counts as the shim.
350        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    /// A unique temporary directory, removed on drop (no dev-dependency: this
399    /// module is also built into the Axel service).
400    #[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(); // a directory, skipped
480        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        // CI's macOS runners ship Xcode: there the answer must be a Git.
510        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        // The host Git is `PATH`'s, when `PATH` has a real Git before the shim.
522        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}