···2020(* {1 Common arguments} *)
21212222let dirs =
2323- let doc =
2424- "Root directories to scan for git projects. Defaults to the current \
2525- directory. Each directory is scanned recursively for repositories \
2626- containing a $(b,.git) entry."
2727- in
2323+ let doc = "Directories to operate on. Defaults to the current directory." in
2824 Arg.(value & pos_all dir [ "." ] & info [] ~docv:"DIR" ~doc)
29253026let dry_run =
3127 let doc = "Show what would be done without making changes." in
3228 Arg.(value & flag & info [ "n"; "dry-run" ] ~doc)
2929+3030+let recursive =
3131+ let doc = "Operate on all OCaml projects in subdirectories." in
3232+ Arg.(value & flag & info [ "r"; "recursive" ] ~doc)
33333434let force =
3535 let doc = "Install hooks even if no dune-project is found." in
···7171 error "%s" msg;
7272 exit 1
73737474-let collect_dirs ~fs dirs =
7575- List.concat_map (fun d -> Precommit.find_git_projects ~fs d) dirs
7474+let collect_dirs ~fs ~recursive dirs =
7575+ if recursive then
7676+ List.concat_map (fun d -> Precommit.find_git_projects ~fs d) dirs
7777+ else dirs
76787779(* {1 Init command} *)
78807979-let init_impl ~fs dry_run force hooks dirs =
8080- let dirs = collect_dirs ~fs dirs in
8181+let init_impl ~fs dry_run force hooks recursive dirs =
8282+ let dirs = collect_dirs ~fs ~recursive dirs in
8183 let count = ref 0 in
8284 List.iter
8385 (fun d ->
···9799 Log.info (fun m ->
98100 m "Processed %d director%s" !count (if !count = 1 then "y" else "ies"))
99101100100-let init dry_run force hooks dirs =
102102+let init dry_run force hooks recursive dirs =
101103 Eio_main.run @@ fun env ->
102104 let fs = Eio.Stdenv.cwd env in
103103- init_impl ~fs dry_run force hooks dirs
105105+ init_impl ~fs dry_run force hooks recursive dirs
104106105107let init_cmd =
106108 let doc = "Initialise pre-commit hooks for OCaml projects." in
···115117 `P "Initialise hooks in the current directory:";
116118 `Pre " precommit init";
117119 `P "Initialise hooks in all projects under src/:";
118118- `Pre " precommit init src/";
120120+ `Pre " precommit init -r src/";
119121 `P "Preview what would be done:";
120120- `Pre " precommit init -n";
122122+ `Pre " precommit init -n -r .";
121123 `P "Install only the AI attribution hook in a non-OCaml project:";
122124 `Pre " precommit init -f --hooks ai";
123125 `P "Install only the dune fmt hook:";
···125127 ]
126128 in
127129 let info = Cmd.info "init" ~doc ~man in
128128- Cmd.v info Term.(const init $ dry_run $ force $ hooks $ dirs)
130130+ Cmd.v info Term.(const init $ dry_run $ force $ hooks $ recursive $ dirs)
129131130132(* {1 Status command} *)
131133···133135 if b then Tty.Span.styled Tty.Style.(fg Tty.Color.green) "+"
134136 else Tty.Span.styled Tty.Style.(fg Tty.Color.red) "-"
135137136136-let status_impl ~fs dirs =
137137- let dirs = collect_dirs ~fs dirs in
138138+let status_impl ~fs recursive dirs =
139139+ let dirs = collect_dirs ~fs ~recursive dirs in
138140 let missing = ref 0 in
139141 let ok = ref 0 in
140142 let rows =
···181183 else if !ok > 0 then
182184 success "%d project%s properly configured" !ok (if !ok = 1 then "" else "s")
183185184184-let status dirs =
186186+let status recursive dirs =
185187 Eio_main.run @@ fun env ->
186188 let fs = Eio.Stdenv.cwd env in
187187- status_impl ~fs dirs
189189+ status_impl ~fs recursive dirs
188190189191let status_cmd =
190192 let doc = "Check pre-commit hook status." in
···198200 hooks, .ocamlformat, or has formatting disabled.";
199201 `S Manpage.s_examples;
200202 `P "Check status of all projects under src/:";
201201- `Pre " precommit status src/";
203203+ `Pre " precommit status -r src/";
202204 ]
203205 in
204206 let info = Cmd.info "status" ~doc ~man in
205205- Cmd.v info Term.(const status $ dirs)
207207+ Cmd.v info Term.(const status $ recursive $ dirs)
206208207209(* {1 Check command} *)
208210···271273 end;
272274 (List.rev !affected_dirs, !total_commits, !repos_with_issues)
273275274274-let check_impl ~process_mgr ~fs dirs =
275275- let dirs = collect_dirs ~fs dirs in
276276+let check_impl ~process_mgr ~fs recursive dirs =
277277+ let dirs = collect_dirs ~fs ~recursive dirs in
276278 let _affected, total_commits, repos_with_issues =
277279 find_and_display_ai_commits ~process_mgr ~fs dirs
278280 in
···285287 end
286288 else success "No AI attribution found in commit history"
287289288288-let check dirs =
290290+let check recursive dirs =
289291 Eio_main.run @@ fun env ->
290292 let fs = Eio.Stdenv.cwd env in
291293 let process_mgr = Eio.Stdenv.process_mgr env in
292292- check_impl ~process_mgr ~fs dirs
294294+ check_impl ~process_mgr ~fs recursive dirs
293295294296let check_cmd =
295297 let doc = "Check git history for commits with AI attribution." in
···301303 'claude' in the commit message. Exit code is 1 if any are found.";
302304 `S Manpage.s_examples;
303305 `P "Check all projects under src/:";
304304- `Pre " precommit check src/";
306306+ `Pre " precommit check -r src/";
305307 ]
306308 in
307309 let info = Cmd.info "check" ~doc ~man in
308308- Cmd.v info Term.(const check $ dirs)
310310+ Cmd.v info Term.(const check $ recursive $ dirs)
309311310312(* {1 Fix command} *)
311313···332334 let answer = String.trim line in
333335 answer = "y" || answer = "Y"
334336335335-let fix_impl ~process_mgr ~fs dry_run yes dirs =
336336- let dirs = collect_dirs ~fs dirs in
337337+let fix_impl ~process_mgr ~fs dry_run yes recursive dirs =
338338+ let dirs = collect_dirs ~fs ~recursive dirs in
337339 let affected, total_commits, repos_with_issues =
338340 find_and_display_ai_commits ~process_mgr ~fs dirs
339341 in
···385387 let doc = "Skip interactive confirmation prompt." in
386388 Arg.(value & flag & info [ "y"; "yes" ] ~doc)
387389388388-let fix dry_run yes dirs =
390390+let fix dry_run yes recursive dirs =
389391 Eio_main.run @@ fun env ->
390392 let fs = Eio.Stdenv.cwd env in
391393 let process_mgr = Eio.Stdenv.process_mgr env in
392392- fix_impl ~process_mgr ~fs dry_run yes dirs
394394+ fix_impl ~process_mgr ~fs dry_run yes recursive dirs
393395394396let fix_cmd =
395397 let doc = "Remove AI attribution from commit history." in
···406408 the interactive confirmation prompt.";
407409 `S Manpage.s_examples;
408410 `P "Fix all projects under the current directory:";
409409- `Pre " precommit fix";
411411+ `Pre " precommit fix -r";
410412 `P "Preview what would be done:";
411411- `Pre " precommit fix -n";
413413+ `Pre " precommit fix -n -r .";
412414 `P "Fix without confirmation prompt:";
413415 `Pre " precommit fix -y";
414416 ]
415417 in
416418 let info = Cmd.info "fix" ~doc ~man in
417417- Cmd.v info Term.(const fix $ dry_run $ yes $ dirs)
419419+ Cmd.v info Term.(const fix $ dry_run $ yes $ recursive $ dirs)
418420419421(* {1 Main} *)
420422
+9-22
lib/precommit.ml
···7272 | `Directory -> true
7373 | _ -> false
74747575-let is_symlink ~fs path =
7676- match Eio.Path.kind ~follow:false Eio.Path.(fs / path) with
7777- | `Symbolic_link -> true
7878- | _ -> false
7979-8075let read_file ~fs path = Eio.Path.load Eio.Path.(fs / path)
81768277let write_file ~fs ~dry_run path content =
···202197 |> List.sort String.compare
203198204199let rec find_git_projects ~fs dir =
205205- let entries = try Eio.Path.read_dir Eio.Path.(fs / dir) with _ -> [] in
206206- let child_path name = if dir = "." then name else Filename.concat dir name in
207207- let self = if List.mem ".git" entries then [ dir ] else [] in
208208- let children =
200200+ let git_dir = Filename.concat dir ".git" in
201201+ if file_exists ~fs git_dir then [ dir ]
202202+ else
203203+ let entries = try Eio.Path.read_dir Eio.Path.(fs / dir) with _ -> [] in
209204 entries
210205 |> List.filter_map (fun name ->
211211- if String.length name > 0 && (name.[0] = '.' || name.[0] = '_') then
212212- None
206206+ if String.length name > 0 && name.[0] = '.' then None
213207 else
214214- let path = child_path name in
215215- (* Skip symlinks to avoid traversing outside the sandbox *)
216216- if is_symlink ~fs path then None
217217- else if is_directory ~fs path then Some path
218218- else None)
208208+ let path = Filename.concat dir name in
209209+ if is_directory ~fs path then Some path else None)
219210 |> List.sort String.compare
220220- |> List.concat_map (fun sub ->
221221- if file_exists ~fs (Filename.concat sub ".git") then [ sub ]
222222- else find_git_projects ~fs sub)
223223- in
224224- self @ children
211211+ |> List.concat_map (fun sub -> find_git_projects ~fs sub)
225212226213let run_in_dir ~process_mgr ~fs dir cmd =
227214 let cwd = Eio.Path.(fs / dir) in
···301288 | Ok _lines ->
302289 (* Count how many commits were actually rewritten *)
303290 let count_cmd =
304304- "git log --format='%H' HEAD --grep='Co-Authored-By.*[Cc]laude' \
291291+ "git log --format='%H' --all --grep='Co-Authored-By.*[Cc]laude' \
305292 2>/dev/null | wc -l"
306293 in
307294 let remaining =