Git object storage and pack files for Eio
at main 127 lines 4.9 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 15(** Git smart HTTP protocol implementation for remote queries. 16 17 This implements the git smart HTTP protocol for lightweight remote 18 operations like ls-remote without cloning or fetching. 19 20 Reference: https://git-scm.com/docs/http-protocol *) 21 22type ref_entry = { ref_name : string; hash : Hash.t } 23 24(* Parse a pkt-line length (4 hex digits) *) 25let parse_pkt_len s offset = 26 if offset + 4 > String.length s then None 27 else 28 let hex = String.sub s offset 4 in 29 try Some (int_of_string ("0x" ^ hex)) with Failure _ -> None 30 31(* Parse pkt-line formatted data. 32 Format: 4 hex digits length + content, or "0000" for flush packet *) 33let parse_pkt_lines data = 34 let len = String.length data in 35 let rec parse acc offset = 36 if offset >= len then List.rev acc 37 else 38 match parse_pkt_len data offset with 39 | None -> List.rev acc 40 | Some 0 -> parse acc (offset + 4) (* flush packet *) 41 | Some pkt_len when pkt_len <= 4 -> List.rev acc 42 | Some pkt_len -> 43 let content_len = pkt_len - 4 in 44 if offset + 4 + content_len > len then List.rev acc 45 else 46 let content = String.sub data (offset + 4) content_len in 47 (* Strip trailing newline if present *) 48 let content = 49 if 50 String.length content > 0 51 && content.[String.length content - 1] = '\n' 52 then String.sub content 0 (String.length content - 1) 53 else content 54 in 55 parse (content :: acc) (offset + pkt_len) 56 in 57 parse [] 0 58 59(* Parse a ref line: "sha1 refname\0capabilities" or "sha1 refname" *) 60let parse_ref_line line = 61 (* Skip service announcement lines *) 62 if String.length line = 0 || line.[0] = '#' then None 63 else 64 (* Split on space to get hash and ref *) 65 match String.index_opt line ' ' with 66 | None -> None 67 | Some i -> 68 let hash_str = String.sub line 0 i in 69 let rest = String.sub line (i + 1) (String.length line - i - 1) in 70 (* Strip capabilities after NUL if present *) 71 let ref_name = 72 match String.index_opt rest '\x00' with 73 | Some j -> String.sub rest 0 j 74 | None -> rest 75 in 76 if String.length hash_str = 40 then 77 try Some { ref_name; hash = Hash.of_hex hash_str } 78 with Invalid_argument _ -> None 79 else None 80 81let git_headers = 82 Requests.Headers.empty 83 |> Requests.Headers.set_string "User-Agent" "ocaml-git/4.0" 84 |> Requests.Headers.set_string "Accept" 85 "application/x-git-upload-pack-advertisement" 86 87let normalize_url url = 88 let s = Uri.to_string url in 89 if String.length s > 0 && s.[String.length s - 1] = '/' then 90 String.sub s 0 (String.length s - 1) 91 else s 92 93(** Query remote refs using git smart HTTP protocol. Pass an existing [session] 94 to reuse TLS connection and avoid cert reload. *) 95let ls_remote ?session ~sw ~env url = 96 let url_str = normalize_url url in 97 let info_refs_url = url_str ^ "/info/refs?service=git-upload-pack" in 98 try 99 let session = 100 match session with Some s -> s | None -> Requests.v ~sw env 101 in 102 let resp = Requests.get session ~headers:git_headers info_refs_url in 103 if not (Requests.Response.ok resp) then None 104 else 105 let body = Requests.Response.text resp in 106 let lines = parse_pkt_lines body in 107 let refs = List.filter_map parse_ref_line lines in 108 Some refs 109 with Eio.Io _ | Failure _ -> None 110 111(** Get the HEAD ref for a specific branch. Pass an existing [session] to reuse 112 TLS connection. *) 113let head ?session ~sw ~env url ~branch = 114 match ls_remote ?session ~sw ~env url with 115 | None -> None 116 | Some refs -> 117 let ref_name = "refs/heads/" ^ branch in 118 List.find_map 119 (fun r -> if r.ref_name = ref_name then Some r.hash else None) 120 refs 121 122(** Check if a remote ref matches a local hash. Pass an existing [session] to 123 reuse TLS connection. *) 124let matches_local ?session ~sw ~env url ~branch ~local_hash = 125 match head ?session ~sw ~env url ~branch with 126 | None -> false 127 | Some remote_hash -> Hash.equal remote_hash local_hash