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}