type cmd_result = { exit_code : int; stdout : string; stderr : string } type error = | Command_failed of string * cmd_result | Not_a_repo of Fpath.t | Dirty_worktree of Fpath.t | Remote_not_found of string | Branch_not_found of string | Subtree_prefix_exists of string | Subtree_prefix_missing of string | Io_error of string let pp_error ppf = function | Command_failed (cmd, r) -> Fmt.pf ppf "Command failed: %s (exit %d)@.stdout: %s@.stderr: %s" cmd r.exit_code r.stdout r.stderr | Not_a_repo path -> Fmt.pf ppf "Not a git repository: %a" Fpath.pp path | Dirty_worktree path -> Fmt.pf ppf "Repository has uncommitted changes: %a" Fpath.pp path | Remote_not_found name -> Fmt.pf ppf "Remote not found: %s" name | Branch_not_found name -> Fmt.pf ppf "Branch not found: %s" name | Subtree_prefix_exists prefix -> Fmt.pf ppf "Subtree prefix already exists: %s" prefix | Subtree_prefix_missing prefix -> Fmt.pf ppf "Subtree prefix does not exist: %s" prefix | Io_error msg -> Fmt.pf ppf "I/O error: %s" msg let run_git ~proc ~cwd args = let cmd = "git" :: args in let buf_stdout = Buffer.create 256 in let buf_stderr = Buffer.create 256 in Eio.Switch.run @@ fun sw -> let child = Eio.Process.spawn proc ~sw ~cwd ~stdout:(Eio.Flow.buffer_sink buf_stdout) ~stderr:(Eio.Flow.buffer_sink buf_stderr) cmd in let exit_status = Eio.Process.await child in let exit_code = match exit_status with `Exited n -> n | `Signaled n -> 128 + n in { exit_code; stdout = Buffer.contents buf_stdout |> String.trim; stderr = Buffer.contents buf_stderr |> String.trim; } let run_git_ok ~proc ~cwd args = let result = run_git ~proc ~cwd args in if result.exit_code = 0 then Ok result.stdout else Error (Command_failed (String.concat " " ("git" :: args), result)) let path_to_eio ~(fs : Eio.Fs.dir_ty Eio.Path.t) path = let dir, _ = fs in (dir, Fpath.to_string path) let is_repo ~proc ~fs path = let cwd = path_to_eio ~fs path in let result = run_git ~proc ~cwd [ "rev-parse"; "--git-dir" ] in result.exit_code = 0 let is_dirty ~proc ~fs path = let cwd = path_to_eio ~fs path in let result = run_git ~proc ~cwd [ "status"; "--porcelain" ] in result.exit_code = 0 && result.stdout <> "" let current_branch ~proc ~fs path = let cwd = path_to_eio ~fs path in let result = run_git ~proc ~cwd [ "symbolic-ref"; "--short"; "HEAD" ] in if result.exit_code = 0 then Some result.stdout else None let head_commit ~proc ~fs path = let cwd = path_to_eio ~fs path in run_git_ok ~proc ~cwd [ "rev-parse"; "HEAD" ] let rev_parse ~proc ~fs ~rev path = let cwd = path_to_eio ~fs path in run_git_ok ~proc ~cwd [ "rev-parse"; rev ] let clone ~proc ~fs ~url ~branch target = let parent = Fpath.parent target in let cwd = Eio.Path.(fs / Fpath.to_string parent) in let target_name = Fpath.basename target in let url_str = Uri.to_string url in run_git_ok ~proc ~cwd [ "clone"; "--branch"; branch; url_str; target_name ] |> Result.map ignore let fetch ~proc ~fs ?(remote = "origin") path = let cwd = path_to_eio ~fs path in run_git_ok ~proc ~cwd [ "fetch"; remote ] |> Result.map ignore let merge_ff ~proc ~fs ?(remote = "origin") ?branch path = let cwd = path_to_eio ~fs path in let branch = match branch with | Some b -> b | None -> Option.value ~default:"main" (current_branch ~proc ~fs path) in let upstream = remote ^ "/" ^ branch in run_git_ok ~proc ~cwd [ "merge"; "--ff-only"; upstream ] |> Result.map ignore let pull ~proc ~fs ?(remote = "origin") ?branch path = let cwd = path_to_eio ~fs path in let args = match branch with | Some b -> [ "pull"; remote; b ] | None -> [ "pull"; remote ] in run_git_ok ~proc ~cwd args |> Result.map ignore let checkout ~proc ~fs ~branch path = let cwd = path_to_eio ~fs path in run_git_ok ~proc ~cwd [ "checkout"; branch ] |> Result.map ignore type ahead_behind = { ahead : int; behind : int } let ahead_behind ~proc ~fs ?(remote = "origin") ?branch path = let cwd = path_to_eio ~fs path in let branch = match branch with | Some b -> b | None -> Option.value ~default:"HEAD" (current_branch ~proc ~fs path) in let upstream = remote ^ "/" ^ branch in match run_git_ok ~proc ~cwd [ "rev-list"; "--left-right"; "--count"; branch ^ "..." ^ upstream ] with | Error e -> Error e | Ok output -> ( match String.split_on_char '\t' output with | [ ahead; behind ] -> Ok { ahead = int_of_string ahead; behind = int_of_string behind } | _ -> Ok { ahead = 0; behind = 0 }) module Subtree = struct let exists ~fs ~repo ~prefix = let path = Eio.Path.(fs / Fpath.to_string repo / prefix) in match Eio.Path.kind ~follow:true path with | `Directory -> true | _ -> false | exception _ -> false let add ~proc ~fs ~repo ~prefix ~url ~branch () = if exists ~fs ~repo ~prefix then Error (Subtree_prefix_exists prefix) else let cwd = path_to_eio ~fs repo in let url_str = Uri.to_string url in run_git_ok ~proc ~cwd [ "subtree"; "add"; "--prefix"; prefix; url_str; branch; "--squash" ] |> Result.map ignore let pull ~proc ~fs ~repo ~prefix ~url ~branch () = if not (exists ~fs ~repo ~prefix) then Error (Subtree_prefix_missing prefix) else let cwd = path_to_eio ~fs repo in let url_str = Uri.to_string url in run_git_ok ~proc ~cwd [ "subtree"; "pull"; "--prefix"; prefix; url_str; branch; "--squash" ] |> Result.map ignore let push ~proc ~fs ~repo ~prefix ~url ~branch () = if not (exists ~fs ~repo ~prefix) then Error (Subtree_prefix_missing prefix) else let cwd = path_to_eio ~fs repo in let url_str = Uri.to_string url in run_git_ok ~proc ~cwd [ "subtree"; "push"; "--prefix"; prefix; url_str; branch ] |> Result.map ignore let split ~proc ~fs ~repo ~prefix () = if not (exists ~fs ~repo ~prefix) then Error (Subtree_prefix_missing prefix) else let cwd = path_to_eio ~fs repo in run_git_ok ~proc ~cwd [ "subtree"; "split"; "--prefix"; prefix ] end let init ~proc ~fs path = let cwd = path_to_eio ~fs (Fpath.parent path) in let name = Fpath.basename path in run_git_ok ~proc ~cwd [ "init"; name ] |> Result.map ignore let commit_allow_empty ~proc ~fs ~message path = let cwd = path_to_eio ~fs path in run_git_ok ~proc ~cwd [ "commit"; "--allow-empty"; "-m"; message ] |> Result.map ignore let push_remote ~proc ~fs ?(remote = "origin") ?branch path = let cwd = path_to_eio ~fs path in let branch = match branch with | Some b -> b | None -> Option.value ~default:"main" (current_branch ~proc ~fs path) in run_git_ok ~proc ~cwd [ "push"; remote; branch ] |> Result.map ignore let set_push_url ~proc ~fs ?(remote = "origin") ~url path = let cwd = path_to_eio ~fs path in run_git_ok ~proc ~cwd [ "remote"; "set-url"; "--push"; remote; url ] |> Result.map ignore let get_push_url ~proc ~fs ?(remote = "origin") path = let cwd = path_to_eio ~fs path in match run_git_ok ~proc ~cwd [ "remote"; "get-url"; "--push"; remote ] with | Ok url -> Some url | Error _ -> None type log_entry = { hash : string; author : string; date : string; subject : string; body : string; } let parse_log_entries output = if String.trim output = "" then [] else (* Split by the record separator (NUL at end of each record) *) let records = String.split_on_char '\x00' output in (* Filter empty strings and parse each record *) List.filter_map (fun record -> let record = String.trim record in if record = "" then None else (* Each record is: hash\nauthor\ndate\nsubject\nbody *) match String.split_on_char '\n' record with | hash :: author :: date :: subject :: body_lines -> Some { hash; author; date; subject; body = String.concat "\n" body_lines; } | _ -> None) records let log ~proc ~fs ?since ?until ?path:(filter_path : string option) repo_path = let cwd = path_to_eio ~fs repo_path in (* Build args: use format with NUL separator between records *) let format_arg = "--format=%H%n%an%n%aI%n%s%n%b%x00" in let args = [ "log"; format_arg ] in let args = match since with Some s -> args @ [ "--since=" ^ s ] | None -> args in let args = match until with Some u -> args @ [ "--until=" ^ u ] | None -> args in let args = match filter_path with Some p -> args @ [ "--"; p ] | None -> args in match run_git_ok ~proc ~cwd args with | Ok output -> Ok (parse_log_entries output) | Error e -> Error e