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;