Monorepo management for opam overlays

Add monopam verse fork command for forking packages from verse members

New command: monopam verse fork <package> --from <handle> --url <fork-url>

This allows users to fork a package from a verse member's repository:
- Looks up the package in verse/<handle>-opam/
- Finds all packages sharing the same git repository
- Creates entries in user's opam-repo with fork URL as dev-repo
- Supports --dry-run to preview what would be forked
- Detects conflicts if packages already exist in user's opam-repo

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+363
+90
bin/main.ml
··· 608 608 in 609 609 Cmd.v info Term.(ret (const run $ logging_term)) 610 610 611 + let verse_fork_cmd = 612 + let doc = "Fork a package from a verse member's repository" in 613 + let man = 614 + [ 615 + `S Manpage.s_description; 616 + `P 617 + "Fork a package from a verse member's opam repository into your workspace. \ 618 + This creates entries in your opam-repo with your fork URL as the dev-repo."; 619 + `P 620 + "The command finds all packages sharing the same git repository and forks \ 621 + them together. For example, if you fork 'cohttp', it will also fork \ 622 + cohttp-eio, cohttp-lwt, etc."; 623 + `S "WHAT IT DOES"; 624 + `P "For the specified package:"; 625 + `I ("1.", "Looks up the package in <handle>'s opam-repo (verse/<handle>-opam/)"); 626 + `I ("2.", "Finds all packages from the same git repository"); 627 + `I ("3.", "Creates entries in your opam-repo with your fork URL"); 628 + `P "After forking:"; 629 + `I ("1.", "Commit the new opam files: $(b,cd opam-repo && git add -A && git commit)"); 630 + `I ("2.", "Run $(b,monopam sync) to pull the fork into your monorepo"); 631 + `S "PREREQUISITES"; 632 + `P "Before forking:"; 633 + `I ("-", "Run $(b,monopam verse pull <handle>) to sync the member's opam-repo"); 634 + `I ("-", "Create a fork of the repository on GitHub/GitLab/etc."); 635 + `S Manpage.s_examples; 636 + `P "Fork a package from a verse member:"; 637 + `Pre "monopam fork http2 --from sadiq.bsky.social --url git@github.com:me/http2.git"; 638 + `P "Preview what would be forked (multi-package repos):"; 639 + `Pre "monopam fork cohttp --from avsm.bsky.social --url git@github.com:me/cohttp.git --dry-run\n\ 640 + Would fork 5 packages from cohttp repository:\n\ 641 + \ cohttp\n\ 642 + \ cohttp-eio\n\ 643 + \ cohttp-lwt\n\ 644 + \ cohttp-async\n\ 645 + \ cohttp-mirage"; 646 + `P "After forking, commit and sync:"; 647 + `Pre "cd opam-repo && git add -A && git commit -m \"Fork cohttp\"\n\ 648 + monopam sync"; 649 + `S "ERRORS"; 650 + `P 651 + "The command will fail if any package from the source repo already exists \ 652 + in your opam-repo. Remove conflicting packages first with:"; 653 + `Pre "rm -rf opam-repo/packages/<package-name>"; 654 + ] 655 + in 656 + let info = Cmd.info "fork" ~doc ~man in 657 + let package_arg = 658 + let doc = "Package name to fork (e.g., 'cohttp', 'eio')" in 659 + Arg.(required & pos 0 (some string) None & info [] ~docv:"PACKAGE" ~doc) 660 + in 661 + let from_arg = 662 + let doc = "Verse member handle to fork from (e.g., 'avsm.bsky.social')" in 663 + Arg.(required & opt (some string) None & info [ "from" ] ~docv:"HANDLE" ~doc) 664 + in 665 + let url_arg = 666 + let doc = "Git URL of your fork (e.g., 'git@github.com:you/repo.git')" in 667 + Arg.(required & opt (some string) None & info [ "url" ] ~docv:"URL" ~doc) 668 + in 669 + let dry_run_arg = 670 + let doc = "Show what would be forked without making changes" in 671 + Arg.(value & flag & info [ "dry-run"; "n" ] ~doc) 672 + in 673 + let run package handle fork_url dry_run () = 674 + Eio_main.run @@ fun env -> 675 + with_verse_config env @@ fun config -> 676 + let fs = Eio.Stdenv.fs env in 677 + let proc = Eio.Stdenv.process_mgr env in 678 + match Monopam.Verse.fork ~proc ~fs ~config ~handle ~package ~fork_url ~dry_run () with 679 + | Ok result -> 680 + if dry_run then begin 681 + Fmt.pr "Would fork %d package(s) from %s:@." 682 + (List.length result.packages_forked) result.source_handle; 683 + List.iter (fun p -> Fmt.pr " %s@." p) result.packages_forked 684 + end else begin 685 + Fmt.pr "Forked %d package(s): %a@." 686 + (List.length result.packages_forked) 687 + Fmt.(list ~sep:(any ", ") string) result.packages_forked; 688 + Fmt.pr "@.Next steps:@."; 689 + Fmt.pr " 1. cd opam-repo && git add -A && git commit -m \"Fork ...\"@."; 690 + Fmt.pr " 2. monopam sync@." 691 + end; 692 + `Ok () 693 + | Error e -> 694 + Fmt.epr "Error: %a@." Monopam.Verse.pp_error_with_hint e; 695 + `Error (false, "fork failed") 696 + in 697 + Cmd.v info Term.(ret (const run $ package_arg $ from_arg $ url_arg $ dry_run_arg $ logging_term)) 698 + 611 699 let verse_cmd = 612 700 let doc = "Federated monorepo collaboration" in 613 701 let man = ··· 700 788 `I ("members", "List all members in the registry"); 701 789 `I ("pull [<handle>]", "Clone/pull all members (or specific member)"); 702 790 `I ("sync", "Update registry and pull all members"); 791 + `I ("fork <pkg> --from <handle> --url <url>", "Fork a package from a verse member"); 703 792 `S "AUTHENTICATION"; 704 793 `P 705 794 "Handle validation uses the AT Protocol identity system. The tangled \ ··· 715 804 verse_members_cmd; 716 805 verse_pull_cmd; 717 806 verse_sync_cmd; 807 + verse_fork_cmd; 718 808 ] 719 809 720 810 (* Doctor command *)
+98
lib/opam_repo.ml
··· 185 185 with _ -> []) 186 186 opam_files 187 187 with Eio.Io _ -> [] 188 + 189 + (** Read the raw content of an opam file. *) 190 + let read_opam_file ~fs opam_file_path = 191 + let eio_path = Eio.Path.(fs / Fpath.to_string opam_file_path) in 192 + try Ok (Eio.Path.load eio_path) with Eio.Io _ as e -> Error (Io_error (Printexc.to_string e)) 193 + 194 + (** Replace dev-repo line in content. Looks for 'dev-repo: "..."' and replaces the URL. *) 195 + let replace_dev_repo_line content ~new_url = 196 + let lines = String.split_on_char '\n' content in 197 + let dev_repo_url = 198 + if String.starts_with ~prefix:"git@" new_url then "git+" ^ new_url 199 + else if String.starts_with ~prefix:"https://" new_url then "git+" ^ new_url 200 + else if String.starts_with ~prefix:"git+" new_url then new_url 201 + else "git+" ^ new_url 202 + in 203 + let lines = 204 + List.map 205 + (fun line -> 206 + let trimmed = String.trim line in 207 + if String.starts_with ~prefix:"dev-repo:" trimmed then 208 + Printf.sprintf {|dev-repo: "%s"|} dev_repo_url 209 + else line) 210 + lines 211 + in 212 + String.concat "\n" lines 213 + 214 + (** Replace url { src: "..." } section in content. *) 215 + let replace_url_section content ~new_url = 216 + let url_src = 217 + let base = 218 + if String.starts_with ~prefix:"git@" new_url then "git+" ^ new_url 219 + else if String.starts_with ~prefix:"https://" new_url then "git+" ^ new_url 220 + else if String.starts_with ~prefix:"git+" new_url then new_url 221 + else "git+" ^ new_url 222 + in 223 + base ^ "#main" 224 + in 225 + (* Simple state machine to find and replace url { src: "..." } block *) 226 + let lines = String.split_on_char '\n' content in 227 + let rec process lines in_url_block acc = 228 + match lines with 229 + | [] -> List.rev acc 230 + | line :: rest -> 231 + let trimmed = String.trim line in 232 + if in_url_block then 233 + (* We're inside url { ... }, skip until we see } *) 234 + if String.starts_with ~prefix:"}" trimmed then 235 + (* End of url block - output our replacement *) 236 + let replacement = 237 + [ "url {"; Printf.sprintf {| src: "%s"|} url_src; "}" ] 238 + in 239 + process rest false (List.rev_append replacement acc) 240 + else 241 + (* Skip this line, it's part of the old url block *) 242 + process rest true acc 243 + else if trimmed = "url {" || String.starts_with ~prefix:"url {" trimmed then 244 + (* Start of url block *) 245 + if String.ends_with ~suffix:"}" trimmed then 246 + (* Single-line url block *) 247 + let replacement = 248 + [ "url {"; Printf.sprintf {| src: "%s"|} url_src; "}" ] 249 + in 250 + process rest false (List.rev_append replacement acc) 251 + else process rest true acc 252 + else process rest false (line :: acc) 253 + in 254 + String.concat "\n" (process lines false []) 255 + 256 + (** Replace the dev-repo and url fields in an opam file content with a new git URL. 257 + The new URL should be a plain git URL (e.g., "git@github.com:user/repo.git"). *) 258 + let replace_dev_repo_url content ~new_url = 259 + let content = replace_dev_repo_line content ~new_url in 260 + let content = replace_url_section content ~new_url in 261 + content 262 + 263 + (** Write an opam package to the opam-repo overlay. 264 + Creates the directory structure: packages/<name>/<name.version>/opam *) 265 + let write_package ~fs ~repo_path ~name ~version ~content = 266 + let pkg_dir = Fpath.(repo_path / "packages" / name / (name ^ "." ^ version)) in 267 + let opam_path = Fpath.(pkg_dir / "opam") in 268 + let eio_pkg_dir = Eio.Path.(fs / Fpath.to_string pkg_dir) in 269 + let eio_opam_path = Eio.Path.(fs / Fpath.to_string opam_path) in 270 + try 271 + (* Create directory structure *) 272 + Eio.Path.mkdirs ~perm:0o755 eio_pkg_dir; 273 + (* Write opam file *) 274 + Eio.Path.save ~create:(`Or_truncate 0o644) eio_opam_path content; 275 + Ok () 276 + with Eio.Io _ as e -> Error (Io_error (Printexc.to_string e)) 277 + 278 + (** Check if a package exists in the opam-repo. *) 279 + let package_exists ~fs ~repo_path ~name = 280 + let pkg_dir = Fpath.(repo_path / "packages" / name) in 281 + let eio_path = Eio.Path.(fs / Fpath.to_string pkg_dir) in 282 + match Eio.Path.kind ~follow:true eio_path with 283 + | `Directory -> true 284 + | _ -> false 285 + | exception _ -> false
+25
lib/opam_repo.mli
··· 91 91 92 92 val find_dev_repo : OpamParserTypes.FullPos.opamfile_item list -> string option 93 93 (** [find_dev_repo items] extracts the dev-repo field from parsed opam file items. *) 94 + 95 + (** {1 Writing Packages} *) 96 + 97 + val read_opam_file : fs:_ Eio.Path.t -> Fpath.t -> (string, error) result 98 + (** [read_opam_file ~fs path] reads the raw content of an opam file. *) 99 + 100 + val replace_dev_repo_url : string -> new_url:string -> string 101 + (** [replace_dev_repo_url content ~new_url] replaces the dev-repo and url fields 102 + in an opam file content with a new git URL. The new URL should be a plain 103 + git URL (e.g., "git@github.com:user/repo.git" or "https://github.com/user/repo.git"). *) 104 + 105 + val write_package : 106 + fs:_ Eio.Path.t -> 107 + repo_path:Fpath.t -> 108 + name:string -> 109 + version:string -> 110 + content:string -> 111 + (unit, error) result 112 + (** [write_package ~fs ~repo_path ~name ~version ~content] writes an opam package 113 + to the opam-repo overlay. 114 + 115 + Creates the directory structure: [packages/<name>/<name.version>/opam] *) 116 + 117 + val package_exists : fs:_ Eio.Path.t -> repo_path:Fpath.t -> name:string -> bool 118 + (** [package_exists ~fs ~repo_path ~name] checks if a package exists in the opam-repo. *)
+108
lib/verse.ml
··· 5 5 | Member_not_found of string 6 6 | Workspace_exists of Fpath.t 7 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 8 11 9 12 let pp_error ppf = function 10 13 | Config_error msg -> Fmt.pf ppf "Configuration error: %s" msg ··· 13 16 | Member_not_found h -> Fmt.pf ppf "Member not in registry: %s" h 14 17 | Workspace_exists p -> Fmt.pf ppf "Workspace already exists: %a" Fpath.pp p 15 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 16 25 17 26 let error_hint = function 18 27 | Config_error _ -> ··· 32 41 Some "Use a different directory, or remove the existing workspace." 33 42 | Not_a_workspace _ -> 34 43 Some "Run 'monopam verse init --handle <your-handle>' to create a workspace here." 44 + | Package_not_found (pkg, handle) -> 45 + Some (Fmt.str "Run 'monopam verse pull %s' to sync their opam repo, then check package name: %s" handle pkg) 46 + | Package_already_exists pkgs -> 47 + Some (Fmt.str "Remove conflicting packages first:\n %s" 48 + (String.concat "\n " (List.map (fun p -> "rm -rf opam-repo/packages/" ^ p) pkgs))) 49 + | Opam_repo_error _ -> None 35 50 36 51 let pp_error_with_hint ppf e = 37 52 pp_error ppf e; ··· 359 374 end) 360 375 tracked_handles; 361 376 subtree_map 377 + 378 + (** Result of a fork operation. *) 379 + type fork_result = { 380 + packages_forked : string list; (** Package names that were forked *) 381 + source_handle : string; (** Handle of the verse member we forked from *) 382 + fork_url : string; (** URL of the fork *) 383 + } 384 + 385 + let pp_fork_result ppf r = 386 + Fmt.pf ppf "@[<v>Forked %d package(s) from %s:@, @[<v>%a@]@,Fork URL: %s@]" 387 + (List.length r.packages_forked) 388 + r.source_handle 389 + Fmt.(list ~sep:cut string) r.packages_forked 390 + r.fork_url 391 + 392 + (** Fork a package from a verse member's opam repo into your workspace. 393 + 394 + This looks up the package in the member's opam-repo (verse/<handle>-opam/), 395 + finds all packages sharing the same dev-repo, and creates entries in your 396 + opam-repo with the fork URL as the dev-repo. 397 + 398 + @param proc Eio process manager 399 + @param fs Eio filesystem 400 + @param config Verse configuration 401 + @param handle Verse member handle to fork from 402 + @param package Package name to fork 403 + @param fork_url Git URL of your fork 404 + @param dry_run If true, show what would be done without making changes *) 405 + let fork ~proc ~fs ~config ~handle ~package ~fork_url ?(dry_run = false) () = 406 + (* Ensure the member exists and their opam-repo is synced *) 407 + match Verse_registry.clone_or_pull ~proc ~fs ~config () with 408 + | Error msg -> Error (Registry_error msg) 409 + | Ok registry -> 410 + match Verse_registry.find_member registry ~handle with 411 + | None -> Error (Member_not_found handle) 412 + | Some _member -> 413 + let verse_path = Verse_config.verse_path config in 414 + let member_opam_repo = Fpath.(verse_path / (handle ^ "-opam")) in 415 + (* Check if their opam repo exists locally *) 416 + if not (is_directory ~fs member_opam_repo) then 417 + Error (Config_error (Fmt.str "Member's opam repo not synced. Run: monopam verse pull %s" handle)) 418 + else 419 + (* Scan their opam repo to find the package *) 420 + let pkgs, _errors = Opam_repo.scan_all ~fs member_opam_repo in 421 + (* Find the requested package *) 422 + match List.find_opt (fun p -> Package.name p = package) pkgs with 423 + | None -> Error (Package_not_found (package, handle)) 424 + | Some pkg -> 425 + (* Find all packages from the same dev-repo *) 426 + let related_pkgs = 427 + List.filter (fun p -> Package.same_repo p pkg) pkgs 428 + in 429 + let pkg_names = List.map Package.name related_pkgs in 430 + (* Check for conflicts in user's opam-repo *) 431 + let user_opam_repo = Verse_config.opam_repo_path config in 432 + let conflicts = 433 + List.filter 434 + (fun name -> Opam_repo.package_exists ~fs ~repo_path:user_opam_repo ~name) 435 + pkg_names 436 + in 437 + if conflicts <> [] then 438 + Error (Package_already_exists conflicts) 439 + else if dry_run then 440 + (* Dry run - just report what would be done *) 441 + Ok { packages_forked = pkg_names; source_handle = handle; fork_url } 442 + else begin 443 + (* Fork each package *) 444 + let results = 445 + List.map 446 + (fun p -> 447 + let name = Package.name p in 448 + let version = Package.version p in 449 + let opam_path = 450 + Fpath.(member_opam_repo / "packages" / name / (name ^ "." ^ version) / "opam") 451 + in 452 + match Opam_repo.read_opam_file ~fs opam_path with 453 + | Error e -> Error (Opam_repo_error e) 454 + | Ok content -> 455 + (* Replace dev-repo and url with fork URL *) 456 + let new_content = Opam_repo.replace_dev_repo_url content ~new_url:fork_url in 457 + (* Write to user's opam-repo *) 458 + match Opam_repo.write_package ~fs ~repo_path:user_opam_repo ~name ~version ~content:new_content with 459 + | Error e -> Error (Opam_repo_error e) 460 + | Ok () -> Ok name) 461 + related_pkgs 462 + in 463 + (* Check for errors *) 464 + match List.find_opt Result.is_error results with 465 + | Some (Error e) -> Error e 466 + | _ -> 467 + let forked_names = List.filter_map (function Ok n -> Some n | Error _ -> None) results in 468 + Ok { packages_forked = forked_names; source_handle = handle; fork_url } 469 + end
+42
lib/verse.mli
··· 12 12 | Member_not_found of string (** Handle not in registry *) 13 13 | Workspace_exists of Fpath.t (** Workspace already initialized *) 14 14 | Not_a_workspace of Fpath.t (** Not a opamverse workspace *) 15 + | Package_not_found of string * string (** Package not found in member's repo: (package, handle) *) 16 + | Package_already_exists of string list (** Packages already exist in user's opam repo *) 17 + | Opam_repo_error of Opam_repo.error (** Error reading/writing opam files *) 15 18 16 19 val pp_error : error Fmt.t 17 20 (** [pp_error] formats errors. *) ··· 140 143 and returns a map from subtree name to list of (handle, monorepo_path) pairs. 141 144 142 145 This allows finding which verse users have a particular repo. *) 146 + 147 + (** {1 Forking} *) 148 + 149 + (** Result of a fork operation. *) 150 + type fork_result = { 151 + packages_forked : string list; (** Package names that were forked *) 152 + source_handle : string; (** Handle of the verse member we forked from *) 153 + fork_url : string; (** URL of the fork *) 154 + } 155 + 156 + val pp_fork_result : fork_result Fmt.t 157 + (** [pp_fork_result] formats a fork result. *) 158 + 159 + val fork : 160 + proc:_ Eio.Process.mgr -> 161 + fs:Eio.Fs.dir_ty Eio.Path.t -> 162 + config:Verse_config.t -> 163 + handle:string -> 164 + package:string -> 165 + fork_url:string -> 166 + ?dry_run:bool -> 167 + unit -> 168 + (fork_result, error) result 169 + (** [fork ~proc ~fs ~config ~handle ~package ~fork_url ?dry_run ()] forks a 170 + package from a verse member's opam repo into your workspace. 171 + 172 + This looks up the package in the member's opam-repo (verse/<handle>-opam/), 173 + finds all packages sharing the same dev-repo, and creates entries in your 174 + opam-repo with the fork URL as the dev-repo. 175 + 176 + After forking, run [monopam sync] to pull the fork into your monorepo. 177 + 178 + @param proc Eio process manager 179 + @param fs Eio filesystem 180 + @param config Verse configuration 181 + @param handle Verse member handle to fork from 182 + @param package Package name to fork 183 + @param fork_url Git URL of your fork 184 + @param dry_run If true, show what would be done without making changes *)