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

Configure Feed

Select the types of activity you want to include in your feed.

Run dune fmt across codebase

+306 -50
+156 -16
bin/main.ml
··· 1 - (** CLI for pre-commit hook initialization. *) 1 + (** CLI for pre-commit hook initialisation. *) 2 2 3 3 open Cmdliner 4 4 5 + (* {1 Common arguments} *) 6 + 7 + let dirs = 8 + let doc = "Directories to operate on. Defaults to the current directory." in 9 + Arg.(value & pos_all dir [ "." ] & info [] ~docv:"DIR" ~doc) 10 + 5 11 let dry_run = 6 - let doc = "Print what would be done without making changes." in 12 + let doc = "Show what would be done without making changes." in 7 13 Arg.(value & flag & info [ "n"; "dry-run" ] ~doc) 8 14 9 - let init dry_run = 10 - match Precommit.init ~dry_run () with 11 - | Ok () -> 12 - if not dry_run then 13 - print_endline "Pre-commit hooks initialized successfully." 15 + let recursive = 16 + let doc = "Operate on all OCaml projects in subdirectories." in 17 + Arg.(value & flag & info [ "r"; "recursive" ] ~doc) 18 + 19 + (* {1 Helpers} *) 20 + 21 + let or_die = function 22 + | Ok () -> () 14 23 | Error msg -> 15 - Printf.eprintf "Error: %s\n" msg; 24 + Printf.eprintf "precommit: %s\n" msg; 16 25 exit 1 17 26 27 + let collect_dirs ~recursive dirs = 28 + if recursive then 29 + List.concat_map 30 + (fun d -> 31 + let subs = Precommit.list_subdirs d in 32 + if subs = [] then [ d ] else subs) 33 + dirs 34 + else dirs 35 + 36 + (* {1 Init command} *) 37 + 38 + let init dry_run recursive dirs = 39 + let dirs = collect_dirs ~recursive dirs in 40 + List.iter 41 + (fun d -> 42 + let s = Precommit.status_in_dir d in 43 + if s.is_ocaml_project && s.is_git_repo then 44 + if not (s.has_pre_commit && s.has_commit_msg) then begin 45 + or_die (Precommit.init_in_dir ~dry_run d); 46 + let verb = if dry_run then "Would initialise" else "Initialised" in 47 + Printf.printf "%s: %s\n" d verb 48 + end) 49 + dirs 50 + 18 51 let init_cmd = 19 - let doc = "Initialize pre-commit hooks for an OCaml project." in 20 - let info = Cmd.info "init" ~doc in 21 - Cmd.v info Term.(const init $ dry_run) 52 + let doc = "Initialise pre-commit hooks for OCaml projects." in 53 + let man = 54 + [ 55 + `S Manpage.s_description; 56 + `P 57 + "Install git hooks that run $(b,dune fmt) before commit and remove \ 58 + Claude attribution from commit messages."; 59 + `S Manpage.s_examples; 60 + `P "Initialise hooks in the current directory:"; 61 + `Pre " precommit init"; 62 + `P "Initialise hooks in all projects under src/:"; 63 + `Pre " precommit init -r src/"; 64 + `P "Preview what would be done:"; 65 + `Pre " precommit init -n -r ."; 66 + ] 67 + in 68 + let info = Cmd.info "init" ~doc ~man in 69 + Cmd.v info Term.(const init $ dry_run $ recursive $ dirs) 70 + 71 + (* {1 Status command} *) 72 + 73 + let pp_check b = if b then "\027[32m✓\027[0m" else "\027[31m✗\027[0m" 74 + 75 + let pp_status name (s : Precommit.hook_status) = 76 + Printf.printf "%s %s %s %s\n" 77 + (pp_check s.has_pre_commit) 78 + (pp_check s.has_commit_msg) 79 + (pp_check s.is_ocaml_project) 80 + name 22 81 23 - let default_cmd = 24 - let doc = "Pre-commit hook initialization for OCaml projects." in 25 - let info = Cmd.info "precommit" ~version:"0.1.0" ~doc in 82 + let status recursive dirs = 83 + let dirs = collect_dirs ~recursive dirs in 84 + Printf.printf "pre-commit commit-msg ocaml directory\n"; 85 + Printf.printf "---------- ---------- ----- ---------\n"; 86 + let missing = ref false in 87 + List.iter 88 + (fun d -> 89 + let s = Precommit.status_in_dir d in 90 + pp_status d s; 91 + if s.is_ocaml_project && s.is_git_repo then 92 + if not (s.has_pre_commit && s.has_commit_msg) then missing := true) 93 + dirs; 94 + if !missing then exit 1 95 + 96 + let status_cmd = 97 + let doc = "Check pre-commit hook status." in 98 + let man = 99 + [ 100 + `S Manpage.s_description; 101 + `P "Show which directories have hooks installed."; 102 + `P 103 + "Columns show: pre-commit hook, commit-msg hook, is OCaml project. \ 104 + Exit code is 1 if any OCaml project is missing hooks."; 105 + `S Manpage.s_examples; 106 + `P "Check status of all projects under src/:"; 107 + `Pre " precommit status -r src/"; 108 + ] 109 + in 110 + let info = Cmd.info "status" ~doc ~man in 111 + Cmd.v info Term.(const status $ recursive $ dirs) 112 + 113 + (* {1 Check command} *) 114 + 115 + let check recursive dirs = 116 + let dirs = collect_dirs ~recursive dirs in 117 + let found = ref false in 118 + List.iter 119 + (fun d -> 120 + let commits = Precommit.check_ai_attribution d in 121 + if commits <> [] then begin 122 + found := true; 123 + Printf.printf "%s:\n" d; 124 + List.iter 125 + (fun (c : Precommit.ai_commit) -> 126 + Printf.printf " %s %s\n" c.hash c.subject) 127 + commits 128 + end) 129 + dirs; 130 + if !found then exit 1 131 + 132 + let check_cmd = 133 + let doc = "Check git history for commits with AI attribution." in 134 + let man = 135 + [ 136 + `S Manpage.s_description; 137 + `P 138 + "Scan git history for commits by the configured user that contain \ 139 + 'claude' in the commit message. Exit code is 1 if any are found."; 140 + `S Manpage.s_examples; 141 + `P "Check all projects under src/:"; 142 + `Pre " precommit check -r src/"; 143 + ] 144 + in 145 + let info = Cmd.info "check" ~doc ~man in 146 + Cmd.v info Term.(const check $ recursive $ dirs) 147 + 148 + (* {1 Main} *) 149 + 150 + let main_cmd = 151 + let doc = "Manage pre-commit hooks for OCaml projects." in 152 + let man = 153 + [ 154 + `S Manpage.s_description; 155 + `P 156 + "$(tname) installs git hooks that enforce code formatting and commit \ 157 + message hygiene for OCaml projects."; 158 + `S Manpage.s_bugs; 159 + `P "Report issues at https://github.com/gazagnaire.org/ocaml-precommit"; 160 + `S "EXIT STATUS"; 161 + `P "$(b,0) on success."; 162 + `P "$(b,1) if hooks are missing (status) or initialisation failed."; 163 + ] 164 + in 165 + let info = Cmd.info "precommit" ~version:"0.1.0" ~doc ~man in 26 166 let default = Term.(ret (const (`Help (`Pager, None)))) in 27 - Cmd.group info ~default [ init_cmd ] 167 + Cmd.group info ~default [ init_cmd; status_cmd; check_cmd ] 28 168 29 - let () = exit (Cmd.eval default_cmd) 169 + let () = exit (Cmd.eval main_cmd)
+119 -34
lib/precommit.ml
··· 79 79 if dry_run then Printf.printf "Would chmod +x %s\n" path 80 80 else Unix.chmod path 0o755 81 81 82 - let find_git_dir () = 83 - (* Find .git directory, handling worktrees *) 84 - if file_exists ".git" then 85 - if Sys.is_directory ".git" then Some ".git" 86 - else begin 87 - (* .git is a file pointing to the real git dir (worktree) *) 88 - let ic = open_in ".git" in 89 - let line = input_line ic in 90 - close_in ic; 91 - if String.length line > 8 && String.sub line 0 8 = "gitdir: " then 92 - Some (String.sub line 8 (String.length line - 8)) 93 - else None 94 - end 95 - else None 82 + let init_in_dir ~dry_run dir = 83 + let dune_project = Filename.concat dir "dune-project" in 84 + let git_dir_path = Filename.concat dir ".git" in 85 + if not (file_exists dune_project) then 86 + Error (Printf.sprintf "%s: No dune-project found" dir) 87 + else if not (file_exists git_dir_path) then 88 + Error (Printf.sprintf "%s: No .git directory found" dir) 89 + else 90 + let git_dir = 91 + if Sys.is_directory git_dir_path then git_dir_path 92 + else begin 93 + (* .git is a file pointing to the real git dir (worktree) *) 94 + let ic = open_in git_dir_path in 95 + let line = input_line ic in 96 + close_in ic; 97 + if String.length line > 8 && String.sub line 0 8 = "gitdir: " then 98 + String.sub line 8 (String.length line - 8) 99 + else git_dir_path 100 + end 101 + in 102 + let hooks_dir = Filename.concat git_dir "hooks" in 103 + 104 + (* Create hooks directory if needed *) 105 + if dry_run then Printf.printf "Would create %s/\n" hooks_dir 106 + else mkdir_p hooks_dir; 107 + 108 + (* Install pre-commit hook *) 109 + let pre_commit_path = Filename.concat hooks_dir "pre-commit" in 110 + write_file ~dry_run pre_commit_path pre_commit_hook; 111 + chmod_exec ~dry_run pre_commit_path; 112 + 113 + (* Install commit-msg hook *) 114 + let commit_msg_path = Filename.concat hooks_dir "commit-msg" in 115 + write_file ~dry_run commit_msg_path commit_msg_hook; 116 + chmod_exec ~dry_run commit_msg_path; 117 + 118 + Ok () 119 + 120 + let init ~dry_run () = init_in_dir ~dry_run "." 96 121 97 - let init ~dry_run () = 98 - (* Check if we're in an OCaml project *) 99 - if not (file_exists "dune-project") then 100 - Error "No dune-project found. Run this from an OCaml project root." 122 + type hook_status = { 123 + has_pre_commit : bool; 124 + has_commit_msg : bool; 125 + is_ocaml_project : bool; 126 + is_git_repo : bool; 127 + } 128 + 129 + let status_in_dir dir = 130 + let dune_project = Filename.concat dir "dune-project" in 131 + let git_dir_path = Filename.concat dir ".git" in 132 + let is_ocaml_project = file_exists dune_project in 133 + let is_git_repo = file_exists git_dir_path in 134 + if not is_git_repo then 135 + { 136 + has_pre_commit = false; 137 + has_commit_msg = false; 138 + is_ocaml_project; 139 + is_git_repo; 140 + } 101 141 else 102 - match find_git_dir () with 103 - | None -> Error "No .git directory found. Run this from a git repository." 104 - | Some git_dir -> 105 - let hooks_dir = Filename.concat git_dir "hooks" in 142 + let git_dir = 143 + if Sys.is_directory git_dir_path then git_dir_path 144 + else begin 145 + let ic = open_in git_dir_path in 146 + let line = input_line ic in 147 + close_in ic; 148 + if String.length line > 8 && String.sub line 0 8 = "gitdir: " then 149 + String.sub line 8 (String.length line - 8) 150 + else git_dir_path 151 + end 152 + in 153 + let hooks_dir = Filename.concat git_dir "hooks" in 154 + let pre_commit_path = Filename.concat hooks_dir "pre-commit" in 155 + let commit_msg_path = Filename.concat hooks_dir "commit-msg" in 156 + { 157 + has_pre_commit = file_exists pre_commit_path; 158 + has_commit_msg = file_exists commit_msg_path; 159 + is_ocaml_project; 160 + is_git_repo; 161 + } 106 162 107 - (* Create hooks directory if needed *) 108 - if dry_run then Printf.printf "Would create %s/\n" hooks_dir 109 - else mkdir_p hooks_dir; 163 + let status () = status_in_dir "." 110 164 111 - (* Install pre-commit hook *) 112 - let pre_commit_path = Filename.concat hooks_dir "pre-commit" in 113 - write_file ~dry_run pre_commit_path pre_commit_hook; 114 - chmod_exec ~dry_run pre_commit_path; 165 + let list_subdirs dir = 166 + let entries = Sys.readdir dir in 167 + Array.to_list entries 168 + |> List.filter (fun name -> 169 + let path = Filename.concat dir name in 170 + Sys.is_directory path && name.[0] <> '.') 171 + |> List.map (fun name -> Filename.concat dir name) 172 + |> List.sort String.compare 115 173 116 - (* Install commit-msg hook *) 117 - let commit_msg_path = Filename.concat hooks_dir "commit-msg" in 118 - write_file ~dry_run commit_msg_path commit_msg_hook; 119 - chmod_exec ~dry_run commit_msg_path; 174 + let run_in_dir dir cmd = 175 + let old_cwd = Sys.getcwd () in 176 + Sys.chdir dir; 177 + let ic = Unix.open_process_in cmd in 178 + let lines = ref [] in 179 + (try 180 + while true do 181 + lines := input_line ic :: !lines 182 + done 183 + with End_of_file -> ()); 184 + ignore (Unix.close_process_in ic); 185 + Sys.chdir old_cwd; 186 + List.rev !lines 120 187 121 - Ok () 188 + type ai_commit = { hash : string; subject : string } 189 + 190 + let check_ai_attribution dir = 191 + if not (file_exists (Filename.concat dir ".git")) then [] 192 + else 193 + (* Get commits by the configured user that contain "claude" *) 194 + let cmd = 195 + "git log --format='%h %s' --grep='[Cc]laude' --author=\"$(git config \ 196 + user.name)\" 2>/dev/null" 197 + in 198 + let lines = run_in_dir dir cmd in 199 + List.filter_map 200 + (fun line -> 201 + if String.length line > 8 then 202 + let hash = String.sub line 0 7 in 203 + let subject = String.sub line 8 (String.length line - 8) in 204 + Some { hash; subject } 205 + else None) 206 + lines
+31
lib/precommit.mli
··· 12 12 (** Shell script for the commit-msg hook. Checks for emojis (rejected) and 13 13 removes lines containing "claude" (case-insensitive). *) 14 14 15 + (** {1 Types} *) 16 + 17 + type hook_status = { 18 + has_pre_commit : bool; 19 + has_commit_msg : bool; 20 + is_ocaml_project : bool; 21 + is_git_repo : bool; 22 + } 23 + (** Status of hooks in a directory. *) 24 + 15 25 (** {1 Operations} *) 16 26 17 27 val init : dry_run:bool -> unit -> (unit, string) result ··· 24 34 If [dry_run] is [true], prints what would be done without making changes. 25 35 26 36 Returns [Error msg] if not in a git repository or OCaml project. *) 37 + 38 + val init_in_dir : dry_run:bool -> string -> (unit, string) result 39 + (** [init_in_dir ~dry_run dir] installs hooks in the specified directory. *) 40 + 41 + val status : unit -> hook_status 42 + (** [status ()] checks hook status in the current directory. *) 43 + 44 + val status_in_dir : string -> hook_status 45 + (** [status_in_dir dir] checks hook status in the specified directory. *) 46 + 47 + val list_subdirs : string -> string list 48 + (** [list_subdirs dir] lists subdirectories (excluding hidden ones). *) 49 + 50 + (** {1 History checks} *) 51 + 52 + type ai_commit = { hash : string; subject : string } 53 + (** A commit with AI attribution. *) 54 + 55 + val check_ai_attribution : string -> ai_commit list 56 + (** [check_ai_attribution dir] finds commits by the configured git user that 57 + contain "claude" in the commit message. *)