···11-(** CLI for pre-commit hook initialization. *)
11+(** CLI for pre-commit hook initialisation. *)
2233open Cmdliner
4455+(* {1 Common arguments} *)
66+77+let dirs =
88+ let doc = "Directories to operate on. Defaults to the current directory." in
99+ Arg.(value & pos_all dir [ "." ] & info [] ~docv:"DIR" ~doc)
1010+511let dry_run =
66- let doc = "Print what would be done without making changes." in
1212+ let doc = "Show what would be done without making changes." in
713 Arg.(value & flag & info [ "n"; "dry-run" ] ~doc)
81499-let init dry_run =
1010- match Precommit.init ~dry_run () with
1111- | Ok () ->
1212- if not dry_run then
1313- print_endline "Pre-commit hooks initialized successfully."
1515+let recursive =
1616+ let doc = "Operate on all OCaml projects in subdirectories." in
1717+ Arg.(value & flag & info [ "r"; "recursive" ] ~doc)
1818+1919+(* {1 Helpers} *)
2020+2121+let or_die = function
2222+ | Ok () -> ()
1423 | Error msg ->
1515- Printf.eprintf "Error: %s\n" msg;
2424+ Printf.eprintf "precommit: %s\n" msg;
1625 exit 1
17262727+let collect_dirs ~recursive dirs =
2828+ if recursive then
2929+ List.concat_map
3030+ (fun d ->
3131+ let subs = Precommit.list_subdirs d in
3232+ if subs = [] then [ d ] else subs)
3333+ dirs
3434+ else dirs
3535+3636+(* {1 Init command} *)
3737+3838+let init dry_run recursive dirs =
3939+ let dirs = collect_dirs ~recursive dirs in
4040+ List.iter
4141+ (fun d ->
4242+ let s = Precommit.status_in_dir d in
4343+ if s.is_ocaml_project && s.is_git_repo then
4444+ if not (s.has_pre_commit && s.has_commit_msg) then begin
4545+ or_die (Precommit.init_in_dir ~dry_run d);
4646+ let verb = if dry_run then "Would initialise" else "Initialised" in
4747+ Printf.printf "%s: %s\n" d verb
4848+ end)
4949+ dirs
5050+1851let init_cmd =
1919- let doc = "Initialize pre-commit hooks for an OCaml project." in
2020- let info = Cmd.info "init" ~doc in
2121- Cmd.v info Term.(const init $ dry_run)
5252+ let doc = "Initialise pre-commit hooks for OCaml projects." in
5353+ let man =
5454+ [
5555+ `S Manpage.s_description;
5656+ `P
5757+ "Install git hooks that run $(b,dune fmt) before commit and remove \
5858+ Claude attribution from commit messages.";
5959+ `S Manpage.s_examples;
6060+ `P "Initialise hooks in the current directory:";
6161+ `Pre " precommit init";
6262+ `P "Initialise hooks in all projects under src/:";
6363+ `Pre " precommit init -r src/";
6464+ `P "Preview what would be done:";
6565+ `Pre " precommit init -n -r .";
6666+ ]
6767+ in
6868+ let info = Cmd.info "init" ~doc ~man in
6969+ Cmd.v info Term.(const init $ dry_run $ recursive $ dirs)
7070+7171+(* {1 Status command} *)
7272+7373+let pp_check b = if b then "\027[32m✓\027[0m" else "\027[31m✗\027[0m"
7474+7575+let pp_status name (s : Precommit.hook_status) =
7676+ Printf.printf "%s %s %s %s\n"
7777+ (pp_check s.has_pre_commit)
7878+ (pp_check s.has_commit_msg)
7979+ (pp_check s.is_ocaml_project)
8080+ name
22812323-let default_cmd =
2424- let doc = "Pre-commit hook initialization for OCaml projects." in
2525- let info = Cmd.info "precommit" ~version:"0.1.0" ~doc in
8282+let status recursive dirs =
8383+ let dirs = collect_dirs ~recursive dirs in
8484+ Printf.printf "pre-commit commit-msg ocaml directory\n";
8585+ Printf.printf "---------- ---------- ----- ---------\n";
8686+ let missing = ref false in
8787+ List.iter
8888+ (fun d ->
8989+ let s = Precommit.status_in_dir d in
9090+ pp_status d s;
9191+ if s.is_ocaml_project && s.is_git_repo then
9292+ if not (s.has_pre_commit && s.has_commit_msg) then missing := true)
9393+ dirs;
9494+ if !missing then exit 1
9595+9696+let status_cmd =
9797+ let doc = "Check pre-commit hook status." in
9898+ let man =
9999+ [
100100+ `S Manpage.s_description;
101101+ `P "Show which directories have hooks installed.";
102102+ `P
103103+ "Columns show: pre-commit hook, commit-msg hook, is OCaml project. \
104104+ Exit code is 1 if any OCaml project is missing hooks.";
105105+ `S Manpage.s_examples;
106106+ `P "Check status of all projects under src/:";
107107+ `Pre " precommit status -r src/";
108108+ ]
109109+ in
110110+ let info = Cmd.info "status" ~doc ~man in
111111+ Cmd.v info Term.(const status $ recursive $ dirs)
112112+113113+(* {1 Check command} *)
114114+115115+let check recursive dirs =
116116+ let dirs = collect_dirs ~recursive dirs in
117117+ let found = ref false in
118118+ List.iter
119119+ (fun d ->
120120+ let commits = Precommit.check_ai_attribution d in
121121+ if commits <> [] then begin
122122+ found := true;
123123+ Printf.printf "%s:\n" d;
124124+ List.iter
125125+ (fun (c : Precommit.ai_commit) ->
126126+ Printf.printf " %s %s\n" c.hash c.subject)
127127+ commits
128128+ end)
129129+ dirs;
130130+ if !found then exit 1
131131+132132+let check_cmd =
133133+ let doc = "Check git history for commits with AI attribution." in
134134+ let man =
135135+ [
136136+ `S Manpage.s_description;
137137+ `P
138138+ "Scan git history for commits by the configured user that contain \
139139+ 'claude' in the commit message. Exit code is 1 if any are found.";
140140+ `S Manpage.s_examples;
141141+ `P "Check all projects under src/:";
142142+ `Pre " precommit check -r src/";
143143+ ]
144144+ in
145145+ let info = Cmd.info "check" ~doc ~man in
146146+ Cmd.v info Term.(const check $ recursive $ dirs)
147147+148148+(* {1 Main} *)
149149+150150+let main_cmd =
151151+ let doc = "Manage pre-commit hooks for OCaml projects." in
152152+ let man =
153153+ [
154154+ `S Manpage.s_description;
155155+ `P
156156+ "$(tname) installs git hooks that enforce code formatting and commit \
157157+ message hygiene for OCaml projects.";
158158+ `S Manpage.s_bugs;
159159+ `P "Report issues at https://github.com/gazagnaire.org/ocaml-precommit";
160160+ `S "EXIT STATUS";
161161+ `P "$(b,0) on success.";
162162+ `P "$(b,1) if hooks are missing (status) or initialisation failed.";
163163+ ]
164164+ in
165165+ let info = Cmd.info "precommit" ~version:"0.1.0" ~doc ~man in
26166 let default = Term.(ret (const (`Help (`Pager, None)))) in
2727- Cmd.group info ~default [ init_cmd ]
167167+ Cmd.group info ~default [ init_cmd; status_cmd; check_cmd ]
281682929-let () = exit (Cmd.eval default_cmd)
169169+let () = exit (Cmd.eval main_cmd)
+119-34
lib/precommit.ml
···7979 if dry_run then Printf.printf "Would chmod +x %s\n" path
8080 else Unix.chmod path 0o755
81818282-let find_git_dir () =
8383- (* Find .git directory, handling worktrees *)
8484- if file_exists ".git" then
8585- if Sys.is_directory ".git" then Some ".git"
8686- else begin
8787- (* .git is a file pointing to the real git dir (worktree) *)
8888- let ic = open_in ".git" in
8989- let line = input_line ic in
9090- close_in ic;
9191- if String.length line > 8 && String.sub line 0 8 = "gitdir: " then
9292- Some (String.sub line 8 (String.length line - 8))
9393- else None
9494- end
9595- else None
8282+let init_in_dir ~dry_run dir =
8383+ let dune_project = Filename.concat dir "dune-project" in
8484+ let git_dir_path = Filename.concat dir ".git" in
8585+ if not (file_exists dune_project) then
8686+ Error (Printf.sprintf "%s: No dune-project found" dir)
8787+ else if not (file_exists git_dir_path) then
8888+ Error (Printf.sprintf "%s: No .git directory found" dir)
8989+ else
9090+ let git_dir =
9191+ if Sys.is_directory git_dir_path then git_dir_path
9292+ else begin
9393+ (* .git is a file pointing to the real git dir (worktree) *)
9494+ let ic = open_in git_dir_path in
9595+ let line = input_line ic in
9696+ close_in ic;
9797+ if String.length line > 8 && String.sub line 0 8 = "gitdir: " then
9898+ String.sub line 8 (String.length line - 8)
9999+ else git_dir_path
100100+ end
101101+ in
102102+ let hooks_dir = Filename.concat git_dir "hooks" in
103103+104104+ (* Create hooks directory if needed *)
105105+ if dry_run then Printf.printf "Would create %s/\n" hooks_dir
106106+ else mkdir_p hooks_dir;
107107+108108+ (* Install pre-commit hook *)
109109+ let pre_commit_path = Filename.concat hooks_dir "pre-commit" in
110110+ write_file ~dry_run pre_commit_path pre_commit_hook;
111111+ chmod_exec ~dry_run pre_commit_path;
112112+113113+ (* Install commit-msg hook *)
114114+ let commit_msg_path = Filename.concat hooks_dir "commit-msg" in
115115+ write_file ~dry_run commit_msg_path commit_msg_hook;
116116+ chmod_exec ~dry_run commit_msg_path;
117117+118118+ Ok ()
119119+120120+let init ~dry_run () = init_in_dir ~dry_run "."
961219797-let init ~dry_run () =
9898- (* Check if we're in an OCaml project *)
9999- if not (file_exists "dune-project") then
100100- Error "No dune-project found. Run this from an OCaml project root."
122122+type hook_status = {
123123+ has_pre_commit : bool;
124124+ has_commit_msg : bool;
125125+ is_ocaml_project : bool;
126126+ is_git_repo : bool;
127127+}
128128+129129+let status_in_dir dir =
130130+ let dune_project = Filename.concat dir "dune-project" in
131131+ let git_dir_path = Filename.concat dir ".git" in
132132+ let is_ocaml_project = file_exists dune_project in
133133+ let is_git_repo = file_exists git_dir_path in
134134+ if not is_git_repo then
135135+ {
136136+ has_pre_commit = false;
137137+ has_commit_msg = false;
138138+ is_ocaml_project;
139139+ is_git_repo;
140140+ }
101141 else
102102- match find_git_dir () with
103103- | None -> Error "No .git directory found. Run this from a git repository."
104104- | Some git_dir ->
105105- let hooks_dir = Filename.concat git_dir "hooks" in
142142+ let git_dir =
143143+ if Sys.is_directory git_dir_path then git_dir_path
144144+ else begin
145145+ let ic = open_in git_dir_path in
146146+ let line = input_line ic in
147147+ close_in ic;
148148+ if String.length line > 8 && String.sub line 0 8 = "gitdir: " then
149149+ String.sub line 8 (String.length line - 8)
150150+ else git_dir_path
151151+ end
152152+ in
153153+ let hooks_dir = Filename.concat git_dir "hooks" in
154154+ let pre_commit_path = Filename.concat hooks_dir "pre-commit" in
155155+ let commit_msg_path = Filename.concat hooks_dir "commit-msg" in
156156+ {
157157+ has_pre_commit = file_exists pre_commit_path;
158158+ has_commit_msg = file_exists commit_msg_path;
159159+ is_ocaml_project;
160160+ is_git_repo;
161161+ }
106162107107- (* Create hooks directory if needed *)
108108- if dry_run then Printf.printf "Would create %s/\n" hooks_dir
109109- else mkdir_p hooks_dir;
163163+let status () = status_in_dir "."
110164111111- (* Install pre-commit hook *)
112112- let pre_commit_path = Filename.concat hooks_dir "pre-commit" in
113113- write_file ~dry_run pre_commit_path pre_commit_hook;
114114- chmod_exec ~dry_run pre_commit_path;
165165+let list_subdirs dir =
166166+ let entries = Sys.readdir dir in
167167+ Array.to_list entries
168168+ |> List.filter (fun name ->
169169+ let path = Filename.concat dir name in
170170+ Sys.is_directory path && name.[0] <> '.')
171171+ |> List.map (fun name -> Filename.concat dir name)
172172+ |> List.sort String.compare
115173116116- (* Install commit-msg hook *)
117117- let commit_msg_path = Filename.concat hooks_dir "commit-msg" in
118118- write_file ~dry_run commit_msg_path commit_msg_hook;
119119- chmod_exec ~dry_run commit_msg_path;
174174+let run_in_dir dir cmd =
175175+ let old_cwd = Sys.getcwd () in
176176+ Sys.chdir dir;
177177+ let ic = Unix.open_process_in cmd in
178178+ let lines = ref [] in
179179+ (try
180180+ while true do
181181+ lines := input_line ic :: !lines
182182+ done
183183+ with End_of_file -> ());
184184+ ignore (Unix.close_process_in ic);
185185+ Sys.chdir old_cwd;
186186+ List.rev !lines
120187121121- Ok ()
188188+type ai_commit = { hash : string; subject : string }
189189+190190+let check_ai_attribution dir =
191191+ if not (file_exists (Filename.concat dir ".git")) then []
192192+ else
193193+ (* Get commits by the configured user that contain "claude" *)
194194+ let cmd =
195195+ "git log --format='%h %s' --grep='[Cc]laude' --author=\"$(git config \
196196+ user.name)\" 2>/dev/null"
197197+ in
198198+ let lines = run_in_dir dir cmd in
199199+ List.filter_map
200200+ (fun line ->
201201+ if String.length line > 8 then
202202+ let hash = String.sub line 0 7 in
203203+ let subject = String.sub line 8 (String.length line - 8) in
204204+ Some { hash; subject }
205205+ else None)
206206+ lines
+31
lib/precommit.mli
···1212(** Shell script for the commit-msg hook. Checks for emojis (rejected) and
1313 removes lines containing "claude" (case-insensitive). *)
14141515+(** {1 Types} *)
1616+1717+type hook_status = {
1818+ has_pre_commit : bool;
1919+ has_commit_msg : bool;
2020+ is_ocaml_project : bool;
2121+ is_git_repo : bool;
2222+}
2323+(** Status of hooks in a directory. *)
2424+1525(** {1 Operations} *)
16261727val init : dry_run:bool -> unit -> (unit, string) result
···2434 If [dry_run] is [true], prints what would be done without making changes.
25352636 Returns [Error msg] if not in a git repository or OCaml project. *)
3737+3838+val init_in_dir : dry_run:bool -> string -> (unit, string) result
3939+(** [init_in_dir ~dry_run dir] installs hooks in the specified directory. *)
4040+4141+val status : unit -> hook_status
4242+(** [status ()] checks hook status in the current directory. *)
4343+4444+val status_in_dir : string -> hook_status
4545+(** [status_in_dir dir] checks hook status in the specified directory. *)
4646+4747+val list_subdirs : string -> string list
4848+(** [list_subdirs dir] lists subdirectories (excluding hidden ones). *)
4949+5050+(** {1 History checks} *)
5151+5252+type ai_commit = { hash : string; subject : string }
5353+(** A commit with AI attribution. *)
5454+5555+val check_ai_attribution : string -> ai_commit list
5656+(** [check_ai_attribution dir] finds commits by the configured git user that
5757+ contain "claude" in the commit message. *)