Monorepo management for opam overlays
at main 531 lines 22 kB view raw
1type error = 2 | Config_error of string 3 | Git_error of Git.error 4 | Registry_error of string 5 | Member_not_found of string 6 | Workspace_exists of Fpath.t 7 | Not_a_workspace of Fpath.t 8 | Package_not_found of string * string (** (package, handle) *) 9 | Package_already_exists of string list (** List of conflicting package names *) 10 | Opam_repo_error of Opam_repo.error 11 12let pp_error ppf = function 13 | Config_error msg -> Fmt.pf ppf "Configuration error: %s" msg 14 | Git_error e -> Fmt.pf ppf "Git error: %a" Git.pp_error e 15 | Registry_error msg -> Fmt.pf ppf "Registry error: %s" msg 16 | Member_not_found h -> Fmt.pf ppf "Member not in registry: %s" h 17 | Workspace_exists p -> Fmt.pf ppf "Workspace already exists: %a" Fpath.pp p 18 | Not_a_workspace p -> Fmt.pf ppf "Not a opamverse workspace: %a" Fpath.pp p 19 | Package_not_found (pkg, handle) -> 20 Fmt.pf ppf "Package %s not found in %s's opam repo" pkg handle 21 | Package_already_exists pkgs -> 22 Fmt.pf ppf "Packages already exist in your opam repo: %a" 23 Fmt.(list ~sep:comma string) pkgs 24 | Opam_repo_error e -> Fmt.pf ppf "Opam repo error: %a" Opam_repo.pp_error e 25 26let error_hint = function 27 | Config_error _ -> 28 Some 29 "Run 'monopam init --handle <your-handle>' to create a workspace." 30 | Git_error (Git.Dirty_worktree _) -> 31 Some "Commit or stash your changes first: git status" 32 | Git_error (Git.Command_failed (cmd, _)) 33 when String.starts_with ~prefix:"git clone" cmd -> 34 Some "Check the URL is correct and you have network access." 35 | Git_error (Git.Command_failed (cmd, _)) 36 when String.starts_with ~prefix:"git pull" cmd -> 37 Some "Check your network connection. Try: git fetch origin" 38 | Git_error _ -> None 39 | Registry_error _ -> 40 Some "The registry may be temporarily unavailable. Try again later." 41 | Member_not_found h -> 42 Some 43 (Fmt.str 44 "Check available members: monopam verse members (looking for '%s')" h) 45 | Workspace_exists _ -> 46 Some "Use a different directory, or remove the existing workspace." 47 | Not_a_workspace _ -> 48 Some "Run 'monopam init --handle <your-handle>' to create a workspace here." 49 | Package_not_found (pkg, handle) -> 50 Some (Fmt.str "Run 'monopam verse pull %s' to sync their opam repo, then check package name: %s" handle pkg) 51 | Package_already_exists pkgs -> 52 Some (Fmt.str "Remove conflicting packages first:\n %s" 53 (String.concat "\n " (List.map (fun p -> "rm -rf opam-repo/packages/" ^ p) pkgs))) 54 | Opam_repo_error _ -> None 55 56let pp_error_with_hint ppf e = 57 pp_error ppf e; 58 match error_hint e with 59 | Some hint -> Fmt.pf ppf "@.@[<v 2>Hint: %s@]" hint 60 | None -> () 61 62type member_status = { 63 handle : string; 64 monorepo_url : string; 65 local_path : Fpath.t; 66 cloned : bool; 67 clean : bool option; 68 ahead_behind : Git.ahead_behind option; 69} 70 71type status = { 72 config : Verse_config.t; 73 registry : Verse_registry.t; 74 tracked_members : member_status list; 75} 76 77let pp_member_status ppf m = 78 let status = 79 if not m.cloned then "not cloned" 80 else 81 match (m.clean, m.ahead_behind) with 82 | Some false, _ -> "dirty" 83 | Some true, Some ab when ab.ahead > 0 || ab.behind > 0 -> 84 Fmt.str "ahead %d, behind %d" ab.ahead ab.behind 85 | Some true, _ -> "clean" 86 | None, _ -> "unknown" 87 in 88 Fmt.pf ppf "@[<hov 2>%s@ (%s)@ [%s]@]" m.handle m.monorepo_url status 89 90let pp_status ppf s = 91 Fmt.pf ppf "@[<v>Workspace: %a@,Registry: %s@,Members:@, @[<v>%a@]@]" 92 Fpath.pp 93 (Verse_config.root s.config) 94 s.registry.name 95 Fmt.(list ~sep:cut pp_member_status) 96 s.tracked_members 97 98(* Helper to check if a path is a directory *) 99let is_directory ~fs path = 100 let eio_path = Eio.Path.(fs / Fpath.to_string path) in 101 match Eio.Path.kind ~follow:true eio_path with 102 | `Directory -> true 103 | _ -> false 104 | exception _ -> false 105 106(* Helper to check if a path is a regular file *) 107let is_file ~fs path = 108 let eio_path = Eio.Path.(fs / Fpath.to_string path) in 109 match Eio.Path.kind ~follow:true eio_path with 110 | `Regular_file -> true 111 | _ -> false 112 | exception _ -> false 113 114(* Helper to create a directory if it doesn't exist *) 115let ensure_dir ~fs path = 116 let eio_path = Eio.Path.(fs / Fpath.to_string path) in 117 try Eio.Path.mkdirs ~perm:0o755 eio_path with Eio.Io _ -> () 118 119(* Get list of tracked members by looking at verse/ directory *) 120let get_tracked_handles ~fs config = 121 let verse_path = Verse_config.verse_path config in 122 if not (is_directory ~fs verse_path) then [] 123 else 124 let eio_path = Eio.Path.(fs / Fpath.to_string verse_path) in 125 try 126 Eio.Path.read_dir eio_path 127 |> List.filter (fun name -> is_directory ~fs Fpath.(verse_path / name)) 128 with Eio.Io _ -> [] 129 130let init ~proc ~fs ~root ~handle () = 131 (* Check if config already exists in XDG *) 132 let config_file = Verse_config.config_file () in 133 Logs.info (fun m -> m "Config file: %a" Fpath.pp config_file); 134 if is_file ~fs config_file then begin 135 Logs.err (fun m -> m "Config already exists at %a" Fpath.pp config_file); 136 Error (Workspace_exists root) 137 end 138 else 139 (* Resolve root to absolute path *) 140 let root = 141 if Fpath.is_abs root then root 142 else 143 (* Get absolute path via realpath *) 144 let root_str = Fpath.to_string root in 145 let eio_path = Eio.Path.(fs / root_str) in 146 (* Ensure the directory exists first so realpath works *) 147 (try Eio.Path.mkdirs ~perm:0o755 eio_path with Eio.Io _ -> ()); 148 match Unix.realpath root_str with 149 | abs_str -> ( 150 match Fpath.of_string abs_str with Ok p -> p | Error _ -> root) 151 | exception _ -> root 152 in 153 Logs.info (fun m -> m "Workspace root: %a" Fpath.pp root); 154 (* Create config - need this temporarily to get paths *) 155 let config = Verse_config.create ~root ~handle () in 156 (* Clone registry first to look up user's repos *) 157 Logs.info (fun m -> m "Cloning registry..."); 158 match Verse_registry.clone_or_pull ~proc ~fs ~config () with 159 | Error msg -> 160 Logs.err (fun m -> m "Registry clone failed: %s" msg); 161 Error (Registry_error msg) 162 | Ok registry -> ( 163 Logs.info (fun m -> m "Registry loaded"); 164 (* Look up user in registry - this validates the handle *) 165 match Verse_registry.find_member registry ~handle with 166 | None -> 167 Logs.err (fun m -> m "Handle %s not found in registry" handle); 168 Error (Member_not_found handle) 169 | Some member -> ( 170 Logs.info (fun m -> 171 m "Found member: mono=%s opam=%s" member.monorepo 172 member.opamrepo); 173 (* Create workspace directories *) 174 Logs.info (fun m -> m "Creating workspace directories..."); 175 ensure_dir ~fs root; 176 ensure_dir ~fs (Verse_config.src_path config); 177 ensure_dir ~fs (Verse_config.verse_path config); 178 (* Clone user's monorepo *) 179 let mono_path = Verse_config.mono_path config in 180 Logs.info (fun m -> m "Cloning monorepo to %a" Fpath.pp mono_path); 181 let mono_url = Uri.of_string member.monorepo in 182 match 183 Git.clone ~proc ~fs ~url:mono_url 184 ~branch:Verse_config.default_branch mono_path 185 with 186 | Error e -> 187 Logs.err (fun m -> m "Monorepo clone failed: %a" Git.pp_error e); 188 Error (Git_error e) 189 | Ok () -> ( 190 Logs.info (fun m -> m "Monorepo cloned"); 191 (* Clone user's opam repo *) 192 let opam_path = Verse_config.opam_repo_path config in 193 Logs.info (fun m -> 194 m "Cloning opam repo to %a" Fpath.pp opam_path); 195 let opam_url = Uri.of_string member.opamrepo in 196 match 197 Git.clone ~proc ~fs ~url:opam_url 198 ~branch:Verse_config.default_branch opam_path 199 with 200 | Error e -> 201 Logs.err (fun m -> 202 m "Opam repo clone failed: %a" Git.pp_error e); 203 Error (Git_error e) 204 | Ok () -> ( 205 Logs.info (fun m -> m "Opam repo cloned"); 206 (* Save config to XDG *) 207 Logs.info (fun m -> 208 m "Saving config to %a" Fpath.pp config_file); 209 match Verse_config.save ~fs config with 210 | Error msg -> 211 Logs.err (fun m -> m "Failed to save config: %s" msg); 212 Error (Config_error msg) 213 | Ok () -> 214 Logs.info (fun m -> 215 m "Workspace initialized successfully"); 216 Ok ())))) 217 218let status ~proc ~fs ~config () = 219 (* Load registry *) 220 match Verse_registry.clone_or_pull ~proc ~fs ~config () with 221 | Error msg -> Error (Registry_error msg) 222 | Ok registry -> 223 (* Get tracked handles *) 224 let tracked_handles = get_tracked_handles ~fs config in 225 (* Build status for each tracked member *) 226 let tracked_members = 227 List.filter_map 228 (fun handle -> 229 (* Find member in registry *) 230 match Verse_registry.find_member registry ~handle with 231 | None -> 232 (* Member not in registry but locally tracked - show anyway *) 233 let local_path = 234 Fpath.(Verse_config.verse_path config / handle) 235 in 236 let cloned = is_directory ~fs local_path in 237 Some 238 { 239 handle; 240 monorepo_url = "(not in registry)"; 241 local_path; 242 cloned; 243 clean = None; 244 ahead_behind = None; 245 } 246 | Some member -> 247 let local_path = 248 Fpath.(Verse_config.verse_path config / handle) 249 in 250 let cloned = Git.is_repo ~proc ~fs local_path in 251 let clean = 252 if cloned then Some (not (Git.is_dirty ~proc ~fs local_path)) 253 else None 254 in 255 let ahead_behind = 256 if cloned then 257 match Git.ahead_behind ~proc ~fs local_path with 258 | Ok ab -> Some ab 259 | Error _ -> None 260 else None 261 in 262 Some 263 { 264 handle; 265 monorepo_url = member.monorepo; 266 local_path; 267 cloned; 268 clean; 269 ahead_behind; 270 }) 271 tracked_handles 272 in 273 Ok { config; registry; tracked_members } 274 275let members ~proc ~fs ~config () = 276 match Verse_registry.clone_or_pull ~proc ~fs ~config () with 277 | Error msg -> Error (Registry_error msg) 278 | Ok registry -> Ok registry.members 279 280(** Clone or fetch+reset a single git repo. Returns Ok true if cloned, Ok false if reset. 281 Uses fetch+reset instead of pull since verse repos should not have local changes. *) 282let clone_or_reset_repo ~proc ~fs ~url ~branch path = 283 if Git.is_repo ~proc ~fs path then begin 284 match Git.fetch_and_reset ~proc ~fs ~branch path with 285 | Error e -> Error e 286 | Ok () -> Ok false 287 end 288 else begin 289 let url = Uri.of_string url in 290 match Git.clone ~proc ~fs ~url ~branch path with 291 | Error e -> Error e 292 | Ok () -> Ok true 293 end 294 295let pull ~proc ~fs ~config ?handle () = 296 (* Load registry to get all members *) 297 match Verse_registry.clone_or_pull ~proc ~fs ~config () with 298 | Error msg -> Error (Registry_error msg) 299 | Ok registry -> 300 let members = 301 match handle with 302 | Some h -> ( 303 match Verse_registry.find_member registry ~handle:h with 304 | Some m -> [ m ] 305 | None -> []) 306 | None -> registry.members 307 in 308 if members = [] && handle <> None then 309 Error (Member_not_found (Option.get handle)) 310 else begin 311 let verse_dir = Verse_config.verse_path config in 312 ensure_dir ~fs verse_dir; 313 Logs.info (fun m -> m "Syncing %d members" (List.length members)); 314 let errors = 315 List.filter_map 316 (fun (member : Verse_registry.member) -> 317 let h = member.handle in 318 let mono_path = Fpath.(verse_dir / h) in 319 let opam_path = Fpath.(verse_dir / (h ^ "-opam")) in 320 (* Clone or fetch+reset monorepo *) 321 Logs.info (fun m -> m "Syncing %s monorepo" h); 322 let mono_branch = 323 Option.value ~default:Verse_config.default_branch member.monorepo_branch 324 in 325 let mono_result = 326 clone_or_reset_repo ~proc ~fs ~url:member.monorepo 327 ~branch:mono_branch mono_path 328 in 329 let mono_err = 330 match mono_result with 331 | Ok true -> 332 Logs.info (fun m -> m " Cloned %s monorepo" h); 333 None 334 | Ok false -> 335 Logs.info (fun m -> m " Reset %s monorepo" h); 336 None 337 | Error e -> 338 Logs.warn (fun m -> 339 m " Failed %s monorepo: %a" h Git.pp_error e); 340 Some (Fmt.str "%s monorepo: %a" h Git.pp_error e) 341 in 342 (* Clone or fetch+reset opam repo *) 343 Logs.info (fun m -> m "Syncing %s opam repo" h); 344 let opam_branch = 345 Option.value ~default:Verse_config.default_branch member.opamrepo_branch 346 in 347 let opam_result = 348 clone_or_reset_repo ~proc ~fs ~url:member.opamrepo 349 ~branch:opam_branch opam_path 350 in 351 let opam_err = 352 match opam_result with 353 | Ok true -> 354 Logs.info (fun m -> m " Cloned %s opam repo" h); 355 None 356 | Ok false -> 357 Logs.info (fun m -> m " Reset %s opam repo" h); 358 None 359 | Error e -> 360 Logs.warn (fun m -> 361 m " Failed %s opam repo: %a" h Git.pp_error e); 362 Some (Fmt.str "%s opam: %a" h Git.pp_error e) 363 in 364 match (mono_err, opam_err) with 365 | None, None -> None 366 | Some e, None | None, Some e -> Some e 367 | Some e1, Some e2 -> Some (e1 ^ "; " ^ e2)) 368 members 369 in 370 if errors = [] then Ok () 371 else Error (Git_error (Git.Io_error (String.concat "; " errors))) 372 end 373 374let sync ~proc ~fs ~config () = 375 (* pull already updates registry and syncs all members *) 376 pull ~proc ~fs ~config () 377 378(** Scan a monorepo for subtree directories. Returns a list of directory names 379 that look like subtrees (have commits). *) 380let scan_subtrees ~proc ~fs monorepo_path = 381 if not (Git.is_repo ~proc ~fs monorepo_path) then [] 382 else 383 let eio_path = Eio.Path.(fs / Fpath.to_string monorepo_path) in 384 try 385 Eio.Path.read_dir eio_path 386 |> List.filter (fun name -> 387 (* Skip hidden dirs and common non-subtree dirs *) 388 (not (String.starts_with ~prefix:"." name)) 389 && name <> "_build" && name <> "node_modules" 390 && is_directory ~fs Fpath.(monorepo_path / name)) 391 with Eio.Io _ -> [] 392 393(** Get subtrees from all tracked verse members. Returns a map from subtree name 394 to list of (handle, monorepo_path) pairs. *) 395let get_verse_subtrees ~proc ~fs ~config () = 396 let verse_path = Verse_config.verse_path config in 397 let tracked_handles = get_tracked_handles ~fs config in 398 (* Build map: subtree_name -> [(handle, monorepo_path)] *) 399 let subtree_map = Hashtbl.create 64 in 400 List.iter 401 (fun handle -> 402 let member_mono = Fpath.(verse_path / handle) in 403 if Git.is_repo ~proc ~fs member_mono then begin 404 let subtrees = scan_subtrees ~proc ~fs member_mono in 405 List.iter 406 (fun subtree -> 407 let existing = 408 try Hashtbl.find subtree_map subtree with Not_found -> [] 409 in 410 Hashtbl.replace subtree_map subtree 411 ((handle, member_mono) :: existing)) 412 subtrees 413 end) 414 tracked_handles; 415 subtree_map 416 417(** Result of a fork operation. *) 418type fork_result = { 419 packages_forked : string list; (** Package names that were forked *) 420 source_handle : string; (** Handle of the verse member we forked from *) 421 fork_url : string; (** URL of the fork *) 422 upstream_url : string; (** Original dev-repo URL (upstream) *) 423 subtree_name : string; (** Name for the subtree directory (derived from fork URL) *) 424} 425 426(** Extract subtree name from a URL (last path component without .git suffix) *) 427let subtree_name_from_url url = 428 let uri = Uri.of_string url in 429 let path = Uri.path uri in 430 (* Remove leading slash and .git suffix *) 431 let path = if String.length path > 0 && path.[0] = '/' then 432 String.sub path 1 (String.length path - 1) 433 else path in 434 let path = if String.ends_with ~suffix:".git" path then 435 String.sub path 0 (String.length path - 4) 436 else path in 437 (* Get last component *) 438 match String.rindex_opt path '/' with 439 | Some i -> String.sub path (i + 1) (String.length path - i - 1) 440 | None -> path 441 442let pp_fork_result ppf r = 443 Fmt.pf ppf "@[<v>Forked %d package(s) from %s:@, @[<v>%a@]@,Fork URL: %s@,Upstream: %s@,Subtree: %s@]" 444 (List.length r.packages_forked) 445 r.source_handle 446 Fmt.(list ~sep:cut string) r.packages_forked 447 r.fork_url 448 r.upstream_url 449 r.subtree_name 450 451(** Fork a package from a verse member's opam repo into your workspace. 452 453 This looks up the package in the member's opam-repo (verse/<handle>-opam/), 454 finds all packages sharing the same dev-repo, and creates entries in your 455 opam-repo with the fork URL as the dev-repo. 456 457 @param proc Eio process manager 458 @param fs Eio filesystem 459 @param config Verse configuration 460 @param handle Verse member handle to fork from 461 @param package Package name to fork 462 @param fork_url Git URL of your fork 463 @param dry_run If true, show what would be done without making changes *) 464let fork ~proc ~fs ~config ~handle ~package ~fork_url ?(dry_run = false) () = 465 (* Ensure the member exists and their opam-repo is synced *) 466 match Verse_registry.clone_or_pull ~proc ~fs ~config () with 467 | Error msg -> Error (Registry_error msg) 468 | Ok registry -> 469 match Verse_registry.find_member registry ~handle with 470 | None -> Error (Member_not_found handle) 471 | Some _member -> 472 let verse_path = Verse_config.verse_path config in 473 let member_opam_repo = Fpath.(verse_path / (handle ^ "-opam")) in 474 (* Check if their opam repo exists locally *) 475 if not (is_directory ~fs member_opam_repo) then 476 Error (Config_error (Fmt.str "Member's opam repo not synced. Run: monopam verse pull %s" handle)) 477 else 478 (* Scan their opam repo to find the package *) 479 let pkgs, _errors = Opam_repo.scan_all ~fs member_opam_repo in 480 (* Find the requested package *) 481 match List.find_opt (fun p -> Package.name p = package) pkgs with 482 | None -> Error (Package_not_found (package, handle)) 483 | Some pkg -> 484 (* Find all packages from the same dev-repo *) 485 let related_pkgs = 486 List.filter (fun p -> Package.same_repo p pkg) pkgs 487 in 488 let pkg_names = List.map Package.name related_pkgs in 489 (* Get upstream URL and subtree name *) 490 let upstream_url = Uri.to_string (Package.dev_repo pkg) in 491 let subtree_name = subtree_name_from_url fork_url in 492 (* Check for conflicts in user's opam-repo *) 493 let user_opam_repo = Verse_config.opam_repo_path config in 494 let conflicts = 495 List.filter 496 (fun name -> Opam_repo.package_exists ~fs ~repo_path:user_opam_repo ~name) 497 pkg_names 498 in 499 if conflicts <> [] then 500 Error (Package_already_exists conflicts) 501 else if dry_run then 502 (* Dry run - just report what would be done *) 503 Ok { packages_forked = pkg_names; source_handle = handle; fork_url; upstream_url; subtree_name } 504 else begin 505 (* Fork each package *) 506 let results = 507 List.map 508 (fun p -> 509 let name = Package.name p in 510 let version = Package.version p in 511 let opam_path = 512 Fpath.(member_opam_repo / "packages" / name / (name ^ "." ^ version) / "opam") 513 in 514 match Opam_repo.read_opam_file ~fs opam_path with 515 | Error e -> Error (Opam_repo_error e) 516 | Ok content -> 517 (* Replace dev-repo and url with fork URL *) 518 let new_content = Opam_repo.replace_dev_repo_url content ~new_url:fork_url in 519 (* Write to user's opam-repo *) 520 match Opam_repo.write_package ~fs ~repo_path:user_opam_repo ~name ~version ~content:new_content with 521 | Error e -> Error (Opam_repo_error e) 522 | Ok () -> Ok name) 523 related_pkgs 524 in 525 (* Check for errors *) 526 match List.find_opt Result.is_error results with 527 | Some (Error e) -> Error e 528 | _ -> 529 let forked_names = List.filter_map (function Ok n -> Some n | Error _ -> None) results in 530 Ok { packages_forked = forked_names; source_handle = handle; fork_url; upstream_url; subtree_name } 531 end