1use std::cell::Cell;
46use std::fs;
47use std::path::{Path, PathBuf};
48use std::process::Command;
49
50use anyhow::{anyhow, bail, Context, Result};
51
52const HOOK_NAMES: &[&str] = &[
55 "applypatch-msg",
56 "pre-applypatch",
57 "post-applypatch",
58 "pre-commit",
59 "pre-merge-commit",
60 "prepare-commit-msg",
61 "commit-msg",
62 "post-commit",
63 "pre-rebase",
64 "post-checkout",
65 "post-merge",
66 "pre-push",
67 "pre-receive",
68 "update",
69 "proc-receive",
70 "post-receive",
71 "post-update",
72 "reference-transaction",
73 "push-to-checkout",
74 "pre-auto-gc",
75 "post-rewrite",
76 "sendemail-validate",
77 "fsmonitor-watchman",
78 "p4-changelist",
79 "p4-prepare-changelist",
80 "p4-post-changelist",
81 "p4-pre-submit",
82 "post-index-change",
83];
84
85fn books_commit(name: &str) -> bool {
86 matches!(name, "post-commit" | "post-rewrite")
87}
88
89fn gates_git(name: &str) -> bool {
90 matches!(name, "pre-commit" | "pre-push")
91}
92
93pub fn run(action: &str) -> Result<()> {
99 match action {
100 "install" => {
101 let dir = install()?;
102 println!("installed {}", dir.display());
103 Ok(())
104 }
105 "uninstall" => {
106 uninstall()?;
107 println!("uninstalled");
108 Ok(())
109 }
110 "status" => {
111 print!("{}", status());
112 Ok(())
113 }
114 other => bail!("git-hooks: {other} is not install, uninstall, or status"),
115 }
116}
117
118pub fn install() -> Result<PathBuf> {
128 install_at(&hooks_dir(), &[])
129}
130
131pub(crate) fn install_at(dir: &Path, git_env: &[(&str, &str)]) -> Result<PathBuf> {
141 let current = global_hooks_path(git_env);
142 if let Some(path) = nonempty(current.as_deref()) {
143 if !same_dir(Path::new(path), dir) {
144 chain_user_hooks(Path::new(path))?;
145 return Ok(PathBuf::from(path));
146 }
147 }
148 write_owned_hooks(dir)?;
149 if nonempty(current.as_deref()).is_none() {
150 let _ = fs::remove_file(dir.join("previous-hooks-path"));
151 set_global_hooks_path(dir, git_env)?;
152 }
153 Ok(dir.to_path_buf())
154}
155
156pub fn uninstall() -> Result<()> {
167 let dir = hooks_dir();
168 let current = global_hooks_path(&[]);
169 match nonempty(current.as_deref()) {
170 Some(path) if same_dir(Path::new(path), &dir) => {
171 let previous = fs::read_to_string(dir.join("previous-hooks-path"))
172 .ok()
173 .map(|text| text.trim().to_string())
174 .filter(|text| !text.is_empty());
175 if let Some(prev) = previous {
176 set_global_hooks_path(Path::new(&prev), &[])?;
177 } else {
178 unset_global_hooks_path(&[])?;
179 }
180 remove_owned_hooks(&dir);
181 }
182 Some(path) => strip_user_hooks(Path::new(path))?,
183 None => remove_owned_hooks(&dir),
184 }
185 Ok(())
186}
187
188#[must_use]
190pub fn status() -> String {
191 let dir = hooks_dir();
192 let configured_path = global_hooks_path(&[]);
193 let configured = configured_path.clone().unwrap_or_else(|| "(unset)".into());
194 let present = nonempty(configured_path.as_deref())
195 .is_some_and(|path| hooks_present(Path::new(path), &dir));
196 let stale = nonempty(configured_path.as_deref())
197 .filter(|_| present)
198 .and_then(|path| stale_note(Path::new(path), &dir));
199 let hooks = if present {
200 configured.clone()
201 } else if owned_installed(&dir) {
202 dir.display().to_string()
203 } else {
204 "not installed".into()
205 };
206 let reach = if present && stale.is_none() {
207 "ljos hooks run".to_string()
208 } else if let Some(note) = stale {
209 format!("ljos hooks run, but {note}; {HOOKS_FIX}")
210 } else {
211 format!("ljos hooks do not run; {HOOKS_FIX}")
212 };
213 format!("hooks: {hooks}\ncore.hooksPath: {configured}\n{reach}\n")
214}
215
216pub const HOOKS_UNSET: &str = "core.hooksPath is unset";
220
221pub const HOOKS_FIX: &str = "ljos git-hooks install";
223
224#[must_use]
232pub fn doctor_row() -> crate::Habitat {
233 let dir = hooks_dir();
234 let (ok, state) = doctor_state(&dir, global_hooks_path(&[]).as_deref(), &config_ljos_dir());
235 crate::Habitat {
236 name: "git hooks",
237 state,
238 ok,
239 }
240}
241
242#[must_use]
245pub fn doctor_state(dir: &Path, configured: Option<&str>, config_dir: &Path) -> (bool, String) {
246 let (mut ok, mut state) = hooks_state(dir, configured);
247 if let Some(drift) = env_hooks_drift(config_dir, dir) {
248 ok = false;
249 state = if state.starts_with(HOOKS_UNSET) {
251 drift
252 } else {
253 format!("{state}; {drift}")
254 };
255 }
256 (ok, state)
257}
258
259#[must_use]
263pub fn env_hooks_drift(config_dir: &Path, onboard_dir: &Path) -> Option<String> {
264 let mut files = Vec::new();
265 for name in ["env.sh", "env"] {
266 let path = config_dir.join(name);
267 if path.is_file() {
268 files.push(path);
269 }
270 }
271 if let Ok(entries) = fs::read_dir(config_dir) {
272 let mut more: Vec<PathBuf> = entries
273 .flatten()
274 .map(|entry| entry.path())
275 .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("env"))
276 .collect();
277 more.sort();
278 files.extend(more);
279 }
280 let ours = onboard_dir.display().to_string();
281 for path in files {
282 let Ok(text) = fs::read_to_string(&path) else {
283 continue;
284 };
285 for line in text.lines() {
286 let Some(value) = hooks_assignment(line) else {
287 continue;
288 };
289 if assignment_agrees(&value, onboard_dir) {
290 continue;
291 }
292 return Some(format!(
293 "{} sets LJOS_GIT_HOOKS to {value}, not {ours}, the hooks `ljos onboard` installs",
294 path.display()
295 ));
296 }
297 }
298 None
299}
300
301fn hooks_assignment(line: &str) -> Option<String> {
302 let line = line.trim();
303 if line.is_empty() || line.starts_with('#') {
304 return None;
305 }
306 let rest = line.strip_prefix("export ").unwrap_or(line).trim();
307 let rest = rest.strip_prefix("LJOS_GIT_HOOKS=")?;
308 let value = unquote_assignment(rest.trim());
309 if value.is_empty() {
310 None
311 } else {
312 Some(value)
313 }
314}
315
316fn unquote_assignment(value: &str) -> String {
317 let value = value.trim();
318 if let Some(inner) = value
319 .strip_prefix('"')
320 .and_then(|text| text.strip_suffix('"'))
321 {
322 return inner.to_string();
323 }
324 if let Some(inner) = value
325 .strip_prefix('\'')
326 .and_then(|text| text.strip_suffix('\''))
327 {
328 return inner.to_string();
329 }
330 value.split_whitespace().next().unwrap_or("").to_string()
331}
332
333fn assignment_agrees(value: &str, onboard: &Path) -> bool {
334 if value.contains("${XDG_DATA_HOME:-$HOME/.local/share}/ljos/git-hooks") {
335 return true;
336 }
337 if value.contains('$') || value.contains('`') {
338 return false;
339 }
340 same_dir(Path::new(value), onboard)
341}
342
343fn config_ljos_dir() -> PathBuf {
344 let base = std::env::var_os("XDG_CONFIG_HOME")
345 .filter(|value| !value.is_empty())
346 .map(PathBuf::from)
347 .or_else(|| {
348 std::env::var_os("HOME")
349 .filter(|value| !value.is_empty())
350 .map(|home| PathBuf::from(home).join(".config"))
351 })
352 .unwrap_or_else(|| PathBuf::from(".config"));
353 base.join("ljos")
354}
355
356pub const HOOKS_ENV_LINE: &str = "\
359# Git hooks directory. `ljos git-hooks install` writes the stubs here.\n\
360export LJOS_GIT_HOOKS=\"${LJOS_GIT_HOOKS:-${XDG_DATA_HOME:-$HOME/.local/share}/ljos/git-hooks}\"";
361
362#[must_use]
365pub fn hooks_state(ours: &Path, configured: Option<&str>) -> (bool, String) {
366 let Some(path) = nonempty(configured) else {
367 return (
368 false,
369 format!("{HOOKS_UNSET}, so commits are not booked; {HOOKS_FIX}"),
370 );
371 };
372 let dir = Path::new(path);
373 if hooks_reachable(dir, ours) {
374 let state = if same_dir(dir, ours) {
375 format!("core.hooksPath is {path}; commits are booked")
376 } else {
377 format!("core.hooksPath is {path}; ljos git-hook runs from those hooks")
378 };
379 return (true, state);
380 }
381 let stale = if hooks_present(dir, ours) {
382 stale_note(dir, ours)
383 } else {
384 None
385 };
386 let state = if let Some(note) = stale {
387 format!("core.hooksPath is {path}, but {note}; {HOOKS_FIX}")
388 } else if same_dir(dir, ours) {
389 format!("core.hooksPath is {path}, but some hook stubs are missing there; {HOOKS_FIX}")
390 } else {
391 format!("core.hooksPath is {path}, and ljos is not chained there; {HOOKS_FIX}")
392 };
393 (false, state)
394}
395
396fn hooks_dir() -> PathBuf {
397 if let Some(dir) = std::env::var_os("LJOS_GIT_HOOKS").filter(|value| !value.is_empty()) {
398 return PathBuf::from(dir);
399 }
400 data_home().join("ljos").join("git-hooks")
401}
402
403pub(crate) fn onboard_hooks_dir() -> PathBuf {
404 hooks_dir()
405}
406
407pub(crate) fn onboard_hook_paths(dir: &Path) -> Vec<PathBuf> {
408 HOOK_NAMES.iter().map(|name| dir.join(name)).collect()
409}
410
411pub(crate) fn configured_hooks_path(git_env: &[(&str, &str)]) -> Option<PathBuf> {
412 let configured = global_hooks_path(git_env);
413 nonempty(configured.as_deref()).map(PathBuf::from)
414}
415
416pub(crate) fn onboarding_installed() -> bool {
417 let dir = hooks_dir();
418 let configured = global_hooks_path(&[]);
419 match nonempty(configured.as_deref()) {
420 Some(path) if same_dir(Path::new(path), &dir) => owned_installed(&dir),
421 Some(path) => hooks_present(Path::new(path), &dir),
422 None => owned_installed(&dir),
423 }
424}
425
426fn data_home() -> PathBuf {
427 if let Some(dir) = std::env::var_os("XDG_DATA_HOME").filter(|value| !value.is_empty()) {
428 return PathBuf::from(dir);
429 }
430 std::env::var_os("HOME")
431 .filter(|value| !value.is_empty())
432 .map(|home| PathBuf::from(home).join(".local").join("share"))
433 .unwrap_or_else(|| PathBuf::from("."))
434}
435
436const BLOCK_BEGIN: &str = "# ljos:begin git-hook";
437const BLOCK_END: &str = "# ljos:end git-hook";
438
439fn nonempty(value: Option<&str>) -> Option<&str> {
440 value.map(str::trim).filter(|path| !path.is_empty())
441}
442
443fn ljos_executable() -> String {
444 std::env::current_exe()
445 .map(|path| path.display().to_string())
446 .unwrap_or_else(|_| "ljos".to_string())
447}
448
449fn hook_block(name: &str) -> String {
457 hook_block_for(name, &shell_quote(&ljos_executable()))
458}
459
460fn hook_block_for(name: &str, bin: &str) -> String {
462 let (otherwise, fail) = if gates_git(name) {
463 (format!("elif [ -x {bin} ]; then"), "exit 1")
464 } else {
465 ("else".to_string(), "true")
466 };
467 if matches!(name, "post-rewrite" | "pre-push") {
468 return format!(
469 "{BLOCK_BEGIN}\n\
470 if [ -z \"${{_ljos_in:-}}\" ]; then\n\
471 \t_ljos_in=$(mktemp 2>/dev/null || true)\n\
472 \tif [ -n \"$_ljos_in\" ]; then\n\
473 \t\tcat > \"$_ljos_in\" || true\n\
474 \tfi\n\
475 fi\n\
476 if command -v ljos >/dev/null 2>&1; then\n\
477 \tif [ -n \"${{_ljos_in:-}}\" ]; then\n\
478 \t\tljos git-hook {name} \"$@\" < \"$_ljos_in\" >&2 || {fail}\n\
479 \telse\n\
480 \t\tljos git-hook {name} \"$@\" >&2 || {fail}\n\
481 \tfi\n\
482 {otherwise}\n\
483 \tif [ -n \"${{_ljos_in:-}}\" ]; then\n\
484 \t\t{bin} git-hook {name} \"$@\" < \"$_ljos_in\" >&2 || {fail}\n\
485 \telse\n\
486 \t\t{bin} git-hook {name} \"$@\" >&2 || {fail}\n\
487 \tfi\n\
488 fi\n\
489 {BLOCK_END}\n"
490 );
491 }
492 format!(
493 "{BLOCK_BEGIN}\n\
494 if command -v ljos >/dev/null 2>&1; then\n\
495 \tljos git-hook {name} \"$@\" >&2 || {fail}\n\
496 {otherwise}\n\
497 \t{bin} git-hook {name} \"$@\" >&2 || {fail}\n\
498 fi\n\
499 {BLOCK_END}\n"
500 )
501}
502
503fn has_block(text: &str, name: &str) -> bool {
504 text.contains(&hook_block(name))
505}
506
507fn carries_block(text: &str, name: &str) -> bool {
512 let template = hook_block_for(name, "\0");
514 let mut parts = template.split('\0');
515 let (Some(head), Some(next)) = (parts.next(), parts.next()) else {
516 return text.contains(&template);
517 };
518 text.match_indices(head).any(|(at, _)| {
519 let rest = &text[at + head.len()..];
520 let line = rest.find('\n').unwrap_or(rest.len());
521 rest.match_indices(next)
522 .take_while(|(end, _)| *end <= line)
523 .any(|(end, _)| rest.starts_with(&template.replace('\0', &rest[..end])[head.len()..]))
524 })
525}
526
527fn strip_block(text: &str) -> String {
528 let Some(start) = text.find(BLOCK_BEGIN) else {
529 return text.to_string();
530 };
531 let Some(rel) = text[start..].find(BLOCK_END) else {
532 return text.to_string();
533 };
534 let mut end = start + rel + BLOCK_END.len();
535 if text[end..].starts_with('\n') {
536 end += 1;
537 }
538 let mut out = String::new();
539 out.push_str(&text[..start]);
540 out.push_str(&text[end..]);
541 out
542}
543
544fn strip_all(text: &str) -> String {
545 let mut out = text.to_string();
546 loop {
547 let next = strip_block(&out);
548 if next == out {
549 return out;
550 }
551 out = next;
552 }
553}
554
555fn merge_block(text: &str, name: &str) -> String {
556 if has_block(text, name) {
557 return text.to_string();
558 }
559 let stripped = strip_all(text);
560 let block = hook_block(name);
561 if stripped.trim().is_empty() {
562 return format!("#!/bin/sh\n{block}");
563 }
564 if stripped.ends_with('\n') {
565 format!("{stripped}{block}")
566 } else {
567 format!("{stripped}\n{block}")
568 }
569}
570
571fn block_only(text: &str) -> bool {
572 matches!(text.trim(), "" | "#!/bin/sh")
573}
574
575fn hook_calls_ljos(text: &str, name: &str) -> bool {
576 text.contains(&format!("ljos git-hook {name} "))
577}
578
579fn is_executable(path: &Path) -> bool {
580 #[cfg(unix)]
581 {
582 use std::os::unix::fs::PermissionsExt;
583 fs::metadata(path)
584 .map(|meta| meta.permissions().mode() & 0o111 != 0)
585 .unwrap_or(false)
586 }
587 #[cfg(not(unix))]
588 {
589 path.is_file()
590 }
591}
592
593fn ensure_executable(path: &Path) -> Result<()> {
594 #[cfg(unix)]
595 {
596 use std::os::unix::fs::PermissionsExt;
597 let meta = fs::metadata(path).with_context(|| format!("git-hooks: {}", path.display()))?;
598 let mode = meta.permissions().mode();
599 if mode & 0o111 == 0 {
600 fs::set_permissions(path, fs::Permissions::from_mode(mode | 0o111))?;
601 }
602 }
603 Ok(())
604}
605
606fn owned_installed(dir: &Path) -> bool {
607 HOOK_NAMES.iter().all(|name| {
608 let path = dir.join(name);
609 let Ok(text) = fs::read_to_string(&path) else {
610 return false;
611 };
612 is_executable(&path) && (text.contains("#!/bin/sh") || hook_calls_ljos(&text, name))
613 })
614}
615
616fn user_chained(dir: &Path) -> bool {
617 HOOK_NAMES.iter().all(|name| {
618 let path = dir.join(name);
619 let Ok(text) = fs::read_to_string(&path) else {
620 return false;
621 };
622 is_executable(&path) && hook_calls_ljos(&text, name)
623 })
624}
625
626fn hooks_present(configured: &Path, ours: &Path) -> bool {
627 if same_dir(configured, ours) {
628 owned_installed(configured)
629 } else {
630 user_chained(configured)
631 }
632}
633
634fn stale_gate(dir: &Path) -> Option<&'static str> {
638 HOOK_NAMES
639 .iter()
640 .copied()
641 .filter(|name| gates_git(name))
642 .find(|name| {
643 !fs::read_to_string(dir.join(name)).is_ok_and(|text| carries_block(&text, name))
644 })
645}
646
647fn stale_note(configured: &Path, ours: &Path) -> Option<String> {
649 let name = stale_gate(configured)?;
650 let verb = if name == "pre-push" { "push" } else { "commit" };
651 let what = if same_dir(configured, ours) {
652 "stub"
653 } else {
654 "hook's ljos block"
655 };
656 Some(format!(
657 "the {name} {what} predates the current gate and lets a refused {verb} through"
658 ))
659}
660
661fn hooks_reachable(configured: &Path, ours: &Path) -> bool {
662 hooks_present(configured, ours) && stale_gate(configured).is_none()
663}
664
665fn write_owned_hooks(dir: &Path) -> Result<()> {
666 fs::create_dir_all(dir).with_context(|| format!("git-hooks: {}", dir.display()))?;
667 for name in HOOK_NAMES {
668 let path = dir.join(name);
669 fs::write(&path, hook_body(name))
670 .with_context(|| format!("git-hooks: {}", path.display()))?;
671 #[cfg(unix)]
672 {
673 use std::os::unix::fs::PermissionsExt;
674 fs::set_permissions(&path, fs::Permissions::from_mode(0o755))?;
675 }
676 }
677 Ok(())
678}
679
680fn chain_user_hooks(dir: &Path) -> Result<()> {
681 fs::create_dir_all(dir).with_context(|| format!("git-hooks: {}", dir.display()))?;
682 for name in HOOK_NAMES {
683 let path = dir.join(name);
684 let existing = if path.is_file() {
685 Some(fs::read(&path).with_context(|| format!("git-hooks: {}", path.display()))?)
686 } else {
687 None
688 };
689 if let Some(bytes) = &existing {
690 if bytes.contains(&0) || std::str::from_utf8(bytes).is_err() {
691 eprintln!("ljos: {} is not a shell script; left it", path.display());
692 continue;
693 }
694 }
695 let text = existing
696 .as_deref()
697 .map(|bytes| std::str::from_utf8(bytes).unwrap_or(""))
698 .unwrap_or("");
699 let next = merge_block(text, name);
700 if existing.is_none() || next != text {
701 fs::write(&path, next).with_context(|| format!("git-hooks: {}", path.display()))?;
702 }
703 ensure_executable(&path)?;
704 }
705 Ok(())
706}
707
708fn strip_user_hooks(dir: &Path) -> Result<()> {
709 if !dir.is_dir() {
710 return Ok(());
711 }
712 for name in HOOK_NAMES {
713 let path = dir.join(name);
714 let Ok(bytes) = fs::read(&path) else {
715 continue;
716 };
717 let Ok(text) = std::str::from_utf8(&bytes) else {
718 continue;
719 };
720 if !text.contains(BLOCK_BEGIN) {
721 continue;
722 }
723 let stripped = strip_all(text);
724 if block_only(&stripped) {
725 fs::remove_file(&path).with_context(|| format!("git-hooks: {}", path.display()))?;
726 } else if stripped != text {
727 fs::write(&path, stripped).with_context(|| format!("git-hooks: {}", path.display()))?;
728 }
729 }
730 Ok(())
731}
732
733fn remove_owned_hooks(dir: &Path) {
734 for name in HOOK_NAMES {
735 let _ = fs::remove_file(dir.join(name));
736 }
737 let _ = fs::remove_file(dir.join("previous-hooks-path"));
738}
739
740fn hook_body(name: &str) -> String {
741 let block = hook_block(name);
742 format!(
743 "#!/bin/sh\n\
744 # ljos books the commit, then chains to this repository's\n\
745 # .git/hooks/{name}, or to the core.hooksPath recorded in\n\
746 # previous-hooks-path. Booking cannot fail the hook; a refusal\n\
747 # from ljos in pre-commit or pre-push does. The chained hook's\n\
748 # exit status is kept. post-rewrite and pre-push keep a copy of\n\
749 # stdin for that hook.\n\
750 #\n\
751 # pre-commit install follows core.hooksPath and would replace this\n\
752 # file. Install the framework into the repository, then re-run\n\
753 # `ljos git-hooks install` if a stub was overwritten:\n\
754 # dir=$(git rev-parse --git-dir)\n\
755 # git config --local core.hooksPath \"$dir/hooks\"\n\
756 # pre-commit install\n\
757 # git config --local --unset core.hooksPath\n\
758 {block}\
759 git_dir=$(git rev-parse --git-dir 2>/dev/null || true)\n\
760 if [ -n \"$git_dir\" ]; then\n\
761 \tcase \"$git_dir\" in\n\
762 \t\t/*) ;;\n\
763 \t\t*) git_dir=$(pwd)/$git_dir ;;\n\
764 \tesac\n\
765 \trepo_hook=\"$git_dir/hooks/{name}\"\n\
766 \tif [ -x \"$repo_hook\" ] && [ \"$repo_hook\" != \"$0\" ]; then\n\
767 \t\tif [ -n \"${{_ljos_in:-}}\" ]; then\n\
768 \t\t\texec \"$repo_hook\" \"$@\" < \"$_ljos_in\"\n\
769 \t\tfi\n\
770 \t\texec \"$repo_hook\" \"$@\"\n\
771 \tfi\n\
772 fi\n\
773 here=$(dirname \"$0\")\n\
774 here=$(CDPATH= cd -- \"$here\" && pwd) || exit 0\n\
775 prev=$(sed -n '1s/[[:space:]]*$//p' \"$here/previous-hooks-path\" 2>/dev/null || true)\n\
776 if [ -n \"$prev\" ] && [ \"$prev\" != \"$here\" ]; then\n\
777 \tprev_hook=\"$prev/{name}\"\n\
778 \tif [ -x \"$prev_hook\" ] && [ \"$prev_hook\" != \"$0\" ]; then\n\
779 \t\tif [ -n \"${{_ljos_in:-}}\" ]; then\n\
780 \t\t\texec \"$prev_hook\" \"$@\" < \"$_ljos_in\"\n\
781 \t\tfi\n\
782 \t\texec \"$prev_hook\" \"$@\"\n\
783 \tfi\n\
784 fi\n\
785 exit 0\n"
786 )
787}
788
789fn shell_quote(text: &str) -> String {
790 format!("'{}'", text.replace('\'', "'\\''"))
791}
792
793pub fn run_git_commit(hook: &str) -> Result<()> {
799 if let Err(err) = book_commit(hook) {
800 crate::bookkeep::log_line(&format!("git-hooks: {err:#}"));
801 }
802 Ok(())
803}
804
805pub fn run_named(name: &str) -> Result<()> {
818 let name = name.trim();
819 if books_commit(name) {
820 return run_git_commit(name);
821 }
822 if name == "pre-push" {
823 let refs = std::io::read_to_string(std::io::stdin())
824 .map_err(|err| anyhow!("pre-push: cannot read the refs ({err})"))?;
825 refuse_push_messages(&refs)?;
826 return gate(name, &refs).map_err(|err| dropped(name, err));
827 }
828 if gates_git(name) {
829 return gate(name, "").map_err(|err| dropped(name, err));
830 }
831 Ok(())
832}
833
834fn dropped(name: &str, err: anyhow::Error) -> anyhow::Error {
835 match dropped_refusal(name) {
836 Some(why) => anyhow!("{err:#}\n{why}"),
837 None => err,
838 }
839}
840
841fn refuse_push_messages(refs: &str) -> Result<()> {
845 for line in refs.lines() {
846 let line = line.trim();
847 if line.is_empty() {
848 continue;
849 }
850 let fields: Vec<&str> = line.split_whitespace().collect();
851 if fields.len() != 4 {
852 bail!("pre-push: cannot read `{line}`");
853 }
854 let local_sha = fields[1];
855 let remote_sha = fields[3];
856 if !is_object_name(local_sha) || !is_object_name(remote_sha) {
857 bail!("pre-push: cannot read `{line}`");
858 }
859 if is_zero_name(local_sha) {
860 continue;
861 }
862 let listed = if is_zero_name(remote_sha) {
863 listed_commits(&[local_sha, "--not", "--remotes"])?
864 } else {
865 let range = format!("{remote_sha}..{local_sha}");
866 listed_commits(&[&range])?
867 };
868 for sha in listed {
869 let body = commit_body(&sha)?;
870 if message_refused(&body) {
871 bail!("pre-push: {sha} message is refused");
872 }
873 }
874 }
875 Ok(())
876}
877
878fn is_object_name(text: &str) -> bool {
879 matches!(text.len(), 40 | 64) && text.bytes().all(|b| b.is_ascii_hexdigit())
880}
881
882fn is_zero_name(text: &str) -> bool {
883 is_object_name(text) && text.bytes().all(|b| b == b'0')
884}
885
886fn listed_commits(args: &[&str]) -> Result<Vec<String>> {
887 let mut git_args = vec!["rev-list"];
888 git_args.extend_from_slice(args);
889 let shown = args.join(" ");
890 let out = git_command(&git_args, &[])
891 .stdin(std::process::Stdio::null())
892 .output()
893 .with_context(|| format!("pre-push: cannot read commits in {shown}"))?;
894 if !out.status.success() {
895 let err = String::from_utf8_lossy(&out.stderr);
896 bail!("pre-push: cannot read commits in {shown} ({})", err.trim());
897 }
898 Ok(String::from_utf8_lossy(&out.stdout)
899 .lines()
900 .filter(|line| !line.is_empty())
901 .map(str::to_string)
902 .collect())
903}
904
905fn commit_body(sha: &str) -> Result<String> {
906 let out = git_command(&["log", "-1", "--format=%B", sha], &[])
907 .stdin(std::process::Stdio::null())
908 .output()
909 .with_context(|| format!("pre-push: cannot read {sha}"))?;
910 if !out.status.success() {
911 bail!("pre-push: cannot read {sha}");
912 }
913 Ok(String::from_utf8_lossy(&out.stdout).into_owned())
914}
915
916fn message_refused(body: &str) -> bool {
917 phrase_refused(&body.to_ascii_lowercase()) || body.lines().any(line_refused)
918}
919
920fn line_refused(line: &str) -> bool {
925 let lower = line.to_ascii_lowercase();
926 account_anywhere(&lower) || phrase_refused(&lower) || anchored_mark(&lower)
927}
928
929fn account_anywhere(lower: &str) -> bool {
931 lower.contains("cursor agent") || lower.contains("cursoragent")
932}
933
934fn phrase_refused(lower: &str) -> bool {
937 let rest = lower.trim_start_matches([' ', '\t']);
938 rest.starts_with("generated with") || rest.starts_with("made with cursor")
939}
940
941fn anchored_mark(lower: &str) -> bool {
942 let rest = lower.trim_start_matches([' ', '\t']);
943 made_with_mark(rest) || by_mark(rest) || cursor_assign(rest)
944}
945
946fn made_with_mark(rest: &str) -> bool {
947 let Some(after) = rest.strip_prefix("made-with") else {
948 return false;
949 };
950 after.trim_start_matches([' ', '\t']).starts_with(':')
951}
952
953fn by_mark(rest: &str) -> bool {
954 let Some(colon) = rest.find(':') else {
955 return false;
956 };
957 let token = rest[..colon].trim_end_matches([' ', '\t']);
958 let Some(name) = token.strip_suffix("-by") else {
959 return false;
960 };
961 !name.is_empty() && name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
962}
963
964fn cursor_assign(rest: &str) -> bool {
965 let Some(after_cursor) = rest.strip_prefix("cursor") else {
966 return false;
967 };
968 let after_sep = after_cursor
969 .strip_prefix('_')
970 .or_else(|| after_cursor.strip_prefix('-'))
971 .unwrap_or(after_cursor);
972 let Some(after_agent) = after_sep.strip_prefix("agent") else {
973 return false;
974 };
975 let mut end = 0;
976 for c in after_agent.chars() {
977 if c.is_ascii_alphanumeric() || c == '_' {
978 end += c.len_utf8();
979 } else {
980 break;
981 }
982 }
983 let after = after_agent[end..].trim_start_matches([' ', '\t']);
984 after.starts_with('=') || after.starts_with(':')
985}
986
987fn dropped_refusal(name: &str) -> Option<String> {
993 let raw = git_line(&["rev-parse", "--git-path", &format!("hooks/{name}")])?;
994 let path = std::env::current_dir().ok()?.join(raw);
995 let text = fs::read_to_string(&path).ok()?;
996 if !text.contains(BLOCK_BEGIN) || carries_block(&text, name) {
997 return None;
998 }
999 let verb = if name == "pre-push" { "push" } else { "commit" };
1000 Some(format!(
1001 "sitting-gate: {} predates this gate and drops its refusal, so git will {verb} anyway; {HOOKS_FIX} replaces it",
1002 path.display()
1003 ))
1004}
1005
1006fn gate(name: &str, refs: &str) -> Result<()> {
1015 let Some(cause) = crate::runner_cause() else {
1016 return Ok(());
1017 };
1018 let refused = match gate_runner(name, refs) {
1019 Ok(()) => return Ok(()),
1020 Err(refused) => refused,
1021 };
1022 if cause == crate::RunnerCause::Hard {
1023 return Err(refused);
1024 }
1025 let verb = if name == "pre-push" { "push" } else { "commit" };
1026 let marks = crate::runner_marks();
1027 match crate::person_key::ask_person(&marks, &format!("git {verb}")) {
1028 Ok(()) => Ok(()),
1029 Err(why) => {
1030 let detail = format!("{refused:#}");
1031 let detail = detail.strip_prefix("sitting-gate: ").unwrap_or(&detail);
1032 let text = crate::sitting_gate::for_marks(
1033 detail,
1034 &marks,
1035 verb,
1036 crate::person_key::is_set(),
1037 &why,
1038 );
1039 Err(anyhow!("sitting-gate: {text}"))
1040 }
1041 }
1042}
1043
1044fn gate_runner(name: &str, refs: &str) -> Result<()> {
1045 let repo = git_line(&["rev-parse", "--show-toplevel"])
1046 .map(PathBuf::from)
1047 .or_else(git_dir);
1048 if repo
1049 .as_deref()
1050 .is_some_and(|repo| is_store(repo) || holds_tracker(repo))
1051 {
1052 return Ok(());
1053 }
1054 if name != "pre-push" {
1056 return Ok(());
1057 }
1058 let seat = crate::git_hook_seat();
1065 let _actor = seat.as_deref().map(crate::HookActor::enter);
1066 let _push = crate::PushCheck::enter();
1067 let issue = match crate::sitting_gate::live_sitting() {
1068 Ok(issue) => issue,
1069 Err(_) => {
1070 let named = crate::sitting_gate::open_or_name_sitting().unwrap_or_default();
1071 if crate::push_slow() {
1072 if named.is_empty() {
1073 eprintln!("{}", crate::push_slow_line());
1074 } else {
1075 eprintln!("{}; sitting {named}", crate::push_slow_line());
1076 }
1077 }
1078 named
1079 }
1080 };
1081 let cfg = match crate::gates::consensus_push(repo.as_deref()) {
1082 Ok(cfg) => cfg,
1083 Err(why) => {
1084 return Err(anyhow!(
1085 "sitting-gate: {}",
1086 crate::sitting_gate::unread_settings(&why)
1087 ))
1088 }
1089 };
1090 let dest = protected_ref(refs, &cfg.branches);
1091 let Some(dest) = dest else {
1092 return Ok(());
1093 };
1094 if push_lines(refs, &cfg.branches)
1098 .iter()
1099 .any(|line| crate::user_rules::allows(line).is_some())
1100 {
1101 return Ok(());
1102 }
1103 if crate::push_tracker_slow() {
1106 return Err(anyhow!("sitting-gate: {}", crate::push_tracker_slow_line()));
1107 }
1108 if issue.is_empty() {
1109 let why = "the sitting has no settled consensus".to_string();
1110 let wait = crate::sitting_gate::seats_wait(&dest, &cfg.branches, &issue, &why);
1111 return Err(anyhow!("sitting-gate: {wait}"));
1112 }
1113 if let Err(reason) = crate::protected_push_refusal(&issue, &crate::seat_name(), cfg.quorum) {
1114 let why = format!("the sitting has no settled consensus: {reason}");
1115 let wait = crate::sitting_gate::seats_wait(&dest, &cfg.branches, &issue, &why);
1116 return Err(anyhow!("sitting-gate: {wait}"));
1117 }
1118 Ok(())
1119}
1120
1121fn push_lines(refs: &str, branches: &[String]) -> Vec<String> {
1127 let remote = std::env::args()
1128 .nth(3)
1129 .filter(|a| !a.is_empty() && !a.starts_with('-'))
1130 .unwrap_or_else(|| "origin".to_string());
1131 let mut out = Vec::new();
1132 for line in refs.lines().filter(|l| !l.trim().is_empty()) {
1133 let fields: Vec<&str> = line.split_whitespace().collect();
1134 let [local, _, remote_ref, _] = fields[..] else {
1135 continue;
1136 };
1137 let Some(branch) = remote_ref.strip_prefix("refs/heads/") else {
1138 continue;
1139 };
1140 if !branches.iter().any(|b| b.eq_ignore_ascii_case(branch)) {
1141 continue;
1142 }
1143 let short = local.strip_prefix("refs/heads/").unwrap_or(local);
1144 out.push(format!("git push {remote} {branch}"));
1145 out.push(format!("git push {remote} {short}:{branch}"));
1146 out.push(format!("git push {remote} {local}:{remote_ref}"));
1147 }
1148 out
1149}
1150
1151fn protected_ref(refs: &str, branches: &[String]) -> Option<String> {
1157 for line in refs.lines().filter(|l| !l.trim().is_empty()) {
1158 let fields: Vec<&str> = line.split_whitespace().collect();
1159 let [_, _, remote, _] = fields[..] else {
1160 return Some(format!("lists `{line}`, which is not a ref git would send"));
1161 };
1162 let Some(branch) = remote.strip_prefix("refs/heads/") else {
1163 continue;
1164 };
1165 if let Some(b) = branches.iter().find(|b| b.eq_ignore_ascii_case(branch)) {
1166 return Some(format!("sends `{b}`"));
1167 }
1168 }
1169 None
1170}
1171
1172fn holds_tracker(repo: &Path) -> bool {
1176 if repo.join("vissue.toml").is_file() {
1177 return true;
1178 }
1179 std::fs::read_dir(repo.join("Software")).is_ok_and(|entries| {
1180 entries
1181 .flatten()
1182 .any(|entry| entry.path().join("issues.org").is_file())
1183 })
1184}
1185
1186fn book_commit(hook: &str) -> Result<()> {
1187 if std::env::var_os("LJOS_GIT_HOOK").is_some() || booking_off() {
1188 return Ok(());
1189 }
1190 if foreign_session() {
1191 return Ok(());
1192 }
1193 if hook != "post-rewrite" && mid_rewrite() {
1194 return Ok(());
1195 }
1196 let Some(repo) = git_line(&["rev-parse", "--show-toplevel"]) else {
1197 return Ok(());
1198 };
1199 let repo_path = PathBuf::from(&repo);
1200 let tracker = tracker_repo(&repo_path);
1203 if !tracker && (crate::bookkeep::path_is_temp(Some(&repo_path)) || skip_repo(&repo_path)) {
1204 return Ok(());
1205 }
1206 let Some(head) = git_line(&["rev-parse", "HEAD"]) else {
1207 return Ok(());
1208 };
1209 let Some(seat) = crate::git_hook_seat() else {
1212 if tracker {
1213 refuse_tracker_commit(
1214 &head,
1215 "no seat is named; export LJOS_SEAT=<your seat> to write the tracker",
1216 );
1217 } else if crate::unseated_subagent() {
1218 println!(
1220 "ljos: commit {} not booked: this subagent has no seat; export LJOS_SEAT=<your seat> (the parent's) to book it",
1221 &head[..head.len().min(12)]
1222 );
1223 }
1224 return Ok(());
1225 };
1226 let _actor = crate::HookActor::enter(&seat);
1227 if let Err(e) = crate::write_seat("commit") {
1230 if tracker {
1231 refuse_tracker_commit(&head, &format!("{e:#}"));
1232 } else {
1233 println!(
1234 "ljos: commit {} not booked: {e:#}",
1235 &head[..head.len().min(12)]
1236 );
1237 }
1238 return Ok(());
1239 }
1240 if tracker {
1241 return Ok(());
1242 }
1243 let _children = HookChildren::enter();
1244 if already_booked(&repo, &head) {
1248 return Ok(());
1249 }
1250 let subject = git_line(&["log", "-1", "--format=%s"]).unwrap_or_default();
1251 println!(
1252 "{}",
1253 crate::bookkeep::note_after_commit(&repo, &head, &subject)
1254 );
1255 remember_booked(&repo, &head);
1256 Ok(())
1257}
1258
1259fn booking_off() -> bool {
1260 matches!(
1261 std::env::var("LJOS_OFF").ok().as_deref(),
1262 Some("1" | "true" | "TRUE" | "yes" | "YES" | "on" | "ON")
1263 ) || crate::bookkeep::guard_suspended()
1264}
1265
1266fn foreign_session() -> bool {
1267 ["CLAUDECODE", "CODEX_THREAD_ID", "CODEX_SESSION_ID"]
1271 .iter()
1272 .any(|key| std::env::var_os(key).is_some_and(|value| !value.is_empty()))
1273}
1274
1275fn mid_rewrite() -> bool {
1276 let Some(dir) = git_dir() else {
1277 return false;
1278 };
1279 dir.join("rebase-merge").is_dir()
1280 || dir.join("rebase-apply").is_dir()
1281 || dir.join("MERGE_HEAD").is_file()
1282}
1283
1284fn tracker_repo(repo: &Path) -> bool {
1286 ["VISSUE_ROOT", "ISSUE_ROOT"].iter().any(|key| {
1287 std::env::var_os(key)
1288 .filter(|value| !value.is_empty())
1289 .map(PathBuf::from)
1290 .is_some_and(|root| useful_root(&root) && covered(repo, &root))
1291 })
1292}
1293
1294fn refuse_tracker_commit(head: &str, why: &str) {
1298 let line = format!(
1299 "ljos: tracker commit {} was not made by a seat that may write the tracker: {why}",
1300 &head[..head.len().min(12)]
1301 );
1302 println!("{line}");
1303 crate::bookkeep::log_line(&format!("git-hooks: {line}"));
1304}
1305
1306fn skip_repo(repo: &Path) -> bool {
1307 if is_store(repo) {
1308 return true;
1309 }
1310 skip_roots().iter().any(|root| covered(repo, root))
1311}
1312
1313fn skip_roots() -> Vec<PathBuf> {
1317 let mut roots = vec![
1318 PathBuf::from("/tmp"),
1319 PathBuf::from("/var/tmp"),
1320 PathBuf::from("/dev/shm"),
1321 ];
1322 for key in ["VISSUE_ROOT", "ISSUE_ROOT", "CLAIMDAG_DIR"] {
1323 if let Some(path) = std::env::var_os(key).filter(|value| !value.is_empty()) {
1324 roots.push(PathBuf::from(path));
1325 }
1326 }
1327 roots.extend(store_dirs());
1328 for path in config_files() {
1329 roots.extend(configured_skips(&path));
1330 }
1331 roots.retain(|path| useful_root(path));
1332 roots
1333}
1334
1335fn useful_root(path: &Path) -> bool {
1336 let text = path.to_string_lossy();
1337 let text = text.trim();
1338 !text.is_empty() && text != "/" && text != "."
1339}
1340
1341fn config_files() -> Vec<PathBuf> {
1342 let mut out = Vec::new();
1343 let base = std::env::var_os("XDG_CONFIG_HOME")
1344 .filter(|value| !value.is_empty())
1345 .map(PathBuf::from)
1346 .or_else(|| {
1347 std::env::var_os("HOME")
1348 .filter(|value| !value.is_empty())
1349 .map(|home| PathBuf::from(home).join(".config"))
1350 });
1351 if let Some(base) = base {
1352 out.push(base.join("ljos").join("ljos.toml"));
1353 if let Some(seat) = std::env::var("LJOS_SEAT")
1354 .ok()
1355 .map(|name| name.trim().to_string())
1356 .filter(|name| !name.is_empty() && !name.contains('/') && !name.contains(".."))
1357 {
1358 out.push(base.join("ljos").join("seats").join(seat).join("ljos.toml"));
1359 }
1360 }
1361 if let Some(path) = std::env::var_os("LJOS_CONFIG").filter(|value| !value.is_empty()) {
1362 out.push(PathBuf::from(path));
1363 }
1364 if let Ok(cwd) = std::env::current_dir() {
1365 out.push(cwd.join("ljos.toml"));
1366 }
1367 out
1368}
1369
1370fn configured_skips(path: &Path) -> Vec<PathBuf> {
1371 let Ok(text) = fs::read_to_string(path) else {
1372 return Vec::new();
1373 };
1374 let Ok(value) = toml::from_str::<toml::Value>(&text) else {
1375 return Vec::new();
1376 };
1377 let Some(list) = value
1378 .get("git_hooks")
1379 .and_then(|table| table.get("skip"))
1380 .and_then(toml::Value::as_array)
1381 else {
1382 return Vec::new();
1383 };
1384 list.iter()
1385 .filter_map(toml::Value::as_str)
1386 .map(str::trim)
1387 .filter(|text| !text.is_empty())
1388 .map(PathBuf::from)
1389 .collect()
1390}
1391
1392fn covered(repo: &Path, root: &Path) -> bool {
1393 let repo = canonical_or(repo);
1394 let root = canonical_or(root);
1395 repo == root || repo.starts_with(&root)
1396}
1397
1398fn canonical_or(path: &Path) -> PathBuf {
1399 fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
1400}
1401
1402fn is_store(repo: &Path) -> bool {
1405 store_dirs()
1406 .iter()
1407 .any(|root| same_dir(repo, root) || repo_starts(repo, root))
1408}
1409
1410fn store_dirs() -> Vec<PathBuf> {
1411 let mut out = Vec::new();
1412 if let Ok(url) = std::env::var("DEEDAR_URL") {
1413 if let Some(path) = url.strip_prefix("file://") {
1414 out.push(PathBuf::from(path));
1415 }
1416 }
1417 if let Some(data) = std::env::var_os("XDG_DATA_HOME").filter(|value| !value.is_empty()) {
1418 out.push(PathBuf::from(data).join("deedar").join("store"));
1419 } else if let Some(home) = std::env::var_os("HOME").filter(|value| !value.is_empty()) {
1420 out.push(
1421 PathBuf::from(home)
1422 .join(".local")
1423 .join("share")
1424 .join("deedar")
1425 .join("store"),
1426 );
1427 }
1428 out
1429}
1430
1431fn same_dir(left: &Path, right: &Path) -> bool {
1432 let left = fs::canonicalize(left).unwrap_or_else(|_| left.to_path_buf());
1433 let right = fs::canonicalize(right).unwrap_or_else(|_| right.to_path_buf());
1434 left == right
1435}
1436
1437fn repo_starts(repo: &Path, root: &Path) -> bool {
1438 if !root.exists() {
1439 return false;
1440 }
1441 let repo = fs::canonicalize(repo).unwrap_or_else(|_| repo.to_path_buf());
1442 let root = fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
1443 repo.starts_with(root)
1444}
1445
1446fn already_booked(repo: &str, head: &str) -> bool {
1447 let key = format!("{repo}\t{head}");
1448 fs::read_to_string(booked_path())
1449 .map(|text| text.lines().any(|line| line == key))
1450 .unwrap_or(false)
1451}
1452
1453fn remember_booked(repo: &str, head: &str) {
1454 let path = booked_path();
1455 if let Some(dir) = path.parent() {
1456 let _ = fs::create_dir_all(dir);
1457 }
1458 let mut lines: Vec<String> = fs::read_to_string(&path)
1459 .unwrap_or_default()
1460 .lines()
1461 .map(str::to_string)
1462 .filter(|line| !line.starts_with(&format!("{repo}\t")))
1463 .collect();
1464 lines.push(format!("{repo}\t{head}"));
1465 if lines.len() > 200 {
1466 lines.drain(0..lines.len() - 200);
1467 }
1468 let _ = fs::write(path, lines.join("\n") + "\n");
1469}
1470
1471fn booked_path() -> PathBuf {
1472 crate::runtime_dir().join("git-hooks-booked")
1473}
1474
1475fn git_dir() -> Option<PathBuf> {
1476 let raw = git_line(&["rev-parse", "--git-dir"])?;
1477 let path = PathBuf::from(&raw);
1478 if path.is_absolute() {
1479 Some(path)
1480 } else {
1481 std::env::current_dir().ok().map(|cwd| cwd.join(path))
1482 }
1483}
1484
1485const HOOK_GIT_ENV: &[&str] = &[
1489 "GIT_DIR",
1490 "GIT_WORK_TREE",
1491 "GIT_INDEX_FILE",
1492 "GIT_PREFIX",
1493 "GIT_OBJECT_DIRECTORY",
1494 "GIT_COMMON_DIR",
1495 "GIT_NAMESPACE",
1496];
1497
1498thread_local! {
1499 static HOOK_CHILDREN: Cell<bool> = const { Cell::new(false) };
1500}
1501
1502#[must_use]
1507struct HookChildren {
1508 previous: bool,
1509}
1510
1511impl HookChildren {
1512 fn enter() -> Self {
1513 let previous = HOOK_CHILDREN.with(Cell::get);
1514 HOOK_CHILDREN.with(|flag| flag.set(true));
1515 Self { previous }
1516 }
1517}
1518
1519impl Drop for HookChildren {
1520 fn drop(&mut self) {
1521 HOOK_CHILDREN.with(|flag| flag.set(self.previous));
1522 }
1523}
1524
1525pub(crate) fn hook_child_env(cmd: &mut Command) {
1529 if !HOOK_CHILDREN.with(Cell::get) {
1530 return;
1531 }
1532 cmd.env("LJOS_GIT_HOOK", "1");
1533 for key in HOOK_GIT_ENV {
1534 cmd.env_remove(key);
1535 }
1536}
1537
1538#[cfg(test)]
1539pub(crate) fn with_hook_children_for_test<T>(run: impl FnOnce() -> T) -> T {
1540 let _guard = HookChildren::enter();
1541 run()
1542}
1543
1544fn git_command(args: &[&str], git_env: &[(&str, &str)]) -> Command {
1545 let mut cmd = crate::git_cmd();
1546 cmd.args(args);
1547 for (key, value) in git_env {
1548 cmd.env(key, value);
1549 }
1550 hook_child_env(&mut cmd);
1551 cmd
1552}
1553
1554fn git_line(args: &[&str]) -> Option<String> {
1555 let out = git_command(args, &[]).output().ok()?;
1556 if !out.status.success() {
1557 return None;
1558 }
1559 let text = String::from_utf8(out.stdout).ok()?;
1560 let text = text.trim();
1561 if text.is_empty() {
1562 None
1563 } else {
1564 Some(text.to_string())
1565 }
1566}
1567
1568fn global_hooks_path(git_env: &[(&str, &str)]) -> Option<String> {
1569 let out = git_command(&["config", "--global", "--get", "core.hooksPath"], git_env)
1570 .output()
1571 .ok()?;
1572 if !out.status.success() {
1573 return None;
1574 }
1575 let text = String::from_utf8(out.stdout).ok()?;
1576 let text = text.trim();
1577 if text.is_empty() {
1578 None
1579 } else {
1580 Some(text.to_string())
1581 }
1582}
1583
1584fn set_global_hooks_path(path: &Path, git_env: &[(&str, &str)]) -> Result<()> {
1585 let value = path.display().to_string();
1586 let status = git_command(&["config", "--global", "core.hooksPath", &value], git_env)
1587 .status()
1588 .context("git-hooks: git config")?;
1589 if !status.success() {
1590 bail!("git-hooks: could not set core.hooksPath");
1591 }
1592 Ok(())
1593}
1594
1595fn unset_global_hooks_path(git_env: &[(&str, &str)]) -> Result<()> {
1596 let status = git_command(
1597 &["config", "--global", "--unset", "core.hooksPath"],
1598 git_env,
1599 )
1600 .status()
1601 .context("git-hooks: git config")?;
1602 if !status.success() {
1603 bail!("git-hooks: could not unset core.hooksPath");
1604 }
1605 Ok(())
1606}
1607
1608#[cfg(test)]
1609mod tests {
1610 use std::ffi::OsStr;
1611
1612 use super::*;
1613
1614 fn mapped<'a>(cmd: &'a Command, key: &str) -> Option<Option<&'a OsStr>> {
1615 cmd.get_envs()
1616 .find(|(name, _)| *name == OsStr::new(key))
1617 .map(|(_, value)| value)
1618 }
1619
1620 #[test]
1621 fn the_doctor_names_a_hooks_path_that_is_not_the_onboard_one() {
1622 let dir = tempfile::tempdir().unwrap();
1623 let ours = dir.path().join("ljos").join("git-hooks");
1624 let other = dir.path().join("user-hooks");
1625 std::fs::create_dir_all(&other).unwrap();
1626 let prior = "#!/bin/sh\nprintf 'user-hook\\n'\n";
1627 std::fs::write(other.join("post-commit"), prior).unwrap();
1628 let ours_s = ours.display().to_string();
1629 let other_s = other.display().to_string();
1630
1631 let (ok, state) = super::hooks_state(&ours, None);
1632 assert!(!ok);
1633 assert!(state.starts_with(super::HOOKS_UNSET), "{state}");
1634 assert_eq!(state.matches(super::HOOKS_FIX).count(), 1, "{state}");
1635
1636 let (ok, state) = super::hooks_state(&ours, Some(&other_s));
1637 assert!(!ok, "{state}");
1638 assert!(state.contains(&other_s), "{state}");
1639 assert!(!state.contains(&ours_s), "{state}");
1640 assert_eq!(state.matches(super::HOOKS_FIX).count(), 1, "{state}");
1641
1642 super::chain_user_hooks(&other).unwrap();
1643 let (ok, state) = super::hooks_state(&ours, Some(&other_s));
1644 assert!(ok, "{state}");
1645 assert!(state.contains("ljos git-hook runs"), "{state}");
1646 let hooked = std::fs::read_to_string(other.join("post-commit")).unwrap();
1647 assert!(hooked.starts_with(prior), "{hooked}");
1648 assert_eq!(
1649 hooked.matches("# ljos:begin git-hook").count(),
1650 1,
1651 "{hooked}"
1652 );
1653 super::chain_user_hooks(&other).unwrap();
1654 assert_eq!(
1655 std::fs::read_to_string(other.join("post-commit")).unwrap(),
1656 hooked
1657 );
1658 assert!(other.join("pre-push").is_file());
1659
1660 super::strip_user_hooks(&other).unwrap();
1661 assert_eq!(
1662 std::fs::read_to_string(other.join("post-commit")).unwrap(),
1663 prior
1664 );
1665 assert!(!other.join("pre-push").exists());
1666
1667 let config = dir.path().join("config");
1668 std::fs::create_dir_all(&config).unwrap();
1669 std::fs::write(
1670 config.join("env.sh"),
1671 format!("export LJOS_GIT_HOOKS={}\n", other.display()),
1672 )
1673 .unwrap();
1674 let drift = super::env_hooks_drift(&config, &ours).expect("a drifted env file");
1675 assert!(drift.contains(&other_s), "{drift}");
1676 assert!(drift.contains(&ours_s), "{drift}");
1677 let (ok, state) = super::doctor_state(&ours, None, &config);
1678 assert!(!ok, "{state}");
1679 assert!(
1680 !state.starts_with(super::HOOKS_UNSET),
1681 "a mismatch stayed info: {state}"
1682 );
1683 assert!(state.contains(&other_s), "{state}");
1684 assert!(state.contains(&ours_s), "{state}");
1685
1686 std::fs::write(
1687 config.join("env.sh"),
1688 format!("{}\n", super::HOOKS_ENV_LINE),
1689 )
1690 .unwrap();
1691 assert!(super::env_hooks_drift(&config, &ours).is_none());
1692 let body = super::hook_body("post-rewrite");
1693 assert!(body.contains("mktemp"), "{body}");
1694 assert!(body.contains("git-hook post-rewrite"), "{body}");
1695 assert!(body.contains("< \"$_ljos_in\""), "{body}");
1696 let commit = super::hook_body("post-commit");
1697 assert!(!commit.contains("mktemp"), "{commit}");
1698 assert!(commit.contains("git-hook post-commit"), "{commit}");
1699
1700 super::write_owned_hooks(&ours).unwrap();
1701 let (ok, state) = super::hooks_state(&ours, Some(&ours_s));
1702 assert!(ok, "{state}");
1703 std::fs::remove_file(ours.join("pre-push")).unwrap();
1704 let (ok, state) = super::hooks_state(&ours, Some(&ours_s));
1705 assert!(!ok, "{state}");
1706 assert!(state.contains(super::HOOKS_FIX), "{state}");
1707 }
1708
1709 const OLD_PRE_PUSH: &str = "# ljos:begin git-hook\n\
1712 if command -v ljos >/dev/null 2>&1; then\n\
1713 \tljos git-hook pre-push \"$@\" >&2 || true\n\
1714 else\n\
1715 \t'/usr/local/bin/ljos' git-hook pre-push \"$@\" >&2 || true\n\
1716 fi\n\
1717 # ljos:end git-hook\n";
1718
1719 #[test]
1720 fn the_doctor_fails_a_pre_push_stub_that_drops_a_refusal() {
1721 let dir = tempfile::tempdir().unwrap();
1722 let ours = dir.path().join("git-hooks");
1723 let ours_s = ours.display().to_string();
1724 let config = dir.path().join("config");
1725 super::write_owned_hooks(&ours).unwrap();
1726 let (ok, state) = super::doctor_state(&ours, Some(&ours_s), &config);
1727 assert!(ok, "{state}");
1728
1729 let fresh = super::hook_body("pre-push");
1730 let stale = fresh.replace(&super::hook_block("pre-push"), OLD_PRE_PUSH);
1731 assert_ne!(stale, fresh);
1732 std::fs::write(ours.join("pre-push"), &stale).unwrap();
1733 let (ok, state) = super::doctor_state(&ours, Some(&ours_s), &config);
1734 assert!(!ok, "a stale pre-push stub passed: {state}");
1735 assert!(state.contains("pre-push stub predates"), "{state}");
1736 assert_eq!(state.matches(super::HOOKS_FIX).count(), 1, "{state}");
1737
1738 let moved = fresh.replace(
1739 &super::shell_quote(&super::ljos_executable()),
1740 "'/opt/other/ljos'",
1741 );
1742 assert_ne!(moved, fresh);
1743 std::fs::write(ours.join("pre-push"), moved).unwrap();
1744 let (ok, state) = super::doctor_state(&ours, Some(&ours_s), &config);
1745 assert!(ok, "another fallback path failed the row: {state}");
1746
1747 let user = dir.path().join("user-hooks");
1748 super::chain_user_hooks(&user).unwrap();
1749 let user_s = user.display().to_string();
1750 let prior = format!("#!/bin/sh\necho mine\n{OLD_PRE_PUSH}");
1751 std::fs::write(user.join("pre-push"), &prior).unwrap();
1752 let (ok, state) = super::hooks_state(&ours, Some(&user_s));
1753 assert!(!ok, "a stale user block passed: {state}");
1754 assert!(state.contains("pre-push hook's ljos block"), "{state}");
1755 super::chain_user_hooks(&user).unwrap();
1756 let (ok, state) = super::hooks_state(&ours, Some(&user_s));
1757 assert!(ok, "install left the stale block: {state}");
1758 let hooked = std::fs::read_to_string(user.join("pre-push")).unwrap();
1759 assert!(hooked.starts_with("#!/bin/sh\necho mine\n"), "{hooked}");
1760 assert_eq!(hooked.matches("# ljos:begin git-hook").count(), 1);
1761 assert!(!hooked.contains("\"$@\" >&2 || true"), "{hooked}");
1762 }
1763
1764 #[test]
1765 fn a_hook_that_does_not_book_succeeds() {
1766 super::run_named("post-checkout").unwrap();
1767 }
1768
1769 #[test]
1770 fn a_pre_push_ref_list_names_the_protected_branch_it_sends() {
1771 let branches = vec!["develop".to_string(), "main".to_string()];
1772 let line = |dst: &str| format!("refs/heads/x 1111 {dst} 0000\n");
1773 assert_eq!(protected_ref("", &branches), None);
1774 assert_eq!(protected_ref(&line("refs/heads/topic"), &branches), None);
1775 assert_eq!(protected_ref(&line("refs/tags/main"), &branches), None);
1776 let both = line("refs/heads/topic") + &line("refs/heads/Main");
1777 assert_eq!(
1778 protected_ref(&both, &branches).as_deref(),
1779 Some("sends `main`")
1780 );
1781 let odd = protected_ref("refs/heads/x main\n", &branches).unwrap();
1782 assert!(odd.contains("not a ref git would send"), "{odd}");
1783 }
1784
1785 #[test]
1786 fn hook_children_carry_the_guard_without_touching_the_process() {
1787 let before_hook = std::env::var_os("LJOS_GIT_HOOK");
1788 let before_dir = std::env::var_os("GIT_DIR");
1789 let mut plain = Command::new("true");
1790 plain.env("GIT_DIR", "keep");
1791 hook_child_env(&mut plain);
1792 assert_eq!(mapped(&plain, "GIT_DIR"), Some(Some(OsStr::new("keep"))));
1793 assert_eq!(mapped(&plain, "LJOS_GIT_HOOK"), None);
1794
1795 let _guard = HookChildren::enter();
1796 let mut cmd = Command::new("true");
1797 cmd.env("GIT_DIR", "from-the-hook");
1798 cmd.env("PATH", "/usr/bin");
1799 hook_child_env(&mut cmd);
1800 assert_eq!(mapped(&cmd, "LJOS_GIT_HOOK"), Some(Some(OsStr::new("1"))));
1801 for key in HOOK_GIT_ENV {
1802 assert_eq!(mapped(&cmd, key), Some(None), "{key}");
1803 }
1804 assert_eq!(mapped(&cmd, "PATH"), Some(Some(OsStr::new("/usr/bin"))));
1805 assert_eq!(std::env::var_os("LJOS_GIT_HOOK"), before_hook);
1806 assert_eq!(std::env::var_os("GIT_DIR"), before_dir);
1807 }
1808
1809 #[test]
1810 fn a_tests_paragraph_is_kept_and_a_by_line_is_refused() {
1811 assert!(!super::line_refused("Tests: cargo test"));
1812 assert!(!super::message_refused("note\n\nTests: cargo test\n"));
1813 assert!(super::line_refused("Co-authored-by: Ada <ada@example.com>"));
1814 assert!(super::line_refused("Signed-off-by: Ada <ada@example.com>"));
1815 assert!(super::message_refused(
1816 "note\nCo-authored-by: Ada <ada@example.com>\n"
1817 ));
1818 assert!(super::line_refused("Made-with: a tool"));
1819 assert!(super::line_refused("Generated with a tool"));
1820 assert!(super::line_refused("made with cursor"));
1821 assert!(!super::line_refused(
1822 "See Co-authored-by: Ada <ada@example.com>"
1823 ));
1824 assert!(super::line_refused("Cursor Agent wrote this"));
1825 assert!(super::line_refused("cursoragent"));
1826 assert!(super::line_refused("see cursoragent in the note"));
1827 assert!(super::line_refused("see a cursor agent in the note"));
1828 assert!(!super::line_refused("the plot was made with cursor tools"));
1829 assert!(super::line_refused("CURSOR_AGENT=1"));
1830 assert!(super::line_refused(" CURSOR_AGENT=1"));
1831 assert!(super::line_refused("CURSOR_AGENT: 1"));
1832 assert!(super::line_refused("Co-authored-by : x"));
1833 assert!(super::line_refused(" Co-authored-by: x"));
1834 assert!(super::line_refused("Made-with : x"));
1835 assert!(super::line_refused(" Made-with: x"));
1836 assert!(!super::line_refused("Note: kept"));
1837 assert!(!super::line_refused("The Signed-off-by line is gone."));
1838 assert!(!super::line_refused("see CURSOR_AGENT in the prose"));
1839 assert!(super::line_refused("\tCo-authored-by: A"));
1840 assert!(super::line_refused("Co-authored-by\t: A"));
1841 assert!(super::line_refused("\tCURSOR_AGENT=1"));
1842 assert!(super::line_refused("CURSOR_AGENT\t=1"));
1843 assert!(!super::line_refused(
1844 "The figures are generated with matplotlib."
1845 ));
1846 assert!(!super::line_refused("Tests: ran cargo test"));
1847 assert!(!super::line_refused("Ask the author by mail."));
1848 assert!(!super::line_refused("Signed-off-by"));
1849 }
1850}