···4455module Log = (val Logs.src_log src)
6677-type result = {
88- name : string;
99- duration : float;
1010- status : [ `Ok | `Slow | `Fail | `Timeout ];
1111-}
77+type status = [ `Ok | `Slow | `Fail | `Timeout | `Build_fail ]
88+type result = { name : string; duration : float; status : status }
1291310let status_to_string = function
1411 | `Ok -> "OK"
1512 | `Slow -> "SLOW"
1613 | `Fail -> "FAIL"
1714 | `Timeout -> "TIMEOUT"
1515+ | `Build_fail -> "FAIL (build)"
1616+1717+let run_cmd ~process_mgr cmd =
1818+ let args = [ "sh"; "-c"; cmd ] in
1919+ Eio.Switch.run @@ fun sw ->
2020+ let proc = Eio.Process.spawn ~sw process_mgr args in
2121+ Eio.Process.await proc
18221923let run_test_eio ~process_mgr ~timeout dir =
2024 let cmd = Printf.sprintf "timeout %ds dune test %s 2>&1" timeout dir in
2121- Log.info (fun m -> m "Running: %s" cmd);
2525+ Log.debug (fun m -> m "Running: %s" cmd);
2226 let start = Unix.gettimeofday () in
2323- let args = [ "sh"; "-c"; cmd ] in
2424- Eio.Switch.run @@ fun sw ->
2525- let proc = Eio.Process.spawn ~sw process_mgr args in
2626- let status = Eio.Process.await proc in
2727+ let status = run_cmd ~process_mgr cmd in
2728 let duration = Unix.gettimeofday () -. start in
2829 let status =
2930 match status with
···3132 | `Exited 124 -> `Timeout
3233 | _ -> `Fail
3334 in
3434- Log.info (fun m ->
3535+ Log.debug (fun m ->
3536 m " %s: %s (%.2fs)" dir (status_to_string status) duration);
3637 { name = dir; duration; status }
37383838-let find_test_dirs () =
3939- let cmd =
4040- "find . -name 'test' -type d -path '*/test' | grep -v _build | grep -v \
4141- _opam | sort"
4242- in
4343- let ic = Unix.open_process_in cmd in
4444- let lines = In_channel.input_all ic |> String.split_on_char '\n' in
4545- let _ = Unix.close_process_in ic in
4646- lines
4747- |> List.filter (fun s -> s <> "")
4848- |> List.filter_map (fun path ->
4949- match String.split_on_char '/' path with
5050- | [ "."; dir; "test" ] -> Some dir
5151- | _ -> None)
5252- |> List.sort_uniq String.compare
3939+let find_test_dirs ~fs =
4040+ let cwd = Eio.Path.(fs / ".") in
4141+ Eio.Path.read_dir cwd
4242+ |> List.filter (fun d ->
4343+ d.[0] <> '_'
4444+ && d.[0] <> '.'
4545+ && Eio.Path.kind ~follow:true Eio.Path.(cwd / d) = `Directory
4646+ && Eio.Path.kind ~follow:true Eio.Path.(cwd / d / "test") = `Directory)
4747+ |> List.sort String.compare
53485449let status_style = function
5550 | `Ok -> Tty.Style.(fg (Tty.Color.ansi `Green))
5651 | `Slow -> Tty.Style.(fg (Tty.Color.ansi `Yellow))
5757- | `Fail -> Tty.Style.(fg (Tty.Color.ansi `Red))
5858- | `Timeout -> Tty.Style.(fg (Tty.Color.ansi `Red))
5252+ | `Fail | `Timeout | `Build_fail -> Tty.Style.(fg (Tty.Color.ansi `Red))
59536054let styled style s = Fmt.str "%a" (Tty.Style.styled style Fmt.string) s
61556262-let prebuild ~process_mgr dirs =
6363- (* Pre-build all test directories to warm up dune cache *)
6464- let targets = String.concat " " dirs in
6565- let cmd = Printf.sprintf "dune build %s 2>/dev/null" targets in
6666- Log.info (fun m -> m "Running: %s" cmd);
6767- let args = [ "sh"; "-c"; cmd ] in
6868- Eio.Switch.run @@ fun sw ->
6969- let proc = Eio.Process.spawn ~sw process_mgr args in
7070- let _ = Eio.Process.await proc in
7171- ()
5656+let prebuild ~process_mgr ~timeout dirs =
5757+ let total = List.length dirs in
5858+ let bar = Tty.Progress.create ~total "Building" in
5959+ let build_failures =
6060+ List.filter_map
6161+ (fun dir ->
6262+ Tty.Progress.update bar ~phase:"build" ~msg:dir;
6363+ let cmd = Printf.sprintf "timeout %ds dune build %s 2>&1" timeout dir in
6464+ Log.debug (fun m -> m "Running: %s" cmd);
6565+ let start = Unix.gettimeofday () in
6666+ let status = run_cmd ~process_mgr cmd in
6767+ let duration = Unix.gettimeofday () -. start in
6868+ match status with
6969+ | `Exited 0 ->
7070+ Log.debug (fun m -> m " %s: built (%.2fs)" dir duration);
7171+ None
7272+ | `Exited 124 ->
7373+ Log.info (fun m -> m " %s: build timed out after %ds" dir timeout);
7474+ Some { name = dir; duration; status = `Build_fail }
7575+ | _ ->
7676+ Log.info (fun m -> m " %s: build failed (%.2fs)" dir duration);
7777+ Some { name = dir; duration; status = `Build_fail })
7878+ dirs
7979+ in
8080+ Tty.Progress.finish bar;
8181+ build_failures
72827383let run_tests_sequential ~process_mgr ~timeout dirs =
7474- (* Run tests sequentially - they share the dune build lock *)
7575- List.map (run_test_eio ~process_mgr ~timeout) dirs
8484+ let total = List.length dirs in
8585+ let bar = Tty.Progress.create ~total "Testing" in
8686+ let results =
8787+ List.map
8888+ (fun dir ->
8989+ Tty.Progress.update bar ~phase:"test" ~msg:dir;
9090+ run_test_eio ~process_mgr ~timeout dir)
9191+ dirs
9292+ in
9393+ Tty.Progress.finish bar;
9494+ results
76957796let run timeout filter () =
7897 Eio_main.run @@ fun env ->
7998 let process_mgr = Eio.Stdenv.process_mgr env in
8080- let dirs = find_test_dirs () in
9999+ let fs = Eio.Stdenv.fs env in
100100+ let dirs = find_test_dirs ~fs in
81101 let dirs =
82102 match filter with
83103 | [] -> dirs
84104 | fs -> List.filter (fun d -> List.mem d fs) dirs
85105 in
86106 Log.info (fun m -> m "Testing %d directories" (List.length dirs));
8787- List.iter (fun d -> Log.info (fun m -> m " - %s" d)) dirs;
8888- Log.info (fun m -> m "Pre-building test targets...");
8989- prebuild ~process_mgr dirs;
9090- Log.info (fun m -> m "Running tests sequentially (timeout: %ds)..." timeout);
9191- let results = run_tests_sequential ~process_mgr ~timeout dirs in
107107+ List.iter (fun d -> Log.debug (fun m -> m " - %s" d)) dirs;
108108+ let build_failures = prebuild ~process_mgr ~timeout dirs in
109109+ let failed_dirs = List.map (fun r -> r.name) build_failures in
110110+ let testable_dirs =
111111+ List.filter (fun d -> not (List.mem d failed_dirs)) dirs
112112+ in
113113+ Log.info (fun m ->
114114+ m "Running tests on %d directories (timeout: %ds)..."
115115+ (List.length testable_dirs)
116116+ timeout);
117117+ let test_results = run_tests_sequential ~process_mgr ~timeout testable_dirs in
118118+ let results = build_failures @ test_results in
92119 let pp_result r =
93120 let status_str =
94121 styled (status_style r.status) (status_to_string r.status)
···98125 List.iter pp_result results;
99126 let slow = List.filter (fun r -> r.status = `Slow) results in
100127 let fail =
101101- List.filter (fun r -> r.status = `Fail || r.status = `Timeout) results
128128+ List.filter
129129+ (fun r ->
130130+ r.status = `Fail || r.status = `Timeout || r.status = `Build_fail)
131131+ results
102132 in
103133 Fmt.pr "@.";
104134 if fail <> [] then
···107137 (List.length fail);
108138 if slow <> [] then
109139 Fmt.pr "%s %d tests slow (>2s)@."
110110- (styled (status_style `Slow) "⚠")
140140+ (styled (status_style `Slow) "!")
111141 (List.length slow);
112142 if fail = [] && slow = [] then
113143 Fmt.pr "%s All %d tests OK@."
···122152 `S Manpage.s_description;
123153 `P
124154 "Runs $(b,dune test) on each directory in the monorepo and reports \
125125- which pass, fail, or are slow (>2s). Tests are prebuilt then run \
155155+ which pass, fail, or are slow (>2s). Each directory is built \
156156+ individually first to detect build failures, then tests are run \
126157 sequentially.";
127158 `S "EXAMPLES";
128159 `Pre "monopam test";
160160+ `Pre "monopam test -vv";
129161 `Pre "monopam test ocaml-qemu ocaml-bloom";
130162 `Pre "monopam test --timeout 120";
131163 ]
132164 in
133165 let info = Cmd.info "test" ~doc ~man in
134166 let timeout_arg =
135135- let doc = "Timeout in seconds per test directory." in
167167+ let doc = "Timeout in seconds per build/test directory." in
136168 Arg.(value & opt int 60 & info [ "timeout"; "t" ] ~docv:"SECONDS" ~doc)
137169 in
138170 let filter_arg =
···854854 let git_repo = Git.Repository.open_repo ~fs repo in
855855 let commit = Git.Hash.of_hex hash_hex in
856856 let user =
857857- match Git_cli.global_git_user () with
857857+ match Git_cli.global_git_user ~fs () with
858858 | Some u -> u
859859 | None ->
860860 Git.User.v ~name:"monopam" ~email:"monopam@localhost"
···11541154 let git_repo = Git.Repository.open_repo ~fs monorepo in
11551155 let commit = Git.Hash.of_hex hash_hex in
11561156 let user =
11571157- match Git_cli.global_git_user () with
11571157+ match Git_cli.global_git_user ~fs () with
11581158 | Some u -> u
11591159 | None ->
11601160 Git.User.v ~name:"monopam" ~email:"monopam@localhost"
+46-64
lib/git_cli.ml
···11let src = Logs.Src.create "monopam.git_cli" ~doc:"Git CLI operations"
2233-(** Read user info from global git config. *)
44-let global_git_user () =
55- let read_config key =
66- let ic = Unix.open_process_in (Fmt.str "git config %s" key) in
77- let value =
88- try Some (String.trim (input_line ic)) with End_of_file -> None
99- in
1010- ignore (Unix.close_process_in ic);
1111- value
1212- in
1313- match (read_config "user.name", read_config "user.email") with
1414- | Some name, Some email ->
1515- let date = Int64.of_float (Unix.gettimeofday ()) in
1616- Some (Git.User.v ~name ~email ~date ())
1717- | _ -> None
1818-193type cmd_result = { exit_code : int; stdout : string; stderr : string }
204215type error =
···6953 if result.exit_code = 0 then Ok result.stdout
7054 else Error (Command_failed (String.concat " " ("git" :: args), result))
71557272-(** Helper for substring check *)
7373-let string_contains ~needle haystack =
7474- let needle_len = String.length needle in
7575- let haystack_len = String.length haystack in
7676- if needle_len > haystack_len then false
7777- else
7878- let rec check i =
7979- if i + needle_len > haystack_len then false
8080- else if String.sub haystack i needle_len = needle then true
8181- else check (i + 1)
8282- in
8383- check 0
5656+(** Read user info from global git config (~/.gitconfig). *)
5757+let global_git_user ~fs () =
5858+ let path = Eio.Path.(Xdge.home_dir fs / ".gitconfig") in
5959+ match Eio.Path.load path with
6060+ | content -> (
6161+ let config = Git.Config.of_string content in
6262+ let user = Git.Config.get_user config in
6363+ match (user.name, user.email) with
6464+ | Some name, Some email ->
6565+ let date = Int64.of_float (Unix.gettimeofday ()) in
6666+ Some (Git.User.v ~name ~email ~date ())
6767+ | _ -> None)
6868+ | exception Eio.Io _ -> None
84698585-(** Patterns indicating retryable HTTP 5xx or network errors *)
8686-let retryable_error_patterns =
8787- [
8888- (* HTTP 5xx errors *)
8989- "500";
9090- "502";
9191- "503";
9292- "504";
9393- "HTTP 5";
9494- "http 5";
9595- "Internal Server Error";
9696- "Bad Gateway";
9797- "Service Unavailable";
9898- "Gateway Timeout";
9999- (* RPC failures (common git smart HTTP errors) *)
100100- "RPC failed";
101101- "curl";
102102- "unexpected disconnect";
103103- "the remote end hung up";
104104- "early EOF";
105105- (* Connection errors *)
106106- "Connection refused";
107107- "Connection reset";
108108- "Connection timed out";
109109- "Could not resolve host";
110110- "Failed to connect";
111111- "Network is unreachable";
112112- "Temporary failure";
113113- ]
7070+(** Retryable HTTP 5xx and network error patterns, compiled once. *)
7171+let retryable_re =
7272+ let open Re in
7373+ let patterns =
7474+ [
7575+ "500";
7676+ "502";
7777+ "503";
7878+ "504";
7979+ "HTTP 5";
8080+ "http 5";
8181+ "Internal Server Error";
8282+ "Bad Gateway";
8383+ "Service Unavailable";
8484+ "Gateway Timeout";
8585+ "RPC failed";
8686+ "curl";
8787+ "unexpected disconnect";
8888+ "the remote end hung up";
8989+ "early EOF";
9090+ "Connection refused";
9191+ "Connection reset";
9292+ "Connection timed out";
9393+ "Could not resolve host";
9494+ "Failed to connect";
9595+ "Network is unreachable";
9696+ "Temporary failure";
9797+ ]
9898+ in
9999+ compile (alt (List.map str patterns))
114100115101(** Check if an error is a retryable HTTP server error (5xx) or network error *)
116102let is_retryable_error result =
117117- let stderr = result.stderr in
118118- String.length stderr > 0
119119- && List.exists
120120- (fun needle -> string_contains ~needle stderr)
121121- retryable_error_patterns
103103+ result.stderr <> "" && Re.execp retryable_re result.stderr
122104123105(** Run a git command with retry logic for network errors. Retries up to
124106 [max_retries] times with exponential backoff starting at [initial_delay_ms].
···157139 match result with
158140 | Ok _ -> Ok ()
159141 | Error (Command_failed (_, r))
160160- when string_contains ~needle:"Remote branch" r.stderr
161161- && string_contains ~needle:"not found" r.stderr ->
142142+ when Re.execp Re.(compile (str "Remote branch")) r.stderr
143143+ && Re.execp Re.(compile (str "not found")) r.stderr ->
162144 (* Empty remote repo - init locally and add remote *)
163145 let target_dir = Eio.Path.(cwd / target_name) in
164146 (try Eio.Path.mkdir ~perm:0o755 target_dir with Eio.Io _ -> ());
+2-2
lib/git_cli.mli
···6677(** {1 User configuration} *)
8899-val global_git_user : unit -> Git.User.t option
1010-(** [global_git_user ()] reads user.name and user.email from global git config.
99+val global_git_user : fs:Eio.Fs.dir_ty Eio.Path.t -> unit -> Git.User.t option
1010+(** [global_git_user ~fs ()] reads user.name and user.email from [~/.gitconfig].
1111 Returns [None] if either is not configured. *)
12121313(** {1 Types} *)
+1-1
lib/init.ml
···329329 Ctx.Git_error (Git_cli.Io_error msg)))
330330 (fun () ->
331331 let user =
332332- match Git_cli.global_git_user () with
332332+ match Git_cli.global_git_user ~fs () with
333333 | Some u -> u
334334 | None ->
335335 Git.User.v ~name:"monopam" ~email:"monopam@localhost"
···136136 ?refresh:bool ->
137137 unit ->
138138 (cherrypick_result, Ctx.error) result
139139-140140-val sync_opam_files :
141141- fs:Eio.Fs.dir_ty Eio.Path.t ->
142142- config:Config.t ->
143143- ?packages:string list ->
144144- unit ->
145145- (Opam_sync.t, [ `Config_error of string ]) result
146146-147147-val pp_opam_sync_result : Format.formatter -> Opam_sync.t -> unit
+2-2
lib/opam_sync.ml
···176176 if result.synced <> [] || result.orphaned <> [] then begin
177177 let repo = Git.Repository.open_repo ~fs opam_repo in
178178 let msg = commit_message result in
179179- match Git_cli.global_git_user () with
179179+ match Git_cli.global_git_user ~fs () with
180180 | Some user -> (
181181 match
182182 Git.Repository.commit_index repo ~author:user ~committer:user
···356356 then begin
357357 let repo = Git.Repository.open_repo ~fs target in
358358 let msg = commit_message result in
359359- match Git_cli.global_git_user () with
359359+ match Git_cli.global_git_user ~fs () with
360360 | Some user -> (
361361 match
362362 Git.Repository.commit_index repo ~author:user ~committer:user
+1-1
lib/pull.ml
···3232 let git_repo = Git.Repository.open_repo ~fs monorepo in
3333 let commit = Git.Hash.of_hex hash_hex in
3434 let user =
3535- match Git_cli.global_git_user () with
3535+ match Git_cli.global_git_user ~fs () with
3636 | Some u -> u
3737 | None ->
3838 Git.User.v ~name:"monopam" ~email:"monopam@localhost"