git_policy_cli/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 Git 2.56.0,
9//! and the
10//! owner settled on 2.55.0 as the oldest release that runs the swap,
11//! so 2.55.0 or newer counts as supported and that read-only commands are never checked
12//! (`doc/handover/cli-git-rust-implementation.md`,
13//! "Adopted without a question").
14//!
15//! In TS you'd write (pseudocode):
16//! ```ts
17//! await requireSupportedGit({ gitPath }); // once
18//! ```
19
20/// The per-executable cache of the version text.
21use super::git_version_cache::{binary_identity, cache_directory, read_version_text, store_version_text};
22/// Running real Git.
23use super::transaction_git::{GitContext, GitOutput, GitRequest, run_git};
24/// OS strings and paths for the cache location.
25use std::ffi::OsString;
26use std::path::Path;
27/// `OnceLock` holds the one answer of this process.
28use std::sync::OnceLock;
29
30/// The oldest supported Git release.
31pub const MINIMUM_GIT_VERSION: (u64, u64, u64) = (2, 55, 0);
32
33/// A Git release as its major,
34/// minor and patch numbers.
35pub type GitRelease = (u64, u64, u64);
36
37/// The answer of this process's one check.
38static CHECKED: OnceLock<Result<GitRelease, String>> = OnceLock::new();
39
40/// What:
41/// The release numbers of `git version` output,
42/// such as `git version 2.56.0.windows.1`.
43/// Why:
44/// Only the first three dotted numbers decide support;
45/// platform suffixes vary.
46///
47/// In TS you'd write (pseudocode):
48/// ```ts
49/// function parseGitVersion(output: string): [number, number, number] | undefined;
50/// ```
51pub fn parse_git_version(output: &[u8]) -> Option<(u64, u64, u64)> {
52 let text: &str = std::str::from_utf8(output).ok()?;
53 let release: &str = text.strip_prefix("git version ")?.split_whitespace().next()?;
54 let mut parts: std::str::Split<'_, char> = release.split('.');
55 let major: u64 = parts.next()?.parse::<u64>().ok()?;
56 let minor: u64 = parts.next()?.parse::<u64>().ok()?;
57 let patch_text: &str = parts.next()?;
58 // A release candidate such as `2.56.0-rc1` keeps its leading digits.
59 let digits: &str = &patch_text[..patch_text.find(not_digit).unwrap_or(patch_text.len())];
60 let patch: u64 = digits.parse::<u64>().ok()?;
61 return Some((major, minor, patch));
62}
63
64/// What:
65/// Whether a character is not an ASCII digit.
66/// Why:
67/// A named predicate keeps the search free of closures.
68///
69/// In TS you'd write (pseudocode):
70/// ```ts
71/// (character: string) => !/[0-9]/.test(character)
72/// ```
73fn not_digit(character: char) -> bool {
74 return !character.is_ascii_digit();
75}
76
77/// What:
78/// The message for an unsupported or unreadable Git.
79/// Why:
80/// The person must know which Git was asked and what to do.
81///
82/// In TS you'd write (pseudocode):
83/// ```ts
84/// `cli-git: ... needs Git 2.55.0 or newer ...`
85/// ```
86pub fn unsupported_message(context: &GitContext, found: &str) -> String {
87 return format!(
88 "cli-git: real Git at {} reports {found}; commits, hooks and the locks that coordinate \
89 with them need Git 2.55.0 or newer. Nothing was run. Upgrade Git, then run the command \
90 again.",
91 context.real_git.display()
92 );
93}
94
95/// What:
96/// Decide support from the text `git version` printed.
97/// Why:
98/// One judgment serves the text a Git process printed and the text the cache kept,
99/// so both paths refuse the same releases with the same message.
100///
101/// In TS you'd write (pseudocode):
102/// ```ts
103/// function judgeGitVersion(context, stdout): [number, number, number]; // throws the unsupported message
104/// ```
105fn judge_version_text(context: &GitContext, stdout: &[u8]) -> Result<GitRelease, String> {
106 let Some(version) = parse_git_version(stdout) else {
107 let shown: String = String::from_utf8_lossy(stdout).trim().to_string();
108 return Err(unsupported_message(context, format!("an unreadable version {shown:?}").as_str()));
109 };
110 if version < MINIMUM_GIT_VERSION {
111 return Err(unsupported_message(
112 context,
113 format!("version {}.{}.{}", version.0, version.1, version.2).as_str(),
114 ));
115 }
116 return Ok(version);
117}
118
119/// What:
120/// Start `git version` and return what it printed and whether it exited with success.
121/// Why:
122/// Only a successful run is worth caching;
123/// a failed start is the unsupported message.
124///
125/// In TS you'd write (pseudocode):
126/// ```ts
127/// const { stdout, exitCode } = await runGit({ args: ['version'] });
128/// ```
129fn run_git_version(context: &GitContext) -> Result<GitOutput, String> {
130 let mut request: GitRequest = GitRequest::new(std::path::Path::new("."), &["version"]);
131 request.without_prefix = true;
132 return match run_git(context, &request) {
133 Ok(finished) => Ok(finished),
134 Err(error) => Err(unsupported_message(context, format!("nothing ({error})").as_str())),
135 };
136}
137
138/// What:
139/// Ask Git its version and decide support,
140/// without the cache.
141/// Why:
142/// `check_git_version` remembers this answer.
143///
144/// In TS you'd write (pseudocode):
145/// ```ts
146/// async function askGitVersion(gitPath): Promise<[number, number, number]>;
147/// ```
148pub fn ask_git_version(context: &GitContext) -> Result<(u64, u64, u64), String> {
149 let output: GitOutput = run_git_version(context)?;
150 return judge_version_text(context, output.stdout.as_slice());
151}
152
153/// What:
154/// Ask for the Git version through the per-executable cache in `cache_directory`,
155/// starting `git version` only on a miss.
156/// Why:
157/// The text is read from the cache only when the executable's identity matches the recorded one exactly
158/// and the text still parses;
159/// a stale,
160/// unreadable or absent entry,
161/// an executable that is not identifiable,
162/// or no cache directory all run Git.
163/// The identity is taken before the start and again after it,
164/// and the text is recorded only when both agree and Git succeeded,
165/// so an executable replaced during the start is never recorded under the old identity.
166///
167/// In TS you'd write (pseudocode):
168/// ```ts
169/// async function askGitVersionCached(context, cacheDirectory?: string): Promise<[number, number, number]>;
170/// ```
171pub fn ask_git_version_cached(context: &GitContext, cache_directory: Option<&Path>) -> Result<GitRelease, String> {
172 let Some(directory) = cache_directory else {
173 return ask_git_version(context);
174 };
175 let Some(identity) = binary_identity(context.real_git.as_path()) else {
176 return ask_git_version(context);
177 };
178 if let Some(text) = read_version_text(directory, &identity)
179 && parse_git_version(text.as_slice()).is_some()
180 {
181 return judge_version_text(context, text.as_slice());
182 }
183 let output: GitOutput = run_git_version(context)?;
184 let judged: Result<GitRelease, String> = judge_version_text(context, output.stdout.as_slice());
185 let unchanged: bool = binary_identity(context.real_git.as_path()).as_ref() == Some(&identity);
186 if judged.is_ok() && output.succeeded() && unchanged {
187 store_version_text(directory, &identity, output.stdout.as_slice());
188 }
189 return judged;
190}
191
192/// What:
193/// The process-wide version ask:
194/// the cache in the user's cache directory when the environment names one.
195/// Why:
196/// `check_git_version` takes only the Git context;
197/// the environment is the process's own,
198/// as the Git children inherit it.
199/// Unit tests of the library never touch the real home, so they ask without a cache.
200///
201/// In TS you'd write (pseudocode):
202/// ```ts
203/// askGitVersionCached(context, cacheDirectory(process.env))
204/// ```
205fn ask_git_version_with_process_cache(context: &GitContext) -> Result<GitRelease, String> {
206 if cfg!(test) {
207 return ask_git_version(context);
208 }
209 let environment: Vec<(OsString, OsString)> = std::env::vars_os().collect();
210 return ask_git_version_cached(context, cache_directory(environment.as_slice()).as_deref());
211}
212
213/// What:
214/// Require a supported Git,
215/// asking at most once per process.
216/// Why:
217/// The check costs a Git process,
218/// so later dependent steps reuse the answer.
219///
220/// In TS you'd write (pseudocode):
221/// ```ts
222/// await requireSupportedGit({ gitPath });
223/// ```
224pub fn check_git_version(context: &GitContext) -> Result<(u64, u64, u64), String> {
225 return check_once(&CHECKED, context, ask_git_version_with_process_cache);
226}
227
228/// What:
229/// Return the answer already stored in `cell`,
230/// or ask once and store the answer.
231/// Why:
232/// `check_git_version` keeps one answer per process;
233/// a separate cell lets the controls
234/// prove the ask runs once without sharing the process-wide answer.
235///
236/// In TS you'd write (pseudocode):
237/// ```ts
238/// const checked ??= await askGitVersion(context);
239/// ```
240pub fn check_once(
241 cell: &OnceLock<Result<GitRelease, String>>,
242 context: &GitContext,
243 ask: fn(&GitContext) -> Result<GitRelease, String>,
244) -> Result<GitRelease, String> {
245 if let Some(known) = cell.get() {
246 return known.clone();
247 }
248 let answer: Result<(u64, u64, u64), String> = ask(context);
249 // Another thread may have set it meanwhile; the first answer stands.
250 let _ = cell.set(answer.clone());
251 return answer;
252}
253
254/// Version controls stay out of the release executable.
255#[cfg(test)]
256#[path = "git_version_tests.rs"]
257mod tests;