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//! A hook file that is byte for byte the shim `git lfs` writes for its own name is expected and harmless, because the
10//! wrapper does the Git LFS upload itself and starts Git with traditional hooks off; it is explained apart from the
11//! other hooks and does not make the exit status 1.
12//!
13//! In TS you'd write (pseudocode):
14//! ```ts
15//! const hooks = (await Promise.all(directories.map(scanHooksDirectory))).flat();
16//! process.exitCode = hooks.length === 0 ? 0 : 1;
17//! ```
18
19/// The decision this command ends in.
20use super::action::{Action, ENGINE_FAILURE_EXIT_CODE};
21/// Lexical path normalization, so one directory named two ways is scanned once.
22use super::hook_dispatch_plan::normalized_path;
23/// One required Git start with the caller's context.
24use super::transaction_git::{GitContext, GitRequest, run_git};
25/// `ErrorKind` tells a missing hooks directory from one that cannot be read.
26use std::io::ErrorKind;
27/// `Path`/`PathBuf` are borrowed/owned filesystem paths.
28use std::path::{Path, PathBuf};
29
30/// Exit status when at least one traditional hook was found.
31pub const HOOKS_FOUND_EXIT_CODE: i32 = 1;
32
33/// File name suffix of the samples Git installs, which are never hooks.
34const SAMPLE_SUFFIX: &[u8] = b".sample";
35
36/// What:
37/// Where a repository keeps hooks.
38/// Why:
39/// Git reads hooks from `<git-common-dir>/hooks` unless `core.hooksPath` names another directory, and a
40/// repository can hold hooks in both.
41///
42/// In TS you'd write (pseudocode):
43/// ```ts
44/// type HooksLocation = { commonDirectory: string; configured?: string };
45/// ```
46#[derive(Clone, Debug, Eq, PartialEq)]
47pub struct HooksLocation {
48 /// Absolute common Git directory, whose `hooks` directory is the default.
49 pub common_directory: PathBuf,
50 /// Absolute directory a configured `core.hooksPath` names, resolved the way Git resolves it.
51 pub configured: Option<PathBuf>,
52}
53
54/// What:
55/// The directories to scan, each once.
56/// Why:
57/// The default directory comes first, then the configured one unless it is the same directory.
58///
59/// In TS you'd write (pseudocode):
60/// ```ts
61/// unique([join(commonDirectory, 'hooks'), configured].filter(Boolean));
62/// ```
63pub fn hook_directories(location: &HooksLocation) -> Vec<PathBuf> {
64 let mut directories: Vec<PathBuf> = vec![normalized_path(location.common_directory.join("hooks").as_path())];
65 if let Some(configured) = &location.configured {
66 let normalized: PathBuf = normalized_path(configured.as_path());
67 if !directories.contains(&normalized) {
68 directories.push(normalized);
69 }
70 }
71 return directories;
72}
73
74/// What:
75/// Whether a file mode makes the file runnable by someone.
76/// Why:
77/// 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.
78///
79/// In TS you'd write (pseudocode):
80/// ```ts
81/// (stats.mode & 0o111) !== 0
82/// ```
83#[cfg(unix)]
84fn is_executable(metadata: &std::fs::Metadata) -> bool {
85 use std::os::unix::fs::PermissionsExt;
86 return metadata.permissions().mode() & 0o111 != 0;
87}
88
89/// What:
90/// Whether a file is runnable.
91/// Why:
92/// Without Unix permission bits every regular file counts, because the platform has no mode to test.
93///
94/// In TS you'd write (pseudocode):
95/// ```ts
96/// true
97/// ```
98#[cfg(not(unix))]
99fn is_executable(_metadata: &std::fs::Metadata) -> bool {
100 return true;
101}
102
103/// What:
104/// The traditional hooks in one directory: executable regular files and symbolic links, except `*.sample`.
105/// Why:
106/// A missing directory holds none; any other failure to read it is reported, because silence would pass a
107/// directory the command could not look at.
108/// A link counts whatever it points at (a dangling link too), because Git follows it when it starts the hook.
109///
110/// In TS you'd write (pseudocode):
111/// ```ts
112/// (await readdir(directory)).filter((name) => !name.endsWith('.sample') && (isSymlink(name) || isExecutableFile(name)));
113/// ```
114pub fn scan_hooks_directory(directory: &Path) -> std::io::Result<Vec<PathBuf>> {
115 let entries = match std::fs::read_dir(directory) {
116 Ok(found) => found,
117 Err(error) if error.kind() == ErrorKind::NotFound || error.kind() == ErrorKind::NotADirectory => {
118 return Ok(Vec::new());
119 }
120 Err(error) => return Err(error),
121 };
122 let mut hooks: Vec<PathBuf> = Vec::new();
123 for entry in entries {
124 let found = entry?;
125 if found.file_name().as_encoded_bytes().ends_with(SAMPLE_SUFFIX) {
126 continue;
127 }
128 let metadata: std::fs::Metadata = std::fs::symlink_metadata(found.path())?;
129 let kind: std::fs::FileType = metadata.file_type();
130 if kind.is_symlink() || (kind.is_file() && is_executable(&metadata)) {
131 hooks.push(found.path());
132 }
133 }
134 hooks.sort();
135 return Ok(hooks);
136}
137
138/// What:
139/// The traditional hooks in every directory, in directory order and then name order.
140/// Why:
141/// The report lists them as a person reads them: the default directory first.
142///
143/// In TS you'd write (pseudocode):
144/// ```ts
145/// (await Promise.all(directories.map(scanHooksDirectory))).flat();
146/// ```
147pub fn find_traditional_hooks(directories: &[PathBuf]) -> std::io::Result<Vec<PathBuf>> {
148 let mut hooks: Vec<PathBuf> = Vec::new();
149 for directory in directories {
150 hooks.extend(scan_hooks_directory(directory.as_path())?);
151 }
152 return Ok(hooks);
153}
154
155/// What:
156/// The report: one hook path per line.
157/// Why:
158/// The output is for people and scripts alike, so it carries nothing but the paths.
159///
160/// In TS you'd write (pseudocode):
161/// ```ts
162/// hooks.map((hook) => `${hook}\n`).join('')
163/// ```
164pub fn render_hooks(hooks: &[PathBuf]) -> String {
165 let mut text: String = String::new();
166 for hook in hooks {
167 text.push_str(hook.display().to_string().as_str());
168 text.push('\n');
169 }
170 return text;
171}
172
173/// What:
174/// The explanation printed beside a non-empty report.
175/// Why:
176/// The paths alone do not say why they are listed or what to do with them.
177///
178/// In TS you'd write (pseudocode):
179/// ```ts
180/// const explanation: string;
181/// ```
182const FOUND_EXPLANATION: &str = "git cli-git doctor: the traditional hooks listed on standard output are unsupported, \
183and their behavior with cli-git is undefined. Move each into a config hook (`hook.<name>.event` with \
184`hook.<name>.command`) and delete the script.\n";
185
186/// What:
187/// Run one Git query in `cwd` and return its standard output as text.
188/// Why:
189/// Locating the repository takes three small questions, and each failure is reported with Git's own words.
190///
191/// In TS you'd write (pseudocode):
192/// ```ts
193/// const { stdout } = await git(args, { cwd });
194/// ```
195fn query(context: &GitContext, cwd: &Path, arguments: &[&str]) -> Result<Option<String>, String> {
196 let request: GitRequest = GitRequest::new(cwd, arguments);
197 let output = run_git(context, &request).map_err(|error| {
198 return format!("git {} could not start: {error}", arguments.join(" "));
199 })?;
200 if output.code == Some(1) && arguments.first() == Some(&"config") {
201 return Ok(None);
202 }
203 if !output.succeeded() {
204 return Err(format!("git {} failed: {}", arguments.join(" "), output.error_text()));
205 }
206 let text: String = String::from_utf8(output.stdout)
207 .map_err(|_| { return format!("git {} printed bytes that are not UTF-8", arguments.join(" ")); })?;
208 return Ok(Some(text));
209}
210
211/// What:
212/// Locate the hooks of the repository that Git selects from `cwd` and the caller's global options.
213/// Why:
214/// Git alone knows the common directory (worktrees, `--git-dir`, `GIT_DIR`) and how `core.hooksPath` expands.
215/// A relative `core.hooksPath` is relative to where Git runs hooks, which is the worktree root, or the Git
216/// directory of a bare repository.
217///
218/// In TS you'd write (pseudocode):
219/// ```ts
220/// const location = await locateHooks({ context, cwd });
221/// ```
222pub fn locate_hooks(context: &GitContext, cwd: &Path) -> Result<HooksLocation, String> {
223 let report: String = query(
224 context,
225 cwd,
226 &["rev-parse", "--path-format=absolute", "--git-common-dir", "--absolute-git-dir"],
227 )?
228 .unwrap_or_default();
229 let mut lines = report.lines();
230 let (Some(common), Some(git_directory)) = (lines.next(), lines.next()) else {
231 return Err(String::from("git rev-parse did not report the common and Git directories"));
232 };
233 let configured_text: Option<String> = query(context, cwd, &["config", "--type=path", "--get", "core.hooksPath"])?
234 .map(|text| { return text.strip_suffix('\n').unwrap_or(text.as_str()).to_string(); })
235 .filter(|text| { return !text.is_empty(); });
236 let Some(value) = configured_text else {
237 return Ok(HooksLocation {
238 common_directory: PathBuf::from(common),
239 configured: None,
240 });
241 };
242 if Path::new(value.as_str()).is_absolute() {
243 return Ok(HooksLocation {
244 common_directory: PathBuf::from(common),
245 configured: Some(PathBuf::from(value)),
246 });
247 }
248 // A relative value resolves where Git runs hooks; a repository without a worktree has no top level.
249 let base: PathBuf = match query(context, cwd, &["rev-parse", "--path-format=absolute", "--show-toplevel"]) {
250 Ok(Some(top)) if !top.trim_end().is_empty() => PathBuf::from(top.trim_end()),
251 Ok(_) | Err(_) => PathBuf::from(git_directory),
252 };
253 return Ok(HooksLocation {
254 common_directory: PathBuf::from(common),
255 configured: Some(base.join(value)),
256 });
257}
258
259/// The four hooks `git lfs install` writes.
260const LFS_SHIM_NAMES: [&str; 4] = ["pre-push", "post-checkout", "post-commit", "post-merge"];
261
262/// The longest shim file the comparison reads, in bytes; a longer file is not a shim.
263const LFS_SHIM_LIMIT: u64 = 1024;
264
265/// The text git-lfs 3.8.0 writes (`lfs/hook.go`, `hookBaseContent` and its `write`, which appends a line feed),
266/// with the hook's own name where `{{Command}}` stands.
267const LFS_SHIM_TEMPLATE: &str = r##"#!/bin/sh
268command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the '{{Command}}' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; }
269git lfs {{Command}} "$@"
270"##;
271
272/// What:
273/// The text git-lfs writes for the hook called `name`.
274/// Why:
275/// Recognition is by these exact bytes, so an edited or foreign file is never taken for a shim.
276///
277/// In TS you'd write (pseudocode):
278/// ```ts
279/// template.replaceAll('{{Command}}', name)
280/// ```
281pub fn lfs_shim_text(name: &str) -> String {
282 return LFS_SHIM_TEMPLATE.replace("{{Command}}", name);
283}
284
285/// What:
286/// Whether a hook file is exactly the git-lfs shim for its own name.
287/// Why:
288/// Only a regular file whose name is one of the four hooks and whose bytes equal that hook's shim counts; a link,
289/// a file that differs by one byte, and a shim under another hook's name are ordinary unsupported hooks.
290///
291/// In TS you'd write (pseudocode):
292/// ```ts
293/// isFile(path) && readFileSync(path).equals(lfsShimText(basename(path)))
294/// ```
295pub fn is_lfs_shim(path: &Path) -> bool {
296 use std::io::Read;
297 let Some(name) = path.file_name().and_then(|found| { return found.to_str(); }) else {
298 return false;
299 };
300 if !LFS_SHIM_NAMES.contains(&name) {
301 return false;
302 }
303 let Ok(metadata) = std::fs::symlink_metadata(path) else {
304 return false;
305 };
306 if !metadata.file_type().is_file() {
307 return false;
308 }
309 let Ok(file) = std::fs::File::open(path) else {
310 return false;
311 };
312 let mut content: Vec<u8> = Vec::new();
313 if file.take(LFS_SHIM_LIMIT).read_to_end(&mut content).is_err() {
314 return false;
315 }
316 return content == lfs_shim_text(name).as_bytes();
317}
318
319/// What:
320/// The explanation of the Git LFS shims a repository holds.
321/// Why:
322/// They are listed apart from the unsupported hooks and say what is going on, because the person who finds them
323/// after deleting them needs to know they come back, that this is normal, and that nothing needs doing.
324///
325/// In TS you'd write (pseudocode):
326/// ```ts
327/// const explanation = (shims: string[]) => '...';
328/// ```
329pub fn render_lfs_shims(shims: &[PathBuf]) -> String {
330 let mut text: String = String::from(
331 "git cli-git doctor: the hook files below are the Git LFS shims, byte for byte, and nothing needs doing:\n",
332 );
333 for shim in shims {
334 text.push_str(" ");
335 text.push_str(shim.display().to_string().as_str());
336 text.push('\n');
337 }
338 text.push_str(
339 "git-lfs writes these files again whenever it cleans a file in a Git that cli-git did not start (for example \
340`git add` of an LFS file run as `/usr/bin/git` directly), so they can come back after you delete them; this is \
341normal. cli-git uploads the LFS objects itself before every push and starts Git with traditional hooks off, so these \
342files never run through cli-git. You may delete them or leave them. Only a script that starts Git without cli-git, \
343such as `/usr/bin/git` directly, would run them.\n",
344 );
345 return text;
346}
347
348/// What:
349/// Decide what `git cli-git doctor` prints and exits with.
350/// Why:
351/// Exit 0 with no output means no traditional hook was found, exit 1 lists each one on standard output, and exit 2
352/// reports a repository or a directory that could not be examined.
353/// Git LFS shims are explained on standard error apart from the other hooks and leave an exit status of 0 as it is;
354/// any other hook beside them still ends in 1 with the unsupported report.
355///
356/// In TS you'd write (pseudocode):
357/// ```ts
358/// async function doctor({ context, cwd }): Promise<Action>;
359/// ```
360pub fn doctor_action(context: &GitContext, cwd: &Path) -> Action {
361 let location: HooksLocation = match locate_hooks(context, cwd) {
362 Ok(found) => found,
363 Err(message) => return doctor_failure(message.as_str()),
364 };
365 let found: Vec<PathBuf> = match find_traditional_hooks(hook_directories(&location).as_slice()) {
366 Ok(listed) => listed,
367 Err(error) => return doctor_failure(format!("cannot read a hooks directory: {error}").as_str()),
368 };
369 let (shims, hooks): (Vec<PathBuf>, Vec<PathBuf>) =
370 found.into_iter().partition(|path| { return is_lfs_shim(path.as_path()); });
371 let shim_report: String = if shims.is_empty() { String::new() } else { render_lfs_shims(shims.as_slice()) };
372 if hooks.is_empty() {
373 return Action::Exit {
374 code: 0,
375 stdout: String::new(),
376 stderr: shim_report,
377 };
378 }
379 return Action::Exit {
380 code: HOOKS_FOUND_EXIT_CODE,
381 stdout: render_hooks(hooks.as_slice()),
382 stderr: format!("{FOUND_EXPLANATION}{shim_report}"),
383 };
384}
385
386/// What:
387/// The action for a doctor run that could not examine the repository.
388/// Why:
389/// Status 2 is the wrapper's status for a failure that is not a finding.
390///
391/// In TS you'd write (pseudocode):
392/// ```ts
393/// { code: 2, stdout: '', stderr: `git cli-git doctor: ${message}\n` }
394/// ```
395fn doctor_failure(message: &str) -> Action {
396 return Action::Exit {
397 code: ENGINE_FAILURE_EXIT_CODE,
398 stdout: String::new(),
399 stderr: format!("git cli-git doctor: {message}\n"),
400 };
401}
402
403/// Doctor controls stay out of the release executable.
404#[cfg(test)]
405#[path = "doctor_tests.rs"]
406mod tests;