···11+MIT License
22+33+Copyright (c) 2025 Thomas Gazagnaire
44+55+Permission is hereby granted, free of charge, to any person obtaining a copy
66+of this software and associated documentation files (the "Software"), to deal
77+in the Software without restriction, including without limitation the rights
88+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
99+copies of the Software, and to permit persons to whom the Software is
1010+furnished to do so, subject to the following conditions:
1111+1212+The above copyright notice and this permission notice shall be included in all
1313+copies or substantial portions of the Software.
1414+1515+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
1616+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
1717+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
1818+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
1919+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
2020+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
2121+SOFTWARE.
+47
README.md
···11+# precommit
22+33+Pre-commit hook initialization for OCaml projects.
44+55+## Overview
66+77+This tool sets up pre-commit hooks for OCaml projects:
88+- **dune-format**: Automatically formats OCaml code using `dune fmt --auto`
99+- **remove-claude-attribution**: Removes Claude co-author lines and rejects emojis in commit messages
1010+1111+## Installation
1212+1313+```
1414+opam install precommit
1515+```
1616+1717+## Usage
1818+1919+Run in an OCaml project directory (where `dune-project` exists):
2020+2121+```bash
2222+precommit init
2323+```
2424+2525+This creates:
2626+- `.pre-commit-config.yaml` - Hook configuration
2727+- `.hooks/remove-claude-lines.py` - Commit message filter
2828+2929+And runs `pre-commit install` to activate the hooks.
3030+3131+### Dry run
3232+3333+To see what would be created without making changes:
3434+3535+```bash
3636+precommit init --dry-run
3737+```
3838+3939+## Requirements
4040+4141+- [pre-commit](https://pre-commit.com/) must be installed
4242+- Python 3 (for the commit message hook)
4343+- dune (for code formatting)
4444+4545+## License
4646+4747+MIT License. See [LICENSE.md](LICENSE.md) for details.
···11+(** CLI for pre-commit hook initialization. *)
22+33+open Cmdliner
44+55+let dry_run =
66+ let doc = "Print what would be done without making changes." in
77+ Arg.(value & flag & info [ "n"; "dry-run" ] ~doc)
88+99+let init_cmd =
1010+ let doc = "Initialize pre-commit hooks for an OCaml project." in
1111+ let info = Cmd.info "init" ~doc in
1212+ let term =
1313+ Term.(
1414+ const (fun dry_run ->
1515+ match Precommit.init ~dry_run () with
1616+ | Ok () ->
1717+ if not dry_run then
1818+ print_endline "Pre-commit hooks initialized successfully.";
1919+ `Ok ()
2020+ | Error msg ->
2121+ Printf.eprintf "Error: %s\n" msg;
2222+ `Error (false, msg))
2323+ $ dry_run)
2424+ in
2525+ Cmd.v info (Term.ret term)
2626+2727+let default_cmd =
2828+ let doc = "Pre-commit hook initialization for OCaml projects." in
2929+ let info = Cmd.info "precommit" ~version:"0.1.0" ~doc in
3030+ let default = Term.(ret (const (`Help (`Pager, None)))) in
3131+ Cmd.group info ~default [ init_cmd ]
3232+3333+let () = exit (Cmd.eval default_cmd)
+24
dune-project
···11+(lang dune 3.0)
22+33+(name precommit)
44+55+(generate_opam_files true)
66+77+(license MIT)
88+(authors "Thomas Gazagnaire <thomas@gazagnaire.org>")
99+(maintainers "Thomas Gazagnaire <thomas@gazagnaire.org>")
1010+(homepage "https://tangled.org/@gazagnaire.org/ocaml-precommit")
1111+(bug_reports "https://tangled.org/@gazagnaire.org/ocaml-precommit/issues")
1212+1313+(package
1414+ (name precommit)
1515+ (synopsis "Pre-commit hook initialization for OCaml projects")
1616+ (description
1717+ "A CLI tool to initialize pre-commit hooks for OCaml projects. Sets up \
1818+ automatic formatting with dune fmt and removes Claude attribution from \
1919+ commit messages.")
2020+ (depends
2121+ (ocaml (>= 4.08))
2222+ (cmdliner (>= 1.2))
2323+ (ocamlformat :with-dev-setup)
2424+ (alcotest :with-test)))
···11+(** Pre-commit hook initialization for OCaml projects. *)
22+33+let pre_commit_config =
44+ {|repos:
55+ - repo: local
66+ hooks:
77+ - id: dune-format
88+ name: Auto format with dune
99+ entry: dune fmt --auto
1010+ language: system
1111+ files: \.(ml|mli|mll|mly)$
1212+ stages: [pre-commit]
1313+ pass_filenames: false
1414+1515+ - id: remove-claude-attribution
1616+ name: Remove Claude attribution from commit message
1717+ entry: python3 .hooks/remove-claude-lines.py
1818+ language: system
1919+ stages: [commit-msg]
2020+|}
2121+2222+let remove_claude_lines =
2323+ {|#!/usr/bin/env python3
2424+import sys
2525+import re
2626+2727+def has_emoji(text):
2828+ # Unicode ranges for emojis
2929+ emoji_pattern = re.compile(
3030+ "["
3131+ "\U0001F600-\U0001F64F" # emoticons
3232+ "\U0001F300-\U0001F5FF" # symbols & pictographs
3333+ "\U0001F680-\U0001F6FF" # transport & map symbols
3434+ "\U0001F1E0-\U0001F1FF" # flags (iOS)
3535+ "\U00002702-\U000027B0" # dingbats
3636+ "\U000024C2-\U0001F251"
3737+ "]+", flags=re.UNICODE)
3838+ return bool(emoji_pattern.search(text))
3939+4040+def main():
4141+ commit_msg_file = sys.argv[1]
4242+ with open(commit_msg_file, 'r', encoding='utf-8') as f:
4343+ lines = f.readlines()
4444+4545+ # Check for emojis in the commit message
4646+ commit_text = ''.join(lines)
4747+ if has_emoji(commit_text):
4848+ print("Error: Commit message contains emojis, which are not allowed.", file=sys.stderr)
4949+ return 1
5050+5151+ filtered_lines = [line for line in lines if 'claude' not in line.lower()]
5252+5353+ with open(commit_msg_file, 'w', encoding='utf-8') as f:
5454+ f.writelines(filtered_lines)
5555+5656+ return 0
5757+5858+if __name__ == '__main__':
5959+ sys.exit(main())
6060+|}
6161+6262+let file_exists path = Sys.file_exists path
6363+6464+let mkdir_p path =
6565+ if not (file_exists path) then Unix.mkdir path 0o755
6666+6767+let write_file ~dry_run path content =
6868+ if dry_run then Printf.printf "Would create %s\n" path
6969+ else begin
7070+ let oc = open_out path in
7171+ output_string oc content;
7272+ close_out oc
7373+ end
7474+7575+let chmod_exec ~dry_run path =
7676+ if dry_run then Printf.printf "Would chmod +x %s\n" path
7777+ else Unix.chmod path 0o755
7878+7979+let run_command ~dry_run cmd =
8080+ if dry_run then begin
8181+ Printf.printf "Would run: %s\n" cmd;
8282+ 0
8383+ end
8484+ else Sys.command cmd
8585+8686+let init ~dry_run () =
8787+ (* Check if we're in an OCaml project *)
8888+ if not (file_exists "dune-project") then
8989+ Error "No dune-project found. Run this from an OCaml project root."
9090+ else begin
9191+ (* Create .hooks directory *)
9292+ if dry_run then Printf.printf "Would create .hooks/\n"
9393+ else mkdir_p ".hooks";
9494+9595+ (* Write .pre-commit-config.yaml *)
9696+ write_file ~dry_run ".pre-commit-config.yaml" pre_commit_config;
9797+9898+ (* Write .hooks/remove-claude-lines.py *)
9999+ write_file ~dry_run ".hooks/remove-claude-lines.py" remove_claude_lines;
100100+ chmod_exec ~dry_run ".hooks/remove-claude-lines.py";
101101+102102+ (* Run pre-commit install *)
103103+ let cmd = "pre-commit install --hook-type pre-commit --hook-type commit-msg" in
104104+ let exit_code = run_command ~dry_run cmd in
105105+ if exit_code <> 0 then
106106+ Error (Printf.sprintf "pre-commit install failed with exit code %d" exit_code)
107107+ else
108108+ Ok ()
109109+ end
+18
lib/precommit.mli
···11+(** Pre-commit hook initialization for OCaml projects. *)
22+33+(** {1 Templates} *)
44+55+val pre_commit_config : string
66+(** The content of [.pre-commit-config.yaml]. *)
77+88+val remove_claude_lines : string
99+(** The content of [.hooks/remove-claude-lines.py]. *)
1010+1111+(** {1 Operations} *)
1212+1313+val init : dry_run:bool -> unit -> (unit, string) result
1414+(** [init ~dry_run ()] initializes pre-commit hooks in the current directory.
1515+ Creates [.pre-commit-config.yaml] and [.hooks/remove-claude-lines.py],
1616+ then runs [pre-commit install].
1717+1818+ If [dry_run] is [true], prints what would be done without making changes. *)
+32
precommit.opam
···11+# This file is generated by dune, edit dune-project instead
22+opam-version: "2.0"
33+synopsis: "Pre-commit hook initialization for OCaml projects"
44+description:
55+ "A CLI tool to initialize pre-commit hooks for OCaml projects. Sets up automatic formatting with dune fmt and removes Claude attribution from commit messages."
66+maintainer: ["Thomas Gazagnaire <thomas@gazagnaire.org>"]
77+authors: ["Thomas Gazagnaire <thomas@gazagnaire.org>"]
88+license: "MIT"
99+homepage: "https://tangled.org/@gazagnaire.org/ocaml-precommit"
1010+bug-reports: "https://tangled.org/@gazagnaire.org/ocaml-precommit/issues"
1111+depends: [
1212+ "dune" {>= "3.0"}
1313+ "ocaml" {>= "4.08"}
1414+ "cmdliner" {>= "1.2"}
1515+ "ocamlformat" {with-dev-setup}
1616+ "alcotest" {with-test}
1717+ "odoc" {with-doc}
1818+]
1919+build: [
2020+ ["dune" "subst"] {dev}
2121+ [
2222+ "dune"
2323+ "build"
2424+ "-p"
2525+ name
2626+ "-j"
2727+ jobs
2828+ "@install"
2929+ "@runtest" {with-test}
3030+ "@doc" {with-doc}
3131+ ]
3232+]