Persistent store with Git semantics: lazy reads, delayed writes, content-addressing
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor(irmin): flat Store.* API, Disk backend, fix Git interop

- common.ml: replace Mst_tree + per-backend modules with Mst_store functor;
all backends now use module S = Store.Git/Store.Mst so ops are S.*
- config: add Disk backend (append-only WAL); single canonical name per backend
- git_interop: skip write if object exists; fix blob/tree detection with try-parse
- pds_interop: add mst_backend bridging Pds.t to Backend.t
- irmin.ml: add Git.open_/init/import and Mst.of_pds/disk/memory builders
- ocaml-git/repository: mkdirs instead of mkdir in init (handles nested paths)
- cmd_info/export: use Git.open_ instead of import_git ~git_dir

+256 -133
+2 -3
bin/cmd_export.ml
··· 15 15 1 16 16 | Config.Git -> ( 17 17 (* Export as bundle or tar - for now just list what would be exported *) 18 - let git_dir = Fpath.(v config.store_path / ".git") in 19 - let store = Irmin.Git_interop.import_git ~sw ~fs ~git_dir in 18 + let store = Irmin.Git.open_ ~sw ~fs ~path:(Fpath.v config.store_path) in 20 19 match Irmin.Store.Git.checkout store ~branch with 21 20 | None -> 22 21 Common.error "Branch %a not found" Common.styled_cyan branch; ··· 50 49 Common.error "Only .car export supported for PDS backend"; 51 50 1 52 51 end 53 - | Config.Memory -> 52 + | Config.Memory | Config.Disk -> 54 53 Common.error "Export not supported for in-memory backend"; 55 54 1
+7 -2
bin/cmd_info.ml
··· 37 37 Fmt.pr "Branch: %s@." config.default_branch; 38 38 match config.backend with 39 39 | Config.Git -> 40 - let git_dir = Fpath.(v config.store_path / ".git") in 41 - let store = Irmin.Git_interop.import_git ~sw ~fs ~git_dir in 40 + let store = Irmin.Git.open_ ~sw ~fs ~path:(Fpath.v config.store_path) in 42 41 let branches = Irmin.Store.Git.branches store in 43 42 Fmt.pr "Branches: %d@." (List.length branches); 44 43 List.iter (fun b -> Fmt.pr " %s@." b) branches; ··· 53 52 0 54 53 | Config.Memory -> 55 54 Fmt.pr "Store: (in-memory)@."; 55 + 0 56 + | Config.Disk -> 57 + let store = Irmin.Mst.disk ~sw Eio.Path.(fs / config.store_path) in 58 + let branches = Irmin.Store.Mst.branches store in 59 + Fmt.pr "Branches: %d@." (List.length branches); 60 + List.iter (fun b -> Fmt.pr " %s@." b) branches; 56 61 0 57 62 58 63 let run ~repo file =
+5
bin/cmd_init.ml
··· 17 17 let pds = Pds.v ~sw eio_path ~did in 18 18 Pds.close pds; 19 19 Common.success "Initialised PDS store at %a" Common.styled_bold path 20 + | `Memory -> 21 + Common.success "Memory store is transient, no initialisation needed" 22 + | `Disk -> 23 + let _store = Mst.disk ~sw Eio.Path.(fs / path) in 24 + Common.success "Initialised disk store at %a" Common.styled_bold path
+121 -106
bin/common.ml
··· 55 55 val hash_short : hash -> string 56 56 end 57 57 58 - (** Git backend implementation. *) 58 + (** Git backend — SHA-1, real git objects, git-compatible. *) 59 59 module Git : BACKEND = struct 60 - type store = Store.Git.t 61 - type tree = Tree.Git.t 62 - type hash = Hash.sha1 60 + module S = Store.Git 61 + 62 + type store = S.t 63 + type tree = S.Tree.t 64 + type hash = S.hash 63 65 64 66 let open_store ~sw ~fs ~config = 65 - let path = Fpath.v config.Config.store_path in 66 - Git_interop.open_git ~sw ~fs ~path 67 + Git_interop.open_git ~sw ~fs ~path:(Fpath.v config.Config.store_path) 67 68 68 - let checkout store ~branch = Store.Git.checkout store ~branch 69 - let empty_tree _store = Tree.Git.empty () 70 - let tree_find tree path = Tree.Git.find tree path 71 - let tree_add tree path content = Tree.Git.add tree path content 72 - let tree_remove tree path = Tree.Git.remove tree path 73 - let tree_list tree path = Tree.Git.list tree path 74 - let head store ~branch = Store.Git.head store ~branch 69 + let checkout s ~branch = S.checkout s ~branch 70 + let empty_tree _s = S.Tree.empty () 71 + let tree_find t path = S.Tree.find t path 72 + let tree_add t path c = S.Tree.add t path c 73 + let tree_remove t path = S.Tree.remove t path 74 + let tree_list t path = S.Tree.list t path 75 + let head s ~branch = S.head s ~branch 75 76 76 - let commit store ~tree ~parents ~message ~author = 77 - Store.Git.commit store ~tree ~parents ~message ~author 77 + let commit s ~tree ~parents ~message ~author = 78 + S.commit s ~tree ~parents ~message ~author 78 79 79 - let set_head store ~branch hash = Store.Git.set_head store ~branch hash 80 - let branches store = Store.Git.branches store 80 + let set_head s ~branch h = S.set_head s ~branch h 81 + let branches s = S.branches s 81 82 82 - let log store ~branch ~limit = 83 - let rec walk n hash acc = 83 + let log s ~branch ~limit = 84 + let rec walk n h acc = 84 85 if n = Some 0 then List.rev acc 85 86 else 86 - match Store.Git.read_commit store hash with 87 + match S.read_commit s h with 87 88 | None -> List.rev acc 88 - | Some commit -> ( 89 + | Some c -> ( 89 90 let entry = 90 91 { 91 - hash = Hash.to_hex hash; 92 - author = Commit.Git.author commit; 93 - message = Commit.Git.message commit; 92 + hash = Hash.to_hex h; 93 + author = S.Commit.author c; 94 + message = S.Commit.message c; 94 95 } 95 96 in 96 - match Commit.Git.parents commit with 97 + match S.Commit.parents c with 97 98 | [] -> List.rev (entry :: acc) 98 99 | p :: _ -> walk (Option.map pred n) p (entry :: acc)) 99 100 in 100 - match Store.Git.head store ~branch with 101 - | None -> [] 102 - | Some h -> walk limit h [] 101 + match S.head s ~branch with None -> [] | Some h -> walk limit h [] 103 102 104 103 let hash_to_hex h = Hash.to_hex h 105 104 let hash_short h = String.sub (Hash.to_hex h) 0 7 106 105 end 107 106 108 - (** Shared MST tree operations for PDS and Memory backends. *) 109 - module Mst_tree = struct 107 + (** Functor for MST-format backends (Memory, Disk). *) 108 + module Mst_store (Open : sig 109 + val v : 110 + sw:Eio.Switch.t -> 111 + fs:Eio.Fs.dir_ty Eio.Path.t -> 112 + config:Config.t -> 113 + Store.Mst.t 114 + end) : BACKEND = struct 115 + module S = Store.Mst 116 + 117 + type store = S.t 118 + type tree = S.Tree.t 119 + type hash = S.hash 120 + 121 + let open_store = Open.v 122 + let checkout s ~branch = S.checkout s ~branch 123 + let empty_tree _s = S.Tree.empty () 124 + let tree_find t path = S.Tree.find t path 125 + let tree_add t path c = S.Tree.add t path c 126 + let tree_remove t path = S.Tree.remove t path 127 + let tree_list t path = S.Tree.list t path 128 + let head s ~branch = S.head s ~branch 129 + 130 + let commit s ~tree ~parents ~message ~author = 131 + S.commit s ~tree ~parents ~message ~author 132 + 133 + let set_head s ~branch h = S.set_head s ~branch h 134 + let branches s = S.branches s 135 + 136 + let log s ~branch ~limit = 137 + let rec walk n h acc = 138 + if n = Some 0 then List.rev acc 139 + else 140 + match S.read_commit s h with 141 + | None -> List.rev acc 142 + | Some c -> ( 143 + let entry = 144 + { 145 + hash = Hash.to_hex h; 146 + author = S.Commit.author c; 147 + message = S.Commit.message c; 148 + } 149 + in 150 + match S.Commit.parents c with 151 + | [] -> List.rev (entry :: acc) 152 + | p :: _ -> walk (Option.map pred n) p (entry :: acc)) 153 + in 154 + match S.head s ~branch with None -> [] | Some h -> walk limit h [] 155 + 156 + let hash_to_hex h = Hash.to_hex h 157 + let hash_short h = String.sub (Hash.to_hex h) 0 7 158 + end 159 + 160 + (** In-memory MST store — for testing. *) 161 + module Memory = Mst_store (struct 162 + let v ~sw:_ ~fs:_ ~config:_ = Mst.memory () 163 + end) 164 + 165 + (** Disk-backed MST store — append-only WAL, high-throughput. *) 166 + module Disk = Mst_store (struct 167 + let v ~sw ~fs ~config = Mst.disk ~sw Eio.Path.(fs / config.Config.store_path) 168 + end) 169 + 170 + (** PDS backend — SQLite-backed ATProto storage. 171 + 172 + Kept separate because PDS HEAD = MST root CID (not an Irmin commit hash). 173 + Cannot use Store.Mst.checkout/commit, which expect HEAD to point to a commit 174 + object. Instead we call Pds.checkout/set_head directly. *) 175 + module Pds_backend : BACKEND = struct 176 + type store = Pds.t 177 + type tree = Atp.Mst.node * Atp.Blockstore.writable 178 + type hash = Atp.Cid.t 179 + 180 + let open_store ~sw ~fs ~config = 181 + let path = Eio.Path.(fs / config.Config.store_path) in 182 + let pds_db = Filename.concat config.Config.store_path "pds.db" in 183 + if Sys.file_exists pds_db then Pds.open_ ~sw path 184 + else 185 + let did = Atp.Did.of_string_exn "did:web:localhost" in 186 + Pds.v ~sw path ~did 187 + 188 + let checkout store ~branch = 189 + ignore branch; 190 + match Pds.checkout store with 191 + | None -> None 192 + | Some mst -> Some (mst, Pds.blockstore store) 193 + 194 + let empty_tree store = (Atp.Mst.empty, Pds.blockstore store) 195 + 110 196 let tree_find (mst, bs) path = 111 197 let key = String.concat "/" path in 112 198 match Atp.Mst.find key mst ~store:(bs :> Atp.Blockstore.readable) with ··· 146 232 else None) 147 233 |> List.of_seq |> List.sort_uniq compare 148 234 149 - let hash_to_hex cid = Atp.Cid.to_string cid 150 - 151 - let hash_short cid = 152 - let s = Atp.Cid.to_string cid in 153 - if String.length s > 7 then String.sub s 0 7 else s 154 - end 155 - 156 - (** PDS backend — SQLite-backed ATProto storage. *) 157 - module Pds_backend : BACKEND = struct 158 - type store = Pds.t 159 - type tree = Atp.Mst.node * Atp.Blockstore.writable 160 - type hash = Atp.Cid.t 161 - 162 - let open_store ~sw ~fs ~config = 163 - let path = Eio.Path.(fs / config.Config.store_path) in 164 - let pds_db = Filename.concat config.Config.store_path "pds.db" in 165 - if Sys.file_exists pds_db then Pds.open_ ~sw path 166 - else 167 - let did = Atp.Did.of_string_exn "did:web:localhost" in 168 - Pds.v ~sw path ~did 169 - 170 - let checkout store ~branch = 171 - ignore branch; 172 - match Pds.checkout store with 173 - | None -> None 174 - | Some mst -> Some (mst, Pds.blockstore store) 175 - 176 - let empty_tree store = (Atp.Mst.empty, Pds.blockstore store) 177 - let tree_find = Mst_tree.tree_find 178 - let tree_add = Mst_tree.tree_add 179 - let tree_remove = Mst_tree.tree_remove 180 - let tree_list = Mst_tree.tree_list 181 - 182 235 let head store ~branch = 183 236 ignore branch; 184 237 Pds.head store ··· 200 253 ignore limit; 201 254 [] 202 255 203 - let hash_to_hex = Mst_tree.hash_to_hex 204 - let hash_short = Mst_tree.hash_short 205 - end 206 - 207 - (** In-memory backend for testing. *) 208 - module Memory : BACKEND = struct 209 - type store = Atp.Blockstore.writable 210 - type tree = Atp.Mst.node * Atp.Blockstore.writable 211 - type hash = Atp.Cid.t 212 - 213 - let open_store ~sw:_ ~fs:_ ~config:_ = Atp.Blockstore.memory () 214 - 215 - let checkout _store ~branch = 216 - ignore branch; 217 - None 218 - 219 - let empty_tree store = (Atp.Mst.empty, store) 220 - let tree_find = Mst_tree.tree_find 221 - let tree_add = Mst_tree.tree_add 222 - let tree_remove = Mst_tree.tree_remove 223 - let tree_list = Mst_tree.tree_list 224 - 225 - let head _store ~branch = 226 - ignore branch; 227 - None 228 - 229 - let commit bs ~tree:(mst, _) ~parents ~message ~author = 230 - ignore parents; 231 - ignore message; 232 - ignore author; 233 - let cid = Atp.Mst.to_cid mst ~store:bs in 234 - bs#sync; 235 - cid 236 - 237 - let set_head _store ~branch:_ _hash = () 238 - let branches _store = [ "main" ] 239 - 240 - let log _store ~branch ~limit = 241 - ignore branch; 242 - ignore limit; 243 - [] 256 + let hash_to_hex cid = Atp.Cid.to_string cid 244 257 245 - let hash_to_hex = Mst_tree.hash_to_hex 246 - let hash_short = Mst_tree.hash_short 258 + let hash_short cid = 259 + let s = Atp.Cid.to_string cid in 260 + if String.length s > 7 then String.sub s 0 7 else s 247 261 end 248 262 249 263 (** Get the appropriate backend module for a configuration. *) ··· 252 266 | Config.Git -> (module Git) 253 267 | Config.Pds -> (module Pds_backend) 254 268 | Config.Memory -> (module Memory) 269 + | Config.Disk -> (module Disk)
+5 -3
bin/config.ml
··· 1 1 (** Irmin CLI configuration. *) 2 2 3 - type backend = Git | Pds | Memory 3 + type backend = Git | Pds | Memory | Disk 4 4 type t = { backend : backend; store_path : string; default_branch : string } 5 5 6 6 let default = { backend = Git; store_path = "."; default_branch = "main" } ··· 9 9 | Git -> Fmt.string ppf "git" 10 10 | Pds -> Fmt.string ppf "pds" 11 11 | Memory -> Fmt.string ppf "memory" 12 + | Disk -> Fmt.string ppf "disk" 12 13 13 14 let backend_of_string = function 14 15 | "git" -> Some Git 15 - | "mst" | "atproto" | "pds" -> Some Pds 16 - | "memory" | "mem" -> Some Memory 16 + | "pds" -> Some Pds 17 + | "memory" -> Some Memory 18 + | "disk" -> Some Disk 17 19 | _ -> None 18 20 19 21 (** Parse config file. Format: key = value, one per line. *)
+3 -1
bin/main.ml
··· 43 43 value 44 44 & opt 45 45 (enum 46 - [ ("git", `Git); ("pds", `Pds); ("mst", `Pds); ("atproto", `Pds) ]) 46 + [ 47 + ("git", `Git); ("pds", `Pds); ("memory", `Memory); ("disk", `Disk); 48 + ]) 47 49 `Git 48 50 & info [ "backend" ] ~docv:"TYPE" ~doc) 49 51
+18 -14
lib/git_interop.ml
··· 11 11 12 12 (* Detect object type from content. 13 13 Commits start with "tree ", trees have binary format with mode prefixes. *) 14 - let detect_object_type data = 15 - if String.length data >= 5 && String.sub data 0 5 = "tree " then `Commit 16 - else if String.length data >= 2 && data.[0] >= '1' && data.[0] <= '7' then 17 - (* Tree entries start with mode like "100644 " or "40000 " *) 18 - `Tree 19 - else `Blob 20 14 21 15 let git_value_of_data data = 22 - match detect_object_type data with 23 - | `Blob -> Git.Value.blob (Git.Blob.of_string data) 24 - | `Tree -> Git.Value.tree (Git.Tree.of_string_exn data) 25 - | `Commit -> Git.Value.commit (Git.Commit.of_string_exn data) 16 + (* Commit objects start with "tree <sha1>\n" *) 17 + if String.length data >= 5 && String.sub data 0 5 = "tree " then 18 + Git.Value.commit (Git.Commit.of_string_exn data) 19 + else 20 + (* Try parsing as tree; git tree format is binary and distinctive. 21 + Any other content (blobs, including text starting with digits) 22 + falls back to blob. *) 23 + match Git.Tree.of_string data with 24 + | Ok tree -> Git.Value.tree tree 25 + | Error _ -> Git.Value.blob (Git.Blob.of_string data) 26 26 27 27 let test_and_set_ref repo name ~test ~set = 28 28 let current = Git.Repository.read_ref repo name in ··· 49 49 | Ok value -> Some (Git.Value.to_string_without_header value) 50 50 | Error _ -> None); 51 51 write = 52 - (fun _expected_hash data -> 53 - ignore (Git.Repository.write repo (git_value_of_data data))); 52 + (fun expected_hash data -> 53 + let git_hash = git_hash_of_sha1 expected_hash in 54 + if not (Git.Repository.exists repo git_hash) then 55 + ignore (Git.Repository.write repo (git_value_of_data data))); 54 56 exists = 55 57 (fun hash -> 56 58 let git_hash = git_hash_of_sha1 hash in ··· 66 68 write_batch = 67 69 (fun objects -> 68 70 List.iter 69 - (fun (_expected_hash, data) -> 70 - ignore (Git.Repository.write repo (git_value_of_data data))) 71 + (fun (expected_hash, data) -> 72 + let git_hash = git_hash_of_sha1 expected_hash in 73 + if not (Git.Repository.exists repo git_hash) then 74 + ignore (Git.Repository.write repo (git_value_of_data data))) 71 75 objects); 72 76 flush = (fun () -> ()); 73 77 close = (fun () -> ());
+10
lib/irmin.ml
··· 89 89 90 90 let import = Git_interop.import_git 91 91 let init = Git_interop.init_git 92 + let open_ = Git_interop.open_git 92 93 end 93 94 94 95 (** {1 Pre-instantiated: MST Format} *) ··· 100 101 module Store = Store.Mst 101 102 module Subtree = Subtree.Mst 102 103 module Proof = Proof.Mst 104 + 105 + (* After [module Store = Store.Mst], [Store] refers to [Store.Mst] in scope *) 106 + 107 + let of_pds pds = Store.create ~backend:(Pds_interop.mst_backend pds) 108 + 109 + let disk ~sw root = 110 + Store.create ~backend:(Backend.Disk.create_sha256 ~sw root) 111 + 112 + let memory () = Store.create ~backend:(Backend.Memory.create_sha256 ()) 103 113 end
+27 -4
lib/irmin.mli
··· 80 80 (** {1 Pre-instantiated: Git Format} *) 81 81 82 82 module Git : sig 83 - (** Git-compatible store (SHA-1, Git object format). *) 83 + (** Git-compatible store (SHA-1, Git object format). 84 + 85 + All three builders return [Store.t = Store.Git.t], which embeds the 86 + backend. After creation use the flat [Store.Git.*] / [Tree.Git.*] API. *) 84 87 85 88 module Tree = Tree.Git 86 89 module Store : module type of Store.Git ··· 89 92 90 93 val import : 91 94 sw:Eio.Switch.t -> fs:Eio.Fs.dir_ty Eio.Path.t -> git_dir:Fpath.t -> Store.t 92 - (** Open a bare .git directory as a store. *) 95 + (** [import ~sw ~fs ~git_dir] opens a bare .git directory as a store. *) 96 + 97 + val open_ : 98 + sw:Eio.Switch.t -> fs:Eio.Fs.dir_ty Eio.Path.t -> path:Fpath.t -> Store.t 99 + (** [open_ ~sw ~fs ~path] opens an existing Git repository at [path]. *) 93 100 94 101 val init : 95 102 sw:Eio.Switch.t -> fs:Eio.Fs.dir_ty Eio.Path.t -> path:Fpath.t -> Store.t 96 - (** Initialize a new Git repository and return a store. *) 103 + (** [init ~sw ~fs ~path] initializes a new Git repository at [path]. *) 97 104 end 98 105 99 106 (** {1 Pre-instantiated: MST Format} *) 100 107 101 108 module Mst : sig 102 - (** ATProto-compatible store (SHA-256, DAG-CBOR MST). *) 109 + (** ATProto-compatible store (SHA-256, DAG-CBOR MST). 110 + 111 + Three backends, one [Store.t] type. Pick the backend at construction; use 112 + the flat [Store.Mst.*] / [Tree.Mst.*] API for everything after. *) 103 113 104 114 module Tree = Tree.Mst 105 115 module Store : module type of Store.Mst 106 116 module Subtree = Subtree.Mst 107 117 module Proof = Proof.Mst 118 + 119 + val of_pds : Pds.t -> Store.t 120 + (** [of_pds pds] creates a store backed by an ATProto PDS (SQLite). Interops 121 + with the bsky/PDS CLI: [HEAD]/[refs/heads/main] map to 122 + [Pds.head]/[Pds.set_head], so commit CIDs are visible to PDS tools. *) 123 + 124 + val disk : sw:Eio.Switch.t -> Eio.Fs.dir_ty Eio.Path.t -> Store.t 125 + (** [disk ~sw root] creates a store backed by the append-only disk backend 126 + (WAL + bloom filter). Not git-compatible. Suitable for high-throughput MST 127 + workloads ("lavyek" backend). *) 128 + 129 + val memory : unit -> Store.t 130 + (** [memory ()] creates a transient in-memory store. Useful for testing. *) 108 131 end
+50
lib/pds_interop.ml
··· 4 4 (Merkle Search Tree) and blockstore, enabling interoperability between the 5 5 PDS record API and the MST key-value layer. *) 6 6 7 + (* CID ↔ Hash.sha256 conversion (same as Codec.Mst uses internally) *) 8 + let cid_of_sha256 h = Atp.Cid.of_digest `Dag_cbor (Hash.to_bytes h) 9 + let sha256_of_cid cid = Hash.sha256_of_bytes (Atp.Cid.digest cid) 10 + 11 + let mst_backend (pds : Pds.t) : Hash.sha256 Backend.t = 12 + let bs = Pds.blockstore pds in 13 + { 14 + Backend.read = (fun h -> bs#get (cid_of_sha256 h)); 15 + write = (fun h data -> bs#put (cid_of_sha256 h) data); 16 + exists = (fun h -> Option.is_some (bs#get (cid_of_sha256 h))); 17 + get_ref = 18 + (fun name -> 19 + if name = "HEAD" || name = "refs/heads/main" then 20 + Option.map sha256_of_cid (Pds.head pds) 21 + else None); 22 + set_ref = 23 + (fun name h -> 24 + if name = "HEAD" || name = "refs/heads/main" then 25 + Pds.set_head pds (cid_of_sha256 h)); 26 + test_and_set_ref = 27 + (fun name ~test ~set -> 28 + let current = 29 + if name = "HEAD" || name = "refs/heads/main" then 30 + Option.map sha256_of_cid (Pds.head pds) 31 + else None 32 + in 33 + let matches = 34 + match (test, current) with 35 + | None, None -> true 36 + | Some t, Some c -> Hash.equal t c 37 + | _ -> false 38 + in 39 + if matches then ( 40 + (match set with 41 + | None -> () 42 + | Some h -> 43 + if name = "HEAD" || name = "refs/heads/main" then 44 + Pds.set_head pds (cid_of_sha256 h)); 45 + true) 46 + else false); 47 + list_refs = 48 + (fun () -> 49 + match Pds.head pds with None -> [] | Some _ -> [ "refs/heads/main" ]); 50 + write_batch = 51 + (fun objects -> 52 + List.iter (fun (h, data) -> bs#put (cid_of_sha256 h) data) objects); 53 + flush = (fun () -> bs#sync); 54 + close = (fun () -> Pds.close pds); 55 + } 56 + 7 57 let mst_find pds key = 8 58 match Pds.checkout pds with 9 59 | None -> None
+8
lib/pds_interop.mli
··· 4 4 Tree) and blockstore. This allows Irmin to interoperate with PDS stores at 5 5 the MST level, complementing the high-level record API. *) 6 6 7 + (** {1 Backend Bridge} *) 8 + 9 + val mst_backend : Pds.t -> Hash.sha256 Backend.t 10 + (** [mst_backend pds] wraps a PDS store as a [Hash.sha256 Backend.t], allowing 11 + it to be used with [Store.Mst.create ~backend] for a fully irmin-managed MST 12 + store. Only [refs/heads/main] and [HEAD] are supported as branch refs; they 13 + map directly to [Pds.head]/[Pds.set_head]. *) 14 + 7 15 (** {1 MST Read Operations} *) 8 16 9 17 val mst_find : Pds.t -> string -> string option