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.

precommit: update library

+119 -78
+1 -1
lib/dune
··· 1 1 (library 2 2 (name precommit) 3 3 (public_name precommit) 4 - (libraries unix)) 4 + (libraries eio re))
+115 -76
lib/precommit.ml
··· 58 58 let default_ocamlformat = {|version = 0.28.1 59 59 |} 60 60 61 - let file_exists path = Sys.file_exists path 61 + (* Regular expression for detecting "(formatting disabled)" in dune-project *) 62 + let formatting_disabled_re = 63 + Re.compile (Re.Pcre.re {|\(formatting\s+disabled\)|}) 62 64 63 - let mkdir_p path = 64 - let rec aux path = 65 - if not (file_exists path) then begin 66 - let parent = Filename.dirname path in 67 - if parent <> path then aux parent; 68 - Unix.mkdir path 0o755 69 - end 70 - in 71 - aux path 65 + let file_exists ~fs path = 66 + match Eio.Path.kind ~follow:true Eio.Path.(fs / path) with 67 + | `Not_found -> false 68 + | _ -> true 72 69 73 - let write_file ~dry_run path content = 70 + let is_directory ~fs path = 71 + match Eio.Path.kind ~follow:true Eio.Path.(fs / path) with 72 + | `Directory -> true 73 + | _ -> false 74 + 75 + let read_file ~fs path = Eio.Path.load Eio.Path.(fs / path) 76 + 77 + let write_file ~fs ~dry_run path content = 74 78 if dry_run then Printf.printf "Would create %s\n" path 75 - else begin 76 - let oc = open_out path in 77 - output_string oc content; 78 - close_out oc 79 - end 79 + else Eio.Path.save ~create:(`Or_truncate 0o644) Eio.Path.(fs / path) content 80 80 81 81 let chmod_exec ~dry_run path = 82 82 if dry_run then Printf.printf "Would chmod +x %s\n" path 83 83 else Unix.chmod path 0o755 84 84 85 - let init_in_dir ~dry_run dir = 85 + let mkdir_p ~fs ~dry_run path = 86 + if dry_run then Printf.printf "Would create %s/\n" path 87 + else Eio.Path.mkdirs ~exists_ok:true ~perm:0o755 Eio.Path.(fs / path) 88 + 89 + let check_formatting_disabled ~fs path = 90 + if not (file_exists ~fs path) then false 91 + else 92 + let content = read_file ~fs path in 93 + Re.execp formatting_disabled_re content 94 + 95 + let resolve_git_dir ~fs git_dir_path = 96 + if is_directory ~fs git_dir_path then git_dir_path 97 + else 98 + (* .git is a file pointing to the real git dir (worktree) *) 99 + let content = read_file ~fs git_dir_path in 100 + let line = String.trim (List.hd (String.split_on_char '\n' content)) in 101 + if String.length line > 8 && String.sub line 0 8 = "gitdir: " then 102 + String.sub line 8 (String.length line - 8) 103 + else git_dir_path 104 + 105 + let init_in_dir ~fs ~dry_run dir = 86 106 let dune_project = Filename.concat dir "dune-project" in 87 107 let git_dir_path = Filename.concat dir ".git" in 88 108 let ocamlformat_path = Filename.concat dir ".ocamlformat" in 89 - if not (file_exists dune_project) then 109 + if not (file_exists ~fs dune_project) then 90 110 Error (Printf.sprintf "%s: No dune-project found" dir) 91 - else if not (file_exists git_dir_path) then 111 + else if not (file_exists ~fs git_dir_path) then 92 112 Error (Printf.sprintf "%s: No .git directory found" dir) 93 113 else 94 - let git_dir = 95 - if Sys.is_directory git_dir_path then git_dir_path 96 - else begin 97 - (* .git is a file pointing to the real git dir (worktree) *) 98 - let ic = open_in git_dir_path in 99 - let line = input_line ic in 100 - close_in ic; 101 - if String.length line > 8 && String.sub line 0 8 = "gitdir: " then 102 - String.sub line 8 (String.length line - 8) 103 - else git_dir_path 104 - end 105 - in 114 + let git_dir = resolve_git_dir ~fs git_dir_path in 106 115 let hooks_dir = Filename.concat git_dir "hooks" in 107 116 108 117 (* Create hooks directory if needed *) 109 - if dry_run then Printf.printf "Would create %s/\n" hooks_dir 110 - else mkdir_p hooks_dir; 118 + mkdir_p ~fs ~dry_run hooks_dir; 111 119 112 120 (* Install pre-commit hook *) 113 121 let pre_commit_path = Filename.concat hooks_dir "pre-commit" in 114 - write_file ~dry_run pre_commit_path pre_commit_hook; 122 + write_file ~fs ~dry_run pre_commit_path pre_commit_hook; 115 123 chmod_exec ~dry_run pre_commit_path; 116 124 117 125 (* Install commit-msg hook *) 118 126 let commit_msg_path = Filename.concat hooks_dir "commit-msg" in 119 - write_file ~dry_run commit_msg_path commit_msg_hook; 127 + write_file ~fs ~dry_run commit_msg_path commit_msg_hook; 120 128 chmod_exec ~dry_run commit_msg_path; 121 129 122 130 (* Create .ocamlformat if missing *) 123 - if not (file_exists ocamlformat_path) then 124 - write_file ~dry_run ocamlformat_path default_ocamlformat; 131 + if not (file_exists ~fs ocamlformat_path) then 132 + write_file ~fs ~dry_run ocamlformat_path default_ocamlformat; 125 133 126 134 Ok () 127 135 128 - let init ~dry_run () = init_in_dir ~dry_run "." 136 + let init ~fs ~dry_run () = init_in_dir ~fs ~dry_run "." 129 137 130 138 type hook_status = { 131 139 has_pre_commit : bool; 132 140 has_commit_msg : bool; 133 141 has_ocamlformat : bool; 142 + formatting_disabled : bool; 134 143 is_ocaml_project : bool; 135 144 is_git_repo : bool; 136 145 } 137 146 138 - let status_in_dir dir = 147 + let status_in_dir ~fs dir = 139 148 let dune_project = Filename.concat dir "dune-project" in 140 149 let git_dir_path = Filename.concat dir ".git" in 141 150 let ocamlformat_path = Filename.concat dir ".ocamlformat" in 142 - let is_ocaml_project = file_exists dune_project in 143 - let is_git_repo = file_exists git_dir_path in 144 - let has_ocamlformat = file_exists ocamlformat_path in 151 + let is_ocaml_project = file_exists ~fs dune_project in 152 + let is_git_repo = file_exists ~fs git_dir_path in 153 + let has_ocamlformat = file_exists ~fs ocamlformat_path in 154 + let formatting_disabled = check_formatting_disabled ~fs dune_project in 145 155 if not is_git_repo then 146 156 { 147 157 has_pre_commit = false; 148 158 has_commit_msg = false; 149 159 has_ocamlformat; 160 + formatting_disabled; 150 161 is_ocaml_project; 151 162 is_git_repo; 152 163 } 153 164 else 154 - let git_dir = 155 - if Sys.is_directory git_dir_path then git_dir_path 156 - else begin 157 - let ic = open_in git_dir_path in 158 - let line = input_line ic in 159 - close_in ic; 160 - if String.length line > 8 && String.sub line 0 8 = "gitdir: " then 161 - String.sub line 8 (String.length line - 8) 162 - else git_dir_path 163 - end 164 - in 165 + let git_dir = resolve_git_dir ~fs git_dir_path in 165 166 let hooks_dir = Filename.concat git_dir "hooks" in 166 167 let pre_commit_path = Filename.concat hooks_dir "pre-commit" in 167 168 let commit_msg_path = Filename.concat hooks_dir "commit-msg" in 168 169 { 169 - has_pre_commit = file_exists pre_commit_path; 170 - has_commit_msg = file_exists commit_msg_path; 170 + has_pre_commit = file_exists ~fs pre_commit_path; 171 + has_commit_msg = file_exists ~fs commit_msg_path; 171 172 has_ocamlformat; 173 + formatting_disabled; 172 174 is_ocaml_project; 173 175 is_git_repo; 174 176 } 175 177 176 - let status () = status_in_dir "." 178 + let status ~fs () = status_in_dir ~fs "." 177 179 178 - let list_subdirs dir = 179 - let entries = Sys.readdir dir in 180 - Array.to_list entries 180 + let list_subdirs ~fs dir = 181 + Eio.Path.read_dir Eio.Path.(fs / dir) 181 182 |> List.filter (fun name -> 182 183 let path = Filename.concat dir name in 183 - Sys.is_directory path && name.[0] <> '.') 184 + is_directory ~fs path && name.[0] <> '.') 184 185 |> List.map (fun name -> Filename.concat dir name) 185 186 |> List.sort String.compare 186 187 187 - let run_in_dir dir cmd = 188 - let old_cwd = Sys.getcwd () in 189 - Sys.chdir dir; 190 - let ic = Unix.open_process_in cmd in 191 - let lines = ref [] in 192 - (try 193 - while true do 194 - lines := input_line ic :: !lines 195 - done 196 - with End_of_file -> ()); 197 - ignore (Unix.close_process_in ic); 198 - Sys.chdir old_cwd; 199 - List.rev !lines 188 + let run_in_dir ~process_mgr ~fs dir cmd = 189 + let cwd = Eio.Path.(fs / dir) in 190 + let proc = 191 + Eio.Process.spawn ~cwd process_mgr ~executable:"/bin/sh" 192 + ~args:[ "/bin/sh"; "-c"; cmd ] ~stdout:(Eio.Process.Pipe `Close_on_exec) 193 + in 194 + let buf = Buffer.create 256 in 195 + (match Eio.Process.as_source proc with 196 + | Some source -> 197 + let chunk = Cstruct.create 4096 in 198 + let rec read_all () = 199 + match Eio.Flow.single_read source chunk with 200 + | n -> 201 + Buffer.add_string buf (Cstruct.to_string ~off:0 ~len:n chunk); 202 + read_all () 203 + | exception End_of_file -> () 204 + in 205 + read_all () 206 + | None -> ()); 207 + ignore (Eio.Process.await proc); 208 + Buffer.contents buf |> String.split_on_char '\n' 209 + |> List.filter (fun s -> String.length s > 0) 200 210 201 211 type ai_commit = { hash : string; subject : string } 202 212 203 - let check_ai_attribution dir = 204 - if not (file_exists (Filename.concat dir ".git")) then [] 213 + let check_ai_attribution ~process_mgr ~fs dir = 214 + if not (file_exists ~fs (Filename.concat dir ".git")) then [] 205 215 else 206 216 (* Get commits by the configured user that contain AI attribution patterns *) 207 217 let cmd = 208 218 "git log --format='%h %s' --grep='Co-Authored-By.*[Cc]laude' \ 209 219 --author=\"$(git config user.name)\" 2>/dev/null" 210 220 in 211 - let lines = run_in_dir dir cmd in 221 + let lines = run_in_dir ~process_mgr ~fs dir cmd in 212 222 List.filter_map 213 223 (fun line -> 214 224 if String.length line > 8 then ··· 217 227 Some { hash; subject } 218 228 else None) 219 229 lines 230 + 231 + (* Tabular output helpers *) 232 + 233 + let status_icon ok = if ok then "+" else "-" 234 + 235 + let format_status_row dir status = 236 + let name = Filename.basename dir in 237 + Printf.sprintf 238 + "%-20s %s pre-commit %s commit-msg %s ocamlformat %s fmt-enabled" name 239 + (status_icon status.has_pre_commit) 240 + (status_icon status.has_commit_msg) 241 + (status_icon status.has_ocamlformat) 242 + (status_icon (not status.formatting_disabled)) 243 + 244 + let format_status_header () = 245 + Printf.sprintf "%-20s %-11s %-11s %-12s %-11s" "Directory" "pre-commit" 246 + "commit-msg" "ocamlformat" "formatting" 247 + 248 + let format_status_separator () = String.make 80 '-' 249 + 250 + let pp_status_table ppf statuses = 251 + Format.fprintf ppf "%s@." (format_status_header ()); 252 + Format.fprintf ppf "%s@." (format_status_separator ()); 253 + List.iter 254 + (fun (dir, status) -> 255 + Format.fprintf ppf "%s@." (format_status_row dir status)) 256 + statuses 257 + 258 + let check_all ~fs dirs = List.map (fun dir -> (dir, status_in_dir ~fs dir)) dirs
+3 -1
lib/precommit.mli
··· 18 18 has_pre_commit : bool; 19 19 has_commit_msg : bool; 20 20 has_ocamlformat : bool; 21 + formatting_disabled : bool; 21 22 is_ocaml_project : bool; 22 23 is_git_repo : bool; 23 24 } 24 - (** Status of hooks in a directory. *) 25 + (** Status of hooks in a directory. [formatting_disabled] is [true] if 26 + dune-project contains "(formatting disabled)". *) 25 27 26 28 (** {1 Operations} *) 27 29