Installs pre-commit hooks for OCaml projects that run dune fmt automatically
1(** Pre-commit hook initialization for OCaml projects.
2
3 Installs git hooks directly without requiring the pre-commit tool. *)
4
5let log_src = Logs.Src.create "precommit"
6
7module Log = (val Logs.src_log log_src : Logs.LOG)
8
9let err_no_dune_project dir =
10 Error (Fmt.str "%s: No dune-project found (use --force to override)" dir)
11
12let err_no_git_dir dir = Error (Fmt.str "%s: No .git directory found" dir)
13
14type ctx = { cwd : Eio.Fs.dir_ty Eio.Path.t; fs : Eio.Fs.dir_ty Eio.Path.t }
15
16let ctx ~cwd ~fs =
17 { cwd :> Eio.Fs.dir_ty Eio.Path.t; fs :> Eio.Fs.dir_ty Eio.Path.t }
18
19let pre_commit_hook =
20 {|#!/bin/sh
21# Auto-format OCaml files with dune before commit
22
23# Check if any OCaml files are staged
24STAGED_ML=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(ml|mli|mll|mly)$' || true)
25
26if [ -n "$STAGED_ML" ]; then
27 # Run dune fmt
28 if ! dune fmt 2>/dev/null; then
29 echo "Error: dune fmt failed" >&2
30 exit 1
31 fi
32
33 # Check if formatting changed any staged files
34 CHANGED=$(git diff --name-only $STAGED_ML 2>/dev/null || true)
35 if [ -n "$CHANGED" ]; then
36 echo "Files were reformatted by dune fmt:"
37 echo "$CHANGED"
38 echo ""
39 echo "Please review and stage the changes, then commit again."
40 exit 1
41 fi
42fi
43
44exit 0
45|}
46
47let commit_msg_hook =
48 {|#!/bin/sh
49# Check commit message for emojis and remove AI attribution lines
50
51COMMIT_MSG_FILE="$1"
52
53# Check for emojis using grep with Unicode ranges
54if grep -qP '[\x{1F600}-\x{1F64F}\x{1F300}-\x{1F5FF}\x{1F680}-\x{1F6FF}\x{1F1E0}-\x{1F1FF}\x{2702}-\x{27B0}\x{24C2}-\x{1F251}]' "$COMMIT_MSG_FILE" 2>/dev/null; then
55 echo "Error: Commit message contains emojis, which are not allowed." >&2
56 exit 1
57fi
58
59# Remove AI attribution lines (Co-Authored-By: Claude or similar patterns)
60if grep -qiE 'Co-Authored-By:.*[Cc]laude' "$COMMIT_MSG_FILE"; then
61 # Use sed to filter out AI attribution lines in-place
62 if [ "$(uname)" = "Darwin" ]; then
63 sed -i '' '/[Cc]o-[Aa]uthored-[Bb]y:.*[Cc]laude/d' "$COMMIT_MSG_FILE"
64 else
65 sed -i '/[Cc]o-[Aa]uthored-[Bb]y:.*[Cc]laude/d' "$COMMIT_MSG_FILE"
66 fi
67fi
68
69exit 0
70|}
71
72let default_ocamlformat = {|version = 0.28.1
73|}
74
75(* Regular expression for detecting "(formatting disabled)" in dune-project *)
76let formatting_disabled_re =
77 Re.compile (Re.Pcre.re {|\(formatting\s+disabled\)|})
78
79let file_exists ~fs path =
80 match Eio.Path.kind ~follow:true Eio.Path.(fs / path) with
81 | `Not_found -> false
82 | _ -> true
83
84let is_directory ~fs path =
85 match Eio.Path.kind ~follow:true Eio.Path.(fs / path) with
86 | `Directory -> true
87 | _ -> false
88
89let is_symlink ~fs path =
90 match Eio.Path.kind ~follow:false Eio.Path.(fs / path) with
91 | `Symbolic_link -> true
92 | _ -> false
93
94let read_file ~fs path = Eio.Path.load Eio.Path.(fs / path)
95
96let write_file ~fs ~dry_run path content =
97 if dry_run then Fmt.pr "Would create %s@." path
98 else Eio.Path.save ~create:(`Or_truncate 0o644) Eio.Path.(fs / path) content
99
100let chmod_exec ~dry_run path =
101 if dry_run then Fmt.pr "Would chmod +x %s@." path else Unix.chmod path 0o755
102
103let mkdir_p ~fs ~dry_run path =
104 if dry_run then Fmt.pr "Would create %s/@." path
105 else Eio.Path.mkdirs ~exists_ok:true ~perm:0o755 Eio.Path.(fs / path)
106
107let check_formatting_disabled ~fs path =
108 if not (file_exists ~fs path) then false
109 else
110 let content = read_file ~fs path in
111 Re.execp formatting_disabled_re content
112
113let resolve_git_dir ~fs git_dir_path =
114 if is_directory ~fs git_dir_path then git_dir_path
115 else
116 (* .git is a file pointing to the real git dir (worktree) *)
117 let content = read_file ~fs git_dir_path in
118 let line = String.trim (List.hd (String.split_on_char '\n' content)) in
119 match String.split_on_char ' ' line with
120 | "gitdir:" :: rest -> String.concat " " rest
121 | _ -> git_dir_path
122
123(* Find git root by walking up from dir.
124 [cwd] is the working directory path, [fs] is the full filesystem. *)
125let git_root ~cwd ~fs dir =
126 let _, cwd_path = cwd in
127 let base_path =
128 if cwd_path = "" || cwd_path = "." then Sys.getcwd () else cwd_path
129 in
130 let abs_dir =
131 if Filename.is_relative dir then Filename.concat base_path dir else dir
132 in
133 let abs_dir =
134 if
135 String.length abs_dir >= 2
136 && String.sub abs_dir (String.length abs_dir - 2) 2 = "/."
137 then String.sub abs_dir 0 (String.length abs_dir - 2)
138 else abs_dir
139 in
140 Log.debug (fun m -> m "git_root: dir=%s abs_dir=%s" dir abs_dir);
141 let rec walk path =
142 let git_dir = Filename.concat path ".git" in
143 Log.debug (fun m -> m "git_root: checking %s" git_dir);
144 match Eio.Path.kind ~follow:true Eio.Path.(fs / git_dir) with
145 | `Not_found ->
146 let parent = Filename.dirname path in
147 if parent = path then None else walk parent
148 | _ -> Some path
149 in
150 walk abs_dir
151
152type hooks = { fmt : bool; ai : bool }
153
154let all_hooks = { fmt = true; ai = true }
155
156let init_in_dir ctx ~dry_run ~force ~hooks dir =
157 let dune_project = Filename.concat dir "dune-project" in
158 let ocamlformat_path = Filename.concat dir ".ocamlformat" in
159 if (not force) && not (file_exists ~fs:ctx.cwd dune_project) then
160 err_no_dune_project dir
161 else
162 match git_root ~cwd:ctx.cwd ~fs:ctx.fs dir with
163 | None -> err_no_git_dir dir
164 | Some git_root ->
165 let git_dir_path = Filename.concat git_root ".git" in
166 let git_dir = resolve_git_dir ~fs:ctx.fs git_dir_path in
167 let hooks_dir = Filename.concat git_dir "hooks" in
168
169 (* Create hooks directory if needed *)
170 mkdir_p ~fs:ctx.fs ~dry_run hooks_dir;
171
172 (* Install pre-commit hook if requested *)
173 if hooks.fmt then begin
174 let pre_commit_path = Filename.concat hooks_dir "pre-commit" in
175 write_file ~fs:ctx.fs ~dry_run pre_commit_path pre_commit_hook;
176 chmod_exec ~dry_run pre_commit_path
177 end;
178
179 (* Install commit-msg hook if requested *)
180 if hooks.ai then begin
181 let commit_msg_path = Filename.concat hooks_dir "commit-msg" in
182 write_file ~fs:ctx.fs ~dry_run commit_msg_path commit_msg_hook;
183 chmod_exec ~dry_run commit_msg_path
184 end;
185
186 (* Create .ocamlformat if missing and fmt hooks requested *)
187 if hooks.fmt && not (file_exists ~fs:ctx.cwd ocamlformat_path) then
188 write_file ~fs:ctx.cwd ~dry_run ocamlformat_path default_ocamlformat;
189
190 Ok ()
191
192let init ctx ~dry_run ~force ~hooks () =
193 init_in_dir ctx ~dry_run ~force ~hooks "."
194
195type hook_status = {
196 has_pre_commit : bool;
197 has_commit_msg : bool;
198 has_ocamlformat : bool;
199 formatting_disabled : bool;
200 is_ocaml_project : bool;
201 is_git_repo : bool;
202}
203
204let status_in_dir ctx dir =
205 let dune_project = Filename.concat dir "dune-project" in
206 let ocamlformat_path = Filename.concat dir ".ocamlformat" in
207 let is_ocaml_project = file_exists ~fs:ctx.cwd dune_project in
208 let has_ocamlformat = file_exists ~fs:ctx.cwd ocamlformat_path in
209 let formatting_disabled =
210 check_formatting_disabled ~fs:ctx.cwd dune_project
211 in
212 match git_root ~cwd:ctx.cwd ~fs:ctx.fs dir with
213 | None ->
214 {
215 has_pre_commit = false;
216 has_commit_msg = false;
217 has_ocamlformat;
218 formatting_disabled;
219 is_ocaml_project;
220 is_git_repo = false;
221 }
222 | Some git_root ->
223 let git_dir_path = Filename.concat git_root ".git" in
224 let git_dir = resolve_git_dir ~fs:ctx.fs git_dir_path in
225 let hooks_dir = Filename.concat git_dir "hooks" in
226 let pre_commit_path = Filename.concat hooks_dir "pre-commit" in
227 let commit_msg_path = Filename.concat hooks_dir "commit-msg" in
228 {
229 has_pre_commit = file_exists ~fs:ctx.fs pre_commit_path;
230 has_commit_msg = file_exists ~fs:ctx.fs commit_msg_path;
231 has_ocamlformat;
232 formatting_disabled;
233 is_ocaml_project;
234 is_git_repo = true;
235 }
236
237let status ctx () = status_in_dir ctx "."
238
239let list_subdirs ~fs dir =
240 Eio.Path.read_dir Eio.Path.(fs / dir)
241 |> List.filter_map (fun name ->
242 if name.[0] = '.' then None
243 else
244 let path = Filename.concat dir name in
245 if is_directory ~fs path then Some path else None)
246 |> List.sort String.compare
247
248(* Check if dir is inside a git repo (ancestor has .git, not dir itself) *)
249let is_inside_git_repo ~cwd ~fs dir =
250 (* First check if dir itself has .git *)
251 let has_git_here = file_exists ~fs (Filename.concat dir ".git") in
252 if has_git_here then false
253 else
254 (* Check if an ancestor has .git *)
255 match git_root ~cwd ~fs dir with
256 | Some _ -> true
257 | None -> false
258
259(* Convert absolute path to relative path from cwd_path *)
260let relative ~cwd_path abs_path =
261 if
262 String.length abs_path >= String.length cwd_path
263 && String.sub abs_path 0 (String.length cwd_path) = cwd_path
264 then
265 let rest =
266 String.sub abs_path (String.length cwd_path)
267 (String.length abs_path - String.length cwd_path)
268 in
269 if rest = "" then "."
270 else if rest.[0] = '/' then String.sub rest 1 (String.length rest - 1)
271 else rest
272 else abs_path
273
274let self ctx dir entries =
275 if List.mem ".git" entries then [ dir ]
276 else if is_inside_git_repo ~cwd:ctx.cwd ~fs:ctx.fs dir then
277 match git_root ~cwd:ctx.cwd ~fs:ctx.fs dir with
278 | Some root ->
279 let _, cwd_path = ctx.cwd in
280 let cwd_path =
281 if cwd_path = "" || cwd_path = "." then Sys.getcwd () else cwd_path
282 in
283 [ relative ~cwd_path root ]
284 | None -> []
285 else []
286
287let rec git_projects ctx dir =
288 let entries =
289 try Eio.Path.read_dir Eio.Path.(ctx.cwd / dir) with Eio.Io _ -> []
290 in
291 let child_path name = if dir = "." then name else Filename.concat dir name in
292 let self = self ctx dir entries in
293 (* Only descend into children if we haven't found a git root yet *)
294 let children =
295 if self <> [] then []
296 else
297 entries
298 |> List.filter_map (fun name ->
299 if String.length name > 0 && (name.[0] = '.' || name.[0] = '_') then
300 None
301 else
302 let path = child_path name in
303 (* Skip symlinks to avoid traversing outside the sandbox *)
304 if is_symlink ~fs:ctx.cwd path then None
305 else if is_directory ~fs:ctx.cwd path then Some path
306 else None)
307 |> List.sort String.compare
308 |> List.concat_map (fun sub ->
309 if file_exists ~fs:ctx.cwd (Filename.concat sub ".git") then [ sub ]
310 else git_projects ctx sub)
311 in
312 self @ children
313
314type ai_commit = { hash : string; subject : string }
315
316(* AI attribution pattern for filtering commit messages *)
317let ai_pattern =
318 Re.compile (Re.Pcre.re {|[Cc]o-[Aa]uthored-[Bb]y:.*[Cc]laude.*\n?|})
319
320let filter_message msg = Re.replace_string ai_pattern ~by:"" msg
321let message_has_ai_attribution msg = Re.execp ai_pattern msg
322
323(* Get current user's email from git config (local or global) *)
324let user_email ~fs repo =
325 (* First check local repo config *)
326 let local_email =
327 match Git.Repository.read_config repo with
328 | Some config -> (Git.Config.user config).email
329 | None -> None
330 in
331 match local_email with
332 | Some _ -> local_email
333 | None -> (
334 (* Fall back to global config ~/.gitconfig *)
335 let home =
336 try Sys.getenv "HOME"
337 with Not_found -> (
338 try Sys.getenv "USERPROFILE" with Not_found -> "")
339 in
340 if home = "" then None
341 else
342 let global_config_path = Filename.concat home ".gitconfig" in
343 try
344 let content = Eio.Path.load Eio.Path.(fs / global_config_path) in
345 let config = Git.Config.of_string content in
346 (Git.Config.user config).email
347 with Eio.Io _ | Failure _ -> None)
348
349(* Check if commit was made by the current user *)
350let is_my_commit ~user_email commit =
351 match user_email with
352 | None -> true (* If no user configured, include all commits *)
353 | Some email ->
354 let committer_email = Git.User.email (Git.Commit.committer commit) in
355 String.equal email committer_email
356
357let scan_commits_for_ai_attribution repo commits user_email =
358 let found = ref 0 in
359 let skipped_email = ref 0 in
360 let skipped_no_ai = ref 0 in
361 let result =
362 List.filter_map
363 (fun (info : Git.Rev_list.commit_info) ->
364 match Git.Repository.read repo info.hash with
365 | Ok (Git.Value.Commit c) ->
366 if not (is_my_commit ~user_email c) then begin
367 incr skipped_email;
368 None
369 end
370 else
371 let msg = Git.Commit.message c in
372 let has_ai =
373 match msg with
374 | Some m -> message_has_ai_attribution m
375 | None -> false
376 in
377 if has_ai then begin
378 incr found;
379 let hash = String.sub (Git.Hash.to_hex info.hash) 0 7 in
380 let subject =
381 match msg with
382 | Some m -> (
383 match String.split_on_char '\n' m with
384 | subj :: _ -> String.trim subj
385 | [] -> "")
386 | None -> ""
387 in
388 Some { hash; subject }
389 end
390 else begin
391 incr skipped_no_ai;
392 None
393 end
394 | _ -> None)
395 commits
396 in
397 Log.debug (fun m ->
398 m "check_ai_attribution: found=%d skipped_email=%d skipped_no_ai=%d"
399 !found !skipped_email !skipped_no_ai);
400 result
401
402let check_ai_attribution ctx dir =
403 Log.debug (fun m -> m "check_ai_attribution: dir=%s" dir);
404 match git_root ~cwd:ctx.cwd ~fs:ctx.fs dir with
405 | None ->
406 Log.debug (fun m ->
407 m "check_ai_attribution: no git root found for %s" dir);
408 []
409 | Some git_root -> (
410 Log.debug (fun m -> m "check_ai_attribution: git_root=%s" git_root);
411 let repo = Git.Repository.open_repo ~fs:ctx.fs (Fpath.v git_root) in
412 let user_email = user_email ~fs:ctx.fs repo in
413 Log.debug (fun m ->
414 m "check_ai_attribution: git_root=%s user_email=%s" git_root
415 (Option.value ~default:"<none>" user_email));
416 match Git.Repository.head repo with
417 | None ->
418 Log.debug (fun m -> m "check_ai_attribution: no HEAD");
419 []
420 | Some head_hash ->
421 Log.debug (fun m ->
422 m "check_ai_attribution: HEAD=%s" (Git.Hash.to_hex head_hash));
423 let commits =
424 match
425 Git.Rev_list.topo_sort_reverse repo head_hash ~stop:(fun _ ->
426 false)
427 with
428 | Ok cs ->
429 Log.debug (fun m ->
430 m "check_ai_attribution: %d commits" (List.length cs));
431 cs
432 | Error (`Msg e) ->
433 Log.debug (fun m -> m "check_ai_attribution: error %s" e);
434 []
435 in
436 scan_commits_for_ai_attribution repo commits user_email)
437
438let current_branch ctx dir =
439 match git_root ~cwd:ctx.cwd ~fs:ctx.fs dir with
440 | None -> None
441 | Some git_root ->
442 let repo = Git.Repository.open_repo ~fs:ctx.fs (Fpath.v git_root) in
443 Git.Repository.current_branch repo
444
445let backup_branch ctx dir =
446 match git_root ~cwd:ctx.cwd ~fs:ctx.fs dir with
447 | None -> Fmt.failwith "%s: No .git directory found" dir
448 | Some git_root ->
449 let repo = Git.Repository.open_repo ~fs:ctx.fs (Fpath.v git_root) in
450 let branch =
451 match Git.Repository.current_branch repo with
452 | Some b -> b
453 | None -> "HEAD"
454 in
455 let now = Unix.gettimeofday () in
456 let tm = Unix.localtime now in
457 let timestamp =
458 Fmt.str "%04d%02d%02d-%02d%02d%02d" (1900 + tm.tm_year) (tm.tm_mon + 1)
459 tm.tm_mday tm.tm_hour tm.tm_min tm.tm_sec
460 in
461 let backup_name = Fmt.str "backup/%s-before-fix-%s" branch timestamp in
462 (match Git.Repository.head repo with
463 | Some hash ->
464 Git.Repository.write_ref repo ("refs/heads/" ^ backup_name) hash
465 | None -> ());
466 backup_name
467
468let mark_commits_needing_rewrite repo commits user_email =
469 let needs_rewrite = Hashtbl.create 64 in
470 List.iter
471 (fun (info : Git.Rev_list.commit_info) ->
472 match Git.Repository.read repo info.hash with
473 | Ok (Git.Value.Commit c) when is_my_commit ~user_email c -> (
474 match Git.Commit.message c with
475 | Some msg when message_has_ai_attribution msg ->
476 Hashtbl.add needs_rewrite info.hash true
477 | _ -> ())
478 | _ -> ())
479 commits;
480 needs_rewrite
481
482let rewrite_commit_history repo commits hash_map =
483 List.iter
484 (fun (info : Git.Rev_list.commit_info) ->
485 match Git.Repository.read repo info.hash with
486 | Ok (Git.Value.Commit c) ->
487 let new_parents =
488 List.map
489 (fun p ->
490 match Hashtbl.find_opt hash_map p with
491 | Some new_p -> new_p
492 | None -> p)
493 (Git.Commit.parents c)
494 in
495 let parents_changed =
496 not (List.equal Git.Hash.equal new_parents (Git.Commit.parents c))
497 in
498 let msg = Git.Commit.message c in
499 let needs_msg_rewrite =
500 match msg with
501 | Some m -> message_has_ai_attribution m
502 | None -> false
503 in
504 if parents_changed || needs_msg_rewrite then begin
505 let new_msg =
506 match msg with Some m -> Some (filter_message m) | None -> None
507 in
508 let new_commit =
509 Git.Commit.v ~tree:(Git.Commit.tree c)
510 ~author:(Git.Commit.author c)
511 ~committer:(Git.Commit.committer c) ~parents:new_parents
512 ~extra:(Git.Commit.extra c) new_msg
513 in
514 let new_hash = Git.Repository.write_commit repo new_commit in
515 Hashtbl.add hash_map info.hash new_hash
516 end
517 | _ -> ())
518 commits
519
520let rewrite_ai_attribution ctx dir =
521 match git_root ~cwd:ctx.cwd ~fs:ctx.fs dir with
522 | None -> err_no_git_dir dir
523 | Some git_root -> (
524 let repo = Git.Repository.open_repo ~fs:ctx.fs (Fpath.v git_root) in
525 let user_email = user_email ~fs:ctx.fs repo in
526 match Git.Repository.head repo with
527 | None -> Ok 0
528 | Some head_hash ->
529 let commits =
530 match
531 Git.Rev_list.topo_sort_reverse repo head_hash ~stop:(fun _ ->
532 false)
533 with
534 | Ok cs -> cs
535 | Error _ -> []
536 in
537 let needs_rewrite =
538 mark_commits_needing_rewrite repo commits user_email
539 in
540 if Hashtbl.length needs_rewrite = 0 then Ok 0
541 else
542 let hash_map = Hashtbl.create 64 in
543 rewrite_commit_history repo commits hash_map;
544 (match Hashtbl.find_opt hash_map head_hash with
545 | Some new_head -> Git.Repository.advance_head repo new_head
546 | None -> ());
547 Ok (Hashtbl.length needs_rewrite))
548
549(* Tabular output helpers *)
550
551let status_icon ok = if ok then "+" else "-"
552
553let format_status_row dir status =
554 let name = Filename.basename dir in
555 Fmt.str "%-20s %s pre-commit %s commit-msg %s ocamlformat %s fmt-enabled"
556 name
557 (status_icon status.has_pre_commit)
558 (status_icon status.has_commit_msg)
559 (status_icon status.has_ocamlformat)
560 (status_icon (not status.formatting_disabled))
561
562let format_status_header () =
563 Fmt.str "%-20s %-11s %-11s %-12s %-11s" "Directory" "pre-commit"
564 "commit-msg" "ocamlformat" "formatting"
565
566let format_status_separator () = String.make 80 '-'
567
568let pp_status_table ppf statuses =
569 Fmt.pf ppf "%s@." (format_status_header ());
570 Fmt.pf ppf "%s@." (format_status_separator ());
571 List.iter
572 (fun (dir, status) -> Fmt.pf ppf "%s@." (format_status_row dir status))
573 statuses
574
575let check_all ctx dirs = List.map (fun dir -> (dir, status_in_dir ctx dir)) dirs