Repository: ocamllabs/duniverse
Pull request #110 (2020-08-10 13:08:50)

Generate opam lockfiles
This PR introduces a new command that generates a `.opam.locked` file from the local `dune-get`.

The generated opam files contains the dependencies and all the pin depends necessary to have a reproducible opam workflow.

For now the opam lockfile doesn't contain all the information the `dune-get` file does but I'm looking into this so that we can totally replace the dune-get file, using `x-` extra opam fields.
One annoying thing is that the pin-depends don't allow us to track the ref initially used to do the commit resolution i.e. the tag for regular packages or the eventually the branch for the pinned ones.
The pinned packages won't cause much trouble since we'll probably record them in an extra field somewhere, with the branch information stored so if we want to run the equivalent of `duniverse update` it's still possible.
For the regular ones we still have the version but we lose the tag we inferred. It's not much work to recompute it but it still feels like we lose a bit of information and I'm wondering if it can cause issues in some cases.

Also the opam file might contain the same information but it won't be structured in a duniverse friendly way. Again, it's not blocking but it will complexify this part of the code slightly.

Just to summarize if I got this correctly, the next steps are:
1. Add extra field so that the generated lockfile holds the same information as the dune-get
2. Add functions to parse such a lock file to the internal `Duniverse.t` representation
3. Make it so `duniverse init` generates that instead and do not use `dune-get` internally

Another point worth discussing is how should we deal with multi-packages repos. Right now there's a single lockfile with all the deps for the root packages. The idea is to use it as a strict replacement for the dune-get file so we're keeping the same convention. As we discussed on slack it might make sense to generate one lockfile per package, as long as we pin the internal deps to the local package so that it "inherits" their pin-depends but that does make the lockfiles less standalone so I'm not too sure about that. Right now all the deps are described in this lockfile, including transitive ones and I'm not sure how that should translate into multiple lock files. Anyway that's definitely not necessary for our first prototype!

NathanReb: @avsm feel free to give this a try as the fields relevant to opam won't change and this should already give you an opam installable setup.

If you want to give it a spin the workflow would be:
1. run `duniverse init` to generate a `dune-get` file if you don't already have one
2. run `duniverse to-opam` to generate the corresponding opam lockfile
3. run `opam install ./duniverse-lock.opam.locked` to install the locked dependencies using pins

It should work from there!
NathanReb: A quick note on what's left to do:
1. [ ] the `depends` and `pin-depends` aren't actually enough to recompute the exact internal `Duniverse.t` so we either need to store it in a separate field, either just what's required so we don't duplicate the information from `depends` and `pin-depends` or a complete representation of the source and opam deps to ease the parsing of the lockfile. The latter seems alright even though it induces quite some redundancy in the lockfile.
2. [ ] decide what to do about `depexts`. I kind of liked the idea that the lockfile would be standalone, i.e. it defines all dependencies, including transitive ones + the depexts but if those are going to be installed through opam anyway, there isn't much value in doing so I guess.
3. [ ] Make it so the opam lockfile becomes the default duniverse config file, replacing `dune-get`. I'll keep the `to-opam` command I added for testing, renaming it `migrate-dune-get` so that existing users can migrate their `dune-get` file without having to recompute the lock from scratch, might be helpful for RWO for instance.
NathanReb: We'll keep the depext field for now to maintain the same set of features!
NathanReb: @avsm I think this has everything we need now, you should give it a spin! I'll look into re-implementing `pull` using opam's lib in the meantime!
avsm: Looks good. It's possibly finding a few latent bugs in opam's lockfile handling: lots of the below when running on duniverse itself:

```
[0install-solver.2.17] synchronised from git+https://github.com/0install/0install.git#4a837bd638d93905b96d073c28c644894f8d4a0b
[WARNING] Lock-file /Users/avsm/src/git/ocamllabs/duniverse/_opam/.opam-switch/sources/0install-solver/0install-solver.opam.locked doesn't exist, using
          /Users/avsm/src/git/ocamllabs/duniverse/_opam/.opam-switch/sources/0install-solver/0install-solver.opam instead.
0install-solver is now pinned to git+https://github.com/0install/0install.git#4a837bd638d93905b96d073c28c644894f8d4a0b (version 2.17)
[alcotest.1.2.1] synchronised from git+https://github.com/mirage/alcotest.git#6ca7cc04ea07f5bd5b001fed4a0313d82e4b3cc0
[WARNING] Lock-file /Users/avsm/src/git/ocamllabs/duniverse/_opam/.opam-switch/sources/alcotest/alcotest.opam.locked doesn't exist, using
          /Users/avsm/src/git/ocamllabs/duniverse/_opam/.opam-switch/sources/alcotest/alcotest.opam instead.
alcotest is now pinned to git+https://github.com/mirage/alcotest.git#6ca7cc04ea07f5bd5b001fed4a0313d82e4b3cc0 (version 1.2.1)
Processing: [astring.0.8.4+dune: git]
[astring.0.8.4+dune] synchronised from git://github.com/dune-universe/astring.git#baa4e7e2bf919293aac2c899786c2ac40757dd9b
[WARNING] Lock-file /Users/avsm/src/git/ocamllabs/duniverse/_opam/.opam-switch/sources/astring/astring.opam.locked doesn't exist, using
          /Users/avsm/src/git/ocamllabs/duniverse/_opam/.opam-switch/sources/astring/astring.opam instead.
astring is now pinned to git://github.com/dune-universe/astring.git#baa4e7e2bf919293aac2c899786c2ac40757dd9b (version 0.8.4+dune)
```

It looks like opam expects both a .opam and a .opam.locked.  Things improved when I did a `mv duniverse-lock.opam.locked duniverse.opam.locked`.  Perhaps we should just pick the root package (from dune-project (name)) rather than duniverse-lock. That would let `opam switch create . --locked` work out of the box.

More worryingly, the pin-depends handling is really slow in 2.0. I'll try that in 2.1alpha3 to see if its improved.
NathanReb: You might have a bit of a hard time trying it with opam 2.1 as the opam lib has introduced breaking changes between now and then unfortunately.

Also do you understand why changing the name of the lockfile would fix the above warnings? Is it because if it doesn't have `.opam` corresponding to the `.opam.locked`, it expects a `.opam.locked` for all the packages?

I'll change it to use the package name when there's only one and eventually prompt for user input when there's more. I think we should really think the multi-opam scenario through as it might be really confusing. Having such different `.opam` and `.opam.locked` for the main package because it includes the dependencies for all packages might come off as a suprise to users of regular `.opam.locked` files. Even worse, what if there are non co-installable packages in the repo? I think we can't go wrong if we let the user chose in those cases.
NathanReb: Hmm now that I think about this further not having a fixed name or our own extension makes it harder to reliably find the duniverse lock file in most commands.

Using the name from the `dune-project` slightly improves this on our side but it comes with the same issues I mentioned above.

Understanding exactly why opam goes nuts if there's no corresponding `.opam` file might actually help figuring out the best solution here. I'll take a look.

Patch:
diff --git a/README.md b/README.md
index 6200f2ac..6adae867 100644
--- a/README.md
+++ b/README.md
@@ -47,12 +47,15 @@ COMMANDS
            print external packages required to build this duniverse
 
        init
-           analyse opam files to generate an initial dune-get
+           analyse opam files to generate an initial lock file
+
+       migrate-dune-get
+           convert a legacy dune-get file to an opam lock file
 
        opam-install
            install packages that are not duniverse-compatible via opam
 
-       pin Add a pinned package dependency to dune-get
+       pin Add a pinned package dependency to the lock file
 
        print-ocaml-compilers
            print OCaml compilers that are supported for this duniverse
@@ -64,7 +67,7 @@ COMMANDS
            Make all the tools needed to build the project available
 
        unpin
-           Remove a pinned package dependency from dune-get
+           Remove a pinned package dependency from the lock file
 
        update
            update the commit hash corresponding to the tracked branch/tag for
diff --git a/bin/duniverse.ml b/bin/duniverse.ml
index 7ac997b1..8a74dc94 100644
--- a/bin/duniverse.ml
+++ b/bin/duniverse.ml
@@ -18,6 +18,6 @@ open Duniverse_cli
 
 let cmds =
   [ Init.cmd; Pin.pin_cmd; Pin.unpin_cmd; Update.cmd; Pull.cmd; Opam_install.cmd;
-    Depext.cmd; Print_ocaml_compilers.cmd; Tools.cmd ]
+    Depext.cmd; Print_ocaml_compilers.cmd; Tools.cmd; Migrate_dune_get.cmd ]
 
 let () = Cmdliner.Term.(exit @@ eval_choice Default.cmd cmds)
diff --git a/cli/depext.ml b/cli/depext.ml
index 328d96e5..c998e2d6 100644
--- a/cli/depext.ml
+++ b/cli/depext.ml
@@ -2,24 +2,14 @@ open Stdune
 open Duniverse_lib
 
 let show_opam_depexts config env =
-  (* TODO cant find a OpamFilter.of_string, so parse a minimal opam buffer
-     to reconstruct formula instead *)
-  let b = Buffer.create 1024 in
-  Buffer.add_string b "opam-version: \"2.0\"\ndepexts: [\n";
-  List.iter
-    ~f:(fun (s, f) ->
-      let tags = String.concat ~sep:" " (List.map ~f:(Printf.sprintf "%S") s) in
-      Printf.bprintf b "  [ %s ] {%s}\n" tags f)
-    config.Duniverse.depexts;
-  Buffer.add_string b "]\n";
-  Buffer.to_bytes b |> Bytes.to_string |> OpamFile.OPAM.read_from_string |> OpamFile.OPAM.depexts
-  |> List.fold_left ~init:[] ~f:(fun acc (tags, filter) ->
-         if OpamFilter.eval_to_bool ~default:false env filter then tags @ acc else acc)
+  List.fold_left config.Duniverse.depexts ~init:[]
+    ~f:(fun acc (tags, filter) ->
+      if OpamFilter.eval_to_bool ~default:false env filter then tags @ acc else acc)
   |> fun tags -> List.sort_uniq ~compare:String.compare tags |> List.iter ~f:print_endline
 
 let run (`Repo repo) () =
   let open Rresult.R in
-  let duniverse_file = Fpath.(repo // Config.duniverse_file) in
+  Repo.duniverse_file repo >>= fun duniverse_file ->
   Duniverse.load ~file:duniverse_file >>= fun config ->
   Osrelease.Distro.v () >>= fun distro ->
   Osrelease.Version.v () >>= fun version ->
diff --git a/cli/init.ml b/cli/init.ml
index 3f1cba4b..fde57155 100644
--- a/cli/init.ml
+++ b/cli/init.ml
@@ -43,15 +43,16 @@ let run (`Repo repo)
     (`Opam_repo opam_repo) (`Pull_mode pull_mode) () =
   let open Rresult.R.Infix in
   init_local_opam_repo opam_repo >>= fun local_opam_repo ->
