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 --diff "$base" --tip "$local_oid" < /dev/null
rc=$?
# Highest exit code wins, not the last one. 2 ("could not analyze") must
# not be downgraded to 1 ("found issues") by a later ref that merely had
# findings - the two mean different things to whoever reads the output.
[ "$rc" -gt "$status" ] && status=$rc
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 {
body.contains(MANAGED_MARKER)
}
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?;
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);
if path.exists() {
let existing = std::fs::read_to_string(&path)
.with_context(|| format!("could not read existing hook {}", path.display()))?;
if is_drep_managed(&existing) {
write_executable(&path, body)?;
writeln!(out, " Wrote {}", path.display())?;
} else if force {
let backup = path.with_extension("drep-backup");
std::fs::write(&backup, &existing)
.with_context(|| format!("could not back up to {}", backup.display()))?;
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.")?;
}
continue;
}
write_executable(&path, body)?;
writeln!(out, " Wrote {}", path.display())?;
}
let configured = run_git_config_path(root).await?;
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).await?;
}
}
Ok(())
}
async fn locate_hooks_dir(root: &Path) -> Result<PathBuf> {
let common = diff::run_git(root, &["rev-parse", "--git-common-dir"])
.await
.with_context(|| format!("could not locate git common dir under {}", root.display()))?;
let common = PathBuf::from(common);
let hooks_dir = if common.is_absolute() {
common
} else {
root.join(common)
};
Ok(hooks_dir.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"
)),
}
}
async fn ensure_chainer<W: Write>(out: &mut W, dir: &Path, name: &str) -> Result<()> {
let chainer = dir.join(name);
if chainer.exists() {
let body = std::fs::read_to_string(&chainer)
.with_context(|| format!("could not read existing chainer {}", chainer.display()))?;
let marker = format!("hooks/{name}");
if !body.contains(&marker) {
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(());
}
let was_executable = crate::languages::runner::is_executable(&chainer);
set_executable(&chainer)?;
if !was_executable {
writeln!(
out,
" {} is not executable; making it so.",
chainer.display()
)?;
}
return Ok(());
}
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 write_executable(path: &Path, body: &str) -> Result<()> {
let temp = path.with_extension("drep-tmp");
std::fs::write(&temp, body)
.with_context(|| format!("could not write hook {}", temp.display()))?;
set_executable(&temp)?;
std::fs::rename(&temp, path).map_err(|err| {
let _ = std::fs::remove_file(&temp);
anyhow::Error::new(err).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 --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());
}
}