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