git_policy_cli/invocation/git_version.rs
1//! What:
2//! The real Git version check,
3//! run once per invocation before work that depends on Git's
4//! behavior:
5//! a commit,
6//! hooks and locks.
7//! Why:
8//! The native tables and protocols follow the Git release the test image runs the tests with,
9//! and a Git older than that release is not refused:
10//! the owner chose to warn once and go on
11//! ("Raise minimum, drop the test, merely print a warning on older git and not refuse.",
12//! 2026-11-04),
13//! so the constant below is the release the wrapper was tested with.
14//! Only a Git whose release cannot be read stops the command,
15//! because nothing is known about it.
16//! Read-only commands are never checked
17//! (`doc/handover/cli-git-rust-implementation.md`,
18//! "Adopted without a question";
19//! that section moved on 2026-11-03 to
20//! `doc/planning/cli-git-rust-open-decisions.md`).
21//!
22//! In TS you'd write (pseudocode):
23//! ```ts
24//! await requireReadableGit({ gitPath }); // once; warns when the release is older than the tested one
25//! ```
26
27/// The per-executable cache of the version text.
28use crate::git_version_cache::{binary_identity, cache_directory, read_version_text, store_version_text};
29/// Running real Git.
30use crate::transaction_git::{GitContext, GitOutput, GitRequest, run_git};
31/// OS strings and paths for the cache location.
32use std::ffi::OsString;
33use std::io::Write;
34use std::path::Path;
35/// `OnceLock` holds the one answer of this process.
36use std::sync::OnceLock;
37
38/// The Git release the wrapper was tested with, and the minimum below which every run prints a warning.
39///
40/// Keep it in step with the test image: the gate's base image installs the distribution's current `git` package
41/// (`native-test-base.Containerfile`), and `mise run //package/git-policy/cli:test:git-image` prints the release it
42/// got. When that release moves past this one, change this constant to it in the same commit as the first gate run
43/// on the new release, together with the release named in `README.md` and `SPEC.md` ("tested with").
44pub const MINIMUM_GIT_VERSION: (u64, u64, u64) = (2, 56, 0);
45
46/// A Git release as its major,
47/// minor and patch numbers.
48pub type GitRelease = (u64, u64, u64);
49
50/// The answer of this process's one check.
51static CHECKED: OnceLock<Result<GitRelease, String>> = OnceLock::new();
52
53/// What:
54/// The release numbers of `git version` output,
55/// such as `git version 2.56.0.windows.1`.
56/// Why:
57/// Only the first three dotted numbers decide support;
58/// platform suffixes vary.
59///
60/// In TS you'd write (pseudocode):
61/// ```ts
62/// function parseGitVersion(output: string): [number, number, number] | undefined;
63/// ```
64pub fn parse_git_version(output: &[u8]) -> Option<(u64, u64, u64)> {
65 let text: &str = std::str::from_utf8(output).ok()?;
66 let release: &str = text.strip_prefix("git version ")?.split_whitespace().next()?;
67 let mut parts: std::str::Split<'_, char> = release.split('.');
68 let major: u64 = parts.next()?.parse::<u64>().ok()?;
69 let minor: u64 = parts.next()?.parse::<u64>().ok()?;
70 let patch_text: &str = parts.next()?;
71 // A release candidate such as `2.56.0-rc1` keeps its leading digits.
72 let digits: &str = &patch_text[..patch_text.find(not_digit).unwrap_or(patch_text.len())];
73 let patch: u64 = digits.parse::<u64>().ok()?;
74 return Some((major, minor, patch));
75}
76
77/// What:
78/// Whether a character is not an ASCII digit.
79/// Why:
80/// A named predicate keeps the search free of closures.
81///
82/// In TS you'd write (pseudocode):
83/// ```ts
84/// (character: string) => !/[0-9]/.test(character)
85/// ```
86fn not_digit(character: char) -> bool {
87 return !character.is_ascii_digit();
88}
89
90/// What:
91/// The message for a Git whose release cannot be read.
92/// Why:
93/// The person must know which Git was asked and what to do.
94///
95/// In TS you'd write (pseudocode):
96/// ```ts
97/// `cli-git: real Git at ${path} reports ${found}, so its release cannot be read ...`
98/// ```
99pub fn unsupported_message(context: &GitContext, found: &str) -> String {
100 return format!(
101 "cli-git: real Git at {} reports {found}, so its release cannot be read; commits, hooks and the \
102 locks that coordinate with them depend on it. Nothing was run. Point the wrapper at a working \
103 Git, then run the command again.",
104 context.real_git.display()
105 );
106}
107
108/// What:
109/// The warning for a Git older than the release the wrapper was tested with,
110/// or `None` for that release and every later one.
111/// Why:
112/// An older Git is not refused;
113/// the person learns which Git was found,
114/// which release the wrapper was tested with,
115/// and that the command continues.
116///
117/// In TS you'd write (pseudocode):
118/// ```ts
119/// function olderGitWarning(context, release): string | undefined;
120/// ```
121pub fn older_git_warning(context: &GitContext, release: GitRelease) -> Option<String> {
122 if release >= MINIMUM_GIT_VERSION {
123 return None;
124 }
125 return Some(format!(
126 "cli-git: warning: real Git at {} is version {}.{}.{}, older than {}.{}.{}, the release this wrapper was tested \
127 with; the command continues with it.\n",
128 context.real_git.display(),
129 release.0,
130 release.1,
131 release.2,
132 MINIMUM_GIT_VERSION.0,
133 MINIMUM_GIT_VERSION.1,
134 MINIMUM_GIT_VERSION.2
135 ));
136}
137
138/// What:
139/// Read the release from the text `git version` printed.
140/// Why:
141/// One judgment serves the text a Git process printed and the text the cache kept,
142/// so both paths answer the same release or refuse the same unreadable text with the same message.
143///
144/// In TS you'd write (pseudocode):
145/// ```ts
146/// function judgeGitVersion(context, stdout): [number, number, number]; // throws the unsupported message
147/// ```
148fn judge_version_text(context: &GitContext, stdout: &[u8]) -> Result<GitRelease, String> {
149 let Some(version) = parse_git_version(stdout) else {
150 let shown: String = String::from_utf8_lossy(stdout).trim().to_string();
151 return Err(unsupported_message(context, format!("an unreadable version {shown:?}").as_str()));
152 };
153 return Ok(version);
154}
155
156/// What:
157/// Start `git version` and return what it printed and whether it exited with success.
158/// Why:
159/// Only a successful run is worth caching;
160/// a failed start is the unsupported message.
161///
162/// In TS you'd write (pseudocode):
163/// ```ts
164/// const { stdout, exitCode } = await runGit({ args: ['version'] });
165/// ```
166fn run_git_version(context: &GitContext) -> Result<GitOutput, String> {
167 let mut request: GitRequest = GitRequest::new(std::path::Path::new("."), &["version"]);
168 request.without_prefix = true;
169 return match run_git(context, &request) {
170 Ok(finished) => Ok(finished),
171 Err(error) => Err(unsupported_message(context, format!("nothing ({error})").as_str())),
172 };
173}
174
175/// What:
176/// Ask Git its version without the cache.
177/// Why:
178/// `check_git_version` remembers this answer.
179///
180/// In TS you'd write (pseudocode):
181/// ```ts
182/// async function askGitVersion(gitPath): Promise<[number, number, number]>;
183/// ```
184pub fn ask_git_version(context: &GitContext) -> Result<(u64, u64, u64), String> {
185 let output: GitOutput = run_git_version(context)?;
186 return judge_version_text(context, output.stdout.as_slice());
187}
188
189/// What:
190/// Ask for the Git version through the per-executable cache in `cache_directory`,
191/// starting `git version` only on a miss.
192/// Why:
193/// The text is read from the cache only when the executable's identity matches the recorded one exactly
194/// and the text still parses;
195/// a stale,
196/// unreadable or absent entry,
197/// an executable that is not identifiable,
198/// or no cache directory all run Git.
199/// The identity is taken before the start and again after it,
200/// and the text is recorded only when both agree and Git succeeded,
201/// so an executable replaced during the start is never recorded under the old identity.
202///
203/// In TS you'd write (pseudocode):
204/// ```ts
205/// async function askGitVersionCached(context, cacheDirectory?: string): Promise<[number, number, number]>;
206/// ```
207pub fn ask_git_version_cached(context: &GitContext, cache_directory: Option<&Path>) -> Result<GitRelease, String> {
208 let Some(directory) = cache_directory else {
209 return ask_git_version(context);
210 };
211 let Some(identity) = binary_identity(context.real_git.as_path()) else {
212 return ask_git_version(context);
213 };
214 if let Some(text) = read_version_text(directory, &identity)
215 && parse_git_version(text.as_slice()).is_some()
216 {
217 return judge_version_text(context, text.as_slice());
218 }
219 let output: GitOutput = run_git_version(context)?;
220 let judged: Result<GitRelease, String> = judge_version_text(context, output.stdout.as_slice());
221 let unchanged: bool = binary_identity(context.real_git.as_path()).as_ref() == Some(&identity);
222 if judged.is_ok() && output.succeeded() && unchanged {
223 store_version_text(directory, &identity, output.stdout.as_slice());
224 }
225 return judged;
226}
227
228/// What:
229/// The process-wide version ask:
230/// the cache in the user's cache directory when the environment names one,
231/// and the warning for an older Git printed once to standard error.
232/// Why:
233/// `check_git_version` takes only the Git context;
234/// the environment is the process's own,
235/// as the Git children inherit it.
236/// Unit tests of the library never touch the real home, so they ask without a cache.
237///
238/// In TS you'd write (pseudocode):
239/// ```ts
240/// askGitVersionCached(context, cacheDirectory(process.env))
241/// ```
242fn ask_git_version_with_process_cache(context: &GitContext) -> Result<GitRelease, String> {
243 let answer: Result<GitRelease, String> = if cfg!(test) {
244 ask_git_version(context)
245 } else {
246 let environment: Vec<(OsString, OsString)> = std::env::vars_os().collect();
247 ask_git_version_cached(context, cache_directory(environment.as_slice()).as_deref())
248 };
249 // This function runs once per process (`check_git_version` keeps its answer), so the warning prints once.
250 if let Ok(release) = &answer
251 && let Some(warning) = older_git_warning(context, *release)
252 {
253 let mut stream = std::io::stderr();
254 let _ = stream.write_all(warning.as_bytes());
255 let _ = stream.flush();
256 }
257 return answer;
258}
259
260/// What:
261/// Require a Git whose release can be read,
262/// asking at most once per process.
263/// Why:
264/// The check costs a Git process,
265/// so later dependent steps reuse the answer.
266///
267/// In TS you'd write (pseudocode):
268/// ```ts
269/// await requireReadableGit({ gitPath });
270/// ```
271pub fn check_git_version(context: &GitContext) -> Result<(u64, u64, u64), String> {
272 return check_once(&CHECKED, context, ask_git_version_with_process_cache);
273}
274
275/// What:
276/// Return the answer already stored in `cell`,
277/// or ask once and store the answer.
278/// Why:
279/// `check_git_version` keeps one answer per process;
280/// a separate cell lets the controls
281/// prove the ask runs once without sharing the process-wide answer.
282///
283/// In TS you'd write (pseudocode):
284/// ```ts
285/// const checked ??= await askGitVersion(context);
286/// ```
287pub fn check_once(
288 cell: &OnceLock<Result<GitRelease, String>>,
289 context: &GitContext,
290 ask: fn(&GitContext) -> Result<GitRelease, String>,
291) -> Result<GitRelease, String> {
292 if let Some(known) = cell.get() {
293 return known.clone();
294 }
295 let answer: Result<(u64, u64, u64), String> = ask(context);
296 // Another thread may have set it meanwhile; the first answer stands.
297 let _ = cell.set(answer.clone());
298 return answer;
299}
300
301/// Version controls stay out of the release executable.
302#[cfg(test)]
303#[path = "git_version_tests.rs"]
304mod tests;