1use std::cell::Cell;
27use std::fs;
28use std::path::{Path, PathBuf};
29use std::process::Command;
30
31use anyhow::{bail, Context, Result};
32
33const HOOK_NAMES: &[&str] = &[
36 "applypatch-msg",
37 "pre-applypatch",
38 "post-applypatch",
39 "pre-commit",
40 "pre-merge-commit",
41 "prepare-commit-msg",
42 "commit-msg",
43 "post-commit",
44 "pre-rebase",
45 "post-checkout",
46 "post-merge",
47 "pre-push",
48 "pre-receive",
49 "update",
50 "proc-receive",
51 "post-receive",
52 "post-update",
53 "reference-transaction",
54 "push-to-checkout",
55 "pre-auto-gc",
56 "post-rewrite",
57 "sendemail-validate",
58 "fsmonitor-watchman",
59 "p4-changelist",
60 "p4-prepare-changelist",
61 "p4-post-changelist",
62 "p4-pre-submit",
63 "post-index-change",
64];
65
66fn books_commit(name: &str) -> bool {
67 matches!(name, "post-commit" | "post-rewrite")
68}
69
70pub fn run(action: &str) -> Result<()> {
76 match action {
77 "install" => {
78 let dir = install()?;
79 println!("installed {}", dir.display());
80 Ok(())
81 }
82 "uninstall" => {
83 uninstall()?;
84 println!("uninstalled");
85 Ok(())
86 }
87 "status" => {
88 print!("{}", status());
89 Ok(())
90 }
91 other => bail!("git-hooks: {other} is not install, uninstall, or status"),
92 }
93}
94
95pub fn install() -> Result<PathBuf> {
101 install_at(&hooks_dir(), &[])
102}
103
104pub(crate) fn install_at(dir: &Path, git_env: &[(&str, &str)]) -> Result<PathBuf> {
114 fs::create_dir_all(dir).with_context(|| format!("git-hooks: {}", dir.display()))?;
115 let bin = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("ljos"));
116 for name in HOOK_NAMES {
117 let path = dir.join(name);
118 fs::write(&path, hook_body(&bin, name))
119 .with_context(|| format!("git-hooks: {}", path.display()))?;
120 #[cfg(unix)]
121 {
122 use std::os::unix::fs::PermissionsExt;
123 fs::set_permissions(&path, fs::Permissions::from_mode(0o755))?;
124 }
125 }
126 let current = global_hooks_path(git_env);
127 let ours = dir.display().to_string();
128 if current.as_deref() != Some(ours.as_str()) {
129 match current {
130 Some(prev) => {
131 fs::write(dir.join("previous-hooks-path"), prev)?;
132 }
133 None => {
134 let _ = fs::remove_file(dir.join("previous-hooks-path"));
135 }
136 }
137 }
138 set_global_hooks_path(dir, git_env)?;
139 Ok(dir.to_path_buf())
140}
141
142pub fn uninstall() -> Result<()> {
148 let dir = hooks_dir();
149 let current = global_hooks_path(&[]);
150 let ours = dir.display().to_string();
151 if current.as_deref() == Some(ours.as_str()) {
152 let previous = fs::read_to_string(dir.join("previous-hooks-path"))
153 .ok()
154 .map(|text| text.trim().to_string())
155 .filter(|text| !text.is_empty());
156 if let Some(prev) = previous {
157 set_global_hooks_path(Path::new(&prev), &[])?;
158 } else {
159 unset_global_hooks_path(&[])?;
160 }
161 }
162 for name in HOOK_NAMES {
163 let _ = fs::remove_file(dir.join(name));
164 }
165 let _ = fs::remove_file(dir.join("previous-hooks-path"));
166 Ok(())
167}
168
169#[must_use]
171pub fn status() -> String {
172 let dir = hooks_dir();
173 let installed = HOOK_NAMES.iter().all(|name| dir.join(name).is_file());
174 let configured = global_hooks_path(&[]).unwrap_or_else(|| "(unset)".into());
175 let hooks = if installed {
176 dir.display().to_string()
177 } else {
178 "not installed".into()
179 };
180 format!("hooks: {hooks}\ncore.hooksPath: {configured}\n")
181}
182
183pub const HOOKS_UNSET: &str = "core.hooksPath is unset";
187
188#[must_use]
195pub fn doctor_row() -> crate::Habitat {
196 let dir = hooks_dir();
197 let installed = HOOK_NAMES.iter().all(|name| dir.join(name).is_file());
198 let (ok, state) = hooks_state(&dir, global_hooks_path(&[]).as_deref(), installed);
199 crate::Habitat {
200 name: "git hooks",
201 state,
202 ok,
203 }
204}
205
206#[must_use]
209pub fn hooks_state(dir: &Path, configured: Option<&str>, installed: bool) -> (bool, String) {
210 let ours = dir.display().to_string();
211 match configured.map(str::trim).filter(|path| !path.is_empty()) {
212 None => (
213 false,
214 format!(
215 "{HOOKS_UNSET}, so commits are not booked; `ljos git-hooks install` points it at {ours}"
216 ),
217 ),
218 Some(path) if !same_dir(Path::new(path), dir) => (
219 false,
220 format!(
221 "core.hooksPath is {path}, not {ours}, the hooks `ljos onboard` installs; `ljos git-hooks install` points it back"
222 ),
223 ),
224 Some(_) if !installed => (
225 false,
226 format!(
227 "core.hooksPath is {ours}, but some hook stubs are missing there; `ljos git-hooks install` writes them"
228 ),
229 ),
230 Some(_) => (true, format!("core.hooksPath is {ours}; commits are booked")),
231 }
232}
233
234fn hooks_dir() -> PathBuf {
235 if let Some(dir) = std::env::var_os("LJOS_GIT_HOOKS").filter(|value| !value.is_empty()) {
236 return PathBuf::from(dir);
237 }
238 data_home().join("ljos").join("git-hooks")
239}
240
241fn data_home() -> PathBuf {
242 if let Some(dir) = std::env::var_os("XDG_DATA_HOME").filter(|value| !value.is_empty()) {
243 return PathBuf::from(dir);
244 }
245 std::env::var_os("HOME")
246 .filter(|value| !value.is_empty())
247 .map(|home| PathBuf::from(home).join(".local").join("share"))
248 .unwrap_or_else(|| PathBuf::from("."))
249}
250
251fn hook_body(bin: &Path, name: &str) -> String {
252 let bin = shell_quote(&bin.display().to_string());
253 let book = if books_commit(name) {
254 format!(
255 "\t\tif [ -z \"${{LJOS_GIT_HOOK:-}}\" ]; then\n\
256 \t\t\tline=$({bin} hook --git-commit --git-hook {name} 2>/dev/null || true)\n\
257 \t\t\tif [ -n \"$line\" ]; then\n\
258 \t\t\t\tprintf '%s\\n' \"$line\" >&2\n\
259 \t\t\tfi\n\
260 \t\tfi\n"
261 )
262 } else {
263 String::new()
264 };
265 format!(
266 "#!/bin/sh\n\
267 # ljos chains to this repository's .git/hooks/{name}, or to the\n\
268 # core.hooksPath that was set before `ljos git-hooks install`.\n\
269 # post-commit and post-rewrite also book the commit. Booking cannot\n\
270 # fail the hook. The repository hook's exit status is kept.\n\
271 #\n\
272 # pre-commit install follows core.hooksPath and would replace this\n\
273 # file. Install the framework into the repository, then re-run\n\
274 # `ljos git-hooks install` if a stub was overwritten:\n\
275 # dir=$(git rev-parse --git-dir)\n\
276 # git config --local core.hooksPath \"$dir/hooks\"\n\
277 # pre-commit install\n\
278 # git config --local --unset core.hooksPath\n\
279 case \",1,true,TRUE,yes,YES,on,ON,\" in\n\
280 \t*,\"${{LJOS_OFF}}\",*) ;;\n\
281 \t*)\n\
282 {book}\
283 \t\t;;\n\
284 esac\n\
285 git_dir=$(git rev-parse --git-dir 2>/dev/null || true)\n\
286 if [ -n \"$git_dir\" ]; then\n\
287 \tcase \"$git_dir\" in\n\
288 \t\t/*) ;;\n\
289 \t\t*) git_dir=$(pwd)/$git_dir ;;\n\
290 \tesac\n\
291 \trepo_hook=\"$git_dir/hooks/{name}\"\n\
292 \tif [ -x \"$repo_hook\" ] && [ \"$repo_hook\" != \"$0\" ]; then\n\
293 \t\texec \"$repo_hook\" \"$@\"\n\
294 \tfi\n\
295 fi\n\
296 here=$(dirname \"$0\")\n\
297 here=$(CDPATH= cd -- \"$here\" && pwd) || exit 0\n\
298 prev=$(sed -n '1s/[[:space:]]*$//p' \"$here/previous-hooks-path\" 2>/dev/null || true)\n\
299 if [ -n \"$prev\" ] && [ \"$prev\" != \"$here\" ]; then\n\
300 \tprev_hook=\"$prev/{name}\"\n\
301 \tif [ -x \"$prev_hook\" ] && [ \"$prev_hook\" != \"$0\" ]; then\n\
302 \t\texec \"$prev_hook\" \"$@\"\n\
303 \tfi\n\
304 fi\n\
305 exit 0\n"
306 )
307}
308
309fn shell_quote(text: &str) -> String {
310 format!("'{}'", text.replace('\'', "'\\''"))
311}
312
313pub fn run_git_commit(hook: &str) -> Result<()> {
319 if let Err(err) = book_commit(hook) {
320 crate::bookkeep::log_line(&format!("git-hooks: {err:#}"));
321 }
322 Ok(())
323}
324
325fn book_commit(hook: &str) -> Result<()> {
326 if std::env::var_os("LJOS_GIT_HOOK").is_some() || booking_off() {
327 return Ok(());
328 }
329 if foreign_session() {
330 return Ok(());
331 }
332 if hook != "post-rewrite" && mid_rewrite() {
333 return Ok(());
334 }
335 let Some(repo) = git_line(&["rev-parse", "--show-toplevel"]) else {
336 return Ok(());
337 };
338 let repo_path = PathBuf::from(&repo);
339 if crate::bookkeep::path_is_temp(Some(&repo_path)) || skip_repo(&repo_path) {
340 return Ok(());
341 }
342 let Some(head) = git_line(&["rev-parse", "HEAD"]) else {
343 return Ok(());
344 };
345 if already_booked(&repo, &head) {
346 return Ok(());
347 }
348 let Some(seat) = crate::git_hook_seat() else {
351 if crate::unseated_subagent() {
352 println!(
354 "ljos: commit {} not booked: this subagent has no seat; export LJOS_SEAT=<your seat> (the parent's) to book it",
355 &head[..head.len().min(12)]
356 );
357 }
358 return Ok(());
359 };
360 let _actor = crate::HookActor::enter(&seat);
361 let subject = git_line(&["log", "-1", "--format=%s"]).unwrap_or_default();
362 let _children = HookChildren::enter();
367 let session = crate::bookkeep::seat_session();
371 crate::bookkeep::sweep_idle_sittings(&session, &repo);
372 let message = git_line(&["log", "-1", "--format=%B"]).unwrap_or_default();
376 if let Some(issue) = crate::bookkeep::named_open_issue(&message) {
377 crate::bookkeep::book_named_commit(&repo, &head, &issue);
378 remember_booked(&repo, &head);
379 if let Some(line) = crate::bookkeep::commit_recall_line(&session, &repo, &subject) {
380 println!("{line}");
381 }
382 return Ok(());
383 }
384 if crate::bookkeep::cite_into_open_sitting(&repo) {
387 remember_booked(&repo, &head);
388 return Ok(());
389 }
390 let command = if subject.is_empty() {
391 "git commit".to_string()
392 } else {
393 format!("git commit -m {}", shell_quote(&subject))
394 };
395 let raw = serde_json::json!({
396 "hook_event_name": "PostToolUse",
397 "session_id": session,
398 "cwd": repo,
399 "tool_name": "Bash",
400 "tool_input": {"command": command},
401 "tool_response": {"exit_code": 0},
402 "prompt": subject,
403 "ljos_git_hook": true,
404 })
405 .to_string();
406 let call = crate::HookCall {
407 event: "PostToolUse".into(),
408 cue: command,
409 session: Some(session.clone()),
410 shape: crate::HookShape::Asks,
411 };
412 let _ = crate::bookkeep::observe(&raw, &call);
413 remember_booked(&repo, &head);
414 if let Some(line) = crate::bookkeep::commit_recall_line(&session, &repo, &subject) {
415 println!("{line}");
416 }
417 Ok(())
418}
419
420fn booking_off() -> bool {
421 matches!(
422 std::env::var("LJOS_OFF").ok().as_deref(),
423 Some("1" | "true" | "TRUE" | "yes" | "YES" | "on" | "ON")
424 ) || crate::bookkeep::guard_suspended()
425}
426
427fn foreign_session() -> bool {
428 [
429 "CLAUDECODE",
430 "CODEX_THREAD_ID",
431 "CODEX_SESSION_ID",
432 "OPENCODE_SESSION_ID",
433 ]
434 .iter()
435 .any(|key| std::env::var_os(key).is_some_and(|value| !value.is_empty()))
436}
437
438fn mid_rewrite() -> bool {
439 let Some(dir) = git_dir() else {
440 return false;
441 };
442 dir.join("rebase-merge").is_dir()
443 || dir.join("rebase-apply").is_dir()
444 || dir.join("MERGE_HEAD").is_file()
445}
446
447fn skip_repo(repo: &Path) -> bool {
448 if is_store(repo) {
449 return true;
450 }
451 skip_roots().iter().any(|root| covered(repo, root))
452}
453
454fn skip_roots() -> Vec<PathBuf> {
458 let mut roots = vec![
459 PathBuf::from("/tmp"),
460 PathBuf::from("/var/tmp"),
461 PathBuf::from("/dev/shm"),
462 ];
463 for key in ["VISSUE_ROOT", "ISSUE_ROOT", "CLAIMDAG_DIR"] {
464 if let Some(path) = std::env::var_os(key).filter(|value| !value.is_empty()) {
465 roots.push(PathBuf::from(path));
466 }
467 }
468 roots.extend(store_dirs());
469 for path in config_files() {
470 roots.extend(configured_skips(&path));
471 }
472 roots.retain(|path| useful_root(path));
473 roots
474}
475
476fn useful_root(path: &Path) -> bool {
477 let text = path.to_string_lossy();
478 let text = text.trim();
479 !text.is_empty() && text != "/" && text != "."
480}
481
482fn config_files() -> Vec<PathBuf> {
483 let mut out = Vec::new();
484 let base = std::env::var_os("XDG_CONFIG_HOME")
485 .filter(|value| !value.is_empty())
486 .map(PathBuf::from)
487 .or_else(|| {
488 std::env::var_os("HOME")
489 .filter(|value| !value.is_empty())
490 .map(|home| PathBuf::from(home).join(".config"))
491 });
492 if let Some(base) = base {
493 out.push(base.join("ljos").join("ljos.toml"));
494 if let Some(seat) = std::env::var("LJOS_SEAT")
495 .ok()
496 .map(|name| name.trim().to_string())
497 .filter(|name| !name.is_empty() && !name.contains('/') && !name.contains(".."))
498 {
499 out.push(base.join("ljos").join("seats").join(seat).join("ljos.toml"));
500 }
501 }
502 if let Some(path) = std::env::var_os("LJOS_CONFIG").filter(|value| !value.is_empty()) {
503 out.push(PathBuf::from(path));
504 }
505 if let Ok(cwd) = std::env::current_dir() {
506 out.push(cwd.join("ljos.toml"));
507 }
508 out
509}
510
511fn configured_skips(path: &Path) -> Vec<PathBuf> {
512 let Ok(text) = fs::read_to_string(path) else {
513 return Vec::new();
514 };
515 let Ok(value) = toml::from_str::<toml::Value>(&text) else {
516 return Vec::new();
517 };
518 let Some(list) = value
519 .get("git_hooks")
520 .and_then(|table| table.get("skip"))
521 .and_then(toml::Value::as_array)
522 else {
523 return Vec::new();
524 };
525 list.iter()
526 .filter_map(toml::Value::as_str)
527 .map(str::trim)
528 .filter(|text| !text.is_empty())
529 .map(PathBuf::from)
530 .collect()
531}
532
533fn covered(repo: &Path, root: &Path) -> bool {
534 let repo = canonical_or(repo);
535 let root = canonical_or(root);
536 repo == root || repo.starts_with(&root)
537}
538
539fn canonical_or(path: &Path) -> PathBuf {
540 fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
541}
542
543fn is_store(repo: &Path) -> bool {
544 let text = repo.to_string_lossy();
545 if text.contains("/deedar/store") || text.contains("/claimdag/") || text.ends_with("/claimdag")
546 {
547 return true;
548 }
549 store_dirs()
550 .iter()
551 .any(|root| same_dir(repo, root) || repo_starts(repo, root))
552}
553
554fn store_dirs() -> Vec<PathBuf> {
555 let mut out = Vec::new();
556 if let Ok(url) = std::env::var("DEEDAR_URL") {
557 if let Some(path) = url.strip_prefix("file://") {
558 out.push(PathBuf::from(path));
559 }
560 }
561 if let Some(data) = std::env::var_os("XDG_DATA_HOME").filter(|value| !value.is_empty()) {
562 out.push(PathBuf::from(data).join("deedar").join("store"));
563 } else if let Some(home) = std::env::var_os("HOME").filter(|value| !value.is_empty()) {
564 out.push(
565 PathBuf::from(home)
566 .join(".local")
567 .join("share")
568 .join("deedar")
569 .join("store"),
570 );
571 }
572 out
573}
574
575fn same_dir(left: &Path, right: &Path) -> bool {
576 let left = fs::canonicalize(left).unwrap_or_else(|_| left.to_path_buf());
577 let right = fs::canonicalize(right).unwrap_or_else(|_| right.to_path_buf());
578 left == right
579}
580
581fn repo_starts(repo: &Path, root: &Path) -> bool {
582 if !root.exists() {
583 return false;
584 }
585 let repo = fs::canonicalize(repo).unwrap_or_else(|_| repo.to_path_buf());
586 let root = fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
587 repo.starts_with(root)
588}
589
590fn already_booked(repo: &str, head: &str) -> bool {
591 let key = format!("{repo}\t{head}");
592 fs::read_to_string(booked_path())
593 .map(|text| text.lines().any(|line| line == key))
594 .unwrap_or(false)
595}
596
597fn remember_booked(repo: &str, head: &str) {
598 let path = booked_path();
599 if let Some(dir) = path.parent() {
600 let _ = fs::create_dir_all(dir);
601 }
602 let mut lines: Vec<String> = fs::read_to_string(&path)
603 .unwrap_or_default()
604 .lines()
605 .map(str::to_string)
606 .filter(|line| !line.starts_with(&format!("{repo}\t")))
607 .collect();
608 lines.push(format!("{repo}\t{head}"));
609 if lines.len() > 200 {
610 lines.drain(0..lines.len() - 200);
611 }
612 let _ = fs::write(path, lines.join("\n") + "\n");
613}
614
615fn booked_path() -> PathBuf {
616 crate::runtime_dir().join("git-hooks-booked")
617}
618
619fn git_dir() -> Option<PathBuf> {
620 let raw = git_line(&["rev-parse", "--git-dir"])?;
621 let path = PathBuf::from(&raw);
622 if path.is_absolute() {
623 Some(path)
624 } else {
625 std::env::current_dir().ok().map(|cwd| cwd.join(path))
626 }
627}
628
629const HOOK_GIT_ENV: &[&str] = &[
633 "GIT_DIR",
634 "GIT_WORK_TREE",
635 "GIT_INDEX_FILE",
636 "GIT_PREFIX",
637 "GIT_OBJECT_DIRECTORY",
638 "GIT_COMMON_DIR",
639 "GIT_NAMESPACE",
640];
641
642thread_local! {
643 static HOOK_CHILDREN: Cell<bool> = const { Cell::new(false) };
644}
645
646#[must_use]
651struct HookChildren {
652 previous: bool,
653}
654
655impl HookChildren {
656 fn enter() -> Self {
657 let previous = HOOK_CHILDREN.with(Cell::get);
658 HOOK_CHILDREN.with(|flag| flag.set(true));
659 Self { previous }
660 }
661}
662
663impl Drop for HookChildren {
664 fn drop(&mut self) {
665 HOOK_CHILDREN.with(|flag| flag.set(self.previous));
666 }
667}
668
669pub(crate) fn hook_child_env(cmd: &mut Command) {
673 if !HOOK_CHILDREN.with(Cell::get) {
674 return;
675 }
676 cmd.env("LJOS_GIT_HOOK", "1");
677 for key in HOOK_GIT_ENV {
678 cmd.env_remove(key);
679 }
680}
681
682fn git_command(args: &[&str], git_env: &[(&str, &str)]) -> Command {
683 let mut cmd = Command::new("git");
684 cmd.args(args);
685 for (key, value) in git_env {
686 cmd.env(key, value);
687 }
688 hook_child_env(&mut cmd);
689 cmd
690}
691
692fn git_line(args: &[&str]) -> Option<String> {
693 let out = git_command(args, &[]).output().ok()?;
694 if !out.status.success() {
695 return None;
696 }
697 let text = String::from_utf8(out.stdout).ok()?;
698 let text = text.trim();
699 if text.is_empty() {
700 None
701 } else {
702 Some(text.to_string())
703 }
704}
705
706fn global_hooks_path(git_env: &[(&str, &str)]) -> Option<String> {
707 let out = git_command(&["config", "--global", "--get", "core.hooksPath"], git_env)
708 .output()
709 .ok()?;
710 if !out.status.success() {
711 return None;
712 }
713 let text = String::from_utf8(out.stdout).ok()?;
714 let text = text.trim();
715 if text.is_empty() {
716 None
717 } else {
718 Some(text.to_string())
719 }
720}
721
722fn set_global_hooks_path(path: &Path, git_env: &[(&str, &str)]) -> Result<()> {
723 let value = path.display().to_string();
724 let status = git_command(&["config", "--global", "core.hooksPath", &value], git_env)
725 .status()
726 .context("git-hooks: git config")?;
727 if !status.success() {
728 bail!("git-hooks: could not set core.hooksPath");
729 }
730 Ok(())
731}
732
733fn unset_global_hooks_path(git_env: &[(&str, &str)]) -> Result<()> {
734 let status = git_command(
735 &["config", "--global", "--unset", "core.hooksPath"],
736 git_env,
737 )
738 .status()
739 .context("git-hooks: git config")?;
740 if !status.success() {
741 bail!("git-hooks: could not unset core.hooksPath");
742 }
743 Ok(())
744}
745
746#[cfg(test)]
747mod tests {
748 use std::ffi::OsStr;
749
750 use super::*;
751
752 fn mapped<'a>(cmd: &'a Command, key: &str) -> Option<Option<&'a OsStr>> {
753 cmd.get_envs()
754 .find(|(name, _)| *name == OsStr::new(key))
755 .map(|(_, value)| value)
756 }
757
758 #[test]
759 fn the_doctor_names_a_hooks_path_that_is_not_the_onboard_one() {
760 let dir = tempfile::tempdir().unwrap();
761 let ours = dir.path().join("ljos").join("git-hooks");
762 std::fs::create_dir_all(&ours).unwrap();
763 let other = dir.path().join("leidarljos").join("git-hooks");
764 std::fs::create_dir_all(&other).unwrap();
765 let ours_s = ours.display().to_string();
766 let other_s = other.display().to_string();
767
768 let (ok, state) = super::hooks_state(&ours, None, true);
769 assert!(!ok);
770 assert!(state.starts_with(super::HOOKS_UNSET), "{state}");
771
772 let (ok, state) = super::hooks_state(&ours, Some(&other_s), true);
773 assert!(!ok);
774 assert!(
775 state.contains(&other_s) && state.contains(&ours_s),
776 "{state}"
777 );
778
779 let (ok, state) = super::hooks_state(&ours, Some(&ours_s), false);
780 assert!(!ok, "{state}");
781
782 let (ok, state) = super::hooks_state(&ours, Some(&ours_s), true);
783 assert!(ok, "{state}");
784 }
785
786 #[test]
787 fn hook_children_carry_the_guard_without_touching_the_process() {
788 let before_hook = std::env::var_os("LJOS_GIT_HOOK");
789 let before_dir = std::env::var_os("GIT_DIR");
790 let mut plain = Command::new("true");
791 plain.env("GIT_DIR", "keep");
792 hook_child_env(&mut plain);
793 assert_eq!(mapped(&plain, "GIT_DIR"), Some(Some(OsStr::new("keep"))));
794 assert_eq!(mapped(&plain, "LJOS_GIT_HOOK"), None);
795
796 let _guard = HookChildren::enter();
797 let mut cmd = Command::new("true");
798 cmd.env("GIT_DIR", "from-the-hook");
799 cmd.env("PATH", "/usr/bin");
800 hook_child_env(&mut cmd);
801 assert_eq!(mapped(&cmd, "LJOS_GIT_HOOK"), Some(Some(OsStr::new("1"))));
802 for key in HOOK_GIT_ENV {
803 assert_eq!(mapped(&cmd, key), Some(None), "{key}");
804 }
805 assert_eq!(mapped(&cmd, "PATH"), Some(Some(OsStr::new("/usr/bin"))));
806 assert_eq!(std::env::var_os("LJOS_GIT_HOOK"), before_hook);
807 assert_eq!(std::env::var_os("GIT_DIR"), before_dir);
808 }
809}