Monorepo management for opam overlays
at main 663 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(** 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