Git object storage and pack files for Eio
at main 687 lines 27 kB view raw
1(* Copyright (c) 2024-2026 Thomas Gazagnaire <thomas@gazagnaire.org> 2 3 Permission to use, copy, modify, and distribute this software for any 4 purpose with or without fee is hereby granted, provided that the above 5 copyright notice and this permission notice appear in all copies. 6 7 THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. *) 14 15let src = Logs.Src.create "git.subtree" ~doc:"Git subtree operations" 16 17module L = (val Logs.src_log src : Logs.LOG) 18 19(** {1 Persistent cache} *) 20 21module Cache = struct 22 type t = { tbl : (Hash.t, Hash.t) Hashtbl.t } 23 24 let empty () = { tbl = Hashtbl.create 256 } 25 let find t h = Hashtbl.find_opt t.tbl h 26 let add t old_hash new_hash = Hashtbl.replace t.tbl old_hash new_hash 27 let mem t h = Hashtbl.mem t.tbl h 28 let remove t h = Hashtbl.remove t.tbl h 29 30 let cache_dir_name prefix = 31 (* Normalize prefix to a flat filename for cache storage. *) 32 let s = String.map (fun c -> if c = '/' then '_' else c) prefix in 33 (* Avoid "." as a filename (would conflict with the directory itself). *) 34 if s = "." || s = ".." then "_root_" else s 35 36 let load repo ~prefix = 37 let t = empty () in 38 let git_dir = Fpath.to_string (Repository.git_dir repo) in 39 let fs = Repository.fs repo in 40 let dir = cache_dir_name prefix in 41 let path = Eio.Path.(fs / git_dir / "subtree-cache" / dir) in 42 (try 43 let data = Eio.Path.load path in 44 let lines = String.split_on_char '\n' data in 45 List.iter 46 (fun line -> 47 if String.length line >= 81 then begin 48 (* Format: <40-hex-old> <40-hex-new> *) 49 let old_hex = String.sub line 0 40 in 50 let new_hex = String.sub line 41 40 in 51 Hashtbl.replace t.tbl (Hash.of_hex old_hex) (Hash.of_hex new_hex) 52 end) 53 lines 54 with Eio.Io _ | Invalid_argument _ -> ()); 55 t 56 57 let save repo ~prefix t = 58 let git_dir = Fpath.to_string (Repository.git_dir repo) in 59 let fs = Repository.fs repo in 60 let dir = cache_dir_name prefix in 61 let cache_dir = Eio.Path.(fs / git_dir / "subtree-cache") in 62 (try Eio.Path.mkdir ~perm:0o755 cache_dir with Eio.Io _ -> ()); 63 let path = Eio.Path.(cache_dir / dir) in 64 let buf = Buffer.create (Hashtbl.length t.tbl * 82) in 65 Hashtbl.iter 66 (fun old_hash new_hash -> 67 Buffer.add_string buf (Hash.to_hex old_hash); 68 Buffer.add_char buf ' '; 69 Buffer.add_string buf (Hash.to_hex new_hash); 70 Buffer.add_char buf '\n') 71 t.tbl; 72 Eio.Path.save ~create:(`Or_truncate 0o644) path (Buffer.contents buf) 73 74 let iter t f = Hashtbl.iter f t.tbl 75 let length t = Hashtbl.length t.tbl 76 77 let clear repo ~prefix = 78 let git_dir = Fpath.to_string (Repository.git_dir repo) in 79 let fs = Repository.fs repo in 80 let dir = cache_dir_name prefix in 81 let path = Eio.Path.(fs / git_dir / "subtree-cache" / dir) in 82 try Eio.Path.unlink path with Eio.Io _ -> () 83end 84 85(** {1 Tree operations} *) 86 87let tree_at_prefix repo tree_hash prefix = 88 (* Split prefix into path segments. O(d) where d = depth. *) 89 let segments = 90 String.split_on_char '/' prefix |> List.filter (fun s -> s <> "") 91 in 92 (* Walk the tree path, reading one tree object per segment. 93 Each Tree.find is O(e) where e = number of entries (linear scan). *) 94 let rec walk hash = function 95 | [] -> Some hash 96 | seg :: rest -> ( 97 match Repository.read repo hash with 98 | Ok (Value.Tree tree) -> ( 99 match Tree.find ~name:seg tree with 100 | Some entry when entry.perm = `Dir -> walk entry.hash rest 101 | _ -> None) 102 | _ -> None) 103 in 104 walk tree_hash segments 105 106(** {1 Split} *) 107 108(** Extract a metadata value from commit message. Looks for "<key>: <value>" 109 pattern in the message lines. *) 110let extract_metadata key message = 111 match message with 112 | None -> None 113 | Some msg -> 114 let prefix = key ^ ": " in 115 let prefix_len = String.length prefix in 116 let rec find_in_lines = function 117 | [] -> None 118 | line :: rest -> 119 let line = String.trim line in 120 if 121 String.length line > prefix_len 122 && String.sub line 0 prefix_len = prefix 123 then 124 Some 125 (String.sub line prefix_len (String.length line - prefix_len)) 126 else find_in_lines rest 127 in 128 find_in_lines (String.split_on_char '\n' msg) 129 130(** Extract subtree directory from commit message if present. *) 131let extract_subtree_dir message = extract_metadata "git-subtree-dir" message 132 133(** Check if subtree is unchanged from first parent (copy_or_skip optimization). 134 For merge commits, skip if the subtree didn't change from the first parent - 135 this filters out unrelated cross-package merges from the history. *) 136let should_skip_commit repo new_parents sub_hash = 137 match new_parents with 138 | first_parent :: _ -> ( 139 match Repository.read repo first_parent with 140 | Ok (Value.Commit pc) -> 141 let dominated = Hash.equal sub_hash (Commit.tree pc) in 142 if not dominated then 143 L.debug (fun m -> 144 m "not skipping: sub=%s parent_tree=%s" (Hash.to_hex sub_hash) 145 (Hash.to_hex (Commit.tree pc))); 146 dominated 147 | Ok _ -> 148 L.debug (fun m -> m "not skipping: not a commit"); 149 false 150 | Error _ -> 151 L.debug (fun m -> m "not skipping: read error"); 152 false) 153 | [] -> false 154 155(** Walk backward through ancestor chain to find the nearest commit that maps to 156 a non-null split hash. This bridges over "gap" commits that don't contain 157 the subtree (e.g. empty-tree commits or subtree-only commits that ended up 158 in the main branch). *) 159let find_ancestor_split repo cache p = 160 let rec walk visited h = 161 if Hash.Set.mem h visited then None 162 else 163 match Cache.find cache h with 164 | Some s when not (Hash.equal s Hash.null) -> Some s 165 | _ -> ( 166 let visited = Hash.Set.add h visited in 167 match Repository.read repo h with 168 | Ok (Value.Commit c) -> 169 let rec try_parents = function 170 | [] -> None 171 | gp :: rest -> ( 172 match walk visited gp with 173 | Some _ as found -> found 174 | None -> try_parents rest) 175 in 176 try_parents (Commit.parents c) 177 | _ -> None) 178 in 179 walk Hash.Set.empty p 180 181(** Process a single commit for split operation. *) 182let process_split_commit repo cache prefix { Rev_list.hash; parents } = 183 match Repository.read repo hash with 184 | Error _ -> Cache.add cache hash Hash.null 185 | Ok (Value.Commit commit) -> ( 186 let tree_hash = Commit.tree commit in 187 match tree_at_prefix repo tree_hash prefix with 188 | None -> Cache.add cache hash Hash.null 189 | Some sub_hash -> 190 (* Map all parents through cache *) 191 let new_parents = 192 List.filter_map 193 (fun p -> 194 match Cache.find cache p with 195 | Some h when not (Hash.equal h Hash.null) -> Some h 196 | _ -> None) 197 parents 198 in 199 (* If all parents mapped to null, try walking backward through 200 ancestors to bridge over gap commits (e.g. empty-tree or 201 subtree-only commits that don't contain this prefix). *) 202 let new_parents = 203 if new_parents <> [] then new_parents 204 else List.filter_map (find_ancestor_split repo cache) parents 205 in 206 if should_skip_commit repo new_parents sub_hash then 207 Cache.add cache hash (List.hd new_parents) 208 else 209 let new_commit = 210 Commit.v ~tree:sub_hash ~author:(Commit.author commit) 211 ~committer:(Commit.committer commit) ~parents:new_parents 212 ~extra:(Commit.extra commit) (Commit.message commit) 213 in 214 Cache.add cache hash (Repository.write_commit repo new_commit)) 215 | _ -> Cache.add cache hash Hash.null 216 217type verify_error = { original : Hash.t; split : Hash.t; reason : string } 218 219let verify_cache repo ~prefix cache = 220 let errors = ref [] in 221 let checked = ref 0 in 222 Cache.iter cache (fun orig split -> 223 incr checked; 224 if not (Hash.equal split Hash.null) then 225 match (Repository.read repo orig, Repository.read repo split) with 226 | Ok (Value.Commit orig_c), Ok (Value.Commit split_c) -> ( 227 (* Check tree matches subtree at prefix *) 228 match tree_at_prefix repo (Commit.tree orig_c) prefix with 229 | None -> 230 errors := 231 { 232 original = orig; 233 split; 234 reason = "original has no subtree at prefix"; 235 } 236 :: !errors 237 | Some expected_tree -> 238 if not (Hash.equal expected_tree (Commit.tree split_c)) then begin 239 let short h = String.sub (Hash.to_hex h) 0 7 in 240 errors := 241 { 242 original = orig; 243 split; 244 reason = 245 Fmt.str "tree mismatch: expected %s, got %s" 246 (short expected_tree) 247 (short (Commit.tree split_c)); 248 } 249 :: !errors 250 end 251 else if Commit.parents split_c = [] then begin 252 (* Check parent consistency: if the split has no parents but 253 the original has ancestors with non-null splits, the cache 254 entry was created from a broken parent chain (e.g. gap 255 commits with empty trees). *) 256 let has_ancestor_split = 257 List.exists 258 (fun p -> 259 match find_ancestor_split repo cache p with 260 | Some _ -> true 261 | None -> false) 262 (Commit.parents orig_c) 263 in 264 if has_ancestor_split then 265 errors := 266 { 267 original = orig; 268 split; 269 reason = "orphaned split: parents have reachable splits"; 270 } 271 :: !errors 272 end) 273 | Error _, _ -> 274 errors := 275 { original = orig; split; reason = "cannot read original commit" } 276 :: !errors 277 | _, Error _ -> 278 errors := 279 { original = orig; split; reason = "cannot read split commit" } 280 :: !errors 281 | _ -> ()); 282 (!checked, List.rev !errors) 283 284let verify repo ~prefix () = 285 let cache = Cache.load repo ~prefix in 286 verify_cache repo ~prefix cache 287 288(** {1 Split (cont.)} *) 289 290let split_with_cache repo ~prefix ~head cache = 291 match Rev_list.topo_sort_reverse repo head ~stop:(Cache.mem cache) with 292 | Error e -> Error e 293 | Ok commits -> 294 List.iter (process_split_commit repo cache prefix) commits; 295 Cache.save repo ~prefix cache; 296 Ok 297 (match Cache.find cache head with 298 | Some h when Hash.equal h Hash.null -> None 299 | other -> other) 300 301let split repo ~prefix ~head () = 302 let cache = Cache.load repo ~prefix in 303 match Cache.find cache head with 304 | Some h -> 305 (* Cache hit — verify the result is still valid before returning it. *) 306 let _checked, errors = verify_cache repo ~prefix cache in 307 if errors <> [] then begin 308 L.info (fun m -> 309 m "Repairing cache for %s (%d bad entries)" prefix 310 (List.length errors)); 311 List.iter (fun e -> Cache.remove cache e.original) errors; 312 Cache.save repo ~prefix cache; 313 split_with_cache repo ~prefix ~head cache 314 end 315 else Ok (if Hash.equal h Hash.null then None else Some h) 316 | None -> 317 (* Cache miss — process new commits; find_ancestor_split handles gaps. *) 318 split_with_cache repo ~prefix ~head cache 319 320(** {1 Add} *) 321 322let insert_tree_at_prefix repo base_tree_hash prefix subtree_hash = 323 (* Split prefix into path segments. *) 324 let segments = 325 String.split_on_char '/' prefix |> List.filter (fun s -> s <> "") 326 in 327 (* Recursively build trees from the deepest level up. 328 For each level, we need to either modify an existing tree or create a new one. *) 329 let rec build_trees current_tree_hash = function 330 | [] -> 331 (* No more segments - replace with subtree *) 332 Ok subtree_hash 333 | [ name ] -> ( 334 (* Last segment - insert subtree here *) 335 match Repository.read repo current_tree_hash with 336 | Error e -> Error e 337 | Ok (Value.Tree tree) -> 338 let new_tree = 339 tree |> Tree.remove ~name 340 |> Tree.add (Tree.entry ~perm:`Dir ~name subtree_hash) 341 in 342 Ok (Repository.write_tree repo new_tree) 343 | _ -> Error (`Msg "Expected tree object")) 344 | name :: rest -> ( 345 (* Intermediate segment - descend or create *) 346 match Repository.read repo current_tree_hash with 347 | Error e -> Error e 348 | Ok (Value.Tree tree) -> ( 349 let existing_entry = Tree.find ~name tree in 350 let child_hash = 351 match existing_entry with 352 | Some entry when entry.perm = `Dir -> entry.hash 353 | _ -> 354 (* No existing dir or not a dir - use empty tree *) 355 Repository.write_tree repo Tree.empty 356 in 357 match build_trees child_hash rest with 358 | Error e -> Error e 359 | Ok new_child_hash -> 360 let new_tree = 361 tree |> Tree.remove ~name 362 |> Tree.add (Tree.entry ~perm:`Dir ~name new_child_hash) 363 in 364 Ok (Repository.write_tree repo new_tree)) 365 | _ -> Error (`Msg "Expected tree object")) 366 in 367 match segments with 368 | [] -> 369 (* Empty prefix means replace root tree entirely *) 370 Ok subtree_hash 371 | _ -> build_trees base_tree_hash segments 372 373(** Build a nested tree structure from a list of path segments. *) 374let build_nested_tree repo remote_tree segments = 375 let rec build = function 376 | [] -> remote_tree 377 | [ name ] -> 378 Repository.write_tree repo 379 (Tree.v [ Tree.entry ~perm:`Dir ~name remote_tree ]) 380 | name :: rest -> 381 let child = build rest in 382 Repository.write_tree repo 383 (Tree.v [ Tree.entry ~perm:`Dir ~name child ]) 384 in 385 build segments 386 387(** Default message for add/merge operations. *) 388let default_add_message op prefix commit = 389 Fmt.str "%s '%s' from commit %s\n" op prefix (Hash.to_hex commit) 390 391let add repo ~prefix ~commit ~author ~committer ?message () = 392 match Repository.read repo commit with 393 | Error e -> Error e 394 | Ok (Value.Commit remote_commit) -> ( 395 let remote_tree = Commit.tree remote_commit in 396 let msg = 397 Option.value message ~default:(default_add_message "Add" prefix commit) 398 in 399 match Repository.head repo with 400 | None -> 401 let segments = 402 String.split_on_char '/' prefix |> List.filter (( <> ) "") 403 in 404 let root_tree = build_nested_tree repo remote_tree segments in 405 let new_commit = 406 Commit.v ~tree:root_tree ~author ~committer ~parents:[ commit ] 407 (Some msg) 408 in 409 let new_hash = Repository.write_commit repo new_commit in 410 Repository.advance_head repo new_hash; 411 Ok new_hash 412 | Some head_hash -> ( 413 match Repository.read repo head_hash with 414 | Error e -> Error e 415 | Ok (Value.Commit head_commit) -> ( 416 match 417 insert_tree_at_prefix repo (Commit.tree head_commit) prefix 418 remote_tree 419 with 420 | Error e -> Error e 421 | Ok new_tree -> 422 let new_commit = 423 Commit.v ~tree:new_tree ~author ~committer 424 ~parents:[ head_hash; commit ] (Some msg) 425 in 426 let new_hash = Repository.write_commit repo new_commit in 427 Repository.advance_head repo new_hash; 428 Ok new_hash) 429 | _ -> Error (`Msg "HEAD does not point to a commit"))) 430 | _ -> Error (`Msg "Not a commit object") 431 432let merge repo ~prefix ~commit ~author ~committer ?message () = 433 (* Get the tree from the commit we're merging *) 434 match Repository.read repo commit with 435 | Error e -> Error e 436 | Ok (Value.Commit remote_commit) -> ( 437 let remote_tree = Commit.tree remote_commit in 438 (* Get current HEAD *) 439 match Repository.head repo with 440 | None -> Error (`Msg "No HEAD - use add for initial subtree") 441 | Some head_hash -> ( 442 match Repository.read repo head_hash with 443 | Error e -> Error e 444 | Ok (Value.Commit head_commit) -> ( 445 let base_tree = Commit.tree head_commit in 446 (* Check that subtree exists at prefix *) 447 match tree_at_prefix repo base_tree prefix with 448 | None -> Error (`Msg ("Subtree not found at prefix: " ^ prefix)) 449 | Some _ -> ( 450 (* Replace the subtree at prefix with the remote tree *) 451 match 452 insert_tree_at_prefix repo base_tree prefix remote_tree 453 with 454 | Error e -> Error e 455 | Ok new_tree -> 456 let message = 457 match message with 458 | Some m -> m 459 | None -> 460 Fmt.str "Merge '%s' from commit %s\n" prefix 461 (Hash.to_hex commit) 462 in 463 (* Create merge commit with two parents *) 464 let new_commit = 465 Commit.v ~tree:new_tree ~author ~committer 466 ~parents:[ head_hash; commit ] (Some message) 467 in 468 let new_hash = Repository.write_commit repo new_commit in 469 Repository.advance_head repo new_hash; 470 Ok new_hash)) 471 | _ -> Error (`Msg "HEAD does not point to a commit"))) 472 | _ -> Error (`Msg "Not a commit object") 473 474(** {1 Check and Fix} *) 475 476type issue = { commit : Hash.t; message : string; subtree_dir : string option } 477 478(** Check if a commit message indicates a subtree merge for a different package. 479*) 480let is_unrelated_merge ~prefix message = 481 match extract_subtree_dir message with 482 | None -> None (* Not a subtree merge *) 483 | Some dir -> 484 if 485 String.equal dir prefix 486 || String.starts_with ~prefix:(prefix ^ "/") dir 487 || String.starts_with ~prefix:(dir ^ "/") prefix 488 then None (* Related to our prefix *) 489 else Some dir 490 491let check repo ~prefix ~head () = 492 let issues = ref [] in 493 let checked = ref 0 in 494 (* Walk the commit history *) 495 let rec walk visited hash = 496 if Hash.equal hash Hash.null || Hashtbl.mem visited hash then () 497 else begin 498 Hashtbl.add visited hash (); 499 match Repository.read repo hash with 500 | Ok (Value.Commit commit) -> 501 incr checked; 502 let message = Commit.message commit in 503 (* Check if this is an unrelated subtree merge *) 504 (match message with 505 | Some msg -> ( 506 match is_unrelated_merge ~prefix message with 507 | Some dir -> 508 issues := 509 { commit = hash; message = msg; subtree_dir = Some dir } 510 :: !issues 511 | None -> ()) 512 | None -> ()); 513 (* Continue walking parents *) 514 List.iter (walk visited) (Commit.parents commit) 515 | _ -> () 516 end 517 in 518 let visited = Hashtbl.create 1024 in 519 walk visited head; 520 (!checked, List.rev !issues) 521 522(** Process a single commit for fix rewriting. Determines if the commit is a 523 self-merge, unrelated merge, or regular commit, and either skips it or 524 rewrites it with remapped parents. *) 525let rewrite_commit repo ~prefix ~cache commit hash parents = 526 let message = Commit.message commit in 527 let tree = Commit.tree commit in 528 (* Get remapped parents, filtering out null hashes *) 529 let new_parents = 530 List.filter_map 531 (fun p -> 532 match Hashtbl.find_opt cache p with 533 | Some h when not (Hash.equal h Hash.null) -> Some h 534 | _ -> None) 535 parents 536 in 537 (* Check subtree merge type *) 538 let subtree_dir = extract_subtree_dir message in 539 let is_unrelated = 540 match subtree_dir with 541 | None -> false 542 | Some dir -> 543 not 544 (String.equal dir prefix 545 || String.starts_with ~prefix:(prefix ^ "/") dir 546 || String.starts_with ~prefix:(dir ^ "/") prefix) 547 in 548 let is_self_merge = 549 match subtree_dir with 550 | None -> false 551 | Some dir -> 552 String.equal dir prefix 553 || String.starts_with ~prefix:(prefix ^ "/") dir 554 || String.starts_with ~prefix:(dir ^ "/") prefix 555 in 556 (* Determine action: 557 - Self-merges: skip if tree unchanged from first parent (linearize) 558 - Unrelated merges: skip if tree unchanged from first parent 559 - Otherwise: keep the commit with remapped parents *) 560 let action = 561 if is_self_merge || is_unrelated then 562 (* Skip if tree unchanged from first parent *) 563 match new_parents with 564 | first :: _ -> ( 565 match Repository.read repo first with 566 | Ok (Value.Commit pc) when Hash.equal tree (Commit.tree pc) -> 567 `Skip_to first 568 | _ -> `Keep) 569 | [] -> `Keep 570 else `Keep 571 in 572 match action with 573 | `Skip_to parent -> Hashtbl.add cache hash parent 574 | `Keep -> 575 let new_commit = 576 Commit.v ~tree ~author:(Commit.author commit) 577 ~committer:(Commit.committer commit) ~parents:new_parents 578 ~extra:(Commit.extra commit) message 579 in 580 Hashtbl.add cache hash (Repository.write_commit repo new_commit) 581 582let fix repo ~prefix ~head () = 583 (* Rewrite history, removing subtree merge commits: 584 1. Unrelated merges (git-subtree-dir for a different prefix) - skip if tree 585 unchanged from first parent 586 2. Self-merges (git-subtree-dir matches our prefix) - follow mainline parent 587 to linearize history *) 588 let cache = Hashtbl.create 1024 in 589 (* Process commits in reverse topological order *) 590 match Rev_list.topo_sort_reverse repo head ~stop:(fun _ -> false) with 591 | Error e -> Error e 592 | Ok commits -> 593 List.iter 594 (fun { Rev_list.hash; parents } -> 595 match Repository.read repo hash with 596 | Ok (Value.Commit commit) -> 597 rewrite_commit repo ~prefix ~cache commit hash parents 598 | Ok (Value.Blob _ | Value.Tree _ | Value.Tag _) -> 599 Hashtbl.add cache hash Hash.null 600 | Error _ -> Hashtbl.add cache hash Hash.null) 601 commits; 602 Ok (Hashtbl.find_opt cache head) 603 604type mono_issue = { 605 mono_commit : Hash.t; 606 mono_message : string; 607 is_empty : bool; 608} 609 610let check_mono repo ~head () = 611 match Rev_list.topo_sort_reverse repo head ~stop:(fun _ -> false) with 612 | Error _ -> (0, []) 613 | Ok commits -> 614 let issues = ref [] in 615 let count = ref 0 in 616 List.iter 617 (fun { Rev_list.hash; parents } -> 618 incr count; 619 match Repository.read repo hash with 620 | Ok (Value.Commit commit) -> 621 let message = Option.value ~default:"" (Commit.message commit) in 622 let tree = Commit.tree commit in 623 let is_empty = 624 match parents with 625 | first :: _ -> ( 626 match Repository.read repo first with 627 | Ok (Value.Commit pc) -> Hash.equal tree (Commit.tree pc) 628 | _ -> false) 629 | [] -> false 630 in 631 if is_empty then 632 issues := 633 { mono_commit = hash; mono_message = message; is_empty } 634 :: !issues 635 | _ -> ()) 636 commits; 637 (!count, List.rev !issues) 638 639let fix_mono repo ~head () = 640 (* Rewrite history, removing all empty commits. *) 641 let cache = Hashtbl.create 1024 in 642 match Rev_list.topo_sort_reverse repo head ~stop:(fun _ -> false) with 643 | Error e -> Error e 644 | Ok commits -> 645 List.iter 646 (fun { Rev_list.hash; parents } -> 647 match Repository.read repo hash with 648 | Ok (Value.Commit commit) -> 649 let message = Commit.message commit in 650 let tree = Commit.tree commit in 651 (* Get remapped parents, filtering out null hashes *) 652 let new_parents = 653 List.filter_map 654 (fun p -> 655 match Hashtbl.find_opt cache p with 656 | Some h when not (Hash.equal h Hash.null) -> Some h 657 | None -> Some p (* Parent not in cache, keep original *) 658 | _ -> None) 659 parents 660 in 661 (* Check if this is an empty commit (tree unchanged from first parent) *) 662 let is_empty = 663 match new_parents with 664 | first :: _ -> ( 665 match Repository.read repo first with 666 | Ok (Value.Commit pc) -> Hash.equal tree (Commit.tree pc) 667 | _ -> false) 668 | [] -> false 669 in 670 if is_empty then 671 (* Skip to first parent *) 672 match new_parents with 673 | first :: _ -> Hashtbl.add cache hash first 674 | [] -> Hashtbl.add cache hash Hash.null 675 else 676 (* Keep the commit with remapped parents *) 677 let new_commit = 678 Commit.v ~tree ~author:(Commit.author commit) 679 ~committer:(Commit.committer commit) ~parents:new_parents 680 ~extra:(Commit.extra commit) message 681 in 682 Hashtbl.add cache hash (Repository.write_commit repo new_commit) 683 | Ok (Value.Blob _ | Value.Tree _ | Value.Tag _) -> 684 Hashtbl.add cache hash Hash.null 685 | Error _ -> Hashtbl.add cache hash Hash.null) 686 commits; 687 Ok (Hashtbl.find_opt cache head)