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/// Running real Git.
21use super::transaction_git::{GitContext, GitOutput, GitRequest, run_git};
22/// `OnceLock` holds the one answer of this process.
23use std::sync::OnceLock;
24
25/// The oldest supported Git release.
26pub const MINIMUM_GIT_VERSION: (u64, u64, u64) = (2, 55, 0);
27
28/// A Git release as its major,
29/// minor and patch numbers.
30pub type GitRelease = (u64, u64, u64);
31
32/// The answer of this process's one check.
33static CHECKED: OnceLock<Result<GitRelease, String>> = OnceLock::new();
34
35/// What:
36/// The release numbers of `git version` output,
37/// such as `git version 2.56.0.windows.1`.
38/// Why:
39/// Only the first three dotted numbers decide support;
40/// platform suffixes vary.
41///
42/// In TS you'd write (pseudocode):
43/// ```ts
44/// function parseGitVersion(output: string): [number, number, number] | undefined;
45/// ```
46pub fn parse_git_version(output: &[u8]) -> Option<(u64, u64, u64)> {
47 let text: &str = std::str::from_utf8(output).ok()?;
48 let release: &str = text.strip_prefix("git version ")?.split_whitespace().next()?;
49 let mut parts: std::str::Split<'_, char> = release.split('.');
50 let major: u64 = parts.next()?.parse::<u64>().ok()?;
51 let minor: u64 = parts.next()?.parse::<u64>().ok()?;
52 let patch_text: &str = parts.next()?;
53 // A release candidate such as `2.56.0-rc1` keeps its leading digits.
54 let digits: &str = &patch_text[..patch_text.find(not_digit).unwrap_or(patch_text.len())];
55 let patch: u64 = digits.parse::<u64>().ok()?;
56 return Some((major, minor, patch));
57}
58
59/// What:
60/// Whether a character is not an ASCII digit.
61/// Why:
62/// A named predicate keeps the search free of closures.
63///
64/// In TS you'd write (pseudocode):
65/// ```ts
66/// (character: string) => !/[0-9]/.test(character)
67/// ```
68fn not_digit(character: char) -> bool {
69 return !character.is_ascii_digit();
70}
71
72/// What:
73/// The message for an unsupported or unreadable Git.
74/// Why:
75/// The person must know which Git was asked and what to do.
76///
77/// In TS you'd write (pseudocode):
78/// ```ts
79/// `cli-git: ... needs Git 2.55.0 or newer ...`
80/// ```
81pub fn unsupported_message(context: &GitContext, found: &str) -> String {
82 return format!(
83 "cli-git: real Git at {} reports {found}; commits, hooks and the locks that coordinate \
84 with them need Git 2.55.0 or newer. Nothing was run. Upgrade Git, then run the command \
85 again.",
86 context.real_git.display()
87 );
88}
89
90/// What:
91/// Ask Git its version and decide support,
92/// without the cache.
93/// Why:
94/// `check_git_version` remembers this answer.
95///
96/// In TS you'd write (pseudocode):
97/// ```ts
98/// async function askGitVersion(gitPath): Promise<[number, number, number]>;
99/// ```
100pub fn ask_git_version(context: &GitContext) -> Result<(u64, u64, u64), String> {
101 let mut request: GitRequest = GitRequest::new(std::path::Path::new("."), &["version"]);
102 request.without_prefix = true;
103 let output: GitOutput = match run_git(context, &request) {
104 Ok(finished) => finished,
105 Err(error) => return Err(unsupported_message(context, format!("nothing ({error})").as_str())),
106 };
107 let Some(version) = parse_git_version(output.stdout.as_slice()) else {
108 let shown: String = String::from_utf8_lossy(output.stdout.as_slice()).trim().to_string();
109 return Err(unsupported_message(context, format!("an unreadable version {shown:?}").as_str()));
110 };
111 if version < MINIMUM_GIT_VERSION {
112 return Err(unsupported_message(
113 context,
114 format!("version {}.{}.{}", version.0, version.1, version.2).as_str(),
115 ));
116 }
117 return Ok(version);
118}
119
120/// What:
121/// Require a supported Git,
122/// asking at most once per process.
123/// Why:
124/// The check costs a Git process,
125/// so later dependent steps reuse the answer.
126///
127/// In TS you'd write (pseudocode):
128/// ```ts
129/// await requireSupportedGit({ gitPath });
130/// ```
131pub fn check_git_version(context: &GitContext) -> Result<(u64, u64, u64), String> {
132 return check_once(&CHECKED, context, ask_git_version);
133}
134
135/// What:
136/// Return the answer already stored in `cell`,
137/// or ask once and store the answer.
138/// Why:
139/// `check_git_version` keeps one answer per process;
140/// a separate cell lets the controls
141/// prove the ask runs once without sharing the process-wide answer.
142///
143/// In TS you'd write (pseudocode):
144/// ```ts
145/// const checked ??= await askGitVersion(context);
146/// ```
147pub fn check_once(
148 cell: &OnceLock<Result<GitRelease, String>>,
149 context: &GitContext,
150 ask: fn(&GitContext) -> Result<GitRelease, String>,
151) -> Result<GitRelease, String> {
152 if let Some(known) = cell.get() {
153 return known.clone();
154 }
155 let answer: Result<(u64, u64, u64), String> = ask(context);
156 // Another thread may have set it meanwhile; the first answer stands.
157 let _ = cell.set(answer.clone());
158 return answer;
159}
160
161/// Version controls stay out of the release executable.
162#[cfg(test)]
163#[path = "git_version_tests.rs"]
164mod tests;