forked from
anil.recoil.org/monopam
Monorepo management for opam overlays
1type error =
2 | Multiple_versions of string * string list
3 | No_dev_repo of string
4 | Invalid_dev_repo of string * string
5 | Not_git_remote of string * string
6 | Parse_error of string * string
7 | Io_error of string
8
9let pp_error ppf = function
10 | Multiple_versions (name, versions) ->
11 Fmt.pf ppf "Package %s has multiple versions: %a" name
12 Fmt.(list ~sep:comma string)
13 versions
14 | No_dev_repo name -> Fmt.pf ppf "Package %s has no dev-repo field" name
15 | Invalid_dev_repo (name, url) ->
16 Fmt.pf ppf "Package %s has invalid dev-repo: %s" name url
17 | Not_git_remote (name, url) ->
18 Fmt.pf ppf "Package %s dev-repo is not a git URL: %s" name url
19 | Parse_error (path, msg) -> Fmt.pf ppf "Failed to parse %s: %s" path msg
20 | Io_error msg -> Fmt.pf ppf "I/O error: %s" msg
21
22let is_git_url url =
23 String.starts_with ~prefix:"git+" url
24 || String.starts_with ~prefix:"git://" url
25 || String.starts_with ~prefix:"git@" url
26 || String.ends_with ~suffix:".git" url
27
28let normalize_git_url url =
29 let url =
30 match String.starts_with ~prefix:"git+" url with
31 | true -> String.sub url 4 (String.length url - 4)
32 | false -> url
33 in
34 Uri.of_string url
35
36module OP = OpamParserTypes.FullPos
37
38let extract_string_value (v : OP.value) : string option =
39 match v.pelem with OP.String s -> Some s | _ -> None
40
41let find_dev_repo (items : OP.opamfile_item list) : string option =
42 List.find_map
43 (fun (item : OP.opamfile_item) ->
44 match item.pelem with
45 | OP.Variable (name, value) when name.pelem = "dev-repo" ->
46 extract_string_value value
47 | _ -> None)
48 items
49
50(** Extract package name from a dependency formula value.
51 Handles cases like:
52 - "pkgname"
53 - "pkgname" {>= "1.0"}
54 - "pkgname" {with-test}
55 Returns the package name if found. *)
56let rec extract_dep_name (v : OP.value) : string option =
57 match v.pelem with
58 | OP.String s -> Some s
59 | OP.Option (inner, _) -> extract_dep_name inner
60 | _ -> None
61
62(** Extract all dependency package names from a depends value. The depends field
63 is a list of package formulas. *)
64let extract_depends_list (v : OP.value) : string list =
65 match v.pelem with
66 | OP.List { pelem = items; _ } -> List.filter_map extract_dep_name items
67 | _ -> ( match extract_dep_name v with Some s -> [ s ] | None -> [])
68
69let find_depends (items : OP.opamfile_item list) : string list =
70 List.find_map
71 (fun (item : OP.opamfile_item) ->
72 match item.pelem with
73 | OP.Variable (name, value) when name.pelem = "depends" ->
74 Some (extract_depends_list value)
75 | _ -> None)
76 items
77 |> Option.value ~default:[]
78
79let find_synopsis (items : OP.opamfile_item list) : string option =
80 List.find_map
81 (fun (item : OP.opamfile_item) ->
82 match item.pelem with
83 | OP.Variable (name, value) when name.pelem = "synopsis" ->
84 extract_string_value value
85 | _ -> None)
86 items
87
88let parse_package_path (path : Fpath.t) : (string * string) option =
89 let segs = Fpath.segs path in
90 let rec find_after_packages = function
91 | [] -> None
92 | "packages" :: name :: name_version :: _ -> (
93 match String.split_on_char '.' name_version with
94 | [ n; v ] when n = name -> Some (name, v)
95 | n :: rest when n = name -> Some (name, String.concat "." rest)
96 | _ -> None)
97 | _ :: rest -> find_after_packages rest
98 in
99 find_after_packages segs
100
101let load_package ~fs opam_file_path =
102 let path_str = Fpath.to_string opam_file_path in
103 match parse_package_path opam_file_path with
104 | None ->
105 Error (Parse_error (path_str, "Cannot determine package name/version"))
106 | Some (name, version) -> (
107 try
108 let eio_path = Eio.Path.(fs / path_str) in
109 let content = Eio.Path.load eio_path in
110 let opamfile = OpamParser.FullPos.string content path_str in
111 match find_dev_repo opamfile.file_contents with
112 | None -> Error (No_dev_repo name)
113 | Some url ->
114 if not (is_git_url url) then Error (Not_git_remote (name, url))
115 else
116 let dev_repo = normalize_git_url url in
117 let depends = find_depends opamfile.file_contents in
118 let synopsis = find_synopsis opamfile.file_contents in
119 Ok (Package.create ~name ~version ~dev_repo ~depends ?synopsis ())
120 with
121 | Eio.Io _ as e -> Error (Io_error (Printexc.to_string e))
122 | exn -> Error (Parse_error (path_str, Printexc.to_string exn)))
123
124let list_dirs ~fs path =
125 let eio_path = Eio.Path.(fs / Fpath.to_string path) in
126 try
127 Eio.Path.read_dir eio_path
128 |> List.filter (fun name ->
129 let child = Eio.Path.(eio_path / name) in
130 match Eio.Path.kind ~follow:false child with
131 | `Directory -> true
132 | _ -> false)
133 with Eio.Io _ -> []
134
135let scan_all ~fs repo_path =
136 let packages_dir = Fpath.(repo_path / "packages") in
137 let package_names = list_dirs ~fs packages_dir in
138 let packages, errors =
139 List.fold_left
140 (fun (pkgs, errs) name ->
141 let pkg_dir = Fpath.(packages_dir / name) in
142 let versions = list_dirs ~fs pkg_dir in
143 match versions with
144 | [] -> (pkgs, errs)
145 | [ version_dir ] -> (
146 let opam_path = Fpath.(pkg_dir / version_dir / "opam") in
147 match load_package ~fs opam_path with
148 | Ok pkg -> (pkg :: pkgs, errs)
149 | Error e -> (pkgs, e :: errs))
150 | _ :: _ :: _ as vs -> (pkgs, Multiple_versions (name, vs) :: errs))
151 ([], []) package_names
152 in
153 (List.rev packages, List.rev errors)
154
155let scan ~fs repo_path =
156 match scan_all ~fs repo_path with
157 | pkgs, [] -> Ok pkgs
158 | _, err :: _ -> Error err
159
160let validate_repo ~fs repo_path =
161 let _, errors = scan_all ~fs repo_path in
162 errors
163
164(** Scan a directory for .opam files and extract all dependencies. This is used
165 to find dependencies from monorepo subtree directories, where multiple .opam
166 files may exist that aren't in the opam overlay. *)
167let scan_opam_files_for_deps ~fs dir_path =
168 let eio_path = Eio.Path.(fs / Fpath.to_string dir_path) in
169 try
170 let files = Eio.Path.read_dir eio_path in
171 let opam_files =
172 List.filter (fun name -> Filename.check_suffix name ".opam") files
173 in
174 List.concat_map
175 (fun opam_file ->
176 let opam_path = Eio.Path.(eio_path / opam_file) in
177 try
178 let content = Eio.Path.load opam_path in
179 let opamfile =
180 OpamParser.FullPos.string content
181 (Fpath.to_string dir_path ^ "/" ^ opam_file)
182 in
183 find_depends opamfile.file_contents
184 with _ -> [])
185 opam_files
186 with Eio.Io _ -> []
187
188(** Read the raw content of an opam file. *)
189let read_opam_file ~fs opam_file_path =
190 let eio_path = Eio.Path.(fs / Fpath.to_string opam_file_path) in
191 try Ok (Eio.Path.load eio_path) with Eio.Io _ as e -> Error (Io_error (Printexc.to_string e))
192
193(** Replace dev-repo line in content. Looks for 'dev-repo: "..."' and replaces the URL. *)
194let replace_dev_repo_line content ~new_url =
195 let lines = String.split_on_char '\n' content in
196 let dev_repo_url =
197 if String.starts_with ~prefix:"git@" new_url then "git+" ^ new_url
198 else if String.starts_with ~prefix:"https://" new_url then "git+" ^ new_url
199 else if String.starts_with ~prefix:"git+" new_url then new_url
200 else "git+" ^ new_url
201 in
202 let lines =
203 List.map
204 (fun line ->
205 let trimmed = String.trim line in
206 if String.starts_with ~prefix:"dev-repo:" trimmed then
207 Printf.sprintf {|dev-repo: "%s"|} dev_repo_url
208 else line)
209 lines
210 in
211 String.concat "\n" lines
212
213(** Replace url { src: "..." } section in content. *)
214let replace_url_section content ~new_url =
215 let url_src =
216 let base =
217 if String.starts_with ~prefix:"git@" new_url then "git+" ^ new_url
218 else if String.starts_with ~prefix:"https://" new_url then "git+" ^ new_url
219 else if String.starts_with ~prefix:"git+" new_url then new_url
220 else "git+" ^ new_url
221 in
222 base ^ "#main"
223 in
224 (* Simple state machine to find and replace url { src: "..." } block *)
225 let lines = String.split_on_char '\n' content in
226 let rec process lines in_url_block acc =
227 match lines with
228 | [] -> List.rev acc
229 | line :: rest ->
230 let trimmed = String.trim line in
231 if in_url_block then
232 (* We're inside url { ... }, skip until we see } *)
233 if String.starts_with ~prefix:"}" trimmed then
234 (* End of url block - output our replacement *)
235 let replacement =
236 [ "url {"; Printf.sprintf {| src: "%s"|} url_src; "}" ]
237 in
238 process rest false (List.rev_append replacement acc)
239 else
240 (* Skip this line, it's part of the old url block *)
241 process rest true acc
242 else if trimmed = "url {" || String.starts_with ~prefix:"url {" trimmed then
243 (* Start of url block *)
244 if String.ends_with ~suffix:"}" trimmed then
245 (* Single-line url block *)
246 let replacement =
247 [ "url {"; Printf.sprintf {| src: "%s"|} url_src; "}" ]
248 in
249 process rest false (List.rev_append replacement acc)
250 else process rest true acc
251 else process rest false (line :: acc)
252 in
253 String.concat "\n" (process lines false [])
254
255(** Replace the dev-repo and url fields in an opam file content with a new git URL.
256 The new URL should be a plain git URL (e.g., "git@github.com:user/repo.git"). *)
257let replace_dev_repo_url content ~new_url =
258 let content = replace_dev_repo_line content ~new_url in
259 let content = replace_url_section content ~new_url in
260 content
261
262(** Write an opam package to the opam-repo overlay.
263 Creates the directory structure: packages/<name>/<name.version>/opam *)
264let write_package ~fs ~repo_path ~name ~version ~content =
265 let pkg_dir = Fpath.(repo_path / "packages" / name / (name ^ "." ^ version)) in
266 let opam_path = Fpath.(pkg_dir / "opam") in
267 let eio_pkg_dir = Eio.Path.(fs / Fpath.to_string pkg_dir) in
268 let eio_opam_path = Eio.Path.(fs / Fpath.to_string opam_path) in
269 try
270 (* Create directory structure *)
271 Eio.Path.mkdirs ~perm:0o755 eio_pkg_dir;
272 (* Write opam file *)
273 Eio.Path.save ~create:(`Or_truncate 0o644) eio_opam_path content;
274 Ok ()
275 with Eio.Io _ as e -> Error (Io_error (Printexc.to_string e))
276
277(** Check if a package exists in the opam-repo. *)
278let package_exists ~fs ~repo_path ~name =
279 let pkg_dir = Fpath.(repo_path / "packages" / name) in
280 let eio_path = Eio.Path.(fs / Fpath.to_string pkg_dir) in
281 match Eio.Path.kind ~follow:true eio_path with
282 | `Directory -> true
283 | _ -> false
284 | exception _ -> false