use crate::action::{Action, ENGINE_FAILURE_EXIT_CODE};
use crate::discarded_prune::doctor_section;
use crate::doctor_config::{ConfigFindings, config_findings, render_hook_lines, render_notes};
use crate::doctor_fsmonitor::fsmonitor_report;
use crate::lexical_path::normalized_path;
use crate::transaction_git::{GitContext, GitRequest, run_git};
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
pub const HOOKS_FOUND_EXIT_CODE: i32 = 1;
const SAMPLE_SUFFIX: &[u8] = b".sample";
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct HooksLocation {
pub common_directory: PathBuf,
pub configured: Option<PathBuf>,
}
pub fn hook_directories(location: &HooksLocation) -> Vec<PathBuf> {
let mut directories: Vec<PathBuf> = vec![normalized_path(location.common_directory.join("hooks").as_path())];
if let Some(configured) = &location.configured {
let normalized: PathBuf = normalized_path(configured.as_path());
if !directories.contains(&normalized) {
directories.push(normalized);
}
}
return directories;
}
#[cfg(unix)]
fn is_executable(metadata: &std::fs::Metadata) -> bool {
use std::os::unix::fs::PermissionsExt;
return metadata.permissions().mode() & 0o111 != 0;
}
#[cfg(not(unix))]
fn is_executable(_metadata: &std::fs::Metadata) -> bool {
return true;
}
pub fn scan_hooks_directory(directory: &Path) -> std::io::Result<Vec<PathBuf>> {
let entries = match std::fs::read_dir(directory) {
Ok(found) => found,
Err(error) if error.kind() == ErrorKind::NotFound || error.kind() == ErrorKind::NotADirectory => {
return Ok(Vec::new());
}
Err(error) => return Err(error),
};
let mut hooks: Vec<PathBuf> = Vec::new();
for entry in entries {
let found = entry?;
if found.file_name().as_encoded_bytes().ends_with(SAMPLE_SUFFIX) {
continue;
}
let metadata: std::fs::Metadata = std::fs::symlink_metadata(found.path())?;
let kind: std::fs::FileType = metadata.file_type();
if kind.is_symlink() || (kind.is_file() && is_executable(&metadata)) {
hooks.push(found.path());
}
}
hooks.sort();
return Ok(hooks);
}
pub fn find_traditional_hooks(directories: &[PathBuf]) -> std::io::Result<Vec<PathBuf>> {
let mut hooks: Vec<PathBuf> = Vec::new();
for directory in directories {
hooks.extend(scan_hooks_directory(directory.as_path())?);
}
return Ok(hooks);
}
pub fn render_hooks(hooks: &[PathBuf]) -> String {
let mut text: String = String::new();
for hook in hooks {
text.push_str(hook.display().to_string().as_str());
text.push('\n');
}
return text;
}
const FOUND_EXPLANATION: &str = "git cli-git doctor: the traditional hooks listed on standard output never run through \
cli-git, which supports no external hook program and runs no program named by any configuration. Delete each script, \
or express what it checked as a policy under policies in cli-git.config.jsonc.\n";
fn query(context: &GitContext, cwd: &Path, arguments: &[&str]) -> Result<Option<String>, String> {
let request: GitRequest = GitRequest::new(cwd, arguments);
let output = run_git(context, &request).map_err(|error| {
return format!("git {} could not start: {error}", arguments.join(" "));
})?;
if output.code == Some(1) && arguments.first() == Some(&"config") {
return Ok(None);
}
if !output.succeeded() {
return Err(format!("git {} failed: {}", arguments.join(" "), output.error_text()));
}
let text: String = String::from_utf8(output.stdout)
.map_err(|_| { return format!("git {} printed bytes that are not UTF-8", arguments.join(" ")); })?;
return Ok(Some(text));
}
pub fn locate_hooks(context: &GitContext, cwd: &Path) -> Result<HooksLocation, String> {
let report: String = query(
context,
cwd,
&["rev-parse", "--path-format=absolute", "--git-common-dir", "--absolute-git-dir"],
)?
.unwrap_or_default();
let mut lines = report.lines();
let (Some(common), Some(git_directory)) = (lines.next(), lines.next()) else {
return Err(String::from("git rev-parse did not report the common and Git directories"));
};
let configured_text: Option<String> = query(context, cwd, &["config", "--type=path", "--get", "core.hooksPath"])?
.map(|text| { return text.strip_suffix('\n').unwrap_or(text.as_str()).to_string(); })
.filter(|text| { return !text.is_empty(); });
let Some(value) = configured_text else {
return Ok(HooksLocation {
common_directory: PathBuf::from(common),
configured: None,
});
};
if Path::new(value.as_str()).is_absolute() {
return Ok(HooksLocation {
common_directory: PathBuf::from(common),
configured: Some(PathBuf::from(value)),
});
}
let base: PathBuf = match query(context, cwd, &["rev-parse", "--path-format=absolute", "--show-toplevel"]) {
Ok(Some(top)) if !top.trim_end().is_empty() => PathBuf::from(top.trim_end()),
Ok(_) | Err(_) => PathBuf::from(git_directory),
};
return Ok(HooksLocation {
common_directory: PathBuf::from(common),
configured: Some(base.join(value)),
});
}
const LFS_SHIM_NAMES: [&str; 4] = ["pre-push", "post-checkout", "post-commit", "post-merge"];
const LFS_SHIM_LIMIT: u64 = 1024;
const LFS_SHIM_TEMPLATE: &str = r##"#!/bin/sh
command -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; }
git lfs {{Command}} "$@"
"##;
pub fn lfs_shim_text(name: &str) -> String {
return LFS_SHIM_TEMPLATE.replace("{{Command}}", name);
}
pub fn is_lfs_shim(path: &Path) -> bool {
use std::io::Read;
let Some(name) = path.file_name().and_then(|found| { return found.to_str(); }) else {
return false;
};
if !LFS_SHIM_NAMES.contains(&name) {
return false;
}
let Ok(metadata) = std::fs::symlink_metadata(path) else {
return false;
};
if !metadata.file_type().is_file() {
return false;
}
let Ok(file) = std::fs::File::open(path) else {
return false;
};
let mut content: Vec<u8> = Vec::new();
if file.take(LFS_SHIM_LIMIT).read_to_end(&mut content).is_err() {
return false;
}
return content == lfs_shim_text(name).as_bytes();
}
pub fn render_lfs_shims(shims: &[PathBuf]) -> String {
let mut text: String = String::from(
"git cli-git doctor: the hook files below are the Git LFS shims, byte for byte, and nothing needs doing:\n",
);
for shim in shims {
text.push_str(" ");
text.push_str(shim.display().to_string().as_str());
text.push('\n');
}
text.push_str(
"git-lfs writes these files again whenever it cleans a file in a Git that cli-git did not start (for example \
`git add` of an LFS file run as `/usr/bin/git` directly), so they can come back after you delete them; this is \
normal. cli-git uploads the LFS objects itself before every push and starts Git with traditional hooks off, so these \
files never run through cli-git. You may delete them or leave them. Only a script that starts Git without cli-git, \
such as `/usr/bin/git` directly, would run them.\n",
);
return text;
}
pub fn doctor_action(context: &GitContext, cwd: &Path) -> Action {
return doctor_action_with_section(context, cwd, &|_common_directory: &Path| { return String::new(); });
}
pub fn doctor_action_with_section(context: &GitContext, cwd: &Path, filesystem_section: &dyn Fn(&Path) -> String) -> Action {
let location: HooksLocation = match locate_hooks(context, cwd) {
Ok(found) => found,
Err(message) => return doctor_failure(message.as_str()),
};
let found: Vec<PathBuf> = match find_traditional_hooks(hook_directories(&location).as_slice()) {
Ok(listed) => listed,
Err(error) => return doctor_failure(format!("cannot read a hooks directory: {error}").as_str()),
};
let (shims, hooks): (Vec<PathBuf>, Vec<PathBuf>) =
found.into_iter().partition(|path| { return is_lfs_shim(path.as_path()); });
let shim_report: String = if shims.is_empty() { String::new() } else { render_lfs_shims(shims.as_slice()) };
let findings: ConfigFindings = config_findings(context, cwd);
let notes: String = format!("{}{}", render_notes(&findings), fsmonitor_report(context, cwd));
let discarded: String = doctor_section(location.common_directory.as_path(), findings.discarded);
let section: String = filesystem_section(location.common_directory.as_path());
if hooks.is_empty() && findings.hooks.is_empty() {
return Action::Exit {
code: 0,
stdout: String::new(),
stderr: format!("{shim_report}{notes}{discarded}{section}"),
};
}
let explanation: &str = if hooks.is_empty() { "" } else { FOUND_EXPLANATION };
return Action::Exit {
code: HOOKS_FOUND_EXIT_CODE,
stdout: format!("{}{}", render_hooks(hooks.as_slice()), render_hook_lines(&findings)),
stderr: format!("{explanation}{shim_report}{notes}{discarded}{section}"),
};
}
fn doctor_failure(message: &str) -> Action {
return Action::Exit {
code: ENGINE_FAILURE_EXIT_CODE,
stdout: String::new(),
stderr: format!("git cli-git doctor: {message}\n"),
};
}
#[cfg(test)]
#[path = "doctor_tests.rs"]
mod tests;