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