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 an
34//! agy or opencode conversation id. A conversation id does not name a seat.
35//! 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 present = nonempty(configured_path.as_deref())
191        .is_some_and(|path| hooks_present(Path::new(path), &dir));
192    let stale = nonempty(configured_path.as_deref())
193        .filter(|_| present)
194        .and_then(|path| stale_note(Path::new(path), &dir));
195    let hooks = if present {
196        configured.clone()
197    } else if owned_installed(&dir) {
198        dir.display().to_string()
199    } else {
200        "not installed".into()
201    };
202    let reach = if present && stale.is_none() {
203        "ljos hooks run".to_string()
204    } else if let Some(note) = stale {
205        format!("ljos hooks run, but {note}; {HOOKS_FIX}")
206    } else {
207        format!("ljos hooks do not run; {HOOKS_FIX}")
208    };
209    format!("hooks: {hooks}\ncore.hooksPath: {configured}\n{reach}\n")
210}
211
212/// The start of the doctor's `git hooks` state when `core.hooksPath` is
213/// unset. That row is `info`: a seat that has not onboarded books no
214/// commits, and nothing else is wrong.
215pub const HOOKS_UNSET: &str = "core.hooksPath is unset";
216
217/// The one command that installs the hooks doctor checks.
218pub const HOOKS_FIX: &str = "ljos git-hooks install";
219
220/// The doctor's `git hooks` row. It passes when `ljos git-hook` runs from
221/// the directory `core.hooksPath` names: ljos's own stubs, or a block in
222/// each hook of a directory the user already set. The `pre-commit` and
223/// `pre-push` there must carry this build's block, so that a refusal
224/// fails the hook; the other hooks only book. It does not ask to take
225/// that path over. An env file that sets `LJOS_GIT_HOOKS` to another
226/// directory fails the row.
227#[must_use]
228pub fn doctor_row() -> crate::Habitat {
229    let dir = hooks_dir();
230    let (ok, state) = doctor_state(&dir, global_hooks_path(&[]).as_deref(), &config_ljos_dir());
231    crate::Habitat {
232        name: "git hooks",
233        state,
234        ok,
235    }
236}
237
238/// [`doctor_row`] for a chosen hooks directory, the global `core.hooksPath`,
239/// and the config directory that holds env files.
240#[must_use]
241pub fn doctor_state(dir: &Path, configured: Option<&str>, config_dir: &Path) -> (bool, String) {
242    let (mut ok, mut state) = hooks_state(dir, configured);
243    if let Some(drift) = env_hooks_drift(config_dir, dir) {
244        ok = false;
245        // A mismatch is a failed row. The unset-path prefix would keep it `info`.
246        state = if state.starts_with(HOOKS_UNSET) {
247            drift
248        } else {
249            format!("{state}; {drift}")
250        };
251    }
252    (ok, state)
253}
254
255/// An env file under `config_dir` that sets `LJOS_GIT_HOOKS` to a directory
256/// other than `onboard_dir`. The expression [`HOOKS_ENV_LINE`] writes is the
257/// same directory and is not a mismatch. `None` when every assignment agrees.
258#[must_use]
259pub fn env_hooks_drift(config_dir: &Path, onboard_dir: &Path) -> Option<String> {
260    let mut files = Vec::new();
261    for name in ["env.sh", "env"] {
262        let path = config_dir.join(name);
263        if path.is_file() {
264            files.push(path);
265        }
266    }
267    if let Ok(entries) = fs::read_dir(config_dir) {
268        let mut more: Vec<PathBuf> = entries
269            .flatten()
270            .map(|entry| entry.path())
271            .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("env"))
272            .collect();
273        more.sort();
274        files.extend(more);
275    }
276    let ours = onboard_dir.display().to_string();
277    for path in files {
278        let Ok(text) = fs::read_to_string(&path) else {
279            continue;
280        };
281        for line in text.lines() {
282            let Some(value) = hooks_assignment(line) else {
283                continue;
284            };
285            if assignment_agrees(&value, onboard_dir) {
286                continue;
287            }
288            return Some(format!(
289                "{} sets LJOS_GIT_HOOKS to {value}, not {ours}, the hooks `ljos onboard` installs",
290                path.display()
291            ));
292        }
293    }
294    None
295}
296
297fn hooks_assignment(line: &str) -> Option<String> {
298    let line = line.trim();
299    if line.is_empty() || line.starts_with('#') {
300        return None;
301    }
302    let rest = line.strip_prefix("export ").unwrap_or(line).trim();
303    let rest = rest.strip_prefix("LJOS_GIT_HOOKS=")?;
304    let value = unquote_assignment(rest.trim());
305    if value.is_empty() {
306        None
307    } else {
308        Some(value)
309    }
310}
311
312fn unquote_assignment(value: &str) -> String {
313    let value = value.trim();
314    if let Some(inner) = value
315        .strip_prefix('"')
316        .and_then(|text| text.strip_suffix('"'))
317    {
318        return inner.to_string();
319    }
320    if let Some(inner) = value
321        .strip_prefix('\'')
322        .and_then(|text| text.strip_suffix('\''))
323    {
324        return inner.to_string();
325    }
326    value.split_whitespace().next().unwrap_or("").to_string()
327}
328
329fn assignment_agrees(value: &str, onboard: &Path) -> bool {
330    if value.contains("${XDG_DATA_HOME:-$HOME/.local/share}/ljos/git-hooks") {
331        return true;
332    }
333    if value.contains('$') || value.contains('`') {
334        return false;
335    }
336    same_dir(Path::new(value), onboard)
337}
338
339fn config_ljos_dir() -> PathBuf {
340    let base = std::env::var_os("XDG_CONFIG_HOME")
341        .filter(|value| !value.is_empty())
342        .map(PathBuf::from)
343        .or_else(|| {
344            std::env::var_os("HOME")
345                .filter(|value| !value.is_empty())
346                .map(|home| PathBuf::from(home).join(".config"))
347        })
348        .unwrap_or_else(|| PathBuf::from(".config"));
349    base.join("ljos")
350}
351
352/// The `LJOS_GIT_HOOKS` line `ljos onboard` writes into a shell runner's
353/// env file. It names the same directory [`install`] writes.
354pub const HOOKS_ENV_LINE: &str = "\
355# Git hooks directory. `ljos git-hooks install` writes the stubs here.\n\
356export LJOS_GIT_HOOKS=\"${LJOS_GIT_HOOKS:-${XDG_DATA_HOME:-$HOME/.local/share}/ljos/git-hooks}\"";
357
358/// [`doctor_row`]'s verdict for the onboard hooks at `ours` and the global
359/// `core.hooksPath` git reports.
360#[must_use]
361pub fn hooks_state(ours: &Path, configured: Option<&str>) -> (bool, String) {
362    let Some(path) = nonempty(configured) else {
363        return (
364            false,
365            format!("{HOOKS_UNSET}, so commits are not booked; {HOOKS_FIX}"),
366        );
367    };
368    let dir = Path::new(path);
369    if hooks_reachable(dir, ours) {
370        let state = if same_dir(dir, ours) {
371            format!("core.hooksPath is {path}; commits are booked")
372        } else {
373            format!("core.hooksPath is {path}; ljos git-hook runs from those hooks")
374        };
375        return (true, state);
376    }
377    let stale = if hooks_present(dir, ours) {
378        stale_note(dir, ours)
379    } else {
380        None
381    };
382    let state = if let Some(note) = stale {
383        format!("core.hooksPath is {path}, but {note}; {HOOKS_FIX}")
384    } else if same_dir(dir, ours) {
385        format!("core.hooksPath is {path}, but some hook stubs are missing there; {HOOKS_FIX}")
386    } else {
387        format!("core.hooksPath is {path}, and ljos is not chained there; {HOOKS_FIX}")
388    };
389    (false, state)
390}
391
392fn hooks_dir() -> PathBuf {
393    if let Some(dir) = std::env::var_os("LJOS_GIT_HOOKS").filter(|value| !value.is_empty()) {
394        return PathBuf::from(dir);
395    }
396    data_home().join("ljos").join("git-hooks")
397}
398
399pub(crate) fn onboard_hooks_dir() -> PathBuf {
400    hooks_dir()
401}
402
403pub(crate) fn onboard_hook_paths(dir: &Path) -> Vec<PathBuf> {
404    HOOK_NAMES.iter().map(|name| dir.join(name)).collect()
405}
406
407pub(crate) fn configured_hooks_path(git_env: &[(&str, &str)]) -> Option<PathBuf> {
408    let configured = global_hooks_path(git_env);
409    nonempty(configured.as_deref()).map(PathBuf::from)
410}
411
412pub(crate) fn onboarding_installed() -> bool {
413    let dir = hooks_dir();
414    let configured = global_hooks_path(&[]);
415    match nonempty(configured.as_deref()) {
416        Some(path) if same_dir(Path::new(path), &dir) => owned_installed(&dir),
417        Some(path) => hooks_present(Path::new(path), &dir),
418        None => owned_installed(&dir),
419    }
420}
421
422fn data_home() -> PathBuf {
423    if let Some(dir) = std::env::var_os("XDG_DATA_HOME").filter(|value| !value.is_empty()) {
424        return PathBuf::from(dir);
425    }
426    std::env::var_os("HOME")
427        .filter(|value| !value.is_empty())
428        .map(|home| PathBuf::from(home).join(".local").join("share"))
429        .unwrap_or_else(|| PathBuf::from("."))
430}
431
432const BLOCK_BEGIN: &str = "# ljos:begin git-hook";
433const BLOCK_END: &str = "# ljos:end git-hook";
434
435fn nonempty(value: Option<&str>) -> Option<&str> {
436    value.map(str::trim).filter(|path| !path.is_empty())
437}
438
439fn ljos_executable() -> String {
440    std::env::current_exe()
441        .map(|path| path.display().to_string())
442        .unwrap_or_else(|_| "ljos".to_string())
443}
444
445/// The block appended to a hook. `post-commit` and `post-rewrite` book
446/// through `ljos git-hook`, and the `pre-commit` and `pre-push` blocks
447/// fail the hook when it refuses. The absolute path is the fallback when
448/// `ljos` is not on `PATH`; a `pre-commit` or `pre-push` with neither
449/// runs nothing. `post-rewrite` and `pre-push` copy stdin when the caller
450/// has not, so this block and a chained hook can both read the shas and
451/// refs.
452fn hook_block(name: &str) -> String {
453    hook_block_for(name, &shell_quote(&ljos_executable()))
454}
455
456/// [`hook_block`] with `bin`, already quoted, as the fallback binary.
457fn hook_block_for(name: &str, bin: &str) -> String {
458    let (otherwise, fail) = if gates_git(name) {
459        (format!("elif [ -x {bin} ]; then"), "exit 1")
460    } else {
461        ("else".to_string(), "true")
462    };
463    if matches!(name, "post-rewrite" | "pre-push") {
464        return format!(
465            "{BLOCK_BEGIN}\n\
466             if [ -z \"${{_ljos_in:-}}\" ]; then\n\
467             \t_ljos_in=$(mktemp 2>/dev/null || true)\n\
468             \tif [ -n \"$_ljos_in\" ]; then\n\
469             \t\tcat > \"$_ljos_in\" || true\n\
470             \tfi\n\
471             fi\n\
472             if command -v ljos >/dev/null 2>&1; then\n\
473             \tif [ -n \"${{_ljos_in:-}}\" ]; then\n\
474             \t\tljos git-hook {name} \"$@\" < \"$_ljos_in\" >&2 || {fail}\n\
475             \telse\n\
476             \t\tljos git-hook {name} \"$@\" >&2 || {fail}\n\
477             \tfi\n\
478             {otherwise}\n\
479             \tif [ -n \"${{_ljos_in:-}}\" ]; then\n\
480             \t\t{bin} git-hook {name} \"$@\" < \"$_ljos_in\" >&2 || {fail}\n\
481             \telse\n\
482             \t\t{bin} git-hook {name} \"$@\" >&2 || {fail}\n\
483             \tfi\n\
484             fi\n\
485             {BLOCK_END}\n"
486        );
487    }
488    format!(
489        "{BLOCK_BEGIN}\n\
490         if command -v ljos >/dev/null 2>&1; then\n\
491         \tljos git-hook {name} \"$@\" >&2 || {fail}\n\
492         {otherwise}\n\
493         \t{bin} git-hook {name} \"$@\" >&2 || {fail}\n\
494         fi\n\
495         {BLOCK_END}\n"
496    )
497}
498
499fn has_block(text: &str, name: &str) -> bool {
500    text.contains(&hook_block(name))
501}
502
503/// Whether `text` holds the block this build writes for `name`, whatever
504/// binary it falls back to. Another `ljos` on the box wrote a different
505/// path into the same block. A block from an older build, such as one
506/// whose gated call ends in `|| true`, does not count.
507fn carries_block(text: &str, name: &str) -> bool {
508    // A path cannot hold NUL, so it marks where the fallback goes.
509    let template = hook_block_for(name, "\0");
510    let mut parts = template.split('\0');
511    let (Some(head), Some(next)) = (parts.next(), parts.next()) else {
512        return text.contains(&template);
513    };
514    text.match_indices(head).any(|(at, _)| {
515        let rest = &text[at + head.len()..];
516        let line = rest.find('\n').unwrap_or(rest.len());
517        rest.match_indices(next)
518            .take_while(|(end, _)| *end <= line)
519            .any(|(end, _)| rest.starts_with(&template.replace('\0', &rest[..end])[head.len()..]))
520    })
521}
522
523fn strip_block(text: &str) -> String {
524    let Some(start) = text.find(BLOCK_BEGIN) else {
525        return text.to_string();
526    };
527    let Some(rel) = text[start..].find(BLOCK_END) else {
528        return text.to_string();
529    };
530    let mut end = start + rel + BLOCK_END.len();
531    if text[end..].starts_with('\n') {
532        end += 1;
533    }
534    let mut out = String::new();
535    out.push_str(&text[..start]);
536    out.push_str(&text[end..]);
537    out
538}
539
540fn strip_all(text: &str) -> String {
541    let mut out = text.to_string();
542    loop {
543        let next = strip_block(&out);
544        if next == out {
545            return out;
546        }
547        out = next;
548    }
549}
550
551fn merge_block(text: &str, name: &str) -> String {
552    if has_block(text, name) {
553        return text.to_string();
554    }
555    let stripped = strip_all(text);
556    let block = hook_block(name);
557    if stripped.trim().is_empty() {
558        return format!("#!/bin/sh\n{block}");
559    }
560    if stripped.ends_with('\n') {
561        format!("{stripped}{block}")
562    } else {
563        format!("{stripped}\n{block}")
564    }
565}
566
567fn block_only(text: &str) -> bool {
568    matches!(text.trim(), "" | "#!/bin/sh")
569}
570
571fn hook_calls_ljos(text: &str, name: &str) -> bool {
572    text.contains(&format!("ljos git-hook {name} "))
573}
574
575fn is_executable(path: &Path) -> bool {
576    #[cfg(unix)]
577    {
578        use std::os::unix::fs::PermissionsExt;
579        fs::metadata(path)
580            .map(|meta| meta.permissions().mode() & 0o111 != 0)
581            .unwrap_or(false)
582    }
583    #[cfg(not(unix))]
584    {
585        path.is_file()
586    }
587}
588
589fn ensure_executable(path: &Path) -> Result<()> {
590    #[cfg(unix)]
591    {
592        use std::os::unix::fs::PermissionsExt;
593        let meta = fs::metadata(path).with_context(|| format!("git-hooks: {}", path.display()))?;
594        let mode = meta.permissions().mode();
595        if mode & 0o111 == 0 {
596            fs::set_permissions(path, fs::Permissions::from_mode(mode | 0o111))?;
597        }
598    }
599    Ok(())
600}
601
602fn owned_installed(dir: &Path) -> bool {
603    HOOK_NAMES.iter().all(|name| {
604        let path = dir.join(name);
605        let Ok(text) = fs::read_to_string(&path) else {
606            return false;
607        };
608        is_executable(&path) && (text.contains("#!/bin/sh") || hook_calls_ljos(&text, name))
609    })
610}
611
612fn user_chained(dir: &Path) -> bool {
613    HOOK_NAMES.iter().all(|name| {
614        let path = dir.join(name);
615        let Ok(text) = fs::read_to_string(&path) else {
616            return false;
617        };
618        is_executable(&path) && hook_calls_ljos(&text, name)
619    })
620}
621
622fn hooks_present(configured: &Path, ours: &Path) -> bool {
623    if same_dir(configured, ours) {
624        owned_installed(configured)
625    } else {
626        user_chained(configured)
627    }
628}
629
630/// The first `pre-commit` or `pre-push` in `dir` without this build's
631/// block. Git ignores the gate's refusal when that hook drops its exit
632/// status.
633fn stale_gate(dir: &Path) -> Option<&'static str> {
634    HOOK_NAMES
635        .iter()
636        .copied()
637        .filter(|name| gates_git(name))
638        .find(|name| {
639            !fs::read_to_string(dir.join(name)).is_ok_and(|text| carries_block(&text, name))
640        })
641}
642
643/// Why the gated hooks in `configured` let a refusal through, or `None`.
644fn stale_note(configured: &Path, ours: &Path) -> Option<String> {
645    let name = stale_gate(configured)?;
646    let verb = if name == "pre-push" { "push" } else { "commit" };
647    let what = if same_dir(configured, ours) {
648        "stub"
649    } else {
650        "hook's ljos block"
651    };
652    Some(format!(
653        "the {name} {what} predates the current gate and lets a refused {verb} through"
654    ))
655}
656
657fn hooks_reachable(configured: &Path, ours: &Path) -> bool {
658    hooks_present(configured, ours) && stale_gate(configured).is_none()
659}
660
661fn write_owned_hooks(dir: &Path) -> Result<()> {
662    fs::create_dir_all(dir).with_context(|| format!("git-hooks: {}", dir.display()))?;
663    for name in HOOK_NAMES {
664        let path = dir.join(name);
665        fs::write(&path, hook_body(name))
666            .with_context(|| format!("git-hooks: {}", path.display()))?;
667        #[cfg(unix)]
668        {
669            use std::os::unix::fs::PermissionsExt;
670            fs::set_permissions(&path, fs::Permissions::from_mode(0o755))?;
671        }
672    }
673    Ok(())
674}
675
676fn chain_user_hooks(dir: &Path) -> Result<()> {
677    fs::create_dir_all(dir).with_context(|| format!("git-hooks: {}", dir.display()))?;
678    for name in HOOK_NAMES {
679        let path = dir.join(name);
680        let existing = if path.is_file() {
681            Some(fs::read(&path).with_context(|| format!("git-hooks: {}", path.display()))?)
682        } else {
683            None
684        };
685        if let Some(bytes) = &existing {
686            if bytes.contains(&0) || std::str::from_utf8(bytes).is_err() {
687                eprintln!("ljos: {} is not a shell script; left it", path.display());
688                continue;
689            }
690        }
691        let text = existing
692            .as_deref()
693            .map(|bytes| std::str::from_utf8(bytes).unwrap_or(""))
694            .unwrap_or("");
695        let next = merge_block(text, name);
696        if existing.is_none() || next != text {
697            fs::write(&path, next).with_context(|| format!("git-hooks: {}", path.display()))?;
698        }
699        ensure_executable(&path)?;
700    }
701    Ok(())
702}
703
704fn strip_user_hooks(dir: &Path) -> Result<()> {
705    if !dir.is_dir() {
706        return Ok(());
707    }
708    for name in HOOK_NAMES {
709        let path = dir.join(name);
710        let Ok(bytes) = fs::read(&path) else {
711            continue;
712        };
713        let Ok(text) = std::str::from_utf8(&bytes) else {
714            continue;
715        };
716        if !text.contains(BLOCK_BEGIN) {
717            continue;
718        }
719        let stripped = strip_all(text);
720        if block_only(&stripped) {
721            fs::remove_file(&path).with_context(|| format!("git-hooks: {}", path.display()))?;
722        } else if stripped != text {
723            fs::write(&path, stripped).with_context(|| format!("git-hooks: {}", path.display()))?;
724        }
725    }
726    Ok(())
727}
728
729fn remove_owned_hooks(dir: &Path) {
730    for name in HOOK_NAMES {
731        let _ = fs::remove_file(dir.join(name));
732    }
733    let _ = fs::remove_file(dir.join("previous-hooks-path"));
734}
735
736fn hook_body(name: &str) -> String {
737    let block = hook_block(name);
738    format!(
739        "#!/bin/sh\n\
740         # ljos books the commit, then chains to this repository's\n\
741         # .git/hooks/{name}, or to the core.hooksPath recorded in\n\
742         # previous-hooks-path. Booking cannot fail the hook; a refusal\n\
743         # from ljos in pre-commit or pre-push does. The chained hook's\n\
744         # exit status is kept. post-rewrite and pre-push keep a copy of\n\
745         # stdin for that hook.\n\
746         #\n\
747         # pre-commit install follows core.hooksPath and would replace this\n\
748         # file. Install the framework into the repository, then re-run\n\
749         # `ljos git-hooks install` if a stub was overwritten:\n\
750         #   dir=$(git rev-parse --git-dir)\n\
751         #   git config --local core.hooksPath \"$dir/hooks\"\n\
752         #   pre-commit install\n\
753         #   git config --local --unset core.hooksPath\n\
754         {block}\
755         git_dir=$(git rev-parse --git-dir 2>/dev/null || true)\n\
756         if [ -n \"$git_dir\" ]; then\n\
757         \tcase \"$git_dir\" in\n\
758         \t\t/*) ;;\n\
759         \t\t*) git_dir=$(pwd)/$git_dir ;;\n\
760         \tesac\n\
761         \trepo_hook=\"$git_dir/hooks/{name}\"\n\
762         \tif [ -x \"$repo_hook\" ] && [ \"$repo_hook\" != \"$0\" ]; then\n\
763         \t\tif [ -n \"${{_ljos_in:-}}\" ]; then\n\
764         \t\t\texec \"$repo_hook\" \"$@\" < \"$_ljos_in\"\n\
765         \t\tfi\n\
766         \t\texec \"$repo_hook\" \"$@\"\n\
767         \tfi\n\
768         fi\n\
769         here=$(dirname \"$0\")\n\
770         here=$(CDPATH= cd -- \"$here\" && pwd) || exit 0\n\
771         prev=$(sed -n '1s/[[:space:]]*$//p' \"$here/previous-hooks-path\" 2>/dev/null || true)\n\
772         if [ -n \"$prev\" ] && [ \"$prev\" != \"$here\" ]; then\n\
773         \tprev_hook=\"$prev/{name}\"\n\
774         \tif [ -x \"$prev_hook\" ] && [ \"$prev_hook\" != \"$0\" ]; then\n\
775         \t\tif [ -n \"${{_ljos_in:-}}\" ]; then\n\
776         \t\t\texec \"$prev_hook\" \"$@\" < \"$_ljos_in\"\n\
777         \t\tfi\n\
778         \t\texec \"$prev_hook\" \"$@\"\n\
779         \tfi\n\
780         fi\n\
781         exit 0\n"
782    )
783}
784
785fn shell_quote(text: &str) -> String {
786    format!("'{}'", text.replace('\'', "'\\''"))
787}
788
789/// Book the commit that just landed. Always succeeds: the commit stands.
790///
791/// # Errors
792///
793/// None. A habitat that did not answer is a line in the book log.
794pub fn run_git_commit(hook: &str) -> Result<()> {
795    if let Err(err) = book_commit(hook) {
796        crate::bookkeep::log_line(&format!("git-hooks: {err:#}"));
797    }
798    Ok(())
799}
800
801/// Run the ljos part of git hook `name`.
802///
803/// `post-commit` and `post-rewrite` book the commit. `pre-commit` and
804/// `pre-push` run the sitting gate ([`gate`]). Any other name succeeds
805/// and does nothing. Arguments git passed the hook are ignored.
806///
807/// # Errors
808///
809/// The sitting gate's refusal. Booking that did not land is a line in
810/// the book log.
811pub fn run_named(name: &str) -> Result<()> {
812    let name = name.trim();
813    if books_commit(name) {
814        run_git_commit(name)
815    } else if gates_git(name) {
816        gate(name).map_err(|err| match dropped_refusal(name) {
817            Some(why) => anyhow!("{err:#}\n{why}"),
818            None => err,
819        })
820    } else {
821        Ok(())
822    }
823}
824
825/// Why git goes on after the gate refuses, when the hook file it is
826/// running carries an ljos block older than this build's. Such a block
827/// can end in `|| true`, and this process cannot change the exit status
828/// the hook returns. A hook that calls `ljos git-hook` with no block is
829/// the user's own and is not judged.
830fn dropped_refusal(name: &str) -> Option<String> {
831    let raw = git_line(&["rev-parse", "--git-path", &format!("hooks/{name}")])?;
832    let path = std::env::current_dir().ok()?.join(raw);
833    let text = fs::read_to_string(&path).ok()?;
834    if !text.contains(BLOCK_BEGIN) || carries_block(&text, name) {
835        return None;
836    }
837    let verb = if name == "pre-push" { "push" } else { "commit" };
838    Some(format!(
839        "sitting-gate: {} predates this gate and drops its refusal, so git will {verb} anyway; {HOOKS_FIX} replaces it",
840        path.display()
841    ))
842}
843
844/// The guard's sitting gate for a commit or push it did not see, such as
845/// one a script runs: under a runner, `pre-commit` needs a live sitting,
846/// and `pre-push` also needs that ticket to clear each ref it sends onto
847/// a branch [`crate::gates::consensus_push`] names. A store or a tracker
848/// checkout, which ljos writes itself, is not gated, and nor is the
849/// person. When a runner variable is the only cause, and the call would
850/// be refused, the person is asked for their passphrase on the
851/// controlling terminal; a right answer lets this one call through.
852fn gate(name: &str) -> Result<()> {
853    let Some(cause) = crate::runner_cause() else {
854        return Ok(());
855    };
856    let refused = match gate_runner(name) {
857        Ok(()) => return Ok(()),
858        Err(refused) => refused,
859    };
860    if cause == crate::RunnerCause::Hard {
861        return Err(refused);
862    }
863    let verb = if name == "pre-push" { "push" } else { "commit" };
864    let marks = crate::runner_marks();
865    match crate::person_key::ask_person(&marks, &format!("git {verb}")) {
866        Ok(()) => Ok(()),
867        Err(why) => {
868            let detail = format!("{refused:#}");
869            let detail = detail.strip_prefix("sitting-gate: ").unwrap_or(&detail);
870            let text = crate::sitting_gate::for_marks(
871                detail,
872                &marks,
873                verb,
874                crate::person_key::is_set(),
875                &why,
876            );
877            Err(anyhow!("sitting-gate: {text}"))
878        }
879    }
880}
881
882fn gate_runner(name: &str) -> Result<()> {
883    let repo = git_line(&["rev-parse", "--show-toplevel"])
884        .map(PathBuf::from)
885        .or_else(git_dir);
886    if repo
887        .as_deref()
888        .is_some_and(|repo| is_store(repo) || holds_tracker(repo))
889    {
890        return Ok(());
891    }
892    // In a hook the process tree names the hook's own shell. The seat a
893    // booking would take is the one the sitting was taken under. With a
894    // conversation id in the environment only that conversation's holder
895    // counts, or for a subagent its parent's records and process when
896    // that process carries a conversation id. With no id it falls back
897    // to the process match.
898    let seat = crate::git_hook_seat();
899    let _actor = seat.as_deref().map(crate::HookActor::enter);
900    let issue = match crate::sitting_gate::live_sitting() {
901        Ok(issue) => issue,
902        Err(_) => match crate::sitting_gate::open_or_name_sitting() {
903            Some(issue) => issue,
904            None if name != "pre-push" => return Ok(()),
905            None => String::new(),
906        },
907    };
908    if name != "pre-push" {
909        return Ok(());
910    }
911    let cfg = match crate::gates::consensus_push(repo.as_deref()) {
912        Ok(cfg) => cfg,
913        Err(why) => {
914            return Err(anyhow!(
915                "sitting-gate: {}",
916                crate::sitting_gate::unread_settings(&why)
917            ))
918        }
919    };
920    let dest = match std::io::read_to_string(std::io::stdin()) {
921        Ok(refs) => protected_ref(&refs, &cfg.branches),
922        Err(e) => Some(format!("sends refs the hook cannot read ({e})")),
923    };
924    let Some(dest) = dest else {
925        return Ok(());
926    };
927    if issue.is_empty() || !crate::protected_push_cleared(&issue, &crate::seat_name(), cfg.quorum) {
928        let why = "the sitting has no settled consensus".to_string();
929        let wait = crate::sitting_gate::seats_wait(&dest, &cfg.branches, &issue, &why);
930        return Err(anyhow!("sitting-gate: {wait}"));
931    }
932    Ok(())
933}
934
935/// What a push whose pre-push stdin is `refs` sends onto a branch in
936/// `branches`, as `sends BRANCH`, or `None` when no ref lands on one.
937/// Git runs the hook with no lines when nothing is to be sent. A line
938/// not in git's four-field form cannot be read, so it is taken to land
939/// on one.
940fn protected_ref(refs: &str, branches: &[String]) -> Option<String> {
941    for line in refs.lines().filter(|l| !l.trim().is_empty()) {
942        let fields: Vec<&str> = line.split_whitespace().collect();
943        let [_, _, remote, _] = fields[..] else {
944            return Some(format!("lists `{line}`, which is not a ref git would send"));
945        };
946        let Some(branch) = remote.strip_prefix("refs/heads/") else {
947            continue;
948        };
949        if let Some(b) = branches.iter().find(|b| b.eq_ignore_ascii_case(branch)) {
950            return Some(format!("sends `{b}`"));
951        }
952    }
953    None
954}
955
956/// Whether `repo` is a tracker checkout: it holds `vissue.toml`, or a
957/// `Software/<project>/issues.org` as `vissue` reads a tracker root. A
958/// bare `Software/` directory is any repository's.
959fn holds_tracker(repo: &Path) -> bool {
960    if repo.join("vissue.toml").is_file() {
961        return true;
962    }
963    std::fs::read_dir(repo.join("Software")).is_ok_and(|entries| {
964        entries
965            .flatten()
966            .any(|entry| entry.path().join("issues.org").is_file())
967    })
968}
969
970fn book_commit(hook: &str) -> Result<()> {
971    if std::env::var_os("LJOS_GIT_HOOK").is_some() || booking_off() {
972        return Ok(());
973    }
974    if foreign_session() {
975        return Ok(());
976    }
977    if hook != "post-rewrite" && mid_rewrite() {
978        return Ok(());
979    }
980    let Some(repo) = git_line(&["rev-parse", "--show-toplevel"]) else {
981        return Ok(());
982    };
983    let repo_path = PathBuf::from(&repo);
984    // A commit in the tracker's checkout is a tracker write: never booked,
985    // but its seat is checked as any other write's is.
986    let tracker = tracker_repo(&repo_path);
987    if !tracker && (crate::bookkeep::path_is_temp(Some(&repo_path)) || skip_repo(&repo_path)) {
988        return Ok(());
989    }
990    let Some(head) = git_line(&["rev-parse", "HEAD"]) else {
991        return Ok(());
992    };
993    // The process tree is not a seat. Without LJOS_SEAT, or an agy or
994    // opencode conversation, the commit stays unbooked; a subagent is told.
995    let Some(seat) = crate::git_hook_seat() else {
996        if tracker {
997            refuse_tracker_commit(
998                &head,
999                "no seat is named; export LJOS_SEAT=<your seat> to write the tracker",
1000            );
1001        } else if crate::unseated_subagent() {
1002            // Stdout: the installed hook shows that line and drops stderr.
1003            println!(
1004                "ljos: commit {} not booked: this subagent has no seat; export LJOS_SEAT=<your seat> (the parent's) to book it",
1005                &head[..head.len().min(12)]
1006            );
1007        }
1008        return Ok(());
1009    };
1010    let _actor = crate::HookActor::enter(&seat);
1011    // Every path below writes the tracker: a repointed citation, a named
1012    // issue, an open sitting, a new claim.
1013    if let Err(e) = crate::write_seat("commit") {
1014        if tracker {
1015            refuse_tracker_commit(&head, &format!("{e:#}"));
1016        } else {
1017            println!(
1018                "ljos: commit {} not booked: {e:#}",
1019                &head[..head.len().min(12)]
1020            );
1021        }
1022        return Ok(());
1023    }
1024    if tracker {
1025        return Ok(());
1026    }
1027    let _children = HookChildren::enter();
1028    // An amend or a rebase lists old and new shas. Move the citation
1029    // before the "already booked" return: post-commit may have cited the
1030    // new HEAD already, and the old sha would otherwise stay on the issue.
1031    if already_booked(&repo, &head) {
1032        return Ok(());
1033    }
1034    let subject = git_line(&["log", "-1", "--format=%s"]).unwrap_or_default();
1035    println!(
1036        "{}",
1037        crate::bookkeep::note_after_commit(&repo, &head, &subject)
1038    );
1039    remember_booked(&repo, &head);
1040    Ok(())
1041}
1042
1043fn booking_off() -> bool {
1044    matches!(
1045        std::env::var("LJOS_OFF").ok().as_deref(),
1046        Some("1" | "true" | "TRUE" | "yes" | "YES" | "on" | "ON")
1047    ) || crate::bookkeep::guard_suspended()
1048}
1049
1050fn foreign_session() -> bool {
1051    // OpenCode's shell sets OPENCODE_SESSION_ID and never calls the plugin's
1052    // tool.execute.after, so the git hook is the booker. Claude and Codex
1053    // book from their own hooks.
1054    ["CLAUDECODE", "CODEX_THREAD_ID", "CODEX_SESSION_ID"]
1055        .iter()
1056        .any(|key| std::env::var_os(key).is_some_and(|value| !value.is_empty()))
1057}
1058
1059fn mid_rewrite() -> bool {
1060    let Some(dir) = git_dir() else {
1061        return false;
1062    };
1063    dir.join("rebase-merge").is_dir()
1064        || dir.join("rebase-apply").is_dir()
1065        || dir.join("MERGE_HEAD").is_file()
1066}
1067
1068/// Whether `repo` is the tracker's checkout, `VISSUE_ROOT` or `ISSUE_ROOT`.
1069fn tracker_repo(repo: &Path) -> bool {
1070    ["VISSUE_ROOT", "ISSUE_ROOT"].iter().any(|key| {
1071        std::env::var_os(key)
1072            .filter(|value| !value.is_empty())
1073            .map(PathBuf::from)
1074            .is_some_and(|root| useful_root(&root) && covered(repo, &root))
1075    })
1076}
1077
1078/// A tracker commit no seat that may write the tracker made. It has landed
1079/// and a post-commit hook cannot take it back, so the refusal is said on
1080/// stdout, which the installed hook shows, and kept in the book log.
1081fn refuse_tracker_commit(head: &str, why: &str) {
1082    let line = format!(
1083        "ljos: tracker commit {} was not made by a seat that may write the tracker: {why}",
1084        &head[..head.len().min(12)]
1085    );
1086    println!("{line}");
1087    crate::bookkeep::log_line(&format!("git-hooks: {line}"));
1088}
1089
1090fn skip_repo(repo: &Path) -> bool {
1091    if is_store(repo) {
1092        return true;
1093    }
1094    skip_roots().iter().any(|root| covered(repo, root))
1095}
1096
1097/// Paths a commit under which is not booked. The defaults always apply.
1098/// `[git_hooks] skip` in `ljos.toml` adds box-specific paths, and a
1099/// reinstall rewrites the hook scripts without touching that file.
1100fn skip_roots() -> Vec<PathBuf> {
1101    let mut roots = vec![
1102        PathBuf::from("/tmp"),
1103        PathBuf::from("/var/tmp"),
1104        PathBuf::from("/dev/shm"),
1105    ];
1106    for key in ["VISSUE_ROOT", "ISSUE_ROOT", "CLAIMDAG_DIR"] {
1107        if let Some(path) = std::env::var_os(key).filter(|value| !value.is_empty()) {
1108            roots.push(PathBuf::from(path));
1109        }
1110    }
1111    roots.extend(store_dirs());
1112    for path in config_files() {
1113        roots.extend(configured_skips(&path));
1114    }
1115    roots.retain(|path| useful_root(path));
1116    roots
1117}
1118
1119fn useful_root(path: &Path) -> bool {
1120    let text = path.to_string_lossy();
1121    let text = text.trim();
1122    !text.is_empty() && text != "/" && text != "."
1123}
1124
1125fn config_files() -> Vec<PathBuf> {
1126    let mut out = Vec::new();
1127    let base = std::env::var_os("XDG_CONFIG_HOME")
1128        .filter(|value| !value.is_empty())
1129        .map(PathBuf::from)
1130        .or_else(|| {
1131            std::env::var_os("HOME")
1132                .filter(|value| !value.is_empty())
1133                .map(|home| PathBuf::from(home).join(".config"))
1134        });
1135    if let Some(base) = base {
1136        out.push(base.join("ljos").join("ljos.toml"));
1137        if let Some(seat) = std::env::var("LJOS_SEAT")
1138            .ok()
1139            .map(|name| name.trim().to_string())
1140            .filter(|name| !name.is_empty() && !name.contains('/') && !name.contains(".."))
1141        {
1142            out.push(base.join("ljos").join("seats").join(seat).join("ljos.toml"));
1143        }
1144    }
1145    if let Some(path) = std::env::var_os("LJOS_CONFIG").filter(|value| !value.is_empty()) {
1146        out.push(PathBuf::from(path));
1147    }
1148    if let Ok(cwd) = std::env::current_dir() {
1149        out.push(cwd.join("ljos.toml"));
1150    }
1151    out
1152}
1153
1154fn configured_skips(path: &Path) -> Vec<PathBuf> {
1155    let Ok(text) = fs::read_to_string(path) else {
1156        return Vec::new();
1157    };
1158    let Ok(value) = toml::from_str::<toml::Value>(&text) else {
1159        return Vec::new();
1160    };
1161    let Some(list) = value
1162        .get("git_hooks")
1163        .and_then(|table| table.get("skip"))
1164        .and_then(toml::Value::as_array)
1165    else {
1166        return Vec::new();
1167    };
1168    list.iter()
1169        .filter_map(toml::Value::as_str)
1170        .map(str::trim)
1171        .filter(|text| !text.is_empty())
1172        .map(PathBuf::from)
1173        .collect()
1174}
1175
1176fn covered(repo: &Path, root: &Path) -> bool {
1177    let repo = canonical_or(repo);
1178    let root = canonical_or(root);
1179    repo == root || repo.starts_with(&root)
1180}
1181
1182fn canonical_or(path: &Path) -> PathBuf {
1183    fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
1184}
1185
1186/// Whether `repo` is a deed or claim store ljos writes itself, read from
1187/// the store settings; a path that merely contains `claimdag` is not one.
1188fn is_store(repo: &Path) -> bool {
1189    store_dirs()
1190        .iter()
1191        .any(|root| same_dir(repo, root) || repo_starts(repo, root))
1192}
1193
1194fn store_dirs() -> Vec<PathBuf> {
1195    let mut out = Vec::new();
1196    if let Ok(url) = std::env::var("DEEDAR_URL") {
1197        if let Some(path) = url.strip_prefix("file://") {
1198            out.push(PathBuf::from(path));
1199        }
1200    }
1201    if let Some(data) = std::env::var_os("XDG_DATA_HOME").filter(|value| !value.is_empty()) {
1202        out.push(PathBuf::from(data).join("deedar").join("store"));
1203    } else if let Some(home) = std::env::var_os("HOME").filter(|value| !value.is_empty()) {
1204        out.push(
1205            PathBuf::from(home)
1206                .join(".local")
1207                .join("share")
1208                .join("deedar")
1209                .join("store"),
1210        );
1211    }
1212    out
1213}
1214
1215fn same_dir(left: &Path, right: &Path) -> bool {
1216    let left = fs::canonicalize(left).unwrap_or_else(|_| left.to_path_buf());
1217    let right = fs::canonicalize(right).unwrap_or_else(|_| right.to_path_buf());
1218    left == right
1219}
1220
1221fn repo_starts(repo: &Path, root: &Path) -> bool {
1222    if !root.exists() {
1223        return false;
1224    }
1225    let repo = fs::canonicalize(repo).unwrap_or_else(|_| repo.to_path_buf());
1226    let root = fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
1227    repo.starts_with(root)
1228}
1229
1230fn already_booked(repo: &str, head: &str) -> bool {
1231    let key = format!("{repo}\t{head}");
1232    fs::read_to_string(booked_path())
1233        .map(|text| text.lines().any(|line| line == key))
1234        .unwrap_or(false)
1235}
1236
1237fn remember_booked(repo: &str, head: &str) {
1238    let path = booked_path();
1239    if let Some(dir) = path.parent() {
1240        let _ = fs::create_dir_all(dir);
1241    }
1242    let mut lines: Vec<String> = fs::read_to_string(&path)
1243        .unwrap_or_default()
1244        .lines()
1245        .map(str::to_string)
1246        .filter(|line| !line.starts_with(&format!("{repo}\t")))
1247        .collect();
1248    lines.push(format!("{repo}\t{head}"));
1249    if lines.len() > 200 {
1250        lines.drain(0..lines.len() - 200);
1251    }
1252    let _ = fs::write(path, lines.join("\n") + "\n");
1253}
1254
1255fn booked_path() -> PathBuf {
1256    crate::runtime_dir().join("git-hooks-booked")
1257}
1258
1259fn git_dir() -> Option<PathBuf> {
1260    let raw = git_line(&["rev-parse", "--git-dir"])?;
1261    let path = PathBuf::from(&raw);
1262    if path.is_absolute() {
1263        Some(path)
1264    } else {
1265        std::env::current_dir().ok().map(|cwd| cwd.join(path))
1266    }
1267}
1268
1269/// `GIT_*` values a hook exports. A later `git -C` follows `GIT_DIR` and
1270/// ignores `-C`, so a tracker commit would land in the checkout that just
1271/// finished.
1272const HOOK_GIT_ENV: &[&str] = &[
1273    "GIT_DIR",
1274    "GIT_WORK_TREE",
1275    "GIT_INDEX_FILE",
1276    "GIT_PREFIX",
1277    "GIT_OBJECT_DIRECTORY",
1278    "GIT_COMMON_DIR",
1279    "GIT_NAMESPACE",
1280];
1281
1282thread_local! {
1283    static HOOK_CHILDREN: Cell<bool> = const { Cell::new(false) };
1284}
1285
1286/// While this guard is alive, child processes started through
1287/// [`hook_child_env`] carry `LJOS_GIT_HOOK=1` and do not inherit the hook's
1288/// `GIT_DIR`. Dropping it restores the previous flag. The process
1289/// environment is not changed.
1290#[must_use]
1291struct HookChildren {
1292    previous: bool,
1293}
1294
1295impl HookChildren {
1296    fn enter() -> Self {
1297        let previous = HOOK_CHILDREN.with(Cell::get);
1298        HOOK_CHILDREN.with(|flag| flag.set(true));
1299        Self { previous }
1300    }
1301}
1302
1303impl Drop for HookChildren {
1304    fn drop(&mut self) {
1305        HOOK_CHILDREN.with(|flag| flag.set(self.previous));
1306    }
1307}
1308
1309/// Put the re-entry guard on a child `git` or `ljos`, and drop the hook's
1310/// git directory from that child. No effect unless a git hook is booking
1311/// a commit on this thread.
1312pub(crate) fn hook_child_env(cmd: &mut Command) {
1313    if !HOOK_CHILDREN.with(Cell::get) {
1314        return;
1315    }
1316    cmd.env("LJOS_GIT_HOOK", "1");
1317    for key in HOOK_GIT_ENV {
1318        cmd.env_remove(key);
1319    }
1320}
1321
1322#[cfg(test)]
1323pub(crate) fn with_hook_children_for_test<T>(run: impl FnOnce() -> T) -> T {
1324    let _guard = HookChildren::enter();
1325    run()
1326}
1327
1328fn git_command(args: &[&str], git_env: &[(&str, &str)]) -> Command {
1329    let mut cmd = Command::new("git");
1330    cmd.args(args);
1331    for (key, value) in git_env {
1332        cmd.env(key, value);
1333    }
1334    hook_child_env(&mut cmd);
1335    cmd
1336}
1337
1338fn git_line(args: &[&str]) -> Option<String> {
1339    let out = git_command(args, &[]).output().ok()?;
1340    if !out.status.success() {
1341        return None;
1342    }
1343    let text = String::from_utf8(out.stdout).ok()?;
1344    let text = text.trim();
1345    if text.is_empty() {
1346        None
1347    } else {
1348        Some(text.to_string())
1349    }
1350}
1351
1352fn global_hooks_path(git_env: &[(&str, &str)]) -> Option<String> {
1353    let out = git_command(&["config", "--global", "--get", "core.hooksPath"], git_env)
1354        .output()
1355        .ok()?;
1356    if !out.status.success() {
1357        return None;
1358    }
1359    let text = String::from_utf8(out.stdout).ok()?;
1360    let text = text.trim();
1361    if text.is_empty() {
1362        None
1363    } else {
1364        Some(text.to_string())
1365    }
1366}
1367
1368fn set_global_hooks_path(path: &Path, git_env: &[(&str, &str)]) -> Result<()> {
1369    let value = path.display().to_string();
1370    let status = git_command(&["config", "--global", "core.hooksPath", &value], git_env)
1371        .status()
1372        .context("git-hooks: git config")?;
1373    if !status.success() {
1374        bail!("git-hooks: could not set core.hooksPath");
1375    }
1376    Ok(())
1377}
1378
1379fn unset_global_hooks_path(git_env: &[(&str, &str)]) -> Result<()> {
1380    let status = git_command(
1381        &["config", "--global", "--unset", "core.hooksPath"],
1382        git_env,
1383    )
1384    .status()
1385    .context("git-hooks: git config")?;
1386    if !status.success() {
1387        bail!("git-hooks: could not unset core.hooksPath");
1388    }
1389    Ok(())
1390}
1391
1392#[cfg(test)]
1393mod tests {
1394    use std::ffi::OsStr;
1395
1396    use super::*;
1397
1398    fn mapped<'a>(cmd: &'a Command, key: &str) -> Option<Option<&'a OsStr>> {
1399        cmd.get_envs()
1400            .find(|(name, _)| *name == OsStr::new(key))
1401            .map(|(_, value)| value)
1402    }
1403
1404    #[test]
1405    fn the_doctor_names_a_hooks_path_that_is_not_the_onboard_one() {
1406        let dir = tempfile::tempdir().unwrap();
1407        let ours = dir.path().join("ljos").join("git-hooks");
1408        let other = dir.path().join("user-hooks");
1409        std::fs::create_dir_all(&other).unwrap();
1410        let prior = "#!/bin/sh\nprintf 'user-hook\\n'\n";
1411        std::fs::write(other.join("post-commit"), prior).unwrap();
1412        let ours_s = ours.display().to_string();
1413        let other_s = other.display().to_string();
1414
1415        let (ok, state) = super::hooks_state(&ours, None);
1416        assert!(!ok);
1417        assert!(state.starts_with(super::HOOKS_UNSET), "{state}");
1418        assert_eq!(state.matches(super::HOOKS_FIX).count(), 1, "{state}");
1419
1420        let (ok, state) = super::hooks_state(&ours, Some(&other_s));
1421        assert!(!ok, "{state}");
1422        assert!(state.contains(&other_s), "{state}");
1423        assert!(!state.contains(&ours_s), "{state}");
1424        assert_eq!(state.matches(super::HOOKS_FIX).count(), 1, "{state}");
1425
1426        super::chain_user_hooks(&other).unwrap();
1427        let (ok, state) = super::hooks_state(&ours, Some(&other_s));
1428        assert!(ok, "{state}");
1429        assert!(state.contains("ljos git-hook runs"), "{state}");
1430        let hooked = std::fs::read_to_string(other.join("post-commit")).unwrap();
1431        assert!(hooked.starts_with(prior), "{hooked}");
1432        assert_eq!(
1433            hooked.matches("# ljos:begin git-hook").count(),
1434            1,
1435            "{hooked}"
1436        );
1437        super::chain_user_hooks(&other).unwrap();
1438        assert_eq!(
1439            std::fs::read_to_string(other.join("post-commit")).unwrap(),
1440            hooked
1441        );
1442        assert!(other.join("pre-push").is_file());
1443
1444        super::strip_user_hooks(&other).unwrap();
1445        assert_eq!(
1446            std::fs::read_to_string(other.join("post-commit")).unwrap(),
1447            prior
1448        );
1449        assert!(!other.join("pre-push").exists());
1450
1451        let config = dir.path().join("config");
1452        std::fs::create_dir_all(&config).unwrap();
1453        std::fs::write(
1454            config.join("env.sh"),
1455            format!("export LJOS_GIT_HOOKS={}\n", other.display()),
1456        )
1457        .unwrap();
1458        let drift = super::env_hooks_drift(&config, &ours).expect("a drifted env file");
1459        assert!(drift.contains(&other_s), "{drift}");
1460        assert!(drift.contains(&ours_s), "{drift}");
1461        let (ok, state) = super::doctor_state(&ours, None, &config);
1462        assert!(!ok, "{state}");
1463        assert!(
1464            !state.starts_with(super::HOOKS_UNSET),
1465            "a mismatch stayed info: {state}"
1466        );
1467        assert!(state.contains(&other_s), "{state}");
1468        assert!(state.contains(&ours_s), "{state}");
1469
1470        std::fs::write(
1471            config.join("env.sh"),
1472            format!("{}\n", super::HOOKS_ENV_LINE),
1473        )
1474        .unwrap();
1475        assert!(super::env_hooks_drift(&config, &ours).is_none());
1476        let body = super::hook_body("post-rewrite");
1477        assert!(body.contains("mktemp"), "{body}");
1478        assert!(body.contains("git-hook post-rewrite"), "{body}");
1479        assert!(body.contains("< \"$_ljos_in\""), "{body}");
1480        let commit = super::hook_body("post-commit");
1481        assert!(!commit.contains("mktemp"), "{commit}");
1482        assert!(commit.contains("git-hook post-commit"), "{commit}");
1483
1484        super::write_owned_hooks(&ours).unwrap();
1485        let (ok, state) = super::hooks_state(&ours, Some(&ours_s));
1486        assert!(ok, "{state}");
1487        std::fs::remove_file(ours.join("pre-push")).unwrap();
1488        let (ok, state) = super::hooks_state(&ours, Some(&ours_s));
1489        assert!(!ok, "{state}");
1490        assert!(state.contains(super::HOOKS_FIX), "{state}");
1491    }
1492
1493    /// The `pre-push` block a build before 64e489f5 wrote. `|| true`
1494    /// drops the gate's refusal, so git pushes anyway.
1495    const OLD_PRE_PUSH: &str = "# ljos:begin git-hook\n\
1496        if command -v ljos >/dev/null 2>&1; then\n\
1497        \tljos git-hook pre-push \"$@\" >&2 || true\n\
1498        else\n\
1499        \t'/usr/local/bin/ljos' git-hook pre-push \"$@\" >&2 || true\n\
1500        fi\n\
1501        # ljos:end git-hook\n";
1502
1503    #[test]
1504    fn the_doctor_fails_a_pre_push_stub_that_drops_a_refusal() {
1505        let dir = tempfile::tempdir().unwrap();
1506        let ours = dir.path().join("git-hooks");
1507        let ours_s = ours.display().to_string();
1508        let config = dir.path().join("config");
1509        super::write_owned_hooks(&ours).unwrap();
1510        let (ok, state) = super::doctor_state(&ours, Some(&ours_s), &config);
1511        assert!(ok, "{state}");
1512
1513        let fresh = super::hook_body("pre-push");
1514        let stale = fresh.replace(&super::hook_block("pre-push"), OLD_PRE_PUSH);
1515        assert_ne!(stale, fresh);
1516        std::fs::write(ours.join("pre-push"), &stale).unwrap();
1517        let (ok, state) = super::doctor_state(&ours, Some(&ours_s), &config);
1518        assert!(!ok, "a stale pre-push stub passed: {state}");
1519        assert!(state.contains("pre-push stub predates"), "{state}");
1520        assert_eq!(state.matches(super::HOOKS_FIX).count(), 1, "{state}");
1521
1522        let moved = fresh.replace(
1523            &super::shell_quote(&super::ljos_executable()),
1524            "'/opt/other/ljos'",
1525        );
1526        assert_ne!(moved, fresh);
1527        std::fs::write(ours.join("pre-push"), moved).unwrap();
1528        let (ok, state) = super::doctor_state(&ours, Some(&ours_s), &config);
1529        assert!(ok, "another fallback path failed the row: {state}");
1530
1531        let user = dir.path().join("user-hooks");
1532        super::chain_user_hooks(&user).unwrap();
1533        let user_s = user.display().to_string();
1534        let prior = format!("#!/bin/sh\necho mine\n{OLD_PRE_PUSH}");
1535        std::fs::write(user.join("pre-push"), &prior).unwrap();
1536        let (ok, state) = super::hooks_state(&ours, Some(&user_s));
1537        assert!(!ok, "a stale user block passed: {state}");
1538        assert!(state.contains("pre-push hook's ljos block"), "{state}");
1539        super::chain_user_hooks(&user).unwrap();
1540        let (ok, state) = super::hooks_state(&ours, Some(&user_s));
1541        assert!(ok, "install left the stale block: {state}");
1542        let hooked = std::fs::read_to_string(user.join("pre-push")).unwrap();
1543        assert!(hooked.starts_with("#!/bin/sh\necho mine\n"), "{hooked}");
1544        assert_eq!(hooked.matches("# ljos:begin git-hook").count(), 1);
1545        assert!(!hooked.contains("\"$@\" >&2 || true"), "{hooked}");
1546    }
1547
1548    #[test]
1549    fn a_hook_that_does_not_book_succeeds() {
1550        super::run_named("post-checkout").unwrap();
1551    }
1552
1553    #[test]
1554    fn a_pre_push_ref_list_names_the_protected_branch_it_sends() {
1555        let branches = vec!["develop".to_string(), "main".to_string()];
1556        let line = |dst: &str| format!("refs/heads/x 1111 {dst} 0000\n");
1557        assert_eq!(protected_ref("", &branches), None);
1558        assert_eq!(protected_ref(&line("refs/heads/topic"), &branches), None);
1559        assert_eq!(protected_ref(&line("refs/tags/main"), &branches), None);
1560        let both = line("refs/heads/topic") + &line("refs/heads/Main");
1561        assert_eq!(
1562            protected_ref(&both, &branches).as_deref(),
1563            Some("sends `main`")
1564        );
1565        let odd = protected_ref("refs/heads/x main\n", &branches).unwrap();
1566        assert!(odd.contains("not a ref git would send"), "{odd}");
1567    }
1568
1569    #[test]
1570    fn hook_children_carry_the_guard_without_touching_the_process() {
1571        let before_hook = std::env::var_os("LJOS_GIT_HOOK");
1572        let before_dir = std::env::var_os("GIT_DIR");
1573        let mut plain = Command::new("true");
1574        plain.env("GIT_DIR", "keep");
1575        hook_child_env(&mut plain);
1576        assert_eq!(mapped(&plain, "GIT_DIR"), Some(Some(OsStr::new("keep"))));
1577        assert_eq!(mapped(&plain, "LJOS_GIT_HOOK"), None);
1578
1579        let _guard = HookChildren::enter();
1580        let mut cmd = Command::new("true");
1581        cmd.env("GIT_DIR", "from-the-hook");
1582        cmd.env("PATH", "/usr/bin");
1583        hook_child_env(&mut cmd);
1584        assert_eq!(mapped(&cmd, "LJOS_GIT_HOOK"), Some(Some(OsStr::new("1"))));
1585        for key in HOOK_GIT_ENV {
1586            assert_eq!(mapped(&cmd, key), Some(None), "{key}");
1587        }
1588        assert_eq!(mapped(&cmd, "PATH"), Some(Some(OsStr::new("/usr/bin"))));
1589        assert_eq!(std::env::var_os("LJOS_GIT_HOOK"), before_hook);
1590        assert_eq!(std::env::var_os("GIT_DIR"), before_dir);
1591    }
1592}