use crate::config_snapshot::ConfigSnapshot;
use std::ffi::{OsStr, OsString};
use std::path::Path;
pub const SWITCH_NAME: &str = "cli-git-lfs-pre-push";
pub const LFS_PROGRAM: &str = "git-lfs";
const LFS_KEY_PREFIXES: [&str; 2] = ["filter.lfs.", "lfs."];
const MACHINE_SCOPES: [&str; 2] = ["system", "global"];
pub fn uses_lfs(snapshot: &ConfigSnapshot) -> bool {
for entry in snapshot.entries() {
if MACHINE_SCOPES.contains(&entry.scope.as_str()) {
continue;
}
for prefix in LFS_KEY_PREFIXES {
if entry.key.starts_with(prefix) {
return true;
}
}
}
return false;
}
pub fn environment_config_count(environment: &[(OsString, OsString)]) -> usize {
let Some(value) = crate::child_environment::environment_value(environment, "GIT_CONFIG_COUNT") else {
return 0;
};
let Some(text) = value.to_str() else {
return 0;
};
return text.parse::<usize>().unwrap_or(0);
}
fn numbered_config_variable(environment: &[(OsString, OsString)], kind: &str, index: usize) -> Option<OsString> {
return crate::child_environment::environment_value(environment, &format!("GIT_CONFIG_{kind}_{index}"));
}
fn is_switch_key(name: &str) -> bool {
const VARIABLE: &str = ".enabled";
let Some(section) = name.get(..5) else {
return false;
};
if !section.eq_ignore_ascii_case("hook.") {
return false;
}
let rest: &str = &name[5..];
if rest.len() <= VARIABLE.len() {
return false;
}
if !rest.is_char_boundary(rest.len() - VARIABLE.len()) {
return false;
}
let (subsection, variable) = rest.split_at(rest.len() - VARIABLE.len());
return variable.eq_ignore_ascii_case(VARIABLE) && subsection == SWITCH_NAME;
}
fn reads_as_false(value: &str) -> bool {
for spelling in ["false", "no", "off", "0", ""] {
if value.eq_ignore_ascii_case(spelling) {
return true;
}
}
return false;
}
pub fn hook_switched_off(global_prefix: &[OsString], environment: &[(OsString, OsString)]) -> bool {
let mut off: bool = false;
let count: usize = environment_config_count(environment);
for index in 0..count {
let key_is_switch: bool = match numbered_config_variable(environment, "KEY", index) {
Some(name) => is_switch_key(&name.to_string_lossy()),
None => false,
};
if key_is_switch {
off = match numbered_config_variable(environment, "VALUE", index) {
Some(value) => reads_as_false(&value.to_string_lossy()),
None => false,
};
}
}
let mut tokens = global_prefix.iter();
while let Some(token) = tokens.next() {
if token.as_os_str() != "-c" {
continue;
}
let Some(setting_token) = tokens.next() else {
continue;
};
let Some(setting) = setting_token.to_str() else {
continue;
};
let (name, value): (&str, Option<&str>) = match setting.split_once('=') {
Some((named, valued)) => (named, Some(valued)),
None => (setting, None),
};
if is_switch_key(name) {
off = value.is_some_and(reads_as_false);
}
}
return off;
}
pub fn find_program(path: &OsStr) -> Option<std::path::PathBuf> {
use std::os::unix::fs::PermissionsExt;
for directory in std::env::split_paths(path) {
let base: &Path = if directory.as_os_str().is_empty() { Path::new(".") } else { directory.as_path() };
let candidate: std::path::PathBuf = base.join(LFS_PROGRAM);
let is_executable_file: bool = match std::fs::metadata(&candidate) {
Ok(found) => found.is_file() && found.permissions().mode() & 0o111 != 0,
Err(_) => false,
};
if is_executable_file {
return std::path::absolute(candidate).ok();
}
}
return None;
}
pub fn program_on_path(path: &OsStr) -> bool {
return find_program(path).is_some();
}
pub fn skip_requested(environment: &[(std::ffi::OsString, std::ffi::OsString)]) -> bool {
return match crate::child_environment::environment_value(environment, "GIT_LFS_SKIP_PUSH") {
Some(value) => {
let text: String = value.to_string_lossy().to_ascii_lowercase();
["1", "t", "true", "y", "yes", "on"].contains(&text.as_str())
}
None => false,
};
}
pub fn missing_program_message() -> String {
return format!(
"monochromatic-git-policy-cli: this push was stopped before it sent anything. The Git configuration of this repository has Git LFS \
keys (`filter.lfs.*` or `lfs.*`), so the wrapper runs `{LFS_PROGRAM} push` to upload the large \
files the pushed commits point to before it publishes the pointer files, but `{LFS_PROGRAM}` was not found in \
any directory of PATH. Pushing without that upload would publish pointers whose content the LFS server does \
not have. Ways forward: install Git LFS so that `{LFS_PROGRAM}` is on PATH and push again; or, if this \
repository does not use Git LFS, remove its configuration (`git lfs uninstall`, or delete the `filter.lfs.*` \
and `lfs.*` keys); or accept pointers without objects for this push with `git push --no-verify` and upload \
the objects later with `git lfs push --all <remote>` once Git LFS is installed.\n"
);
}
pub fn plan_failed_message(why: &str) -> String {
return format!(
"monochromatic-git-policy-cli: this push was stopped before it sent anything. The Git configuration of this repository has Git LFS \
keys, so the wrapper uploads the large files the pushed commits point to before it publishes the pointer \
files, but it could not work out which refs this push sends ({why}). Pushing without that upload could \
publish pointers whose content the LFS server does not have. Ways forward: push again, because the cause may \
be transient; or upload by hand with `git lfs push --all <remote>` and push with `git push --no-verify`.\n"
);
}
pub fn failed_message(status: &str) -> String {
return format!(
"monochromatic-git-policy-cli: `{LFS_PROGRAM} push` {status}, so this push was not started and nothing was published. \
The lines above come from `{LFS_PROGRAM}`; they usually name the cause: the LFS server \
(`lfs.url`) is unreachable or refused the credentials, or a large file the pushed commits point to is not \
in this clone's `.git/lfs`. Ways forward: fix that cause and push again, because objects the server already \
has are not sent twice; upload by hand with `git lfs push --all <remote>` and push again; or publish the \
pointer files without their objects, which nobody can download until the objects are uploaded, with \
`git push --no-verify` (or `GIT_LFS_SKIP_PUSH=1`).\n"
);
}
#[cfg(test)]
#[path = "lfs_pre_push_tests.rs"]
mod tests;