Monorepo management for opam overlays
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(monopam): add export and import commands

- Add `export` command (replaces `split`) for exporting opam files
from any project directory to opam-repo
- Add `import` command for adding git repos as subtrees
- Add mono_lock.ml for tracking imported packages
Format: `<name> <url>#<ref>` (opam-style URLs)
- Rename monorepo_pkg.ml to pkg.ml
- Remove deprecated cmd_split.ml

+962 -63
+117
bin/cmd_export.ml
··· 1 + open Cmdliner 2 + 3 + let cmd = 4 + let doc = "Export packages to opam-repo and checkouts" in 5 + let man = 6 + [ 7 + `S Manpage.s_description; 8 + `P 9 + "Exports packages from the current directory to an opam-repo overlay \ 10 + and to local checkouts. Works from any OCaml project directory, not \ 11 + just the main monorepo."; 12 + `S "WHAT IT DOES"; 13 + `I ("1.", "Scans the current directory for dune-project and opam files"); 14 + `I 15 + ( "2.", 16 + "Transforms opam files for opam-repo (adds dev-repo, url stanzas)" ); 17 + `I ("3.", "Writes to opam-repo/packages/<name>/<name>.dev/opam"); 18 + `I ("4.", "Removes orphaned packages (when syncing all)"); 19 + `I ("5.", "Stages and commits changes in opam-repo (unless --no-commit)"); 20 + `I 21 + ("6.", "Exports subtrees to checkouts (when running from main monorepo)"); 22 + `S "WORKING FROM SUBSET PROJECTS"; 23 + `P 24 + "The export command works from any directory containing OCaml \ 25 + packages. You can export from:"; 26 + `I ("- ", "The main monorepo (mono/) - also exports to checkouts"); 27 + `I ("- ", "A standalone project checkout - opam files only"); 28 + `I ("- ", "A subtree directory - opam files only"); 29 + `P 30 + "By default, it exports to ../opam-repo relative to the current \ 31 + directory. Use --target to specify a different location."; 32 + `S Manpage.s_examples; 33 + `Pre "monopam export # Export all packages"; 34 + `Pre "monopam export aos clcw # Export specific packages"; 35 + `Pre "monopam export --dry-run # Show what would be exported"; 36 + `Pre 37 + "monopam export --target ~/opam-repo # Export to a specific opam-repo"; 38 + `Pre "monopam export --no-checkouts # Skip checkout export"; 39 + `S Manpage.s_see_also; 40 + `P "$(b,monopam import)(1), $(b,monopam push)(1), $(b,monopam status)(1)"; 41 + ] 42 + in 43 + let info = Cmd.info "export" ~doc ~man in 44 + let target_arg = 45 + let doc = 46 + "Target opam-repo directory. Defaults to ../opam-repo or the value from \ 47 + config." 48 + in 49 + Arg.(value & opt (some string) None & info [ "target" ] ~docv:"PATH" ~doc) 50 + in 51 + let no_commit_arg = 52 + let doc = "Skip automatic git commit in opam-repo." in 53 + Arg.(value & flag & info [ "no-commit" ] ~doc) 54 + in 55 + let dry_run_arg = 56 + let doc = "Show what would be exported without making changes." in 57 + Arg.(value & flag & info [ "dry-run"; "n" ] ~doc) 58 + in 59 + let no_checkouts_arg = 60 + let doc = "Skip exporting to checkouts (only export opam files)." in 61 + Arg.(value & flag & info [ "no-checkouts" ] ~doc) 62 + in 63 + let run packages target no_commit dry_run no_checkouts () = 64 + Eio_main.run @@ fun env -> 65 + let fs = Eio.Stdenv.fs env in 66 + let proc = Eio.Stdenv.process_mgr env in 67 + (* Determine source directory (CWD) *) 68 + let source = Fpath.v (Sys.getcwd ()) in 69 + (* Check if we have config (running from main mono) *) 70 + let config_opt = Result.to_option (Common.load_config env) in 71 + (* Determine target opam-repo *) 72 + let target = 73 + match target with 74 + | Some path -> 75 + if Filename.is_relative path then Fpath.(source / path) 76 + else Fpath.v path 77 + | None -> ( 78 + match config_opt with 79 + | Some config -> Monopam.Config.Paths.opam_repo config 80 + | None -> Fpath.(parent source / "opam-repo")) 81 + in 82 + if dry_run then 83 + Fmt.pr "Dry run: exporting from %a to %a@." Fpath.pp source Fpath.pp 84 + target; 85 + (* Export opam files *) 86 + match 87 + Monopam.Opam_sync.run_from_cwd ~fs ~proc ~source ~target ~packages 88 + ~no_commit ~dry_run () 89 + with 90 + | Error (`Config_error e) -> 91 + Fmt.epr "Error: %s@." e; 92 + `Error (false, "export failed") 93 + | Ok opam_result -> 94 + Fmt.pr "%a@." Monopam.Opam_sync.pp opam_result; 95 + (* Also export to checkouts if we have config and not disabled *) 96 + (match config_opt with 97 + | Some config when (not no_checkouts) && not dry_run -> ( 98 + Fmt.pr "@.Exporting to checkouts...@."; 99 + match 100 + Monopam.Push.push ~proc ~fs ~config ~packages ~upstream:false 101 + ~clean:false ~force:false () 102 + with 103 + | Ok () -> Fmt.pr "Checkouts updated.@." 104 + | Error e -> 105 + Fmt.epr "Warning: checkout export failed: %a@." 106 + Monopam.Ctx.pp_error_with_hint e) 107 + | Some _ when no_checkouts -> 108 + Fmt.pr "(Skipping checkout export due to --no-checkouts)@." 109 + | Some _ when dry_run -> Fmt.pr "(Would also export to checkouts)@." 110 + | _ -> ()); 111 + `Ok () 112 + in 113 + Cmd.v info 114 + Term.( 115 + ret 116 + (const run $ Common.packages_arg $ target_arg $ no_commit_arg 117 + $ dry_run_arg $ no_checkouts_arg $ Common.logging_term))
+96
bin/cmd_import.ml
··· 1 + open Cmdliner 2 + 3 + let cmd = 4 + let doc = "Import a package from a git repository" in 5 + let man = 6 + [ 7 + `S Manpage.s_description; 8 + `P 9 + "Imports a package from a git repository into the current project. \ 10 + Uses git subtree to bring the package source into your monorepo while \ 11 + preserving its commit history."; 12 + `S "SOURCE FORMATS"; 13 + `P "The SOURCE argument can be:"; 14 + `I ("Git URL", "A git repository URL (https or git@ format)"); 15 + `I ("Lock file", "A mono.lock file path (with --from-lock)"); 16 + `S "LOCK FILE"; 17 + `P 18 + "When you import a package, monopam creates or updates a mono.lock \ 19 + file in the target directory. This file records:"; 20 + `I ("- ", "The source URL of each imported package"); 21 + `I ("- ", "The exact commit SHA that was imported"); 22 + `I ("- ", "The branch or ref that was specified"); 23 + `P 24 + "You can use --from-lock to re-import all packages from an existing \ 25 + lock file, ensuring reproducible builds."; 26 + `S Manpage.s_examples; 27 + `Pre "monopam import https://github.com/mirage/eio.git"; 28 + `Pre "monopam import https://github.com/mirage/eio.git --name ocaml-eio"; 29 + `Pre "monopam import https://github.com/ocaml/dune.git --ref v3.17.0"; 30 + `Pre "monopam import mono.lock --from-lock"; 31 + `S Manpage.s_see_also; 32 + `P "$(b,monopam export)(1), $(b,monopam sync)(1)"; 33 + ] 34 + in 35 + let info = Cmd.info "import" ~doc ~man in 36 + let source_arg = 37 + let doc = 38 + "Git URL to import, or path to mono.lock file (with --from-lock)." 39 + in 40 + Arg.(required & pos 0 (some string) None & info [] ~docv:"SOURCE" ~doc) 41 + in 42 + let name_arg = 43 + let doc = "Override the subtree directory name." in 44 + Arg.(value & opt (some string) None & info [ "name" ] ~docv:"NAME" ~doc) 45 + in 46 + let branch_arg = 47 + let doc = "Branch to import (default: main)." in 48 + Arg.( 49 + value 50 + & opt (some string) None 51 + & info [ "branch"; "b" ] ~docv:"BRANCH" ~doc) 52 + in 53 + let ref_arg = 54 + let doc = "Specific commit or tag to pin." in 55 + Arg.(value & opt (some string) None & info [ "ref"; "r" ] ~docv:"REF" ~doc) 56 + in 57 + let from_lock_arg = 58 + let doc = "Treat SOURCE as a mono.lock file path and import all entries." in 59 + Arg.(value & flag & info [ "from-lock" ] ~doc) 60 + in 61 + let dry_run_arg = 62 + let doc = "Show what would be imported without making changes." in 63 + Arg.(value & flag & info [ "dry-run"; "n" ] ~doc) 64 + in 65 + let run source name branch ref_ from_lock dry_run () = 66 + Eio_main.run @@ fun env -> 67 + let fs = Eio.Stdenv.fs env in 68 + let proc = Eio.Stdenv.process_mgr env in 69 + let target = Fpath.v (Sys.getcwd ()) in 70 + let source = 71 + if from_lock then Monopam.Import.Lock_file (Fpath.v source) 72 + else Monopam.Import.Git_url { url = source; branch; ref_ } 73 + in 74 + match Monopam.Import.run ~proc ~fs ~target ~source ~name ~dry_run () with 75 + | Ok results -> 76 + if results = [] then Fmt.pr "Nothing imported.@." 77 + else begin 78 + Fmt.pr "Imported %d subtree%s:@." (List.length results) 79 + (if List.length results = 1 then "" else "s"); 80 + List.iter 81 + (fun r -> 82 + Fmt.pr " %s (%s)@." r.Monopam.Import.name 83 + (String.sub r.Monopam.Import.commit 0 84 + (min 7 (String.length r.Monopam.Import.commit)))) 85 + results 86 + end; 87 + `Ok () 88 + | Error e -> 89 + Fmt.epr "Error: %s@." e; 90 + `Error (false, "import failed") 91 + in 92 + Cmd.v info 93 + Term.( 94 + ret 95 + (const run $ source_arg $ name_arg $ branch_arg $ ref_arg 96 + $ from_lock_arg $ dry_run_arg $ Common.logging_term))
-39
bin/cmd_split.ml
··· 1 - open Cmdliner 2 - 3 - let cmd = 4 - let doc = "Export opam files from monorepo to opam-repo" in 5 - let man = 6 - [ 7 - `S Manpage.s_description; 8 - `P 9 - "Generates opam-repo entries from dune-project files in the monorepo. \ 10 - This syncs the opam-repo overlay with the current monorepo state."; 11 - `S "WHAT IT DOES"; 12 - `I ("1.", "Scans mono/ for dune-project files with (generate_opam_files)"); 13 - `I 14 - ( "2.", 15 - "Transforms opam files for opam-repo (adds dev-repo, url stanzas)" ); 16 - `I ("3.", "Writes to opam-repo/packages/<name>/<name>.dev/opam"); 17 - `I ("4.", "Removes orphaned packages not in monorepo (when syncing all)"); 18 - `I ("5.", "Stages and commits changes in opam-repo"); 19 - `S "EXAMPLE"; 20 - `Pre "monopam split # Sync all packages"; 21 - `Pre "monopam split aos clcw # Sync specific packages"; 22 - `S Manpage.s_see_also; 23 - `P "$(b,monopam push)(1), $(b,monopam status)(1)"; 24 - ] 25 - in 26 - let info = Cmd.info "split" ~doc ~man in 27 - let run packages () = 28 - Eio_main.run @@ fun env -> 29 - Common.with_config env @@ fun config -> 30 - let fs = Eio.Stdenv.fs env in 31 - match Monopam.sync_opam_files ~fs ~config ~packages () with 32 - | Ok result -> 33 - Fmt.pr "%a@." Monopam.pp_opam_sync_result result; 34 - `Ok () 35 - | Error (`Config_error e) -> 36 - Fmt.epr "Error: %s@." e; 37 - `Error (false, "split failed") 38 - in 39 - Cmd.v info Term.(ret (const run $ Common.packages_arg $ Common.logging_term))
+2 -1
bin/main.ml
··· 59 59 Cmd_diff.cmd; 60 60 Cmd_init.cmd; 61 61 Cmd_clean.cmd; 62 - Cmd_split.cmd; 62 + Cmd_export.cmd; 63 + Cmd_import.cmd; 63 64 Cmd_verse.cmd; 64 65 ] 65 66
+241
lib/import.ml
··· 1 + (** Import packages from git repositories. 2 + 3 + Handles the core logic for importing packages from git repositories into the 4 + current project using git subtree add. *) 5 + 6 + let src = Logs.Src.create "monopam.import" ~doc:"Git subtree import" 7 + 8 + module Log = (val Logs.src_log src : Logs.LOG) 9 + 10 + (** {1 Types} *) 11 + 12 + type source = 13 + | Git_url of { url : string; branch : string option; ref_ : string option } 14 + | Lock_file of Fpath.t 15 + 16 + type import_result = { 17 + name : string; 18 + commit : string; 19 + added : bool; (** true if newly added, false if already exists *) 20 + } 21 + 22 + (** {1 URL Parsing} *) 23 + 24 + (** Extract repository name from a git URL. Examples: 25 + - https://github.com/mirage/eio.git -> eio 26 + - git+https://github.com/ocaml/dune -> dune *) 27 + let repo_name_from_url url = 28 + let url = 29 + if String.starts_with ~prefix:"git+" url then 30 + String.sub url 4 (String.length url - 4) 31 + else url 32 + in 33 + let uri = Uri.of_string url in 34 + let path = Uri.path uri in 35 + let basename = Filename.basename path in 36 + if String.ends_with ~suffix:".git" basename then 37 + String.sub basename 0 (String.length basename - 4) 38 + else basename 39 + 40 + (** Normalize URL to have git+ prefix *) 41 + let normalize_url url = 42 + if String.starts_with ~prefix:"git+" url then url 43 + else if String.starts_with ~prefix:"git@" url then "git+" ^ url 44 + else if String.starts_with ~prefix:"https://" url then "git+" ^ url 45 + else if String.starts_with ~prefix:"http://" url then 46 + "git+https" ^ String.sub url 4 (String.length url - 4) 47 + else "git+" ^ url 48 + 49 + (** Strip git+ prefix for git commands *) 50 + let strip_git_prefix url = 51 + if String.starts_with ~prefix:"git+" url then 52 + String.sub url 4 (String.length url - 4) 53 + else url 54 + 55 + (** {1 Git Operations} *) 56 + 57 + (** Check if a directory exists *) 58 + let dir_exists ~fs path = 59 + let eio_path = Eio.Path.(fs / Fpath.to_string path) in 60 + match Eio.Path.kind ~follow:true eio_path with 61 + | `Directory -> true 62 + | _ -> false 63 + | exception _ -> false 64 + 65 + (** Get current timestamp in ISO 8601 format *) 66 + let timestamp () = 67 + let t = Unix.gettimeofday () in 68 + let tm = Unix.gmtime t in 69 + Fmt.str "%04d-%02d-%02dT%02d:%02d:%02dZ" (1900 + tm.Unix.tm_year) 70 + (tm.Unix.tm_mon + 1) tm.Unix.tm_mday tm.Unix.tm_hour tm.Unix.tm_min 71 + tm.Unix.tm_sec 72 + 73 + (** Run git subtree add command *) 74 + let git_subtree_add ~proc ~cwd ~prefix ~url ~ref_ = 75 + let url = strip_git_prefix url in 76 + let args = [ "subtree"; "add"; "--prefix"; prefix; url; ref_ ] in 77 + Log.info (fun m -> m "Running: git %s" (String.concat " " args)); 78 + let cmd = "git" :: args in 79 + let buf_stdout = Buffer.create 256 in 80 + let buf_stderr = Buffer.create 256 in 81 + Eio.Switch.run @@ fun sw -> 82 + let child = 83 + Eio.Process.spawn proc ~sw ~cwd 84 + ~stdout:(Eio.Flow.buffer_sink buf_stdout) 85 + ~stderr:(Eio.Flow.buffer_sink buf_stderr) 86 + cmd 87 + in 88 + let exit_status = Eio.Process.await child in 89 + let exit_code = 90 + match exit_status with `Exited n -> n | `Signaled n -> 128 + n 91 + in 92 + if exit_code = 0 then Ok (String.trim (Buffer.contents buf_stdout)) 93 + else 94 + Error 95 + (Fmt.str "git subtree add failed (exit %d): %s" exit_code 96 + (Buffer.contents buf_stderr)) 97 + 98 + (** Get the commit hash of HEAD *) 99 + let git_rev_parse ~proc ~cwd ref_ = 100 + let cmd = [ "git"; "rev-parse"; ref_ ] in 101 + let buf = Buffer.create 64 in 102 + Eio.Switch.run @@ fun sw -> 103 + let child = 104 + Eio.Process.spawn proc ~sw ~cwd ~stdout:(Eio.Flow.buffer_sink buf) cmd 105 + in 106 + match Eio.Process.await child with 107 + | `Exited 0 -> Ok (String.trim (Buffer.contents buf)) 108 + | _ -> Error "Failed to get commit hash" 109 + 110 + (** Fetch a ref and get its commit hash *) 111 + let git_fetch_ref ~proc ~cwd ~url ~ref_ = 112 + let url = strip_git_prefix url in 113 + let cmd = [ "git"; "fetch"; url; ref_ ] in 114 + let buf_stderr = Buffer.create 256 in 115 + Eio.Switch.run @@ fun sw -> 116 + let child = 117 + Eio.Process.spawn proc ~sw ~cwd 118 + ~stderr:(Eio.Flow.buffer_sink buf_stderr) 119 + cmd 120 + in 121 + match Eio.Process.await child with 122 + | `Exited 0 -> git_rev_parse ~proc ~cwd "FETCH_HEAD" 123 + | _ -> 124 + Error 125 + (Fmt.str "git fetch failed: %s" 126 + (String.trim (Buffer.contents buf_stderr))) 127 + 128 + (** {1 Import Operations} *) 129 + 130 + (** Import a single git URL as a subtree *) 131 + let import_git_url ~proc ~fs ~target ~url ~branch ~ref_ ~name ~dry_run = 132 + let name = match name with Some n -> n | None -> repo_name_from_url url in 133 + let url = normalize_url url in 134 + let target_eio = Eio.Path.(fs / Fpath.to_string target) in 135 + let prefix_path = Fpath.(target / name) in 136 + (* Check if subtree already exists *) 137 + if dir_exists ~fs prefix_path then 138 + Error (Fmt.str "Directory already exists: %s" name) 139 + else begin 140 + let ref_to_use = 141 + match ref_ with 142 + | Some r -> r 143 + | None -> Option.value ~default:"main" branch 144 + in 145 + if dry_run then begin 146 + Log.app (fun m -> 147 + m "Would import %s from %s (ref: %s)" name url ref_to_use); 148 + Ok { name; commit = "<dry-run>"; added = true } 149 + end 150 + else begin 151 + (* Fetch the ref to get the commit hash *) 152 + match git_fetch_ref ~proc ~cwd:target_eio ~url ~ref_:ref_to_use with 153 + | Error e -> Error e 154 + | Ok commit -> ( 155 + (* Add the subtree *) 156 + match 157 + git_subtree_add ~proc ~cwd:target_eio ~prefix:name ~url 158 + ~ref_:ref_to_use 159 + with 160 + | Error e -> Error e 161 + | Ok _ -> 162 + Log.app (fun m -> 163 + m "Imported %s at %s" name (String.sub commit 0 7)); 164 + Ok { name; commit; added = true }) 165 + end 166 + end 167 + 168 + (** Import all entries from a lock file *) 169 + let import_from_lock ~proc ~fs ~target ~lock_path ~dry_run = 170 + let lock_dir = Fpath.parent lock_path in 171 + match Mono_lock.load ~fs lock_dir with 172 + | Error e -> Error e 173 + | Ok lock -> 174 + let imports = Mono_lock.to_list lock in 175 + if imports = [] then begin 176 + Log.info (fun m -> m "No imports found in %a" Fpath.pp lock_path); 177 + Ok [] 178 + end 179 + else begin 180 + let results = 181 + List.map 182 + (fun (name, entry) -> 183 + let result = 184 + import_git_url ~proc ~fs ~target ~url:entry.Mono_lock.url 185 + ~branch:None ~ref_:(Some entry.Mono_lock.ref_) 186 + ~name:(Some name) ~dry_run 187 + in 188 + (name, result)) 189 + imports 190 + in 191 + let successes = 192 + List.filter_map 193 + (fun (_, r) -> match r with Ok res -> Some res | Error _ -> None) 194 + results 195 + in 196 + let failures = 197 + List.filter_map 198 + (fun (name, r) -> 199 + match r with Ok _ -> None | Error e -> Some (name, e)) 200 + results 201 + in 202 + List.iter 203 + (fun (name, e) -> 204 + Log.warn (fun m -> m "Failed to import %s: %s" name e)) 205 + failures; 206 + Ok successes 207 + end 208 + 209 + (** Main import function *) 210 + let run ~proc ~fs ~target ~source ~name ~dry_run () = 211 + match source with 212 + | Git_url { url; branch; ref_ } -> ( 213 + match 214 + import_git_url ~proc ~fs ~target ~url ~branch ~ref_ ~name ~dry_run 215 + with 216 + | Error e -> Error e 217 + | Ok result -> 218 + (* Update lock file *) 219 + if not dry_run then begin 220 + let lock = 221 + match Mono_lock.load ~fs target with 222 + | Ok l -> l 223 + | Error _ -> Mono_lock.empty 224 + in 225 + let entry = 226 + Mono_lock. 227 + { 228 + url = normalize_url url; 229 + ref_ = result.commit; 230 + (* Lock to exact commit *) 231 + } 232 + in 233 + let lock' = Mono_lock.add lock ~name:result.name entry in 234 + match Mono_lock.save ~fs target lock' with 235 + | Ok () -> () 236 + | Error e -> 237 + Log.warn (fun m -> m "Failed to update mono.lock: %s" e) 238 + end; 239 + Ok [ result ]) 240 + | Lock_file path -> 241 + import_from_lock ~proc ~fs ~target ~lock_path:path ~dry_run
+48
lib/import.mli
··· 1 + (** Import packages from git repositories. 2 + 3 + Handles the core logic for importing packages from git repositories into the 4 + current project using git subtree add. *) 5 + 6 + (** {1 Types} *) 7 + 8 + type source = 9 + | Git_url of { url : string; branch : string option; ref_ : string option } 10 + | Lock_file of Fpath.t 11 + 12 + type import_result = { 13 + name : string; (** Directory name of the imported subtree *) 14 + commit : string; (** Full commit SHA that was imported *) 15 + added : bool; (** true if newly added, false if already exists *) 16 + } 17 + 18 + (** {1 URL Utilities} *) 19 + 20 + val repo_name_from_url : string -> string 21 + (** [repo_name_from_url url] extracts the repository name from a git URL. For 22 + example, "https://github.com/mirage/eio.git" returns "eio". *) 23 + 24 + val normalize_url : string -> string 25 + (** [normalize_url url] ensures the URL has a "git+" prefix. *) 26 + 27 + (** {1 Import Operations} *) 28 + 29 + val run : 30 + proc:_ Eio.Process.mgr -> 31 + fs:Eio.Fs.dir_ty Eio.Path.t -> 32 + target:Fpath.t -> 33 + source:source -> 34 + name:string option -> 35 + dry_run:bool -> 36 + unit -> 37 + (import_result list, string) result 38 + (** [run ~proc ~fs ~target ~source ~name ~dry_run ()] imports a git repository 39 + as a subtree into [target]. 40 + 41 + - [source] specifies either a Git URL or a lock file to import from 42 + - [name] overrides the default subtree directory name 43 + - [dry_run] shows what would be imported without making changes 44 + 45 + Returns the list of imported subtrees, or an error message. *) 46 + 47 + val timestamp : unit -> string 48 + (** [timestamp ()] returns the current time in ISO 8601 format. *)
+97
lib/mono_lock.ml
··· 1 + (** Lock file for tracking imported packages. 2 + 3 + Simple line-based format using opam URL syntax: 4 + {v 5 + # mono.lock 6 + ocaml-eio git+https://github.com/mirage/eio.git#main 7 + ocaml-dune git+https://github.com/ocaml/dune.git#v3.17.0 8 + ocaml-logs git+https://github.com/dbuenzli/logs.git#abc123def456 9 + v} 10 + 11 + Format: [<name> <url>#<branch|tag|commit>] Lines starting with # are 12 + comments. *) 13 + 14 + type entry = { 15 + url : string; (** Git URL without fragment *) 16 + ref_ : string; (** Branch, tag, or commit SHA *) 17 + } 18 + 19 + type t = (string * entry) list 20 + 21 + let empty = [] 22 + let find t ~name = List.assoc_opt name t 23 + let add t ~name entry = (name, entry) :: List.remove_assoc name t 24 + let remove t ~name = List.remove_assoc name t 25 + let to_list t = t 26 + let names t = List.map fst t 27 + 28 + (** {1 Parsing} *) 29 + 30 + let parse_url_with_ref url_str = 31 + let uri = Uri.of_string url_str in 32 + let ref_ = Uri.fragment uri in 33 + let url = Uri.with_fragment uri None |> Uri.to_string in 34 + (url, ref_) 35 + 36 + let parse_line line = 37 + let line = String.trim line in 38 + if line = "" || (String.length line > 0 && line.[0] = '#') then None 39 + else 40 + match String.index_opt line ' ' with 41 + | None -> None 42 + | Some i -> ( 43 + let name = String.sub line 0 i in 44 + let url_with_ref = 45 + String.trim (String.sub line (i + 1) (String.length line - i - 1)) 46 + in 47 + let url, ref_ = parse_url_with_ref url_with_ref in 48 + match ref_ with 49 + | Some r -> Some (name, { url; ref_ = r }) 50 + | None -> Some (name, { url; ref_ = "main" })) 51 + (* Default to main *) 52 + 53 + let of_string s = String.split_on_char '\n' s |> List.filter_map parse_line 54 + 55 + (** {1 Serializing} *) 56 + 57 + let entry_to_string name e = Fmt.str "%s %s#%s" name e.url e.ref_ 58 + 59 + let to_string t = 60 + if t = [] then "" 61 + else 62 + let lines = List.map (fun (name, e) -> entry_to_string name e) t in 63 + String.concat "\n" lines ^ "\n" 64 + 65 + (** {1 File Operations} *) 66 + 67 + let lock_filename = "mono.lock" 68 + 69 + let load ~fs dir = 70 + let path = Fpath.(dir / lock_filename) in 71 + let path_str = Fpath.to_string path in 72 + let eio_path = Eio.Path.(fs / path_str) in 73 + match Eio.Path.kind ~follow:true eio_path with 74 + | `Regular_file -> ( 75 + try 76 + let content = Eio.Path.load eio_path in 77 + Ok (of_string content) 78 + with exn -> 79 + Error (Fmt.str "Error loading mono.lock: %s" (Printexc.to_string exn))) 80 + | _ -> Ok empty 81 + | exception _ -> Ok empty 82 + 83 + let save ~fs dir t = 84 + let path = Fpath.(dir / lock_filename) in 85 + let eio_path = Eio.Path.(fs / Fpath.to_string path) in 86 + try 87 + Eio.Path.save ~create:(`Or_truncate 0o644) eio_path (to_string t); 88 + Ok () 89 + with exn -> Error (Printexc.to_string exn) 90 + 91 + (** {1 Pretty Printing} *) 92 + 93 + let pp_entry ppf e = Fmt.pf ppf "%s#%s" e.url e.ref_ 94 + 95 + let pp ppf t = 96 + if t = [] then Fmt.pf ppf "(empty)" 97 + else List.iter (fun (name, e) -> Fmt.pf ppf "%s %a@," name pp_entry e) t
+37
lib/mono_lock.mli
··· 1 + (** Lock file for tracking imported packages. 2 + 3 + Simple line-based format using opam URL syntax: 4 + {v 5 + # mono.lock 6 + ocaml-eio git+https://github.com/mirage/eio.git#main 7 + ocaml-dune git+https://github.com/ocaml/dune.git#v3.17.0 8 + ocaml-logs git+https://github.com/dbuenzli/logs.git#abc123def456 9 + v} 10 + 11 + Format: [<name> <url>#<branch|tag|commit>] Lines starting with # are 12 + comments. *) 13 + 14 + type entry = { 15 + url : string; (** Git URL without fragment *) 16 + ref_ : string; (** Branch, tag, or commit SHA *) 17 + } 18 + 19 + type t 20 + (** Lock file contents, indexed by package/subtree name. *) 21 + 22 + val empty : t 23 + val find : t -> name:string -> entry option 24 + val add : t -> name:string -> entry -> t 25 + val remove : t -> name:string -> t 26 + val to_list : t -> (string * entry) list 27 + val names : t -> string list 28 + 29 + val lock_filename : string 30 + (** Default filename: "mono.lock" *) 31 + 32 + val load : fs:Eio.Fs.dir_ty Eio.Path.t -> Fpath.t -> (t, string) result 33 + val save : fs:Eio.Fs.dir_ty Eio.Path.t -> Fpath.t -> t -> (unit, string) result 34 + val of_string : string -> t 35 + val to_string : t -> string 36 + val pp_entry : entry Fmt.t 37 + val pp : t Fmt.t
+3 -1
lib/monopam.ml
··· 25 25 module Site = Site 26 26 module Remote_cache = Remote_cache 27 27 module Opam_sync = Opam_sync 28 - module Monorepo_pkg = Monorepo_pkg 28 + module Pkg = Pkg 29 29 module Progress = Sync_progress 30 + module Mono_lock = Mono_lock 31 + module Import = Import 30 32 31 33 (** {1 Command Modules} *) 32 34
+3 -1
lib/monopam.mli
··· 47 47 module Site = Site 48 48 module Remote_cache = Remote_cache 49 49 module Opam_sync = Opam_sync 50 - module Monorepo_pkg = Monorepo_pkg 50 + module Pkg = Pkg 51 51 module Progress = Sync_progress 52 + module Mono_lock = Mono_lock 53 + module Import = Import 52 54 53 55 (** {1 Command Modules} *) 54 56
+3
lib/monorepo_pkg.ml lib/pkg.ml
··· 12 12 opam_content : string; 13 13 } 14 14 15 + let make ~pkg_name ~subtree ~dev_repo ~url_src ~opam_content = 16 + { pkg_name; subtree; dev_repo; url_src; opam_content } 17 + 15 18 let pp ppf t = Fmt.pf ppf "@[<v>%s (subtree: %s)@]" t.pkg_name t.subtree 16 19 let name t = t.pkg_name 17 20 let subtree t = t.subtree
+11 -7
lib/monorepo_pkg.mli lib/pkg.mli
··· 1 1 (** Package discovery from monorepo subtrees. *) 2 2 3 - type t = { 4 - pkg_name : string; 5 - subtree : string; 6 - dev_repo : string; 7 - url_src : string; 8 - opam_content : string; 9 - } 3 + type t 4 + (** Package metadata from a monorepo subtree. *) 5 + 6 + val make : 7 + pkg_name:string -> 8 + subtree:string -> 9 + dev_repo:string -> 10 + url_src:string -> 11 + opam_content:string -> 12 + t 13 + (** Create a package. *) 10 14 11 15 val pp : t Fmt.t 12 16 (** Pretty-printer for monorepo package. *)
+287 -13
lib/opam_sync.ml
··· 63 63 64 64 (** Sync a single package to opam-repo. Returns `Synced or `Unchanged. *) 65 65 let sync_package ~fs ~opam_repo pkg = 66 - let pkg_name = Monorepo_pkg.name pkg in 66 + let pkg_name = Pkg.name pkg in 67 67 let pkg_dir = 68 68 Fpath.(opam_repo / "packages" / pkg_name / (pkg_name ^ ".dev")) 69 69 in 70 70 let dst_path = Eio.Path.(fs / Fpath.to_string pkg_dir / "opam") in 71 71 let dst_content = read_file_opt dst_path in 72 - if Some (Monorepo_pkg.opam_content pkg) = dst_content then `Unchanged pkg_name 72 + if Some (Pkg.opam_content pkg) = dst_content then `Unchanged pkg_name 73 73 else begin 74 74 let pkg_dir_eio = Eio.Path.(fs / Fpath.to_string pkg_dir) in 75 75 (try Eio.Path.mkdirs ~perm:0o755 pkg_dir_eio with Eio.Io _ -> ()); 76 76 Log.info (fun m -> m "Generating %s.opam in opam-repo" pkg_name); 77 - Eio.Path.save ~create:(`Or_truncate 0o644) dst_path 78 - (Monorepo_pkg.opam_content pkg); 77 + Eio.Path.save ~create:(`Or_truncate 0o644) dst_path (Pkg.opam_content pkg); 79 78 let repo = Git.Repository.open_repo ~fs opam_repo in 80 79 let rel_path = Fmt.str "packages/%s/%s.dev/opam" pkg_name pkg_name in 81 80 (match Git.Repository.add_to_index repo [ rel_path ] with ··· 100 99 Fmt.str "Sync opam files from monorepo (%s packages)" 101 100 (String.concat ", " parts) 102 101 103 - (** Load sources registry from monorepo. *) 104 - let load_sources ~fs ~monorepo = 105 - let sources_path = Fpath.(monorepo / "sources.toml") in 102 + (** Load sources registry from a directory. *) 103 + let load_sources ~fs ~dir = 104 + let sources_path = Fpath.(dir / "sources.toml") in 106 105 match Sources_registry.load ~fs sources_path with 107 106 | Ok s -> 108 107 let count = List.length (Sources_registry.to_list s) in ··· 129 128 130 129 let run ~fs ~config ?(packages = []) () = 131 130 let monorepo = Config.Paths.monorepo config in 132 - let sources = load_sources ~fs ~monorepo in 133 - match Monorepo_pkg.discover ~fs ~config ~sources () with 131 + let sources = load_sources ~fs ~dir:monorepo in 132 + match Pkg.discover ~fs ~config ~sources () with 134 133 | Error (`Config_error e) -> Error (`Config_error e) 135 134 | Ok all_pkgs -> 136 135 let pkgs = ··· 140 139 List.filter 141 140 (fun p -> 142 141 List.exists 143 - (fun name -> 144 - Monorepo_pkg.name p = name || Monorepo_pkg.subtree p = name) 142 + (fun name -> Pkg.name p = name || Pkg.subtree p = name) 145 143 names) 146 144 all_pkgs 147 145 in ··· 151 149 let sync_results = 152 150 List.mapi 153 151 (fun i pkg -> 154 - Tty.Progress.message progress (Monorepo_pkg.name pkg); 152 + Tty.Progress.message progress (Pkg.name pkg); 155 153 Tty.Progress.set progress (i + 1); 156 154 sync_package ~fs ~opam_repo pkg) 157 155 pkgs ··· 164 162 ([], []) sync_results 165 163 in 166 164 let generated_names = 167 - List.map Monorepo_pkg.name pkgs |> List.sort_uniq String.compare 165 + List.map Pkg.name pkgs |> List.sort_uniq String.compare 168 166 in 169 167 let deleted = 170 168 if packages = [] then delete_orphaned ~fs ~config ~generated_names ··· 193 191 Log.warn (fun m -> m "No git user config found, skipping commit") 194 192 end; 195 193 Ok result 194 + 195 + (** {1 CWD-based Export} *) 196 + 197 + (** Discover packages from a standalone project directory. Similar to 198 + Pkg.discover but works without config, scanning for dune-project and opam 199 + files in the source directory. *) 200 + let discover_from_cwd ~fs ~source = 201 + let source_eio = Eio.Path.(fs / Fpath.to_string source) in 202 + let sources = load_sources ~fs ~dir:source in 203 + (* First check if source itself is a package (has dune-project) *) 204 + let dune_project_path = Eio.Path.(source_eio / "dune-project") in 205 + match Eio.Path.kind ~follow:false dune_project_path with 206 + | `Regular_file -> ( 207 + (* Single project - discover packages from this directory *) 208 + let content = 209 + try Some (Eio.Path.load dune_project_path) with Eio.Io _ -> None 210 + in 211 + match content with 212 + | None -> Error (`Config_error "Cannot read dune-project") 213 + | Some content -> ( 214 + match Dune_project.parse content with 215 + | Error msg -> Error (`Config_error msg) 216 + | Ok dune_proj -> 217 + let subtree = Fpath.basename source in 218 + let dev_repo, url_src = 219 + match 220 + ( Dune_project.dev_repo_url dune_proj, 221 + Dune_project.url_with_branch dune_proj ) 222 + with 223 + | Ok dev_repo, Ok url_src -> (dev_repo, url_src) 224 + | _ -> ( 225 + match Sources_registry.derive_url sources ~subtree with 226 + | Some url -> (url, url ^ "#main") 227 + | None -> ("", "")) 228 + in 229 + let opam_files = 230 + try 231 + Eio.Path.read_dir source_eio 232 + |> List.filter (fun name -> 233 + Filename.check_suffix name ".opam") 234 + with Eio.Io _ -> [] 235 + in 236 + let pkgs = 237 + List.filter_map 238 + (fun opam_file -> 239 + let pkg_name = Filename.chop_suffix opam_file ".opam" in 240 + let opam_path = Eio.Path.(source_eio / opam_file) in 241 + try 242 + let raw_content = Eio.Path.load opam_path in 243 + let opam_content = 244 + Opam_transform.transform ~content:raw_content ~dev_repo 245 + ~url_src 246 + in 247 + Some 248 + (Pkg.make ~pkg_name ~subtree ~dev_repo ~url_src 249 + ~opam_content) 250 + with Eio.Io _ -> None) 251 + opam_files 252 + in 253 + Ok pkgs)) 254 + | _ -> 255 + (* Directory of subtrees - scan subdirectories like monorepo *) 256 + let subdirs = 257 + try 258 + Eio.Path.read_dir source_eio 259 + |> List.filter (fun name -> 260 + let child = Eio.Path.(source_eio / name) in 261 + match Eio.Path.kind ~follow:false child with 262 + | `Directory -> true 263 + | _ -> false) 264 + with Eio.Io _ -> [] 265 + in 266 + let packages = 267 + List.fold_left 268 + (fun acc subtree -> 269 + let subtree_path = Eio.Path.(source_eio / subtree) in 270 + let dune_path = Eio.Path.(subtree_path / "dune-project") in 271 + match Eio.Path.kind ~follow:false dune_path with 272 + | `Regular_file -> ( 273 + let content = 274 + try Some (Eio.Path.load dune_path) with Eio.Io _ -> None 275 + in 276 + match content with 277 + | None -> acc 278 + | Some content -> ( 279 + match Dune_project.parse content with 280 + | Error _ -> acc 281 + | Ok dune_proj -> 282 + let dev_repo, url_src = 283 + match 284 + ( Dune_project.dev_repo_url dune_proj, 285 + Dune_project.url_with_branch dune_proj ) 286 + with 287 + | Ok dr, Ok us -> (dr, us) 288 + | _ -> ( 289 + match 290 + Sources_registry.derive_url sources ~subtree 291 + with 292 + | Some url -> (url, url ^ "#main") 293 + | None -> ("", "")) 294 + in 295 + let opam_files = 296 + try 297 + Eio.Path.read_dir subtree_path 298 + |> List.filter (fun name -> 299 + Filename.check_suffix name ".opam") 300 + with Eio.Io _ -> [] 301 + in 302 + let pkgs = 303 + List.filter_map 304 + (fun opam_file -> 305 + let pkg_name = 306 + Filename.chop_suffix opam_file ".opam" 307 + in 308 + let opam_path = 309 + Eio.Path.(subtree_path / opam_file) 310 + in 311 + try 312 + let raw_content = Eio.Path.load opam_path in 313 + let opam_content = 314 + Opam_transform.transform ~content:raw_content 315 + ~dev_repo ~url_src 316 + in 317 + Some 318 + (Pkg.make ~pkg_name ~subtree ~dev_repo 319 + ~url_src ~opam_content) 320 + with Eio.Io _ -> None) 321 + opam_files 322 + in 323 + pkgs @ acc)) 324 + | _ -> acc 325 + | exception Eio.Io _ -> acc) 326 + [] subdirs 327 + in 328 + Ok (List.rev packages) 329 + 330 + (** List packages in a standalone opam-repo. *) 331 + let list_opam_repo_packages_at ~fs opam_repo = 332 + let packages_dir = Eio.Path.(fs / Fpath.to_string opam_repo / "packages") in 333 + try 334 + Eio.Path.read_dir packages_dir 335 + |> List.filter (fun name -> 336 + let child = Eio.Path.(packages_dir / name) in 337 + match Eio.Path.kind ~follow:false child with 338 + | `Directory -> true 339 + | _ -> false) 340 + with Eio.Io _ -> [] 341 + 342 + (** Delete orphaned package from a standalone opam-repo. *) 343 + let delete_opam_repo_package_at ~fs opam_repo name = 344 + let pkg_dir = Eio.Path.(fs / Fpath.to_string opam_repo / "packages" / name) in 345 + try 346 + Eio.Path.rmtree pkg_dir; 347 + let repo = Git.Repository.open_repo ~fs opam_repo in 348 + let rel_path = Fmt.str "packages/%s" name in 349 + (match Git.Repository.remove_from_index repo rel_path with 350 + | Ok () -> () 351 + | Error (`Msg e) -> Log.warn (fun m -> m "Failed to update index: %s" e)); 352 + Log.info (fun m -> m "Deleted orphaned package %s from opam-repo" name); 353 + true 354 + with Eio.Io _ -> 355 + Log.warn (fun m -> m "Failed to delete package %s" name); 356 + false 357 + 358 + (** Sync a single package to a target opam-repo with dry_run support. *) 359 + let sync_package_to ~fs ~opam_repo ~dry_run pkg = 360 + let pkg_name = Pkg.name pkg in 361 + let pkg_dir = 362 + Fpath.(opam_repo / "packages" / pkg_name / (pkg_name ^ ".dev")) 363 + in 364 + let dst_path = Eio.Path.(fs / Fpath.to_string pkg_dir / "opam") in 365 + let dst_content = read_file_opt dst_path in 366 + if Some (Pkg.opam_content pkg) = dst_content then `Unchanged pkg_name 367 + else if dry_run then `Synced pkg_name 368 + else begin 369 + let pkg_dir_eio = Eio.Path.(fs / Fpath.to_string pkg_dir) in 370 + (try Eio.Path.mkdirs ~perm:0o755 pkg_dir_eio with Eio.Io _ -> ()); 371 + Log.info (fun m -> m "Generating %s.opam in opam-repo" pkg_name); 372 + Eio.Path.save ~create:(`Or_truncate 0o644) dst_path (Pkg.opam_content pkg); 373 + let repo = Git.Repository.open_repo ~fs opam_repo in 374 + let rel_path = Fmt.str "packages/%s/%s.dev/opam" pkg_name pkg_name in 375 + (match Git.Repository.add_to_index repo [ rel_path ] with 376 + | Ok () -> () 377 + | Error (`Msg e) -> Log.warn (fun m -> m "Failed to add to index: %s" e)); 378 + `Synced pkg_name 379 + end 380 + 381 + let run_from_cwd ~fs ~proc:_ ~source ~target ?(packages = []) 382 + ?(no_commit = false) ?(dry_run = false) () = 383 + match discover_from_cwd ~fs ~source with 384 + | Error e -> Error e 385 + | Ok all_pkgs -> 386 + let pkgs = 387 + match packages with 388 + | [] -> all_pkgs 389 + | names -> 390 + List.filter 391 + (fun p -> 392 + List.exists 393 + (fun name -> Pkg.name p = name || Pkg.subtree p = name) 394 + names) 395 + all_pkgs 396 + in 397 + if pkgs = [] then begin 398 + Log.info (fun m -> m "No packages found to export"); 399 + Ok { synced = []; unchanged = []; missing = []; orphaned = [] } 400 + end 401 + else begin 402 + let total = List.length pkgs in 403 + let progress = Tty.Progress.create ~total "Export" in 404 + let sync_results = 405 + List.mapi 406 + (fun i pkg -> 407 + Tty.Progress.message progress (Pkg.name pkg); 408 + Tty.Progress.set progress (i + 1); 409 + sync_package_to ~fs ~opam_repo:target ~dry_run pkg) 410 + pkgs 411 + in 412 + Tty.Progress.finish progress; 413 + let synced, unchanged = 414 + List.fold_left 415 + (fun (s, u) r -> 416 + match r with 417 + | `Synced n -> (n :: s, u) 418 + | `Unchanged n -> (s, n :: u)) 419 + ([], []) sync_results 420 + in 421 + let generated_names = 422 + List.map Pkg.name pkgs |> List.sort_uniq String.compare 423 + in 424 + (* Only delete orphaned when syncing all packages and not dry-run *) 425 + let deleted = 426 + if packages = [] && not dry_run then begin 427 + let existing = list_opam_repo_packages_at ~fs target in 428 + let orphaned = 429 + List.filter 430 + (fun name -> not (List.mem name generated_names)) 431 + existing 432 + in 433 + List.iter 434 + (fun name -> 435 + Log.info (fun m -> m "Removing orphaned package: %s" name); 436 + ignore (delete_opam_repo_package_at ~fs target name)) 437 + orphaned; 438 + orphaned 439 + end 440 + else [] 441 + in 442 + let result = 443 + { 444 + synced = List.rev synced; 445 + unchanged = List.rev unchanged; 446 + missing = []; 447 + orphaned = deleted; 448 + } 449 + in 450 + if 451 + (not no_commit) && (not dry_run) 452 + && (result.synced <> [] || result.orphaned <> []) 453 + then begin 454 + let repo = Git.Repository.open_repo ~fs target in 455 + let msg = commit_message result in 456 + match Git_cli.global_git_user () with 457 + | Some user -> ( 458 + match 459 + Git.Repository.commit_index repo ~author:user ~committer:user 460 + ~message:msg () 461 + with 462 + | Ok _ -> () 463 + | Error (`Msg e) -> Log.warn (fun m -> m "Failed to commit: %s" e) 464 + ) 465 + | None -> 466 + Log.warn (fun m -> m "No git user config found, skipping commit") 467 + end; 468 + Ok result 469 + end
+17 -1
lib/opam_sync.mli
··· 1 - (** Sync opam files from monorepo to opam-repo. *) 1 + (** Sync opam files from a source directory to opam-repo. *) 2 2 3 3 type t = { 4 4 synced : string list; ··· 19 19 (** [run ~fs ~config ?packages ()] syncs opam files from monorepo subtrees to 20 20 the local opam-repo. If [packages] is specified, only syncs those packages. 21 21 *) 22 + 23 + val run_from_cwd : 24 + fs:Eio.Fs.dir_ty Eio.Path.t -> 25 + proc:_ Eio.Process.mgr -> 26 + source:Fpath.t -> 27 + target:Fpath.t -> 28 + ?packages:string list -> 29 + ?no_commit:bool -> 30 + ?dry_run:bool -> 31 + unit -> 32 + (t, [> `Config_error of string ]) result 33 + (** [run_from_cwd ~fs ~proc ~source ~target ?packages ?no_commit ?dry_run ()] 34 + syncs opam files from [source] directory to [target] opam-repo. Works from 35 + any directory, not just the main monorepo. If [no_commit] is true, skips 36 + auto-commit. If [dry_run] is true, shows what would be exported without 37 + making changes. *)