Skip to main content

ljos_cli/
git_hooks.rs

1//! Global git hooks that book a commit when the runner has no prompt hook.
2//!
3//! `install` writes a chaining stub for every standard git hook. The stub
4//! execs the repository's `.git/hooks/<name>` or, when that file is absent,
5//! the `core.hooksPath` that was set before install, with the same arguments
6//! and stdin. `post-commit` and `post-rewrite` also call `ljos hook
7//! --git-commit`. Booking never fails the hook.
8//!
9//! `pre-commit install` follows `core.hooksPath` and would replace these
10//! stubs. Install the framework into the repository, then let the stub chain
11//! to it:
12//!
13//! ```text
14//! dir=$(git rev-parse --git-dir)
15//! git config --local core.hooksPath "$dir/hooks"
16//! pre-commit install
17//! git config --local --unset core.hooksPath
18//! ```
19//!
20//! A commit is booked only when a seat resolves from `LJOS_SEAT`, then
21//! `seats.toml` through `CURSOR_CONVERSATION_ID`, then `grokbot-<id8>`.
22//! The process tree is not a seat. Paths in `[git_hooks] skip` of `ljos.toml`,
23//! plus `/tmp`, `/var/tmp`, `/dev/shm`, the tracker, `CLAIMDAG_DIR` and the
24//! deedar store, are not booked. An amend and a rebase book once.
25
26use std::cell::Cell;
27use std::fs;
28use std::path::{Path, PathBuf};
29use std::process::Command;
30
31use anyhow::{bail, Context, Result};
32
33/// Every hook git looks up by name. A global `core.hooksPath` hides the
34/// repository's copy of each of these, so the stub has to exist for each.
35const 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
70/// `install`, `uninstall`, or `status`.
71///
72/// # Errors
73///
74/// `action` is not one of those, or git cannot record `core.hooksPath`.
75pub 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
95/// Write the hook scripts and point `core.hooksPath` at them.
96///
97/// # Errors
98///
99/// The directory cannot be written, or `git config` fails.
100pub fn install() -> Result<PathBuf> {
101    install_at(&hooks_dir(), &[])
102}
103
104/// [`install`] into `dir`, with `git_env` on the child `git config`.
105///
106/// An empty `git_env` leaves that child with this process's environment.
107/// `GIT_CONFIG_GLOBAL` and `HOME` belong here when the caller is keeping
108/// the login config untouched.
109///
110/// # Errors
111///
112/// The directory cannot be written, or `git config` fails.
113pub(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
142/// Remove the scripts and put `core.hooksPath` back.
143///
144/// # Errors
145///
146/// `git config` cannot restore or unset the path.
147pub 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/// Where the hooks are, and what `core.hooksPath` says.
170#[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
183/// The start of the doctor's `git hooks` state when `core.hooksPath` is
184/// unset. That row is `info`: a seat that has not onboarded books no
185/// commits, and nothing else is wrong.
186pub const HOOKS_UNSET: &str = "core.hooksPath is unset";
187
188/// The doctor's `git hooks` row: whether the global `core.hooksPath`
189/// names the hooks `ljos onboard` installs, and whether they are there.
190/// A path that names another directory books a commit only if that
191/// directory runs ljos, and an env file or a note that names it drifts
192/// from what onboard wrote, so the row says which path is set and which
193/// one onboard installs.
194#[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/// [`doctor_row`]'s verdict for the onboard hooks at `dir`, the global
207/// `core.hooksPath` git reports, and whether every stub is in `dir`.
208#[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
313/// Book the commit that just landed. Always succeeds: the commit stands.
314///
315/// # Errors
316///
317/// None. A habitat that did not answer is a line in the book log.
318pub 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    // The process tree is not a seat. Without LJOS_SEAT, seats.toml, or the
349    // grokbot-<id8> fallback, the commit stays unbooked; a subagent is told.
350    let Some(seat) = crate::git_hook_seat() else {
351        if crate::unseated_subagent() {
352            // Stdout: the installed hook shows that line and drops stderr.
353            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    // A hook exports GIT_DIR. Later `git -C` on the tracker must not follow
363    // it, and a nested commit must not re-enter this hook. Both ride on the
364    // child git and ljos processes; this process keeps the environment it
365    // was started with, so the rev-parse above still sees the checkout.
366    let _children = HookChildren::enter();
367    // Finish idle rows in other checkouts before this commit is cited into
368    // an open sitting. That path returns without observe, which is the
369    // other place the idle sweep runs.
370    let session = crate::bookkeep::seat_session();
371    crate::bookkeep::sweep_idle_sittings(&session, &repo);
372    // A subject or body that names an open issue (`ljos-policyd-32fx: ...`,
373    // `Fixes ljos-xsor`) books the commit on that issue. A new issue is
374    // filed only when the message names none the tracker holds open.
375    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    // The commit already belongs to an open sitting in this checkout.
385    // Booking it on the seat's own session would open a second book.
386    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
454/// Paths a commit under which is not booked. The defaults always apply.
455/// `[git_hooks] skip` in `ljos.toml` adds box-specific paths, and a
456/// reinstall rewrites the hook scripts without touching that file.
457fn 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
629/// `GIT_*` values a hook exports. A later `git -C` follows `GIT_DIR` and
630/// ignores `-C`, so a tracker commit would land in the checkout that just
631/// finished.
632const 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/// While this guard is alive, child processes started through
647/// [`hook_child_env`] carry `LJOS_GIT_HOOK=1` and do not inherit the hook's
648/// `GIT_DIR`. Dropping it restores the previous flag. The process
649/// environment is not changed.
650#[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
669/// Put the re-entry guard on a child `git` or `ljos`, and drop the hook's
670/// git directory from that child. No effect unless a git hook is booking
671/// a commit on this thread.
672pub(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}