···11+type rejection =
22+ | UserConstraint of OpamFormula.atom
33+ | Unavailable
44+55+type t = {
66+ env : string -> OpamVariable.variable_contents option;
77+ packages : Git_packages.t;
88+ pins : (OpamPackage.Version.t * OpamFile.OPAM.t) OpamPackage.Name.Map.t;
99+ constraints : OpamFormula.version_constraint OpamTypes.name_map;
1010+ test : OpamPackage.Name.Set.t;
1111+ prefer_oldest : bool;
1212+ doc : bool;
1313+ post : bool;
1414+ examined_packages : OpamPackage.Name.Set.t ref;
1515+}
1616+1717+let user_restrictions t name =
1818+ OpamPackage.Name.Map.find_opt name t.constraints
1919+2020+let dev = OpamPackage.Version.of_string "dev"
2121+2222+let env t pkg v =
2323+ if List.mem v OpamPackageVar.predefined_depends_variables then None
2424+ else
2525+ match OpamVariable.Full.to_string v with
2626+ | "version" -> Some (OpamTypes.S (OpamPackage.Version.to_string (OpamPackage.version pkg)))
2727+ | x -> t.env x
2828+2929+let filter_deps t pkg f =
3030+ let dev = OpamPackage.Version.compare (OpamPackage.version pkg) dev = 0 in
3131+ let test = OpamPackage.Name.Set.mem (OpamPackage.name pkg) t.test in
3232+ f
3333+ |> OpamFilter.partial_filter_formula (env t pkg)
3434+ |> OpamFilter.filter_deps ~build:true ~post:t.post ~test ~doc:t.doc ~dev ~dev_setup:false ~default:false
3535+3636+let filter_available t pkg opam =
3737+ let available = OpamFile.OPAM.available opam in
3838+ match OpamFilter.eval ~default:(B false) (env t pkg) available with
3939+ | B true -> Ok opam
4040+ | B false -> Error Unavailable
4141+ | _ ->
4242+ OpamConsole.error "Available expression not a boolean: %s"
4343+ (OpamFilter.to_string available);
4444+ Error Unavailable
4545+4646+let version_compare t (v1, v1_avoid, _) (v2, v2_avoid, _) =
4747+ match (v1_avoid, v2_avoid) with
4848+ | true, true
4949+ | false, false ->
5050+ if t.prefer_oldest then OpamPackage.Version.compare v1 v2
5151+ else OpamPackage.Version.compare v2 v1
5252+ | true, false -> 1
5353+ | false, true -> -1
5454+5555+let candidates t name =
5656+ t.examined_packages := OpamPackage.Name.Set.add name !(t.examined_packages);
5757+ match OpamPackage.Name.Map.find_opt name t.pins with
5858+ | Some (version, opam) ->
5959+ let pkg = OpamPackage.create name version in
6060+ [ (version, filter_available t pkg opam) ]
6161+ | None ->
6262+ let versions = Git_packages.get_versions t.packages name in
6363+ let user_constraints = user_restrictions t name in
6464+ OpamPackage.Version.Map.bindings versions
6565+ |> List.filter_map (fun (v, opam) ->
6666+ let pkg = OpamPackage.create name v in
6767+ let avoid = OpamFile.OPAM.has_flag Pkgflag_AvoidVersion opam in
6868+ let available = OpamFile.OPAM.available opam in
6969+ match OpamFilter.eval_to_bool ~default:false (env t pkg) available with
7070+ | true -> Some (v, avoid, opam)
7171+ | false -> None)
7272+ |> (fun l -> if List.for_all (fun (_, avoid, _) -> avoid) l then [] else l)
7373+ |> List.sort (version_compare t)
7474+ |> List.map (fun (v, _, opam) ->
7575+ match user_constraints with
7676+ | Some test when not (OpamFormula.check_version_formula (OpamFormula.Atom test) v) ->
7777+ (v, Error (UserConstraint (name, Some test)))
7878+ | _ -> (v, Ok opam))
7979+8080+let pp_rejection f = function
8181+ | UserConstraint x ->
8282+ Fmt.pf f "Rejected by user-specified constraint %s"
8383+ (OpamFormula.string_of_atom x)
8484+ | Unavailable -> Fmt.string f "Availability condition not satisfied"
8585+8686+let create ?(prefer_oldest = false) ?(test = OpamPackage.Name.Set.empty)
8787+ ?(pins = OpamPackage.Name.Map.empty) ?(doc = false) ?(post = true)
8888+ ~constraints ~env ~packages () =
8989+ { env; packages; pins; constraints; test; prefer_oldest; doc; post;
9090+ examined_packages = ref OpamPackage.Name.Set.empty }
9191+9292+let examined_packages t = !(t.examined_packages)
9393+9494+(** Create a new context with different doc/post settings. *)
9595+let with_doc_post ~doc ~post t =
9696+ { t with doc; post }
9797+9898+(** Extract x-extra-doc-deps from an opam file. *)
9999+let get_extra_doc_deps opamfile =
100100+ let open OpamParserTypes.FullPos in
101101+ let extensions = OpamFile.OPAM.extensions opamfile in
102102+ match OpamStd.String.Map.find_opt "x-extra-doc-deps" extensions with
103103+ | None -> OpamPackage.Name.Set.empty
104104+ | Some value ->
105105+ let extract_name item =
106106+ match item.pelem with
107107+ | String name -> Some name
108108+ | Option (inner, _) ->
109109+ (match inner.pelem with
110110+ | String name -> Some name
111111+ | _ -> None)
112112+ | _ -> None
113113+ in
114114+ let extract_names acc v =
115115+ match v.pelem with
116116+ | List { pelem = items; _ } ->
117117+ List.fold_left (fun acc item ->
118118+ match extract_name item with
119119+ | Some name ->
120120+ OpamPackage.Name.Set.add (OpamPackage.Name.of_string name) acc
121121+ | None -> acc
122122+ ) acc items
123123+ | _ -> acc
124124+ in
125125+ extract_names OpamPackage.Name.Set.empty value
126126+127127+(** Create an extended context where x-extra-doc-deps are added to each package's
128128+ regular depends. *)
129129+let extend_with_extra_doc_deps t =
130130+ let new_pins = OpamPackage.Name.Map.mapi (fun _name (version, opam) ->
131131+ let extra_deps = get_extra_doc_deps opam in
132132+ if OpamPackage.Name.Set.is_empty extra_deps then
133133+ (version, opam)
134134+ else begin
135135+ let depends = OpamFile.OPAM.depends opam in
136136+ let extra_formula =
137137+ OpamPackage.Name.Set.fold (fun dep_name acc ->
138138+ let atom = OpamFormula.Atom (dep_name, OpamFormula.Empty) in
139139+ OpamFormula.And (acc, atom)
140140+ ) extra_deps OpamFormula.Empty
141141+ in
142142+ let new_depends = match extra_formula with
143143+ | OpamFormula.Empty -> depends
144144+ | _ -> OpamFormula.And (depends, extra_formula)
145145+ in
146146+ let new_opam = OpamFile.OPAM.with_depends new_depends opam in
147147+ (version, new_opam)
148148+ end
149149+ ) t.pins in
150150+ { t with pins = new_pins }
+151
day10/bin/git_packages.ml
···11+open Lwt.Infix
22+module Store = Git_unix.Store
33+module Search = Git.Search.Make (Digestif.SHA1) (Store)
44+55+type package = OpamFile.OPAM.t
66+type t = package OpamPackage.Version.Map.t Lazy.t OpamPackage.Name.Map.t
77+88+let empty = OpamPackage.Name.Map.empty
99+1010+let read_dir store hash =
1111+ Store.read store hash >|= function
1212+ | Error e -> Fmt.failwith "Failed to read tree: %a" Store.pp_error e
1313+ | Ok (Git.Value.Tree tree) -> Some tree
1414+ | Ok _ -> None
1515+1616+let load_opam_from_string pkg opam_content =
1717+ let opam = OpamFile.OPAM.read_from_string opam_content in
1818+ let opam = OpamFile.OPAM.with_name (OpamPackage.name pkg) opam in
1919+ OpamFile.OPAM.with_version (OpamPackage.version pkg) opam
2020+2121+let read_package store pkg hash =
2222+ Search.find store hash (`Path [ "opam" ]) >>= function
2323+ | None -> Fmt.failwith "opam file not found for %s" (OpamPackage.to_string pkg)
2424+ | Some hash -> (
2525+ Store.read store hash >|= function
2626+ | Ok (Git.Value.Blob blob) -> (
2727+ try
2828+ let opam_content = Store.Value.Blob.to_string blob in
2929+ load_opam_from_string pkg opam_content
3030+ with
3131+ | ex -> Fmt.failwith "Error parsing %s: %s" (OpamPackage.to_string pkg) (Printexc.to_string ex))
3232+ | _ -> Fmt.failwith "Bad Git object type for %s!" (OpamPackage.to_string pkg))
3333+3434+(* Get a map of the versions inside [entry] (an entry under "packages") — Lwt version *)
3535+let read_versions_lwt store (entry : Store.Value.Tree.entry) =
3636+ read_dir store entry.node >>= function
3737+ | None -> Lwt.return OpamPackage.Version.Map.empty
3838+ | Some tree ->
3939+ Store.Value.Tree.to_list tree
4040+ |> Lwt_list.fold_left_s
4141+ (fun acc (entry : Store.Value.Tree.entry) ->
4242+ match OpamPackage.of_string_opt entry.name with
4343+ | Some pkg ->
4444+ read_package store pkg entry.node >|= fun opam ->
4545+ OpamPackage.Version.Map.add pkg.version opam acc
4646+ | None ->
4747+ Lwt.return acc)
4848+ OpamPackage.Version.Map.empty
4949+5050+(* Lazy version: wraps read_versions_lwt in Lwt_main.run.
5151+ Only safe when not already inside Lwt_main.run (i.e. single-domain use). *)
5252+let read_versions store entry =
5353+ Lwt_main.run @@ read_versions_lwt store entry
5454+5555+let read_packages ~store tree =
5656+ Store.Value.Tree.to_list tree
5757+ |> List.filter_map (fun (entry : Store.Value.Tree.entry) ->
5858+ match OpamPackage.Name.of_string entry.name with
5959+ | exception _ -> None
6060+ | name -> Some (name, lazy (read_versions store entry)))
6161+ |> OpamPackage.Name.Map.of_list
6262+6363+(* Eager version: reads all versions within a single Lwt_main.run context.
6464+ Safe for use with Domains. *)
6565+let read_packages_eager ~store tree =
6666+ Store.Value.Tree.to_list tree
6767+ |> List.filter_map (fun (entry : Store.Value.Tree.entry) ->
6868+ match OpamPackage.Name.of_string entry.name with
6969+ | exception _ -> None
7070+ | name ->
7171+ let versions = read_versions_lwt store entry in
7272+ Some (name, versions))
7373+7474+let of_commit_eager store commit : t =
7575+ Lwt_main.run @@
7676+ (Search.find store commit (`Commit (`Path [ "packages" ])) >>= function
7777+ | None -> Fmt.failwith "Failed to find packages directory!"
7878+ | Some tree_hash -> (
7979+ read_dir store tree_hash >>= function
8080+ | None -> Fmt.failwith "'packages' is not a directory!"
8181+ | Some tree ->
8282+ let entries = read_packages_eager ~store tree in
8383+ Lwt_list.map_s (fun (name, versions_lwt) ->
8484+ versions_lwt >|= fun versions ->
8585+ (name, lazy versions)
8686+ ) entries >|= fun resolved ->
8787+ OpamPackage.Name.Map.of_list resolved))
8888+8989+let diff_packages_lwt ~store commit1 commit2 =
9090+ Search.find store commit1 (`Commit (`Path [ "packages" ])) >>= function
9191+ | None -> Fmt.failwith "Failed to find packages directory in commit1"
9292+ | Some tree1_hash -> (
9393+ read_dir store tree1_hash >>= function
9494+ | None -> Fmt.failwith "'packages' is not a directory in commit1"
9595+ | Some tree1 -> (
9696+ Search.find store commit2 (`Commit (`Path [ "packages" ])) >>= function
9797+ | None -> Fmt.failwith "Failed to find packages directory in commit2"
9898+ | Some tree2_hash -> (
9999+ read_dir store tree2_hash >>= function
100100+ | None -> Fmt.failwith "'packages' is not a directory in commit2"
101101+ | Some tree2 ->
102102+ let tree1_list = Store.Value.Tree.to_list tree1 in
103103+ let htbl = Hashtbl.create (List.length tree1_list) in
104104+ let tree2_list = Store.Value.Tree.to_list tree2 in
105105+ List.iter (fun (entry : Store.Value.Tree.entry) ->
106106+ Hashtbl.add htbl entry.name entry) tree2_list;
107107+ Lwt.return
108108+ (List.fold_left
109109+ (fun acc (entry : Store.Value.Tree.entry) ->
110110+ match Hashtbl.find_opt htbl entry.name with
111111+ | Some entry2 when entry.node = entry2.node -> acc
112112+ | _ -> (
113113+ match OpamPackage.Name.of_string entry.name with
114114+ | exception _ -> acc
115115+ | name -> name :: acc))
116116+ [] tree1_list))))
117117+118118+let diff_packages ~store commit1 commit2 =
119119+ Lwt_main.run @@
120120+ diff_packages_lwt ~store commit1 commit2
121121+122122+let overlay v1 v2 =
123123+ lazy (
124124+ let v1 = Lazy.force v1 in
125125+ let v2 = Lazy.force v2 in
126126+ OpamPackage.Version.Map.union (fun _ v2 -> v2) v1 v2)
127127+128128+let of_commit ?(super = empty) store commit : t =
129129+ Lwt_main.run @@
130130+ (Search.find store commit (`Commit (`Path [ "packages" ])) >>= function
131131+ | None -> Fmt.failwith "Failed to find packages directory!"
132132+ | Some tree_hash -> (
133133+ read_dir store tree_hash >>= function
134134+ | None -> Fmt.failwith "'packages' is not a directory!"
135135+ | Some tree ->
136136+ let packages = read_packages ~store tree in
137137+ Lwt.return (OpamPackage.Name.Map.union overlay super packages)))
138138+139139+let get_versions (t : t) name =
140140+ match OpamPackage.Name.Map.find_opt name t with
141141+ | None -> OpamPackage.Version.Map.empty
142142+ | Some versions -> Lazy.force versions
143143+144144+let get_package (t : t) pkg =
145145+ let versions = get_versions t (OpamPackage.name pkg) in
146146+ OpamPackage.Version.Map.find (OpamPackage.version pkg) versions
147147+148148+let of_opam_repository repo_path =
149149+ let store, commit = Git_utils.get_git_repo_store_and_hash repo_path in
150150+ let packages = of_commit store commit in
151151+ (packages, store, commit)
+51
day10/bin/git_utils.ml
···11+open Lwt.Infix
22+module Store = Git_unix.Store
33+44+let get_git_repo_store_and_hash_commit_lwt repo_path commit_opt =
55+ Store.v (Fpath.v repo_path) >>= function
66+ | Error e -> Fmt.failwith "Failed to open git store at %s: %a" repo_path Store.pp_error e
77+ | Ok store ->
88+ (match commit_opt with
99+ | Some commit_str ->
1010+ let ref_name = Git.Reference.v commit_str in
1111+ Store.Ref.resolve store ref_name >>= (function
1212+ | Ok hash -> Lwt.return hash
1313+ | Error _ ->
1414+ (try
1515+ let hash = Store.Hash.of_hex commit_str in
1616+ Lwt.return hash
1717+ with _ ->
1818+ Fmt.failwith "Cannot resolve commit %s in %s" commit_str repo_path))
1919+ | None ->
2020+ Store.Ref.resolve store Git.Reference.head >>= function
2121+ | Error e -> Fmt.failwith "Failed to resolve HEAD in %s: %a" repo_path Store.pp_error e
2222+ | Ok hash -> Lwt.return hash) >>= fun hash ->
2323+ Lwt.return (store, hash)
2424+2525+let get_git_repo_store_and_hash_commit repo_path commit_opt =
2626+ Lwt_main.run @@
2727+ get_git_repo_store_and_hash_commit_lwt repo_path commit_opt
2828+2929+let get_git_repo_store_and_hash repo_path =
3030+ get_git_repo_store_and_hash_commit repo_path None
3131+3232+let resolve_commit_in_store_lwt store commit_opt =
3333+ match commit_opt with
3434+ | Some commit_str ->
3535+ let ref_name = Git.Reference.v commit_str in
3636+ Store.Ref.resolve store ref_name >>= (function
3737+ | Ok hash -> Lwt.return hash
3838+ | Error _ ->
3939+ (try
4040+ let hash = Store.Hash.of_hex commit_str in
4141+ Lwt.return hash
4242+ with _ ->
4343+ Fmt.failwith "Cannot resolve commit %s" commit_str))
4444+ | None ->
4545+ Store.Ref.resolve store Git.Reference.head >>= function
4646+ | Error e -> Fmt.failwith "Failed to resolve HEAD: %a" Store.pp_error e
4747+ | Ok hash -> Lwt.return hash
4848+4949+let resolve_commit_in_store store commit_opt =
5050+ Lwt_main.run @@
5151+ resolve_commit_in_store_lwt store commit_opt
+376
day10/bin/test_incr_solver.ml
···11+(** Test incremental solver reuse decisions across consecutive commits.
22+33+ For each pair of consecutive commits (A, B):
44+ 1. Eagerly load packages from both commits
55+ 2. Solve all packages for both commits in parallel via Domains
66+ 3. Compute changed packages between A and B
77+ 4. For each package where reuse would apply (examined ∩ changed = ∅):
88+ verify that solution_A == solution_B
99+ 5. Report any false negatives (reuse says "keep" but solutions differ) *)
1010+1111+module Git_solver = Opam_0install.Solver.Make (Git_context)
1212+module Git_input = Git_solver.Input
1313+module Git_output = Git_solver.Solver.Output
1414+module Git_role_map = Git_output.RoleMap
1515+1616+(* --- Solver --- *)
1717+1818+let opam_env ~(config : Config.t) pkg v =
1919+ match OpamVariable.Full.to_string v with
2020+ | "version" -> Some (OpamTypes.S (OpamPackage.Version.to_string (OpamPackage.version pkg)))
2121+ | "with-test" ->
2222+ let is_tested_pkg = String.equal (OpamPackage.to_string pkg) config.package in
2323+ Some (OpamTypes.B (config.with_test && is_tested_pkg))
2424+ | "with-dev" | "with-dev-setup" | "dev" | "with-doc" -> Some (OpamTypes.B false)
2525+ | "build" -> Some (OpamTypes.B true)
2626+ | "post" -> None
2727+ | x -> Config.std_env ~config x
2828+2929+let solve_git ~packages (config : Config.t) pkg =
3030+ let pkg_constraint = (OpamPackage.name pkg, (`Eq, OpamPackage.version pkg)) in
3131+ let constraints =
3232+ match config.ocaml_version with
3333+ | Some ocaml_ver ->
3434+ OpamPackage.Name.Map.of_list
3535+ [ (OpamPackage.name ocaml_ver, (`Eq, OpamPackage.version ocaml_ver)); pkg_constraint ]
3636+ | None ->
3737+ let ocaml_constraint = (OpamPackage.Name.of_string "ocaml-base-compiler",
3838+ (`Geq, OpamPackage.Version.of_string "4.08.0")) in
3939+ OpamPackage.Name.Map.of_list [ ocaml_constraint; pkg_constraint ]
4040+ in
4141+ let test = if config.with_test then OpamPackage.Name.Set.singleton (OpamPackage.name pkg)
4242+ else OpamPackage.Name.Set.empty in
4343+ let context =
4444+ Git_context.create ~env:(Config.std_env ~config) ~constraints
4545+ ~pins:OpamPackage.Name.Map.empty ~test ~packages ()
4646+ in
4747+ let ocaml_name = match config.ocaml_version with
4848+ | Some v -> OpamPackage.name v
4949+ | None -> OpamPackage.Name.of_string "ocaml-base-compiler"
5050+ in
5151+ let roots = [ ocaml_name; OpamPackage.name pkg ] in
5252+ let r = Git_solver.solve context roots in
5353+ let examined = Git_context.examined_packages context in
5454+ match r with
5555+ | Ok out ->
5656+ let sels = Git_output.to_map out in
5757+ let depends = Hashtbl.create 100 in
5858+ let classify x =
5959+ match Git_solver.package_name x with
6060+ | Some pkg -> `Opam pkg
6161+ | None -> `Virtual x
6262+ in
6363+ Git_role_map.iter
6464+ (fun role sel ->
6565+ let impl = Git_output.unwrap sel in
6666+ Git_solver.Input.requires role impl |> fst
6767+ |> List.iter (fun dep ->
6868+ let dep = Git_input.dep_info dep in
6969+ let dep_role = dep.dep_role in
7070+ if dep.dep_importance <> `Restricts then
7171+ Hashtbl.add depends (classify role) (classify dep_role)))
7272+ sels;
7373+ let rec expand role =
7474+ Hashtbl.find_all depends role
7575+ |> List.concat_map (function
7676+ | `Opam dep -> [ dep ]
7777+ | `Virtual _ as role -> expand role)
7878+ in
7979+ let pkgs = Git_solver.packages_of_result out |> OpamPackage.Set.of_list in
8080+ let pkgnames = OpamPackage.names_of_packages pkgs in
8181+ let deptree =
8282+ OpamPackage.Set.fold
8383+ (fun pkg acc ->
8484+ let opam = Git_packages.get_package packages pkg in
8585+ let deps = OpamFile.OPAM.depends opam |> OpamFilter.partial_filter_formula (opam_env ~config pkg) in
8686+ let with_post = OpamFilter.filter_deps ~build:true ~post:true deps |> OpamFormula.all_names in
8787+ let without_post = OpamFilter.filter_deps ~build:true ~post:false deps |> OpamFormula.all_names in
8888+ let deppost = OpamPackage.Name.Set.diff with_post without_post in
8989+ let depopts = OpamFile.OPAM.depopts opam |> OpamFormula.all_names in
9090+ let depopts = OpamPackage.Name.Set.inter depopts pkgnames |> OpamPackage.Name.Set.to_list in
9191+ let name = OpamPackage.name pkg in
9292+ let deps =
9393+ expand (`Opam name) @ depopts |> OpamPackage.Name.Set.of_list |> fun x ->
9494+ OpamPackage.Name.Set.diff x deppost |> OpamPackage.packages_of_names pkgs
9595+ in
9696+ OpamPackage.Map.add pkg deps acc)
9797+ pkgs OpamPackage.Map.empty
9898+ in
9999+ let rec dfs map pkg =
100100+ let deps = OpamPackage.Map.find pkg deptree in
101101+ OpamPackage.Set.fold
102102+ (fun p acc ->
103103+ match OpamPackage.Map.mem p acc with
104104+ | true -> acc
105105+ | false -> dfs acc p)
106106+ deps (OpamPackage.Map.add pkg deps map)
107107+ in
108108+ let solution = dfs OpamPackage.Map.empty pkg in
109109+ Ok (solution, examined)
110110+ | Error problem -> Error (Git_solver.diagnostics problem, examined)
111111+112112+(* --- Domain-based parallel map --- *)
113113+114114+let parallel_map ~np (f : 'a -> 'b) (items : 'a array) : 'b array =
115115+ let n = Array.length items in
116116+ if n = 0 then [||]
117117+ else begin
118118+ let results = Array.make n (f items.(0)) in
119119+ let next = Atomic.make 1 in (* 0 already computed above *)
120120+ let domains = Array.init (min (np - 1) (n - 1)) (fun _ ->
121121+ Domain.spawn (fun () ->
122122+ let rec loop () =
123123+ let i = Atomic.fetch_and_add next 1 in
124124+ if i < n then begin
125125+ results.(i) <- f items.(i);
126126+ loop ()
127127+ end
128128+ in
129129+ loop ()
130130+ )
131131+ ) in
132132+ (* Parent also does work *)
133133+ let rec loop () =
134134+ let i = Atomic.fetch_and_add next 1 in
135135+ if i < n then begin
136136+ results.(i) <- f items.(i);
137137+ loop ()
138138+ end
139139+ in
140140+ loop ();
141141+ Array.iter Domain.join domains;
142142+ results
143143+ end
144144+145145+(* --- Package enumeration --- *)
146146+147147+let enumerate_packages (packages : Git_packages.t) =
148148+ OpamPackage.Name.Map.fold (fun name versions_lazy acc ->
149149+ let versions = Lazy.force versions_lazy in
150150+ match OpamPackage.Version.Map.max_binding_opt versions with
151151+ | Some (v, _) -> OpamPackage.create name v :: acc
152152+ | None -> acc
153153+ ) packages []
154154+ |> List.sort OpamPackage.compare
155155+156156+let solutions_match s1 s2 =
157157+ OpamPackage.Map.equal OpamPackage.Set.equal s1 s2
158158+159159+(* --- Main --- *)
160160+161161+let () = OpamFormatConfig.init ()
162162+let () = OpamCoreConfig.init ()
163163+164164+let () =
165165+ let usage = "test_incr_solver <opam-repository-path> <commit1> <commit2> [commit3 ...] [--limit N] [--domains N]" in
166166+ let args = Array.to_list Sys.argv |> List.tl in
167167+ let limit = ref None in
168168+ let np = ref (Domain.recommended_domain_count ()) in
169169+ let positional = ref [] in
170170+ let rec parse = function
171171+ | [] -> ()
172172+ | "--limit" :: n :: rest -> limit := Some (int_of_string n); parse rest
173173+ | "--domains" :: n :: rest -> np := int_of_string n; parse rest
174174+ | x :: rest -> positional := x :: !positional; parse rest
175175+ in
176176+ parse args;
177177+ let positional = List.rev !positional in
178178+ match positional with
179179+ | repo_path :: commits when List.length commits >= 2 ->
180180+ let limit = !limit in
181181+ let np = !np in
182182+183183+ Printf.printf "Repository: %s\n%!" repo_path;
184184+ Printf.printf "Commits: %s\n%!" (String.concat " -> " commits);
185185+ Printf.printf "Parallelism: %d domains\n%!" np;
186186+187187+ (* Open git store once *)
188188+ let store, _ = Git_utils.get_git_repo_store_and_hash repo_path in
189189+190190+ let config : Config.t = {
191191+ dir = "/tmp/test-incr-solver";
192192+ ocaml_version = None;
193193+ opam_repositories = [ repo_path ];
194194+ package = "";
195195+ arch = "x86_64";
196196+ os = "linux";
197197+ os_distribution = "ubuntu";
198198+ os_family = "debian";
199199+ os_version = "24.04";
200200+ directory = None;
201201+ md = None;
202202+ json = None;
203203+ dot = None;
204204+ with_test = false;
205205+ with_doc = false;
206206+ with_jtw = false;
207207+ doc_tools_repo = "";
208208+ doc_tools_branch = "";
209209+ jtw_tools_repo = "";
210210+ jtw_tools_branch = "";
211211+ local_repos = [];
212212+ html_output = None;
213213+ jtw_output = None;
214214+ tag = None;
215215+ log = false;
216216+ dry_run = false;
217217+ fork = None;
218218+ prune_layers = false;
219219+ blessed_map = None;
220220+ } in
221221+222222+ (* Preload ALL commits — Lwt_main.run must finish before we spawn domains *)
223223+ let pairs = List.combine (List.rev (List.tl (List.rev commits))) (List.tl commits) in
224224+ let total_wrong = ref 0 in
225225+226226+ let resolve sha =
227227+ let cmd = Printf.sprintf "git -C %s rev-parse %s" repo_path sha in
228228+ Os.run cmd |> String.trim
229229+ in
230230+231231+ Printf.printf "\n=== Preloading packages for all %d commits ===\n%!" (List.length commits);
232232+ let commit_data = List.map (fun sha_str ->
233233+ let full = resolve sha_str in
234234+ let hash = Git_unix.Store.Hash.of_hex full in
235235+ Printf.printf "Loading packages for %s (%s)...\n%!" sha_str full;
236236+ let t = Unix.gettimeofday () in
237237+ let packages = Git_packages.of_commit_eager store hash in
238238+ Printf.printf " Loaded in %.1fs\n%!" (Unix.gettimeofday () -. t);
239239+ (sha_str, full, hash, packages)
240240+ ) commits in
241241+242242+ (* Precompute changed sets *)
243243+ let pair_data = List.map (fun (a_str, b_str) ->
244244+ let (_, _, hash_a, _) = List.find (fun (s, _, _, _) -> s = a_str) commit_data in
245245+ let (_, _, hash_b, _) = List.find (fun (s, _, _, _) -> s = b_str) commit_data in
246246+ let changed_names = Git_packages.diff_packages ~store hash_a hash_b in
247247+ let changed_set = OpamPackage.Name.Set.of_list changed_names in
248248+ (a_str, b_str, changed_set)
249249+ ) pairs in
250250+ Printf.printf "=== Preloading complete ===\n%!";
251251+252252+ let find_commit sha_str =
253253+ List.find (fun (s, _, _, _) -> s = sha_str) commit_data
254254+ in
255255+256256+ List.iter (fun (commit_a_str, commit_b_str, changed_set) ->
257257+ Printf.printf "\n================================================================\n%!";
258258+ Printf.printf "Testing pair: %s -> %s\n%!" commit_a_str commit_b_str;
259259+ Printf.printf "================================================================\n%!";
260260+261261+ let (_, commit_a_full, _, packages_a) = find_commit commit_a_str in
262262+ let (_, commit_b_full, _, packages_b) = find_commit commit_b_str in
263263+ Printf.printf "Commit A: %s (%s)\n%!" commit_a_str commit_a_full;
264264+ Printf.printf "Commit B: %s (%s)\n%!" commit_b_str commit_b_full;
265265+266266+ let t0 = Unix.gettimeofday () in
267267+ Printf.printf "Changed packages: %d\n%!"
268268+ (OpamPackage.Name.Set.cardinal changed_set);
269269+ OpamPackage.Name.Set.iter (fun n ->
270270+ Printf.printf " - %s\n%!" (OpamPackage.Name.to_string n)
271271+ ) changed_set;
272272+273273+ (* Enumerate packages: latest version per name from commit A *)
274274+ let all_packages = enumerate_packages packages_a in
275275+ let pkg_list = match limit with
276276+ | Some n -> List.filteri (fun i _ -> i < n) all_packages
277277+ | None -> all_packages
278278+ in
279279+ let pkg_arr = Array.of_list pkg_list in
280280+ Printf.printf "Packages to test: %d%s\n%!"
281281+ (Array.length pkg_arr)
282282+ (match limit with Some n -> Printf.sprintf " (limited to %d)" n | None -> "");
283283+284284+ let do_solve ~packages pkg =
285285+ let pkg_name = OpamPackage.to_string pkg in
286286+ let pkg_config = { config with package = pkg_name } in
287287+ solve_git ~packages pkg_config pkg
288288+ in
289289+290290+ (* Solve all packages for both commits in parallel *)
291291+ Printf.printf "\nSolving for commit A (%s)...\n%!" commit_a_str;
292292+ let t1 = Unix.gettimeofday () in
293293+ let results_a = parallel_map ~np (do_solve ~packages:packages_a) pkg_arr in
294294+ let t2 = Unix.gettimeofday () in
295295+ Printf.printf " Done in %.1fs\n%!" (t2 -. t1);
296296+297297+ Printf.printf "Solving for commit B (%s)...\n%!" commit_b_str;
298298+ let results_b = parallel_map ~np (do_solve ~packages:packages_b) pkg_arr in
299299+ let t3 = Unix.gettimeofday () in
300300+ Printf.printf " Done in %.1fs\n%!" (t3 -. t2);
301301+302302+ (* Analyze results *)
303303+ let n_reusable = ref 0 in
304304+ let n_correct_reuse = ref 0 in
305305+ let n_wrong_reuse = ref 0 in
306306+ let n_not_reusable = ref 0 in
307307+ let n_both_ok = ref 0 in
308308+ let n_a_failed = ref 0 in
309309+ let n_b_failed = ref 0 in
310310+ let wrong_details = Buffer.create 1024 in
311311+312312+ Array.iteri (fun i pkg ->
313313+ let pkg_name = OpamPackage.to_string pkg in
314314+ match results_a.(i), results_b.(i) with
315315+ | Ok (sol_a, examined_a), Ok (sol_b, _) ->
316316+ incr n_both_ok;
317317+ if OpamPackage.Name.Set.disjoint examined_a changed_set then begin
318318+ incr n_reusable;
319319+ if solutions_match sol_a sol_b then
320320+ incr n_correct_reuse
321321+ else begin
322322+ incr n_wrong_reuse;
323323+ let in_a_not_b = OpamPackage.Map.filter (fun k _ -> not (OpamPackage.Map.mem k sol_b)) sol_a in
324324+ let in_b_not_a = OpamPackage.Map.filter (fun k _ -> not (OpamPackage.Map.mem k sol_a)) sol_b in
325325+ let msg = Printf.sprintf " WRONG REUSE: %s (A has %d extra, B has %d extra, examined %d pkgs)\n"
326326+ pkg_name
327327+ (OpamPackage.Map.cardinal in_a_not_b)
328328+ (OpamPackage.Map.cardinal in_b_not_a)
329329+ (OpamPackage.Name.Set.cardinal examined_a) in
330330+ Printf.printf "%s%!" msg;
331331+ Buffer.add_string wrong_details msg;
332332+ let detail p tag =
333333+ let s = Printf.sprintf " %s %s\n" tag (OpamPackage.to_string p) in
334334+ Printf.printf "%s%!" s;
335335+ Buffer.add_string wrong_details s
336336+ in
337337+ OpamPackage.Map.iter (fun p _ -> detail p "+ (in A only)") in_a_not_b;
338338+ OpamPackage.Map.iter (fun p _ -> detail p "- (in B only)") in_b_not_a;
339339+ let examined_list = OpamPackage.Name.Set.elements examined_a
340340+ |> List.map OpamPackage.Name.to_string in
341341+ let s = Printf.sprintf " examined: %s\n" (String.concat ", " examined_list) in
342342+ Printf.printf "%s%!" s;
343343+ Buffer.add_string wrong_details s
344344+ end
345345+ end else
346346+ incr n_not_reusable
347347+ | Error _, _ -> incr n_a_failed
348348+ | _, Error _ -> incr n_b_failed
349349+ ) pkg_arr;
350350+351351+ let t4 = Unix.gettimeofday () in
352352+ Printf.printf "\n--- Results for %s -> %s (%.1fs total) ---\n%!" commit_a_str commit_b_str (t4 -. t0);
353353+ Printf.printf "Both solved OK: %d\n%!" !n_both_ok;
354354+ Printf.printf "Reusable: %d (%.1f%%)\n%!" !n_reusable
355355+ (if !n_both_ok > 0 then 100.0 *. float_of_int !n_reusable /. float_of_int !n_both_ok else 0.0);
356356+ Printf.printf " Correct reuse: %d\n%!" !n_correct_reuse;
357357+ Printf.printf " WRONG reuse: %d\n%!" !n_wrong_reuse;
358358+ Printf.printf "Not reusable: %d (would re-solve)\n%!" !n_not_reusable;
359359+ Printf.printf "A failed: %d\n%!" !n_a_failed;
360360+ Printf.printf "B failed: %d\n%!" !n_b_failed;
361361+ if !n_wrong_reuse > 0 then begin
362362+ Printf.printf "\nWrong reuse details:\n%!";
363363+ print_string (Buffer.contents wrong_details)
364364+ end else
365365+ Printf.printf "All reuse decisions are correct!\n%!";
366366+ total_wrong := !total_wrong + !n_wrong_reuse
367367+ ) pair_data;
368368+369369+ Printf.printf "\n================================================================\n%!";
370370+ Printf.printf "OVERALL: %d wrong reuse decisions across %d commit pairs\n%!"
371371+ !total_wrong (List.length pairs);
372372+ if !total_wrong > 0 then exit 1
373373+374374+ | _ ->
375375+ Printf.eprintf "Usage: %s\n" usage;
376376+ exit 1
+292
day10/docs/ON_DISK_STRUCTURE.md
···11+# On-Disk File Structure
22+33+This document describes the files and directories managed by day10's build
44+cache, and how each CLI command reads and writes them.
55+66+## Directory Layout
77+88+```
99+<cache-dir>/
1010+├── solutions/
1111+│ └── <opam-repo-sha>/
1212+│ └── <package>.json # Cached solver solution
1313+│
1414+├── logs/
1515+│ ├── <pid>.log # Per-process execution log (timestamps + commands)
1616+│ └── runs/
1717+│ └── <run-id>/ # YYYY-MM-DD-HHMMSS
1818+│ ├── summary.json # Run results and statistics
1919+│ ├── progress.json # Real-time progress (deleted on completion)
2020+│ ├── build/
2121+│ │ └── <package>.log # Symlink to build layer's build.log
2222+│ └── docs/
2323+│ └── <package>.log # Symlink to doc layer's log
2424+│
2525+└── <os-key>/ # e.g. "debian-12-x86_64"
2626+ ├── build-config.json # Saved batch configuration
2727+ ├── status.json # Current build status overview
2828+ │
2929+ ├── base/ # Base container image layer
3030+ │ ├── build.log # Base image build log
3131+ │ ├── Dockerfile # Generated Dockerfile
3232+ │ ├── fs/ # Root filesystem
3333+ │ └── opam-repository/ # Checked-out opam repository
3434+ │
3535+ ├── build-<hash>/ # Build layer (one per unique dependency set)
3636+ │ ├── layer.json # Metadata: package, exit_status, deps, hashes,
3737+ │ │ # created, installed_libs, installed_docs
3838+ │ ├── build.log # Build output (absent for dep-failure skeletons)
3939+ │ ├── fs/ # Layer filesystem delta (absent for dep-failure skeletons)
4040+ │ └── opam-repository/ # Opam repo snapshot: exact opam files for this
4141+ │ # package and all its deps. Used by rerun/cascade
4242+ │ # so they don't need an external opam-repository path.
4343+ │
4444+ ├── doc-<hash>/ # Documentation layer
4545+ │ ├── layer.json # Metadata: package, status, build_hash reference
4646+ │ └── odoc-voodoo-all.log # Doc generation log
4747+ │
4848+ ├── jtw-<hash>/ # JS Top Worker layer
4949+ │ ├── layer.json # Metadata: package, status, build_hash reference
5050+ │ └── jtw.log # Worker generation log
5151+ │
5252+ ├── universes/
5353+ │ └── <hash>.json # Universe snapshot: universe_hash + packages array
5454+ │
5555+ └── packages/
5656+ └── <package>/ # e.g. "dune.3.17.2"
5757+ ├── history.jsonl # Build history (append-only, one JSON object per line)
5858+ ├── build-<hash> # Symlink -> ../../build-<hash>
5959+ ├── blessed-build # Symlink -> ../../build-<hash> (blessed result)
6060+ └── blessed-docs # Symlink -> ../../doc-<hash> (blessed docs)
6161+```
6262+6363+### OS Key
6464+6565+`<os-key>` = `<os_distribution>-<os_version>-<arch>`, e.g. `debian-12-x86_64`,
6666+`ubuntu-24.04-aarch64`, `freebsd-14.2-amd64`.
6767+6868+### Content Addressing
6969+7070+Build layer hashes are computed from:
7171+- The `base_hash` (OS distribution + version + arch, plus opam-build source hash on Linux)
7272+- The opam file contents of every dependency in the layer
7373+7474+This means identical dependency sets always produce the same hash, enabling
7575+cross-solution cache reuse.
7676+7777+### Skeleton Layers (Dependency Failures)
7878+7979+When a package is skipped because a dependency failed, a skeleton
8080+`build-<hash>/` directory is still created containing:
8181+- `layer.json` with `exit_status: -1` (marks it as never built)
8282+- `opam-repository/` with the package's and all deps' opam files
8383+8484+This allows `rerun` and `cascade` to rebuild the package later using the
8585+exact same opam files, without needing access to the original opam-repository
8686+path or `build-config.json`. The skeleton has no `fs/` or `build.log`.
8787+8888+## Key File Formats
8989+9090+### history.jsonl
9191+9292+One JSON object per line, appended after each build attempt:
9393+9494+```json
9595+{"ts":"2026-03-09T13:01:44","run":"2026-03-09-125444","build_hash":"build-68cface...","status":"success","category":"success","compiler":"4.14.3","blessed":false}
9696+```
9797+9898+Categories: `success`, `failure`, `dep-failure`, `aborted`.
9999+100100+### layer.json
101101+102102+Build layer metadata written when a layer completes:
103103+104104+```json
105105+{"package":"either.1.0.0","exit_status":0,"deps":["ocaml.4.14.3"],"hashes":{"ocaml.4.14.3":"build-abc123"},"created":"2026-03-09T13:01:44","installed_libs":["either"],"installed_docs":["either"]}
106106+```
107107+108108+### status.json
109109+110110+Snapshot of the most recent run's results:
111111+112112+```json
113113+{"generated":"2026-03-09T13:05:00","run_id":"2026-03-09-125444","blessed_totals":{"success":1200,"failure":45,"dep-failure":12},"non_blessed_totals":{"success":800},"changes_since_last":[...],"new_packages":[...]}
114114+```
115115+116116+### build-config.json
117117+118118+Saved configuration from the batch that created the cache. Used by `rerun`
119119+and `cascade` so they don't need `--opam-repository` arguments:
120120+121121+```json
122122+{"opam_repositories":["/path/to/opam-repository"],"local_repos":[{"path":"/path/to/repo","packages":["pkg1","pkg2"]}],"with_doc":true,"html_output":"/var/www/docs","jtw_output":"/var/www/jtw"}
123123+```
124124+125125+### solutions/\<sha\>/\<package\>.json
126126+127127+Cached opam solver result, keyed by opam repository commit SHA:
128128+129129+```json
130130+{"package":"dune.3.17.2","solution":["ocaml.4.14.3","ocaml-base-compiler.4.14.3","dune.3.17.2"]}
131131+```
132132+133133+## Per-Command File Usage
134134+135135+### status
136136+137137+Displays an overview of build results (success/failure/dep-failure counts).
138138+139139+| Operation | Files |
140140+|-----------|-------|
141141+| Reads | `<os-key>/status.json` |
142142+| Reads (with `--details`) | `<os-key>/packages/<pkg>/history.jsonl`, `<os-key>/build-<hash>/layer.json` |
143143+144144+### query
145145+146146+Shows detailed build information for a specific package.
147147+148148+| Operation | Files |
149149+|-----------|-------|
150150+| Reads | `<os-key>/packages/<pkg>/history.jsonl` |
151151+| Reads | `<os-key>/build-<hash>/layer.json` |
152152+| Reads (with `--log`) | `<os-key>/build-<hash>/build.log` |
153153+154154+### failures
155155+156156+Lists all packages with failing builds, with optional category filtering.
157157+158158+| Operation | Files |
159159+|-----------|-------|
160160+| Reads | `<os-key>/packages/` (directory listing) |
161161+| Reads | `<os-key>/packages/<pkg>/history.jsonl` (for each package) |
162162+163163+### changes
164164+165165+Shows status transitions since the last run (e.g. success -> failure).
166166+167167+| Operation | Files |
168168+|-----------|-------|
169169+| Reads | `<os-key>/status.json` (the `changes_since_last` field) |
170170+171171+### disk
172172+173173+Reports disk usage breakdown by category.
174174+175175+| Operation | Files |
176176+|-----------|-------|
177177+| Reads (size only) | `<os-key>/base/`, `<os-key>/build-*/`, `<os-key>/doc-*/`, `<os-key>/packages/`, `logs/`, `solutions/` |
178178+179179+### log
180180+181181+Displays the build or doc log for a specific layer hash.
182182+183183+| Operation | Files |
184184+|-----------|-------|
185185+| Reads | `<os-key>/<layer>/layer.json` |
186186+| Reads | `<os-key>/<layer>/build.log` or `odoc-voodoo-all.log` |
187187+188188+### rerun
189189+190190+Retries a failed build. Uses the opam files stored in the layer's own
191191+`opam-repository/` directory, so no external opam-repository path is needed
192192+and the rebuild uses the exact same opam files as the original build.
193193+194194+| Operation | Files |
195195+|-----------|-------|
196196+| Reads | `<os-key>/build-<hash>/layer.json` (package, deps, hashes) |
197197+| Reads | `<os-key>/build-<hash>/opam-repository/` (saved opam files for rebuild) |
198198+| Reads | `<os-key>/packages/<pkg>/history.jsonl` |
199199+| Reads (with `--cascade`) | `<os-key>/build-config.json` (cascade re-solves, needs opam repo paths) |
200200+| Writes | `<os-key>/build-<hash>/layer.json`, `build.log`, `fs/` (new layer) |
201201+| Writes | `<os-key>/packages/<pkg>/history.jsonl` (appends result) |
202202+| Writes | `<os-key>/status.json` (regenerated) |
203203+| Writes | `logs/runs/<run-id>/summary.json`, `build/<pkg>.log` |
204204+205205+### cascade
206206+207207+Reruns packages whose dependency failures have since been fixed. Like `rerun`,
208208+uses the opam files stored in each layer's own `opam-repository/` directory.
209209+No `build-config.json` or external opam-repository path needed.
210210+211211+| Operation | Files |
212212+|-----------|-------|
213213+| Reads | `<os-key>/packages/` (all packages) |
214214+| Reads | `<os-key>/packages/<pkg>/history.jsonl` (finds dep-failure entries) |
215215+| Reads | `<os-key>/build-<hash>/layer.json` (checks if failed dep now succeeds) |
216216+| Reads | `<os-key>/build-<hash>/opam-repository/` (saved opam files for rebuild) |
217217+| Writes | `<os-key>/build-<hash>/layer.json`, `build.log`, `fs/` (new layers) |
218218+| Writes | `<os-key>/packages/<pkg>/history.jsonl` (appends results) |
219219+| Writes | `<os-key>/status.json` (regenerated) |
220220+221221+### rdeps
222222+223223+Finds reverse dependencies of a package by scanning cached solutions.
224224+225225+| Operation | Files |
226226+|-----------|-------|
227227+| Reads | `solutions/<sha>/<pkg>.json` (all cached solutions) |
228228+| Reads (with `--failing`) | `<os-key>/packages/<pkg>/history.jsonl` |
229229+230230+### gc
231231+232232+Reclaims disk space by removing unreferenced layers and old logs.
233233+234234+| Operation | Files |
235235+|-----------|-------|
236236+| Reads | `<os-key>/` (lists all layer directories) |
237237+| Reads | `<os-key>/packages/<pkg>/history.jsonl` |
238238+| Reads | `logs/runs/` (lists all run directories) |
239239+| Deletes | `<os-key>/build-<hash>/`, `doc-<hash>/`, `jtw-<hash>/` (unreferenced) |
240240+| Deletes | `logs/runs/<old-run-id>/` (per `--keep-runs` policy) |
241241+| Compacts | `<os-key>/packages/<pkg>/history.jsonl` (merges old entries) |
242242+243243+**Never deleted by gc:** `base/`, `packages/`, `universes/`, `solutions/`.
244244+245245+### notify
246246+247247+Sends build status notifications to external channels. No file I/O.
248248+249249+### batch (the main build command)
250250+251251+Runs a full build across all packages. This is the primary producer of cache data.
252252+253253+| Operation | Files |
254254+|-----------|-------|
255255+| Writes | `<os-key>/build-config.json` |
256256+| Writes | `solutions/<sha>/<pkg>.json` (cached solver results) |
257257+| Writes | `<os-key>/base/` (Dockerfile, fs/, opam-repository/, build.log) |
258258+| Writes | `<os-key>/build-<hash>/` (layer.json, build.log, fs/) |
259259+| Writes | `<os-key>/doc-<hash>/` (layer.json, log) — if `--with-doc` |
260260+| Writes | `<os-key>/jtw-<hash>/` (layer.json, log) — if `--with-jtw` |
261261+| Writes | `<os-key>/universes/<hash>.json` |
262262+| Writes | `<os-key>/packages/<pkg>/history.jsonl` (appends per build) |
263263+| Writes | `<os-key>/packages/<pkg>/build-<hash>` (symlink) |
264264+| Writes | `<os-key>/packages/<pkg>/blessed-build` (symlink, if blessed) |
265265+| Writes | `<os-key>/status.json` (at end of run) |
266266+| Writes | `logs/runs/<run-id>/summary.json`, `progress.json`, `build/`, `docs/` |
267267+268268+## Lock Files
269269+270270+When building a layer, `create_directory_exclusively` creates the layer
271271+directory atomically. If it already exists, another process owns it. The lock
272272+file inside contains:
273273+274274+- PID of the owning process
275275+- Timestamp
276276+- Layer name
277277+- Path to the temporary build log
278278+279279+This allows multiple workers to build concurrently without collisions — if
280280+two workers need the same layer, the second one waits for the first to finish.
281281+282282+## DAG Execution (--fork mode)
283283+284284+When `--fork N` is specified, batch mode builds a global DAG across all
285285+solutions. Layers are deduplicated by hash and executed in topological order
286286+with up to N concurrent workers. Cache hits are resolved inline without
287287+forking. Dependency failures propagate immediately, skipping downstream layers.
288288+289289+Progress is logged to `logs/<pid>.log` with entries like:
290290+```
291291+[2026-03-09T13:01:44] dag: [5/42] ok either.1.0.0 (build-68cface...)
292292+```