1use std::ops::Range;
15use std::path::Path;
16
17pub const START: &str = "<!-- amont:start -->";
18pub const END: &str = "<!-- amont:end -->";
19
20pub fn generate_block(settings: &crate::config::Settings) -> String {
27 let clocks = |secs: u64| match secs {
28 0 => "no limit".to_string(),
29 s => crate::hooks::common::human_secs(s),
30 };
31 let idle = clocks(crate::hooks::common::idle_timeout(settings));
32 let ceiling = clocks(crate::hooks::common::check_timeout(settings));
33 let prefixes = crate::vocabulary::BRANCH_PREFIXES
34 .iter()
35 .map(|p| p.name)
36 .collect::<Vec<_>>()
37 .join(", ");
38 format!(
39 "{START}\n\
40\n\
41## Git hooks (amont)\n\
42\n\
43This repository enforces pre-commit / pre-push checks. Ask the registry\n\
44rather than guessing before assuming a change is safe:\n\
45\n\
46```sh\n\
47amont list --json\n\
48amont list --json --stage pre-push --pushed # exactly what pushing next gates\n\
49```\n\
50\n\
51Each check reports its _effective_ severity (`block`/`warn`, including any\n\
52`amont.severity.*` override) and whether it fires here. The same output\n\
53carries `commit_style`: the subject and description limits `commit-msg`\n\
54enforces, and where the type's gitmoji is placed. It also carries\n\
55`branch_style`: name a branch `<prefix>/<name>` BEFORE creating it —\n\
56prefixes are {prefixes} — because `pre-push` refuses a\n\
57new branch that breaks the pattern, at the end of the work instead of the\n\
58start.\n\
59\n\
60`git commit` and `git push` both run their checks first, and neither is\n\
61instant: pre-commit can invoke formatters, linters or clippy (a workspace\n\
62build), and pre-push can run the test suite. Run `amont rehearse --wait`\n\
63BEFORE `git push`: it runs the same push gate on a snapshot of HEAD with no\n\
64connection open and stamps the tree, so the push itself skips the suite and\n\
65holds the remote for seconds instead of minutes (git connects before\n\
66pre-push runs, and a remote may drop the idle session while a suite runs).\n\
67Where `amont.rehearseOnCommit` is on, every commit already starts that\n\
68rehearsal in the background and `--wait` only follows it. Give both commands the\n\
69longest timeout your tooling allows, never its default: here a check is\n\
70killed only after {idle} of silence or {ceiling} in total\n\
71(`amont.idleTimeout` / `amont.timeout`) — and on Linux and macOS a silent\n\
72check whose processes are busy on CPU is not \"silent\": it can run to the\n\
73{ceiling} ceiling. A test suite may legitimately run for most of that. If your tooling caps a foreground command below it,\n\
74run the command in the background and read its result when it exits —\n\
75while it runs, a line a minute on stderr says which check is alive and\n\
76when it last printed. A push killed mid-suite pushed nothing; a commit\n\
77killed mid-check committed nothing, and your unstaged work stays parked\n\
78until the next run says how to recover it. Neither is the checks failing —\n\
79it is the timeout. Run both bare and check the effect (`git log --oneline\n\
80-1`, `git ls-remote origin <branch>`): trimming their output with `| tail`\n\
81reports the pipe's exit status, so a killed or rejected run reads as\n\
82success.\n\
83\n\
84Never bypass with `--no-verify`. To change enforcement, downgrade it\n\
85intentionally instead:\n\
86\n\
87```sh\n\
88git config amont.severity.<check-id> warn\n\
89```\n\
90\n\
91`commit-msg` takes neither `hook.skip` nor a severity override. Write the\n\
92message it asks for, or change what it asks for — `amont setup`, or\n\
93`amont.commit.*` directly.\n\
94\n\
95{END}\n"
96 )
97}
98
99pub fn generate_pointer() -> String {
108 format!(
109 "{START}\n\
110\n\
111## Git hooks (amont)\n\
112\n\
113This repository enforces pre-commit / pre-push checks that can REJECT a\n\
114commit or a push. What runs, the branch-name rule, and why `git commit`\n\
115and `git push` both need a long timeout — or a background run — are in\n\
116[AGENTS.md](AGENTS.md) — read it before committing. Both files are\n\
117generated: run `amont agents-md` after changing either.\n\
118\n\
119{END}\n"
120 )
121}
122
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub enum MarkerState {
125 Malformed,
128}
129
130fn marker_range(text: &str) -> Result<Option<Range<usize>>, MarkerState> {
139 let start = text.find(START);
140 let end = text.find(END);
141
142 match (start, end) {
143 (None, None) => Ok(None),
144 (Some(s), Some(e)) if e >= s + START.len() => {
145 let mut range_end = e + END.len();
146 if text[range_end..].starts_with('\n') {
147 range_end += 1; }
149 Ok(Some(s..range_end))
150 }
151 _ => Err(MarkerState::Malformed),
153 }
154}
155
156pub fn desired_file_content(
164 settings: &crate::config::Settings,
165 existing: &str,
166) -> Result<String, MarkerState> {
167 desired_with(existing, &generate_block(settings))
168}
169
170pub fn desired_pointer_content(existing: &str) -> Result<String, MarkerState> {
172 desired_with(existing, &generate_pointer())
173}
174
175fn desired_with(existing: &str, block: &str) -> Result<String, MarkerState> {
180 match marker_range(existing)? {
181 Some(range) => Ok(format!(
182 "{}{}{}",
183 &existing[..range.start],
184 block,
185 &existing[range.end..]
186 )),
187 None if existing.is_empty() => Ok(block.to_string()),
188 None => {
189 let sep = if existing.ends_with("\n\n") {
190 ""
191 } else if existing.ends_with('\n') {
192 "\n"
193 } else {
194 "\n\n"
195 };
196 Ok(format!("{existing}{sep}{block}"))
197 }
198 }
199}
200
201pub fn write(settings: &crate::config::Settings, path: &Path) -> Result<(), String> {
202 write_with(path, &generate_block(settings))
203}
204
205pub fn write_pointer(path: &Path) -> Result<(), String> {
207 write_with(path, &generate_pointer())
208}
209
210fn write_with(path: &Path, block: &str) -> Result<(), String> {
211 let existing = std::fs::read_to_string(path).unwrap_or_default();
212 let content = desired_with(&existing, block).map_err(|_| {
213 format!(
214 "{}: has an unpaired amont marker — fix or remove it by hand, \
215 then re-run `amont agents-md`",
216 path.display()
217 )
218 })?;
219 if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
220 std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
221 }
222 std::fs::write(path, content).map_err(|e| format!("{}: {e}", path.display()))
223}
224
225#[derive(Debug, Clone, Copy, PartialEq, Eq)]
226pub enum CheckResult {
227 NotPresent,
230 MatchesGenerated,
231 Drifted,
232}
233
234pub fn check(settings: &crate::config::Settings, path: &Path) -> Result<CheckResult, String> {
235 check_with(path, &generate_block(settings))
236}
237
238pub fn check_pointer(path: &Path) -> Result<CheckResult, String> {
240 check_with(path, &generate_pointer())
241}
242
243fn check_with(path: &Path, block: &str) -> Result<CheckResult, String> {
244 let existing = std::fs::read_to_string(path).unwrap_or_default();
245 match marker_range(&existing) {
246 Err(_) => Err(format!(
247 "{}: has an unpaired amont marker — fix or remove it by hand",
248 path.display()
249 )),
250 Ok(None) => Ok(CheckResult::NotPresent),
251 Ok(Some(range)) => {
252 if &existing[range] == block {
253 Ok(CheckResult::MatchesGenerated)
254 } else {
255 Ok(CheckResult::Drifted)
256 }
257 }
258 }
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264
265 fn test_settings() -> crate::config::Settings {
268 crate::config::Settings::default()
269 }
270
271 #[test]
272 fn no_file_produces_just_the_block() {
273 assert_eq!(
274 desired_file_content(&test_settings(), "").unwrap(),
275 generate_block(&test_settings())
276 );
277 }
278
279 #[test]
288 fn the_generated_markdown_satisfies_prettier() {
289 for (what, text) in [
290 ("block", generate_block(&test_settings())),
291 ("pointer", generate_pointer()),
292 ] {
293 assert!(
294 text.starts_with(&format!("{START}\n\n")),
295 "{what}: prettier wants a blank line after the opening comment"
296 );
297 assert!(
298 text.ends_with(&format!("\n\n{END}\n")),
299 "{what}: prettier wants a blank line before the closing comment"
300 );
301 let opener = text
306 .as_bytes()
307 .windows(2)
308 .any(|w| w[0] == b'*' && w[1].is_ascii_alphanumeric());
309 assert!(
310 !opener,
311 "{what}: prettier rewrites *emphasis* into _emphasis_, and a \
312 formatted file then reads as drift — emit underscores"
313 );
314 }
315 }
316
317 #[test]
321 fn the_pointer_is_generated_and_therefore_checkable() {
322 let p = generate_pointer();
323 assert!(p.starts_with(START) && p.ends_with(&format!("{END}\n")));
324 assert!(p.contains("AGENTS.md"), "it has to name where to look");
325 assert_ne!(
326 p,
327 generate_block(&test_settings()),
328 "a signpost, not a second copy"
329 );
330 assert!(
331 !p.contains("amont list --json"),
332 "the registry command lives in ONE place; duplicating it here is \
333 the drift this design avoids"
334 );
335 let existing = "# Notes\n\nsomething the repo wrote\n";
338 let once = desired_pointer_content(existing).unwrap();
339 assert!(once.starts_with(existing), "never touches what was there");
340 assert!(once.ends_with(&p));
341 assert_eq!(
342 desired_pointer_content(&once).unwrap(),
343 once,
344 "re-running is a no-op, so `--check` can be trusted"
345 );
346 }
347
348 #[test]
349 fn no_markers_appends_with_one_blank_line() {
350 let got = desired_file_content(&test_settings(), "# My Project\n\nSome docs.\n").unwrap();
351 assert!(got.starts_with("# My Project\n\nSome docs.\n\n"));
352 assert!(got.ends_with(&generate_block(&test_settings())));
353 }
354
355 #[test]
356 fn no_markers_and_no_trailing_newline_still_gets_a_blank_line() {
357 let got = desired_file_content(&test_settings(), "# My Project").unwrap();
358 assert!(got.starts_with("# My Project\n\n"));
359 }
360
361 #[test]
362 fn existing_markers_are_replaced_and_everything_else_survives() {
363 let before = format!("before\n\n{START}\nstale\n{END}\n\nafter\n");
364 let got = desired_file_content(&test_settings(), &before).unwrap();
365 assert!(got.starts_with("before\n\n"));
366 assert!(got.ends_with("\n\nafter\n"));
367 assert!(got.contains(&generate_block(&test_settings())));
368 assert!(!got.contains("stale"));
369 }
370
371 #[test]
372 fn applying_twice_is_idempotent() {
373 let once = desired_file_content(&test_settings(), "preamble\n").unwrap();
374 let twice = desired_file_content(&test_settings(), &once).unwrap();
375 assert_eq!(once, twice);
376 }
377
378 #[test]
379 fn an_unpaired_marker_is_refused_not_guessed_at() {
380 assert_eq!(
381 desired_file_content(&test_settings(), &format!("{START}\nno end here\n")),
382 Err(MarkerState::Malformed)
383 );
384 assert_eq!(
385 desired_file_content(&test_settings(), &format!("no start\n{END}\n")),
386 Err(MarkerState::Malformed)
387 );
388 }
389
390 #[test]
395 fn end_appearing_before_start_is_malformed_not_reordered() {
396 assert_eq!(
397 desired_file_content(&test_settings(), &format!("{END}\n...\n{START}\n...\n")),
398 Err(MarkerState::Malformed)
399 );
400 }
401
402 #[test]
403 fn check_reports_not_present_for_a_missing_file() {
404 let tmp = std::env::temp_dir().join("amont-agents-md-test-nonexistent-xyz");
405 let _ = std::fs::remove_file(&tmp);
406 assert_eq!(
407 check(&test_settings(), &tmp).unwrap(),
408 CheckResult::NotPresent
409 );
410 }
411
412 #[test]
413 fn check_reports_matches_generated_after_a_write() {
414 let tmp =
415 std::env::temp_dir().join(format!("amont-agents-md-test-{}-match", std::process::id()));
416 std::fs::write(&tmp, generate_block(&test_settings())).unwrap();
417 assert_eq!(
418 check(&test_settings(), &tmp).unwrap(),
419 CheckResult::MatchesGenerated
420 );
421 let _ = std::fs::remove_file(&tmp);
422 }
423
424 #[test]
425 fn check_reports_drifted_for_a_stale_block() {
426 let tmp =
427 std::env::temp_dir().join(format!("amont-agents-md-test-{}-drift", std::process::id()));
428 std::fs::write(&tmp, format!("{START}\nstale\n{END}\n")).unwrap();
429 assert_eq!(check(&test_settings(), &tmp).unwrap(), CheckResult::Drifted);
430 let _ = std::fs::remove_file(&tmp);
431 }
432}