···8787 let doc = "Show all repos including those not in your workspace." in
8888 Arg.(value & flag & info [ "all"; "a" ] ~doc)
8989 in
9090- let run show_all () =
9090+ let forks_arg =
9191+ let doc = "Include fork analysis from verse members (slower)." in
9292+ Arg.(value & flag & info [ "forks"; "f" ] ~doc)
9393+ in
9494+ (* Helper: abbreviate handle to first part before dot, max 4 chars *)
9595+ let abbrev_handle h =
9696+ match String.split_on_char '.' h with
9797+ | first :: _ ->
9898+ if String.length first <= 4 then first else String.sub first 0 3
9999+ | [] -> h
100100+ in
101101+ (* Helper: load sources.toml *)
102102+ let load_sources ~fs ~config =
103103+ let sources_path =
104104+ Fpath.(Monopam.Config.Paths.monorepo config / "sources.toml")
105105+ in
106106+ match
107107+ Monopam.Sources_registry.load ~fs:(fs :> _ Eio.Path.t) sources_path
108108+ with
109109+ | Ok s -> Some s
110110+ | Error _ -> None
111111+ in
112112+ (* Helper: print unregistered opam files if any *)
113113+ let print_unregistered ~fs ~config pkgs =
114114+ let unregistered = Monopam.find_unregistered_opam_files ~fs ~config pkgs in
115115+ if unregistered <> [] then begin
116116+ let handle_abbrev =
117117+ match Monopam.Verse_config.load ~fs () with
118118+ | Ok vc -> abbrev_handle (Monopam.Verse_config.handle vc)
119119+ | Error _ -> "local"
120120+ in
121121+ Fmt.pr "%a %a\n"
122122+ Fmt.(styled `Bold string)
123123+ "Unregistered:"
124124+ Fmt.(styled `Faint int)
125125+ (List.length unregistered);
126126+ List.iter
127127+ (fun (_r, p) ->
128128+ Fmt.pr " %-22s %a\n" p
129129+ Fmt.(styled `Faint (fun ppf s -> pf ppf "%s~" s))
130130+ handle_abbrev)
131131+ unregistered
132132+ end
133133+ in
134134+ (* Helper: run fork analysis if requested *)
135135+ let print_forks ~proc ~fs ~config ~show_all =
136136+ match Monopam.Verse_config.load ~fs () with
137137+ | Error _ -> ()
138138+ | Ok verse_config ->
139139+ let forks =
140140+ Monopam.Forks.compute ~proc ~fs ~verse_config ~monopam_config:config
141141+ ()
142142+ in
143143+ if forks.repos <> [] then
144144+ Fmt.pr "%a" (Monopam.Forks.pp_summary' ~show_all) forks
145145+ in
146146+ let run show_all show_forks () =
91147 Eio_main.run @@ fun env ->
92148 with_config env @@ fun config ->
93149 let fs = Eio.Stdenv.fs env in
94150 let proc = Eio.Stdenv.process_mgr env in
95151 match Monopam.status ~proc ~fs ~config () with
96152 | Ok statuses ->
9797- (* Load sources.toml for origin indicators *)
9898- let sources =
9999- let mono_path = Monopam.Config.Paths.monorepo config in
100100- let sources_path = Fpath.(mono_path / "sources.toml") in
101101- match
102102- Monopam.Sources_registry.load ~fs:(fs :> _ Eio.Path.t) sources_path
103103- with
104104- | Ok s -> Some s
105105- | Error _ -> None
106106- in
153153+ let sources = load_sources ~fs ~config in
107154 Fmt.pr "%a" (Monopam.Status.pp_summary ?sources) statuses;
108108- (* Check for unregistered opam files *)
109155 (match Monopam.discover_packages ~fs ~config () with
110110- | Ok pkgs ->
111111- let unregistered =
112112- Monopam.find_unregistered_opam_files ~fs ~config pkgs
113113- in
114114- if unregistered <> [] then begin
115115- (* Get local handle abbreviation *)
116116- let handle_abbrev =
117117- match Monopam.Verse_config.load ~fs () with
118118- | Ok vc -> (
119119- let h = Monopam.Verse_config.handle vc in
120120- match String.split_on_char '.' h with
121121- | first :: _ ->
122122- if String.length first <= 4 then first
123123- else String.sub first 0 3
124124- | [] -> h)
125125- | Error _ -> "local"
126126- in
127127- Fmt.pr "%a %a\n"
128128- Fmt.(styled `Bold string)
129129- "Unregistered:"
130130- Fmt.(styled `Faint int)
131131- (List.length unregistered);
132132- List.iter
133133- (fun (_r, p) ->
134134- Fmt.pr " %-22s %a\n" p
135135- Fmt.(styled `Faint (fun ppf s -> pf ppf "%s~" s))
136136- handle_abbrev)
137137- unregistered
138138- end
156156+ | Ok pkgs -> print_unregistered ~fs ~config pkgs
139157 | Error _ -> ());
140140- (* Fork analysis *)
141141- (match Monopam.Verse_config.load ~fs () with
142142- | Error _ -> ()
143143- | Ok verse_config ->
144144- let forks =
145145- Monopam.Forks.compute ~proc ~fs ~verse_config
146146- ~monopam_config:config ()
147147- in
148148- if forks.repos <> [] then
149149- Fmt.pr "%a" (Monopam.Forks.pp_summary' ~show_all) forks);
158158+ if show_forks then print_forks ~proc ~fs ~config ~show_all;
150159 `Ok ()
151160 | Error e ->
152161 Fmt.epr "Error: %a@." Monopam.pp_error_with_hint e;
153162 `Error (false, "status failed")
154163 in
155155- Cmd.v info Term.(ret (const run $ all_arg $ logging_term))
164164+ Cmd.v info Term.(ret (const run $ all_arg $ forks_arg $ logging_term))
156165157166(* Sync command *)
158167···21222131 site_cmd;
21232132 ]
2124213321252125-let () = exit (Cmd.eval main_cmd)
21342134+let () =
21352135+ Memtrace.trace_if_requested ~context:"monopam" ();
21362136+ exit (Cmd.eval main_cmd)
+1-1
lib/doctor.ml
···922922 | Ok pkgs -> pkgs
923923 | Error _ -> []
924924 in
925925- let statuses = Status.compute_all ~proc ~fs ~config packages in
925925+ let statuses = Status.compute_all ~fs ~config packages in
926926927927 (* Filter by package if specified *)
928928 let statuses =
+157-147
lib/fork_join.ml
···612612 let src_path = Fpath.(checkouts / name) in
613613614614 (* Gather discovery information *)
615615- let subtree_exists = Git_cli.subtree_prefix_exists ~fs ~repo:monorepo ~prefix in
615615+ let subtree_exists =
616616+ Git_cli.subtree_prefix_exists ~fs ~repo:monorepo ~prefix
617617+ in
616618 let src_exists = is_directory ~fs src_path in
617619 let local_is_repo =
618620 if is_local then begin
···754756 let src_path = Fpath.(checkouts / name) in
755757756758 (* Gather discovery information *)
757757- let subtree_exists = Git_cli.subtree_prefix_exists ~fs ~repo:monorepo ~prefix in
759759+ let subtree_exists =
760760+ Git_cli.subtree_prefix_exists ~fs ~repo:monorepo ~prefix
761761+ in
758762 let src_exists = is_directory ~fs src_path in
759763 let src_is_repo =
760764 if src_exists then Git_cli.is_repo ~proc ~fs src_path else false
···827831 | Git_clone { url; dest; branch } ->
828832 Git_cli.clone ~proc ~fs ~url:(Uri.of_string url) ~branch dest
829833 |> Result.map_error (fun e -> Git_error e)
830830- | Git_subtree_split { repo; prefix } ->
834834+ | Git_subtree_split { repo; prefix } -> (
831835 let repo_path = Fpath.to_string repo in
832836 let git_repo = Git.Repository.open_repo ~fs repo_path in
833833- (match Git.Repository.read_ref git_repo "HEAD" with
837837+ match Git.Repository.read_ref git_repo "HEAD" with
834838 | None -> Error (Git_error (Git_cli.Io_error "no HEAD ref found"))
835839 | Some head -> (
836840 match Git.Subtree.split git_repo ~prefix ~head () with
837837- | Ok None ->
838838- Error (Git_error (Git_cli.Subtree_prefix_missing prefix))
841841+ | Ok None -> Error (Git_error (Git_cli.Subtree_prefix_missing prefix))
839842 | Error (`Msg msg) -> Error (Git_error (Git_cli.Io_error msg))
840843 | Ok (Some split_hash) ->
841844 state.split_commit <- Some (Git.Hash.to_hex split_hash);
842845 Ok ()))
843843- | Git_subtree_add { repo; prefix; url; branch } ->
846846+ | Git_subtree_add { repo; prefix; url; branch } -> (
844847 (* Fetch the branch first to get the commit *)
845845- (match Git_cli.fetch_url ~proc ~fs ~repo ~url ~branch () with
848848+ match Git_cli.fetch_url ~proc ~fs ~repo ~url ~branch () with
846849 | Error e -> Error (Git_error e)
847847- | Ok hash_hex ->
850850+ | Ok hash_hex -> (
848851 let repo_path = Fpath.to_string repo in
849852 let git_repo = Git.Repository.open_repo ~fs repo_path in
850853 let commit = Git.Hash.of_hex hash_hex in
851854 let user =
852855 Git.User.make ~name:"monopam" ~email:"monopam@localhost"
853853- ~date:(Int64.of_float (Unix.time ())) ()
856856+ ~date:(Int64.of_float (Unix.time ()))
857857+ ()
854858 in
855859 let message =
856860 Fmt.str "Add '%s' from %s\n\ngit-subtree-dir: %s\n" prefix
857861 (Uri.to_string url) prefix
858862 in
859859- (match
860860- Git.Subtree.add git_repo ~prefix ~commit ~author:user
861861- ~committer:user ~message ()
862862- with
863863+ match
864864+ Git.Subtree.add git_repo ~prefix ~commit ~author:user
865865+ ~committer:user ~message ()
866866+ with
863867 | Ok _ -> Ok ()
864868 | Error (`Msg msg) -> Error (Git_error (Git_cli.Io_error msg))))
865869 | Git_add_remote { repo; name; url } ->
···9971001 | None -> Error (Git_error (Git_cli.Io_error "no HEAD ref found"))
9981002 | Some head -> (
9991003 match Git.Subtree.split git_repo ~prefix ~head () with
10001000- | Ok None ->
10011001- Error (Git_error (Git_cli.Subtree_prefix_missing prefix))
10041004+ | Ok None -> Error (Git_error (Git_cli.Subtree_prefix_missing prefix))
10021005 | Error (`Msg msg) -> Error (Git_error (Git_cli.Io_error msg))
10031003- | Ok (Some split_hash) ->
10061006+ | Ok (Some split_hash) -> (
10041007 let split_commit = Git.Hash.to_hex split_hash in
10051005- (
10061006- (* Ensure src/ exists *)
10071007- ensure_dir ~fs checkouts;
10081008- (* Initialize new git repo at src/<name>/ *)
10091009- match Git_cli.init ~proc ~fs src_path with
10101010- | Error e -> Error (Git_error e)
10111011- | Ok () -> (
10121012- (* Add 'origin' remote pointing to monorepo path temporarily *)
10131013- let mono_str = Fpath.to_string monorepo in
10141014- match
10151015- Git_cli.add_remote ~proc ~fs ~name:"mono" ~url:mono_str src_path
10161016- with
10081008+ (* Ensure src/ exists *)
10091009+ ensure_dir ~fs checkouts;
10101010+ (* Initialize new git repo at src/<name>/ *)
10111011+ match Git_cli.init ~proc ~fs src_path with
10171012 | Error e -> Error (Git_error e)
10181013 | Ok () -> (
10191019- (* Push split commit to local repo *)
10201020- let ref_spec = split_commit ^ ":refs/heads/main" in
10141014+ (* Add 'origin' remote pointing to monorepo path temporarily *)
10151015+ let mono_str = Fpath.to_string monorepo in
10211016 match
10221022- Git_cli.push_ref ~proc ~fs ~repo:monorepo
10231023- ~target:(Fpath.to_string src_path) ~ref_spec ()
10171017+ Git_cli.add_remote ~proc ~fs ~name:"mono" ~url:mono_str
10181018+ src_path
10241019 with
10251020 | Error e -> Error (Git_error e)
10261021 | Ok () -> (
10271027- (* Checkout main branch *)
10221022+ (* Push split commit to local repo *)
10231023+ let ref_spec = split_commit ^ ":refs/heads/main" in
10281024 match
10291029- Git_cli.checkout ~proc ~fs ~branch:"main" src_path
10251025+ Git_cli.push_ref ~proc ~fs ~repo:monorepo
10261026+ ~target:(Fpath.to_string src_path) ~ref_spec ()
10301027 with
10311028 | Error e -> Error (Git_error e)
10321029 | Ok () -> (
10331033- (* Set push URL if provided *)
10341034- let push_result =
10351035- match push_url with
10361036- | Some url -> (
10371037- match
10381038- Git_cli.add_remote ~proc ~fs ~name:"origin"
10391039- ~url src_path
10401040- with
10411041- | Error e -> Error (Git_error e)
10421042- | Ok () -> Ok ())
10431043- | None -> Ok ()
10441044- in
10451045- match push_result with
10461046- | Error _ as e -> e
10471047- | Ok () ->
10481048- (* Only update sources.toml if there's a push URL *)
10491049- (match push_url with
10501050- | Some url -> (
10511051- let sources_path =
10521052- Fpath.(monorepo / "sources.toml")
10531053- in
10541054- let sources =
10301030+ (* Checkout main branch *)
10311031+ match
10321032+ Git_cli.checkout ~proc ~fs ~branch:"main" src_path
10331033+ with
10341034+ | Error e -> Error (Git_error e)
10351035+ | Ok () -> (
10361036+ (* Set push URL if provided *)
10371037+ let push_result =
10381038+ match push_url with
10391039+ | Some url -> (
10551040 match
10561056- Sources_registry.load
10571057- ~fs:(fs :> _ Eio.Path.t)
10581058- sources_path
10411041+ Git_cli.add_remote ~proc ~fs
10421042+ ~name:"origin" ~url src_path
10591043 with
10601060- | Ok s -> s
10611061- | Error _ -> Sources_registry.empty
10621062- in
10631063- let entry =
10641064- Sources_registry.
10651065- {
10661066- url = normalize_git_url url;
10671067- upstream = None;
10681068- branch = Some "main";
10691069- reason = None;
10701070- origin = Some Fork;
10711071- }
10721072- in
10731073- let sources =
10741074- Sources_registry.add sources ~subtree:name
10751075- entry
10761076- in
10771077- match
10781078- Sources_registry.save
10791079- ~fs:(fs :> _ Eio.Path.t)
10801080- sources_path sources
10811081- with
10821082- | Ok () -> ()
10831083- | Error msg ->
10841084- Logs.warn (fun m ->
10851085- m "Failed to update sources.toml: %s"
10861086- msg))
10871087- | None -> ());
10881088- Ok
10891089- {
10901090- name;
10911091- split_commit;
10921092- src_path;
10931093- push_url;
10941094- packages_created = packages;
10951095- }))))))
10441044+ | Error e -> Error (Git_error e)
10451045+ | Ok () -> Ok ())
10461046+ | None -> Ok ()
10471047+ in
10481048+ match push_result with
10491049+ | Error _ as e -> e
10501050+ | Ok () ->
10511051+ (* Only update sources.toml if there's a push URL *)
10521052+ (match push_url with
10531053+ | Some url -> (
10541054+ let sources_path =
10551055+ Fpath.(monorepo / "sources.toml")
10561056+ in
10571057+ let sources =
10581058+ match
10591059+ Sources_registry.load
10601060+ ~fs:(fs :> _ Eio.Path.t)
10611061+ sources_path
10621062+ with
10631063+ | Ok s -> s
10641064+ | Error _ -> Sources_registry.empty
10651065+ in
10661066+ let entry =
10671067+ Sources_registry.
10681068+ {
10691069+ url = normalize_git_url url;
10701070+ upstream = None;
10711071+ branch = Some "main";
10721072+ reason = None;
10731073+ origin = Some Fork;
10741074+ }
10751075+ in
10761076+ let sources =
10771077+ Sources_registry.add sources
10781078+ ~subtree:name entry
10791079+ in
10801080+ match
10811081+ Sources_registry.save
10821082+ ~fs:(fs :> _ Eio.Path.t)
10831083+ sources_path sources
10841084+ with
10851085+ | Ok () -> ()
10861086+ | Error msg ->
10871087+ Logs.warn (fun m ->
10881088+ m
10891089+ "Failed to update \
10901090+ sources.toml: %s"
10911091+ msg))
10921092+ | None -> ());
10931093+ Ok
10941094+ {
10951095+ name;
10961096+ split_commit;
10971097+ src_path;
10981098+ push_url;
10991099+ packages_created = packages;
11001100+ }))))))
10961101 end
10971102 end
10981103···11251130 | Error e -> Error (Git_error e)
11261131 | Ok () -> (
11271132 (* Add subtree to monorepo - first fetch to get the commit *)
11281128- match Git_cli.fetch_url ~proc ~fs ~repo:monorepo ~url:uri ~branch () with
11331133+ match
11341134+ Git_cli.fetch_url ~proc ~fs ~repo:monorepo ~url:uri ~branch ()
11351135+ with
11291136 | Error e -> Error (Git_error e)
11301130- | Ok hash_hex ->
11371137+ | Ok hash_hex -> (
11311138 let repo_path = Fpath.to_string monorepo in
11321139 let git_repo = Git.Repository.open_repo ~fs repo_path in
11331140 let commit = Git.Hash.of_hex hash_hex in
11341141 let user =
11351142 Git.User.make ~name:"monopam" ~email:"monopam@localhost"
11361136- ~date:(Int64.of_float (Unix.time ())) ()
11431143+ ~date:(Int64.of_float (Unix.time ()))
11441144+ ()
11371145 in
11381146 let message =
11391147 Fmt.str "Add '%s' from %s\n\ngit-subtree-dir: %s\n" prefix url
11401148 prefix
11411149 in
11421142- (match
11431143- Git.Subtree.add git_repo ~prefix ~commit ~author:user
11441144- ~committer:user ~message ()
11451145- with
11501150+ match
11511151+ Git.Subtree.add git_repo ~prefix ~commit ~author:user
11521152+ ~committer:user ~message ()
11531153+ with
11461154 | Error (`Msg msg) -> Error (Git_error (Git_cli.Io_error msg))
11471155 | Ok _ ->
11481148- (* Find .opam files in the new subtree *)
11491149- let packages = find_opam_files ~fs subtree_path in
11501150- (* Only update sources.toml if there's an upstream to track *)
11511151- (match upstream with
11521152- | Some _ -> (
11531153- let sources_path = Fpath.(monorepo / "sources.toml") in
11541154- let sources =
11551155- match
11561156- Sources_registry.load ~fs:(fs :> _ Eio.Path.t) sources_path
11571157- with
11581158- | Ok s -> s
11591159- | Error _ -> Sources_registry.empty
11601160- in
11611161- let entry =
11621162- Sources_registry.
11631163- {
11641164- url = normalize_git_url url;
11651165- upstream = Option.map normalize_git_url upstream;
11661166- branch = Some branch;
11671167- reason = None;
11681168- origin = Some Join;
11691169- }
11701170- in
11711171- let sources =
11721172- Sources_registry.add sources ~subtree:name entry
11731173- in
11741174- match
11751175- Sources_registry.save
11761176- ~fs:(fs :> _ Eio.Path.t)
11771177- sources_path sources
11781178- with
11791179- | Ok () -> ()
11801180- | Error msg ->
11811181- Logs.warn (fun m ->
11821182- m "Failed to update sources.toml: %s" msg))
11831183- | None -> ());
11841184- Ok
11851185- {
11861186- name;
11871187- source_url = url;
11881188- upstream_url = upstream;
11891189- packages_added = packages;
11901190- from_handle = None;
11911191- }))
11561156+ (* Find .opam files in the new subtree *)
11571157+ let packages = find_opam_files ~fs subtree_path in
11581158+ (* Only update sources.toml if there's an upstream to track *)
11591159+ (match upstream with
11601160+ | Some _ -> (
11611161+ let sources_path = Fpath.(monorepo / "sources.toml") in
11621162+ let sources =
11631163+ match
11641164+ Sources_registry.load
11651165+ ~fs:(fs :> _ Eio.Path.t)
11661166+ sources_path
11671167+ with
11681168+ | Ok s -> s
11691169+ | Error _ -> Sources_registry.empty
11701170+ in
11711171+ let entry =
11721172+ Sources_registry.
11731173+ {
11741174+ url = normalize_git_url url;
11751175+ upstream = Option.map normalize_git_url upstream;
11761176+ branch = Some branch;
11771177+ reason = None;
11781178+ origin = Some Join;
11791179+ }
11801180+ in
11811181+ let sources =
11821182+ Sources_registry.add sources ~subtree:name entry
11831183+ in
11841184+ match
11851185+ Sources_registry.save
11861186+ ~fs:(fs :> _ Eio.Path.t)
11871187+ sources_path sources
11881188+ with
11891189+ | Ok () -> ()
11901190+ | Error msg ->
11911191+ Logs.warn (fun m ->
11921192+ m "Failed to update sources.toml: %s" msg))
11931193+ | None -> ());
11941194+ Ok
11951195+ {
11961196+ name;
11971197+ source_url = url;
11981198+ upstream_url = upstream;
11991199+ packages_added = packages;
12001200+ from_handle = None;
12011201+ }))
11921202 end
1193120311941204let join_from_verse ~proc ~fs ~config ~verse_config ~package ~handle ~fork_url
+99-11
lib/forks.ml
···9292 Hashtbl.replace fetch_cache key now;
9393 save_cache ()
94949595+(* ==================== Scan Cache ==================== *)
9696+9797+(** In-memory cache of scanned opam repo results: opam_path -> (pkg_name, url)
9898+ list *)
9999+let scan_cache : (string, (string * Uri.t) list) Hashtbl.t = Hashtbl.create 64
100100+101101+(** Scan cache file path *)
102102+let scan_cache_file_path () =
103103+ Fpath.(to_string (Verse_config.cache_dir () / "scan-cache.json"))
104104+105105+(** Load scan cache from disk. Uses simple line-based format:
106106+ path<TAB>pkg1<TAB>url1<TAB>pkg2<TAB>url2... *)
107107+let load_scan_cache () =
108108+ let path = scan_cache_file_path () in
109109+ if Sys.file_exists path then begin
110110+ try
111111+ let lines =
112112+ In_channel.with_open_text path (fun ic ->
113113+ let rec read acc =
114114+ match In_channel.input_line ic with
115115+ | Some line -> read (line :: acc)
116116+ | None -> List.rev acc
117117+ in
118118+ read [])
119119+ in
120120+ List.iter
121121+ (fun line ->
122122+ match String.split_on_char '\t' line with
123123+ | key :: rest when List.length rest >= 2 ->
124124+ (* rest is alternating pkg, url, pkg, url, ... *)
125125+ let rec parse_pairs acc = function
126126+ | pkg :: url :: tail ->
127127+ parse_pairs ((pkg, Uri.of_string url) :: acc) tail
128128+ | _ -> List.rev acc
129129+ in
130130+ let pairs = parse_pairs [] rest in
131131+ if pairs <> [] then Hashtbl.replace scan_cache key pairs
132132+ | _ -> ())
133133+ lines;
134134+ Log.debug (fun m ->
135135+ m "Loaded scan cache with %d entries" (Hashtbl.length scan_cache))
136136+ with _ -> ()
137137+ end
138138+139139+(** Save scan cache to disk. Uses simple line-based format. *)
140140+let save_scan_cache () =
141141+ let path = scan_cache_file_path () in
142142+ try
143143+ let dir = Filename.dirname path in
144144+ if not (Sys.file_exists dir) then
145145+ ignore (Sys.command (Printf.sprintf "mkdir -p %s" (Filename.quote dir)));
146146+ Out_channel.with_open_text path (fun oc ->
147147+ Hashtbl.iter
148148+ (fun key pairs ->
149149+ output_string oc key;
150150+ List.iter
151151+ (fun (pkg, url) ->
152152+ output_char oc '\t';
153153+ output_string oc pkg;
154154+ output_char oc '\t';
155155+ output_string oc (Uri.to_string url))
156156+ pairs;
157157+ output_char oc '\n')
158158+ scan_cache)
159159+ with _ -> ()
160160+161161+(** Get cached scan results for a path, or None if not cached *)
162162+let get_cached_scan path =
163163+ if Hashtbl.length scan_cache = 0 then load_scan_cache ();
164164+ Hashtbl.find_opt scan_cache (Fpath.to_string path)
165165+166166+(** Store scan results in cache *)
167167+let cache_scan path results =
168168+ Hashtbl.replace scan_cache (Fpath.to_string path) results;
169169+ save_scan_cache ()
170170+95171type repo_source = {
96172 handle : string; (** Member handle or "me" *)
97173 url : Uri.t; (** Normalized git URL *)
···397473 package_names
398474 with _ -> []
399475400400-(** Fetch a verse opam repo (with caching) *)
476476+(** Fetch a verse opam repo (with caching). Returns true if actually fetched. *)
401477let fetch_verse_opam_repo ~proc ~fs ~refresh path =
402478 let cache_key = "verse-opam/" ^ Fpath.to_string path in
403479 if not (needs_fetch ~refresh ~timeout:default_cache_timeout cache_key) then begin
404480 Log.debug (fun m -> m "Skipping fetch for %a (cached)" Fpath.pp path);
405405- ()
481481+ false (* Did not fetch *)
406482 end
407483 else begin
408484 let cwd = Eio.Path.(fs / Fpath.to_string path) in
···417493 cmd
418494 in
419495 match Eio.Process.await child with
420420- | `Exited 0 -> record_fetch cache_key
421421- | _ -> Log.debug (fun m -> m "Failed to fetch %a" Fpath.pp path)
496496+ | `Exited 0 ->
497497+ record_fetch cache_key;
498498+ true (* Actually fetched *)
499499+ | _ ->
500500+ Log.debug (fun m -> m "Failed to fetch %a" Fpath.pp path);
501501+ false
422502 end
423503424504(** Scan all verse opam repos and build a map: repo_basename ->
···430510 let opam_dirs =
431511 List.filter (fun name -> String.ends_with ~suffix:"-opam" name) entries
432512 in
433433- (* Fetch each opam repo first (respecting cache unless refresh) *)
434513 Log.info (fun m -> m "Checking %d verse opam repos" (List.length opam_dirs));
435435- List.iter
436436- (fun opam_dir ->
437437- let opam_path = Fpath.(verse_path / opam_dir) in
438438- fetch_verse_opam_repo ~proc ~fs ~refresh opam_path)
439439- opam_dirs;
440514 (* Build map: repo_basename -> [(handle, url, [packages])] *)
441515 let repo_map = Hashtbl.create 64 in
442516 List.iter
···444518 let handle = String.sub opam_dir 0 (String.length opam_dir - 5) in
445519 (* strip -opam *)
446520 let opam_path = Fpath.(verse_path / opam_dir) in
447447- let pkg_urls = scan_verse_opam_repo ~fs opam_path in
521521+ (* Fetch and decide whether to rescan *)
522522+ let did_fetch = fetch_verse_opam_repo ~proc ~fs ~refresh opam_path in
523523+ (* Use cached scan results unless we fetched or have no cache *)
524524+ let pkg_urls =
525525+ match (did_fetch, get_cached_scan opam_path) with
526526+ | false, Some cached ->
527527+ Log.debug (fun m -> m "Using cached scan for %a" Fpath.pp opam_path);
528528+ cached
529529+ | _ ->
530530+ (* Need to scan: either we fetched or no cache exists *)
531531+ Log.debug (fun m -> m "Scanning %a" Fpath.pp opam_path);
532532+ let results = scan_verse_opam_repo ~fs opam_path in
533533+ cache_scan opam_path results;
534534+ results
535535+ in
448536 (* Group by repo basename *)
449537 let by_repo = Hashtbl.create 16 in
450538 List.iter
+2-2
lib/git_cli.ml
···675675 [ "ls-remote"; "--heads"; remote; Printf.sprintf "refs/heads/%s" branch ]
676676 with
677677 | Error _ -> None
678678- | Ok output ->
678678+ | Ok output -> (
679679 if String.trim output = "" then None
680680 else
681681 (* Output format: "hash\trefs/heads/branch" *)
682682 match String.split_on_char '\t' (String.trim output) with
683683 | hash :: _ -> Some hash
684684- | [] -> None
684684+ | [] -> None)
+4-4
lib/git_cli.mli
···177177 branch:string ->
178178 unit ->
179179 (string, error) result
180180-(** [fetch_url ~proc ~fs ~repo ~url ~branch ()] fetches a branch from a URL
181181- and returns the commit hash of FETCH_HEAD.
180180+(** [fetch_url ~proc ~fs ~repo ~url ~branch ()] fetches a branch from a URL and
181181+ returns the commit hash of FETCH_HEAD.
182182183183 @param repo Path to the local repository
184184 @param url Git remote URL to fetch from
···200200201201val subtree_prefix_exists :
202202 fs:Eio.Fs.dir_ty Eio.Path.t -> repo:Fpath.t -> prefix:string -> bool
203203-(** [subtree_prefix_exists ~fs ~repo ~prefix] returns true if the subtree
204204- prefix directory exists in the repository. *)
203203+(** [subtree_prefix_exists ~fs ~repo ~prefix] returns true if the subtree prefix
204204+ directory exists in the repository. *)
205205206206(** {1 Initialization} *)
207207
+420-455
lib/monopam.ml
···1616module Sources_registry = Sources_registry
1717module Fork_join = Fork_join
1818module Site = Site
1919+module Remote_cache = Remote_cache
19202021let src = Logs.Src.create "monopam" ~doc:"Monopam operations"
2122···145146 let fs = fs_typed fs in
146147 ensure_checkouts_dir ~fs ~config;
147148 discover_packages ~fs:(fs :> _ Eio.Path.t) ~config ()
148148- |> Result.map (Status.compute_all ~proc ~fs ~config)
149149+ |> Result.map (Status.compute_all ~fs ~config)
149150150151(** Find opam files in monorepo subtrees that aren't registered in the overlay.
151152 Returns a list of (subtree_name, unregistered_package_name) pairs. *)
···10471048 else begin
10481049 Log.info (fun m ->
10491050 m "Checking status of %d packages" (List.length pkgs));
10501050- let statuses = Status.compute_all ~proc ~fs:fs_t ~config pkgs in
10511051+ let statuses = Status.compute_all ~fs:fs_t ~config pkgs in
10511052 let dirty =
10521053 List.filter Status.has_local_changes statuses
10531054 |> List.map (fun s -> s.Status.package)
···12381239 else begin
12391240 Log.info (fun m ->
12401241 m "Checking status of %d packages" (List.length pkgs));
12411241- let statuses = Status.compute_all ~proc ~fs:fs_t ~config pkgs in
12421242+ let statuses = Status.compute_all ~fs:fs_t ~config pkgs in
12421243 let dirty =
12431244 List.filter Status.has_local_changes statuses
12441245 |> List.map (fun s -> s.Status.package)
···13921393 end
1393139413941395(* Fetch a single checkout - safe for parallel execution *)
13951395-(** Remote HEAD cache with O(1) in-memory lookup and disk persistence.
13961396- File format: one entry per line "url:branch hash timestamp" *)
13971397-module Remote_cache : sig
13961396+13971397+(** Wrapper around Remote_cache that adds disk persistence via XDG cache *)
13981398+module Cached_remote_heads : sig
13981399 type t
1399140014001401 val create : xdg:Xdge.t -> now:(unit -> float) -> t
14011402 val get : t -> url:Uri.t -> branch:string -> string option
14021403 val set : t -> url:Uri.t -> branch:string -> hash:string -> unit
14031404end = struct
14041404- type entry = { hash : string; expires : float }
14051405- type t = { tbl : (string, entry) Hashtbl.t; xdg : Xdge.t; now : unit -> float }
14051405+ type t = { cache : Remote_cache.t; cache_file : Eio.Fs.dir_ty Eio.Path.t }
1406140614071407- let ttl = 300.0 (* 5 minutes *)
14081407 let filename = "remote-heads"
14091409- let cache_file xdg = Eio.Path.(Xdge.cache_dir xdg / filename)
14101410- let make_key url branch = Fmt.str "%s:%s" (Uri.to_string url) branch
14111411-14121412- let parse_line line =
14131413- match String.split_on_char ' ' line with
14141414- | [ key; hash; ts ] -> Some (key, hash, float_of_string ts)
14151415- | _ -> None
14161416-14171417- let load_from_disk xdg =
14181418- let tbl = Hashtbl.create 32 in
14191419- (try
14201420- Eio.Path.load (cache_file xdg)
14211421- |> String.split_on_char '\n'
14221422- |> List.iter (fun line ->
14231423- match parse_line line with
14241424- | Some (key, hash, ts) ->
14251425- Hashtbl.replace tbl key { hash; expires = ts +. ttl }
14261426- | None -> ())
14271427- with _ -> ());
14281428- tbl
14291429-14301430- let save_to_disk t =
14311431- let now = t.now () in
14321432- let lines =
14331433- Hashtbl.fold
14341434- (fun key entry acc ->
14351435- if entry.expires > now then
14361436- let ts = entry.expires -. ttl in
14371437- Fmt.str "%s %s %.0f" key entry.hash ts :: acc
14381438- else acc)
14391439- t.tbl []
14401440- in
14411441- let content = String.concat "\n" lines ^ "\n" in
14421442- try Eio.Path.save ~create:(`Or_truncate 0o644) (cache_file t.xdg) content
14431443- with _ -> ()
1444140814451409 let create ~xdg ~now =
14461446- let tbl = load_from_disk xdg in
14471447- { tbl; xdg; now }
14101410+ let cache_file = Eio.Path.(Xdge.cache_dir xdg / filename) in
14111411+ let content = try Eio.Path.load cache_file with _ -> "" in
14121412+ let cache = Remote_cache.create_from_string ~now content in
14131413+ { cache; cache_file }
1448141414491449- let get t ~url ~branch =
14501450- let key = make_key url branch in
14511451- let now = t.now () in
14521452- match Hashtbl.find_opt t.tbl key with
14531453- | Some entry when entry.expires > now ->
14541454- Log.debug (fun m -> m "Cache hit for %s (expires in %.0fs)" key (entry.expires -. now));
14551455- Some entry.hash
14561456- | Some entry ->
14571457- Log.debug (fun m -> m "Cache expired for %s (%.0fs ago)" key (now -. entry.expires));
14581458- None
14591459- | None ->
14601460- Log.debug (fun m -> m "Cache miss for %s (not found)" key);
14611461- None
14151415+ let get t = Remote_cache.get t.cache
1462141614631417 let set t ~url ~branch ~hash =
14641464- let key = make_key url branch in
14651465- let expires = t.now () +. ttl in
14661466- Hashtbl.replace t.tbl key { hash; expires };
14671467- save_to_disk t
14181418+ Remote_cache.set t.cache ~url ~branch ~hash;
14191419+ let content = Remote_cache.to_string t.cache in
14201420+ try Eio.Path.save ~create:(`Or_truncate 0o644) t.cache_file content
14211421+ with _ -> ()
14681422end
1469142314701424let fetch_checkout_safe ~sw ~env ~proc ~fs ~config ~cache ~get_session pkg =
···14821436 match local_head with Some h -> hash = h | None -> false
14831437 in
14841438 (* Step 1: Try cached remote HEAD - O(1) hashtbl lookup *)
14851485- match Remote_cache.get cache ~url:remote_url ~branch with
14391439+ match Cached_remote_heads.get cache ~url:remote_url ~branch with
14861440 | Some cached when remote_matches_local cached ->
14871441 Log.debug (fun m -> m "Skipping fetch for %s (cached)" repo);
14881442 Ok 0
···14901444 (* Step 2: Query remote HEAD via HTTP (lazily creates session) *)
14911445 let remote =
14921446 time_phase (Fmt.str "ls-remote:%s" repo) (fun () ->
14931493- Git.Remote.get_remote_head ~session:(get_session ()) ~sw ~env remote_url ~branch)
14471447+ Git.Remote.get_remote_head ~session:(get_session ()) ~sw ~env
14481448+ remote_url ~branch)
14941449 in
14951450 Option.iter
14961451 (fun h ->
14971497- Remote_cache.set cache ~url:remote_url ~branch ~hash:(Git.Hash.to_hex h))
14521452+ Cached_remote_heads.set cache ~url:remote_url ~branch
14531453+ ~hash:(Git.Hash.to_hex h))
14981454 remote;
14991455 match remote with
15001456 | Some h when remote_matches_local (Git.Hash.to_hex h) ->
···15391495 (* Replace @ and . with - for valid git remote names *)
15401496 String.map (function '@' | '.' -> '-' | c -> c) handle
1541149715421542-(* Ensure verse remotes for a single repo *)
15431543-let ensure_verse_remotes_for_repo ~proc ~fs ~config ~verse_subtrees pkg =
14981498+(* Ensure verse remotes for a single repo - fully native git *)
14991499+let ensure_verse_remotes_for_repo ~fs ~config ~verse_subtrees pkg =
15441500 let checkouts_root = Config.Paths.checkouts config in
15451501 let checkout_dir = Package.checkout_dir ~checkouts_root pkg in
15021502+ let checkout_path = Fpath.to_string checkout_dir in
15461503 let repo_name = Package.repo_name pkg in
1547150415481548- (* Only process if checkout exists *)
15491549- if not (Git_cli.is_repo ~proc ~fs checkout_dir) then ()
15051505+ (* Only process if checkout exists - use native git *)
15061506+ if not (Git.Repository.is_repo ~fs checkout_path) then ()
15501507 else begin
15511508 (* Get all verse members who have this repo *)
15521509 let members_with_repo =
15531510 Hashtbl.find_opt verse_subtrees repo_name |> Option.value ~default:[]
15541511 in
1555151215561556- (* Get current remotes *)
15571557- let current_remotes = Git_cli.list_remotes ~proc ~fs checkout_dir in
15131513+ (* Get current remotes - use native git *)
15141514+ let repo = Git.Repository.open_repo ~fs checkout_path in
15151515+ let current_remotes = Git.Repository.list_remotes repo in
15581516 let verse_remotes =
15591517 List.filter
15601518 (fun r -> String.starts_with ~prefix:"verse-" r)
15611519 current_remotes
15621520 in
1563152115641564- (* Build set of expected verse remotes *)
15221522+ (* Build set of expected verse remotes with their URLs *)
15651523 let expected_remotes =
15661566- List.map
15671567- (fun (handle, _) -> "verse-" ^ sanitize_remote_name handle)
15241524+ List.filter_map
15251525+ (fun (handle, verse_mono_path) ->
15261526+ let remote_name = "verse-" ^ sanitize_remote_name handle in
15271527+ let verse_src = Fpath.(parent verse_mono_path / "src" / repo_name) in
15281528+ if Sys.file_exists (Fpath.to_string verse_src) then
15291529+ Some (remote_name, Fpath.to_string verse_src)
15301530+ else None)
15681531 members_with_repo
15691532 in
15331533+ let expected_names = List.map fst expected_remotes in
1570153415711571- (* Add/update remotes for verse members *)
15351535+ (* Add/update remotes for verse members - native git *)
15721536 List.iter
15731573- (fun (handle, verse_mono_path) ->
15741574- let remote_name = "verse-" ^ sanitize_remote_name handle in
15751575- (* Point to their src/ checkout for this repo *)
15761576- let verse_src = Fpath.(parent verse_mono_path / "src" / repo_name) in
15771577- if Sys.file_exists (Fpath.to_string verse_src) then begin
15781578- let url = Fpath.to_string verse_src in
15791579- match
15801580- Git_cli.ensure_remote ~proc ~fs ~name:remote_name ~url checkout_dir
15811581- with
15821582- | Ok () ->
15831583- Log.debug (fun m ->
15841584- m "Ensured verse remote %s -> %s" remote_name url)
15851585- | Error e ->
15861586- Log.warn (fun m ->
15871587- m "Failed to add verse remote %s: %a" remote_name
15881588- Git_cli.pp_error e)
15891589- end)
15901590- members_with_repo;
15371537+ (fun (remote_name, url) ->
15381538+ match Git.Repository.ensure_remote repo ~name:remote_name ~url with
15391539+ | Ok () ->
15401540+ Log.debug (fun m ->
15411541+ m "Ensured verse remote %s -> %s" remote_name url)
15421542+ | Error (`Msg msg) ->
15431543+ Log.warn (fun m ->
15441544+ m "Failed to add verse remote %s: %s" remote_name msg))
15451545+ expected_remotes;
1591154615921592- (* Remove outdated verse remotes *)
15471547+ (* Remove outdated verse remotes - native git *)
15931548 List.iter
15941549 (fun remote_name ->
15951595- if not (List.mem remote_name expected_remotes) then begin
15501550+ if not (List.mem remote_name expected_names) then begin
15961551 Log.debug (fun m -> m "Removing outdated verse remote %s" remote_name);
15971597- match
15981598- Git_cli.remove_remote ~proc ~fs ~name:remote_name checkout_dir
15991599- with
15521552+ match Git.Repository.remove_remote repo remote_name with
16001553 | Ok () -> ()
16011601- | Error e ->
15541554+ | Error (`Msg msg) ->
16021555 Log.warn (fun m ->
16031603- m "Failed to remove verse remote %s: %a" remote_name
16041604- Git_cli.pp_error e)
15561556+ m "Failed to remove verse remote %s: %s" remote_name msg)
16051557 end)
16061558 verse_remotes
16071559 end
···16131565 Verse.get_verse_subtrees ~proc ~fs ~config:verse_config ()
16141566 in
16151567 List.iter
16161616- (fun pkg ->
16171617- ensure_verse_remotes_for_repo ~proc ~fs ~config ~verse_subtrees pkg)
15681568+ (fun pkg -> ensure_verse_remotes_for_repo ~fs ~config ~verse_subtrees pkg)
16181569 repos
1619157016201620-(* Fetch from verse remotes for a repo *)
15711571+(* Fetch from verse remotes for a repo - uses native git for list_remotes *)
16211572let fetch_verse_remotes ~proc ~fs ~config pkg =
16221573 let checkouts_root = Config.Paths.checkouts config in
16231574 let checkout_dir = Package.checkout_dir ~checkouts_root pkg in
16241624- let remotes = Git_cli.list_remotes ~proc ~fs checkout_dir in
15751575+ let checkout_path = Fpath.to_string checkout_dir in
15761576+ let remotes =
15771577+ if Git.Repository.is_repo ~fs checkout_path then
15781578+ let repo = Git.Repository.open_repo ~fs checkout_path in
15791579+ Git.Repository.list_remotes repo
15801580+ else []
15811581+ in
16251582 let verse_remotes =
16261583 List.filter (fun r -> String.starts_with ~prefix:"verse-" r) remotes
16271584 in
···17531710 ?(skip_push = false) ?(skip_pull = false) ?(skip_verse = false) () =
17541711 let fs_t = fs_typed fs in
17551712 (* Create remote HEAD cache with O(1) lookup - loaded once, persisted on updates *)
17561756- let cache = Remote_cache.create ~xdg ~now:(fun () -> Eio.Time.now env#clock) in
17571757- (* Lazily create HTTP session to avoid TLS cert loading if cache hits *)
17581758- let session_ref = ref None in
17131713+ let cache =
17141714+ Cached_remote_heads.create ~xdg ~now:(fun () -> Eio.Time.now env#clock)
17151715+ in
17161716+ (* Domain-safe lazy HTTP session to avoid TLS cert loading if cache hits *)
17171717+ let session_atom : Requests.t option Atomic.t = Atomic.make None in
17591718 let get_session () =
17601760- match !session_ref with
17191719+ match Atomic.get session_atom with
17611720 | Some s -> s
17621721 | None ->
17631722 let s = Requests.create ~sw env in
17641764- session_ref := Some s;
17651765- s
17231723+ (* CAS to avoid races - if another domain created one, use theirs *)
17241724+ if Atomic.compare_and_set session_atom None (Some s) then s
17251725+ else Option.get (Atomic.get session_atom)
17661726 in
1767172717681728 (* Step 0: Sync verse members if verse config exists and not skipping
···17711731 (if not should_skip_verse then
17721732 match Verse_config.load ~fs:fs_t () with
17731733 | Error _ -> () (* No verse config = skip *)
17741774- | Ok verse_config -> (
17341734+ | Ok verse_config ->
17751735 Log.app (fun m -> m "Syncing verse members...");
17761736 time_phase "verse-sync" (fun () ->
17771737 match Verse.pull ~proc ~fs:fs_t ~config:verse_config () with
17781738 | Ok () -> ()
17791739 | Error e ->
17801780- Log.warn (fun m -> m "Verse sync: %a" Verse.pp_error e))));
17401740+ Log.warn (fun m -> m "Verse sync: %a" Verse.pp_error e)));
1781174117821742 (* Clone from verse registry if repos don't exist *)
17831743 match clone_from_verse_if_needed ~proc ~fs:fs_t ~config () with
···17871747 Skip when syncing a single package for faster operations *)
17881748 let opam_repo = Config.Paths.opam_repo config in
17891749 let skip_opam_repo = Option.is_some package in
17901790- if (not skip_pull) && (not skip_opam_repo) && Git_cli.is_repo ~proc ~fs:fs_t opam_repo then begin
17501750+ if
17511751+ (not skip_pull) && (not skip_opam_repo)
17521752+ && Git_cli.is_repo ~proc ~fs:fs_t opam_repo
17531753+ then begin
17911754 Log.info (fun m -> m "Updating opam repo at %a" Fpath.pp opam_repo);
17921755 time_phase "opam-repo-fetch" (fun () ->
17931756 let result =
···18051768 ensure_checkouts_dir ~fs:fs_t ~config;
18061769 match ensure_monorepo_initialized ~proc ~fs:fs_t ~config with
18071770 | Error e -> Error e
18081808- | Ok () ->
17711771+ | Ok () -> (
18091772 (* Regenerate opam-repo from monorepo to ensure URLs are up to date *)
18101773 (* Skip when syncing a single package for faster operations *)
18111811- (if Option.is_none package then
17741774+ if Option.is_none package then
18121775 time_phase "regenerate-opam-repo" (fun () ->
18131813- regenerate_opam_repo ~fs:(fs_t :> _ Eio.Path.t) ~config ()));
18141814- (match
18151815- time_phase "discover-packages" (fun () ->
18161816- discover_packages ~fs:(fs_t :> _ Eio.Path.t) ~config ())
18171817- with
17761776+ regenerate_opam_repo ~fs:(fs_t :> _ Eio.Path.t) ~config ());
17771777+ match
17781778+ time_phase "discover-packages" (fun () ->
17791779+ discover_packages ~fs:(fs_t :> _ Eio.Path.t) ~config ())
17801780+ with
18181781 | Error e -> Error e
18191782 | Ok all_pkgs ->
18201783 let pkgs =
···18311794 m "Checking status of %d packages" (List.length pkgs));
18321795 let statuses =
18331796 time_phase "compute-status" (fun () ->
18341834- Status.compute_all ~proc ~fs:fs_t ~config pkgs)
17971797+ Status.compute_all ~fs:fs_t ~config pkgs)
18351798 in
18361799 let dirty =
18371800 List.filter Status.has_local_changes statuses
···18391802 in
18401803 if dirty <> [] then Error (Dirty_state dirty)
18411804 else begin
18421842- let repos = unique_repos pkgs in
18431843- let total = List.length repos in
18441844- Log.app (fun m -> m "Syncing %d repositories..." total);
18051805+ let repos = unique_repos pkgs in
18061806+ let total = List.length repos in
18071807+ Log.app (fun m -> m "Syncing %d repositories..." total);
1845180818461846- (* Build status lookup for optimization *)
18471847- let status_by_name =
18481848- List.map
18491849- (fun s -> (Package.name s.Status.package, s))
18501850- statuses
18511851- in
18521852- let sync_needs_push = function
18531853- | Status.Subtree_ahead _ | Status.Trees_differ -> true
18541854- | Status.In_sync | Status.Subtree_behind _
18551855- | Status.Unknown ->
18561856- false
18571857- in
18581858- let needs_push pkg =
18591859- List.assoc_opt (Package.name pkg) status_by_name
18601860- |> Option.fold ~none:true ~some:(fun s ->
18611861- sync_needs_push s.Status.subtree_sync)
18621862- in
18631863- let sync_needs_pull = function
18641864- | Status.Subtree_behind _ | Status.Trees_differ -> true
18651865- | Status.In_sync | Status.Subtree_ahead _ | Status.Unknown
18661866- ->
18671867- false
18681868- in
18691869- let needs_pull pkg =
18701870- List.assoc_opt (Package.name pkg) status_by_name
18711871- |> Option.fold ~none:true ~some:(fun s ->
18721872- sync_needs_pull s.Status.subtree_sync)
18731873- in
18091809+ (* Build status lookup for optimization *)
18101810+ let status_by_name =
18111811+ List.map
18121812+ (fun s -> (Package.name s.Status.package, s))
18131813+ statuses
18141814+ in
18151815+ let sync_needs_push = function
18161816+ | Status.Subtree_ahead _ | Status.Trees_differ -> true
18171817+ | Status.In_sync | Status.Subtree_behind _ | Status.Unknown
18181818+ ->
18191819+ false
18201820+ in
18211821+ let needs_push pkg =
18221822+ List.assoc_opt (Package.name pkg) status_by_name
18231823+ |> Option.fold ~none:true ~some:(fun s ->
18241824+ sync_needs_push s.Status.subtree_sync)
18251825+ in
18261826+ let sync_needs_pull = function
18271827+ | Status.Subtree_behind _ | Status.Trees_differ -> true
18281828+ | Status.In_sync | Status.Subtree_ahead _ | Status.Unknown
18291829+ ->
18301830+ false
18311831+ in
18321832+ let needs_pull pkg =
18331833+ List.assoc_opt (Package.name pkg) status_by_name
18341834+ |> Option.fold ~none:true ~some:(fun s ->
18351835+ sync_needs_pull s.Status.subtree_sync)
18361836+ in
1874183718751875- (* Step 2: Push phase - export monorepo changes to checkouts (PARALLEL) *)
18761876- (* git subtree push is read-only on the monorepo, so safe to parallelize *)
18771877- (* OPTIMIZATION: skip packages already in sync *)
18781878- let push_results =
18791879- if skip_push then begin
18381838+ (* Step 2: Push phase - export monorepo changes to checkouts (PARALLEL) *)
18391839+ (* git subtree push is read-only on the monorepo, so safe to parallelize *)
18401840+ (* OPTIMIZATION: skip packages already in sync *)
18411841+ let push_results =
18421842+ if skip_push then begin
18431843+ Log.app (fun m ->
18441844+ m " Skipping push to checkouts (--skip-push)");
18451845+ List.map (fun pkg -> Ok (Package.repo_name pkg)) repos
18461846+ end
18471847+ else begin
18481848+ let to_push, to_skip = List.partition needs_push repos in
18491849+ Log.app (fun m ->
18501850+ m
18511851+ " Pushing monorepo changes to checkouts \
18521852+ (parallel)...");
18531853+ if to_skip <> [] then
18801854 Log.app (fun m ->
18811881- m " Skipping push to checkouts (--skip-push)");
18821882- List.map (fun pkg -> Ok (Package.repo_name pkg)) repos
18831883- end
18841884- else begin
18851885- let to_push, to_skip =
18861886- List.partition needs_push repos
18871887- in
18881888- Log.app (fun m ->
18891889- m
18901890- " Pushing monorepo changes to checkouts \
18911891- (parallel)...");
18921892- if to_skip <> [] then
18931893- Log.app (fun m ->
18941894- m " Skipping %d already-synced packages"
18951895- (List.length to_skip));
18961896- (* Local git subtree push - no parallelism limit needed *)
18971897- let pushed =
18981898- Eio.Fiber.List.map
18551855+ m " Skipping %d already-synced packages"
18561856+ (List.length to_skip));
18571857+ (* Local git subtree push - no parallelism limit needed *)
18581858+ let pushed =
18591859+ Eio.Fiber.List.map
18601860+ (fun pkg ->
18611861+ let repo_name = Package.repo_name pkg in
18621862+ Log.info (fun m ->
18631863+ m "Push to checkout: %s" repo_name);
18641864+ match push_one ~proc ~fs ~config pkg with
18651865+ | Ok () -> Ok repo_name
18661866+ | Error (Git_error e) ->
18671867+ Error
18681868+ {
18691869+ repo_name;
18701870+ phase = `Push_checkout;
18711871+ error = e;
18721872+ }
18731873+ | Error _ -> Ok repo_name)
18741874+ to_push
18751875+ in
18761876+ let skipped_ok =
18771877+ List.map (fun pkg -> Ok (Package.repo_name pkg)) to_skip
18781878+ in
18791879+ pushed @ skipped_ok
18801880+ end
18811881+ in
18821882+ let push_errors =
18831883+ List.filter_map
18841884+ (function Error e -> Some e | Ok _ -> None)
18851885+ push_results
18861886+ in
18871887+18881888+ (* Steps 3-5: Pull phases (fetch, merge, subtree) - skip if --skip-pull *)
18891889+ let ( fetch_errors,
18901890+ unchanged_count,
18911891+ total_commits_pulled,
18921892+ merge_errors,
18931893+ subtree_errors,
18941894+ successfully_fetched_repos ) =
18951895+ if skip_pull then begin
18961896+ Log.app (fun m ->
18971897+ m " Skipping pull from remotes (--skip-pull)");
18981898+ ([], List.length repos, 0, ref [], ref [], repos)
18991899+ end
19001900+ else begin
19011901+ (* Step 3: Fetch phase - clone/fetch from remotes (PARALLEL) *)
19021902+ Log.app (fun m ->
19031903+ m " Fetching from remotes (parallel)...");
19041904+ let fetch_results =
19051905+ time_phase "fetch-phase" (fun () ->
19061906+ Eio.Fiber.List.map ~max_fibers:8
19071907+ (fun pkg ->
19081908+ let repo_name = Package.repo_name pkg in
19091909+ (* First ensure checkout exists *)
19101910+ match
19111911+ time_phase
19121912+ (Printf.sprintf "ensure-checkout:%s"
19131913+ repo_name) (fun () ->
19141914+ ensure_checkout_safe ~proc ~fs:fs_t
19151915+ ~config pkg)
19161916+ with
19171917+ | Error e ->
19181918+ Error
19191919+ { repo_name; phase = `Fetch; error = e }
19201920+ | Ok (was_cloned, _) -> (
19211921+ if was_cloned then Ok (repo_name, true, 0)
19221922+ else
19231923+ match
19241924+ time_phase
19251925+ (Printf.sprintf "fetch:%s" repo_name)
19261926+ (fun () ->
19271927+ fetch_checkout_safe ~sw ~env ~proc
19281928+ ~fs:fs_t ~config ~cache
19291929+ ~get_session pkg)
19301930+ with
19311931+ | Error e ->
19321932+ Error
19331933+ {
19341934+ repo_name;
19351935+ phase = `Fetch;
19361936+ error = e;
19371937+ }
19381938+ | Ok commits ->
19391939+ Ok (repo_name, false, commits)))
19401940+ repos)
19411941+ in
19421942+ let fetch_errs, fetch_successes =
19431943+ List.partition_map
19441944+ (function Error e -> Left e | Ok r -> Right r)
19451945+ fetch_results
19461946+ in
19471947+ let cloned =
19481948+ List.filter (fun (_, c, _) -> c) fetch_successes
19491949+ in
19501950+ let updated =
19511951+ List.filter
19521952+ (fun (_, c, commits) -> (not c) && commits > 0)
19531953+ fetch_successes
19541954+ in
19551955+ let unchanged =
19561956+ List.length fetch_successes
19571957+ - List.length cloned - List.length updated
19581958+ in
19591959+ let commits_pulled =
19601960+ List.fold_left
19611961+ (fun acc (_, _, c) -> acc + c)
19621962+ 0 fetch_successes
19631963+ in
19641964+ Log.app (fun m ->
19651965+ m " Pulled: %d cloned, %d updated, %d unchanged"
19661966+ (List.length cloned) (List.length updated) unchanged);
19671967+19681968+ (* Filter repos to only those that were successfully fetched *)
19691969+ let success_names =
19701970+ List.map (fun (name, _, _) -> name) fetch_successes
19711971+ in
19721972+ let successfully_fetched =
19731973+ List.filter
19741974+ (fun pkg ->
19751975+ List.mem (Package.repo_name pkg) success_names)
19761976+ repos
19771977+ in
19781978+19791979+ (* Step 4: Merge phase - fast-forward merge checkouts (SEQUENTIAL) *)
19801980+ Log.app (fun m -> m " Merging checkouts...");
19811981+ let merge_errs = ref [] in
19821982+ time_phase "merge-phase" (fun () ->
19831983+ List.iter
18991984 (fun pkg ->
19001900- let repo_name = Package.repo_name pkg in
19011901- Log.info (fun m ->
19021902- m "Push to checkout: %s" repo_name);
19031903- match push_one ~proc ~fs ~config pkg with
19041904- | Ok () -> Ok repo_name
19051905- | Error (Git_error e) ->
19061906- Error
19851985+ match
19861986+ time_phase
19871987+ (Printf.sprintf "merge:%s"
19881988+ (Package.repo_name pkg))
19891989+ (fun () ->
19901990+ merge_checkout_safe ~proc ~fs:fs_t ~config
19911991+ pkg)
19921992+ with
19931993+ | Ok () -> ()
19941994+ | Error e ->
19951995+ merge_errs :=
19071996 {
19081908- repo_name;
19091909- phase = `Push_checkout;
19971997+ repo_name = Package.repo_name pkg;
19981998+ phase = `Merge;
19101999 error = e;
19112000 }
19121912- | Error _ -> Ok repo_name)
19131913- to_push
19141914- in
19151915- let skipped_ok =
19161916- List.map
19171917- (fun pkg -> Ok (Package.repo_name pkg))
19181918- to_skip
19191919- in
19201920- pushed @ skipped_ok
19211921- end
19221922- in
19231923- let push_errors =
19241924- List.filter_map
19251925- (function Error e -> Some e | Ok _ -> None)
19261926- push_results
19271927- in
20012001+ :: !merge_errs)
20022002+ successfully_fetched);
1928200319291929- (* Steps 3-5: Pull phases (fetch, merge, subtree) - skip if --skip-pull *)
19301930- let ( fetch_errors,
19311931- unchanged_count,
19321932- total_commits_pulled,
19331933- merge_errors,
19341934- subtree_errors,
19351935- successfully_fetched_repos ) =
19361936- if skip_pull then begin
20042004+ (* Step 5: Subtree phase - pull subtrees into monorepo (SEQUENTIAL) *)
20052005+ (* Check if monorepo has local modifications first *)
20062006+ let monorepo = Config.Paths.monorepo config in
20072007+ let monorepo_dirty =
20082008+ Git_cli.is_dirty ~proc ~fs:fs_t monorepo
20092009+ in
20102010+ let subtree_errs = ref [] in
20112011+ if monorepo_dirty then begin
20122012+ Log.warn (fun m ->
20132013+ m
20142014+ "Monorepo has uncommitted changes, skipping \
20152015+ subtree pulls");
19372016 Log.app (fun m ->
19381938- m " Skipping pull from remotes (--skip-pull)");
19391939- ([], List.length repos, 0, ref [], ref [], repos)
20172017+ m
20182018+ " Skipping subtree updates (local \
20192019+ modifications)...")
19402020 end
19412021 else begin
19421942- (* Step 3: Fetch phase - clone/fetch from remotes (PARALLEL) *)
19431943- Log.app (fun m ->
19441944- m " Fetching from remotes (parallel)...");
19451945- let fetch_results =
19461946- time_phase "fetch-phase" (fun () ->
19471947- Eio.Fiber.List.map ~max_fibers:4
19481948- (fun pkg ->
19491949- let repo_name = Package.repo_name pkg in
19501950- (* First ensure checkout exists *)
19511951- match
19521952- time_phase (Printf.sprintf "ensure-checkout:%s" repo_name) (fun () ->
19531953- ensure_checkout_safe ~proc ~fs:fs_t ~config pkg)
19541954- with
19551955- | Error e ->
19561956- Error { repo_name; phase = `Fetch; error = e }
19571957- | Ok (was_cloned, _) -> (
19581958- if was_cloned then Ok (repo_name, true, 0)
19591959- else
19601960- match
19611961- time_phase (Printf.sprintf "fetch:%s" repo_name) (fun () ->
19621962- fetch_checkout_safe ~sw ~env ~proc ~fs:fs_t
19631963- ~config ~cache ~get_session pkg)
19641964- with
19651965- | Error e ->
19661966- Error
19671967- {
19681968- repo_name;
19691969- phase = `Fetch;
19701970- error = e;
19711971- }
19721972- | Ok commits ->
19731973- Ok (repo_name, false, commits)))
19741974- repos)
19751975- in
19761976- let fetch_errs, fetch_successes =
19771977- List.partition_map
19781978- (function Error e -> Left e | Ok r -> Right r)
19791979- fetch_results
19801980- in
19811981- let cloned =
19821982- List.filter (fun (_, c, _) -> c) fetch_successes
19831983- in
19841984- let updated =
19851985- List.filter
19861986- (fun (_, c, commits) -> (not c) && commits > 0)
20222022+ (* OPTIMIZATION: skip packages already in sync *)
20232023+ (* But always pull repos that received commits from fetch *)
20242024+ let repos_updated_by_fetch =
20252025+ List.filter_map
20262026+ (fun (name, was_cloned, commits) ->
20272027+ if was_cloned || commits > 0 then Some name
20282028+ else None)
19872029 fetch_successes
19882030 in
19891989- let unchanged =
19901990- List.length fetch_successes
19911991- - List.length cloned - List.length updated
20312031+ let needs_pull_after_fetch pkg =
20322032+ needs_pull pkg
20332033+ || List.mem (Package.repo_name pkg)
20342034+ repos_updated_by_fetch
19922035 in
19931993- let commits_pulled =
19941994- List.fold_left
19951995- (fun acc (_, _, c) -> acc + c)
19961996- 0 fetch_successes
20362036+ let to_pull, to_skip =
20372037+ List.partition needs_pull_after_fetch
20382038+ successfully_fetched
19972039 in
19981998- Log.app (fun m ->
19991999- m " Pulled: %d cloned, %d updated, %d unchanged"
20002000- (List.length cloned) (List.length updated)
20012001- unchanged);
20022002-20032003- (* Filter repos to only those that were successfully fetched *)
20042004- let success_names =
20052005- List.map (fun (name, _, _) -> name) fetch_successes
20062006- in
20072007- let successfully_fetched =
20082008- List.filter
20092009- (fun pkg ->
20102010- List.mem (Package.repo_name pkg) success_names)
20112011- repos
20122012- in
20132013-20142014- (* Step 4: Merge phase - fast-forward merge checkouts (SEQUENTIAL) *)
20152015- Log.app (fun m -> m " Merging checkouts...");
20162016- let merge_errs = ref [] in
20172017- time_phase "merge-phase" (fun () ->
20182018- List.iter
20192019- (fun pkg ->
20202020- match
20212021- time_phase (Printf.sprintf "merge:%s" (Package.repo_name pkg)) (fun () ->
20222022- merge_checkout_safe ~proc ~fs:fs_t ~config pkg)
20232023- with
20242024- | Ok () -> ()
20252025- | Error e ->
20262026- merge_errs :=
20402040+ Log.app (fun m -> m " Updating subtrees...");
20412041+ if to_skip <> [] then
20422042+ Log.app (fun m ->
20432043+ m " Skipping %d already-synced subtrees"
20442044+ (List.length to_skip));
20452045+ let pull_count = List.length to_pull in
20462046+ List.iteri
20472047+ (fun i pkg ->
20482048+ Log.info (fun m ->
20492049+ m "[%d/%d] Subtree %s" (i + 1) pull_count
20502050+ (Package.subtree_prefix pkg));
20512051+ match pull_subtree ~proc ~fs ~config pkg with
20522052+ | Ok _ -> ()
20532053+ | Error (Git_error e) ->
20542054+ subtree_errs :=
20272055 {
20282056 repo_name = Package.repo_name pkg;
20292029- phase = `Merge;
20572057+ phase = `Subtree;
20302058 error = e;
20312059 }
20322032- :: !merge_errs)
20332033- successfully_fetched);
20602060+ :: !subtree_errs
20612061+ | Error _ -> ())
20622062+ to_pull
20632063+ end;
20642064+ ( fetch_errs,
20652065+ unchanged,
20662066+ commits_pulled,
20672067+ merge_errs,
20682068+ subtree_errs,
20692069+ successfully_fetched )
20702070+ end
20712071+ in
2034207220352035- (* Step 5: Subtree phase - pull subtrees into monorepo (SEQUENTIAL) *)
20362036- (* Check if monorepo has local modifications first *)
20372037- let monorepo = Config.Paths.monorepo config in
20382038- let monorepo_dirty =
20392039- Git_cli.is_dirty ~proc ~fs:fs_t monorepo
20402040- in
20412041- let subtree_errs = ref [] in
20422042- if monorepo_dirty then begin
20432043- Log.warn (fun m ->
20442044- m
20452045- "Monorepo has uncommitted changes, skipping \
20462046- subtree pulls");
20472047- Log.app (fun m ->
20482048- m
20492049- " Skipping subtree updates (local \
20502050- modifications)...")
20512051- end
20522052- else begin
20532053- (* OPTIMIZATION: skip packages already in sync *)
20542054- (* But always pull repos that received commits from fetch *)
20552055- let repos_updated_by_fetch =
20562056- List.filter_map
20572057- (fun (name, was_cloned, commits) ->
20582058- if was_cloned || commits > 0 then Some name
20592059- else None)
20602060- fetch_successes
20612061- in
20622062- let needs_pull_after_fetch pkg =
20632063- needs_pull pkg
20642064- || List.mem (Package.repo_name pkg)
20652065- repos_updated_by_fetch
20662066- in
20672067- let to_pull, to_skip =
20682068- List.partition needs_pull_after_fetch
20692069- successfully_fetched
20702070- in
20712071- Log.app (fun m -> m " Updating subtrees...");
20722072- if to_skip <> [] then
20732073- Log.app (fun m ->
20742074- m " Skipping %d already-synced subtrees"
20752075- (List.length to_skip));
20762076- let pull_count = List.length to_pull in
20772077- List.iteri
20782078- (fun i pkg ->
20792079- Log.info (fun m ->
20802080- m "[%d/%d] Subtree %s" (i + 1) pull_count
20812081- (Package.subtree_prefix pkg));
20822082- match pull_subtree ~proc ~fs ~config pkg with
20832083- | Ok _ -> ()
20842084- | Error (Git_error e) ->
20852085- subtree_errs :=
20862086- {
20872087- repo_name = Package.repo_name pkg;
20882088- phase = `Subtree;
20892089- error = e;
20902090- }
20912091- :: !subtree_errs
20922092- | Error _ -> ())
20932093- to_pull
20942094- end;
20952095- ( fetch_errs,
20962096- unchanged,
20972097- commits_pulled,
20982098- merge_errs,
20992099- subtree_errs,
21002100- successfully_fetched )
21012101- end
21022102- in
20732073+ (* Step 5.5: Verse remotes - update and fetch from verse members *)
20742074+ (* Skip when syncing a single package for faster operations *)
20752075+ (* Only operate on successfully fetched repos to avoid missing directory errors *)
20762076+ (if Option.is_some package then
20772077+ Log.debug (fun m ->
20782078+ m "Skipping verse remotes (single package sync)")
20792079+ else
20802080+ match Verse_config.load ~fs:(fs_t :> _ Eio.Path.t) () with
20812081+ | Error _ -> () (* No verse config, skip verse remotes *)
20822082+ | Ok verse_config ->
20832083+ time_phase "sync-verse-remotes" (fun () ->
20842084+ sync_verse_remotes ~proc ~fs:fs_t ~config
20852085+ ~verse_config successfully_fetched_repos);
20862086+ (* Fetch from verse remotes in parallel *)
20872087+ Log.app (fun m -> m " Fetching from verse remotes...");
20882088+ time_phase "fetch-verse-remotes" (fun () ->
20892089+ Eio.Fiber.List.iter ~max_fibers:8
20902090+ (fun pkg ->
20912091+ fetch_verse_remotes ~proc ~fs:fs_t ~config pkg)
20922092+ successfully_fetched_repos));
2103209321042104- (* Step 5.5: Verse remotes - update and fetch from verse members *)
21052105- (* Skip when syncing a single package for faster operations *)
21062106- (* Only operate on successfully fetched repos to avoid missing directory errors *)
21072107- (if Option.is_some package then
21082108- Log.debug (fun m -> m "Skipping verse remotes (single package sync)")
21092109- else
21102110- match Verse_config.load ~fs:(fs_t :> _ Eio.Path.t) () with
21112111- | Error _ -> () (* No verse config, skip verse remotes *)
21122112- | Ok verse_config ->
21132113- time_phase "sync-verse-remotes" (fun () ->
21142114- sync_verse_remotes ~proc ~fs:fs_t ~config ~verse_config
21152115- successfully_fetched_repos);
21162116- (* Fetch from verse remotes in parallel *)
21172117- Log.app (fun m -> m " Fetching from verse remotes...");
21182118- time_phase "fetch-verse-remotes" (fun () ->
21192119- Eio.Fiber.List.iter ~max_fibers:4
21202120- (fun pkg ->
21212121- fetch_verse_remotes ~proc ~fs:fs_t ~config pkg)
21222122- successfully_fetched_repos));
21232123-21242124- (* Step 6: Finalize - write README.md, CLAUDE.md, and dune-project (SEQUENTIAL) *)
21252125- (* Skip when syncing a single package for faster operations *)
21262126- (if Option.is_some package then
21272127- Log.debug (fun m -> m "Skipping finalize (single package sync)")
21282128- else begin
20942094+ (* Step 6: Finalize - write README.md, CLAUDE.md, and dune-project (SEQUENTIAL) *)
20952095+ (* Skip when syncing a single package for faster operations *)
20962096+ if Option.is_some package then
20972097+ Log.debug (fun m ->
20982098+ m "Skipping finalize (single package sync)")
20992099+ else begin
21292100 Log.app (fun m ->
21302101 m " Writing README.md, CLAUDE.md, and dune-project...");
21312102 time_phase "write-readme" (fun () ->
···21342105 write_claude_md ~proc ~fs:fs_t ~config);
21352106 time_phase "write-dune-project" (fun () ->
21362107 write_dune_project ~proc ~fs:fs_t ~config all_pkgs)
21372137- end);
21082108+ end;
2138210921392139- (* Step 7: Remote phase - push to upstream remotes if --remote (LIMITED PARALLEL) *)
21402140- (* Only push repos that were successfully fetched *)
21412141- let remote_errors =
21422142- if remote then begin
21432143- Log.app (fun m -> m " Pushing to upstream remotes...");
21442144- (* Limit to 2 concurrent pushes to avoid overwhelming remotes *)
21452145- let push_results =
21462146- Eio.Fiber.List.map ~max_fibers:2
21472147- (fun pkg ->
21482148- let repo_name = Package.repo_name pkg in
21492149- match
21502150- push_remote_safe ~proc ~fs:fs_t ~config pkg
21512151- with
21522152- | Error e ->
21532153- Error
21542154- {
21552155- repo_name;
21562156- phase = `Push_remote;
21572157- error = e;
21582158- }
21592159- | Ok () ->
21602160- Log.app (fun m -> m " Pushed %s" repo_name);
21612161- Ok repo_name)
21622162- successfully_fetched_repos
21632163- in
21642164- let errors, successes =
21652165- List.partition_map
21662166- (function Error e -> Left e | Ok r -> Right r)
21672167- push_results
21682168- in
21692169- Log.app (fun m ->
21702170- m " Pushed: %d repos to upstream"
21712171- (List.length successes));
21722172- errors
21732173- end
21742174- else []
21752175- in
21102110+ (* Step 7: Remote phase - push to upstream remotes if --remote (LIMITED PARALLEL) *)
21112111+ (* Only push repos that were successfully fetched *)
21122112+ let remote_errors =
21132113+ if remote then begin
21142114+ Log.app (fun m -> m " Pushing to upstream remotes...");
21152115+ (* Limit to 2 concurrent pushes to avoid overwhelming remotes *)
21162116+ let push_results =
21172117+ Eio.Fiber.List.map ~max_fibers:2
21182118+ (fun pkg ->
21192119+ let repo_name = Package.repo_name pkg in
21202120+ match
21212121+ push_remote_safe ~proc ~fs:fs_t ~config pkg
21222122+ with
21232123+ | Error e ->
21242124+ Error
21252125+ { repo_name; phase = `Push_remote; error = e }
21262126+ | Ok () ->
21272127+ Log.app (fun m -> m " Pushed %s" repo_name);
21282128+ Ok repo_name)
21292129+ successfully_fetched_repos
21302130+ in
21312131+ let errors, successes =
21322132+ List.partition_map
21332133+ (function Error e -> Left e | Ok r -> Right r)
21342134+ push_results
21352135+ in
21362136+ Log.app (fun m ->
21372137+ m " Pushed: %d repos to upstream"
21382138+ (List.length successes));
21392139+ errors
21402140+ end
21412141+ else []
21422142+ in
2176214321772177- (* Collect all errors *)
21782178- let all_errors =
21792179- push_errors @ fetch_errors @ !merge_errors
21802180- @ !subtree_errors @ remote_errors
21812181- in
21822182- let summary =
21832183- {
21842184- repos_synced =
21852185- List.length repos - List.length all_errors;
21862186- repos_unchanged = unchanged_count;
21872187- commits_pulled = total_commits_pulled;
21882188- commits_pushed = 0;
21892189- (* TODO: track this *)
21902190- errors = all_errors;
21912191- }
21922192- in
21442144+ (* Collect all errors *)
21452145+ let all_errors =
21462146+ push_errors @ fetch_errors @ !merge_errors @ !subtree_errors
21472147+ @ remote_errors
21482148+ in
21492149+ let summary =
21502150+ {
21512151+ repos_synced = List.length repos - List.length all_errors;
21522152+ repos_unchanged = unchanged_count;
21532153+ commits_pulled = total_commits_pulled;
21542154+ commits_pushed = 0;
21552155+ (* TODO: track this *)
21562156+ errors = all_errors;
21572157+ }
21582158+ in
2193215921942194- (* Print summary *)
21952195- Log.app (fun m ->
21962196- m "@.Summary: %d synced, %d errors" summary.repos_synced
21972197- (List.length summary.errors));
21982198- if summary.errors <> [] then
21992199- List.iter
22002200- (fun e ->
22012201- Log.warn (fun m -> m " %a" pp_sync_failure e))
22022202- summary.errors;
21602160+ (* Print summary *)
21612161+ Log.app (fun m ->
21622162+ m "@.Summary: %d synced, %d errors" summary.repos_synced
21632163+ (List.length summary.errors));
21642164+ if summary.errors <> [] then
21652165+ List.iter
21662166+ (fun e -> Log.warn (fun m -> m " %a" pp_sync_failure e))
21672167+ summary.errors;
2203216822042204- Ok summary
22052205- end
22062206- end))
21692169+ Ok summary
21702170+ end
21712171+ end))
2207217222082173(* Opam metadata sync: copy .opam files from monorepo subtrees to opam-repo *)
22092174
+8-6
lib/monopam.mli
···4040module Sources_registry = Sources_registry
4141module Fork_join = Fork_join
4242module Site = Site
4343+module Remote_cache = Remote_cache
43444445(** {1 High-Level Operations} *)
4546···167168168169val sync :
169170 sw:Eio.Switch.t ->
170170- env:< clock : _ Eio.Time.clock
171171- ; net : _ Eio.Net.t
172172- ; fs : Eio.Fs.dir_ty Eio.Path.t
173173- ; .. > ->
171171+ env:
172172+ < clock : _ Eio.Time.clock
173173+ ; net : _ Eio.Net.t
174174+ ; fs : Eio.Fs.dir_ty Eio.Path.t
175175+ ; .. > ->
174176 proc:_ Eio.Process.mgr ->
175177 fs:Eio.Fs.dir_ty Eio.Path.t ->
176178 config:Config.t ->
···182184 ?skip_verse:bool ->
183185 unit ->
184186 (sync_summary, error) result
185185-(** [sync ~sw ~env ~proc ~fs ~config ~xdg ?package ?remote ?skip_push ?skip_pull ?skip_verse ()]
186186- synchronizes the monorepo with upstream repositories.
187187+(** [sync ~sw ~env ~proc ~fs ~config ~xdg ?package ?remote ?skip_push ?skip_pull
188188+ ?skip_verse ()] synchronizes the monorepo with upstream repositories.
187189188190 This is the primary command for all sync operations. It performs both push
189191 and pull operations in the correct order: 1. Validate: check for dirty state
+91
lib/remote_cache.ml
···11+(* Copyright (c) 2024-2026 Thomas Gazagnaire <thomas@gazagnaire.org>
22+33+ Permission to use, copy, modify, and distribute this software for any
44+ purpose with or without fee is hereby granted, provided that the above
55+ copyright notice and this permission notice appear in all copies.
66+77+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
88+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
99+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
1010+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
1111+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
1212+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
1313+ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. *)
1414+1515+(** Remote HEAD cache with O(1) in-memory lookup and optional disk persistence.
1616+1717+ Uses a Hashtbl for O(1) amortized lookup by key (url:branch). Entries expire
1818+ after a configurable TTL (default 5 minutes).
1919+2020+ File format: one entry per line "url:branch hash timestamp" *)
2121+2222+let src = Logs.Src.create "monopam.remote_cache" ~doc:"Remote HEAD cache"
2323+2424+module Log = (val Logs.src_log src : Logs.LOG)
2525+2626+type entry = { hash : string; expires : float }
2727+type t = { tbl : (string, entry) Hashtbl.t; ttl : float; now : unit -> float }
2828+2929+let default_ttl = 300.0 (* 5 minutes *)
3030+let make_key url branch = Fmt.str "%s:%s" (Uri.to_string url) branch
3131+3232+let parse_line ~ttl line =
3333+ match String.split_on_char ' ' line with
3434+ | [ key; hash; ts ] ->
3535+ let ts = float_of_string ts in
3636+ Some (key, { hash; expires = ts +. ttl })
3737+ | _ -> None
3838+3939+let load_from_string ~ttl content =
4040+ let tbl = Hashtbl.create 32 in
4141+ String.split_on_char '\n' content
4242+ |> List.iter (fun line ->
4343+ match parse_line ~ttl line with
4444+ | Some (key, entry) -> Hashtbl.replace tbl key entry
4545+ | None -> ());
4646+ tbl
4747+4848+let to_string t =
4949+ let now = t.now () in
5050+ let lines =
5151+ Hashtbl.fold
5252+ (fun key entry acc ->
5353+ if entry.expires > now then
5454+ let ts = entry.expires -. t.ttl in
5555+ Fmt.str "%s %s %.0f" key entry.hash ts :: acc
5656+ else acc)
5757+ t.tbl []
5858+ in
5959+ String.concat "\n" lines ^ "\n"
6060+6161+let create ?(ttl = default_ttl) ~now () =
6262+ let tbl = Hashtbl.create 32 in
6363+ { tbl; ttl; now }
6464+6565+let create_from_string ?(ttl = default_ttl) ~now content =
6666+ let tbl = load_from_string ~ttl content in
6767+ { tbl; ttl; now }
6868+6969+let get t ~url ~branch =
7070+ let key = make_key url branch in
7171+ let now = t.now () in
7272+ match Hashtbl.find_opt t.tbl key with
7373+ | Some entry when entry.expires > now ->
7474+ Log.debug (fun m ->
7575+ m "Cache hit for %s (expires in %.0fs)" key (entry.expires -. now));
7676+ Some entry.hash
7777+ | Some entry ->
7878+ Log.debug (fun m ->
7979+ m "Cache expired for %s (%.0fs ago)" key (now -. entry.expires));
8080+ None
8181+ | None ->
8282+ Log.debug (fun m -> m "Cache miss for %s (not found)" key);
8383+ None
8484+8585+let set t ~url ~branch ~hash =
8686+ let key = make_key url branch in
8787+ let expires = t.now () +. t.ttl in
8888+ Hashtbl.replace t.tbl key { hash; expires }
8989+9090+let size t = Hashtbl.length t.tbl
9191+let clear t = Hashtbl.clear t.tbl
+76
lib/remote_cache.mli
···11+(* Copyright (c) 2024-2026 Thomas Gazagnaire <thomas@gazagnaire.org>
22+33+ Permission to use, copy, modify, and distribute this software for any
44+ purpose with or without fee is hereby granted, provided that the above
55+ copyright notice and this permission notice appear in all copies.
66+77+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
88+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
99+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
1010+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
1111+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
1212+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
1313+ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. *)
1414+1515+(** Remote HEAD cache with O(1) in-memory lookup.
1616+1717+ This module provides an in-memory cache for remote git HEAD refs with
1818+ time-based expiration. Uses a Hashtbl for O(1) amortized lookup.
1919+2020+ {2 Example with mock time}
2121+2222+ {[
2323+ let time = ref 0.0 in
2424+ let now () = !time in
2525+ let cache = Remote_cache.create ~ttl:60.0 ~now () in
2626+2727+ (* Set a value *)
2828+ let url = Uri.of_string "https://github.com/ocaml/ocaml.git" in
2929+ Remote_cache.set cache ~url ~branch:"trunk" ~hash:"abc123";
3030+3131+ (* Get it back immediately *)
3232+ assert (Remote_cache.get cache ~url ~branch:"trunk" = Some "abc123");
3333+3434+ (* Advance time past TTL *)
3535+ time := 61.0;
3636+ assert (Remote_cache.get cache ~url ~branch:"trunk" = None)
3737+ ]} *)
3838+3939+type t
4040+(** The cache type. *)
4141+4242+val default_ttl : float
4343+(** Default TTL in seconds (300.0 = 5 minutes). *)
4444+4545+val create : ?ttl:float -> now:(unit -> float) -> unit -> t
4646+(** [create ~ttl ~now ()] creates a new empty cache.
4747+4848+ @param ttl Time-to-live in seconds (default {!default_ttl})
4949+ @param now Function to get current time in seconds *)
5050+5151+val create_from_string : ?ttl:float -> now:(unit -> float) -> string -> t
5252+(** [create_from_string ~ttl ~now content] creates a cache populated from
5353+ serialized content.
5454+5555+ @param ttl Time-to-live in seconds (default {!default_ttl})
5656+ @param now Function to get current time in seconds
5757+ @param content Serialized cache content from {!to_string} *)
5858+5959+val get : t -> url:Uri.t -> branch:string -> string option
6060+(** [get t ~url ~branch] returns the cached hash if present and not expired.
6161+ O(1) amortized time complexity. *)
6262+6363+val set : t -> url:Uri.t -> branch:string -> hash:string -> unit
6464+(** [set t ~url ~branch ~hash] adds or updates a cache entry. O(1) amortized
6565+ time complexity. *)
6666+6767+val to_string : t -> string
6868+(** [to_string t] serializes the cache to a string for disk persistence. Format:
6969+ one entry per line "url:branch hash timestamp". Expired entries are not
7070+ included. *)
7171+7272+val size : t -> int
7373+(** [size t] returns the number of entries in the cache. *)
7474+7575+val clear : t -> unit
7676+(** [clear t] removes all entries from the cache. *)
+77-76
lib/status.ml
···11-type checkout_status =
22- | Missing
33- | Not_a_repo
44- | Dirty
55- | Clean of Git_cli.ahead_behind
66-11+type ahead_behind = { ahead : int; behind : int }
22+type checkout_status = Missing | Not_a_repo | Dirty | Clean of ahead_behind
73type subtree_status = Not_added | Present
8495(** Sync state between monorepo subtree and local checkout *)
···2723 let dir, _ = fs in
2824 (dir, "")
29253030-let compute ~proc ~fs ~config pkg =
3131- let checkouts_root = Config.Paths.checkouts config in
2626+(** Check if a directory exists *)
2727+let dir_exists fs path =
2828+ let eio_path = Eio.Path.(fs / Fpath.to_string path) in
2929+ match Eio.Path.kind ~follow:true eio_path with
3030+ | `Directory -> true
3131+ | _ -> false
3232+ | exception Eio.Io _ -> false
3333+3434+let to_ahead_behind (ab : Git.Repository.ahead_behind) =
3535+ { ahead = ab.ahead; behind = ab.behind }
3636+3737+(** Pre-compute all subtree hashes from mono repo's HEAD *)
3838+let get_subtree_hashes ~fs ~monorepo =
3939+ let mono_repo = Git.Repository.open_repo ~fs (Fpath.to_string monorepo) in
4040+ match Git.Repository.read_ref mono_repo "HEAD" with
4141+ | None -> Hashtbl.create 0
4242+ | Some commit_hash -> (
4343+ match Git.Repository.read mono_repo commit_hash with
4444+ | Error _ -> Hashtbl.create 0
4545+ | Ok (Git.Value.Commit c) ->
4646+ let root_tree_hash = Git.Commit.tree c in
4747+ let tbl = Hashtbl.create 128 in
4848+ (* Read root tree and cache all subtree hashes *)
4949+ (match Git.Repository.read mono_repo root_tree_hash with
5050+ | Ok (Git.Value.Tree tree) ->
5151+ List.iter
5252+ (fun (e : Git.Tree.entry) ->
5353+ if e.perm = `Dir then Hashtbl.add tbl e.name e.hash)
5454+ (Git.Tree.to_list tree)
5555+ | _ -> ());
5656+ tbl
5757+ | Ok _ -> Hashtbl.create 0)
5858+5959+(** Internal: compute status for a single package with pre-computed subtree
6060+ hashes *)
6161+let compute_one ~fs ~checkouts_root ~monorepo ~subtree_hashes pkg =
3262 let checkout_dir = Package.checkout_dir ~checkouts_root pkg in
3333- let monorepo = Config.Paths.monorepo config in
3463 let prefix = Package.subtree_prefix pkg in
3535- let fs_t = fs_typed fs in
3636- let fs_dir =
3737- let dir, _ = fs in
3838- (dir, Fpath.to_string checkout_dir)
3939- in
6464+ let checkout_path = Fpath.to_string checkout_dir in
4065 let checkout =
4141- match Eio.Path.kind ~follow:true fs_dir with
4242- | exception Eio.Io _ -> Missing
4343- | `Directory -> (
4444- if not (Git_cli.is_repo ~proc ~fs:fs_t checkout_dir) then Not_a_repo
4545- else if Git_cli.is_dirty ~proc ~fs:fs_t checkout_dir then Dirty
4646- else
4747- match Git_cli.ahead_behind ~proc ~fs:fs_t checkout_dir with
4848- | Ok ab -> Clean ab
4949- | Error _ -> Clean { ahead = 0; behind = 0 })
5050- | _ -> Missing
6666+ if not (dir_exists fs checkout_dir) then Missing
6767+ else if not (Git.Repository.is_repo ~fs checkout_path) then Not_a_repo
6868+ else
6969+ let repo = Git.Repository.open_repo ~fs checkout_path in
7070+ if Git.Repository.is_dirty repo then Dirty
7171+ else
7272+ let branch =
7373+ match Git.Repository.current_branch repo with
7474+ | Some b -> b
7575+ | None -> "main"
7676+ in
7777+ match Git.Repository.ahead_behind repo ~branch () with
7878+ | Some ab -> Clean (to_ahead_behind ab)
7979+ | None -> Clean { ahead = 0; behind = 0 }
5180 in
5252- let subtree =
5353- if Git_cli.subtree_prefix_exists ~fs:fs_t ~repo:monorepo ~prefix then Present
5454- else Not_added
5555- in
5656- (* Compute subtree sync state: compare tree content between monorepo subtree and checkout.
5757- This is more accurate than commit ancestry because it handles both push and pull directions.
5858- If the trees match, the content is in sync regardless of how it got there. *)
8181+ let subtree_dir = Fpath.(monorepo / prefix) in
8282+ let subtree = if dir_exists fs subtree_dir then Present else Not_added in
5983 let subtree_sync =
6084 match (checkout, subtree) with
6185 | (Missing | Not_a_repo | Dirty), _ -> Unknown
6286 | _, Not_added -> Unknown
6387 | Clean _, Present -> (
6464- (* Get tree hash of subtree directory in monorepo *)
6565- let subtree_tree =
6666- Git_cli.rev_parse ~proc ~fs:fs_t ~rev:("HEAD:" ^ prefix) monorepo
6767- in
6868- (* Get tree hash of checkout root *)
8888+ let checkout_repo = Git.Repository.open_repo ~fs checkout_path in
8989+ let subtree_tree = Hashtbl.find_opt subtree_hashes prefix in
6990 let checkout_tree =
7070- Git_cli.rev_parse ~proc ~fs:fs_t ~rev:"HEAD^{tree}" checkout_dir
9191+ Git.Repository.tree_hash_at_path checkout_repo ~rev:"HEAD" ~path:""
7192 in
7293 match (subtree_tree, checkout_tree) with
7373- | Ok st, Ok ct when st = ct -> In_sync
7474- | Ok _, Ok _ -> (
7575- (* Trees differ - check commit ancestry to determine direction *)
7676- let subtree_commit =
7777- Git_cli.subtree_last_upstream_commit ~proc ~fs:fs_t ~repo:monorepo
7878- ~prefix ()
7979- in
8080- let checkout_head =
8181- Git_cli.head_commit ~proc ~fs:fs_t checkout_dir
8282- in
8383- match (subtree_commit, checkout_head) with
8484- | Some subtree_sha, Ok checkout_sha ->
8585- if
8686- Git_cli.is_ancestor ~proc ~fs:fs_t ~repo:checkout_dir
8787- ~commit1:subtree_sha ~commit2:checkout_sha ()
8888- then
8989- (* Checkout has commits not in subtree - need subtree pull *)
9090- let count =
9191- Git_cli.count_commits_between ~proc ~fs:fs_t
9292- ~repo:checkout_dir ~base:subtree_sha ~head:checkout_sha ()
9393- in
9494- if count > 0 then Subtree_behind count else Trees_differ
9595- (* Same commit but trees differ - monorepo has changes *)
9696- else if
9797- Git_cli.is_ancestor ~proc ~fs:fs_t ~repo:checkout_dir
9898- ~commit1:checkout_sha ~commit2:subtree_sha ()
9999- then
100100- (* Subtree has content not in checkout - need push *)
101101- let count =
102102- Git_cli.count_commits_between ~proc ~fs:fs_t
103103- ~repo:checkout_dir ~base:checkout_sha ~head:subtree_sha ()
104104- in
105105- if count > 0 then Subtree_ahead count else Trees_differ
106106- else Trees_differ (* Diverged *)
107107- | _ -> Trees_differ
108108- (* Trees differ but can't determine ancestry *))
9494+ | Some st, Some ct when Git.Hash.equal st ct -> In_sync
9595+ | Some _, Some _ -> Trees_differ
10996 | _ -> Unknown)
11097 in
11198 { package = pkg; checkout; subtree; subtree_sync }
11299113113-let compute_all ~proc ~fs ~config packages =
114114- List.map (compute ~proc ~fs ~config) packages
100100+let compute ~fs ~config pkg =
101101+ let fs_t = fs_typed fs in
102102+ let checkouts_root = Config.Paths.checkouts config in
103103+ let monorepo = Config.Paths.monorepo config in
104104+ let subtree_hashes = get_subtree_hashes ~fs:fs_t ~monorepo in
105105+ compute_one ~fs:fs_t ~checkouts_root ~monorepo ~subtree_hashes pkg
106106+107107+let compute_all ~fs ~config packages =
108108+ let fs_t = fs_typed fs in
109109+ let checkouts_root = Config.Paths.checkouts config in
110110+ let monorepo = Config.Paths.monorepo config in
111111+ (* Pre-compute all subtree hashes once *)
112112+ let subtree_hashes = get_subtree_hashes ~fs:fs_t ~monorepo in
113113+ Eio.Fiber.List.map ~max_fibers:8
114114+ (compute_one ~fs:fs_t ~checkouts_root ~monorepo ~subtree_hashes)
115115+ packages
115116116117let is_checkout_clean t = match t.checkout with Clean _ -> true | _ -> false
117118let has_local_changes t = match t.checkout with Dirty -> true | _ -> false
···220221 let entry = Option.bind sources (fun s -> Sources_registry.find s ~subtree) in
221222 (* Helper to print remote sync info *)
222223 let pp_remote ab =
223223- if ab.Git_cli.ahead > 0 && ab.behind > 0 then
224224+ if ab.ahead > 0 && ab.behind > 0 then
224225 Fmt.pf ppf " %a"
225226 Fmt.(styled `Yellow (fun ppf (a, b) -> pf ppf "remote:+%d/-%d" a b))
226227 (ab.ahead, ab.behind)
+10-26
lib/status.mli
···11(** Status computation and display.
2233 This module computes the synchronization status of packages across the three
44- locations: git remote, individual checkout, and monorepo subtree. *)
44+ locations: git remote, individual checkout, and monorepo subtree. Uses
55+ native OCaml git library for fast in-process operations. *)
5667(** {1 Types} *)
7899+type ahead_behind = { ahead : int; behind : int }
1010+(** Commits ahead/behind relative to upstream. *)
1111+812(** Status of an individual checkout relative to its remote. *)
913type checkout_status =
1014 | Missing (** Checkout directory does not exist *)
1115 | Not_a_repo (** Directory exists but is not a git repository *)
1216 | Dirty (** Has uncommitted changes *)
1313- | Clean of Git_cli.ahead_behind
1717+ | Clean of ahead_behind
1418 (** Clean with ahead/behind info relative to remote *)
15191620(** Status of a subtree in the monorepo. *)
···40444145(** {1 Status Computation} *)
42464343-val compute :
4444- proc:_ Eio.Process.mgr ->
4545- fs:Eio.Fs.dir_ty Eio.Path.t ->
4646- config:Config.t ->
4747- Package.t ->
4848- t
4949-(** [compute ~proc ~fs ~config pkg] computes the status of a single package.
5050-5151- @param proc Eio process manager
5252- @param fs Eio filesystem
5353- @param config Monopam configuration
5454- @param pkg Package to check *)
4747+val compute : fs:Eio.Fs.dir_ty Eio.Path.t -> config:Config.t -> Package.t -> t
4848+(** [compute ~fs ~config pkg] computes the status of a single package. *)
55495650val compute_all :
5757- proc:_ Eio.Process.mgr ->
5858- fs:Eio.Fs.dir_ty Eio.Path.t ->
5959- config:Config.t ->
6060- Package.t list ->
6161- t list
6262-(** [compute_all ~proc ~fs ~config packages] computes status for all packages in
6363- parallel.
6464-6565- @param proc Eio process manager
6666- @param fs Eio filesystem
6767- @param config Monopam configuration
6868- @param packages List of packages to check *)
5151+ fs:Eio.Fs.dir_ty Eio.Path.t -> config:Config.t -> Package.t list -> t list
5252+(** [compute_all ~fs ~config packages] computes status for all packages. *)
69537054(** {1 Predicates} *)
7155
+2-1
lib/verse.ml
···322322 let verse_dir = Verse_config.verse_path config in
323323 ensure_dir ~fs verse_dir;
324324 Logs.info (fun m -> m "Syncing %d members" (List.length members));
325325+ (* Sync all members in parallel *)
325326 let errors =
326326- List.filter_map
327327+ Eio.Fiber.List.filter_map ~max_fibers:4
327328 (fun (member : Verse_registry.member) ->
328329 let h = member.handle in
329330 let mono_path = Fpath.(verse_dir / h) in