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