Skip to main content

git_policy_cli/invocation/
real_git.rs

1//! What:
2//!  Find the real Git executable on PATH without ever selecting this wrapper.
3//! Why:
4//!  The wrapper is installed as `git` ahead of real Git;
5//!  forwarding needs the
6//!      first usable candidate that is not a cli-git wrapper.
7//!
8//! In TS you'd write (pseudocode):
9//! ```ts
10//! // const gitPath = resolveRealGit(processResolutionInputs()); // '/usr/bin/git'
11//! ```
12
13/// What:
14///  `use super::...` imports sibling modules of this crate.
15/// Why:
16///   Inputs are read with getenv semantics and each candidate is classified by
17///       the self-exclusion rules.
18///
19/// In TS you'd write (pseudocode):
20/// ```ts
21/// import { classifyCandidate } from './real-git-candidate.ts';
22/// ```
23use crate::child_environment::environment_value;
24use crate::real_git_candidate::{CandidateKind, classify_candidate};
25use crate::real_git_record::service_unit_file;
26/// `pub use` re-exports names so callers keep importing the resolution from this module.
27pub use crate::real_git_resolution::{RecordedGit, ResolvedGit, resolve_real_git};
28/// What:
29///  `OsStr`/`OsString` are borrowed/owned operating-system text of raw OS bytes
30///       (siblings `&str`/`String` must be UTF-8).
31/// Why:
32///   PATH entries and executable paths need not be UTF-8.
33///
34/// In TS you'd write (pseudocode):
35/// ```ts
36/// // Node strings; no byte-preserving equivalent.
37/// ```
38use std::ffi::{OsStr, OsString};
39/// `Path`/`PathBuf` are borrowed/owned filesystem paths made of the same raw bytes.
40use std::path::{Path, PathBuf};
41
42/// Windows executable extensions in shell lookup order when `PATHEXT` is unset.
43pub const DEFAULT_WINDOWS_PATH_EXTENSIONS: &str = ".COM;.EXE;.BAT;.CMD";
44
45/// What:
46///  Which operating-system family's lookup rules apply.
47///       `#[derive(...)]` generates copying,
48///  debug printing and `==`.
49/// Why:
50///   Executable names,
51///  conventional install locations and path identity differ by
52///       family;
53///  passing the family as a value keeps every branch testable on one host.
54///
55/// In TS you'd write (pseudocode):
56/// ```ts
57/// type Platform = 'linux' | 'darwin' | 'win32';
58/// ```
59#[derive(Clone, Copy, Debug, Eq, PartialEq)]
60pub enum Platform {
61    /// Linux and other Unix-like systems:
62    ///  `git`,
63    ///  case-sensitive paths.
64    Unix,
65    /// macOS:
66    ///  Unix rules plus Homebrew and MacPorts locations.
67    MacOs,
68    /// Windows:
69    ///  `git` plus `PATHEXT` extensions,
70    ///  case-insensitive paths.
71    Windows,
72}
73
74/// What:
75///  Everything resolution reads from the process,
76///  gathered into one value.
77///       `Vec<PathBuf>` is an owned list of owned paths.
78/// Why:
79///   Tests inject a disposable PATH and executable identity instead of mutating
80///       the real process environment.
81///
82/// In TS you'd write (pseudocode):
83/// ```ts
84/// type ResolutionInputs = { platform: Platform; path: string; pathExtensions: string;
85///   currentDirectory: string; commonPaths: string[]; ownExecutable: string };
86/// ```
87#[derive(Clone, Debug, Eq, PartialEq)]
88pub struct ResolutionInputs {
89    /// Lookup rules to apply.
90    pub platform: Platform,
91    /// The `PATH` value;
92    ///  empty when unset.
93    pub path: OsString,
94    /// The `PATHEXT` value;
95    ///  only read on Windows.
96    pub path_extensions: OsString,
97    /// Directory that relative PATH entries are resolved against.
98    pub current_directory: PathBuf,
99    /// Conventional Git locations promoted to the front when PATH exposes them.
100    pub common_paths: Vec<PathBuf>,
101    /// This wrapper's own executable,
102    ///  excluded from selection.
103    pub own_executable: PathBuf,
104    /// The unit file of the commit service,
105    ///  whose `--git` option records the real Git the wrapper last set the service up with;
106    ///  nothing when the runtime directory is unknown.
107    ///  It is read only when `PATH` offers no real Git.
108    pub service_unit: Option<PathBuf>,
109}
110
111/// What:
112///  One PATH candidate the search examined and what it found.
113///       `CandidateKind` is the verdict (real Git, wrapper, unusable).
114/// Why:
115///   The failure message lists every candidate with its verdict, so the person can see which entries led back to cli-git
116///  and which were not executable files at all.
117///
118/// In TS you'd write (pseudocode):
119/// ```ts
120/// type SearchedCandidate = { path: string; kind: 'real-git' | 'wrapper' | 'unusable' };
121/// ```
122#[derive(Clone, Debug, Eq, PartialEq)]
123pub struct SearchedCandidate {
124    /// The candidate's absolute path as the search built it from PATH.
125    pub path: PathBuf,
126    /// The verdict about it.
127    pub kind: CandidateKind,
128}
129
130/// What:
131///  The failure when no PATH candidate can act as real Git.
132///       `usize` counts items;
133///  it is the length type of every Rust list.
134///       `Vec<SearchedCandidate>` is an owned list of owned records (siblings: `&[T]`, a borrowed view).
135/// Why:
136///   The counts tell the user whether Git is missing or only wrappers were found, and the list and the `PATH` value
137///  name what was searched.
138///  The record owns text, so it is not `Copy`.
139///
140/// In TS you'd write (pseudocode):
141/// ```ts
142/// class RealGitNotFoundError extends Error { candidateCount: number; skippedWrapperCount: number; searched: SearchedCandidate[]; path: string }
143/// ```
144#[derive(Clone, Debug, Eq, PartialEq)]
145pub struct RealGitNotFound {
146    /// PATH-derived candidates examined before failure.
147    pub candidate_count: usize,
148    /// Candidates rejected because they are cli-git wrappers.
149    pub skipped_wrapper_count: usize,
150    /// Every candidate examined, in the order the search took them, with its verdict.
151    pub searched: Vec<SearchedCandidate>,
152    /// The `PATH` value the search read; empty when unset.
153    pub path: OsString,
154    /// The Git the commit service's unit records and the verdict on it,
155    ///  when the unit names one that was refused;
156    ///  nothing when there is no record or it is not readable.
157    pub recorded: Option<RecordedGit>,
158}
159
160/// What:
161///  The words for one candidate's verdict.
162/// Why:
163///   The message says why a candidate was passed over in terms a person can act on, with no internal names.
164///
165/// In TS you'd write (pseudocode):
166/// ```ts
167/// const reason = { wrapper: 'leads back to cli-git ...', unusable: 'is not an executable file ...' }[kind];
168/// ```
169fn verdict_words(kind: CandidateKind) -> &'static str {
170    match kind {
171        CandidateKind::Wrapper => {
172            return "skipped, it leads back to cli-git (this executable, a link or copy of it, its toolchain launcher, or a script that starts it)";
173        }
174        CandidateKind::Unusable => return "skipped, it is missing, not an executable file, or unreadable",
175        CandidateKind::RealGit => return "usable",
176    }
177}
178
179/// What:
180///  `impl std::fmt::Display for ...` supplies Rust's "print me" interface.
181/// Why:
182///   The executable prints what was searched, why nothing qualified, and every way to fix it, naming the `PATH` value as
183///  the input.
184///
185/// In TS you'd write (pseudocode):
186/// ```ts
187/// toString(): string { return `Could not find a real Git executable ...`; }
188/// ```
189impl std::fmt::Display for RealGitNotFound {
190    /// `&self` borrows the failure;
191    ///  `&mut` lends the formatter for writing;
192    ///  `'_` is an unnamed lifetime.
193    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
194        // `write!` formats into the borrowed formatter; `?` returns early if writing failed.
195        write!(
196            formatter,
197            "Could not find a real Git executable after examining {} PATH candidates and \
198             skipping {} cli-git wrappers, so no Git command was run. PATH is {}.",
199            self.candidate_count,
200            self.skipped_wrapper_count,
201            if self.path.is_empty() { String::from("empty or unset") } else { self.path.to_string_lossy().into_owned() }
202        )?;
203        for candidate in &self.searched {
204            write!(formatter, "\n  {}: {}", candidate.path.display(), verdict_words(candidate.kind))?;
205        }
206        // `if let Some(x) = &field` runs only when a unit named a Git;
207        //  the line says where the name was read and why it was not used.
208        if let Some(recorded) = &self.recorded {
209            write!(
210                formatter,
211                "\n  {}: recorded by the commit service unit {} as the Git it starts, {}",
212                recorded.candidate.path.display(),
213                recorded.unit_file.display(),
214                verdict_words(recorded.candidate.kind)
215            )?;
216        }
217        // The last write is the function's result.
218        return write!(
219            formatter,
220            "\ncli-git never starts itself as Git, so a candidate that leads back to cli-git is passed over on purpose. \
221             To fix it, install Git; or add the directory that holds the real Git executable (for example /usr/bin) to \
222             the PATH of the program that ran this command; or, when an entry of that PATH should not be used, remove it."
223        );
224    }
225}
226
227/// An empty `impl` marks the type as a standard error value.
228impl std::error::Error for RealGitNotFound {}
229
230/// What:
231///  List the file names PATH lookup tries,
232///  in order.
233/// Why:
234///   Unix runs `git`;
235///  Windows tries `git` plus each `PATHEXT` extension,
236///  adding a
237///       missing leading dot.
238///  An extension that is not UTF-8 cannot name a file
239///       portably and is skipped.
240///
241/// In TS you'd write (pseudocode):
242/// ```ts
243/// function executableNames(platform: Platform, pathExtensions: string): string[];
244/// ```
245pub fn executable_names(platform: Platform, path_extensions: &OsStr) -> Vec<OsString> {
246    if platform != Platform::Windows {
247        // `vec![...]` builds an owned list; `OsString::from` copies text into OS form.
248        return vec![OsString::from("git")];
249    }
250    // `Vec::<OsString>::new()` is an empty owned list; `mut` allows pushing.
251    let mut names: Vec<OsString> = Vec::<OsString>::new();
252    // `.split(...)` with a named predicate cuts the raw bytes at each semicolon.
253    for piece in path_extensions.as_encoded_bytes().split(is_semicolon) {
254        if piece.is_empty() {
255            continue;
256        }
257        // What: `let Ok(text) = ... else { continue; }` keeps valid UTF-8, skips the rest.
258        // Why:  Bytes can only become an OS string safely through checked text.
259        //
260        // In TS you'd write (pseudocode):
261        // ```ts
262        // const text = decodeUtf8OrSkip(piece);
263        // ```
264        let Ok(extension) = std::str::from_utf8(piece) else {
265            continue;
266        };
267        if extension.starts_with('.') {
268            names.push(OsString::from(format!("git{extension}")));
269        } else {
270            names.push(OsString::from(format!("git.{extension}")));
271        }
272    }
273    return names;
274}
275
276/// Named predicate for splitting `PATHEXT`;
277///  `&u8` borrows one byte.
278fn is_semicolon(byte: &u8) -> bool {
279    return *byte == b';';
280}
281
282/// What:
283///  List conventional Git install locations for a platform.
284///       `&[(OsString, OsString)]` borrows the environment as name/value pairs.
285/// Why:
286///   A conventional location wins over an earlier PATH entry,
287///  so a stray `git`
288///       script early on PATH cannot displace the system Git.
289///  Windows roots come from
290///       the environment;
291///  a trailing separator on a root is dropped before joining.
292///
293/// In TS you'd write (pseudocode):
294/// ```ts
295/// function commonGitPaths(platform: Platform, environment: [string, string][]): string[];
296/// ```
297pub fn common_git_paths(platform: Platform, environment: &[(OsString, OsString)]) -> Vec<PathBuf> {
298    if platform == Platform::Unix {
299        return vec![
300            PathBuf::from("/usr/bin/git"),
301            PathBuf::from("/usr/local/bin/git"),
302        ];
303    }
304    if platform == Platform::MacOs {
305        return vec![
306            PathBuf::from("/usr/bin/git"),
307            PathBuf::from("/usr/local/bin/git"),
308            PathBuf::from("/opt/homebrew/bin/git"),
309            PathBuf::from("/opt/local/bin/git"),
310        ];
311    }
312    let mut roots: Vec<OsString> = Vec::<OsString>::new();
313    for name in ["ProgramFiles", "ProgramW6432", "ProgramFiles(x86)"] {
314        // `if let Some(x) = ...` runs only when the variable is set.
315        if let Some(root) = environment_value(environment, name) {
316            roots.push(root);
317        }
318    }
319    roots.push(OsString::from("C:\\Program Files"));
320    if let Some(local) = environment_value(environment, "LOCALAPPDATA") {
321        let mut programs: OsString = trimmed_root(local.as_os_str());
322        programs.push("\\Programs");
323        roots.push(programs);
324    }
325    let mut paths: Vec<PathBuf> = Vec::<PathBuf>::new();
326    // `&roots` lends the list so the loop does not consume it.
327    for root in &roots {
328        for tail in ["\\Git\\cmd\\git.exe", "\\Git\\bin\\git.exe"] {
329            let mut path: OsString = trimmed_root(root.as_os_str());
330            path.push(tail);
331            let candidate: PathBuf = PathBuf::from(path);
332            // `.contains(&candidate)` borrows the new path to skip repeated roots.
333            if !paths.contains(&candidate) {
334                paths.push(candidate);
335            }
336        }
337    }
338    return paths;
339}
340
341/// What:
342///  Copy a Windows root without trailing `\` or `/` separators.
343/// Why:
344///   Joining `C:\Program Files\` and `\Git` must not produce a doubled separator
345///       that no PATH candidate would equal.
346///
347/// In TS you'd write (pseudocode):
348/// ```ts
349/// function trimmedRoot(root: string): string { return root.replace(/[\\/]+$/, ''); }
350/// ```
351fn trimmed_root(root: &OsStr) -> OsString {
352    let bytes: &[u8] = root.as_encoded_bytes();
353    let mut end: usize = bytes.len();
354    while end > 0 && (bytes[end - 1] == b'\\' || bytes[end - 1] == b'/') {
355        end -= 1;
356    }
357    // What: `std::str::from_utf8` checks the kept bytes; `match` picks by outcome.
358    // Why:  Cutting ASCII separators off valid text leaves valid text; a root that is
359    //       not UTF-8 is returned whole rather than re-encoded.
360    //
361    // In TS you'd write (pseudocode):
362    // ```ts
363    // return root.slice(0, end);
364    // ```
365    match std::str::from_utf8(&bytes[..end]) {
366        Ok(text) => return OsString::from(text),
367        Err(_) => return root.to_os_string(),
368    }
369}
370
371/// What:
372///  The form of a path used to compare candidates for equality.
373/// Why:
374///   Windows paths are case-insensitive;
375///  ASCII case is folded there.
376///  Other
377///       platforms compare the exact bytes.
378///
379/// In TS you'd write (pseudocode):
380/// ```ts
381/// function candidateIdentity(path: string, platform: Platform): string;
382/// ```
383fn candidate_identity(path: &Path, platform: Platform) -> OsString {
384    if platform == Platform::Windows {
385        return path.as_os_str().to_ascii_lowercase();
386    }
387    return path.as_os_str().to_os_string();
388}
389
390/// What:
391///  Rebuild a path from its components.
392/// Why:
393///   This removes `.` segments and repeated separators,
394///  so `/usr/./bin//git` and
395///       `/usr/bin/git` compare equal.
396///  `..` is kept:
397///  resolving it without the
398///       filesystem would be wrong across symbolic links.
399///
400/// In TS you'd write (pseudocode):
401/// ```ts
402/// function lexicallyTidied(path: string): string;
403/// ```
404fn lexically_tidied(path: &Path) -> PathBuf {
405    let mut result: PathBuf = PathBuf::new();
406    // `.components()` yields each path segment in order.
407    for component in path.components() {
408        result.push(component.as_os_str());
409    }
410    return result;
411}
412
413/// What:
414///  Build the ordered,
415///  de-duplicated list of absolute candidates.
416/// Why:
417///   Every PATH directory contributes each executable name in order;
418///  conventional
419///       locations that PATH exposes are promoted to the front in their PATH spelling.
420///       Relative PATH entries are resolved against the current directory,
421///  as a shell does.
422///
423/// In TS you'd write (pseudocode):
424/// ```ts
425/// function candidateSequence(inputs: ResolutionInputs): string[];
426/// ```
427pub fn candidate_sequence(inputs: &ResolutionInputs) -> Vec<PathBuf> {
428    let names: Vec<OsString> =
429        executable_names(inputs.platform, inputs.path_extensions.as_os_str());
430    let mut path_candidates: Vec<PathBuf> = Vec::<PathBuf>::new();
431    // `std::env::split_paths` cuts PATH by the host platform's separator.
432    for directory in std::env::split_paths(inputs.path.as_os_str()) {
433        for name in &names {
434            // `.join` appends a segment; an absolute `directory` replaces the base.
435            let candidate: PathBuf = inputs.current_directory.join(&directory).join(name);
436            path_candidates.push(lexically_tidied(candidate.as_path()));
437        }
438    }
439    let mut sequence: Vec<PathBuf> = Vec::<PathBuf>::new();
440    for common in &inputs.common_paths {
441        let wanted: OsString = candidate_identity(common.as_path(), inputs.platform);
442        for candidate in &path_candidates {
443            if candidate_identity(candidate.as_path(), inputs.platform) == wanted
444                && !sequence.contains(candidate)
445            {
446                // `.clone()` copies the path so both lists own their entries.
447                sequence.push(candidate.clone());
448            }
449        }
450    }
451    for candidate in path_candidates {
452        if !sequence.contains(&candidate) {
453            sequence.push(candidate);
454        }
455    }
456    return sequence;
457}
458
459/// What:
460///  Select the first `PATH` candidate that is real Git.
461///       `Result<PathBuf, RealGitNotFound>` is the selected path or the typed failure.
462/// Why:
463///   Wrappers are skipped and counted,
464///  unusable entries are skipped,
465///  and the
466///       first remaining candidate in priority order is forwarded to.
467///  This is the search of `PATH` alone;
468///  every start of Git goes through `resolve_real_git`,
469///  which adds the recorded fallback.
470///
471/// In TS you'd write (pseudocode):
472/// ```ts
473/// function searchPathForRealGit(inputs: ResolutionInputs): string;
474/// ```
475pub fn search_path_for_real_git(inputs: &ResolutionInputs) -> Result<PathBuf, RealGitNotFound> {
476    let candidates: Vec<PathBuf> = candidate_sequence(inputs);
477    let mut skipped_wrapper_count: usize = 0;
478    // `mut` lets the loop record each verdict for the failure message.
479    let mut searched: Vec<SearchedCandidate> = Vec::<SearchedCandidate>::new();
480    for candidate in &candidates {
481        let kind: CandidateKind =
482            classify_candidate(candidate.as_path(), inputs.own_executable.as_path());
483        if kind == CandidateKind::RealGit {
484            // `Ok(...)` is the success variant carrying an owned copy of the path.
485            return Ok(candidate.clone());
486        }
487        if kind == CandidateKind::Wrapper {
488            skipped_wrapper_count += 1;
489        }
490        searched.push(SearchedCandidate { path: candidate.clone(), kind });
491    }
492    // `Err(...)` is the failure variant carrying the evidence: counts, every verdict and the PATH read.
493    return Err(RealGitNotFound {
494        candidate_count: candidates.len(),
495        skipped_wrapper_count,
496        searched,
497        path: inputs.path.clone(),
498        recorded: None,
499    });
500}
501
502/// What:
503///  The lookup rules of the platform this executable was compiled for.
504///       `cfg!(...)` is a compile-time `true`/`false` about the target platform.
505/// Why:
506///   Production uses the host's rules;
507///  tests pass a platform explicitly.
508///
509/// In TS you'd write (pseudocode):
510/// ```ts
511/// function hostPlatform(): Platform { return process.platform; }
512/// ```
513pub fn host_platform() -> Platform {
514    if cfg!(windows) {
515        return Platform::Windows;
516    }
517    if cfg!(target_os = "macos") {
518        return Platform::MacOs;
519    }
520    return Platform::Unix;
521}
522
523/// What:
524///  The unit file of the commit service below a runtime directory,
525///  when that directory is an absolute path.
526/// Why:
527///   A relative runtime directory would name a different file for each directory the wrapper runs in,
528///  which is no record.
529///
530/// In TS you'd write (pseudocode):
531/// ```ts
532/// function serviceUnitBelow(runtime: string): string | undefined { return isAbsolute(runtime) ? serviceUnitFile(runtime) : undefined; }
533/// ```
534fn service_unit_below(runtime: OsString) -> Option<PathBuf> {
535    let directory: PathBuf = PathBuf::from(runtime);
536    if !directory.is_absolute() {
537        // `None` says there is no record to look for.
538        return None;
539    }
540    // `Some(...)` wraps the unit file's path.
541    return Some(service_unit_file(directory.as_path()));
542}
543
544/// What:
545///  Gather resolution inputs from the running process.
546///       `std::io::Result<T>` is `Result<T, std::io::Error>`.
547/// Why:
548///   The wrapper must know its own executable to exclude it;
549///  if the operating
550///       system cannot report it (or the current directory),
551///  resolution fails rather
552///       than risk selecting itself.
553///
554/// In TS you'd write (pseudocode):
555/// ```ts
556/// function processResolutionInputs(environment: [string, string][]): ResolutionInputs;
557/// ```
558pub fn process_resolution_inputs(
559    environment: &[(OsString, OsString)],
560) -> std::io::Result<ResolutionInputs> {
561    let platform: Platform = host_platform();
562    // A trailing `?` returns the OS error to our caller, or unwraps the value.
563    // Identification only: this path is excluded from the search for the real Git so the wrapper never selects itself;
564    //  nothing is started from it (doc/planning/cli-git-no-self-invocation.md).
565    #[allow(
566        clippy::disallowed_methods,
567        reason = "identifies the wrapper so the search for the real Git skips it; nothing is started from the path"
568    )]
569    let own_executable: PathBuf = std::env::current_exe()?;
570    crate::service_route::remember_own_executable(own_executable.as_path());
571    let current_directory: PathBuf = std::env::current_dir()?;
572    // `.unwrap_or_default()` substitutes an empty value when the variable is unset.
573    let path: OsString = environment_value(environment, "PATH").unwrap_or_default();
574    let path_extensions: OsString = environment_value(environment, "PATHEXT")
575        .unwrap_or(OsString::from(DEFAULT_WINDOWS_PATH_EXTENSIONS));
576    // What: The unit file is looked for below an absolute runtime directory only.
577    // Why:  A relative one would name a different file for each directory the wrapper runs in, which is no record.
578    //
579    // In TS you'd write (pseudocode):
580    // ```ts
581    // const runtime = env.XDG_RUNTIME_DIR; const serviceUnit = runtime && isAbsolute(runtime) ? serviceUnitFile(runtime) : undefined;
582    // ```
583    let service_unit: Option<PathBuf> =
584        environment_value(environment, "XDG_RUNTIME_DIR").and_then(service_unit_below);
585    return Ok(ResolutionInputs {
586        platform,
587        path,
588        path_extensions,
589        current_directory,
590        common_paths: common_git_paths(platform, environment),
591        own_executable,
592        service_unit,
593    });
594}
595
596/// Disposable-PATH controls stay out of the release executable.
597#[cfg(test)]
598#[path = "real_git_tests.rs"]
599mod tests;
600
601/// Controls for the fallback to the Git the commit service's unit records.
602#[cfg(test)]
603#[path = "real_git_fallback_tests.rs"]
604mod fallback_tests;