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