···2020(* {1 Common arguments} *)
21212222let dirs =
2323- let doc = "Directories to operate on. Defaults to the current directory." in
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
2428 Arg.(value & pos_all dir [ "." ] & info [] ~docv:"DIR" ~doc)
25292630let dry_run =
2731 let doc = "Show what would be done without making changes." in
2832 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 ~recursive dirs =
7575- if recursive then
7676- List.concat_map (fun d -> Precommit.find_git_projects ~fs d) dirs
7777- else dirs
7474+let collect_dirs ~fs dirs =
7575+ List.concat_map (fun d -> Precommit.find_git_projects ~fs d) dirs
78767977(* {1 Init command} *)
80788181-let init_impl ~fs dry_run force hooks recursive dirs =
8282- let dirs = collect_dirs ~fs ~recursive dirs in
7979+let init_impl ~fs dry_run force hooks dirs =
8080+ let dirs = collect_dirs ~fs dirs in
8381 let count = ref 0 in
8482 List.iter
8583 (fun d ->
···9997 Log.info (fun m ->
10098 m "Processed %d director%s" !count (if !count = 1 then "y" else "ies"))
10199102102-let init dry_run force hooks recursive dirs =
100100+let init dry_run force hooks dirs =
103101 Eio_main.run @@ fun env ->
104102 let fs = Eio.Stdenv.cwd env in
105105- init_impl ~fs dry_run force hooks recursive dirs
103103+ init_impl ~fs dry_run force hooks dirs
106104107105let init_cmd =
108106 let doc = "Initialise pre-commit hooks for OCaml projects." in
···117115 `P "Initialise hooks in the current directory:";
118116 `Pre " precommit init";
119117 `P "Initialise hooks in all projects under src/:";
120120- `Pre " precommit init -r src/";
118118+ `Pre " precommit init src/";
121119 `P "Preview what would be done:";
122122- `Pre " precommit init -n -r .";
120120+ `Pre " precommit init -n";
123121 `P "Install only the AI attribution hook in a non-OCaml project:";
124122 `Pre " precommit init -f --hooks ai";
125123 `P "Install only the dune fmt hook:";
···127125 ]
128126 in
129127 let info = Cmd.info "init" ~doc ~man in
130130- Cmd.v info Term.(const init $ dry_run $ force $ hooks $ recursive $ dirs)
128128+ Cmd.v info Term.(const init $ dry_run $ force $ hooks $ dirs)
131129132130(* {1 Status command} *)
133131···135133 if b then Tty.Span.styled Tty.Style.(fg Tty.Color.green) "+"
136134 else Tty.Span.styled Tty.Style.(fg Tty.Color.red) "-"
137135138138-let status_impl ~fs recursive dirs =
139139- let dirs = collect_dirs ~fs ~recursive dirs in
136136+let status_impl ~fs dirs =
137137+ let dirs = collect_dirs ~fs dirs in
140138 let missing = ref 0 in
141139 let ok = ref 0 in
142140 let rows =
···183181 else if !ok > 0 then
184182 success "%d project%s properly configured" !ok (if !ok = 1 then "" else "s")
185183186186-let status recursive dirs =
184184+let status dirs =
187185 Eio_main.run @@ fun env ->
188186 let fs = Eio.Stdenv.cwd env in
189189- status_impl ~fs recursive dirs
187187+ status_impl ~fs dirs
190188191189let status_cmd =
192190 let doc = "Check pre-commit hook status." in
···200198 hooks, .ocamlformat, or has formatting disabled.";
201199 `S Manpage.s_examples;
202200 `P "Check status of all projects under src/:";
203203- `Pre " precommit status -r src/";
201201+ `Pre " precommit status src/";
204202 ]
205203 in
206204 let info = Cmd.info "status" ~doc ~man in
207207- Cmd.v info Term.(const status $ recursive $ dirs)
205205+ Cmd.v info Term.(const status $ dirs)
208206209207(* {1 Check command} *)
210208···273271 end;
274272 (List.rev !affected_dirs, !total_commits, !repos_with_issues)
275273276276-let check_impl ~process_mgr ~fs recursive dirs =
277277- let dirs = collect_dirs ~fs ~recursive dirs in
274274+let check_impl ~process_mgr ~fs dirs =
275275+ let dirs = collect_dirs ~fs dirs in
278276 let _affected, total_commits, repos_with_issues =
279277 find_and_display_ai_commits ~process_mgr ~fs dirs
280278 in
···287285 end
288286 else success "No AI attribution found in commit history"
289287290290-let check recursive dirs =
288288+let check dirs =
291289 Eio_main.run @@ fun env ->
292290 let fs = Eio.Stdenv.cwd env in
293291 let process_mgr = Eio.Stdenv.process_mgr env in
294294- check_impl ~process_mgr ~fs recursive dirs
292292+ check_impl ~process_mgr ~fs dirs
295293296294let check_cmd =
297295 let doc = "Check git history for commits with AI attribution." in
···303301 'claude' in the commit message. Exit code is 1 if any are found.";
304302 `S Manpage.s_examples;
305303 `P "Check all projects under src/:";
306306- `Pre " precommit check -r src/";
304304+ `Pre " precommit check src/";
307305 ]
308306 in
309307 let info = Cmd.info "check" ~doc ~man in
310310- Cmd.v info Term.(const check $ recursive $ dirs)
308308+ Cmd.v info Term.(const check $ dirs)
311309312310(* {1 Fix command} *)
313311···334332 let answer = String.trim line in
335333 answer = "y" || answer = "Y"
336334337337-let fix_impl ~process_mgr ~fs dry_run yes recursive dirs =
338338- let dirs = collect_dirs ~fs ~recursive dirs in
335335+let fix_impl ~process_mgr ~fs dry_run yes dirs =
336336+ let dirs = collect_dirs ~fs dirs in
339337 let affected, total_commits, repos_with_issues =
340338 find_and_display_ai_commits ~process_mgr ~fs dirs
341339 in
···387385 let doc = "Skip interactive confirmation prompt." in
388386 Arg.(value & flag & info [ "y"; "yes" ] ~doc)
389387390390-let fix dry_run yes recursive dirs =
388388+let fix dry_run yes dirs =
391389 Eio_main.run @@ fun env ->
392390 let fs = Eio.Stdenv.cwd env in
393391 let process_mgr = Eio.Stdenv.process_mgr env in
394394- fix_impl ~process_mgr ~fs dry_run yes recursive dirs
392392+ fix_impl ~process_mgr ~fs dry_run yes dirs
395393396394let fix_cmd =
397395 let doc = "Remove AI attribution from commit history." in
···408406 the interactive confirmation prompt.";
409407 `S Manpage.s_examples;
410408 `P "Fix all projects under the current directory:";
411411- `Pre " precommit fix -r";
409409+ `Pre " precommit fix";
412410 `P "Preview what would be done:";
413413- `Pre " precommit fix -n -r .";
411411+ `Pre " precommit fix -n";
414412 `P "Fix without confirmation prompt:";
415413 `Pre " precommit fix -y";
416414 ]
417415 in
418416 let info = Cmd.info "fix" ~doc ~man in
419419- Cmd.v info Term.(const fix $ dry_run $ yes $ recursive $ dirs)
417417+ Cmd.v info Term.(const fix $ dry_run $ yes $ dirs)
420418421419(* {1 Main} *)
422420
+47-31
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+7580let read_file ~fs path = Eio.Path.load Eio.Path.(fs / path)
76817782let write_file ~fs ~dry_run path content =
···197202 |> List.sort String.compare
198203199204let rec find_git_projects ~fs dir =
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
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 =
204209 entries
205210 |> List.filter_map (fun name ->
206206- if String.length name > 0 && name.[0] = '.' then None
211211+ if String.length name > 0 && (name.[0] = '.' || name.[0] = '_') then
212212+ None
207213 else
208208- let path = Filename.concat dir name in
209209- if is_directory ~fs path then Some path else None)
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)
210219 |> List.sort String.compare
211211- |> List.concat_map (fun sub -> find_git_projects ~fs sub)
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
212225213226let run_in_dir ~process_mgr ~fs dir cmd =
214227 let cwd = Eio.Path.(fs / dir) in
···218231 in
219232 output |> String.split_on_char '\n' |> List.filter (fun s -> s <> "")
220233234234+let run_in_dir_opt ~process_mgr ~fs dir cmd =
235235+ let cwd = Eio.Path.(fs / dir) in
236236+ try
237237+ let output =
238238+ Eio.Process.parse_out process_mgr Eio.Buf_read.take_all ~cwd
239239+ [ "/bin/sh"; "-c"; cmd ]
240240+ in
241241+ Ok (output |> String.split_on_char '\n' |> List.filter (fun s -> s <> ""))
242242+ with Eio.Io _ as e -> Error (Printexc.to_string e)
243243+221244type ai_commit = { hash : string; subject : string }
222245223246let check_ai_attribution ~process_mgr ~fs dir =
224247 if not (file_exists ~fs (Filename.concat dir ".git")) then []
225248 else
226226- (* Get commits by the configured user that contain AI attribution patterns *)
249249+ (* Get commits by the configured user that contain AI attribution patterns.
250250+ Use run_in_dir_opt to handle repos with no commits gracefully. *)
227251 let cmd =
228252 "git log --format='%h %s' --grep='Co-Authored-By.*[Cc]laude' \
229253 --author=\"$(git config user.name)\" 2>/dev/null"
230254 in
231231- let lines = run_in_dir ~process_mgr ~fs dir cmd in
232232- List.filter_map
233233- (fun line ->
234234- if String.length line > 8 then
235235- let hash = String.sub line 0 7 in
236236- let subject = String.sub line 8 (String.length line - 8) in
237237- Some { hash; subject }
238238- else None)
239239- lines
240240-241241-let run_in_dir_opt ~process_mgr ~fs dir cmd =
242242- let cwd = Eio.Path.(fs / dir) in
243243- try
244244- let output =
245245- Eio.Process.parse_out process_mgr Eio.Buf_read.take_all ~cwd
246246- [ "/bin/sh"; "-c"; cmd ]
247247- in
248248- Ok (output |> String.split_on_char '\n' |> List.filter (fun s -> s <> ""))
249249- with Eio.Io _ as e -> Error (Printexc.to_string e)
255255+ match run_in_dir_opt ~process_mgr ~fs dir cmd with
256256+ | Error _ -> [] (* No commits or command failed *)
257257+ | Ok lines ->
258258+ List.filter_map
259259+ (fun line ->
260260+ if String.length line > 8 then
261261+ let hash = String.sub line 0 7 in
262262+ let subject = String.sub line 8 (String.length line - 8) in
263263+ Some { hash; subject }
264264+ else None)
265265+ lines
250266251267let current_branch ~process_mgr ~fs dir =
252268 match
···280296 Error (Printf.sprintf "%s: No .git directory found" dir)
281297 else
282298 let cmd =
283283- "FILTER_BRANCH_SQUELCH_WARNING=1 git filter-branch -f --msg-filter 'sed \
284284- \"/[Cc]o-[Aa]uthored-[Bb]y:.*[Cc]laude/d\"' -- HEAD 2>&1"
299299+ "FILTER_BRANCH_SQUELCH_WARNING=1 git filter-branch -f --msg-filter \"sed \
300300+ '/[Cc]o-[Aa]uthored-[Bb]y:.*[Cc]laude/d'\" -- HEAD 2>&1"
285301 in
286302 match run_in_dir_opt ~process_mgr ~fs dir cmd with
287303 | Error e -> Error (Printf.sprintf "%s: %s" dir e)
288304 | Ok _lines ->
289305 (* Count how many commits were actually rewritten *)
290306 let count_cmd =
291291- "git log --format='%H' --all --grep='Co-Authored-By.*[Cc]laude' \
307307+ "git log --format='%H' HEAD --grep='Co-Authored-By.*[Cc]laude' \
292308 2>/dev/null | wc -l"
293309 in
294310 let remaining =