Installs pre-commit hooks for OCaml projects that run dune fmt automatically
at main 575 lines 20 kB view raw
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