···11+open Cmdliner
22+33+(* Logging setup *)
44+55+let setup_logging style_renderer level =
66+ Fmt_tty.setup_std_outputs ?style_renderer ();
77+ Logs.set_level level;
88+ Logs.set_reporter (Logs_fmt.reporter ());
99+ ()
1010+1111+let logging_term =
1212+ Term.(const setup_logging $ Fmt_cli.style_renderer () $ Logs_cli.level ())
1313+1414+(* Common options *)
1515+1616+let config_file =
1717+ let doc = "Path to unpac.toml config file." in
1818+ Arg.(value & opt file "unpac.toml" & info [ "c"; "config" ] ~doc ~docv:"FILE")
1919+2020+let cache_dir_term =
2121+ let app_env = "UNPAC_CACHE_DIR" in
2222+ let xdg_var = "XDG_CACHE_HOME" in
2323+ let home = Sys.getenv "HOME" in
2424+ let default_path = home ^ "/.cache/unpac" in
2525+ let doc =
2626+ Printf.sprintf
2727+ "Override cache directory. Can also be set with %s or %s. Default: %s"
2828+ app_env xdg_var default_path
2929+ in
3030+ let arg =
3131+ Arg.(value & opt string default_path & info [ "cache-dir" ] ~docv:"DIR" ~doc)
3232+ in
3333+ Term.(
3434+ const (fun cmdline_val ->
3535+ if cmdline_val <> default_path then cmdline_val
3636+ else
3737+ match Sys.getenv_opt app_env with
3838+ | Some v when v <> "" -> v
3939+ | _ -> (
4040+ match Sys.getenv_opt xdg_var with
4141+ | Some v when v <> "" -> v ^ "/unpac"
4242+ | _ -> default_path))
4343+ $ arg)
4444+4545+(* Output format selection *)
4646+type output_format = Text | Json | Toml
4747+4848+let output_format_term =
4949+ let json =
5050+ let doc = "Output in JSON format." in
5151+ Arg.(value & flag & info [ "json" ] ~doc)
5252+ in
5353+ let toml =
5454+ let doc = "Output in TOML format." in
5555+ Arg.(value & flag & info [ "toml" ] ~doc)
5656+ in
5757+ let select json toml =
5858+ match (json, toml) with
5959+ | true, false -> Json
6060+ | false, true -> Toml
6161+ | false, false -> Text
6262+ | true, true ->
6363+ Format.eprintf "Cannot use both --json and --toml@.";
6464+ Text
6565+ in
6666+ Term.(const select $ json $ toml)
6767+6868+let get_format = function
6969+ | Text -> Unpac.Output.Text
7070+ | Json -> Unpac.Output.Json
7171+ | Toml -> Unpac.Output.Toml
7272+7373+(* Helper to load index from config with caching *)
7474+7575+let load_index ~fs ~cache_dir config_path =
7676+ let cache_path = Eio.Path.(fs / cache_dir) in
7777+ Eio.Path.mkdirs ~exists_ok:true ~perm:0o755 cache_path;
7878+ Unpac.Cache.load_index ~cache_dir:cache_path ~config_path
7979+8080+(* Get compiler spec from config *)
8181+let get_compiler_spec config_path =
8282+ try
8383+ let config = Unpac.Config.load_exn config_path in
8484+ match config.opam.compiler with
8585+ | Some s -> Unpac.Solver.parse_compiler_spec s
8686+ | None -> None
8787+ with _ -> None
8888+8989+(* Source kind selection *)
9090+let source_kind_term =
9191+ let git =
9292+ let doc = "Get git/dev-repo URLs instead of archive URLs." in
9393+ Arg.(value & flag & info [ "git" ] ~doc)
9494+ in
9595+ Term.(
9696+ const (fun git ->
9797+ if git then Unpac.Source.Git else Unpac.Source.Archive)
9898+ $ git)
9999+100100+(* Resolve dependencies flag *)
101101+let resolve_deps_term =
102102+ let doc = "Resolve dependencies using the 0install solver." in
103103+ Arg.(value & flag & info [ "deps"; "with-deps" ] ~doc)
104104+105105+(* ============================================================================
106106+ INIT COMMAND
107107+ ============================================================================ *)
108108+109109+let init_cmd =
110110+ let doc = "Initialize a new unpac repository." in
111111+ let man = [
112112+ `S Manpage.s_description;
113113+ `P "Initializes a new git repository with unpac project structure.";
114114+ `P "Creates the main branch with a project registry.";
115115+ ] in
116116+ let run () =
117117+ Eio_main.run @@ fun env ->
118118+ let cwd = Eio.Stdenv.cwd env in
119119+ let proc_mgr = Eio.Stdenv.process_mgr env in
120120+ Unpac.Project.init ~proc_mgr ~cwd:(cwd :> Eio.Fs.dir_ty Eio.Path.t);
121121+ Format.printf "Repository initialized.@.";
122122+ Format.printf "Create a project with: unpac project create <name>@."
123123+ in
124124+ let info = Cmd.info "init" ~doc ~man in
125125+ Cmd.v info Term.(const run $ logging_term)
126126+127127+(* ============================================================================
128128+ PROJECT COMMANDS
129129+ ============================================================================ *)
130130+131131+let project_name_arg =
132132+ let doc = "Project name." in
133133+ Arg.(required & pos 0 (some string) None & info [] ~docv:"NAME" ~doc)
134134+135135+let project_desc_opt =
136136+ let doc = "Project description." in
137137+ Arg.(value & opt (some string) None & info ["d"; "description"] ~docv:"DESC" ~doc)
138138+139139+let project_create_cmd =
140140+ let doc = "Create a new project." in
141141+ let man = [
142142+ `S Manpage.s_description;
143143+ `P "Creates a new project branch and switches to it.";
144144+ `P "The project is registered in the main branch's unpac.toml.";
145145+ ] in
146146+ let run () name description =
147147+ Eio_main.run @@ fun env ->
148148+ let cwd = Eio.Stdenv.cwd env in
149149+ let proc_mgr = Eio.Stdenv.process_mgr env in
150150+ let description = match description with Some d -> d | None -> "" in
151151+ Unpac.Project.create ~proc_mgr ~cwd:(cwd :> Eio.Fs.dir_ty Eio.Path.t)
152152+ ~name ~description ()
153153+ in
154154+ let info = Cmd.info "create" ~doc ~man in
155155+ Cmd.v info Term.(const run $ logging_term $ project_name_arg $ project_desc_opt)
156156+157157+let project_list_cmd =
158158+ let doc = "List all projects." in
159159+ let man = [
160160+ `S Manpage.s_description;
161161+ `P "Lists all projects in the repository.";
162162+ ] in
163163+ let run () =
164164+ Eio_main.run @@ fun env ->
165165+ let cwd = Eio.Stdenv.cwd env in
166166+ let proc_mgr = Eio.Stdenv.process_mgr env in
167167+ let projects = Unpac.Project.list_projects ~proc_mgr
168168+ ~cwd:(cwd :> Eio.Fs.dir_ty Eio.Path.t) in
169169+ let current = Unpac.Project.current_project ~proc_mgr
170170+ ~cwd:(cwd :> Eio.Fs.dir_ty Eio.Path.t) in
171171+ if projects = [] then
172172+ Format.printf "No projects. Create one with: unpac project create <name>@."
173173+ else begin
174174+ Format.printf "Projects:@.";
175175+ List.iter (fun (p : Unpac.Project.project_info) ->
176176+ let marker = if Some p.name = current then "* " else " " in
177177+ Format.printf "%s%s (%s)@." marker p.name p.branch
178178+ ) projects
179179+ end
180180+ in
181181+ let info = Cmd.info "list" ~doc ~man in
182182+ Cmd.v info Term.(const run $ logging_term)
183183+184184+let project_switch_cmd =
185185+ let doc = "Switch to a project." in
186186+ let man = [
187187+ `S Manpage.s_description;
188188+ `P "Switches to the specified project's branch.";
189189+ ] in
190190+ let run () name =
191191+ Eio_main.run @@ fun env ->
192192+ let cwd = Eio.Stdenv.cwd env in
193193+ let proc_mgr = Eio.Stdenv.process_mgr env in
194194+ Unpac.Project.switch ~proc_mgr ~cwd:(cwd :> Eio.Fs.dir_ty Eio.Path.t) name
195195+ in
196196+ let info = Cmd.info "switch" ~doc ~man in
197197+ Cmd.v info Term.(const run $ logging_term $ project_name_arg)
198198+199199+let project_cmd =
200200+ let doc = "Project management commands." in
201201+ let man = [
202202+ `S Manpage.s_description;
203203+ `P "Commands for managing projects (branches).";
204204+ ] in
205205+ let info = Cmd.info "project" ~doc ~man in
206206+ Cmd.group info [project_create_cmd; project_list_cmd; project_switch_cmd]
207207+208208+(* ============================================================================
209209+ ADD COMMANDS
210210+ ============================================================================ *)
211211+212212+let package_name_arg =
213213+ let doc = "Package name to add." in
214214+ Arg.(required & pos 0 (some string) None & info [] ~docv:"PACKAGE" ~doc)
215215+216216+let add_opam_cmd =
217217+ let doc = "Add a package from opam." in
218218+ let man = [
219219+ `S Manpage.s_description;
220220+ `P "Adds a package from opam, creating vendor branches and merging into the current project.";
221221+ `P "Must be on a project branch (not main).";
222222+ `P "Use --with-deps to include all transitive dependencies.";
223223+ `S Manpage.s_examples;
224224+ `P "Add a single package:";
225225+ `Pre " unpac add opam eio";
226226+ `P "Add a package with all dependencies:";
227227+ `Pre " unpac add opam lwt --with-deps";
228228+ ] in
229229+ let run () config_path cache_dir resolve_deps pkg_name =
230230+ Eio_main.run @@ fun env ->
231231+ let fs = Eio.Stdenv.fs env in
232232+ let cwd = Eio.Stdenv.cwd env in
233233+ let proc_mgr = Eio.Stdenv.process_mgr env in
234234+ let cwd_path = (cwd :> Eio.Fs.dir_ty Eio.Path.t) in
235235+236236+ (* Check we're on a project branch *)
237237+ let _project = Unpac.Project.require_project_branch ~proc_mgr ~cwd:cwd_path in
238238+239239+ (* Check for pending recovery *)
240240+ if Unpac.Recovery.has_recovery ~cwd:cwd_path then begin
241241+ Format.eprintf "There's a pending operation. Run 'unpac vendor continue' or 'unpac vendor abort'.@.";
242242+ exit 1
243243+ end;
244244+245245+ (* Load opam index *)
246246+ let index = load_index ~fs ~cache_dir config_path in
247247+ let compiler = get_compiler_spec config_path in
248248+249249+ (* Parse package spec *)
250250+ let spec = match Unpac.Solver.parse_package_spec pkg_name with
251251+ | Ok s -> s
252252+ | Error msg ->
253253+ Format.eprintf "Invalid package spec: %s@." msg;
254254+ exit 1
255255+ in
256256+257257+ (* Get packages to add *)
258258+ let packages_to_add =
259259+ if resolve_deps then begin
260260+ match Unpac.Solver.select_with_deps ?compiler index [spec] with
261261+ | Ok selection -> selection.packages
262262+ | Error msg ->
263263+ Format.eprintf "Error resolving dependencies: %s@." msg;
264264+ exit 1
265265+ end else begin
266266+ match Unpac.Solver.select_packages index [spec] with
267267+ | Ok selection -> selection.packages
268268+ | Error msg ->
269269+ Format.eprintf "Error selecting package: %s@." msg;
270270+ exit 1
271271+ end
272272+ in
273273+274274+ if packages_to_add = [] then begin
275275+ Format.eprintf "Package '%s' not found.@." pkg_name;
276276+ exit 1
277277+ end;
278278+279279+ (* Group packages by dev-repo *)
280280+ let sources = Unpac.Source.extract_all Unpac.Source.Git packages_to_add in
281281+ let grouped = Unpac.Source.group_by_dev_repo sources in
282282+283283+ Format.printf "Found %d package group(s) to vendor:@." (List.length grouped);
284284+285285+ (* Add each group *)
286286+ List.iter (fun (group : Unpac.Source.grouped_sources) ->
287287+ match group.dev_repo with
288288+ | None ->
289289+ Format.printf " Skipping packages without dev-repo@."
290290+ | Some dev_repo ->
291291+ let url_str = Unpac.Dev_repo.to_string dev_repo in
292292+ let opam_packages = List.map (fun (p : Unpac.Source.package_source) -> p.name) group.packages in
293293+294294+ (* Use first package name as canonical name, or extract from URL *)
295295+ let name =
296296+ match opam_packages with
297297+ | first :: _ -> first
298298+ | [] -> "unknown"
299299+ in
300300+301301+ (* Reconstruct full URL for git clone *)
302302+ let url =
303303+ let first_pkg = List.hd group.packages in
304304+ match first_pkg.source with
305305+ | Unpac.Source.GitSource g -> g.url
306306+ | _ -> "https://" ^ url_str (* Fallback *)
307307+ in
308308+309309+ Format.printf " Adding %s (%d packages: %s)@."
310310+ name (List.length opam_packages)
311311+ (String.concat ", " opam_packages);
312312+313313+ (* Detect default branch *)
314314+ let branch = Unpac.Git.ls_remote_default_branch ~proc_mgr ~url in
315315+316316+ match Unpac.Vendor.add_package ~proc_mgr ~cwd:cwd_path
317317+ ~name ~url ~branch ~opam_packages with
318318+ | Unpac.Vendor.Success { canonical_name; opam_packages; _ } ->
319319+ Format.printf " [OK] Added %s (%d opam packages)@."
320320+ canonical_name (List.length opam_packages)
321321+ | Unpac.Vendor.Already_vendored name ->
322322+ Format.printf " [SKIP] %s already vendored@." name
323323+ | Unpac.Vendor.Failed { step; recovery_hint; error } ->
324324+ Format.eprintf " [FAIL] Failed at step '%s': %s@." step
325325+ (Printexc.to_string error);
326326+ Format.eprintf " %s@." recovery_hint;
327327+ exit 1
328328+ ) grouped;
329329+330330+ Format.printf "Done.@."
331331+ in
332332+ let info = Cmd.info "opam" ~doc ~man in
333333+ Cmd.v info Term.(const run $ logging_term $ config_file $ cache_dir_term
334334+ $ resolve_deps_term $ package_name_arg)
335335+336336+let add_cmd =
337337+ let doc = "Add packages to the project." in
338338+ let man = [
339339+ `S Manpage.s_description;
340340+ `P "Commands for adding packages from various sources.";
341341+ ] in
342342+ let info = Cmd.info "add" ~doc ~man in
343343+ Cmd.group info [add_opam_cmd]
344344+345345+(* ============================================================================
346346+ VENDOR COMMANDS
347347+ ============================================================================ *)
348348+349349+let vendor_package_arg =
350350+ let doc = "Package name." in
351351+ Arg.(required & pos 0 (some string) None & info [] ~docv:"PACKAGE" ~doc)
352352+353353+let vendor_status_cmd =
354354+ let doc = "Show status of vendored packages." in
355355+ let man = [
356356+ `S Manpage.s_description;
357357+ `P "Shows the status of all vendored packages including their SHAs and patch counts.";
358358+ ] in
359359+ let run () =
360360+ Eio_main.run @@ fun env ->
361361+ let cwd = Eio.Stdenv.cwd env in
362362+ let proc_mgr = Eio.Stdenv.process_mgr env in
363363+ let cwd_path = (cwd :> Eio.Fs.dir_ty Eio.Path.t) in
364364+365365+ let statuses = Unpac.Vendor.all_status ~proc_mgr ~cwd:cwd_path in
366366+367367+ if statuses = [] then begin
368368+ Format.printf "No vendored packages.@.";
369369+ Format.printf "Add packages with: unpac add opam <pkg>@."
370370+ end else begin
371371+ (* Print header *)
372372+ Format.printf "%-20s %-12s %-12s %-8s %-8s@."
373373+ "PACKAGE" "UPSTREAM" "VENDOR" "PATCHES" "MERGED";
374374+ Format.printf "%-20s %-12s %-12s %-8s %-8s@."
375375+ "-------" "--------" "------" "-------" "------";
376376+377377+ List.iter (fun (s : Unpac.Vendor.package_status) ->
378378+ let upstream = match s.upstream_sha with Some x -> x | None -> "-" in
379379+ let vendor = match s.vendor_sha with Some x -> x | None -> "-" in
380380+ let patches = string_of_int s.patch_count in
381381+ let merged = if s.in_project then "yes" else "no" in
382382+ Format.printf "%-20s %-12s %-12s %-8s %-8s@."
383383+ s.name upstream vendor patches merged
384384+ ) statuses
385385+ end
386386+ in
387387+ let info = Cmd.info "status" ~doc ~man in
388388+ Cmd.v info Term.(const run $ logging_term)
389389+390390+let vendor_update_cmd =
391391+ let doc = "Update a vendored package from upstream." in
392392+ let man = [
393393+ `S Manpage.s_description;
394394+ `P "Fetches the latest changes from upstream and updates the vendor branch.";
395395+ `P "After updating, use 'unpac vendor rebase <pkg>' to rebase your patches.";
396396+ ] in
397397+ let run () name =
398398+ Eio_main.run @@ fun env ->
399399+ let cwd = Eio.Stdenv.cwd env in
400400+ let proc_mgr = Eio.Stdenv.process_mgr env in
401401+ let cwd_path = (cwd :> Eio.Fs.dir_ty Eio.Path.t) in
402402+403403+ match Unpac.Vendor.update_package ~proc_mgr ~cwd:cwd_path ~name with
404404+ | Unpac.Vendor.Updated { old_sha; new_sha; commit_count } ->
405405+ let old_short = String.sub old_sha 0 7 in
406406+ let new_short = String.sub new_sha 0 7 in
407407+ Format.printf "[OK] Updated %s: %s -> %s (%d commits)@."
408408+ name old_short new_short commit_count;
409409+ Format.printf "Next: unpac vendor rebase %s@." name
410410+ | Unpac.Vendor.No_changes ->
411411+ Format.printf "[OK] %s is up to date@." name
412412+ | Unpac.Vendor.Update_failed { step; error; recovery_hint } ->
413413+ Format.eprintf "[FAIL] Failed at step '%s': %s@." step
414414+ (Printexc.to_string error);
415415+ Format.eprintf "%s@." recovery_hint;
416416+ exit 1
417417+ in
418418+ let info = Cmd.info "update" ~doc ~man in
419419+ Cmd.v info Term.(const run $ logging_term $ vendor_package_arg)
420420+421421+let vendor_rebase_cmd =
422422+ let doc = "Rebase patches onto updated vendor branch." in
423423+ let man = [
424424+ `S Manpage.s_description;
425425+ `P "Rebases your patches on top of the updated vendor branch.";
426426+ `P "Run this after 'unpac vendor update <pkg>'.";
427427+ ] in
428428+ let run () name =
429429+ Eio_main.run @@ fun env ->
430430+ let cwd = Eio.Stdenv.cwd env in
431431+ let proc_mgr = Eio.Stdenv.process_mgr env in
432432+ let cwd_path = (cwd :> Eio.Fs.dir_ty Eio.Path.t) in
433433+434434+ match Unpac.Vendor.rebase_patches ~proc_mgr ~cwd:cwd_path ~name with
435435+ | Ok () ->
436436+ Format.printf "[OK] Rebased %s@." name;
437437+ Format.printf "Next: unpac vendor merge %s@." name
438438+ | Error (`Conflict _hint) ->
439439+ Format.eprintf "[CONFLICT] Rebase has conflicts@.";
440440+ Format.eprintf "Resolve conflicts, then: git rebase --continue@.";
441441+ Format.eprintf "Or abort: git rebase --abort@.";
442442+ exit 1
443443+ in
444444+ let info = Cmd.info "rebase" ~doc ~man in
445445+ Cmd.v info Term.(const run $ logging_term $ vendor_package_arg)
446446+447447+let vendor_merge_cmd =
448448+ let doc = "Merge patches into current project branch." in
449449+ let man = [
450450+ `S Manpage.s_description;
451451+ `P "Merges the patches branch into the current project branch.";
452452+ ] in
453453+ let run () name =
454454+ Eio_main.run @@ fun env ->
455455+ let cwd = Eio.Stdenv.cwd env in
456456+ let proc_mgr = Eio.Stdenv.process_mgr env in
457457+ let cwd_path = (cwd :> Eio.Fs.dir_ty Eio.Path.t) in
458458+459459+ match Unpac.Vendor.merge_to_project ~proc_mgr ~cwd:cwd_path ~name with
460460+ | Ok () ->
461461+ Format.printf "[OK] Merged %s into project@." name
462462+ | Error (`Conflict _files) ->
463463+ Format.eprintf "[CONFLICT] Merge has conflicts@.";
464464+ Format.eprintf "Resolve conflicts, then: git add <files> && git commit@.";
465465+ Format.eprintf "Or abort: git merge --abort@.";
466466+ exit 1
467467+ in
468468+ let info = Cmd.info "merge" ~doc ~man in
469469+ Cmd.v info Term.(const run $ logging_term $ vendor_package_arg)
470470+471471+let vendor_continue_cmd =
472472+ let doc = "Continue an interrupted operation." in
473473+ let man = [
474474+ `S Manpage.s_description;
475475+ `P "Continues an operation that was interrupted (e.g., by a conflict).";
476476+ `P "Run this after resolving conflicts.";
477477+ ] in
478478+ let run () =
479479+ Eio_main.run @@ fun env ->
480480+ let cwd = Eio.Stdenv.cwd env in
481481+ let proc_mgr = Eio.Stdenv.process_mgr env in
482482+ let cwd_path = (cwd :> Eio.Fs.dir_ty Eio.Path.t) in
483483+484484+ match Unpac.Recovery.load ~cwd:cwd_path with
485485+ | None ->
486486+ Format.printf "No pending operation to continue.@."
487487+ | Some state ->
488488+ Format.printf "Continuing: %a@." Unpac.Recovery.pp_operation state.operation;
489489+ match Unpac.Vendor.continue ~proc_mgr ~cwd:cwd_path state with
490490+ | Unpac.Vendor.Success { canonical_name; _ } ->
491491+ Format.printf "[OK] Completed %s@." canonical_name
492492+ | Unpac.Vendor.Already_vendored name ->
493493+ Format.printf "[OK] %s already vendored@." name
494494+ | Unpac.Vendor.Failed { step; error; recovery_hint } ->
495495+ Format.eprintf "[FAIL] Failed at step '%s': %s@." step
496496+ (Printexc.to_string error);
497497+ Format.eprintf "%s@." recovery_hint;
498498+ exit 1
499499+ in
500500+ let info = Cmd.info "continue" ~doc ~man in
501501+ Cmd.v info Term.(const run $ logging_term)
502502+503503+let vendor_abort_cmd =
504504+ let doc = "Abort an interrupted operation." in
505505+ let man = [
506506+ `S Manpage.s_description;
507507+ `P "Aborts an operation and restores the repository to its previous state.";
508508+ ] in
509509+ let run () =
510510+ Eio_main.run @@ fun env ->
511511+ let cwd = Eio.Stdenv.cwd env in
512512+ let proc_mgr = Eio.Stdenv.process_mgr env in
513513+ let cwd_path = (cwd :> Eio.Fs.dir_ty Eio.Path.t) in
514514+515515+ match Unpac.Recovery.load ~cwd:cwd_path with
516516+ | None ->
517517+ Format.printf "No pending operation to abort.@."
518518+ | Some state ->
519519+ Format.printf "Aborting: %a@." Unpac.Recovery.pp_operation state.operation;
520520+ Unpac.Recovery.abort ~proc_mgr ~cwd:cwd_path state;
521521+ Format.printf "[OK] Aborted. Repository restored.@."
522522+ in
523523+ let info = Cmd.info "abort" ~doc ~man in
524524+ Cmd.v info Term.(const run $ logging_term)
525525+526526+let vendor_cmd =
527527+ let doc = "Vendor package management." in
528528+ let man = [
529529+ `S Manpage.s_description;
530530+ `P "Commands for managing vendored packages.";
531531+ ] in
532532+ let info = Cmd.info "vendor" ~doc ~man in
533533+ Cmd.group info [
534534+ vendor_status_cmd;
535535+ vendor_update_cmd;
536536+ vendor_rebase_cmd;
537537+ vendor_merge_cmd;
538538+ vendor_continue_cmd;
539539+ vendor_abort_cmd;
540540+ ]
541541+542542+(* ============================================================================
543543+ OPAM COMMANDS (existing)
544544+ ============================================================================ *)
545545+546546+let opam_list_cmd =
547547+ let doc = "List packages in the merged repository." in
548548+ let man =
549549+ [
550550+ `S Manpage.s_description;
551551+ `P "Lists packages from all configured opam repositories.";
552552+ `P "If no packages are specified, lists all available packages.";
553553+ `P "Use --deps to include transitive dependencies.";
554554+ `S Manpage.s_examples;
555555+ `P "List all packages:";
556556+ `Pre " unpac opam list";
557557+ `P "List specific packages with dependencies:";
558558+ `Pre " unpac opam list --deps lwt cmdliner";
559559+ ]
560560+ in
561561+ let run () config_path cache_dir format resolve_deps package_specs =
562562+ Eio_main.run @@ fun env ->
563563+ let fs = Eio.Stdenv.fs env in
564564+ let index = load_index ~fs ~cache_dir config_path in
565565+ let compiler = get_compiler_spec config_path in
566566+ let selection_result =
567567+ if package_specs = [] then Ok (Unpac.Solver.select_all index)
568568+ else if resolve_deps then Unpac.Solver.select_with_deps ?compiler index package_specs
569569+ else Unpac.Solver.select_packages index package_specs
570570+ in
571571+ match selection_result with
572572+ | Error msg ->
573573+ Format.eprintf "Error selecting packages: %s@." msg;
574574+ exit 1
575575+ | Ok selection ->
576576+ let packages =
577577+ List.sort
578578+ (fun (a : Unpac.Repo_index.package_info) b ->
579579+ let cmp = OpamPackage.Name.compare a.name b.name in
580580+ if cmp <> 0 then cmp
581581+ else OpamPackage.Version.compare a.version b.version)
582582+ selection.packages
583583+ in
584584+ Unpac.Output.output_package_list (get_format format) packages
585585+ in
586586+ let info = Cmd.info "list" ~doc ~man in
587587+ Cmd.v info
588588+ Term.(
589589+ const run $ logging_term $ config_file $ cache_dir_term $ output_format_term
590590+ $ resolve_deps_term $ Unpac.Solver.package_specs_term)
591591+592592+let opam_info_cmd =
593593+ let doc = "Show detailed information about packages." in
594594+ let man =
595595+ [
596596+ `S Manpage.s_description;
597597+ `P "Displays detailed information about the specified packages.";
598598+ `P "Use --deps to include transitive dependencies.";
599599+ `S Manpage.s_examples;
600600+ `P "Show info for a package:";
601601+ `Pre " unpac opam info lwt";
602602+ `P "Show info for packages and their dependencies:";
603603+ `Pre " unpac opam info --deps cmdliner";
604604+ ]
605605+ in
606606+ let run () config_path cache_dir format resolve_deps package_specs =
607607+ Eio_main.run @@ fun env ->
608608+ let fs = Eio.Stdenv.fs env in
609609+ let index = load_index ~fs ~cache_dir config_path in
610610+ let compiler = get_compiler_spec config_path in
611611+ if package_specs = [] then begin
612612+ Format.eprintf "Please specify at least one package.@.";
613613+ exit 1
614614+ end;
615615+ let selection_result =
616616+ if resolve_deps then Unpac.Solver.select_with_deps ?compiler index package_specs
617617+ else Unpac.Solver.select_packages index package_specs
618618+ in
619619+ match selection_result with
620620+ | Error msg ->
621621+ Format.eprintf "Error selecting packages: %s@." msg;
622622+ exit 1
623623+ | Ok selection ->
624624+ if selection.packages = [] then
625625+ Format.eprintf "No packages found.@."
626626+ else Unpac.Output.output_package_info (get_format format) selection.packages
627627+ in
628628+ let info = Cmd.info "info" ~doc ~man in
629629+ Cmd.v info
630630+ Term.(
631631+ const run $ logging_term $ config_file $ cache_dir_term $ output_format_term
632632+ $ resolve_deps_term $ Unpac.Solver.package_specs_term)
633633+634634+let opam_related_cmd =
635635+ let doc = "Show packages sharing the same dev-repo." in
636636+ let man =
637637+ [
638638+ `S Manpage.s_description;
639639+ `P
640640+ "Lists all packages that share a development repository with the \
641641+ specified packages.";
642642+ `P "Use --deps to first resolve dependencies, then find related packages.";
643643+ `S Manpage.s_examples;
644644+ `P "Find related packages for a single package:";
645645+ `Pre " unpac opam related lwt";
646646+ `P "Find related packages including dependencies:";
647647+ `Pre " unpac opam related --deps cmdliner";
648648+ ]
649649+ in
650650+ let run () config_path cache_dir format resolve_deps package_specs =
651651+ Eio_main.run @@ fun env ->
652652+ let fs = Eio.Stdenv.fs env in
653653+ let index = load_index ~fs ~cache_dir config_path in
654654+ let compiler = get_compiler_spec config_path in
655655+ if package_specs = [] then begin
656656+ Format.eprintf "Please specify at least one package.@.";
657657+ exit 1
658658+ end;
659659+ (* First, get the packages (with optional deps) *)
660660+ let selection_result =
661661+ if resolve_deps then Unpac.Solver.select_with_deps ?compiler index package_specs
662662+ else Unpac.Solver.select_packages index package_specs
663663+ in
664664+ match selection_result with
665665+ | Error msg ->
666666+ Format.eprintf "Error selecting packages: %s@." msg;
667667+ exit 1
668668+ | Ok selection ->
669669+ (* Find related packages for all selected packages *)
670670+ let all_related = List.concat_map (fun (info : Unpac.Repo_index.package_info) ->
671671+ Unpac.Repo_index.related_packages info.name index)
672672+ selection.packages
673673+ in
674674+ (* Deduplicate *)
675675+ let seen = Hashtbl.create 64 in
676676+ let unique = List.filter (fun (info : Unpac.Repo_index.package_info) ->
677677+ let key = OpamPackage.Name.to_string info.name in
678678+ if Hashtbl.mem seen key then false
679679+ else begin Hashtbl.add seen key (); true end)
680680+ all_related
681681+ in
682682+ let first_pkg = List.hd package_specs in
683683+ let pkg_name = OpamPackage.Name.to_string first_pkg.Unpac.Solver.name in
684684+ if unique = [] then
685685+ Format.eprintf "No related packages found.@."
686686+ else Unpac.Output.output_related (get_format format) pkg_name unique
687687+ in
688688+ let info = Cmd.info "related" ~doc ~man in
689689+ Cmd.v info
690690+ Term.(
691691+ const run $ logging_term $ config_file $ cache_dir_term $ output_format_term
692692+ $ resolve_deps_term $ Unpac.Solver.package_specs_term)
693693+694694+let opam_sources_cmd =
695695+ let doc = "Get source URLs for packages, grouped by dev-repo." in
696696+ let man =
697697+ [
698698+ `S Manpage.s_description;
699699+ `P
700700+ "Outputs source URLs (archive or git) for the specified packages, \
701701+ grouped by their development repository (dev-repo). Packages that \
702702+ share the same dev-repo are listed together since they typically \
703703+ need to be fetched from the same source.";
704704+ `P
705705+ "If no packages are specified, outputs sources for all packages \
706706+ (latest version of each).";
707707+ `P
708708+ "Use --git to get development repository URLs instead of archive URLs.";
709709+ `P
710710+ "Use --deps to include transitive dependencies using the 0install solver.";
711711+ `S Manpage.s_examples;
712712+ `P "Get archive URLs for all packages:";
713713+ `Pre " unpac opam sources";
714714+ `P "Get git URLs for specific packages:";
715715+ `Pre " unpac opam sources --git lwt dune";
716716+ `P "Get sources with version constraints:";
717717+ `Pre " unpac opam sources cmdliner>=1.0 lwt.5.6.0";
718718+ `P "Get sources with dependencies resolved:";
719719+ `Pre " unpac opam sources --deps lwt";
720720+ ]
721721+ in
722722+ let run () config_path cache_dir format source_kind resolve_deps package_specs =
723723+ Eio_main.run @@ fun env ->
724724+ let fs = Eio.Stdenv.fs env in
725725+ let index = load_index ~fs ~cache_dir config_path in
726726+ let compiler = get_compiler_spec config_path in
727727+ (* Select packages based on specs *)
728728+ let selection_result =
729729+ if package_specs = [] then Ok (Unpac.Solver.select_all index)
730730+ else if resolve_deps then Unpac.Solver.select_with_deps ?compiler index package_specs
731731+ else Unpac.Solver.select_packages index package_specs
732732+ in
733733+ match selection_result with
734734+ | Error msg ->
735735+ Format.eprintf "Error selecting packages: %s@." msg;
736736+ exit 1
737737+ | Ok selection ->
738738+ let sources =
739739+ Unpac.Source.extract_all source_kind selection.packages
740740+ in
741741+ (* Filter out packages with no source *)
742742+ let sources =
743743+ List.filter
744744+ (fun (s : Unpac.Source.package_source) ->
745745+ s.source <> Unpac.Source.NoSource)
746746+ sources
747747+ in
748748+ Unpac.Output.output_sources (get_format format) sources
749749+ in
750750+ let info = Cmd.info "sources" ~doc ~man in
751751+ Cmd.v info
752752+ Term.(
753753+ const run $ logging_term $ config_file $ cache_dir_term $ output_format_term $ source_kind_term
754754+ $ resolve_deps_term $ Unpac.Solver.package_specs_term)
755755+756756+(* Opam subcommand group *)
757757+758758+let opam_cmd =
759759+ let doc = "Opam repository operations." in
760760+ let man =
761761+ [
762762+ `S Manpage.s_description;
763763+ `P
764764+ "Commands for querying and managing opam repositories defined in the \
765765+ configuration file.";
766766+ ]
767767+ in
768768+ let info = Cmd.info "opam" ~doc ~man in
769769+ Cmd.group info [ opam_list_cmd; opam_info_cmd; opam_related_cmd; opam_sources_cmd ]
770770+771771+(* ============================================================================
772772+ MAIN COMMAND
773773+ ============================================================================ *)
774774+775775+let main_cmd =
776776+ let doc = "Monorepo management tool." in
777777+ let man =
778778+ [
779779+ `S Manpage.s_description;
780780+ `P "unpac is a tool for managing OCaml monorepos with vendored packages.";
781781+ `P "It uses a project-based branch model:";
782782+ `P " - main branch holds the project registry";
783783+ `P " - project/<name> branches hold actual code and vendor packages";
784784+ `S "QUICK START";
785785+ `P "Initialize a new repository:";
786786+ `Pre " unpac init";
787787+ `P "Create a project:";
788788+ `Pre " unpac project create myapp";
789789+ `P "Add packages:";
790790+ `Pre " unpac add opam eio";
791791+ `Pre " unpac add opam lwt --with-deps";
792792+ `P "Check status:";
793793+ `Pre " unpac vendor status";
794794+ `S Manpage.s_bugs;
795795+ `P "Report bugs at https://github.com/avsm/unpac/issues";
796796+ ]
797797+ in
798798+ let info = Cmd.info "unpac" ~version:"0.1.0" ~doc ~man in
799799+ Cmd.group info [ init_cmd; project_cmd; add_cmd; vendor_cmd; opam_cmd ]
800800+801801+let () = exit (Cmd.eval main_cmd)
···11+type cache_header = {
22+ config_path : string;
33+ config_mtime : float;
44+}
55+66+type cache_key = {
77+ repos : (string * string) list; (* (name, path) pairs *)
88+ repo_mtimes : float list; (* mtime of each repo's packages directory *)
99+}
1010+1111+let cache_filename = "repo_index.cache"
1212+1313+let get_file_mtime path =
1414+ try
1515+ let stat = Unix.stat path in
1616+ stat.Unix.st_mtime
1717+ with Unix.Unix_error _ -> 0.0
1818+1919+let get_repo_mtime path =
2020+ let packages_dir = Filename.concat path "packages" in
2121+ get_file_mtime packages_dir
2222+2323+let make_cache_key (repos : Config.repo_config list) =
2424+ let repo_list =
2525+ List.filter_map
2626+ (fun (r : Config.repo_config) ->
2727+ match r.source with
2828+ | Config.Local path -> Some (r.name, path)
2929+ | Config.Remote _ -> None)
3030+ repos
3131+ in
3232+ let repo_mtimes =
3333+ List.map (fun (_, path) -> get_repo_mtime path) repo_list
3434+ in
3535+ { repos = repo_list; repo_mtimes }
3636+3737+let cache_path cache_dir =
3838+ Eio.Path.(cache_dir / cache_filename)
3939+4040+(* Read just the header to check if config has changed *)
4141+let read_cache_header cache_dir =
4242+ let path = cache_path cache_dir in
4343+ try
4444+ let path_str = Eio.Path.native_exn path in
4545+ let ic = open_in_bin path_str in
4646+ Fun.protect
4747+ ~finally:(fun () -> close_in ic)
4848+ (fun () ->
4949+ let header : cache_header = Marshal.from_channel ic in
5050+ Some header)
5151+ with
5252+ | Sys_error _ -> None
5353+ | End_of_file -> None
5454+ | Failure _ -> None
5555+5656+(* Load full cache if header and key match *)
5757+let load_cached cache_dir expected_header expected_key =
5858+ let path = cache_path cache_dir in
5959+ try
6060+ let path_str = Eio.Path.native_exn path in
6161+ let ic = open_in_bin path_str in
6262+ Fun.protect
6363+ ~finally:(fun () -> close_in ic)
6464+ (fun () ->
6565+ let header : cache_header = Marshal.from_channel ic in
6666+ if header <> expected_header then None
6767+ else
6868+ let key : cache_key = Marshal.from_channel ic in
6969+ if key <> expected_key then None
7070+ else
7171+ let index : Repo_index.t = Marshal.from_channel ic in
7272+ Some index)
7373+ with
7474+ | Sys_error _ -> None
7575+ | End_of_file -> None
7676+ | Failure _ -> None
7777+7878+let save_cache cache_dir header key (index : Repo_index.t) =
7979+ let path = cache_path cache_dir in
8080+ try
8181+ let path_str = Eio.Path.native_exn path in
8282+ let oc = open_out_bin path_str in
8383+ Fun.protect
8484+ ~finally:(fun () -> close_out oc)
8585+ (fun () ->
8686+ Marshal.to_channel oc header [];
8787+ Marshal.to_channel oc key [];
8888+ Marshal.to_channel oc index [])
8989+ with
9090+ | Sys_error msg ->
9191+ Format.eprintf "Warning: Could not save cache: %s@." msg
9292+ | Failure msg ->
9393+ Format.eprintf "Warning: Could not serialize cache: %s@." msg
9494+9595+let rec load_index ~cache_dir ~config_path =
9696+ let config_mtime = get_file_mtime config_path in
9797+ let header = { config_path; config_mtime } in
9898+9999+ (* Quick check: has config file changed? *)
100100+ let cached_header = read_cache_header cache_dir in
101101+ let config_unchanged =
102102+ match cached_header with
103103+ | Some h -> h = header
104104+ | None -> false
105105+ in
106106+107107+ (* Load config *)
108108+ let config = Config.load_exn config_path in
109109+ let key = make_cache_key config.opam.repositories in
110110+111111+ (* If config unchanged, try to load from cache *)
112112+ if config_unchanged then
113113+ match load_cached cache_dir header key with
114114+ | Some index -> index
115115+ | None ->
116116+ (* Cache invalid, rebuild *)
117117+ let index = build_index config in
118118+ save_cache cache_dir header key index;
119119+ index
120120+ else begin
121121+ (* Config changed, rebuild *)
122122+ let index = build_index config in
123123+ save_cache cache_dir header key index;
124124+ index
125125+ end
126126+127127+and build_index (config : Config.t) =
128128+ List.fold_left
129129+ (fun acc (repo : Config.repo_config) ->
130130+ match repo.source with
131131+ | Config.Local path ->
132132+ Repo_index.load_local_repo ~name:repo.name ~path acc
133133+ | Config.Remote _url ->
134134+ Format.eprintf
135135+ "Warning: Remote repositories not yet supported: %s@."
136136+ repo.name;
137137+ acc)
138138+ Repo_index.empty config.opam.repositories
+23
lib/cache.mli
···11+(** Cache for repository index.
22+33+ This module provides caching for the repository index using Marshal
44+ serialization. The cache is stored in the XDG cache directory and
55+ is invalidated when:
66+ - The config file path or mtime changes
77+ - Repository paths change
88+ - Repository package directories' mtimes change *)
99+1010+val load_index :
1111+ cache_dir:Eio.Fs.dir_ty Eio.Path.t ->
1212+ config_path:string ->
1313+ Repo_index.t
1414+(** [load_index ~cache_dir ~config_path] loads the repository index,
1515+ using a cached version if available and valid.
1616+1717+ The cache stores the config file path and mtime, along with repository
1818+ paths and their package directory mtimes. If any of these change, the
1919+ cache is invalidated and rebuilt.
2020+2121+ @param cache_dir The XDG cache directory path
2222+ @param config_path Path to the unpac.toml config file
2323+ @return The repository index *)
+71
lib/config.ml
···11+type repo_source =
22+ | Local of string
33+ | Remote of string
44+55+type repo_config = {
66+ name : string;
77+ source : repo_source;
88+}
99+1010+type opam_config = {
1111+ repositories : repo_config list;
1212+ compiler : string option; (* e.g., "ocaml.5.4.0" or "5.4.0" *)
1313+}
1414+1515+type t = { opam : opam_config }
1616+1717+(* TOML Codecs *)
1818+1919+let repo_config_codec =
2020+ let open Tomlt in
2121+ let open Table in
2222+ let make name path url =
2323+ let source =
2424+ match (path, url) with
2525+ | Some p, None -> Local p
2626+ | None, Some u -> Remote u
2727+ | Some _, Some _ ->
2828+ failwith "Repository cannot have both 'path' and 'url'"
2929+ | None, None -> failwith "Repository must have either 'path' or 'url'"
3030+ in
3131+ { name; source }
3232+ in
3333+ let enc_path r =
3434+ match r.source with Local p -> Some p | Remote _ -> None
3535+ in
3636+ let enc_url r =
3737+ match r.source with Remote u -> Some u | Local _ -> None
3838+ in
3939+ obj make
4040+ |> mem "name" string ~enc:(fun r -> r.name)
4141+ |> opt_mem "path" string ~enc:enc_path
4242+ |> opt_mem "url" string ~enc:enc_url
4343+ |> finish
4444+4545+let opam_config_codec =
4646+ let open Tomlt in
4747+ let open Table in
4848+ obj (fun repositories compiler -> { repositories; compiler })
4949+ |> mem "repositories" (list repo_config_codec)
5050+ ~enc:(fun c -> c.repositories)
5151+ |> opt_mem "compiler" string ~enc:(fun c -> c.compiler)
5252+ |> finish
5353+5454+let codec =
5555+ let open Tomlt in
5656+ let open Table in
5757+ obj (fun opam -> { opam })
5858+ |> mem "opam" opam_config_codec ~enc:(fun c -> c.opam)
5959+ |> finish
6060+6161+let load path =
6262+ try
6363+ let content = In_channel.with_open_text path In_channel.input_all in
6464+ Tomlt_bytesrw.decode_string codec content
6565+ |> Result.map_error Tomlt.Toml.Error.to_string
6666+ with
6767+ | Sys_error msg -> Error msg
6868+ | Failure msg -> Error msg
6969+7070+let load_exn path =
7171+ match load path with Ok c -> c | Error msg -> failwith msg
+38
lib/config.mli
···11+(** Configuration file handling for unpac.
22+33+ Loads and parses unpac.toml configuration files using tomlt. *)
44+55+(** {1 Types} *)
66+77+type repo_source =
88+ | Local of string (** Local filesystem path *)
99+ | Remote of string (** Remote URL (git+https://..., etc.) *)
1010+(** Source location for an opam repository. *)
1111+1212+type repo_config = {
1313+ name : string;
1414+ source : repo_source;
1515+}
1616+(** Configuration for a single opam repository. *)
1717+1818+type opam_config = {
1919+ repositories : repo_config list;
2020+ compiler : string option; (** Target compiler version, e.g. "5.4.0" or "ocaml.5.4.0" *)
2121+}
2222+(** Opam-specific configuration. *)
2323+2424+type t = { opam : opam_config }
2525+(** The complete unpac configuration. *)
2626+2727+(** {1 Loading} *)
2828+2929+val load : string -> (t, string) result
3030+(** [load path] loads configuration from the TOML file at [path]. *)
3131+3232+val load_exn : string -> t
3333+(** [load_exn path] is like {!load} but raises on error. *)
3434+3535+(** {1 Codecs} *)
3636+3737+val codec : t Tomlt.t
3838+(** TOML codec for the configuration type. *)
+67
lib/dev_repo.ml
···11+type t = string
22+33+let normalize_url s =
44+ let s = String.lowercase_ascii s in
55+ (* Remove git+ prefix *)
66+ let s =
77+ if String.starts_with ~prefix:"git+" s then
88+ String.sub s 4 (String.length s - 4)
99+ else s
1010+ in
1111+ (* Remove .git suffix *)
1212+ let s =
1313+ if String.ends_with ~suffix:".git" s then
1414+ String.sub s 0 (String.length s - 4)
1515+ else s
1616+ in
1717+ (* Remove trailing slash *)
1818+ let s =
1919+ if String.ends_with ~suffix:"/" s then
2020+ String.sub s 0 (String.length s - 1)
2121+ else s
2222+ in
2323+ (* Strip #branch fragment *)
2424+ let s =
2525+ match String.index_opt s '#' with
2626+ | Some i -> String.sub s 0 i
2727+ | None -> s
2828+ in
2929+ (* Normalize ssh-style github.com:user/repo to github.com/user/repo *)
3030+ let s =
3131+ match String.index_opt s ':' with
3232+ | Some i when i > 0 ->
3333+ let before = String.sub s 0 i in
3434+ let after = String.sub s (i + 1) (String.length s - i - 1) in
3535+ (* Only convert if it looks like host:path (no // after) *)
3636+ if
3737+ (not (String.contains before '/'))
3838+ && not (String.starts_with ~prefix:"/" after)
3939+ && String.contains before '.'
4040+ then before ^ "/" ^ after
4141+ else s
4242+ | _ -> s
4343+ in
4444+ (* Remove protocol prefix for comparison *)
4545+ let s =
4646+ let protocols = [ "https://"; "http://"; "ssh://"; "git://"; "file://" ] in
4747+ List.fold_left
4848+ (fun s proto ->
4949+ if String.starts_with ~prefix:proto s then
5050+ String.sub s (String.length proto) (String.length s - String.length proto)
5151+ else s)
5252+ s protocols
5353+ in
5454+ s
5555+5656+let of_opam_url url = normalize_url (OpamUrl.to_string url)
5757+5858+let of_string s = normalize_url s
5959+6060+let equal = String.equal
6161+let compare = String.compare
6262+let to_string t = t
6363+6464+let pp fmt t = Format.pp_print_string fmt t
6565+6666+module Map = Map.Make (String)
6767+module Set = Set.Make (String)
+47
lib/dev_repo.mli
···11+(** Normalized dev-repo URLs.
22+33+ This module provides URL normalization for dev-repo fields to enable
44+ matching packages that share the same source repository even when
55+ the URLs are written differently.
66+77+ Normalization rules:
88+ - Strip [.git] suffix
99+ - Normalize to lowercase
1010+ - Remove [git+] prefix from transport
1111+ - Normalize [github.com:user/repo] to [github.com/user/repo]
1212+ - Remove trailing slashes
1313+ - Strip [#branch] fragment *)
1414+1515+(** {1 Types} *)
1616+1717+type t
1818+(** Normalized dev-repo URL. *)
1919+2020+(** {1 Creation} *)
2121+2222+val of_opam_url : OpamUrl.t -> t
2323+(** [of_opam_url url] creates a normalized dev-repo from an opam URL. *)
2424+2525+val of_string : string -> t
2626+(** [of_string s] parses and normalizes a URL string. *)
2727+2828+(** {1 Comparison} *)
2929+3030+val equal : t -> t -> bool
3131+(** [equal a b] is [true] if [a] and [b] represent the same repository. *)
3232+3333+val compare : t -> t -> int
3434+(** [compare a b] is a total ordering on normalized URLs. *)
3535+3636+(** {1 Conversion} *)
3737+3838+val to_string : t -> string
3939+(** [to_string t] returns the normalized URL string. *)
4040+4141+val pp : Format.formatter -> t -> unit
4242+(** [pp fmt t] pretty-prints the normalized URL. *)
4343+4444+(** {1 Collections} *)
4545+4646+module Map : Map.S with type key = t
4747+module Set : Set.S with type elt = t
···11+(** Git operations wrapped with Eio and robust error handling. *)
22+33+let src = Logs.Src.create "unpac.git" ~doc:"Git operations"
44+module Log = (val Logs.src_log src : Logs.LOG)
55+66+(* Error types *)
77+88+type error =
99+ | Command_failed of {
1010+ cmd : string list;
1111+ exit_code : int;
1212+ stdout : string;
1313+ stderr : string;
1414+ }
1515+ | Not_a_repository
1616+ | Remote_exists of string
1717+ | Remote_not_found of string
1818+ | Branch_exists of string
1919+ | Branch_not_found of string
2020+ | Merge_conflict of { branch : string; conflicting_files : string list }
2121+ | Rebase_conflict of { onto : string; hint : string }
2222+ | Uncommitted_changes
2323+ | Not_on_branch
2424+ | Detached_head
2525+2626+let pp_error fmt = function
2727+ | Command_failed { cmd; exit_code; stderr; _ } ->
2828+ Format.fprintf fmt "git %a failed (exit %d): %s"
2929+ Fmt.(list ~sep:sp string) cmd exit_code
3030+ (String.trim stderr)
3131+ | Not_a_repository ->
3232+ Format.fprintf fmt "not a git repository"
3333+ | Remote_exists name ->
3434+ Format.fprintf fmt "remote '%s' already exists" name
3535+ | Remote_not_found name ->
3636+ Format.fprintf fmt "remote '%s' not found" name
3737+ | Branch_exists name ->
3838+ Format.fprintf fmt "branch '%s' already exists" name
3939+ | Branch_not_found name ->
4040+ Format.fprintf fmt "branch '%s' not found" name
4141+ | Merge_conflict { branch; conflicting_files } ->
4242+ Format.fprintf fmt "merge conflict in '%s': %a" branch
4343+ Fmt.(list ~sep:comma string) conflicting_files
4444+ | Rebase_conflict { onto; hint } ->
4545+ Format.fprintf fmt "rebase conflict onto '%s': %s" onto hint
4646+ | Uncommitted_changes ->
4747+ Format.fprintf fmt "uncommitted changes in working directory"
4848+ | Not_on_branch ->
4949+ Format.fprintf fmt "not on any branch"
5050+ | Detached_head ->
5151+ Format.fprintf fmt "HEAD is detached"
5252+5353+type Eio.Exn.err += E of error
5454+5555+let () =
5656+ Eio.Exn.register_pp (fun fmt -> function
5757+ | E e -> Format.fprintf fmt "Git %a" pp_error e; true
5858+ | _ -> false)
5959+6060+let err e = Eio.Exn.create (E e)
6161+6262+(* Types *)
6363+6464+type proc_mgr = [ `Generic | `Unix ] Eio.Process.mgr_ty Eio.Resource.t
6565+type path = Eio.Fs.dir_ty Eio.Path.t
6666+6767+(* Helpers *)
6868+6969+let string_trim s = String.trim s
7070+7171+let lines s =
7272+ String.split_on_char '\n' s
7373+ |> List.filter (fun s -> String.trim s <> "")
7474+7575+(* Low-level execution *)
7676+7777+let run ~proc_mgr ?cwd args =
7878+ let full_cmd = "git" :: args in
7979+ Log.debug (fun m -> m "Running: %a" Fmt.(list ~sep:sp string) full_cmd);
8080+ let stdout_buf = Buffer.create 256 in
8181+ let stderr_buf = Buffer.create 256 in
8282+ try
8383+ Eio.Switch.run @@ fun sw ->
8484+ let stdout_r, stdout_w = Eio.Process.pipe proc_mgr ~sw in
8585+ let stderr_r, stderr_w = Eio.Process.pipe proc_mgr ~sw in
8686+ let child = Eio.Process.spawn proc_mgr ~sw
8787+ ?cwd:(Option.map (fun p -> (p :> Eio.Fs.dir_ty Eio.Path.t)) cwd)
8888+ ~stdout:stdout_w ~stderr:stderr_w
8989+ full_cmd
9090+ in
9191+ Eio.Flow.close stdout_w;
9292+ Eio.Flow.close stderr_w;
9393+ (* Read stdout and stderr concurrently *)
9494+ Eio.Fiber.both
9595+ (fun () ->
9696+ let chunk = Cstruct.create 4096 in
9797+ let rec loop () =
9898+ match Eio.Flow.single_read stdout_r chunk with
9999+ | n ->
100100+ Buffer.add_string stdout_buf (Cstruct.to_string (Cstruct.sub chunk 0 n));
101101+ loop ()
102102+ | exception End_of_file -> ()
103103+ in
104104+ loop ())
105105+ (fun () ->
106106+ let chunk = Cstruct.create 4096 in
107107+ let rec loop () =
108108+ match Eio.Flow.single_read stderr_r chunk with
109109+ | n ->
110110+ Buffer.add_string stderr_buf (Cstruct.to_string (Cstruct.sub chunk 0 n));
111111+ loop ()
112112+ | exception End_of_file -> ()
113113+ in
114114+ loop ());
115115+ let status = Eio.Process.await child in
116116+ let stdout = Buffer.contents stdout_buf in
117117+ let stderr = Buffer.contents stderr_buf in
118118+ match status with
119119+ | `Exited 0 ->
120120+ Log.debug (fun m -> m "Output: %s" (string_trim stdout));
121121+ Ok stdout
122122+ | `Exited exit_code ->
123123+ Log.debug (fun m -> m "Failed (exit %d): %s" exit_code (string_trim stderr));
124124+ Error (Command_failed { cmd = args; exit_code; stdout; stderr })
125125+ | `Signaled signal ->
126126+ Log.debug (fun m -> m "Killed by signal %d" signal);
127127+ Error (Command_failed { cmd = args; exit_code = 128 + signal; stdout; stderr })
128128+ with exn ->
129129+ Log.err (fun m -> m "Exception running git: %a" Fmt.exn exn);
130130+ raise exn
131131+132132+let run_exn ~proc_mgr ?cwd args =
133133+ match run ~proc_mgr ?cwd args with
134134+ | Ok output -> output
135135+ | Error e ->
136136+ let ex = err e in
137137+ raise (Eio.Exn.add_context ex "running git %a" Fmt.(list ~sep:sp string) args)
138138+139139+let run_lines ~proc_mgr ?cwd args =
140140+ run_exn ~proc_mgr ?cwd args |> string_trim |> lines
141141+142142+(* Queries *)
143143+144144+let is_repository path =
145145+ let git_dir = Eio.Path.(path / ".git") in
146146+ match Eio.Path.kind ~follow:false git_dir with
147147+ | `Directory | `Regular_file -> true (* .git can be a file for worktrees *)
148148+ | _ -> false
149149+ | exception _ -> false
150150+151151+let current_branch ~proc_mgr ~cwd =
152152+ match run ~proc_mgr ~cwd ["symbolic-ref"; "--short"; "HEAD"] with
153153+ | Ok output -> Some (string_trim output)
154154+ | Error _ -> None
155155+156156+let current_branch_exn ~proc_mgr ~cwd =
157157+ match current_branch ~proc_mgr ~cwd with
158158+ | Some b -> b
159159+ | None -> raise (err Not_on_branch)
160160+161161+let current_head ~proc_mgr ~cwd =
162162+ run_exn ~proc_mgr ~cwd ["rev-parse"; "HEAD"] |> string_trim
163163+164164+let has_uncommitted_changes ~proc_mgr ~cwd =
165165+ let status = run_exn ~proc_mgr ~cwd ["status"; "--porcelain"] in
166166+ String.trim status <> ""
167167+168168+let remote_exists ~proc_mgr ~cwd name =
169169+ match run ~proc_mgr ~cwd ["remote"; "get-url"; name] with
170170+ | Ok _ -> true
171171+ | Error _ -> false
172172+173173+let branch_exists ~proc_mgr ~cwd name =
174174+ match run ~proc_mgr ~cwd ["show-ref"; "--verify"; "--quiet"; "refs/heads/" ^ name] with
175175+ | Ok _ -> true
176176+ | Error _ -> false
177177+178178+let rev_parse ~proc_mgr ~cwd ref_ =
179179+ match run ~proc_mgr ~cwd ["rev-parse"; "--verify"; "--quiet"; ref_] with
180180+ | Ok output -> Some (string_trim output)
181181+ | Error _ -> None
182182+183183+let rev_parse_exn ~proc_mgr ~cwd ref_ =
184184+ match rev_parse ~proc_mgr ~cwd ref_ with
185185+ | Some sha -> sha
186186+ | None -> raise (err (Branch_not_found ref_))
187187+188188+let rev_parse_short ~proc_mgr ~cwd ref_ =
189189+ run_exn ~proc_mgr ~cwd ["rev-parse"; "--short"; ref_] |> string_trim
190190+191191+let ls_remote_default_branch ~proc_mgr ~url =
192192+ Log.info (fun m -> m "Detecting default branch for %s..." url);
193193+ (* Try to get the default branch from the remote *)
194194+ let output = run_exn ~proc_mgr ["ls-remote"; "--symref"; url; "HEAD"] in
195195+ (* Parse output like: ref: refs/heads/main\tHEAD *)
196196+ let default =
197197+ let lines = String.split_on_char '\n' output in
198198+ List.find_map (fun line ->
199199+ if String.starts_with ~prefix:"ref:" line then
200200+ let parts = String.split_on_char '\t' line in
201201+ match parts with
202202+ | ref_part :: _ ->
203203+ let ref_part = String.trim ref_part in
204204+ if String.starts_with ~prefix:"ref: refs/heads/" ref_part then
205205+ Some (String.sub ref_part 16 (String.length ref_part - 16))
206206+ else None
207207+ | _ -> None
208208+ else None
209209+ ) lines
210210+ in
211211+ match default with
212212+ | Some branch ->
213213+ Log.info (fun m -> m "Default branch: %s" branch);
214214+ branch
215215+ | None ->
216216+ (* Fallback: try common branch names *)
217217+ Log.debug (fun m -> m "Could not detect default branch, trying common names...");
218218+ let try_branch name =
219219+ match run ~proc_mgr ["ls-remote"; "--heads"; url; name] with
220220+ | Ok output when String.trim output <> "" -> true
221221+ | _ -> false
222222+ in
223223+ if try_branch "main" then "main"
224224+ else if try_branch "master" then "master"
225225+ else begin
226226+ Log.warn (fun m -> m "Could not detect default branch, assuming 'main'");
227227+ "main"
228228+ end
229229+230230+let list_remotes ~proc_mgr ~cwd =
231231+ run_lines ~proc_mgr ~cwd ["remote"]
232232+233233+let remote_url ~proc_mgr ~cwd name =
234234+ match run ~proc_mgr ~cwd ["remote"; "get-url"; name] with
235235+ | Ok output -> Some (string_trim output)
236236+ | Error _ -> None
237237+238238+let log_oneline ~proc_mgr ~cwd ?max_count from_ref to_ref =
239239+ let range = from_ref ^ ".." ^ to_ref in
240240+ let args = ["log"; "--oneline"; range] in
241241+ let args = match max_count with
242242+ | Some n -> args @ ["--max-count"; string_of_int n]
243243+ | None -> args
244244+ in
245245+ run_lines ~proc_mgr ~cwd args
246246+247247+let diff_stat ~proc_mgr ~cwd from_ref to_ref =
248248+ let range = from_ref ^ ".." ^ to_ref in
249249+ run_exn ~proc_mgr ~cwd ["diff"; "--stat"; range]
250250+251251+let ls_tree ~proc_mgr ~cwd ~tree ~path =
252252+ match run ~proc_mgr ~cwd ["ls-tree"; tree; path] with
253253+ | Ok output -> String.trim output <> ""
254254+ | Error _ -> false
255255+256256+let rev_list_count ~proc_mgr ~cwd from_ref to_ref =
257257+ let range = from_ref ^ ".." ^ to_ref in
258258+ let output = run_exn ~proc_mgr ~cwd ["rev-list"; "--count"; range] in
259259+ int_of_string (string_trim output)
260260+261261+(* Idempotent mutations *)
262262+263263+let ensure_remote ~proc_mgr ~cwd ~name ~url =
264264+ match remote_url ~proc_mgr ~cwd name with
265265+ | None ->
266266+ Log.info (fun m -> m "Adding remote %s -> %s" name url);
267267+ run_exn ~proc_mgr ~cwd ["remote"; "add"; name; url] |> ignore;
268268+ `Created
269269+ | Some existing_url ->
270270+ if existing_url = url then begin
271271+ Log.debug (fun m -> m "Remote %s already exists with correct URL" name);
272272+ `Existed
273273+ end else begin
274274+ Log.info (fun m -> m "Updating remote %s URL: %s -> %s" name existing_url url);
275275+ run_exn ~proc_mgr ~cwd ["remote"; "set-url"; name; url] |> ignore;
276276+ `Updated
277277+ end
278278+279279+let ensure_branch ~proc_mgr ~cwd ~name ~start_point =
280280+ if branch_exists ~proc_mgr ~cwd name then begin
281281+ Log.debug (fun m -> m "Branch %s already exists" name);
282282+ `Existed
283283+ end else begin
284284+ Log.info (fun m -> m "Creating branch %s at %s" name start_point);
285285+ run_exn ~proc_mgr ~cwd ["branch"; name; start_point] |> ignore;
286286+ `Created
287287+ end
288288+289289+(* State-changing operations *)
290290+291291+let init ~proc_mgr ~cwd =
292292+ Log.info (fun m -> m "Initializing git repository...");
293293+ run_exn ~proc_mgr ~cwd ["init"] |> ignore
294294+295295+let fetch ~proc_mgr ~cwd ~remote =
296296+ Log.info (fun m -> m "Fetching from %s..." remote);
297297+ run_exn ~proc_mgr ~cwd ["fetch"; remote] |> ignore
298298+299299+let checkout ~proc_mgr ~cwd ref_ =
300300+ Log.debug (fun m -> m "Checking out %s" ref_);
301301+ run_exn ~proc_mgr ~cwd ["checkout"; ref_] |> ignore
302302+303303+let checkout_orphan ~proc_mgr ~cwd name =
304304+ Log.info (fun m -> m "Creating orphan branch %s" name);
305305+ run_exn ~proc_mgr ~cwd ["checkout"; "--orphan"; name] |> ignore
306306+307307+let read_tree_prefix ~proc_mgr ~cwd ~prefix ~tree =
308308+ Log.debug (fun m -> m "Reading tree %s with prefix %s" tree prefix);
309309+ run_exn ~proc_mgr ~cwd ["read-tree"; "--prefix=" ^ prefix; tree] |> ignore
310310+311311+let checkout_index ~proc_mgr ~cwd =
312312+ Log.debug (fun m -> m "Checking out index to working directory");
313313+ run_exn ~proc_mgr ~cwd ["checkout-index"; "-a"; "-f"] |> ignore
314314+315315+let rm_rf ~proc_mgr ~cwd ~target =
316316+ Log.debug (fun m -> m "Removing %s from git" target);
317317+ (* Ignore errors - target might not exist *)
318318+ ignore (run ~proc_mgr ~cwd ["rm"; "-rf"; target])
319319+320320+let rm_cached_rf ~proc_mgr ~cwd =
321321+ Log.debug (fun m -> m "Removing all files from index");
322322+ (* Ignore errors - index might be empty *)
323323+ ignore (run ~proc_mgr ~cwd ["rm"; "-rf"; "--cached"; "."])
324324+325325+let add_all ~proc_mgr ~cwd =
326326+ Log.debug (fun m -> m "Staging all changes");
327327+ run_exn ~proc_mgr ~cwd ["add"; "-A"] |> ignore
328328+329329+let commit ~proc_mgr ~cwd ~message =
330330+ Log.debug (fun m -> m "Committing: %s" (String.sub message 0 (min 50 (String.length message))));
331331+ run_exn ~proc_mgr ~cwd ["commit"; "-m"; message] |> ignore
332332+333333+let commit_allow_empty ~proc_mgr ~cwd ~message =
334334+ Log.debug (fun m -> m "Committing (allow empty): %s" (String.sub message 0 (min 50 (String.length message))));
335335+ run_exn ~proc_mgr ~cwd ["commit"; "--allow-empty"; "-m"; message] |> ignore
336336+337337+let branch_create ~proc_mgr ~cwd ~name ~start_point =
338338+ Log.info (fun m -> m "Creating branch %s at %s" name start_point);
339339+ run_exn ~proc_mgr ~cwd ["branch"; name; start_point] |> ignore
340340+341341+let branch_force ~proc_mgr ~cwd ~name ~point =
342342+ Log.info (fun m -> m "Force-moving branch %s to %s" name point);
343343+ run_exn ~proc_mgr ~cwd ["branch"; "-f"; name; point] |> ignore
344344+345345+let remote_add ~proc_mgr ~cwd ~name ~url =
346346+ Log.info (fun m -> m "Adding remote %s -> %s" name url);
347347+ run_exn ~proc_mgr ~cwd ["remote"; "add"; name; url] |> ignore
348348+349349+let remote_set_url ~proc_mgr ~cwd ~name ~url =
350350+ Log.info (fun m -> m "Setting remote %s URL to %s" name url);
351351+ run_exn ~proc_mgr ~cwd ["remote"; "set-url"; name; url] |> ignore
352352+353353+let merge_allow_unrelated ~proc_mgr ~cwd ~branch ~message =
354354+ Log.info (fun m -> m "Merging %s (allow unrelated histories)..." branch);
355355+ match run ~proc_mgr ~cwd ["merge"; "--allow-unrelated-histories"; "-m"; message; branch] with
356356+ | Ok _ -> Ok ()
357357+ | Error (Command_failed { exit_code = 1; _ }) ->
358358+ (* Merge conflict - get list of conflicting files *)
359359+ let output = run_exn ~proc_mgr ~cwd ["diff"; "--name-only"; "--diff-filter=U"] in
360360+ let files = lines output in
361361+ Log.warn (fun m -> m "Merge conflict: %a" Fmt.(list ~sep:comma string) files);
362362+ Error (`Conflict files)
363363+ | Error e ->
364364+ raise (err e)
365365+366366+let rebase ~proc_mgr ~cwd ~onto =
367367+ Log.info (fun m -> m "Rebasing onto %s..." onto);
368368+ match run ~proc_mgr ~cwd ["rebase"; onto] with
369369+ | Ok _ -> Ok ()
370370+ | Error (Command_failed { stderr; _ }) ->
371371+ let hint =
372372+ if String.length stderr > 200 then
373373+ String.sub stderr 0 200 ^ "..."
374374+ else
375375+ stderr
376376+ in
377377+ Log.warn (fun m -> m "Rebase conflict onto %s" onto);
378378+ Error (`Conflict hint)
379379+ | Error e ->
380380+ raise (err e)
381381+382382+let rebase_abort ~proc_mgr ~cwd =
383383+ Log.info (fun m -> m "Aborting rebase...");
384384+ ignore (run ~proc_mgr ~cwd ["rebase"; "--abort"])
385385+386386+let merge_abort ~proc_mgr ~cwd =
387387+ Log.info (fun m -> m "Aborting merge...");
388388+ ignore (run ~proc_mgr ~cwd ["merge"; "--abort"])
389389+390390+let reset_hard ~proc_mgr ~cwd ref_ =
391391+ Log.info (fun m -> m "Hard reset to %s" ref_);
392392+ run_exn ~proc_mgr ~cwd ["reset"; "--hard"; ref_] |> ignore
393393+394394+let clean_fd ~proc_mgr ~cwd =
395395+ Log.debug (fun m -> m "Cleaning untracked files");
396396+ run_exn ~proc_mgr ~cwd ["clean"; "-fd"] |> ignore
+345
lib/git.mli
···11+(** Git operations wrapped with Eio and robust error handling.
22+33+ All git commands are executed via [Eio.Process] with proper logging
44+ and error context. Errors are wrapped in [Eio.Exn.Io] with context
55+ chains for debugging. *)
66+77+(** {1 Error Types} *)
88+99+type error =
1010+ | Command_failed of {
1111+ cmd : string list;
1212+ exit_code : int;
1313+ stdout : string;
1414+ stderr : string;
1515+ }
1616+ | Not_a_repository
1717+ | Remote_exists of string
1818+ | Remote_not_found of string
1919+ | Branch_exists of string
2020+ | Branch_not_found of string
2121+ | Merge_conflict of { branch : string; conflicting_files : string list }
2222+ | Rebase_conflict of { onto : string; hint : string }
2323+ | Uncommitted_changes
2424+ | Not_on_branch
2525+ | Detached_head
2626+2727+val pp_error : Format.formatter -> error -> unit
2828+2929+type Eio.Exn.err += E of error
3030+3131+val err : error -> exn
3232+(** [err e] creates an [Eio.Exn.Io] exception with the given error. *)
3333+3434+(** {1 Types} *)
3535+3636+type proc_mgr = [ `Generic | `Unix ] Eio.Process.mgr_ty Eio.Resource.t
3737+type path = Eio.Fs.dir_ty Eio.Path.t
3838+3939+(** {1 Low-level execution} *)
4040+4141+val run :
4242+ proc_mgr:proc_mgr ->
4343+ ?cwd:path ->
4444+ string list ->
4545+ (string, error) result
4646+(** [run ~proc_mgr args] executes [git args] and returns stdout on success. *)
4747+4848+val run_exn :
4949+ proc_mgr:proc_mgr ->
5050+ ?cwd:path ->
5151+ string list ->
5252+ string
5353+(** [run_exn ~proc_mgr args] executes [git args] and returns stdout.
5454+ Raises on failure with context. *)
5555+5656+val run_lines :
5757+ proc_mgr:proc_mgr ->
5858+ ?cwd:path ->
5959+ string list ->
6060+ string list
6161+(** [run_lines ~proc_mgr args] executes and splits output by newlines. *)
6262+6363+(** {1 Queries - Safe read-only operations} *)
6464+6565+val is_repository : path -> bool
6666+(** [is_repository path] checks if [path] contains a [.git] directory. *)
6767+6868+val current_branch :
6969+ proc_mgr:proc_mgr ->
7070+ cwd:path ->
7171+ string option
7272+(** [current_branch] returns [Some branch] if on a branch, [None] if detached. *)
7373+7474+val current_branch_exn :
7575+ proc_mgr:proc_mgr ->
7676+ cwd:path ->
7777+ string
7878+(** [current_branch_exn] returns current branch or raises [Not_on_branch]. *)
7979+8080+val current_head :
8181+ proc_mgr:proc_mgr ->
8282+ cwd:path ->
8383+ string
8484+(** [current_head] returns the current HEAD SHA. *)
8585+8686+val has_uncommitted_changes :
8787+ proc_mgr:proc_mgr ->
8888+ cwd:path ->
8989+ bool
9090+(** [has_uncommitted_changes] returns true if there are staged or unstaged changes. *)
9191+9292+val remote_exists :
9393+ proc_mgr:proc_mgr ->
9494+ cwd:path ->
9595+ string ->
9696+ bool
9797+(** [remote_exists ~proc_mgr ~cwd name] checks if remote [name] exists. *)
9898+9999+val branch_exists :
100100+ proc_mgr:proc_mgr ->
101101+ cwd:path ->
102102+ string ->
103103+ bool
104104+(** [branch_exists ~proc_mgr ~cwd name] checks if branch [name] exists. *)
105105+106106+val rev_parse :
107107+ proc_mgr:proc_mgr ->
108108+ cwd:path ->
109109+ string ->
110110+ string option
111111+(** [rev_parse ~proc_mgr ~cwd ref] returns the SHA for [ref], or [None]. *)
112112+113113+val rev_parse_exn :
114114+ proc_mgr:proc_mgr ->
115115+ cwd:path ->
116116+ string ->
117117+ string
118118+(** [rev_parse_exn] returns SHA or raises. *)
119119+120120+val rev_parse_short :
121121+ proc_mgr:proc_mgr ->
122122+ cwd:path ->
123123+ string ->
124124+ string
125125+(** [rev_parse_short] returns abbreviated SHA. *)
126126+127127+val ls_remote_default_branch :
128128+ proc_mgr:proc_mgr ->
129129+ url:string ->
130130+ string
131131+(** [ls_remote_default_branch ~proc_mgr ~url] detects the default branch of remote. *)
132132+133133+val list_remotes :
134134+ proc_mgr:proc_mgr ->
135135+ cwd:path ->
136136+ string list
137137+(** [list_remotes] returns all remote names. *)
138138+139139+val remote_url :
140140+ proc_mgr:proc_mgr ->
141141+ cwd:path ->
142142+ string ->
143143+ string option
144144+(** [remote_url ~proc_mgr ~cwd name] returns the URL for remote [name]. *)
145145+146146+val log_oneline :
147147+ proc_mgr:proc_mgr ->
148148+ cwd:path ->
149149+ ?max_count:int ->
150150+ string ->
151151+ string ->
152152+ string list
153153+(** [log_oneline ~proc_mgr ~cwd from_ref to_ref] returns commit summaries. *)
154154+155155+val diff_stat :
156156+ proc_mgr:proc_mgr ->
157157+ cwd:path ->
158158+ string ->
159159+ string ->
160160+ string
161161+(** [diff_stat ~proc_mgr ~cwd from_ref to_ref] returns diff statistics. *)
162162+163163+val ls_tree :
164164+ proc_mgr:proc_mgr ->
165165+ cwd:path ->
166166+ tree:string ->
167167+ path:string ->
168168+ bool
169169+(** [ls_tree ~proc_mgr ~cwd ~tree ~path] checks if [path] exists in [tree]. *)
170170+171171+val rev_list_count :
172172+ proc_mgr:proc_mgr ->
173173+ cwd:path ->
174174+ string ->
175175+ string ->
176176+ int
177177+(** [rev_list_count ~proc_mgr ~cwd from_ref to_ref] counts commits between refs. *)
178178+179179+(** {1 Idempotent mutations - Safe to re-run} *)
180180+181181+val ensure_remote :
182182+ proc_mgr:proc_mgr ->
183183+ cwd:path ->
184184+ name:string ->
185185+ url:string ->
186186+ [ `Created | `Existed | `Updated ]
187187+(** [ensure_remote] adds remote if missing, updates URL if different. *)
188188+189189+val ensure_branch :
190190+ proc_mgr:proc_mgr ->
191191+ cwd:path ->
192192+ name:string ->
193193+ start_point:string ->
194194+ [ `Created | `Existed ]
195195+(** [ensure_branch] creates branch if it doesn't exist. *)
196196+197197+(** {1 State-changing operations} *)
198198+199199+val init :
200200+ proc_mgr:proc_mgr ->
201201+ cwd:path ->
202202+ unit
203203+(** [init] initializes a new git repository. *)
204204+205205+val fetch :
206206+ proc_mgr:proc_mgr ->
207207+ cwd:path ->
208208+ remote:string ->
209209+ unit
210210+(** [fetch] fetches from a remote. *)
211211+212212+val checkout :
213213+ proc_mgr:proc_mgr ->
214214+ cwd:path ->
215215+ string ->
216216+ unit
217217+(** [checkout] switches to a branch or commit. *)
218218+219219+val checkout_orphan :
220220+ proc_mgr:proc_mgr ->
221221+ cwd:path ->
222222+ string ->
223223+ unit
224224+(** [checkout_orphan] creates and switches to a new orphan branch. *)
225225+226226+val read_tree_prefix :
227227+ proc_mgr:proc_mgr ->
228228+ cwd:path ->
229229+ prefix:string ->
230230+ tree:string ->
231231+ unit
232232+(** [read_tree_prefix] reads a tree into the index with a path prefix. *)
233233+234234+val checkout_index :
235235+ proc_mgr:proc_mgr ->
236236+ cwd:path ->
237237+ unit
238238+(** [checkout_index] checks out files from the index to working directory. *)
239239+240240+val rm_rf :
241241+ proc_mgr:proc_mgr ->
242242+ cwd:path ->
243243+ target:string ->
244244+ unit
245245+(** [rm_rf] removes files/directories from git tracking. *)
246246+247247+val rm_cached_rf :
248248+ proc_mgr:proc_mgr ->
249249+ cwd:path ->
250250+ unit
251251+(** [rm_cached_rf] removes all files from index (for orphan branch setup). *)
252252+253253+val add_all :
254254+ proc_mgr:proc_mgr ->
255255+ cwd:path ->
256256+ unit
257257+(** [add_all] stages all changes. *)
258258+259259+val commit :
260260+ proc_mgr:proc_mgr ->
261261+ cwd:path ->
262262+ message:string ->
263263+ unit
264264+(** [commit] creates a commit with the given message. *)
265265+266266+val commit_allow_empty :
267267+ proc_mgr:proc_mgr ->
268268+ cwd:path ->
269269+ message:string ->
270270+ unit
271271+(** [commit_allow_empty] creates a commit even if there are no changes. *)
272272+273273+val branch_create :
274274+ proc_mgr:proc_mgr ->
275275+ cwd:path ->
276276+ name:string ->
277277+ start_point:string ->
278278+ unit
279279+(** [branch_create] creates a new branch at [start_point]. *)
280280+281281+val branch_force :
282282+ proc_mgr:proc_mgr ->
283283+ cwd:path ->
284284+ name:string ->
285285+ point:string ->
286286+ unit
287287+(** [branch_force] moves branch to point (creates if needed). *)
288288+289289+val remote_add :
290290+ proc_mgr:proc_mgr ->
291291+ cwd:path ->
292292+ name:string ->
293293+ url:string ->
294294+ unit
295295+(** [remote_add] adds a new remote. *)
296296+297297+val remote_set_url :
298298+ proc_mgr:proc_mgr ->
299299+ cwd:path ->
300300+ name:string ->
301301+ url:string ->
302302+ unit
303303+(** [remote_set_url] updates the URL of an existing remote. *)
304304+305305+val merge_allow_unrelated :
306306+ proc_mgr:proc_mgr ->
307307+ cwd:path ->
308308+ branch:string ->
309309+ message:string ->
310310+ (unit, [ `Conflict of string list ]) result
311311+(** [merge_allow_unrelated] merges with [--allow-unrelated-histories].
312312+ Returns [Error (`Conflict files)] if there are conflicts. *)
313313+314314+val rebase :
315315+ proc_mgr:proc_mgr ->
316316+ cwd:path ->
317317+ onto:string ->
318318+ (unit, [ `Conflict of string ]) result
319319+(** [rebase] rebases current branch onto [onto].
320320+ Returns [Error (`Conflict hint)] if there are conflicts. *)
321321+322322+val rebase_abort :
323323+ proc_mgr:proc_mgr ->
324324+ cwd:path ->
325325+ unit
326326+(** [rebase_abort] aborts an in-progress rebase. *)
327327+328328+val merge_abort :
329329+ proc_mgr:proc_mgr ->
330330+ cwd:path ->
331331+ unit
332332+(** [merge_abort] aborts an in-progress merge. *)
333333+334334+val reset_hard :
335335+ proc_mgr:proc_mgr ->
336336+ cwd:path ->
337337+ string ->
338338+ unit
339339+(** [reset_hard] does a hard reset to the given ref. *)
340340+341341+val clean_fd :
342342+ proc_mgr:proc_mgr ->
343343+ cwd:path ->
344344+ unit
345345+(** [clean_fd] removes untracked files and directories. *)
+221
lib/output.ml
···11+type format = Text | Json | Toml
22+33+(* JSON Codecs *)
44+55+let dev_repo_jsont =
66+ Jsont.(
77+ map
88+ ~dec:(fun s -> Dev_repo.of_string s)
99+ ~enc:Dev_repo.to_string string)
1010+1111+let package_name_jsont =
1212+ Jsont.(
1313+ map
1414+ ~dec:OpamPackage.Name.of_string
1515+ ~enc:OpamPackage.Name.to_string
1616+ string)
1717+1818+let package_version_jsont =
1919+ Jsont.(
2020+ map
2121+ ~dec:OpamPackage.Version.of_string
2222+ ~enc:OpamPackage.Version.to_string
2323+ string)
2424+2525+let package_info_jsont : Repo_index.package_info Jsont.t =
2626+ let open Jsont in
2727+ let open Repo_index in
2828+ Object.map
2929+ ~kind:"package_info"
3030+ (fun name version dev_repo source_repo ->
3131+ (* Create a minimal opam record - we don't encode the full opam file *)
3232+ let opam = OpamFile.OPAM.empty in
3333+ { name; version; opam; dev_repo; source_repo })
3434+ |> Object.mem "name" package_name_jsont
3535+ ~enc:(fun p -> p.name)
3636+ |> Object.mem "version" package_version_jsont
3737+ ~enc:(fun p -> p.version)
3838+ |> Object.opt_mem "dev_repo" dev_repo_jsont
3939+ ~enc:(fun p -> p.dev_repo)
4040+ |> Object.mem "source_repo" string
4141+ ~enc:(fun p -> p.source_repo)
4242+ |> Object.finish
4343+4444+let package_list_jsont = Jsont.list package_info_jsont
4545+4646+(* Text Output *)
4747+4848+let pp_package_info fmt (info : Repo_index.package_info) =
4949+ Format.fprintf fmt "%s.%s"
5050+ (OpamPackage.Name.to_string info.name)
5151+ (OpamPackage.Version.to_string info.version)
5252+5353+let pp_package_info_detailed fmt (info : Repo_index.package_info) =
5454+ Format.fprintf fmt "@[<v>%s.%s@, repo: %s"
5555+ (OpamPackage.Name.to_string info.name)
5656+ (OpamPackage.Version.to_string info.version)
5757+ info.source_repo;
5858+ (match info.dev_repo with
5959+ | Some dr -> Format.fprintf fmt "@, dev-repo: %s" (Dev_repo.to_string dr)
6060+ | None -> ());
6161+ Format.fprintf fmt "@]"
6262+6363+(* JSON encoding helper *)
6464+let encode_json codec value =
6565+ match Jsont_bytesrw.encode_string codec value with
6666+ | Ok s -> s
6767+ | Error e -> failwith e
6868+6969+(* Output functions *)
7070+7171+let output_package_list format packages =
7272+ match format with
7373+ | Text ->
7474+ List.iter
7575+ (fun info -> Format.printf "%a@." pp_package_info info)
7676+ packages
7777+ | Json ->
7878+ let json = encode_json package_list_jsont packages in
7979+ print_endline json
8080+ | Toml ->
8181+ (* For TOML, we output as array of inline tables *)
8282+ Format.printf "# Package list@.";
8383+ List.iter
8484+ (fun (info : Repo_index.package_info) ->
8585+ Format.printf "[[packages]]@.";
8686+ Format.printf "name = %S@." (OpamPackage.Name.to_string info.name);
8787+ Format.printf "version = %S@."
8888+ (OpamPackage.Version.to_string info.version);
8989+ Format.printf "@.")
9090+ packages
9191+9292+let output_package_info format packages =
9393+ match format with
9494+ | Text ->
9595+ List.iter
9696+ (fun info -> Format.printf "%a@.@." pp_package_info_detailed info)
9797+ packages
9898+ | Json ->
9999+ let json = encode_json package_list_jsont packages in
100100+ print_endline json
101101+ | Toml ->
102102+ List.iter
103103+ (fun (info : Repo_index.package_info) ->
104104+ Format.printf "[[packages]]@.";
105105+ Format.printf "name = %S@." (OpamPackage.Name.to_string info.name);
106106+ Format.printf "version = %S@."
107107+ (OpamPackage.Version.to_string info.version);
108108+ Format.printf "source_repo = %S@." info.source_repo;
109109+ (match info.dev_repo with
110110+ | Some dr -> Format.printf "dev_repo = %S@." (Dev_repo.to_string dr)
111111+ | None -> ());
112112+ Format.printf "@.")
113113+ packages
114114+115115+let output_related format pkg_name packages =
116116+ match format with
117117+ | Text ->
118118+ Format.printf "Packages related to %s:@." pkg_name;
119119+ List.iter
120120+ (fun info -> Format.printf " %a@." pp_package_info info)
121121+ packages
122122+ | Json ->
123123+ let json_obj =
124124+ let open Jsont in
125125+ Object.map ~kind:"related_packages" (fun pkg related ->
126126+ (pkg, related))
127127+ |> Object.mem "package" string ~enc:fst
128128+ |> Object.mem "related" package_list_jsont ~enc:snd
129129+ |> Object.finish
130130+ in
131131+ let json = encode_json json_obj (pkg_name, packages) in
132132+ print_endline json
133133+ | Toml ->
134134+ Format.printf "package = %S@." pkg_name;
135135+ Format.printf "@.";
136136+ List.iter
137137+ (fun (info : Repo_index.package_info) ->
138138+ Format.printf "[[related]]@.";
139139+ Format.printf "name = %S@." (OpamPackage.Name.to_string info.name);
140140+ Format.printf "version = %S@."
141141+ (OpamPackage.Version.to_string info.version);
142142+ Format.printf "@.")
143143+ packages
144144+145145+let pp_grouped_source fmt (group : Source.grouped_sources) =
146146+ (match group.dev_repo with
147147+ | Some dr ->
148148+ Format.fprintf fmt "@[<v>## %s@," (Dev_repo.to_string dr)
149149+ | None ->
150150+ Format.fprintf fmt "@[<v>## (no dev-repo)@,");
151151+ List.iter
152152+ (fun (src : Source.package_source) ->
153153+ Format.fprintf fmt " %s.%s" src.name src.version;
154154+ (match src.source with
155155+ | Source.ArchiveSource a ->
156156+ Format.fprintf fmt " [%s]" a.url
157157+ | Source.GitSource g ->
158158+ Format.fprintf fmt " [git: %s]" g.url
159159+ | Source.NoSource -> ());
160160+ Format.fprintf fmt "@,")
161161+ group.packages;
162162+ Format.fprintf fmt "@]"
163163+164164+let output_sources format sources =
165165+ let grouped = Source.group_by_dev_repo sources in
166166+ match format with
167167+ | Text ->
168168+ List.iter (fun g -> Format.printf "%a@." pp_grouped_source g) grouped
169169+ | Json ->
170170+ let json = encode_json Source.grouped_sources_list_jsont grouped in
171171+ print_endline json
172172+ | Toml ->
173173+ (* Format as array of tables with nested packages *)
174174+ List.iter
175175+ (fun (group : Source.grouped_sources) ->
176176+ Format.printf "[[repos]]@.";
177177+ (match group.dev_repo with
178178+ | Some dr -> Format.printf "dev_repo = %S@." (Dev_repo.to_string dr)
179179+ | None -> Format.printf "# no dev-repo@.");
180180+ Format.printf "@.";
181181+ List.iter
182182+ (fun (src : Source.package_source) ->
183183+ Format.printf "[[repos.packages]]@.";
184184+ Format.printf "name = %S@." src.name;
185185+ Format.printf "version = %S@." src.version;
186186+ (match src.source with
187187+ | Source.ArchiveSource a ->
188188+ Format.printf "[repos.packages.source]@.";
189189+ Format.printf "type = \"archive\"@.";
190190+ Format.printf "url = %S@." a.url;
191191+ if a.checksums <> [] then begin
192192+ Format.printf "checksums = [";
193193+ List.iteri
194194+ (fun i cs ->
195195+ if i > 0 then Format.printf ", ";
196196+ Format.printf "%S" cs)
197197+ a.checksums;
198198+ Format.printf "]@."
199199+ end;
200200+ if a.mirrors <> [] then begin
201201+ Format.printf "mirrors = [";
202202+ List.iteri
203203+ (fun i m ->
204204+ if i > 0 then Format.printf ", ";
205205+ Format.printf "%S" m)
206206+ a.mirrors;
207207+ Format.printf "]@."
208208+ end
209209+ | Source.GitSource g ->
210210+ Format.printf "[repos.packages.source]@.";
211211+ Format.printf "type = \"git\"@.";
212212+ Format.printf "url = %S@." g.url;
213213+ (match g.branch with
214214+ | Some b -> Format.printf "branch = %S@." b
215215+ | None -> ())
216216+ | Source.NoSource ->
217217+ Format.printf "[repos.packages.source]@.";
218218+ Format.printf "type = \"none\"@.");
219219+ Format.printf "@.")
220220+ group.packages)
221221+ grouped
+35
lib/output.mli
···11+(** Output formatting for unpac commands.
22+33+ Provides plain text, JSON, and TOML output formats. *)
44+55+(** {1 Output Format} *)
66+77+type format =
88+ | Text (** Human-readable text output *)
99+ | Json (** Machine-readable JSON output *)
1010+ | Toml (** TOML output *)
1111+(** Output format selection. *)
1212+1313+(** {1 Package Output} *)
1414+1515+val output_package_list : format -> Repo_index.package_info list -> unit
1616+(** [output_package_list fmt packages] outputs a list of packages. *)
1717+1818+val output_package_info : format -> Repo_index.package_info list -> unit
1919+(** [output_package_info fmt packages] outputs detailed package information. *)
2020+2121+val output_related : format -> string -> Repo_index.package_info list -> unit
2222+(** [output_related fmt pkg_name packages] outputs related packages. *)
2323+2424+(** {1 Source Output} *)
2525+2626+val output_sources : format -> Source.package_source list -> unit
2727+(** [output_sources fmt sources] outputs package sources. *)
2828+2929+(** {1 JSON Codecs} *)
3030+3131+val package_info_jsont : Repo_index.package_info Jsont.t
3232+(** JSON codec for package info. *)
3333+3434+val package_list_jsont : Repo_index.package_info list Jsont.t
3535+(** JSON codec for package list. *)
+323
lib/project.ml
···11+(** Project management - handling project branches. *)
22+33+let src = Logs.Src.create "unpac.project" ~doc:"Project operations"
44+module Log = (val Logs.src_log src : Logs.LOG)
55+66+(* Option helper for compatibility *)
77+let option_value ~default = function
88+ | Some x -> x
99+ | None -> default
1010+1111+(* Types *)
1212+1313+type project_info = {
1414+ name : string;
1515+ branch : string;
1616+ description : string;
1717+ created : string;
1818+}
1919+2020+type registry = {
2121+ version : string;
2222+ projects : project_info list;
2323+}
2424+2525+(* Branch conventions *)
2626+2727+let project_prefix = "project/"
2828+2929+let project_branch name = project_prefix ^ name
3030+3131+let is_project_branch branch =
3232+ String.starts_with ~prefix:project_prefix branch
3333+3434+let project_name_of_branch branch =
3535+ if is_project_branch branch then
3636+ Some (String.sub branch (String.length project_prefix)
3737+ (String.length branch - String.length project_prefix))
3838+ else
3939+ None
4040+4141+(* Get current timestamp in ISO 8601 format *)
4242+let iso_timestamp () =
4343+ let t = Unix.gettimeofday () in
4444+ let tm = Unix.gmtime t in
4545+ Printf.sprintf "%04d-%02d-%02dT%02d:%02d:%02dZ"
4646+ (tm.Unix.tm_year + 1900)
4747+ (tm.Unix.tm_mon + 1)
4848+ tm.Unix.tm_mday
4949+ tm.Unix.tm_hour
5050+ tm.Unix.tm_min
5151+ tm.Unix.tm_sec
5252+5353+(* TOML encoding for registry *)
5454+5555+let project_info_codec =
5656+ let open Tomlt in
5757+ let open Table in
5858+ obj (fun name branch description created ->
5959+ { name; branch; description; created })
6060+ |> mem "name" string ~enc:(fun p -> p.name)
6161+ |> mem "branch" string ~enc:(fun p -> p.branch)
6262+ |> mem "description" string ~dec_absent:"" ~enc:(fun p -> p.description)
6363+ |> mem "created" string ~dec_absent:"" ~enc:(fun p -> p.created)
6464+ |> finish
6565+6666+let registry_codec =
6767+ let open Tomlt in
6868+ let open Table in
6969+ obj (fun version projects -> { version; projects })
7070+ |> mem "version" string ~dec_absent:"0.1.0" ~enc:(fun r -> r.version)
7171+ |> mem "projects" (list project_info_codec) ~dec_absent:[] ~enc:(fun r -> r.projects)
7272+ |> finish
7373+7474+let unpac_toml_codec =
7575+ let open Tomlt in
7676+ let open Table in
7777+ obj (fun unpac -> unpac)
7878+ |> mem "unpac" registry_codec ~enc:Fun.id
7979+ |> finish
8080+8181+(* Configuration *)
8282+8383+let config_file = "unpac.toml"
8484+8585+let load_registry ~cwd =
8686+ let path = Eio.Path.(cwd / config_file) in
8787+ match Eio.Path.load path with
8888+ | content ->
8989+ begin match Tomlt_bytesrw.decode_string unpac_toml_codec content with
9090+ | Ok registry -> Some registry
9191+ | Error e ->
9292+ Log.warn (fun m -> m "Failed to parse %s: %s" config_file
9393+ (Tomlt.Toml.Error.to_string e));
9494+ None
9595+ end
9696+ | exception Eio.Io (Eio.Fs.E (Eio.Fs.Not_found _), _) ->
9797+ None
9898+ | exception exn ->
9999+ Log.warn (fun m -> m "Failed to load %s: %a" config_file Fmt.exn exn);
100100+ None
101101+102102+let save_registry ~cwd registry =
103103+ let path = Eio.Path.(cwd / config_file) in
104104+ let content = Tomlt_bytesrw.encode_string unpac_toml_codec registry in
105105+ Eio.Path.save ~create:(`Or_truncate 0o644) path content;
106106+ Log.debug (fun m -> m "Saved registry to %s" config_file)
107107+108108+(* Queries *)
109109+110110+let current_project ~proc_mgr ~cwd =
111111+ match Git.current_branch ~proc_mgr ~cwd with
112112+ | None -> None
113113+ | Some branch -> project_name_of_branch branch
114114+115115+let require_project_branch ~proc_mgr ~cwd =
116116+ match Git.current_branch ~proc_mgr ~cwd with
117117+ | None ->
118118+ Log.err (fun m -> m "Not on any branch (detached HEAD)");
119119+ failwith "Not on any branch. Switch to a project branch first."
120120+ | Some branch ->
121121+ match project_name_of_branch branch with
122122+ | Some name -> name
123123+ | None ->
124124+ Log.err (fun m -> m "Not on a project branch. Current branch: %s" branch);
125125+ failwith (Printf.sprintf
126126+ "Not on a project branch (current: %s).\n\
127127+ Switch to a project: unpac project switch <name>\n\
128128+ Or create one: unpac project create <name>" branch)
129129+130130+let is_main_branch ~proc_mgr ~cwd =
131131+ match Git.current_branch ~proc_mgr ~cwd with
132132+ | Some "main" | Some "master" -> true
133133+ | _ -> false
134134+135135+let list_projects ~proc_mgr ~cwd =
136136+ (* First try to load from registry on current branch *)
137137+ match load_registry ~cwd with
138138+ | Some registry -> registry.projects
139139+ | None ->
140140+ (* Fallback: scan for project branches *)
141141+ let branches = Git.run_lines ~proc_mgr ~cwd
142142+ ["for-each-ref"; "--format=%(refname:short)"; "refs/heads/project/"]
143143+ in
144144+ List.filter_map (fun branch ->
145145+ match project_name_of_branch branch with
146146+ | Some name -> Some { name; branch; description = ""; created = "" }
147147+ | None -> None
148148+ ) branches
149149+150150+let project_exists ~proc_mgr ~cwd name =
151151+ Git.branch_exists ~proc_mgr ~cwd (project_branch name)
152152+153153+(* Operations *)
154154+155155+let init ~proc_mgr ~cwd =
156156+ if Git.is_repository cwd then begin
157157+ Log.warn (fun m -> m "Git repository already exists");
158158+ (* Check if we have a registry *)
159159+ if Option.is_some (load_registry ~cwd) then
160160+ Log.info (fun m -> m "Registry already exists")
161161+ else begin
162162+ (* Create registry on current branch *)
163163+ let registry = { version = "0.1.0"; projects = [] } in
164164+ save_registry ~cwd registry;
165165+ if Git.has_uncommitted_changes ~proc_mgr ~cwd then begin
166166+ Git.add_all ~proc_mgr ~cwd;
167167+ Git.commit ~proc_mgr ~cwd ~message:"unpac: initialize project registry"
168168+ end
169169+ end
170170+ end else begin
171171+ Log.info (fun m -> m "Initializing git repository...");
172172+ Git.init ~proc_mgr ~cwd;
173173+174174+ (* Create README *)
175175+ let readme_path = Eio.Path.(cwd / "README.md") in
176176+ let readme_content = {|# Unpac Vendor Repository
177177+178178+This repository uses unpac's project-based branch model for vendoring OCaml packages.
179179+180180+## Branch Structure
181181+182182+- `main` - Project registry (metadata only)
183183+- `project/<name>` - Individual project branches with vendored code
184184+185185+## Quick Start
186186+187187+```bash
188188+# Create a new project
189189+unpac project create myapp
190190+191191+# Add packages (must be on a project branch)
192192+unpac add opam eio
193193+unpac add opam lwt --with-deps
194194+195195+# Check status
196196+unpac vendor status
197197+```
198198+199199+## Commands
200200+201201+```bash
202202+unpac init # Initialize repository
203203+unpac project create <name> # Create new project
204204+unpac project switch <name> # Switch to project
205205+unpac add opam <pkg> # Add package from opam
206206+unpac vendor status # Show vendored packages
207207+unpac vendor update <pkg> # Update from upstream
208208+```
209209+|}
210210+ in
211211+ Eio.Path.save ~create:(`Or_truncate 0o644) readme_path readme_content;
212212+213213+ (* Create .gitignore *)
214214+ let gitignore_path = Eio.Path.(cwd / ".gitignore") in
215215+ let gitignore_content = {|_build/
216216+*.install
217217+.merlin
218218+*.byte
219219+*.native
220220+*.cmo
221221+*.cmi
222222+*.cma
223223+*.cmx
224224+*.cmxa
225225+*.cmxs
226226+*.o
227227+*.a
228228+.unpac/
229229+|}
230230+ in
231231+ Eio.Path.save ~create:(`Or_truncate 0o644) gitignore_path gitignore_content;
232232+233233+ (* Create registry *)
234234+ let registry = { version = "0.1.0"; projects = [] } in
235235+ save_registry ~cwd registry;
236236+237237+ (* Initial commit *)
238238+ Git.add_all ~proc_mgr ~cwd;
239239+ Git.commit ~proc_mgr ~cwd ~message:"Initial unpac repository setup";
240240+241241+ Log.info (fun m -> m "Repository initialized")
242242+ end
243243+244244+let create ~proc_mgr ~cwd ~name ?(description="") () =
245245+ if project_exists ~proc_mgr ~cwd name then begin
246246+ Log.err (fun m -> m "Project %s already exists" name);
247247+ failwith (Printf.sprintf "Project '%s' already exists" name)
248248+ end;
249249+250250+ let branch = project_branch name in
251251+ let created = iso_timestamp () in
252252+253253+ Log.info (fun m -> m "Creating project: %s" name);
254254+255255+ (* Load current registry (might be on main or another branch) *)
256256+ let registry = load_registry ~cwd |> option_value
257257+ ~default:{ version = "0.1.0"; projects = [] }
258258+ in
259259+260260+ (* Add project to registry *)
261261+ let project = { name; branch; description; created } in
262262+ let registry = { registry with projects = project :: registry.projects } in
263263+264264+ (* Create the project branch from current HEAD *)
265265+ let current = Git.current_branch ~proc_mgr ~cwd in
266266+ let start_point = Git.current_head ~proc_mgr ~cwd in
267267+268268+ Git.branch_create ~proc_mgr ~cwd ~name:branch ~start_point;
269269+ Git.checkout ~proc_mgr ~cwd branch;
270270+271271+ (* Create project-specific config *)
272272+ let project_config_path = Eio.Path.(cwd / config_file) in
273273+ let project_config = Printf.sprintf {|[project]
274274+name = "%s"
275275+description = "%s"
276276+277277+[opam]
278278+repositories = []
279279+# compiler = "ocaml.5.3.0"
280280+281281+[vendor]
282282+# Vendored packages will be listed here
283283+|} name description
284284+ in
285285+ Eio.Path.save ~create:(`Or_truncate 0o644) project_config_path project_config;
286286+287287+ Git.add_all ~proc_mgr ~cwd;
288288+ Git.commit ~proc_mgr ~cwd ~message:(Printf.sprintf "project: create %s" name);
289289+290290+ (* Update registry on main branch if it exists *)
291291+ begin match current with
292292+ | Some "main" | Some "master" as main_branch ->
293293+ let main = Option.get main_branch in
294294+ Git.checkout ~proc_mgr ~cwd main;
295295+ save_registry ~cwd registry;
296296+ if Git.has_uncommitted_changes ~proc_mgr ~cwd then begin
297297+ Git.add_all ~proc_mgr ~cwd;
298298+ Git.commit ~proc_mgr ~cwd ~message:(Printf.sprintf "registry: add project %s" name)
299299+ end;
300300+ (* Switch back to project branch *)
301301+ Git.checkout ~proc_mgr ~cwd branch
302302+ | _ ->
303303+ (* Not on main, just save registry to current project branch too *)
304304+ save_registry ~cwd registry
305305+ end;
306306+307307+ Log.info (fun m -> m "Created project '%s' on branch '%s'" name branch);
308308+ Log.info (fun m -> m "Add packages with: unpac add opam <pkg>")
309309+310310+let switch ~proc_mgr ~cwd name =
311311+ let branch = project_branch name in
312312+ if not (Git.branch_exists ~proc_mgr ~cwd branch) then begin
313313+ Log.err (fun m -> m "Project %s does not exist" name);
314314+ failwith (Printf.sprintf "Project '%s' does not exist. Create it with: unpac project create %s" name name)
315315+ end;
316316+317317+ if Git.has_uncommitted_changes ~proc_mgr ~cwd then begin
318318+ Log.warn (fun m -> m "You have uncommitted changes");
319319+ Log.warn (fun m -> m "Commit or stash them before switching projects")
320320+ end;
321321+322322+ Log.info (fun m -> m "Switching to project: %s" name);
323323+ Git.checkout ~proc_mgr ~cwd branch
+100
lib/project.mli
···11+(** Project management - handling project branches.
22+33+ The main branch serves as a registry of all projects.
44+ Each project has its own branch [project/<name>] where actual work happens. *)
55+66+(** {1 Types} *)
77+88+type project_info = {
99+ name : string;
1010+ branch : string;
1111+ description : string;
1212+ created : string; (** ISO 8601 timestamp *)
1313+}
1414+1515+(** {1 Branch conventions} *)
1616+1717+val project_branch : string -> string
1818+(** [project_branch name] returns ["project/<name>"] *)
1919+2020+val is_project_branch : string -> bool
2121+(** [is_project_branch branch] checks if [branch] is a project branch. *)
2222+2323+val project_name_of_branch : string -> string option
2424+(** [project_name_of_branch branch] extracts project name from branch. *)
2525+2626+(** {1 Queries} *)
2727+2828+val current_project :
2929+ proc_mgr:Git.proc_mgr ->
3030+ cwd:Git.path ->
3131+ string option
3232+(** [current_project ~proc_mgr ~cwd] returns the current project name if on a project branch. *)
3333+3434+val require_project_branch :
3535+ proc_mgr:Git.proc_mgr ->
3636+ cwd:Git.path ->
3737+ string
3838+(** [require_project_branch ~proc_mgr ~cwd] returns project name or raises error. *)
3939+4040+val is_main_branch :
4141+ proc_mgr:Git.proc_mgr ->
4242+ cwd:Git.path ->
4343+ bool
4444+(** [is_main_branch ~proc_mgr ~cwd] checks if currently on main branch. *)
4545+4646+val list_projects :
4747+ proc_mgr:Git.proc_mgr ->
4848+ cwd:Git.path ->
4949+ project_info list
5050+(** [list_projects ~proc_mgr ~cwd] returns all projects from the registry. *)
5151+5252+val project_exists :
5353+ proc_mgr:Git.proc_mgr ->
5454+ cwd:Git.path ->
5555+ string ->
5656+ bool
5757+(** [project_exists ~proc_mgr ~cwd name] checks if project [name] exists. *)
5858+5959+(** {1 Operations} *)
6060+6161+val init :
6262+ proc_mgr:Git.proc_mgr ->
6363+ cwd:Git.path ->
6464+ unit
6565+(** [init ~proc_mgr ~cwd] initializes the repository with main branch and registry. *)
6666+6767+val create :
6868+ proc_mgr:Git.proc_mgr ->
6969+ cwd:Git.path ->
7070+ name:string ->
7171+ ?description:string ->
7272+ unit ->
7373+ unit
7474+(** [create ~proc_mgr ~cwd ~name ()] creates a new project and switches to it.
7575+ The project is registered in main branch's unpac.toml. *)
7676+7777+val switch :
7878+ proc_mgr:Git.proc_mgr ->
7979+ cwd:Git.path ->
8080+ string ->
8181+ unit
8282+(** [switch ~proc_mgr ~cwd name] switches to project [name]. *)
8383+8484+(** {1 Configuration} *)
8585+8686+type registry = {
8787+ version : string;
8888+ projects : project_info list;
8989+}
9090+9191+val load_registry :
9292+ cwd:Git.path ->
9393+ registry option
9494+(** [load_registry ~cwd] loads the project registry from main branch's unpac.toml. *)
9595+9696+val save_registry :
9797+ cwd:Git.path ->
9898+ registry ->
9999+ unit
100100+(** [save_registry ~cwd registry] saves the project registry. *)
+315
lib/recovery.ml
···11+(** Recovery state for error recovery during multi-step operations. *)
22+33+let src = Logs.Src.create "unpac.recovery" ~doc:"Recovery operations"
44+module Log = (val Logs.src_log src : Logs.LOG)
55+66+(* Step types *)
77+88+type step =
99+ | Remote_add of { remote : string; url : string }
1010+ | Fetch of { remote : string }
1111+ | Create_upstream of { branch : string; start_point : string }
1212+ | Create_vendor of { name : string; upstream : string }
1313+ | Create_patches of { branch : string; vendor : string }
1414+ | Merge_to_project of { patches : string }
1515+ | Update_toml of { package_name : string }
1616+ | Commit of { message : string }
1717+1818+let step_name = function
1919+ | Remote_add _ -> "remote_add"
2020+ | Fetch _ -> "fetch"
2121+ | Create_upstream _ -> "create_upstream"
2222+ | Create_vendor _ -> "create_vendor"
2323+ | Create_patches _ -> "create_patches"
2424+ | Merge_to_project _ -> "merge_to_project"
2525+ | Update_toml _ -> "update_toml"
2626+ | Commit _ -> "commit"
2727+2828+let pp_step fmt = function
2929+ | Remote_add { remote; url } ->
3030+ Format.fprintf fmt "remote_add(%s -> %s)" remote url
3131+ | Fetch { remote } ->
3232+ Format.fprintf fmt "fetch(%s)" remote
3333+ | Create_upstream { branch; start_point } ->
3434+ Format.fprintf fmt "create_upstream(%s from %s)" branch start_point
3535+ | Create_vendor { name; upstream } ->
3636+ Format.fprintf fmt "create_vendor(%s from %s)" name upstream
3737+ | Create_patches { branch; vendor } ->
3838+ Format.fprintf fmt "create_patches(%s from %s)" branch vendor
3939+ | Merge_to_project { patches } ->
4040+ Format.fprintf fmt "merge_to_project(%s)" patches
4141+ | Update_toml { package_name } ->
4242+ Format.fprintf fmt "update_toml(%s)" package_name
4343+ | Commit { message } ->
4444+ let msg = if String.length message > 30 then String.sub message 0 30 ^ "..." else message in
4545+ Format.fprintf fmt "commit(%s)" msg
4646+4747+(* Operation types *)
4848+4949+type operation =
5050+ | Add_package of {
5151+ name : string;
5252+ url : string;
5353+ branch : string;
5454+ opam_packages : string list
5555+ }
5656+ | Update_package of { name : string }
5757+ | Rebase_patches of { name : string }
5858+5959+let pp_operation fmt = function
6060+ | Add_package { name; _ } ->
6161+ Format.fprintf fmt "add_package(%s)" name
6262+ | Update_package { name } ->
6363+ Format.fprintf fmt "update_package(%s)" name
6464+ | Rebase_patches { name } ->
6565+ Format.fprintf fmt "rebase_patches(%s)" name
6666+6767+(* State *)
6868+6969+type state = {
7070+ operation : operation;
7171+ original_branch : string;
7272+ original_head : string;
7373+ started : string;
7474+ completed : step list;
7575+ pending : step list;
7676+}
7777+7878+let pp_state fmt state =
7979+ Format.fprintf fmt "@[<v>Operation: %a@,Original: %s @ %s@,Started: %s@,Completed: %d steps@,Pending: %d steps@]"
8080+ pp_operation state.operation
8181+ state.original_branch state.original_head
8282+ state.started
8383+ (List.length state.completed)
8484+ (List.length state.pending)
8585+8686+(* Persistence *)
8787+8888+let recovery_dir = ".unpac"
8989+let recovery_file = ".unpac/recovery.toml"
9090+9191+(* TOML encoding for steps - uses Tomlt.Toml for raw value construction *)
9292+module T = Tomlt.Toml
9393+9494+let step_to_toml step =
9595+ let typ = step_name step in
9696+ let data = match step with
9797+ | Remote_add { remote; url } ->
9898+ [("remote", T.string remote); ("url", T.string url)]
9999+ | Fetch { remote } ->
100100+ [("remote", T.string remote)]
101101+ | Create_upstream { branch; start_point } ->
102102+ [("branch", T.string branch); ("start_point", T.string start_point)]
103103+ | Create_vendor { name; upstream } ->
104104+ [("name", T.string name); ("upstream", T.string upstream)]
105105+ | Create_patches { branch; vendor } ->
106106+ [("branch", T.string branch); ("vendor", T.string vendor)]
107107+ | Merge_to_project { patches } ->
108108+ [("patches", T.string patches)]
109109+ | Update_toml { package_name } ->
110110+ [("package_name", T.string package_name)]
111111+ | Commit { message } ->
112112+ [("message", T.string message)]
113113+ in
114114+ T.table (("type", T.string typ) :: data)
115115+116116+let step_of_toml toml =
117117+ let get_string key =
118118+ match T.find_opt key toml with
119119+ | Some (T.String s) -> s
120120+ | _ -> failwith ("missing key: " ^ key)
121121+ in
122122+ match get_string "type" with
123123+ | "remote_add" ->
124124+ Remote_add { remote = get_string "remote"; url = get_string "url" }
125125+ | "fetch" ->
126126+ Fetch { remote = get_string "remote" }
127127+ | "create_upstream" ->
128128+ Create_upstream { branch = get_string "branch"; start_point = get_string "start_point" }
129129+ | "create_vendor" ->
130130+ Create_vendor { name = get_string "name"; upstream = get_string "upstream" }
131131+ | "create_patches" ->
132132+ Create_patches { branch = get_string "branch"; vendor = get_string "vendor" }
133133+ | "merge_to_project" ->
134134+ Merge_to_project { patches = get_string "patches" }
135135+ | "update_toml" ->
136136+ Update_toml { package_name = get_string "package_name" }
137137+ | "commit" ->
138138+ Commit { message = get_string "message" }
139139+ | typ ->
140140+ failwith ("unknown step type: " ^ typ)
141141+142142+let operation_to_toml op =
143143+ match op with
144144+ | Add_package { name; url; branch; opam_packages } ->
145145+ T.table [
146146+ ("type", T.string "add_package");
147147+ ("name", T.string name);
148148+ ("url", T.string url);
149149+ ("branch", T.string branch);
150150+ ("opam_packages", T.array (List.map T.string opam_packages));
151151+ ]
152152+ | Update_package { name } ->
153153+ T.table [
154154+ ("type", T.string "update_package");
155155+ ("name", T.string name);
156156+ ]
157157+ | Rebase_patches { name } ->
158158+ T.table [
159159+ ("type", T.string "rebase_patches");
160160+ ("name", T.string name);
161161+ ]
162162+163163+let operation_of_toml toml =
164164+ let get_string key =
165165+ match T.find_opt key toml with
166166+ | Some (T.String s) -> s
167167+ | _ -> failwith ("missing key: " ^ key)
168168+ in
169169+ let get_string_list key =
170170+ match T.find_opt key toml with
171171+ | Some (T.Array arr) ->
172172+ List.filter_map (function T.String s -> Some s | _ -> None) arr
173173+ | _ -> []
174174+ in
175175+ match get_string "type" with
176176+ | "add_package" ->
177177+ Add_package {
178178+ name = get_string "name";
179179+ url = get_string "url";
180180+ branch = get_string "branch";
181181+ opam_packages = get_string_list "opam_packages";
182182+ }
183183+ | "update_package" ->
184184+ Update_package { name = get_string "name" }
185185+ | "rebase_patches" ->
186186+ Rebase_patches { name = get_string "name" }
187187+ | typ ->
188188+ failwith ("unknown operation type: " ^ typ)
189189+190190+let state_to_toml state =
191191+ T.table [
192192+ ("operation", operation_to_toml state.operation);
193193+ ("original_branch", T.string state.original_branch);
194194+ ("original_head", T.string state.original_head);
195195+ ("started", T.string state.started);
196196+ ("completed", T.array (List.map step_to_toml state.completed));
197197+ ("pending", T.array (List.map step_to_toml state.pending));
198198+ ]
199199+200200+let state_of_toml toml =
201201+ let get_string key =
202202+ match T.find_opt key toml with
203203+ | Some (T.String s) -> s
204204+ | _ -> failwith ("missing key: " ^ key)
205205+ in
206206+ let get_table key =
207207+ match T.find_opt key toml with
208208+ | Some (T.Table t) -> T.table t
209209+ | _ -> failwith ("missing table: " ^ key)
210210+ in
211211+ let get_step_list key =
212212+ match T.find_opt key toml with
213213+ | Some (T.Array arr) ->
214214+ List.filter_map (function
215215+ | T.Table t -> Some (step_of_toml (T.table t))
216216+ | _ -> None
217217+ ) arr
218218+ | _ -> []
219219+ in
220220+ {
221221+ operation = operation_of_toml (get_table "operation");
222222+ original_branch = get_string "original_branch";
223223+ original_head = get_string "original_head";
224224+ started = get_string "started";
225225+ completed = get_step_list "completed";
226226+ pending = get_step_list "pending";
227227+ }
228228+229229+let save ~cwd state =
230230+ let dir_path = Eio.Path.(cwd / recovery_dir) in
231231+ Eio.Path.mkdirs ~exists_ok:true ~perm:0o755 dir_path;
232232+ let file_path = Eio.Path.(cwd / recovery_file) in
233233+ let toml = state_to_toml state in
234234+ let content = Tomlt_bytesrw.to_string toml in
235235+ Eio.Path.save ~create:(`Or_truncate 0o644) file_path content;
236236+ Log.debug (fun m -> m "Saved recovery state to %s" recovery_file)
237237+238238+let load ~cwd =
239239+ let file_path = Eio.Path.(cwd / recovery_file) in
240240+ match Eio.Path.load file_path with
241241+ | content ->
242242+ begin match Tomlt_bytesrw.of_string content with
243243+ | Ok toml ->
244244+ let state = state_of_toml toml in
245245+ Log.debug (fun m -> m "Loaded recovery state: %a" pp_state state);
246246+ Some state
247247+ | Error e ->
248248+ Log.warn (fun m -> m "Failed to parse recovery file: %s"
249249+ (Tomlt.Toml.Error.to_string e));
250250+ None
251251+ end
252252+ | exception Eio.Io (Eio.Fs.E (Eio.Fs.Not_found _), _) ->
253253+ None
254254+ | exception exn ->
255255+ Log.warn (fun m -> m "Failed to load recovery file: %a" Fmt.exn exn);
256256+ None
257257+258258+let clear ~cwd =
259259+ let file_path = Eio.Path.(cwd / recovery_file) in
260260+ begin try Eio.Path.unlink file_path
261261+ with Eio.Io (Eio.Fs.E (Eio.Fs.Not_found _), _) -> ()
262262+ end;
263263+ Log.debug (fun m -> m "Cleared recovery state")
264264+265265+let has_recovery ~cwd =
266266+ let file_path = Eio.Path.(cwd / recovery_file) in
267267+ match Eio.Path.kind ~follow:false file_path with
268268+ | `Regular_file -> true
269269+ | _ -> false
270270+ | exception _ -> false
271271+272272+(* State transitions *)
273273+274274+let mark_step_complete state =
275275+ match state.pending with
276276+ | [] -> state
277277+ | step :: rest ->
278278+ { state with
279279+ completed = step :: state.completed;
280280+ pending = rest;
281281+ }
282282+283283+let current_step state =
284284+ match state.pending with
285285+ | [] -> None
286286+ | step :: _ -> Some step
287287+288288+(* Abort and resume *)
289289+290290+let abort ~proc_mgr ~cwd state =
291291+ Log.info (fun m -> m "Aborting operation: %a" pp_operation state.operation);
292292+ Log.info (fun m -> m "Restoring to: %s @ %s" state.original_branch state.original_head);
293293+294294+ (* Abort any in-progress operations *)
295295+ Git.rebase_abort ~proc_mgr ~cwd;
296296+ Git.merge_abort ~proc_mgr ~cwd;
297297+298298+ (* Reset to original state *)
299299+ Git.reset_hard ~proc_mgr ~cwd state.original_head;
300300+ Git.clean_fd ~proc_mgr ~cwd;
301301+302302+ (* Switch back to original branch if possible *)
303303+ begin try
304304+ Git.checkout ~proc_mgr ~cwd state.original_branch
305305+ with _ ->
306306+ Log.warn (fun m -> m "Could not switch back to %s" state.original_branch)
307307+ end;
308308+309309+ (* Clear recovery state *)
310310+ clear ~cwd;
311311+312312+ Log.info (fun m -> m "Aborted. Repository restored to previous state.")
313313+314314+let can_resume state =
315315+ state.pending <> []
+93
lib/recovery.mli
···11+(** Recovery state for error recovery during multi-step operations.
22+33+ When a multi-step operation (like adding a package) fails partway through,
44+ the recovery state allows us to either:
55+ - Resume from where we left off
66+ - Abort and rollback to the original state *)
77+88+(** {1 Step Types} *)
99+1010+type step =
1111+ | Remote_add of { remote : string; url : string }
1212+ | Fetch of { remote : string }
1313+ | Create_upstream of { branch : string; start_point : string }
1414+ | Create_vendor of { name : string; upstream : string }
1515+ | Create_patches of { branch : string; vendor : string }
1616+ | Merge_to_project of { patches : string }
1717+ | Update_toml of { package_name : string }
1818+ | Commit of { message : string }
1919+2020+val pp_step : Format.formatter -> step -> unit
2121+val step_name : step -> string
2222+2323+(** {1 Operation Types} *)
2424+2525+type operation =
2626+ | Add_package of {
2727+ name : string;
2828+ url : string;
2929+ branch : string;
3030+ opam_packages : string list
3131+ }
3232+ | Update_package of { name : string }
3333+ | Rebase_patches of { name : string }
3434+3535+val pp_operation : Format.formatter -> operation -> unit
3636+3737+(** {1 State} *)
3838+3939+type state = {
4040+ operation : operation;
4141+ original_branch : string;
4242+ original_head : string;
4343+ started : string; (** ISO 8601 timestamp *)
4444+ completed : step list;
4545+ pending : step list;
4646+}
4747+4848+val pp_state : Format.formatter -> state -> unit
4949+5050+(** {1 Persistence} *)
5151+5252+val recovery_dir : string
5353+(** [".unpac"] - directory for recovery state *)
5454+5555+val recovery_file : string
5656+(** [".unpac/recovery.toml"] - recovery state file *)
5757+5858+val save : cwd:Git.path -> state -> unit
5959+(** [save ~cwd state] persists recovery state to disk. *)
6060+6161+val load : cwd:Git.path -> state option
6262+(** [load ~cwd] loads recovery state if it exists. *)
6363+6464+val clear : cwd:Git.path -> unit
6565+(** [clear ~cwd] removes recovery state file. *)
6666+6767+val has_recovery : cwd:Git.path -> bool
6868+(** [has_recovery ~cwd] checks if there's pending recovery state. *)
6969+7070+(** {1 State Transitions} *)
7171+7272+val mark_step_complete : state -> state
7373+(** [mark_step_complete state] moves the first pending step to completed. *)
7474+7575+val current_step : state -> step option
7676+(** [current_step state] returns the next step to execute. *)
7777+7878+(** {1 Abort and Resume} *)
7979+8080+val abort :
8181+ proc_mgr:Git.proc_mgr ->
8282+ cwd:Git.path ->
8383+ state ->
8484+ unit
8585+(** [abort ~proc_mgr ~cwd state] aborts the operation and restores original state.
8686+ This will:
8787+ - Abort any in-progress merge or rebase
8888+ - Reset to original HEAD
8989+ - Clean up partial state
9090+ - Remove recovery file *)
9191+9292+val can_resume : state -> bool
9393+(** [can_resume state] returns true if the operation can be resumed. *)
+120
lib/repo_index.ml
···11+type package_info = {
22+ name : OpamPackage.Name.t;
33+ version : OpamPackage.Version.t;
44+ opam : OpamFile.OPAM.t;
55+ dev_repo : Dev_repo.t option;
66+ source_repo : string;
77+}
88+99+type t = {
1010+ packages : package_info OpamPackage.Map.t;
1111+ by_name : OpamPackage.Set.t OpamPackage.Name.Map.t;
1212+ by_dev_repo : OpamPackage.Set.t Dev_repo.Map.t;
1313+ repos : string list;
1414+}
1515+1616+let empty =
1717+ {
1818+ packages = OpamPackage.Map.empty;
1919+ by_name = OpamPackage.Name.Map.empty;
2020+ by_dev_repo = Dev_repo.Map.empty;
2121+ repos = [];
2222+ }
2323+2424+let add_package nv info t =
2525+ let packages = OpamPackage.Map.add nv info t.packages in
2626+ let by_name =
2727+ let name = OpamPackage.name nv in
2828+ let existing =
2929+ match OpamPackage.Name.Map.find_opt name t.by_name with
3030+ | Some s -> s
3131+ | None -> OpamPackage.Set.empty
3232+ in
3333+ OpamPackage.Name.Map.add name (OpamPackage.Set.add nv existing) t.by_name
3434+ in
3535+ let by_dev_repo =
3636+ match info.dev_repo with
3737+ | Some dev_repo ->
3838+ let existing =
3939+ match Dev_repo.Map.find_opt dev_repo t.by_dev_repo with
4040+ | Some s -> s
4141+ | None -> OpamPackage.Set.empty
4242+ in
4343+ Dev_repo.Map.add dev_repo (OpamPackage.Set.add nv existing) t.by_dev_repo
4444+ | None -> t.by_dev_repo
4545+ in
4646+ { t with packages; by_name; by_dev_repo }
4747+4848+let load_local_repo ~name ~path t =
4949+ let repo_root = OpamFilename.Dir.of_string path in
5050+ let pkg_prefixes = OpamRepository.packages_with_prefixes repo_root in
5151+ let t =
5252+ if List.mem name t.repos then t else { t with repos = name :: t.repos }
5353+ in
5454+ OpamPackage.Map.fold
5555+ (fun nv prefix acc ->
5656+ let opam_file = OpamRepositoryPath.opam repo_root prefix nv in
5757+ match OpamFile.OPAM.read_opt opam_file with
5858+ | Some opam ->
5959+ let dev_repo =
6060+ OpamFile.OPAM.dev_repo opam |> Option.map Dev_repo.of_opam_url
6161+ in
6262+ let info =
6363+ {
6464+ name = OpamPackage.name nv;
6565+ version = OpamPackage.version nv;
6666+ opam;
6767+ dev_repo;
6868+ source_repo = name;
6969+ }
7070+ in
7171+ add_package nv info acc
7272+ | None -> acc)
7373+ pkg_prefixes t
7474+7575+let all_packages t =
7676+ OpamPackage.Map.fold (fun _ info acc -> info :: acc) t.packages []
7777+7878+let find_package name t =
7979+ match OpamPackage.Name.Map.find_opt name t.by_name with
8080+ | None -> []
8181+ | Some nvs ->
8282+ OpamPackage.Set.fold
8383+ (fun nv acc ->
8484+ match OpamPackage.Map.find_opt nv t.packages with
8585+ | Some info -> info :: acc
8686+ | None -> acc)
8787+ nvs []
8888+8989+let find_package_version name version t =
9090+ let nv = OpamPackage.create name version in
9191+ OpamPackage.Map.find_opt nv t.packages
9292+9393+let packages_by_dev_repo dev_repo t =
9494+ match Dev_repo.Map.find_opt dev_repo t.by_dev_repo with
9595+ | None -> []
9696+ | Some nvs ->
9797+ OpamPackage.Set.fold
9898+ (fun nv acc ->
9999+ match OpamPackage.Map.find_opt nv t.packages with
100100+ | Some info -> info :: acc
101101+ | None -> acc)
102102+ nvs []
103103+104104+let related_packages name t =
105105+ let versions = find_package name t in
106106+ let dev_repos =
107107+ List.filter_map (fun info -> info.dev_repo) versions
108108+ |> List.sort_uniq Dev_repo.compare
109109+ in
110110+ List.concat_map (fun dr -> packages_by_dev_repo dr t) dev_repos
111111+ |> List.sort_uniq (fun a b ->
112112+ let cmp = OpamPackage.Name.compare a.name b.name in
113113+ if cmp <> 0 then cmp
114114+ else OpamPackage.Version.compare a.version b.version)
115115+116116+let package_names t =
117117+ OpamPackage.Name.Map.fold (fun name _ acc -> name :: acc) t.by_name []
118118+119119+let package_count t = OpamPackage.Map.cardinal t.packages
120120+let repo_count t = List.length t.repos
+59
lib/repo_index.mli
···11+(** Repository index for opam packages.
22+33+ This module provides functionality to load and query packages from
44+ multiple opam repositories, with support for merging with configurable
55+ priority. *)
66+77+(** {1 Types} *)
88+99+type package_info = {
1010+ name : OpamPackage.Name.t;
1111+ version : OpamPackage.Version.t;
1212+ opam : OpamFile.OPAM.t;
1313+ dev_repo : Dev_repo.t option;
1414+ source_repo : string; (** Name of the repository this package came from *)
1515+}
1616+(** Information about a single package version. *)
1717+1818+type t
1919+(** The repository index containing all loaded packages. *)
2020+2121+(** {1 Creation} *)
2222+2323+val empty : t
2424+(** [empty] is an empty repository index. *)
2525+2626+val load_local_repo : name:string -> path:string -> t -> t
2727+(** [load_local_repo ~name ~path index] loads all packages from the local
2828+ opam repository at [path] and adds them to [index]. Packages from this
2929+ load will take priority over existing packages with the same name/version. *)
3030+3131+(** {1 Queries} *)
3232+3333+val all_packages : t -> package_info list
3434+(** [all_packages t] returns all packages in the index. *)
3535+3636+val find_package : OpamPackage.Name.t -> t -> package_info list
3737+(** [find_package name t] returns all versions of package [name]. *)
3838+3939+val find_package_version :
4040+ OpamPackage.Name.t -> OpamPackage.Version.t -> t -> package_info option
4141+(** [find_package_version name version t] returns the specific package version. *)
4242+4343+val packages_by_dev_repo : Dev_repo.t -> t -> package_info list
4444+(** [packages_by_dev_repo dev_repo t] returns all packages with the given dev-repo. *)
4545+4646+val related_packages : OpamPackage.Name.t -> t -> package_info list
4747+(** [related_packages name t] returns all packages that share a dev-repo with
4848+ any version of package [name]. *)
4949+5050+val package_names : t -> OpamPackage.Name.t list
5151+(** [package_names t] returns all unique package names. *)
5252+5353+(** {1 Statistics} *)
5454+5555+val package_count : t -> int
5656+(** [package_count t] returns the total number of package versions. *)
5757+5858+val repo_count : t -> int
5959+(** [repo_count t] returns the number of source repositories loaded. *)
+431
lib/solver.ml
···11+open Cmdliner
22+33+type version_constraint = OpamFormula.relop * OpamPackage.Version.t
44+55+type package_spec = {
66+ name : OpamPackage.Name.t;
77+ constraint_ : version_constraint option;
88+}
99+1010+(* Target platform configuration *)
1111+type platform = {
1212+ os : string;
1313+ os_family : string;
1414+ os_distribution : string;
1515+ arch : string;
1616+}
1717+1818+let debian_x86_64 = {
1919+ os = "linux";
2020+ os_family = "debian";
2121+ os_distribution = "debian";
2222+ arch = "x86_64";
2323+}
2424+2525+(* Create a filter environment for the target platform *)
2626+let make_filter_env platform : OpamFilter.env =
2727+ fun var ->
2828+ let open OpamVariable in
2929+ let s = to_string (Full.variable var) in
3030+ match s with
3131+ | "os" -> Some (S platform.os)
3232+ | "os-family" -> Some (S platform.os_family)
3333+ | "os-distribution" -> Some (S platform.os_distribution)
3434+ | "arch" -> Some (S platform.arch)
3535+ | "opam-version" -> Some (S "2.1.0")
3636+ | "make" -> Some (S "make")
3737+ | "jobs" -> Some (S "4")
3838+ | "pinned" -> Some (B false)
3939+ | "build" -> Some (B true)
4040+ | "post" -> Some (B false)
4141+ | "dev" -> Some (B false)
4242+ | "with-test" -> Some (B false)
4343+ | "with-doc" -> Some (B false)
4444+ | "with-dev-setup" -> Some (B false)
4545+ | _ -> None
4646+4747+(* Check if a package is available on the target platform *)
4848+let is_available_on_platform env (opam : OpamFile.OPAM.t) : bool =
4949+ let available = OpamFile.OPAM.available opam in
5050+ OpamFilter.opt_eval_to_bool env (Some available)
5151+5252+(* Check if a package has the compiler flag or is a compiler-related package *)
5353+let is_compiler_package (opam : OpamFile.OPAM.t) (name : OpamPackage.Name.t) : bool =
5454+ let name_s = OpamPackage.Name.to_string name in
5555+ (* Check for flags:compiler *)
5656+ let has_compiler_flag =
5757+ List.mem OpamTypes.Pkgflag_Compiler (OpamFile.OPAM.flags opam)
5858+ in
5959+ (* Also filter out known compiler-related packages by name pattern *)
6060+ let is_compiler_name =
6161+ name_s = "ocaml" ||
6262+ String.starts_with ~prefix:"ocaml-base-compiler" name_s ||
6363+ String.starts_with ~prefix:"ocaml-variants" name_s ||
6464+ String.starts_with ~prefix:"ocaml-system" name_s ||
6565+ String.starts_with ~prefix:"ocaml-option-" name_s ||
6666+ String.starts_with ~prefix:"ocaml-config" name_s ||
6767+ String.starts_with ~prefix:"ocaml-compiler" name_s ||
6868+ String.starts_with ~prefix:"base-" name_s || (* base-threads, base-unix, etc. *)
6969+ String.starts_with ~prefix:"dkml-base-compiler" name_s ||
7070+ String.starts_with ~prefix:"dkml-runtime" name_s
7171+ in
7272+ has_compiler_flag || is_compiler_name
7373+7474+(* Filter dependencies to remove platform-filtered ones.
7575+ Uses OpamFilter.filter_formula which evaluates filters and simplifies. *)
7676+let filter_depends env (formula : OpamTypes.filtered_formula) : OpamTypes.formula =
7777+ OpamFilter.filter_formula ~default:false env formula
7878+7979+(* Parse version constraint from string like ">=1.0.0" *)
8080+let parse_constraint s =
8181+ let s = String.trim s in
8282+ if String.length s = 0 then None
8383+ else
8484+ let try_parse prefix relop =
8585+ if String.starts_with ~prefix s then
8686+ let v = String.sub s (String.length prefix) (String.length s - String.length prefix) in
8787+ Some (relop, OpamPackage.Version.of_string v)
8888+ else None
8989+ in
9090+ match try_parse ">=" `Geq with
9191+ | Some c -> Some c
9292+ | None -> (
9393+ match try_parse "<=" `Leq with
9494+ | Some c -> Some c
9595+ | None -> (
9696+ match try_parse ">" `Gt with
9797+ | Some c -> Some c
9898+ | None -> (
9999+ match try_parse "<" `Lt with
100100+ | Some c -> Some c
101101+ | None -> (
102102+ match try_parse "!=" `Neq with
103103+ | Some c -> Some c
104104+ | None -> (
105105+ match try_parse "=" `Eq with
106106+ | Some c -> Some c
107107+ | None ->
108108+ (* Treat bare version as exact match *)
109109+ Some (`Eq, OpamPackage.Version.of_string s))))))
110110+111111+let parse_package_spec s =
112112+ try
113113+ let s = String.trim s in
114114+ (* Check for constraint operators *)
115115+ let has_constraint =
116116+ String.contains s '>' || String.contains s '<'
117117+ || String.contains s '=' || String.contains s '!'
118118+ in
119119+ if has_constraint then
120120+ (* Find where constraint starts *)
121121+ let constraint_start =
122122+ let find_op c = try Some (String.index s c) with Not_found -> None in
123123+ [ find_op '>'; find_op '<'; find_op '='; find_op '!' ]
124124+ |> List.filter_map Fun.id
125125+ |> List.fold_left min (String.length s)
126126+ in
127127+ let name_part = String.sub s 0 constraint_start in
128128+ let constraint_part =
129129+ String.sub s constraint_start (String.length s - constraint_start)
130130+ in
131131+ let name = OpamPackage.Name.of_string name_part in
132132+ let constraint_ = parse_constraint constraint_part in
133133+ Ok { name; constraint_ }
134134+ else
135135+ (* Check for pkg.version format *)
136136+ match String.rindex_opt s '.' with
137137+ | Some i when i > 0 ->
138138+ let name_part = String.sub s 0 i in
139139+ let version_part = String.sub s (i + 1) (String.length s - i - 1) in
140140+ (* Validate that version_part looks like a version *)
141141+ if String.length version_part > 0
142142+ && (version_part.[0] >= '0' && version_part.[0] <= '9' || version_part.[0] = 'v')
143143+ then
144144+ let name = OpamPackage.Name.of_string name_part in
145145+ let version = OpamPackage.Version.of_string version_part in
146146+ Ok { name; constraint_ = Some (`Eq, version) }
147147+ else
148148+ (* Treat as package name without constraint *)
149149+ let name = OpamPackage.Name.of_string s in
150150+ Ok { name; constraint_ = None }
151151+ | _ ->
152152+ let name = OpamPackage.Name.of_string s in
153153+ Ok { name; constraint_ = None }
154154+ with e -> Error (Printexc.to_string e)
155155+156156+let package_spec_to_string spec =
157157+ let name = OpamPackage.Name.to_string spec.name in
158158+ match spec.constraint_ with
159159+ | None -> name
160160+ | Some (op, v) ->
161161+ let op_s =
162162+ match op with
163163+ | `Eq -> "="
164164+ | `Neq -> "!="
165165+ | `Geq -> ">="
166166+ | `Gt -> ">"
167167+ | `Leq -> "<="
168168+ | `Lt -> "<"
169169+ in
170170+ name ^ op_s ^ OpamPackage.Version.to_string v
171171+172172+(* Parse compiler spec string like "ocaml.5.4.0" or "5.4.0" *)
173173+let parse_compiler_spec (s : string) : package_spec option =
174174+ let s = String.trim s in
175175+ if s = "" then None
176176+ else
177177+ (* Handle formats: "ocaml.5.4.0", "5.4.0", "ocaml>=5.0" *)
178178+ let spec_str =
179179+ if String.starts_with ~prefix:"ocaml" s then s
180180+ else if s.[0] >= '0' && s.[0] <= '9' then "ocaml." ^ s
181181+ else s
182182+ in
183183+ match parse_package_spec spec_str with
184184+ | Ok spec -> Some spec
185185+ | Error _ -> None
186186+187187+(* Selection results *)
188188+type selection_result = { packages : Repo_index.package_info list }
189189+190190+(* Get latest version of each package that is available on the platform *)
191191+let latest_versions ?(platform=debian_x86_64) (index : Repo_index.t) : Repo_index.package_info list =
192192+ let env = make_filter_env platform in
193193+ let names = Repo_index.package_names index in
194194+ List.filter_map
195195+ (fun name ->
196196+ let versions = Repo_index.find_package name index in
197197+ (* Filter by availability and sort by version descending *)
198198+ let available_versions =
199199+ List.filter (fun (info : Repo_index.package_info) ->
200200+ is_available_on_platform env info.opam) versions
201201+ in
202202+ match
203203+ List.sort
204204+ (fun (a : Repo_index.package_info) b ->
205205+ OpamPackage.Version.compare b.version a.version)
206206+ available_versions
207207+ with
208208+ | latest :: _ -> Some latest
209209+ | [] -> None)
210210+ names
211211+212212+let select_all index = { packages = latest_versions index }
213213+214214+(* Check if a package version satisfies a constraint *)
215215+let satisfies_constraint version = function
216216+ | None -> true
217217+ | Some (op, cv) -> (
218218+ let cmp = OpamPackage.Version.compare version cv in
219219+ match op with
220220+ | `Eq -> cmp = 0
221221+ | `Neq -> cmp <> 0
222222+ | `Geq -> cmp >= 0
223223+ | `Gt -> cmp > 0
224224+ | `Leq -> cmp <= 0
225225+ | `Lt -> cmp < 0)
226226+227227+let select_packages ?(platform=debian_x86_64) index specs =
228228+ if specs = [] then Ok (select_all index)
229229+ else
230230+ let env = make_filter_env platform in
231231+ let selected =
232232+ List.filter_map
233233+ (fun spec ->
234234+ let versions = Repo_index.find_package spec.name index in
235235+ (* Filter by constraint, availability, and get latest matching *)
236236+ let matching =
237237+ List.filter
238238+ (fun (info : Repo_index.package_info) ->
239239+ satisfies_constraint info.version spec.constraint_
240240+ && is_available_on_platform env info.opam)
241241+ versions
242242+ in
243243+ match
244244+ List.sort
245245+ (fun (a : Repo_index.package_info) b ->
246246+ OpamPackage.Version.compare b.version a.version)
247247+ matching
248248+ with
249249+ | latest :: _ -> Some latest
250250+ | [] -> None)
251251+ specs
252252+ in
253253+ Ok { packages = selected }
254254+255255+(* Build a version map for CUDF conversion *)
256256+let build_version_map (packages : Repo_index.package_info list) : int OpamPackage.Map.t =
257257+ (* Group by name and sort versions *)
258258+ let by_name = Hashtbl.create 256 in
259259+ List.iter (fun (info : Repo_index.package_info) ->
260260+ let name = info.name in
261261+ let versions = try Hashtbl.find by_name name with Not_found -> [] in
262262+ Hashtbl.replace by_name name (info.version :: versions))
263263+ packages;
264264+ (* Assign version numbers *)
265265+ let version_map = ref OpamPackage.Map.empty in
266266+ Hashtbl.iter (fun name versions ->
267267+ let sorted = List.sort OpamPackage.Version.compare versions in
268268+ List.iteri (fun i v ->
269269+ let nv = OpamPackage.create name v in
270270+ version_map := OpamPackage.Map.add nv (i + 1) !version_map)
271271+ sorted)
272272+ by_name;
273273+ !version_map
274274+275275+(* Convert opam formula to CUDF vpkgformula (list of disjunctions for AND semantics)
276276+ Simplified: we ignore version constraints and just require the package exists.
277277+ This allows the 0install solver to pick the best version. *)
278278+let formula_to_vpkgformula available_names (formula : OpamTypes.formula) : Cudf_types.vpkgformula =
279279+ let atoms = OpamFormula.atoms formula in
280280+ List.filter_map (fun (name, _version_constraint) ->
281281+ let name_s = OpamPackage.Name.to_string name in
282282+ (* Only include dependency if package exists in our available set *)
283283+ if not (Hashtbl.mem available_names name_s) then None
284284+ else Some [(name_s, None)]) (* No version constraint - solver picks best *)
285285+ atoms
286286+287287+(* For conflicts, we ignore them in CUDF since proper conflict handling requires
288288+ complex version mapping. The 0install solver will still produce valid results
289289+ since we filter packages by platform availability. *)
290290+let formula_to_vpkglist (_formula : OpamTypes.formula) : Cudf_types.vpkglist =
291291+ [] (* Ignore conflicts for simplicity *)
292292+293293+(* Build CUDF universe from packages *)
294294+let build_cudf_universe ?(platform=debian_x86_64) (packages : Repo_index.package_info list) =
295295+ let env = make_filter_env platform in
296296+ let version_map = build_version_map packages in
297297+298298+ (* First, collect all available package names *)
299299+ let available_names = Hashtbl.create 256 in
300300+ List.iter (fun (info : Repo_index.package_info) ->
301301+ Hashtbl.replace available_names (OpamPackage.Name.to_string info.name) ())
302302+ packages;
303303+304304+ let cudf_packages = List.filter_map (fun (info : Repo_index.package_info) ->
305305+ let nv = OpamPackage.create info.name info.version in
306306+ match OpamPackage.Map.find_opt nv version_map with
307307+ | None -> None
308308+ | Some cudf_version ->
309309+ (* Get and filter dependencies *)
310310+ let depends_formula = OpamFile.OPAM.depends info.opam in
311311+ let filtered_depends = filter_depends env depends_formula in
312312+ let depends = formula_to_vpkgformula available_names filtered_depends in
313313+314314+ (* Get conflicts - simplified to empty for now *)
315315+ let conflicts_formula = OpamFile.OPAM.conflicts info.opam in
316316+ let filtered_conflicts = filter_depends env conflicts_formula in
317317+ let conflicts = formula_to_vpkglist filtered_conflicts in
318318+319319+ Some {
320320+ Cudf.default_package with
321321+ package = OpamPackage.Name.to_string info.name;
322322+ version = cudf_version;
323323+ depends = depends;
324324+ conflicts = conflicts;
325325+ installed = false;
326326+ pkg_extra = [
327327+ (OpamCudf.s_source, `String (OpamPackage.Name.to_string info.name));
328328+ (OpamCudf.s_source_number, `String (OpamPackage.Version.to_string info.version));
329329+ ];
330330+ })
331331+ packages
332332+ in
333333+334334+ let universe = Cudf.load_universe cudf_packages in
335335+ (universe, version_map)
336336+337337+(* Resolve dependencies using 0install solver *)
338338+let resolve_deps ?(platform=debian_x86_64) ?compiler index (root_specs : package_spec list) =
339339+ let env = make_filter_env platform in
340340+341341+ (* Get all available packages *)
342342+ let all_packages =
343343+ List.filter (fun (info : Repo_index.package_info) ->
344344+ is_available_on_platform env info.opam)
345345+ (Repo_index.all_packages index)
346346+ in
347347+348348+ (* Build CUDF universe *)
349349+ let universe, version_map = build_cudf_universe ~platform all_packages in
350350+351351+ (* Build request - add compiler if specified *)
352352+ let all_specs = match compiler with
353353+ | Some compiler_spec -> compiler_spec :: root_specs
354354+ | None -> root_specs
355355+ in
356356+357357+ let requested = List.filter_map (fun spec ->
358358+ let name_s = OpamPackage.Name.to_string spec.name in
359359+ (* Check if package exists in universe *)
360360+ if Cudf.mem_package universe (name_s, 1) ||
361361+ List.exists (fun p -> p.Cudf.package = name_s) (Cudf.get_packages universe)
362362+ then Some (name_s, `Essential)
363363+ else begin
364364+ Format.eprintf "Warning: Package %s not found in universe@." name_s;
365365+ None
366366+ end)
367367+ all_specs
368368+ in
369369+370370+ if requested = [] then
371371+ Error "No valid packages to resolve"
372372+ else
373373+ (* Create solver and solve *)
374374+ let solver = Opam_0install_cudf.create ~constraints:[] universe in
375375+ match Opam_0install_cudf.solve solver requested with
376376+ | Error diag ->
377377+ Error (Opam_0install_cudf.diagnostics diag)
378378+ | Ok selections ->
379379+ (* Convert results back to package info *)
380380+ let selected_cudf = Opam_0install_cudf.packages_of_result selections in
381381+ let selected_packages = List.filter_map (fun (name, cudf_version) ->
382382+ (* Find the opam package *)
383383+ let opam_name = OpamPackage.Name.of_string name in
384384+ let versions = Repo_index.find_package opam_name index in
385385+ (* Get the version that matches *)
386386+ List.find_opt (fun (info : Repo_index.package_info) ->
387387+ let nv = OpamPackage.create info.name info.version in
388388+ match OpamPackage.Map.find_opt nv version_map with
389389+ | Some v -> v = cudf_version
390390+ | None -> false)
391391+ versions)
392392+ selected_cudf
393393+ in
394394+ (* Deduplicate by package name+version *)
395395+ let seen = Hashtbl.create 64 in
396396+ let unique_packages = List.filter (fun (info : Repo_index.package_info) ->
397397+ let key = OpamPackage.to_string (OpamPackage.create info.name info.version) in
398398+ if Hashtbl.mem seen key then false
399399+ else begin Hashtbl.add seen key (); true end)
400400+ selected_packages
401401+ in
402402+ (* Filter out compiler packages from results *)
403403+ let non_compiler_packages = List.filter (fun (info : Repo_index.package_info) ->
404404+ not (is_compiler_package info.opam info.name))
405405+ unique_packages
406406+ in
407407+ Ok { packages = non_compiler_packages }
408408+409409+let select_with_deps ?(platform=debian_x86_64) ?compiler index specs =
410410+ if specs = [] then Ok (select_all index)
411411+ else
412412+ resolve_deps ~platform ?compiler index specs
413413+414414+(* Cmdliner integration *)
415415+416416+let package_specs_conv : package_spec Arg.conv =
417417+ let parse s =
418418+ match parse_package_spec s with
419419+ | Ok spec -> Ok spec
420420+ | Error msg -> Error (`Msg msg)
421421+ in
422422+ let print fmt spec = Format.pp_print_string fmt (package_spec_to_string spec) in
423423+ Arg.conv (parse, print)
424424+425425+let package_specs_term : package_spec list Term.t =
426426+ let doc =
427427+ "Package specification. Can be a package name (any version), \
428428+ name.version (exact version), or name>=version (constraint). \
429429+ Examples: cmdliner, lwt.5.6.0, dune>=3.0"
430430+ in
431431+ Arg.(value & pos_all package_specs_conv [] & info [] ~docv:"PACKAGE" ~doc)
+88
lib/solver.mli
···11+(** Package selection with constraint solving.
22+33+ Uses the 0install solver (via opam-0install-cudf) to select
44+ a consistent set of packages based on constraints, filtered
55+ for Debian x86_64 platform. *)
66+77+(** {1 Platform Configuration} *)
88+99+type platform = {
1010+ os : string;
1111+ os_family : string;
1212+ os_distribution : string;
1313+ arch : string;
1414+}
1515+(** Target platform for filtering packages. *)
1616+1717+val debian_x86_64 : platform
1818+(** Default platform: Debian Linux on x86_64. *)
1919+2020+(** {1 Package Specifications} *)
2121+2222+type version_constraint = OpamFormula.relop * OpamPackage.Version.t
2323+(** A version constraint like [>=, 1.0.0]. *)
2424+2525+type package_spec = {
2626+ name : OpamPackage.Name.t;
2727+ constraint_ : version_constraint option;
2828+}
2929+(** A package specification with optional version constraint. *)
3030+3131+val parse_package_spec : string -> (package_spec, string) result
3232+(** [parse_package_spec s] parses a package spec string like:
3333+ - "pkg" (any version)
3434+ - "pkg.1.0.0" (exact version)
3535+ - "pkg>=1.0.0" (version constraint)
3636+ - "pkg<2.0" (version constraint) *)
3737+3838+val package_spec_to_string : package_spec -> string
3939+(** [package_spec_to_string spec] converts a spec back to string form. *)
4040+4141+val parse_compiler_spec : string -> package_spec option
4242+(** [parse_compiler_spec s] parses a compiler version string like:
4343+ - "5.4.0" (parsed as ocaml.5.4.0)
4444+ - "ocaml.5.4.0" (exact version)
4545+ - "ocaml>=5.0" (version constraint)
4646+ Returns None if the string is empty or invalid. *)
4747+4848+(** {1 Selection} *)
4949+5050+type selection_result = {
5151+ packages : Repo_index.package_info list;
5252+ (** Selected packages that satisfy all constraints. *)
5353+}
5454+(** Result of package selection. *)
5555+5656+val select_all : Repo_index.t -> selection_result
5757+(** [select_all index] returns all packages (latest version of each)
5858+ that are available on the target platform (Debian x86_64). *)
5959+6060+val select_packages :
6161+ ?platform:platform ->
6262+ Repo_index.t -> package_spec list -> (selection_result, string) result
6363+(** [select_packages index specs] finds packages matching the given
6464+ specifications, filtered by platform availability. Returns the
6565+ latest compatible version of each package. *)
6666+6767+val select_with_deps :
6868+ ?platform:platform ->
6969+ ?compiler:package_spec ->
7070+ Repo_index.t -> package_spec list -> (selection_result, string) result
7171+(** [select_with_deps ?platform ?compiler index specs] selects packages and
7272+ their transitive dependencies using the 0install solver.
7373+7474+ - Dependencies are filtered by platform (Debian x86_64 by default)
7575+ - If [compiler] is specified, it is added as a constraint and all
7676+ compiler-related packages (those with flags:compiler or matching
7777+ known compiler package patterns) are filtered from the results
7878+ - The solver finds a consistent installation set *)
7979+8080+(** {1 Cmdliner Integration} *)
8181+8282+val package_specs_term : package_spec list Cmdliner.Term.t
8383+(** Cmdliner term for parsing package specifications from command line.
8484+ Accepts zero or more package specs as positional arguments.
8585+ If no packages specified, returns empty list (meaning "all packages"). *)
8686+8787+val package_specs_conv : package_spec Cmdliner.Arg.conv
8888+(** Cmdliner converter for a single package spec. *)
+237
lib/source.ml
···11+type source_kind = Archive | Git
22+33+type archive_source = {
44+ url : string;
55+ checksums : string list;
66+ mirrors : string list;
77+}
88+99+type git_source = {
1010+ url : string;
1111+ branch : string option;
1212+}
1313+1414+type source = ArchiveSource of archive_source | GitSource of git_source | NoSource
1515+1616+type package_source = {
1717+ name : string;
1818+ version : string;
1919+ source : source;
2020+ dev_repo : Dev_repo.t option;
2121+}
2222+2323+type grouped_sources = {
2424+ dev_repo : Dev_repo.t option;
2525+ packages : package_source list;
2626+}
2727+2828+(* Helper to check if URL is git-like *)
2929+let is_git_url url =
3030+ let s = OpamUrl.to_string url in
3131+ String.starts_with ~prefix:"git" s
3232+ || String.ends_with ~suffix:".git" s
3333+ || url.OpamUrl.backend = `git
3434+3535+(* Extract archive source from opam URL.t *)
3636+let extract_archive_from_url (url_t : OpamFile.URL.t) : archive_source =
3737+ let main_url = OpamFile.URL.url url_t in
3838+ let checksums =
3939+ OpamFile.URL.checksum url_t
4040+ |> List.map OpamHash.to_string
4141+ in
4242+ let mirrors =
4343+ OpamFile.URL.mirrors url_t
4444+ |> List.map OpamUrl.to_string
4545+ in
4646+ { url = OpamUrl.to_string main_url; checksums; mirrors }
4747+4848+(* Extract git source from OpamUrl.t *)
4949+let normalize_git_url s =
5050+ (* Strip git+ prefix so URLs work directly with git clone *)
5151+ if String.starts_with ~prefix:"git+" s then
5252+ String.sub s 4 (String.length s - 4)
5353+ else s
5454+5555+let extract_git_from_url (url : OpamUrl.t) : git_source =
5656+ { url = normalize_git_url (OpamUrl.to_string url); branch = url.OpamUrl.hash }
5757+5858+let extract kind (info : Repo_index.package_info) : package_source =
5959+ let name = OpamPackage.Name.to_string info.name in
6060+ let version = OpamPackage.Version.to_string info.version in
6161+ let dev_repo = info.dev_repo in
6262+ let source =
6363+ match kind with
6464+ | Archive -> (
6565+ match OpamFile.OPAM.url info.opam with
6666+ | Some url_t -> ArchiveSource (extract_archive_from_url url_t)
6767+ | None -> NoSource)
6868+ | Git -> (
6969+ (* Prefer dev-repo for git, fall back to url if it's a git URL *)
7070+ match OpamFile.OPAM.dev_repo info.opam with
7171+ | Some url -> GitSource (extract_git_from_url url)
7272+ | None -> (
7373+ match OpamFile.OPAM.url info.opam with
7474+ | Some url_t ->
7575+ let main_url = OpamFile.URL.url url_t in
7676+ if is_git_url main_url then
7777+ GitSource (extract_git_from_url main_url)
7878+ else NoSource
7979+ | None -> NoSource))
8080+ in
8181+ { name; version; source; dev_repo }
8282+8383+let extract_all kind packages = List.map (extract kind) packages
8484+8585+let group_by_dev_repo (sources : package_source list) : grouped_sources list =
8686+ (* Build separate lists: one map for packages with dev_repo, one list for those without *)
8787+ let with_repo, without_repo =
8888+ List.partition (fun (src : package_source) -> Option.is_some src.dev_repo) sources
8989+ in
9090+ (* Group packages with dev_repo *)
9191+ let add_to_map map (src : package_source) =
9292+ match src.dev_repo with
9393+ | Some dr ->
9494+ let existing =
9595+ match Dev_repo.Map.find_opt dr map with
9696+ | Some l -> l
9797+ | None -> []
9898+ in
9999+ Dev_repo.Map.add dr (src :: existing) map
100100+ | None -> map
101101+ in
102102+ let map = List.fold_left add_to_map Dev_repo.Map.empty with_repo in
103103+ (* Convert map to list of grouped_sources *)
104104+ let grouped_with_repo =
105105+ Dev_repo.Map.fold
106106+ (fun dr pkgs acc ->
107107+ let packages = List.rev pkgs in (* Preserve original order *)
108108+ { dev_repo = Some dr; packages } :: acc)
109109+ map []
110110+ |> List.sort (fun a b ->
111111+ match (a.dev_repo, b.dev_repo) with
112112+ | Some a, Some b -> Dev_repo.compare a b
113113+ | _ -> 0)
114114+ in
115115+ (* Add packages without dev_repo at the end *)
116116+ if without_repo = [] then grouped_with_repo
117117+ else grouped_with_repo @ [{ dev_repo = None; packages = without_repo }]
118118+119119+(* JSON Codecs - simplified with tagged object *)
120120+121121+let source_jsont : source Jsont.t =
122122+ let open Jsont in
123123+ Object.map ~kind:"source"
124124+ (fun source_type url checksums mirrors branch ->
125125+ match source_type with
126126+ | "archive" ->
127127+ let checksums = match checksums with Some cs -> cs | None -> [] in
128128+ ArchiveSource { url; checksums; mirrors }
129129+ | "git" -> GitSource { url; branch }
130130+ | _ -> NoSource)
131131+ |> Object.mem "type" string ~enc:(function
132132+ | ArchiveSource _ -> "archive"
133133+ | GitSource _ -> "git"
134134+ | NoSource -> "none")
135135+ |> Object.mem "url" string ~dec_absent:"" ~enc:(function
136136+ | ArchiveSource a -> a.url
137137+ | GitSource g -> g.url
138138+ | NoSource -> "")
139139+ |> Object.opt_mem "checksums" (list string) ~enc:(function
140140+ | ArchiveSource a -> Some a.checksums
141141+ | _ -> None)
142142+ |> Object.mem "mirrors" (list string) ~dec_absent:[] ~enc:(function
143143+ | ArchiveSource a -> a.mirrors
144144+ | _ -> [])
145145+ |> Object.opt_mem "branch" string ~enc:(function
146146+ | GitSource g -> g.branch
147147+ | _ -> None)
148148+ |> Object.finish
149149+150150+let dev_repo_jsont =
151151+ Jsont.(
152152+ map
153153+ ~dec:(fun s -> Dev_repo.of_string s)
154154+ ~enc:Dev_repo.to_string string)
155155+156156+let package_source_jsont : package_source Jsont.t =
157157+ let open Jsont in
158158+ Object.map ~kind:"package_source"
159159+ (fun name version source dev_repo ->
160160+ ({ name; version; source; dev_repo } : package_source))
161161+ |> Object.mem "name" string ~enc:(fun (p : package_source) -> p.name)
162162+ |> Object.mem "version" string ~enc:(fun (p : package_source) -> p.version)
163163+ |> Object.mem "source" source_jsont ~enc:(fun (p : package_source) -> p.source)
164164+ |> Object.opt_mem "dev_repo" dev_repo_jsont ~enc:(fun (p : package_source) -> p.dev_repo)
165165+ |> Object.finish
166166+167167+let package_sources_jsont = Jsont.list package_source_jsont
168168+169169+let grouped_sources_jsont : grouped_sources Jsont.t =
170170+ let open Jsont in
171171+ Object.map ~kind:"grouped_sources"
172172+ (fun dev_repo packages -> ({ dev_repo; packages } : grouped_sources))
173173+ |> Object.opt_mem "dev_repo" dev_repo_jsont ~enc:(fun (g : grouped_sources) -> g.dev_repo)
174174+ |> Object.mem "packages" (list package_source_jsont) ~enc:(fun (g : grouped_sources) -> g.packages)
175175+ |> Object.finish
176176+177177+let grouped_sources_list_jsont = Jsont.list grouped_sources_jsont
178178+179179+(* TOML Codecs *)
180180+181181+let source_tomlt : source Tomlt.t =
182182+ let open Tomlt in
183183+ let open Table in
184184+ obj (fun source_type url checksums mirrors branch ->
185185+ match source_type with
186186+ | "archive" ->
187187+ let checksums = match checksums with Some cs -> cs | None -> [] in
188188+ ArchiveSource { url; checksums; mirrors }
189189+ | "git" -> GitSource { url; branch }
190190+ | "none" | _ -> NoSource)
191191+ |> mem "type" string ~enc:(function
192192+ | ArchiveSource _ -> "archive"
193193+ | GitSource _ -> "git"
194194+ | NoSource -> "none")
195195+ |> mem "url" string ~dec_absent:"" ~enc:(function
196196+ | ArchiveSource a -> a.url
197197+ | GitSource g -> g.url
198198+ | NoSource -> "")
199199+ |> opt_mem "checksums" (list string) ~enc:(function
200200+ | ArchiveSource a -> Some a.checksums
201201+ | _ -> None)
202202+ |> mem "mirrors" (list string) ~dec_absent:[] ~enc:(function
203203+ | ArchiveSource a -> a.mirrors
204204+ | _ -> [])
205205+ |> opt_mem "branch" string ~enc:(function
206206+ | GitSource g -> g.branch
207207+ | _ -> None)
208208+ |> finish
209209+210210+let dev_repo_tomlt =
211211+ Tomlt.(
212212+ map
213213+ ~dec:(fun s -> Dev_repo.of_string s)
214214+ ~enc:Dev_repo.to_string string)
215215+216216+let package_source_tomlt : package_source Tomlt.t =
217217+ let open Tomlt in
218218+ let open Table in
219219+ obj (fun name version source dev_repo ->
220220+ ({ name; version; source; dev_repo } : package_source))
221221+ |> mem "name" string ~enc:(fun (p : package_source) -> p.name)
222222+ |> mem "version" string ~enc:(fun (p : package_source) -> p.version)
223223+ |> mem "source" source_tomlt ~enc:(fun (p : package_source) -> p.source)
224224+ |> opt_mem "dev_repo" dev_repo_tomlt ~enc:(fun (p : package_source) -> p.dev_repo)
225225+ |> finish
226226+227227+let package_sources_tomlt = Tomlt.array_of_tables package_source_tomlt
228228+229229+let grouped_sources_tomlt : grouped_sources Tomlt.t =
230230+ let open Tomlt in
231231+ let open Table in
232232+ obj (fun dev_repo packages -> ({ dev_repo; packages } : grouped_sources))
233233+ |> opt_mem "dev_repo" dev_repo_tomlt ~enc:(fun (g : grouped_sources) -> g.dev_repo)
234234+ |> mem "packages" (array_of_tables package_source_tomlt) ~enc:(fun (g : grouped_sources) -> g.packages)
235235+ |> finish
236236+237237+let grouped_sources_list_tomlt = Tomlt.array_of_tables grouped_sources_tomlt
+85
lib/source.mli
···11+(** Package source URL extraction.
22+33+ Extracts download URLs or git remotes from opam package metadata. *)
44+55+(** {1 Source Types} *)
66+77+type source_kind =
88+ | Archive (** Tarball/archive URL with optional checksums *)
99+ | Git (** Git repository URL *)
1010+(** The kind of source to extract. *)
1111+1212+type archive_source = {
1313+ url : string;
1414+ checksums : string list; (** SHA256, MD5, etc. *)
1515+ mirrors : string list;
1616+}
1717+(** An archive source with URL and integrity info. *)
1818+1919+type git_source = {
2020+ url : string;
2121+ branch : string option; (** Branch/tag/ref if specified *)
2222+}
2323+(** A git repository source. *)
2424+2525+type source =
2626+ | ArchiveSource of archive_source
2727+ | GitSource of git_source
2828+ | NoSource
2929+(** A package source. *)
3030+3131+type package_source = {
3232+ name : string;
3333+ version : string;
3434+ source : source;
3535+ dev_repo : Dev_repo.t option;
3636+}
3737+(** A package with its source and dev-repo for grouping. *)
3838+3939+type grouped_sources = {
4040+ dev_repo : Dev_repo.t option;
4141+ packages : package_source list;
4242+}
4343+(** Packages grouped by their shared dev-repo. *)
4444+4545+(** {1 Extraction} *)
4646+4747+val extract : source_kind -> Repo_index.package_info -> package_source
4848+(** [extract kind info] extracts the source of the specified kind from
4949+ package [info]. For [Archive], uses the url field. For [Git], uses
5050+ dev-repo or falls back to url if it's a git URL. *)
5151+5252+val extract_all : source_kind -> Repo_index.package_info list -> package_source list
5353+(** [extract_all kind packages] extracts sources for all packages. *)
5454+5555+val group_by_dev_repo : package_source list -> grouped_sources list
5656+(** [group_by_dev_repo sources] groups packages by their dev-repo.
5757+ Packages with the same dev-repo are grouped together since they
5858+ come from the same repository. Groups with dev-repo are sorted first,
5959+ followed by packages without dev-repo. *)
6060+6161+(** {1 Codecs} *)
6262+6363+val package_source_jsont : package_source Jsont.t
6464+(** JSON codec for a package source. *)
6565+6666+val package_sources_jsont : package_source list Jsont.t
6767+(** JSON codec for a list of package sources. *)
6868+6969+val grouped_sources_jsont : grouped_sources Jsont.t
7070+(** JSON codec for grouped sources. *)
7171+7272+val grouped_sources_list_jsont : grouped_sources list Jsont.t
7373+(** JSON codec for a list of grouped sources. *)
7474+7575+val package_source_tomlt : package_source Tomlt.t
7676+(** TOML codec for a package source. *)
7777+7878+val package_sources_tomlt : package_source list Tomlt.t
7979+(** TOML codec for a list of package sources (as array of tables). *)
8080+8181+val grouped_sources_tomlt : grouped_sources Tomlt.t
8282+(** TOML codec for grouped sources. *)
8383+8484+val grouped_sources_list_tomlt : grouped_sources list Tomlt.t
8585+(** TOML codec for a list of grouped sources (as array of tables). *)