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::run_git(root, &["rev-parse", "--git-common-dir"])
333 .await
334 .with_context(|| format!("could not locate git common dir under {}", root.display()))?;
335 let common = PathBuf::from(common);
336 let hooks_dir = if common.is_absolute() {
337 common
338 } else {
339 root.join(common)
340 };
341 Ok(hooks_dir.join("hooks"))
342}
343
344async fn run_git_config_path(root: &Path) -> Result<Option<String>> {
356 match diff::git_query(root, &["config", "--get", "--type=path", "core.hooksPath"]).await {
359 Ok(Some(value)) if value.is_empty() => Ok(None),
360 Ok(value) => Ok(value),
361 Err(err) => Err(anyhow!(
362 "could not read core.hooksPath ({err}); refusing to install a hook that \
363 may never run"
364 )),
365 }
366}
367
368fn ensure_chainer<W: Write>(out: &mut W, dir: &Path, name: &str) -> Result<()> {
374 let chainer = dir.join(name);
375 match std::fs::read(&chainer) {
376 Ok(bytes) if is_drep_managed(&String::from_utf8_lossy(&bytes)) => {
377 let body = String::from_utf8_lossy(&bytes);
378 let current = chainer_body(name);
379 if body != current {
380 write_executable(&chainer, ¤t)?;
381 writeln!(out, " Wrote {}", chainer.display())?;
382 return Ok(());
383 }
384 ensure_executable(out, &chainer)?;
385 return Ok(());
386 }
387 Ok(bytes) => {
388 let body = String::from_utf8_lossy(&bytes);
389 let marker = format!("hooks/{name}");
390 let mentions_hook_in_command = body.lines().any(|line| {
391 let line = line.trim_start();
392 !line.starts_with('#') && line.contains(&marker)
393 });
394 if mentions_hook_in_command {
395 ensure_executable(out, &chainer)?;
396 return Ok(());
397 }
398 writeln!(
399 out,
400 " {} exists but does not appear to chain to the repo-local hook.",
401 chainer.display()
402 )?;
403 writeln!(out, " drep will not run until it does.")?;
404 return Ok(());
405 }
406 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
407 Err(err) => {
408 return Err(anyhow::Error::new(err).context(format!(
409 "could not read existing chainer {}",
410 chainer.display()
411 )));
412 }
413 }
414
415 std::fs::create_dir_all(dir)
416 .with_context(|| format!("could not create chainer dir {}", dir.display()))?;
417 write_executable(&chainer, &chainer_body(name))?;
418 writeln!(
422 out,
423 " Wrote a chainer at {} (outside this repository)",
424 chainer.display()
425 )?;
426 Ok(())
427}
428
429fn ensure_executable<W: Write>(out: &mut W, path: &Path) -> Result<()> {
431 let was_executable = crate::languages::runner::is_executable(path);
432 set_executable(path)?;
433 if !was_executable {
434 writeln!(out, " {} is not executable; making it so.", path.display())?;
435 }
436 Ok(())
437}
438
439fn write_backup(path: &Path, body: &[u8]) -> Result<()> {
441 let parent = path
442 .parent()
443 .ok_or_else(|| anyhow!("backup path {} has no parent", path.display()))?;
444 let mut temporary = tempfile::NamedTempFile::new_in(parent)
445 .with_context(|| format!("could not back up to {}", path.display()))?;
446 temporary
447 .write_all(body)
448 .with_context(|| format!("could not back up to {}", path.display()))?;
449 temporary
450 .as_file()
451 .sync_all()
452 .with_context(|| format!("could not back up to {}", path.display()))?;
453 temporary.persist_noclobber(path).map_err(|err| {
454 if err.error.kind() == std::io::ErrorKind::AlreadyExists {
455 anyhow::Error::new(err.error).context(format!(
456 "could not back up to {}; move the existing backup and retry",
457 path.display()
458 ))
459 } else {
460 anyhow::Error::new(err.error)
461 .context(format!("could not publish backup to {}", path.display()))
462 }
463 })?;
464 Ok(())
465}
466
467fn write_executable(path: &Path, body: &str) -> Result<()> {
475 let parent = path
476 .parent()
477 .ok_or_else(|| anyhow!("hook path {} has no parent", path.display()))?;
478 let mut temporary = tempfile::NamedTempFile::new_in(parent)
479 .with_context(|| format!("could not write hook {}", path.display()))?;
480 temporary
481 .write_all(body.as_bytes())
482 .with_context(|| format!("could not write hook {}", path.display()))?;
483 set_executable(temporary.path())?;
484 temporary
485 .as_file()
486 .sync_all()
487 .with_context(|| format!("could not write hook {}", path.display()))?;
488 temporary.persist(path).map_err(|err| {
489 anyhow::Error::new(err.error).context(format!("could not install hook {}", path.display()))
490 })?;
491 Ok(())
492}
493
494fn set_executable(path: &Path) -> Result<()> {
502 #[cfg(unix)]
503 {
504 use std::os::unix::fs::PermissionsExt;
505 let mut perms = std::fs::metadata(path)
506 .with_context(|| format!("could not stat {}", path.display()))?
507 .permissions();
508 perms.set_mode(perms.mode() | 0o111);
513 std::fs::set_permissions(path, perms)
514 .with_context(|| format!("could not chmod {}", path.display()))?;
515 }
516 #[cfg(not(unix))]
517 let _ = path;
518 Ok(())
519}
520
521#[cfg(test)]
522mod tests {
523 use super::*;
524
525 #[test]
534 fn every_body_is_built_from_the_marker_constant() {
535 for body in [
536 PRE_COMMIT_BODY.to_owned(),
537 PRE_PUSH_BODY.to_owned(),
538 chainer_body("pre-push"),
539 ] {
540 assert!(
541 body.contains(MANAGED_MARKER),
542 "body must carry the marker: {body}"
543 );
544 assert!(
545 is_drep_managed(&body),
546 "and must therefore be recognised as drep's own"
547 );
548 }
549 assert!(!is_drep_managed("#!/bin/sh\necho hi\n"));
550 }
551
552 #[test]
559 fn each_hook_body_runs_the_mode_it_is_named_for() {
560 let pre_commit = hook_body("pre-commit").expect("known");
561 let pre_push = hook_body("pre-push").expect("known");
562
563 assert!(
564 pre_commit.contains("drep check --staged"),
565 "pre-commit reviews what is staged: {pre_commit}"
566 );
567 assert!(
568 !pre_commit.contains("--diff"),
569 "and not a diff against a ref: {pre_commit}"
570 );
571 assert!(
572 pre_push.contains("drep check --push-gate --diff") && pre_push.contains("--tip"),
573 "pre-push reviews a range ending at the pushed ref: {pre_push}"
574 );
575 assert!(
576 !pre_push.contains("--staged"),
577 "nothing is staged at push time: {pre_push}"
578 );
579 assert_ne!(pre_commit, pre_push);
580 assert!(hook_body("unknown-hook").is_none());
581 }
582}