type error = | Config_error of string | Git_error of Git.error | Registry_error of string | Member_not_found of string | Workspace_exists of Fpath.t | Not_a_workspace of Fpath.t | Package_not_found of string * string (** (package, handle) *) | Package_already_exists of string list (** List of conflicting package names *) | Opam_repo_error of Opam_repo.error let pp_error ppf = function | Config_error msg -> Fmt.pf ppf "Configuration error: %s" msg | Git_error e -> Fmt.pf ppf "Git error: %a" Git.pp_error e | Registry_error msg -> Fmt.pf ppf "Registry error: %s" msg | Member_not_found h -> Fmt.pf ppf "Member not in registry: %s" h | Workspace_exists p -> Fmt.pf ppf "Workspace already exists: %a" Fpath.pp p | Not_a_workspace p -> Fmt.pf ppf "Not a opamverse workspace: %a" Fpath.pp p | Package_not_found (pkg, handle) -> Fmt.pf ppf "Package %s not found in %s's opam repo" pkg handle | Package_already_exists pkgs -> Fmt.pf ppf "Packages already exist in your opam repo: %a" Fmt.(list ~sep:comma string) pkgs | Opam_repo_error e -> Fmt.pf ppf "Opam repo error: %a" Opam_repo.pp_error e let error_hint = function | Config_error _ -> Some "Run 'monopam init --handle ' to create a workspace." | Git_error (Git.Dirty_worktree _) -> Some "Commit or stash your changes first: git status" | Git_error (Git.Command_failed (cmd, _)) when String.starts_with ~prefix:"git clone" cmd -> Some "Check the URL is correct and you have network access." | Git_error (Git.Command_failed (cmd, _)) when String.starts_with ~prefix:"git pull" cmd -> Some "Check your network connection. Try: git fetch origin" | Git_error _ -> None | Registry_error _ -> Some "The registry may be temporarily unavailable. Try again later." | Member_not_found h -> Some (Fmt.str "Check available members: monopam verse members (looking for '%s')" h) | Workspace_exists _ -> Some "Use a different directory, or remove the existing workspace." | Not_a_workspace _ -> Some "Run 'monopam init --handle ' to create a workspace here." | Package_not_found (pkg, handle) -> Some (Fmt.str "Run 'monopam verse pull %s' to sync their opam repo, then check package name: %s" handle pkg) | Package_already_exists pkgs -> Some (Fmt.str "Remove conflicting packages first:\n %s" (String.concat "\n " (List.map (fun p -> "rm -rf opam-repo/packages/" ^ p) pkgs))) | Opam_repo_error _ -> None let pp_error_with_hint ppf e = pp_error ppf e; match error_hint e with | Some hint -> Fmt.pf ppf "@.@[Hint: %s@]" hint | None -> () type member_status = { handle : string; monorepo_url : string; local_path : Fpath.t; cloned : bool; clean : bool option; ahead_behind : Git.ahead_behind option; } type status = { config : Verse_config.t; registry : Verse_registry.t; tracked_members : member_status list; } let pp_member_status ppf m = let status = if not m.cloned then "not cloned" else match (m.clean, m.ahead_behind) with | Some false, _ -> "dirty" | Some true, Some ab when ab.ahead > 0 || ab.behind > 0 -> Fmt.str "ahead %d, behind %d" ab.ahead ab.behind | Some true, _ -> "clean" | None, _ -> "unknown" in Fmt.pf ppf "@[%s@ (%s)@ [%s]@]" m.handle m.monorepo_url status let pp_status ppf s = Fmt.pf ppf "@[Workspace: %a@,Registry: %s@,Members:@, @[%a@]@]" Fpath.pp (Verse_config.root s.config) s.registry.name Fmt.(list ~sep:cut pp_member_status) s.tracked_members (* Helper to check if a path is a directory *) let is_directory ~fs path = let eio_path = Eio.Path.(fs / Fpath.to_string path) in match Eio.Path.kind ~follow:true eio_path with | `Directory -> true | _ -> false | exception _ -> false (* Helper to check if a path is a regular file *) let is_file ~fs path = let eio_path = Eio.Path.(fs / Fpath.to_string path) in match Eio.Path.kind ~follow:true eio_path with | `Regular_file -> true | _ -> false | exception _ -> false (* Helper to create a directory if it doesn't exist *) let ensure_dir ~fs path = let eio_path = Eio.Path.(fs / Fpath.to_string path) in try Eio.Path.mkdirs ~perm:0o755 eio_path with Eio.Io _ -> () (* Get list of tracked members by looking at verse/ directory *) let get_tracked_handles ~fs config = let verse_path = Verse_config.verse_path config in if not (is_directory ~fs verse_path) then [] else let eio_path = Eio.Path.(fs / Fpath.to_string verse_path) in try Eio.Path.read_dir eio_path |> List.filter (fun name -> is_directory ~fs Fpath.(verse_path / name)) with Eio.Io _ -> [] let init ~proc ~fs ~root ~handle () = (* Check if config already exists in XDG *) let config_file = Verse_config.config_file () in Logs.info (fun m -> m "Config file: %a" Fpath.pp config_file); if is_file ~fs config_file then begin Logs.err (fun m -> m "Config already exists at %a" Fpath.pp config_file); Error (Workspace_exists root) end else (* Resolve root to absolute path *) let root = if Fpath.is_abs root then root else (* Get absolute path via realpath *) let root_str = Fpath.to_string root in let eio_path = Eio.Path.(fs / root_str) in (* Ensure the directory exists first so realpath works *) (try Eio.Path.mkdirs ~perm:0o755 eio_path with Eio.Io _ -> ()); match Unix.realpath root_str with | abs_str -> ( match Fpath.of_string abs_str with Ok p -> p | Error _ -> root) | exception _ -> root in Logs.info (fun m -> m "Workspace root: %a" Fpath.pp root); (* Create config - need this temporarily to get paths *) let config = Verse_config.create ~root ~handle () in (* Clone registry first to look up user's repos *) Logs.info (fun m -> m "Cloning registry..."); match Verse_registry.clone_or_pull ~proc ~fs ~config () with | Error msg -> Logs.err (fun m -> m "Registry clone failed: %s" msg); Error (Registry_error msg) | Ok registry -> ( Logs.info (fun m -> m "Registry loaded"); (* Look up user in registry - this validates the handle *) match Verse_registry.find_member registry ~handle with | None -> Logs.err (fun m -> m "Handle %s not found in registry" handle); Error (Member_not_found handle) | Some member -> ( Logs.info (fun m -> m "Found member: mono=%s opam=%s" member.monorepo member.opamrepo); (* Create workspace directories *) Logs.info (fun m -> m "Creating workspace directories..."); ensure_dir ~fs root; ensure_dir ~fs (Verse_config.src_path config); ensure_dir ~fs (Verse_config.verse_path config); (* Clone user's monorepo *) let mono_path = Verse_config.mono_path config in Logs.info (fun m -> m "Cloning monorepo to %a" Fpath.pp mono_path); let mono_url = Uri.of_string member.monorepo in match Git.clone ~proc ~fs ~url:mono_url ~branch:Verse_config.default_branch mono_path with | Error e -> Logs.err (fun m -> m "Monorepo clone failed: %a" Git.pp_error e); Error (Git_error e) | Ok () -> ( Logs.info (fun m -> m "Monorepo cloned"); (* Clone user's opam repo *) let opam_path = Verse_config.opam_repo_path config in Logs.info (fun m -> m "Cloning opam repo to %a" Fpath.pp opam_path); let opam_url = Uri.of_string member.opamrepo in match Git.clone ~proc ~fs ~url:opam_url ~branch:Verse_config.default_branch opam_path with | Error e -> Logs.err (fun m -> m "Opam repo clone failed: %a" Git.pp_error e); Error (Git_error e) | Ok () -> ( Logs.info (fun m -> m "Opam repo cloned"); (* Save config to XDG *) Logs.info (fun m -> m "Saving config to %a" Fpath.pp config_file); match Verse_config.save ~fs config with | Error msg -> Logs.err (fun m -> m "Failed to save config: %s" msg); Error (Config_error msg) | Ok () -> Logs.info (fun m -> m "Workspace initialized successfully"); Ok ())))) let status ~proc ~fs ~config () = (* Load registry *) match Verse_registry.clone_or_pull ~proc ~fs ~config () with | Error msg -> Error (Registry_error msg) | Ok registry -> (* Get tracked handles *) let tracked_handles = get_tracked_handles ~fs config in (* Build status for each tracked member *) let tracked_members = List.filter_map (fun handle -> (* Find member in registry *) match Verse_registry.find_member registry ~handle with | None -> (* Member not in registry but locally tracked - show anyway *) let local_path = Fpath.(Verse_config.verse_path config / handle) in let cloned = is_directory ~fs local_path in Some { handle; monorepo_url = "(not in registry)"; local_path; cloned; clean = None; ahead_behind = None; } | Some member -> let local_path = Fpath.(Verse_config.verse_path config / handle) in let cloned = Git.is_repo ~proc ~fs local_path in let clean = if cloned then Some (not (Git.is_dirty ~proc ~fs local_path)) else None in let ahead_behind = if cloned then match Git.ahead_behind ~proc ~fs local_path with | Ok ab -> Some ab | Error _ -> None else None in Some { handle; monorepo_url = member.monorepo; local_path; cloned; clean; ahead_behind; }) tracked_handles in Ok { config; registry; tracked_members } let members ~proc ~fs ~config () = match Verse_registry.clone_or_pull ~proc ~fs ~config () with | Error msg -> Error (Registry_error msg) | Ok registry -> Ok registry.members (** Clone or fetch+reset a single git repo. Returns Ok true if cloned, Ok false if reset. Uses fetch+reset instead of pull since verse repos should not have local changes. *) let clone_or_reset_repo ~proc ~fs ~url ~branch path = if Git.is_repo ~proc ~fs path then begin match Git.fetch_and_reset ~proc ~fs ~branch path with | Error e -> Error e | Ok () -> Ok false end else begin let url = Uri.of_string url in match Git.clone ~proc ~fs ~url ~branch path with | Error e -> Error e | Ok () -> Ok true end let pull ~proc ~fs ~config ?handle () = (* Load registry to get all members *) match Verse_registry.clone_or_pull ~proc ~fs ~config () with | Error msg -> Error (Registry_error msg) | Ok registry -> let members = match handle with | Some h -> ( match Verse_registry.find_member registry ~handle:h with | Some m -> [ m ] | None -> []) | None -> registry.members in if members = [] && handle <> None then Error (Member_not_found (Option.get handle)) else begin let verse_dir = Verse_config.verse_path config in ensure_dir ~fs verse_dir; Logs.info (fun m -> m "Syncing %d members" (List.length members)); let errors = List.filter_map (fun (member : Verse_registry.member) -> let h = member.handle in let mono_path = Fpath.(verse_dir / h) in let opam_path = Fpath.(verse_dir / (h ^ "-opam")) in (* Clone or fetch+reset monorepo *) Logs.info (fun m -> m "Syncing %s monorepo" h); let mono_branch = Option.value ~default:Verse_config.default_branch member.monorepo_branch in let mono_result = clone_or_reset_repo ~proc ~fs ~url:member.monorepo ~branch:mono_branch mono_path in let mono_err = match mono_result with | Ok true -> Logs.info (fun m -> m " Cloned %s monorepo" h); None | Ok false -> Logs.info (fun m -> m " Reset %s monorepo" h); None | Error e -> Logs.warn (fun m -> m " Failed %s monorepo: %a" h Git.pp_error e); Some (Fmt.str "%s monorepo: %a" h Git.pp_error e) in (* Clone or fetch+reset opam repo *) Logs.info (fun m -> m "Syncing %s opam repo" h); let opam_branch = Option.value ~default:Verse_config.default_branch member.opamrepo_branch in let opam_result = clone_or_reset_repo ~proc ~fs ~url:member.opamrepo ~branch:opam_branch opam_path in let opam_err = match opam_result with | Ok true -> Logs.info (fun m -> m " Cloned %s opam repo" h); None | Ok false -> Logs.info (fun m -> m " Reset %s opam repo" h); None | Error e -> Logs.warn (fun m -> m " Failed %s opam repo: %a" h Git.pp_error e); Some (Fmt.str "%s opam: %a" h Git.pp_error e) in match (mono_err, opam_err) with | None, None -> None | Some e, None | None, Some e -> Some e | Some e1, Some e2 -> Some (e1 ^ "; " ^ e2)) members in if errors = [] then Ok () else Error (Git_error (Git.Io_error (String.concat "; " errors))) end let sync ~proc ~fs ~config () = (* pull already updates registry and syncs all members *) pull ~proc ~fs ~config () (** Scan a monorepo for subtree directories. Returns a list of directory names that look like subtrees (have commits). *) let scan_subtrees ~proc ~fs monorepo_path = if not (Git.is_repo ~proc ~fs monorepo_path) then [] else let eio_path = Eio.Path.(fs / Fpath.to_string monorepo_path) in try Eio.Path.read_dir eio_path |> List.filter (fun name -> (* Skip hidden dirs and common non-subtree dirs *) (not (String.starts_with ~prefix:"." name)) && name <> "_build" && name <> "node_modules" && is_directory ~fs Fpath.(monorepo_path / name)) with Eio.Io _ -> [] (** Get subtrees from all tracked verse members. Returns a map from subtree name to list of (handle, monorepo_path) pairs. *) let get_verse_subtrees ~proc ~fs ~config () = let verse_path = Verse_config.verse_path config in let tracked_handles = get_tracked_handles ~fs config in (* Build map: subtree_name -> [(handle, monorepo_path)] *) let subtree_map = Hashtbl.create 64 in List.iter (fun handle -> let member_mono = Fpath.(verse_path / handle) in if Git.is_repo ~proc ~fs member_mono then begin let subtrees = scan_subtrees ~proc ~fs member_mono in List.iter (fun subtree -> let existing = try Hashtbl.find subtree_map subtree with Not_found -> [] in Hashtbl.replace subtree_map subtree ((handle, member_mono) :: existing)) subtrees end) tracked_handles; subtree_map (** Result of a fork operation. *) type fork_result = { packages_forked : string list; (** Package names that were forked *) source_handle : string; (** Handle of the verse member we forked from *) fork_url : string; (** URL of the fork *) upstream_url : string; (** Original dev-repo URL (upstream) *) subtree_name : string; (** Name for the subtree directory (derived from fork URL) *) } (** Extract subtree name from a URL (last path component without .git suffix) *) let subtree_name_from_url url = let uri = Uri.of_string url in let path = Uri.path uri in (* Remove leading slash and .git suffix *) let path = if String.length path > 0 && path.[0] = '/' then String.sub path 1 (String.length path - 1) else path in let path = if String.ends_with ~suffix:".git" path then String.sub path 0 (String.length path - 4) else path in (* Get last component *) match String.rindex_opt path '/' with | Some i -> String.sub path (i + 1) (String.length path - i - 1) | None -> path let pp_fork_result ppf r = Fmt.pf ppf "@[Forked %d package(s) from %s:@, @[%a@]@,Fork URL: %s@,Upstream: %s@,Subtree: %s@]" (List.length r.packages_forked) r.source_handle Fmt.(list ~sep:cut string) r.packages_forked r.fork_url r.upstream_url r.subtree_name (** Fork a package from a verse member's opam repo into your workspace. This looks up the package in the member's opam-repo (verse/-opam/), finds all packages sharing the same dev-repo, and creates entries in your opam-repo with the fork URL as the dev-repo. @param proc Eio process manager @param fs Eio filesystem @param config Verse configuration @param handle Verse member handle to fork from @param package Package name to fork @param fork_url Git URL of your fork @param dry_run If true, show what would be done without making changes *) let fork ~proc ~fs ~config ~handle ~package ~fork_url ?(dry_run = false) () = (* Ensure the member exists and their opam-repo is synced *) match Verse_registry.clone_or_pull ~proc ~fs ~config () with | Error msg -> Error (Registry_error msg) | Ok registry -> match Verse_registry.find_member registry ~handle with | None -> Error (Member_not_found handle) | Some _member -> let verse_path = Verse_config.verse_path config in let member_opam_repo = Fpath.(verse_path / (handle ^ "-opam")) in (* Check if their opam repo exists locally *) if not (is_directory ~fs member_opam_repo) then Error (Config_error (Fmt.str "Member's opam repo not synced. Run: monopam verse pull %s" handle)) else (* Scan their opam repo to find the package *) let pkgs, _errors = Opam_repo.scan_all ~fs member_opam_repo in (* Find the requested package *) match List.find_opt (fun p -> Package.name p = package) pkgs with | None -> Error (Package_not_found (package, handle)) | Some pkg -> (* Find all packages from the same dev-repo *) let related_pkgs = List.filter (fun p -> Package.same_repo p pkg) pkgs in let pkg_names = List.map Package.name related_pkgs in (* Get upstream URL and subtree name *) let upstream_url = Uri.to_string (Package.dev_repo pkg) in let subtree_name = subtree_name_from_url fork_url in (* Check for conflicts in user's opam-repo *) let user_opam_repo = Verse_config.opam_repo_path config in let conflicts = List.filter (fun name -> Opam_repo.package_exists ~fs ~repo_path:user_opam_repo ~name) pkg_names in if conflicts <> [] then Error (Package_already_exists conflicts) else if dry_run then (* Dry run - just report what would be done *) Ok { packages_forked = pkg_names; source_handle = handle; fork_url; upstream_url; subtree_name } else begin (* Fork each package *) let results = List.map (fun p -> let name = Package.name p in let version = Package.version p in let opam_path = Fpath.(member_opam_repo / "packages" / name / (name ^ "." ^ version) / "opam") in match Opam_repo.read_opam_file ~fs opam_path with | Error e -> Error (Opam_repo_error e) | Ok content -> (* Replace dev-repo and url with fork URL *) let new_content = Opam_repo.replace_dev_repo_url content ~new_url:fork_url in (* Write to user's opam-repo *) match Opam_repo.write_package ~fs ~repo_path:user_opam_repo ~name ~version ~content:new_content with | Error e -> Error (Opam_repo_error e) | Ok () -> Ok name) related_pkgs in (* Check for errors *) match List.find_opt Result.is_error results with | Some (Error e) -> Error e | _ -> let forked_names = List.filter_map (function Ok n -> Some n | Error _ -> None) results in Ok { packages_forked = forked_names; source_handle = handle; fork_url; upstream_url; subtree_name } end