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: refactor to use ctx type and fix subdirectory handling

- Add ctx type bundling cwd and fs for consistent filesystem access
- find_git_projects now returns git root when running from subdirectory
- Consolidate is_inside_git_repo to use find_git_root (remove duplication)
- collect_dirs handles "no repos found" error centrally
- Better init messaging: distinguish between no repos, no OCaml projects,
and already configured

+241 -252
+53 -52
bin/main.ml
··· 71 71 error "%s" msg; 72 72 exit 1 73 73 74 - let collect_dirs ~cwd ~fs dirs = 75 - List.concat_map (fun d -> Precommit.find_git_projects ~cwd ~fs d) dirs 74 + let collect_dirs ctx dirs = 75 + let result = 76 + List.concat_map (fun d -> Precommit.find_git_projects ctx d) dirs 77 + in 78 + if result = [] then begin 79 + error "No git repositories found"; 80 + exit 1 81 + end; 82 + result 83 + 84 + let with_ctx chdir f = 85 + Eio_main.run @@ fun env -> 86 + let fs = Eio.Stdenv.fs env in 87 + let cwd = 88 + match chdir with None -> Eio.Stdenv.cwd env | Some d -> Eio.Path.(fs / d) 89 + in 90 + f (Precommit.make_ctx ~cwd ~fs) 76 91 77 92 (* {1 Init command} *) 78 93 79 - let init_impl ~fs dry_run force hooks dirs = 80 - let dirs = collect_dirs ~cwd:fs ~fs dirs in 94 + let init_impl ctx dry_run force hooks dirs = 95 + let dirs = collect_dirs ctx dirs in 81 96 let count = ref 0 in 97 + let skipped_not_ocaml = ref 0 in 98 + let already_configured = ref 0 in 82 99 List.iter 83 100 (fun d -> 84 - let s = Precommit.status_in_dir ~fs d in 85 - if (force || s.is_ocaml_project) && s.is_git_repo then 101 + let s = Precommit.status_in_dir ctx d in 102 + if not s.is_git_repo then () 103 + else if not (force || s.is_ocaml_project) then incr skipped_not_ocaml 104 + else 86 105 let needs_fmt = hooks.Precommit.fmt && not s.has_pre_commit in 87 106 let needs_ai = hooks.Precommit.ai && not s.has_commit_msg in 88 107 if needs_fmt || needs_ai then begin 89 - or_die (Precommit.init_in_dir ~fs ~dry_run ~force ~hooks d); 108 + or_die (Precommit.init_in_dir ctx ~dry_run ~force ~hooks d); 90 109 incr count; 91 110 if dry_run then info "Would initialise %a" Fmt.(styled `Bold string) d 92 111 else success "Initialised %a" Fmt.(styled `Bold string) d 93 - end) 112 + end 113 + else incr already_configured) 94 114 dirs; 95 - if !count = 0 then info "All directories already have hooks installed" 96 - else 115 + if !count > 0 then 97 116 success "Processed %d director%s" !count (if !count = 1 then "y" else "ies") 117 + else if !already_configured > 0 then 118 + info "All directories already have hooks installed" 119 + else if !skipped_not_ocaml > 0 then begin 120 + info "No OCaml projects found (use --force to install anyway)"; 121 + exit 1 122 + end 98 123 99 124 let init chdir dry_run force hooks dirs () = 100 - Eio_main.run @@ fun env -> 101 - let fs = 102 - match chdir with 103 - | None -> Eio.Stdenv.cwd env 104 - | Some d -> Eio.Path.(Eio.Stdenv.fs env / d) 105 - in 106 - init_impl ~fs dry_run force hooks dirs 125 + with_ctx chdir (fun ctx -> init_impl ctx dry_run force hooks dirs) 107 126 108 127 let init_cmd = 109 128 let doc = "Initialise pre-commit hooks for OCaml projects." in ··· 139 158 if b then Tty.Span.styled Tty.Style.(fg Tty.Color.green) "+" 140 159 else Tty.Span.styled Tty.Style.(fg Tty.Color.red) "-" 141 160 142 - let status_impl ~fs dirs = 143 - let dirs = collect_dirs ~cwd:fs ~fs dirs in 161 + let status_impl ctx dirs = 162 + let dirs = collect_dirs ctx dirs in 144 163 let missing = ref 0 in 145 164 let ok = ref 0 in 146 165 let rows = 147 166 List.map 148 167 (fun d -> 149 - let s = Precommit.status_in_dir ~fs d in 168 + let s = Precommit.status_in_dir ctx d in 150 169 if s.is_ocaml_project && s.is_git_repo then begin 151 170 if not (s.has_pre_commit && s.has_commit_msg && s.has_ocamlformat) 152 171 then incr missing ··· 187 206 else if !ok > 0 then 188 207 success "%d project%s properly configured" !ok (if !ok = 1 then "" else "s") 189 208 190 - let status chdir dirs () = 191 - Eio_main.run @@ fun env -> 192 - let fs = 193 - match chdir with 194 - | None -> Eio.Stdenv.cwd env 195 - | Some d -> Eio.Path.(Eio.Stdenv.fs env / d) 196 - in 197 - status_impl ~fs dirs 209 + let status chdir dirs () = with_ctx chdir (fun ctx -> status_impl ctx dirs) 198 210 199 211 let status_cmd = 200 212 let doc = "Check pre-commit hook status." in ··· 231 243 232 244 (* Shared: find AI commits across dirs and display a unified table. 233 245 Returns [(affected_dirs, total_commits, repos_with_issues)]. *) 234 - let find_and_display_ai_commits ~cwd ~fs dirs = 246 + let find_and_display_ai_commits ctx dirs = 235 247 let term_width = get_terminal_width () in 236 248 let subject_max = max 20 (term_width - 35) in 237 249 let total_commits = ref 0 in ··· 240 252 let affected_dirs = ref [] in 241 253 List.iter 242 254 (fun d -> 243 - let commits = Precommit.check_ai_attribution ~cwd ~fs d in 255 + let commits = Precommit.check_ai_attribution ctx d in 244 256 if commits <> [] then begin 245 257 incr repos_with_issues; 246 258 total_commits := !total_commits + List.length commits; ··· 283 295 end; 284 296 (List.rev !affected_dirs, !total_commits, !repos_with_issues) 285 297 286 - let check_impl ~cwd ~fs dirs = 287 - let dirs = collect_dirs ~cwd ~fs dirs in 298 + let check_impl ctx dirs = 299 + let dirs = collect_dirs ctx dirs in 288 300 let _affected, total_commits, repos_with_issues = 289 - find_and_display_ai_commits ~cwd ~fs dirs 301 + find_and_display_ai_commits ctx dirs 290 302 in 291 303 if total_commits > 0 then begin 292 304 error "%d commit%s with AI attribution in %d repo%s" total_commits ··· 297 309 end 298 310 else success "No AI attribution found in commit history" 299 311 300 - let check chdir dirs () = 301 - Eio_main.run @@ fun env -> 302 - let fs = Eio.Stdenv.fs env in 303 - let cwd = 304 - match chdir with None -> Eio.Stdenv.cwd env | Some d -> Eio.Path.(fs / d) 305 - in 306 - check_impl ~cwd ~fs dirs 312 + let check chdir dirs () = with_ctx chdir (fun ctx -> check_impl ctx dirs) 307 313 308 314 let check_cmd = 309 315 let doc = "Check git history for commits with AI attribution." in ··· 350 356 let answer = String.trim line in 351 357 answer = "y" || answer = "Y" 352 358 353 - let fix_impl ~cwd ~fs dry_run yes dirs = 354 - let dirs = collect_dirs ~cwd ~fs dirs in 359 + let fix_impl ctx dry_run yes dirs = 360 + let dirs = collect_dirs ctx dirs in 355 361 let affected, total_commits, repos_with_issues = 356 - find_and_display_ai_commits ~cwd ~fs dirs 362 + find_and_display_ai_commits ctx dirs 357 363 in 358 364 if total_commits = 0 then success "No AI attribution found in commit history" 359 365 else if dry_run then begin ··· 363 369 (if repos_with_issues = 1 then "" else "s"); 364 370 List.iter 365 371 (fun d -> 366 - let branch = Precommit.current_branch ~cwd ~fs d in 372 + let branch = Precommit.current_branch ctx d in 367 373 let name = Option.value ~default:"HEAD" branch in 368 374 info "Would backup %s:%s before rewriting" d name) 369 375 affected ··· 379 385 Eio.Fiber.all 380 386 (List.map 381 387 (fun d () -> 382 - let backup = Precommit.backup_branch ~cwd ~fs d in 388 + let backup = Precommit.backup_branch ctx d in 383 389 success "%s: backed up to %s" d backup; 384 - match Precommit.rewrite_ai_attribution ~cwd ~fs d with 390 + match Precommit.rewrite_ai_attribution ctx d with 385 391 | Ok _ -> 386 392 Atomic.incr fixed; 387 393 success "%s: attribution removed" d ··· 407 413 Arg.(value & flag & info [ "y"; "yes" ] ~doc) 408 414 409 415 let fix chdir dry_run yes dirs () = 410 - Eio_main.run @@ fun env -> 411 - let fs = Eio.Stdenv.fs env in 412 - let cwd = 413 - match chdir with None -> Eio.Stdenv.cwd env | Some d -> Eio.Path.(fs / d) 414 - in 415 - fix_impl ~cwd ~fs dry_run yes dirs 416 + with_ctx chdir (fun ctx -> fix_impl ctx dry_run yes dirs) 416 417 417 418 let fix_cmd = 418 419 let doc = "Remove AI attribution from commit history." in
+152 -160
lib/precommit.ml
··· 6 6 7 7 module Log = (val Logs.src_log log_src : Logs.LOG) 8 8 9 + type ctx = { cwd : Eio.Fs.dir_ty Eio.Path.t; fs : Eio.Fs.dir_ty Eio.Path.t } 10 + 11 + let make_ctx ~cwd ~fs = 12 + { cwd :> Eio.Fs.dir_ty Eio.Path.t; fs :> Eio.Fs.dir_ty Eio.Path.t } 13 + 9 14 let pre_commit_hook = 10 15 {|#!/bin/sh 11 16 # Auto-format OCaml files with dune before commit ··· 111 116 | "gitdir:" :: rest -> String.concat " " rest 112 117 | _ -> git_dir_path 113 118 119 + (* Find git root by walking up from dir. 120 + [cwd] is the working directory path, [fs] is the full filesystem. *) 121 + let find_git_root ~cwd ~fs dir = 122 + let _, cwd_path = cwd in 123 + let base_path = 124 + if cwd_path = "" || cwd_path = "." then Sys.getcwd () else cwd_path 125 + in 126 + let abs_dir = 127 + if Filename.is_relative dir then Filename.concat base_path dir else dir 128 + in 129 + let abs_dir = 130 + if 131 + String.length abs_dir >= 2 132 + && String.sub abs_dir (String.length abs_dir - 2) 2 = "/." 133 + then String.sub abs_dir 0 (String.length abs_dir - 2) 134 + else abs_dir 135 + in 136 + Log.debug (fun m -> m "find_git_root: dir=%s abs_dir=%s" dir abs_dir); 137 + let rec walk path = 138 + let git_dir = Filename.concat path ".git" in 139 + Log.debug (fun m -> m "find_git_root: checking %s" git_dir); 140 + match Eio.Path.kind ~follow:true Eio.Path.(fs / git_dir) with 141 + | `Not_found -> 142 + let parent = Filename.dirname path in 143 + if parent = path then None else walk parent 144 + | _ -> Some path 145 + in 146 + walk abs_dir 147 + 114 148 type hooks = { fmt : bool; ai : bool } 115 149 116 150 let all_hooks = { fmt = true; ai = true } 117 151 118 - let init_in_dir ~fs ~dry_run ~force ~hooks dir = 152 + let init_in_dir ctx ~dry_run ~force ~hooks dir = 119 153 let dune_project = Filename.concat dir "dune-project" in 120 - let git_dir_path = Filename.concat dir ".git" in 121 154 let ocamlformat_path = Filename.concat dir ".ocamlformat" in 122 - if (not force) && not (file_exists ~fs dune_project) then 155 + if (not force) && not (file_exists ~fs:ctx.cwd dune_project) then 123 156 Error 124 157 (Printf.sprintf "%s: No dune-project found (use --force to override)" dir) 125 - else if not (file_exists ~fs git_dir_path) then 126 - Error (Printf.sprintf "%s: No .git directory found" dir) 127 158 else 128 - let git_dir = resolve_git_dir ~fs git_dir_path in 129 - let hooks_dir = Filename.concat git_dir "hooks" in 159 + match find_git_root ~cwd:ctx.cwd ~fs:ctx.fs dir with 160 + | None -> Error (Printf.sprintf "%s: No .git directory found" dir) 161 + | Some git_root -> 162 + let git_dir_path = Filename.concat git_root ".git" in 163 + let git_dir = resolve_git_dir ~fs:ctx.fs git_dir_path in 164 + let hooks_dir = Filename.concat git_dir "hooks" in 130 165 131 - (* Create hooks directory if needed *) 132 - mkdir_p ~fs ~dry_run hooks_dir; 166 + (* Create hooks directory if needed *) 167 + mkdir_p ~fs:ctx.fs ~dry_run hooks_dir; 133 168 134 - (* Install pre-commit hook if requested *) 135 - if hooks.fmt then begin 136 - let pre_commit_path = Filename.concat hooks_dir "pre-commit" in 137 - write_file ~fs ~dry_run pre_commit_path pre_commit_hook; 138 - chmod_exec ~dry_run pre_commit_path 139 - end; 169 + (* Install pre-commit hook if requested *) 170 + if hooks.fmt then begin 171 + let pre_commit_path = Filename.concat hooks_dir "pre-commit" in 172 + write_file ~fs:ctx.fs ~dry_run pre_commit_path pre_commit_hook; 173 + chmod_exec ~dry_run pre_commit_path 174 + end; 140 175 141 - (* Install commit-msg hook if requested *) 142 - if hooks.ai then begin 143 - let commit_msg_path = Filename.concat hooks_dir "commit-msg" in 144 - write_file ~fs ~dry_run commit_msg_path commit_msg_hook; 145 - chmod_exec ~dry_run commit_msg_path 146 - end; 176 + (* Install commit-msg hook if requested *) 177 + if hooks.ai then begin 178 + let commit_msg_path = Filename.concat hooks_dir "commit-msg" in 179 + write_file ~fs:ctx.fs ~dry_run commit_msg_path commit_msg_hook; 180 + chmod_exec ~dry_run commit_msg_path 181 + end; 147 182 148 - (* Create .ocamlformat if missing and fmt hooks requested *) 149 - if hooks.fmt && not (file_exists ~fs ocamlformat_path) then 150 - write_file ~fs ~dry_run ocamlformat_path default_ocamlformat; 183 + (* Create .ocamlformat if missing and fmt hooks requested *) 184 + if hooks.fmt && not (file_exists ~fs:ctx.cwd ocamlformat_path) then 185 + write_file ~fs:ctx.cwd ~dry_run ocamlformat_path default_ocamlformat; 151 186 152 - Ok () 187 + Ok () 153 188 154 - let init ~fs ~dry_run ~force ~hooks () = 155 - init_in_dir ~fs ~dry_run ~force ~hooks "." 189 + let init ctx ~dry_run ~force ~hooks () = 190 + init_in_dir ctx ~dry_run ~force ~hooks "." 156 191 157 192 type hook_status = { 158 193 has_pre_commit : bool; ··· 163 198 is_git_repo : bool; 164 199 } 165 200 166 - let status_in_dir ~fs dir = 201 + let status_in_dir ctx dir = 167 202 let dune_project = Filename.concat dir "dune-project" in 168 - let git_dir_path = Filename.concat dir ".git" in 169 203 let ocamlformat_path = Filename.concat dir ".ocamlformat" in 170 - let is_ocaml_project = file_exists ~fs dune_project in 171 - let is_git_repo = file_exists ~fs git_dir_path in 172 - let has_ocamlformat = file_exists ~fs ocamlformat_path in 173 - let formatting_disabled = check_formatting_disabled ~fs dune_project in 174 - if not is_git_repo then 175 - { 176 - has_pre_commit = false; 177 - has_commit_msg = false; 178 - has_ocamlformat; 179 - formatting_disabled; 180 - is_ocaml_project; 181 - is_git_repo; 182 - } 183 - else 184 - let git_dir = resolve_git_dir ~fs git_dir_path in 185 - let hooks_dir = Filename.concat git_dir "hooks" in 186 - let pre_commit_path = Filename.concat hooks_dir "pre-commit" in 187 - let commit_msg_path = Filename.concat hooks_dir "commit-msg" in 188 - { 189 - has_pre_commit = file_exists ~fs pre_commit_path; 190 - has_commit_msg = file_exists ~fs commit_msg_path; 191 - has_ocamlformat; 192 - formatting_disabled; 193 - is_ocaml_project; 194 - is_git_repo; 195 - } 204 + let is_ocaml_project = file_exists ~fs:ctx.cwd dune_project in 205 + let has_ocamlformat = file_exists ~fs:ctx.cwd ocamlformat_path in 206 + let formatting_disabled = 207 + check_formatting_disabled ~fs:ctx.cwd dune_project 208 + in 209 + match find_git_root ~cwd:ctx.cwd ~fs:ctx.fs dir with 210 + | None -> 211 + { 212 + has_pre_commit = false; 213 + has_commit_msg = false; 214 + has_ocamlformat; 215 + formatting_disabled; 216 + is_ocaml_project; 217 + is_git_repo = false; 218 + } 219 + | Some git_root -> 220 + let git_dir_path = Filename.concat git_root ".git" in 221 + let git_dir = resolve_git_dir ~fs:ctx.fs git_dir_path in 222 + let hooks_dir = Filename.concat git_dir "hooks" in 223 + let pre_commit_path = Filename.concat hooks_dir "pre-commit" in 224 + let commit_msg_path = Filename.concat hooks_dir "commit-msg" in 225 + { 226 + has_pre_commit = file_exists ~fs:ctx.fs pre_commit_path; 227 + has_commit_msg = file_exists ~fs:ctx.fs commit_msg_path; 228 + has_ocamlformat; 229 + formatting_disabled; 230 + is_ocaml_project; 231 + is_git_repo = true; 232 + } 196 233 197 - let status ~fs () = status_in_dir ~fs "." 234 + let status ctx () = status_in_dir ctx "." 198 235 199 236 let list_subdirs ~fs dir = 200 237 Eio.Path.read_dir Eio.Path.(fs / dir) ··· 205 242 if is_directory ~fs path then Some path else None) 206 243 |> List.sort String.compare 207 244 208 - (* Check if dir is inside a git repo by walking up to find .git. 209 - Uses the full filesystem (not sandboxed cwd) to walk up to parent directories. 210 - [cwd] is the working directory path, [fs] is the full filesystem. *) 245 + (* Check if dir is inside a git repo (ancestor has .git, not dir itself) *) 211 246 let is_inside_git_repo ~cwd ~fs dir = 212 - (* Get absolute path by resolving relative to cwd *) 213 - let _, cwd_path = cwd in 214 - (* If cwd_path is empty or ".", use the actual working directory *) 215 - let base_path = 216 - if cwd_path = "" || cwd_path = "." then Sys.getcwd () else cwd_path 217 - in 218 - let abs_dir = 219 - if Filename.is_relative dir then Filename.concat base_path dir else dir 220 - in 221 - (* Normalize trailing /. *) 222 - let abs_dir = 223 - if 224 - String.length abs_dir >= 2 225 - && String.sub abs_dir (String.length abs_dir - 2) 2 = "/." 226 - then String.sub abs_dir 0 (String.length abs_dir - 2) 227 - else abs_dir 228 - in 229 - Log.debug (fun m -> m "is_inside_git_repo: dir=%s abs_dir=%s" dir abs_dir); 230 - let check_path path = 231 - try 232 - match Eio.Path.kind ~follow:true Eio.Path.(fs / path) with 233 - | `Not_found -> `Not_found 234 - | _ -> `Found 235 - with Eio.Io _ -> `Error (* Permission denied or other FS error *) 236 - in 237 - let rec walk path = 238 - let git_dir = Filename.concat path ".git" in 239 - Log.debug (fun m -> m "is_inside_git_repo: checking %s" git_dir); 240 - match check_path git_dir with 241 - | `Found -> true (* .git exists *) 242 - | `Error -> false (* Can't access, assume not in git repo *) 243 - | `Not_found -> 244 - let parent = Filename.dirname path in 245 - if parent = path then false (* reached filesystem root *) 246 - else walk parent 247 - in 248 - (* Don't count if dir itself has .git - only if an ancestor does *) 249 - let has_git_here = 250 - match check_path (Filename.concat abs_dir ".git") with 251 - | `Found -> true 252 - | _ -> false 253 - in 254 - if has_git_here then false else walk abs_dir 247 + (* First check if dir itself has .git *) 248 + let has_git_here = file_exists ~fs (Filename.concat dir ".git") in 249 + if has_git_here then false 250 + else 251 + (* Check if an ancestor has .git *) 252 + match find_git_root ~cwd ~fs dir with 253 + | Some _ -> true 254 + | None -> false 255 + 256 + (* Convert absolute path to relative path from cwd_path *) 257 + let make_relative ~cwd_path abs_path = 258 + if 259 + String.length abs_path >= String.length cwd_path 260 + && String.sub abs_path 0 (String.length cwd_path) = cwd_path 261 + then 262 + let rest = 263 + String.sub abs_path (String.length cwd_path) 264 + (String.length abs_path - String.length cwd_path) 265 + in 266 + if rest = "" then "." 267 + else if rest.[0] = '/' then String.sub rest 1 (String.length rest - 1) 268 + else rest 269 + else abs_path 255 270 256 - let rec find_git_projects ~cwd ~fs dir = 257 - let entries = try Eio.Path.read_dir Eio.Path.(cwd / dir) with _ -> [] in 271 + let rec find_git_projects ctx dir = 272 + let entries = try Eio.Path.read_dir Eio.Path.(ctx.cwd / dir) with _ -> [] in 258 273 let child_path name = if dir = "." then name else Filename.concat dir name in 259 274 (* If dir has .git, include it *) 260 275 let self_has_git = List.mem ".git" entries in 261 - (* If dir is inside a git repo (ancestor has .git), include it *) 262 - let self_inside_repo = 263 - (not self_has_git) && is_inside_git_repo ~cwd ~fs dir 276 + (* If dir is inside a git repo (ancestor has .git), return the git root *) 277 + let self = 278 + if self_has_git then [ dir ] 279 + else if is_inside_git_repo ~cwd:ctx.cwd ~fs:ctx.fs dir then 280 + match find_git_root ~cwd:ctx.cwd ~fs:ctx.fs dir with 281 + | Some git_root -> 282 + let _, cwd_path = ctx.cwd in 283 + let cwd_path = 284 + if cwd_path = "" || cwd_path = "." then Sys.getcwd () else cwd_path 285 + in 286 + [ make_relative ~cwd_path git_root ] 287 + | None -> [] 288 + else [] 264 289 in 265 - let self = if self_has_git || self_inside_repo then [ dir ] else [] in 266 290 (* Only descend into children if we haven't found a git root yet *) 267 291 let children = 268 - if self_has_git || self_inside_repo then [] 292 + if self <> [] then [] 269 293 else 270 294 entries 271 295 |> List.filter_map (fun name -> ··· 274 298 else 275 299 let path = child_path name in 276 300 (* Skip symlinks to avoid traversing outside the sandbox *) 277 - if is_symlink ~fs:cwd path then None 278 - else if is_directory ~fs:cwd path then Some path 301 + if is_symlink ~fs:ctx.cwd path then None 302 + else if is_directory ~fs:ctx.cwd path then Some path 279 303 else None) 280 304 |> List.sort String.compare 281 305 |> List.concat_map (fun sub -> 282 - if file_exists ~fs:cwd (Filename.concat sub ".git") then [ sub ] 283 - else find_git_projects ~cwd ~fs sub) 306 + if file_exists ~fs:ctx.cwd (Filename.concat sub ".git") then [ sub ] 307 + else find_git_projects ctx sub) 284 308 in 285 309 self @ children 286 310 ··· 327 351 let committer_email = Git.User.email (Git.Commit.committer commit) in 328 352 String.equal email committer_email 329 353 330 - (* Find git root by walking up from dir. 331 - [cwd] is the working directory path, [fs] is the full filesystem. *) 332 - let find_git_root ~cwd ~fs dir = 333 - let _, cwd_path = cwd in 334 - (* If cwd_path is empty or ".", use the actual working directory *) 335 - let base_path = 336 - if cwd_path = "" || cwd_path = "." then Sys.getcwd () else cwd_path 337 - in 338 - let abs_dir = 339 - if Filename.is_relative dir then Filename.concat base_path dir else dir 340 - in 341 - (* Normalize trailing /. *) 342 - let abs_dir = 343 - if 344 - String.length abs_dir >= 2 345 - && String.sub abs_dir (String.length abs_dir - 2) 2 = "/." 346 - then String.sub abs_dir 0 (String.length abs_dir - 2) 347 - else abs_dir 348 - in 349 - Log.debug (fun m -> m "find_git_root: dir=%s abs_dir=%s" dir abs_dir); 350 - let rec walk path = 351 - let git_dir = Filename.concat path ".git" in 352 - Log.debug (fun m -> m "find_git_root: checking %s" git_dir); 353 - match Eio.Path.kind ~follow:true Eio.Path.(fs / git_dir) with 354 - | `Not_found -> 355 - let parent = Filename.dirname path in 356 - if parent = path then None (* reached filesystem root *) 357 - else walk parent 358 - | _ -> Some path 359 - in 360 - walk abs_dir 361 - 362 - let check_ai_attribution ~cwd ~fs dir = 354 + let check_ai_attribution ctx dir = 363 355 Log.debug (fun m -> m "check_ai_attribution: dir=%s" dir); 364 - match find_git_root ~cwd ~fs dir with 356 + match find_git_root ~cwd:ctx.cwd ~fs:ctx.fs dir with 365 357 | None -> 366 358 Log.debug (fun m -> 367 359 m "check_ai_attribution: no git root found for %s" dir); ··· 369 361 | Some git_root -> ( 370 362 Log.debug (fun m -> m "check_ai_attribution: git_root=%s" git_root); 371 363 (* Use full filesystem for absolute paths *) 372 - let repo = Git.Repository.open_repo ~fs (Fpath.v git_root) in 373 - let user_email = get_user_email ~fs repo in 364 + let repo = Git.Repository.open_repo ~fs:ctx.fs (Fpath.v git_root) in 365 + let user_email = get_user_email ~fs:ctx.fs repo in 374 366 Log.debug (fun m -> 375 367 m "check_ai_attribution: git_root=%s user_email=%s" git_root 376 368 (Option.value ~default:"<none>" user_email)); ··· 442 434 !found !skipped_email !skipped_no_ai); 443 435 result) 444 436 445 - let current_branch ~cwd ~fs dir = 446 - match find_git_root ~cwd ~fs dir with 437 + let current_branch ctx dir = 438 + match find_git_root ~cwd:ctx.cwd ~fs:ctx.fs dir with 447 439 | None -> None 448 440 | Some git_root -> 449 - let repo = Git.Repository.open_repo ~fs (Fpath.v git_root) in 441 + let repo = Git.Repository.open_repo ~fs:ctx.fs (Fpath.v git_root) in 450 442 Git.Repository.current_branch repo 451 443 452 - let backup_branch ~cwd ~fs dir = 453 - match find_git_root ~cwd ~fs dir with 444 + let backup_branch ctx dir = 445 + match find_git_root ~cwd:ctx.cwd ~fs:ctx.fs dir with 454 446 | None -> failwith (Printf.sprintf "%s: No .git directory found" dir) 455 447 | Some git_root -> 456 - let repo = Git.Repository.open_repo ~fs (Fpath.v git_root) in 448 + let repo = Git.Repository.open_repo ~fs:ctx.fs (Fpath.v git_root) in 457 449 let branch = 458 450 match Git.Repository.current_branch repo with 459 451 | Some b -> b ··· 474 466 | None -> ()); 475 467 backup_name 476 468 477 - let rewrite_ai_attribution ~cwd ~fs dir = 478 - match find_git_root ~cwd ~fs dir with 469 + let rewrite_ai_attribution ctx dir = 470 + match find_git_root ~cwd:ctx.cwd ~fs:ctx.fs dir with 479 471 | None -> Error (Printf.sprintf "%s: No .git directory found" dir) 480 472 | Some git_root -> ( 481 - let repo = Git.Repository.open_repo ~fs (Fpath.v git_root) in 482 - let user_email = get_user_email ~fs repo in 473 + let repo = Git.Repository.open_repo ~fs:ctx.fs (Fpath.v git_root) in 474 + let user_email = get_user_email ~fs:ctx.fs repo in 483 475 match Git.Repository.head repo with 484 476 | None -> Ok 0 485 477 | Some head_hash -> ··· 585 577 Format.fprintf ppf "%s@." (format_status_row dir status)) 586 578 statuses 587 579 588 - let check_all ~fs dirs = List.map (fun dir -> (dir, status_in_dir ~fs dir)) dirs 580 + let check_all ctx dirs = List.map (fun dir -> (dir, status_in_dir ctx dir)) dirs
+36 -40
lib/precommit.mli
··· 2 2 3 3 Installs git hooks directly without requiring the pre-commit tool. *) 4 4 5 + (** {1 Context} *) 6 + 7 + type ctx 8 + (** Filesystem context for operations. Contains the working directory for 9 + relative paths and the full filesystem for walking up to parent directories. 10 + *) 11 + 12 + val make_ctx : cwd:_ Eio.Path.t -> fs:_ Eio.Path.t -> ctx 13 + (** [make_ctx ~cwd ~fs] creates a context from the working directory and full 14 + filesystem paths. *) 15 + 5 16 (** {1 Hook Templates} *) 6 17 7 18 val pre_commit_hook : string ··· 34 45 (** Both fmt and ai hooks. *) 35 46 36 47 val init : 37 - fs:_ Eio.Path.t -> 48 + ctx -> 38 49 dry_run:bool -> 39 50 force:bool -> 40 51 hooks:hooks -> 41 52 unit -> 42 53 (unit, string) result 43 - (** [init ~fs ~dry_run ~force ~hooks ()] installs git hooks in the current 54 + (** [init ctx ~dry_run ~force ~hooks ()] installs git hooks in the current 44 55 repository. 45 56 46 57 Creates (depending on [hooks]): ··· 54 65 Returns [Error msg] if not in a git repository. *) 55 66 56 67 val init_in_dir : 57 - fs:_ Eio.Path.t -> 68 + ctx -> 58 69 dry_run:bool -> 59 70 force:bool -> 60 71 hooks:hooks -> 61 72 string -> 62 73 (unit, string) result 63 - (** [init_in_dir ~fs ~dry_run ~force ~hooks dir] installs hooks in the specified 74 + (** [init_in_dir ctx ~dry_run ~force ~hooks dir] installs hooks in the specified 64 75 directory. *) 65 76 66 - val status : fs:_ Eio.Path.t -> unit -> hook_status 67 - (** [status ~fs ()] checks hook status in the current directory. *) 77 + val status : ctx -> unit -> hook_status 78 + (** [status ctx ()] checks hook status in the current directory. *) 68 79 69 - val status_in_dir : fs:_ Eio.Path.t -> string -> hook_status 70 - (** [status_in_dir ~fs dir] checks hook status in the specified directory. *) 80 + val status_in_dir : ctx -> string -> hook_status 81 + (** [status_in_dir ctx dir] checks hook status in the specified directory. *) 71 82 72 83 val list_subdirs : fs:_ Eio.Path.t -> string -> string list 73 84 (** [list_subdirs ~fs dir] lists subdirectories (excluding hidden ones). *) 74 85 75 - val find_git_projects : 76 - cwd:_ Eio.Path.t -> fs:_ Eio.Path.t -> string -> string list 77 - (** [find_git_projects ~cwd ~fs dir] recursively scans [dir] for directories 86 + val find_git_projects : ctx -> string -> string list 87 + (** [find_git_projects ctx dir] recursively scans [dir] for directories 78 88 containing a [.git] entry. Stops recursing into a directory once a [.git] is 79 89 found. Hidden directories are skipped. If [dir] is inside a git repository 80 - (by checking parent directories using [fs]), it is included even if it 81 - doesn't contain [.git] directly. [cwd] is the working directory for relative 82 - paths, [fs] is the full filesystem for walking up to parent directories. *) 90 + (by checking parent directories), it is included even if it doesn't contain 91 + [.git] directly. *) 83 92 84 - val check_all : fs:_ Eio.Path.t -> string list -> (string * hook_status) list 85 - (** [check_all ~fs dirs] checks hook status for all directories and returns a 93 + val check_all : ctx -> string list -> (string * hook_status) list 94 + (** [check_all ctx dirs] checks hook status for all directories and returns a 86 95 list of (directory, status) pairs. *) 87 96 88 97 (** {1 Tabular Output} *) ··· 104 113 type ai_commit = { hash : string; subject : string } 105 114 (** A commit with AI attribution. *) 106 115 107 - val check_ai_attribution : 108 - cwd:Eio.Fs.dir_ty Eio.Path.t -> 109 - fs:Eio.Fs.dir_ty Eio.Path.t -> 110 - string -> 111 - ai_commit list 112 - (** [check_ai_attribution ~cwd ~fs dir] uses ocaml-git to find commits that 113 - contain AI attribution patterns in the commit message. Only checks commits 114 - authored by the current user (determined from git config). [fs] is used to 115 - read the global git config if the local repo doesn't have user settings. *) 116 + val check_ai_attribution : ctx -> string -> ai_commit list 117 + (** [check_ai_attribution ctx dir] uses ocaml-git to find commits that contain 118 + AI attribution patterns in the commit message. Only checks commits authored 119 + by the current user (determined from git config). *) 116 120 117 121 (** {1 History Rewriting} *) 118 122 119 - val current_branch : 120 - cwd:_ Eio.Path.t -> fs:Eio.Fs.dir_ty Eio.Path.t -> string -> string option 121 - (** [current_branch ~cwd ~fs dir] returns the current branch name, or [None] if 122 - HEAD is detached or no git root is found. Uses ocaml-git. [cwd] is the 123 - working directory, [fs] is the full filesystem for walking up to parent 124 - directories. *) 123 + val current_branch : ctx -> string -> string option 124 + (** [current_branch ctx dir] returns the current branch name, or [None] if HEAD 125 + is detached or no git root is found. Uses ocaml-git. *) 125 126 126 - val backup_branch : 127 - cwd:_ Eio.Path.t -> fs:Eio.Fs.dir_ty Eio.Path.t -> string -> string 128 - (** [backup_branch ~cwd ~fs dir] creates a backup branch named 127 + val backup_branch : ctx -> string -> string 128 + (** [backup_branch ctx dir] creates a backup branch named 129 129 [backup/<branch>-before-fix-<timestamp>] and returns the backup name. Uses 130 130 ocaml-git. Raises [Failure] if no git root is found. *) 131 131 132 - val rewrite_ai_attribution : 133 - cwd:Eio.Fs.dir_ty Eio.Path.t -> 134 - fs:Eio.Fs.dir_ty Eio.Path.t -> 135 - string -> 136 - (int, string) result 137 - (** [rewrite_ai_attribution ~cwd ~fs dir] uses ocaml-git to rewrite commits and 132 + val rewrite_ai_attribution : ctx -> string -> (int, string) result 133 + (** [rewrite_ai_attribution ctx dir] uses ocaml-git to rewrite commits and 138 134 remove [Co-Authored-By:.*claude] lines from commit messages. Only rewrites 139 135 commits authored by the current user. Returns [Ok n] where [n] is the number 140 136 of commits rewritten, or [Error msg] on failure. *)