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