use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("{0}")]
pub struct GitError(pub String);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitHead {
pub branch: String,
pub sha: String,
pub short_sha: String,
pub dirty: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct KnownHook {
pub name: &'static str,
pub arity: usize,
}
pub const KNOWN_HOOKS: &[KnownHook] = &[
KnownHook {
name: "applypatch-msg",
arity: 1,
},
KnownHook {
name: "pre-applypatch",
arity: 0,
},
KnownHook {
name: "post-applypatch",
arity: 0,
},
KnownHook {
name: "pre-commit",
arity: 0,
},
KnownHook {
name: "pre-merge-commit",
arity: 0,
},
KnownHook {
name: "prepare-commit-msg",
arity: 1,
},
KnownHook {
name: "commit-msg",
arity: 1,
},
KnownHook {
name: "post-commit",
arity: 0,
},
KnownHook {
name: "pre-rebase",
arity: 1,
},
KnownHook {
name: "post-checkout",
arity: 3,
},
KnownHook {
name: "post-merge",
arity: 1,
},
KnownHook {
name: "pre-push",
arity: 2,
},
KnownHook {
name: "pre-auto-gc",
arity: 0,
},
KnownHook {
name: "post-rewrite",
arity: 1,
},
KnownHook {
name: "sendemail-validate",
arity: 1,
},
KnownHook {
name: "post-index-change",
arity: 2,
},
];
pub fn known_hook(name: &str) -> Option<&'static KnownHook> {
KNOWN_HOOKS.iter().find(|hook| hook.name == name)
}
fn spawn(cwd: &Path, args: &[&str]) -> Result<std::process::Output, GitError> {
Command::new("git")
.current_dir(cwd)
.args(args)
.output()
.map_err(|error| {
if error.kind() == std::io::ErrorKind::NotFound {
GitError("git not found on PATH".to_string())
} else {
GitError(format!("cannot run git: {error}"))
}
})
}
fn git_output(cwd: &Path, args: &[&str]) -> Result<Vec<u8>, GitError> {
let output = spawn(cwd, args)?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(GitError(format!(
"git {} failed: {}",
args.join(" "),
stderr.trim()
)));
}
Ok(output.stdout)
}
fn git(cwd: &Path, args: &[&str]) -> Result<String, GitError> {
let stdout = git_output(cwd, args)?;
let stdout = String::from_utf8_lossy(&stdout);
Ok(stdout.trim_end_matches(['\n', '\r']).to_string())
}
fn split_nul_terminated(bytes: &[u8]) -> Vec<PathBuf> {
String::from_utf8_lossy(bytes)
.split('\0')
.filter(|entry| !entry.is_empty())
.map(PathBuf::from)
.collect()
}
pub fn repository_root(cwd: &Path) -> Result<PathBuf, GitError> {
let root = git(cwd, &["rev-parse", "--show-toplevel"])?;
PathBuf::from(root)
.canonicalize()
.map_err(|error| GitError(format!("cannot canonicalize repository root: {error}")))
}
pub fn head(cwd: &Path) -> Result<GitHead, GitError> {
let branch = git(cwd, &["rev-parse", "--abbrev-ref", "HEAD"])?;
let sha = git(cwd, &["rev-parse", "HEAD"])?;
let short_sha = git(cwd, &["rev-parse", "--short", "HEAD"])?;
let status = git(cwd, &["status", "--porcelain", "--untracked-files=all"])?;
Ok(GitHead {
branch,
sha,
short_sha,
dirty: !status.is_empty(),
})
}
pub fn changed_files(cwd: &Path, reference: &str) -> Result<Vec<PathBuf>, GitError> {
let diff = git_output(cwd, &["diff", "--name-only", "--relative", "-z", reference])?;
let untracked = git_output(cwd, &["ls-files", "-z", "--others", "--exclude-standard"])?;
let mut paths = split_nul_terminated(&diff);
paths.extend(split_nul_terminated(&untracked));
paths.sort();
paths.dedup();
Ok(paths)
}
pub fn hooks_path(cwd: &Path) -> Result<Option<String>, GitError> {
let args = ["config", "--local", "--get", "core.hooksPath"];
let output = spawn(cwd, &args)?;
match output.status.code() {
Some(0) => Ok(Some(
String::from_utf8_lossy(&output.stdout).trim().to_string(),
)),
Some(1) => Ok(None),
_ => Err(GitError(format!(
"git {} failed: {}",
args.join(" "),
String::from_utf8_lossy(&output.stderr).trim()
))),
}
}
pub fn set_hooks_path(cwd: &Path, value: &str) -> Result<(), GitError> {
git(cwd, &["config", "--local", "core.hooksPath", value]).map(|_| ())
}
pub fn unset_hooks_path(cwd: &Path) -> Result<(), GitError> {
git(cwd, &["config", "--local", "--unset", "core.hooksPath"]).map(|_| ())
}