Git object storage and pack files for Eio
1(* Copyright (c) 2013-2017 Thomas Gazagnaire <thomas@gazagnaire.org>
2 Copyright (c) 2017-2024 Romain Calascibetta <romain.calascibetta@gmail.com>
3 Copyright (c) 2024-2026 Thomas Gazagnaire <thomas@gazagnaire.org>
4
5 Permission to use, copy, modify, and distribute this software for any
6 purpose with or without fee is hereby granted, provided that the above
7 copyright notice and this permission notice appear in all copies.
8
9 THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10 WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11 MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
12 ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13 WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
14 ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
15 OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. *)
16
17(** Git commit objects. *)
18
19type t = {
20 tree : Hash.t;
21 parents : Hash.t list;
22 author : User.t;
23 committer : User.t;
24 extra : (string * string list) list;
25 message : string option;
26}
27
28let v ~tree ~author ~committer ?(parents = []) ?(extra = []) message =
29 { tree; parents; author; committer; extra; message }
30
31let tree t = t.tree
32let parents t = t.parents
33let author t = t.author
34let committer t = t.committer
35let message t = t.message
36let extra t = t.extra
37
38let pp ppf t =
39 Fmt.pf ppf "@[<v>tree %a@," Hash.pp t.tree;
40 List.iter (fun p -> Fmt.pf ppf "parent %a@," Hash.pp p) t.parents;
41 Fmt.pf ppf "author %a@," User.pp t.author;
42 Fmt.pf ppf "committer %a@," User.pp t.committer;
43 List.iter
44 (fun (k, vs) -> List.iter (fun v -> Fmt.pf ppf "%s %s@," k v) vs)
45 t.extra;
46 (match t.message with Some msg -> Fmt.pf ppf "@,%s" msg | None -> ());
47 Fmt.pf ppf "@]"
48
49let equal a b =
50 Hash.equal a.tree b.tree
51 && List.equal Hash.equal a.parents b.parents
52 && User.equal a.author b.author
53 && User.equal a.committer b.committer
54
55let compare a b =
56 match Hash.compare a.tree b.tree with
57 | 0 -> (
58 match List.compare Hash.compare a.parents b.parents with
59 | 0 -> User.compare a.author b.author
60 | n -> n)
61 | n -> n
62
63let hash t = Hashtbl.hash t
64
65let compare_by_date a b =
66 Int64.compare (User.date a.author) (User.date b.author)
67
68(** Encode commit to git format. *)
69let to_string t =
70 let buf = Buffer.create 512 in
71 Buffer.add_string buf "tree ";
72 Buffer.add_string buf (Hash.to_hex t.tree);
73 Buffer.add_char buf '\n';
74 List.iter
75 (fun p ->
76 Buffer.add_string buf "parent ";
77 Buffer.add_string buf (Hash.to_hex p);
78 Buffer.add_char buf '\n')
79 t.parents;
80 Buffer.add_string buf "author ";
81 Buffer.add_string buf (User.to_string t.author);
82 Buffer.add_char buf '\n';
83 Buffer.add_string buf "committer ";
84 Buffer.add_string buf (User.to_string t.committer);
85 Buffer.add_char buf '\n';
86 List.iter
87 (fun (key, values) ->
88 List.iter
89 (fun value ->
90 Buffer.add_string buf key;
91 Buffer.add_char buf ' ';
92 Buffer.add_string buf value;
93 Buffer.add_char buf '\n')
94 values)
95 t.extra;
96 (match t.message with
97 | Some msg ->
98 Buffer.add_char buf '\n';
99 Buffer.add_string buf msg
100 | None -> ());
101 Buffer.contents buf
102
103(** Parse a single header line. *)
104let parse_header line =
105 match String.index_opt line ' ' with
106 | None -> None
107 | Some pos ->
108 let key = String.sub line 0 pos in
109 let value = String.sub line (pos + 1) (String.length line - pos - 1) in
110 Some (key, value)
111
112(** Parse commit from git format. *)
113let of_string s =
114 let lines = String.split_on_char '\n' s in
115 let rec parse_headers acc tree parents author committer extra = function
116 | [] -> Error (`Msg "Unexpected end of commit")
117 | "" :: rest -> (
118 (* Empty line marks start of message *)
119 let message =
120 match rest with [] -> None | _ -> Some (String.concat "\n" rest)
121 in
122 match (tree, author, committer) with
123 | Some tree, Some author, Some committer ->
124 Ok
125 {
126 tree;
127 parents = List.rev parents;
128 author;
129 committer;
130 extra = List.rev extra;
131 message;
132 }
133 | None, _, _ -> Error (`Msg "Missing tree in commit")
134 | _, None, _ -> Error (`Msg "Missing author in commit")
135 | _, _, None -> Error (`Msg "Missing committer in commit"))
136 | line :: rest -> (
137 match parse_header line with
138 | None -> Error (`Msg ("Invalid header line: " ^ line))
139 | Some ("tree", hex) ->
140 let tree = Hash.of_hex hex in
141 parse_headers acc (Some tree) parents author committer extra rest
142 | Some ("parent", hex) ->
143 let parent = Hash.of_hex hex in
144 parse_headers acc tree (parent :: parents) author committer extra
145 rest
146 | Some ("author", user_str) -> (
147 match User.of_string user_str with
148 | Ok user ->
149 parse_headers acc tree parents (Some user) committer extra rest
150 | Error _ as e -> e)
151 | Some ("committer", user_str) -> (
152 match User.of_string user_str with
153 | Ok user ->
154 parse_headers acc tree parents author (Some user) extra rest
155 | Error _ as e -> e)
156 | Some (key, value) ->
157 (* Handle extra headers like gpgsig *)
158 let extra =
159 match List.assoc_opt key extra with
160 | Some values ->
161 (key, values @ [ value ]) :: List.remove_assoc key extra
162 | None -> (key, [ value ]) :: extra
163 in
164 parse_headers acc tree parents author committer extra rest)
165 in
166 parse_headers [] None [] None None [] lines
167
168let of_string_exn s =
169 match of_string s with Ok t -> t | Error (`Msg m) -> failwith m
170
171(** {1 Reader-based parsing}
172
173 Parse directly from a {!Bytesrw.Bytes.Reader.t} without materialising the
174 full object into a string. The reader must be positioned at the start of the
175 commit body (after the loose-object header, if any). *)
176
177module Reader = Bytesrw.Bytes.Reader
178module Slice = Bytesrw.Bytes.Slice
179
180(** Read until [delim] from a reader. Returns the bytes before the delimiter;
181 the delimiter is consumed. *)
182let read_until reader delim =
183 let buf = Buffer.create 128 in
184 let rec loop () =
185 match Reader.read reader with
186 | slice when Slice.is_eod slice ->
187 if Buffer.length buf = 0 then None else Some (Buffer.contents buf)
188 | slice ->
189 let bytes = Slice.bytes slice in
190 let first = Slice.first slice in
191 let len = Slice.length slice in
192 let rec scan i =
193 if i >= first + len then begin
194 Buffer.add_subbytes buf bytes first len;
195 loop ()
196 end
197 else if Char.code delim = Bytes.get_uint8 bytes i then begin
198 Buffer.add_subbytes buf bytes first (i - first);
199 (* Push back the remaining bytes after the delimiter *)
200 let rest_off = i + 1 in
201 let rest_len = first + len - rest_off in
202 if rest_len > 0 then
203 Reader.push_back reader
204 (Slice.make bytes ~first:rest_off ~length:rest_len);
205 Some (Buffer.contents buf)
206 end
207 else scan (i + 1)
208 in
209 scan first
210 in
211 loop ()
212
213(** Read all remaining bytes from a reader. *)
214let read_rest reader =
215 let buf = Buffer.create 256 in
216 Reader.add_to_buffer buf reader;
217 if Buffer.length buf = 0 then None else Some (Buffer.contents buf)
218
219let of_reader reader =
220 let rec parse_headers tree parents author committer extra =
221 match read_until reader '\n' with
222 | None -> Error (`Msg "Unexpected end of commit")
223 | Some "" -> (
224 (* Empty line marks start of message *)
225 let message = read_rest reader in
226 match (tree, author, committer) with
227 | Some tree, Some author, Some committer ->
228 Ok
229 {
230 tree;
231 parents = List.rev parents;
232 author;
233 committer;
234 extra = List.rev extra;
235 message;
236 }
237 | None, _, _ -> Error (`Msg "Missing tree in commit")
238 | _, None, _ -> Error (`Msg "Missing author in commit")
239 | _, _, None -> Error (`Msg "Missing committer in commit"))
240 | Some line -> (
241 match parse_header line with
242 | None -> Error (`Msg ("Invalid header line: " ^ line))
243 | Some ("tree", hex) ->
244 let tree = Hash.of_hex hex in
245 parse_headers (Some tree) parents author committer extra
246 | Some ("parent", hex) ->
247 let parent = Hash.of_hex hex in
248 parse_headers tree (parent :: parents) author committer extra
249 | Some ("author", user_str) -> (
250 match User.of_string user_str with
251 | Ok user -> parse_headers tree parents (Some user) committer extra
252 | Error _ as e -> e)
253 | Some ("committer", user_str) -> (
254 match User.of_string user_str with
255 | Ok user -> parse_headers tree parents author (Some user) extra
256 | Error _ as e -> e)
257 | Some (key, value) ->
258 let extra =
259 match List.assoc_opt key extra with
260 | Some values ->
261 (key, values @ [ value ]) :: List.remove_assoc key extra
262 | None -> (key, [ value ]) :: extra
263 in
264 parse_headers tree parents author committer extra)
265 in
266 parse_headers None [] None None []
267
268let digest t =
269 let s = to_string t in
270 Hash.digest_string ~kind:`Commit s
271
272module Set = Set.Make (struct
273 type nonrec t = t
274
275 let compare = compare
276end)
277
278module Map = Map.Make (struct
279 type nonrec t = t
280
281 let compare = compare
282end)