git_policy_cli/doctor.rs
1//! What:
2//! `git cli-git doctor`: list the traditional hooks a repository holds.
3//! Why:
4//! Traditional hooks (executable scripts in `<git-common-dir>/hooks` or in a `core.hooksPath` directory) are
5//! unsupported, and their behavior with the wrapper is undefined.
6//! The commit path warns when `core.hooksPath` is configured but never scans a directory, because a scan costs
7//! file system calls on every commit; this command is where the scan happens, on request.
8//! Exit status 0 means none were found, 1 means at least one was, and 2 means the repository could not be located.
9//!
10//! In TS you'd write (pseudocode):
11//! ```ts
12//! const hooks = (await Promise.all(directories.map(scanHooksDirectory))).flat();
13//! process.exitCode = hooks.length === 0 ? 0 : 1;
14//! ```
15
16/// The decision this command ends in.
17use super::action::{Action, ENGINE_FAILURE_EXIT_CODE};
18/// Lexical path normalization, so one directory named two ways is scanned once.
19use super::hook_dispatch_plan::normalized_path;
20/// One required Git start with the caller's context.
21use super::transaction_git::{GitContext, GitRequest, run_git};
22/// `ErrorKind` tells a missing hooks directory from one that cannot be read.
23use std::io::ErrorKind;
24/// `Path`/`PathBuf` are borrowed/owned filesystem paths.
25use std::path::{Path, PathBuf};
26
27/// Exit status when at least one traditional hook was found.
28pub const HOOKS_FOUND_EXIT_CODE: i32 = 1;
29
30/// File name suffix of the samples Git installs, which are never hooks.
31const SAMPLE_SUFFIX: &[u8] = b".sample";
32
33/// What:
34/// Where a repository keeps hooks.
35/// Why:
36/// Git reads hooks from `<git-common-dir>/hooks` unless `core.hooksPath` names another directory, and a
37/// repository can hold hooks in both.
38///
39/// In TS you'd write (pseudocode):
40/// ```ts
41/// type HooksLocation = { commonDirectory: string; configured?: string };
42/// ```
43#[derive(Clone, Debug, Eq, PartialEq)]
44pub struct HooksLocation {
45 /// Absolute common Git directory, whose `hooks` directory is the default.
46 pub common_directory: PathBuf,
47 /// Absolute directory a configured `core.hooksPath` names, resolved the way Git resolves it.
48 pub configured: Option<PathBuf>,
49}
50
51/// What:
52/// The directories to scan, each once.
53/// Why:
54/// The default directory comes first, then the configured one unless it is the same directory.
55///
56/// In TS you'd write (pseudocode):
57/// ```ts
58/// unique([join(commonDirectory, 'hooks'), configured].filter(Boolean));
59/// ```
60pub fn hook_directories(location: &HooksLocation) -> Vec<PathBuf> {
61 let mut directories: Vec<PathBuf> = vec![normalized_path(location.common_directory.join("hooks").as_path())];
62 if let Some(configured) = &location.configured {
63 let normalized: PathBuf = normalized_path(configured.as_path());
64 if !directories.contains(&normalized) {
65 directories.push(normalized);
66 }
67 }
68 return directories;
69}
70
71/// What:
72/// Whether a file mode makes the file runnable by someone.
73/// Why:
74/// Git runs a hook only when it is executable and prints a hint for a file it skips, so a plain file is not a hook.
75///
76/// In TS you'd write (pseudocode):
77/// ```ts
78/// (stats.mode & 0o111) !== 0
79/// ```
80#[cfg(unix)]
81fn is_executable(metadata: &std::fs::Metadata) -> bool {
82 use std::os::unix::fs::PermissionsExt;
83 return metadata.permissions().mode() & 0o111 != 0;
84}
85
86/// What:
87/// Whether a file is runnable.
88/// Why:
89/// Without Unix permission bits every regular file counts, because the platform has no mode to test.
90///
91/// In TS you'd write (pseudocode):
92/// ```ts
93/// true
94/// ```
95#[cfg(not(unix))]
96fn is_executable(_metadata: &std::fs::Metadata) -> bool {
97 return true;
98}
99
100/// What:
101/// The traditional hooks in one directory: executable regular files and symbolic links, except `*.sample`.
102/// Why:
103/// A missing directory holds none; any other failure to read it is reported, because silence would pass a
104/// directory the command could not look at.
105/// A link counts whatever it points at (a dangling link too), because Git follows it when it starts the hook.
106///
107/// In TS you'd write (pseudocode):
108/// ```ts
109/// (await readdir(directory)).filter((name) => !name.endsWith('.sample') && (isSymlink(name) || isExecutableFile(name)));
110/// ```
111pub fn scan_hooks_directory(directory: &Path) -> std::io::Result<Vec<PathBuf>> {
112 let entries = match std::fs::read_dir(directory) {
113 Ok(found) => found,
114 Err(error) if error.kind() == ErrorKind::NotFound || error.kind() == ErrorKind::NotADirectory => {
115 return Ok(Vec::new());
116 }
117 Err(error) => return Err(error),
118 };
119 let mut hooks: Vec<PathBuf> = Vec::new();
120 for entry in entries {
121 let found = entry?;
122 if found.file_name().as_encoded_bytes().ends_with(SAMPLE_SUFFIX) {
123 continue;
124 }
125 let metadata: std::fs::Metadata = std::fs::symlink_metadata(found.path())?;
126 let kind: std::fs::FileType = metadata.file_type();
127 if kind.is_symlink() || (kind.is_file() && is_executable(&metadata)) {
128 hooks.push(found.path());
129 }
130 }
131 hooks.sort();
132 return Ok(hooks);
133}
134
135/// What:
136/// The traditional hooks in every directory, in directory order and then name order.
137/// Why:
138/// The report lists them as a person reads them: the default directory first.
139///
140/// In TS you'd write (pseudocode):
141/// ```ts
142/// (await Promise.all(directories.map(scanHooksDirectory))).flat();
143/// ```
144pub fn find_traditional_hooks(directories: &[PathBuf]) -> std::io::Result<Vec<PathBuf>> {
145 let mut hooks: Vec<PathBuf> = Vec::new();
146 for directory in directories {
147 hooks.extend(scan_hooks_directory(directory.as_path())?);
148 }
149 return Ok(hooks);
150}
151
152/// What:
153/// The report: one hook path per line.
154/// Why:
155/// The output is for people and scripts alike, so it carries nothing but the paths.
156///
157/// In TS you'd write (pseudocode):
158/// ```ts
159/// hooks.map((hook) => `${hook}\n`).join('')
160/// ```
161pub fn render_hooks(hooks: &[PathBuf]) -> String {
162 let mut text: String = String::new();
163 for hook in hooks {
164 text.push_str(hook.display().to_string().as_str());
165 text.push('\n');
166 }
167 return text;
168}
169
170/// What:
171/// The explanation printed beside a non-empty report.
172/// Why:
173/// The paths alone do not say why they are listed or what to do with them.
174///
175/// In TS you'd write (pseudocode):
176/// ```ts
177/// const explanation: string;
178/// ```
179const FOUND_EXPLANATION: &str = "git cli-git doctor: the traditional hooks listed on standard output are unsupported, \
180and their behavior with cli-git is undefined. Move each into a config hook (`hook.<name>.event` with \
181`hook.<name>.command`) and delete the script.\n";
182
183/// What:
184/// Run one Git query in `cwd` and return its standard output as text.
185/// Why:
186/// Locating the repository takes three small questions, and each failure is reported with Git's own words.
187///
188/// In TS you'd write (pseudocode):
189/// ```ts
190/// const { stdout } = await git(args, { cwd });
191/// ```
192fn query(context: &GitContext, cwd: &Path, arguments: &[&str]) -> Result<Option<String>, String> {
193 let request: GitRequest = GitRequest::new(cwd, arguments);
194 let output = run_git(context, &request).map_err(|error| {
195 return format!("git {} could not start: {error}", arguments.join(" "));
196 })?;
197 if output.code == Some(1) && arguments.first() == Some(&"config") {
198 return Ok(None);
199 }
200 if !output.succeeded() {
201 return Err(format!("git {} failed: {}", arguments.join(" "), output.error_text()));
202 }
203 let text: String = String::from_utf8(output.stdout)
204 .map_err(|_| { return format!("git {} printed bytes that are not UTF-8", arguments.join(" ")); })?;
205 return Ok(Some(text));
206}
207
208/// What:
209/// Locate the hooks of the repository that Git selects from `cwd` and the caller's global options.
210/// Why:
211/// Git alone knows the common directory (worktrees, `--git-dir`, `GIT_DIR`) and how `core.hooksPath` expands.
212/// A relative `core.hooksPath` is relative to where Git runs hooks, which is the worktree root, or the Git
213/// directory of a bare repository.
214///
215/// In TS you'd write (pseudocode):
216/// ```ts
217/// const location = await locateHooks({ context, cwd });
218/// ```
219pub fn locate_hooks(context: &GitContext, cwd: &Path) -> Result<HooksLocation, String> {
220 let report: String = query(
221 context,
222 cwd,
223 &["rev-parse", "--path-format=absolute", "--git-common-dir", "--absolute-git-dir"],
224 )?
225 .unwrap_or_default();
226 let mut lines = report.lines();
227 let (Some(common), Some(git_directory)) = (lines.next(), lines.next()) else {
228 return Err(String::from("git rev-parse did not report the common and Git directories"));
229 };
230 let configured_text: Option<String> = query(context, cwd, &["config", "--type=path", "--get", "core.hooksPath"])?
231 .map(|text| { return text.strip_suffix('\n').unwrap_or(text.as_str()).to_string(); })
232 .filter(|text| { return !text.is_empty(); });
233 let Some(value) = configured_text else {
234 return Ok(HooksLocation {
235 common_directory: PathBuf::from(common),
236 configured: None,
237 });
238 };
239 if Path::new(value.as_str()).is_absolute() {
240 return Ok(HooksLocation {
241 common_directory: PathBuf::from(common),
242 configured: Some(PathBuf::from(value)),
243 });
244 }
245 // A relative value resolves where Git runs hooks; a repository without a worktree has no top level.
246 let base: PathBuf = match query(context, cwd, &["rev-parse", "--path-format=absolute", "--show-toplevel"]) {
247 Ok(Some(top)) if !top.trim_end().is_empty() => PathBuf::from(top.trim_end()),
248 Ok(_) | Err(_) => PathBuf::from(git_directory),
249 };
250 return Ok(HooksLocation {
251 common_directory: PathBuf::from(common),
252 configured: Some(base.join(value)),
253 });
254}
255
256/// What:
257/// Decide what `git cli-git doctor` prints and exits with.
258/// Why:
259/// Exit 0 with no output means no traditional hook was found, exit 1 lists each one, and exit 2 reports a
260/// repository or a directory that could not be examined.
261///
262/// In TS you'd write (pseudocode):
263/// ```ts
264/// async function doctor({ context, cwd }): Promise<Action>;
265/// ```
266pub fn doctor_action(context: &GitContext, cwd: &Path) -> Action {
267 let location: HooksLocation = match locate_hooks(context, cwd) {
268 Ok(found) => found,
269 Err(message) => return doctor_failure(message.as_str()),
270 };
271 let hooks: Vec<PathBuf> = match find_traditional_hooks(hook_directories(&location).as_slice()) {
272 Ok(found) => found,
273 Err(error) => return doctor_failure(format!("cannot read a hooks directory: {error}").as_str()),
274 };
275 if hooks.is_empty() {
276 return Action::Exit {
277 code: 0,
278 stdout: String::new(),
279 stderr: String::new(),
280 };
281 }
282 return Action::Exit {
283 code: HOOKS_FOUND_EXIT_CODE,
284 stdout: render_hooks(hooks.as_slice()),
285 stderr: String::from(FOUND_EXPLANATION),
286 };
287}
288
289/// What:
290/// The action for a doctor run that could not examine the repository.
291/// Why:
292/// Status 2 is the wrapper's status for a failure that is not a finding.
293///
294/// In TS you'd write (pseudocode):
295/// ```ts
296/// { code: 2, stdout: '', stderr: `git cli-git doctor: ${message}\n` }
297/// ```
298fn doctor_failure(message: &str) -> Action {
299 return Action::Exit {
300 code: ENGINE_FAILURE_EXIT_CODE,
301 stdout: String::new(),
302 stderr: format!("git cli-git doctor: {message}\n"),
303 };
304}
305
306/// Doctor controls stay out of the release executable.
307#[cfg(test)]
308#[path = "doctor_tests.rs"]
309mod tests;