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.
18//!
19//! `pre-commit install` follows `core.hooksPath` and would replace stubs
20//! ljos owns. Install the framework into the repository, then let the stub
21//! chain to it:
22//!
23//! ```text
24//! dir=$(git rev-parse --git-dir)
25//! git config --local core.hooksPath "$dir/hooks"
26//! pre-commit install
27//! git config --local --unset core.hooksPath
28//! ```
29//!
30//! A commit is booked only when a seat resolves from `LJOS_SEAT`, then
31//! `seats.toml` through `CURSOR_CONVERSATION_ID`, then `grokbot-<id8>`,
32//! then an agy or opencode conversation id. The process tree is not a seat.
33//! The holder is `SEAT@TAG`, the same name `ljos claim` uses. Paths in
34//! `[git_hooks] skip` of `ljos.toml`, plus `/tmp`, `/var/tmp`, `/dev/shm`,
35//! the tracker, `CLAIMDAG_DIR` and the deedar store, are not booked.
36//! An amend or a rebase moves the citation onto the new sha.
37
38use std::cell::Cell;
39use std::fs;
40use std::path::{Path, PathBuf};
41use std::process::Command;
42
43use anyhow::{bail, Context, Result};
44
45/// Every hook git looks up by name. A global `core.hooksPath` hides the
46/// repository's copy of each of these, so the stub has to exist for each.
47const HOOK_NAMES: &[&str] = &[
48    "applypatch-msg",
49    "pre-applypatch",
50    "post-applypatch",
51    "pre-commit",
52    "pre-merge-commit",
53    "prepare-commit-msg",
54    "commit-msg",
55    "post-commit",
56    "pre-rebase",
57    "post-checkout",
58    "post-merge",
59    "pre-push",
60    "pre-receive",
61    "update",
62    "proc-receive",
63    "post-receive",
64    "post-update",
65    "reference-transaction",
66    "push-to-checkout",
67    "pre-auto-gc",
68    "post-rewrite",
69    "sendemail-validate",
70    "fsmonitor-watchman",
71    "p4-changelist",
72    "p4-prepare-changelist",
73    "p4-post-changelist",
74    "p4-pre-submit",
75    "post-index-change",
76];
77
78fn books_commit(name: &str) -> bool {
79    matches!(name, "post-commit" | "post-rewrite")
80}
81
82/// `install`, `uninstall`, or `status`.
83///
84/// # Errors
85///
86/// `action` is not one of those, or git cannot record `core.hooksPath`.
87pub fn run(action: &str) -> Result<()> {
88    match action {
89        "install" => {
90            let dir = install()?;
91            println!("installed {}", dir.display());
92            Ok(())
93        }
94        "uninstall" => {
95            uninstall()?;
96            println!("uninstalled");
97            Ok(())
98        }
99        "status" => {
100            print!("{}", status());
101            Ok(())
102        }
103        other => bail!("git-hooks: {other} is not install, uninstall, or status"),
104    }
105}
106
107/// Install the hooks `ljos doctor` checks.
108///
109/// An unset `core.hooksPath` is pointed at ljos's hook directory. A path
110/// that already names a directory is left alone, and each hook there gains
111/// a block that calls `ljos git-hook`.
112///
113/// # Errors
114///
115/// The directory cannot be written, or `git config` fails.
116pub fn install() -> Result<PathBuf> {
117    install_at(&hooks_dir(), &[])
118}
119
120/// [`install`] into `dir`, with `git_env` on the child `git config`.
121///
122/// An empty `git_env` leaves that child with this process's environment.
123/// `GIT_CONFIG_GLOBAL` and `HOME` belong here when the caller is keeping
124/// the login config untouched.
125///
126/// # Errors
127///
128/// The directory cannot be written, or `git config` fails.
129pub(crate) fn install_at(dir: &Path, git_env: &[(&str, &str)]) -> Result<PathBuf> {
130    let current = global_hooks_path(git_env);
131    if let Some(path) = nonempty(current.as_deref()) {
132        if !same_dir(Path::new(path), dir) {
133            chain_user_hooks(Path::new(path))?;
134            return Ok(PathBuf::from(path));
135        }
136    }
137    write_owned_hooks(dir)?;
138    if nonempty(current.as_deref()).is_none() {
139        let _ = fs::remove_file(dir.join("previous-hooks-path"));
140        set_global_hooks_path(dir, git_env)?;
141    }
142    Ok(dir.to_path_buf())
143}
144
145/// Remove ljos from the hooks git runs.
146///
147/// A directory ljos does not own loses only the ljos block. A file that
148/// held nothing else is deleted. When ljos owns `core.hooksPath`, the
149/// previous path is restored and the stubs are removed.
150///
151/// # Errors
152///
153/// `git config` cannot restore or unset the path, or a hook file cannot
154/// be rewritten.
155pub fn uninstall() -> Result<()> {
156    let dir = hooks_dir();
157    let current = global_hooks_path(&[]);
158    match nonempty(current.as_deref()) {
159        Some(path) if same_dir(Path::new(path), &dir) => {
160            let previous = fs::read_to_string(dir.join("previous-hooks-path"))
161                .ok()
162                .map(|text| text.trim().to_string())
163                .filter(|text| !text.is_empty());
164            if let Some(prev) = previous {
165                set_global_hooks_path(Path::new(&prev), &[])?;
166            } else {
167                unset_global_hooks_path(&[])?;
168            }
169            remove_owned_hooks(&dir);
170        }
171        Some(path) => strip_user_hooks(Path::new(path))?,
172        None => remove_owned_hooks(&dir),
173    }
174    Ok(())
175}
176
177/// Where the hooks are, and what `core.hooksPath` says.
178#[must_use]
179pub fn status() -> String {
180    let dir = hooks_dir();
181    let configured_path = global_hooks_path(&[]);
182    let configured = configured_path.clone().unwrap_or_else(|| "(unset)".into());
183    let reachable = nonempty(configured_path.as_deref())
184        .is_some_and(|path| hooks_reachable(Path::new(path), &dir));
185    let hooks = if reachable {
186        configured.clone()
187    } else if owned_installed(&dir) {
188        dir.display().to_string()
189    } else {
190        "not installed".into()
191    };
192    let reach = if reachable {
193        "ljos hooks run".to_string()
194    } else {
195        format!("ljos hooks do not run; {HOOKS_FIX}")
196    };
197    format!("hooks: {hooks}\ncore.hooksPath: {configured}\n{reach}\n")
198}
199
200/// The start of the doctor's `git hooks` state when `core.hooksPath` is
201/// unset. That row is `info`: a seat that has not onboarded books no
202/// commits, and nothing else is wrong.
203pub const HOOKS_UNSET: &str = "core.hooksPath is unset";
204
205/// The one command that installs the hooks doctor checks.
206pub const HOOKS_FIX: &str = "ljos git-hooks install";
207
208/// The doctor's `git hooks` row. It passes when `ljos git-hook` runs from
209/// the directory `core.hooksPath` names: ljos's own stubs, or a block in
210/// each hook of a directory the user already set. It does not ask to take
211/// that path over. An env file that sets `LJOS_GIT_HOOKS` to another
212/// directory fails the row.
213#[must_use]
214pub fn doctor_row() -> crate::Habitat {
215    let dir = hooks_dir();
216    let (ok, state) = doctor_state(&dir, global_hooks_path(&[]).as_deref(), &config_ljos_dir());
217    crate::Habitat {
218        name: "git hooks",
219        state,
220        ok,
221    }
222}
223
224/// [`doctor_row`] for a chosen hooks directory, the global `core.hooksPath`,
225/// and the config directory that holds env files.
226#[must_use]
227pub fn doctor_state(dir: &Path, configured: Option<&str>, config_dir: &Path) -> (bool, String) {
228    let (mut ok, mut state) = hooks_state(dir, configured);
229    if let Some(drift) = env_hooks_drift(config_dir, dir) {
230        ok = false;
231        // A mismatch is a failed row. The unset-path prefix would keep it `info`.
232        state = if state.starts_with(HOOKS_UNSET) {
233            drift
234        } else {
235            format!("{state}; {drift}")
236        };
237    }
238    (ok, state)
239}
240
241/// An env file under `config_dir` that sets `LJOS_GIT_HOOKS` to a directory
242/// other than `onboard_dir`. The expression [`HOOKS_ENV_LINE`] writes is the
243/// same directory and is not a mismatch. `None` when every assignment agrees.
244#[must_use]
245pub fn env_hooks_drift(config_dir: &Path, onboard_dir: &Path) -> Option<String> {
246    let mut files = Vec::new();
247    for name in ["env.sh", "env"] {
248        let path = config_dir.join(name);
249        if path.is_file() {
250            files.push(path);
251        }
252    }
253    if let Ok(entries) = fs::read_dir(config_dir) {
254        let mut more: Vec<PathBuf> = entries
255            .flatten()
256            .map(|entry| entry.path())
257            .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("env"))
258            .collect();
259        more.sort();
260        files.extend(more);
261    }
262    let ours = onboard_dir.display().to_string();
263    for path in files {
264        let Ok(text) = fs::read_to_string(&path) else {
265            continue;
266        };
267        for line in text.lines() {
268            let Some(value) = hooks_assignment(line) else {
269                continue;
270            };
271            if assignment_agrees(&value, onboard_dir) {
272                continue;
273            }
274            return Some(format!(
275                "{} sets LJOS_GIT_HOOKS to {value}, not {ours}, the hooks `ljos onboard` installs",
276                path.display()
277            ));
278        }
279    }
280    None
281}
282
283fn hooks_assignment(line: &str) -> Option<String> {
284    let line = line.trim();
285    if line.is_empty() || line.starts_with('#') {
286        return None;
287    }
288    let rest = line.strip_prefix("export ").unwrap_or(line).trim();
289    let rest = rest.strip_prefix("LJOS_GIT_HOOKS=")?;
290    let value = unquote_assignment(rest.trim());
291    if value.is_empty() {
292        None
293    } else {
294        Some(value)
295    }
296}
297
298fn unquote_assignment(value: &str) -> String {
299    let value = value.trim();
300    if let Some(inner) = value
301        .strip_prefix('"')
302        .and_then(|text| text.strip_suffix('"'))
303    {
304        return inner.to_string();
305    }
306    if let Some(inner) = value
307        .strip_prefix('\'')
308        .and_then(|text| text.strip_suffix('\''))
309    {
310        return inner.to_string();
311    }
312    value.split_whitespace().next().unwrap_or("").to_string()
313}
314
315fn assignment_agrees(value: &str, onboard: &Path) -> bool {
316    if value.contains("${XDG_DATA_HOME:-$HOME/.local/share}/ljos/git-hooks") {
317        return true;
318    }
319    if value.contains('$') || value.contains('`') {
320        return false;
321    }
322    same_dir(Path::new(value), onboard)
323}
324
325fn config_ljos_dir() -> PathBuf {
326    let base = std::env::var_os("XDG_CONFIG_HOME")
327        .filter(|value| !value.is_empty())
328        .map(PathBuf::from)
329        .or_else(|| {
330            std::env::var_os("HOME")
331                .filter(|value| !value.is_empty())
332                .map(|home| PathBuf::from(home).join(".config"))
333        })
334        .unwrap_or_else(|| PathBuf::from(".config"));
335    base.join("ljos")
336}
337
338/// The `LJOS_GIT_HOOKS` line `ljos onboard` writes into a shell runner's
339/// env file. It names the same directory [`install`] writes.
340pub const HOOKS_ENV_LINE: &str = "\
341# Git hooks directory. `ljos git-hooks install` writes the stubs here.\n\
342export LJOS_GIT_HOOKS=\"${LJOS_GIT_HOOKS:-${XDG_DATA_HOME:-$HOME/.local/share}/ljos/git-hooks}\"";
343
344/// [`doctor_row`]'s verdict for the onboard hooks at `ours` and the global
345/// `core.hooksPath` git reports.
346#[must_use]
347pub fn hooks_state(ours: &Path, configured: Option<&str>) -> (bool, String) {
348    let Some(path) = nonempty(configured) else {
349        return (
350            false,
351            format!("{HOOKS_UNSET}, so commits are not booked; {HOOKS_FIX}"),
352        );
353    };
354    let dir = Path::new(path);
355    if hooks_reachable(dir, ours) {
356        let state = if same_dir(dir, ours) {
357            format!("core.hooksPath is {path}; commits are booked")
358        } else {
359            format!("core.hooksPath is {path}; ljos git-hook runs from those hooks")
360        };
361        return (true, state);
362    }
363    let state = if same_dir(dir, ours) {
364        format!("core.hooksPath is {path}, but some hook stubs are missing there; {HOOKS_FIX}")
365    } else {
366        format!("core.hooksPath is {path}, and ljos is not chained there; {HOOKS_FIX}")
367    };
368    (false, state)
369}
370
371fn hooks_dir() -> PathBuf {
372    if let Some(dir) = std::env::var_os("LJOS_GIT_HOOKS").filter(|value| !value.is_empty()) {
373        return PathBuf::from(dir);
374    }
375    data_home().join("ljos").join("git-hooks")
376}
377
378fn data_home() -> PathBuf {
379    if let Some(dir) = std::env::var_os("XDG_DATA_HOME").filter(|value| !value.is_empty()) {
380        return PathBuf::from(dir);
381    }
382    std::env::var_os("HOME")
383        .filter(|value| !value.is_empty())
384        .map(|home| PathBuf::from(home).join(".local").join("share"))
385        .unwrap_or_else(|| PathBuf::from("."))
386}
387
388const BLOCK_BEGIN: &str = "# ljos:begin git-hook";
389const BLOCK_END: &str = "# ljos:end git-hook";
390
391fn nonempty(value: Option<&str>) -> Option<&str> {
392    value.map(str::trim).filter(|path| !path.is_empty())
393}
394
395fn ljos_executable() -> String {
396    std::env::current_exe()
397        .map(|path| path.display().to_string())
398        .unwrap_or_else(|_| "ljos".to_string())
399}
400
401/// The block appended to a hook. `post-commit` and `post-rewrite` book
402/// through `ljos git-hook`. The absolute path is the fallback when `ljos`
403/// is not on `PATH`. `post-rewrite` copies stdin when the caller has not,
404/// so this block and a chained hook can both read the old and new shas.
405fn hook_block(name: &str) -> String {
406    let bin = shell_quote(&ljos_executable());
407    if name == "post-rewrite" {
408        return format!(
409            "{BLOCK_BEGIN}\n\
410             if [ -z \"${{_ljos_in:-}}\" ]; then\n\
411             \t_ljos_in=$(mktemp 2>/dev/null || true)\n\
412             \tif [ -n \"$_ljos_in\" ]; then\n\
413             \t\tcat > \"$_ljos_in\" || true\n\
414             \tfi\n\
415             fi\n\
416             if command -v ljos >/dev/null 2>&1; then\n\
417             \tif [ -n \"${{_ljos_in:-}}\" ]; then\n\
418             \t\tljos git-hook {name} \"$@\" < \"$_ljos_in\" >&2 || true\n\
419             \telse\n\
420             \t\tljos git-hook {name} \"$@\" >&2 || true\n\
421             \tfi\n\
422             else\n\
423             \tif [ -n \"${{_ljos_in:-}}\" ]; then\n\
424             \t\t{bin} git-hook {name} \"$@\" < \"$_ljos_in\" >&2 || true\n\
425             \telse\n\
426             \t\t{bin} git-hook {name} \"$@\" >&2 || true\n\
427             \tfi\n\
428             fi\n\
429             {BLOCK_END}\n"
430        );
431    }
432    format!(
433        "{BLOCK_BEGIN}\n\
434         if command -v ljos >/dev/null 2>&1; then\n\
435         \tljos git-hook {name} \"$@\" >&2 || true\n\
436         else\n\
437         \t{bin} git-hook {name} \"$@\" >&2 || true\n\
438         fi\n\
439         {BLOCK_END}\n"
440    )
441}
442
443fn has_block(text: &str, name: &str) -> bool {
444    text.contains(&hook_block(name))
445}
446
447fn strip_block(text: &str) -> String {
448    let Some(start) = text.find(BLOCK_BEGIN) else {
449        return text.to_string();
450    };
451    let Some(rel) = text[start..].find(BLOCK_END) else {
452        return text.to_string();
453    };
454    let mut end = start + rel + BLOCK_END.len();
455    if text[end..].starts_with('\n') {
456        end += 1;
457    }
458    let mut out = String::new();
459    out.push_str(&text[..start]);
460    out.push_str(&text[end..]);
461    out
462}
463
464fn strip_all(text: &str) -> String {
465    let mut out = text.to_string();
466    loop {
467        let next = strip_block(&out);
468        if next == out {
469            return out;
470        }
471        out = next;
472    }
473}
474
475fn merge_block(text: &str, name: &str) -> String {
476    if has_block(text, name) {
477        return text.to_string();
478    }
479    let stripped = strip_all(text);
480    let block = hook_block(name);
481    if stripped.trim().is_empty() {
482        return format!("#!/bin/sh\n{block}");
483    }
484    if stripped.ends_with('\n') {
485        format!("{stripped}{block}")
486    } else {
487        format!("{stripped}\n{block}")
488    }
489}
490
491fn block_only(text: &str) -> bool {
492    matches!(text.trim(), "" | "#!/bin/sh")
493}
494
495fn hook_calls_ljos(text: &str, name: &str) -> bool {
496    text.contains(&format!("ljos git-hook {name} "))
497}
498
499fn is_executable(path: &Path) -> bool {
500    #[cfg(unix)]
501    {
502        use std::os::unix::fs::PermissionsExt;
503        fs::metadata(path)
504            .map(|meta| meta.permissions().mode() & 0o111 != 0)
505            .unwrap_or(false)
506    }
507    #[cfg(not(unix))]
508    {
509        path.is_file()
510    }
511}
512
513fn ensure_executable(path: &Path) -> Result<()> {
514    #[cfg(unix)]
515    {
516        use std::os::unix::fs::PermissionsExt;
517        let meta = fs::metadata(path).with_context(|| format!("git-hooks: {}", path.display()))?;
518        let mode = meta.permissions().mode();
519        if mode & 0o111 == 0 {
520            fs::set_permissions(path, fs::Permissions::from_mode(mode | 0o111))?;
521        }
522    }
523    Ok(())
524}
525
526fn owned_installed(dir: &Path) -> bool {
527    HOOK_NAMES.iter().all(|name| {
528        let path = dir.join(name);
529        let Ok(text) = fs::read_to_string(&path) else {
530            return false;
531        };
532        is_executable(&path) && (text.contains("#!/bin/sh") || hook_calls_ljos(&text, name))
533    })
534}
535
536fn user_chained(dir: &Path) -> bool {
537    HOOK_NAMES.iter().all(|name| {
538        let path = dir.join(name);
539        let Ok(text) = fs::read_to_string(&path) else {
540            return false;
541        };
542        is_executable(&path) && hook_calls_ljos(&text, name)
543    })
544}
545
546fn hooks_reachable(configured: &Path, ours: &Path) -> bool {
547    if same_dir(configured, ours) {
548        owned_installed(configured)
549    } else {
550        user_chained(configured)
551    }
552}
553
554fn write_owned_hooks(dir: &Path) -> Result<()> {
555    fs::create_dir_all(dir).with_context(|| format!("git-hooks: {}", dir.display()))?;
556    for name in HOOK_NAMES {
557        let path = dir.join(name);
558        fs::write(&path, hook_body(name))
559            .with_context(|| format!("git-hooks: {}", path.display()))?;
560        #[cfg(unix)]
561        {
562            use std::os::unix::fs::PermissionsExt;
563            fs::set_permissions(&path, fs::Permissions::from_mode(0o755))?;
564        }
565    }
566    Ok(())
567}
568
569fn chain_user_hooks(dir: &Path) -> Result<()> {
570    fs::create_dir_all(dir).with_context(|| format!("git-hooks: {}", dir.display()))?;
571    for name in HOOK_NAMES {
572        let path = dir.join(name);
573        let existing = if path.is_file() {
574            Some(fs::read(&path).with_context(|| format!("git-hooks: {}", path.display()))?)
575        } else {
576            None
577        };
578        if let Some(bytes) = &existing {
579            if bytes.contains(&0) || std::str::from_utf8(bytes).is_err() {
580                eprintln!("ljos: {} is not a shell script; left it", path.display());
581                continue;
582            }
583        }
584        let text = existing
585            .as_deref()
586            .map(|bytes| std::str::from_utf8(bytes).unwrap_or(""))
587            .unwrap_or("");
588        let next = merge_block(text, name);
589        if existing.is_none() || next != text {
590            fs::write(&path, next).with_context(|| format!("git-hooks: {}", path.display()))?;
591        }
592        ensure_executable(&path)?;
593    }
594    Ok(())
595}
596
597fn strip_user_hooks(dir: &Path) -> Result<()> {
598    if !dir.is_dir() {
599        return Ok(());
600    }
601    for name in HOOK_NAMES {
602        let path = dir.join(name);
603        let Ok(bytes) = fs::read(&path) else {
604            continue;
605        };
606        let Ok(text) = std::str::from_utf8(&bytes) else {
607            continue;
608        };
609        if !text.contains(BLOCK_BEGIN) {
610            continue;
611        }
612        let stripped = strip_all(text);
613        if block_only(&stripped) {
614            fs::remove_file(&path).with_context(|| format!("git-hooks: {}", path.display()))?;
615        } else if stripped != text {
616            fs::write(&path, stripped).with_context(|| format!("git-hooks: {}", path.display()))?;
617        }
618    }
619    Ok(())
620}
621
622fn remove_owned_hooks(dir: &Path) {
623    for name in HOOK_NAMES {
624        let _ = fs::remove_file(dir.join(name));
625    }
626    let _ = fs::remove_file(dir.join("previous-hooks-path"));
627}
628
629fn hook_body(name: &str) -> String {
630    let block = hook_block(name);
631    format!(
632        "#!/bin/sh\n\
633         # ljos books the commit, then chains to this repository's\n\
634         # .git/hooks/{name}, or to the core.hooksPath recorded in\n\
635         # previous-hooks-path. Booking cannot fail the hook. The chained\n\
636         # hook's exit status is kept. post-rewrite keeps a copy of stdin\n\
637         # for that hook.\n\
638         #\n\
639         # pre-commit install follows core.hooksPath and would replace this\n\
640         # file. Install the framework into the repository, then re-run\n\
641         # `ljos git-hooks install` if a stub was overwritten:\n\
642         #   dir=$(git rev-parse --git-dir)\n\
643         #   git config --local core.hooksPath \"$dir/hooks\"\n\
644         #   pre-commit install\n\
645         #   git config --local --unset core.hooksPath\n\
646         {block}\
647         git_dir=$(git rev-parse --git-dir 2>/dev/null || true)\n\
648         if [ -n \"$git_dir\" ]; then\n\
649         \tcase \"$git_dir\" in\n\
650         \t\t/*) ;;\n\
651         \t\t*) git_dir=$(pwd)/$git_dir ;;\n\
652         \tesac\n\
653         \trepo_hook=\"$git_dir/hooks/{name}\"\n\
654         \tif [ -x \"$repo_hook\" ] && [ \"$repo_hook\" != \"$0\" ]; then\n\
655         \t\tif [ -n \"${{_ljos_in:-}}\" ]; then\n\
656         \t\t\texec \"$repo_hook\" \"$@\" < \"$_ljos_in\"\n\
657         \t\tfi\n\
658         \t\texec \"$repo_hook\" \"$@\"\n\
659         \tfi\n\
660         fi\n\
661         here=$(dirname \"$0\")\n\
662         here=$(CDPATH= cd -- \"$here\" && pwd) || exit 0\n\
663         prev=$(sed -n '1s/[[:space:]]*$//p' \"$here/previous-hooks-path\" 2>/dev/null || true)\n\
664         if [ -n \"$prev\" ] && [ \"$prev\" != \"$here\" ]; then\n\
665         \tprev_hook=\"$prev/{name}\"\n\
666         \tif [ -x \"$prev_hook\" ] && [ \"$prev_hook\" != \"$0\" ]; then\n\
667         \t\tif [ -n \"${{_ljos_in:-}}\" ]; then\n\
668         \t\t\texec \"$prev_hook\" \"$@\" < \"$_ljos_in\"\n\
669         \t\tfi\n\
670         \t\texec \"$prev_hook\" \"$@\"\n\
671         \tfi\n\
672         fi\n\
673         exit 0\n"
674    )
675}
676
677fn shell_quote(text: &str) -> String {
678    format!("'{}'", text.replace('\'', "'\\''"))
679}
680
681/// Book the commit that just landed. Always succeeds: the commit stands.
682///
683/// # Errors
684///
685/// None. A habitat that did not answer is a line in the book log.
686pub fn run_git_commit(hook: &str) -> Result<()> {
687    if let Err(err) = book_commit(hook) {
688        crate::bookkeep::log_line(&format!("git-hooks: {err:#}"));
689    }
690    Ok(())
691}
692
693/// Run the ljos part of git hook `name`.
694///
695/// `post-commit` and `post-rewrite` book the commit. Any other name
696/// succeeds and does nothing. Arguments git passed the hook are ignored.
697///
698/// # Errors
699///
700/// None. Booking that did not land is a line in the book log.
701pub fn run_named(name: &str) -> Result<()> {
702    let name = name.trim();
703    if books_commit(name) {
704        run_git_commit(name)
705    } else {
706        Ok(())
707    }
708}
709
710fn book_commit(hook: &str) -> Result<()> {
711    if std::env::var_os("LJOS_GIT_HOOK").is_some() || booking_off() {
712        return Ok(());
713    }
714    if foreign_session() {
715        return Ok(());
716    }
717    if hook != "post-rewrite" && mid_rewrite() {
718        return Ok(());
719    }
720    let Some(repo) = git_line(&["rev-parse", "--show-toplevel"]) else {
721        return Ok(());
722    };
723    let repo_path = PathBuf::from(&repo);
724    if crate::bookkeep::path_is_temp(Some(&repo_path)) || skip_repo(&repo_path) {
725        return Ok(());
726    }
727    let Some(head) = git_line(&["rev-parse", "HEAD"]) else {
728        return Ok(());
729    };
730    // The process tree is not a seat. Without LJOS_SEAT, seats.toml, the
731    // grokbot-<id8> fallback, or an agy or opencode conversation, the
732    // commit stays unbooked; a subagent is told.
733    let Some(seat) = crate::git_hook_seat() else {
734        if crate::unseated_subagent() {
735            // Stdout: the installed hook shows that line and drops stderr.
736            println!(
737                "ljos: commit {} not booked: this subagent has no seat; export LJOS_SEAT=<your seat> (the parent's) to book it",
738                &head[..head.len().min(12)]
739            );
740        }
741        return Ok(());
742    };
743    let _actor = crate::HookActor::enter(&seat);
744    let _children = HookChildren::enter();
745    // An amend or a rebase lists old and new shas. Move the citation
746    // before the "already booked" return: post-commit may have cited the
747    // new HEAD already, and the old sha would otherwise stay on the issue.
748    if hook == "post-rewrite" {
749        crate::bookkeep::repoint_commits(&repo, &rewrite_pairs());
750    }
751    if already_booked(&repo, &head) {
752        return Ok(());
753    }
754    let subject = git_line(&["log", "-1", "--format=%s"]).unwrap_or_default();
755    // A hook exports GIT_DIR. Later `git -C` on the tracker must not follow
756    // it, and a nested commit must not re-enter this hook. Both ride on the
757    // child git and ljos processes; this process keeps the environment it
758    // was started with, so the rev-parse above still sees the checkout.
759    // Finish idle rows in other checkouts before this commit is cited into
760    // an open sitting. That path returns without observe, which is the
761    // other place the idle sweep runs.
762    let session = crate::harness_book_session().unwrap_or_else(crate::bookkeep::seat_session);
763    crate::bookkeep::sweep_idle_sittings(&session, &repo);
764    // A subject or body that names an open issue (`ljos-policyd-32fx: ...`,
765    // `Fixes ljos-xsor`) books the commit on that issue. A new issue is
766    // filed only when the message names none the tracker holds open.
767    let message = git_line(&["log", "-1", "--format=%B"]).unwrap_or_default();
768    if let Some(issue) = crate::bookkeep::named_open_issue(&message) {
769        crate::bookkeep::book_named_commit(&repo, &head, &issue);
770        remember_booked(&repo, &head);
771        if let Some(line) = crate::bookkeep::commit_recall_line(&session, &repo, &subject) {
772            println!("{line}");
773        }
774        return Ok(());
775    }
776    // The commit already belongs to an open sitting in this checkout.
777    // Booking it on the seat's own session would open a second book.
778    if crate::bookkeep::cite_into_open_sitting(&repo) {
779        remember_booked(&repo, &head);
780        return Ok(());
781    }
782    let command = if subject.is_empty() {
783        "git commit".to_string()
784    } else {
785        format!("git commit -m {}", shell_quote(&subject))
786    };
787    let raw = serde_json::json!({
788        "hook_event_name": "PostToolUse",
789        "session_id": session,
790        "cwd": repo,
791        "tool_name": "Bash",
792        "tool_input": {"command": command},
793        "tool_response": {"exit_code": 0},
794        "prompt": subject,
795        "ljos_git_hook": true,
796    })
797    .to_string();
798    let call = crate::HookCall {
799        event: "PostToolUse".into(),
800        cue: command,
801        session: Some(session.clone()),
802        shape: crate::HookShape::Asks,
803    };
804    let _ = crate::bookkeep::observe(&raw, &call);
805    remember_booked(&repo, &head);
806    if let Some(line) = crate::bookkeep::commit_recall_line(&session, &repo, &subject) {
807        println!("{line}");
808    }
809    Ok(())
810}
811
812fn booking_off() -> bool {
813    matches!(
814        std::env::var("LJOS_OFF").ok().as_deref(),
815        Some("1" | "true" | "TRUE" | "yes" | "YES" | "on" | "ON")
816    ) || crate::bookkeep::guard_suspended()
817}
818
819fn foreign_session() -> bool {
820    // OpenCode's shell sets OPENCODE_SESSION_ID and never calls the plugin's
821    // tool.execute.after, so the git hook is the booker. Claude and Codex
822    // book from their own hooks.
823    ["CLAUDECODE", "CODEX_THREAD_ID", "CODEX_SESSION_ID"]
824        .iter()
825        .any(|key| std::env::var_os(key).is_some_and(|value| !value.is_empty()))
826}
827
828fn rewrite_pairs() -> Vec<(String, String)> {
829    use std::io::{IsTerminal, Read};
830    if std::io::stdin().is_terminal() {
831        return Vec::new();
832    }
833    let mut raw = String::new();
834    if std::io::stdin().read_to_string(&mut raw).is_err() {
835        return Vec::new();
836    }
837    raw.lines()
838        .filter_map(|line| {
839            let mut parts = line.split_whitespace();
840            let old = parts.next()?;
841            let new = parts.next()?;
842            if commit_sha(old) && commit_sha(new) {
843                Some((old.to_string(), new.to_string()))
844            } else {
845                None
846            }
847        })
848        .collect()
849}
850
851fn commit_sha(text: &str) -> bool {
852    (text.len() == 40 || text.len() == 64) && text.chars().all(|c| c.is_ascii_hexdigit())
853}
854
855fn mid_rewrite() -> bool {
856    let Some(dir) = git_dir() else {
857        return false;
858    };
859    dir.join("rebase-merge").is_dir()
860        || dir.join("rebase-apply").is_dir()
861        || dir.join("MERGE_HEAD").is_file()
862}
863
864fn skip_repo(repo: &Path) -> bool {
865    if is_store(repo) {
866        return true;
867    }
868    skip_roots().iter().any(|root| covered(repo, root))
869}
870
871/// Paths a commit under which is not booked. The defaults always apply.
872/// `[git_hooks] skip` in `ljos.toml` adds box-specific paths, and a
873/// reinstall rewrites the hook scripts without touching that file.
874fn skip_roots() -> Vec<PathBuf> {
875    let mut roots = vec![
876        PathBuf::from("/tmp"),
877        PathBuf::from("/var/tmp"),
878        PathBuf::from("/dev/shm"),
879    ];
880    for key in ["VISSUE_ROOT", "ISSUE_ROOT", "CLAIMDAG_DIR"] {
881        if let Some(path) = std::env::var_os(key).filter(|value| !value.is_empty()) {
882            roots.push(PathBuf::from(path));
883        }
884    }
885    roots.extend(store_dirs());
886    for path in config_files() {
887        roots.extend(configured_skips(&path));
888    }
889    roots.retain(|path| useful_root(path));
890    roots
891}
892
893fn useful_root(path: &Path) -> bool {
894    let text = path.to_string_lossy();
895    let text = text.trim();
896    !text.is_empty() && text != "/" && text != "."
897}
898
899fn config_files() -> Vec<PathBuf> {
900    let mut out = Vec::new();
901    let base = std::env::var_os("XDG_CONFIG_HOME")
902        .filter(|value| !value.is_empty())
903        .map(PathBuf::from)
904        .or_else(|| {
905            std::env::var_os("HOME")
906                .filter(|value| !value.is_empty())
907                .map(|home| PathBuf::from(home).join(".config"))
908        });
909    if let Some(base) = base {
910        out.push(base.join("ljos").join("ljos.toml"));
911        if let Some(seat) = std::env::var("LJOS_SEAT")
912            .ok()
913            .map(|name| name.trim().to_string())
914            .filter(|name| !name.is_empty() && !name.contains('/') && !name.contains(".."))
915        {
916            out.push(base.join("ljos").join("seats").join(seat).join("ljos.toml"));
917        }
918    }
919    if let Some(path) = std::env::var_os("LJOS_CONFIG").filter(|value| !value.is_empty()) {
920        out.push(PathBuf::from(path));
921    }
922    if let Ok(cwd) = std::env::current_dir() {
923        out.push(cwd.join("ljos.toml"));
924    }
925    out
926}
927
928fn configured_skips(path: &Path) -> Vec<PathBuf> {
929    let Ok(text) = fs::read_to_string(path) else {
930        return Vec::new();
931    };
932    let Ok(value) = toml::from_str::<toml::Value>(&text) else {
933        return Vec::new();
934    };
935    let Some(list) = value
936        .get("git_hooks")
937        .and_then(|table| table.get("skip"))
938        .and_then(toml::Value::as_array)
939    else {
940        return Vec::new();
941    };
942    list.iter()
943        .filter_map(toml::Value::as_str)
944        .map(str::trim)
945        .filter(|text| !text.is_empty())
946        .map(PathBuf::from)
947        .collect()
948}
949
950fn covered(repo: &Path, root: &Path) -> bool {
951    let repo = canonical_or(repo);
952    let root = canonical_or(root);
953    repo == root || repo.starts_with(&root)
954}
955
956fn canonical_or(path: &Path) -> PathBuf {
957    fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
958}
959
960fn is_store(repo: &Path) -> bool {
961    let text = repo.to_string_lossy();
962    if text.contains("/deedar/store") || text.contains("/claimdag/") || text.ends_with("/claimdag")
963    {
964        return true;
965    }
966    store_dirs()
967        .iter()
968        .any(|root| same_dir(repo, root) || repo_starts(repo, root))
969}
970
971fn store_dirs() -> Vec<PathBuf> {
972    let mut out = Vec::new();
973    if let Ok(url) = std::env::var("DEEDAR_URL") {
974        if let Some(path) = url.strip_prefix("file://") {
975            out.push(PathBuf::from(path));
976        }
977    }
978    if let Some(data) = std::env::var_os("XDG_DATA_HOME").filter(|value| !value.is_empty()) {
979        out.push(PathBuf::from(data).join("deedar").join("store"));
980    } else if let Some(home) = std::env::var_os("HOME").filter(|value| !value.is_empty()) {
981        out.push(
982            PathBuf::from(home)
983                .join(".local")
984                .join("share")
985                .join("deedar")
986                .join("store"),
987        );
988    }
989    out
990}
991
992fn same_dir(left: &Path, right: &Path) -> bool {
993    let left = fs::canonicalize(left).unwrap_or_else(|_| left.to_path_buf());
994    let right = fs::canonicalize(right).unwrap_or_else(|_| right.to_path_buf());
995    left == right
996}
997
998fn repo_starts(repo: &Path, root: &Path) -> bool {
999    if !root.exists() {
1000        return false;
1001    }
1002    let repo = fs::canonicalize(repo).unwrap_or_else(|_| repo.to_path_buf());
1003    let root = fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
1004    repo.starts_with(root)
1005}
1006
1007fn already_booked(repo: &str, head: &str) -> bool {
1008    let key = format!("{repo}\t{head}");
1009    fs::read_to_string(booked_path())
1010        .map(|text| text.lines().any(|line| line == key))
1011        .unwrap_or(false)
1012}
1013
1014fn remember_booked(repo: &str, head: &str) {
1015    let path = booked_path();
1016    if let Some(dir) = path.parent() {
1017        let _ = fs::create_dir_all(dir);
1018    }
1019    let mut lines: Vec<String> = fs::read_to_string(&path)
1020        .unwrap_or_default()
1021        .lines()
1022        .map(str::to_string)
1023        .filter(|line| !line.starts_with(&format!("{repo}\t")))
1024        .collect();
1025    lines.push(format!("{repo}\t{head}"));
1026    if lines.len() > 200 {
1027        lines.drain(0..lines.len() - 200);
1028    }
1029    let _ = fs::write(path, lines.join("\n") + "\n");
1030}
1031
1032fn booked_path() -> PathBuf {
1033    crate::runtime_dir().join("git-hooks-booked")
1034}
1035
1036fn git_dir() -> Option<PathBuf> {
1037    let raw = git_line(&["rev-parse", "--git-dir"])?;
1038    let path = PathBuf::from(&raw);
1039    if path.is_absolute() {
1040        Some(path)
1041    } else {
1042        std::env::current_dir().ok().map(|cwd| cwd.join(path))
1043    }
1044}
1045
1046/// `GIT_*` values a hook exports. A later `git -C` follows `GIT_DIR` and
1047/// ignores `-C`, so a tracker commit would land in the checkout that just
1048/// finished.
1049const HOOK_GIT_ENV: &[&str] = &[
1050    "GIT_DIR",
1051    "GIT_WORK_TREE",
1052    "GIT_INDEX_FILE",
1053    "GIT_PREFIX",
1054    "GIT_OBJECT_DIRECTORY",
1055    "GIT_COMMON_DIR",
1056    "GIT_NAMESPACE",
1057];
1058
1059thread_local! {
1060    static HOOK_CHILDREN: Cell<bool> = const { Cell::new(false) };
1061}
1062
1063/// While this guard is alive, child processes started through
1064/// [`hook_child_env`] carry `LJOS_GIT_HOOK=1` and do not inherit the hook's
1065/// `GIT_DIR`. Dropping it restores the previous flag. The process
1066/// environment is not changed.
1067#[must_use]
1068struct HookChildren {
1069    previous: bool,
1070}
1071
1072impl HookChildren {
1073    fn enter() -> Self {
1074        let previous = HOOK_CHILDREN.with(Cell::get);
1075        HOOK_CHILDREN.with(|flag| flag.set(true));
1076        Self { previous }
1077    }
1078}
1079
1080impl Drop for HookChildren {
1081    fn drop(&mut self) {
1082        HOOK_CHILDREN.with(|flag| flag.set(self.previous));
1083    }
1084}
1085
1086/// Put the re-entry guard on a child `git` or `ljos`, and drop the hook's
1087/// git directory from that child. No effect unless a git hook is booking
1088/// a commit on this thread.
1089pub(crate) fn hook_child_env(cmd: &mut Command) {
1090    if !HOOK_CHILDREN.with(Cell::get) {
1091        return;
1092    }
1093    cmd.env("LJOS_GIT_HOOK", "1");
1094    for key in HOOK_GIT_ENV {
1095        cmd.env_remove(key);
1096    }
1097}
1098
1099#[cfg(test)]
1100pub(crate) fn with_hook_children_for_test<T>(run: impl FnOnce() -> T) -> T {
1101    let _guard = HookChildren::enter();
1102    run()
1103}
1104
1105fn git_command(args: &[&str], git_env: &[(&str, &str)]) -> Command {
1106    let mut cmd = Command::new("git");
1107    cmd.args(args);
1108    for (key, value) in git_env {
1109        cmd.env(key, value);
1110    }
1111    hook_child_env(&mut cmd);
1112    cmd
1113}
1114
1115fn git_line(args: &[&str]) -> Option<String> {
1116    let out = git_command(args, &[]).output().ok()?;
1117    if !out.status.success() {
1118        return None;
1119    }
1120    let text = String::from_utf8(out.stdout).ok()?;
1121    let text = text.trim();
1122    if text.is_empty() {
1123        None
1124    } else {
1125        Some(text.to_string())
1126    }
1127}
1128
1129fn global_hooks_path(git_env: &[(&str, &str)]) -> Option<String> {
1130    let out = git_command(&["config", "--global", "--get", "core.hooksPath"], git_env)
1131        .output()
1132        .ok()?;
1133    if !out.status.success() {
1134        return None;
1135    }
1136    let text = String::from_utf8(out.stdout).ok()?;
1137    let text = text.trim();
1138    if text.is_empty() {
1139        None
1140    } else {
1141        Some(text.to_string())
1142    }
1143}
1144
1145fn set_global_hooks_path(path: &Path, git_env: &[(&str, &str)]) -> Result<()> {
1146    let value = path.display().to_string();
1147    let status = git_command(&["config", "--global", "core.hooksPath", &value], git_env)
1148        .status()
1149        .context("git-hooks: git config")?;
1150    if !status.success() {
1151        bail!("git-hooks: could not set core.hooksPath");
1152    }
1153    Ok(())
1154}
1155
1156fn unset_global_hooks_path(git_env: &[(&str, &str)]) -> Result<()> {
1157    let status = git_command(
1158        &["config", "--global", "--unset", "core.hooksPath"],
1159        git_env,
1160    )
1161    .status()
1162    .context("git-hooks: git config")?;
1163    if !status.success() {
1164        bail!("git-hooks: could not unset core.hooksPath");
1165    }
1166    Ok(())
1167}
1168
1169#[cfg(test)]
1170mod tests {
1171    use std::ffi::OsStr;
1172
1173    use super::*;
1174
1175    fn mapped<'a>(cmd: &'a Command, key: &str) -> Option<Option<&'a OsStr>> {
1176        cmd.get_envs()
1177            .find(|(name, _)| *name == OsStr::new(key))
1178            .map(|(_, value)| value)
1179    }
1180
1181    #[test]
1182    fn the_doctor_names_a_hooks_path_that_is_not_the_onboard_one() {
1183        let dir = tempfile::tempdir().unwrap();
1184        let ours = dir.path().join("ljos").join("git-hooks");
1185        let other = dir.path().join("user-hooks");
1186        std::fs::create_dir_all(&other).unwrap();
1187        let prior = "#!/bin/sh\nprintf 'user-hook\\n'\n";
1188        std::fs::write(other.join("post-commit"), prior).unwrap();
1189        let ours_s = ours.display().to_string();
1190        let other_s = other.display().to_string();
1191
1192        let (ok, state) = super::hooks_state(&ours, None);
1193        assert!(!ok);
1194        assert!(state.starts_with(super::HOOKS_UNSET), "{state}");
1195        assert_eq!(state.matches(super::HOOKS_FIX).count(), 1, "{state}");
1196
1197        let (ok, state) = super::hooks_state(&ours, Some(&other_s));
1198        assert!(!ok, "{state}");
1199        assert!(state.contains(&other_s), "{state}");
1200        assert!(!state.contains(&ours_s), "{state}");
1201        assert_eq!(state.matches(super::HOOKS_FIX).count(), 1, "{state}");
1202
1203        super::chain_user_hooks(&other).unwrap();
1204        let (ok, state) = super::hooks_state(&ours, Some(&other_s));
1205        assert!(ok, "{state}");
1206        assert!(state.contains("ljos git-hook runs"), "{state}");
1207        let hooked = std::fs::read_to_string(other.join("post-commit")).unwrap();
1208        assert!(hooked.starts_with(prior), "{hooked}");
1209        assert_eq!(
1210            hooked.matches("# ljos:begin git-hook").count(),
1211            1,
1212            "{hooked}"
1213        );
1214        super::chain_user_hooks(&other).unwrap();
1215        assert_eq!(
1216            std::fs::read_to_string(other.join("post-commit")).unwrap(),
1217            hooked
1218        );
1219        assert!(other.join("pre-push").is_file());
1220
1221        super::strip_user_hooks(&other).unwrap();
1222        assert_eq!(
1223            std::fs::read_to_string(other.join("post-commit")).unwrap(),
1224            prior
1225        );
1226        assert!(!other.join("pre-push").exists());
1227
1228        let config = dir.path().join("config");
1229        std::fs::create_dir_all(&config).unwrap();
1230        std::fs::write(
1231            config.join("env.sh"),
1232            format!("export LJOS_GIT_HOOKS={}\n", other.display()),
1233        )
1234        .unwrap();
1235        let drift = super::env_hooks_drift(&config, &ours).expect("a drifted env file");
1236        assert!(drift.contains(&other_s), "{drift}");
1237        assert!(drift.contains(&ours_s), "{drift}");
1238        let (ok, state) = super::doctor_state(&ours, None, &config);
1239        assert!(!ok, "{state}");
1240        assert!(
1241            !state.starts_with(super::HOOKS_UNSET),
1242            "a mismatch stayed info: {state}"
1243        );
1244        assert!(state.contains(&other_s), "{state}");
1245        assert!(state.contains(&ours_s), "{state}");
1246
1247        std::fs::write(
1248            config.join("env.sh"),
1249            format!("{}\n", super::HOOKS_ENV_LINE),
1250        )
1251        .unwrap();
1252        assert!(super::env_hooks_drift(&config, &ours).is_none());
1253        let body = super::hook_body("post-rewrite");
1254        assert!(body.contains("mktemp"), "{body}");
1255        assert!(body.contains("git-hook post-rewrite"), "{body}");
1256        assert!(body.contains("< \"$_ljos_in\""), "{body}");
1257        let commit = super::hook_body("post-commit");
1258        assert!(!commit.contains("mktemp"), "{commit}");
1259        assert!(commit.contains("git-hook post-commit"), "{commit}");
1260
1261        super::write_owned_hooks(&ours).unwrap();
1262        let (ok, state) = super::hooks_state(&ours, Some(&ours_s));
1263        assert!(ok, "{state}");
1264        std::fs::remove_file(ours.join("pre-push")).unwrap();
1265        let (ok, state) = super::hooks_state(&ours, Some(&ours_s));
1266        assert!(!ok, "{state}");
1267        assert!(state.contains(super::HOOKS_FIX), "{state}");
1268    }
1269
1270    #[test]
1271    fn a_hook_that_does_not_book_succeeds() {
1272        super::run_named("pre-push").unwrap();
1273    }
1274
1275    #[test]
1276    fn hook_children_carry_the_guard_without_touching_the_process() {
1277        let before_hook = std::env::var_os("LJOS_GIT_HOOK");
1278        let before_dir = std::env::var_os("GIT_DIR");
1279        let mut plain = Command::new("true");
1280        plain.env("GIT_DIR", "keep");
1281        hook_child_env(&mut plain);
1282        assert_eq!(mapped(&plain, "GIT_DIR"), Some(Some(OsStr::new("keep"))));
1283        assert_eq!(mapped(&plain, "LJOS_GIT_HOOK"), None);
1284
1285        let _guard = HookChildren::enter();
1286        let mut cmd = Command::new("true");
1287        cmd.env("GIT_DIR", "from-the-hook");
1288        cmd.env("PATH", "/usr/bin");
1289        hook_child_env(&mut cmd);
1290        assert_eq!(mapped(&cmd, "LJOS_GIT_HOOK"), Some(Some(OsStr::new("1"))));
1291        for key in HOOK_GIT_ENV {
1292            assert_eq!(mapped(&cmd, key), Some(None), "{key}");
1293        }
1294        assert_eq!(mapped(&cmd, "PATH"), Some(Some(OsStr::new("/usr/bin"))));
1295        assert_eq!(std::env::var_os("LJOS_GIT_HOOK"), before_hook);
1296        assert_eq!(std::env::var_os("GIT_DIR"), before_dir);
1297    }
1298}