-  Opam_cmd.find_local_opam_packages repo >>= fun local_paths ->
-  Pins.read_from_config Fpath.(repo // Config.duniverse_file) >>= fun pins ->
+  Repo.local_packages repo >>= fun local_paths ->
+  Repo.duniverse_file ~local_packages:local_paths repo >>= fun duniverse_file ->
+  Pins.read_from_config duniverse_file >>= fun pins ->
   let local_packages = List.map fst (String.Map.bindings local_paths) in
   build_config ~local_packages ~pins ~pull_mode ~opam_repo
   >>= fun config ->
   if pins <> [] then
     Common.Logs.app (fun l -> l "Using %a pins from %a: %a."
         Fmt.(styled `Green int) (List.length pins)
-        Styled_pp.path (Fpath.normalize Config.duniverse_file)
+        Styled_pp.path (Fpath.normalize duniverse_file)
         Fmt.(list ~sep:(unit " ") (styled `Yellow string))
         (List.map (fun pin -> pin.Types.Opam.pin) pins));
   Pins.init ~repo ~pull_mode ~pins >>= fun pin_deps ->
@@ -76,18 +77,20 @@ let run (`Repo repo)
         (List.length depexts)
         Fmt.(styled `Green int)
         (List.length opam_entries));
-  List.iter (fun (k, v) -> Logs.info (fun l -> l "depext %s %s" (String.concat ~sep:"," k) v)) depexts;
+  List.iter
+    (fun (k, v) ->
+       Logs.info (fun l -> l "depext %s %s" (String.concat ~sep:"," k) (OpamFilter.to_string v)))
+    depexts;
   Common.Logs.app (fun l -> l "Calculating Git repositories to vendor source code.");
   compute_deps ~opam_entries >>= resolve_ref >>= fun deps ->
   let deps = { deps with duniverse = deps.duniverse @ pin_deps } in
   let duniverse = { Duniverse.config; deps; depexts } in
-  let file = Fpath.(repo // Config.duniverse_file) in
-  Duniverse.save ~file duniverse >>= fun () ->
+  Duniverse.save ~file:duniverse_file duniverse >>= fun () ->
   Common.Logs.app (fun l ->
       l "Wrote duniverse file with %a entries to %a. You can now run %a to fetch the sources."
         Fmt.(styled `Green int)
         (Duniverse.Deps.count duniverse.deps)
-        Styled_pp.path (Fpath.normalize file)
+        Styled_pp.path (Fpath.normalize duniverse_file)
         Fmt.(styled `Blue string)
         "duniverse pull");
   Ok ()
@@ -110,9 +113,7 @@ let pull_mode =
 
 let info =
   let exits = Term.default_exits in
-  let doc =
-    Fmt.strf "analyse opam files to generate an initial $(b,%a)" Fpath.pp Config.duniverse_file
-  in
+  let doc = Fmt.strf "analyse opam files to generate an initial lock file" in
   let man = [] in
   Term.info "init" ~doc ~exits ~man ~envs:Common.Arg.caches
 
diff --git a/cli/migrate_dune_get.ml b/cli/migrate_dune_get.ml
new file mode 100644
index 00000000..0ba43f50
--- /dev/null
+++ b/cli/migrate_dune_get.ml
@@ -0,0 +1,20 @@
+open Duniverse_lib
+open Rresult
+
+let run (`Repo repo) () =
+  let dune_get = Fpath.(repo // Config.dune_get) in
+  let duniverse_res = Duniverse.load_dune_get ~file:dune_get [@warning "-3"] in
+  duniverse_res >>= fun duniverse ->
+  Repo.duniverse_file repo >>= fun file ->
+  Duniverse.save ~file duniverse
+
+let info =
+  let open Cmdliner in
+  let doc = "convert a legacy dune-get file to an opam lock file" in
+  let exits = Term.default_exits in
+  Term.info "migrate-dune-get" ~doc ~exits
+
+let term =
+  Cmdliner.Term.(term_result (const run $ Common.Arg.repo $ Common.Arg.setup_logs ()))
+
+let cmd = (term, info)
diff --git a/cli/migrate_dune_get.mli b/cli/migrate_dune_get.mli
new file mode 100644
index 00000000..ef33aedd
--- /dev/null
+++ b/cli/migrate_dune_get.mli
@@ -0,0 +1,1 @@
+val cmd : unit Cmdliner.Term.t * Cmdliner.Term.info
diff --git a/cli/pin.ml b/cli/pin.ml
index 277a86b8..f2f93125 100644
--- a/cli/pin.ml
+++ b/cli/pin.ml
@@ -7,7 +7,7 @@ let confirm_replace_pin name ~yes () =
       Fmt.(styled `Yellow string) name)
 
 let pin (`Pin_name pin_name) (`Pin_uri uri) (`Repo repo) (`Yes yes) () =
