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 check waiting on a cargo or uv lock answers to\n\
74`amont.lockWait` instead, the silence budget stretches with the machine's\n\
75load (`amont.idleLoadScale`), and a heavy check (clippy, a test suite) may\n\
76first queue for a host slot (`amont.hostSlots`) while other worktrees run\n\
77theirs. A test suite may legitimately run for most of that. If your tooling caps a foreground command below it,\n\
78run the command in the background and read its result when it exits —\n\
79while it runs, a line a minute on stderr says which check is alive and\n\
80when it last printed. A push killed mid-suite pushed nothing; a commit\n\
81killed mid-check committed nothing, and your unstaged work stays parked\n\
82until the next run says how to recover it. Neither is the checks failing —\n\
83it is the timeout. Run both bare and check the effect (`git log --oneline\n\
84-1`, `git ls-remote origin <branch>`): trimming their output with `| tail`\n\
85reports the pipe's exit status, so a killed or rejected run reads as\n\
86success.\n\
87\n\
88Never bypass with `--no-verify`. To change enforcement, downgrade it\n\
89intentionally instead:\n\
90\n\
91```sh\n\
92git config amont.severity.<check-id> warn\n\
93```\n\
94\n\
95`commit-msg` takes neither `hook.skip` nor a severity override. Write the\n\
96message it asks for, or change what it asks for — `amont setup`, or\n\
97`amont.commit.*` directly.\n\
98\n\
99{END}\n"
100 )
101}
102
103pub fn generate_pointer() -> String {
112 format!(
113 "{START}\n\
114\n\
115## Git hooks (amont)\n\
116\n\
117This repository enforces pre-commit / pre-push checks that can REJECT a\n\
118commit or a push. What runs, the branch-name rule, and why `git commit`\n\
119and `git push` both need a long timeout — or a background run — are in\n\
120[AGENTS.md](AGENTS.md) — read it before committing. Both files are\n\
121generated: run `amont agents-md` after changing either.\n\
122\n\
123{END}\n"
124 )
125}
126
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128pub enum MarkerState {
129 Malformed,
132}
133
134fn marker_range(text: &str) -> Result<Option<Range<usize>>, MarkerState> {
143 let start = text.find(START);
144 let end = text.find(END);
145
146 match (start, end) {
147 (None, None) => Ok(None),
148 (Some(s), Some(e)) if e >= s + START.len() => {
149 let mut range_end = e + END.len();
150 if text[range_end..].starts_with('\n') {
151 range_end += 1; }
153 Ok(Some(s..range_end))
154 }
155 _ => Err(MarkerState::Malformed),
157 }
158}
159
160pub fn desired_file_content(
168 settings: &crate::config::Settings,
169 existing: &str,
170) -> Result<String, MarkerState> {
171 desired_with(existing, &generate_block(settings))
172}
173
174pub fn desired_pointer_content(existing: &str) -> Result<String, MarkerState> {
176 desired_with(existing, &generate_pointer())
177}
178
179fn desired_with(existing: &str, block: &str) -> Result<String, MarkerState> {
184 match marker_range(existing)? {
185 Some(range) => Ok(format!(
186 "{}{}{}",
187 &existing[..range.start],
188 block,
189 &existing[range.end..]
190 )),
191 None if existing.is_empty() => Ok(block.to_string()),
192 None => {
193 let sep = if existing.ends_with("\n\n") {
194 ""
195 } else if existing.ends_with('\n') {
196 "\n"
197 } else {
198 "\n\n"
199 };
200 Ok(format!("{existing}{sep}{block}"))
201 }
202 }
203}
204
205pub fn write(settings: &crate::config::Settings, path: &Path) -> Result<(), String> {
206 write_with(path, &generate_block(settings))
207}
208
209pub fn write_pointer(path: &Path) -> Result<(), String> {
211 write_with(path, &generate_pointer())
212}
213
214fn write_with(path: &Path, block: &str) -> Result<(), String> {
215 let existing = std::fs::read_to_string(path).unwrap_or_default();
216 let content = desired_with(&existing, block).map_err(|_| {
217 format!(
218 "{}: has an unpaired amont marker — fix or remove it by hand, \
219 then re-run `amont agents-md`",
220 path.display()
221 )
222 })?;
223 if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
224 std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
225 }
226 std::fs::write(path, content).map_err(|e| format!("{}: {e}", path.display()))
227}
228
229#[derive(Debug, Clone, Copy, PartialEq, Eq)]
230pub enum CheckResult {
231 NotPresent,
234 MatchesGenerated,
235 Drifted,
236}
237
238pub fn check(settings: &crate::config::Settings, path: &Path) -> Result<CheckResult, String> {
239 check_with(path, &generate_block(settings))
240}
241
242pub fn check_pointer(path: &Path) -> Result<CheckResult, String> {
244 check_with(path, &generate_pointer())
245}
246
247fn check_with(path: &Path, block: &str) -> Result<CheckResult, String> {
248 let existing = std::fs::read_to_string(path).unwrap_or_default();
249 match marker_range(&existing) {
250 Err(_) => Err(format!(
251 "{}: has an unpaired amont marker — fix or remove it by hand",
252 path.display()
253 )),
254 Ok(None) => Ok(CheckResult::NotPresent),
255 Ok(Some(range)) => {
256 if &existing[range] == block {
257 Ok(CheckResult::MatchesGenerated)
258 } else {
259 Ok(CheckResult::Drifted)
260 }
261 }
262 }
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268
269 fn test_settings() -> crate::config::Settings {
272 crate::config::Settings::default()
273 }
274
275 #[test]
276 fn no_file_produces_just_the_block() {
277 assert_eq!(
278 desired_file_content(&test_settings(), "").unwrap(),
279 generate_block(&test_settings())
280 );
281 }
282
283 #[test]
292 fn the_generated_markdown_satisfies_prettier() {
293 for (what, text) in [
294 ("block", generate_block(&test_settings())),
295 ("pointer", generate_pointer()),
296 ] {
297 assert!(
298 text.starts_with(&format!("{START}\n\n")),
299 "{what}: prettier wants a blank line after the opening comment"
300 );
301 assert!(
302 text.ends_with(&format!("\n\n{END}\n")),
303 "{what}: prettier wants a blank line before the closing comment"
304 );
305 let opener = text
310 .as_bytes()
311 .windows(2)
312 .any(|w| w[0] == b'*' && w[1].is_ascii_alphanumeric());
313 assert!(
314 !opener,
315 "{what}: prettier rewrites *emphasis* into _emphasis_, and a \
316 formatted file then reads as drift — emit underscores"
317 );
318 }
319 }
320
321 #[test]
325 fn the_pointer_is_generated_and_therefore_checkable() {
326 let p = generate_pointer();
327 assert!(p.starts_with(START) && p.ends_with(&format!("{END}\n")));
328 assert!(p.contains("AGENTS.md"), "it has to name where to look");
329 assert_ne!(
330 p,
331 generate_block(&test_settings()),
332 "a signpost, not a second copy"
333 );
334 assert!(
335 !p.contains("amont list --json"),
336 "the registry command lives in ONE place; duplicating it here is \
337 the drift this design avoids"
338 );
339 let existing = "# Notes\n\nsomething the repo wrote\n";
342 let once = desired_pointer_content(existing).unwrap();
343 assert!(once.starts_with(existing), "never touches what was there");
344 assert!(once.ends_with(&p));
345 assert_eq!(
346 desired_pointer_content(&once).unwrap(),
347 once,
348 "re-running is a no-op, so `--check` can be trusted"
349 );
350 }
351
352 #[test]
353 fn no_markers_appends_with_one_blank_line() {
354 let got = desired_file_content(&test_settings(), "# My Project\n\nSome docs.\n").unwrap();
355 assert!(got.starts_with("# My Project\n\nSome docs.\n\n"));
356 assert!(got.ends_with(&generate_block(&test_settings())));
357 }
358
359 #[test]
360 fn no_markers_and_no_trailing_newline_still_gets_a_blank_line() {
361 let got = desired_file_content(&test_settings(), "# My Project").unwrap();
362 assert!(got.starts_with("# My Project\n\n"));
363 }
364
365 #[test]
366 fn existing_markers_are_replaced_and_everything_else_survives() {
367 let before = format!("before\n\n{START}\nstale\n{END}\n\nafter\n");
368 let got = desired_file_content(&test_settings(), &before).unwrap();
369 assert!(got.starts_with("before\n\n"));
370 assert!(got.ends_with("\n\nafter\n"));
371 assert!(got.contains(&generate_block(&test_settings())));
372 assert!(!got.contains("stale"));
373 }
374
375 #[test]
376 fn applying_twice_is_idempotent() {
377 let once = desired_file_content(&test_settings(), "preamble\n").unwrap();
378 let twice = desired_file_content(&test_settings(), &once).unwrap();
379 assert_eq!(once, twice);
380 }
381
382 #[test]
383 fn an_unpaired_marker_is_refused_not_guessed_at() {
384 assert_eq!(
385 desired_file_content(&test_settings(), &format!("{START}\nno end here\n")),
386 Err(MarkerState::Malformed)
387 );
388 assert_eq!(
389 desired_file_content(&test_settings(), &format!("no start\n{END}\n")),
390 Err(MarkerState::Malformed)
391 );
392 }
393
394 #[test]
399 fn end_appearing_before_start_is_malformed_not_reordered() {
400 assert_eq!(
401 desired_file_content(&test_settings(), &format!("{END}\n...\n{START}\n...\n")),
402 Err(MarkerState::Malformed)
403 );
404 }
405
406 #[test]
407 fn check_reports_not_present_for_a_missing_file() {
408 let tmp = std::env::temp_dir().join("amont-agents-md-test-nonexistent-xyz");
409 let _ = std::fs::remove_file(&tmp);
410 assert_eq!(
411 check(&test_settings(), &tmp).unwrap(),
412 CheckResult::NotPresent
413 );
414 }
415
416 #[test]
417 fn check_reports_matches_generated_after_a_write() {
418 let tmp =
419 std::env::temp_dir().join(format!("amont-agents-md-test-{}-match", std::process::id()));
420 std::fs::write(&tmp, generate_block(&test_settings())).unwrap();
421 assert_eq!(
422 check(&test_settings(), &tmp).unwrap(),
423 CheckResult::MatchesGenerated
424 );
425 let _ = std::fs::remove_file(&tmp);
426 }
427
428 #[test]
429 fn check_reports_drifted_for_a_stale_block() {
430 let tmp =
431 std::env::temp_dir().join(format!("amont-agents-md-test-{}-drift", std::process::id()));
432 std::fs::write(&tmp, format!("{START}\nstale\n{END}\n")).unwrap();
433 assert_eq!(check(&test_settings(), &tmp).unwrap(), CheckResult::Drifted);
434 let _ = std::fs::remove_file(&tmp);
435 }
436}