Skip to main content

git_policy_cli/
doctor.rs

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