use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::process::Command;
use git_lfs_git::{ConfigScope, config, git_dir};
#[derive(Debug, Clone)]
pub enum InstallScope {
Global,
System,
Local,
Worktree,
File(PathBuf),
}
impl InstallScope {
fn config_arg(&self) -> String {
match self {
Self::Global => "--global".into(),
Self::System => "--system".into(),
Self::Local => "--local".into(),
Self::Worktree => "--worktree".into(),
Self::File(p) => format!("--file={}", p.display()),
}
}
pub fn is_repo_scope(&self) -> bool {
matches!(self, Self::Local | Self::Worktree)
}
pub fn announces_global(&self) -> bool {
matches!(self, Self::Global | Self::System | Self::File(_))
}
}
const FILTER_KEYS: &[(&str, &str)] = &[
("filter.lfs.clean", "git-lfs clean -- %f"),
("filter.lfs.smudge", "git-lfs smudge -- %f"),
("filter.lfs.process", "git-lfs filter-process"),
("filter.lfs.required", "true"),
];
const FILTER_KEYS_SKIP_SMUDGE: &[(&str, &str)] = &[
("filter.lfs.clean", "git-lfs clean -- %f"),
("filter.lfs.smudge", "git-lfs smudge --skip -- %f"),
("filter.lfs.process", "git-lfs filter-process --skip"),
("filter.lfs.required", "true"),
];
fn upgradeables_for(key: &str, skip_smudge: bool) -> &'static [&'static str] {
match (key, skip_smudge) {
("filter.lfs.clean", false) => &["git-lfs clean %f"],
("filter.lfs.clean", true) => &["git-lfs clean %f"],
("filter.lfs.smudge", false) => &[
"git-lfs smudge %f",
"git-lfs smudge --skip %f",
"git-lfs smudge --skip -- %f",
],
("filter.lfs.smudge", true) => &[
"git-lfs smudge %f",
"git-lfs smudge --skip %f",
"git-lfs smudge -- %f",
],
("filter.lfs.process", false) => &[
"git-lfs filter",
"git-lfs filter --skip",
"git-lfs filter-process --skip",
],
("filter.lfs.process", true) => &[
"git-lfs filter",
"git-lfs filter --skip",
"git-lfs filter-process",
],
_ => &[],
}
}
const HOOKS: &[&str] = &["pre-push", "post-checkout", "post-commit", "post-merge"];
const HOOK_TEMPLATE: &str = "\
#!/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}} \"$@\"
";
#[derive(Debug, Clone)]
pub struct InstallOptions {
pub scope: InstallScope,
pub force: bool,
pub skip_repo: bool,
pub skip_smudge: bool,
}
#[derive(Debug, thiserror::Error)]
pub enum InstallError {
#[error(transparent)]
Git(#[from] git_lfs_git::Error),
#[error(transparent)]
Io(#[from] io::Error),
#[error("the {key:?} attribute should be {wanted:?} but is {existing:?}")]
FilterAttribute {
key: String,
existing: String,
wanted: String,
},
#[error("hook {hook:?} already exists with different contents")]
HookConflict { hook: String, existing: String },
#[error("error running 'git {}': {stderr}", args.join(" "))]
ConfigCommandFailed { args: Vec<String>, stderr: String },
}
pub fn install(cwd: &Path, opts: &InstallOptions) -> Result<(), InstallError> {
set_filter_config(cwd, opts)?;
let in_repo = git_dir(cwd).is_ok();
let install_hooks_too = !opts.skip_repo && (opts.scope.is_repo_scope() || in_repo);
if install_hooks_too {
install_all_hooks(cwd, opts)?;
}
Ok(())
}
fn set_filter_config(cwd: &Path, opts: &InstallOptions) -> Result<(), InstallError> {
let keys = if opts.skip_smudge {
FILTER_KEYS_SKIP_SMUDGE
} else {
FILTER_KEYS
};
for (key, wanted) in keys {
let current = scoped_get(cwd, &opts.scope, key)?;
let needs_set = match current.as_deref() {
None | Some("") => true,
Some(v) if v == *wanted => false,
Some(v) if opts.force => {
let _ = v;
true
}
Some(v) => {
let upgradeables = upgradeables_for(key, opts.skip_smudge);
if upgradeables.contains(&v) {
true
} else {
return Err(InstallError::FilterAttribute {
key: (*key).into(),
existing: v.to_owned(),
wanted: (*wanted).into(),
});
}
}
};
if needs_set {
scoped_set(cwd, &opts.scope, key, wanted)?;
}
}
Ok(())
}
fn scoped_get(
cwd: &Path,
scope: &InstallScope,
key: &str,
) -> Result<Option<String>, git_lfs_git::Error> {
let out = Command::new("git")
.arg("-C")
.arg(cwd)
.args(["config", "--includes"])
.arg(scope.config_arg())
.args(["--get", key])
.output()?;
match out.status.code() {
Some(0) => Ok(Some(String::from_utf8_lossy(&out.stdout).trim().to_owned())),
Some(1) | Some(2) | Some(128) | Some(129) => Ok(None),
_ => Err(git_lfs_git::Error::Failed(
String::from_utf8_lossy(&out.stderr).trim().to_owned(),
)),
}
}
fn scoped_set(
cwd: &Path,
scope: &InstallScope,
key: &str,
value: &str,
) -> Result<(), InstallError> {
let scope_arg = scope.config_arg();
let args = vec![
"config".into(),
scope_arg.clone(),
"--replace-all".into(),
key.into(),
value.into(),
];
let out = Command::new("git")
.arg("-C")
.arg(cwd)
.arg("config")
.arg(&scope_arg)
.args(["--replace-all", key, value])
.output()?;
if out.status.success() {
Ok(())
} else {
Err(InstallError::ConfigCommandFailed {
args,
stderr: String::from_utf8_lossy(&out.stderr).trim().to_owned(),
})
}
}
fn scoped_unset(cwd: &Path, scope: &InstallScope, key: &str) -> Result<(), InstallError> {
let scope_arg = scope.config_arg();
let args = vec![
"config".into(),
scope_arg.clone(),
"--unset".into(),
key.into(),
];
let out = Command::new("git")
.arg("-C")
.arg(cwd)
.arg("config")
.arg(&scope_arg)
.args(["--unset", key])
.output()?;
match out.status.code() {
Some(0) | Some(5) => Ok(()),
_ => Err(InstallError::ConfigCommandFailed {
args,
stderr: String::from_utf8_lossy(&out.stderr).trim().to_owned(),
}),
}
}
pub(crate) fn install_all_hooks(cwd: &Path, opts: &InstallOptions) -> Result<(), InstallError> {
let hooks_dir = effective_hooks_dir(cwd)?;
fs::create_dir_all(&hooks_dir)?;
if !opts.force {
for hook in HOOKS {
let path = hooks_dir.join(hook);
if let HookStatus::Conflict { existing } = classify_hook(&path, hook)? {
return Err(InstallError::HookConflict {
hook: (*hook).into(),
existing,
});
}
}
}
for hook in HOOKS {
install_one_hook(&hooks_dir, hook, opts)?;
}
Ok(())
}
pub fn print_hook_conflict(hook: &str, existing: &str) {
eprintln!("Hook already exists: {hook}");
eprintln!();
for line in existing.lines() {
eprintln!("\t{line}");
}
eprintln!();
eprintln!("To resolve this, either:");
eprintln!(" 1: run `git lfs update --manual` for instructions on how to merge hooks.");
eprintln!(" 2: run `git lfs update --force` to overwrite your hook.");
}
pub fn effective_hooks_dir(cwd: &Path) -> Result<std::path::PathBuf, InstallError> {
let git_dir = git_dir(cwd)?;
if let Ok(Some(hookspath)) = config::get(cwd, ConfigScope::Local, "core.hookspath")
&& !hookspath.is_empty()
{
let expanded = expand_home(&hookspath);
let hp = Path::new(&expanded);
if hp.is_absolute() {
return Ok(hp.to_path_buf());
}
let base = git_dir.parent().unwrap_or(&git_dir);
return Ok(base.join(hp));
}
Ok(git_dir.join("hooks"))
}
fn expand_home(raw: &str) -> String {
if raw == "~"
&& let Some(home) = std::env::var_os("HOME")
{
return home.to_string_lossy().into_owned();
}
if let Some(rest) = raw.strip_prefix("~/")
&& let Some(home) = std::env::var_os("HOME")
{
return Path::new(&home).join(rest).to_string_lossy().into_owned();
}
raw.to_owned()
}
pub fn try_install_hooks(cwd: &Path) -> Result<(), InstallError> {
let hooks_dir = git_dir(cwd)?.join("hooks");
fs::create_dir_all(&hooks_dir)?;
for hook in HOOKS {
let path = hooks_dir.join(hook);
let wanted = HOOK_TEMPLATE.replace("{{Command}}", hook);
match classify_hook(&path, hook)? {
HookStatus::Missing | HookStatus::Current | HookStatus::Legacy => {
write_hook(&path, &wanted)?;
}
HookStatus::Conflict { .. } => {
}
}
}
Ok(())
}
#[derive(Debug)]
pub enum HookStatus {
Missing,
Current,
Legacy,
Conflict { existing: String },
}
pub fn classify_hook(hook_path: &Path, hook: &str) -> io::Result<HookStatus> {
let existing = match fs::read_to_string(hook_path) {
Ok(s) => s,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(HookStatus::Missing),
Err(e) => return Err(e),
};
if existing.trim().is_empty() {
return Ok(HookStatus::Missing);
}
let wanted = HOOK_TEMPLATE.replace("{{Command}}", hook);
if existing.trim() == wanted.trim() {
return Ok(HookStatus::Current);
}
let normalized = strip_leading_indent(&existing);
if legacy_templates(hook)
.iter()
.any(|t| normalized.trim() == t.trim())
{
return Ok(HookStatus::Legacy);
}
Ok(HookStatus::Conflict { existing })
}
fn strip_leading_indent(s: &str) -> String {
s.lines()
.map(|l| l.trim_start_matches(['\t', ' ']))
.collect::<Vec<_>>()
.join("\n")
}
fn legacy_templates(hook: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
if hook == "pre-push" {
out.push("#!/bin/sh\ngit lfs push --stdin $*".into());
out.push("#!/bin/sh\ngit lfs push --stdin \"$@\"".into());
}
out.push(format!("#!/bin/sh\ngit lfs {hook} \"$@\""));
out.push(format!(
"#!/bin/sh\n\
command -v git-lfs >/dev/null 2>&1 || \
{{ echo >&2 \"\\nThis repository has been set up with Git LFS but Git LFS is not installed.\\n\"; exit 0; }}\n\
git lfs {hook} \"$@\""
));
out.push(format!(
"#!/bin/sh\n\
command -v git-lfs >/dev/null 2>&1 || \
{{ echo >&2 \"\\nThis repository has been set up with Git LFS but Git LFS is not installed.\\n\"; exit 2; }}\n\
git lfs {hook} \"$@\""
));
out.push(format!(
"#!/bin/sh\n\
command -v git-lfs >/dev/null 2>&1 || \
{{ echo >&2 \"\\nThis 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 '.git/hooks/{hook}'.\\n\"; exit 2; }}\n\
git lfs {hook} \"$@\""
));
out.push(format!(
"#!/bin/sh\n\
command -v git-lfs >/dev/null 2>&1 || \
{{ echo >&2 \"\\nThis 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 '{hook}' file in the hooks directory \
(set by 'core.hookspath'; usually '.git/hooks').\\n\"; exit 2; }}\n\
git lfs {hook} \"$@\""
));
out
}
pub fn current_template(hook: &str) -> String {
HOOK_TEMPLATE.replace("{{Command}}", hook)
}
fn install_one_hook(
hooks_dir: &Path,
hook: &str,
opts: &InstallOptions,
) -> Result<(), InstallError> {
let path = hooks_dir.join(hook);
let wanted = HOOK_TEMPLATE.replace("{{Command}}", hook);
match classify_hook(&path, hook)? {
HookStatus::Current => Ok(()),
HookStatus::Missing | HookStatus::Legacy => {
write_hook(&path, &wanted)?;
Ok(())
}
HookStatus::Conflict { existing } if opts.force => {
let _ = existing;
write_hook(&path, &wanted)?;
Ok(())
}
HookStatus::Conflict { existing } => Err(InstallError::HookConflict {
hook: hook.into(),
existing,
}),
}
}
fn write_hook(path: &Path, content: &str) -> io::Result<()> {
fs::write(path, content)?;
set_executable(path)
}
#[cfg(unix)]
fn set_executable(path: &Path) -> io::Result<()> {
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(path)?.permissions();
perms.set_mode(0o755);
fs::set_permissions(path, perms)
}
#[cfg(not(unix))]
fn set_executable(_path: &Path) -> io::Result<()> {
Ok(())
}
#[derive(Debug, Clone)]
pub struct UninstallOptions {
pub scope: InstallScope,
pub skip_repo: bool,
pub hooks_only: bool,
}
pub fn uninstall(cwd: &Path, opts: &UninstallOptions) -> Result<(), InstallError> {
if !opts.hooks_only {
unset_filter_config(cwd, opts)?;
}
let in_repo = git_dir(cwd).is_ok();
let touch_hooks =
opts.hooks_only || (!opts.skip_repo && (opts.scope.is_repo_scope() || in_repo));
if touch_hooks {
uninstall_all_hooks(cwd)?;
}
Ok(())
}
fn unset_filter_config(cwd: &Path, opts: &UninstallOptions) -> Result<(), InstallError> {
for (key, _) in FILTER_KEYS {
scoped_unset(cwd, &opts.scope, key)?;
}
Ok(())
}
fn uninstall_all_hooks(cwd: &Path) -> Result<(), InstallError> {
let hooks_dir = effective_hooks_dir(cwd)?;
for hook in HOOKS {
uninstall_one_hook(&hooks_dir, hook)?;
}
Ok(())
}
fn uninstall_one_hook(hooks_dir: &Path, hook: &str) -> Result<(), InstallError> {
let path = hooks_dir.join(hook);
let wanted = HOOK_TEMPLATE.replace("{{Command}}", hook);
match fs::read_to_string(&path) {
Ok(existing) if existing.trim() == wanted.trim() => {
fs::remove_file(&path)?;
Ok(())
}
Ok(_) => {
Ok(())
}
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(InstallError::Io(e)),
}
}