Skip to main content

git_policy_cli/invocation/
real_git_resolution.rs

1//! What:
2//!  The one resolution of the real Git that every start uses:
3//!  the first real Git on `PATH`,
4//! otherwise the Git the commit service's unit records,
5//! with a notice.
6//! Why:
7//!  The coordinator picked,
8//! under the owner's delegation and open to the owner's veto
9//!  (`doc/planning/cli-git-no-self-invocation.md`,
10//! section "Falling back to the Git the commit service was set up
11//!  with"),
12//! that a wrapper which is found but has no real Git after it on `PATH` uses the Git it was set up with instead
13//!  of stopping.
14//! The search of `PATH` stays in `real_git.rs`;
15//! this module adds the fallback after it and the types that
16//!  carry its result,
17//! so no caller can take the path and drop the notice.
18//!
19//! In TS you'd write (pseudocode):
20//! ```ts
21//! const { path, notice } = resolveRealGit(inputs); // throws RealGitNotFound
22//! ```
23
24/// What:
25///  `use super::...` imports the search,
26/// the inputs and the failure from the module that owns them,
27///  the candidate verdicts,
28/// and the reader of the record.
29/// Why:
30///   The fallback is judged by the same `classify_candidate` that judges a `PATH` candidate.
31///
32/// In TS you'd write (pseudocode):
33/// ```ts
34/// import { searchPathForRealGit, type RealGitNotFound } from './real-git.ts';
35/// ```
36use crate::real_git::{RealGitNotFound, ResolutionInputs, SearchedCandidate, search_path_for_real_git};
37use crate::real_git_candidate::{CandidateKind, classify_candidate};
38use crate::real_git_record::read_recorded_git;
39/// `OsStr` is borrowed operating-system text (siblings:
40/// `OsString` owned,
41/// `&str` UTF-8 only).
42use std::ffi::OsStr;
43/// `Path` is a borrowed filesystem path;
44///  `PathBuf` is the owned sibling.
45use std::path::{Path, PathBuf};
46
47/// What:
48///  The real Git a unit file records,
49///  with the verdict the `PATH` candidate checks gave it.
50/// Why:
51///   When the fallback is refused,
52///  the failure names the unit and the recorded file,
53///  so the person sees that the record was read and why it was not used.
54///
55/// In TS you'd write (pseudocode):
56/// ```ts
57/// type RecordedGit = { unitFile: string; candidate: SearchedCandidate };
58/// ```
59#[derive(Clone, Debug, Eq, PartialEq)]
60pub struct RecordedGit {
61    /// The unit file the path was read from.
62    pub unit_file: PathBuf,
63    /// The recorded path and the verdict about it.
64    pub candidate: SearchedCandidate,
65}
66
67/// What:
68///  The real Git a resolution selected,
69///  and the notice owed to the person when it was not found on `PATH`.
70/// Why:
71///   A caller that starts Git must also print the notice,
72///  so the two travel together and a caller cannot take the path alone by accident.
73///
74/// In TS you'd write (pseudocode):
75/// ```ts
76/// type ResolvedGit = { path: string; notice: string | undefined };
77/// ```
78#[derive(Clone, Debug, Eq, PartialEq)]
79pub struct ResolvedGit {
80    /// The executable to start as real Git.
81    pub path: PathBuf,
82    /// One line for standard error,
83    ///  ending in a newline,
84    ///  when the path came from the unit's record and not from `PATH`.
85    pub notice: Option<String>,
86}
87
88/// What:
89///  The one line a person reads when the recorded Git was used because `PATH` offered none.
90///  `&OsStr` and `&Path` borrow operating-system text and a path (siblings:
91/// `OsString` and `PathBuf` own them).
92///  `String` is owned UTF-8 text (sibling:
93/// `&str`,
94/// a borrowed view).
95/// Why:
96///  It says what happened (no real Git after the wrapper on `PATH`,
97/// and which `PATH`),
98///  what was done (which file,
99/// read from which unit),
100///  and the one way to make it stop (put the file's directory on the `PATH` of the program that ran the command).
101///  An empty `PATH` is said in words,
102/// as the failure does.
103///  `String` and not `&str` because the line is built here and handed on.
104///
105/// In TS you'd write (pseudocode):
106/// ```ts
107/// function fallbackNotice(path: string, git: string, unit: string): string;
108/// ```
109fn fallback_notice(path: &OsStr, git: &Path, unit_file: &Path) -> String {
110    // What:
111    //  An `if`/`else` that yields a value, the last expression of each branch.
112    //  `String::from("...")` copies a literal into owned text.
113    //  `.to_string_lossy()` views operating-system text as UTF-8, replacing bytes that are not valid;
114    //  `.into_owned()` makes the result an owned `String`.
115    // Why:
116    //  The notice shows the `PATH` the person's program had,
117    //  and an empty one is more useful said as words than as nothing between parentheses.
118    //
119    // Gotcha:
120    //  `.into_owned()` copies only when the view was borrowed.
121    //
122    // In TS you'd write (pseudocode):
123    // ```ts
124    // const shownPath = path === '' ? 'empty or unset' : path;
125    // ```
126    let shown_path: String = if path.is_empty() {
127        String::from("empty or unset")
128    } else {
129        path.to_string_lossy().into_owned()
130    };
131    // What:
132    //  `match` on `git.parent()`, which is `Some(directory)` or `None`.
133    //  `Some(found)` binds the directory;
134    //  `.display()` makes a path printable and `.to_string()` turns that into owned text.
135    // Why:
136    //  The notice names the directory to add to `PATH`.
137    //  A path with no parent has no such directory, and the words say so instead of printing nothing.
138    //
139    // In TS you'd write (pseudocode):
140    // ```ts
141    // const directory = dirname(git) ?? 'its directory';
142    // ```
143    let directory: String = match git.parent() {
144        Some(found) => found.display().to_string(),
145        None => String::from("its directory"),
146    };
147    // What:
148    //  `format!` builds owned text from a template;
149    //  `{shown_path}` and `{directory}` read the local variables by name, `{}` takes the next argument in order.
150    //  A trailing `\` at the end of a line inside the quotes joins the next line without a break or its indentation.
151    // Why:
152    //  The notice is one line that ends in a newline, so it can lead standard error without disturbing what follows.
153    //
154    // In TS you'd write (pseudocode):
155    // ```ts
156    // return `cli-git: no real Git follows this wrapper on PATH (${shownPath}); used ${git}, ... add ${directory} to the PATH ...\n`;
157    // ```
158    return format!(
159        "cli-git: no real Git follows this wrapper on PATH ({shown_path}); used {}, the Git recorded in {} as the one \
160         the commit service starts; to silence this notice, add {directory} to the PATH of the program that ran this \
161         command.\n",
162        git.display(),
163        unit_file.display()
164    );
165}
166
167/// What:
168///  Select the real Git for every start:
169///  the first `PATH` candidate that is real Git,
170///  otherwise the Git the commit service's unit records when that file passes the same checks.
171///  `Result<ResolvedGit, RealGitNotFound>` is the selection with its notice,
172///  or the typed failure.
173/// Why:
174///  The coordinator picked,
175/// under the owner's delegation and open to the owner's veto,
176///  that a wrapper which is found but has no real Git after it on `PATH` uses the Git it was set up with,
177///  instead of stopping.
178///  The recorded file is judged by `classify_candidate` as a `PATH` candidate is:
179///  never this executable,
180/// a link or copy of it,
181/// a launcher of it,
182/// or a script that starts it,
183///  and it must be an executable file that exists.
184///  A refusal keeps the stop and adds the record to the failure.
185///
186/// In TS you'd write (pseudocode):
187/// ```ts
188/// function resolveRealGit(inputs: ResolutionInputs): { path: string; notice?: string }; // throws RealGitNotFound
189/// ```
190pub fn resolve_real_git(inputs: &ResolutionInputs) -> Result<ResolvedGit, RealGitNotFound> {
191    // What:
192    //  `match` on the search's `Result`.
193    //  `Ok(path)` is the success variant carrying the found path, and `return Ok(...)` leaves with it;
194    //  `Err(failure)` is the failure variant, kept for the fallback.
195    //  `mut` lets the failure be extended below.
196    //  `&` in `search_path_for_real_git(inputs)` lends `inputs` without giving it away.
197    // Why:
198    //  A `PATH` that offers a real Git is used as it always was, with no notice and no look at the record.
199    //
200    // In TS you'd write (pseudocode):
201    // ```ts
202    // let notFound; try { return { path: searchPathForRealGit(inputs), notice: undefined }; } catch (e) { notFound = e; }
203    // ```
204    let mut not_found: RealGitNotFound = match search_path_for_real_git(inputs) {
205        Ok(path) => {
206            return Ok(ResolvedGit { path, notice: None });
207        }
208        Err(failure) => failure,
209    };
210    // What:
211    //  `let Some(unit_file) = ... else { ... };` takes the value out of an `Option` or runs the `else` block,
212    //  which must leave the function.
213    //  `.as_deref()` lends the optional owned path as an optional borrowed one.
214    //  `Err(not_found)` returns the failure unchanged.
215    // Why:
216    //  Inputs that name no unit file have no record, and the stop stays as it was.
217    //
218    // In TS you'd write (pseudocode):
219    // ```ts
220    // if (inputs.serviceUnit === undefined) throw notFound;
221    // ```
222    let Some(unit_file) = inputs.service_unit.as_deref() else {
223        return Err(not_found);
224    };
225    // A unit that cannot be read, or that names no absolute Git, is no record either.
226    let Some(recorded) = read_recorded_git(unit_file) else {
227        return Err(not_found);
228    };
229    // What:
230    //  The recorded path gets the verdict a `PATH` candidate gets.
231    //  `.as_path()` lends an owned path as a borrowed one.
232    // Why:
233    //  A record may name the wrapper, a launcher of it, or a file that is gone;
234    //  starting any of them as Git would be wrong, so none is trusted before it is checked.
235    //
236    // In TS you'd write (pseudocode):
237    // ```ts
238    // const kind = classifyCandidate(recorded, inputs.ownExecutable);
239    // ```
240    let kind: CandidateKind = classify_candidate(recorded.as_path(), inputs.own_executable.as_path());
241    if kind == CandidateKind::RealGit {
242        let notice: String = fallback_notice(inputs.path.as_os_str(), recorded.as_path(), unit_file);
243        // `Some(notice)` wraps the line the caller must print.
244        return Ok(ResolvedGit { path: recorded, notice: Some(notice) });
245    }
246    // What:
247    //  `Some(RecordedGit { ... })` stores the refused record in the failure.
248    //  `.to_path_buf()` copies the borrowed unit path into an owned one.
249    //  `SearchedCandidate { path: recorded, kind }` moves the recorded path in, with the verdict.
250    // Why:
251    //  The failure then names the unit, the path it recorded and why it was not used.
252    //
253    // In TS you'd write (pseudocode):
254    // ```ts
255    // notFound.recorded = { unitFile, candidate: { path: recorded, kind } };
256    // ```
257    not_found.recorded = Some(RecordedGit {
258        unit_file: unit_file.to_path_buf(),
259        candidate: SearchedCandidate { path: recorded, kind },
260    });
261    return Err(not_found);
262}