Monorepo management for opam overlays
0
fork

Configure Feed

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

at main 472 lines 17 kB view raw
1(** Operational context for monopam commands. 2 3 Provides filesystem utilities, package discovery, checkout management, and 4 repository grouping functions shared across command modules. *) 5 6let src = Logs.Src.create "monopam.ctx" ~doc:"Monopam context" 7 8module Log = (val Logs.src_log src : Logs.LOG) 9 10(** {1 Error Types} *) 11 12type error = 13 | Config_error of string 14 | Repo_error of Opam_repo.error 15 | Git_error of Git_cli.error 16 | Dirty_state of Package.t list 17 | Monorepo_dirty 18 | Package_not_found of string 19 | Pull_conflict of { paths : string list; hint : string } 20 | Claude_error of string 21 | Other of { msg : string; hint : string option } 22 23let err ?hint msg = Other { msg; hint } 24 25let pp_error ppf = function 26 | Config_error msg -> Fmt.pf ppf "Configuration error: %s" msg 27 | Repo_error e -> Fmt.pf ppf "Repository error: %a" Opam_repo.pp_error e 28 | Git_error e -> Fmt.pf ppf "Git error: %a" Git_cli.pp_error e 29 | Dirty_state pkgs -> 30 Fmt.pf ppf "Dirty packages: %a" 31 Fmt.(list ~sep:comma (using Package.name string)) 32 pkgs 33 | Monorepo_dirty -> Fmt.pf ppf "Monorepo has uncommitted changes" 34 | Package_not_found name -> Fmt.pf ppf "Package not found: %s" name 35 | Pull_conflict _ -> () 36 | Claude_error msg -> Fmt.pf ppf "Claude error: %s" msg 37 | Other { msg; hint = _ } -> Fmt.pf ppf "%s" msg 38 39let error_hint = function 40 | Config_error _ -> 41 Some "Run 'monopam init --handle <your-handle>' to create a workspace." 42 | Repo_error (Opam_repo.No_dev_repo _) -> 43 Some 44 "Add a 'dev-repo' field to the package's opam file pointing to a git \ 45 URL." 46 | Repo_error (Opam_repo.Not_git_remote _) -> 47 Some "The dev-repo must be a git URL (git+https:// or git://)." 48 | Repo_error _ -> None 49 | Git_error (Git_cli.Dirty_worktree _) -> 50 Some "Commit or stash your changes first: cd <repo> && git status" 51 | Git_error (Git_cli.Not_a_repo _) -> 52 Some "Run 'monopam pull' to clone missing repositories." 53 | Git_error (Git_cli.Subtree_prefix_missing _) -> 54 Some "Run 'monopam pull' to set up the subtree." 55 | Git_error (Git_cli.Remote_not_found _) -> 56 Some "Check that the remote is configured: git remote -v" 57 | Git_error (Git_cli.Branch_not_found _) -> 58 Some "Check available branches: git branch -a" 59 | Git_error (Git_cli.Command_failed (cmd, result)) 60 when String.starts_with ~prefix:"git push" cmd -> 61 if 62 Astring.String.is_infix ~affix:"non-fast-forward" result.Git_cli.stderr 63 || Astring.String.is_infix ~affix:"[rejected]" result.Git_cli.stderr 64 || Astring.String.is_infix ~affix:"fetch first" result.Git_cli.stderr 65 then 66 Some 67 "Run 'monopam pull' to merge the upstream changes, resolve any \ 68 conflicts, and push again." 69 else Some "Check your network connection and git credentials." 70 | Git_error _ -> None 71 | Dirty_state _ -> 72 Some 73 "Commit changes in the monorepo first: cd mono && git add -A && git \ 74 commit" 75 | Monorepo_dirty -> 76 Some 77 "Commit or stash your changes first: git status && git add -A && git \ 78 commit" 79 | Package_not_found _ -> 80 Some "Check available packages: ls opam-repo/packages/" 81 | Pull_conflict { hint; _ } -> Some hint 82 | Claude_error msg when String.starts_with ~prefix:"Failed to decode" msg -> 83 Some "The Claude API may have returned an unexpected response. Try again." 84 | Claude_error _ -> 85 Some "Check ANTHROPIC_API_KEY is set. See: https://console.anthropic.com/" 86 | Other { hint; _ } -> hint 87 88let pp_error_with_hint ppf e = 89 pp_error ppf e; 90 match error_hint e with 91 | Some hint -> Fmt.pf ppf "@.@[<v 2>Hint: %s@]" hint 92 | None -> () 93 94(** Map each error category to a shell exit code. See [ctx.mli] for the 95 contract. Kept as a flat match so new variants force a compile error if 96 someone extends [error] without thinking about exit codes. *) 97let exit_code = function 98 | Config_error _ -> 2 99 | Repo_error _ -> 2 100 | Dirty_state _ -> 2 101 | Monorepo_dirty -> 2 102 | Package_not_found _ -> 2 103 | Pull_conflict _ -> 4 104 | Claude_error _ -> 5 105 | Other _ -> 2 106 | Git_error g -> ( 107 match g with 108 | Git_cli.Not_a_repo _ -> 2 109 | Git_cli.Dirty_worktree _ -> 2 110 | Git_cli.Remote_not_found _ -> 2 111 | Git_cli.Branch_not_found _ -> 2 112 | Git_cli.Subtree_prefix_exists _ -> 2 113 | Git_cli.Subtree_prefix_missing _ -> 2 114 | Git_cli.Io_error _ -> 3 115 | Git_cli.Command_failed (_, r) -> 116 let err = r.Git_cli.stderr in 117 let has s = Astring.String.is_infix ~affix:s err in 118 if has "non-fast-forward" || has "[rejected]" || has "fetch first" 119 then 4 120 else if 121 has "Could not resolve host" 122 || has "unable to access" || has "Connection refused" 123 || has "Network is unreachable" 124 || has "Could not read from remote" 125 then 3 126 else 2) 127 128(** {1 Filesystem Utilities} *) 129 130let fs_typed (fs : _ Eio.Path.t) : Eio.Fs.dir_ty Eio.Path.t = 131 let dir, _ = fs in 132 (dir, "") 133 134let rec mkdirs path = 135 match Eio.Path.kind ~follow:true path with 136 | `Directory -> () 137 | _ -> 138 Log.debug (fun m -> m "Creating directory %a" Eio.Path.pp path); 139 Eio.Path.mkdir ~perm:0o755 path 140 | exception Eio.Io _ -> 141 let parent = Eio.Path.split path in 142 (match parent with 143 | Some (parent_path, _) -> mkdirs parent_path 144 | None -> ()); 145 Log.debug (fun m -> m "Creating directory %a" Eio.Path.pp path); 146 Eio.Path.mkdir ~perm:0o755 path 147 148let is_directory ~fs path = 149 let eio_path = Eio.Path.(fs / Fpath.to_string path) in 150 match Eio.Path.kind ~follow:true eio_path with 151 | `Directory -> true 152 | _ -> false 153 | exception _ -> false 154 155(** Walk every immediate subdirectory of [monorepo] and pick the ones that look 156 like nested monorepos (= contain a [sources.toml] file). Pair each with its 157 outer sources entry if any. *) 158let nested_monos ~fs ~monorepo ~sources = 159 let monorepo_eio = Eio.Path.(fs / Fpath.to_string monorepo) in 160 let entries = try Eio.Path.read_dir monorepo_eio with Eio.Io _ -> [] in 161 List.filter_map 162 (fun name -> 163 if name = ".git" || name = "_build" then None 164 else 165 let sub = Fpath.(monorepo / name) in 166 if not (is_directory ~fs sub) then None 167 else 168 let inner_toml = Eio.Path.(monorepo_eio / name / "sources.toml") in 169 match Eio.Path.kind ~follow:true inner_toml with 170 | `Regular_file -> 171 let entry = 172 Option.bind sources (fun s -> 173 Sources_registry.find s ~subtree:name) 174 in 175 Some (name, entry) 176 | _ -> None 177 | exception _ -> None) 178 entries 179 180let normalize_opam_url_string s = 181 if String.starts_with ~prefix:"git+" s then 182 String.sub s 4 (String.length s - 4) 183 else s 184 185let normalize_opam_url uri = 186 Uri.of_string (normalize_opam_url_string (Uri.to_string uri)) 187 188let ensure_checkouts_dir ~fs ~config = 189 let checkouts = Config.Paths.checkouts config in 190 let checkouts_eio = Eio.Path.(fs / Fpath.to_string checkouts) in 191 Log.debug (fun m -> 192 m "Ensuring checkouts directory exists: %a" Fpath.pp checkouts); 193 mkdirs checkouts_eio 194 195(** {1 Package Discovery} *) 196 197let discover_packages ~fs ~config () = 198 let repo_path = Config.Paths.opam_repo config in 199 Log.debug (fun m -> m "Scanning opam repo at %a" Fpath.pp repo_path); 200 Opam_repo.scan ~fs repo_path 201 |> Result.map_error (fun e -> Repo_error e) 202 |> Result.map (fun pkgs -> 203 Log.info (fun m -> m "Found %d packages in opam repo" (List.length pkgs)); 204 pkgs) 205 206let package ~fs ~config name = 207 Result.bind (discover_packages ~fs ~config ()) (fun pkgs -> 208 List.find_opt (fun p -> Package.name p = name) pkgs 209 |> Option.to_result ~none:(Package_not_found name)) 210 211(** {1 Branch and Checkout Management} *) 212 213let branch ~config pkg = 214 let default = Config.default_branch in 215 match Package.branch pkg with 216 | Some b -> b 217 | None -> 218 Option.bind 219 (Config.package_config config (Package.name pkg)) 220 Config.Package_config.branch 221 |> Option.value ~default 222 223let ensure_checkout ~proc ~fs ~config ?url pkg = 224 let checkouts_root = Config.Paths.checkouts config in 225 let checkout_dir = Package.checkout_dir ~checkouts_root pkg in 226 let checkout_eio = Eio.Path.(fs / Fpath.to_string checkout_dir) in 227 let branch = branch ~config pkg in 228 let clone_url = 229 let default = Uri.to_string (Package.dev_repo pkg) in 230 normalize_opam_url_string (Option.value ~default url) 231 in 232 let do_clone () = 233 Log.info (fun m -> 234 m "Cloning %s from %s (branch: %s)" (Package.repo_name pkg) clone_url 235 branch); 236 Git_cli.clone ~proc ~fs ~url:clone_url ~branch checkout_dir 237 in 238 let is_directory = 239 match Eio.Path.kind ~follow:true checkout_eio with 240 | `Directory -> true 241 | _ -> false 242 | exception Eio.Io _ -> false 243 in 244 if not is_directory then do_clone () 245 else if not (Git.Repository.is_repo ~fs checkout_dir) then do_clone () 246 else begin 247 Log.info (fun m -> m "Fetching %s" (Package.repo_name pkg)); 248 match Git_cli.fetch ~proc ~fs checkout_dir with 249 | Error e -> Error e 250 | Ok () -> ( 251 Log.info (fun m -> m "Updating %s to %s" (Package.repo_name pkg) branch); 252 match Git_cli.merge_ff ~proc ~fs ~branch checkout_dir with 253 | Ok () -> Ok () 254 | Error _ -> 255 (* Checkout diverged from upstream (e.g. after a failed push where 256 the split was pushed to the checkout but the upstream had a 257 different commit). Reset to the upstream's HEAD — the checkout 258 is a derived cache, the monorepo is authoritative. *) 259 Log.info (fun m -> 260 m "Fast-forward failed for %s, resetting to upstream" 261 (Package.repo_name pkg)); 262 Git_cli.fetch_and_reset ~proc ~fs ~branch checkout_dir) 263 end 264 265let checkout_exists ~fs ~config pkg = 266 let checkouts_root = Config.Paths.checkouts config in 267 let checkout_dir = Package.checkout_dir ~checkouts_root pkg in 268 let checkout_eio = Eio.Path.(fs / Fpath.to_string checkout_dir) in 269 match Eio.Path.kind ~follow:true checkout_eio with 270 | `Directory -> Git.Repository.is_repo ~fs checkout_dir 271 | _ -> false 272 | exception Eio.Io _ -> false 273 274let behind ~sw ~fs ~config pkg = 275 let checkouts_root = Config.Paths.checkouts config in 276 let checkout_dir = Package.checkout_dir ~checkouts_root pkg in 277 let branch = branch ~config pkg in 278 if not (Git.Repository.is_repo ~fs checkout_dir) then 0 279 else 280 let repo = Git.Repository.open_repo ~sw ~fs checkout_dir in 281 match Git.Repository.ahead_behind repo ~branch () with 282 | Some ab -> ab.behind 283 | None -> 0 284 285(** {1 Repository Grouping} *) 286 287let group_by_repo pkgs = 288 let tbl = Hashtbl.create 16 in 289 List.iter 290 (fun pkg -> 291 let repo = Package.repo_name pkg in 292 let existing = try Hashtbl.find tbl repo with Not_found -> [] in 293 Hashtbl.replace tbl repo (pkg :: existing)) 294 pkgs; 295 Hashtbl.fold 296 (fun repo pkgs acc -> (repo, List.sort Package.compare pkgs) :: acc) 297 tbl [] 298 |> List.sort (fun (a, _) (b, _) -> String.compare a b) 299 300let normalize_url_for_comparison uri = 301 let scheme = Option.value ~default:"" (Uri.scheme uri) in 302 let host = Option.value ~default:"" (Uri.host uri) in 303 let path = Uri.path uri in 304 let path = 305 if String.length path > 1 && path.[String.length path - 1] = '/' then 306 String.sub path 0 (String.length path - 1) 307 else path 308 in 309 Fmt.str "%s://%s%s" scheme host path 310 311let unique_repos pkgs = 312 let seen = Hashtbl.create 16 in 313 List.filter 314 (fun pkg -> 315 let url = normalize_url_for_comparison (Package.dev_repo pkg) in 316 Log.debug (fun m -> 317 m "Checking repo URL: %s (from %s)" url (Package.name pkg)); 318 if Hashtbl.mem seen url then begin 319 Log.debug (fun m -> m " -> Already seen, skipping"); 320 false 321 end 322 else begin 323 Hashtbl.add seen url (); 324 Log.debug (fun m -> m " -> New repo, keeping"); 325 true 326 end) 327 pkgs 328 329(** {1 URL Utilities} *) 330 331let is_tangled_host = function 332 | Some "tangled.org" | Some "tangled.sh" -> true 333 | _ -> false 334 335let url_to_push_url ?knot url = 336 let url = normalize_opam_url_string url in 337 (* SSH URLs (git@host:path) are already push URLs *) 338 if String.contains url '@' && not (String.contains url '/') then url 339 else if String.starts_with ~prefix:"git@" url then url 340 else 341 let uri = Uri.of_string url in 342 let scheme = Uri.scheme uri in 343 let host = Uri.host uri in 344 let path = Uri.path uri in 345 match (scheme, host) with 346 | Some ("https" | "http"), Some "github.com" -> 347 let path = 348 if String.length path > 0 && path.[0] = '/' then 349 String.sub path 1 (String.length path - 1) 350 else path 351 in 352 Fmt.str "git@github.com:%s" path 353 | Some ("https" | "http"), Some "gitlab.com" -> 354 let path = 355 if String.length path > 0 && path.[0] = '/' then 356 String.sub path 1 (String.length path - 1) 357 else path 358 in 359 Fmt.str "git@gitlab.com:%s" path 360 | Some ("https" | "http"), _ when is_tangled_host host -> 361 let path = 362 if String.length path > 0 && path.[0] = '/' then 363 String.sub path 1 (String.length path - 1) 364 else path 365 in 366 let path = 367 if String.length path > 0 && path.[0] = '@' then 368 String.sub path 1 (String.length path - 1) 369 else path 370 in 371 let path = 372 if String.ends_with ~suffix:".git" path then 373 String.sub path 0 (String.length path - 4) 374 else path 375 in 376 let knot_server = Option.value ~default:"git.recoil.org" knot in 377 Fmt.str "git@%s:%s" knot_server path 378 | _ -> url 379 380(** {1 Unregistered Package Detection} *) 381 382let unregistered_opam_files ~fs ~config pkgs = 383 let fs = fs_typed fs in 384 let monorepo = Config.Paths.monorepo config in 385 let registered_by_repo = Hashtbl.create 16 in 386 List.iter 387 (fun pkg -> 388 let repo = Package.repo_name pkg in 389 let name = Package.name pkg in 390 let existing = 391 try Hashtbl.find registered_by_repo repo with Not_found -> [] 392 in 393 Hashtbl.replace registered_by_repo repo (name :: existing)) 394 pkgs; 395 let seen_repos = Hashtbl.create 16 in 396 let repos = 397 List.filter 398 (fun pkg -> 399 let repo = Package.repo_name pkg in 400 if Hashtbl.mem seen_repos repo then false 401 else begin 402 Hashtbl.add seen_repos repo (); 403 true 404 end) 405 pkgs 406 in 407 let check_opam_file ~repo ~registered name = 408 if not (Filename.check_suffix name ".opam") then None 409 else 410 let pkg_name = Filename.chop_suffix name ".opam" in 411 if List.mem pkg_name registered then None else Some (repo, pkg_name) 412 in 413 List.concat_map 414 (fun pkg -> 415 let repo = Package.repo_name pkg in 416 let subtree_dir = Fpath.(monorepo / Package.subtree_prefix pkg) in 417 let eio_path = Eio.Path.(fs / Fpath.to_string subtree_dir) in 418 let registered = 419 try Hashtbl.find registered_by_repo repo with Not_found -> [] 420 in 421 try 422 Eio.Path.read_dir eio_path 423 |> List.filter_map (check_opam_file ~repo ~registered) 424 with Eio.Io _ -> []) 425 repos 426 427let untracked_subdirs ~fs ~config pkgs = 428 let fs = fs_typed fs in 429 let monorepo = Config.Paths.monorepo config in 430 let monorepo_eio = Eio.Path.(fs / Fpath.to_string monorepo) in 431 let tracked = Hashtbl.create 256 in 432 List.iter 433 (fun pkg -> Hashtbl.replace tracked (Package.subtree_prefix pkg) ()) 434 pkgs; 435 let is_candidate name = 436 if Hashtbl.mem tracked name then false 437 else if String.length name = 0 then false 438 else 439 match name.[0] with 440 | '.' | '_' -> false 441 | _ -> ( 442 let dune_project = Eio.Path.(monorepo_eio / name / "dune-project") in 443 match Eio.Path.kind ~follow:false dune_project with 444 | `Regular_file -> true 445 | _ -> false) 446 in 447 try 448 Eio.Path.read_dir monorepo_eio 449 |> List.filter (fun name -> 450 let child = Eio.Path.(monorepo_eio / name) in 451 match Eio.Path.kind ~follow:false child with 452 | `Directory -> is_candidate name 453 | _ -> false) 454 |> List.sort String.compare 455 with Eio.Io _ -> [] 456 457(** {1 Status} *) 458 459let status ~sw ~fs ~config () = 460 let fs = fs_typed fs in 461 ensure_checkouts_dir ~fs ~config; 462 discover_packages ~fs:(fs :> _ Eio.Path.t) ~config () 463 |> Result.map (Status.compute_all ~sw ~fs ~config) 464 465(** {1 Timing} *) 466 467let time_phase name f = 468 let t0 = Unix.gettimeofday () in 469 let result = f () in 470 let t1 = Unix.gettimeofday () in 471 Log.debug (fun m -> m "[TIMING] %s: %.3fs" name (t1 -. t0)); 472 result