-  let file = Fpath.(repo // Config.duniverse_file) in
+  Repo.duniverse_file repo >>= fun file ->
   Bos.OS.File.exists file >>= fun exists ->
   if not exists then
     Rresult.R.error_msgf
@@ -37,7 +37,7 @@ let pin (`Pin_name pin_name) (`Pin_uri uri) (`Repo repo) (`Yes yes) () =
     Init.run (`Repo repo) (`Opam_repo opam_repo) (`Pull_mode pull_mode) ()
 
 let unpin (`Pin_name pin_name) (`Repo repo) () =
-  let file = Fpath.(repo // Config.duniverse_file) in
+  Repo.duniverse_file repo >>= fun file ->
   Bos.OS.File.exists file >>= fun exists ->
   if not exists then
     Rresult.R.error_msgf
@@ -67,7 +67,7 @@ open Cmdliner
 
 let pin_name =
   let docv = "PACKAGE_NAME" in
-  let doc = "The $(docv) to be added to the " ^ Fpath.to_string Config.duniverse_file ^ " file." in
+  let doc = "The $(docv) to be added to the lock file." in
   Common.Arg.named
     (fun x -> `Pin_name x)
     Arg.(required & pos 0 (some string) None & info [] ~docv ~doc)
@@ -87,7 +87,7 @@ let pin_cmd =
   let info =
     let exits = Term.default_exits in
     let doc =
-      Fmt.strf "Add a pinned package dependency to $(b,%a)" Fpath.pp Config.duniverse_file
+      Fmt.strf "Add a pinned package dependency to the lock file"
     in
     let man = [] in
     Term.info "pin" ~doc ~exits ~man ~envs:Common.Arg.caches in
@@ -100,9 +100,7 @@ let unpin_cmd =
       ( const unpin $ pin_name $ Common.Arg.repo $ Common.Arg.setup_logs () ) in
   let info =
     let exits = Term.default_exits in
-    let doc =
-      Fmt.strf "Remove a pinned package dependency from $(b,%a)" Fpath.pp Config.duniverse_file
-    in
+    let doc = Fmt.strf "Remove a pinned package dependency from the lock file" in
     let man = [] in
     Term.info "unpin" ~doc ~exits ~man ~envs:Common.Arg.caches in
   (term, info)
diff --git a/cli/print_ocaml_compilers.ml b/cli/print_ocaml_compilers.ml
index ce94c955..45e2d776 100644
--- a/cli/print_ocaml_compilers.ml
+++ b/cli/print_ocaml_compilers.ml
@@ -3,7 +3,7 @@ open Duniverse_lib
 
 let run (`Repo repo) () =
   let open Rresult.R in
-  let duniverse_file = Fpath.(repo // Config.duniverse_file) in
+  Repo.duniverse_file repo >>= fun duniverse_file ->
   Duniverse.load ~file:duniverse_file >>= fun config ->
   print_endline (String.concat ~sep:" " config.Duniverse.config.ocaml_compilers);
   Ok ()
diff --git a/cli/pull.ml b/cli/pull.ml
index 6856ff1d..47656c5d 100644
--- a/cli/pull.ml
+++ b/cli/pull.ml
@@ -58,7 +58,7 @@ let check_dune_lang_version ~yes ~repo =
 
 let run (`Yes yes) (`No_cache no_cache) (`Repo repo) (`Duniverse_repos duniverse_repos) () =
   let open Result.O in
-  let duniverse_file = Fpath.(repo // Config.duniverse_file) in
+  Repo.duniverse_file repo >>= fun duniverse_file ->
   Duniverse.load ~file:duniverse_file >>= function
   | { deps = { duniverse = []; _ }; _ } ->
       Common.Logs.app (fun l -> l "No dependencies to pull, there's nothing to be done here!");
diff --git a/cli/tools.ml b/cli/tools.ml
index 6a3e88ee..18f5bc92 100644
--- a/cli/tools.ml
+++ b/cli/tools.ml
@@ -192,7 +192,7 @@ let install_local_tools repo opam_repo config cache strategy =
 
 let tools (`Repo repo) (`Opam_repo opam_repo) (`No_cache no_cache) () =
   let open R.Infix in
-  let duniverse_file = Fpath.(repo // Config.duniverse_file) in
+  Repo.duniverse_file repo >>= fun duniverse_file ->
   Duniverse.load ~file:duniverse_file >>= fun config -> (* TODO better error handling if no dune-get file *)
   (* Check for OCaml *)
   Common.get_cache ~no_cache >>= fun cache ->
diff --git a/cli/update.ml b/cli/update.ml
index f7994476..da963b76 100644
--- a/cli/update.ml
+++ b/cli/update.ml
@@ -27,7 +27,7 @@ let update ~total ~updated ~to_update src_dep =
 
 let run (`Repo repo) (`Duniverse_repos duniverse_repos) () =
   let open Result.O in
-  let duniverse_file = Fpath.(repo // Config.duniverse_file) in
+  Repo.duniverse_file repo >>= fun duniverse_file ->
   Common.Logs.app (fun l ->
       l "Updating %a to track the latest commits" Styled_pp.path duniverse_file);
   Duniverse.load ~file:duniverse_file >>= function
diff --git a/dune-get b/dune-get
deleted file mode 100644
index 891095ce..00000000
--- a/dune-get
+++ /dev/null
@@ -1,188 +0,0 @@
-((config
-  ((version 1) (root_packages (((name duniverse))))
-   (pins (((pin osrelease) (url (https://github.com/avsm/osrelease.git)))))
-   (pull_mode Source) (ocaml_compilers (4.07.1 4.08.1 4.09.1 4.10.0))
-   (tools
-    ((opam Latest) (ocamlformat (Eq 0.13.0)) (dune Latest) (odoc Latest)
-     (mdx Latest) (lsp Latest) (merlin Latest)))))
- (deps
-  ((opamverse ())
-   (duniverse
-    (((dir 0install-solver.2.17)
-      (upstream https://github.com/0install/0install.git)
-      (ref ((t v2.17) (commit 4a837bd638d93905b96d073c28c644894f8d4a0b)))
-      (provided_packages (((name 0install-solver) (version (2.17))))))
-     ((dir alcotest.1.1.0) (upstream https://github.com/mirage/alcotest.git)
-      (ref ((t 1.1.0) (commit 4974a9b652cbec92b9aaa71354be9a6c85d14f52)))
-      (provided_packages (((name alcotest) (version (1.1.0))))))
-     ((dir astring.0.8.4+dune)
-      (upstream git://github.com/dune-universe/astring.git)
-      (ref
-       ((t duniverse-v0.8.4)
-        (commit baa4e7e2bf919293aac2c899786c2ac40757dd9b)))
-      (provided_packages (((name astring) (version (0.8.4+dune))))))
-     ((dir base.v0.14.0) (upstream https://github.com/janestreet/base.git)
-      (ref ((t v0.14.0) (commit b333879831027292802556282855e60a472660e2)))
-      (provided_packages (((name base) (version (v0.14.0))))))
-     ((dir bos.0.2.0+dune) (upstream git://github.com/dune-universe/bos.git)
-      (ref
-       ((t duniverse-v0.2.0)
-        (commit 6c69eddd9a56f3caa21a7240c37cbf1c7b7ba1a1)))
-      (provided_packages (((name bos) (version (0.2.0+dune))))))
-     ((dir cmdliner.1.0.4+dune)
-      (upstream git://github.com/dune-universe/cmdliner.git)
-      (ref
-       ((t duniverse-v1.0.4)
-        (commit 4fc08affe26642b6285cedc1d2ca7ec03127caf3)))
-      (provided_packages (((name cmdliner) (version (1.0.4+dune))))))
-     ((dir cppo.1.6.6) (upstream https://github.com/ocaml-community/cppo.git)
-      (ref ((t v1.6.6) (commit 51c598bf4a4732fdd9be1b16a5b26743ec4023d2)))
-      (provided_packages (((name cppo) (version (1.6.6))))))
-     ((dir dune-build-info.2.6.0)
-      (upstream https://github.com/ocaml/dune.git)
-      (ref ((t 2.6.0) (commit 693946bdac8e12d96d16bda076f32c268ec2e2f3)))
-      (provided_packages
-       (((name dune-private-libs) (version (2.6.0)))
-        ((name dune-configurator) (version (2.6.0)))
-        ((name dune-build-info) (version (2.6.0))))))
-     ((dir findlib.1.8.1+dune)
-      (upstream https://github.com/dune-universe/lib-findlib)
-      (ref
-       ((t duniverse-1.8.1)
-        (commit 55b3374114ea425e8881e4758a06b02ba06aabb5)))
-      (provided_packages
-       (((name ocamlfind) (version (1.8.1+dune)))
-        ((name findlib) (version (1.8.1+dune))))))
-     ((dir fmt.0.8.8+dune) (upstream https://github.com/dune-universe/fmt)
-      (ref
-       ((t duniverse-v0.8.8)
-        (commit 342fa6d95e847694b71f798ad55cb932b64d0d35)))
-      (provided_packages (((name fmt) (version (0.8.8+dune))))))
-     ((dir fpath.0.7.2+dune)
-      (upstream git://github.com/dune-universe/fpath.git)
-      (ref
-       ((t duniverse-v0.7.2)
-        (commit 9402e6033c994ab3201be04c6f8e8a101e737ab9)))
-      (provided_packages (((name fpath) (version (0.7.2+dune))))))
-     ((dir logs.0.7.0+dune)
-      (upstream git://github.com/dune-universe/logs.git)
-      (ref
-       ((t duniverse-v0.7.0)
-        (commit 4f141048f71b1d84b8e283086c62ca270aa13051)))
-      (provided_packages (((name logs) (version (0.7.0+dune))))))
-     ((dir lwt.5.3.0) (upstream https://github.com/ocsigen/lwt.git)
-      (ref ((t 5.3.0) (commit ba43d08a484774545428fd9e5a7ba6a8fbb6440a)))
-      (provided_packages (((name lwt) (version (5.3.0))))))
-     ((dir mmap.1.1.0) (upstream https://github.com/mirage/mmap.git)
-      (ref ((t v1.1.0) (commit 46f613db11c00667764523ccbb3d63e53e1c666c)))
-      (provided_packages (((name mmap) (version (1.1.0))))))
-     ((dir num.1.3+dune) (upstream git://github.com/dune-universe/num.git)
-      (ref
-       ((t duniverse-v1.3) (commit bdb2d7653e927e142b701b51d89f393471279713)))
-      (provided_packages (((name num) (version (1.3+dune))))))
-     ((dir ocaml-compiler-libs.v0.12.1)
-      (upstream https://github.com/janestreet/ocaml-compiler-libs.git)
-      (ref ((t v0.12.1) (commit c202a46641ed0bb20eac15ea4f005cbd9f3e5c01)))
-      (provided_packages (((name ocaml-compiler-libs) (version (v0.12.1))))))
-     ((dir ocaml-migrate-parsetree.1.7.3)
-      (upstream https://github.com/ocaml-ppx/ocaml-migrate-parsetree.git)
-      (ref ((t v1.7.3) (commit c5d4f6fce30d3eb699d79d288d0b265752ba64c5)))
-      (provided_packages
-       (((name ocaml-migrate-parsetree) (version (1.7.3))))))
-     ((dir ocaml-version.2.5.0)
-      (upstream https://github.com/ocurrent/ocaml-version.git)
-      (ref ((t v2.5.0) (commit 92c2ceced4bb625a855cfb6fa1aba560b9792e81)))
-      (provided_packages (((name ocaml-version) (version (2.5.0))))))
-     ((dir ocamlgraph.1.8.8+dune)
-      (upstream git://github.com/dune-universe/ocamlgraph.git)
-      (ref
-       ((t duniverse-v1.8.8)
-        (commit 8b911e0c9c17b6c996b4af8e27fe5edf4e766d81)))
-      (provided_packages (((name ocamlgraph) (version (1.8.8+dune))))))
-     ((dir ocplib-endian.1.1)
-      (upstream https://github.com/OCamlPro/ocplib-endian.git)
-      (ref ((t 1.1) (commit 6ee41bb7f8f2bef103bcf4e05ace57597c0d2316)))
-      (provided_packages (((name ocplib-endian) (version (1.1))))))
-     ((dir opam-0install.0.2)
-      (upstream https://github.com/talex5/opam-0install-solver.git)
-      (ref ((t v0.2) (commit bd2df903a079e46aa9934f3349ed358afa1a5105)))
-      (provided_packages (((name opam-0install) (version (0.2))))))
-     ((dir opam-core.2.0.7+dune)
-      (upstream git://github.com/dune-universe/opam.git)
-      (ref
-       ((t duniverse-2.0.7)
-        (commit 98605c52251efebcf2b52e4ba5cb16e306f01642)))
-      (provided_packages
-       (((name opam-state) (version (2.0.7+dune)))
-        ((name opam-repository) (version (2.0.7+dune)))
-        ((name opam-format) (version (2.0.7+dune)))
-        ((name opam-core) (version (2.0.7+dune))))))
-     ((dir opam-file-format.2.0.0+dune)
-      (upstream git://github.com/dune-universe/opam-file-format.git)
-      (ref
-       ((t duniverse-2.0.0)
-        (commit ec0db8081136a93f4d6873dba90e0f0d29d1b43b)))
-      (provided_packages (((name opam-file-format) (version (2.0.0+dune))))))
-     ((dir osrelease.zdev) (upstream https://github.com/avsm/osrelease.git)
-      (ref ((t master) (commit b7f343c8ecbb63884eb6a94e9bdcbf1c21919f46)))
-      (provided_packages (((name osrelease)))))
-     ((dir parsexp.v0.14.0)
-      (upstream https://github.com/janestreet/parsexp.git)
-      (ref ((t v0.14.0) (commit 910f52daf663ae19763c72981138efe9de308906)))
-      (provided_packages (((name parsexp) (version (v0.14.0))))))
-     ((dir ppx_derivers.1.2.1)
-      (upstream git://github.com/ocaml-ppx/ppx_derivers.git)
-      (ref ((t 1.2.1) (commit d6655353f647d33de12d215aaa477ba936febef3)))
-      (provided_packages (((name ppx_derivers) (version (1.2.1))))))
-     ((dir ppx_sexp_conv.v0.14.0)
-      (upstream https://github.com/janestreet/ppx_sexp_conv.git)
-      (ref ((t v0.14.0) (commit ef6002c97086d2f084c300731159e2d5702830bc)))
-      (provided_packages (((name ppx_sexp_conv) (version (v0.14.0))))))
-     ((dir ppxlib.0.13.0) (upstream https://github.com/ocaml-ppx/ppxlib.git)
-      (ref ((t 0.13.0) (commit a148a3c976e0a3d71e93ea5287c7cfd546844656)))
-      (provided_packages (((name ppxlib) (version (0.13.0))))))
-     ((dir re.1.9.0) (upstream https://github.com/ocaml/ocaml-re.git)
-      (ref ((t 1.9.0) (commit 42c7f8899c9b1a4e011053487a97cf7eab312d5f)))
-      (provided_packages (((name re) (version (1.9.0))))))
-     ((dir result.1.5) (upstream https://github.com/janestreet/result.git)
-      (ref ((t 1.5) (commit b0b9cd6e9cce8d2b7ad11127a6135540ec3f8a92)))
-      (provided_packages (((name result) (version (1.5))))))
-     ((dir rresult.0.6.0+dune)
-      (upstream git://github.com/dune-universe/rresult.git)
-      (ref
-       ((t duniverse-v0.6.0)
-        (commit 1ff30e48943f69f7e7bcac211b56f6c7c76eeccb)))
-      (provided_packages (((name rresult) (version (0.6.0+dune))))))
-     ((dir seq.base+dune) (upstream https://github.com/c-cube/seq.git)
-      (ref ((t 0.2.2) (commit 6934813195285661cba3e017e7d3cd79d362bb2f)))
-      (provided_packages (((name seq) (version (base+dune))))))
-     ((dir sexplib.v0.14.0)
-      (upstream https://github.com/janestreet/sexplib.git)
-      (ref ((t v0.14.0) (commit 7d9414bf2086d95b10fe2001e16ff418331ccdd9)))
-      (provided_packages (((name sexplib) (version (v0.14.0))))))
-     ((dir sexplib0.v0.14.0)
-      (upstream https://github.com/janestreet/sexplib0.git)
-      (ref ((t v0.14.0) (commit d29026829f320fcf7903f3828428ec921b04edc3)))
-      (provided_packages (((name sexplib0) (version (v0.14.0))))))
-     ((dir stdio.v0.14.0) (upstream https://github.com/janestreet/stdio.git)
-      (ref ((t v0.14.0) (commit c93007fb8c3b93397ded5ac04275a8ac574e6c30)))
-      (provided_packages (((name stdio) (version (v0.14.0))))))
-     ((dir stdlib-shims.0.1.0)
-      (upstream https://github.com/ocaml/stdlib-shims.git)
-      (ref ((t 0.1.0) (commit d32f70e5bec8255903e9b27f89c49da812ad9b6e)))
-      (provided_packages (((name stdlib-shims) (version (0.1.0))))))
-     ((dir stringext.1.6.0)
-      (upstream https://github.com/rgrinberg/stringext.git)
-      (ref ((t 1.6.0) (commit 2bce0a6fe54e8f8782f7a3b2be44a5e1fb37a522)))
-      (provided_packages (((name stringext) (version (1.6.0))))))
-     ((dir uri-sexp.3.1.0) (upstream https://github.com/mirage/ocaml-uri.git)
-      (ref ((t v3.1.0) (commit 21f76c72f126be44ac29c7cb22e1fc95ef9c8b0c)))
-      (provided_packages
-       (((name uri-sexp) (version (3.1.0))) ((name uri) (version (3.1.0))))))
-     ((dir uuidm.0.9.7+dune)
-      (upstream git://github.com/dune-universe/uuidm.git)
-      (ref
-       ((t duniverse-v0.9.7)
-        (commit fa124fd3c0a82f7005fdac982e520c9c066488c4)))
-      (provided_packages (((name uuidm) (version (0.9.7+dune))))))))))
- (depexts ()))
diff --git a/duniverse.opam.locked b/duniverse.opam.locked
new file mode 100644
index 00000000..bacb57a3
--- /dev/null
+++ b/duniverse.opam.locked
@@ -0,0 +1,692 @@
+x-duniverse-config: [
+  ["version" "1"]
+  ["root_packages" [[["name" "duniverse"]]]]
+  [
+    "pins"
+    [[["pin" "osrelease"] ["url" ["https://github.com/avsm/osrelease.git"]]]]
+  ]
+  ["pull_mode" "Source"]
+  ["ocaml_compilers" ["4.07.1" "4.08.1" "4.09.1" "4.10.0"]]
+  [
+    "tools"
+    [
+      ["opam" "Latest"]
+      ["ocamlformat" ["Eq" "0.13.0"]]
+      ["dune" "Latest"]
+      ["odoc" "Latest"]
+      ["mdx" "Latest"]
+      ["lsp" "Latest"]
+      ["merlin" "Latest"]
+    ]
+  ]
+]
+x-duniverse-duniverse: [
+  [
+    ["dir" "0install-solver.2.17"]
+    ["upstream" "https://github.com/0install/0install.git"]
+    [
+      "ref"
+      [["t" "v2.17"] ["commit" "4a837bd638d93905b96d073c28c644894f8d4a0b"]]
+    ]
+    ["provided_packages" [[["name" "0install-solver"] ["version" ["2.17"]]]]]
+  ]
+  [
+    ["dir" "alcotest.1.1.0"]
+    ["upstream" "https://github.com/mirage/alcotest.git"]
+    [
+      "ref"
+      [["t" "1.1.0"] ["commit" "4974a9b652cbec92b9aaa71354be9a6c85d14f52"]]
+    ]
+    ["provided_packages" [[["name" "alcotest"] ["version" ["1.1.0"]]]]]
+  ]
+  [
+    ["dir" "astring.0.8.4+dune"]
+    ["upstream" "git://github.com/dune-universe/astring.git"]
+    [
+      "ref"
+      [
+        ["t" "duniverse-v0.8.4"]
+        ["commit" "baa4e7e2bf919293aac2c899786c2ac40757dd9b"]
+      ]
+    ]
+    ["provided_packages" [[["name" "astring"] ["version" ["0.8.4+dune"]]]]]
+  ]
+  [
+    ["dir" "base.v0.14.0"]
+    ["upstream" "https://github.com/janestreet/base.git"]
+    [
+      "ref"
+      [["t" "v0.14.0"] ["commit" "b333879831027292802556282855e60a472660e2"]]
+    ]
+    ["provided_packages" [[["name" "base"] ["version" ["v0.14.0"]]]]]
+  ]
+  [
+    ["dir" "bos.0.2.0+dune"]
+    ["upstream" "git://github.com/dune-universe/bos.git"]
+    [
+      "ref"
+      [
+        ["t" "duniverse-v0.2.0"]
+        ["commit" "6c69eddd9a56f3caa21a7240c37cbf1c7b7ba1a1"]
+      ]
+    ]
+    ["provided_packages" [[["name" "bos"] ["version" ["0.2.0+dune"]]]]]
+  ]
+  [
+    ["dir" "cmdliner.1.0.4+dune"]
+    ["upstream" "git://github.com/dune-universe/cmdliner.git"]
+    [
+      "ref"
+      [
+        ["t" "duniverse-v1.0.4"]
+        ["commit" "4fc08affe26642b6285cedc1d2ca7ec03127caf3"]
+      ]
+    ]
+    ["provided_packages" [[["name" "cmdliner"] ["version" ["1.0.4+dune"]]]]]
+  ]
+  [
+    ["dir" "cppo.1.6.6"]
+    ["upstream" "https://github.com/ocaml-community/cppo.git"]
+    [
+      "ref"
+      [["t" "v1.6.6"] ["commit" "51c598bf4a4732fdd9be1b16a5b26743ec4023d2"]]
+    ]
+    ["provided_packages" [[["name" "cppo"] ["version" ["1.6.6"]]]]]
+  ]
+  [
+    ["dir" "dune-build-info.2.6.0"]
+    ["upstream" "https://github.com/ocaml/dune.git"]
+    [
+      "ref"
+      [["t" "2.6.0"] ["commit" "693946bdac8e12d96d16bda076f32c268ec2e2f3"]]
+    ]
+    [
+      "provided_packages"
+      [
+        [["name" "dune-private-libs"] ["version" ["2.6.0"]]]
+        [["name" "dune-configurator"] ["version" ["2.6.0"]]]
+        [["name" "dune-build-info"] ["version" ["2.6.0"]]]
+      ]
+    ]
+  ]
+  [
+    ["dir" "findlib.1.8.1+dune"]
+    ["upstream" "https://github.com/dune-universe/lib-findlib"]
+    [
+      "ref"
+      [
+        ["t" "duniverse-1.8.1"]
+        ["commit" "55b3374114ea425e8881e4758a06b02ba06aabb5"]
+      ]
+    ]
+    [
+      "provided_packages"
+      [
+        [["name" "ocamlfind"] ["version" ["1.8.1+dune"]]]
+        [["name" "findlib"] ["version" ["1.8.1+dune"]]]
+      ]
+    ]
+  ]
+  [
+    ["dir" "fmt.0.8.8+dune"]
+    ["upstream" "https://github.com/dune-universe/fmt"]
+    [
+      "ref"
+      [
+        ["t" "duniverse-v0.8.8"]
+        ["commit" "342fa6d95e847694b71f798ad55cb932b64d0d35"]
+      ]
+    ]
+    ["provided_packages" [[["name" "fmt"] ["version" ["0.8.8+dune"]]]]]
+  ]
+  [
+    ["dir" "fpath.0.7.2+dune"]
+    ["upstream" "git://github.com/dune-universe/fpath.git"]
+    [
+      "ref"
+      [
+        ["t" "duniverse-v0.7.2"]
+        ["commit" "9402e6033c994ab3201be04c6f8e8a101e737ab9"]
+      ]
+    ]
+    ["provided_packages" [[["name" "fpath"] ["version" ["0.7.2+dune"]]]]]
+  ]
+  [
+    ["dir" "logs.0.7.0+dune"]
+    ["upstream" "git://github.com/dune-universe/logs.git"]
+    [
+      "ref"
+      [
+        ["t" "duniverse-v0.7.0"]
+        ["commit" "4f141048f71b1d84b8e283086c62ca270aa13051"]
+      ]
+    ]
+    ["provided_packages" [[["name" "logs"] ["version" ["0.7.0+dune"]]]]]
+  ]
+  [
+    ["dir" "lwt.5.3.0"]
+    ["upstream" "https://github.com/ocsigen/lwt.git"]
+    [
+      "ref"
+      [["t" "5.3.0"] ["commit" "ba43d08a484774545428fd9e5a7ba6a8fbb6440a"]]
+    ]
+    ["provided_packages" [[["name" "lwt"] ["version" ["5.3.0"]]]]]
+  ]
+  [
+    ["dir" "mmap.1.1.0"]
+    ["upstream" "https://github.com/mirage/mmap.git"]
+    [
+      "ref"
+      [["t" "v1.1.0"] ["commit" "46f613db11c00667764523ccbb3d63e53e1c666c"]]
+    ]
+    ["provided_packages" [[["name" "mmap"] ["version" ["1.1.0"]]]]]
+  ]
+  [
+    ["dir" "num.1.3+dune"]
+    ["upstream" "git://github.com/dune-universe/num.git"]
+    [
+      "ref"
+      [
+        ["t" "duniverse-v1.3"]
+        ["commit" "bdb2d7653e927e142b701b51d89f393471279713"]
+      ]
+    ]
+    ["provided_packages" [[["name" "num"] ["version" ["1.3+dune"]]]]]
+  ]
+  [
+    ["dir" "ocaml-compiler-libs.v0.12.1"]
+    ["upstream" "https://github.com/janestreet/ocaml-compiler-libs.git"]
+    [
+      "ref"
+      [["t" "v0.12.1"] ["commit" "c202a46641ed0bb20eac15ea4f005cbd9f3e5c01"]]
+    ]
+    [
+      "provided_packages"
+      [[["name" "ocaml-compiler-libs"] ["version" ["v0.12.1"]]]]
+    ]
+  ]
+  [
+    ["dir" "ocaml-migrate-parsetree.1.7.3"]
+    ["upstream" "https://github.com/ocaml-ppx/ocaml-migrate-parsetree.git"]
+    [
+      "ref"
+      [["t" "v1.7.3"] ["commit" "c5d4f6fce30d3eb699d79d288d0b265752ba64c5"]]
+    ]
+    [
+      "provided_packages"
+      [[["name" "ocaml-migrate-parsetree"] ["version" ["1.7.3"]]]]
+    ]
+  ]
+  [
+    ["dir" "ocaml-version.2.5.0"]
+    ["upstream" "https://github.com/ocurrent/ocaml-version.git"]
+    [
+      "ref"
+      [["t" "v2.5.0"] ["commit" "92c2ceced4bb625a855cfb6fa1aba560b9792e81"]]
+    ]
+    ["provided_packages" [[["name" "ocaml-version"] ["version" ["2.5.0"]]]]]
+  ]
+  [
+    ["dir" "ocamlgraph.1.8.8+dune"]
+    ["upstream" "git://github.com/dune-universe/ocamlgraph.git"]
+    [
+      "ref"
+      [
+        ["t" "duniverse-v1.8.8"]
+        ["commit" "8b911e0c9c17b6c996b4af8e27fe5edf4e766d81"]
+      ]
+    ]
+    [
+      "provided_packages" [[["name" "ocamlgraph"] ["version" ["1.8.8+dune"]]]]
+    ]
+  ]
+  [
+    ["dir" "ocplib-endian.1.1"]
+    ["upstream" "https://github.com/OCamlPro/ocplib-endian.git"]
+    [
+      "ref"
+      [["t" "1.1"] ["commit" "6ee41bb7f8f2bef103bcf4e05ace57597c0d2316"]]
+    ]
+    ["provided_packages" [[["name" "ocplib-endian"] ["version" ["1.1"]]]]]
+  ]
+  [
+    ["dir" "opam-0install.0.2"]
+    ["upstream" "https://github.com/talex5/opam-0install-solver.git"]
+    [
+      "ref"
+      [["t" "v0.2"] ["commit" "bd2df903a079e46aa9934f3349ed358afa1a5105"]]
+    ]
+    ["provided_packages" [[["name" "opam-0install"] ["version" ["0.2"]]]]]
+  ]
+  [
+    ["dir" "opam-core.2.0.7+dune"]
+    ["upstream" "git://github.com/dune-universe/opam.git"]
+    [
+      "ref"
+      [
+        ["t" "duniverse-2.0.7"]
+        ["commit" "98605c52251efebcf2b52e4ba5cb16e306f01642"]
+      ]
+    ]
+    [
+      "provided_packages"
+      [
+        [["name" "opam-state"] ["version" ["2.0.7+dune"]]]
+        [["name" "opam-repository"] ["version" ["2.0.7+dune"]]]
+        [["name" "opam-format"] ["version" ["2.0.7+dune"]]]
+        [["name" "opam-core"] ["version" ["2.0.7+dune"]]]
+      ]
+    ]
+  ]
+  [
+    ["dir" "opam-file-format.2.0.0+dune"]
+    ["upstream" "git://github.com/dune-universe/opam-file-format.git"]
+    [
+      "ref"
+      [
+        ["t" "duniverse-2.0.0"]
+        ["commit" "ec0db8081136a93f4d6873dba90e0f0d29d1b43b"]
+      ]
+    ]
+    [
+      "provided_packages"
+      [[["name" "opam-file-format"] ["version" ["2.0.0+dune"]]]]
+    ]
+  ]
+  [
+    ["dir" "osrelease.zdev"]
+    ["upstream" "https://github.com/avsm/osrelease.git"]
+    [
+      "ref"
+      [["t" "master"] ["commit" "b7f343c8ecbb63884eb6a94e9bdcbf1c21919f46"]]
+    ]
+    ["provided_packages" [[["name" "osrelease"]]]]
+  ]
+  [
+    ["dir" "parsexp.v0.14.0"]
+    ["upstream" "https://github.com/janestreet/parsexp.git"]
+    [
+      "ref"
+      [["t" "v0.14.0"] ["commit" "910f52daf663ae19763c72981138efe9de308906"]]
+    ]
+    ["provided_packages" [[["name" "parsexp"] ["version" ["v0.14.0"]]]]]
+  ]
+  [
+    ["dir" "ppx_derivers.1.2.1"]
+    ["upstream" "git://github.com/ocaml-ppx/ppx_derivers.git"]
+    [
+      "ref"
+      [["t" "1.2.1"] ["commit" "d6655353f647d33de12d215aaa477ba936febef3"]]
+    ]
+    ["provided_packages" [[["name" "ppx_derivers"] ["version" ["1.2.1"]]]]]
+  ]
+  [
+    ["dir" "ppx_sexp_conv.v0.14.0"]
+    ["upstream" "https://github.com/janestreet/ppx_sexp_conv.git"]
+    [
+      "ref"
+      [["t" "v0.14.0"] ["commit" "ef6002c97086d2f084c300731159e2d5702830bc"]]
+    ]
+    [
+      "provided_packages" [[["name" "ppx_sexp_conv"] ["version" ["v0.14.0"]]]]
+    ]
+  ]
+  [
+    ["dir" "ppxlib.0.13.0"]
+    ["upstream" "https://github.com/ocaml-ppx/ppxlib.git"]
+    [
+      "ref"
+      [["t" "0.13.0"] ["commit" "a148a3c976e0a3d71e93ea5287c7cfd546844656"]]
+    ]
+    ["provided_packages" [[["name" "ppxlib"] ["version" ["0.13.0"]]]]]
+  ]
+  [
+    ["dir" "re.1.9.0"]
+    ["upstream" "https://github.com/ocaml/ocaml-re.git"]
+    [
+      "ref"
+      [["t" "1.9.0"] ["commit" "42c7f8899c9b1a4e011053487a97cf7eab312d5f"]]
+    ]
+    ["provided_packages" [[["name" "re"] ["version" ["1.9.0"]]]]]
+  ]
+  [
+    ["dir" "result.1.5"]
+    ["upstream" "https://github.com/janestreet/result.git"]
+    [
+      "ref"
+      [["t" "1.5"] ["commit" "b0b9cd6e9cce8d2b7ad11127a6135540ec3f8a92"]]
+    ]
+    ["provided_packages" [[["name" "result"] ["version" ["1.5"]]]]]
+  ]
+  [
+    ["dir" "rresult.0.6.0+dune"]
+    ["upstream" "git://github.com/dune-universe/rresult.git"]
+    [
+      "ref"
+      [
+        ["t" "duniverse-v0.6.0"]
+        ["commit" "1ff30e48943f69f7e7bcac211b56f6c7c76eeccb"]
+      ]
+    ]
+    ["provided_packages" [[["name" "rresult"] ["version" ["0.6.0+dune"]]]]]
+  ]
+  [
+    ["dir" "seq.base+dune"]
+    ["upstream" "https://github.com/c-cube/seq.git"]
+    [
+      "ref"
+      [["t" "0.2.2"] ["commit" "6934813195285661cba3e017e7d3cd79d362bb2f"]]
+    ]
+    ["provided_packages" [[["name" "seq"] ["version" ["base+dune"]]]]]
+  ]
+  [
+    ["dir" "sexplib.v0.14.0"]
+    ["upstream" "https://github.com/janestreet/sexplib.git"]
+    [
+      "ref"
+      [["t" "v0.14.0"] ["commit" "7d9414bf2086d95b10fe2001e16ff418331ccdd9"]]
+    ]
+    ["provided_packages" [[["name" "sexplib"] ["version" ["v0.14.0"]]]]]
+  ]
+  [
+    ["dir" "sexplib0.v0.14.0"]
+    ["upstream" "https://github.com/janestreet/sexplib0.git"]
+    [
+      "ref"
+      [["t" "v0.14.0"] ["commit" "d29026829f320fcf7903f3828428ec921b04edc3"]]
+    ]
+    ["provided_packages" [[["name" "sexplib0"] ["version" ["v0.14.0"]]]]]
+  ]
+  [
+    ["dir" "stdio.v0.14.0"]
+    ["upstream" "https://github.com/janestreet/stdio.git"]
+    [
+      "ref"
+      [["t" "v0.14.0"] ["commit" "c93007fb8c3b93397ded5ac04275a8ac574e6c30"]]
+    ]
+    ["provided_packages" [[["name" "stdio"] ["version" ["v0.14.0"]]]]]
+  ]
+  [
+    ["dir" "stdlib-shims.0.1.0"]
+    ["upstream" "https://github.com/ocaml/stdlib-shims.git"]
+    [
+      "ref"
+      [["t" "0.1.0"] ["commit" "d32f70e5bec8255903e9b27f89c49da812ad9b6e"]]
+    ]
+    ["provided_packages" [[["name" "stdlib-shims"] ["version" ["0.1.0"]]]]]
+  ]
+  [
+    ["dir" "stringext.1.6.0"]
+    ["upstream" "https://github.com/rgrinberg/stringext.git"]
+    [
+      "ref"
+      [["t" "1.6.0"] ["commit" "2bce0a6fe54e8f8782f7a3b2be44a5e1fb37a522"]]
+    ]
+    ["provided_packages" [[["name" "stringext"] ["version" ["1.6.0"]]]]]
+  ]
+  [
+    ["dir" "uri-sexp.3.1.0"]
+    ["upstream" "https://github.com/mirage/ocaml-uri.git"]
+    [
+      "ref"
+      [["t" "v3.1.0"] ["commit" "21f76c72f126be44ac29c7cb22e1fc95ef9c8b0c"]]
+    ]
+    [
+      "provided_packages"
+      [
+        [["name" "uri-sexp"] ["version" ["3.1.0"]]]
+        [["name" "uri"] ["version" ["3.1.0"]]]
+      ]
+    ]
+  ]
+  [
+    ["dir" "uuidm.0.9.7+dune"]
+    ["upstream" "git://github.com/dune-universe/uuidm.git"]
+    [
+      "ref"
+      [
+        ["t" "duniverse-v0.9.7"]
+        ["commit" "fa124fd3c0a82f7005fdac982e520c9c066488c4"]
+      ]
+    ]
+    ["provided_packages" [[["name" "uuidm"] ["version" ["0.9.7+dune"]]]]]
+  ]
+]
+
+opam-version: "2.0"
+synopsis: "duniverse generated lockfile"
+maintainer: "duniverse"
+depends: [
+  "0install-solver" {= "2.17"}
+  "alcotest" {= "1.1.0"}
+  "astring" {= "0.8.4+dune"}
+  "base" {= "v0.14.0"}
+  "bos" {= "0.2.0+dune"}
+  "cmdliner" {= "1.0.4+dune"}
+  "cppo" {= "1.6.6"}
+  "dune-build-info" {= "2.6.0"}
+  "dune-configurator" {= "2.6.0"}
+  "dune-private-libs" {= "2.6.0"}
+  "findlib" {= "1.8.1+dune"}
+  "fmt" {= "0.8.8+dune"}
+  "fpath" {= "0.7.2+dune"}
+  "logs" {= "0.7.0+dune"}
+  "lwt" {= "5.3.0"}
+  "mmap" {= "1.1.0"}
+  "num" {= "1.3+dune"}
+  "ocaml-compiler-libs" {= "v0.12.1"}
+  "ocaml-migrate-parsetree" {= "1.7.3"}
+  "ocaml-version" {= "2.5.0"}
+  "ocamlfind" {= "1.8.1+dune"}
+  "ocamlgraph" {= "1.8.8+dune"}
+  "ocplib-endian" {= "1.1"}
+  "opam-0install" {= "0.2"}
+  "opam-core" {= "2.0.7+dune"}
+  "opam-file-format" {= "2.0.0+dune"}
+  "opam-format" {= "2.0.7+dune"}
+  "opam-repository" {= "2.0.7+dune"}
+  "opam-state" {= "2.0.7+dune"}
+  "osrelease" {= "zdev"}
+  "parsexp" {= "v0.14.0"}
+  "ppx_derivers" {= "1.2.1"}
+  "ppx_sexp_conv" {= "v0.14.0"}
+  "ppxlib" {= "0.13.0"}
+  "re" {= "1.9.0"}
+  "result" {= "1.5"}
+  "rresult" {= "0.6.0+dune"}
+  "seq" {= "base+dune"}
+  "sexplib" {= "v0.14.0"}
+  "sexplib0" {= "v0.14.0"}
+  "stdio" {= "v0.14.0"}
+  "stdlib-shims" {= "0.1.0"}
+  "stringext" {= "1.6.0"}
+  "uri" {= "3.1.0"}
+  "uri-sexp" {= "3.1.0"}
+  "uuidm" {= "0.9.7+dune"}
+]
+pin-depends: [
+  [
+    "0install-solver.2.17"
+    "git+https://github.com/0install/0install.git#4a837bd638d93905b96d073c28c644894f8d4a0b"
+  ]
+  [
+    "alcotest.1.1.0"
+    "git+https://github.com/mirage/alcotest.git#4974a9b652cbec92b9aaa71354be9a6c85d14f52"
+  ]
+  [
+    "astring.0.8.4+dune"
+    "git://github.com/dune-universe/astring.git#baa4e7e2bf919293aac2c899786c2ac40757dd9b"
+  ]
+  [
+    "base.v0.14.0"
+    "git+https://github.com/janestreet/base.git#b333879831027292802556282855e60a472660e2"
+  ]
+  [
+    "bos.0.2.0+dune"
+    "git://github.com/dune-universe/bos.git#6c69eddd9a56f3caa21a7240c37cbf1c7b7ba1a1"
+  ]
+  [
+    "cmdliner.1.0.4+dune"
+    "git://github.com/dune-universe/cmdliner.git#4fc08affe26642b6285cedc1d2ca7ec03127caf3"
+  ]
+  [
+    "cppo.1.6.6"
+    "git+https://github.com/ocaml-community/cppo.git#51c598bf4a4732fdd9be1b16a5b26743ec4023d2"
+  ]
+  [
+    "dune-build-info.2.6.0"
+    "git+https://github.com/ocaml/dune.git#693946bdac8e12d96d16bda076f32c268ec2e2f3"
+  ]
+  [
+    "dune-configurator.2.6.0"
+    "git+https://github.com/ocaml/dune.git#693946bdac8e12d96d16bda076f32c268ec2e2f3"
+  ]
+  [
+    "dune-private-libs.2.6.0"
+    "git+https://github.com/ocaml/dune.git#693946bdac8e12d96d16bda076f32c268ec2e2f3"
+  ]
+  [
+    "findlib.1.8.1+dune"
+    "git+https://github.com/dune-universe/lib-findlib#55b3374114ea425e8881e4758a06b02ba06aabb5"
+  ]
+  [
+    "fmt.0.8.8+dune"
+    "git+https://github.com/dune-universe/fmt#342fa6d95e847694b71f798ad55cb932b64d0d35"
+  ]
+  [
+    "fpath.0.7.2+dune"
+    "git://github.com/dune-universe/fpath.git#9402e6033c994ab3201be04c6f8e8a101e737ab9"
+  ]
+  [
+    "logs.0.7.0+dune"
+    "git://github.com/dune-universe/logs.git#4f141048f71b1d84b8e283086c62ca270aa13051"
+  ]
+  [
+    "lwt.5.3.0"
+    "git+https://github.com/ocsigen/lwt.git#ba43d08a484774545428fd9e5a7ba6a8fbb6440a"
+  ]
+  [
+    "mmap.1.1.0"
+    "git+https://github.com/mirage/mmap.git#46f613db11c00667764523ccbb3d63e53e1c666c"
+  ]
+  [
+    "num.1.3+dune"
+    "git://github.com/dune-universe/num.git#bdb2d7653e927e142b701b51d89f393471279713"
+  ]
+  [
+    "ocaml-compiler-libs.v0.12.1"
+    "git+https://github.com/janestreet/ocaml-compiler-libs.git#c202a46641ed0bb20eac15ea4f005cbd9f3e5c01"
+  ]
+  [
+    "ocaml-migrate-parsetree.1.7.3"
+    "git+https://github.com/ocaml-ppx/ocaml-migrate-parsetree.git#c5d4f6fce30d3eb699d79d288d0b265752ba64c5"
+  ]
+  [
+    "ocaml-version.2.5.0"
+    "git+https://github.com/ocurrent/ocaml-version.git#92c2ceced4bb625a855cfb6fa1aba560b9792e81"
+  ]
+  [
+    "ocamlfind.1.8.1+dune"
+    "git+https://github.com/dune-universe/lib-findlib#55b3374114ea425e8881e4758a06b02ba06aabb5"
+  ]
+  [
+    "ocamlgraph.1.8.8+dune"
+    "git://github.com/dune-universe/ocamlgraph.git#8b911e0c9c17b6c996b4af8e27fe5edf4e766d81"
+  ]
+  [
+    "ocplib-endian.1.1"
+    "git+https://github.com/OCamlPro/ocplib-endian.git#6ee41bb7f8f2bef103bcf4e05ace57597c0d2316"
+  ]
+  [
+    "opam-0install.0.2"
+    "git+https://github.com/talex5/opam-0install-solver.git#bd2df903a079e46aa9934f3349ed358afa1a5105"
+  ]
+  [
+    "opam-core.2.0.7+dune"
+    "git://github.com/dune-universe/opam.git#98605c52251efebcf2b52e4ba5cb16e306f01642"
+  ]
+  [
+    "opam-file-format.2.0.0+dune"
+    "git://github.com/dune-universe/opam-file-format.git#ec0db8081136a93f4d6873dba90e0f0d29d1b43b"
+  ]
+  [
+    "opam-format.2.0.7+dune"
+    "git://github.com/dune-universe/opam.git#98605c52251efebcf2b52e4ba5cb16e306f01642"
+  ]
+  [
+    "opam-repository.2.0.7+dune"
+    "git://github.com/dune-universe/opam.git#98605c52251efebcf2b52e4ba5cb16e306f01642"
+  ]
+  [
+    "opam-state.2.0.7+dune"
+    "git://github.com/dune-universe/opam.git#98605c52251efebcf2b52e4ba5cb16e306f01642"
+  ]
+  [
+    "osrelease.zdev"
+    "git+https://github.com/avsm/osrelease.git#b7f343c8ecbb63884eb6a94e9bdcbf1c21919f46"
+  ]
+  [
+    "parsexp.v0.14.0"
+    "git+https://github.com/janestreet/parsexp.git#910f52daf663ae19763c72981138efe9de308906"
+  ]
+  [
+    "ppx_derivers.1.2.1"
+    "git://github.com/ocaml-ppx/ppx_derivers.git#d6655353f647d33de12d215aaa477ba936febef3"
+  ]
+  [
+    "ppx_sexp_conv.v0.14.0"
+    "git+https://github.com/janestreet/ppx_sexp_conv.git#ef6002c97086d2f084c300731159e2d5702830bc"
+  ]
+  [
+    "ppxlib.0.13.0"
+    "git+https://github.com/ocaml-ppx/ppxlib.git#a148a3c976e0a3d71e93ea5287c7cfd546844656"
+  ]
+  [
+    "re.1.9.0"
+    "git+https://github.com/ocaml/ocaml-re.git#42c7f8899c9b1a4e011053487a97cf7eab312d5f"
+  ]
+  [
+    "result.1.5"
+    "git+https://github.com/janestreet/result.git#b0b9cd6e9cce8d2b7ad11127a6135540ec3f8a92"
+  ]
+  [
+    "rresult.0.6.0+dune"
+    "git://github.com/dune-universe/rresult.git#1ff30e48943f69f7e7bcac211b56f6c7c76eeccb"
+  ]
+  [
+    "seq.base+dune"
+    "git+https://github.com/c-cube/seq.git#6934813195285661cba3e017e7d3cd79d362bb2f"
+  ]
+  [
+    "sexplib.v0.14.0"
+    "git+https://github.com/janestreet/sexplib.git#7d9414bf2086d95b10fe2001e16ff418331ccdd9"
+  ]
+  [
+    "sexplib0.v0.14.0"
+    "git+https://github.com/janestreet/sexplib0.git#d29026829f320fcf7903f3828428ec921b04edc3"
+  ]
+  [
+    "stdio.v0.14.0"
+    "git+https://github.com/janestreet/stdio.git#c93007fb8c3b93397ded5ac04275a8ac574e6c30"
+  ]
+  [
+    "stdlib-shims.0.1.0"
+    "git+https://github.com/ocaml/stdlib-shims.git#d32f70e5bec8255903e9b27f89c49da812ad9b6e"
+  ]
+  [
+    "stringext.1.6.0"
+    "git+https://github.com/rgrinberg/stringext.git#2bce0a6fe54e8f8782f7a3b2be44a5e1fb37a522"
+  ]
+  [
+    "uri.3.1.0"
+    "git+https://github.com/mirage/ocaml-uri.git#21f76c72f126be44ac29c7cb22e1fc95ef9c8b0c"
+  ]
+  [
+    "uri-sexp.3.1.0"
+    "git+https://github.com/mirage/ocaml-uri.git#21f76c72f126be44ac29c7cb22e1fc95ef9c8b0c"
+  ]
+  [
+    "uuidm.0.9.7+dune"
+    "git://github.com/dune-universe/uuidm.git#fa124fd3c0a82f7005fdac982e520c9c066488c4"
+  ]
+]
diff --git a/lib/config.ml b/lib/config.ml
index bac413e3..1a45d7d3 100644
--- a/lib/config.ml
+++ b/lib/config.ml
@@ -29,11 +29,11 @@ let base_packages =
 
 let duniverse_opam_repo = "https://github.com/dune-universe/opam-repository.git#duniverse"
 
-let duniverse_file = Fpath.v "dune-get"
+let dune_get = Fpath.v "dune-get"
 
 let vendor_dir = Fpath.v "duniverse"
 
-let pins_dir = Fpath.v "duniverse/.pins"
+let pins_dir = Fpath.(vendor_dir / ".pins")
 
 let duniverse_log = Fpath.v ".duniverse-log"
 
diff --git a/lib/dune_file.ml b/lib/dune_file.ml
index 8c9563f6..32848496 100644
--- a/lib/dune_file.ml
+++ b/lib/dune_file.ml
@@ -35,6 +35,14 @@ module Lang = struct
 end
 
 module Raw = struct
+  let as_sexps path =
+    try Ok (Sexplib.Sexp.load_sexps (Fpath.to_string path))
+    with
+    | Sexplib.Sexp.Parse_error pe ->
+      Error (`Msg (Format.asprintf "Failed to parse dune file %a: %s" Fpath.pp path pe.err_msg))
+    | Failure _ ->
+      Error (`Msg (Format.asprintf "Failed to parse dune file %a: Invalid sexp" Fpath.pp path))
+
   let comment s = Printf.sprintf "; %s" s
 
   let vendored_dirs glob = Printf.sprintf "(vendored_dirs %s)" glob
@@ -53,10 +61,13 @@ end
 module Project = struct
   module OV = Ocaml_version
 
-  let load_dune_project () =
-    match Sexplib.Sexp.load_sexps "dune-project" with
-    | v -> Ok v
-    | exception exn -> Error (`Msg (Printexc.to_string exn))
+  let rec name sexps =
+    match (sexps : Sexplib0.Sexp.t list) with
+    | [] -> Error (`Msg "Missing a name field in the dune-project file")
+    | (List [Atom "name"; Atom name])::_ -> Ok name
+    | _::tl -> name tl
+
+  let load_dune_project () = Raw.as_sexps (Fpath.v "dune-project")
 
   let check_opam_generation =
     let open Sexplib.Sexp in
diff --git a/lib/dune_file.mli b/lib/dune_file.mli
index 69d606dd..83aab109 100644
--- a/lib/dune_file.mli
+++ b/lib/dune_file.mli
@@ -1,6 +1,9 @@
 open Stdune
 
 module Raw : sig
+  val as_sexps : Fpath.t -> (Sexplib0.Sexp.t list, [> `Msg of string]) result
+  (** Parses a dune file as a list of S-expressions. *)
+
   val comment : string -> string
 
   val vendored_dirs : string -> string
@@ -33,6 +36,10 @@ module Lang : sig
 end
 
 module Project : sig
+  val name : Sexplib0.Sexp.t list -> (string, [> `Msg of string]) result
+  (** Returns the dune-project's name given the content of the file as a list of S-expressions,
+      if any. *)
+
   val supported_ocaml_compilers : unit -> (Ocaml_version.t list, [> `Msg of string ]) result
   (** Enumerate the supported released OCaml compilers that match the specification
       in the [dune-project] file. If no [dune-project] file is present then an error is
diff --git a/lib/duniverse.ml b/lib/duniverse.ml
index ffc08b10..2d1e6c50 100644
--- a/lib/duniverse.ml
+++ b/lib/duniverse.ml
@@ -19,6 +19,14 @@ module Deps = struct
     let raw_pp fmt { name; version } =
       let open Pp_combinators.Ocaml in
       Format.fprintf fmt "@[<hov 2>{ name = %S;@ version = %a }@]" name (option string) version
+
+    let explicit_version t =
+      Option.value ~default:"zdev" t.version
+
+    let to_opam_dep t : OpamTypes.filtered_formula =
+      let version = explicit_version t in
+      let name = OpamPackage.Name.of_string t.name in
+      Atom (name, Atom (Constraint (`Eq, (FString version))))
   end
 
   module Source = struct
@@ -95,6 +103,14 @@ module Deps = struct
     let resolve ~resolve_ref ({ upstream; ref; _ } as t) =
       let open Result.O in
       resolve_ref ~upstream ~ref >>= fun resolved_ref -> Ok { t with ref = resolved_ref }
+
+    let to_opam_pin_deps (t : resolved t) =
+      let url = OpamUrl.of_string (Printf.sprintf "git+%s#%s" t.upstream t.ref.commit) in
+      List.map t.provided_packages
+        ~f:(fun pkg ->
+            let version = Opam.explicit_version pkg in
+            let opam_pkg = OpamPackage.of_string (Printf.sprintf "%s.%s" pkg.name version) in
+            (opam_pkg, url))
   end
 
   module Classified = struct
@@ -162,7 +178,31 @@ module Deps = struct
 end
 
 module Depexts = struct
-  type t = (string list * string) list [@@deriving sexp]
+  module Repr = struct
+    type t = (string list * string) list [@@deriving sexp]
+  end
+
+  type t = (string list * OpamTypes.filter) list
+
+  let to_repr t = List.map ~f:(fun (l, filter) -> (l, OpamFilter.to_string filter)) t
+
+  let of_repr repr =
+    let minimal_opam_str =
+      let b = Buffer.create 1024 in
+      Buffer.add_string b "opam-version: \"2.0\"\ndepexts: [\n";
+      List.iter repr
+        ~f:(fun (s, f) ->
+            let quoted = List.map s ~f:(Printf.sprintf "%S") in
+            let tags = String.concat ~sep:" " quoted in
+            Printf.bprintf b " [ %s ] {%s}\n" tags f);
+      Buffer.add_string b "]\n";
+      Buffer.contents b
+    in
+    let minimal_opam = OpamFile.OPAM.read_from_string minimal_opam_str in
+    OpamFile.OPAM.depexts minimal_opam
+
+  let sexp_of_t t = Repr.sexp_of_t (to_repr t)
+  let t_of_sexp sexp = of_repr (Repr.t_of_sexp sexp)
 end
 
 (** duniverse knows about which tools to use, and can calculate a set of allowable versions
@@ -232,7 +272,7 @@ end
 
 type t = { config : Config.t; deps : resolved Deps.t; depexts : Depexts.t } [@@deriving sexp]
 
-let load ~file = Persist.load_sexp "duniverse" t_of_sexp file
+let load_dune_get ~file = Persist.load_sexp "duniverse" t_of_sexp file
 
 let sort ({ deps = { opamverse; duniverse }; _ } as t) =
   let sorted_opamverse =
@@ -247,4 +287,110 @@ let sort ({ deps = { opamverse; duniverse }; _ } as t) =
   in
   { t with deps = { opamverse = sorted_opamverse; duniverse = sorted_duniverse } }
 
-let save ~file t = Persist.save_sexp "duniverse" sexp_of_t file (sort t)
+let sexp_of_opamverse opamverse =
+  Sexplib0.Sexp_conv.sexp_of_list Deps.Opam.sexp_of_t opamverse
+
+let opamverse_of_sexp sexp =
+  Sexplib0.Sexp_conv.list_of_sexp Deps.Opam.t_of_sexp sexp
+
+let sexp_of_duniverse duniverse =
+  Sexplib0.Sexp_conv.sexp_of_list (Deps.Source.sexp_of_t Git.Ref.sexp_of_resolved) duniverse
+
+let duniverse_of_sexp sexp =
+  Sexplib0.Sexp_conv.list_of_sexp (Deps.Source.t_of_sexp Git.Ref.resolved_of_sexp) sexp
+
+module Opam_ext : sig
+  type 'a field
+
+  val duniverse_field : Git.Ref.resolved Deps.Source.t list field
+  val opamverse_field : Deps.Opam.t list field
+  val config_field : Config.t field
+
+  val add : ('a -> Sexplib0.Sexp.t) -> 'a field -> 'a -> OpamFile.OPAM.t -> OpamFile.OPAM.t
+  val get :
+    ?file: string ->
+    ?default: 'a ->
+    (Sexplib0.Sexp.t -> 'a) ->
+    'a field ->
+    OpamFile.OPAM.t ->
+    ('a, [> `Msg of string]) result
+end = struct
+  type _ field = string
+
+  let duniverse_field = "x-duniverse-duniverse"
+  let opamverse_field = "x-duniverse-opamverse"
+  let config_field = "x-duniverse-config"
+
+  let add sexp_of field a opam =
+    OpamFile.OPAM.add_extension opam field (Opam_value.from_sexp (sexp_of a))
+
+  let get ?file ?default of_sexp field opam =
+    let open Result.O in
+    match OpamFile.OPAM.extended opam field (fun i -> i), default with
+    | None, Some default -> Ok default
+    | None, None ->
+      let file_suffix_opt = Option.map ~f:(Printf.sprintf " in %s") file in
+      let file_suffix = Option.value ~default:"" file_suffix_opt in
+      Error (`Msg (Printf.sprintf "Missing %s field%s" field file_suffix))
+    | Some ov, _ -> Opam_value.to_sexp_strict ov >>| of_sexp
+end
+
+let to_opam t =
+  let deps =
+    let packages =
+      let opam = t.deps.opamverse in
+      let source = List.concat_map t.deps.duniverse ~f:(fun s -> s.provided_packages) in
+      List.sort ~compare:(fun o o' -> String.compare o.name o'.name) (opam @ source)
+    in
+    match packages with
+    | hd::tl ->
+      List.fold_left tl
+        ~f:(fun acc pkg -> OpamFormula.(And (acc, (Deps.Opam.to_opam_dep pkg))))
+        ~init:(Deps.Opam.to_opam_dep hd)
+    | [] ->
+      OpamFormula.Empty
+  in
+  let pin_deps =
+    List.concat_map t.deps.duniverse ~f:Deps.Source.to_opam_pin_deps
+    |> List.sort ~compare:(fun (p, _) (p', _) -> Ordering.of_int (OpamPackage.compare p p'))
+  in
+  let t = sort t in
+  let open OpamFile.OPAM in
+  empty
+  |> with_maintainer ["duniverse"]
+  |> with_synopsis "duniverse generated lockfile"
+  |> with_depends deps
+  |> with_pin_depends pin_deps
+  |> with_depexts t.depexts
+  |> Opam_ext.(add Config.sexp_of_t config_field t.config)
+  |> Opam_ext.(add sexp_of_opamverse opamverse_field t.deps.opamverse)
+  |> Opam_ext.(add sexp_of_duniverse duniverse_field t.deps.duniverse)
+
+let from_opam ?file opam =
+  let open Result.O in
+  let open OpamFile.OPAM in
+  let depexts = depexts opam in
+  Opam_ext.(get ?file Config.t_of_sexp config_field opam) >>= fun config ->
+  Opam_ext.(get ?file ~default:[] opamverse_of_sexp opamverse_field opam) >>= fun opamverse ->
+  Opam_ext.(get ?file ~default:[] duniverse_of_sexp duniverse_field opam) >>= fun duniverse ->
+  let deps = { Deps.opamverse; duniverse } in
+  Ok { config; deps; depexts }
+
+let save ~file t =
+  let open Result.O in
+  let opam = to_opam t in
+  Bos.OS.File.with_oc file (fun oc () ->
+      OpamFile.OPAM.write_to_channel oc opam;
+      Ok ())
+    ()
+  >>= fun res -> res
+
+let load ~file =
+  let open Result.O in
+  let filename = Fpath.to_string file in
+  Bos.OS.File.with_ic file (fun ic () ->
+      let filename = OpamFile.make (OpamFilename.of_string filename) in
+      OpamFile.OPAM.read_from_channel ~filename ic)
+    ()
+  >>= fun opam ->
+  from_opam ~file:filename opam
diff --git a/lib/duniverse.mli b/lib/duniverse.mli
index 383832b4..23bade0c 100644
--- a/lib/duniverse.mli
+++ b/lib/duniverse.mli
@@ -82,7 +82,7 @@ module Deps : sig
 end
 
 module Depexts : sig
-  type t = (string list * string) list [@@deriving sexp]
+  type t = (string list * OpamTypes.filter) list [@@deriving sexp]
 end
 
 module Tools : sig
@@ -126,6 +126,22 @@ end
 
 type t = { config : Config.t; deps : resolved Deps.t; depexts : Depexts.t } [@@deriving sexp]
 
+(* Converts a [t] to an opam file.
+   Extensions are used to store extra information and depends, pin-depends and depexts
+   fields are properly set so that the resulting lockfile can be reproducibly
+   opam-installed. *)
+val to_opam : t -> OpamFile.OPAM.t
+
+(* Parses a duniverse generated opam lockfile to a [t] value.
+   The optional [file] argument is used for improved error reporting only, it should
+   be passed when available. *)
+val from_opam : ?file: string -> OpamFile.OPAM.t -> (t, [> `Msg of string]) result
+
 val load : file:Fpath.t -> (t, [> `Msg of string ]) result
 
 val save : file:Fpath.t -> t -> (unit, [> `Msg of string ]) result
+
+(* Load a [t] from a legacy dune-get file.
+   Should be used for migration to the .opam.locked format only. *)
+val load_dune_get : file:Fpath.t -> (t, [> `Msg of string ]) result
+[@@deprecated "dune-get files are a legacy format, you should use load instead"]
diff --git a/lib/opam_cmd.ml b/lib/opam_cmd.ml
index bb4a67e2..61ae7e6c 100644
--- a/lib/opam_cmd.ml
+++ b/lib/opam_cmd.ml
@@ -36,13 +36,6 @@ let strip_ext fname =
   let fname = if has_ext "tar" fname then rem_ext fname else fname in
   to_string fname
 
-let find_local_opam_packages dir =
-  Bos.OS.Dir.exists dir >>= fun exists -> if not exists then Ok String.Map.empty else
-  Bos.OS.Dir.contents ~rel:true dir
-  >>| List.filter (Fpath.has_ext ".opam")
-  >>| List.map (fun path -> Fpath.(to_string (rem_ext path), dir // path))
-  >>| String.Map.of_list
-
 let tag_from_archive archive =
   let uri = Uri.of_string archive in
   let path = String.cuts ~empty:false ~sep:"/" (Uri.path uri) in
@@ -141,8 +134,7 @@ let compute_depexts ~get_opam_path pkgs =
     let opam_file = get_opam_path pkg in
     Bos.OS.File.read opam_file >>| fun opam_contents ->
     let opam = OpamFile.OPAM.read_from_string opam_contents in
-    OpamFile.OPAM.depexts opam |>
-    List.map (fun (s, f) -> (s, OpamFilter.to_string f))
+    OpamFile.OPAM.depexts opam
   in
   let open Rresult.R in
   Exec.map opam_depexts_of_pkg pkgs >>| fun depexts ->
@@ -290,11 +282,11 @@ let choose_root_packages ~local_packages =
       Ok local_packages
 
 let install_incompatible_packages yes repo =
+  Repo.duniverse_file repo >>= fun file ->
   Logs.app (fun l ->
       l "%aGathering dune-incompatible packages from %a." pp_header header
         Fmt.(styled `Cyan Fpath.pp)
-        Config.duniverse_file);
-  let file = Fpath.(repo // Config.duniverse_file) in
+        file);
   Duniverse.load ~file >>= fun { deps = { opamverse; _ }; _ } ->
   match opamverse with
   | [] ->
diff --git a/lib/opam_value.ml b/lib/opam_value.ml
new file mode 100644
index 00000000..b94aa2a1
--- /dev/null
+++ b/lib/opam_value.ml
@@ -0,0 +1,32 @@
+open Stdune
+
+let default_pos = "None", 0, 0
+
+let pos_errorf ~pos fmt =
+  let file, line, char = pos in
+  Format.ksprintf (fun msg -> Error (`Msg msg))
+    ("Error in %s, line %d, col %d: " ^^ fmt)
+    file line char
+
+let rec from_sexp ?(pos=default_pos) sexp : OpamTypes.value =
+  match (sexp : Sexplib.Sexp.t) with
+  | Atom s -> String (pos, s)
+  | List l -> List (pos, List.map ~f:(from_sexp ~pos) l)
+
+let rec to_sexp_strict value =
+  let open Result.O in
+  match (value : OpamTypes.value) with
+  | String (_, s) -> Ok (Sexplib0.Sexp.Atom s)
+  | List (_, l) ->
+    Result.List.all (List.map ~f:to_sexp_strict l) >>| fun l ->
+    Sexplib0.Sexp.List l
+  | Bool (pos, _)
+  | Int (pos, _)
+  | Relop (pos, _, _, _)
+  | Prefix_relop (pos, _, _)
+  | Logop (pos, _, _, _)
+  | Pfxop (pos, _, _)
+  | Ident (pos, _)
+  | Group (pos, _)
+  | Option (pos, _, _)
+  | Env_binding (pos, _, _, _) -> pos_errorf ~pos "Expected a sexp compatible opam value"
diff --git a/lib/opam_value.mli b/lib/opam_value.mli
new file mode 100644
index 00000000..aa8c6efd
--- /dev/null
+++ b/lib/opam_value.mli
@@ -0,0 +1,6 @@
+(* Convert a sexpression to an opam value using only lists and strings *)
+val from_sexp : ?pos: OpamTypes.pos -> Sexplib0.Sexp.t -> OpamTypes.value
+
+(* Converts an opam value to a sexpression assuming it's only made of
+   opam lists and strings *)
+val to_sexp_strict : OpamTypes.value -> (Sexplib0.Sexp.t, [> `Msg of string]) result
diff --git a/lib/pins.ml b/lib/pins.ml
index aa521692..b109adcb 100644
--- a/lib/pins.ml
+++ b/lib/pins.ml
@@ -65,7 +65,8 @@ let pull ~pull_mode ~repo ~pinned_paths pins =
   Ok duniverse
 
 let init ~repo ~pull_mode ~pins =
-  Opam_cmd.find_local_opam_packages Config.pins_dir >>= fun pinned_paths ->
+  let pins_dir = Fpath.(repo // Config.pins_dir) in
+  Repo.local_packages pins_dir >>= fun pinned_paths ->
   remove_stale_pins ~pinned_paths pins >>= fun () ->
   pull ~pull_mode ~repo ~pinned_paths pins >>= fun src_deps ->
   copy_opam_files ~pinned_paths src_deps >>= fun () ->
diff --git a/lib/repo.ml b/lib/repo.ml
new file mode 100644
index 00000000..1f0f7c62
--- /dev/null
+++ b/lib/repo.ml
@@ -0,0 +1,33 @@
+open Rresult
+open Astring
+
+type t = Fpath.t
+
+let local_packages t =
+  Bos.OS.Dir.exists t >>= fun exists -> if not exists then Ok String.Map.empty else
+  Bos.OS.Dir.contents ~rel:true t
+  >>| List.filter (Fpath.has_ext ".opam")
+  >>| List.map (fun path -> Fpath.(to_string (rem_ext path), t // path))
+  >>| String.Map.of_list
+
+let dune_project t = Fpath.(t / "dune-project")
+
+let project_name t =
+  let dune_project = dune_project t in
+  Dune_file.Raw.as_sexps dune_project >>= Dune_file.Project.name
+
+let duniverse_file ~name t =
+  Fpath.(t / (name ^ ".opam.locked"))
+
+let duniverse_file ?local_packages:lp t =
+  let local_packages =
+    match lp with
+    | Some lp -> Ok lp
+    | None -> local_packages t
+  in
+  local_packages >>= fun pkgs ->
+  match String.Map.bindings pkgs with
+  | [(name, _)] -> Ok (duniverse_file ~name t)
+  | _ ->
+    project_name t >>= fun name ->
+    Ok (duniverse_file ~name t)
diff --git a/lib/repo.mli b/lib/repo.mli
new file mode 100644
index 00000000..4878bfe5
--- /dev/null
+++ b/lib/repo.mli
@@ -0,0 +1,25 @@
+(** Utility functions to extract repository specific path and values *)
+
+type t = Fpath.t
+
+(** Returns the locally defined opam packages as a map from package names to
+    to the corresponding .opam file path.
+    Only considers packages defined at the repo's root. *)
+val local_packages : t -> (Fpath.t Astring.String.Map.t, [> `Msg of string]) result
+
+(** Returns the path to the dune-project file. *)
+val dune_project : t -> Fpath.t
+
+(** Returns the name of the project, as set in the dune-project. *)
+val project_name : t -> (string, [> `Msg of string]) result
+
+(** Returns the path to the duniverse lock file for the given repo.
+    If the repo contains a single package, then it's the ["<package_name>.opam.locked"]
+    file at the root of the repo.
+    If it contains multiple packages, then it's the ["<project_name>.opam.locked"] file
+    at the root of the repo.
+    One can provide [local_packages] if they were already computed. *)
+val duniverse_file :
+  ?local_packages: Fpath.t Astring.String.Map.t ->
+  t ->
+  (Fpath.t, [> `Msg of string]) result


Test patch:
diff --git a/test/test_duniverse_lib.ml b/test/test_duniverse_lib.ml
index fa8abac3..b9344b29 100644
--- a/test/test_duniverse_lib.ml
+++ b/test/test_duniverse_lib.ml
@@ -8,4 +8,5 @@ let () =
       Test_dune_file.suite;
       Test_git.suite;
       Test_parallel.suite;
+      Test_opam_value.suite;
     ]
diff --git a/test/test_opam_value.ml b/test/test_opam_value.ml
new file mode 100644
index 00000000..00425edd
--- /dev/null
+++ b/test/test_opam_value.ml
@@ -0,0 +1,23 @@
+let test_to_sexp_strict =
+  let make_test ~name ~input ~expected () =
+    let test_name = Printf.sprintf "to_sexp_strict: %s" name in
+    let test_fun () =
+      let actual = Duniverse_lib.Opam_value.to_sexp_strict input in
+      Alcotest.(check (result Testable.sexp Testable.r_msg)) test_name expected actual
+    in
+    (test_name, `Quick, test_fun)
+  in
+  let pos = "test.opam", 1, 2 in
+  [ make_test ~name:"String" ~input:(String (pos, "a")) ~expected:(Ok (Atom "a")) ()
+  ; make_test ~name:"List"
+      ~input:(List (pos, [String (pos, "a"); String (pos, "b")]))
+      ~expected:(Ok (List [Atom "a"; Atom "b"]))
+      ()
+  ; make_test ~name:"Error"
+      ~input:(Bool (pos, true))
+      ~expected:
+        (Error (`Msg "Error in test.opam, line 1, col 2: Expected a sexp compatible opam value"))
+      ()
+  ]
+
+let suite = ("Opam_value", test_to_sexp_strict)
diff --git a/test/test_opam_value.mli b/test/test_opam_value.mli
new file mode 100644
index 00000000..eef1c42a
--- /dev/null
+++ b/test/test_opam_value.mli
@@ -0,0 +1,1 @@
+val suite : string * unit Alcotest.test_case list
diff --git a/test/testable.ml b/test/testable.ml
index 55428904..77821df9 100644
--- a/test/testable.ml
+++ b/test/testable.ml
@@ -1,1 +1,3 @@
 let r_msg = Alcotest.testable Rresult.R.pp_msg (fun (`Msg s) (`Msg s') -> String.equal s s')
+
+let sexp = Alcotest.testable Sexplib0.Sexp.pp Sexplib0.Sexp.equal
diff --git a/test/testable.mli b/test/testable.mli
index 8837ddd9..7436e344 100644
--- a/test/testable.mli
+++ b/test/testable.mli
@@ -1,1 +1,3 @@
 val r_msg : Rresult.R.msg Alcotest.testable
+
+val sexp : Sexplib0.Sexp.t Alcotest.testable