forked from
anil.recoil.org/monopam
Monorepo management for opam overlays
1type cmd_result = { exit_code : int; stdout : string; stderr : string }
2
3type error =
4 | Command_failed of string * cmd_result
5 | Not_a_repo of Fpath.t
6 | Dirty_worktree of Fpath.t
7 | Remote_not_found of string
8 | Branch_not_found of string
9 | Subtree_prefix_exists of string
10 | Subtree_prefix_missing of string
11 | Io_error of string
12
13let pp_error ppf = function
14 | Command_failed (cmd, r) ->
15 Fmt.pf ppf "Command failed: %s (exit %d)@.stdout: %s@.stderr: %s" cmd
16 r.exit_code r.stdout r.stderr
17 | Not_a_repo path -> Fmt.pf ppf "Not a git repository: %a" Fpath.pp path
18 | Dirty_worktree path ->
19 Fmt.pf ppf "Repository has uncommitted changes: %a" Fpath.pp path
20 | Remote_not_found name -> Fmt.pf ppf "Remote not found: %s" name
21 | Branch_not_found name -> Fmt.pf ppf "Branch not found: %s" name
22 | Subtree_prefix_exists prefix ->
23 Fmt.pf ppf "Subtree prefix already exists: %s" prefix
24 | Subtree_prefix_missing prefix ->
25 Fmt.pf ppf "Subtree prefix does not exist: %s" prefix
26 | Io_error msg -> Fmt.pf ppf "I/O error: %s" msg
27
28let run_git ~proc ~cwd args =
29 let cmd = "git" :: args in
30 let buf_stdout = Buffer.create 256 in
31 let buf_stderr = Buffer.create 256 in
32 Eio.Switch.run @@ fun sw ->
33 let child =
34 Eio.Process.spawn proc ~sw ~cwd
35 ~stdout:(Eio.Flow.buffer_sink buf_stdout)
36 ~stderr:(Eio.Flow.buffer_sink buf_stderr)
37 cmd
38 in
39 let exit_status = Eio.Process.await child in
40 let exit_code =
41 match exit_status with `Exited n -> n | `Signaled n -> 128 + n
42 in
43 {
44 exit_code;
45 stdout = Buffer.contents buf_stdout |> String.trim;
46 stderr = Buffer.contents buf_stderr |> String.trim;
47 }
48
49let run_git_ok ~proc ~cwd args =
50 let result = run_git ~proc ~cwd args in
51 if result.exit_code = 0 then Ok result.stdout
52 else Error (Command_failed (String.concat " " ("git" :: args), result))
53
54(** Helper for substring check *)
55let string_contains ~needle haystack =
56 let needle_len = String.length needle in
57 let haystack_len = String.length haystack in
58 if needle_len > haystack_len then false
59 else
60 let rec check i =
61 if i + needle_len > haystack_len then false
62 else if String.sub haystack i needle_len = needle then true
63 else check (i + 1)
64 in
65 check 0
66
67(** Check if an error is a retryable HTTP server error (5xx) or network error *)
68let is_retryable_error result =
69 let stderr = result.stderr in
70 (* Common patterns for HTTP 5xx errors in git output *)
71 String.length stderr > 0 &&
72 (string_contains ~needle:"500" stderr ||
73 string_contains ~needle:"502" stderr ||
74 string_contains ~needle:"503" stderr ||
75 string_contains ~needle:"504" stderr ||
76 string_contains ~needle:"HTTP 5" stderr ||
77 string_contains ~needle:"http 5" stderr ||
78 string_contains ~needle:"Internal Server Error" stderr ||
79 string_contains ~needle:"Bad Gateway" stderr ||
80 string_contains ~needle:"Service Unavailable" stderr ||
81 string_contains ~needle:"Gateway Timeout" stderr ||
82 (* RPC failures (common git smart HTTP errors) *)
83 string_contains ~needle:"RPC failed" stderr ||
84 string_contains ~needle:"curl" stderr ||
85 string_contains ~needle:"unexpected disconnect" stderr ||
86 string_contains ~needle:"the remote end hung up" stderr ||
87 string_contains ~needle:"early EOF" stderr ||
88 (* Connection errors *)
89 string_contains ~needle:"Connection refused" stderr ||
90 string_contains ~needle:"Connection reset" stderr ||
91 string_contains ~needle:"Connection timed out" stderr ||
92 string_contains ~needle:"Could not resolve host" stderr ||
93 string_contains ~needle:"Failed to connect" stderr ||
94 string_contains ~needle:"Network is unreachable" stderr ||
95 string_contains ~needle:"Temporary failure" stderr)
96
97(** Run a git command with retry logic for network errors.
98 Retries up to [max_retries] times with exponential backoff starting at [initial_delay_ms]. *)
99let run_git_ok_with_retry ~proc ~cwd ?(max_retries = 3) ?(initial_delay_ms = 2000) args =
100 let rec attempt n delay_ms =
101 let result = run_git ~proc ~cwd args in
102 if result.exit_code = 0 then Ok result.stdout
103 else if n < max_retries && is_retryable_error result then begin
104 (* Log the retry *)
105 Logs.warn (fun m ->
106 m "Git command failed with retryable error, retrying in %dms (%d/%d): %s"
107 delay_ms (n + 1) max_retries result.stderr);
108 (* Sleep before retry - convert ms to seconds for Unix.sleepf *)
109 Unix.sleepf (float_of_int delay_ms /. 1000.0);
110 (* Exponential backoff: double the delay for next attempt *)
111 attempt (n + 1) (delay_ms * 2)
112 end
113 else Error (Command_failed (String.concat " " ("git" :: args), result))
114 in
115 attempt 0 initial_delay_ms
116
117let path_to_eio ~(fs : Eio.Fs.dir_ty Eio.Path.t) path =
118 let dir, _ = fs in
119 (dir, Fpath.to_string path)
120
121let is_repo ~proc ~fs path =
122 let cwd = path_to_eio ~fs path in
123 try
124 let result = run_git ~proc ~cwd [ "rev-parse"; "--git-dir" ] in
125 result.exit_code = 0
126 with Eio.Io _ -> false (* Directory doesn't exist or not accessible *)
127
128let is_dirty ~proc ~fs path =
129 let cwd = path_to_eio ~fs path in
130 let result = run_git ~proc ~cwd [ "status"; "--porcelain" ] in
131 result.exit_code = 0 && result.stdout <> ""
132
133let current_branch ~proc ~fs path =
134 let cwd = path_to_eio ~fs path in
135 let result = run_git ~proc ~cwd [ "symbolic-ref"; "--short"; "HEAD" ] in
136 if result.exit_code = 0 then Some result.stdout else None
137
138let head_commit ~proc ~fs path =
139 let cwd = path_to_eio ~fs path in
140 run_git_ok ~proc ~cwd [ "rev-parse"; "HEAD" ]
141
142let rev_parse ~proc ~fs ~rev path =
143 let cwd = path_to_eio ~fs path in
144 run_git_ok ~proc ~cwd [ "rev-parse"; rev ]
145
146let clone ~proc ~fs ~url ~branch target =
147 let parent = Fpath.parent target in
148 let cwd = Eio.Path.(fs / Fpath.to_string parent) in
149 let target_name = Fpath.basename target in
150 let url_str = Uri.to_string url in
151 run_git_ok_with_retry ~proc ~cwd [ "clone"; "--branch"; branch; url_str; target_name ]
152 |> Result.map ignore
153
154let fetch ~proc ~fs ?(remote = "origin") path =
155 let cwd = path_to_eio ~fs path in
156 run_git_ok_with_retry ~proc ~cwd [ "fetch"; remote ] |> Result.map ignore
157
158let fetch_all ~proc ~fs path =
159 let cwd = path_to_eio ~fs path in
160 run_git_ok_with_retry ~proc ~cwd [ "fetch"; "--all" ] |> Result.map ignore
161
162let merge_ff ~proc ~fs ?(remote = "origin") ?branch path =
163 let cwd = path_to_eio ~fs path in
164 let branch =
165 match branch with
166 | Some b -> b
167 | None -> Option.value ~default:"main" (current_branch ~proc ~fs path)
168 in
169 let upstream = remote ^ "/" ^ branch in
170 run_git_ok ~proc ~cwd [ "merge"; "--ff-only"; upstream ] |> Result.map ignore
171
172let pull ~proc ~fs ?(remote = "origin") ?branch path =
173 let cwd = path_to_eio ~fs path in
174 let args =
175 match branch with
176 | Some b -> [ "pull"; remote; b ]
177 | None -> [ "pull"; remote ]
178 in
179 run_git_ok_with_retry ~proc ~cwd args |> Result.map ignore
180
181let fetch_and_reset ~proc ~fs ?(remote = "origin") ~branch path =
182 let cwd = path_to_eio ~fs path in
183 match run_git_ok_with_retry ~proc ~cwd [ "fetch"; remote ] with
184 | Error e -> Error e
185 | Ok _ ->
186 let upstream = remote ^ "/" ^ branch in
187 run_git_ok ~proc ~cwd [ "reset"; "--hard"; upstream ] |> Result.map ignore
188
189let checkout ~proc ~fs ~branch path =
190 let cwd = path_to_eio ~fs path in
191 run_git_ok ~proc ~cwd [ "checkout"; branch ] |> Result.map ignore
192
193type ahead_behind = { ahead : int; behind : int }
194
195let ahead_behind ~proc ~fs ?(remote = "origin") ?branch path =
196 let cwd = path_to_eio ~fs path in
197 let branch =
198 match branch with
199 | Some b -> b
200 | None -> Option.value ~default:"HEAD" (current_branch ~proc ~fs path)
201 in
202 let upstream = remote ^ "/" ^ branch in
203 match
204 run_git_ok ~proc ~cwd
205 [ "rev-list"; "--left-right"; "--count"; branch ^ "..." ^ upstream ]
206 with
207 | Error e -> Error e
208 | Ok output -> (
209 match String.split_on_char '\t' output with
210 | [ ahead; behind ] ->
211 Ok { ahead = int_of_string ahead; behind = int_of_string behind }
212 | _ -> Ok { ahead = 0; behind = 0 })
213
214module Subtree = struct
215 let exists ~fs ~repo ~prefix =
216 let path = Eio.Path.(fs / Fpath.to_string repo / prefix) in
217 match Eio.Path.kind ~follow:true path with
218 | `Directory -> true
219 | _ -> false
220 | exception _ -> false
221
222 let add ~proc ~fs ~repo ~prefix ~url ~branch () =
223 if exists ~fs ~repo ~prefix then Error (Subtree_prefix_exists prefix)
224 else
225 let cwd = path_to_eio ~fs repo in
226 let url_str = Uri.to_string url in
227 run_git_ok_with_retry ~proc ~cwd
228 [ "subtree"; "add"; "--prefix"; prefix; url_str; branch; "--squash" ]
229 |> Result.map ignore
230
231 let pull ~proc ~fs ~repo ~prefix ~url ~branch () =
232 if not (exists ~fs ~repo ~prefix) then Error (Subtree_prefix_missing prefix)
233 else
234 let cwd = path_to_eio ~fs repo in
235 let url_str = Uri.to_string url in
236 run_git_ok_with_retry ~proc ~cwd
237 [ "subtree"; "pull"; "--prefix"; prefix; url_str; branch; "--squash" ]
238 |> Result.map ignore
239
240 let push ~proc ~fs ~repo ~prefix ~url ~branch () =
241 if not (exists ~fs ~repo ~prefix) then Error (Subtree_prefix_missing prefix)
242 else
243 let cwd = path_to_eio ~fs repo in
244 let url_str = Uri.to_string url in
245 run_git_ok_with_retry ~proc ~cwd
246 [ "subtree"; "push"; "--prefix"; prefix; url_str; branch ]
247 |> Result.map ignore
248
249 let split ~proc ~fs ~repo ~prefix () =
250 if not (exists ~fs ~repo ~prefix) then Error (Subtree_prefix_missing prefix)
251 else
252 let cwd = path_to_eio ~fs repo in
253 run_git_ok ~proc ~cwd [ "subtree"; "split"; "--prefix"; prefix ]
254end
255
256let init ~proc ~fs path =
257 let cwd = path_to_eio ~fs (Fpath.parent path) in
258 let name = Fpath.basename path in
259 run_git_ok ~proc ~cwd [ "init"; name ] |> Result.map ignore
260
261let commit_allow_empty ~proc ~fs ~message path =
262 let cwd = path_to_eio ~fs path in
263 run_git_ok ~proc ~cwd [ "commit"; "--allow-empty"; "-m"; message ]
264 |> Result.map ignore
265
266let push_remote ~proc ~fs ?(remote = "origin") ?branch path =
267 let cwd = path_to_eio ~fs path in
268 let branch =
269 match branch with
270 | Some b -> b
271 | None -> Option.value ~default:"main" (current_branch ~proc ~fs path)
272 in
273 run_git_ok_with_retry ~proc ~cwd [ "push"; remote; branch ] |> Result.map ignore
274
275let push_ref ~proc ~fs ~repo ~target ~ref_spec () =
276 let cwd = path_to_eio ~fs repo in
277 run_git_ok ~proc ~cwd [ "push"; target; ref_spec ] |> Result.map ignore
278
279let set_push_url ~proc ~fs ?(remote = "origin") ~url path =
280 let cwd = path_to_eio ~fs path in
281 run_git_ok ~proc ~cwd [ "remote"; "set-url"; "--push"; remote; url ]
282 |> Result.map ignore
283
284let get_push_url ~proc ~fs ?(remote = "origin") path =
285 let cwd = path_to_eio ~fs path in
286 match run_git_ok ~proc ~cwd [ "remote"; "get-url"; "--push"; remote ] with
287 | Ok url -> Some url
288 | Error _ -> None
289
290let list_remotes ~proc ~fs path =
291 let cwd = path_to_eio ~fs path in
292 match run_git_ok ~proc ~cwd [ "remote" ] with
293 | Ok output ->
294 String.split_on_char '\n' output
295 |> List.filter (fun s -> String.trim s <> "")
296 | Error _ -> []
297
298let get_remote_url ~proc ~fs ~remote path =
299 let cwd = path_to_eio ~fs path in
300 match run_git_ok ~proc ~cwd [ "remote"; "get-url"; remote ] with
301 | Ok url -> Some (String.trim url)
302 | Error _ -> None
303
304let add_remote ~proc ~fs ~name ~url path =
305 let cwd = path_to_eio ~fs path in
306 run_git_ok ~proc ~cwd [ "remote"; "add"; name; url ] |> Result.map ignore
307
308let remove_remote ~proc ~fs ~name path =
309 let cwd = path_to_eio ~fs path in
310 run_git_ok ~proc ~cwd [ "remote"; "remove"; name ] |> Result.map ignore
311
312let set_remote_url ~proc ~fs ~name ~url path =
313 let cwd = path_to_eio ~fs path in
314 run_git_ok ~proc ~cwd [ "remote"; "set-url"; name; url ] |> Result.map ignore
315
316let ensure_remote ~proc ~fs ~name ~url path =
317 let remotes = list_remotes ~proc ~fs path in
318 if List.mem name remotes then begin
319 (* Remote exists, check if URL matches *)
320 match get_remote_url ~proc ~fs ~remote:name path with
321 | Some existing_url when existing_url = url -> Ok ()
322 | _ -> set_remote_url ~proc ~fs ~name ~url path
323 end
324 else add_remote ~proc ~fs ~name ~url path
325
326type log_entry = {
327 hash : string;
328 author : string;
329 date : string;
330 subject : string;
331 body : string;
332}
333
334let parse_log_entries output =
335 if String.trim output = "" then []
336 else
337 (* Split by the record separator (NUL at end of each record) *)
338 let records = String.split_on_char '\x00' output in
339 (* Filter empty strings and parse each record *)
340 List.filter_map
341 (fun record ->
342 let record = String.trim record in
343 if record = "" then None
344 else
345 (* Each record is: hash\nauthor\ndate\nsubject\nbody *)
346 match String.split_on_char '\n' record with
347 | hash :: author :: date :: subject :: body_lines ->
348 Some
349 {
350 hash;
351 author;
352 date;
353 subject;
354 body = String.concat "\n" body_lines;
355 }
356 | _ -> None)
357 records
358
359let log ~proc ~fs ?since ?until ?path:(filter_path : string option) repo_path =
360 let cwd = path_to_eio ~fs repo_path in
361 (* Build args: use format with NUL separator between records *)
362 let format_arg = "--format=%H%n%an%n%aI%n%s%n%b%x00" in
363 let args = [ "log"; format_arg ] in
364 let args =
365 match since with Some s -> args @ [ "--since=" ^ s ] | None -> args
366 in
367 let args =
368 match until with Some u -> args @ [ "--until=" ^ u ] | None -> args
369 in
370 let args =
371 match filter_path with Some p -> args @ [ "--"; p ] | None -> args
372 in
373 match run_git_ok ~proc ~cwd args with
374 | Ok output -> Ok (parse_log_entries output)
375 | Error e -> Error e
376
377let log_range ~proc ~fs ~base ~tip ?max_count repo_path =
378 let cwd = path_to_eio ~fs repo_path in
379 let format_arg = "--format=%H%n%an%n%aI%n%s%n%b%x00" in
380 let range = Printf.sprintf "%s..%s" base tip in
381 let args = [ "log"; format_arg; range ] in
382 let args =
383 match max_count with
384 | Some n -> args @ [ "-n"; string_of_int n ]
385 | None -> args
386 in
387 match run_git_ok ~proc ~cwd args with
388 | Ok output -> Ok (parse_log_entries output)
389 | Error e -> Error e
390
391let show_patch ~proc ~fs ~commit repo_path =
392 let cwd = path_to_eio ~fs repo_path in
393 run_git_ok ~proc ~cwd [ "show"; "--patch"; "--stat"; commit ]
394
395(** Parse a subtree merge/squash commit message to extract the upstream commit range.
396 Messages look like: "Squashed 'prefix/' changes from abc123..def456"
397 or "Squashed 'prefix/' content from commit abc123"
398 Returns the end commit (most recent) if found. *)
399let parse_subtree_message subject =
400 (* Helper to extract hex commit hash starting at position *)
401 let extract_hex s start =
402 let len = String.length s in
403 let rec find_end i =
404 if i >= len then i
405 else
406 match s.[i] with '0' .. '9' | 'a' .. 'f' -> find_end (i + 1) | _ -> i
407 in
408 let end_pos = find_end start in
409 if end_pos > start then Some (String.sub s start (end_pos - start))
410 else None
411 in
412 (* Pattern 1: "Squashed 'prefix/' changes from abc123..def456" *)
413 if String.starts_with ~prefix:"Squashed '" subject then
414 match String.index_opt subject '.' with
415 | Some i when i + 1 < String.length subject && subject.[i + 1] = '.' ->
416 extract_hex subject (i + 2)
417 | _ -> (
418 (* Pattern 2: "Squashed 'prefix/' content from commit abc123" *)
419 match String.split_on_char ' ' subject |> List.rev with
420 | last :: "commit" :: "from" :: _ -> extract_hex last 0
421 | _ -> None) (* Pattern 3: "Add 'prefix/' from commit abc123" *)
422 else if String.starts_with ~prefix:"Add '" subject then
423 match String.split_on_char ' ' subject |> List.rev with
424 | last :: "commit" :: "from" :: _ -> extract_hex last 0
425 | _ -> None
426 else None
427
428(** Find the last subtree-related commit for a given prefix. Searches git log
429 for commits with subtree merge/squash messages. *)
430let subtree_last_upstream_commit ~proc ~fs ~repo ~prefix () =
431 let cwd = path_to_eio ~fs repo in
432 (* Search for subtree-related commits - don't use path filter as it can miss merge commits *)
433 let grep_pattern = Printf.sprintf "^Squashed '%s/'" prefix in
434 match
435 run_git_ok ~proc ~cwd [ "log"; "--oneline"; "-1"; "--grep"; grep_pattern ]
436 with
437 | Error _ -> None
438 | Ok "" -> (
439 (* Try alternate pattern: Add 'prefix/' from commit *)
440 let add_pattern = Printf.sprintf "^Add '%s/'" prefix in
441 match
442 run_git_ok ~proc ~cwd
443 [ "log"; "--oneline"; "-1"; "--grep"; add_pattern ]
444 with
445 | Error _ -> None
446 | Ok "" -> None
447 | Ok line -> (
448 (* line is "abc1234 Add 'prefix/' from commit ..." *)
449 let hash = String.sub line 0 (min 7 (String.length line)) in
450 (* Get the full commit message to parse *)
451 match run_git_ok ~proc ~cwd [ "log"; "-1"; "--format=%s"; hash ] with
452 | Error _ -> None
453 | Ok subject -> parse_subtree_message subject))
454 | Ok line -> (
455 let hash = String.sub line 0 (min 7 (String.length line)) in
456 match run_git_ok ~proc ~cwd [ "log"; "-1"; "--format=%s"; hash ] with
457 | Error _ -> None
458 | Ok subject -> parse_subtree_message subject)
459
460(** Check if commit1 is an ancestor of commit2. *)
461let is_ancestor ~proc ~fs ~repo ~commit1 ~commit2 () =
462 let cwd = path_to_eio ~fs repo in
463 let result =
464 run_git ~proc ~cwd [ "merge-base"; "--is-ancestor"; commit1; commit2 ]
465 in
466 result.exit_code = 0
467
468(** Find the merge-base (common ancestor) of two commits. *)
469let merge_base ~proc ~fs ~repo ~commit1 ~commit2 () =
470 let cwd = path_to_eio ~fs repo in
471 run_git_ok ~proc ~cwd [ "merge-base"; commit1; commit2 ]
472
473(** Count commits between two commits (exclusive of base, inclusive of head). *)
474let count_commits_between ~proc ~fs ~repo ~base ~head () =
475 let cwd = path_to_eio ~fs repo in
476 match run_git_ok ~proc ~cwd [ "rev-list"; "--count"; base ^ ".." ^ head ] with
477 | Error _ -> 0
478 | Ok s -> ( try int_of_string (String.trim s) with _ -> 0)
479
480(** {1 Worktree Operations} *)
481
482module Worktree = struct
483 type entry = {
484 path : Fpath.t;
485 head : string;
486 branch : string option;
487 }
488
489 let add ~proc ~fs ~repo ~path ~branch () =
490 let cwd = path_to_eio ~fs repo in
491 let path_str = Fpath.to_string path in
492 run_git_ok ~proc ~cwd
493 [ "worktree"; "add"; "-b"; branch; path_str ]
494 |> Result.map ignore
495
496 let remove ~proc ~fs ~repo ~path ~force () =
497 let cwd = path_to_eio ~fs repo in
498 let path_str = Fpath.to_string path in
499 let args =
500 if force then [ "worktree"; "remove"; "--force"; path_str ]
501 else [ "worktree"; "remove"; path_str ]
502 in
503 run_git_ok ~proc ~cwd args |> Result.map ignore
504
505 let list ~proc ~fs repo =
506 let cwd = path_to_eio ~fs repo in
507 match run_git_ok ~proc ~cwd [ "worktree"; "list"; "--porcelain" ] with
508 | Error _ -> []
509 | Ok output ->
510 if String.trim output = "" then []
511 else
512 (* Parse porcelain output: blocks separated by blank lines
513 Each block has:
514 worktree /path/to/worktree
515 HEAD abc123...
516 branch refs/heads/branchname (or detached) *)
517 let lines = String.split_on_char '\n' output in
518 let rec parse_entries acc current_path current_head current_branch = function
519 | [] ->
520 (* Finalize last entry if we have one *)
521 (match current_path, current_head with
522 | Some p, Some h ->
523 let entry = { path = p; head = h; branch = current_branch } in
524 List.rev (entry :: acc)
525 | _ -> List.rev acc)
526 | "" :: rest ->
527 (* End of entry block *)
528 (match current_path, current_head with
529 | Some p, Some h ->
530 let entry = { path = p; head = h; branch = current_branch } in
531 parse_entries (entry :: acc) None None None rest
532 | _ -> parse_entries acc None None None rest)
533 | line :: rest ->
534 if String.starts_with ~prefix:"worktree " line then
535 let path_str = String.sub line 9 (String.length line - 9) in
536 (match Fpath.of_string path_str with
537 | Ok p -> parse_entries acc (Some p) current_head current_branch rest
538 | Error _ -> parse_entries acc current_path current_head current_branch rest)
539 else if String.starts_with ~prefix:"HEAD " line then
540 let head = String.sub line 5 (String.length line - 5) in
541 parse_entries acc current_path (Some head) current_branch rest
542 else if String.starts_with ~prefix:"branch " line then
543 let branch_ref = String.sub line 7 (String.length line - 7) in
544 (* Extract branch name from refs/heads/... *)
545 let branch =
546 if String.starts_with ~prefix:"refs/heads/" branch_ref then
547 Some (String.sub branch_ref 11 (String.length branch_ref - 11))
548 else
549 Some branch_ref
550 in
551 parse_entries acc current_path current_head branch rest
552 else if line = "detached" then
553 parse_entries acc current_path current_head None rest
554 else
555 parse_entries acc current_path current_head current_branch rest
556 in
557 parse_entries [] None None None lines
558
559 let exists ~proc ~fs ~repo ~path =
560 let worktrees = list ~proc ~fs repo in
561 List.exists (fun e -> Fpath.equal e.path path) worktrees
562end
563
564let cherry_pick ~proc ~fs ~commit path =
565 let cwd = path_to_eio ~fs path in
566 run_git_ok ~proc ~cwd [ "cherry-pick"; commit ] |> Result.map ignore
567
568let merge ~proc ~fs ~ref_name ?(ff_only=false) path =
569 let cwd = path_to_eio ~fs path in
570 let args = ["merge"] @ (if ff_only then ["--ff-only"] else []) @ [ref_name] in
571 run_git_ok ~proc ~cwd args |> Result.map ignore
572
573(** {1 Diff Operations} *)
574
575let diff_trees ~proc ~fs ~source ~target =
576 (* Use git diff --no-index to compare two directory trees.
577 This works even if neither directory is a git repo.
578 Exit code 0 = no diff, exit code 1 = diff found, other = error *)
579 let cwd = path_to_eio ~fs (Fpath.v ".") in
580 let source_str = Fpath.to_string source in
581 let target_str = Fpath.to_string target in
582 let result =
583 run_git ~proc ~cwd
584 [
585 "diff";
586 "--no-index";
587 "--binary";
588 (* Handle binary files *)
589 "--no-color";
590 target_str;
591 (* old = checkout *)
592 source_str (* new = monorepo subtree *);
593 ]
594 in
595 match result.exit_code with
596 | 0 ->
597 (* No differences *)
598 Ok ""
599 | 1 ->
600 (* Differences found - this is success for diff *)
601 Ok result.stdout
602 | _ ->
603 (* Actual error *)
604 Error
605 (Command_failed
606 (String.concat " " [ "git"; "diff"; "--no-index" ], result))
607
608let apply_diff ~proc ~fs ~cwd ~diff =
609 if String.length diff = 0 then Ok ()
610 else
611 let cwd_eio = path_to_eio ~fs cwd in
612 (* Apply the diff using git apply.
613 We need to handle the path rewriting since git diff --no-index
614 uses absolute or relative paths as prefixes. *)
615 let cmd = [ "apply"; "--binary"; "-p1"; "-" ] in
616 let buf_stdout = Buffer.create 256 in
617 let buf_stderr = Buffer.create 256 in
618 Eio.Switch.run @@ fun sw ->
619 let child =
620 Eio.Process.spawn proc ~sw ~cwd:cwd_eio
621 ~stdin:(Eio.Flow.string_source diff)
622 ~stdout:(Eio.Flow.buffer_sink buf_stdout)
623 ~stderr:(Eio.Flow.buffer_sink buf_stderr)
624 ("git" :: cmd)
625 in
626 let exit_status = Eio.Process.await child in
627 match exit_status with
628 | `Exited 0 -> Ok ()
629 | `Exited n | `Signaled n ->
630 Error
631 (Command_failed
632 ( String.concat " " ("git" :: cmd),
633 {
634 exit_code = n;
635 stdout = Buffer.contents buf_stdout;
636 stderr = Buffer.contents buf_stderr;
637 } ))
638
639let add_all ~proc ~fs path =
640 let cwd = path_to_eio ~fs path in
641 run_git_ok ~proc ~cwd [ "add"; "-A" ] |> Result.map ignore
642
643let commit ~proc ~fs ~message path =
644 let cwd = path_to_eio ~fs path in
645 run_git_ok ~proc ~cwd [ "commit"; "-m"; message ] |> Result.map ignore
646
647let rm ~proc ~fs ~recursive path target =
648 let cwd = path_to_eio ~fs path in
649 let args = if recursive then [ "rm"; "-r"; target ] else [ "rm"; target ] in
650 run_git_ok ~proc ~cwd args |> Result.map ignore
651
652let config ~proc ~fs ~key ~value path =
653 let cwd = path_to_eio ~fs path in
654 run_git_ok ~proc ~cwd [ "config"; key; value ] |> Result.map ignore
655
656let has_subtree_history ~proc ~fs ~repo ~prefix () =
657 (* Check if there's subtree commit history for this prefix.
658 Returns true if we can find a subtree-related commit message. *)
659 subtree_last_upstream_commit ~proc ~fs ~repo ~prefix () |> Option.is_some
660
661let branch_rename ~proc ~fs ~new_name path =
662 let cwd = path_to_eio ~fs path in
663 run_git_ok ~proc ~cwd [ "branch"; "-M"; new_name ] |> Result.map ignore