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` also refuses a commit it would
21//! send whose message has a by-trailer, a made-with line, or a
22//! generated line, and it refuses when those commits cannot be read.
23//! A Tests paragraph is not that line. The check runs with or without
24//! a runner. `pre-push` copies stdin like `post-rewrite`.
25//!
26//! `pre-commit install` follows `core.hooksPath` and would replace stubs
27//! ljos owns. Install the framework into the repository, then let the stub
28//! chain to it:
29//!
30//! ```text
31//! dir=$(git rev-parse --git-dir)
32//! git config --local core.hooksPath "$dir/hooks"
33//! pre-commit install
34//! git config --local --unset core.hooksPath
35//! ```
36//!
37//! A commit is booked only when a seat resolves from `LJOS_SEAT`, then an
38//! agy or opencode conversation id. A conversation id does not name a seat.
39//! The process tree is not a seat.
40//! The holder is `SEAT@TAG`, the same name `ljos claim` uses. Paths in
41//! `[git_hooks] skip` of `ljos.toml`, plus `/tmp`, `/var/tmp`, `/dev/shm`,
42//! the tracker, `CLAIMDAG_DIR` and the deedar store, are not booked.
43//! An amend or a rebase moves the citation onto the new sha.
44
45use std::cell::Cell;
46use std::fs;
47use std::path::{Path, PathBuf};
48use std::process::Command;
49
50use anyhow::{anyhow, bail, Context, Result};
51
52/// Every hook git looks up by name. A global `core.hooksPath` hides the
53/// repository's copy of each of these, so the stub has to exist for each.
54const HOOK_NAMES: &[&str] = &[
55    "applypatch-msg",
56    "pre-applypatch",
57    "post-applypatch",
58    "pre-commit",
59    "pre-merge-commit",
60    "prepare-commit-msg",
61    "commit-msg",
62    "post-commit",
63    "pre-rebase",
64    "post-checkout",
65    "post-merge",
66    "pre-push",
67    "pre-receive",
68    "update",
69    "proc-receive",
70    "post-receive",
71    "post-update",
72    "reference-transaction",
73    "push-to-checkout",
74    "pre-auto-gc",
75    "post-rewrite",
76    "sendemail-validate",
77    "fsmonitor-watchman",
78    "p4-changelist",
79    "p4-prepare-changelist",
80    "p4-post-changelist",
81    "p4-pre-submit",
82    "post-index-change",
83];
84
85fn books_commit(name: &str) -> bool {
86    matches!(name, "post-commit" | "post-rewrite")
87}
88
89fn gates_git(name: &str) -> bool {
90    matches!(name, "pre-commit" | "pre-push")
91}
92
93/// `install`, `uninstall`, or `status`.
94///
95/// # Errors
96///
97/// `action` is not one of those, or git cannot record `core.hooksPath`.
98pub fn run(action: &str) -> Result<()> {
99    match action {
100        "install" => {
101            let dir = install()?;
102            println!("installed {}", dir.display());
103            Ok(())
104        }
105        "uninstall" => {
106            uninstall()?;
107            println!("uninstalled");
108            Ok(())
109        }
110        "status" => {
111            print!("{}", status());
112            Ok(())
113        }
114        other => bail!("git-hooks: {other} is not install, uninstall, or status"),
115    }
116}
117
118/// Install the hooks `ljos doctor` checks.
119///
120/// An unset `core.hooksPath` is pointed at ljos's hook directory. A path
121/// that already names a directory is left alone, and each hook there gains
122/// a block that calls `ljos git-hook`.
123///
124/// # Errors
125///
126/// The directory cannot be written, or `git config` fails.
127pub fn install() -> Result<PathBuf> {
128    install_at(&hooks_dir(), &[])
129}
130
131/// [`install`] into `dir`, with `git_env` on the child `git config`.
132///
133/// An empty `git_env` leaves that child with this process's environment.
134/// `GIT_CONFIG_GLOBAL` and `HOME` belong here when the caller is keeping
135/// the login config untouched.
136///
137/// # Errors
138///
139/// The directory cannot be written, or `git config` fails.
140pub(crate) fn install_at(dir: &Path, git_env: &[(&str, &str)]) -> Result<PathBuf> {
141    let current = global_hooks_path(git_env);
142    if let Some(path) = nonempty(current.as_deref()) {
143        if !same_dir(Path::new(path), dir) {
144            chain_user_hooks(Path::new(path))?;
145            return Ok(PathBuf::from(path));
146        }
147    }
148    write_owned_hooks(dir)?;
149    if nonempty(current.as_deref()).is_none() {
150        let _ = fs::remove_file(dir.join("previous-hooks-path"));
151        set_global_hooks_path(dir, git_env)?;
152    }
153    Ok(dir.to_path_buf())
154}
155
156/// Remove ljos from the hooks git runs.
157///
158/// A directory ljos does not own loses only the ljos block. A file that
159/// held nothing else is deleted. When ljos owns `core.hooksPath`, the
160/// previous path is restored and the stubs are removed.
161///
162/// # Errors
163///
164/// `git config` cannot restore or unset the path, or a hook file cannot
165/// be rewritten.
166pub fn uninstall() -> Result<()> {
167    let dir = hooks_dir();
168    let current = global_hooks_path(&[]);
169    match nonempty(current.as_deref()) {
170        Some(path) if same_dir(Path::new(path), &dir) => {
171            let previous = fs::read_to_string(dir.join("previous-hooks-path"))
172                .ok()
173                .map(|text| text.trim().to_string())
174                .filter(|text| !text.is_empty());
175            if let Some(prev) = previous {
176                set_global_hooks_path(Path::new(&prev), &[])?;
177            } else {
178                unset_global_hooks_path(&[])?;
179            }
180            remove_owned_hooks(&dir);
181        }
182        Some(path) => strip_user_hooks(Path::new(path))?,
183        None => remove_owned_hooks(&dir),
184    }
185    Ok(())
186}
187
188/// Where the hooks are, and what `core.hooksPath` says.
189#[must_use]
190pub fn status() -> String {
191    let dir = hooks_dir();
192    let configured_path = global_hooks_path(&[]);
193    let configured = configured_path.clone().unwrap_or_else(|| "(unset)".into());
194    let present = nonempty(configured_path.as_deref())
195        .is_some_and(|path| hooks_present(Path::new(path), &dir));
196    let stale = nonempty(configured_path.as_deref())
197        .filter(|_| present)
198        .and_then(|path| stale_note(Path::new(path), &dir));
199    let hooks = if present {
200        configured.clone()
201    } else if owned_installed(&dir) {
202        dir.display().to_string()
203    } else {
204        "not installed".into()
205    };
206    let reach = if present && stale.is_none() {
207        "ljos hooks run".to_string()
208    } else if let Some(note) = stale {
209        format!("ljos hooks run, but {note}; {HOOKS_FIX}")
210    } else {
211        format!("ljos hooks do not run; {HOOKS_FIX}")
212    };
213    format!("hooks: {hooks}\ncore.hooksPath: {configured}\n{reach}\n")
214}
215
216/// The start of the doctor's `git hooks` state when `core.hooksPath` is
217/// unset. That row is `info`: a seat that has not onboarded books no
218/// commits, and nothing else is wrong.
219pub const HOOKS_UNSET: &str = "core.hooksPath is unset";
220
221/// The one command that installs the hooks doctor checks.
222pub const HOOKS_FIX: &str = "ljos git-hooks install";
223
224/// The doctor's `git hooks` row. It passes when `ljos git-hook` runs from
225/// the directory `core.hooksPath` names: ljos's own stubs, or a block in
226/// each hook of a directory the user already set. The `pre-commit` and
227/// `pre-push` there must carry this build's block, so that a refusal
228/// fails the hook; the other hooks only book. It does not ask to take
229/// that path over. An env file that sets `LJOS_GIT_HOOKS` to another
230/// directory fails the row.
231#[must_use]
232pub fn doctor_row() -> crate::Habitat {
233    let dir = hooks_dir();
234    let (ok, state) = doctor_state(&dir, global_hooks_path(&[]).as_deref(), &config_ljos_dir());
235    crate::Habitat {
236        name: "git hooks",
237        state,
238        ok,
239    }
240}
241
242/// [`doctor_row`] for a chosen hooks directory, the global `core.hooksPath`,
243/// and the config directory that holds env files.
244#[must_use]
245pub fn doctor_state(dir: &Path, configured: Option<&str>, config_dir: &Path) -> (bool, String) {
246    let (mut ok, mut state) = hooks_state(dir, configured);
247    if let Some(drift) = env_hooks_drift(config_dir, dir) {
248        ok = false;
249        // A mismatch is a failed row. The unset-path prefix would keep it `info`.
250        state = if state.starts_with(HOOKS_UNSET) {
251            drift
252        } else {
253            format!("{state}; {drift}")
254        };
255    }
256    (ok, state)
257}
258
259/// An env file under `config_dir` that sets `LJOS_GIT_HOOKS` to a directory
260/// other than `onboard_dir`. The expression [`HOOKS_ENV_LINE`] writes is the
261/// same directory and is not a mismatch. `None` when every assignment agrees.
262#[must_use]
263pub fn env_hooks_drift(config_dir: &Path, onboard_dir: &Path) -> Option<String> {
264    let mut files = Vec::new();
265    for name in ["env.sh", "env"] {
266        let path = config_dir.join(name);
267        if path.is_file() {
268            files.push(path);
269        }
270    }
271    if let Ok(entries) = fs::read_dir(config_dir) {
272        let mut more: Vec<PathBuf> = entries
273            .flatten()
274            .map(|entry| entry.path())
275            .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("env"))
276            .collect();
277        more.sort();
278        files.extend(more);
279    }
280    let ours = onboard_dir.display().to_string();
281    for path in files {
282        let Ok(text) = fs::read_to_string(&path) else {
283            continue;
284        };
285        for line in text.lines() {
286            let Some(value) = hooks_assignment(line) else {
287                continue;
288            };
289            if assignment_agrees(&value, onboard_dir) {
290                continue;
291            }
292            return Some(format!(
293                "{} sets LJOS_GIT_HOOKS to {value}, not {ours}, the hooks `ljos onboard` installs",
294                path.display()
295            ));
296        }
297    }
298    None
299}
300
301fn hooks_assignment(line: &str) -> Option<String> {
302    let line = line.trim();
303    if line.is_empty() || line.starts_with('#') {
304        return None;
305    }
306    let rest = line.strip_prefix("export ").unwrap_or(line).trim();
307    let rest = rest.strip_prefix("LJOS_GIT_HOOKS=")?;
308    let value = unquote_assignment(rest.trim());
309    if value.is_empty() {
310        None
311    } else {
312        Some(value)
313    }
314}
315
316fn unquote_assignment(value: &str) -> String {
317    let value = value.trim();
318    if let Some(inner) = value
319        .strip_prefix('"')
320        .and_then(|text| text.strip_suffix('"'))
321    {
322        return inner.to_string();
323    }
324    if let Some(inner) = value
325        .strip_prefix('\'')
326        .and_then(|text| text.strip_suffix('\''))
327    {
328        return inner.to_string();
329    }
330    value.split_whitespace().next().unwrap_or("").to_string()
331}
332
333fn assignment_agrees(value: &str, onboard: &Path) -> bool {
334    if value.contains("${XDG_DATA_HOME:-$HOME/.local/share}/ljos/git-hooks") {
335        return true;
336    }
337    if value.contains('$') || value.contains('`') {
338        return false;
339    }
340    same_dir(Path::new(value), onboard)
341}
342
343fn config_ljos_dir() -> PathBuf {
344    let base = std::env::var_os("XDG_CONFIG_HOME")
345        .filter(|value| !value.is_empty())
346        .map(PathBuf::from)
347        .or_else(|| {
348            std::env::var_os("HOME")
349                .filter(|value| !value.is_empty())
350                .map(|home| PathBuf::from(home).join(".config"))
351        })
352        .unwrap_or_else(|| PathBuf::from(".config"));
353    base.join("ljos")
354}
355
356/// The `LJOS_GIT_HOOKS` line `ljos onboard` writes into a shell runner's
357/// env file. It names the same directory [`install`] writes.
358pub const HOOKS_ENV_LINE: &str = "\
359# Git hooks directory. `ljos git-hooks install` writes the stubs here.\n\
360export LJOS_GIT_HOOKS=\"${LJOS_GIT_HOOKS:-${XDG_DATA_HOME:-$HOME/.local/share}/ljos/git-hooks}\"";
361
362/// [`doctor_row`]'s verdict for the onboard hooks at `ours` and the global
363/// `core.hooksPath` git reports.
364#[must_use]
365pub fn hooks_state(ours: &Path, configured: Option<&str>) -> (bool, String) {
366    let Some(path) = nonempty(configured) else {
367        return (
368            false,
369            format!("{HOOKS_UNSET}, so commits are not booked; {HOOKS_FIX}"),
370        );
371    };
372    let dir = Path::new(path);
373    if hooks_reachable(dir, ours) {
374        let state = if same_dir(dir, ours) {
375            format!("core.hooksPath is {path}; commits are booked")
376        } else {
377            format!("core.hooksPath is {path}; ljos git-hook runs from those hooks")
378        };
379        return (true, state);
380    }
381    let stale = if hooks_present(dir, ours) {
382        stale_note(dir, ours)
383    } else {
384        None
385    };
386    let state = if let Some(note) = stale {
387        format!("core.hooksPath is {path}, but {note}; {HOOKS_FIX}")
388    } else if same_dir(dir, ours) {
389        format!("core.hooksPath is {path}, but some hook stubs are missing there; {HOOKS_FIX}")
390    } else {
391        format!("core.hooksPath is {path}, and ljos is not chained there; {HOOKS_FIX}")
392    };
393    (false, state)
394}
395
396fn hooks_dir() -> PathBuf {
397    if let Some(dir) = std::env::var_os("LJOS_GIT_HOOKS").filter(|value| !value.is_empty()) {
398        return PathBuf::from(dir);
399    }
400    data_home().join("ljos").join("git-hooks")
401}
402
403pub(crate) fn onboard_hooks_dir() -> PathBuf {
404    hooks_dir()
405}
406
407pub(crate) fn onboard_hook_paths(dir: &Path) -> Vec<PathBuf> {
408    HOOK_NAMES.iter().map(|name| dir.join(name)).collect()
409}
410
411pub(crate) fn configured_hooks_path(git_env: &[(&str, &str)]) -> Option<PathBuf> {
412    let configured = global_hooks_path(git_env);
413    nonempty(configured.as_deref()).map(PathBuf::from)
414}
415
416pub(crate) fn onboarding_installed() -> bool {
417    let dir = hooks_dir();
418    let configured = global_hooks_path(&[]);
419    match nonempty(configured.as_deref()) {
420        Some(path) if same_dir(Path::new(path), &dir) => owned_installed(&dir),
421        Some(path) => hooks_present(Path::new(path), &dir),
422        None => owned_installed(&dir),
423    }
424}
425
426fn data_home() -> PathBuf {
427    if let Some(dir) = std::env::var_os("XDG_DATA_HOME").filter(|value| !value.is_empty()) {
428        return PathBuf::from(dir);
429    }
430    std::env::var_os("HOME")
431        .filter(|value| !value.is_empty())
432        .map(|home| PathBuf::from(home).join(".local").join("share"))
433        .unwrap_or_else(|| PathBuf::from("."))
434}
435
436const BLOCK_BEGIN: &str = "# ljos:begin git-hook";
437const BLOCK_END: &str = "# ljos:end git-hook";
438
439fn nonempty(value: Option<&str>) -> Option<&str> {
440    value.map(str::trim).filter(|path| !path.is_empty())
441}
442
443fn ljos_executable() -> String {
444    std::env::current_exe()
445        .map(|path| path.display().to_string())
446        .unwrap_or_else(|_| "ljos".to_string())
447}
448
449/// The block appended to a hook. `post-commit` and `post-rewrite` book
450/// through `ljos git-hook`, and the `pre-commit` and `pre-push` blocks
451/// fail the hook when it refuses. The absolute path is the fallback when
452/// `ljos` is not on `PATH`; a `pre-commit` or `pre-push` with neither
453/// runs nothing. `post-rewrite` and `pre-push` copy stdin when the caller
454/// has not, so this block and a chained hook can both read the shas and
455/// refs.
456fn hook_block(name: &str) -> String {
457    hook_block_for(name, &shell_quote(&ljos_executable()))
458}
459
460/// [`hook_block`] with `bin`, already quoted, as the fallback binary.
461fn hook_block_for(name: &str, bin: &str) -> String {
462    let (otherwise, fail) = if gates_git(name) {
463        (format!("elif [ -x {bin} ]; then"), "exit 1")
464    } else {
465        ("else".to_string(), "true")
466    };
467    if matches!(name, "post-rewrite" | "pre-push") {
468        return format!(
469            "{BLOCK_BEGIN}\n\
470             if [ -z \"${{_ljos_in:-}}\" ]; then\n\
471             \t_ljos_in=$(mktemp 2>/dev/null || true)\n\
472             \tif [ -n \"$_ljos_in\" ]; then\n\
473             \t\tcat > \"$_ljos_in\" || true\n\
474             \tfi\n\
475             fi\n\
476             if command -v ljos >/dev/null 2>&1; then\n\
477             \tif [ -n \"${{_ljos_in:-}}\" ]; then\n\
478             \t\tljos git-hook {name} \"$@\" < \"$_ljos_in\" >&2 || {fail}\n\
479             \telse\n\
480             \t\tljos git-hook {name} \"$@\" >&2 || {fail}\n\
481             \tfi\n\
482             {otherwise}\n\
483             \tif [ -n \"${{_ljos_in:-}}\" ]; then\n\
484             \t\t{bin} git-hook {name} \"$@\" < \"$_ljos_in\" >&2 || {fail}\n\
485             \telse\n\
486             \t\t{bin} git-hook {name} \"$@\" >&2 || {fail}\n\
487             \tfi\n\
488             fi\n\
489             {BLOCK_END}\n"
490        );
491    }
492    format!(
493        "{BLOCK_BEGIN}\n\
494         if command -v ljos >/dev/null 2>&1; then\n\
495         \tljos git-hook {name} \"$@\" >&2 || {fail}\n\
496         {otherwise}\n\
497         \t{bin} git-hook {name} \"$@\" >&2 || {fail}\n\
498         fi\n\
499         {BLOCK_END}\n"
500    )
501}
502
503fn has_block(text: &str, name: &str) -> bool {
504    text.contains(&hook_block(name))
505}
506
507/// Whether `text` holds the block this build writes for `name`, whatever
508/// binary it falls back to. Another `ljos` on the box wrote a different
509/// path into the same block. A block from an older build, such as one
510/// whose gated call ends in `|| true`, does not count.
511fn carries_block(text: &str, name: &str) -> bool {
512    // A path cannot hold NUL, so it marks where the fallback goes.
513    let template = hook_block_for(name, "\0");
514    let mut parts = template.split('\0');
515    let (Some(head), Some(next)) = (parts.next(), parts.next()) else {
516        return text.contains(&template);
517    };
518    text.match_indices(head).any(|(at, _)| {
519        let rest = &text[at + head.len()..];
520        let line = rest.find('\n').unwrap_or(rest.len());
521        rest.match_indices(next)
522            .take_while(|(end, _)| *end <= line)
523            .any(|(end, _)| rest.starts_with(&template.replace('\0', &rest[..end])[head.len()..]))
524    })
525}
526
527fn strip_block(text: &str) -> String {
528    let Some(start) = text.find(BLOCK_BEGIN) else {
529        return text.to_string();
530    };
531    let Some(rel) = text[start..].find(BLOCK_END) else {
532        return text.to_string();
533    };
534    let mut end = start + rel + BLOCK_END.len();
535    if text[end..].starts_with('\n') {
536        end += 1;
537    }
538    let mut out = String::new();
539    out.push_str(&text[..start]);
540    out.push_str(&text[end..]);
541    out
542}
543
544fn strip_all(text: &str) -> String {
545    let mut out = text.to_string();
546    loop {
547        let next = strip_block(&out);
548        if next == out {
549            return out;
550        }
551        out = next;
552    }
553}
554
555fn merge_block(text: &str, name: &str) -> String {
556    if has_block(text, name) {
557        return text.to_string();
558    }
559    let stripped = strip_all(text);
560    let block = hook_block(name);
561    if stripped.trim().is_empty() {
562        return format!("#!/bin/sh\n{block}");
563    }
564    if stripped.ends_with('\n') {
565        format!("{stripped}{block}")
566    } else {
567        format!("{stripped}\n{block}")
568    }
569}
570
571fn block_only(text: &str) -> bool {
572    matches!(text.trim(), "" | "#!/bin/sh")
573}
574
575fn hook_calls_ljos(text: &str, name: &str) -> bool {
576    text.contains(&format!("ljos git-hook {name} "))
577}
578
579fn is_executable(path: &Path) -> bool {
580    #[cfg(unix)]
581    {
582        use std::os::unix::fs::PermissionsExt;
583        fs::metadata(path)
584            .map(|meta| meta.permissions().mode() & 0o111 != 0)
585            .unwrap_or(false)
586    }
587    #[cfg(not(unix))]
588    {
589        path.is_file()
590    }
591}
592
593fn ensure_executable(path: &Path) -> Result<()> {
594    #[cfg(unix)]
595    {
596        use std::os::unix::fs::PermissionsExt;
597        let meta = fs::metadata(path).with_context(|| format!("git-hooks: {}", path.display()))?;
598        let mode = meta.permissions().mode();
599        if mode & 0o111 == 0 {
600            fs::set_permissions(path, fs::Permissions::from_mode(mode | 0o111))?;
601        }
602    }
603    Ok(())
604}
605
606fn owned_installed(dir: &Path) -> bool {
607    HOOK_NAMES.iter().all(|name| {
608        let path = dir.join(name);
609        let Ok(text) = fs::read_to_string(&path) else {
610            return false;
611        };
612        is_executable(&path) && (text.contains("#!/bin/sh") || hook_calls_ljos(&text, name))
613    })
614}
615
616fn user_chained(dir: &Path) -> bool {
617    HOOK_NAMES.iter().all(|name| {
618        let path = dir.join(name);
619        let Ok(text) = fs::read_to_string(&path) else {
620            return false;
621        };
622        is_executable(&path) && hook_calls_ljos(&text, name)
623    })
624}
625
626fn hooks_present(configured: &Path, ours: &Path) -> bool {
627    if same_dir(configured, ours) {
628        owned_installed(configured)
629    } else {
630        user_chained(configured)
631    }
632}
633
634/// The first `pre-commit` or `pre-push` in `dir` without this build's
635/// block. Git ignores the gate's refusal when that hook drops its exit
636/// status.
637fn stale_gate(dir: &Path) -> Option<&'static str> {
638    HOOK_NAMES
639        .iter()
640        .copied()
641        .filter(|name| gates_git(name))
642        .find(|name| {
643            !fs::read_to_string(dir.join(name)).is_ok_and(|text| carries_block(&text, name))
644        })
645}
646
647/// Why the gated hooks in `configured` let a refusal through, or `None`.
648fn stale_note(configured: &Path, ours: &Path) -> Option<String> {
649    let name = stale_gate(configured)?;
650    let verb = if name == "pre-push" { "push" } else { "commit" };
651    let what = if same_dir(configured, ours) {
652        "stub"
653    } else {
654        "hook's ljos block"
655    };
656    Some(format!(
657        "the {name} {what} predates the current gate and lets a refused {verb} through"
658    ))
659}
660
661fn hooks_reachable(configured: &Path, ours: &Path) -> bool {
662    hooks_present(configured, ours) && stale_gate(configured).is_none()
663}
664
665fn write_owned_hooks(dir: &Path) -> Result<()> {
666    fs::create_dir_all(dir).with_context(|| format!("git-hooks: {}", dir.display()))?;
667    for name in HOOK_NAMES {
668        let path = dir.join(name);
669        fs::write(&path, hook_body(name))
670            .with_context(|| format!("git-hooks: {}", path.display()))?;
671        #[cfg(unix)]
672        {
673            use std::os::unix::fs::PermissionsExt;
674            fs::set_permissions(&path, fs::Permissions::from_mode(0o755))?;
675        }
676    }
677    Ok(())
678}
679
680fn chain_user_hooks(dir: &Path) -> Result<()> {
681    fs::create_dir_all(dir).with_context(|| format!("git-hooks: {}", dir.display()))?;
682    for name in HOOK_NAMES {
683        let path = dir.join(name);
684        let existing = if path.is_file() {
685            Some(fs::read(&path).with_context(|| format!("git-hooks: {}", path.display()))?)
686        } else {
687            None
688        };
689        if let Some(bytes) = &existing {
690            if bytes.contains(&0) || std::str::from_utf8(bytes).is_err() {
691                eprintln!("ljos: {} is not a shell script; left it", path.display());
692                continue;
693            }
694        }
695        let text = existing
696            .as_deref()
697            .map(|bytes| std::str::from_utf8(bytes).unwrap_or(""))
698            .unwrap_or("");
699        let next = merge_block(text, name);
700        if existing.is_none() || next != text {
701            fs::write(&path, next).with_context(|| format!("git-hooks: {}", path.display()))?;
702        }
703        ensure_executable(&path)?;
704    }
705    Ok(())
706}
707
708fn strip_user_hooks(dir: &Path) -> Result<()> {
709    if !dir.is_dir() {
710        return Ok(());
711    }
712    for name in HOOK_NAMES {
713        let path = dir.join(name);
714        let Ok(bytes) = fs::read(&path) else {
715            continue;
716        };
717        let Ok(text) = std::str::from_utf8(&bytes) else {
718            continue;
719        };
720        if !text.contains(BLOCK_BEGIN) {
721            continue;
722        }
723        let stripped = strip_all(text);
724        if block_only(&stripped) {
725            fs::remove_file(&path).with_context(|| format!("git-hooks: {}", path.display()))?;
726        } else if stripped != text {
727            fs::write(&path, stripped).with_context(|| format!("git-hooks: {}", path.display()))?;
728        }
729    }
730    Ok(())
731}
732
733fn remove_owned_hooks(dir: &Path) {
734    for name in HOOK_NAMES {
735        let _ = fs::remove_file(dir.join(name));
736    }
737    let _ = fs::remove_file(dir.join("previous-hooks-path"));
738}
739
740fn hook_body(name: &str) -> String {
741    let block = hook_block(name);
742    format!(
743        "#!/bin/sh\n\
744         # ljos books the commit, then chains to this repository's\n\
745         # .git/hooks/{name}, or to the core.hooksPath recorded in\n\
746         # previous-hooks-path. Booking cannot fail the hook; a refusal\n\
747         # from ljos in pre-commit or pre-push does. The chained hook's\n\
748         # exit status is kept. post-rewrite and pre-push keep a copy of\n\
749         # stdin for that hook.\n\
750         #\n\
751         # pre-commit install follows core.hooksPath and would replace this\n\
752         # file. Install the framework into the repository, then re-run\n\
753         # `ljos git-hooks install` if a stub was overwritten:\n\
754         #   dir=$(git rev-parse --git-dir)\n\
755         #   git config --local core.hooksPath \"$dir/hooks\"\n\
756         #   pre-commit install\n\
757         #   git config --local --unset core.hooksPath\n\
758         {block}\
759         git_dir=$(git rev-parse --git-dir 2>/dev/null || true)\n\
760         if [ -n \"$git_dir\" ]; then\n\
761         \tcase \"$git_dir\" in\n\
762         \t\t/*) ;;\n\
763         \t\t*) git_dir=$(pwd)/$git_dir ;;\n\
764         \tesac\n\
765         \trepo_hook=\"$git_dir/hooks/{name}\"\n\
766         \tif [ -x \"$repo_hook\" ] && [ \"$repo_hook\" != \"$0\" ]; then\n\
767         \t\tif [ -n \"${{_ljos_in:-}}\" ]; then\n\
768         \t\t\texec \"$repo_hook\" \"$@\" < \"$_ljos_in\"\n\
769         \t\tfi\n\
770         \t\texec \"$repo_hook\" \"$@\"\n\
771         \tfi\n\
772         fi\n\
773         here=$(dirname \"$0\")\n\
774         here=$(CDPATH= cd -- \"$here\" && pwd) || exit 0\n\
775         prev=$(sed -n '1s/[[:space:]]*$//p' \"$here/previous-hooks-path\" 2>/dev/null || true)\n\
776         if [ -n \"$prev\" ] && [ \"$prev\" != \"$here\" ]; then\n\
777         \tprev_hook=\"$prev/{name}\"\n\
778         \tif [ -x \"$prev_hook\" ] && [ \"$prev_hook\" != \"$0\" ]; then\n\
779         \t\tif [ -n \"${{_ljos_in:-}}\" ]; then\n\
780         \t\t\texec \"$prev_hook\" \"$@\" < \"$_ljos_in\"\n\
781         \t\tfi\n\
782         \t\texec \"$prev_hook\" \"$@\"\n\
783         \tfi\n\
784         fi\n\
785         exit 0\n"
786    )
787}
788
789fn shell_quote(text: &str) -> String {
790    format!("'{}'", text.replace('\'', "'\\''"))
791}
792
793/// Book the commit that just landed. Always succeeds: the commit stands.
794///
795/// # Errors
796///
797/// None. A habitat that did not answer is a line in the book log.
798pub fn run_git_commit(hook: &str) -> Result<()> {
799    if let Err(err) = book_commit(hook) {
800        crate::bookkeep::log_line(&format!("git-hooks: {err:#}"));
801    }
802    Ok(())
803}
804
805/// Run the ljos part of git hook `name`.
806///
807/// `post-commit` and `post-rewrite` book the commit. `pre-commit` and
808/// `pre-push` run the sitting gate ([`gate`]). `pre-push` also refuses
809/// a pushed commit whose message matches [`line_refused`]. Any other
810/// name succeeds and does nothing. Arguments git passed the hook are
811/// ignored.
812///
813/// # Errors
814///
815/// The sitting gate's refusal. Booking that did not land is a line in
816/// the book log.
817pub fn run_named(name: &str) -> Result<()> {
818    let name = name.trim();
819    if books_commit(name) {
820        return run_git_commit(name);
821    }
822    if name == "pre-push" {
823        let refs = std::io::read_to_string(std::io::stdin())
824            .map_err(|err| anyhow!("pre-push: cannot read the refs ({err})"))?;
825        refuse_push_messages(&refs)?;
826        return gate(name, &refs).map_err(|err| dropped(name, err));
827    }
828    if gates_git(name) {
829        return gate(name, "").map_err(|err| dropped(name, err));
830    }
831    Ok(())
832}
833
834fn dropped(name: &str, err: anyhow::Error) -> anyhow::Error {
835    match dropped_refusal(name) {
836        Some(why) => anyhow!("{err:#}\n{why}"),
837        None => err,
838    }
839}
840
841/// Refuse when a commit the push would send cannot be read, or its
842/// message has a line [`line_refused`] names. A delete sends nothing.
843/// A line that is not a ref update is refused.
844fn refuse_push_messages(refs: &str) -> Result<()> {
845    for line in refs.lines() {
846        let line = line.trim();
847        if line.is_empty() {
848            continue;
849        }
850        let fields: Vec<&str> = line.split_whitespace().collect();
851        if fields.len() != 4 {
852            bail!("pre-push: cannot read `{line}`");
853        }
854        let local_sha = fields[1];
855        let remote_sha = fields[3];
856        if !is_object_name(local_sha) || !is_object_name(remote_sha) {
857            bail!("pre-push: cannot read `{line}`");
858        }
859        if is_zero_name(local_sha) {
860            continue;
861        }
862        let listed = if is_zero_name(remote_sha) {
863            listed_commits(&[local_sha, "--not", "--remotes"])?
864        } else {
865            let range = format!("{remote_sha}..{local_sha}");
866            listed_commits(&[&range])?
867        };
868        for sha in listed {
869            let body = commit_body(&sha)?;
870            if message_refused(&body) {
871                bail!("pre-push: {sha} message is refused");
872            }
873        }
874    }
875    Ok(())
876}
877
878fn is_object_name(text: &str) -> bool {
879    matches!(text.len(), 40 | 64) && text.bytes().all(|b| b.is_ascii_hexdigit())
880}
881
882fn is_zero_name(text: &str) -> bool {
883    is_object_name(text) && text.bytes().all(|b| b == b'0')
884}
885
886fn listed_commits(args: &[&str]) -> Result<Vec<String>> {
887    let mut git_args = vec!["rev-list"];
888    git_args.extend_from_slice(args);
889    let shown = args.join(" ");
890    let out = git_command(&git_args, &[])
891        .stdin(std::process::Stdio::null())
892        .output()
893        .with_context(|| format!("pre-push: cannot read commits in {shown}"))?;
894    if !out.status.success() {
895        let err = String::from_utf8_lossy(&out.stderr);
896        bail!("pre-push: cannot read commits in {shown} ({})", err.trim());
897    }
898    Ok(String::from_utf8_lossy(&out.stdout)
899        .lines()
900        .filter(|line| !line.is_empty())
901        .map(str::to_string)
902        .collect())
903}
904
905fn commit_body(sha: &str) -> Result<String> {
906    let out = git_command(&["log", "-1", "--format=%B", sha], &[])
907        .stdin(std::process::Stdio::null())
908        .output()
909        .with_context(|| format!("pre-push: cannot read {sha}"))?;
910    if !out.status.success() {
911        bail!("pre-push: cannot read {sha}");
912    }
913    Ok(String::from_utf8_lossy(&out.stdout).into_owned())
914}
915
916fn message_refused(body: &str) -> bool {
917    phrase_refused(&body.to_ascii_lowercase()) || body.lines().any(line_refused)
918}
919
920/// A by-trailer, a made-with line, a generated line at the start of
921/// the line, or a machine account mark anywhere on the line. A Tests
922/// or note paragraph is none of those. A generated phrase
923/// mid-sentence is not a generated line.
924fn line_refused(line: &str) -> bool {
925    let lower = line.to_ascii_lowercase();
926    account_anywhere(&lower) || phrase_refused(&lower) || anchored_mark(&lower)
927}
928
929/// `cursor agent` and `cursoragent` match anywhere on the line.
930fn account_anywhere(lower: &str) -> bool {
931    lower.contains("cursor agent") || lower.contains("cursoragent")
932}
933
934/// `generated with` and `made with cursor` match only at the start of
935/// the line, after optional blanks.
936fn phrase_refused(lower: &str) -> bool {
937    let rest = lower.trim_start_matches([' ', '\t']);
938    rest.starts_with("generated with") || rest.starts_with("made with cursor")
939}
940
941fn anchored_mark(lower: &str) -> bool {
942    let rest = lower.trim_start_matches([' ', '\t']);
943    made_with_mark(rest) || by_mark(rest) || cursor_assign(rest)
944}
945
946fn made_with_mark(rest: &str) -> bool {
947    let Some(after) = rest.strip_prefix("made-with") else {
948        return false;
949    };
950    after.trim_start_matches([' ', '\t']).starts_with(':')
951}
952
953fn by_mark(rest: &str) -> bool {
954    let Some(colon) = rest.find(':') else {
955        return false;
956    };
957    let token = rest[..colon].trim_end_matches([' ', '\t']);
958    let Some(name) = token.strip_suffix("-by") else {
959        return false;
960    };
961    !name.is_empty() && name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
962}
963
964fn cursor_assign(rest: &str) -> bool {
965    let Some(after_cursor) = rest.strip_prefix("cursor") else {
966        return false;
967    };
968    let after_sep = after_cursor
969        .strip_prefix('_')
970        .or_else(|| after_cursor.strip_prefix('-'))
971        .unwrap_or(after_cursor);
972    let Some(after_agent) = after_sep.strip_prefix("agent") else {
973        return false;
974    };
975    let mut end = 0;
976    for c in after_agent.chars() {
977        if c.is_ascii_alphanumeric() || c == '_' {
978            end += c.len_utf8();
979        } else {
980            break;
981        }
982    }
983    let after = after_agent[end..].trim_start_matches([' ', '\t']);
984    after.starts_with('=') || after.starts_with(':')
985}
986
987/// Why git goes on after the gate refuses, when the hook file it is
988/// running carries an ljos block older than this build's. Such a block
989/// can end in `|| true`, and this process cannot change the exit status
990/// the hook returns. A hook that calls `ljos git-hook` with no block is
991/// the user's own and is not judged.
992fn dropped_refusal(name: &str) -> Option<String> {
993    let raw = git_line(&["rev-parse", "--git-path", &format!("hooks/{name}")])?;
994    let path = std::env::current_dir().ok()?.join(raw);
995    let text = fs::read_to_string(&path).ok()?;
996    if !text.contains(BLOCK_BEGIN) || carries_block(&text, name) {
997        return None;
998    }
999    let verb = if name == "pre-push" { "push" } else { "commit" };
1000    Some(format!(
1001        "sitting-gate: {} predates this gate and drops its refusal, so git will {verb} anyway; {HOOKS_FIX} replaces it",
1002        path.display()
1003    ))
1004}
1005
1006/// The guard's sitting gate for a commit or push it did not see, such as
1007/// one a script runs: under a runner, `pre-commit` needs a live sitting,
1008/// and `pre-push` also needs that ticket to clear each ref it sends onto
1009/// a branch [`crate::gates::consensus_push`] names. A store or a tracker
1010/// checkout, which ljos writes itself, is not gated, and nor is the
1011/// person. When a runner variable is the only cause, and the call would
1012/// be refused, the person is asked for their passphrase on the
1013/// controlling terminal; a right answer lets this one call through.
1014fn gate(name: &str, refs: &str) -> Result<()> {
1015    let Some(cause) = crate::runner_cause() else {
1016        return Ok(());
1017    };
1018    let refused = match gate_runner(name, refs) {
1019        Ok(()) => return Ok(()),
1020        Err(refused) => refused,
1021    };
1022    if cause == crate::RunnerCause::Hard {
1023        return Err(refused);
1024    }
1025    let verb = if name == "pre-push" { "push" } else { "commit" };
1026    let marks = crate::runner_marks();
1027    match crate::person_key::ask_person(&marks, &format!("git {verb}")) {
1028        Ok(()) => Ok(()),
1029        Err(why) => {
1030            let detail = format!("{refused:#}");
1031            let detail = detail.strip_prefix("sitting-gate: ").unwrap_or(&detail);
1032            let text = crate::sitting_gate::for_marks(
1033                detail,
1034                &marks,
1035                verb,
1036                crate::person_key::is_set(),
1037                &why,
1038            );
1039            Err(anyhow!("sitting-gate: {text}"))
1040        }
1041    }
1042}
1043
1044fn gate_runner(name: &str, refs: &str) -> Result<()> {
1045    let repo = git_line(&["rev-parse", "--show-toplevel"])
1046        .map(PathBuf::from)
1047        .or_else(git_dir);
1048    if repo
1049        .as_deref()
1050        .is_some_and(|repo| is_store(repo) || holds_tracker(repo))
1051    {
1052        return Ok(());
1053    }
1054    // A commit is not this check. The push below is.
1055    if name != "pre-push" {
1056        return Ok(());
1057    }
1058    // In a hook the process tree names the hook's own shell. The seat a
1059    // booking would take is the one the sitting was taken under. With a
1060    // conversation id in the environment only that conversation's holder
1061    // counts, or for a subagent its parent's records and process when
1062    // that process carries a conversation id. With no id it falls back
1063    // to the process match.
1064    let seat = crate::git_hook_seat();
1065    let _actor = seat.as_deref().map(crate::HookActor::enter);
1066    let _push = crate::PushCheck::enter();
1067    let issue = match crate::sitting_gate::live_sitting() {
1068        Ok(issue) => issue,
1069        Err(_) => {
1070            let named = crate::sitting_gate::open_or_name_sitting().unwrap_or_default();
1071            if crate::push_slow() {
1072                if named.is_empty() {
1073                    eprintln!("{}", crate::push_slow_line());
1074                } else {
1075                    eprintln!("{}; sitting {named}", crate::push_slow_line());
1076                }
1077            }
1078            named
1079        }
1080    };
1081    let cfg = match crate::gates::consensus_push(repo.as_deref()) {
1082        Ok(cfg) => cfg,
1083        Err(why) => {
1084            return Err(anyhow!(
1085                "sitting-gate: {}",
1086                crate::sitting_gate::unread_settings(&why)
1087            ))
1088        }
1089    };
1090    let dest = protected_ref(refs, &cfg.branches);
1091    let Some(dest) = dest else {
1092        return Ok(());
1093    };
1094    // The person's own allow for this push, as a user rule over the line
1095    // `git push REMOTE BRANCH` or `git push REMOTE LOCAL:BRANCH`, clears
1096    // the sitting check: their yes is what the check exists to get.
1097    if push_lines(refs, &cfg.branches)
1098        .iter()
1099        .any(|line| crate::user_rules::allows(line).is_some())
1100    {
1101        return Ok(());
1102    }
1103    // The tracker read already missed its bound. Stop before the
1104    // consensus checks. Empty notes would fail as a missing family.
1105    if crate::push_tracker_slow() {
1106        return Err(anyhow!("sitting-gate: {}", crate::push_tracker_slow_line()));
1107    }
1108    if issue.is_empty() {
1109        let why = "the sitting has no settled consensus".to_string();
1110        let wait = crate::sitting_gate::seats_wait(&dest, &cfg.branches, &issue, &why);
1111        return Err(anyhow!("sitting-gate: {wait}"));
1112    }
1113    if let Err(reason) = crate::protected_push_refusal(&issue, &crate::seat_name(), cfg.quorum) {
1114        let why = format!("the sitting has no settled consensus: {reason}");
1115        let wait = crate::sitting_gate::seats_wait(&dest, &cfg.branches, &issue, &why);
1116        return Err(anyhow!("sitting-gate: {wait}"));
1117    }
1118    Ok(())
1119}
1120
1121/// The command lines a pre-push onto a protected branch stands for, one
1122/// per ref that lands on one: `git push REMOTE BRANCH` and
1123/// `git push REMOTE LOCAL:BRANCH`, with the remote git named the hook
1124/// (its first argument, `origin` when none), so a person's allow rule
1125/// written over either form covers it.
1126fn push_lines(refs: &str, branches: &[String]) -> Vec<String> {
1127    let remote = std::env::args()
1128        .nth(3)
1129        .filter(|a| !a.is_empty() && !a.starts_with('-'))
1130        .unwrap_or_else(|| "origin".to_string());
1131    let mut out = Vec::new();
1132    for line in refs.lines().filter(|l| !l.trim().is_empty()) {
1133        let fields: Vec<&str> = line.split_whitespace().collect();
1134        let [local, _, remote_ref, _] = fields[..] else {
1135            continue;
1136        };
1137        let Some(branch) = remote_ref.strip_prefix("refs/heads/") else {
1138            continue;
1139        };
1140        if !branches.iter().any(|b| b.eq_ignore_ascii_case(branch)) {
1141            continue;
1142        }
1143        let short = local.strip_prefix("refs/heads/").unwrap_or(local);
1144        out.push(format!("git push {remote} {branch}"));
1145        out.push(format!("git push {remote} {short}:{branch}"));
1146        out.push(format!("git push {remote} {local}:{remote_ref}"));
1147    }
1148    out
1149}
1150
1151/// What a push whose pre-push stdin is `refs` sends onto a branch in
1152/// `branches`, as `sends BRANCH`, or `None` when no ref lands on one.
1153/// Git runs the hook with no lines when nothing is to be sent. A line
1154/// not in git's four-field form cannot be read, so it is taken to land
1155/// on one.
1156fn protected_ref(refs: &str, branches: &[String]) -> Option<String> {
1157    for line in refs.lines().filter(|l| !l.trim().is_empty()) {
1158        let fields: Vec<&str> = line.split_whitespace().collect();
1159        let [_, _, remote, _] = fields[..] else {
1160            return Some(format!("lists `{line}`, which is not a ref git would send"));
1161        };
1162        let Some(branch) = remote.strip_prefix("refs/heads/") else {
1163            continue;
1164        };
1165        if let Some(b) = branches.iter().find(|b| b.eq_ignore_ascii_case(branch)) {
1166            return Some(format!("sends `{b}`"));
1167        }
1168    }
1169    None
1170}
1171
1172/// Whether `repo` is a tracker checkout: it holds `vissue.toml`, or a
1173/// `Software/<project>/issues.org` as `vissue` reads a tracker root. A
1174/// bare `Software/` directory is any repository's.
1175fn holds_tracker(repo: &Path) -> bool {
1176    if repo.join("vissue.toml").is_file() {
1177        return true;
1178    }
1179    std::fs::read_dir(repo.join("Software")).is_ok_and(|entries| {
1180        entries
1181            .flatten()
1182            .any(|entry| entry.path().join("issues.org").is_file())
1183    })
1184}
1185
1186fn book_commit(hook: &str) -> Result<()> {
1187    if std::env::var_os("LJOS_GIT_HOOK").is_some() || booking_off() {
1188        return Ok(());
1189    }
1190    if foreign_session() {
1191        return Ok(());
1192    }
1193    if hook != "post-rewrite" && mid_rewrite() {
1194        return Ok(());
1195    }
1196    let Some(repo) = git_line(&["rev-parse", "--show-toplevel"]) else {
1197        return Ok(());
1198    };
1199    let repo_path = PathBuf::from(&repo);
1200    // A commit in the tracker's checkout is a tracker write: never booked,
1201    // but its seat is checked as any other write's is.
1202    let tracker = tracker_repo(&repo_path);
1203    if !tracker && (crate::bookkeep::path_is_temp(Some(&repo_path)) || skip_repo(&repo_path)) {
1204        return Ok(());
1205    }
1206    let Some(head) = git_line(&["rev-parse", "HEAD"]) else {
1207        return Ok(());
1208    };
1209    // The process tree is not a seat. Without LJOS_SEAT, or an agy or
1210    // opencode conversation, the commit stays unbooked; a subagent is told.
1211    let Some(seat) = crate::git_hook_seat() else {
1212        if tracker {
1213            refuse_tracker_commit(
1214                &head,
1215                "no seat is named; export LJOS_SEAT=<your seat> to write the tracker",
1216            );
1217        } else if crate::unseated_subagent() {
1218            // Stdout: the installed hook shows that line and drops stderr.
1219            println!(
1220                "ljos: commit {} not booked: this subagent has no seat; export LJOS_SEAT=<your seat> (the parent's) to book it",
1221                &head[..head.len().min(12)]
1222            );
1223        }
1224        return Ok(());
1225    };
1226    let _actor = crate::HookActor::enter(&seat);
1227    // Every path below writes the tracker: a repointed citation, a named
1228    // issue, an open sitting, a new claim.
1229    if let Err(e) = crate::write_seat("commit") {
1230        if tracker {
1231            refuse_tracker_commit(&head, &format!("{e:#}"));
1232        } else {
1233            println!(
1234                "ljos: commit {} not booked: {e:#}",
1235                &head[..head.len().min(12)]
1236            );
1237        }
1238        return Ok(());
1239    }
1240    if tracker {
1241        return Ok(());
1242    }
1243    let _children = HookChildren::enter();
1244    // An amend or a rebase lists old and new shas. Move the citation
1245    // before the "already booked" return: post-commit may have cited the
1246    // new HEAD already, and the old sha would otherwise stay on the issue.
1247    if already_booked(&repo, &head) {
1248        return Ok(());
1249    }
1250    let subject = git_line(&["log", "-1", "--format=%s"]).unwrap_or_default();
1251    println!(
1252        "{}",
1253        crate::bookkeep::note_after_commit(&repo, &head, &subject)
1254    );
1255    remember_booked(&repo, &head);
1256    Ok(())
1257}
1258
1259fn booking_off() -> bool {
1260    matches!(
1261        std::env::var("LJOS_OFF").ok().as_deref(),
1262        Some("1" | "true" | "TRUE" | "yes" | "YES" | "on" | "ON")
1263    ) || crate::bookkeep::guard_suspended()
1264}
1265
1266fn foreign_session() -> bool {
1267    // OpenCode's shell sets OPENCODE_SESSION_ID and never calls the plugin's
1268    // tool.execute.after, so the git hook is the booker. Claude and Codex
1269    // book from their own hooks.
1270    ["CLAUDECODE", "CODEX_THREAD_ID", "CODEX_SESSION_ID"]
1271        .iter()
1272        .any(|key| std::env::var_os(key).is_some_and(|value| !value.is_empty()))
1273}
1274
1275fn mid_rewrite() -> bool {
1276    let Some(dir) = git_dir() else {
1277        return false;
1278    };
1279    dir.join("rebase-merge").is_dir()
1280        || dir.join("rebase-apply").is_dir()
1281        || dir.join("MERGE_HEAD").is_file()
1282}
1283
1284/// Whether `repo` is the tracker's checkout, `VISSUE_ROOT` or `ISSUE_ROOT`.
1285fn tracker_repo(repo: &Path) -> bool {
1286    ["VISSUE_ROOT", "ISSUE_ROOT"].iter().any(|key| {
1287        std::env::var_os(key)
1288            .filter(|value| !value.is_empty())
1289            .map(PathBuf::from)
1290            .is_some_and(|root| useful_root(&root) && covered(repo, &root))
1291    })
1292}
1293
1294/// A tracker commit no seat that may write the tracker made. It has landed
1295/// and a post-commit hook cannot take it back, so the refusal is said on
1296/// stdout, which the installed hook shows, and kept in the book log.
1297fn refuse_tracker_commit(head: &str, why: &str) {
1298    let line = format!(
1299        "ljos: tracker commit {} was not made by a seat that may write the tracker: {why}",
1300        &head[..head.len().min(12)]
1301    );
1302    println!("{line}");
1303    crate::bookkeep::log_line(&format!("git-hooks: {line}"));
1304}
1305
1306fn skip_repo(repo: &Path) -> bool {
1307    if is_store(repo) {
1308        return true;
1309    }
1310    skip_roots().iter().any(|root| covered(repo, root))
1311}
1312
1313/// Paths a commit under which is not booked. The defaults always apply.
1314/// `[git_hooks] skip` in `ljos.toml` adds box-specific paths, and a
1315/// reinstall rewrites the hook scripts without touching that file.
1316fn skip_roots() -> Vec<PathBuf> {
1317    let mut roots = vec![
1318        PathBuf::from("/tmp"),
1319        PathBuf::from("/var/tmp"),
1320        PathBuf::from("/dev/shm"),
1321    ];
1322    for key in ["VISSUE_ROOT", "ISSUE_ROOT", "CLAIMDAG_DIR"] {
1323        if let Some(path) = std::env::var_os(key).filter(|value| !value.is_empty()) {
1324            roots.push(PathBuf::from(path));
1325        }
1326    }
1327    roots.extend(store_dirs());
1328    for path in config_files() {
1329        roots.extend(configured_skips(&path));
1330    }
1331    roots.retain(|path| useful_root(path));
1332    roots
1333}
1334
1335fn useful_root(path: &Path) -> bool {
1336    let text = path.to_string_lossy();
1337    let text = text.trim();
1338    !text.is_empty() && text != "/" && text != "."
1339}
1340
1341fn config_files() -> Vec<PathBuf> {
1342    let mut out = Vec::new();
1343    let base = std::env::var_os("XDG_CONFIG_HOME")
1344        .filter(|value| !value.is_empty())
1345        .map(PathBuf::from)
1346        .or_else(|| {
1347            std::env::var_os("HOME")
1348                .filter(|value| !value.is_empty())
1349                .map(|home| PathBuf::from(home).join(".config"))
1350        });
1351    if let Some(base) = base {
1352        out.push(base.join("ljos").join("ljos.toml"));
1353        if let Some(seat) = std::env::var("LJOS_SEAT")
1354            .ok()
1355            .map(|name| name.trim().to_string())
1356            .filter(|name| !name.is_empty() && !name.contains('/') && !name.contains(".."))
1357        {
1358            out.push(base.join("ljos").join("seats").join(seat).join("ljos.toml"));
1359        }
1360    }
1361    if let Some(path) = std::env::var_os("LJOS_CONFIG").filter(|value| !value.is_empty()) {
1362        out.push(PathBuf::from(path));
1363    }
1364    if let Ok(cwd) = std::env::current_dir() {
1365        out.push(cwd.join("ljos.toml"));
1366    }
1367    out
1368}
1369
1370fn configured_skips(path: &Path) -> Vec<PathBuf> {
1371    let Ok(text) = fs::read_to_string(path) else {
1372        return Vec::new();
1373    };
1374    let Ok(value) = toml::from_str::<toml::Value>(&text) else {
1375        return Vec::new();
1376    };
1377    let Some(list) = value
1378        .get("git_hooks")
1379        .and_then(|table| table.get("skip"))
1380        .and_then(toml::Value::as_array)
1381    else {
1382        return Vec::new();
1383    };
1384    list.iter()
1385        .filter_map(toml::Value::as_str)
1386        .map(str::trim)
1387        .filter(|text| !text.is_empty())
1388        .map(PathBuf::from)
1389        .collect()
1390}
1391
1392fn covered(repo: &Path, root: &Path) -> bool {
1393    let repo = canonical_or(repo);
1394    let root = canonical_or(root);
1395    repo == root || repo.starts_with(&root)
1396}
1397
1398fn canonical_or(path: &Path) -> PathBuf {
1399    fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
1400}
1401
1402/// Whether `repo` is a deed or claim store ljos writes itself, read from
1403/// the store settings; a path that merely contains `claimdag` is not one.
1404fn is_store(repo: &Path) -> bool {
1405    store_dirs()
1406        .iter()
1407        .any(|root| same_dir(repo, root) || repo_starts(repo, root))
1408}
1409
1410fn store_dirs() -> Vec<PathBuf> {
1411    let mut out = Vec::new();
1412    if let Ok(url) = std::env::var("DEEDAR_URL") {
1413        if let Some(path) = url.strip_prefix("file://") {
1414            out.push(PathBuf::from(path));
1415        }
1416    }
1417    if let Some(data) = std::env::var_os("XDG_DATA_HOME").filter(|value| !value.is_empty()) {
1418        out.push(PathBuf::from(data).join("deedar").join("store"));
1419    } else if let Some(home) = std::env::var_os("HOME").filter(|value| !value.is_empty()) {
1420        out.push(
1421            PathBuf::from(home)
1422                .join(".local")
1423                .join("share")
1424                .join("deedar")
1425                .join("store"),
1426        );
1427    }
1428    out
1429}
1430
1431fn same_dir(left: &Path, right: &Path) -> bool {
1432    let left = fs::canonicalize(left).unwrap_or_else(|_| left.to_path_buf());
1433    let right = fs::canonicalize(right).unwrap_or_else(|_| right.to_path_buf());
1434    left == right
1435}
1436
1437fn repo_starts(repo: &Path, root: &Path) -> bool {
1438    if !root.exists() {
1439        return false;
1440    }
1441    let repo = fs::canonicalize(repo).unwrap_or_else(|_| repo.to_path_buf());
1442    let root = fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
1443    repo.starts_with(root)
1444}
1445
1446fn already_booked(repo: &str, head: &str) -> bool {
1447    let key = format!("{repo}\t{head}");
1448    fs::read_to_string(booked_path())
1449        .map(|text| text.lines().any(|line| line == key))
1450        .unwrap_or(false)
1451}
1452
1453fn remember_booked(repo: &str, head: &str) {
1454    let path = booked_path();
1455    if let Some(dir) = path.parent() {
1456        let _ = fs::create_dir_all(dir);
1457    }
1458    let mut lines: Vec<String> = fs::read_to_string(&path)
1459        .unwrap_or_default()
1460        .lines()
1461        .map(str::to_string)
1462        .filter(|line| !line.starts_with(&format!("{repo}\t")))
1463        .collect();
1464    lines.push(format!("{repo}\t{head}"));
1465    if lines.len() > 200 {
1466        lines.drain(0..lines.len() - 200);
1467    }
1468    let _ = fs::write(path, lines.join("\n") + "\n");
1469}
1470
1471fn booked_path() -> PathBuf {
1472    crate::runtime_dir().join("git-hooks-booked")
1473}
1474
1475fn git_dir() -> Option<PathBuf> {
1476    let raw = git_line(&["rev-parse", "--git-dir"])?;
1477    let path = PathBuf::from(&raw);
1478    if path.is_absolute() {
1479        Some(path)
1480    } else {
1481        std::env::current_dir().ok().map(|cwd| cwd.join(path))
1482    }
1483}
1484
1485/// `GIT_*` values a hook exports. A later `git -C` follows `GIT_DIR` and
1486/// ignores `-C`, so a tracker commit would land in the checkout that just
1487/// finished.
1488const HOOK_GIT_ENV: &[&str] = &[
1489    "GIT_DIR",
1490    "GIT_WORK_TREE",
1491    "GIT_INDEX_FILE",
1492    "GIT_PREFIX",
1493    "GIT_OBJECT_DIRECTORY",
1494    "GIT_COMMON_DIR",
1495    "GIT_NAMESPACE",
1496];
1497
1498thread_local! {
1499    static HOOK_CHILDREN: Cell<bool> = const { Cell::new(false) };
1500}
1501
1502/// While this guard is alive, child processes started through
1503/// [`hook_child_env`] carry `LJOS_GIT_HOOK=1` and do not inherit the hook's
1504/// `GIT_DIR`. Dropping it restores the previous flag. The process
1505/// environment is not changed.
1506#[must_use]
1507struct HookChildren {
1508    previous: bool,
1509}
1510
1511impl HookChildren {
1512    fn enter() -> Self {
1513        let previous = HOOK_CHILDREN.with(Cell::get);
1514        HOOK_CHILDREN.with(|flag| flag.set(true));
1515        Self { previous }
1516    }
1517}
1518
1519impl Drop for HookChildren {
1520    fn drop(&mut self) {
1521        HOOK_CHILDREN.with(|flag| flag.set(self.previous));
1522    }
1523}
1524
1525/// Put the re-entry guard on a child `git` or `ljos`, and drop the hook's
1526/// git directory from that child. No effect unless a git hook is booking
1527/// a commit on this thread.
1528pub(crate) fn hook_child_env(cmd: &mut Command) {
1529    if !HOOK_CHILDREN.with(Cell::get) {
1530        return;
1531    }
1532    cmd.env("LJOS_GIT_HOOK", "1");
1533    for key in HOOK_GIT_ENV {
1534        cmd.env_remove(key);
1535    }
1536}
1537
1538#[cfg(test)]
1539pub(crate) fn with_hook_children_for_test<T>(run: impl FnOnce() -> T) -> T {
1540    let _guard = HookChildren::enter();
1541    run()
1542}
1543
1544fn git_command(args: &[&str], git_env: &[(&str, &str)]) -> Command {
1545    let mut cmd = crate::git_cmd();
1546    cmd.args(args);
1547    for (key, value) in git_env {
1548        cmd.env(key, value);
1549    }
1550    hook_child_env(&mut cmd);
1551    cmd
1552}
1553
1554fn git_line(args: &[&str]) -> Option<String> {
1555    let out = git_command(args, &[]).output().ok()?;
1556    if !out.status.success() {
1557        return None;
1558    }
1559    let text = String::from_utf8(out.stdout).ok()?;
1560    let text = text.trim();
1561    if text.is_empty() {
1562        None
1563    } else {
1564        Some(text.to_string())
1565    }
1566}
1567
1568fn global_hooks_path(git_env: &[(&str, &str)]) -> Option<String> {
1569    let out = git_command(&["config", "--global", "--get", "core.hooksPath"], git_env)
1570        .output()
1571        .ok()?;
1572    if !out.status.success() {
1573        return None;
1574    }
1575    let text = String::from_utf8(out.stdout).ok()?;
1576    let text = text.trim();
1577    if text.is_empty() {
1578        None
1579    } else {
1580        Some(text.to_string())
1581    }
1582}
1583
1584fn set_global_hooks_path(path: &Path, git_env: &[(&str, &str)]) -> Result<()> {
1585    let value = path.display().to_string();
1586    let status = git_command(&["config", "--global", "core.hooksPath", &value], git_env)
1587        .status()
1588        .context("git-hooks: git config")?;
1589    if !status.success() {
1590        bail!("git-hooks: could not set core.hooksPath");
1591    }
1592    Ok(())
1593}
1594
1595fn unset_global_hooks_path(git_env: &[(&str, &str)]) -> Result<()> {
1596    let status = git_command(
1597        &["config", "--global", "--unset", "core.hooksPath"],
1598        git_env,
1599    )
1600    .status()
1601    .context("git-hooks: git config")?;
1602    if !status.success() {
1603        bail!("git-hooks: could not unset core.hooksPath");
1604    }
1605    Ok(())
1606}
1607
1608#[cfg(test)]
1609mod tests {
1610    use std::ffi::OsStr;
1611
1612    use super::*;
1613
1614    fn mapped<'a>(cmd: &'a Command, key: &str) -> Option<Option<&'a OsStr>> {
1615        cmd.get_envs()
1616            .find(|(name, _)| *name == OsStr::new(key))
1617            .map(|(_, value)| value)
1618    }
1619
1620    #[test]
1621    fn the_doctor_names_a_hooks_path_that_is_not_the_onboard_one() {
1622        let dir = tempfile::tempdir().unwrap();
1623        let ours = dir.path().join("ljos").join("git-hooks");
1624        let other = dir.path().join("user-hooks");
1625        std::fs::create_dir_all(&other).unwrap();
1626        let prior = "#!/bin/sh\nprintf 'user-hook\\n'\n";
1627        std::fs::write(other.join("post-commit"), prior).unwrap();
1628        let ours_s = ours.display().to_string();
1629        let other_s = other.display().to_string();
1630
1631        let (ok, state) = super::hooks_state(&ours, None);
1632        assert!(!ok);
1633        assert!(state.starts_with(super::HOOKS_UNSET), "{state}");
1634        assert_eq!(state.matches(super::HOOKS_FIX).count(), 1, "{state}");
1635
1636        let (ok, state) = super::hooks_state(&ours, Some(&other_s));
1637        assert!(!ok, "{state}");
1638        assert!(state.contains(&other_s), "{state}");
1639        assert!(!state.contains(&ours_s), "{state}");
1640        assert_eq!(state.matches(super::HOOKS_FIX).count(), 1, "{state}");
1641
1642        super::chain_user_hooks(&other).unwrap();
1643        let (ok, state) = super::hooks_state(&ours, Some(&other_s));
1644        assert!(ok, "{state}");
1645        assert!(state.contains("ljos git-hook runs"), "{state}");
1646        let hooked = std::fs::read_to_string(other.join("post-commit")).unwrap();
1647        assert!(hooked.starts_with(prior), "{hooked}");
1648        assert_eq!(
1649            hooked.matches("# ljos:begin git-hook").count(),
1650            1,
1651            "{hooked}"
1652        );
1653        super::chain_user_hooks(&other).unwrap();
1654        assert_eq!(
1655            std::fs::read_to_string(other.join("post-commit")).unwrap(),
1656            hooked
1657        );
1658        assert!(other.join("pre-push").is_file());
1659
1660        super::strip_user_hooks(&other).unwrap();
1661        assert_eq!(
1662            std::fs::read_to_string(other.join("post-commit")).unwrap(),
1663            prior
1664        );
1665        assert!(!other.join("pre-push").exists());
1666
1667        let config = dir.path().join("config");
1668        std::fs::create_dir_all(&config).unwrap();
1669        std::fs::write(
1670            config.join("env.sh"),
1671            format!("export LJOS_GIT_HOOKS={}\n", other.display()),
1672        )
1673        .unwrap();
1674        let drift = super::env_hooks_drift(&config, &ours).expect("a drifted env file");
1675        assert!(drift.contains(&other_s), "{drift}");
1676        assert!(drift.contains(&ours_s), "{drift}");
1677        let (ok, state) = super::doctor_state(&ours, None, &config);
1678        assert!(!ok, "{state}");
1679        assert!(
1680            !state.starts_with(super::HOOKS_UNSET),
1681            "a mismatch stayed info: {state}"
1682        );
1683        assert!(state.contains(&other_s), "{state}");
1684        assert!(state.contains(&ours_s), "{state}");
1685
1686        std::fs::write(
1687            config.join("env.sh"),
1688            format!("{}\n", super::HOOKS_ENV_LINE),
1689        )
1690        .unwrap();
1691        assert!(super::env_hooks_drift(&config, &ours).is_none());
1692        let body = super::hook_body("post-rewrite");
1693        assert!(body.contains("mktemp"), "{body}");
1694        assert!(body.contains("git-hook post-rewrite"), "{body}");
1695        assert!(body.contains("< \"$_ljos_in\""), "{body}");
1696        let commit = super::hook_body("post-commit");
1697        assert!(!commit.contains("mktemp"), "{commit}");
1698        assert!(commit.contains("git-hook post-commit"), "{commit}");
1699
1700        super::write_owned_hooks(&ours).unwrap();
1701        let (ok, state) = super::hooks_state(&ours, Some(&ours_s));
1702        assert!(ok, "{state}");
1703        std::fs::remove_file(ours.join("pre-push")).unwrap();
1704        let (ok, state) = super::hooks_state(&ours, Some(&ours_s));
1705        assert!(!ok, "{state}");
1706        assert!(state.contains(super::HOOKS_FIX), "{state}");
1707    }
1708
1709    /// The `pre-push` block a build before 64e489f5 wrote. `|| true`
1710    /// drops the gate's refusal, so git pushes anyway.
1711    const OLD_PRE_PUSH: &str = "# ljos:begin git-hook\n\
1712        if command -v ljos >/dev/null 2>&1; then\n\
1713        \tljos git-hook pre-push \"$@\" >&2 || true\n\
1714        else\n\
1715        \t'/usr/local/bin/ljos' git-hook pre-push \"$@\" >&2 || true\n\
1716        fi\n\
1717        # ljos:end git-hook\n";
1718
1719    #[test]
1720    fn the_doctor_fails_a_pre_push_stub_that_drops_a_refusal() {
1721        let dir = tempfile::tempdir().unwrap();
1722        let ours = dir.path().join("git-hooks");
1723        let ours_s = ours.display().to_string();
1724        let config = dir.path().join("config");
1725        super::write_owned_hooks(&ours).unwrap();
1726        let (ok, state) = super::doctor_state(&ours, Some(&ours_s), &config);
1727        assert!(ok, "{state}");
1728
1729        let fresh = super::hook_body("pre-push");
1730        let stale = fresh.replace(&super::hook_block("pre-push"), OLD_PRE_PUSH);
1731        assert_ne!(stale, fresh);
1732        std::fs::write(ours.join("pre-push"), &stale).unwrap();
1733        let (ok, state) = super::doctor_state(&ours, Some(&ours_s), &config);
1734        assert!(!ok, "a stale pre-push stub passed: {state}");
1735        assert!(state.contains("pre-push stub predates"), "{state}");
1736        assert_eq!(state.matches(super::HOOKS_FIX).count(), 1, "{state}");
1737
1738        let moved = fresh.replace(
1739            &super::shell_quote(&super::ljos_executable()),
1740            "'/opt/other/ljos'",
1741        );
1742        assert_ne!(moved, fresh);
1743        std::fs::write(ours.join("pre-push"), moved).unwrap();
1744        let (ok, state) = super::doctor_state(&ours, Some(&ours_s), &config);
1745        assert!(ok, "another fallback path failed the row: {state}");
1746
1747        let user = dir.path().join("user-hooks");
1748        super::chain_user_hooks(&user).unwrap();
1749        let user_s = user.display().to_string();
1750        let prior = format!("#!/bin/sh\necho mine\n{OLD_PRE_PUSH}");
1751        std::fs::write(user.join("pre-push"), &prior).unwrap();
1752        let (ok, state) = super::hooks_state(&ours, Some(&user_s));
1753        assert!(!ok, "a stale user block passed: {state}");
1754        assert!(state.contains("pre-push hook's ljos block"), "{state}");
1755        super::chain_user_hooks(&user).unwrap();
1756        let (ok, state) = super::hooks_state(&ours, Some(&user_s));
1757        assert!(ok, "install left the stale block: {state}");
1758        let hooked = std::fs::read_to_string(user.join("pre-push")).unwrap();
1759        assert!(hooked.starts_with("#!/bin/sh\necho mine\n"), "{hooked}");
1760        assert_eq!(hooked.matches("# ljos:begin git-hook").count(), 1);
1761        assert!(!hooked.contains("\"$@\" >&2 || true"), "{hooked}");
1762    }
1763
1764    #[test]
1765    fn a_hook_that_does_not_book_succeeds() {
1766        super::run_named("post-checkout").unwrap();
1767    }
1768
1769    #[test]
1770    fn a_pre_push_ref_list_names_the_protected_branch_it_sends() {
1771        let branches = vec!["develop".to_string(), "main".to_string()];
1772        let line = |dst: &str| format!("refs/heads/x 1111 {dst} 0000\n");
1773        assert_eq!(protected_ref("", &branches), None);
1774        assert_eq!(protected_ref(&line("refs/heads/topic"), &branches), None);
1775        assert_eq!(protected_ref(&line("refs/tags/main"), &branches), None);
1776        let both = line("refs/heads/topic") + &line("refs/heads/Main");
1777        assert_eq!(
1778            protected_ref(&both, &branches).as_deref(),
1779            Some("sends `main`")
1780        );
1781        let odd = protected_ref("refs/heads/x main\n", &branches).unwrap();
1782        assert!(odd.contains("not a ref git would send"), "{odd}");
1783    }
1784
1785    #[test]
1786    fn hook_children_carry_the_guard_without_touching_the_process() {
1787        let before_hook = std::env::var_os("LJOS_GIT_HOOK");
1788        let before_dir = std::env::var_os("GIT_DIR");
1789        let mut plain = Command::new("true");
1790        plain.env("GIT_DIR", "keep");
1791        hook_child_env(&mut plain);
1792        assert_eq!(mapped(&plain, "GIT_DIR"), Some(Some(OsStr::new("keep"))));
1793        assert_eq!(mapped(&plain, "LJOS_GIT_HOOK"), None);
1794
1795        let _guard = HookChildren::enter();
1796        let mut cmd = Command::new("true");
1797        cmd.env("GIT_DIR", "from-the-hook");
1798        cmd.env("PATH", "/usr/bin");
1799        hook_child_env(&mut cmd);
1800        assert_eq!(mapped(&cmd, "LJOS_GIT_HOOK"), Some(Some(OsStr::new("1"))));
1801        for key in HOOK_GIT_ENV {
1802            assert_eq!(mapped(&cmd, key), Some(None), "{key}");
1803        }
1804        assert_eq!(mapped(&cmd, "PATH"), Some(Some(OsStr::new("/usr/bin"))));
1805        assert_eq!(std::env::var_os("LJOS_GIT_HOOK"), before_hook);
1806        assert_eq!(std::env::var_os("GIT_DIR"), before_dir);
1807    }
1808
1809    #[test]
1810    fn a_tests_paragraph_is_kept_and_a_by_line_is_refused() {
1811        assert!(!super::line_refused("Tests: cargo test"));
1812        assert!(!super::message_refused("note\n\nTests: cargo test\n"));
1813        assert!(super::line_refused("Co-authored-by: Ada <ada@example.com>"));
1814        assert!(super::line_refused("Signed-off-by: Ada <ada@example.com>"));
1815        assert!(super::message_refused(
1816            "note\nCo-authored-by: Ada <ada@example.com>\n"
1817        ));
1818        assert!(super::line_refused("Made-with: a tool"));
1819        assert!(super::line_refused("Generated with a tool"));
1820        assert!(super::line_refused("made with cursor"));
1821        assert!(!super::line_refused(
1822            "See Co-authored-by: Ada <ada@example.com>"
1823        ));
1824        assert!(super::line_refused("Cursor Agent wrote this"));
1825        assert!(super::line_refused("cursoragent"));
1826        assert!(super::line_refused("see cursoragent in the note"));
1827        assert!(super::line_refused("see a cursor agent in the note"));
1828        assert!(!super::line_refused("the plot was made with cursor tools"));
1829        assert!(super::line_refused("CURSOR_AGENT=1"));
1830        assert!(super::line_refused("  CURSOR_AGENT=1"));
1831        assert!(super::line_refused("CURSOR_AGENT: 1"));
1832        assert!(super::line_refused("Co-authored-by : x"));
1833        assert!(super::line_refused("  Co-authored-by: x"));
1834        assert!(super::line_refused("Made-with : x"));
1835        assert!(super::line_refused(" Made-with: x"));
1836        assert!(!super::line_refused("Note: kept"));
1837        assert!(!super::line_refused("The Signed-off-by line is gone."));
1838        assert!(!super::line_refused("see CURSOR_AGENT in the prose"));
1839        assert!(super::line_refused("\tCo-authored-by: A"));
1840        assert!(super::line_refused("Co-authored-by\t: A"));
1841        assert!(super::line_refused("\tCURSOR_AGENT=1"));
1842        assert!(super::line_refused("CURSOR_AGENT\t=1"));
1843        assert!(!super::line_refused(
1844            "The figures are generated with matplotlib."
1845        ));
1846        assert!(!super::line_refused("Tests: ran cargo test"));
1847        assert!(!super::line_refused("Ask the author by mail."));
1848        assert!(!super::line_refused("Signed-off-by"));
1849    }
1850}