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