Skip to main content

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;