mod forwarding;
use std::io::Write;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, anyhow};
use crate::diff;
macro_rules! managed_marker {
() => {
"# Managed by `drep init`."
};
}
pub const MANAGED_MARKER: &str = managed_marker!();
pub const PRE_COMMIT_BODY: &str = concat!(
"#!/bin/sh\n",
managed_marker!(),
r##"
# Runs the linters this repo configures, and an LLM review of the staged code.
if ! command -v drep > /dev/null 2>&1; then
echo "drep: not found on PATH; refusing to let the commit through unreviewed." >&2
exit 1
fi
drep lint-docs --staged --fail-on error || exit $?
exec drep check --staged
"##
);
pub const PRE_PUSH_BODY: &str = concat!(
"#!/bin/sh\n",
managed_marker!(),
r##"
# git runs this as: pre-push <remote-name> <remote-url>, and sends one line per
# ref on stdin:
# <local ref> <local oid> <remote ref> <remote oid>
#
# Three things here are not obvious, and each was a real defect:
#
# * The ref being pushed is NOT always the checked-out branch
# (`git push origin feature:feature` from elsewhere, or `git push --all`),
# so `--tip` names the oid actually being pushed. Reviewing HEAD instead
# lets the pushed code through unseen.
# * The base search is BOUNDED. An all-zero remote oid means the branch is
# new upstream; falling back to the root commit there sends the repository's
# entire history to the model, which on a mature repo is hours of wall clock
# and real money from one `git push`.
# * `drep` reads no stdin, but `< /dev/null` makes that structural: a command
# inside a `while read` loop that did would swallow the remaining refs and
# the push would go green having reviewed one of them.
remote="${1:-origin}"
zeros=0000000000000000000000000000000000000000
status=0
if ! command -v drep > /dev/null 2>&1; then
echo "drep: not found on PATH; refusing to let the push through unreviewed." >&2
echo " (GUI git clients often use a minimal PATH - see the drep README.)" >&2
exit 1
fi
while read -r _local_ref local_oid _remote_ref remote_oid; do
# A branch deletion has no content to review.
case "$local_oid" in "$zeros"*) continue ;; esac
case "$remote_oid" in
"$zeros"*)
# New upstream: find the nearest sensible base, cheapest first, and
# never scan further back than 50 commits.
base=$(git rev-parse --verify --quiet "$remote/HEAD") ||
base=$(git rev-parse --verify --quiet "$remote/main") ||
base=$(git rev-parse --verify --quiet "$remote/master") ||
base=$(git rev-parse --verify --quiet "$local_oid~50") ||
base=$(git rev-list --max-parents=0 "$local_oid" | tail -n 1)
;;
*) base=$remote_oid ;;
esac
[ -n "$base" ] || continue
drep check --push-gate --diff "$base" --tip "$local_oid" < /dev/null
rc=$?
# Failure precedence is semantic, not numeric: 2 (could not analyze), then
# 1 (findings), then 3 (review cached; reconnect), then 0. Exit 3 is
# numerically highest but is a successful review, so it must not hide a
# harder failure from another ref.
case "$rc" in
2) status=2 ;;
1) [ "$status" -ne 2 ] && status=1 ;;
3) [ "$status" -eq 0 ] && status=3 ;;
0) ;;
*) status=2 ;;
esac
done
exit $status
"##
);
pub fn chainer_body(name: &str) -> String {
format!(
"\
#!/bin/sh
{MANAGED_MARKER}
# Chains to the repo-local {name} hook, which git ignores while core.hooksPath
# is set. `exec` matters twice: it keeps the local hook's exit status (that is
# what aborts the operation) and hands over stdin unread, which is how git
# delivers the refs being pushed.
LOCAL_HOOK=\"$(git rev-parse --git-common-dir)/hooks/{name}\"
if [ -x \"$LOCAL_HOOK\" ]; then
exec \"$LOCAL_HOOK\" \"$@\"
fi
"
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum HookKind {
PrePush,
PreCommit,
Both,
None,
}
pub fn hook_names(kind: HookKind) -> &'static [&'static str] {
match kind {
HookKind::None => &[],
HookKind::PrePush => &["pre-push"],
HookKind::PreCommit => &["pre-commit"],
HookKind::Both => &["pre-commit", "pre-push"],
}
}
pub fn hook_body(name: &str) -> Option<&'static str> {
match name {
"pre-commit" => Some(PRE_COMMIT_BODY),
"pre-push" => Some(PRE_PUSH_BODY),
_ => None,
}
}
pub fn resolve_hooks_dir(root: &Path, value: &str) -> PathBuf {
let candidate = Path::new(value);
if candidate.is_absolute() {
candidate.to_path_buf()
} else {
root.join(value)
}
}
pub fn is_drep_managed(body: &str) -> bool {
let mut lines = body.lines();
match lines.next() {
Some(first) if first == MANAGED_MARKER => true,
Some(first) if first.starts_with("#!") => lines.next() == Some(MANAGED_MARKER),
_ => false,
}
}
pub async fn install<W: Write>(
out: &mut W,
root: &Path,
kind: HookKind,
force: bool,
) -> Result<()> {
let names = hook_names(kind);
if names.is_empty() {
return Ok(());
}
let hooks_dir = locate_hooks_dir(root).await?;
let configured = run_git_config_path(root).await?;
std::fs::create_dir_all(&hooks_dir)
.with_context(|| format!("could not create {}", hooks_dir.display()))?;
for name in names {
let body =
hook_body(name).ok_or_else(|| anyhow!("no hook body is defined for `{name}`"))?;
let path = hooks_dir.join(name);
match std::fs::read(&path) {
Ok(existing) => {
let existing_text = String::from_utf8_lossy(&existing);
if is_drep_managed(&existing_text) {
write_executable(&path, body)?;
writeln!(out, " Wrote {}", path.display())?;
} else if force {
let backup = path.with_extension("drep-backup");
write_backup(&backup, &existing)?;
write_executable(&path, body)?;
writeln!(out, " Wrote {}", path.display())?;
writeln!(out, " Your previous hook is saved at {}", backup.display())?;
} else {
writeln!(
out,
" {} already exists and was not written by drep; leaving it alone.",
path.display()
)?;
writeln!(out, " Re-run with --force to replace it.")?;
}
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
write_executable(&path, body)?;
writeln!(out, " Wrote {}", path.display())?;
}
Err(err) => {
return Err(anyhow::Error::new(err)
.context(format!("could not read existing hook {}", path.display())));
}
}
}
if let Some(value) = configured {
writeln!(out, " core.hooksPath is set to {value}")?;
writeln!(
out,
" git looks there and not in .git/hooks, so a repo hook needs a chainer."
)?;
let chainer_dir = resolve_hooks_dir(root, &value);
for name in names {
ensure_chainer(out, &chainer_dir, name)?;
}
}
Ok(())
}
async fn locate_hooks_dir(root: &Path) -> Result<PathBuf> {
let common = diff::git_path(root, &["rev-parse", "--git-common-dir"])
.await
.with_context(|| format!("could not locate git common dir under {}", root.display()))?;
Ok(common.join("hooks"))
}
async fn run_git_config_path(root: &Path) -> Result<Option<String>> {
match diff::git_query(root, &["config", "--get", "--type=path", "core.hooksPath"]).await {
Ok(Some(value)) if value.is_empty() => Ok(None),
Ok(value) => Ok(value),
Err(err) => Err(anyhow!(
"could not read core.hooksPath ({err}); refusing to install a hook that \
may never run"
)),
}
}
fn ensure_chainer<W: Write>(out: &mut W, dir: &Path, name: &str) -> Result<()> {
let chainer = dir.join(name);
match std::fs::read(&chainer) {
Ok(bytes) if is_drep_managed(&String::from_utf8_lossy(&bytes)) => {
let body = String::from_utf8_lossy(&bytes);
let current = chainer_body(name);
if body != current {
write_executable(&chainer, ¤t)?;
writeln!(out, " Wrote {}", chainer.display())?;
return Ok(());
}
ensure_executable(out, &chainer)?;
return Ok(());
}
Ok(bytes) => {
let body = String::from_utf8_lossy(&bytes);
if forwarding::appears_to_forward(&body, name) {
ensure_executable(out, &chainer)?;
return Ok(());
}
writeln!(
out,
" {} exists but does not appear to chain to the repo-local hook.",
chainer.display()
)?;
writeln!(out, " drep will not run until it does.")?;
return Ok(());
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => {
return Err(anyhow::Error::new(err).context(format!(
"could not read existing chainer {}",
chainer.display()
)));
}
}
std::fs::create_dir_all(dir)
.with_context(|| format!("could not create chainer dir {}", dir.display()))?;
write_executable(&chainer, &chainer_body(name))?;
writeln!(
out,
" Wrote a chainer at {} (outside this repository)",
chainer.display()
)?;
Ok(())
}
fn ensure_executable<W: Write>(out: &mut W, path: &Path) -> Result<()> {
let was_executable = crate::languages::runner::is_executable(path);
set_executable(path)?;
if !was_executable {
writeln!(out, " {} is not executable; making it so.", path.display())?;
}
Ok(())
}
fn write_backup(path: &Path, body: &[u8]) -> Result<()> {
let parent = path
.parent()
.ok_or_else(|| anyhow!("backup path {} has no parent", path.display()))?;
let mut temporary = tempfile::NamedTempFile::new_in(parent)
.with_context(|| format!("could not back up to {}", path.display()))?;
temporary
.write_all(body)
.with_context(|| format!("could not back up to {}", path.display()))?;
temporary
.as_file()
.sync_all()
.with_context(|| format!("could not back up to {}", path.display()))?;
temporary.persist_noclobber(path).map_err(|err| {
if err.error.kind() == std::io::ErrorKind::AlreadyExists {
anyhow::Error::new(err.error).context(format!(
"could not back up to {}; move the existing backup and retry",
path.display()
))
} else {
anyhow::Error::new(err.error)
.context(format!("could not publish backup to {}", path.display()))
}
})?;
Ok(())
}
fn write_executable(path: &Path, body: &str) -> Result<()> {
let parent = path
.parent()
.ok_or_else(|| anyhow!("hook path {} has no parent", path.display()))?;
let mut temporary = tempfile::NamedTempFile::new_in(parent)
.with_context(|| format!("could not write hook {}", path.display()))?;
temporary
.write_all(body.as_bytes())
.with_context(|| format!("could not write hook {}", path.display()))?;
set_executable(temporary.path())?;
temporary
.as_file()
.sync_all()
.with_context(|| format!("could not write hook {}", path.display()))?;
temporary.persist(path).map_err(|err| {
anyhow::Error::new(err.error).context(format!("could not install hook {}", path.display()))
})?;
Ok(())
}
fn set_executable(path: &Path) -> Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(path)
.with_context(|| format!("could not stat {}", path.display()))?
.permissions();
perms.set_mode(perms.mode() | 0o111);
std::fs::set_permissions(path, perms)
.with_context(|| format!("could not chmod {}", path.display()))?;
}
#[cfg(not(unix))]
let _ = path;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_body_is_built_from_the_marker_constant() {
for body in [
PRE_COMMIT_BODY.to_owned(),
PRE_PUSH_BODY.to_owned(),
chainer_body("pre-push"),
] {
assert!(
body.contains(MANAGED_MARKER),
"body must carry the marker: {body}"
);
assert!(
is_drep_managed(&body),
"and must therefore be recognised as drep's own"
);
}
assert!(!is_drep_managed("#!/bin/sh\necho hi\n"));
}
#[test]
fn each_hook_body_runs_the_mode_it_is_named_for() {
let pre_commit = hook_body("pre-commit").expect("known");
let pre_push = hook_body("pre-push").expect("known");
assert!(
pre_commit.contains("drep check --staged"),
"pre-commit reviews what is staged: {pre_commit}"
);
assert!(
!pre_commit.contains("--diff"),
"and not a diff against a ref: {pre_commit}"
);
assert!(
pre_push.contains("drep check --push-gate --diff") && pre_push.contains("--tip"),
"pre-push reviews a range ending at the pushed ref: {pre_push}"
);
assert!(
!pre_push.contains("--staged"),
"nothing is staged at push time: {pre_push}"
);
assert_ne!(pre_commit, pre_push);
assert!(hook_body("unknown-hook").is_none());
}
}