···1+open Cmdliner
2+3+(* Logging setup *)
4+5+let setup_logging style_renderer level =
6+ Fmt_tty.setup_std_outputs ?style_renderer ();
7+ Logs.set_level level;
8+ Logs.set_reporter (Logs_fmt.reporter ());
9+ ()
10+11+let logging_term =
12+ Term.(const setup_logging $ Fmt_cli.style_renderer () $ Logs_cli.level ())
13+14+(* Common options *)
15+16+let config_file =
17+ let doc = "Path to unpac.toml config file." in
18+ Arg.(value & opt file "unpac.toml" & info [ "c"; "config" ] ~doc ~docv:"FILE")
19+20+let cache_dir_term =
21+ let app_env = "UNPAC_CACHE_DIR" in
22+ let xdg_var = "XDG_CACHE_HOME" in
23+ let home = Sys.getenv "HOME" in
24+ let default_path = home ^ "/.cache/unpac" in
25+ let doc =
26+ Printf.sprintf
27+ "Override cache directory. Can also be set with %s or %s. Default: %s"
28+ app_env xdg_var default_path
29+ in
30+ let arg =
31+ Arg.(value & opt string default_path & info [ "cache-dir" ] ~docv:"DIR" ~doc)
32+ in
33+ Term.(
34+ const (fun cmdline_val ->
35+ if cmdline_val <> default_path then cmdline_val
36+ else
37+ match Sys.getenv_opt app_env with
38+ | Some v when v <> "" -> v
39+ | _ -> (
40+ match Sys.getenv_opt xdg_var with
41+ | Some v when v <> "" -> v ^ "/unpac"
42+ | _ -> default_path))
43+ $ arg)
44+45+(* Output format selection *)
46+type output_format = Text | Json | Toml
47+48+let output_format_term =
49+ let json =
50+ let doc = "Output in JSON format." in
51+ Arg.(value & flag & info [ "json" ] ~doc)
52+ in
53+ let toml =
54+ let doc = "Output in TOML format." in
55+ Arg.(value & flag & info [ "toml" ] ~doc)
56+ in
57+ let select json toml =
58+ match (json, toml) with
59+ | true, false -> Json
60+ | false, true -> Toml
61+ | false, false -> Text
62+ | true, true ->
63+ Format.eprintf "Cannot use both --json and --toml@.";
64+ Text
65+ in
66+ Term.(const select $ json $ toml)
67+68+let get_format = function
69+ | Text -> Unpac.Output.Text
70+ | Json -> Unpac.Output.Json
71+ | Toml -> Unpac.Output.Toml
72+73+(* Helper to load index from config with caching *)
74+75+let load_index ~fs ~cache_dir config_path =
76+ let cache_path = Eio.Path.(fs / cache_dir) in
77+ Eio.Path.mkdirs ~exists_ok:true ~perm:0o755 cache_path;
78+ Unpac.Cache.load_index ~cache_dir:cache_path ~config_path
79+80+(* Get compiler spec from config *)
81+let get_compiler_spec config_path =
82+ try
83+ let config = Unpac.Config.load_exn config_path in
84+ match config.opam.compiler with
85+ | Some s -> Unpac.Solver.parse_compiler_spec s
86+ | None -> None
87+ with _ -> None
88+89+(* Source kind selection *)
90+let source_kind_term =
91+ let git =
92+ let doc = "Get git/dev-repo URLs instead of archive URLs." in
93+ Arg.(value & flag & info [ "git" ] ~doc)
94+ in
95+ Term.(
96+ const (fun git ->
97+ if git then Unpac.Source.Git else Unpac.Source.Archive)
98+ $ git)
99+100+(* Resolve dependencies flag *)
101+let resolve_deps_term =
102+ let doc = "Resolve dependencies using the 0install solver." in
103+ Arg.(value & flag & info [ "deps"; "with-deps" ] ~doc)
104+105+(* ============================================================================
106+ INIT COMMAND
107+ ============================================================================ *)
108+109+let init_cmd =
110+ let doc = "Initialize a new unpac repository." in
111+ let man = [
112+ `S Manpage.s_description;
113+ `P "Initializes a new git repository with unpac project structure.";
114+ `P "Creates the main branch with a project registry.";
115+ ] in
116+ let run () =
117+ Eio_main.run @@ fun env ->
118+ let cwd = Eio.Stdenv.cwd env in
119+ let proc_mgr = Eio.Stdenv.process_mgr env in
120+ Unpac.Project.init ~proc_mgr ~cwd:(cwd :> Eio.Fs.dir_ty Eio.Path.t);
121+ Format.printf "Repository initialized.@.";
122+ Format.printf "Create a project with: unpac project create <name>@."
123+ in
124+ let info = Cmd.info "init" ~doc ~man in
125+ Cmd.v info Term.(const run $ logging_term)
126+127+(* ============================================================================
128+ PROJECT COMMANDS
129+ ============================================================================ *)
130+131+let project_name_arg =
132+ let doc = "Project name." in
133+ Arg.(required & pos 0 (some string) None & info [] ~docv:"NAME" ~doc)
134+135+let project_desc_opt =
136+ let doc = "Project description." in
137+ Arg.(value & opt (some string) None & info ["d"; "description"] ~docv:"DESC" ~doc)
138+139+let project_create_cmd =
140+ let doc = "Create a new project." in
141+ let man = [
142+ `S Manpage.s_description;
143+ `P "Creates a new project branch and switches to it.";
144+ `P "The project is registered in the main branch's unpac.toml.";
145+ ] in
146+ let run () name description =
147+ Eio_main.run @@ fun env ->
148+ let cwd = Eio.Stdenv.cwd env in
149+ let proc_mgr = Eio.Stdenv.process_mgr env in
150+ let description = match description with Some d -> d | None -> "" in
151+ Unpac.Project.create ~proc_mgr ~cwd:(cwd :> Eio.Fs.dir_ty Eio.Path.t)
152+ ~name ~description ()
153+ in
154+ let info = Cmd.info "create" ~doc ~man in
155+ Cmd.v info Term.(const run $ logging_term $ project_name_arg $ project_desc_opt)
156+157+let project_list_cmd =
158+ let doc = "List all projects." in
159+ let man = [
160+ `S Manpage.s_description;
161+ `P "Lists all projects in the repository.";
162+ ] in
163+ let run () =
164+ Eio_main.run @@ fun env ->
165+ let cwd = Eio.Stdenv.cwd env in
166+ let proc_mgr = Eio.Stdenv.process_mgr env in
167+ let projects = Unpac.Project.list_projects ~proc_mgr
168+ ~cwd:(cwd :> Eio.Fs.dir_ty Eio.Path.t) in
169+ let current = Unpac.Project.current_project ~proc_mgr
170+ ~cwd:(cwd :> Eio.Fs.dir_ty Eio.Path.t) in
171+ if projects = [] then
172+ Format.printf "No projects. Create one with: unpac project create <name>@."
173+ else begin
174+ Format.printf "Projects:@.";
175+ List.iter (fun (p : Unpac.Project.project_info) ->
176+ let marker = if Some p.name = current then "* " else " " in
177+ Format.printf "%s%s (%s)@." marker p.name p.branch
178+ ) projects
179+ end
180+ in
181+ let info = Cmd.info "list" ~doc ~man in
182+ Cmd.v info Term.(const run $ logging_term)
183+184+let project_switch_cmd =
185+ let doc = "Switch to a project." in
186+ let man = [
187+ `S Manpage.s_description;
188+ `P "Switches to the specified project's branch.";
189+ ] in
190+ let run () name =
191+ Eio_main.run @@ fun env ->
192+ let cwd = Eio.Stdenv.cwd env in
193+ let proc_mgr = Eio.Stdenv.process_mgr env in
194+ Unpac.Project.switch ~proc_mgr ~cwd:(cwd :> Eio.Fs.dir_ty Eio.Path.t) name
195+ in
196+ let info = Cmd.info "switch" ~doc ~man in
197+ Cmd.v info Term.(const run $ logging_term $ project_name_arg)
198+199+let project_cmd =
200+ let doc = "Project management commands." in
201+ let man = [
202+ `S Manpage.s_description;
203+ `P "Commands for managing projects (branches).";
204+ ] in
205+ let info = Cmd.info "project" ~doc ~man in
206+ Cmd.group info [project_create_cmd; project_list_cmd; project_switch_cmd]
207+208+(* ============================================================================
209+ ADD COMMANDS
210+ ============================================================================ *)
211+212+let package_name_arg =
213+ let doc = "Package name to add." in
214+ Arg.(required & pos 0 (some string) None & info [] ~docv:"PACKAGE" ~doc)
215+216+let add_opam_cmd =
217+ let doc = "Add a package from opam." in
218+ let man = [
219+ `S Manpage.s_description;
220+ `P "Adds a package from opam, creating vendor branches and merging into the current project.";
221+ `P "Must be on a project branch (not main).";
222+ `P "Use --with-deps to include all transitive dependencies.";
223+ `S Manpage.s_examples;
224+ `P "Add a single package:";
225+ `Pre " unpac add opam eio";
226+ `P "Add a package with all dependencies:";
227+ `Pre " unpac add opam lwt --with-deps";
228+ ] in
229+ let run () config_path cache_dir resolve_deps pkg_name =
230+ Eio_main.run @@ fun env ->
231+ let fs = Eio.Stdenv.fs env in
232+ let cwd = Eio.Stdenv.cwd env in
233+ let proc_mgr = Eio.Stdenv.process_mgr env in
234+ let cwd_path = (cwd :> Eio.Fs.dir_ty Eio.Path.t) in
235+236+ (* Check we're on a project branch *)
237+ let _project = Unpac.Project.require_project_branch ~proc_mgr ~cwd:cwd_path in
238+239+ (* Check for pending recovery *)
240+ if Unpac.Recovery.has_recovery ~cwd:cwd_path then begin
241+ Format.eprintf "There's a pending operation. Run 'unpac vendor continue' or 'unpac vendor abort'.@.";
242+ exit 1
243+ end;
244+245+ (* Load opam index *)
246+ let index = load_index ~fs ~cache_dir config_path in
247+ let compiler = get_compiler_spec config_path in
248+249+ (* Parse package spec *)
250+ let spec = match Unpac.Solver.parse_package_spec pkg_name with
251+ | Ok s -> s
252+ | Error msg ->
253+ Format.eprintf "Invalid package spec: %s@." msg;
254+ exit 1
255+ in
256+257+ (* Get packages to add *)
258+ let packages_to_add =
259+ if resolve_deps then begin
260+ match Unpac.Solver.select_with_deps ?compiler index [spec] with
261+ | Ok selection -> selection.packages
262+ | Error msg ->
263+ Format.eprintf "Error resolving dependencies: %s@." msg;
264+ exit 1
265+ end else begin
266+ match Unpac.Solver.select_packages index [spec] with
267+ | Ok selection -> selection.packages
268+ | Error msg ->
269+ Format.eprintf "Error selecting package: %s@." msg;
270+ exit 1
271+ end
272+ in
273+274+ if packages_to_add = [] then begin
275+ Format.eprintf "Package '%s' not found.@." pkg_name;
276+ exit 1
277+ end;
278+279+ (* Group packages by dev-repo *)
280+ let sources = Unpac.Source.extract_all Unpac.Source.Git packages_to_add in
281+ let grouped = Unpac.Source.group_by_dev_repo sources in
282+283+ Format.printf "Found %d package group(s) to vendor:@." (List.length grouped);
284+285+ (* Add each group *)
286+ List.iter (fun (group : Unpac.Source.grouped_sources) ->
287+ match group.dev_repo with
288+ | None ->
289+ Format.printf " Skipping packages without dev-repo@."
290+ | Some dev_repo ->
291+ let url_str = Unpac.Dev_repo.to_string dev_repo in
292+ let opam_packages = List.map (fun (p : Unpac.Source.package_source) -> p.name) group.packages in
293+294+ (* Use first package name as canonical name, or extract from URL *)
295+ let name =
296+ match opam_packages with
297+ | first :: _ -> first
298+ | [] -> "unknown"
299+ in
300+301+ (* Reconstruct full URL for git clone *)
302+ let url =
303+ let first_pkg = List.hd group.packages in
304+ match first_pkg.source with
305+ | Unpac.Source.GitSource g -> g.url
306+ | _ -> "https://" ^ url_str (* Fallback *)
307+ in
308+309+ Format.printf " Adding %s (%d packages: %s)@."
310+ name (List.length opam_packages)
311+ (String.concat ", " opam_packages);
312+313+ (* Detect default branch *)
314+ let branch = Unpac.Git.ls_remote_default_branch ~proc_mgr ~url in
315+316+ match Unpac.Vendor.add_package ~proc_mgr ~cwd:cwd_path
317+ ~name ~url ~branch ~opam_packages with
318+ | Unpac.Vendor.Success { canonical_name; opam_packages; _ } ->
319+ Format.printf " [OK] Added %s (%d opam packages)@."
320+ canonical_name (List.length opam_packages)
321+ | Unpac.Vendor.Already_vendored name ->
322+ Format.printf " [SKIP] %s already vendored@." name
323+ | Unpac.Vendor.Failed { step; recovery_hint; error } ->
324+ Format.eprintf " [FAIL] Failed at step '%s': %s@." step
325+ (Printexc.to_string error);
326+ Format.eprintf " %s@." recovery_hint;
327+ exit 1
328+ ) grouped;
329+330+ Format.printf "Done.@."
331+ in
332+ let info = Cmd.info "opam" ~doc ~man in
333+ Cmd.v info Term.(const run $ logging_term $ config_file $ cache_dir_term
334+ $ resolve_deps_term $ package_name_arg)
335+336+let add_cmd =
337+ let doc = "Add packages to the project." in
338+ let man = [
339+ `S Manpage.s_description;
340+ `P "Commands for adding packages from various sources.";
341+ ] in
342+ let info = Cmd.info "add" ~doc ~man in
343+ Cmd.group info [add_opam_cmd]
344+345+(* ============================================================================
346+ VENDOR COMMANDS
347+ ============================================================================ *)
348+349+let vendor_package_arg =
350+ let doc = "Package name." in
351+ Arg.(required & pos 0 (some string) None & info [] ~docv:"PACKAGE" ~doc)
352+353+let vendor_status_cmd =
354+ let doc = "Show status of vendored packages." in
355+ let man = [
356+ `S Manpage.s_description;
357+ `P "Shows the status of all vendored packages including their SHAs and patch counts.";
358+ ] in
359+ let run () =
360+ Eio_main.run @@ fun env ->
361+ let cwd = Eio.Stdenv.cwd env in
362+ let proc_mgr = Eio.Stdenv.process_mgr env in
363+ let cwd_path = (cwd :> Eio.Fs.dir_ty Eio.Path.t) in
364+365+ let statuses = Unpac.Vendor.all_status ~proc_mgr ~cwd:cwd_path in
366+367+ if statuses = [] then begin
368+ Format.printf "No vendored packages.@.";
369+ Format.printf "Add packages with: unpac add opam <pkg>@."
370+ end else begin
371+ (* Print header *)
372+ Format.printf "%-20s %-12s %-12s %-8s %-8s@."
373+ "PACKAGE" "UPSTREAM" "VENDOR" "PATCHES" "MERGED";
374+ Format.printf "%-20s %-12s %-12s %-8s %-8s@."
375+ "-------" "--------" "------" "-------" "------";
376+377+ List.iter (fun (s : Unpac.Vendor.package_status) ->
378+ let upstream = match s.upstream_sha with Some x -> x | None -> "-" in
379+ let vendor = match s.vendor_sha with Some x -> x | None -> "-" in
380+ let patches = string_of_int s.patch_count in
381+ let merged = if s.in_project then "yes" else "no" in
382+ Format.printf "%-20s %-12s %-12s %-8s %-8s@."
383+ s.name upstream vendor patches merged
384+ ) statuses
385+ end
386+ in
387+ let info = Cmd.info "status" ~doc ~man in
388+ Cmd.v info Term.(const run $ logging_term)
389+390+let vendor_update_cmd =
391+ let doc = "Update a vendored package from upstream." in
392+ let man = [
393+ `S Manpage.s_description;
394+ `P "Fetches the latest changes from upstream and updates the vendor branch.";
395+ `P "After updating, use 'unpac vendor rebase <pkg>' to rebase your patches.";
396+ ] in
397+ let run () name =
398+ Eio_main.run @@ fun env ->
399+ let cwd = Eio.Stdenv.cwd env in
400+ let proc_mgr = Eio.Stdenv.process_mgr env in
401+ let cwd_path = (cwd :> Eio.Fs.dir_ty Eio.Path.t) in
402+403+ match Unpac.Vendor.update_package ~proc_mgr ~cwd:cwd_path ~name with
404+ | Unpac.Vendor.Updated { old_sha; new_sha; commit_count } ->
405+ let old_short = String.sub old_sha 0 7 in
406+ let new_short = String.sub new_sha 0 7 in
407+ Format.printf "[OK] Updated %s: %s -> %s (%d commits)@."
408+ name old_short new_short commit_count;
409+ Format.printf "Next: unpac vendor rebase %s@." name
410+ | Unpac.Vendor.No_changes ->
411+ Format.printf "[OK] %s is up to date@." name
412+ | Unpac.Vendor.Update_failed { step; error; recovery_hint } ->
413+ Format.eprintf "[FAIL] Failed at step '%s': %s@." step
414+ (Printexc.to_string error);
415+ Format.eprintf "%s@." recovery_hint;
416+ exit 1
417+ in
418+ let info = Cmd.info "update" ~doc ~man in
419+ Cmd.v info Term.(const run $ logging_term $ vendor_package_arg)
420+421+let vendor_rebase_cmd =
422+ let doc = "Rebase patches onto updated vendor branch." in
423+ let man = [
424+ `S Manpage.s_description;
425+ `P "Rebases your patches on top of the updated vendor branch.";
426+ `P "Run this after 'unpac vendor update <pkg>'.";
427+ ] in
428+ let run () name =
429+ Eio_main.run @@ fun env ->
430+ let cwd = Eio.Stdenv.cwd env in
431+ let proc_mgr = Eio.Stdenv.process_mgr env in
432+ let cwd_path = (cwd :> Eio.Fs.dir_ty Eio.Path.t) in
433+434+ match Unpac.Vendor.rebase_patches ~proc_mgr ~cwd:cwd_path ~name with
435+ | Ok () ->
436+ Format.printf "[OK] Rebased %s@." name;
437+ Format.printf "Next: unpac vendor merge %s@." name
438+ | Error (`Conflict _hint) ->
439+ Format.eprintf "[CONFLICT] Rebase has conflicts@.";
440+ Format.eprintf "Resolve conflicts, then: git rebase --continue@.";
441+ Format.eprintf "Or abort: git rebase --abort@.";
442+ exit 1
443+ in
444+ let info = Cmd.info "rebase" ~doc ~man in
445+ Cmd.v info Term.(const run $ logging_term $ vendor_package_arg)
446+447+let vendor_merge_cmd =
448+ let doc = "Merge patches into current project branch." in
449+ let man = [
450+ `S Manpage.s_description;
451+ `P "Merges the patches branch into the current project branch.";
452+ ] in
453+ let run () name =
454+ Eio_main.run @@ fun env ->
455+ let cwd = Eio.Stdenv.cwd env in
456+ let proc_mgr = Eio.Stdenv.process_mgr env in
457+ let cwd_path = (cwd :> Eio.Fs.dir_ty Eio.Path.t) in
458+459+ match Unpac.Vendor.merge_to_project ~proc_mgr ~cwd:cwd_path ~name with
460+ | Ok () ->
461+ Format.printf "[OK] Merged %s into project@." name
462+ | Error (`Conflict _files) ->
463+ Format.eprintf "[CONFLICT] Merge has conflicts@.";
464+ Format.eprintf "Resolve conflicts, then: git add <files> && git commit@.";
465+ Format.eprintf "Or abort: git merge --abort@.";
466+ exit 1
467+ in
468+ let info = Cmd.info "merge" ~doc ~man in
469+ Cmd.v info Term.(const run $ logging_term $ vendor_package_arg)
470+471+let vendor_continue_cmd =
472+ let doc = "Continue an interrupted operation." in
473+ let man = [
474+ `S Manpage.s_description;
475+ `P "Continues an operation that was interrupted (e.g., by a conflict).";
476+ `P "Run this after resolving conflicts.";
477+ ] in
478+ let run () =
479+ Eio_main.run @@ fun env ->
480+ let cwd = Eio.Stdenv.cwd env in
481+ let proc_mgr = Eio.Stdenv.process_mgr env in
482+ let cwd_path = (cwd :> Eio.Fs.dir_ty Eio.Path.t) in
483+484+ match Unpac.Recovery.load ~cwd:cwd_path with
485+ | None ->
486+ Format.printf "No pending operation to continue.@."
487+ | Some state ->
488+ Format.printf "Continuing: %a@." Unpac.Recovery.pp_operation state.operation;
489+ match Unpac.Vendor.continue ~proc_mgr ~cwd:cwd_path state with
490+ | Unpac.Vendor.Success { canonical_name; _ } ->
491+ Format.printf "[OK] Completed %s@." canonical_name
492+ | Unpac.Vendor.Already_vendored name ->
493+ Format.printf "[OK] %s already vendored@." name
494+ | Unpac.Vendor.Failed { step; error; recovery_hint } ->
495+ Format.eprintf "[FAIL] Failed at step '%s': %s@." step
496+ (Printexc.to_string error);
497+ Format.eprintf "%s@." recovery_hint;
498+ exit 1
499+ in
500+ let info = Cmd.info "continue" ~doc ~man in
501+ Cmd.v info Term.(const run $ logging_term)
502+503+let vendor_abort_cmd =
504+ let doc = "Abort an interrupted operation." in
505+ let man = [
506+ `S Manpage.s_description;
507+ `P "Aborts an operation and restores the repository to its previous state.";
508+ ] in
509+ let run () =
510+ Eio_main.run @@ fun env ->
511+ let cwd = Eio.Stdenv.cwd env in
512+ let proc_mgr = Eio.Stdenv.process_mgr env in
513+ let cwd_path = (cwd :> Eio.Fs.dir_ty Eio.Path.t) in
514+515+ match Unpac.Recovery.load ~cwd:cwd_path with
516+ | None ->
517+ Format.printf "No pending operation to abort.@."
518+ | Some state ->
519+ Format.printf "Aborting: %a@." Unpac.Recovery.pp_operation state.operation;
520+ Unpac.Recovery.abort ~proc_mgr ~cwd:cwd_path state;
521+ Format.printf "[OK] Aborted. Repository restored.@."
522+ in
523+ let info = Cmd.info "abort" ~doc ~man in
524+ Cmd.v info Term.(const run $ logging_term)
525+526+let vendor_cmd =
527+ let doc = "Vendor package management." in
528+ let man = [
529+ `S Manpage.s_description;
530+ `P "Commands for managing vendored packages.";
531+ ] in
532+ let info = Cmd.info "vendor" ~doc ~man in
533+ Cmd.group info [
534+ vendor_status_cmd;
535+ vendor_update_cmd;
536+ vendor_rebase_cmd;
537+ vendor_merge_cmd;
538+ vendor_continue_cmd;
539+ vendor_abort_cmd;
540+ ]
541+542+(* ============================================================================
543+ OPAM COMMANDS (existing)
544+ ============================================================================ *)
545+546+let opam_list_cmd =
547+ let doc = "List packages in the merged repository." in
548+ let man =
549+ [
550+ `S Manpage.s_description;
551+ `P "Lists packages from all configured opam repositories.";
552+ `P "If no packages are specified, lists all available packages.";
553+ `P "Use --deps to include transitive dependencies.";
554+ `S Manpage.s_examples;
555+ `P "List all packages:";
556+ `Pre " unpac opam list";
557+ `P "List specific packages with dependencies:";
558+ `Pre " unpac opam list --deps lwt cmdliner";
559+ ]
560+ in
561+ let run () config_path cache_dir format resolve_deps package_specs =
562+ Eio_main.run @@ fun env ->
563+ let fs = Eio.Stdenv.fs env in
564+ let index = load_index ~fs ~cache_dir config_path in
565+ let compiler = get_compiler_spec config_path in
566+ let selection_result =
567+ if package_specs = [] then Ok (Unpac.Solver.select_all index)
568+ else if resolve_deps then Unpac.Solver.select_with_deps ?compiler index package_specs
569+ else Unpac.Solver.select_packages index package_specs
570+ in
571+ match selection_result with
572+ | Error msg ->
573+ Format.eprintf "Error selecting packages: %s@." msg;
574+ exit 1
575+ | Ok selection ->
576+ let packages =
577+ List.sort
578+ (fun (a : Unpac.Repo_index.package_info) b ->
579+ let cmp = OpamPackage.Name.compare a.name b.name in
580+ if cmp <> 0 then cmp
581+ else OpamPackage.Version.compare a.version b.version)
582+ selection.packages
583+ in
584+ Unpac.Output.output_package_list (get_format format) packages
585+ in
586+ let info = Cmd.info "list" ~doc ~man in
587+ Cmd.v info
588+ Term.(
589+ const run $ logging_term $ config_file $ cache_dir_term $ output_format_term
590+ $ resolve_deps_term $ Unpac.Solver.package_specs_term)
591+592+let opam_info_cmd =
593+ let doc = "Show detailed information about packages." in
594+ let man =
595+ [
596+ `S Manpage.s_description;
597+ `P "Displays detailed information about the specified packages.";
598+ `P "Use --deps to include transitive dependencies.";
599+ `S Manpage.s_examples;
600+ `P "Show info for a package:";
601+ `Pre " unpac opam info lwt";
602+ `P "Show info for packages and their dependencies:";
603+ `Pre " unpac opam info --deps cmdliner";
604+ ]
605+ in
606+ let run () config_path cache_dir format resolve_deps package_specs =
607+ Eio_main.run @@ fun env ->
608+ let fs = Eio.Stdenv.fs env in
609+ let index = load_index ~fs ~cache_dir config_path in
610+ let compiler = get_compiler_spec config_path in
611+ if package_specs = [] then begin
612+ Format.eprintf "Please specify at least one package.@.";
613+ exit 1
614+ end;
615+ let selection_result =
616+ if resolve_deps then Unpac.Solver.select_with_deps ?compiler index package_specs
617+ else Unpac.Solver.select_packages index package_specs
618+ in
619+ match selection_result with
620+ | Error msg ->
621+ Format.eprintf "Error selecting packages: %s@." msg;
622+ exit 1
623+ | Ok selection ->
624+ if selection.packages = [] then
625+ Format.eprintf "No packages found.@."
626+ else Unpac.Output.output_package_info (get_format format) selection.packages
627+ in
628+ let info = Cmd.info "info" ~doc ~man in
629+ Cmd.v info
630+ Term.(
631+ const run $ logging_term $ config_file $ cache_dir_term $ output_format_term
632+ $ resolve_deps_term $ Unpac.Solver.package_specs_term)
633+634+let opam_related_cmd =
635+ let doc = "Show packages sharing the same dev-repo." in
636+ let man =
637+ [
638+ `S Manpage.s_description;
639+ `P
640+ "Lists all packages that share a development repository with the \
641+ specified packages.";
642+ `P "Use --deps to first resolve dependencies, then find related packages.";
643+ `S Manpage.s_examples;
644+ `P "Find related packages for a single package:";
645+ `Pre " unpac opam related lwt";
646+ `P "Find related packages including dependencies:";
647+ `Pre " unpac opam related --deps cmdliner";
648+ ]
649+ in
650+ let run () config_path cache_dir format resolve_deps package_specs =
651+ Eio_main.run @@ fun env ->
652+ let fs = Eio.Stdenv.fs env in
653+ let index = load_index ~fs ~cache_dir config_path in
654+ let compiler = get_compiler_spec config_path in
655+ if package_specs = [] then begin
656+ Format.eprintf "Please specify at least one package.@.";
657+ exit 1
658+ end;
659+ (* First, get the packages (with optional deps) *)
660+ let selection_result =
661+ if resolve_deps then Unpac.Solver.select_with_deps ?compiler index package_specs
662+ else Unpac.Solver.select_packages index package_specs
663+ in
664+ match selection_result with
665+ | Error msg ->
666+ Format.eprintf "Error selecting packages: %s@." msg;
667+ exit 1
668+ | Ok selection ->
669+ (* Find related packages for all selected packages *)
670+ let all_related = List.concat_map (fun (info : Unpac.Repo_index.package_info) ->
671+ Unpac.Repo_index.related_packages info.name index)
672+ selection.packages
673+ in
674+ (* Deduplicate *)
675+ let seen = Hashtbl.create 64 in
676+ let unique = List.filter (fun (info : Unpac.Repo_index.package_info) ->
677+ let key = OpamPackage.Name.to_string info.name in
678+ if Hashtbl.mem seen key then false
679+ else begin Hashtbl.add seen key (); true end)
680+ all_related
681+ in
682+ let first_pkg = List.hd package_specs in
683+ let pkg_name = OpamPackage.Name.to_string first_pkg.Unpac.Solver.name in
684+ if unique = [] then
685+ Format.eprintf "No related packages found.@."
686+ else Unpac.Output.output_related (get_format format) pkg_name unique
687+ in
688+ let info = Cmd.info "related" ~doc ~man in
689+ Cmd.v info
690+ Term.(
691+ const run $ logging_term $ config_file $ cache_dir_term $ output_format_term
692+ $ resolve_deps_term $ Unpac.Solver.package_specs_term)
693+694+let opam_sources_cmd =
695+ let doc = "Get source URLs for packages, grouped by dev-repo." in
696+ let man =
697+ [
698+ `S Manpage.s_description;
699+ `P
700+ "Outputs source URLs (archive or git) for the specified packages, \
701+ grouped by their development repository (dev-repo). Packages that \
702+ share the same dev-repo are listed together since they typically \
703+ need to be fetched from the same source.";
704+ `P
705+ "If no packages are specified, outputs sources for all packages \
706+ (latest version of each).";
707+ `P
708+ "Use --git to get development repository URLs instead of archive URLs.";
709+ `P
710+ "Use --deps to include transitive dependencies using the 0install solver.";
711+ `S Manpage.s_examples;
712+ `P "Get archive URLs for all packages:";
713+ `Pre " unpac opam sources";
714+ `P "Get git URLs for specific packages:";
715+ `Pre " unpac opam sources --git lwt dune";
716+ `P "Get sources with version constraints:";
717+ `Pre " unpac opam sources cmdliner>=1.0 lwt.5.6.0";
718+ `P "Get sources with dependencies resolved:";
719+ `Pre " unpac opam sources --deps lwt";
720+ ]
721+ in
722+ let run () config_path cache_dir format source_kind resolve_deps package_specs =
723+ Eio_main.run @@ fun env ->
724+ let fs = Eio.Stdenv.fs env in
725+ let index = load_index ~fs ~cache_dir config_path in
726+ let compiler = get_compiler_spec config_path in
727+ (* Select packages based on specs *)
728+ let selection_result =
729+ if package_specs = [] then Ok (Unpac.Solver.select_all index)
730+ else if resolve_deps then Unpac.Solver.select_with_deps ?compiler index package_specs
731+ else Unpac.Solver.select_packages index package_specs
732+ in
733+ match selection_result with
734+ | Error msg ->
735+ Format.eprintf "Error selecting packages: %s@." msg;
736+ exit 1
737+ | Ok selection ->
738+ let sources =
739+ Unpac.Source.extract_all source_kind selection.packages
740+ in
741+ (* Filter out packages with no source *)
742+ let sources =
743+ List.filter
744+ (fun (s : Unpac.Source.package_source) ->
745+ s.source <> Unpac.Source.NoSource)
746+ sources
747+ in
748+ Unpac.Output.output_sources (get_format format) sources
749+ in
750+ let info = Cmd.info "sources" ~doc ~man in
751+ Cmd.v info
752+ Term.(
753+ const run $ logging_term $ config_file $ cache_dir_term $ output_format_term $ source_kind_term
754+ $ resolve_deps_term $ Unpac.Solver.package_specs_term)
755+756+(* Opam subcommand group *)
757+758+let opam_cmd =
759+ let doc = "Opam repository operations." in
760+ let man =
761+ [
762+ `S Manpage.s_description;
763+ `P
764+ "Commands for querying and managing opam repositories defined in the \
765+ configuration file.";
766+ ]
767+ in
768+ let info = Cmd.info "opam" ~doc ~man in
769+ Cmd.group info [ opam_list_cmd; opam_info_cmd; opam_related_cmd; opam_sources_cmd ]
770+771+(* ============================================================================
772+ MAIN COMMAND
773+ ============================================================================ *)
774+775+let main_cmd =
776+ let doc = "Monorepo management tool." in
777+ let man =
778+ [
779+ `S Manpage.s_description;
780+ `P "unpac is a tool for managing OCaml monorepos with vendored packages.";
781+ `P "It uses a project-based branch model:";
782+ `P " - main branch holds the project registry";
783+ `P " - project/<name> branches hold actual code and vendor packages";
784+ `S "QUICK START";
785+ `P "Initialize a new repository:";
786+ `Pre " unpac init";
787+ `P "Create a project:";
788+ `Pre " unpac project create myapp";
789+ `P "Add packages:";
790+ `Pre " unpac add opam eio";
791+ `Pre " unpac add opam lwt --with-deps";
792+ `P "Check status:";
793+ `Pre " unpac vendor status";
794+ `S Manpage.s_bugs;
795+ `P "Report bugs at https://github.com/avsm/unpac/issues";
796+ ]
797+ in
798+ let info = Cmd.info "unpac" ~version:"0.1.0" ~doc ~man in
799+ Cmd.group info [ init_cmd; project_cmd; add_cmd; vendor_cmd; opam_cmd ]
800+801+let () = exit (Cmd.eval main_cmd)
···1+type cache_header = {
2+ config_path : string;
3+ config_mtime : float;
4+}
5+6+type cache_key = {
7+ repos : (string * string) list; (* (name, path) pairs *)
8+ repo_mtimes : float list; (* mtime of each repo's packages directory *)
9+}
10+11+let cache_filename = "repo_index.cache"
12+13+let get_file_mtime path =
14+ try
15+ let stat = Unix.stat path in
16+ stat.Unix.st_mtime
17+ with Unix.Unix_error _ -> 0.0
18+19+let get_repo_mtime path =
20+ let packages_dir = Filename.concat path "packages" in
21+ get_file_mtime packages_dir
22+23+let make_cache_key (repos : Config.repo_config list) =
24+ let repo_list =
25+ List.filter_map
26+ (fun (r : Config.repo_config) ->
27+ match r.source with
28+ | Config.Local path -> Some (r.name, path)
29+ | Config.Remote _ -> None)
30+ repos
31+ in
32+ let repo_mtimes =
33+ List.map (fun (_, path) -> get_repo_mtime path) repo_list
34+ in
35+ { repos = repo_list; repo_mtimes }
36+37+let cache_path cache_dir =
38+ Eio.Path.(cache_dir / cache_filename)
39+40+(* Read just the header to check if config has changed *)
41+let read_cache_header cache_dir =
42+ let path = cache_path cache_dir in
43+ try
44+ let path_str = Eio.Path.native_exn path in
45+ let ic = open_in_bin path_str in
46+ Fun.protect
47+ ~finally:(fun () -> close_in ic)
48+ (fun () ->
49+ let header : cache_header = Marshal.from_channel ic in
50+ Some header)
51+ with
52+ | Sys_error _ -> None
53+ | End_of_file -> None
54+ | Failure _ -> None
55+56+(* Load full cache if header and key match *)
57+let load_cached cache_dir expected_header expected_key =
58+ let path = cache_path cache_dir in
59+ try
60+ let path_str = Eio.Path.native_exn path in
61+ let ic = open_in_bin path_str in
62+ Fun.protect
63+ ~finally:(fun () -> close_in ic)
64+ (fun () ->
65+ let header : cache_header = Marshal.from_channel ic in
66+ if header <> expected_header then None
67+ else
68+ let key : cache_key = Marshal.from_channel ic in
69+ if key <> expected_key then None
70+ else
71+ let index : Repo_index.t = Marshal.from_channel ic in
72+ Some index)
73+ with
74+ | Sys_error _ -> None
75+ | End_of_file -> None
76+ | Failure _ -> None
77+78+let save_cache cache_dir header key (index : Repo_index.t) =
79+ let path = cache_path cache_dir in
80+ try
81+ let path_str = Eio.Path.native_exn path in
82+ let oc = open_out_bin path_str in
83+ Fun.protect
84+ ~finally:(fun () -> close_out oc)
85+ (fun () ->
86+ Marshal.to_channel oc header [];
87+ Marshal.to_channel oc key [];
88+ Marshal.to_channel oc index [])
89+ with
90+ | Sys_error msg ->
91+ Format.eprintf "Warning: Could not save cache: %s@." msg
92+ | Failure msg ->
93+ Format.eprintf "Warning: Could not serialize cache: %s@." msg
94+95+let rec load_index ~cache_dir ~config_path =
96+ let config_mtime = get_file_mtime config_path in
97+ let header = { config_path; config_mtime } in
98+99+ (* Quick check: has config file changed? *)
100+ let cached_header = read_cache_header cache_dir in
101+ let config_unchanged =
102+ match cached_header with
103+ | Some h -> h = header
104+ | None -> false
105+ in
106+107+ (* Load config *)
108+ let config = Config.load_exn config_path in
109+ let key = make_cache_key config.opam.repositories in
110+111+ (* If config unchanged, try to load from cache *)
112+ if config_unchanged then
113+ match load_cached cache_dir header key with
114+ | Some index -> index
115+ | None ->
116+ (* Cache invalid, rebuild *)
117+ let index = build_index config in
118+ save_cache cache_dir header key index;
119+ index
120+ else begin
121+ (* Config changed, rebuild *)
122+ let index = build_index config in
123+ save_cache cache_dir header key index;
124+ index
125+ end
126+127+and build_index (config : Config.t) =
128+ List.fold_left
129+ (fun acc (repo : Config.repo_config) ->
130+ match repo.source with
131+ | Config.Local path ->
132+ Repo_index.load_local_repo ~name:repo.name ~path acc
133+ | Config.Remote _url ->
134+ Format.eprintf
135+ "Warning: Remote repositories not yet supported: %s@."
136+ repo.name;
137+ acc)
138+ Repo_index.empty config.opam.repositories
+23
lib/cache.mli
···00000000000000000000000
···1+(** Cache for repository index.
2+3+ This module provides caching for the repository index using Marshal
4+ serialization. The cache is stored in the XDG cache directory and
5+ is invalidated when:
6+ - The config file path or mtime changes
7+ - Repository paths change
8+ - Repository package directories' mtimes change *)
9+10+val load_index :
11+ cache_dir:Eio.Fs.dir_ty Eio.Path.t ->
12+ config_path:string ->
13+ Repo_index.t
14+(** [load_index ~cache_dir ~config_path] loads the repository index,
15+ using a cached version if available and valid.
16+17+ The cache stores the config file path and mtime, along with repository
18+ paths and their package directory mtimes. If any of these change, the
19+ cache is invalidated and rebuilt.
20+21+ @param cache_dir The XDG cache directory path
22+ @param config_path Path to the unpac.toml config file
23+ @return The repository index *)
···1+type repo_source =
2+ | Local of string
3+ | Remote of string
4+5+type repo_config = {
6+ name : string;
7+ source : repo_source;
8+}
9+10+type opam_config = {
11+ repositories : repo_config list;
12+ compiler : string option; (* e.g., "ocaml.5.4.0" or "5.4.0" *)
13+}
14+15+type t = { opam : opam_config }
16+17+(* TOML Codecs *)
18+19+let repo_config_codec =
20+ let open Tomlt in
21+ let open Table in
22+ let make name path url =
23+ let source =
24+ match (path, url) with
25+ | Some p, None -> Local p
26+ | None, Some u -> Remote u
27+ | Some _, Some _ ->
28+ failwith "Repository cannot have both 'path' and 'url'"
29+ | None, None -> failwith "Repository must have either 'path' or 'url'"
30+ in
31+ { name; source }
32+ in
33+ let enc_path r =
34+ match r.source with Local p -> Some p | Remote _ -> None
35+ in
36+ let enc_url r =
37+ match r.source with Remote u -> Some u | Local _ -> None
38+ in
39+ obj make
40+ |> mem "name" string ~enc:(fun r -> r.name)
41+ |> opt_mem "path" string ~enc:enc_path
42+ |> opt_mem "url" string ~enc:enc_url
43+ |> finish
44+45+let opam_config_codec =
46+ let open Tomlt in
47+ let open Table in
48+ obj (fun repositories compiler -> { repositories; compiler })
49+ |> mem "repositories" (list repo_config_codec)
50+ ~enc:(fun c -> c.repositories)
51+ |> opt_mem "compiler" string ~enc:(fun c -> c.compiler)
52+ |> finish
53+54+let codec =
55+ let open Tomlt in
56+ let open Table in
57+ obj (fun opam -> { opam })
58+ |> mem "opam" opam_config_codec ~enc:(fun c -> c.opam)
59+ |> finish
60+61+let load path =
62+ try
63+ let content = In_channel.with_open_text path In_channel.input_all in
64+ Tomlt_bytesrw.decode_string codec content
65+ |> Result.map_error Tomlt.Toml.Error.to_string
66+ with
67+ | Sys_error msg -> Error msg
68+ | Failure msg -> Error msg
69+70+let load_exn path =
71+ match load path with Ok c -> c | Error msg -> failwith msg
+38
lib/config.mli
···00000000000000000000000000000000000000
···1+(** Configuration file handling for unpac.
2+3+ Loads and parses unpac.toml configuration files using tomlt. *)
4+5+(** {1 Types} *)
6+7+type repo_source =
8+ | Local of string (** Local filesystem path *)
9+ | Remote of string (** Remote URL (git+https://..., etc.) *)
10+(** Source location for an opam repository. *)
11+12+type repo_config = {
13+ name : string;
14+ source : repo_source;
15+}
16+(** Configuration for a single opam repository. *)
17+18+type opam_config = {
19+ repositories : repo_config list;
20+ compiler : string option; (** Target compiler version, e.g. "5.4.0" or "ocaml.5.4.0" *)
21+}
22+(** Opam-specific configuration. *)
23+24+type t = { opam : opam_config }
25+(** The complete unpac configuration. *)
26+27+(** {1 Loading} *)
28+29+val load : string -> (t, string) result
30+(** [load path] loads configuration from the TOML file at [path]. *)
31+32+val load_exn : string -> t
33+(** [load_exn path] is like {!load} but raises on error. *)
34+35+(** {1 Codecs} *)
36+37+val codec : t Tomlt.t
38+(** TOML codec for the configuration type. *)
···1+type t = string
2+3+let normalize_url s =
4+ let s = String.lowercase_ascii s in
5+ (* Remove git+ prefix *)
6+ let s =
7+ if String.starts_with ~prefix:"git+" s then
8+ String.sub s 4 (String.length s - 4)
9+ else s
10+ in
11+ (* Remove .git suffix *)
12+ let s =
13+ if String.ends_with ~suffix:".git" s then
14+ String.sub s 0 (String.length s - 4)
15+ else s
16+ in
17+ (* Remove trailing slash *)
18+ let s =
19+ if String.ends_with ~suffix:"/" s then
20+ String.sub s 0 (String.length s - 1)
21+ else s
22+ in
23+ (* Strip #branch fragment *)
24+ let s =
25+ match String.index_opt s '#' with
26+ | Some i -> String.sub s 0 i
27+ | None -> s
28+ in
29+ (* Normalize ssh-style github.com:user/repo to github.com/user/repo *)
30+ let s =
31+ match String.index_opt s ':' with
32+ | Some i when i > 0 ->
33+ let before = String.sub s 0 i in
34+ let after = String.sub s (i + 1) (String.length s - i - 1) in
35+ (* Only convert if it looks like host:path (no // after) *)
36+ if
37+ (not (String.contains before '/'))
38+ && not (String.starts_with ~prefix:"/" after)
39+ && String.contains before '.'
40+ then before ^ "/" ^ after
41+ else s
42+ | _ -> s
43+ in
44+ (* Remove protocol prefix for comparison *)
45+ let s =
46+ let protocols = [ "https://"; "http://"; "ssh://"; "git://"; "file://" ] in
47+ List.fold_left
48+ (fun s proto ->
49+ if String.starts_with ~prefix:proto s then
50+ String.sub s (String.length proto) (String.length s - String.length proto)
51+ else s)
52+ s protocols
53+ in
54+ s
55+56+let of_opam_url url = normalize_url (OpamUrl.to_string url)
57+58+let of_string s = normalize_url s
59+60+let equal = String.equal
61+let compare = String.compare
62+let to_string t = t
63+64+let pp fmt t = Format.pp_print_string fmt t
65+66+module Map = Map.Make (String)
67+module Set = Set.Make (String)
···1+(** Normalized dev-repo URLs.
2+3+ This module provides URL normalization for dev-repo fields to enable
4+ matching packages that share the same source repository even when
5+ the URLs are written differently.
6+7+ Normalization rules:
8+ - Strip [.git] suffix
9+ - Normalize to lowercase
10+ - Remove [git+] prefix from transport
11+ - Normalize [github.com:user/repo] to [github.com/user/repo]
12+ - Remove trailing slashes
13+ - Strip [#branch] fragment *)
14+15+(** {1 Types} *)
16+17+type t
18+(** Normalized dev-repo URL. *)
19+20+(** {1 Creation} *)
21+22+val of_opam_url : OpamUrl.t -> t
23+(** [of_opam_url url] creates a normalized dev-repo from an opam URL. *)
24+25+val of_string : string -> t
26+(** [of_string s] parses and normalizes a URL string. *)
27+28+(** {1 Comparison} *)
29+30+val equal : t -> t -> bool
31+(** [equal a b] is [true] if [a] and [b] represent the same repository. *)
32+33+val compare : t -> t -> int
34+(** [compare a b] is a total ordering on normalized URLs. *)
35+36+(** {1 Conversion} *)
37+38+val to_string : t -> string
39+(** [to_string t] returns the normalized URL string. *)
40+41+val pp : Format.formatter -> t -> unit
42+(** [pp fmt t] pretty-prints the normalized URL. *)
43+44+(** {1 Collections} *)
45+46+module Map : Map.S with type key = t
47+module Set : Set.S with type elt = t
···1+type package_info = {
2+ name : OpamPackage.Name.t;
3+ version : OpamPackage.Version.t;
4+ opam : OpamFile.OPAM.t;
5+ dev_repo : Dev_repo.t option;
6+ source_repo : string;
7+}
8+9+type t = {
10+ packages : package_info OpamPackage.Map.t;
11+ by_name : OpamPackage.Set.t OpamPackage.Name.Map.t;
12+ by_dev_repo : OpamPackage.Set.t Dev_repo.Map.t;
13+ repos : string list;
14+}
15+16+let empty =
17+ {
18+ packages = OpamPackage.Map.empty;
19+ by_name = OpamPackage.Name.Map.empty;
20+ by_dev_repo = Dev_repo.Map.empty;
21+ repos = [];
22+ }
23+24+let add_package nv info t =
25+ let packages = OpamPackage.Map.add nv info t.packages in
26+ let by_name =
27+ let name = OpamPackage.name nv in
28+ let existing =
29+ match OpamPackage.Name.Map.find_opt name t.by_name with
30+ | Some s -> s
31+ | None -> OpamPackage.Set.empty
32+ in
33+ OpamPackage.Name.Map.add name (OpamPackage.Set.add nv existing) t.by_name
34+ in
35+ let by_dev_repo =
36+ match info.dev_repo with
37+ | Some dev_repo ->
38+ let existing =
39+ match Dev_repo.Map.find_opt dev_repo t.by_dev_repo with
40+ | Some s -> s
41+ | None -> OpamPackage.Set.empty
42+ in
43+ Dev_repo.Map.add dev_repo (OpamPackage.Set.add nv existing) t.by_dev_repo
44+ | None -> t.by_dev_repo
45+ in
46+ { t with packages; by_name; by_dev_repo }
47+48+let load_local_repo ~name ~path t =
49+ let repo_root = OpamFilename.Dir.of_string path in
50+ let pkg_prefixes = OpamRepository.packages_with_prefixes repo_root in
51+ let t =
52+ if List.mem name t.repos then t else { t with repos = name :: t.repos }
53+ in
54+ OpamPackage.Map.fold
55+ (fun nv prefix acc ->
56+ let opam_file = OpamRepositoryPath.opam repo_root prefix nv in
57+ match OpamFile.OPAM.read_opt opam_file with
58+ | Some opam ->
59+ let dev_repo =
60+ OpamFile.OPAM.dev_repo opam |> Option.map Dev_repo.of_opam_url
61+ in
62+ let info =
63+ {
64+ name = OpamPackage.name nv;
65+ version = OpamPackage.version nv;
66+ opam;
67+ dev_repo;
68+ source_repo = name;
69+ }
70+ in
71+ add_package nv info acc
72+ | None -> acc)
73+ pkg_prefixes t
74+75+let all_packages t =
76+ OpamPackage.Map.fold (fun _ info acc -> info :: acc) t.packages []
77+78+let find_package name t =
79+ match OpamPackage.Name.Map.find_opt name t.by_name with
80+ | None -> []
81+ | Some nvs ->
82+ OpamPackage.Set.fold
83+ (fun nv acc ->
84+ match OpamPackage.Map.find_opt nv t.packages with
85+ | Some info -> info :: acc
86+ | None -> acc)
87+ nvs []
88+89+let find_package_version name version t =
90+ let nv = OpamPackage.create name version in
91+ OpamPackage.Map.find_opt nv t.packages
92+93+let packages_by_dev_repo dev_repo t =
94+ match Dev_repo.Map.find_opt dev_repo t.by_dev_repo with
95+ | None -> []
96+ | Some nvs ->
97+ OpamPackage.Set.fold
98+ (fun nv acc ->
99+ match OpamPackage.Map.find_opt nv t.packages with
100+ | Some info -> info :: acc
101+ | None -> acc)
102+ nvs []
103+104+let related_packages name t =
105+ let versions = find_package name t in
106+ let dev_repos =
107+ List.filter_map (fun info -> info.dev_repo) versions
108+ |> List.sort_uniq Dev_repo.compare
109+ in
110+ List.concat_map (fun dr -> packages_by_dev_repo dr t) dev_repos
111+ |> List.sort_uniq (fun a b ->
112+ let cmp = OpamPackage.Name.compare a.name b.name in
113+ if cmp <> 0 then cmp
114+ else OpamPackage.Version.compare a.version b.version)
115+116+let package_names t =
117+ OpamPackage.Name.Map.fold (fun name _ acc -> name :: acc) t.by_name []
118+119+let package_count t = OpamPackage.Map.cardinal t.packages
120+let repo_count t = List.length t.repos
···1+(** Repository index for opam packages.
2+3+ This module provides functionality to load and query packages from
4+ multiple opam repositories, with support for merging with configurable
5+ priority. *)
6+7+(** {1 Types} *)
8+9+type package_info = {
10+ name : OpamPackage.Name.t;
11+ version : OpamPackage.Version.t;
12+ opam : OpamFile.OPAM.t;
13+ dev_repo : Dev_repo.t option;
14+ source_repo : string; (** Name of the repository this package came from *)
15+}
16+(** Information about a single package version. *)
17+18+type t
19+(** The repository index containing all loaded packages. *)
20+21+(** {1 Creation} *)
22+23+val empty : t
24+(** [empty] is an empty repository index. *)
25+26+val load_local_repo : name:string -> path:string -> t -> t
27+(** [load_local_repo ~name ~path index] loads all packages from the local
28+ opam repository at [path] and adds them to [index]. Packages from this
29+ load will take priority over existing packages with the same name/version. *)
30+31+(** {1 Queries} *)
32+33+val all_packages : t -> package_info list
34+(** [all_packages t] returns all packages in the index. *)
35+36+val find_package : OpamPackage.Name.t -> t -> package_info list
37+(** [find_package name t] returns all versions of package [name]. *)
38+39+val find_package_version :
40+ OpamPackage.Name.t -> OpamPackage.Version.t -> t -> package_info option
41+(** [find_package_version name version t] returns the specific package version. *)
42+43+val packages_by_dev_repo : Dev_repo.t -> t -> package_info list
44+(** [packages_by_dev_repo dev_repo t] returns all packages with the given dev-repo. *)
45+46+val related_packages : OpamPackage.Name.t -> t -> package_info list
47+(** [related_packages name t] returns all packages that share a dev-repo with
48+ any version of package [name]. *)
49+50+val package_names : t -> OpamPackage.Name.t list
51+(** [package_names t] returns all unique package names. *)
52+53+(** {1 Statistics} *)
54+55+val package_count : t -> int
56+(** [package_count t] returns the total number of package versions. *)
57+58+val repo_count : t -> int
59+(** [repo_count t] returns the number of source repositories loaded. *)
···1+open Cmdliner
2+3+type version_constraint = OpamFormula.relop * OpamPackage.Version.t
4+5+type package_spec = {
6+ name : OpamPackage.Name.t;
7+ constraint_ : version_constraint option;
8+}
9+10+(* Target platform configuration *)
11+type platform = {
12+ os : string;
13+ os_family : string;
14+ os_distribution : string;
15+ arch : string;
16+}
17+18+let debian_x86_64 = {
19+ os = "linux";
20+ os_family = "debian";
21+ os_distribution = "debian";
22+ arch = "x86_64";
23+}
24+25+(* Create a filter environment for the target platform *)
26+let make_filter_env platform : OpamFilter.env =
27+ fun var ->
28+ let open OpamVariable in
29+ let s = to_string (Full.variable var) in
30+ match s with
31+ | "os" -> Some (S platform.os)
32+ | "os-family" -> Some (S platform.os_family)
33+ | "os-distribution" -> Some (S platform.os_distribution)
34+ | "arch" -> Some (S platform.arch)
35+ | "opam-version" -> Some (S "2.1.0")
36+ | "make" -> Some (S "make")
37+ | "jobs" -> Some (S "4")
38+ | "pinned" -> Some (B false)
39+ | "build" -> Some (B true)
40+ | "post" -> Some (B false)
41+ | "dev" -> Some (B false)
42+ | "with-test" -> Some (B false)
43+ | "with-doc" -> Some (B false)
44+ | "with-dev-setup" -> Some (B false)
45+ | _ -> None
46+47+(* Check if a package is available on the target platform *)
48+let is_available_on_platform env (opam : OpamFile.OPAM.t) : bool =
49+ let available = OpamFile.OPAM.available opam in
50+ OpamFilter.opt_eval_to_bool env (Some available)
51+52+(* Check if a package has the compiler flag or is a compiler-related package *)
53+let is_compiler_package (opam : OpamFile.OPAM.t) (name : OpamPackage.Name.t) : bool =
54+ let name_s = OpamPackage.Name.to_string name in
55+ (* Check for flags:compiler *)
56+ let has_compiler_flag =
57+ List.mem OpamTypes.Pkgflag_Compiler (OpamFile.OPAM.flags opam)
58+ in
59+ (* Also filter out known compiler-related packages by name pattern *)
60+ let is_compiler_name =
61+ name_s = "ocaml" ||
62+ String.starts_with ~prefix:"ocaml-base-compiler" name_s ||
63+ String.starts_with ~prefix:"ocaml-variants" name_s ||
64+ String.starts_with ~prefix:"ocaml-system" name_s ||
65+ String.starts_with ~prefix:"ocaml-option-" name_s ||
66+ String.starts_with ~prefix:"ocaml-config" name_s ||
67+ String.starts_with ~prefix:"ocaml-compiler" name_s ||
68+ String.starts_with ~prefix:"base-" name_s || (* base-threads, base-unix, etc. *)
69+ String.starts_with ~prefix:"dkml-base-compiler" name_s ||
70+ String.starts_with ~prefix:"dkml-runtime" name_s
71+ in
72+ has_compiler_flag || is_compiler_name
73+74+(* Filter dependencies to remove platform-filtered ones.
75+ Uses OpamFilter.filter_formula which evaluates filters and simplifies. *)
76+let filter_depends env (formula : OpamTypes.filtered_formula) : OpamTypes.formula =
77+ OpamFilter.filter_formula ~default:false env formula
78+79+(* Parse version constraint from string like ">=1.0.0" *)
80+let parse_constraint s =
81+ let s = String.trim s in
82+ if String.length s = 0 then None
83+ else
84+ let try_parse prefix relop =
85+ if String.starts_with ~prefix s then
86+ let v = String.sub s (String.length prefix) (String.length s - String.length prefix) in
87+ Some (relop, OpamPackage.Version.of_string v)
88+ else None
89+ in
90+ match try_parse ">=" `Geq with
91+ | Some c -> Some c
92+ | None -> (
93+ match try_parse "<=" `Leq with
94+ | Some c -> Some c
95+ | None -> (
96+ match try_parse ">" `Gt with
97+ | Some c -> Some c
98+ | None -> (
99+ match try_parse "<" `Lt with
100+ | Some c -> Some c
101+ | None -> (
102+ match try_parse "!=" `Neq with
103+ | Some c -> Some c
104+ | None -> (
105+ match try_parse "=" `Eq with
106+ | Some c -> Some c
107+ | None ->
108+ (* Treat bare version as exact match *)
109+ Some (`Eq, OpamPackage.Version.of_string s))))))
110+111+let parse_package_spec s =
112+ try
113+ let s = String.trim s in
114+ (* Check for constraint operators *)
115+ let has_constraint =
116+ String.contains s '>' || String.contains s '<'
117+ || String.contains s '=' || String.contains s '!'
118+ in
119+ if has_constraint then
120+ (* Find where constraint starts *)
121+ let constraint_start =
122+ let find_op c = try Some (String.index s c) with Not_found -> None in
123+ [ find_op '>'; find_op '<'; find_op '='; find_op '!' ]
124+ |> List.filter_map Fun.id
125+ |> List.fold_left min (String.length s)
126+ in
127+ let name_part = String.sub s 0 constraint_start in
128+ let constraint_part =
129+ String.sub s constraint_start (String.length s - constraint_start)
130+ in
131+ let name = OpamPackage.Name.of_string name_part in
132+ let constraint_ = parse_constraint constraint_part in
133+ Ok { name; constraint_ }
134+ else
135+ (* Check for pkg.version format *)
136+ match String.rindex_opt s '.' with
137+ | Some i when i > 0 ->
138+ let name_part = String.sub s 0 i in
139+ let version_part = String.sub s (i + 1) (String.length s - i - 1) in
140+ (* Validate that version_part looks like a version *)
141+ if String.length version_part > 0
142+ && (version_part.[0] >= '0' && version_part.[0] <= '9' || version_part.[0] = 'v')
143+ then
144+ let name = OpamPackage.Name.of_string name_part in
145+ let version = OpamPackage.Version.of_string version_part in
146+ Ok { name; constraint_ = Some (`Eq, version) }
147+ else
148+ (* Treat as package name without constraint *)
149+ let name = OpamPackage.Name.of_string s in
150+ Ok { name; constraint_ = None }
151+ | _ ->
152+ let name = OpamPackage.Name.of_string s in
153+ Ok { name; constraint_ = None }
154+ with e -> Error (Printexc.to_string e)
155+156+let package_spec_to_string spec =
157+ let name = OpamPackage.Name.to_string spec.name in
158+ match spec.constraint_ with
159+ | None -> name
160+ | Some (op, v) ->
161+ let op_s =
162+ match op with
163+ | `Eq -> "="
164+ | `Neq -> "!="
165+ | `Geq -> ">="
166+ | `Gt -> ">"
167+ | `Leq -> "<="
168+ | `Lt -> "<"
169+ in
170+ name ^ op_s ^ OpamPackage.Version.to_string v
171+172+(* Parse compiler spec string like "ocaml.5.4.0" or "5.4.0" *)
173+let parse_compiler_spec (s : string) : package_spec option =
174+ let s = String.trim s in
175+ if s = "" then None
176+ else
177+ (* Handle formats: "ocaml.5.4.0", "5.4.0", "ocaml>=5.0" *)
178+ let spec_str =
179+ if String.starts_with ~prefix:"ocaml" s then s
180+ else if s.[0] >= '0' && s.[0] <= '9' then "ocaml." ^ s
181+ else s
182+ in
183+ match parse_package_spec spec_str with
184+ | Ok spec -> Some spec
185+ | Error _ -> None
186+187+(* Selection results *)
188+type selection_result = { packages : Repo_index.package_info list }
189+190+(* Get latest version of each package that is available on the platform *)
191+let latest_versions ?(platform=debian_x86_64) (index : Repo_index.t) : Repo_index.package_info list =
192+ let env = make_filter_env platform in
193+ let names = Repo_index.package_names index in
194+ List.filter_map
195+ (fun name ->
196+ let versions = Repo_index.find_package name index in
197+ (* Filter by availability and sort by version descending *)
198+ let available_versions =
199+ List.filter (fun (info : Repo_index.package_info) ->
200+ is_available_on_platform env info.opam) versions
201+ in
202+ match
203+ List.sort
204+ (fun (a : Repo_index.package_info) b ->
205+ OpamPackage.Version.compare b.version a.version)
206+ available_versions
207+ with
208+ | latest :: _ -> Some latest
209+ | [] -> None)
210+ names
211+212+let select_all index = { packages = latest_versions index }
213+214+(* Check if a package version satisfies a constraint *)
215+let satisfies_constraint version = function
216+ | None -> true
217+ | Some (op, cv) -> (
218+ let cmp = OpamPackage.Version.compare version cv in
219+ match op with
220+ | `Eq -> cmp = 0
221+ | `Neq -> cmp <> 0
222+ | `Geq -> cmp >= 0
223+ | `Gt -> cmp > 0
224+ | `Leq -> cmp <= 0
225+ | `Lt -> cmp < 0)
226+227+let select_packages ?(platform=debian_x86_64) index specs =
228+ if specs = [] then Ok (select_all index)
229+ else
230+ let env = make_filter_env platform in
231+ let selected =
232+ List.filter_map
233+ (fun spec ->
234+ let versions = Repo_index.find_package spec.name index in
235+ (* Filter by constraint, availability, and get latest matching *)
236+ let matching =
237+ List.filter
238+ (fun (info : Repo_index.package_info) ->
239+ satisfies_constraint info.version spec.constraint_
240+ && is_available_on_platform env info.opam)
241+ versions
242+ in
243+ match
244+ List.sort
245+ (fun (a : Repo_index.package_info) b ->
246+ OpamPackage.Version.compare b.version a.version)
247+ matching
248+ with
249+ | latest :: _ -> Some latest
250+ | [] -> None)
251+ specs
252+ in
253+ Ok { packages = selected }
254+255+(* Build a version map for CUDF conversion *)
256+let build_version_map (packages : Repo_index.package_info list) : int OpamPackage.Map.t =
257+ (* Group by name and sort versions *)
258+ let by_name = Hashtbl.create 256 in
259+ List.iter (fun (info : Repo_index.package_info) ->
260+ let name = info.name in
261+ let versions = try Hashtbl.find by_name name with Not_found -> [] in
262+ Hashtbl.replace by_name name (info.version :: versions))
263+ packages;
264+ (* Assign version numbers *)
265+ let version_map = ref OpamPackage.Map.empty in
266+ Hashtbl.iter (fun name versions ->
267+ let sorted = List.sort OpamPackage.Version.compare versions in
268+ List.iteri (fun i v ->
269+ let nv = OpamPackage.create name v in
270+ version_map := OpamPackage.Map.add nv (i + 1) !version_map)
271+ sorted)
272+ by_name;
273+ !version_map
274+275+(* Convert opam formula to CUDF vpkgformula (list of disjunctions for AND semantics)
276+ Simplified: we ignore version constraints and just require the package exists.
277+ This allows the 0install solver to pick the best version. *)
278+let formula_to_vpkgformula available_names (formula : OpamTypes.formula) : Cudf_types.vpkgformula =
279+ let atoms = OpamFormula.atoms formula in
280+ List.filter_map (fun (name, _version_constraint) ->
281+ let name_s = OpamPackage.Name.to_string name in
282+ (* Only include dependency if package exists in our available set *)
283+ if not (Hashtbl.mem available_names name_s) then None
284+ else Some [(name_s, None)]) (* No version constraint - solver picks best *)
285+ atoms
286+287+(* For conflicts, we ignore them in CUDF since proper conflict handling requires
288+ complex version mapping. The 0install solver will still produce valid results
289+ since we filter packages by platform availability. *)
290+let formula_to_vpkglist (_formula : OpamTypes.formula) : Cudf_types.vpkglist =
291+ [] (* Ignore conflicts for simplicity *)
292+293+(* Build CUDF universe from packages *)
294+let build_cudf_universe ?(platform=debian_x86_64) (packages : Repo_index.package_info list) =
295+ let env = make_filter_env platform in
296+ let version_map = build_version_map packages in
297+298+ (* First, collect all available package names *)
299+ let available_names = Hashtbl.create 256 in
300+ List.iter (fun (info : Repo_index.package_info) ->
301+ Hashtbl.replace available_names (OpamPackage.Name.to_string info.name) ())
302+ packages;
303+304+ let cudf_packages = List.filter_map (fun (info : Repo_index.package_info) ->
305+ let nv = OpamPackage.create info.name info.version in
306+ match OpamPackage.Map.find_opt nv version_map with
307+ | None -> None
308+ | Some cudf_version ->
309+ (* Get and filter dependencies *)
310+ let depends_formula = OpamFile.OPAM.depends info.opam in
311+ let filtered_depends = filter_depends env depends_formula in
312+ let depends = formula_to_vpkgformula available_names filtered_depends in
313+314+ (* Get conflicts - simplified to empty for now *)
315+ let conflicts_formula = OpamFile.OPAM.conflicts info.opam in
316+ let filtered_conflicts = filter_depends env conflicts_formula in
317+ let conflicts = formula_to_vpkglist filtered_conflicts in
318+319+ Some {
320+ Cudf.default_package with
321+ package = OpamPackage.Name.to_string info.name;
322+ version = cudf_version;
323+ depends = depends;
324+ conflicts = conflicts;
325+ installed = false;
326+ pkg_extra = [
327+ (OpamCudf.s_source, `String (OpamPackage.Name.to_string info.name));
328+ (OpamCudf.s_source_number, `String (OpamPackage.Version.to_string info.version));
329+ ];
330+ })
331+ packages
332+ in
333+334+ let universe = Cudf.load_universe cudf_packages in
335+ (universe, version_map)
336+337+(* Resolve dependencies using 0install solver *)
338+let resolve_deps ?(platform=debian_x86_64) ?compiler index (root_specs : package_spec list) =
339+ let env = make_filter_env platform in
340+341+ (* Get all available packages *)
342+ let all_packages =
343+ List.filter (fun (info : Repo_index.package_info) ->
344+ is_available_on_platform env info.opam)
345+ (Repo_index.all_packages index)
346+ in
347+348+ (* Build CUDF universe *)
349+ let universe, version_map = build_cudf_universe ~platform all_packages in
350+351+ (* Build request - add compiler if specified *)
352+ let all_specs = match compiler with
353+ | Some compiler_spec -> compiler_spec :: root_specs
354+ | None -> root_specs
355+ in
356+357+ let requested = List.filter_map (fun spec ->
358+ let name_s = OpamPackage.Name.to_string spec.name in
359+ (* Check if package exists in universe *)
360+ if Cudf.mem_package universe (name_s, 1) ||
361+ List.exists (fun p -> p.Cudf.package = name_s) (Cudf.get_packages universe)
362+ then Some (name_s, `Essential)
363+ else begin
364+ Format.eprintf "Warning: Package %s not found in universe@." name_s;
365+ None
366+ end)
367+ all_specs
368+ in
369+370+ if requested = [] then
371+ Error "No valid packages to resolve"
372+ else
373+ (* Create solver and solve *)
374+ let solver = Opam_0install_cudf.create ~constraints:[] universe in
375+ match Opam_0install_cudf.solve solver requested with
376+ | Error diag ->
377+ Error (Opam_0install_cudf.diagnostics diag)
378+ | Ok selections ->
379+ (* Convert results back to package info *)
380+ let selected_cudf = Opam_0install_cudf.packages_of_result selections in
381+ let selected_packages = List.filter_map (fun (name, cudf_version) ->
382+ (* Find the opam package *)
383+ let opam_name = OpamPackage.Name.of_string name in
384+ let versions = Repo_index.find_package opam_name index in
385+ (* Get the version that matches *)
386+ List.find_opt (fun (info : Repo_index.package_info) ->
387+ let nv = OpamPackage.create info.name info.version in
388+ match OpamPackage.Map.find_opt nv version_map with
389+ | Some v -> v = cudf_version
390+ | None -> false)
391+ versions)
392+ selected_cudf
393+ in
394+ (* Deduplicate by package name+version *)
395+ let seen = Hashtbl.create 64 in
396+ let unique_packages = List.filter (fun (info : Repo_index.package_info) ->
397+ let key = OpamPackage.to_string (OpamPackage.create info.name info.version) in
398+ if Hashtbl.mem seen key then false
399+ else begin Hashtbl.add seen key (); true end)
400+ selected_packages
401+ in
402+ (* Filter out compiler packages from results *)
403+ let non_compiler_packages = List.filter (fun (info : Repo_index.package_info) ->
404+ not (is_compiler_package info.opam info.name))
405+ unique_packages
406+ in
407+ Ok { packages = non_compiler_packages }
408+409+let select_with_deps ?(platform=debian_x86_64) ?compiler index specs =
410+ if specs = [] then Ok (select_all index)
411+ else
412+ resolve_deps ~platform ?compiler index specs
413+414+(* Cmdliner integration *)
415+416+let package_specs_conv : package_spec Arg.conv =
417+ let parse s =
418+ match parse_package_spec s with
419+ | Ok spec -> Ok spec
420+ | Error msg -> Error (`Msg msg)
421+ in
422+ let print fmt spec = Format.pp_print_string fmt (package_spec_to_string spec) in
423+ Arg.conv (parse, print)
424+425+let package_specs_term : package_spec list Term.t =
426+ let doc =
427+ "Package specification. Can be a package name (any version), \
428+ name.version (exact version), or name>=version (constraint). \
429+ Examples: cmdliner, lwt.5.6.0, dune>=3.0"
430+ in
431+ Arg.(value & pos_all package_specs_conv [] & info [] ~docv:"PACKAGE" ~doc)
···1+(** Package selection with constraint solving.
2+3+ Uses the 0install solver (via opam-0install-cudf) to select
4+ a consistent set of packages based on constraints, filtered
5+ for Debian x86_64 platform. *)
6+7+(** {1 Platform Configuration} *)
8+9+type platform = {
10+ os : string;
11+ os_family : string;
12+ os_distribution : string;
13+ arch : string;
14+}
15+(** Target platform for filtering packages. *)
16+17+val debian_x86_64 : platform
18+(** Default platform: Debian Linux on x86_64. *)
19+20+(** {1 Package Specifications} *)
21+22+type version_constraint = OpamFormula.relop * OpamPackage.Version.t
23+(** A version constraint like [>=, 1.0.0]. *)
24+25+type package_spec = {
26+ name : OpamPackage.Name.t;
27+ constraint_ : version_constraint option;
28+}
29+(** A package specification with optional version constraint. *)
30+31+val parse_package_spec : string -> (package_spec, string) result
32+(** [parse_package_spec s] parses a package spec string like:
33+ - "pkg" (any version)
34+ - "pkg.1.0.0" (exact version)
35+ - "pkg>=1.0.0" (version constraint)
36+ - "pkg<2.0" (version constraint) *)
37+38+val package_spec_to_string : package_spec -> string
39+(** [package_spec_to_string spec] converts a spec back to string form. *)
40+41+val parse_compiler_spec : string -> package_spec option
42+(** [parse_compiler_spec s] parses a compiler version string like:
43+ - "5.4.0" (parsed as ocaml.5.4.0)
44+ - "ocaml.5.4.0" (exact version)
45+ - "ocaml>=5.0" (version constraint)
46+ Returns None if the string is empty or invalid. *)
47+48+(** {1 Selection} *)
49+50+type selection_result = {
51+ packages : Repo_index.package_info list;
52+ (** Selected packages that satisfy all constraints. *)
53+}
54+(** Result of package selection. *)
55+56+val select_all : Repo_index.t -> selection_result
57+(** [select_all index] returns all packages (latest version of each)
58+ that are available on the target platform (Debian x86_64). *)
59+60+val select_packages :
61+ ?platform:platform ->
62+ Repo_index.t -> package_spec list -> (selection_result, string) result
63+(** [select_packages index specs] finds packages matching the given
64+ specifications, filtered by platform availability. Returns the
65+ latest compatible version of each package. *)
66+67+val select_with_deps :
68+ ?platform:platform ->
69+ ?compiler:package_spec ->
70+ Repo_index.t -> package_spec list -> (selection_result, string) result
71+(** [select_with_deps ?platform ?compiler index specs] selects packages and
72+ their transitive dependencies using the 0install solver.
73+74+ - Dependencies are filtered by platform (Debian x86_64 by default)
75+ - If [compiler] is specified, it is added as a constraint and all
76+ compiler-related packages (those with flags:compiler or matching
77+ known compiler package patterns) are filtered from the results
78+ - The solver finds a consistent installation set *)
79+80+(** {1 Cmdliner Integration} *)
81+82+val package_specs_term : package_spec list Cmdliner.Term.t
83+(** Cmdliner term for parsing package specifications from command line.
84+ Accepts zero or more package specs as positional arguments.
85+ If no packages specified, returns empty list (meaning "all packages"). *)
86+87+val package_specs_conv : package_spec Cmdliner.Arg.conv
88+(** Cmdliner converter for a single package spec. *)
···1+(** Package source URL extraction.
2+3+ Extracts download URLs or git remotes from opam package metadata. *)
4+5+(** {1 Source Types} *)
6+7+type source_kind =
8+ | Archive (** Tarball/archive URL with optional checksums *)
9+ | Git (** Git repository URL *)
10+(** The kind of source to extract. *)
11+12+type archive_source = {
13+ url : string;
14+ checksums : string list; (** SHA256, MD5, etc. *)
15+ mirrors : string list;
16+}
17+(** An archive source with URL and integrity info. *)
18+19+type git_source = {
20+ url : string;
21+ branch : string option; (** Branch/tag/ref if specified *)
22+}
23+(** A git repository source. *)
24+25+type source =
26+ | ArchiveSource of archive_source
27+ | GitSource of git_source
28+ | NoSource
29+(** A package source. *)
30+31+type package_source = {
32+ name : string;
33+ version : string;
34+ source : source;
35+ dev_repo : Dev_repo.t option;
36+}
37+(** A package with its source and dev-repo for grouping. *)
38+39+type grouped_sources = {
40+ dev_repo : Dev_repo.t option;
41+ packages : package_source list;
42+}
43+(** Packages grouped by their shared dev-repo. *)
44+45+(** {1 Extraction} *)
46+47+val extract : source_kind -> Repo_index.package_info -> package_source
48+(** [extract kind info] extracts the source of the specified kind from
49+ package [info]. For [Archive], uses the url field. For [Git], uses
50+ dev-repo or falls back to url if it's a git URL. *)
51+52+val extract_all : source_kind -> Repo_index.package_info list -> package_source list
53+(** [extract_all kind packages] extracts sources for all packages. *)
54+55+val group_by_dev_repo : package_source list -> grouped_sources list
56+(** [group_by_dev_repo sources] groups packages by their dev-repo.
57+ Packages with the same dev-repo are grouped together since they
58+ come from the same repository. Groups with dev-repo are sorted first,
59+ followed by packages without dev-repo. *)
60+61+(** {1 Codecs} *)
62+63+val package_source_jsont : package_source Jsont.t
64+(** JSON codec for a package source. *)
65+66+val package_sources_jsont : package_source list Jsont.t
67+(** JSON codec for a list of package sources. *)
68+69+val grouped_sources_jsont : grouped_sources Jsont.t
70+(** JSON codec for grouped sources. *)
71+72+val grouped_sources_list_jsont : grouped_sources list Jsont.t
73+(** JSON codec for a list of grouped sources. *)
74+75+val package_source_tomlt : package_source Tomlt.t
76+(** TOML codec for a package source. *)
77+78+val package_sources_tomlt : package_source list Tomlt.t
79+(** TOML codec for a list of package sources (as array of tables). *)
80+81+val grouped_sources_tomlt : grouped_sources Tomlt.t
82+(** TOML codec for grouped sources. *)
83+84+val grouped_sources_list_tomlt : grouped_sources list Tomlt.t
85+(** TOML codec for a list of grouped sources (as array of tables). *)