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: Add --force and --hooks options for flexible hook installation

- Add --force/-f to install hooks without requiring dune-project
- Add --hooks option to select which hooks to install (fmt, ai, or both)
- fmt: pre-commit hook running dune fmt on staged OCaml files
- ai: commit-msg hook removing Claude attribution lines
- Skip .ocamlformat creation when using --force
- Allow installing AI hooks in non-OCaml repos like opam-repo

+98 -30
+46 -8
bin/main.ml
··· 16 16 let doc = "Operate on all OCaml projects in subdirectories." in 17 17 Arg.(value & flag & info [ "r"; "recursive" ] ~doc) 18 18 19 + let force = 20 + let doc = "Install hooks even if no dune-project is found." in 21 + Arg.(value & flag & info [ "f"; "force" ] ~doc) 22 + 23 + let hooks_conv = 24 + let parse s = 25 + let parts = String.split_on_char ',' s in 26 + let fmt = List.mem "fmt" parts in 27 + let ai = List.mem "ai" parts in 28 + if fmt || ai then Ok Precommit.{ fmt; ai } 29 + else Error (`Msg "expected comma-separated list of: fmt, ai") 30 + in 31 + let print ppf h = 32 + let parts = 33 + (if h.Precommit.fmt then [ "fmt" ] else []) 34 + @ if h.Precommit.ai then [ "ai" ] else [] 35 + in 36 + Format.pp_print_string ppf (String.concat "," parts) 37 + in 38 + Arg.conv (parse, print) 39 + 40 + let hooks = 41 + let doc = 42 + "Which hooks to install. Comma-separated list of: $(b,fmt) (pre-commit \ 43 + hook running dune fmt), $(b,ai) (commit-msg hook removing Claude \ 44 + attribution). Default: all." 45 + in 46 + Arg.( 47 + value 48 + & opt hooks_conv Precommit.all_hooks 49 + & info [ "hooks" ] ~doc ~docv:"HOOKS") 50 + 19 51 (* {1 Helpers} *) 20 52 21 53 let or_die = function ··· 35 67 36 68 (* {1 Init command} *) 37 69 38 - let init_impl ~fs dry_run recursive dirs = 70 + let init_impl ~fs dry_run force hooks recursive dirs = 39 71 let dirs = collect_dirs ~fs ~recursive dirs in 40 72 List.iter 41 73 (fun d -> 42 74 let s = Precommit.status_in_dir ~fs d in 43 - if s.is_ocaml_project && s.is_git_repo then 44 - if not (s.has_pre_commit && s.has_commit_msg && s.has_ocamlformat) then begin 45 - or_die (Precommit.init_in_dir ~fs ~dry_run d); 75 + if (force || s.is_ocaml_project) && s.is_git_repo then 76 + let needs_fmt = hooks.Precommit.fmt && not s.has_pre_commit in 77 + let needs_ai = hooks.Precommit.ai && not s.has_commit_msg in 78 + if needs_fmt || needs_ai then begin 79 + or_die (Precommit.init_in_dir ~fs ~dry_run ~force ~hooks d); 46 80 let verb = if dry_run then "Would initialise" else "Initialised" in 47 81 Printf.printf "%s: %s\n" d verb 48 82 end) 49 83 dirs 50 84 51 - let init dry_run recursive dirs = 85 + let init dry_run force hooks recursive dirs = 52 86 Eio_main.run @@ fun env -> 53 87 let fs = Eio.Stdenv.cwd env in 54 - init_impl ~fs dry_run recursive dirs 88 + init_impl ~fs dry_run force hooks recursive dirs 55 89 56 90 let init_cmd = 57 91 let doc = "Initialise pre-commit hooks for OCaml projects." in ··· 61 95 `P 62 96 "Install git hooks that run $(b,dune fmt) before commit and remove \ 63 97 Claude attribution from commit messages. Also creates \ 64 - $(b,.ocamlformat) if missing."; 98 + $(b,.ocamlformat) if missing (unless $(b,--force) is used)."; 65 99 `S Manpage.s_examples; 66 100 `P "Initialise hooks in the current directory:"; 67 101 `Pre " precommit init"; ··· 69 103 `Pre " precommit init -r src/"; 70 104 `P "Preview what would be done:"; 71 105 `Pre " precommit init -n -r ."; 106 + `P "Install only the AI attribution hook in a non-OCaml project:"; 107 + `Pre " precommit init -f --hooks ai"; 108 + `P "Install only the dune fmt hook:"; 109 + `Pre " precommit init --hooks fmt"; 72 110 ] 73 111 in 74 112 let info = Cmd.info "init" ~doc ~man in 75 - Cmd.v info Term.(const init $ dry_run $ recursive $ dirs) 113 + Cmd.v info Term.(const init $ dry_run $ force $ hooks $ recursive $ dirs) 76 114 77 115 (* {1 Status command} *) 78 116
+24 -14
lib/precommit.ml
··· 102 102 | "gitdir:" :: rest -> String.concat " " rest 103 103 | _ -> git_dir_path 104 104 105 - let init_in_dir ~fs ~dry_run dir = 105 + type hooks = { fmt : bool; ai : bool } 106 + 107 + let all_hooks = { fmt = true; ai = true } 108 + 109 + let init_in_dir ~fs ~dry_run ~force ~hooks dir = 106 110 let dune_project = Filename.concat dir "dune-project" in 107 111 let git_dir_path = Filename.concat dir ".git" in 108 112 let ocamlformat_path = Filename.concat dir ".ocamlformat" in 109 - if not (file_exists ~fs dune_project) then 110 - Error (Printf.sprintf "%s: No dune-project found" dir) 113 + if (not force) && not (file_exists ~fs dune_project) then 114 + Error 115 + (Printf.sprintf "%s: No dune-project found (use --force to override)" dir) 111 116 else if not (file_exists ~fs git_dir_path) then 112 117 Error (Printf.sprintf "%s: No .git directory found" dir) 113 118 else ··· 117 122 (* Create hooks directory if needed *) 118 123 mkdir_p ~fs ~dry_run hooks_dir; 119 124 120 - (* Install pre-commit hook *) 121 - let pre_commit_path = Filename.concat hooks_dir "pre-commit" in 122 - write_file ~fs ~dry_run pre_commit_path pre_commit_hook; 123 - chmod_exec ~dry_run pre_commit_path; 125 + (* Install pre-commit hook if requested *) 126 + if hooks.fmt then begin 127 + let pre_commit_path = Filename.concat hooks_dir "pre-commit" in 128 + write_file ~fs ~dry_run pre_commit_path pre_commit_hook; 129 + chmod_exec ~dry_run pre_commit_path 130 + end; 124 131 125 - (* Install commit-msg hook *) 126 - let commit_msg_path = Filename.concat hooks_dir "commit-msg" in 127 - write_file ~fs ~dry_run commit_msg_path commit_msg_hook; 128 - chmod_exec ~dry_run commit_msg_path; 132 + (* Install commit-msg hook if requested *) 133 + if hooks.ai then begin 134 + let commit_msg_path = Filename.concat hooks_dir "commit-msg" in 135 + write_file ~fs ~dry_run commit_msg_path commit_msg_hook; 136 + chmod_exec ~dry_run commit_msg_path 137 + end; 129 138 130 - (* Create .ocamlformat if missing *) 131 - if not (file_exists ~fs ocamlformat_path) then 139 + (* Create .ocamlformat if missing and fmt hooks requested and not forcing *) 140 + if hooks.fmt && (not force) && not (file_exists ~fs ocamlformat_path) then 132 141 write_file ~fs ~dry_run ocamlformat_path default_ocamlformat; 133 142 134 143 Ok () 135 144 136 - let init ~fs ~dry_run () = init_in_dir ~fs ~dry_run "." 145 + let init ~fs ~dry_run ~force ~hooks () = 146 + init_in_dir ~fs ~dry_run ~force ~hooks "." 137 147 138 148 type hook_status = { 139 149 has_pre_commit : bool;
+28 -8
lib/precommit.mli
··· 27 27 28 28 (** {1 Operations} *) 29 29 30 - val init : fs:_ Eio.Path.t -> dry_run:bool -> unit -> (unit, string) result 31 - (** [init ~fs ~dry_run ()] installs git hooks in the current repository. 30 + type hooks = { fmt : bool; ai : bool } 31 + (** Which hooks to install. *) 32 + 33 + val all_hooks : hooks 34 + (** Both fmt and ai hooks. *) 32 35 33 - Creates: 36 + val init : 37 + fs:_ Eio.Path.t -> 38 + dry_run:bool -> 39 + force:bool -> 40 + hooks:hooks -> 41 + unit -> 42 + (unit, string) result 43 + (** [init ~fs ~dry_run ~force ~hooks ()] installs git hooks in the current 44 + repository. 45 + 46 + Creates (depending on [hooks]): 34 47 - [.git/hooks/pre-commit] - runs [dune fmt] on staged OCaml files 35 48 - [.git/hooks/commit-msg] - checks for emojis and removes Claude attribution 36 - - [.ocamlformat] - if missing, creates with default version 49 + - [.ocamlformat] - if missing, creates with default version (unless [force]) 37 50 38 - If [dry_run] is [true], prints what would be done without making changes. 51 + If [dry_run] is [true], prints what would be done without making changes. If 52 + [force] is [true], skips dune-project check and .ocamlformat creation. 39 53 40 - Returns [Error msg] if not in a git repository or OCaml project. *) 54 + Returns [Error msg] if not in a git repository. *) 41 55 42 56 val init_in_dir : 43 - fs:_ Eio.Path.t -> dry_run:bool -> string -> (unit, string) result 44 - (** [init_in_dir ~fs ~dry_run dir] installs hooks in the specified directory. *) 57 + fs:_ Eio.Path.t -> 58 + dry_run:bool -> 59 + force:bool -> 60 + hooks:hooks -> 61 + string -> 62 + (unit, string) result 63 + (** [init_in_dir ~fs ~dry_run ~force ~hooks dir] installs hooks in the specified 64 + directory. *) 45 65 46 66 val status : fs:_ Eio.Path.t -> unit -> hook_status 47 67 (** [status ~fs ()] checks hook status in the current directory. *)