1use std::io::Write;
16use std::path::{Path, PathBuf};
17
18use anyhow::{Context, Result, anyhow};
19
20use crate::diff;
21
22macro_rules! managed_marker {
34 () => {
35 "# Managed by `drep init`."
36 };
37}
38
39pub const MANAGED_MARKER: &str = managed_marker!();
40
41pub const PRE_COMMIT_BODY: &str = concat!(
58 "#!/bin/sh\n",
59 managed_marker!(),
60 r##"
61# Runs the linters this repo configures, and an LLM review of the staged code.
62if ! command -v drep > /dev/null 2>&1; then
63 echo "drep: not found on PATH; refusing to let the commit through unreviewed." >&2
64 exit 1
65fi
66drep lint-docs --staged --fail-on error || exit $?
67exec drep check --staged
68"##
69);
70
71pub const PRE_PUSH_BODY: &str = concat!(
85 "#!/bin/sh\n",
86 managed_marker!(),
87 r##"
88# git runs this as: pre-push <remote-name> <remote-url>, and sends one line per
89# ref on stdin:
90# <local ref> <local oid> <remote ref> <remote oid>
91#
92# Three things here are not obvious, and each was a real defect:
93#
94# * The ref being pushed is NOT always the checked-out branch
95# (`git push origin feature:feature` from elsewhere, or `git push --all`),
96# so `--tip` names the oid actually being pushed. Reviewing HEAD instead
97# lets the pushed code through unseen.
98# * The base search is BOUNDED. An all-zero remote oid means the branch is
99# new upstream; falling back to the root commit there sends the repository's
100# entire history to the model, which on a mature repo is hours of wall clock
101# and real money from one `git push`.
102# * `drep` reads no stdin, but `< /dev/null` makes that structural: a command
103# inside a `while read` loop that did would swallow the remaining refs and
104# the push would go green having reviewed one of them.
105remote="${1:-origin}"
106zeros=0000000000000000000000000000000000000000
107status=0
108
109if ! command -v drep > /dev/null 2>&1; then
110 echo "drep: not found on PATH; refusing to let the push through unreviewed." >&2
111 echo " (GUI git clients often use a minimal PATH - see the drep README.)" >&2
112 exit 1
113fi
114
115while read -r _local_ref local_oid _remote_ref remote_oid; do
116 # A branch deletion has no content to review.
117 case "$local_oid" in "$zeros"*) continue ;; esac
118
119 case "$remote_oid" in
120 "$zeros"*)
121 # New upstream: find the nearest sensible base, cheapest first, and
122 # never scan further back than 50 commits.
123 base=$(git rev-parse --verify --quiet "$remote/HEAD") ||
124 base=$(git rev-parse --verify --quiet "$remote/main") ||
125 base=$(git rev-parse --verify --quiet "$remote/master") ||
126 base=$(git rev-parse --verify --quiet "$local_oid~50") ||
127 base=$(git rev-list --max-parents=0 "$local_oid" | tail -n 1)
128 ;;
129 *) base=$remote_oid ;;
130 esac
131
132 [ -n "$base" ] || continue
133
134 drep check --push-gate --diff "$base" --tip "$local_oid" < /dev/null
135 rc=$?
136 # Failure precedence is semantic, not numeric: 2 (could not analyze), then
137 # 1 (findings), then 3 (review cached; reconnect), then 0. Exit 3 is
138 # numerically highest but is a successful review, so it must not hide a
139 # harder failure from another ref.
140 case "$rc" in
141 2) status=2 ;;
142 1) [ "$status" -ne 2 ] && status=1 ;;
143 3) [ "$status" -eq 0 ] && status=3 ;;
144 0) ;;
145 *) status=2 ;;
146 esac
147done
148
149exit $status
150"##
151);
152
153pub fn chainer_body(name: &str) -> String {
164 format!(
165 "\
166#!/bin/sh
167{MANAGED_MARKER}
168# Chains to the repo-local {name} hook, which git ignores while core.hooksPath
169# is set. `exec` matters twice: it keeps the local hook's exit status (that is
170# what aborts the operation) and hands over stdin unread, which is how git
171# delivers the refs being pushed.
172LOCAL_HOOK=\"$(git rev-parse --git-common-dir)/hooks/{name}\"
173if [ -x \"$LOCAL_HOOK\" ]; then
174 exec \"$LOCAL_HOOK\" \"$@\"
175fi
176"
177 )
178}
179
180#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
182pub enum HookKind {
183 PrePush,
185 PreCommit,
187 Both,
189 None,
191}
192
193pub fn hook_names(kind: HookKind) -> &'static [&'static str] {
199 match kind {
200 HookKind::None => &[],
201 HookKind::PrePush => &["pre-push"],
202 HookKind::PreCommit => &["pre-commit"],
203 HookKind::Both => &["pre-commit", "pre-push"],
204 }
205}
206
207pub fn hook_body(name: &str) -> Option<&'static str> {
209 match name {
210 "pre-commit" => Some(PRE_COMMIT_BODY),
211 "pre-push" => Some(PRE_PUSH_BODY),
212 _ => None,
213 }
214}
215
216pub fn resolve_hooks_dir(root: &Path, value: &str) -> PathBuf {
222 let candidate = Path::new(value);
223 if candidate.is_absolute() {
224 candidate.to_path_buf()
225 } else {
226 root.join(value)
227 }
228}
229
230pub fn is_drep_managed(body: &str) -> bool {
232 let mut lines = body.lines();
233 match lines.next() {
234 Some(first) if first == MANAGED_MARKER => true,
235 Some(first) if first.starts_with("#!") => lines.next() == Some(MANAGED_MARKER),
236 _ => false,
237 }
238}
239
240pub async fn install<W: Write>(
242 out: &mut W,
243 root: &Path,
244 kind: HookKind,
245 force: bool,
246) -> Result<()> {
247 let names = hook_names(kind);
248 if names.is_empty() {
252 return Ok(());
253 }
254 let hooks_dir = locate_hooks_dir(root).await?;
255
256 let configured = run_git_config_path(root).await?;
261 std::fs::create_dir_all(&hooks_dir)
262 .with_context(|| format!("could not create {}", hooks_dir.display()))?;
263
264 for name in names {
265 let body =
270 hook_body(name).ok_or_else(|| anyhow!("no hook body is defined for `{name}`"))?;
271 let path = hooks_dir.join(name);
272 match std::fs::read(&path) {
273 Ok(existing) => {
274 let existing_text = String::from_utf8_lossy(&existing);
275 if is_drep_managed(&existing_text) {
276 write_executable(&path, body)?;
277 writeln!(out, " Wrote {}", path.display())?;
278 } else if force {
279 let backup = path.with_extension("drep-backup");
283 write_backup(&backup, &existing)?;
284 write_executable(&path, body)?;
285 writeln!(out, " Wrote {}", path.display())?;
286 writeln!(out, " Your previous hook is saved at {}", backup.display())?;
287 } else {
288 writeln!(
289 out,
290 " {} already exists and was not written by drep; leaving it alone.",
291 path.display()
292 )?;
293 writeln!(out, " Re-run with --force to replace it.")?;
294 }
295 }
296 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
297 write_executable(&path, body)?;
298 writeln!(out, " Wrote {}", path.display())?;
299 }
300 Err(err) => {
301 return Err(anyhow::Error::new(err)
302 .context(format!("could not read existing hook {}", path.display())));
303 }
304 }
305 }
306
307 if let Some(value) = configured {
311 writeln!(out, " core.hooksPath is set to {value}")?;
312 writeln!(
313 out,
314 " git looks there and not in .git/hooks, so a repo hook needs a chainer."
315 )?;
316
317 let chainer_dir = resolve_hooks_dir(root, &value);
318 for name in names {
319 ensure_chainer(out, &chainer_dir, name)?;
320 }
321 }
322
323 Ok(())
324}
325
326async fn locate_hooks_dir(root: &Path) -> Result<PathBuf> {
332 let common = diff::git_path(root, &["rev-parse", "--git-common-dir"])
333 .await
334 .with_context(|| format!("could not locate git common dir under {}", root.display()))?;
335 Ok(common.join("hooks"))
336}
337
338async fn run_git_config_path(root: &Path) -> Result<Option<String>> {
350 match diff::git_query(root, &["config", "--get", "--type=path", "core.hooksPath"]).await {
353 Ok(Some(value)) if value.is_empty() => Ok(None),
354 Ok(value) => Ok(value),
355 Err(err) => Err(anyhow!(
356 "could not read core.hooksPath ({err}); refusing to install a hook that \
357 may never run"
358 )),
359 }
360}
361
362fn ensure_chainer<W: Write>(out: &mut W, dir: &Path, name: &str) -> Result<()> {
368 let chainer = dir.join(name);
369 match std::fs::read(&chainer) {
370 Ok(bytes) if is_drep_managed(&String::from_utf8_lossy(&bytes)) => {
371 let body = String::from_utf8_lossy(&bytes);
372 let current = chainer_body(name);
373 if body != current {
374 write_executable(&chainer, ¤t)?;
375 writeln!(out, " Wrote {}", chainer.display())?;
376 return Ok(());
377 }
378 ensure_executable(out, &chainer)?;
379 return Ok(());
380 }
381 Ok(bytes) => {
382 let body = String::from_utf8_lossy(&bytes);
383 let marker = format!("hooks/{name}");
384 let mentions_hook_in_command = body.lines().any(|line| {
385 let line = line.trim_start();
386 !line.starts_with('#') && line.contains(&marker)
387 });
388 if mentions_hook_in_command {
389 ensure_executable(out, &chainer)?;
390 return Ok(());
391 }
392 writeln!(
393 out,
394 " {} exists but does not appear to chain to the repo-local hook.",
395 chainer.display()
396 )?;
397 writeln!(out, " drep will not run until it does.")?;
398 return Ok(());
399 }
400 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
401 Err(err) => {
402 return Err(anyhow::Error::new(err).context(format!(
403 "could not read existing chainer {}",
404 chainer.display()
405 )));
406 }
407 }
408
409 std::fs::create_dir_all(dir)
410 .with_context(|| format!("could not create chainer dir {}", dir.display()))?;
411 write_executable(&chainer, &chainer_body(name))?;
412 writeln!(
416 out,
417 " Wrote a chainer at {} (outside this repository)",
418 chainer.display()
419 )?;
420 Ok(())
421}
422
423fn ensure_executable<W: Write>(out: &mut W, path: &Path) -> Result<()> {
425 let was_executable = crate::languages::runner::is_executable(path);
426 set_executable(path)?;
427 if !was_executable {
428 writeln!(out, " {} is not executable; making it so.", path.display())?;
429 }
430 Ok(())
431}
432
433fn write_backup(path: &Path, body: &[u8]) -> Result<()> {
435 let parent = path
436 .parent()
437 .ok_or_else(|| anyhow!("backup path {} has no parent", path.display()))?;
438 let mut temporary = tempfile::NamedTempFile::new_in(parent)
439 .with_context(|| format!("could not back up to {}", path.display()))?;
440 temporary
441 .write_all(body)
442 .with_context(|| format!("could not back up to {}", path.display()))?;
443 temporary
444 .as_file()
445 .sync_all()
446 .with_context(|| format!("could not back up to {}", path.display()))?;
447 temporary.persist_noclobber(path).map_err(|err| {
448 if err.error.kind() == std::io::ErrorKind::AlreadyExists {
449 anyhow::Error::new(err.error).context(format!(
450 "could not back up to {}; move the existing backup and retry",
451 path.display()
452 ))
453 } else {
454 anyhow::Error::new(err.error)
455 .context(format!("could not publish backup to {}", path.display()))
456 }
457 })?;
458 Ok(())
459}
460
461fn write_executable(path: &Path, body: &str) -> Result<()> {
469 let parent = path
470 .parent()
471 .ok_or_else(|| anyhow!("hook path {} has no parent", path.display()))?;
472 let mut temporary = tempfile::NamedTempFile::new_in(parent)
473 .with_context(|| format!("could not write hook {}", path.display()))?;
474 temporary
475 .write_all(body.as_bytes())
476 .with_context(|| format!("could not write hook {}", path.display()))?;
477 set_executable(temporary.path())?;
478 temporary
479 .as_file()
480 .sync_all()
481 .with_context(|| format!("could not write hook {}", path.display()))?;
482 temporary.persist(path).map_err(|err| {
483 anyhow::Error::new(err.error).context(format!("could not install hook {}", path.display()))
484 })?;
485 Ok(())
486}
487
488fn set_executable(path: &Path) -> Result<()> {
496 #[cfg(unix)]
497 {
498 use std::os::unix::fs::PermissionsExt;
499 let mut perms = std::fs::metadata(path)
500 .with_context(|| format!("could not stat {}", path.display()))?
501 .permissions();
502 perms.set_mode(perms.mode() | 0o111);
507 std::fs::set_permissions(path, perms)
508 .with_context(|| format!("could not chmod {}", path.display()))?;
509 }
510 #[cfg(not(unix))]
511 let _ = path;
512 Ok(())
513}
514
515#[cfg(test)]
516mod tests {
517 use super::*;
518
519 #[test]
528 fn every_body_is_built_from_the_marker_constant() {
529 for body in [
530 PRE_COMMIT_BODY.to_owned(),
531 PRE_PUSH_BODY.to_owned(),
532 chainer_body("pre-push"),
533 ] {
534 assert!(
535 body.contains(MANAGED_MARKER),
536 "body must carry the marker: {body}"
537 );
538 assert!(
539 is_drep_managed(&body),
540 "and must therefore be recognised as drep's own"
541 );
542 }
543 assert!(!is_drep_managed("#!/bin/sh\necho hi\n"));
544 }
545
546 #[test]
553 fn each_hook_body_runs_the_mode_it_is_named_for() {
554 let pre_commit = hook_body("pre-commit").expect("known");
555 let pre_push = hook_body("pre-push").expect("known");
556
557 assert!(
558 pre_commit.contains("drep check --staged"),
559 "pre-commit reviews what is staged: {pre_commit}"
560 );
561 assert!(
562 !pre_commit.contains("--diff"),
563 "and not a diff against a ref: {pre_commit}"
564 );
565 assert!(
566 pre_push.contains("drep check --push-gate --diff") && pre_push.contains("--tip"),
567 "pre-push reviews a range ending at the pushed ref: {pre_push}"
568 );
569 assert!(
570 !pre_push.contains("--staged"),
571 "nothing is staged at push time: {pre_push}"
572 );
573 assert_ne!(pre_commit, pre_push);
574 assert!(hook_body("unknown-hook").is_none());
575 }
576}