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