Git object storage and pack files for Eio
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)