1use std::cell::Cell;
39use std::fs;
40use std::path::{Path, PathBuf};
41use std::process::Command;
42
43use anyhow::{bail, Context, Result};
44
45const HOOK_NAMES: &[&str] = &[
48 "applypatch-msg",
49 "pre-applypatch",
50 "post-applypatch",
51 "pre-commit",
52 "pre-merge-commit",
53 "prepare-commit-msg",
54 "commit-msg",
55 "post-commit",
56 "pre-rebase",
57 "post-checkout",
58 "post-merge",
59 "pre-push",
60 "pre-receive",
61 "update",
62 "proc-receive",
63 "post-receive",
64 "post-update",
65 "reference-transaction",
66 "push-to-checkout",
67 "pre-auto-gc",
68 "post-rewrite",
69 "sendemail-validate",
70 "fsmonitor-watchman",
71 "p4-changelist",
72 "p4-prepare-changelist",
73 "p4-post-changelist",
74 "p4-pre-submit",
75 "post-index-change",
76];
77
78fn books_commit(name: &str) -> bool {
79 matches!(name, "post-commit" | "post-rewrite")
80}
81
82pub fn run(action: &str) -> Result<()> {
88 match action {
89 "install" => {
90 let dir = install()?;
91 println!("installed {}", dir.display());
92 Ok(())
93 }
94 "uninstall" => {
95 uninstall()?;
96 println!("uninstalled");
97 Ok(())
98 }
99 "status" => {
100 print!("{}", status());
101 Ok(())
102 }
103 other => bail!("git-hooks: {other} is not install, uninstall, or status"),
104 }
105}
106
107pub fn install() -> Result<PathBuf> {
117 install_at(&hooks_dir(), &[])
118}
119
120pub(crate) fn install_at(dir: &Path, git_env: &[(&str, &str)]) -> Result<PathBuf> {
130 let current = global_hooks_path(git_env);
131 if let Some(path) = nonempty(current.as_deref()) {
132 if !same_dir(Path::new(path), dir) {
133 chain_user_hooks(Path::new(path))?;
134 return Ok(PathBuf::from(path));
135 }
136 }
137 write_owned_hooks(dir)?;
138 if nonempty(current.as_deref()).is_none() {
139 let _ = fs::remove_file(dir.join("previous-hooks-path"));
140 set_global_hooks_path(dir, git_env)?;
141 }
142 Ok(dir.to_path_buf())
143}
144
145pub fn uninstall() -> Result<()> {
156 let dir = hooks_dir();
157 let current = global_hooks_path(&[]);
158 match nonempty(current.as_deref()) {
159 Some(path) if same_dir(Path::new(path), &dir) => {
160 let previous = fs::read_to_string(dir.join("previous-hooks-path"))
161 .ok()
162 .map(|text| text.trim().to_string())
163 .filter(|text| !text.is_empty());
164 if let Some(prev) = previous {
165 set_global_hooks_path(Path::new(&prev), &[])?;
166 } else {
167 unset_global_hooks_path(&[])?;
168 }
169 remove_owned_hooks(&dir);
170 }
171 Some(path) => strip_user_hooks(Path::new(path))?,
172 None => remove_owned_hooks(&dir),
173 }
174 Ok(())
175}
176
177#[must_use]
179pub fn status() -> String {
180 let dir = hooks_dir();
181 let configured_path = global_hooks_path(&[]);
182 let configured = configured_path.clone().unwrap_or_else(|| "(unset)".into());
183 let reachable = nonempty(configured_path.as_deref())
184 .is_some_and(|path| hooks_reachable(Path::new(path), &dir));
185 let hooks = if reachable {
186 configured.clone()
187 } else if owned_installed(&dir) {
188 dir.display().to_string()
189 } else {
190 "not installed".into()
191 };
192 let reach = if reachable {
193 "ljos hooks run".to_string()
194 } else {
195 format!("ljos hooks do not run; {HOOKS_FIX}")
196 };
197 format!("hooks: {hooks}\ncore.hooksPath: {configured}\n{reach}\n")
198}
199
200pub const HOOKS_UNSET: &str = "core.hooksPath is unset";
204
205pub const HOOKS_FIX: &str = "ljos git-hooks install";
207
208#[must_use]
214pub fn doctor_row() -> crate::Habitat {
215 let dir = hooks_dir();
216 let (ok, state) = doctor_state(&dir, global_hooks_path(&[]).as_deref(), &config_ljos_dir());
217 crate::Habitat {
218 name: "git hooks",
219 state,
220 ok,
221 }
222}
223
224#[must_use]
227pub fn doctor_state(dir: &Path, configured: Option<&str>, config_dir: &Path) -> (bool, String) {
228 let (mut ok, mut state) = hooks_state(dir, configured);
229 if let Some(drift) = env_hooks_drift(config_dir, dir) {
230 ok = false;
231 state = if state.starts_with(HOOKS_UNSET) {
233 drift
234 } else {
235 format!("{state}; {drift}")
236 };
237 }
238 (ok, state)
239}
240
241#[must_use]
245pub fn env_hooks_drift(config_dir: &Path, onboard_dir: &Path) -> Option<String> {
246 let mut files = Vec::new();
247 for name in ["env.sh", "env"] {
248 let path = config_dir.join(name);
249 if path.is_file() {
250 files.push(path);
251 }
252 }
253 if let Ok(entries) = fs::read_dir(config_dir) {
254 let mut more: Vec<PathBuf> = entries
255 .flatten()
256 .map(|entry| entry.path())
257 .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("env"))
258 .collect();
259 more.sort();
260 files.extend(more);
261 }
262 let ours = onboard_dir.display().to_string();
263 for path in files {
264 let Ok(text) = fs::read_to_string(&path) else {
265 continue;
266 };
267 for line in text.lines() {
268 let Some(value) = hooks_assignment(line) else {
269 continue;
270 };
271 if assignment_agrees(&value, onboard_dir) {
272 continue;
273 }
274 return Some(format!(
275 "{} sets LJOS_GIT_HOOKS to {value}, not {ours}, the hooks `ljos onboard` installs",
276 path.display()
277 ));
278 }
279 }
280 None
281}
282
283fn hooks_assignment(line: &str) -> Option<String> {
284 let line = line.trim();
285 if line.is_empty() || line.starts_with('#') {
286 return None;
287 }
288 let rest = line.strip_prefix("export ").unwrap_or(line).trim();
289 let rest = rest.strip_prefix("LJOS_GIT_HOOKS=")?;
290 let value = unquote_assignment(rest.trim());
291 if value.is_empty() {
292 None
293 } else {
294 Some(value)
295 }
296}
297
298fn unquote_assignment(value: &str) -> String {
299 let value = value.trim();
300 if let Some(inner) = value
301 .strip_prefix('"')
302 .and_then(|text| text.strip_suffix('"'))
303 {
304 return inner.to_string();
305 }
306 if let Some(inner) = value
307 .strip_prefix('\'')
308 .and_then(|text| text.strip_suffix('\''))
309 {
310 return inner.to_string();
311 }
312 value.split_whitespace().next().unwrap_or("").to_string()
313}
314
315fn assignment_agrees(value: &str, onboard: &Path) -> bool {
316 if value.contains("${XDG_DATA_HOME:-$HOME/.local/share}/ljos/git-hooks") {
317 return true;
318 }
319 if value.contains('$') || value.contains('`') {
320 return false;
321 }
322 same_dir(Path::new(value), onboard)
323}
324
325fn config_ljos_dir() -> PathBuf {
326 let base = std::env::var_os("XDG_CONFIG_HOME")
327 .filter(|value| !value.is_empty())
328 .map(PathBuf::from)
329 .or_else(|| {
330 std::env::var_os("HOME")
331 .filter(|value| !value.is_empty())
332 .map(|home| PathBuf::from(home).join(".config"))
333 })
334 .unwrap_or_else(|| PathBuf::from(".config"));
335 base.join("ljos")
336}
337
338pub const HOOKS_ENV_LINE: &str = "\
341# Git hooks directory. `ljos git-hooks install` writes the stubs here.\n\
342export LJOS_GIT_HOOKS=\"${LJOS_GIT_HOOKS:-${XDG_DATA_HOME:-$HOME/.local/share}/ljos/git-hooks}\"";
343
344#[must_use]
347pub fn hooks_state(ours: &Path, configured: Option<&str>) -> (bool, String) {
348 let Some(path) = nonempty(configured) else {
349 return (
350 false,
351 format!("{HOOKS_UNSET}, so commits are not booked; {HOOKS_FIX}"),
352 );
353 };
354 let dir = Path::new(path);
355 if hooks_reachable(dir, ours) {
356 let state = if same_dir(dir, ours) {
357 format!("core.hooksPath is {path}; commits are booked")
358 } else {
359 format!("core.hooksPath is {path}; ljos git-hook runs from those hooks")
360 };
361 return (true, state);
362 }
363 let state = if same_dir(dir, ours) {
364 format!("core.hooksPath is {path}, but some hook stubs are missing there; {HOOKS_FIX}")
365 } else {
366 format!("core.hooksPath is {path}, and ljos is not chained there; {HOOKS_FIX}")
367 };
368 (false, state)
369}
370
371fn hooks_dir() -> PathBuf {
372 if let Some(dir) = std::env::var_os("LJOS_GIT_HOOKS").filter(|value| !value.is_empty()) {
373 return PathBuf::from(dir);
374 }
375 data_home().join("ljos").join("git-hooks")
376}
377
378fn data_home() -> PathBuf {
379 if let Some(dir) = std::env::var_os("XDG_DATA_HOME").filter(|value| !value.is_empty()) {
380 return PathBuf::from(dir);
381 }
382 std::env::var_os("HOME")
383 .filter(|value| !value.is_empty())
384 .map(|home| PathBuf::from(home).join(".local").join("share"))
385 .unwrap_or_else(|| PathBuf::from("."))
386}
387
388const BLOCK_BEGIN: &str = "# ljos:begin git-hook";
389const BLOCK_END: &str = "# ljos:end git-hook";
390
391fn nonempty(value: Option<&str>) -> Option<&str> {
392 value.map(str::trim).filter(|path| !path.is_empty())
393}
394
395fn ljos_executable() -> String {
396 std::env::current_exe()
397 .map(|path| path.display().to_string())
398 .unwrap_or_else(|_| "ljos".to_string())
399}
400
401fn hook_block(name: &str) -> String {
406 let bin = shell_quote(&ljos_executable());
407 if name == "post-rewrite" {
408 return format!(
409 "{BLOCK_BEGIN}\n\
410 if [ -z \"${{_ljos_in:-}}\" ]; then\n\
411 \t_ljos_in=$(mktemp 2>/dev/null || true)\n\
412 \tif [ -n \"$_ljos_in\" ]; then\n\
413 \t\tcat > \"$_ljos_in\" || true\n\
414 \tfi\n\
415 fi\n\
416 if command -v ljos >/dev/null 2>&1; then\n\
417 \tif [ -n \"${{_ljos_in:-}}\" ]; then\n\
418 \t\tljos git-hook {name} \"$@\" < \"$_ljos_in\" >&2 || true\n\
419 \telse\n\
420 \t\tljos git-hook {name} \"$@\" >&2 || true\n\
421 \tfi\n\
422 else\n\
423 \tif [ -n \"${{_ljos_in:-}}\" ]; then\n\
424 \t\t{bin} git-hook {name} \"$@\" < \"$_ljos_in\" >&2 || true\n\
425 \telse\n\
426 \t\t{bin} git-hook {name} \"$@\" >&2 || true\n\
427 \tfi\n\
428 fi\n\
429 {BLOCK_END}\n"
430 );
431 }
432 format!(
433 "{BLOCK_BEGIN}\n\
434 if command -v ljos >/dev/null 2>&1; then\n\
435 \tljos git-hook {name} \"$@\" >&2 || true\n\
436 else\n\
437 \t{bin} git-hook {name} \"$@\" >&2 || true\n\
438 fi\n\
439 {BLOCK_END}\n"
440 )
441}
442
443fn has_block(text: &str, name: &str) -> bool {
444 text.contains(&hook_block(name))
445}
446
447fn strip_block(text: &str) -> String {
448 let Some(start) = text.find(BLOCK_BEGIN) else {
449 return text.to_string();
450 };
451 let Some(rel) = text[start..].find(BLOCK_END) else {
452 return text.to_string();
453 };
454 let mut end = start + rel + BLOCK_END.len();
455 if text[end..].starts_with('\n') {
456 end += 1;
457 }
458 let mut out = String::new();
459 out.push_str(&text[..start]);
460 out.push_str(&text[end..]);
461 out
462}
463
464fn strip_all(text: &str) -> String {
465 let mut out = text.to_string();
466 loop {
467 let next = strip_block(&out);
468 if next == out {
469 return out;
470 }
471 out = next;
472 }
473}
474
475fn merge_block(text: &str, name: &str) -> String {
476 if has_block(text, name) {
477 return text.to_string();
478 }
479 let stripped = strip_all(text);
480 let block = hook_block(name);
481 if stripped.trim().is_empty() {
482 return format!("#!/bin/sh\n{block}");
483 }
484 if stripped.ends_with('\n') {
485 format!("{stripped}{block}")
486 } else {
487 format!("{stripped}\n{block}")
488 }
489}
490
491fn block_only(text: &str) -> bool {
492 matches!(text.trim(), "" | "#!/bin/sh")
493}
494
495fn hook_calls_ljos(text: &str, name: &str) -> bool {
496 text.contains(&format!("ljos git-hook {name} "))
497}
498
499fn is_executable(path: &Path) -> bool {
500 #[cfg(unix)]
501 {
502 use std::os::unix::fs::PermissionsExt;
503 fs::metadata(path)
504 .map(|meta| meta.permissions().mode() & 0o111 != 0)
505 .unwrap_or(false)
506 }
507 #[cfg(not(unix))]
508 {
509 path.is_file()
510 }
511}
512
513fn ensure_executable(path: &Path) -> Result<()> {
514 #[cfg(unix)]
515 {
516 use std::os::unix::fs::PermissionsExt;
517 let meta = fs::metadata(path).with_context(|| format!("git-hooks: {}", path.display()))?;
518 let mode = meta.permissions().mode();
519 if mode & 0o111 == 0 {
520 fs::set_permissions(path, fs::Permissions::from_mode(mode | 0o111))?;
521 }
522 }
523 Ok(())
524}
525
526fn owned_installed(dir: &Path) -> bool {
527 HOOK_NAMES.iter().all(|name| {
528 let path = dir.join(name);
529 let Ok(text) = fs::read_to_string(&path) else {
530 return false;
531 };
532 is_executable(&path) && (text.contains("#!/bin/sh") || hook_calls_ljos(&text, name))
533 })
534}
535
536fn user_chained(dir: &Path) -> bool {
537 HOOK_NAMES.iter().all(|name| {
538 let path = dir.join(name);
539 let Ok(text) = fs::read_to_string(&path) else {
540 return false;
541 };
542 is_executable(&path) && hook_calls_ljos(&text, name)
543 })
544}
545
546fn hooks_reachable(configured: &Path, ours: &Path) -> bool {
547 if same_dir(configured, ours) {
548 owned_installed(configured)
549 } else {
550 user_chained(configured)
551 }
552}
553
554fn write_owned_hooks(dir: &Path) -> Result<()> {
555 fs::create_dir_all(dir).with_context(|| format!("git-hooks: {}", dir.display()))?;
556 for name in HOOK_NAMES {
557 let path = dir.join(name);
558 fs::write(&path, hook_body(name))
559 .with_context(|| format!("git-hooks: {}", path.display()))?;
560 #[cfg(unix)]
561 {
562 use std::os::unix::fs::PermissionsExt;
563 fs::set_permissions(&path, fs::Permissions::from_mode(0o755))?;
564 }
565 }
566 Ok(())
567}
568
569fn chain_user_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 let existing = if path.is_file() {
574 Some(fs::read(&path).with_context(|| format!("git-hooks: {}", path.display()))?)
575 } else {
576 None
577 };
578 if let Some(bytes) = &existing {
579 if bytes.contains(&0) || std::str::from_utf8(bytes).is_err() {
580 eprintln!("ljos: {} is not a shell script; left it", path.display());
581 continue;
582 }
583 }
584 let text = existing
585 .as_deref()
586 .map(|bytes| std::str::from_utf8(bytes).unwrap_or(""))
587 .unwrap_or("");
588 let next = merge_block(text, name);
589 if existing.is_none() || next != text {
590 fs::write(&path, next).with_context(|| format!("git-hooks: {}", path.display()))?;
591 }
592 ensure_executable(&path)?;
593 }
594 Ok(())
595}
596
597fn strip_user_hooks(dir: &Path) -> Result<()> {
598 if !dir.is_dir() {
599 return Ok(());
600 }
601 for name in HOOK_NAMES {
602 let path = dir.join(name);
603 let Ok(bytes) = fs::read(&path) else {
604 continue;
605 };
606 let Ok(text) = std::str::from_utf8(&bytes) else {
607 continue;
608 };
609 if !text.contains(BLOCK_BEGIN) {
610 continue;
611 }
612 let stripped = strip_all(text);
613 if block_only(&stripped) {
614 fs::remove_file(&path).with_context(|| format!("git-hooks: {}", path.display()))?;
615 } else if stripped != text {
616 fs::write(&path, stripped).with_context(|| format!("git-hooks: {}", path.display()))?;
617 }
618 }
619 Ok(())
620}
621
622fn remove_owned_hooks(dir: &Path) {
623 for name in HOOK_NAMES {
624 let _ = fs::remove_file(dir.join(name));
625 }
626 let _ = fs::remove_file(dir.join("previous-hooks-path"));
627}
628
629fn hook_body(name: &str) -> String {
630 let block = hook_block(name);
631 format!(
632 "#!/bin/sh\n\
633 # ljos books the commit, then chains to this repository's\n\
634 # .git/hooks/{name}, or to the core.hooksPath recorded in\n\
635 # previous-hooks-path. Booking cannot fail the hook. The chained\n\
636 # hook's exit status is kept. post-rewrite keeps a copy of stdin\n\
637 # for that hook.\n\
638 #\n\
639 # pre-commit install follows core.hooksPath and would replace this\n\
640 # file. Install the framework into the repository, then re-run\n\
641 # `ljos git-hooks install` if a stub was overwritten:\n\
642 # dir=$(git rev-parse --git-dir)\n\
643 # git config --local core.hooksPath \"$dir/hooks\"\n\
644 # pre-commit install\n\
645 # git config --local --unset core.hooksPath\n\
646 {block}\
647 git_dir=$(git rev-parse --git-dir 2>/dev/null || true)\n\
648 if [ -n \"$git_dir\" ]; then\n\
649 \tcase \"$git_dir\" in\n\
650 \t\t/*) ;;\n\
651 \t\t*) git_dir=$(pwd)/$git_dir ;;\n\
652 \tesac\n\
653 \trepo_hook=\"$git_dir/hooks/{name}\"\n\
654 \tif [ -x \"$repo_hook\" ] && [ \"$repo_hook\" != \"$0\" ]; then\n\
655 \t\tif [ -n \"${{_ljos_in:-}}\" ]; then\n\
656 \t\t\texec \"$repo_hook\" \"$@\" < \"$_ljos_in\"\n\
657 \t\tfi\n\
658 \t\texec \"$repo_hook\" \"$@\"\n\
659 \tfi\n\
660 fi\n\
661 here=$(dirname \"$0\")\n\
662 here=$(CDPATH= cd -- \"$here\" && pwd) || exit 0\n\
663 prev=$(sed -n '1s/[[:space:]]*$//p' \"$here/previous-hooks-path\" 2>/dev/null || true)\n\
664 if [ -n \"$prev\" ] && [ \"$prev\" != \"$here\" ]; then\n\
665 \tprev_hook=\"$prev/{name}\"\n\
666 \tif [ -x \"$prev_hook\" ] && [ \"$prev_hook\" != \"$0\" ]; then\n\
667 \t\tif [ -n \"${{_ljos_in:-}}\" ]; then\n\
668 \t\t\texec \"$prev_hook\" \"$@\" < \"$_ljos_in\"\n\
669 \t\tfi\n\
670 \t\texec \"$prev_hook\" \"$@\"\n\
671 \tfi\n\
672 fi\n\
673 exit 0\n"
674 )
675}
676
677fn shell_quote(text: &str) -> String {
678 format!("'{}'", text.replace('\'', "'\\''"))
679}
680
681pub fn run_git_commit(hook: &str) -> Result<()> {
687 if let Err(err) = book_commit(hook) {
688 crate::bookkeep::log_line(&format!("git-hooks: {err:#}"));
689 }
690 Ok(())
691}
692
693pub fn run_named(name: &str) -> Result<()> {
702 let name = name.trim();
703 if books_commit(name) {
704 run_git_commit(name)
705 } else {
706 Ok(())
707 }
708}
709
710fn book_commit(hook: &str) -> Result<()> {
711 if std::env::var_os("LJOS_GIT_HOOK").is_some() || booking_off() {
712 return Ok(());
713 }
714 if foreign_session() {
715 return Ok(());
716 }
717 if hook != "post-rewrite" && mid_rewrite() {
718 return Ok(());
719 }
720 let Some(repo) = git_line(&["rev-parse", "--show-toplevel"]) else {
721 return Ok(());
722 };
723 let repo_path = PathBuf::from(&repo);
724 if crate::bookkeep::path_is_temp(Some(&repo_path)) || skip_repo(&repo_path) {
725 return Ok(());
726 }
727 let Some(head) = git_line(&["rev-parse", "HEAD"]) else {
728 return Ok(());
729 };
730 let Some(seat) = crate::git_hook_seat() else {
734 if crate::unseated_subagent() {
735 println!(
737 "ljos: commit {} not booked: this subagent has no seat; export LJOS_SEAT=<your seat> (the parent's) to book it",
738 &head[..head.len().min(12)]
739 );
740 }
741 return Ok(());
742 };
743 let _actor = crate::HookActor::enter(&seat);
744 let _children = HookChildren::enter();
745 if hook == "post-rewrite" {
749 crate::bookkeep::repoint_commits(&repo, &rewrite_pairs());
750 }
751 if already_booked(&repo, &head) {
752 return Ok(());
753 }
754 let subject = git_line(&["log", "-1", "--format=%s"]).unwrap_or_default();
755 let session = crate::harness_book_session().unwrap_or_else(crate::bookkeep::seat_session);
763 crate::bookkeep::sweep_idle_sittings(&session, &repo);
764 let message = git_line(&["log", "-1", "--format=%B"]).unwrap_or_default();
768 if let Some(issue) = crate::bookkeep::named_open_issue(&message) {
769 crate::bookkeep::book_named_commit(&repo, &head, &issue);
770 remember_booked(&repo, &head);
771 if let Some(line) = crate::bookkeep::commit_recall_line(&session, &repo, &subject) {
772 println!("{line}");
773 }
774 return Ok(());
775 }
776 if crate::bookkeep::cite_into_open_sitting(&repo) {
779 remember_booked(&repo, &head);
780 return Ok(());
781 }
782 let command = if subject.is_empty() {
783 "git commit".to_string()
784 } else {
785 format!("git commit -m {}", shell_quote(&subject))
786 };
787 let raw = serde_json::json!({
788 "hook_event_name": "PostToolUse",
789 "session_id": session,
790 "cwd": repo,
791 "tool_name": "Bash",
792 "tool_input": {"command": command},
793 "tool_response": {"exit_code": 0},
794 "prompt": subject,
795 "ljos_git_hook": true,
796 })
797 .to_string();
798 let call = crate::HookCall {
799 event: "PostToolUse".into(),
800 cue: command,
801 session: Some(session.clone()),
802 shape: crate::HookShape::Asks,
803 };
804 let _ = crate::bookkeep::observe(&raw, &call);
805 remember_booked(&repo, &head);
806 if let Some(line) = crate::bookkeep::commit_recall_line(&session, &repo, &subject) {
807 println!("{line}");
808 }
809 Ok(())
810}
811
812fn booking_off() -> bool {
813 matches!(
814 std::env::var("LJOS_OFF").ok().as_deref(),
815 Some("1" | "true" | "TRUE" | "yes" | "YES" | "on" | "ON")
816 ) || crate::bookkeep::guard_suspended()
817}
818
819fn foreign_session() -> bool {
820 ["CLAUDECODE", "CODEX_THREAD_ID", "CODEX_SESSION_ID"]
824 .iter()
825 .any(|key| std::env::var_os(key).is_some_and(|value| !value.is_empty()))
826}
827
828fn rewrite_pairs() -> Vec<(String, String)> {
829 use std::io::{IsTerminal, Read};
830 if std::io::stdin().is_terminal() {
831 return Vec::new();
832 }
833 let mut raw = String::new();
834 if std::io::stdin().read_to_string(&mut raw).is_err() {
835 return Vec::new();
836 }
837 raw.lines()
838 .filter_map(|line| {
839 let mut parts = line.split_whitespace();
840 let old = parts.next()?;
841 let new = parts.next()?;
842 if commit_sha(old) && commit_sha(new) {
843 Some((old.to_string(), new.to_string()))
844 } else {
845 None
846 }
847 })
848 .collect()
849}
850
851fn commit_sha(text: &str) -> bool {
852 (text.len() == 40 || text.len() == 64) && text.chars().all(|c| c.is_ascii_hexdigit())
853}
854
855fn mid_rewrite() -> bool {
856 let Some(dir) = git_dir() else {
857 return false;
858 };
859 dir.join("rebase-merge").is_dir()
860 || dir.join("rebase-apply").is_dir()
861 || dir.join("MERGE_HEAD").is_file()
862}
863
864fn skip_repo(repo: &Path) -> bool {
865 if is_store(repo) {
866 return true;
867 }
868 skip_roots().iter().any(|root| covered(repo, root))
869}
870
871fn skip_roots() -> Vec<PathBuf> {
875 let mut roots = vec![
876 PathBuf::from("/tmp"),
877 PathBuf::from("/var/tmp"),
878 PathBuf::from("/dev/shm"),
879 ];
880 for key in ["VISSUE_ROOT", "ISSUE_ROOT", "CLAIMDAG_DIR"] {
881 if let Some(path) = std::env::var_os(key).filter(|value| !value.is_empty()) {
882 roots.push(PathBuf::from(path));
883 }
884 }
885 roots.extend(store_dirs());
886 for path in config_files() {
887 roots.extend(configured_skips(&path));
888 }
889 roots.retain(|path| useful_root(path));
890 roots
891}
892
893fn useful_root(path: &Path) -> bool {
894 let text = path.to_string_lossy();
895 let text = text.trim();
896 !text.is_empty() && text != "/" && text != "."
897}
898
899fn config_files() -> Vec<PathBuf> {
900 let mut out = Vec::new();
901 let base = std::env::var_os("XDG_CONFIG_HOME")
902 .filter(|value| !value.is_empty())
903 .map(PathBuf::from)
904 .or_else(|| {
905 std::env::var_os("HOME")
906 .filter(|value| !value.is_empty())
907 .map(|home| PathBuf::from(home).join(".config"))
908 });
909 if let Some(base) = base {
910 out.push(base.join("ljos").join("ljos.toml"));
911 if let Some(seat) = std::env::var("LJOS_SEAT")
912 .ok()
913 .map(|name| name.trim().to_string())
914 .filter(|name| !name.is_empty() && !name.contains('/') && !name.contains(".."))
915 {
916 out.push(base.join("ljos").join("seats").join(seat).join("ljos.toml"));
917 }
918 }
919 if let Some(path) = std::env::var_os("LJOS_CONFIG").filter(|value| !value.is_empty()) {
920 out.push(PathBuf::from(path));
921 }
922 if let Ok(cwd) = std::env::current_dir() {
923 out.push(cwd.join("ljos.toml"));
924 }
925 out
926}
927
928fn configured_skips(path: &Path) -> Vec<PathBuf> {
929 let Ok(text) = fs::read_to_string(path) else {
930 return Vec::new();
931 };
932 let Ok(value) = toml::from_str::<toml::Value>(&text) else {
933 return Vec::new();
934 };
935 let Some(list) = value
936 .get("git_hooks")
937 .and_then(|table| table.get("skip"))
938 .and_then(toml::Value::as_array)
939 else {
940 return Vec::new();
941 };
942 list.iter()
943 .filter_map(toml::Value::as_str)
944 .map(str::trim)
945 .filter(|text| !text.is_empty())
946 .map(PathBuf::from)
947 .collect()
948}
949
950fn covered(repo: &Path, root: &Path) -> bool {
951 let repo = canonical_or(repo);
952 let root = canonical_or(root);
953 repo == root || repo.starts_with(&root)
954}
955
956fn canonical_or(path: &Path) -> PathBuf {
957 fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
958}
959
960fn is_store(repo: &Path) -> bool {
961 let text = repo.to_string_lossy();
962 if text.contains("/deedar/store") || text.contains("/claimdag/") || text.ends_with("/claimdag")
963 {
964 return true;
965 }
966 store_dirs()
967 .iter()
968 .any(|root| same_dir(repo, root) || repo_starts(repo, root))
969}
970
971fn store_dirs() -> Vec<PathBuf> {
972 let mut out = Vec::new();
973 if let Ok(url) = std::env::var("DEEDAR_URL") {
974 if let Some(path) = url.strip_prefix("file://") {
975 out.push(PathBuf::from(path));
976 }
977 }
978 if let Some(data) = std::env::var_os("XDG_DATA_HOME").filter(|value| !value.is_empty()) {
979 out.push(PathBuf::from(data).join("deedar").join("store"));
980 } else if let Some(home) = std::env::var_os("HOME").filter(|value| !value.is_empty()) {
981 out.push(
982 PathBuf::from(home)
983 .join(".local")
984 .join("share")
985 .join("deedar")
986 .join("store"),
987 );
988 }
989 out
990}
991
992fn same_dir(left: &Path, right: &Path) -> bool {
993 let left = fs::canonicalize(left).unwrap_or_else(|_| left.to_path_buf());
994 let right = fs::canonicalize(right).unwrap_or_else(|_| right.to_path_buf());
995 left == right
996}
997
998fn repo_starts(repo: &Path, root: &Path) -> bool {
999 if !root.exists() {
1000 return false;
1001 }
1002 let repo = fs::canonicalize(repo).unwrap_or_else(|_| repo.to_path_buf());
1003 let root = fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
1004 repo.starts_with(root)
1005}
1006
1007fn already_booked(repo: &str, head: &str) -> bool {
1008 let key = format!("{repo}\t{head}");
1009 fs::read_to_string(booked_path())
1010 .map(|text| text.lines().any(|line| line == key))
1011 .unwrap_or(false)
1012}
1013
1014fn remember_booked(repo: &str, head: &str) {
1015 let path = booked_path();
1016 if let Some(dir) = path.parent() {
1017 let _ = fs::create_dir_all(dir);
1018 }
1019 let mut lines: Vec<String> = fs::read_to_string(&path)
1020 .unwrap_or_default()
1021 .lines()
1022 .map(str::to_string)
1023 .filter(|line| !line.starts_with(&format!("{repo}\t")))
1024 .collect();
1025 lines.push(format!("{repo}\t{head}"));
1026 if lines.len() > 200 {
1027 lines.drain(0..lines.len() - 200);
1028 }
1029 let _ = fs::write(path, lines.join("\n") + "\n");
1030}
1031
1032fn booked_path() -> PathBuf {
1033 crate::runtime_dir().join("git-hooks-booked")
1034}
1035
1036fn git_dir() -> Option<PathBuf> {
1037 let raw = git_line(&["rev-parse", "--git-dir"])?;
1038 let path = PathBuf::from(&raw);
1039 if path.is_absolute() {
1040 Some(path)
1041 } else {
1042 std::env::current_dir().ok().map(|cwd| cwd.join(path))
1043 }
1044}
1045
1046const HOOK_GIT_ENV: &[&str] = &[
1050 "GIT_DIR",
1051 "GIT_WORK_TREE",
1052 "GIT_INDEX_FILE",
1053 "GIT_PREFIX",
1054 "GIT_OBJECT_DIRECTORY",
1055 "GIT_COMMON_DIR",
1056 "GIT_NAMESPACE",
1057];
1058
1059thread_local! {
1060 static HOOK_CHILDREN: Cell<bool> = const { Cell::new(false) };
1061}
1062
1063#[must_use]
1068struct HookChildren {
1069 previous: bool,
1070}
1071
1072impl HookChildren {
1073 fn enter() -> Self {
1074 let previous = HOOK_CHILDREN.with(Cell::get);
1075 HOOK_CHILDREN.with(|flag| flag.set(true));
1076 Self { previous }
1077 }
1078}
1079
1080impl Drop for HookChildren {
1081 fn drop(&mut self) {
1082 HOOK_CHILDREN.with(|flag| flag.set(self.previous));
1083 }
1084}
1085
1086pub(crate) fn hook_child_env(cmd: &mut Command) {
1090 if !HOOK_CHILDREN.with(Cell::get) {
1091 return;
1092 }
1093 cmd.env("LJOS_GIT_HOOK", "1");
1094 for key in HOOK_GIT_ENV {
1095 cmd.env_remove(key);
1096 }
1097}
1098
1099#[cfg(test)]
1100pub(crate) fn with_hook_children_for_test<T>(run: impl FnOnce() -> T) -> T {
1101 let _guard = HookChildren::enter();
1102 run()
1103}
1104
1105fn git_command(args: &[&str], git_env: &[(&str, &str)]) -> Command {
1106 let mut cmd = Command::new("git");
1107 cmd.args(args);
1108 for (key, value) in git_env {
1109 cmd.env(key, value);
1110 }
1111 hook_child_env(&mut cmd);
1112 cmd
1113}
1114
1115fn git_line(args: &[&str]) -> Option<String> {
1116 let out = git_command(args, &[]).output().ok()?;
1117 if !out.status.success() {
1118 return None;
1119 }
1120 let text = String::from_utf8(out.stdout).ok()?;
1121 let text = text.trim();
1122 if text.is_empty() {
1123 None
1124 } else {
1125 Some(text.to_string())
1126 }
1127}
1128
1129fn global_hooks_path(git_env: &[(&str, &str)]) -> Option<String> {
1130 let out = git_command(&["config", "--global", "--get", "core.hooksPath"], git_env)
1131 .output()
1132 .ok()?;
1133 if !out.status.success() {
1134 return None;
1135 }
1136 let text = String::from_utf8(out.stdout).ok()?;
1137 let text = text.trim();
1138 if text.is_empty() {
1139 None
1140 } else {
1141 Some(text.to_string())
1142 }
1143}
1144
1145fn set_global_hooks_path(path: &Path, git_env: &[(&str, &str)]) -> Result<()> {
1146 let value = path.display().to_string();
1147 let status = git_command(&["config", "--global", "core.hooksPath", &value], git_env)
1148 .status()
1149 .context("git-hooks: git config")?;
1150 if !status.success() {
1151 bail!("git-hooks: could not set core.hooksPath");
1152 }
1153 Ok(())
1154}
1155
1156fn unset_global_hooks_path(git_env: &[(&str, &str)]) -> Result<()> {
1157 let status = git_command(
1158 &["config", "--global", "--unset", "core.hooksPath"],
1159 git_env,
1160 )
1161 .status()
1162 .context("git-hooks: git config")?;
1163 if !status.success() {
1164 bail!("git-hooks: could not unset core.hooksPath");
1165 }
1166 Ok(())
1167}
1168
1169#[cfg(test)]
1170mod tests {
1171 use std::ffi::OsStr;
1172
1173 use super::*;
1174
1175 fn mapped<'a>(cmd: &'a Command, key: &str) -> Option<Option<&'a OsStr>> {
1176 cmd.get_envs()
1177 .find(|(name, _)| *name == OsStr::new(key))
1178 .map(|(_, value)| value)
1179 }
1180
1181 #[test]
1182 fn the_doctor_names_a_hooks_path_that_is_not_the_onboard_one() {
1183 let dir = tempfile::tempdir().unwrap();
1184 let ours = dir.path().join("ljos").join("git-hooks");
1185 let other = dir.path().join("user-hooks");
1186 std::fs::create_dir_all(&other).unwrap();
1187 let prior = "#!/bin/sh\nprintf 'user-hook\\n'\n";
1188 std::fs::write(other.join("post-commit"), prior).unwrap();
1189 let ours_s = ours.display().to_string();
1190 let other_s = other.display().to_string();
1191
1192 let (ok, state) = super::hooks_state(&ours, None);
1193 assert!(!ok);
1194 assert!(state.starts_with(super::HOOKS_UNSET), "{state}");
1195 assert_eq!(state.matches(super::HOOKS_FIX).count(), 1, "{state}");
1196
1197 let (ok, state) = super::hooks_state(&ours, Some(&other_s));
1198 assert!(!ok, "{state}");
1199 assert!(state.contains(&other_s), "{state}");
1200 assert!(!state.contains(&ours_s), "{state}");
1201 assert_eq!(state.matches(super::HOOKS_FIX).count(), 1, "{state}");
1202
1203 super::chain_user_hooks(&other).unwrap();
1204 let (ok, state) = super::hooks_state(&ours, Some(&other_s));
1205 assert!(ok, "{state}");
1206 assert!(state.contains("ljos git-hook runs"), "{state}");
1207 let hooked = std::fs::read_to_string(other.join("post-commit")).unwrap();
1208 assert!(hooked.starts_with(prior), "{hooked}");
1209 assert_eq!(
1210 hooked.matches("# ljos:begin git-hook").count(),
1211 1,
1212 "{hooked}"
1213 );
1214 super::chain_user_hooks(&other).unwrap();
1215 assert_eq!(
1216 std::fs::read_to_string(other.join("post-commit")).unwrap(),
1217 hooked
1218 );
1219 assert!(other.join("pre-push").is_file());
1220
1221 super::strip_user_hooks(&other).unwrap();
1222 assert_eq!(
1223 std::fs::read_to_string(other.join("post-commit")).unwrap(),
1224 prior
1225 );
1226 assert!(!other.join("pre-push").exists());
1227
1228 let config = dir.path().join("config");
1229 std::fs::create_dir_all(&config).unwrap();
1230 std::fs::write(
1231 config.join("env.sh"),
1232 format!("export LJOS_GIT_HOOKS={}\n", other.display()),
1233 )
1234 .unwrap();
1235 let drift = super::env_hooks_drift(&config, &ours).expect("a drifted env file");
1236 assert!(drift.contains(&other_s), "{drift}");
1237 assert!(drift.contains(&ours_s), "{drift}");
1238 let (ok, state) = super::doctor_state(&ours, None, &config);
1239 assert!(!ok, "{state}");
1240 assert!(
1241 !state.starts_with(super::HOOKS_UNSET),
1242 "a mismatch stayed info: {state}"
1243 );
1244 assert!(state.contains(&other_s), "{state}");
1245 assert!(state.contains(&ours_s), "{state}");
1246
1247 std::fs::write(
1248 config.join("env.sh"),
1249 format!("{}\n", super::HOOKS_ENV_LINE),
1250 )
1251 .unwrap();
1252 assert!(super::env_hooks_drift(&config, &ours).is_none());
1253 let body = super::hook_body("post-rewrite");
1254 assert!(body.contains("mktemp"), "{body}");
1255 assert!(body.contains("git-hook post-rewrite"), "{body}");
1256 assert!(body.contains("< \"$_ljos_in\""), "{body}");
1257 let commit = super::hook_body("post-commit");
1258 assert!(!commit.contains("mktemp"), "{commit}");
1259 assert!(commit.contains("git-hook post-commit"), "{commit}");
1260
1261 super::write_owned_hooks(&ours).unwrap();
1262 let (ok, state) = super::hooks_state(&ours, Some(&ours_s));
1263 assert!(ok, "{state}");
1264 std::fs::remove_file(ours.join("pre-push")).unwrap();
1265 let (ok, state) = super::hooks_state(&ours, Some(&ours_s));
1266 assert!(!ok, "{state}");
1267 assert!(state.contains(super::HOOKS_FIX), "{state}");
1268 }
1269
1270 #[test]
1271 fn a_hook_that_does_not_book_succeeds() {
1272 super::run_named("pre-push").unwrap();
1273 }
1274
1275 #[test]
1276 fn hook_children_carry_the_guard_without_touching_the_process() {
1277 let before_hook = std::env::var_os("LJOS_GIT_HOOK");
1278 let before_dir = std::env::var_os("GIT_DIR");
1279 let mut plain = Command::new("true");
1280 plain.env("GIT_DIR", "keep");
1281 hook_child_env(&mut plain);
1282 assert_eq!(mapped(&plain, "GIT_DIR"), Some(Some(OsStr::new("keep"))));
1283 assert_eq!(mapped(&plain, "LJOS_GIT_HOOK"), None);
1284
1285 let _guard = HookChildren::enter();
1286 let mut cmd = Command::new("true");
1287 cmd.env("GIT_DIR", "from-the-hook");
1288 cmd.env("PATH", "/usr/bin");
1289 hook_child_env(&mut cmd);
1290 assert_eq!(mapped(&cmd, "LJOS_GIT_HOOK"), Some(Some(OsStr::new("1"))));
1291 for key in HOOK_GIT_ENV {
1292 assert_eq!(mapped(&cmd, key), Some(None), "{key}");
1293 }
1294 assert_eq!(mapped(&cmd, "PATH"), Some(Some(OsStr::new("/usr/bin"))));
1295 assert_eq!(std::env::var_os("LJOS_GIT_HOOK"), before_hook);
1296 assert_eq!(std::env::var_os("GIT_DIR"), before_dir);
1297 }
1298}