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 fix command to remove AI attribution from history

Rewrites commit messages using git filter-branch to strip
backup/<branch>-before-fix-<timestamp> before rewriting and
requires interactive confirmation (skip with --yes).

Refactors check/fix to share the commit-finding and table display
logic via find_and_display_ai_commits.

+222 -12
+129 -12
bin/main.ml
··· 219 219 let truncate_subject max_len s = 220 220 if String.length s <= max_len then s else String.sub s 0 (max_len - 1) ^ "…" 221 221 222 - let check_impl ~process_mgr ~fs recursive dirs = 223 - let dirs = collect_dirs ~fs ~recursive dirs in 224 - let total_commits = ref 0 in 225 - let repos_with_issues = ref 0 in 222 + (* Shared: find AI commits across dirs and display a unified table. 223 + Returns [(affected_dirs, total_commits, repos_with_issues)]. *) 224 + let find_and_display_ai_commits ~process_mgr ~fs dirs = 226 225 let term_width = get_terminal_width () in 227 - (* Reserve space for: border + project column + hash column + padding *) 228 226 let subject_max = max 20 (term_width - 35) in 227 + let total_commits = ref 0 in 228 + let repos_with_issues = ref 0 in 229 229 let all_rows = ref [] in 230 + let affected_dirs = ref [] in 230 231 List.iter 231 232 (fun d -> 232 233 let commits = Precommit.check_ai_attribution ~process_mgr ~fs d in 233 234 if commits <> [] then begin 234 235 incr repos_with_issues; 235 236 total_commits := !total_commits + List.length commits; 237 + affected_dirs := d :: !affected_dirs; 236 238 let first = ref true in 237 239 List.iter 238 240 (fun (c : Precommit.ai_commit) -> ··· 269 271 Tty.Table.pp Format.std_formatter table; 270 272 Format.pp_print_newline Format.std_formatter () 271 273 end; 272 - (* Summary *) 273 - if !total_commits > 0 then begin 274 - error "%d commit%s with AI attribution in %d repo%s" !total_commits 275 - (if !total_commits = 1 then "" else "s") 276 - !repos_with_issues 277 - (if !repos_with_issues = 1 then "" else "s"); 274 + (List.rev !affected_dirs, !total_commits, !repos_with_issues) 275 + 276 + let check_impl ~process_mgr ~fs recursive dirs = 277 + let dirs = collect_dirs ~fs ~recursive dirs in 278 + let _affected, total_commits, repos_with_issues = 279 + find_and_display_ai_commits ~process_mgr ~fs dirs 280 + in 281 + if total_commits > 0 then begin 282 + error "%d commit%s with AI attribution in %d repo%s" total_commits 283 + (if total_commits = 1 then "" else "s") 284 + repos_with_issues 285 + (if repos_with_issues = 1 then "" else "s"); 278 286 exit 1 279 287 end 280 288 else success "No AI attribution found in commit history" ··· 301 309 let info = Cmd.info "check" ~doc ~man in 302 310 Cmd.v info Term.(const check $ recursive $ dirs) 303 311 312 + (* {1 Fix command} *) 313 + 314 + let confirm_prompt n_repos = 315 + let warning = 316 + Tty.Panel.create_lines 317 + ~border: 318 + (Tty.Border.with_style 319 + Tty.Style.(fg Tty.Color.yellow) 320 + Tty.Border.rounded) 321 + ~title:(Tty.Span.styled Tty.Style.(fg Tty.Color.yellow) "Warning") 322 + [ 323 + Tty.Span.text 324 + (Printf.sprintf "This will rewrite git history in %d repositor%s." 325 + n_repos 326 + (if n_repos = 1 then "y" else "ies")); 327 + Tty.Span.text "Branches will be backed up before rewriting."; 328 + ] 329 + in 330 + Tty.Panel.pp Format.std_formatter warning; 331 + Format.pp_print_newline Format.std_formatter (); 332 + Fmt.pf Fmt.stdout "Continue? [y/N] %!"; 333 + let line = try input_line stdin with End_of_file -> "" in 334 + let answer = String.trim line in 335 + answer = "y" || answer = "Y" 336 + 337 + let fix_impl ~process_mgr ~fs dry_run yes recursive dirs = 338 + let dirs = collect_dirs ~fs ~recursive dirs in 339 + let affected, total_commits, repos_with_issues = 340 + find_and_display_ai_commits ~process_mgr ~fs dirs 341 + in 342 + if total_commits = 0 then success "No AI attribution found in commit history" 343 + else if dry_run then begin 344 + info "Would rewrite %d commit%s in %d repo%s" total_commits 345 + (if total_commits = 1 then "" else "s") 346 + repos_with_issues 347 + (if repos_with_issues = 1 then "" else "s"); 348 + List.iter 349 + (fun d -> 350 + let branch = Precommit.current_branch ~process_mgr ~fs d in 351 + let name = Option.value ~default:"HEAD" branch in 352 + info "Would backup %s:%s before rewriting" d name) 353 + affected 354 + end 355 + else begin 356 + if not yes then 357 + if not (confirm_prompt repos_with_issues) then begin 358 + info "Aborted"; 359 + exit 0 360 + end; 361 + let fixed = ref 0 in 362 + let errors = ref 0 in 363 + List.iter 364 + (fun d -> 365 + let backup = Precommit.backup_branch ~process_mgr ~fs d in 366 + success "%s: backed up to %s" d backup; 367 + match Precommit.rewrite_ai_attribution ~process_mgr ~fs d with 368 + | Ok _ -> 369 + incr fixed; 370 + success "%s: attribution removed" d 371 + | Error msg -> 372 + incr errors; 373 + error "%s" msg) 374 + affected; 375 + Format.pp_print_newline Format.std_formatter (); 376 + if !errors > 0 then begin 377 + error "%d repo%s fixed, %d error%s" !fixed 378 + (if !fixed = 1 then "" else "s") 379 + !errors 380 + (if !errors = 1 then "" else "s"); 381 + exit 1 382 + end 383 + else success "%d repo%s fixed" !fixed (if !fixed = 1 then "" else "s") 384 + end 385 + 386 + let yes = 387 + let doc = "Skip interactive confirmation prompt." in 388 + Arg.(value & flag & info [ "y"; "yes" ] ~doc) 389 + 390 + let fix dry_run yes recursive dirs = 391 + Eio_main.run @@ fun env -> 392 + let fs = Eio.Stdenv.cwd env in 393 + let process_mgr = Eio.Stdenv.process_mgr env in 394 + fix_impl ~process_mgr ~fs dry_run yes recursive dirs 395 + 396 + let fix_cmd = 397 + let doc = "Remove AI attribution from commit history." in 398 + let man = 399 + [ 400 + `S Manpage.s_description; 401 + `P 402 + "Scan git history for commits with AI attribution (Co-Authored-By: \ 403 + Claude) and rewrite them to remove the attribution lines. This \ 404 + rewrites git history using $(b,git filter-branch)."; 405 + `P 406 + "Before rewriting, the current branch is backed up to \ 407 + $(b,backup/<branch>-before-fix-<timestamp>). Use $(b,--yes) to skip \ 408 + the interactive confirmation prompt."; 409 + `S Manpage.s_examples; 410 + `P "Fix all projects under the current directory:"; 411 + `Pre " precommit fix -r"; 412 + `P "Preview what would be done:"; 413 + `Pre " precommit fix -n -r ."; 414 + `P "Fix without confirmation prompt:"; 415 + `Pre " precommit fix -y"; 416 + ] 417 + in 418 + let info = Cmd.info "fix" ~doc ~man in 419 + Cmd.v info Term.(const fix $ dry_run $ yes $ recursive $ dirs) 420 + 304 421 (* {1 Main} *) 305 422 306 423 let main_cmd = ··· 324 441 in 325 442 let info = Cmd.info "precommit" ~version:"0.1.0" ~doc ~man in 326 443 let default = Vlog.setup "precommit" in 327 - Cmd.group info ~default [ init_cmd; status_cmd; check_cmd ] 444 + Cmd.group info ~default [ init_cmd; status_cmd; check_cmd; fix_cmd ] 328 445 329 446 let () = exit (Cmd.eval main_cmd)
+70
lib/precommit.ml
··· 238 238 else None) 239 239 lines 240 240 241 + let run_in_dir_opt ~process_mgr ~fs dir cmd = 242 + let cwd = Eio.Path.(fs / dir) in 243 + try 244 + let output = 245 + Eio.Process.parse_out process_mgr Eio.Buf_read.take_all ~cwd 246 + [ "/bin/sh"; "-c"; cmd ] 247 + in 248 + Ok (output |> String.split_on_char '\n' |> List.filter (fun s -> s <> "")) 249 + with Eio.Io _ as e -> Error (Printexc.to_string e) 250 + 251 + let current_branch ~process_mgr ~fs dir = 252 + match 253 + run_in_dir_opt ~process_mgr ~fs dir 254 + "git symbolic-ref --short HEAD 2>/dev/null" 255 + with 256 + | Ok (branch :: _) -> Some branch 257 + | _ -> None 258 + 259 + let backup_branch ~process_mgr ~fs dir = 260 + let branch = 261 + match current_branch ~process_mgr ~fs dir with 262 + | Some b -> b 263 + | None -> "HEAD" 264 + in 265 + let now = Unix.gettimeofday () in 266 + let tm = Unix.localtime now in 267 + let timestamp = 268 + Printf.sprintf "%04d%02d%02d-%02d%02d%02d" (1900 + tm.tm_year) 269 + (tm.tm_mon + 1) tm.tm_mday tm.tm_hour tm.tm_min tm.tm_sec 270 + in 271 + let backup_name = Printf.sprintf "backup/%s-before-fix-%s" branch timestamp in 272 + let _lines = 273 + run_in_dir ~process_mgr ~fs dir 274 + (Printf.sprintf "git branch %s" (Filename.quote backup_name)) 275 + in 276 + backup_name 277 + 278 + let rewrite_ai_attribution ~process_mgr ~fs dir = 279 + if not (file_exists ~fs (Filename.concat dir ".git")) then 280 + Error (Printf.sprintf "%s: No .git directory found" dir) 281 + else 282 + let cmd = 283 + "FILTER_BRANCH_SQUELCH_WARNING=1 git filter-branch -f --msg-filter 'sed \ 284 + \"/[Cc]o-[Aa]uthored-[Bb]y:.*[Cc]laude/d\"' -- HEAD 2>&1" 285 + in 286 + match run_in_dir_opt ~process_mgr ~fs dir cmd with 287 + | Error e -> Error (Printf.sprintf "%s: %s" dir e) 288 + | Ok _lines -> 289 + (* Count how many commits were actually rewritten *) 290 + let count_cmd = 291 + "git log --format='%H' --all --grep='Co-Authored-By.*[Cc]laude' \ 292 + 2>/dev/null | wc -l" 293 + in 294 + let remaining = 295 + match run_in_dir_opt ~process_mgr ~fs dir count_cmd with 296 + | Ok (n :: _) -> ( try int_of_string (String.trim n) with _ -> 0) 297 + | _ -> 0 298 + in 299 + (* Clean up refs/original *) 300 + let _ = 301 + run_in_dir_opt ~process_mgr ~fs dir 302 + "git for-each-ref --format='%(refname)' refs/original/ | xargs -r \ 303 + -n1 git update-ref -d 2>/dev/null; true" 304 + in 305 + if remaining = 0 then Ok 0 306 + else 307 + Error 308 + (Printf.sprintf "%s: %d commits still have attribution" dir 309 + remaining) 310 + 241 311 (* Tabular output helpers *) 242 312 243 313 let status_icon ok = if ok then "+" else "-"
+23
lib/precommit.mli
··· 104 104 process_mgr:_ Eio.Process.mgr -> fs:_ Eio.Path.t -> string -> ai_commit list 105 105 (** [check_ai_attribution ~process_mgr ~fs dir] finds commits by the configured 106 106 git user that contain "claude" in the commit message. *) 107 + 108 + (** {1 History Rewriting} *) 109 + 110 + val current_branch : 111 + process_mgr:_ Eio.Process.mgr -> fs:_ Eio.Path.t -> string -> string option 112 + (** [current_branch ~process_mgr ~fs dir] returns the current branch name, or 113 + [None] if HEAD is detached. *) 114 + 115 + val backup_branch : 116 + process_mgr:_ Eio.Process.mgr -> fs:_ Eio.Path.t -> string -> string 117 + (** [backup_branch ~process_mgr ~fs dir] creates a backup branch named 118 + [backup/<branch>-before-fix-<timestamp>] and returns the backup name. *) 119 + 120 + val rewrite_ai_attribution : 121 + process_mgr:_ Eio.Process.mgr -> 122 + fs:_ Eio.Path.t -> 123 + string -> 124 + (int, string) result 125 + (** [rewrite_ai_attribution ~process_mgr ~fs dir] uses [git filter-branch] to 126 + remove [Co-Authored-By:.*claude] lines from all commit messages on the 127 + current branch. Returns [Ok remaining] where [remaining] is the number of 128 + commits that still have attribution (0 on full success), or [Error msg] on 129 + failure. Cleans up [refs/original] after rewriting. *)