(* Copyright (c) 2024-2026 Thomas Gazagnaire Permission to use, copy, modify, and distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. *) (** Git smart HTTP protocol implementation for remote queries. This implements the git smart HTTP protocol for lightweight remote operations like ls-remote without cloning or fetching. Reference: https://git-scm.com/docs/http-protocol *) type ref_entry = { ref_name : string; hash : Hash.t } (* Parse a pkt-line length (4 hex digits) *) let parse_pkt_len s offset = if offset + 4 > String.length s then None else let hex = String.sub s offset 4 in try Some (int_of_string ("0x" ^ hex)) with Failure _ -> None (* Parse pkt-line formatted data. Format: 4 hex digits length + content, or "0000" for flush packet *) let parse_pkt_lines data = let len = String.length data in let rec parse acc offset = if offset >= len then List.rev acc else match parse_pkt_len data offset with | None -> List.rev acc | Some 0 -> parse acc (offset + 4) (* flush packet *) | Some pkt_len when pkt_len <= 4 -> List.rev acc | Some pkt_len -> let content_len = pkt_len - 4 in if offset + 4 + content_len > len then List.rev acc else let content = String.sub data (offset + 4) content_len in (* Strip trailing newline if present *) let content = if String.length content > 0 && content.[String.length content - 1] = '\n' then String.sub content 0 (String.length content - 1) else content in parse (content :: acc) (offset + pkt_len) in parse [] 0 (* Parse a ref line: "sha1 refname\0capabilities" or "sha1 refname" *) let parse_ref_line line = (* Skip service announcement lines *) if String.length line = 0 || line.[0] = '#' then None else (* Split on space to get hash and ref *) match String.index_opt line ' ' with | None -> None | Some i -> let hash_str = String.sub line 0 i in let rest = String.sub line (i + 1) (String.length line - i - 1) in (* Strip capabilities after NUL if present *) let ref_name = match String.index_opt rest '\x00' with | Some j -> String.sub rest 0 j | None -> rest in if String.length hash_str = 40 then try Some { ref_name; hash = Hash.of_hex hash_str } with Invalid_argument _ -> None else None let git_headers = Requests.Headers.empty |> Requests.Headers.set_string "User-Agent" "ocaml-git/4.0" |> Requests.Headers.set_string "Accept" "application/x-git-upload-pack-advertisement" let normalize_url url = let s = Uri.to_string url in if String.length s > 0 && s.[String.length s - 1] = '/' then String.sub s 0 (String.length s - 1) else s (** Query remote refs using git smart HTTP protocol. Pass an existing [session] to reuse TLS connection and avoid cert reload. *) let ls_remote ?session ~sw ~env url = let url_str = normalize_url url in let info_refs_url = url_str ^ "/info/refs?service=git-upload-pack" in try let session = match session with Some s -> s | None -> Requests.v ~sw env in let resp = Requests.get session ~headers:git_headers info_refs_url in if not (Requests.Response.ok resp) then None else let body = Requests.Response.text resp in let lines = parse_pkt_lines body in let refs = List.filter_map parse_ref_line lines in Some refs with Eio.Io _ | Failure _ -> None (** Get the HEAD ref for a specific branch. Pass an existing [session] to reuse TLS connection. *) let head ?session ~sw ~env url ~branch = match ls_remote ?session ~sw ~env url with | None -> None | Some refs -> let ref_name = "refs/heads/" ^ branch in List.find_map (fun r -> if r.ref_name = ref_name then Some r.hash else None) refs (** Check if a remote ref matches a local hash. Pass an existing [session] to reuse TLS connection. *) let matches_local ?session ~sw ~env url ~branch ~local_hash = match head ?session ~sw ~env url ~branch with | None -> false | Some remote_hash -> Hash.equal remote_hash local_hash