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//! When `core.hooksPath` is unset, `install` writes a stub for every
4//! standard git hook and points the global config at ljos's directory. The
5//! stub calls `ljos git-hook <name>`, then runs the repository's
6//! `.git/hooks/<name>` or the previous path an older install recorded, with
7//! the same arguments. `post-rewrite` copies stdin first, so `ljos git-hook`
8//! and the chained hook both see the old and new shas.
9//!
10//! When `core.hooksPath` already names a directory, install leaves that
11//! setting alone and appends a delimited block to each hook there. The
12//! block calls `ljos git-hook <name>`. A missing hook file is created
13//! executable. Text that was already in the file is kept. Uninstall removes
14//! only that block, and deletes a file that held nothing else.
15//!
16//! `post-commit` and `post-rewrite` book the commit. Booking never fails
17//! the hook. `pre-commit` and `pre-push` run the sitting gate under an
18//! agent runner: a commit needs a live sitting, and a push onto a
19//! protected branch also needs the sitting's ticket to clear it. That
20//! refusal fails the hook. `pre-push` copies stdin like `post-rewrite`.
21//!
22//! `pre-commit install` follows `core.hooksPath` and would replace stubs
23//! ljos owns. Install the framework into the repository, then let the stub
24//! chain to it:
25//!
26//! ```text
27//! dir=$(git rev-parse --git-dir)
28//! git config --local core.hooksPath "$dir/hooks"
29//! pre-commit install
30//! git config --local --unset core.hooksPath
31//! ```
32//!
33//! A commit is booked only when a seat resolves from `LJOS_SEAT`, then
34//! `seats.toml` through `CURSOR_CONVERSATION_ID`, then `grokbot-<id8>`,
35//! then an agy or opencode conversation id. The process tree is not a seat.
36//! The holder is `SEAT@TAG`, the same name `ljos claim` uses. Paths in
37//! `[git_hooks] skip` of `ljos.toml`, plus `/tmp`, `/var/tmp`, `/dev/shm`,
38//! the tracker, `CLAIMDAG_DIR` and the deedar store, are not booked.
39//! An amend or a rebase moves the citation onto the new sha.
40
41use std::cell::Cell;
42use std::fs;
43use std::path::{Path, PathBuf};
44use std::process::Command;
45
46use anyhow::{anyhow, bail, Context, Result};
47
48/// Every hook git looks up by name. A global `core.hooksPath` hides the
49/// repository's copy of each of these, so the stub has to exist for each.
50const 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
89/// `install`, `uninstall`, or `status`.
90///
91/// # Errors
92///
93/// `action` is not one of those, or git cannot record `core.hooksPath`.
94pub 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
114/// Install the hooks `ljos doctor` checks.
115///
116/// An unset `core.hooksPath` is pointed at ljos's hook directory. A path
117/// that already names a directory is left alone, and each hook there gains
118/// a block that calls `ljos git-hook`.
119///
120/// # Errors
121///
122/// The directory cannot be written, or `git config` fails.
123pub fn install() -> Result<PathBuf> {
124    install_at(&hooks_dir(), &[])
125}
126
127/// [`install`] into `dir`, with `git_env` on the child `git config`.
128///
129/// An empty `git_env` leaves that child with this process's environment.
130/// `GIT_CONFIG_GLOBAL` and `HOME` belong here when the caller is keeping
131/// the login config untouched.
132///
133/// # Errors
134///
135/// The directory cannot be written, or `git config` fails.
136pub(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
152/// Remove ljos from the hooks git runs.
153///
154/// A directory ljos does not own loses only the ljos block. A file that
155/// held nothing else is deleted. When ljos owns `core.hooksPath`, the
156/// previous path is restored and the stubs are removed.
157///
158/// # Errors
159///
160/// `git config` cannot restore or unset the path, or a hook file cannot
161/// be rewritten.
162pub 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/// Where the hooks are, and what `core.hooksPath` says.
185#[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
207/// The start of the doctor's `git hooks` state when `core.hooksPath` is
208/// unset. That row is `info`: a seat that has not onboarded books no
209/// commits, and nothing else is wrong.
210pub const HOOKS_UNSET: &str = "core.hooksPath is unset";
211
212/// The one command that installs the hooks doctor checks.
213pub const HOOKS_FIX: &str = "ljos git-hooks install";
214
215/// The doctor's `git hooks` row. It passes when `ljos git-hook` runs from
216/// the directory `core.hooksPath` names: ljos's own stubs, or a block in
217/// each hook of a directory the user already set. It does not ask to take
218/// that path over. An env file that sets `LJOS_GIT_HOOKS` to another
219/// directory fails the row.
220#[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/// [`doctor_row`] for a chosen hooks directory, the global `core.hooksPath`,
232/// and the config directory that holds env files.
233#[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        // A mismatch is a failed row. The unset-path prefix would keep it `info`.
239        state = if state.starts_with(HOOKS_UNSET) {
240            drift
241        } else {
242            format!("{state}; {drift}")
243        };
244    }
245    (ok, state)
246}
247
248/// An env file under `config_dir` that sets `LJOS_GIT_HOOKS` to a directory
249/// other than `onboard_dir`. The expression [`HOOKS_ENV_LINE`] writes is the
250/// same directory and is not a mismatch. `None` when every assignment agrees.
251#[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
345/// The `LJOS_GIT_HOOKS` line `ljos onboard` writes into a shell runner's
346/// env file. It names the same directory [`install`] writes.
347pub 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/// [`doctor_row`]'s verdict for the onboard hooks at `ours` and the global
352/// `core.hooksPath` git reports.
353#[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
408/// The block appended to a hook. `post-commit` and `post-rewrite` book
409/// through `ljos git-hook`, and the `pre-commit` and `pre-push` blocks
410/// fail the hook when it refuses. The absolute path is the fallback when
411/// `ljos` is not on `PATH`; a `pre-commit` or `pre-push` with neither
412/// runs nothing. `post-rewrite` and `pre-push` copy stdin when the caller
413/// has not, so this block and a chained hook can both read the shas and
414/// refs.
415fn 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
697/// Book the commit that just landed. Always succeeds: the commit stands.
698///
699/// # Errors
700///
701/// None. A habitat that did not answer is a line in the book log.
702pub 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
709/// Run the ljos part of git hook `name`.
710///
711/// `post-commit` and `post-rewrite` book the commit. `pre-commit` and
712/// `pre-push` run the sitting gate ([`gate`]). Any other name succeeds
713/// and does nothing. Arguments git passed the hook are ignored.
714///
715/// # Errors
716///
717/// The sitting gate's refusal. Booking that did not land is a line in
718/// the book log.
719pub 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
730/// The guard's sitting gate for a commit or push it did not see, such as
731/// one a script runs: under a runner, `pre-commit` needs a live sitting,
732/// and `pre-push` also needs that ticket to clear each ref it sends onto
733/// a branch [`crate::gates::consensus_push`] names. A store or a tracker
734/// checkout, which ljos writes itself, is not gated, and nor is the
735/// person.
736fn 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    // In a hook the process tree names the hook's own shell. The seat a
750    // booking would take is the one the sitting was taken under. With a
751    // conversation id in the environment only that conversation's holder
752    // counts, or for a subagent its parent's records and process when
753    // that process carries a conversation id. With no id it falls back
754    // to the process match.
755    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
788/// What a push whose pre-push stdin is `refs` sends onto a branch in
789/// `branches`, as `sends BRANCH`, or `None` when no ref lands on one.
790/// Git runs the hook with no lines when nothing is to be sent. A line
791/// not in git's four-field form cannot be read, so it is taken to land
792/// on one.
793fn 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
809/// Whether `repo` is a tracker checkout: it holds `vissue.toml`, or a
810/// `Software/<project>/issues.org` as `vissue` reads a tracker root. A
811/// bare `Software/` directory is any repository's.
812fn 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    // A commit in the tracker's checkout is a tracker write: never booked,
838    // but its seat is checked as any other write's is.
839    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    // The process tree is not a seat. Without LJOS_SEAT, seats.toml, the
847    // grokbot-<id8> fallback, or an agy or opencode conversation, the
848    // commit stays unbooked; a subagent is told.
849    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            // Stdout: the installed hook shows that line and drops stderr.
857            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    // Every path below writes the tracker: a repointed citation, a named
866    // issue, an open sitting, a new claim.
867    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    // An amend or a rebase lists old and new shas. Move the citation
883    // before the "already booked" return: post-commit may have cited the
884    // new HEAD already, and the old sha would otherwise stay on the issue.
885    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    // A hook exports GIT_DIR. Later `git -C` on the tracker must not follow
893    // it, and a nested commit must not re-enter this hook. Both ride on the
894    // child git and ljos processes; this process keeps the environment it
895    // was started with, so the rev-parse above still sees the checkout.
896    // Finish idle rows in other checkouts before this commit is cited into
897    // an open sitting. That path returns without observe, which is the
898    // other place the idle sweep runs.
899    // The tool calls before this commit may still have their book queued.
900    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    // A subject or body that names an open issue (`ljos-policyd-32fx: ...`,
904    // `Fixes ljos-xsor`) books the commit on that issue. A new issue is
905    // filed only when the message names none the tracker holds open.
906    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    // The commit already belongs to an open sitting in this checkout.
916    // Booking it on the seat's own session would open a second book.
917    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    // OpenCode's shell sets OPENCODE_SESSION_ID and never calls the plugin's
960    // tool.execute.after, so the git hook is the booker. Claude and Codex
961    // book from their own hooks.
962    ["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
1003/// Whether `repo` is the tracker's checkout, `VISSUE_ROOT` or `ISSUE_ROOT`.
1004fn 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
1013/// A tracker commit no seat that may write the tracker made. It has landed
1014/// and a post-commit hook cannot take it back, so the refusal is said on
1015/// stdout, which the installed hook shows, and kept in the book log.
1016fn 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
1032/// Paths a commit under which is not booked. The defaults always apply.
1033/// `[git_hooks] skip` in `ljos.toml` adds box-specific paths, and a
1034/// reinstall rewrites the hook scripts without touching that file.
1035fn 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
1121/// Whether `repo` is a deed or claim store ljos writes itself, read from
1122/// the store settings; a path that merely contains `claimdag` is not one.
1123fn 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
1204/// `GIT_*` values a hook exports. A later `git -C` follows `GIT_DIR` and
1205/// ignores `-C`, so a tracker commit would land in the checkout that just
1206/// finished.
1207const 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/// While this guard is alive, child processes started through
1222/// [`hook_child_env`] carry `LJOS_GIT_HOOK=1` and do not inherit the hook's
1223/// `GIT_DIR`. Dropping it restores the previous flag. The process
1224/// environment is not changed.
1225#[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
1244/// Put the re-entry guard on a child `git` or `ljos`, and drop the hook's
1245/// git directory from that child. No effect unless a git hook is booking
1246/// a commit on this thread.
1247pub(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}