Installs pre-commit hooks for OCaml projects that run dune fmt automatically

Merge 'ocaml-precommit/' from /Users/samoht/git/blacksun/src/ocaml-precommit

git-subtree-dir: ocaml-precommit
git-subtree-mainline: d7749ca85dfc7cf9aa15c540326728c94d25713a

monopam 5eeae060 0a0c2ee4

+44 -55
+35 -33
bin/main.ml
··· 20 20 (* {1 Common arguments} *) 21 21 22 22 let dirs = 23 - let doc = 24 - "Root directories to scan for git projects. Defaults to the current \ 25 - directory. Each directory is scanned recursively for repositories \ 26 - containing a $(b,.git) entry." 27 - in 23 + let doc = "Directories to operate on. Defaults to the current directory." in 28 24 Arg.(value & pos_all dir [ "." ] & info [] ~docv:"DIR" ~doc) 29 25 30 26 let dry_run = 31 27 let doc = "Show what would be done without making changes." in 32 28 Arg.(value & flag & info [ "n"; "dry-run" ] ~doc) 29 + 30 + let recursive = 31 + let doc = "Operate on all OCaml projects in subdirectories." in 32 + Arg.(value & flag & info [ "r"; "recursive" ] ~doc) 33 33 34 34 let force = 35 35 let doc = "Install hooks even if no dune-project is found." in ··· 71 71 error "%s" msg; 72 72 exit 1 73 73 74 - let collect_dirs ~fs dirs = 75 - List.concat_map (fun d -> Precommit.find_git_projects ~fs d) dirs 74 + let collect_dirs ~fs ~recursive dirs = 75 + if recursive then 76 + List.concat_map (fun d -> Precommit.find_git_projects ~fs d) dirs 77 + else dirs 76 78 77 79 (* {1 Init command} *) 78 80 79 - let init_impl ~fs dry_run force hooks dirs = 80 - let dirs = collect_dirs ~fs dirs in 81 + let init_impl ~fs dry_run force hooks recursive dirs = 82 + let dirs = collect_dirs ~fs ~recursive dirs in 81 83 let count = ref 0 in 82 84 List.iter 83 85 (fun d -> ··· 97 99 Log.info (fun m -> 98 100 m "Processed %d director%s" !count (if !count = 1 then "y" else "ies")) 99 101 100 - let init dry_run force hooks dirs = 102 + let init dry_run force hooks recursive dirs = 101 103 Eio_main.run @@ fun env -> 102 104 let fs = Eio.Stdenv.cwd env in 103 - init_impl ~fs dry_run force hooks dirs 105 + init_impl ~fs dry_run force hooks recursive dirs 104 106 105 107 let init_cmd = 106 108 let doc = "Initialise pre-commit hooks for OCaml projects." in ··· 115 117 `P "Initialise hooks in the current directory:"; 116 118 `Pre " precommit init"; 117 119 `P "Initialise hooks in all projects under src/:"; 118 - `Pre " precommit init src/"; 120 + `Pre " precommit init -r src/"; 119 121 `P "Preview what would be done:"; 120 - `Pre " precommit init -n"; 122 + `Pre " precommit init -n -r ."; 121 123 `P "Install only the AI attribution hook in a non-OCaml project:"; 122 124 `Pre " precommit init -f --hooks ai"; 123 125 `P "Install only the dune fmt hook:"; ··· 125 127 ] 126 128 in 127 129 let info = Cmd.info "init" ~doc ~man in 128 - Cmd.v info Term.(const init $ dry_run $ force $ hooks $ dirs) 130 + Cmd.v info Term.(const init $ dry_run $ force $ hooks $ recursive $ dirs) 129 131 130 132 (* {1 Status command} *) 131 133 ··· 133 135 if b then Tty.Span.styled Tty.Style.(fg Tty.Color.green) "+" 134 136 else Tty.Span.styled Tty.Style.(fg Tty.Color.red) "-" 135 137 136 - let status_impl ~fs dirs = 137 - let dirs = collect_dirs ~fs dirs in 138 + let status_impl ~fs recursive dirs = 139 + let dirs = collect_dirs ~fs ~recursive dirs in 138 140 let missing = ref 0 in 139 141 let ok = ref 0 in 140 142 let rows = ··· 181 183 else if !ok > 0 then 182 184 success "%d project%s properly configured" !ok (if !ok = 1 then "" else "s") 183 185 184 - let status dirs = 186 + let status recursive dirs = 185 187 Eio_main.run @@ fun env -> 186 188 let fs = Eio.Stdenv.cwd env in 187 - status_impl ~fs dirs 189 + status_impl ~fs recursive dirs 188 190 189 191 let status_cmd = 190 192 let doc = "Check pre-commit hook status." in ··· 198 200 hooks, .ocamlformat, or has formatting disabled."; 199 201 `S Manpage.s_examples; 200 202 `P "Check status of all projects under src/:"; 201 - `Pre " precommit status src/"; 203 + `Pre " precommit status -r src/"; 202 204 ] 203 205 in 204 206 let info = Cmd.info "status" ~doc ~man in 205 - Cmd.v info Term.(const status $ dirs) 207 + Cmd.v info Term.(const status $ recursive $ dirs) 206 208 207 209 (* {1 Check command} *) 208 210 ··· 271 273 end; 272 274 (List.rev !affected_dirs, !total_commits, !repos_with_issues) 273 275 274 - let check_impl ~process_mgr ~fs dirs = 275 - let dirs = collect_dirs ~fs dirs in 276 + let check_impl ~process_mgr ~fs recursive dirs = 277 + let dirs = collect_dirs ~fs ~recursive dirs in 276 278 let _affected, total_commits, repos_with_issues = 277 279 find_and_display_ai_commits ~process_mgr ~fs dirs 278 280 in ··· 285 287 end 286 288 else success "No AI attribution found in commit history" 287 289 288 - let check dirs = 290 + let check recursive dirs = 289 291 Eio_main.run @@ fun env -> 290 292 let fs = Eio.Stdenv.cwd env in 291 293 let process_mgr = Eio.Stdenv.process_mgr env in 292 - check_impl ~process_mgr ~fs dirs 294 + check_impl ~process_mgr ~fs recursive dirs 293 295 294 296 let check_cmd = 295 297 let doc = "Check git history for commits with AI attribution." in ··· 301 303 'claude' in the commit message. Exit code is 1 if any are found."; 302 304 `S Manpage.s_examples; 303 305 `P "Check all projects under src/:"; 304 - `Pre " precommit check src/"; 306 + `Pre " precommit check -r src/"; 305 307 ] 306 308 in 307 309 let info = Cmd.info "check" ~doc ~man in 308 - Cmd.v info Term.(const check $ dirs) 310 + Cmd.v info Term.(const check $ recursive $ dirs) 309 311 310 312 (* {1 Fix command} *) 311 313 ··· 332 334 let answer = String.trim line in 333 335 answer = "y" || answer = "Y" 334 336 335 - let fix_impl ~process_mgr ~fs dry_run yes dirs = 336 - let dirs = collect_dirs ~fs dirs in 337 + let fix_impl ~process_mgr ~fs dry_run yes recursive dirs = 338 + let dirs = collect_dirs ~fs ~recursive dirs in 337 339 let affected, total_commits, repos_with_issues = 338 340 find_and_display_ai_commits ~process_mgr ~fs dirs 339 341 in ··· 385 387 let doc = "Skip interactive confirmation prompt." in 386 388 Arg.(value & flag & info [ "y"; "yes" ] ~doc) 387 389 388 - let fix dry_run yes dirs = 390 + let fix dry_run yes recursive dirs = 389 391 Eio_main.run @@ fun env -> 390 392 let fs = Eio.Stdenv.cwd env in 391 393 let process_mgr = Eio.Stdenv.process_mgr env in 392 - fix_impl ~process_mgr ~fs dry_run yes dirs 394 + fix_impl ~process_mgr ~fs dry_run yes recursive dirs 393 395 394 396 let fix_cmd = 395 397 let doc = "Remove AI attribution from commit history." in ··· 406 408 the interactive confirmation prompt."; 407 409 `S Manpage.s_examples; 408 410 `P "Fix all projects under the current directory:"; 409 - `Pre " precommit fix"; 411 + `Pre " precommit fix -r"; 410 412 `P "Preview what would be done:"; 411 - `Pre " precommit fix -n"; 413 + `Pre " precommit fix -n -r ."; 412 414 `P "Fix without confirmation prompt:"; 413 415 `Pre " precommit fix -y"; 414 416 ] 415 417 in 416 418 let info = Cmd.info "fix" ~doc ~man in 417 - Cmd.v info Term.(const fix $ dry_run $ yes $ dirs) 419 + Cmd.v info Term.(const fix $ dry_run $ yes $ recursive $ dirs) 418 420 419 421 (* {1 Main} *) 420 422
+9 -22
lib/precommit.ml
··· 72 72 | `Directory -> true 73 73 | _ -> false 74 74 75 - let is_symlink ~fs path = 76 - match Eio.Path.kind ~follow:false Eio.Path.(fs / path) with 77 - | `Symbolic_link -> true 78 - | _ -> false 79 - 80 75 let read_file ~fs path = Eio.Path.load Eio.Path.(fs / path) 81 76 82 77 let write_file ~fs ~dry_run path content = ··· 202 197 |> List.sort String.compare 203 198 204 199 let rec find_git_projects ~fs dir = 205 - let entries = try Eio.Path.read_dir Eio.Path.(fs / dir) with _ -> [] in 206 - let child_path name = if dir = "." then name else Filename.concat dir name in 207 - let self = if List.mem ".git" entries then [ dir ] else [] in 208 - let children = 200 + let git_dir = Filename.concat dir ".git" in 201 + if file_exists ~fs git_dir then [ dir ] 202 + else 203 + let entries = try Eio.Path.read_dir Eio.Path.(fs / dir) with _ -> [] in 209 204 entries 210 205 |> List.filter_map (fun name -> 211 - if String.length name > 0 && (name.[0] = '.' || name.[0] = '_') then 212 - None 206 + if String.length name > 0 && name.[0] = '.' then None 213 207 else 214 - let path = child_path name in 215 - (* Skip symlinks to avoid traversing outside the sandbox *) 216 - if is_symlink ~fs path then None 217 - else if is_directory ~fs path then Some path 218 - else None) 208 + let path = Filename.concat dir name in 209 + if is_directory ~fs path then Some path else None) 219 210 |> List.sort String.compare 220 - |> List.concat_map (fun sub -> 221 - if file_exists ~fs (Filename.concat sub ".git") then [ sub ] 222 - else find_git_projects ~fs sub) 223 - in 224 - self @ children 211 + |> List.concat_map (fun sub -> find_git_projects ~fs sub) 225 212 226 213 let run_in_dir ~process_mgr ~fs dir cmd = 227 214 let cwd = Eio.Path.(fs / dir) in ··· 301 288 | Ok _lines -> 302 289 (* Count how many commits were actually rewritten *) 303 290 let count_cmd = 304 - "git log --format='%H' HEAD --grep='Co-Authored-By.*[Cc]laude' \ 291 + "git log --format='%H' --all --grep='Co-Authored-By.*[Cc]laude' \ 305 292 2>/dev/null | wc -l" 306 293 in 307 294 let remaining =