Installs pre-commit hooks for OCaml projects that run dune fmt automatically
1(** CLI for pre-commit hook initialisation. *)
2
3open Cmdliner
4
5let setup =
6 Term.(
7 const (fun () () -> ())
8 $ Vlog.setup ~json_reporter:None "precommit"
9 $ Memtrace.term)
10
11(* {1 Styled output} *)
12
13let success fmt =
14 Fmt.pf Fmt.stdout ("%a " ^^ fmt ^^ "@.") Fmt.(styled (`Fg `Green) string) "✓"
15
16let error fmt =
17 Fmt.pf Fmt.stderr ("%a " ^^ fmt ^^ "@.") Fmt.(styled (`Fg `Red) string) "✗"
18
19let info fmt =
20 Fmt.pf Fmt.stdout ("%a " ^^ fmt ^^ "@.") Fmt.(styled (`Fg `Cyan) string) "ℹ"
21
22(* {1 Common arguments} *)
23
24let chdir =
25 let doc = "Change to $(docv) before running. Equivalent to cd $(docv)." in
26 Arg.(value & opt (some dir) None & info [ "C" ] ~docv:"DIR" ~doc)
27
28let dirs =
29 let doc =
30 "Root directories to scan for git projects. Defaults to the current \
31 directory. Each directory is scanned recursively for repositories \
32 containing a $(b,.git) entry."
33 in
34 Arg.(value & pos_all dir [ "." ] & info [] ~docv:"DIR" ~doc)
35
36let dry_run =
37 let doc = "Show what would be done without making changes." in
38 Arg.(value & flag & info [ "n"; "dry-run" ] ~doc)
39
40let force =
41 let doc = "Install hooks even if no dune-project is found." in
42 Arg.(value & flag & info [ "f"; "force" ] ~doc)
43
44let hooks_conv =
45 let parse s =
46 let parts = String.split_on_char ',' s in
47 let fmt = List.mem "fmt" parts in
48 let ai = List.mem "ai" parts in
49 if fmt || ai then Ok Precommit.{ fmt; ai }
50 else Error (`Msg "expected comma-separated list of: fmt, ai")
51 in
52 let print ppf h =
53 let parts =
54 (if h.Precommit.fmt then [ "fmt" ] else [])
55 @ if h.Precommit.ai then [ "ai" ] else []
56 in
57 Fmt.string ppf (String.concat "," parts)
58 in
59 Arg.conv (parse, print)
60
61let hooks =
62 let doc =
63 "Which hooks to install. Comma-separated list of: $(b,fmt) (pre-commit \
64 hook running dune fmt), $(b,ai) (commit-msg hook removing Claude \
65 attribution). Default: all."
66 in
67 Arg.(
68 value
69 & opt hooks_conv Precommit.all_hooks
70 & info [ "hooks" ] ~doc ~docv:"HOOKS")
71
72(* {1 Helpers} *)
73
74let or_die = function
75 | Ok () -> ()
76 | Error msg ->
77 error "%s" msg;
78 exit 1
79
80let collect_dirs ctx dirs =
81 let result = List.concat_map (fun d -> Precommit.git_projects ctx d) dirs in
82 if result = [] then begin
83 error "No git repositories found";
84 exit 1
85 end;
86 result
87
88let with_ctx chdir f =
89 Eio_main.run @@ fun env ->
90 let fs = Eio.Stdenv.fs env in
91 let cwd =
92 match chdir with None -> Eio.Stdenv.cwd env | Some d -> Eio.Path.(fs / d)
93 in
94 f (Precommit.ctx ~cwd ~fs)
95
96(* {1 Init command} *)
97
98let init_impl ctx ~dry_run ~force hooks dirs =
99 let dirs = collect_dirs ctx dirs in
100 let count = ref 0 in
101 let skipped_not_ocaml = ref 0 in
102 let already_configured = ref 0 in
103 List.iter
104 (fun d ->
105 let s = Precommit.status_in_dir ctx d in
106 if not s.is_git_repo then ()
107 else if not (force || s.is_ocaml_project) then incr skipped_not_ocaml
108 else
109 let needs_fmt = hooks.Precommit.fmt && not s.has_pre_commit in
110 let needs_ai = hooks.Precommit.ai && not s.has_commit_msg in
111 if needs_fmt || needs_ai then begin
112 or_die (Precommit.init_in_dir ctx ~dry_run ~force ~hooks d);
113 incr count;
114 if dry_run then info "Would initialise %a" Fmt.(styled `Bold string) d
115 else success "Initialised %a" Fmt.(styled `Bold string) d
116 end
117 else incr already_configured)
118 dirs;
119 if !count > 0 then
120 success "Processed %d director%s" !count (if !count = 1 then "y" else "ies")
121 else if !already_configured > 0 then
122 info "All directories already have hooks installed"
123 else if !skipped_not_ocaml > 0 then begin
124 info "No OCaml projects found (use --force to install anyway)";
125 exit 1
126 end
127
128let init_cmd =
129 let doc = "Initialise pre-commit hooks for OCaml projects." in
130 let man =
131 [
132 `S Manpage.s_description;
133 `P
134 "Install git hooks that run $(b,dune fmt) before commit and remove \
135 Claude attribution from commit messages. Also creates \
136 $(b,.ocamlformat) if missing (unless $(b,--force) is used).";
137 `S Manpage.s_examples;
138 `P "Initialise hooks in the current directory:";
139 `Pre " precommit init";
140 `P "Initialise hooks in all projects under src/:";
141 `Pre " precommit init src/";
142 `P "Preview what would be done:";
143 `Pre " precommit init -n";
144 `P "Install only the AI attribution hook in a non-OCaml project:";
145 `Pre " precommit init -f --hooks ai";
146 `P "Install only the dune fmt hook:";
147 `Pre " precommit init --hooks fmt";
148 ]
149 in
150 let info = Cmd.info "init" ~doc ~man in
151 Cmd.v info
152 Term.(
153 const (fun chdir dry_run force hooks dirs () ->
154 with_ctx chdir (fun ctx -> init_impl ctx ~dry_run ~force hooks dirs))
155 $ chdir $ dry_run $ force $ hooks $ dirs $ setup)
156
157(* {1 Status command} *)
158
159let check_span b =
160 if b then Tty.Span.styled Tty.Style.(fg Tty.Color.green) "+"
161 else Tty.Span.styled Tty.Style.(fg Tty.Color.red) "-"
162
163let status_impl ctx dirs =
164 let dirs = collect_dirs ctx dirs in
165 let missing = ref 0 in
166 let ok = ref 0 in
167 let rows =
168 List.map
169 (fun d ->
170 let s = Precommit.status_in_dir ctx d in
171 if s.is_ocaml_project && s.is_git_repo then begin
172 if not (s.has_pre_commit && s.has_commit_msg && s.has_ocamlformat)
173 then incr missing
174 else if s.formatting_disabled then incr missing
175 else incr ok
176 end;
177 [
178 Tty.Span.text d;
179 check_span s.has_pre_commit;
180 check_span s.has_commit_msg;
181 check_span s.has_ocamlformat;
182 check_span (not s.formatting_disabled);
183 ])
184 dirs
185 in
186 let table =
187 Tty.Table.(
188 of_rows ~border:Tty.Border.rounded
189 [
190 column "directory";
191 column ~align:`Center "pre-commit";
192 column ~align:`Center "commit-msg";
193 column ~align:`Center "ocamlformat";
194 column ~align:`Center "formatting";
195 ]
196 rows)
197 in
198 Tty.Table.pp Format.std_formatter table;
199 Format.pp_print_newline Format.std_formatter ();
200 (* Summary *)
201 if !missing > 0 then begin
202 Fmt.pf Fmt.stdout "%a %d project%s with missing hooks@."
203 Fmt.(styled (`Fg `Red) string)
204 "✗" !missing
205 (if !missing = 1 then "" else "s");
206 exit 1
207 end
208 else if !ok > 0 then
209 success "%d project%s properly configured" !ok (if !ok = 1 then "" else "s")
210
211let status chdir dirs () = with_ctx chdir (fun ctx -> status_impl ctx dirs)
212
213let status_cmd =
214 let doc = "Check pre-commit hook status." in
215 let man =
216 [
217 `S Manpage.s_description;
218 `P "Show which directories have hooks installed.";
219 `P
220 "Columns show: pre-commit hook, commit-msg hook, .ocamlformat file, \
221 formatting enabled. Exit code is 1 if any OCaml project is missing \
222 hooks, .ocamlformat, or has formatting disabled.";
223 `S Manpage.s_examples;
224 `P "Check status of all projects under src/:";
225 `Pre " precommit status src/";
226 ]
227 in
228 let info = Cmd.info "status" ~doc ~man in
229 Cmd.v info Term.(const status $ chdir $ dirs $ setup)
230
231(* {1 Check command} *)
232
233let terminal_width () =
234 try
235 let ic = Unix.open_process_in "tput cols 2>/dev/null" in
236 let width = int_of_string (String.trim (input_line ic)) in
237 ignore (Unix.close_process_in ic);
238 width
239 with Failure _ | End_of_file | Unix.Unix_error _ -> 80
240
241let truncate_subject max_len s =
242 if String.length s <= max_len then s else String.sub s 0 (max_len - 1) ^ "…"
243
244(* Shared: find AI commits across dirs and display a unified table.
245 Returns [(affected_dirs, total_commits, repos_with_issues)]. *)
246let commit_row subject_max d first (c : Precommit.ai_commit) =
247 let project_cell =
248 if !first then begin
249 first := false;
250 Tty.Span.styled Tty.Style.bold d
251 end
252 else Tty.Span.text ""
253 in
254 [
255 project_cell;
256 Tty.Span.styled Tty.Style.(fg Tty.Color.yellow) c.hash;
257 Tty.Span.text (truncate_subject subject_max c.subject);
258 ]
259
260let display_ai_commits ctx dirs =
261 let term_width = terminal_width () in
262 let subject_max = max 20 (term_width - 35) in
263 let total_commits = ref 0 in
264 let repos_with_issues = ref 0 in
265 let all_rows = ref [] in
266 let affected_dirs = ref [] in
267 List.iter
268 (fun d ->
269 let commits = Precommit.check_ai_attribution ctx d in
270 if commits <> [] then begin
271 incr repos_with_issues;
272 total_commits := !total_commits + List.length commits;
273 affected_dirs := d :: !affected_dirs;
274 let first = ref true in
275 let rows = List.map (commit_row subject_max d first) commits in
276 all_rows := !all_rows @ rows
277 end)
278 dirs;
279 if !all_rows <> [] then begin
280 let table =
281 Tty.Table.(
282 of_rows ~border:Tty.Border.rounded
283 [
284 column ~align:`Left "project";
285 column ~align:`Left "hash";
286 column ~align:`Left "subject";
287 ]
288 !all_rows)
289 in
290 Tty.Table.pp Format.std_formatter table;
291 Format.pp_print_newline Format.std_formatter ()
292 end;
293 (List.rev !affected_dirs, !total_commits, !repos_with_issues)
294
295let check_impl ctx dirs =
296 let dirs = collect_dirs ctx dirs in
297 let _affected, total_commits, repos_with_issues =
298 display_ai_commits ctx dirs
299 in
300 if total_commits > 0 then begin
301 error "%d commit%s with AI attribution in %d repo%s" total_commits
302 (if total_commits = 1 then "" else "s")
303 repos_with_issues
304 (if repos_with_issues = 1 then "" else "s");
305 exit 1
306 end
307 else success "No AI attribution found in commit history"
308
309let check chdir dirs () = with_ctx chdir (fun ctx -> check_impl ctx dirs)
310
311let check_cmd =
312 let doc = "Check git history for commits with AI attribution." in
313 let man =
314 [
315 `S Manpage.s_description;
316 `P
317 "Scan git history for commits by the configured user that contain \
318 'claude' in the commit message. Exit code is 1 if any are found.";
319 `S Manpage.s_examples;
320 `P "Check all projects under src/:";
321 `Pre " precommit check src/";
322 `P "Check a specific directory:";
323 `Pre " precommit check -C ../src/ocaml-requests";
324 ]
325 in
326 let info = Cmd.info "check" ~doc ~man in
327 Cmd.v info Term.(const check $ chdir $ dirs $ setup)
328
329(* {1 Fix command} *)
330
331let confirm_prompt n_repos =
332 let warning =
333 Tty.Panel.lines
334 ~border:
335 (Tty.Border.with_style
336 Tty.Style.(fg Tty.Color.yellow)
337 Tty.Border.rounded)
338 ~title:(Tty.Span.styled Tty.Style.(fg Tty.Color.yellow) "Warning")
339 [
340 Tty.Span.text
341 (Fmt.str "This will rewrite git history in %d repositor%s." n_repos
342 (if n_repos = 1 then "y" else "ies"));
343 Tty.Span.text "Branches will be backed up before rewriting.";
344 ]
345 in
346 Tty.Panel.pp Format.std_formatter warning;
347 Format.pp_print_newline Format.std_formatter ();
348 Fmt.pf Fmt.stdout "Continue? [y/N] %!";
349 let line = try input_line stdin with End_of_file -> "" in
350 let answer = String.trim line in
351 answer = "y" || answer = "Y"
352
353let plural n = if n = 1 then "" else "s"
354
355let run_fixes ctx affected =
356 let fixed = Atomic.make 0 in
357 let errors = Atomic.make 0 in
358 Eio.Fiber.all
359 (List.map
360 (fun d () ->
361 let backup = Precommit.backup_branch ctx d in
362 success "%s: backed up to %s" d backup;
363 match Precommit.rewrite_ai_attribution ctx d with
364 | Ok _ ->
365 Atomic.incr fixed;
366 success "%s: attribution removed" d
367 | Error msg ->
368 Atomic.incr errors;
369 error "%s" msg)
370 affected);
371 (Atomic.get fixed, Atomic.get errors)
372
373let fix_impl ctx ~dry_run ~yes dirs =
374 let dirs = collect_dirs ctx dirs in
375 let affected, total_commits, repos_with_issues =
376 display_ai_commits ctx dirs
377 in
378 if total_commits = 0 then success "No AI attribution found in commit history"
379 else if dry_run then begin
380 info "Would rewrite %d commit%s in %d repo%s" total_commits
381 (plural total_commits) repos_with_issues (plural repos_with_issues);
382 List.iter
383 (fun d ->
384 let branch = Precommit.current_branch ctx d in
385 let name = Option.value ~default:"HEAD" branch in
386 info "Would backup %s:%s before rewriting" d name)
387 affected
388 end
389 else begin
390 if not yes then
391 if not (confirm_prompt repos_with_issues) then begin
392 info "Aborted";
393 exit 0
394 end;
395 Format.pp_print_newline Format.std_formatter ();
396 let n_fixed, n_errors = run_fixes ctx affected in
397 if n_errors > 0 then begin
398 error "%d repo%s fixed, %d error%s" n_fixed (plural n_fixed) n_errors
399 (plural n_errors);
400 exit 1
401 end
402 else success "%d repo%s fixed" n_fixed (plural n_fixed)
403 end
404
405let yes =
406 let doc = "Skip interactive confirmation prompt." in
407 Arg.(value & flag & info [ "y"; "yes" ] ~doc)
408
409let fix_cmd =
410 let doc = "Remove AI attribution from commit history." in
411 let man =
412 [
413 `S Manpage.s_description;
414 `P
415 "Scan git history for commits with AI attribution (Co-Authored-By: \
416 Claude) and rewrite them to remove the attribution lines. This \
417 rewrites git history using $(b,git filter-branch).";
418 `P
419 "Before rewriting, the current branch is backed up to \
420 $(b,backup/<branch>-before-fix-<timestamp>). Use $(b,--yes) to skip \
421 the interactive confirmation prompt.";
422 `S Manpage.s_examples;
423 `P "Fix all projects under the current directory:";
424 `Pre " precommit fix";
425 `P "Preview what would be done:";
426 `Pre " precommit fix -n";
427 `P "Fix without confirmation prompt:";
428 `Pre " precommit fix -y";
429 ]
430 in
431 let info = Cmd.info "fix" ~doc ~man in
432 Cmd.v info
433 Term.(
434 const (fun chdir dry_run yes dirs () ->
435 with_ctx chdir (fun ctx -> fix_impl ctx ~dry_run ~yes dirs))
436 $ chdir $ dry_run $ yes $ dirs $ setup)
437
438(* {1 Main} *)
439
440let cmd =
441 let doc = "Manage pre-commit hooks for OCaml projects." in
442 let man =
443 [
444 `S Manpage.s_description;
445 `P
446 "$(tname) installs git hooks that enforce code formatting and commit \
447 message hygiene for OCaml projects.";
448 `S "ENVIRONMENT";
449 `P
450 "$(b,PRECOMMIT_LOG) can be set to configure logging levels (e.g., \
451 $(b,debug) or $(b,info)).";
452 `S Manpage.s_bugs;
453 `P "Report issues at https://github.com/gazagnaire.org/ocaml-precommit";
454 `S "EXIT STATUS";
455 `P "$(b,0) on success.";
456 `P "$(b,1) if hooks are missing (status) or initialisation failed.";
457 ]
458 in
459 let info = Cmd.info "precommit" ~version:Monopam_info.version ~doc ~man in
460 let default = setup in
461 Cmd.group info ~default [ init_cmd; status_cmd; check_cmd; fix_cmd ]
462
463let () = exit (Cmd.eval cmd)