Skip to main content

klasp_agents_codex/
git_hooks.rs

1//! Git-hook managed-block writer + conflict detection for `CodexSurface`.
2//!
3//! Codex has no programmatic gate equivalent (see [`docs/design.md`] §1),
4//! so enforcement runs through real `.git/hooks/pre-commit` and
5//! `.git/hooks/pre-push` scripts. This module is the analogue of
6//! [`crate::agents_md`] for those shell files. The block is anchored by
7//! the [`MANAGED_START`] / [`MANAGED_END`] marker pair so re-installing
8//! never disturbs sibling content. [`detect_conflict`] sniffs each hook
9//! for husky / lefthook / pre-commit-framework fingerprints; on a match,
10//! callers must skip writing and surface a [`HookConflict`] in their
11//! report (never fail, never clobber). The idempotency contract matches
12//! [`crate::agents_md`]: round-trip canonicalises trailing-newline state
13//! to a single `\n`; bytes outside the block are byte-for-byte preserved.
14
15use thiserror::Error;
16
17/// Opening marker for klasp's managed block in a git-hook file. Stable
18/// across schema bumps; `install_block` greps for this exact substring to
19/// decide whether the file already has a klasp section.
20pub const MANAGED_START: &str = "# >>> klasp managed start <<<";
21
22/// Closing marker for klasp's managed block in a git-hook file.
23pub const MANAGED_END: &str = "# >>> klasp managed end <<<";
24
25/// Shebang klasp uses when it has to fresh-create a hook. We pick the
26/// portable `/usr/bin/env sh` form — git itself ships hook samples with
27/// the same shebang, so users running this on minimal Alpine / BSD images
28/// get a working interpreter without a hard-coded `/bin/bash` dependency.
29pub const SHEBANG: &str = "#!/usr/bin/env sh";
30
31/// Which git-hook this block targets. Drives the `--trigger` arg passed
32/// through to `klasp gate`, and the on-disk filename
33/// (`.git/hooks/pre-commit` vs `.git/hooks/pre-push`).
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum HookKind {
36    /// `.git/hooks/pre-commit` — fires on `git commit`.
37    Commit,
38    /// `.git/hooks/pre-push` — fires on `git push`.
39    Push,
40}
41
42impl HookKind {
43    /// Filename within `.git/hooks/`.
44    pub const fn filename(self) -> &'static str {
45        match self {
46            HookKind::Commit => "pre-commit",
47            HookKind::Push => "pre-push",
48        }
49    }
50
51    /// Value passed to `klasp gate --trigger ...`. Matches the trigger
52    /// vocabulary documented in [`klasp_core::trigger`].
53    pub const fn trigger_arg(self) -> &'static str {
54        match self {
55            HookKind::Commit => "commit",
56            HookKind::Push => "push",
57        }
58    }
59}
60
61/// A foreign hook manager klasp recognises and refuses to overwrite.
62///
63/// Detected via public, version-stable fingerprints in the hook file — see
64/// [`detect_conflict`] for the exact substrings. The variants are intended
65/// for surfacing in [`HookWarning::Skipped`]; the `tool` accessor returns a
66/// short canonical name (`"husky"`, `"lefthook"`, `"pre-commit"`) that
67/// downstream UIs can render verbatim.
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum HookConflict {
70    /// husky — `_/husky.sh` shim is `source`d at the top of the hook.
71    Husky,
72    /// lefthook — `lefthook` invocation + the explicit
73    /// `# DON'T REMOVE THIS LINE (lefthook)` marker.
74    Lefthook,
75    /// pre-commit framework — `# File generated by pre-commit:
76    /// https://pre-commit.com` banner.
77    PreCommit,
78}
79
80impl HookConflict {
81    /// Short canonical tool name, suitable for log output.
82    pub const fn tool(self) -> &'static str {
83        match self {
84            HookConflict::Husky => "husky",
85            HookConflict::Lefthook => "lefthook",
86            HookConflict::PreCommit => "pre-commit",
87        }
88    }
89}
90
91/// Structured warning surfaced by `CodexSurface::install_detailed` when
92/// the hook write was skipped (or otherwise non-fatally adjusted). Never
93/// raised as an error — the install completes; the warning rides
94/// alongside the report.
95#[derive(Debug, Clone, PartialEq, Eq)]
96pub enum HookWarning {
97    /// The hook file is owned by another tool ([`HookConflict`]). klasp
98    /// did not modify it. The user should integrate the gate manually,
99    /// e.g. by adding `klasp gate --agent codex --trigger commit` to
100    /// their pre-commit framework's hook list.
101    Skipped {
102        path: std::path::PathBuf,
103        kind: HookKind,
104        conflict: HookConflict,
105    },
106}
107
108#[derive(Debug, Error)]
109pub enum HookError {
110    /// The hook file contains an unmatched marker pair (start without
111    /// end, end without start, or duplicate markers). We refuse to coerce
112    /// the file because the safe action — overwriting from the first
113    /// marker to EOF — could nuke hand-written content the user intended
114    /// to keep.
115    #[error(
116        "git hook: managed-block markers are malformed \
117         (expected exactly one `{MANAGED_START}` followed by one `{MANAGED_END}`). \
118         Fix the file by hand or remove both markers and re-run install."
119    )]
120    MalformedMarkers,
121}
122
123/// Render the inner body of klasp's managed block for the given hook
124/// kind. Pure: no env, no IO. The result is the lines *between* the
125/// markers — [`render_managed_block`] adds the markers themselves.
126pub fn render_managed_body(kind: HookKind, schema_version: u32) -> String {
127    // Notes on the script itself:
128    // - `KLASP_GATE_SCHEMA` is exported as part of the same command via
129    //   `env`-style prefix so the value is in the child's environment
130    //   regardless of whether the user's parent shell exports it. See
131    //   [`klasp_core::GateProtocol::read_schema_from_env`] for the
132    //   schema-mismatch detection path this feeds into.
133    // - `exec` replaces the shell with `klasp gate` so the hook's exit
134    //   status *is* the gate's exit status — git interprets non-zero as
135    //   "block this commit/push".
136    // - `"$@"` propagates whatever args git passed to the hook (commit
137    //   message file, refs, etc.) untouched.
138    format!(
139        "# Managed by klasp install. Re-run `klasp install` to regenerate.\n\
140         KLASP_GATE_SCHEMA={ver} exec klasp gate --agent codex --trigger {trigger} \"$@\"\n",
141        ver = schema_version,
142        trigger = kind.trigger_arg(),
143    )
144}
145
146/// Render the full managed block (markers + body) for the given hook.
147///
148/// Pure: no IO. The output starts with [`MANAGED_START`] on its own line,
149/// ends with [`MANAGED_END`] on its own line, with the body sandwiched.
150pub fn render_managed_block(kind: HookKind, schema_version: u32) -> String {
151    let body = render_managed_body(kind, schema_version);
152    let trimmed = body.trim_end_matches('\n');
153    format!("{MANAGED_START}\n{trimmed}\n{MANAGED_END}\n")
154}
155
156/// Splice klasp's managed block into `existing`, returning the new file
157/// body.
158///
159/// Behaviour matrix:
160///
161/// | Input shape                        | Output shape                                          |
162/// |------------------------------------|-------------------------------------------------------|
163/// | empty / all-whitespace             | `<shebang>\n\n<block>` (fresh-create)                 |
164/// | starts with a shebang, no block    | `<existing>\n\n<block>` (append after user content)   |
165/// | no shebang, no block               | `<shebang>\n\n<existing>\n\n<block>`                  |
166/// | contains a managed block           | block contents replaced in-place                      |
167///
168/// Idempotent: when the existing block already matches the rendered block
169/// byte-for-byte and no shebang prepending was needed, the input is
170/// returned unchanged.
171pub fn install_block(
172    existing: &str,
173    kind: HookKind,
174    schema_version: u32,
175) -> Result<String, HookError> {
176    let block = render_managed_block(kind, schema_version);
177
178    if let Some(span) = find_block(existing)? {
179        // Replace in-place. Preserve everything outside [start, end).
180        let mut out = String::with_capacity(existing.len() + block.len());
181        out.push_str(&existing[..span.start]);
182        out.push_str(&block);
183        out.push_str(&existing[span.end..]);
184        return Ok(out);
185    }
186
187    // No existing block. Decide on shebang prelude + appropriate spacing.
188    let trimmed = existing.trim();
189    if trimmed.is_empty() {
190        // Fresh-create: shebang line + blank line + block. The blank
191        // line keeps the marker visually separate from the shebang and
192        // matches the structure `uninstall_block` reverses below.
193        let mut out = String::with_capacity(SHEBANG.len() + block.len() + 2);
194        out.push_str(SHEBANG);
195        out.push_str("\n\n");
196        out.push_str(&block);
197        return Ok(out);
198    }
199
200    // Existing user content. If the file already has a shebang, just
201    // append the block after the user content with a one-line spacer.
202    // Otherwise prepend our own shebang first — without one, git won't
203    // execute the hook on systems that don't have an inherited
204    // interpreter for the file mode.
205    let mut out = String::with_capacity(existing.len() + SHEBANG.len() + block.len() + 4);
206    if has_shebang(existing) {
207        out.push_str(existing.trim_end_matches('\n'));
208        out.push_str("\n\n");
209        out.push_str(&block);
210    } else {
211        out.push_str(SHEBANG);
212        out.push_str("\n\n");
213        out.push_str(existing.trim_end_matches('\n'));
214        out.push_str("\n\n");
215        out.push_str(&block);
216    }
217    Ok(out)
218}
219
220/// Inverse of [`install_block`]: remove klasp's managed block and the
221/// blank-line separator install inserted around it.
222///
223/// Idempotent: a file with no managed block is returned unchanged. A file
224/// where klasp was the only meaningful content (shebang + block, no user
225/// commands) is returned as the empty string — the caller can use that
226/// signal to remove the file altogether and round-trip the missing-file
227/// install path.
228///
229/// The trailing-newline normalisation matches [`crate::agents_md`]: when
230/// install *appended* the block to user content, uninstall restores the
231/// canonical `<content>\n` shape. The pre-install file ending in exactly
232/// one `\n` round-trips byte-for-byte; an input without a trailing
233/// newline gains one. This is the same tolerated normalisation the
234/// AGENTS.md writer documents.
235pub fn uninstall_block(existing: &str) -> Result<String, HookError> {
236    let Some(span) = find_block(existing)? else {
237        return Ok(existing.to_string());
238    };
239
240    let before = &existing[..span.start];
241    let after = &existing[span.end..];
242
243    // Three shapes possible after stripping the block:
244    //
245    // 1. `before` is empty (block was at byte 0): collapse to `after`.
246    //    The fresh-create path *never* hits this — install always
247    //    prepends a shebang. The path is reachable only via a
248    //    user-fabricated input whose first byte is the start marker.
249    //
250    // 2. `before` is just our shebang + whitespace, `after` is empty:
251    //    this is the round-trip from the missing-file install. Collapse
252    //    to empty so the caller can `rm` the file.
253    //
254    // 3. `before` has real content: strip the trailing `\n\n` install
255    //    inserted as a separator, restore canonical `<content>\n`. If
256    //    `after` is non-empty, leave it in place verbatim.
257    let mut out = String::with_capacity(before.len() + after.len() + 1);
258    if before.is_empty() {
259        out.push_str(after);
260    } else if after.is_empty() && is_only_shebang_or_whitespace(before) {
261        // Shebang-only prefix means klasp was the sole content. Collapse
262        // to empty so the caller deletes the file.
263    } else if after.is_empty() {
264        out.push_str(before.trim_end_matches('\n'));
265        out.push('\n');
266    } else {
267        out.push_str(before);
268        out.push_str(after);
269    }
270    Ok(out)
271}
272
273/// `true` when `existing` already contains a (well-formed) klasp managed
274/// block — used by callers to decide whether install is a no-op.
275pub fn contains_block(existing: &str) -> Result<bool, HookError> {
276    Ok(find_block(existing)?.is_some())
277}
278
279/// Inspect a hook file's contents for a recognised foreign hook manager.
280///
281/// Returns `Some(HookConflict)` when the file is one we know not to
282/// touch. Returns `None` for files we either own (klasp markers present)
283/// or for plain user-authored hooks (which we'll merge into).
284///
285/// Matches are deliberately conservative — we only fingerprint markers
286/// that the foreign tool itself drops in:
287///
288/// - **husky** ≥ v8 sources `_/husky.sh` (or its successor)
289///   `_/h` / `_/husky` shim. Older v4-v7 husky used `husky.sh` directly.
290///   We match either form.
291/// - **lefthook** drops a `# DON'T REMOVE THIS LINE (lefthook)` sentinel
292///   in the hooks it generates. The sentinel is searched in addition to
293///   the bare `lefthook` invocation to avoid false positives on hooks
294///   that merely *call* `lefthook` from a wrapper.
295/// - **pre-commit** stamps each generated hook with the
296///   `# File generated by pre-commit: https://pre-commit.com` banner.
297pub fn detect_conflict(existing: &str) -> Option<HookConflict> {
298    // Husky's dotted-source line is the only place these substrings
299    // legitimately appear; the leading `/` and trailing `"` together
300    // anchor on `. "$(dirname -- "$0")/_/<shim>"` and refuse plausible
301    // false-positive contexts (a user comment, a here-doc, a `cd` path).
302    if existing.contains("/_/husky.sh\"") || existing.contains("/_/h\"") {
303        return Some(HookConflict::Husky);
304    }
305    // Lefthook: anti-deletion sentinel + `lefthook` invocation token.
306    if existing.contains("DON'T REMOVE THIS LINE (lefthook)") && existing.contains("lefthook") {
307        return Some(HookConflict::Lefthook);
308    }
309    if existing.contains("File generated by pre-commit: https://pre-commit.com") {
310        return Some(HookConflict::PreCommit);
311    }
312    None
313}
314
315/// Byte span of the managed block within `existing`, including both
316/// markers and the trailing `\n` after the end marker. Mirrors the
317/// equivalent helper in [`crate::agents_md`].
318struct Span {
319    start: usize,
320    end: usize,
321}
322
323fn find_block(existing: &str) -> Result<Option<Span>, HookError> {
324    let (Some(start), Some(end_marker_start)) =
325        (existing.find(MANAGED_START), existing.find(MANAGED_END))
326    else {
327        return if existing.contains(MANAGED_START) || existing.contains(MANAGED_END) {
328            Err(HookError::MalformedMarkers)
329        } else {
330            Ok(None)
331        };
332    };
333
334    if existing.rfind(MANAGED_START) != Some(start)
335        || existing.rfind(MANAGED_END) != Some(end_marker_start)
336        || end_marker_start < start
337    {
338        return Err(HookError::MalformedMarkers);
339    }
340
341    let after_marker = end_marker_start + MANAGED_END.len();
342    let end = if existing.as_bytes().get(after_marker) == Some(&b'\n') {
343        after_marker + 1
344    } else {
345        after_marker
346    };
347    Ok(Some(Span { start, end }))
348}
349
350fn has_shebang(s: &str) -> bool {
351    s.starts_with("#!")
352}
353
354/// Returns `true` when `s` consists of only a shebang line plus
355/// whitespace (and nothing else). Used by [`uninstall_block`] to detect
356/// the round-trip-from-missing-file case where klasp owns the entire
357/// file.
358fn is_only_shebang_or_whitespace(s: &str) -> bool {
359    let trimmed = s.trim();
360    trimmed.is_empty() || (trimmed.starts_with("#!") && !trimmed.contains('\n'))
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366
367    #[test]
368    fn render_block_wraps_body_in_markers() {
369        let s = render_managed_block(HookKind::Commit, 1);
370        assert!(s.starts_with(MANAGED_START));
371        assert!(s.contains("KLASP_GATE_SCHEMA=1"));
372        assert!(s.contains("--trigger commit"));
373        assert!(s.contains("--agent codex"));
374        assert!(s.trim_end().ends_with(MANAGED_END));
375    }
376
377    #[test]
378    fn render_block_uses_push_trigger_for_pre_push() {
379        let s = render_managed_block(HookKind::Push, 1);
380        assert!(s.contains("--trigger push"));
381        assert!(!s.contains("--trigger commit"));
382    }
383
384    #[test]
385    fn render_block_parameterises_schema_version() {
386        let s = render_managed_block(HookKind::Commit, 7);
387        assert!(s.contains("KLASP_GATE_SCHEMA=7"));
388    }
389
390    #[test]
391    fn install_into_empty_emits_shebang_and_block() {
392        let out = install_block("", HookKind::Commit, 1).unwrap();
393        assert!(out.starts_with(SHEBANG));
394        assert!(out.contains(MANAGED_START));
395        assert!(out.trim_end().ends_with(MANAGED_END));
396    }
397
398    #[test]
399    fn install_into_user_hook_with_shebang_appends() {
400        let pre = "#!/bin/bash\n\necho 'user lint'\n";
401        let out = install_block(pre, HookKind::Commit, 1).unwrap();
402        assert!(out.starts_with(pre));
403        // Block lives after the user content.
404        assert!(out.contains("echo 'user lint'"));
405        let after_pre = &out[pre.len()..];
406        assert!(after_pre.starts_with('\n'));
407        assert!(after_pre[1..].starts_with(MANAGED_START));
408    }
409
410    #[test]
411    fn install_into_user_hook_without_shebang_prepends_one() {
412        let pre = "echo lint\n";
413        let out = install_block(pre, HookKind::Commit, 1).unwrap();
414        assert!(out.starts_with(SHEBANG));
415        assert!(out.contains("echo lint"));
416        assert!(out.contains(MANAGED_START));
417    }
418
419    #[test]
420    fn install_replaces_existing_block_in_place() {
421        let stale = render_managed_block(HookKind::Commit, 0);
422        let pre = format!("#!/bin/bash\n\n{stale}\nset -e\n");
423        let out = install_block(&pre, HookKind::Commit, 1).unwrap();
424        assert!(out.contains("KLASP_GATE_SCHEMA=1"));
425        assert!(!out.contains("KLASP_GATE_SCHEMA=0"));
426        assert!(out.starts_with("#!/bin/bash\n\n"));
427        assert!(out.ends_with("set -e\n"));
428    }
429
430    #[test]
431    fn install_is_idempotent() {
432        let pre = "#!/bin/bash\n\necho 'user lint'\n";
433        let once = install_block(pre, HookKind::Commit, 1).unwrap();
434        let twice = install_block(&once, HookKind::Commit, 1).unwrap();
435        assert_eq!(once, twice);
436    }
437
438    #[test]
439    fn install_uninstall_round_trip_on_user_hook_restores_input() {
440        let pre = "#!/bin/bash\n\necho 'user lint'\n";
441        let installed = install_block(pre, HookKind::Commit, 1).unwrap();
442        let restored = uninstall_block(&installed).unwrap();
443        assert_eq!(restored, pre);
444    }
445
446    #[test]
447    fn install_uninstall_round_trip_on_empty_collapses_to_empty() {
448        let installed = install_block("", HookKind::Commit, 1).unwrap();
449        let restored = uninstall_block(&installed).unwrap();
450        assert_eq!(restored, "", "fresh-create round-trip must empty out");
451    }
452
453    #[test]
454    fn uninstall_is_noop_when_no_block_present() {
455        let pre = "#!/bin/bash\n\necho lint\n";
456        assert_eq!(uninstall_block(pre).unwrap(), pre);
457    }
458
459    #[test]
460    fn malformed_markers_rejected() {
461        let pre = format!("#!/bin/bash\n{MANAGED_START}\nbody\n");
462        let err = install_block(&pre, HookKind::Commit, 1).expect_err("must fail");
463        assert!(matches!(err, HookError::MalformedMarkers));
464    }
465
466    #[test]
467    fn duplicate_start_marker_rejected() {
468        let pre = format!(
469            "#!/bin/bash\n{MANAGED_START}\nbody\n{MANAGED_END}\n{MANAGED_START}\nbody2\n{MANAGED_END}\n"
470        );
471        let err = install_block(&pre, HookKind::Commit, 1).expect_err("must fail");
472        assert!(matches!(err, HookError::MalformedMarkers));
473    }
474
475    #[test]
476    fn detect_conflict_avoids_false_positive_on_lone_lefthook_word() {
477        // A user hook that mentions "lefthook" in a comment but doesn't
478        // have the sentinel must not match. Positive cases for husky /
479        // lefthook / pre-commit framework live in the integration test
480        // suite under `tests/git_hooks_install.rs`, driven from
481        // captured `.git/hooks/pre-commit` fixtures.
482        let pre = "#!/bin/sh\n# we used to use lefthook, removed it\necho lint\n";
483        assert_eq!(detect_conflict(pre), None);
484        assert_eq!(detect_conflict(""), None);
485    }
486
487    #[test]
488    fn hook_kind_constants_are_canonical() {
489        assert_eq!(HookKind::Commit.filename(), "pre-commit");
490        assert_eq!(HookKind::Push.filename(), "pre-push");
491        assert_eq!(HookKind::Commit.trigger_arg(), "commit");
492        assert_eq!(HookKind::Push.trigger_arg(), "push");
493        assert_eq!(HookConflict::Husky.tool(), "husky");
494        assert_eq!(HookConflict::Lefthook.tool(), "lefthook");
495        assert_eq!(HookConflict::PreCommit.tool(), "pre-commit");
496    }
497}