1use std::cell::Cell;
42use std::fs;
43use std::path::{Path, PathBuf};
44use std::process::Command;
45
46use anyhow::{anyhow, bail, Context, Result};
47
48const HOOK_NAMES: &[&str] = &[
51 "applypatch-msg",
52 "pre-applypatch",
53 "post-applypatch",
54 "pre-commit",
55 "pre-merge-commit",
56 "prepare-commit-msg",
57 "commit-msg",
58 "post-commit",
59 "pre-rebase",
60 "post-checkout",
61 "post-merge",
62 "pre-push",
63 "pre-receive",
64 "update",
65 "proc-receive",
66 "post-receive",
67 "post-update",
68 "reference-transaction",
69 "push-to-checkout",
70 "pre-auto-gc",
71 "post-rewrite",
72 "sendemail-validate",
73 "fsmonitor-watchman",
74 "p4-changelist",
75 "p4-prepare-changelist",
76 "p4-post-changelist",
77 "p4-pre-submit",
78 "post-index-change",
79];
80
81fn books_commit(name: &str) -> bool {
82 matches!(name, "post-commit" | "post-rewrite")
83}
84
85fn gates_git(name: &str) -> bool {
86 matches!(name, "pre-commit" | "pre-push")
87}
88
89pub fn run(action: &str) -> Result<()> {
95 match action {
96 "install" => {
97 let dir = install()?;
98 println!("installed {}", dir.display());
99 Ok(())
100 }
101 "uninstall" => {
102 uninstall()?;
103 println!("uninstalled");
104 Ok(())
105 }
106 "status" => {
107 print!("{}", status());
108 Ok(())
109 }
110 other => bail!("git-hooks: {other} is not install, uninstall, or status"),
111 }
112}
113
114pub fn install() -> Result<PathBuf> {
124 install_at(&hooks_dir(), &[])
125}
126
127pub(crate) fn install_at(dir: &Path, git_env: &[(&str, &str)]) -> Result<PathBuf> {
137 let current = global_hooks_path(git_env);
138 if let Some(path) = nonempty(current.as_deref()) {
139 if !same_dir(Path::new(path), dir) {
140 chain_user_hooks(Path::new(path))?;
141 return Ok(PathBuf::from(path));
142 }
143 }
144 write_owned_hooks(dir)?;
145 if nonempty(current.as_deref()).is_none() {
146 let _ = fs::remove_file(dir.join("previous-hooks-path"));
147 set_global_hooks_path(dir, git_env)?;
148 }
149 Ok(dir.to_path_buf())
150}
151
152pub fn uninstall() -> Result<()> {
163 let dir = hooks_dir();
164 let current = global_hooks_path(&[]);
165 match nonempty(current.as_deref()) {
166 Some(path) if same_dir(Path::new(path), &dir) => {
167 let previous = fs::read_to_string(dir.join("previous-hooks-path"))
168 .ok()
169 .map(|text| text.trim().to_string())
170 .filter(|text| !text.is_empty());
171 if let Some(prev) = previous {
172 set_global_hooks_path(Path::new(&prev), &[])?;
173 } else {
174 unset_global_hooks_path(&[])?;
175 }
176 remove_owned_hooks(&dir);
177 }
178 Some(path) => strip_user_hooks(Path::new(path))?,
179 None => remove_owned_hooks(&dir),
180 }
181 Ok(())
182}
183
184#[must_use]
186pub fn status() -> String {
187 let dir = hooks_dir();
188 let configured_path = global_hooks_path(&[]);
189 let configured = configured_path.clone().unwrap_or_else(|| "(unset)".into());
190 let present = nonempty(configured_path.as_deref())
191 .is_some_and(|path| hooks_present(Path::new(path), &dir));
192 let stale = nonempty(configured_path.as_deref())
193 .filter(|_| present)
194 .and_then(|path| stale_note(Path::new(path), &dir));
195 let hooks = if present {
196 configured.clone()
197 } else if owned_installed(&dir) {
198 dir.display().to_string()
199 } else {
200 "not installed".into()
201 };
202 let reach = if present && stale.is_none() {
203 "ljos hooks run".to_string()
204 } else if let Some(note) = stale {
205 format!("ljos hooks run, but {note}; {HOOKS_FIX}")
206 } else {
207 format!("ljos hooks do not run; {HOOKS_FIX}")
208 };
209 format!("hooks: {hooks}\ncore.hooksPath: {configured}\n{reach}\n")
210}
211
212pub const HOOKS_UNSET: &str = "core.hooksPath is unset";
216
217pub const HOOKS_FIX: &str = "ljos git-hooks install";
219
220#[must_use]
228pub fn doctor_row() -> crate::Habitat {
229 let dir = hooks_dir();
230 let (ok, state) = doctor_state(&dir, global_hooks_path(&[]).as_deref(), &config_ljos_dir());
231 crate::Habitat {
232 name: "git hooks",
233 state,
234 ok,
235 }
236}
237
238#[must_use]
241pub fn doctor_state(dir: &Path, configured: Option<&str>, config_dir: &Path) -> (bool, String) {
242 let (mut ok, mut state) = hooks_state(dir, configured);
243 if let Some(drift) = env_hooks_drift(config_dir, dir) {
244 ok = false;
245 state = if state.starts_with(HOOKS_UNSET) {
247 drift
248 } else {
249 format!("{state}; {drift}")
250 };
251 }
252 (ok, state)
253}
254
255#[must_use]
259pub fn env_hooks_drift(config_dir: &Path, onboard_dir: &Path) -> Option<String> {
260 let mut files = Vec::new();
261 for name in ["env.sh", "env"] {
262 let path = config_dir.join(name);
263 if path.is_file() {
264 files.push(path);
265 }
266 }
267 if let Ok(entries) = fs::read_dir(config_dir) {
268 let mut more: Vec<PathBuf> = entries
269 .flatten()
270 .map(|entry| entry.path())
271 .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("env"))
272 .collect();
273 more.sort();
274 files.extend(more);
275 }
276 let ours = onboard_dir.display().to_string();
277 for path in files {
278 let Ok(text) = fs::read_to_string(&path) else {
279 continue;
280 };
281 for line in text.lines() {
282 let Some(value) = hooks_assignment(line) else {
283 continue;
284 };
285 if assignment_agrees(&value, onboard_dir) {
286 continue;
287 }
288 return Some(format!(
289 "{} sets LJOS_GIT_HOOKS to {value}, not {ours}, the hooks `ljos onboard` installs",
290 path.display()
291 ));
292 }
293 }
294 None
295}
296
297fn hooks_assignment(line: &str) -> Option<String> {
298 let line = line.trim();
299 if line.is_empty() || line.starts_with('#') {
300 return None;
301 }
302 let rest = line.strip_prefix("export ").unwrap_or(line).trim();
303 let rest = rest.strip_prefix("LJOS_GIT_HOOKS=")?;
304 let value = unquote_assignment(rest.trim());
305 if value.is_empty() {
306 None
307 } else {
308 Some(value)
309 }
310}
311
312fn unquote_assignment(value: &str) -> String {
313 let value = value.trim();
314 if let Some(inner) = value
315 .strip_prefix('"')
316 .and_then(|text| text.strip_suffix('"'))
317 {
318 return inner.to_string();
319 }
320 if let Some(inner) = value
321 .strip_prefix('\'')
322 .and_then(|text| text.strip_suffix('\''))
323 {
324 return inner.to_string();
325 }
326 value.split_whitespace().next().unwrap_or("").to_string()
327}
328
329fn assignment_agrees(value: &str, onboard: &Path) -> bool {
330 if value.contains("${XDG_DATA_HOME:-$HOME/.local/share}/ljos/git-hooks") {
331 return true;
332 }
333 if value.contains('$') || value.contains('`') {
334 return false;
335 }
336 same_dir(Path::new(value), onboard)
337}
338
339fn config_ljos_dir() -> PathBuf {
340 let base = std::env::var_os("XDG_CONFIG_HOME")
341 .filter(|value| !value.is_empty())
342 .map(PathBuf::from)
343 .or_else(|| {
344 std::env::var_os("HOME")
345 .filter(|value| !value.is_empty())
346 .map(|home| PathBuf::from(home).join(".config"))
347 })
348 .unwrap_or_else(|| PathBuf::from(".config"));
349 base.join("ljos")
350}
351
352pub const HOOKS_ENV_LINE: &str = "\
355# Git hooks directory. `ljos git-hooks install` writes the stubs here.\n\
356export LJOS_GIT_HOOKS=\"${LJOS_GIT_HOOKS:-${XDG_DATA_HOME:-$HOME/.local/share}/ljos/git-hooks}\"";
357
358#[must_use]
361pub fn hooks_state(ours: &Path, configured: Option<&str>) -> (bool, String) {
362 let Some(path) = nonempty(configured) else {
363 return (
364 false,
365 format!("{HOOKS_UNSET}, so commits are not booked; {HOOKS_FIX}"),
366 );
367 };
368 let dir = Path::new(path);
369 if hooks_reachable(dir, ours) {
370 let state = if same_dir(dir, ours) {
371 format!("core.hooksPath is {path}; commits are booked")
372 } else {
373 format!("core.hooksPath is {path}; ljos git-hook runs from those hooks")
374 };
375 return (true, state);
376 }
377 let stale = if hooks_present(dir, ours) {
378 stale_note(dir, ours)
379 } else {
380 None
381 };
382 let state = if let Some(note) = stale {
383 format!("core.hooksPath is {path}, but {note}; {HOOKS_FIX}")
384 } else if same_dir(dir, ours) {
385 format!("core.hooksPath is {path}, but some hook stubs are missing there; {HOOKS_FIX}")
386 } else {
387 format!("core.hooksPath is {path}, and ljos is not chained there; {HOOKS_FIX}")
388 };
389 (false, state)
390}
391
392fn hooks_dir() -> PathBuf {
393 if let Some(dir) = std::env::var_os("LJOS_GIT_HOOKS").filter(|value| !value.is_empty()) {
394 return PathBuf::from(dir);
395 }
396 data_home().join("ljos").join("git-hooks")
397}
398
399pub(crate) fn onboard_hooks_dir() -> PathBuf {
400 hooks_dir()
401}
402
403pub(crate) fn onboard_hook_paths(dir: &Path) -> Vec<PathBuf> {
404 HOOK_NAMES.iter().map(|name| dir.join(name)).collect()
405}
406
407pub(crate) fn configured_hooks_path(git_env: &[(&str, &str)]) -> Option<PathBuf> {
408 let configured = global_hooks_path(git_env);
409 nonempty(configured.as_deref()).map(PathBuf::from)
410}
411
412pub(crate) fn onboarding_installed() -> bool {
413 let dir = hooks_dir();
414 let configured = global_hooks_path(&[]);
415 match nonempty(configured.as_deref()) {
416 Some(path) if same_dir(Path::new(path), &dir) => owned_installed(&dir),
417 Some(path) => hooks_present(Path::new(path), &dir),
418 None => owned_installed(&dir),
419 }
420}
421
422fn data_home() -> PathBuf {
423 if let Some(dir) = std::env::var_os("XDG_DATA_HOME").filter(|value| !value.is_empty()) {
424 return PathBuf::from(dir);
425 }
426 std::env::var_os("HOME")
427 .filter(|value| !value.is_empty())
428 .map(|home| PathBuf::from(home).join(".local").join("share"))
429 .unwrap_or_else(|| PathBuf::from("."))
430}
431
432const BLOCK_BEGIN: &str = "# ljos:begin git-hook";
433const BLOCK_END: &str = "# ljos:end git-hook";
434
435fn nonempty(value: Option<&str>) -> Option<&str> {
436 value.map(str::trim).filter(|path| !path.is_empty())
437}
438
439fn ljos_executable() -> String {
440 std::env::current_exe()
441 .map(|path| path.display().to_string())
442 .unwrap_or_else(|_| "ljos".to_string())
443}
444
445fn hook_block(name: &str) -> String {
453 hook_block_for(name, &shell_quote(&ljos_executable()))
454}
455
456fn hook_block_for(name: &str, bin: &str) -> String {
458 let (otherwise, fail) = if gates_git(name) {
459 (format!("elif [ -x {bin} ]; then"), "exit 1")
460 } else {
461 ("else".to_string(), "true")
462 };
463 if matches!(name, "post-rewrite" | "pre-push") {
464 return format!(
465 "{BLOCK_BEGIN}\n\
466 if [ -z \"${{_ljos_in:-}}\" ]; then\n\
467 \t_ljos_in=$(mktemp 2>/dev/null || true)\n\
468 \tif [ -n \"$_ljos_in\" ]; then\n\
469 \t\tcat > \"$_ljos_in\" || true\n\
470 \tfi\n\
471 fi\n\
472 if command -v ljos >/dev/null 2>&1; then\n\
473 \tif [ -n \"${{_ljos_in:-}}\" ]; then\n\
474 \t\tljos git-hook {name} \"$@\" < \"$_ljos_in\" >&2 || {fail}\n\
475 \telse\n\
476 \t\tljos git-hook {name} \"$@\" >&2 || {fail}\n\
477 \tfi\n\
478 {otherwise}\n\
479 \tif [ -n \"${{_ljos_in:-}}\" ]; then\n\
480 \t\t{bin} git-hook {name} \"$@\" < \"$_ljos_in\" >&2 || {fail}\n\
481 \telse\n\
482 \t\t{bin} git-hook {name} \"$@\" >&2 || {fail}\n\
483 \tfi\n\
484 fi\n\
485 {BLOCK_END}\n"
486 );
487 }
488 format!(
489 "{BLOCK_BEGIN}\n\
490 if command -v ljos >/dev/null 2>&1; then\n\
491 \tljos git-hook {name} \"$@\" >&2 || {fail}\n\
492 {otherwise}\n\
493 \t{bin} git-hook {name} \"$@\" >&2 || {fail}\n\
494 fi\n\
495 {BLOCK_END}\n"
496 )
497}
498
499fn has_block(text: &str, name: &str) -> bool {
500 text.contains(&hook_block(name))
501}
502
503fn carries_block(text: &str, name: &str) -> bool {
508 let template = hook_block_for(name, "\0");
510 let mut parts = template.split('\0');
511 let (Some(head), Some(next)) = (parts.next(), parts.next()) else {
512 return text.contains(&template);
513 };
514 text.match_indices(head).any(|(at, _)| {
515 let rest = &text[at + head.len()..];
516 let line = rest.find('\n').unwrap_or(rest.len());
517 rest.match_indices(next)
518 .take_while(|(end, _)| *end <= line)
519 .any(|(end, _)| rest.starts_with(&template.replace('\0', &rest[..end])[head.len()..]))
520 })
521}
522
523fn strip_block(text: &str) -> String {
524 let Some(start) = text.find(BLOCK_BEGIN) else {
525 return text.to_string();
526 };
527 let Some(rel) = text[start..].find(BLOCK_END) else {
528 return text.to_string();
529 };
530 let mut end = start + rel + BLOCK_END.len();
531 if text[end..].starts_with('\n') {
532 end += 1;
533 }
534 let mut out = String::new();
535 out.push_str(&text[..start]);
536 out.push_str(&text[end..]);
537 out
538}
539
540fn strip_all(text: &str) -> String {
541 let mut out = text.to_string();
542 loop {
543 let next = strip_block(&out);
544 if next == out {
545 return out;
546 }
547 out = next;
548 }
549}
550
551fn merge_block(text: &str, name: &str) -> String {
552 if has_block(text, name) {
553 return text.to_string();
554 }
555 let stripped = strip_all(text);
556 let block = hook_block(name);
557 if stripped.trim().is_empty() {
558 return format!("#!/bin/sh\n{block}");
559 }
560 if stripped.ends_with('\n') {
561 format!("{stripped}{block}")
562 } else {
563 format!("{stripped}\n{block}")
564 }
565}
566
567fn block_only(text: &str) -> bool {
568 matches!(text.trim(), "" | "#!/bin/sh")
569}
570
571fn hook_calls_ljos(text: &str, name: &str) -> bool {
572 text.contains(&format!("ljos git-hook {name} "))
573}
574
575fn is_executable(path: &Path) -> bool {
576 #[cfg(unix)]
577 {
578 use std::os::unix::fs::PermissionsExt;
579 fs::metadata(path)
580 .map(|meta| meta.permissions().mode() & 0o111 != 0)
581 .unwrap_or(false)
582 }
583 #[cfg(not(unix))]
584 {
585 path.is_file()
586 }
587}
588
589fn ensure_executable(path: &Path) -> Result<()> {
590 #[cfg(unix)]
591 {
592 use std::os::unix::fs::PermissionsExt;
593 let meta = fs::metadata(path).with_context(|| format!("git-hooks: {}", path.display()))?;
594 let mode = meta.permissions().mode();
595 if mode & 0o111 == 0 {
596 fs::set_permissions(path, fs::Permissions::from_mode(mode | 0o111))?;
597 }
598 }
599 Ok(())
600}
601
602fn owned_installed(dir: &Path) -> bool {
603 HOOK_NAMES.iter().all(|name| {
604 let path = dir.join(name);
605 let Ok(text) = fs::read_to_string(&path) else {
606 return false;
607 };
608 is_executable(&path) && (text.contains("#!/bin/sh") || hook_calls_ljos(&text, name))
609 })
610}
611
612fn user_chained(dir: &Path) -> bool {
613 HOOK_NAMES.iter().all(|name| {
614 let path = dir.join(name);
615 let Ok(text) = fs::read_to_string(&path) else {
616 return false;
617 };
618 is_executable(&path) && hook_calls_ljos(&text, name)
619 })
620}
621
622fn hooks_present(configured: &Path, ours: &Path) -> bool {
623 if same_dir(configured, ours) {
624 owned_installed(configured)
625 } else {
626 user_chained(configured)
627 }
628}
629
630fn stale_gate(dir: &Path) -> Option<&'static str> {
634 HOOK_NAMES
635 .iter()
636 .copied()
637 .filter(|name| gates_git(name))
638 .find(|name| {
639 !fs::read_to_string(dir.join(name)).is_ok_and(|text| carries_block(&text, name))
640 })
641}
642
643fn stale_note(configured: &Path, ours: &Path) -> Option<String> {
645 let name = stale_gate(configured)?;
646 let verb = if name == "pre-push" { "push" } else { "commit" };
647 let what = if same_dir(configured, ours) {
648 "stub"
649 } else {
650 "hook's ljos block"
651 };
652 Some(format!(
653 "the {name} {what} predates the current gate and lets a refused {verb} through"
654 ))
655}
656
657fn hooks_reachable(configured: &Path, ours: &Path) -> bool {
658 hooks_present(configured, ours) && stale_gate(configured).is_none()
659}
660
661fn write_owned_hooks(dir: &Path) -> Result<()> {
662 fs::create_dir_all(dir).with_context(|| format!("git-hooks: {}", dir.display()))?;
663 for name in HOOK_NAMES {
664 let path = dir.join(name);
665 fs::write(&path, hook_body(name))
666 .with_context(|| format!("git-hooks: {}", path.display()))?;
667 #[cfg(unix)]
668 {
669 use std::os::unix::fs::PermissionsExt;
670 fs::set_permissions(&path, fs::Permissions::from_mode(0o755))?;
671 }
672 }
673 Ok(())
674}
675
676fn chain_user_hooks(dir: &Path) -> Result<()> {
677 fs::create_dir_all(dir).with_context(|| format!("git-hooks: {}", dir.display()))?;
678 for name in HOOK_NAMES {
679 let path = dir.join(name);
680 let existing = if path.is_file() {
681 Some(fs::read(&path).with_context(|| format!("git-hooks: {}", path.display()))?)
682 } else {
683 None
684 };
685 if let Some(bytes) = &existing {
686 if bytes.contains(&0) || std::str::from_utf8(bytes).is_err() {
687 eprintln!("ljos: {} is not a shell script; left it", path.display());
688 continue;
689 }
690 }
691 let text = existing
692 .as_deref()
693 .map(|bytes| std::str::from_utf8(bytes).unwrap_or(""))
694 .unwrap_or("");
695 let next = merge_block(text, name);
696 if existing.is_none() || next != text {
697 fs::write(&path, next).with_context(|| format!("git-hooks: {}", path.display()))?;
698 }
699 ensure_executable(&path)?;
700 }
701 Ok(())
702}
703
704fn strip_user_hooks(dir: &Path) -> Result<()> {
705 if !dir.is_dir() {
706 return Ok(());
707 }
708 for name in HOOK_NAMES {
709 let path = dir.join(name);
710 let Ok(bytes) = fs::read(&path) else {
711 continue;
712 };
713 let Ok(text) = std::str::from_utf8(&bytes) else {
714 continue;
715 };
716 if !text.contains(BLOCK_BEGIN) {
717 continue;
718 }
719 let stripped = strip_all(text);
720 if block_only(&stripped) {
721 fs::remove_file(&path).with_context(|| format!("git-hooks: {}", path.display()))?;
722 } else if stripped != text {
723 fs::write(&path, stripped).with_context(|| format!("git-hooks: {}", path.display()))?;
724 }
725 }
726 Ok(())
727}
728
729fn remove_owned_hooks(dir: &Path) {
730 for name in HOOK_NAMES {
731 let _ = fs::remove_file(dir.join(name));
732 }
733 let _ = fs::remove_file(dir.join("previous-hooks-path"));
734}
735
736fn hook_body(name: &str) -> String {
737 let block = hook_block(name);
738 format!(
739 "#!/bin/sh\n\
740 # ljos books the commit, then chains to this repository's\n\
741 # .git/hooks/{name}, or to the core.hooksPath recorded in\n\
742 # previous-hooks-path. Booking cannot fail the hook; a refusal\n\
743 # from ljos in pre-commit or pre-push does. The chained hook's\n\
744 # exit status is kept. post-rewrite and pre-push keep a copy of\n\
745 # stdin for that hook.\n\
746 #\n\
747 # pre-commit install follows core.hooksPath and would replace this\n\
748 # file. Install the framework into the repository, then re-run\n\
749 # `ljos git-hooks install` if a stub was overwritten:\n\
750 # dir=$(git rev-parse --git-dir)\n\
751 # git config --local core.hooksPath \"$dir/hooks\"\n\
752 # pre-commit install\n\
753 # git config --local --unset core.hooksPath\n\
754 {block}\
755 git_dir=$(git rev-parse --git-dir 2>/dev/null || true)\n\
756 if [ -n \"$git_dir\" ]; then\n\
757 \tcase \"$git_dir\" in\n\
758 \t\t/*) ;;\n\
759 \t\t*) git_dir=$(pwd)/$git_dir ;;\n\
760 \tesac\n\
761 \trepo_hook=\"$git_dir/hooks/{name}\"\n\
762 \tif [ -x \"$repo_hook\" ] && [ \"$repo_hook\" != \"$0\" ]; then\n\
763 \t\tif [ -n \"${{_ljos_in:-}}\" ]; then\n\
764 \t\t\texec \"$repo_hook\" \"$@\" < \"$_ljos_in\"\n\
765 \t\tfi\n\
766 \t\texec \"$repo_hook\" \"$@\"\n\
767 \tfi\n\
768 fi\n\
769 here=$(dirname \"$0\")\n\
770 here=$(CDPATH= cd -- \"$here\" && pwd) || exit 0\n\
771 prev=$(sed -n '1s/[[:space:]]*$//p' \"$here/previous-hooks-path\" 2>/dev/null || true)\n\
772 if [ -n \"$prev\" ] && [ \"$prev\" != \"$here\" ]; then\n\
773 \tprev_hook=\"$prev/{name}\"\n\
774 \tif [ -x \"$prev_hook\" ] && [ \"$prev_hook\" != \"$0\" ]; then\n\
775 \t\tif [ -n \"${{_ljos_in:-}}\" ]; then\n\
776 \t\t\texec \"$prev_hook\" \"$@\" < \"$_ljos_in\"\n\
777 \t\tfi\n\
778 \t\texec \"$prev_hook\" \"$@\"\n\
779 \tfi\n\
780 fi\n\
781 exit 0\n"
782 )
783}
784
785fn shell_quote(text: &str) -> String {
786 format!("'{}'", text.replace('\'', "'\\''"))
787}
788
789pub fn run_git_commit(hook: &str) -> Result<()> {
795 if let Err(err) = book_commit(hook) {
796 crate::bookkeep::log_line(&format!("git-hooks: {err:#}"));
797 }
798 Ok(())
799}
800
801pub fn run_named(name: &str) -> Result<()> {
812 let name = name.trim();
813 if books_commit(name) {
814 run_git_commit(name)
815 } else if gates_git(name) {
816 gate(name).map_err(|err| match dropped_refusal(name) {
817 Some(why) => anyhow!("{err:#}\n{why}"),
818 None => err,
819 })
820 } else {
821 Ok(())
822 }
823}
824
825fn dropped_refusal(name: &str) -> Option<String> {
831 let raw = git_line(&["rev-parse", "--git-path", &format!("hooks/{name}")])?;
832 let path = std::env::current_dir().ok()?.join(raw);
833 let text = fs::read_to_string(&path).ok()?;
834 if !text.contains(BLOCK_BEGIN) || carries_block(&text, name) {
835 return None;
836 }
837 let verb = if name == "pre-push" { "push" } else { "commit" };
838 Some(format!(
839 "sitting-gate: {} predates this gate and drops its refusal, so git will {verb} anyway; {HOOKS_FIX} replaces it",
840 path.display()
841 ))
842}
843
844fn gate(name: &str) -> Result<()> {
853 let Some(cause) = crate::runner_cause() else {
854 return Ok(());
855 };
856 let refused = match gate_runner(name) {
857 Ok(()) => return Ok(()),
858 Err(refused) => refused,
859 };
860 if cause == crate::RunnerCause::Hard {
861 return Err(refused);
862 }
863 let verb = if name == "pre-push" { "push" } else { "commit" };
864 let marks = crate::runner_marks();
865 match crate::person_key::ask_person(&marks, &format!("git {verb}")) {
866 Ok(()) => Ok(()),
867 Err(why) => {
868 let detail = format!("{refused:#}");
869 let detail = detail.strip_prefix("sitting-gate: ").unwrap_or(&detail);
870 let text = crate::sitting_gate::for_marks(
871 detail,
872 &marks,
873 verb,
874 crate::person_key::is_set(),
875 &why,
876 );
877 Err(anyhow!("sitting-gate: {text}"))
878 }
879 }
880}
881
882fn gate_runner(name: &str) -> Result<()> {
883 let repo = git_line(&["rev-parse", "--show-toplevel"])
884 .map(PathBuf::from)
885 .or_else(git_dir);
886 if repo
887 .as_deref()
888 .is_some_and(|repo| is_store(repo) || holds_tracker(repo))
889 {
890 return Ok(());
891 }
892 let seat = crate::git_hook_seat();
899 let _actor = seat.as_deref().map(crate::HookActor::enter);
900 let issue = match crate::sitting_gate::live_sitting() {
901 Ok(issue) => issue,
902 Err(_) => match crate::sitting_gate::open_or_name_sitting() {
903 Some(issue) => issue,
904 None if name != "pre-push" => return Ok(()),
905 None => String::new(),
906 },
907 };
908 if name != "pre-push" {
909 return Ok(());
910 }
911 let cfg = match crate::gates::consensus_push(repo.as_deref()) {
912 Ok(cfg) => cfg,
913 Err(why) => {
914 return Err(anyhow!(
915 "sitting-gate: {}",
916 crate::sitting_gate::unread_settings(&why)
917 ))
918 }
919 };
920 let dest = match std::io::read_to_string(std::io::stdin()) {
921 Ok(refs) => protected_ref(&refs, &cfg.branches),
922 Err(e) => Some(format!("sends refs the hook cannot read ({e})")),
923 };
924 let Some(dest) = dest else {
925 return Ok(());
926 };
927 if issue.is_empty() || !crate::protected_push_cleared(&issue, &crate::seat_name(), cfg.quorum) {
928 let why = "the sitting has no settled consensus".to_string();
929 let wait = crate::sitting_gate::seats_wait(&dest, &cfg.branches, &issue, &why);
930 return Err(anyhow!("sitting-gate: {wait}"));
931 }
932 Ok(())
933}
934
935fn protected_ref(refs: &str, branches: &[String]) -> Option<String> {
941 for line in refs.lines().filter(|l| !l.trim().is_empty()) {
942 let fields: Vec<&str> = line.split_whitespace().collect();
943 let [_, _, remote, _] = fields[..] else {
944 return Some(format!("lists `{line}`, which is not a ref git would send"));
945 };
946 let Some(branch) = remote.strip_prefix("refs/heads/") else {
947 continue;
948 };
949 if let Some(b) = branches.iter().find(|b| b.eq_ignore_ascii_case(branch)) {
950 return Some(format!("sends `{b}`"));
951 }
952 }
953 None
954}
955
956fn holds_tracker(repo: &Path) -> bool {
960 if repo.join("vissue.toml").is_file() {
961 return true;
962 }
963 std::fs::read_dir(repo.join("Software")).is_ok_and(|entries| {
964 entries
965 .flatten()
966 .any(|entry| entry.path().join("issues.org").is_file())
967 })
968}
969
970fn book_commit(hook: &str) -> Result<()> {
971 if std::env::var_os("LJOS_GIT_HOOK").is_some() || booking_off() {
972 return Ok(());
973 }
974 if foreign_session() {
975 return Ok(());
976 }
977 if hook != "post-rewrite" && mid_rewrite() {
978 return Ok(());
979 }
980 let Some(repo) = git_line(&["rev-parse", "--show-toplevel"]) else {
981 return Ok(());
982 };
983 let repo_path = PathBuf::from(&repo);
984 let tracker = tracker_repo(&repo_path);
987 if !tracker && (crate::bookkeep::path_is_temp(Some(&repo_path)) || skip_repo(&repo_path)) {
988 return Ok(());
989 }
990 let Some(head) = git_line(&["rev-parse", "HEAD"]) else {
991 return Ok(());
992 };
993 let Some(seat) = crate::git_hook_seat() else {
996 if tracker {
997 refuse_tracker_commit(
998 &head,
999 "no seat is named; export LJOS_SEAT=<your seat> to write the tracker",
1000 );
1001 } else if crate::unseated_subagent() {
1002 println!(
1004 "ljos: commit {} not booked: this subagent has no seat; export LJOS_SEAT=<your seat> (the parent's) to book it",
1005 &head[..head.len().min(12)]
1006 );
1007 }
1008 return Ok(());
1009 };
1010 let _actor = crate::HookActor::enter(&seat);
1011 if let Err(e) = crate::write_seat("commit") {
1014 if tracker {
1015 refuse_tracker_commit(&head, &format!("{e:#}"));
1016 } else {
1017 println!(
1018 "ljos: commit {} not booked: {e:#}",
1019 &head[..head.len().min(12)]
1020 );
1021 }
1022 return Ok(());
1023 }
1024 if tracker {
1025 return Ok(());
1026 }
1027 let _children = HookChildren::enter();
1028 if already_booked(&repo, &head) {
1032 return Ok(());
1033 }
1034 let subject = git_line(&["log", "-1", "--format=%s"]).unwrap_or_default();
1035 println!(
1036 "{}",
1037 crate::bookkeep::note_after_commit(&repo, &head, &subject)
1038 );
1039 remember_booked(&repo, &head);
1040 Ok(())
1041}
1042
1043fn booking_off() -> bool {
1044 matches!(
1045 std::env::var("LJOS_OFF").ok().as_deref(),
1046 Some("1" | "true" | "TRUE" | "yes" | "YES" | "on" | "ON")
1047 ) || crate::bookkeep::guard_suspended()
1048}
1049
1050fn foreign_session() -> bool {
1051 ["CLAUDECODE", "CODEX_THREAD_ID", "CODEX_SESSION_ID"]
1055 .iter()
1056 .any(|key| std::env::var_os(key).is_some_and(|value| !value.is_empty()))
1057}
1058
1059fn mid_rewrite() -> bool {
1060 let Some(dir) = git_dir() else {
1061 return false;
1062 };
1063 dir.join("rebase-merge").is_dir()
1064 || dir.join("rebase-apply").is_dir()
1065 || dir.join("MERGE_HEAD").is_file()
1066}
1067
1068fn tracker_repo(repo: &Path) -> bool {
1070 ["VISSUE_ROOT", "ISSUE_ROOT"].iter().any(|key| {
1071 std::env::var_os(key)
1072 .filter(|value| !value.is_empty())
1073 .map(PathBuf::from)
1074 .is_some_and(|root| useful_root(&root) && covered(repo, &root))
1075 })
1076}
1077
1078fn refuse_tracker_commit(head: &str, why: &str) {
1082 let line = format!(
1083 "ljos: tracker commit {} was not made by a seat that may write the tracker: {why}",
1084 &head[..head.len().min(12)]
1085 );
1086 println!("{line}");
1087 crate::bookkeep::log_line(&format!("git-hooks: {line}"));
1088}
1089
1090fn skip_repo(repo: &Path) -> bool {
1091 if is_store(repo) {
1092 return true;
1093 }
1094 skip_roots().iter().any(|root| covered(repo, root))
1095}
1096
1097fn skip_roots() -> Vec<PathBuf> {
1101 let mut roots = vec![
1102 PathBuf::from("/tmp"),
1103 PathBuf::from("/var/tmp"),
1104 PathBuf::from("/dev/shm"),
1105 ];
1106 for key in ["VISSUE_ROOT", "ISSUE_ROOT", "CLAIMDAG_DIR"] {
1107 if let Some(path) = std::env::var_os(key).filter(|value| !value.is_empty()) {
1108 roots.push(PathBuf::from(path));
1109 }
1110 }
1111 roots.extend(store_dirs());
1112 for path in config_files() {
1113 roots.extend(configured_skips(&path));
1114 }
1115 roots.retain(|path| useful_root(path));
1116 roots
1117}
1118
1119fn useful_root(path: &Path) -> bool {
1120 let text = path.to_string_lossy();
1121 let text = text.trim();
1122 !text.is_empty() && text != "/" && text != "."
1123}
1124
1125fn config_files() -> Vec<PathBuf> {
1126 let mut out = Vec::new();
1127 let base = std::env::var_os("XDG_CONFIG_HOME")
1128 .filter(|value| !value.is_empty())
1129 .map(PathBuf::from)
1130 .or_else(|| {
1131 std::env::var_os("HOME")
1132 .filter(|value| !value.is_empty())
1133 .map(|home| PathBuf::from(home).join(".config"))
1134 });
1135 if let Some(base) = base {
1136 out.push(base.join("ljos").join("ljos.toml"));
1137 if let Some(seat) = std::env::var("LJOS_SEAT")
1138 .ok()
1139 .map(|name| name.trim().to_string())
1140 .filter(|name| !name.is_empty() && !name.contains('/') && !name.contains(".."))
1141 {
1142 out.push(base.join("ljos").join("seats").join(seat).join("ljos.toml"));
1143 }
1144 }
1145 if let Some(path) = std::env::var_os("LJOS_CONFIG").filter(|value| !value.is_empty()) {
1146 out.push(PathBuf::from(path));
1147 }
1148 if let Ok(cwd) = std::env::current_dir() {
1149 out.push(cwd.join("ljos.toml"));
1150 }
1151 out
1152}
1153
1154fn configured_skips(path: &Path) -> Vec<PathBuf> {
1155 let Ok(text) = fs::read_to_string(path) else {
1156 return Vec::new();
1157 };
1158 let Ok(value) = toml::from_str::<toml::Value>(&text) else {
1159 return Vec::new();
1160 };
1161 let Some(list) = value
1162 .get("git_hooks")
1163 .and_then(|table| table.get("skip"))
1164 .and_then(toml::Value::as_array)
1165 else {
1166 return Vec::new();
1167 };
1168 list.iter()
1169 .filter_map(toml::Value::as_str)
1170 .map(str::trim)
1171 .filter(|text| !text.is_empty())
1172 .map(PathBuf::from)
1173 .collect()
1174}
1175
1176fn covered(repo: &Path, root: &Path) -> bool {
1177 let repo = canonical_or(repo);
1178 let root = canonical_or(root);
1179 repo == root || repo.starts_with(&root)
1180}
1181
1182fn canonical_or(path: &Path) -> PathBuf {
1183 fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
1184}
1185
1186fn is_store(repo: &Path) -> bool {
1189 store_dirs()
1190 .iter()
1191 .any(|root| same_dir(repo, root) || repo_starts(repo, root))
1192}
1193
1194fn store_dirs() -> Vec<PathBuf> {
1195 let mut out = Vec::new();
1196 if let Ok(url) = std::env::var("DEEDAR_URL") {
1197 if let Some(path) = url.strip_prefix("file://") {
1198 out.push(PathBuf::from(path));
1199 }
1200 }
1201 if let Some(data) = std::env::var_os("XDG_DATA_HOME").filter(|value| !value.is_empty()) {
1202 out.push(PathBuf::from(data).join("deedar").join("store"));
1203 } else if let Some(home) = std::env::var_os("HOME").filter(|value| !value.is_empty()) {
1204 out.push(
1205 PathBuf::from(home)
1206 .join(".local")
1207 .join("share")
1208 .join("deedar")
1209 .join("store"),
1210 );
1211 }
1212 out
1213}
1214
1215fn same_dir(left: &Path, right: &Path) -> bool {
1216 let left = fs::canonicalize(left).unwrap_or_else(|_| left.to_path_buf());
1217 let right = fs::canonicalize(right).unwrap_or_else(|_| right.to_path_buf());
1218 left == right
1219}
1220
1221fn repo_starts(repo: &Path, root: &Path) -> bool {
1222 if !root.exists() {
1223 return false;
1224 }
1225 let repo = fs::canonicalize(repo).unwrap_or_else(|_| repo.to_path_buf());
1226 let root = fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
1227 repo.starts_with(root)
1228}
1229
1230fn already_booked(repo: &str, head: &str) -> bool {
1231 let key = format!("{repo}\t{head}");
1232 fs::read_to_string(booked_path())
1233 .map(|text| text.lines().any(|line| line == key))
1234 .unwrap_or(false)
1235}
1236
1237fn remember_booked(repo: &str, head: &str) {
1238 let path = booked_path();
1239 if let Some(dir) = path.parent() {
1240 let _ = fs::create_dir_all(dir);
1241 }
1242 let mut lines: Vec<String> = fs::read_to_string(&path)
1243 .unwrap_or_default()
1244 .lines()
1245 .map(str::to_string)
1246 .filter(|line| !line.starts_with(&format!("{repo}\t")))
1247 .collect();
1248 lines.push(format!("{repo}\t{head}"));
1249 if lines.len() > 200 {
1250 lines.drain(0..lines.len() - 200);
1251 }
1252 let _ = fs::write(path, lines.join("\n") + "\n");
1253}
1254
1255fn booked_path() -> PathBuf {
1256 crate::runtime_dir().join("git-hooks-booked")
1257}
1258
1259fn git_dir() -> Option<PathBuf> {
1260 let raw = git_line(&["rev-parse", "--git-dir"])?;
1261 let path = PathBuf::from(&raw);
1262 if path.is_absolute() {
1263 Some(path)
1264 } else {
1265 std::env::current_dir().ok().map(|cwd| cwd.join(path))
1266 }
1267}
1268
1269const HOOK_GIT_ENV: &[&str] = &[
1273 "GIT_DIR",
1274 "GIT_WORK_TREE",
1275 "GIT_INDEX_FILE",
1276 "GIT_PREFIX",
1277 "GIT_OBJECT_DIRECTORY",
1278 "GIT_COMMON_DIR",
1279 "GIT_NAMESPACE",
1280];
1281
1282thread_local! {
1283 static HOOK_CHILDREN: Cell<bool> = const { Cell::new(false) };
1284}
1285
1286#[must_use]
1291struct HookChildren {
1292 previous: bool,
1293}
1294
1295impl HookChildren {
1296 fn enter() -> Self {
1297 let previous = HOOK_CHILDREN.with(Cell::get);
1298 HOOK_CHILDREN.with(|flag| flag.set(true));
1299 Self { previous }
1300 }
1301}
1302
1303impl Drop for HookChildren {
1304 fn drop(&mut self) {
1305 HOOK_CHILDREN.with(|flag| flag.set(self.previous));
1306 }
1307}
1308
1309pub(crate) fn hook_child_env(cmd: &mut Command) {
1313 if !HOOK_CHILDREN.with(Cell::get) {
1314 return;
1315 }
1316 cmd.env("LJOS_GIT_HOOK", "1");
1317 for key in HOOK_GIT_ENV {
1318 cmd.env_remove(key);
1319 }
1320}
1321
1322#[cfg(test)]
1323pub(crate) fn with_hook_children_for_test<T>(run: impl FnOnce() -> T) -> T {
1324 let _guard = HookChildren::enter();
1325 run()
1326}
1327
1328fn git_command(args: &[&str], git_env: &[(&str, &str)]) -> Command {
1329 let mut cmd = Command::new("git");
1330 cmd.args(args);
1331 for (key, value) in git_env {
1332 cmd.env(key, value);
1333 }
1334 hook_child_env(&mut cmd);
1335 cmd
1336}
1337
1338fn git_line(args: &[&str]) -> Option<String> {
1339 let out = git_command(args, &[]).output().ok()?;
1340 if !out.status.success() {
1341 return None;
1342 }
1343 let text = String::from_utf8(out.stdout).ok()?;
1344 let text = text.trim();
1345 if text.is_empty() {
1346 None
1347 } else {
1348 Some(text.to_string())
1349 }
1350}
1351
1352fn global_hooks_path(git_env: &[(&str, &str)]) -> Option<String> {
1353 let out = git_command(&["config", "--global", "--get", "core.hooksPath"], git_env)
1354 .output()
1355 .ok()?;
1356 if !out.status.success() {
1357 return None;
1358 }
1359 let text = String::from_utf8(out.stdout).ok()?;
1360 let text = text.trim();
1361 if text.is_empty() {
1362 None
1363 } else {
1364 Some(text.to_string())
1365 }
1366}
1367
1368fn set_global_hooks_path(path: &Path, git_env: &[(&str, &str)]) -> Result<()> {
1369 let value = path.display().to_string();
1370 let status = git_command(&["config", "--global", "core.hooksPath", &value], git_env)
1371 .status()
1372 .context("git-hooks: git config")?;
1373 if !status.success() {
1374 bail!("git-hooks: could not set core.hooksPath");
1375 }
1376 Ok(())
1377}
1378
1379fn unset_global_hooks_path(git_env: &[(&str, &str)]) -> Result<()> {
1380 let status = git_command(
1381 &["config", "--global", "--unset", "core.hooksPath"],
1382 git_env,
1383 )
1384 .status()
1385 .context("git-hooks: git config")?;
1386 if !status.success() {
1387 bail!("git-hooks: could not unset core.hooksPath");
1388 }
1389 Ok(())
1390}
1391
1392#[cfg(test)]
1393mod tests {
1394 use std::ffi::OsStr;
1395
1396 use super::*;
1397
1398 fn mapped<'a>(cmd: &'a Command, key: &str) -> Option<Option<&'a OsStr>> {
1399 cmd.get_envs()
1400 .find(|(name, _)| *name == OsStr::new(key))
1401 .map(|(_, value)| value)
1402 }
1403
1404 #[test]
1405 fn the_doctor_names_a_hooks_path_that_is_not_the_onboard_one() {
1406 let dir = tempfile::tempdir().unwrap();
1407 let ours = dir.path().join("ljos").join("git-hooks");
1408 let other = dir.path().join("user-hooks");
1409 std::fs::create_dir_all(&other).unwrap();
1410 let prior = "#!/bin/sh\nprintf 'user-hook\\n'\n";
1411 std::fs::write(other.join("post-commit"), prior).unwrap();
1412 let ours_s = ours.display().to_string();
1413 let other_s = other.display().to_string();
1414
1415 let (ok, state) = super::hooks_state(&ours, None);
1416 assert!(!ok);
1417 assert!(state.starts_with(super::HOOKS_UNSET), "{state}");
1418 assert_eq!(state.matches(super::HOOKS_FIX).count(), 1, "{state}");
1419
1420 let (ok, state) = super::hooks_state(&ours, Some(&other_s));
1421 assert!(!ok, "{state}");
1422 assert!(state.contains(&other_s), "{state}");
1423 assert!(!state.contains(&ours_s), "{state}");
1424 assert_eq!(state.matches(super::HOOKS_FIX).count(), 1, "{state}");
1425
1426 super::chain_user_hooks(&other).unwrap();
1427 let (ok, state) = super::hooks_state(&ours, Some(&other_s));
1428 assert!(ok, "{state}");
1429 assert!(state.contains("ljos git-hook runs"), "{state}");
1430 let hooked = std::fs::read_to_string(other.join("post-commit")).unwrap();
1431 assert!(hooked.starts_with(prior), "{hooked}");
1432 assert_eq!(
1433 hooked.matches("# ljos:begin git-hook").count(),
1434 1,
1435 "{hooked}"
1436 );
1437 super::chain_user_hooks(&other).unwrap();
1438 assert_eq!(
1439 std::fs::read_to_string(other.join("post-commit")).unwrap(),
1440 hooked
1441 );
1442 assert!(other.join("pre-push").is_file());
1443
1444 super::strip_user_hooks(&other).unwrap();
1445 assert_eq!(
1446 std::fs::read_to_string(other.join("post-commit")).unwrap(),
1447 prior
1448 );
1449 assert!(!other.join("pre-push").exists());
1450
1451 let config = dir.path().join("config");
1452 std::fs::create_dir_all(&config).unwrap();
1453 std::fs::write(
1454 config.join("env.sh"),
1455 format!("export LJOS_GIT_HOOKS={}\n", other.display()),
1456 )
1457 .unwrap();
1458 let drift = super::env_hooks_drift(&config, &ours).expect("a drifted env file");
1459 assert!(drift.contains(&other_s), "{drift}");
1460 assert!(drift.contains(&ours_s), "{drift}");
1461 let (ok, state) = super::doctor_state(&ours, None, &config);
1462 assert!(!ok, "{state}");
1463 assert!(
1464 !state.starts_with(super::HOOKS_UNSET),
1465 "a mismatch stayed info: {state}"
1466 );
1467 assert!(state.contains(&other_s), "{state}");
1468 assert!(state.contains(&ours_s), "{state}");
1469
1470 std::fs::write(
1471 config.join("env.sh"),
1472 format!("{}\n", super::HOOKS_ENV_LINE),
1473 )
1474 .unwrap();
1475 assert!(super::env_hooks_drift(&config, &ours).is_none());
1476 let body = super::hook_body("post-rewrite");
1477 assert!(body.contains("mktemp"), "{body}");
1478 assert!(body.contains("git-hook post-rewrite"), "{body}");
1479 assert!(body.contains("< \"$_ljos_in\""), "{body}");
1480 let commit = super::hook_body("post-commit");
1481 assert!(!commit.contains("mktemp"), "{commit}");
1482 assert!(commit.contains("git-hook post-commit"), "{commit}");
1483
1484 super::write_owned_hooks(&ours).unwrap();
1485 let (ok, state) = super::hooks_state(&ours, Some(&ours_s));
1486 assert!(ok, "{state}");
1487 std::fs::remove_file(ours.join("pre-push")).unwrap();
1488 let (ok, state) = super::hooks_state(&ours, Some(&ours_s));
1489 assert!(!ok, "{state}");
1490 assert!(state.contains(super::HOOKS_FIX), "{state}");
1491 }
1492
1493 const OLD_PRE_PUSH: &str = "# ljos:begin git-hook\n\
1496 if command -v ljos >/dev/null 2>&1; then\n\
1497 \tljos git-hook pre-push \"$@\" >&2 || true\n\
1498 else\n\
1499 \t'/usr/local/bin/ljos' git-hook pre-push \"$@\" >&2 || true\n\
1500 fi\n\
1501 # ljos:end git-hook\n";
1502
1503 #[test]
1504 fn the_doctor_fails_a_pre_push_stub_that_drops_a_refusal() {
1505 let dir = tempfile::tempdir().unwrap();
1506 let ours = dir.path().join("git-hooks");
1507 let ours_s = ours.display().to_string();
1508 let config = dir.path().join("config");
1509 super::write_owned_hooks(&ours).unwrap();
1510 let (ok, state) = super::doctor_state(&ours, Some(&ours_s), &config);
1511 assert!(ok, "{state}");
1512
1513 let fresh = super::hook_body("pre-push");
1514 let stale = fresh.replace(&super::hook_block("pre-push"), OLD_PRE_PUSH);
1515 assert_ne!(stale, fresh);
1516 std::fs::write(ours.join("pre-push"), &stale).unwrap();
1517 let (ok, state) = super::doctor_state(&ours, Some(&ours_s), &config);
1518 assert!(!ok, "a stale pre-push stub passed: {state}");
1519 assert!(state.contains("pre-push stub predates"), "{state}");
1520 assert_eq!(state.matches(super::HOOKS_FIX).count(), 1, "{state}");
1521
1522 let moved = fresh.replace(
1523 &super::shell_quote(&super::ljos_executable()),
1524 "'/opt/other/ljos'",
1525 );
1526 assert_ne!(moved, fresh);
1527 std::fs::write(ours.join("pre-push"), moved).unwrap();
1528 let (ok, state) = super::doctor_state(&ours, Some(&ours_s), &config);
1529 assert!(ok, "another fallback path failed the row: {state}");
1530
1531 let user = dir.path().join("user-hooks");
1532 super::chain_user_hooks(&user).unwrap();
1533 let user_s = user.display().to_string();
1534 let prior = format!("#!/bin/sh\necho mine\n{OLD_PRE_PUSH}");
1535 std::fs::write(user.join("pre-push"), &prior).unwrap();
1536 let (ok, state) = super::hooks_state(&ours, Some(&user_s));
1537 assert!(!ok, "a stale user block passed: {state}");
1538 assert!(state.contains("pre-push hook's ljos block"), "{state}");
1539 super::chain_user_hooks(&user).unwrap();
1540 let (ok, state) = super::hooks_state(&ours, Some(&user_s));
1541 assert!(ok, "install left the stale block: {state}");
1542 let hooked = std::fs::read_to_string(user.join("pre-push")).unwrap();
1543 assert!(hooked.starts_with("#!/bin/sh\necho mine\n"), "{hooked}");
1544 assert_eq!(hooked.matches("# ljos:begin git-hook").count(), 1);
1545 assert!(!hooked.contains("\"$@\" >&2 || true"), "{hooked}");
1546 }
1547
1548 #[test]
1549 fn a_hook_that_does_not_book_succeeds() {
1550 super::run_named("post-checkout").unwrap();
1551 }
1552
1553 #[test]
1554 fn a_pre_push_ref_list_names_the_protected_branch_it_sends() {
1555 let branches = vec!["develop".to_string(), "main".to_string()];
1556 let line = |dst: &str| format!("refs/heads/x 1111 {dst} 0000\n");
1557 assert_eq!(protected_ref("", &branches), None);
1558 assert_eq!(protected_ref(&line("refs/heads/topic"), &branches), None);
1559 assert_eq!(protected_ref(&line("refs/tags/main"), &branches), None);
1560 let both = line("refs/heads/topic") + &line("refs/heads/Main");
1561 assert_eq!(
1562 protected_ref(&both, &branches).as_deref(),
1563 Some("sends `main`")
1564 );
1565 let odd = protected_ref("refs/heads/x main\n", &branches).unwrap();
1566 assert!(odd.contains("not a ref git would send"), "{odd}");
1567 }
1568
1569 #[test]
1570 fn hook_children_carry_the_guard_without_touching_the_process() {
1571 let before_hook = std::env::var_os("LJOS_GIT_HOOK");
1572 let before_dir = std::env::var_os("GIT_DIR");
1573 let mut plain = Command::new("true");
1574 plain.env("GIT_DIR", "keep");
1575 hook_child_env(&mut plain);
1576 assert_eq!(mapped(&plain, "GIT_DIR"), Some(Some(OsStr::new("keep"))));
1577 assert_eq!(mapped(&plain, "LJOS_GIT_HOOK"), None);
1578
1579 let _guard = HookChildren::enter();
1580 let mut cmd = Command::new("true");
1581 cmd.env("GIT_DIR", "from-the-hook");
1582 cmd.env("PATH", "/usr/bin");
1583 hook_child_env(&mut cmd);
1584 assert_eq!(mapped(&cmd, "LJOS_GIT_HOOK"), Some(Some(OsStr::new("1"))));
1585 for key in HOOK_GIT_ENV {
1586 assert_eq!(mapped(&cmd, key), Some(None), "{key}");
1587 }
1588 assert_eq!(mapped(&cmd, "PATH"), Some(Some(OsStr::new("/usr/bin"))));
1589 assert_eq!(std::env::var_os("LJOS_GIT_HOOK"), before_hook);
1590 assert_eq!(std::env::var_os("GIT_DIR"), before_dir);
1591 }
1592}