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, 2);
370 assert!(s.starts_with(MANAGED_START));
371 assert!(s.contains("KLASP_GATE_SCHEMA=2"));
372 assert!(
373 !s.contains("KLASP_GATE_SCHEMA=1"),
374 "block must not contain stale schema v1"
375 );
376 assert!(
377 !s.contains("KLASP_GATE_SCHEMA=0"),
378 "block must not contain stale schema v0"
379 );
380 assert!(s.contains("--trigger commit"));
381 assert!(s.contains("--agent codex"));
382 assert!(s.trim_end().ends_with(MANAGED_END));
383 }
384
385 #[test]
386 fn render_block_uses_push_trigger_for_pre_push() {
387 let s = render_managed_block(HookKind::Push, 1);
388 assert!(s.contains("--trigger push"));
389 assert!(!s.contains("--trigger commit"));
390 }
391
392 #[test]
393 fn render_block_parameterises_schema_version() {
394 let s = render_managed_block(HookKind::Commit, 7);
395 assert!(s.contains("KLASP_GATE_SCHEMA=7"));
396 }
397
398 #[test]
399 fn install_into_empty_emits_shebang_and_block() {
400 let out = install_block("", HookKind::Commit, 1).unwrap();
401 assert!(out.starts_with(SHEBANG));
402 assert!(out.contains(MANAGED_START));
403 assert!(out.trim_end().ends_with(MANAGED_END));
404 }
405
406 #[test]
407 fn install_into_user_hook_with_shebang_appends() {
408 let pre = "#!/bin/bash\n\necho 'user lint'\n";
409 let out = install_block(pre, HookKind::Commit, 1).unwrap();
410 assert!(out.starts_with(pre));
411 // Block lives after the user content.
412 assert!(out.contains("echo 'user lint'"));
413 let after_pre = &out[pre.len()..];
414 assert!(after_pre.starts_with('\n'));
415 assert!(after_pre[1..].starts_with(MANAGED_START));
416 }
417
418 #[test]
419 fn install_into_user_hook_without_shebang_prepends_one() {
420 let pre = "echo lint\n";
421 let out = install_block(pre, HookKind::Commit, 1).unwrap();
422 assert!(out.starts_with(SHEBANG));
423 assert!(out.contains("echo lint"));
424 assert!(out.contains(MANAGED_START));
425 }
426
427 #[test]
428 fn install_replaces_existing_block_in_place() {
429 let stale = render_managed_block(HookKind::Commit, 0);
430 let pre = format!("#!/bin/bash\n\n{stale}\nset -e\n");
431 let out = install_block(&pre, HookKind::Commit, 2).unwrap();
432 assert!(out.contains("KLASP_GATE_SCHEMA=2"));
433 assert!(
434 !out.contains("KLASP_GATE_SCHEMA=1"),
435 "block must not contain stale schema v1"
436 );
437 assert!(
438 !out.contains("KLASP_GATE_SCHEMA=0"),
439 "block must not contain stale schema v0"
440 );
441 assert!(out.starts_with("#!/bin/bash\n\n"));
442 assert!(out.ends_with("set -e\n"));
443 }
444
445 #[test]
446 fn install_is_idempotent() {
447 let pre = "#!/bin/bash\n\necho 'user lint'\n";
448 let once = install_block(pre, HookKind::Commit, 1).unwrap();
449 let twice = install_block(&once, HookKind::Commit, 1).unwrap();
450 assert_eq!(once, twice);
451 }
452
453 #[test]
454 fn install_uninstall_round_trip_on_user_hook_restores_input() {
455 let pre = "#!/bin/bash\n\necho 'user lint'\n";
456 let installed = install_block(pre, HookKind::Commit, 1).unwrap();
457 let restored = uninstall_block(&installed).unwrap();
458 assert_eq!(restored, pre);
459 }
460
461 #[test]
462 fn install_uninstall_round_trip_on_empty_collapses_to_empty() {
463 let installed = install_block("", HookKind::Commit, 1).unwrap();
464 let restored = uninstall_block(&installed).unwrap();
465 assert_eq!(restored, "", "fresh-create round-trip must empty out");
466 }
467
468 #[test]
469 fn uninstall_is_noop_when_no_block_present() {
470 let pre = "#!/bin/bash\n\necho lint\n";
471 assert_eq!(uninstall_block(pre).unwrap(), pre);
472 }
473
474 #[test]
475 fn malformed_markers_rejected() {
476 let pre = format!("#!/bin/bash\n{MANAGED_START}\nbody\n");
477 let err = install_block(&pre, HookKind::Commit, 1).expect_err("must fail");
478 assert!(matches!(err, HookError::MalformedMarkers));
479 }
480
481 #[test]
482 fn duplicate_start_marker_rejected() {
483 let pre = format!(
484 "#!/bin/bash\n{MANAGED_START}\nbody\n{MANAGED_END}\n{MANAGED_START}\nbody2\n{MANAGED_END}\n"
485 );
486 let err = install_block(&pre, HookKind::Commit, 1).expect_err("must fail");
487 assert!(matches!(err, HookError::MalformedMarkers));
488 }
489
490 #[test]
491 fn detect_conflict_avoids_false_positive_on_lone_lefthook_word() {
492 // A user hook that mentions "lefthook" in a comment but doesn't
493 // have the sentinel must not match. Positive cases for husky /
494 // lefthook / pre-commit framework live in the integration test
495 // suite under `tests/git_hooks_install.rs`, driven from
496 // captured `.git/hooks/pre-commit` fixtures.
497 let pre = "#!/bin/sh\n# we used to use lefthook, removed it\necho lint\n";
498 assert_eq!(detect_conflict(pre), None);
499 assert_eq!(detect_conflict(""), None);
500 }
501
502 #[test]
503 fn hook_kind_constants_are_canonical() {
504 assert_eq!(HookKind::Commit.filename(), "pre-commit");
505 assert_eq!(HookKind::Push.filename(), "pre-push");
506 assert_eq!(HookKind::Commit.trigger_arg(), "commit");
507 assert_eq!(HookKind::Push.trigger_arg(), "push");
508 assert_eq!(HookConflict::Husky.tool(), "husky");
509 assert_eq!(HookConflict::Lefthook.tool(), "lefthook");
510 assert_eq!(HookConflict::PreCommit.tool(), "pre-commit");
511 }
512}