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 reachable = nonempty(configured_path.as_deref())
191 .is_some_and(|path| hooks_reachable(Path::new(path), &dir));
192 let hooks = if reachable {
193 configured.clone()
194 } else if owned_installed(&dir) {
195 dir.display().to_string()
196 } else {
197 "not installed".into()
198 };
199 let reach = if reachable {
200 "ljos hooks run".to_string()
201 } else {
202 format!("ljos hooks do not run; {HOOKS_FIX}")
203 };
204 format!("hooks: {hooks}\ncore.hooksPath: {configured}\n{reach}\n")
205}
206
207pub const HOOKS_UNSET: &str = "core.hooksPath is unset";
211
212pub const HOOKS_FIX: &str = "ljos git-hooks install";
214
215#[must_use]
221pub fn doctor_row() -> crate::Habitat {
222 let dir = hooks_dir();
223 let (ok, state) = doctor_state(&dir, global_hooks_path(&[]).as_deref(), &config_ljos_dir());
224 crate::Habitat {
225 name: "git hooks",
226 state,
227 ok,
228 }
229}
230
231#[must_use]
234pub fn doctor_state(dir: &Path, configured: Option<&str>, config_dir: &Path) -> (bool, String) {
235 let (mut ok, mut state) = hooks_state(dir, configured);
236 if let Some(drift) = env_hooks_drift(config_dir, dir) {
237 ok = false;
238 state = if state.starts_with(HOOKS_UNSET) {
240 drift
241 } else {
242 format!("{state}; {drift}")
243 };
244 }
245 (ok, state)
246}
247
248#[must_use]
252pub fn env_hooks_drift(config_dir: &Path, onboard_dir: &Path) -> Option<String> {
253 let mut files = Vec::new();
254 for name in ["env.sh", "env"] {
255 let path = config_dir.join(name);
256 if path.is_file() {
257 files.push(path);
258 }
259 }
260 if let Ok(entries) = fs::read_dir(config_dir) {
261 let mut more: Vec<PathBuf> = entries
262 .flatten()
263 .map(|entry| entry.path())
264 .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("env"))
265 .collect();
266 more.sort();
267 files.extend(more);
268 }
269 let ours = onboard_dir.display().to_string();
270 for path in files {
271 let Ok(text) = fs::read_to_string(&path) else {
272 continue;
273 };
274 for line in text.lines() {
275 let Some(value) = hooks_assignment(line) else {
276 continue;
277 };
278 if assignment_agrees(&value, onboard_dir) {
279 continue;
280 }
281 return Some(format!(
282 "{} sets LJOS_GIT_HOOKS to {value}, not {ours}, the hooks `ljos onboard` installs",
283 path.display()
284 ));
285 }
286 }
287 None
288}
289
290fn hooks_assignment(line: &str) -> Option<String> {
291 let line = line.trim();
292 if line.is_empty() || line.starts_with('#') {
293 return None;
294 }
295 let rest = line.strip_prefix("export ").unwrap_or(line).trim();
296 let rest = rest.strip_prefix("LJOS_GIT_HOOKS=")?;
297 let value = unquote_assignment(rest.trim());
298 if value.is_empty() {
299 None
300 } else {
301 Some(value)
302 }
303}
304
305fn unquote_assignment(value: &str) -> String {
306 let value = value.trim();
307 if let Some(inner) = value
308 .strip_prefix('"')
309 .and_then(|text| text.strip_suffix('"'))
310 {
311 return inner.to_string();
312 }
313 if let Some(inner) = value
314 .strip_prefix('\'')
315 .and_then(|text| text.strip_suffix('\''))
316 {
317 return inner.to_string();
318 }
319 value.split_whitespace().next().unwrap_or("").to_string()
320}
321
322fn assignment_agrees(value: &str, onboard: &Path) -> bool {
323 if value.contains("${XDG_DATA_HOME:-$HOME/.local/share}/ljos/git-hooks") {
324 return true;
325 }
326 if value.contains('$') || value.contains('`') {
327 return false;
328 }
329 same_dir(Path::new(value), onboard)
330}
331
332fn config_ljos_dir() -> PathBuf {
333 let base = std::env::var_os("XDG_CONFIG_HOME")
334 .filter(|value| !value.is_empty())
335 .map(PathBuf::from)
336 .or_else(|| {
337 std::env::var_os("HOME")
338 .filter(|value| !value.is_empty())
339 .map(|home| PathBuf::from(home).join(".config"))
340 })
341 .unwrap_or_else(|| PathBuf::from(".config"));
342 base.join("ljos")
343}
344
345pub const HOOKS_ENV_LINE: &str = "\
348# Git hooks directory. `ljos git-hooks install` writes the stubs here.\n\
349export LJOS_GIT_HOOKS=\"${LJOS_GIT_HOOKS:-${XDG_DATA_HOME:-$HOME/.local/share}/ljos/git-hooks}\"";
350
351#[must_use]
354pub fn hooks_state(ours: &Path, configured: Option<&str>) -> (bool, String) {
355 let Some(path) = nonempty(configured) else {
356 return (
357 false,
358 format!("{HOOKS_UNSET}, so commits are not booked; {HOOKS_FIX}"),
359 );
360 };
361 let dir = Path::new(path);
362 if hooks_reachable(dir, ours) {
363 let state = if same_dir(dir, ours) {
364 format!("core.hooksPath is {path}; commits are booked")
365 } else {
366 format!("core.hooksPath is {path}; ljos git-hook runs from those hooks")
367 };
368 return (true, state);
369 }
370 let state = if same_dir(dir, ours) {
371 format!("core.hooksPath is {path}, but some hook stubs are missing there; {HOOKS_FIX}")
372 } else {
373 format!("core.hooksPath is {path}, and ljos is not chained there; {HOOKS_FIX}")
374 };
375 (false, state)
376}
377
378fn hooks_dir() -> PathBuf {
379 if let Some(dir) = std::env::var_os("LJOS_GIT_HOOKS").filter(|value| !value.is_empty()) {
380 return PathBuf::from(dir);
381 }
382 data_home().join("ljos").join("git-hooks")
383}
384
385fn data_home() -> PathBuf {
386 if let Some(dir) = std::env::var_os("XDG_DATA_HOME").filter(|value| !value.is_empty()) {
387 return PathBuf::from(dir);
388 }
389 std::env::var_os("HOME")
390 .filter(|value| !value.is_empty())
391 .map(|home| PathBuf::from(home).join(".local").join("share"))
392 .unwrap_or_else(|| PathBuf::from("."))
393}
394
395const BLOCK_BEGIN: &str = "# ljos:begin git-hook";
396const BLOCK_END: &str = "# ljos:end git-hook";
397
398fn nonempty(value: Option<&str>) -> Option<&str> {
399 value.map(str::trim).filter(|path| !path.is_empty())
400}
401
402fn ljos_executable() -> String {
403 std::env::current_exe()
404 .map(|path| path.display().to_string())
405 .unwrap_or_else(|_| "ljos".to_string())
406}
407
408fn hook_block(name: &str) -> String {
416 let bin = shell_quote(&ljos_executable());
417 let (otherwise, fail) = if gates_git(name) {
418 (format!("elif [ -x {bin} ]; then"), "exit 1")
419 } else {
420 ("else".to_string(), "true")
421 };
422 if matches!(name, "post-rewrite" | "pre-push") {
423 return format!(
424 "{BLOCK_BEGIN}\n\
425 if [ -z \"${{_ljos_in:-}}\" ]; then\n\
426 \t_ljos_in=$(mktemp 2>/dev/null || true)\n\
427 \tif [ -n \"$_ljos_in\" ]; then\n\
428 \t\tcat > \"$_ljos_in\" || true\n\
429 \tfi\n\
430 fi\n\
431 if command -v ljos >/dev/null 2>&1; then\n\
432 \tif [ -n \"${{_ljos_in:-}}\" ]; then\n\
433 \t\tljos git-hook {name} \"$@\" < \"$_ljos_in\" >&2 || {fail}\n\
434 \telse\n\
435 \t\tljos git-hook {name} \"$@\" >&2 || {fail}\n\
436 \tfi\n\
437 {otherwise}\n\
438 \tif [ -n \"${{_ljos_in:-}}\" ]; then\n\
439 \t\t{bin} git-hook {name} \"$@\" < \"$_ljos_in\" >&2 || {fail}\n\
440 \telse\n\
441 \t\t{bin} git-hook {name} \"$@\" >&2 || {fail}\n\
442 \tfi\n\
443 fi\n\
444 {BLOCK_END}\n"
445 );
446 }
447 format!(
448 "{BLOCK_BEGIN}\n\
449 if command -v ljos >/dev/null 2>&1; then\n\
450 \tljos git-hook {name} \"$@\" >&2 || {fail}\n\
451 {otherwise}\n\
452 \t{bin} git-hook {name} \"$@\" >&2 || {fail}\n\
453 fi\n\
454 {BLOCK_END}\n"
455 )
456}
457
458fn has_block(text: &str, name: &str) -> bool {
459 text.contains(&hook_block(name))
460}
461
462fn strip_block(text: &str) -> String {
463 let Some(start) = text.find(BLOCK_BEGIN) else {
464 return text.to_string();
465 };
466 let Some(rel) = text[start..].find(BLOCK_END) else {
467 return text.to_string();
468 };
469 let mut end = start + rel + BLOCK_END.len();
470 if text[end..].starts_with('\n') {
471 end += 1;
472 }
473 let mut out = String::new();
474 out.push_str(&text[..start]);
475 out.push_str(&text[end..]);
476 out
477}
478
479fn strip_all(text: &str) -> String {
480 let mut out = text.to_string();
481 loop {
482 let next = strip_block(&out);
483 if next == out {
484 return out;
485 }
486 out = next;
487 }
488}
489
490fn merge_block(text: &str, name: &str) -> String {
491 if has_block(text, name) {
492 return text.to_string();
493 }
494 let stripped = strip_all(text);
495 let block = hook_block(name);
496 if stripped.trim().is_empty() {
497 return format!("#!/bin/sh\n{block}");
498 }
499 if stripped.ends_with('\n') {
500 format!("{stripped}{block}")
501 } else {
502 format!("{stripped}\n{block}")
503 }
504}
505
506fn block_only(text: &str) -> bool {
507 matches!(text.trim(), "" | "#!/bin/sh")
508}
509
510fn hook_calls_ljos(text: &str, name: &str) -> bool {
511 text.contains(&format!("ljos git-hook {name} "))
512}
513
514fn is_executable(path: &Path) -> bool {
515 #[cfg(unix)]
516 {
517 use std::os::unix::fs::PermissionsExt;
518 fs::metadata(path)
519 .map(|meta| meta.permissions().mode() & 0o111 != 0)
520 .unwrap_or(false)
521 }
522 #[cfg(not(unix))]
523 {
524 path.is_file()
525 }
526}
527
528fn ensure_executable(path: &Path) -> Result<()> {
529 #[cfg(unix)]
530 {
531 use std::os::unix::fs::PermissionsExt;
532 let meta = fs::metadata(path).with_context(|| format!("git-hooks: {}", path.display()))?;
533 let mode = meta.permissions().mode();
534 if mode & 0o111 == 0 {
535 fs::set_permissions(path, fs::Permissions::from_mode(mode | 0o111))?;
536 }
537 }
538 Ok(())
539}
540
541fn owned_installed(dir: &Path) -> bool {
542 HOOK_NAMES.iter().all(|name| {
543 let path = dir.join(name);
544 let Ok(text) = fs::read_to_string(&path) else {
545 return false;
546 };
547 is_executable(&path) && (text.contains("#!/bin/sh") || hook_calls_ljos(&text, name))
548 })
549}
550
551fn user_chained(dir: &Path) -> bool {
552 HOOK_NAMES.iter().all(|name| {
553 let path = dir.join(name);
554 let Ok(text) = fs::read_to_string(&path) else {
555 return false;
556 };
557 is_executable(&path) && hook_calls_ljos(&text, name)
558 })
559}
560
561fn hooks_reachable(configured: &Path, ours: &Path) -> bool {
562 if same_dir(configured, ours) {
563 owned_installed(configured)
564 } else {
565 user_chained(configured)
566 }
567}
568
569fn write_owned_hooks(dir: &Path) -> Result<()> {
570 fs::create_dir_all(dir).with_context(|| format!("git-hooks: {}", dir.display()))?;
571 for name in HOOK_NAMES {
572 let path = dir.join(name);
573 fs::write(&path, hook_body(name))
574 .with_context(|| format!("git-hooks: {}", path.display()))?;
575 #[cfg(unix)]
576 {
577 use std::os::unix::fs::PermissionsExt;
578 fs::set_permissions(&path, fs::Permissions::from_mode(0o755))?;
579 }
580 }
581 Ok(())
582}
583
584fn chain_user_hooks(dir: &Path) -> Result<()> {
585 fs::create_dir_all(dir).with_context(|| format!("git-hooks: {}", dir.display()))?;
586 for name in HOOK_NAMES {
587 let path = dir.join(name);
588 let existing = if path.is_file() {
589 Some(fs::read(&path).with_context(|| format!("git-hooks: {}", path.display()))?)
590 } else {
591 None
592 };
593 if let Some(bytes) = &existing {
594 if bytes.contains(&0) || std::str::from_utf8(bytes).is_err() {
595 eprintln!("ljos: {} is not a shell script; left it", path.display());
596 continue;
597 }
598 }
599 let text = existing
600 .as_deref()
601 .map(|bytes| std::str::from_utf8(bytes).unwrap_or(""))
602 .unwrap_or("");
603 let next = merge_block(text, name);
604 if existing.is_none() || next != text {
605 fs::write(&path, next).with_context(|| format!("git-hooks: {}", path.display()))?;
606 }
607 ensure_executable(&path)?;
608 }
609 Ok(())
610}
611
612fn strip_user_hooks(dir: &Path) -> Result<()> {
613 if !dir.is_dir() {
614 return Ok(());
615 }
616 for name in HOOK_NAMES {
617 let path = dir.join(name);
618 let Ok(bytes) = fs::read(&path) else {
619 continue;
620 };
621 let Ok(text) = std::str::from_utf8(&bytes) else {
622 continue;
623 };
624 if !text.contains(BLOCK_BEGIN) {
625 continue;
626 }
627 let stripped = strip_all(text);
628 if block_only(&stripped) {
629 fs::remove_file(&path).with_context(|| format!("git-hooks: {}", path.display()))?;
630 } else if stripped != text {
631 fs::write(&path, stripped).with_context(|| format!("git-hooks: {}", path.display()))?;
632 }
633 }
634 Ok(())
635}
636
637fn remove_owned_hooks(dir: &Path) {
638 for name in HOOK_NAMES {
639 let _ = fs::remove_file(dir.join(name));
640 }
641 let _ = fs::remove_file(dir.join("previous-hooks-path"));
642}
643
644fn hook_body(name: &str) -> String {
645 let block = hook_block(name);
646 format!(
647 "#!/bin/sh\n\
648 # ljos books the commit, then chains to this repository's\n\
649 # .git/hooks/{name}, or to the core.hooksPath recorded in\n\
650 # previous-hooks-path. Booking cannot fail the hook; a refusal\n\
651 # from ljos in pre-commit or pre-push does. The chained hook's\n\
652 # exit status is kept. post-rewrite and pre-push keep a copy of\n\
653 # stdin for that hook.\n\
654 #\n\
655 # pre-commit install follows core.hooksPath and would replace this\n\
656 # file. Install the framework into the repository, then re-run\n\
657 # `ljos git-hooks install` if a stub was overwritten:\n\
658 # dir=$(git rev-parse --git-dir)\n\
659 # git config --local core.hooksPath \"$dir/hooks\"\n\
660 # pre-commit install\n\
661 # git config --local --unset core.hooksPath\n\
662 {block}\
663 git_dir=$(git rev-parse --git-dir 2>/dev/null || true)\n\
664 if [ -n \"$git_dir\" ]; then\n\
665 \tcase \"$git_dir\" in\n\
666 \t\t/*) ;;\n\
667 \t\t*) git_dir=$(pwd)/$git_dir ;;\n\
668 \tesac\n\
669 \trepo_hook=\"$git_dir/hooks/{name}\"\n\
670 \tif [ -x \"$repo_hook\" ] && [ \"$repo_hook\" != \"$0\" ]; then\n\
671 \t\tif [ -n \"${{_ljos_in:-}}\" ]; then\n\
672 \t\t\texec \"$repo_hook\" \"$@\" < \"$_ljos_in\"\n\
673 \t\tfi\n\
674 \t\texec \"$repo_hook\" \"$@\"\n\
675 \tfi\n\
676 fi\n\
677 here=$(dirname \"$0\")\n\
678 here=$(CDPATH= cd -- \"$here\" && pwd) || exit 0\n\
679 prev=$(sed -n '1s/[[:space:]]*$//p' \"$here/previous-hooks-path\" 2>/dev/null || true)\n\
680 if [ -n \"$prev\" ] && [ \"$prev\" != \"$here\" ]; then\n\
681 \tprev_hook=\"$prev/{name}\"\n\
682 \tif [ -x \"$prev_hook\" ] && [ \"$prev_hook\" != \"$0\" ]; then\n\
683 \t\tif [ -n \"${{_ljos_in:-}}\" ]; then\n\
684 \t\t\texec \"$prev_hook\" \"$@\" < \"$_ljos_in\"\n\
685 \t\tfi\n\
686 \t\texec \"$prev_hook\" \"$@\"\n\
687 \tfi\n\
688 fi\n\
689 exit 0\n"
690 )
691}
692
693fn shell_quote(text: &str) -> String {
694 format!("'{}'", text.replace('\'', "'\\''"))
695}
696
697pub fn run_git_commit(hook: &str) -> Result<()> {
703 if let Err(err) = book_commit(hook) {
704 crate::bookkeep::log_line(&format!("git-hooks: {err:#}"));
705 }
706 Ok(())
707}
708
709pub fn run_named(name: &str) -> Result<()> {
720 let name = name.trim();
721 if books_commit(name) {
722 run_git_commit(name)
723 } else if gates_git(name) {
724 gate(name)
725 } else {
726 Ok(())
727 }
728}
729
730fn gate(name: &str) -> Result<()> {
737 if !crate::under_a_runner() {
738 return Ok(());
739 }
740 let repo = git_line(&["rev-parse", "--show-toplevel"])
741 .map(PathBuf::from)
742 .or_else(git_dir);
743 if repo
744 .as_deref()
745 .is_some_and(|repo| is_store(repo) || holds_tracker(repo))
746 {
747 return Ok(());
748 }
749 let seat = crate::git_hook_seat();
756 let _actor = seat.as_deref().map(crate::HookActor::enter);
757 let verb = if name == "pre-push" { "push" } else { "commit" };
758 let issue = crate::sitting_gate::live_sitting().map_err(|why| {
759 anyhow!(
760 "sitting-gate: {}",
761 crate::sitting_gate::no_sitting(verb, &why)
762 )
763 })?;
764 if name != "pre-push" {
765 return Ok(());
766 }
767 let cfg = crate::gates::consensus_push(repo.as_deref()).map_err(|why| {
768 anyhow!(
769 "sitting-gate: {}",
770 crate::sitting_gate::unread_settings(&why)
771 )
772 })?;
773 let dest = match std::io::read_to_string(std::io::stdin()) {
774 Ok(refs) => protected_ref(&refs, &cfg.branches),
775 Err(e) => Some(format!("sends refs the hook cannot read ({e})")),
776 };
777 let Some(dest) = dest else {
778 return Ok(());
779 };
780 crate::protected_push_stands(&issue, cfg.quorum)
781 .map(drop)
782 .map_err(|why| {
783 let wait = crate::sitting_gate::seats_wait(&dest, &cfg.branches, &issue, &why);
784 anyhow!("sitting-gate: {wait}")
785 })
786}
787
788fn protected_ref(refs: &str, branches: &[String]) -> Option<String> {
794 for line in refs.lines().filter(|l| !l.trim().is_empty()) {
795 let fields: Vec<&str> = line.split_whitespace().collect();
796 let [_, _, remote, _] = fields[..] else {
797 return Some(format!("lists `{line}`, which is not a ref git would send"));
798 };
799 let Some(branch) = remote.strip_prefix("refs/heads/") else {
800 continue;
801 };
802 if let Some(b) = branches.iter().find(|b| b.eq_ignore_ascii_case(branch)) {
803 return Some(format!("sends `{b}`"));
804 }
805 }
806 None
807}
808
809fn holds_tracker(repo: &Path) -> bool {
813 if repo.join("vissue.toml").is_file() {
814 return true;
815 }
816 std::fs::read_dir(repo.join("Software")).is_ok_and(|entries| {
817 entries
818 .flatten()
819 .any(|entry| entry.path().join("issues.org").is_file())
820 })
821}
822
823fn book_commit(hook: &str) -> Result<()> {
824 if std::env::var_os("LJOS_GIT_HOOK").is_some() || booking_off() {
825 return Ok(());
826 }
827 if foreign_session() {
828 return Ok(());
829 }
830 if hook != "post-rewrite" && mid_rewrite() {
831 return Ok(());
832 }
833 let Some(repo) = git_line(&["rev-parse", "--show-toplevel"]) else {
834 return Ok(());
835 };
836 let repo_path = PathBuf::from(&repo);
837 let tracker = tracker_repo(&repo_path);
840 if !tracker && (crate::bookkeep::path_is_temp(Some(&repo_path)) || skip_repo(&repo_path)) {
841 return Ok(());
842 }
843 let Some(head) = git_line(&["rev-parse", "HEAD"]) else {
844 return Ok(());
845 };
846 let Some(seat) = crate::git_hook_seat() else {
850 if tracker {
851 refuse_tracker_commit(
852 &head,
853 "no seat is named; export LJOS_SEAT=<your seat> to write the tracker",
854 );
855 } else if crate::unseated_subagent() {
856 println!(
858 "ljos: commit {} not booked: this subagent has no seat; export LJOS_SEAT=<your seat> (the parent's) to book it",
859 &head[..head.len().min(12)]
860 );
861 }
862 return Ok(());
863 };
864 let _actor = crate::HookActor::enter(&seat);
865 if let Err(e) = crate::write_seat("commit") {
868 if tracker {
869 refuse_tracker_commit(&head, &format!("{e:#}"));
870 } else {
871 println!(
872 "ljos: commit {} not booked: {e:#}",
873 &head[..head.len().min(12)]
874 );
875 }
876 return Ok(());
877 }
878 if tracker {
879 return Ok(());
880 }
881 let _children = HookChildren::enter();
882 if hook == "post-rewrite" {
886 crate::bookkeep::repoint_commits(&repo, &rewrite_pairs());
887 }
888 if already_booked(&repo, &head) {
889 return Ok(());
890 }
891 let subject = git_line(&["log", "-1", "--format=%s"]).unwrap_or_default();
892 crate::book_queue::catch_up();
901 let session = crate::harness_book_session().unwrap_or_else(crate::bookkeep::seat_session);
902 crate::bookkeep::sweep_idle_sittings(&session, &repo);
903 let message = git_line(&["log", "-1", "--format=%B"]).unwrap_or_default();
907 if let Some(issue) = crate::bookkeep::named_open_issue(&message) {
908 crate::bookkeep::book_named_commit(&repo, &head, &issue);
909 remember_booked(&repo, &head);
910 if let Some(line) = crate::bookkeep::commit_recall_line(&session, &repo, &subject) {
911 println!("{line}");
912 }
913 return Ok(());
914 }
915 if crate::bookkeep::cite_into_open_sitting(&repo) {
918 remember_booked(&repo, &head);
919 return Ok(());
920 }
921 let command = if subject.is_empty() {
922 "git commit".to_string()
923 } else {
924 format!("git commit -m {}", shell_quote(&subject))
925 };
926 let raw = serde_json::json!({
927 "hook_event_name": "PostToolUse",
928 "session_id": session,
929 "cwd": repo,
930 "tool_name": "Bash",
931 "tool_input": {"command": command},
932 "tool_response": {"exit_code": 0},
933 "prompt": subject,
934 "ljos_git_hook": true,
935 })
936 .to_string();
937 let call = crate::HookCall {
938 event: "PostToolUse".into(),
939 cue: command,
940 session: Some(session.clone()),
941 shape: crate::HookShape::Asks,
942 };
943 let _ = crate::bookkeep::observe(&raw, &call);
944 remember_booked(&repo, &head);
945 if let Some(line) = crate::bookkeep::commit_recall_line(&session, &repo, &subject) {
946 println!("{line}");
947 }
948 Ok(())
949}
950
951fn booking_off() -> bool {
952 matches!(
953 std::env::var("LJOS_OFF").ok().as_deref(),
954 Some("1" | "true" | "TRUE" | "yes" | "YES" | "on" | "ON")
955 ) || crate::bookkeep::guard_suspended()
956}
957
958fn foreign_session() -> bool {
959 ["CLAUDECODE", "CODEX_THREAD_ID", "CODEX_SESSION_ID"]
963 .iter()
964 .any(|key| std::env::var_os(key).is_some_and(|value| !value.is_empty()))
965}
966
967fn rewrite_pairs() -> Vec<(String, String)> {
968 use std::io::{IsTerminal, Read};
969 if std::io::stdin().is_terminal() {
970 return Vec::new();
971 }
972 let mut raw = String::new();
973 if std::io::stdin().read_to_string(&mut raw).is_err() {
974 return Vec::new();
975 }
976 raw.lines()
977 .filter_map(|line| {
978 let mut parts = line.split_whitespace();
979 let old = parts.next()?;
980 let new = parts.next()?;
981 if commit_sha(old) && commit_sha(new) {
982 Some((old.to_string(), new.to_string()))
983 } else {
984 None
985 }
986 })
987 .collect()
988}
989
990fn commit_sha(text: &str) -> bool {
991 (text.len() == 40 || text.len() == 64) && text.chars().all(|c| c.is_ascii_hexdigit())
992}
993
994fn mid_rewrite() -> bool {
995 let Some(dir) = git_dir() else {
996 return false;
997 };
998 dir.join("rebase-merge").is_dir()
999 || dir.join("rebase-apply").is_dir()
1000 || dir.join("MERGE_HEAD").is_file()
1001}
1002
1003fn tracker_repo(repo: &Path) -> bool {
1005 ["VISSUE_ROOT", "ISSUE_ROOT"].iter().any(|key| {
1006 std::env::var_os(key)
1007 .filter(|value| !value.is_empty())
1008 .map(PathBuf::from)
1009 .is_some_and(|root| useful_root(&root) && covered(repo, &root))
1010 })
1011}
1012
1013fn refuse_tracker_commit(head: &str, why: &str) {
1017 let line = format!(
1018 "ljos: tracker commit {} was not made by a seat that may write the tracker: {why}",
1019 &head[..head.len().min(12)]
1020 );
1021 println!("{line}");
1022 crate::bookkeep::log_line(&format!("git-hooks: {line}"));
1023}
1024
1025fn skip_repo(repo: &Path) -> bool {
1026 if is_store(repo) {
1027 return true;
1028 }
1029 skip_roots().iter().any(|root| covered(repo, root))
1030}
1031
1032fn skip_roots() -> Vec<PathBuf> {
1036 let mut roots = vec![
1037 PathBuf::from("/tmp"),
1038 PathBuf::from("/var/tmp"),
1039 PathBuf::from("/dev/shm"),
1040 ];
1041 for key in ["VISSUE_ROOT", "ISSUE_ROOT", "CLAIMDAG_DIR"] {
1042 if let Some(path) = std::env::var_os(key).filter(|value| !value.is_empty()) {
1043 roots.push(PathBuf::from(path));
1044 }
1045 }
1046 roots.extend(store_dirs());
1047 for path in config_files() {
1048 roots.extend(configured_skips(&path));
1049 }
1050 roots.retain(|path| useful_root(path));
1051 roots
1052}
1053
1054fn useful_root(path: &Path) -> bool {
1055 let text = path.to_string_lossy();
1056 let text = text.trim();
1057 !text.is_empty() && text != "/" && text != "."
1058}
1059
1060fn config_files() -> Vec<PathBuf> {
1061 let mut out = Vec::new();
1062 let base = std::env::var_os("XDG_CONFIG_HOME")
1063 .filter(|value| !value.is_empty())
1064 .map(PathBuf::from)
1065 .or_else(|| {
1066 std::env::var_os("HOME")
1067 .filter(|value| !value.is_empty())
1068 .map(|home| PathBuf::from(home).join(".config"))
1069 });
1070 if let Some(base) = base {
1071 out.push(base.join("ljos").join("ljos.toml"));
1072 if let Some(seat) = std::env::var("LJOS_SEAT")
1073 .ok()
1074 .map(|name| name.trim().to_string())
1075 .filter(|name| !name.is_empty() && !name.contains('/') && !name.contains(".."))
1076 {
1077 out.push(base.join("ljos").join("seats").join(seat).join("ljos.toml"));
1078 }
1079 }
1080 if let Some(path) = std::env::var_os("LJOS_CONFIG").filter(|value| !value.is_empty()) {
1081 out.push(PathBuf::from(path));
1082 }
1083 if let Ok(cwd) = std::env::current_dir() {
1084 out.push(cwd.join("ljos.toml"));
1085 }
1086 out
1087}
1088
1089fn configured_skips(path: &Path) -> Vec<PathBuf> {
1090 let Ok(text) = fs::read_to_string(path) else {
1091 return Vec::new();
1092 };
1093 let Ok(value) = toml::from_str::<toml::Value>(&text) else {
1094 return Vec::new();
1095 };
1096 let Some(list) = value
1097 .get("git_hooks")
1098 .and_then(|table| table.get("skip"))
1099 .and_then(toml::Value::as_array)
1100 else {
1101 return Vec::new();
1102 };
1103 list.iter()
1104 .filter_map(toml::Value::as_str)
1105 .map(str::trim)
1106 .filter(|text| !text.is_empty())
1107 .map(PathBuf::from)
1108 .collect()
1109}
1110
1111fn covered(repo: &Path, root: &Path) -> bool {
1112 let repo = canonical_or(repo);
1113 let root = canonical_or(root);
1114 repo == root || repo.starts_with(&root)
1115}
1116
1117fn canonical_or(path: &Path) -> PathBuf {
1118 fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
1119}
1120
1121fn is_store(repo: &Path) -> bool {
1124 store_dirs()
1125 .iter()
1126 .any(|root| same_dir(repo, root) || repo_starts(repo, root))
1127}
1128
1129fn store_dirs() -> Vec<PathBuf> {
1130 let mut out = Vec::new();
1131 if let Ok(url) = std::env::var("DEEDAR_URL") {
1132 if let Some(path) = url.strip_prefix("file://") {
1133 out.push(PathBuf::from(path));
1134 }
1135 }
1136 if let Some(data) = std::env::var_os("XDG_DATA_HOME").filter(|value| !value.is_empty()) {
1137 out.push(PathBuf::from(data).join("deedar").join("store"));
1138 } else if let Some(home) = std::env::var_os("HOME").filter(|value| !value.is_empty()) {
1139 out.push(
1140 PathBuf::from(home)
1141 .join(".local")
1142 .join("share")
1143 .join("deedar")
1144 .join("store"),
1145 );
1146 }
1147 out
1148}
1149
1150fn same_dir(left: &Path, right: &Path) -> bool {
1151 let left = fs::canonicalize(left).unwrap_or_else(|_| left.to_path_buf());
1152 let right = fs::canonicalize(right).unwrap_or_else(|_| right.to_path_buf());
1153 left == right
1154}
1155
1156fn repo_starts(repo: &Path, root: &Path) -> bool {
1157 if !root.exists() {
1158 return false;
1159 }
1160 let repo = fs::canonicalize(repo).unwrap_or_else(|_| repo.to_path_buf());
1161 let root = fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
1162 repo.starts_with(root)
1163}
1164
1165fn already_booked(repo: &str, head: &str) -> bool {
1166 let key = format!("{repo}\t{head}");
1167 fs::read_to_string(booked_path())
1168 .map(|text| text.lines().any(|line| line == key))
1169 .unwrap_or(false)
1170}
1171
1172fn remember_booked(repo: &str, head: &str) {
1173 let path = booked_path();
1174 if let Some(dir) = path.parent() {
1175 let _ = fs::create_dir_all(dir);
1176 }
1177 let mut lines: Vec<String> = fs::read_to_string(&path)
1178 .unwrap_or_default()
1179 .lines()
1180 .map(str::to_string)
1181 .filter(|line| !line.starts_with(&format!("{repo}\t")))
1182 .collect();
1183 lines.push(format!("{repo}\t{head}"));
1184 if lines.len() > 200 {
1185 lines.drain(0..lines.len() - 200);
1186 }
1187 let _ = fs::write(path, lines.join("\n") + "\n");
1188}
1189
1190fn booked_path() -> PathBuf {
1191 crate::runtime_dir().join("git-hooks-booked")
1192}
1193
1194fn git_dir() -> Option<PathBuf> {
1195 let raw = git_line(&["rev-parse", "--git-dir"])?;
1196 let path = PathBuf::from(&raw);
1197 if path.is_absolute() {
1198 Some(path)
1199 } else {
1200 std::env::current_dir().ok().map(|cwd| cwd.join(path))
1201 }
1202}
1203
1204const HOOK_GIT_ENV: &[&str] = &[
1208 "GIT_DIR",
1209 "GIT_WORK_TREE",
1210 "GIT_INDEX_FILE",
1211 "GIT_PREFIX",
1212 "GIT_OBJECT_DIRECTORY",
1213 "GIT_COMMON_DIR",
1214 "GIT_NAMESPACE",
1215];
1216
1217thread_local! {
1218 static HOOK_CHILDREN: Cell<bool> = const { Cell::new(false) };
1219}
1220
1221#[must_use]
1226struct HookChildren {
1227 previous: bool,
1228}
1229
1230impl HookChildren {
1231 fn enter() -> Self {
1232 let previous = HOOK_CHILDREN.with(Cell::get);
1233 HOOK_CHILDREN.with(|flag| flag.set(true));
1234 Self { previous }
1235 }
1236}
1237
1238impl Drop for HookChildren {
1239 fn drop(&mut self) {
1240 HOOK_CHILDREN.with(|flag| flag.set(self.previous));
1241 }
1242}
1243
1244pub(crate) fn hook_child_env(cmd: &mut Command) {
1248 if !HOOK_CHILDREN.with(Cell::get) {
1249 return;
1250 }
1251 cmd.env("LJOS_GIT_HOOK", "1");
1252 for key in HOOK_GIT_ENV {
1253 cmd.env_remove(key);
1254 }
1255}
1256
1257#[cfg(test)]
1258pub(crate) fn with_hook_children_for_test<T>(run: impl FnOnce() -> T) -> T {
1259 let _guard = HookChildren::enter();
1260 run()
1261}
1262
1263fn git_command(args: &[&str], git_env: &[(&str, &str)]) -> Command {
1264 let mut cmd = Command::new("git");
1265 cmd.args(args);
1266 for (key, value) in git_env {
1267 cmd.env(key, value);
1268 }
1269 hook_child_env(&mut cmd);
1270 cmd
1271}
1272
1273fn git_line(args: &[&str]) -> Option<String> {
1274 let out = git_command(args, &[]).output().ok()?;
1275 if !out.status.success() {
1276 return None;
1277 }
1278 let text = String::from_utf8(out.stdout).ok()?;
1279 let text = text.trim();
1280 if text.is_empty() {
1281 None
1282 } else {
1283 Some(text.to_string())
1284 }
1285}
1286
1287fn global_hooks_path(git_env: &[(&str, &str)]) -> Option<String> {
1288 let out = git_command(&["config", "--global", "--get", "core.hooksPath"], git_env)
1289 .output()
1290 .ok()?;
1291 if !out.status.success() {
1292 return None;
1293 }
1294 let text = String::from_utf8(out.stdout).ok()?;
1295 let text = text.trim();
1296 if text.is_empty() {
1297 None
1298 } else {
1299 Some(text.to_string())
1300 }
1301}
1302
1303fn set_global_hooks_path(path: &Path, git_env: &[(&str, &str)]) -> Result<()> {
1304 let value = path.display().to_string();
1305 let status = git_command(&["config", "--global", "core.hooksPath", &value], git_env)
1306 .status()
1307 .context("git-hooks: git config")?;
1308 if !status.success() {
1309 bail!("git-hooks: could not set core.hooksPath");
1310 }
1311 Ok(())
1312}
1313
1314fn unset_global_hooks_path(git_env: &[(&str, &str)]) -> Result<()> {
1315 let status = git_command(
1316 &["config", "--global", "--unset", "core.hooksPath"],
1317 git_env,
1318 )
1319 .status()
1320 .context("git-hooks: git config")?;
1321 if !status.success() {
1322 bail!("git-hooks: could not unset core.hooksPath");
1323 }
1324 Ok(())
1325}
1326
1327#[cfg(test)]
1328mod tests {
1329 use std::ffi::OsStr;
1330
1331 use super::*;
1332
1333 fn mapped<'a>(cmd: &'a Command, key: &str) -> Option<Option<&'a OsStr>> {
1334 cmd.get_envs()
1335 .find(|(name, _)| *name == OsStr::new(key))
1336 .map(|(_, value)| value)
1337 }
1338
1339 #[test]
1340 fn the_doctor_names_a_hooks_path_that_is_not_the_onboard_one() {
1341 let dir = tempfile::tempdir().unwrap();
1342 let ours = dir.path().join("ljos").join("git-hooks");
1343 let other = dir.path().join("user-hooks");
1344 std::fs::create_dir_all(&other).unwrap();
1345 let prior = "#!/bin/sh\nprintf 'user-hook\\n'\n";
1346 std::fs::write(other.join("post-commit"), prior).unwrap();
1347 let ours_s = ours.display().to_string();
1348 let other_s = other.display().to_string();
1349
1350 let (ok, state) = super::hooks_state(&ours, None);
1351 assert!(!ok);
1352 assert!(state.starts_with(super::HOOKS_UNSET), "{state}");
1353 assert_eq!(state.matches(super::HOOKS_FIX).count(), 1, "{state}");
1354
1355 let (ok, state) = super::hooks_state(&ours, Some(&other_s));
1356 assert!(!ok, "{state}");
1357 assert!(state.contains(&other_s), "{state}");
1358 assert!(!state.contains(&ours_s), "{state}");
1359 assert_eq!(state.matches(super::HOOKS_FIX).count(), 1, "{state}");
1360
1361 super::chain_user_hooks(&other).unwrap();
1362 let (ok, state) = super::hooks_state(&ours, Some(&other_s));
1363 assert!(ok, "{state}");
1364 assert!(state.contains("ljos git-hook runs"), "{state}");
1365 let hooked = std::fs::read_to_string(other.join("post-commit")).unwrap();
1366 assert!(hooked.starts_with(prior), "{hooked}");
1367 assert_eq!(
1368 hooked.matches("# ljos:begin git-hook").count(),
1369 1,
1370 "{hooked}"
1371 );
1372 super::chain_user_hooks(&other).unwrap();
1373 assert_eq!(
1374 std::fs::read_to_string(other.join("post-commit")).unwrap(),
1375 hooked
1376 );
1377 assert!(other.join("pre-push").is_file());
1378
1379 super::strip_user_hooks(&other).unwrap();
1380 assert_eq!(
1381 std::fs::read_to_string(other.join("post-commit")).unwrap(),
1382 prior
1383 );
1384 assert!(!other.join("pre-push").exists());
1385
1386 let config = dir.path().join("config");
1387 std::fs::create_dir_all(&config).unwrap();
1388 std::fs::write(
1389 config.join("env.sh"),
1390 format!("export LJOS_GIT_HOOKS={}\n", other.display()),
1391 )
1392 .unwrap();
1393 let drift = super::env_hooks_drift(&config, &ours).expect("a drifted env file");
1394 assert!(drift.contains(&other_s), "{drift}");
1395 assert!(drift.contains(&ours_s), "{drift}");
1396 let (ok, state) = super::doctor_state(&ours, None, &config);
1397 assert!(!ok, "{state}");
1398 assert!(
1399 !state.starts_with(super::HOOKS_UNSET),
1400 "a mismatch stayed info: {state}"
1401 );
1402 assert!(state.contains(&other_s), "{state}");
1403 assert!(state.contains(&ours_s), "{state}");
1404
1405 std::fs::write(
1406 config.join("env.sh"),
1407 format!("{}\n", super::HOOKS_ENV_LINE),
1408 )
1409 .unwrap();
1410 assert!(super::env_hooks_drift(&config, &ours).is_none());
1411 let body = super::hook_body("post-rewrite");
1412 assert!(body.contains("mktemp"), "{body}");
1413 assert!(body.contains("git-hook post-rewrite"), "{body}");
1414 assert!(body.contains("< \"$_ljos_in\""), "{body}");
1415 let commit = super::hook_body("post-commit");
1416 assert!(!commit.contains("mktemp"), "{commit}");
1417 assert!(commit.contains("git-hook post-commit"), "{commit}");
1418
1419 super::write_owned_hooks(&ours).unwrap();
1420 let (ok, state) = super::hooks_state(&ours, Some(&ours_s));
1421 assert!(ok, "{state}");
1422 std::fs::remove_file(ours.join("pre-push")).unwrap();
1423 let (ok, state) = super::hooks_state(&ours, Some(&ours_s));
1424 assert!(!ok, "{state}");
1425 assert!(state.contains(super::HOOKS_FIX), "{state}");
1426 }
1427
1428 #[test]
1429 fn a_hook_that_does_not_book_succeeds() {
1430 super::run_named("post-checkout").unwrap();
1431 }
1432
1433 #[test]
1434 fn a_pre_push_ref_list_names_the_protected_branch_it_sends() {
1435 let branches = vec!["develop".to_string(), "main".to_string()];
1436 let line = |dst: &str| format!("refs/heads/x 1111 {dst} 0000\n");
1437 assert_eq!(protected_ref("", &branches), None);
1438 assert_eq!(protected_ref(&line("refs/heads/topic"), &branches), None);
1439 assert_eq!(protected_ref(&line("refs/tags/main"), &branches), None);
1440 let both = line("refs/heads/topic") + &line("refs/heads/Main");
1441 assert_eq!(
1442 protected_ref(&both, &branches).as_deref(),
1443 Some("sends `main`")
1444 );
1445 let odd = protected_ref("refs/heads/x main\n", &branches).unwrap();
1446 assert!(odd.contains("not a ref git would send"), "{odd}");
1447 }
1448
1449 #[test]
1450 fn hook_children_carry_the_guard_without_touching_the_process() {
1451 let before_hook = std::env::var_os("LJOS_GIT_HOOK");
1452 let before_dir = std::env::var_os("GIT_DIR");
1453 let mut plain = Command::new("true");
1454 plain.env("GIT_DIR", "keep");
1455 hook_child_env(&mut plain);
1456 assert_eq!(mapped(&plain, "GIT_DIR"), Some(Some(OsStr::new("keep"))));
1457 assert_eq!(mapped(&plain, "LJOS_GIT_HOOK"), None);
1458
1459 let _guard = HookChildren::enter();
1460 let mut cmd = Command::new("true");
1461 cmd.env("GIT_DIR", "from-the-hook");
1462 cmd.env("PATH", "/usr/bin");
1463 hook_child_env(&mut cmd);
1464 assert_eq!(mapped(&cmd, "LJOS_GIT_HOOK"), Some(Some(OsStr::new("1"))));
1465 for key in HOOK_GIT_ENV {
1466 assert_eq!(mapped(&cmd, key), Some(None), "{key}");
1467 }
1468 assert_eq!(mapped(&cmd, "PATH"), Some(Some(OsStr::new("/usr/bin"))));
1469 assert_eq!(std::env::var_os("LJOS_GIT_HOOK"), before_hook);
1470 assert_eq!(std::env::var_os("GIT_DIR"), before_dir);
1471 }
1472}