Git object storage and pack files for Eio
fork

Configure Feed

Select the types of activity you want to include in your feed.

test(ocaml-git): add unit and fuzz tests for Git.Tree.add deduplication

Add a unit test that catches the duplicate-entry bug (add same name twice
should replace, not accumulate). Add a Crowbar model-based fuzz suite that
applies random Add/Remove sequences to both Git.Tree.t and a Map reference,
checking no-duplicates, sorted-order, and model-agreement invariants after
every step. The roundtrip-after-ops test also exercises serialization across
arbitrary op sequences.

+12688
+2
.gitignore
··· 1 + _build/ 2 + *.install
+1
.ocamlformat
··· 1 + version=0.28.1
+29
LICENSE.md
··· 1 + ## ISC License 2 + 3 + Copyright (c) 2013-2017 Thomas Gazagnaire <thomas@gazagnaire.org> 4 + Copyright (c) 2017-2024 Romain Calascibetta <romain.calascibetta@gmail.com> 5 + Copyright (c) 2024-2026 Thomas Gazagnaire <thomas@gazagnaire.org> 6 + 7 + Permission to use, copy, modify, and distribute this software for any 8 + purpose with or without fee is hereby granted, provided that the above 9 + copyright notice and this permission notice appear in all copies. 10 + 11 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 12 + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 13 + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 14 + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 15 + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 16 + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 17 + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 18 + 19 + --- 20 + 21 + This library is derived from: 22 + 23 + - [ocaml-git](https://github.com/mirage/ocaml-git) - Git format and protocol in pure OCaml 24 + Copyright (c) 2013-2017 Thomas Gazagnaire, Romain Calascibetta 25 + ISC License 26 + 27 + - [carton](https://github.com/robur-coop/carton) - PACKv2 file implementation 28 + Copyright (c) 2020-2024 Romain Calascibetta 29 + ISC License
+58
README.md
··· 1 + # git 2 + 3 + Git object format implementation in pure OCaml. 4 + 5 + ## Overview 6 + 7 + Type-safe encoding and decoding of Git objects: blobs, trees, commits, tags, 8 + and references. Minimal dependencies with streaming I/O via bytesrw. 9 + 10 + ## Features 11 + 12 + - All Git object types: blob, tree, commit, tag 13 + - Reference handling (branches, tags, HEAD) 14 + - Pack file support 15 + - SHA-1 hashing with digestif 16 + - Streaming I/O with bytesrw 17 + 18 + ## Installation 19 + 20 + ``` 21 + opam install git 22 + ``` 23 + 24 + ## Usage 25 + 26 + ```ocaml 27 + open Git 28 + 29 + (* Create a blob *) 30 + let blob = Blob.of_string "Hello, World!\n" 31 + let blob_hash = Blob.digest blob 32 + 33 + (* Create a tree entry *) 34 + let entry = Tree.entry ~perm:`Normal ~name:"hello.txt" blob_hash 35 + let tree = Tree.v [ entry ] 36 + let tree_hash = Tree.digest tree 37 + 38 + (* Create a commit *) 39 + let author = User.v ~name:"Alice" ~email:"alice@example.com" () 40 + let commit = Commit.v 41 + ~tree:tree_hash 42 + ~author 43 + ~committer:author 44 + (Some "Initial commit") 45 + ``` 46 + 47 + ## Credits 48 + 49 + This is a simplified rewrite derived from: 50 + 51 + - [ocaml-git](https://github.com/mirage/ocaml-git) - Git format and protocol 52 + in pure OCaml by Thomas Gazagnaire and Romain Calascibetta 53 + - [carton](https://github.com/robur-coop/carton) - PACKv2 file implementation 54 + by Romain Calascibetta 55 + 56 + ## Licence 57 + 58 + ISC License. See [LICENSE.md](LICENSE.md) for details.
+3
bench/dune
··· 1 + (executable 2 + (name pack_bench) 3 + (libraries git memtrace))
+26
bench/pack_bench.ml
··· 1 + (* Benchmark for pack fold - run with MEMTRACE=pack.ctf to profile *) 2 + 3 + let () = Memtrace.trace_if_requested () 4 + 5 + let pack_file = 6 + let ic = open_in_bin "../test/pack-testzone-0.pack" in 7 + let len = in_channel_length ic in 8 + let data = really_input_string ic len in 9 + close_in ic; 10 + data 11 + 12 + let () = 13 + match Git.Pack.of_string pack_file with 14 + | Error (`Msg m) -> failwith m 15 + | Ok pack -> 16 + (* Run fold multiple times to get meaningful profile data *) 17 + for _ = 1 to 100 do 18 + let _ = 19 + Git.Pack.fold 20 + (fun ~offset:_ ~kind:_ ~data acc -> acc + String.length data) 21 + 0 pack 22 + in 23 + () 24 + done; 25 + Fmt.pr "Done: folded %d objects x 100 iterations\n%!" 26 + (Git.Pack.count pack)
+4
bin/dune
··· 1 + (executable 2 + (name git_mono) 3 + (public_name git-mono) 4 + (libraries git eio_posix cmdliner vlog tty memtrace monopam-info))
+314
bin/git_mono.ml
··· 1 + (* Copyright (c) 2024-2026 Thomas Gazagnaire <thomas@gazagnaire.org> 2 + 3 + Permission to use, copy, modify, and distribute this software for any 4 + purpose with or without fee is hereby granted, provided that the above 5 + copyright notice and this permission notice appear in all copies. 6 + 7 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. *) 14 + 15 + open Cmdliner 16 + 17 + let setup = 18 + Term.(const (fun () () -> ()) $ Vlog.setup "git-mono" $ Memtrace.term) 19 + 20 + let src = Logs.Src.create "git-mono" ~doc:"git-mono CLI" 21 + 22 + module Log = (val Logs.src_log src : Logs.LOG) 23 + 24 + (** {1 Common options} *) 25 + 26 + let repo_path = 27 + let doc = "Path to the git repository. Defaults to the current directory." in 28 + Arg.(value & opt dir "." & info [ "C" ] ~docv:"DIR" ~doc) 29 + 30 + (** {1 Output helpers} *) 31 + 32 + let pp_error ppf msg = 33 + Fmt.pf ppf "%a %s@." 34 + Tty.(Style.styled Style.(fg Color.red) Fmt.string) 35 + "error:" msg 36 + 37 + (** {1 Split command} *) 38 + 39 + let split_prefix = 40 + let doc = 41 + "Subtree prefix to split. This is the subdirectory whose history will be \ 42 + extracted into a standalone commit chain." 43 + in 44 + Arg.(required & pos 0 (some string) None & info [] ~docv:"PREFIX" ~doc) 45 + 46 + let split_rev = 47 + let doc = 48 + "Revision to split from. Defaults to HEAD. Can be a branch name, tag, or \ 49 + commit hash." 50 + in 51 + Arg.(value & opt string "HEAD" & info [ "r"; "rev" ] ~docv:"REV" ~doc) 52 + 53 + let resolve_rev repo rev = 54 + if rev = "HEAD" then Git.Repository.read_ref repo "HEAD" 55 + else 56 + match Git.Repository.read_ref repo ("refs/heads/" ^ rev) with 57 + | Some h -> Some h 58 + | None -> ( 59 + try Some (Git.Hash.of_hex rev) 60 + with Invalid_argument _ -> 61 + Git.Repository.read_ref repo ("refs/tags/" ^ rev)) 62 + 63 + let split ~repo_path ~prefix ~rev () = 64 + Eio_posix.run @@ fun env -> 65 + let fs = Eio.Stdenv.fs env in 66 + let repo = Git.Repository.open_repo ~fs (Fpath.v repo_path) in 67 + match resolve_rev repo rev with 68 + | None -> 69 + pp_error Fmt.stderr (Fmt.str "Could not resolve revision '%s'." rev); 70 + `Error (false, Fmt.str "could not resolve revision '%s'" rev) 71 + | Some head_hash -> ( 72 + Log.info (fun m -> 73 + m "Splitting prefix '%s' from %a" prefix Git.Hash.pp head_hash); 74 + match Git.Subtree.split repo ~prefix ~head:head_hash () with 75 + | Error (`Msg msg) -> 76 + pp_error Fmt.stderr msg; 77 + `Error (false, msg) 78 + | Ok None -> 79 + Log.warn (fun m -> m "No commits touch prefix '%s'." prefix); 80 + `Ok () 81 + | Ok (Some hash) -> 82 + Fmt.pr "%s@." (Git.Hash.to_hex hash); 83 + `Ok ()) 84 + 85 + let split_term = 86 + Term.( 87 + ret 88 + (const (fun () repo_path prefix rev -> split ~repo_path ~prefix ~rev ()) 89 + $ setup $ repo_path $ split_prefix $ split_rev)) 90 + 91 + (** {1 Verify command} *) 92 + 93 + let verify ~repo_path ~prefix () = 94 + Eio_posix.run @@ fun env -> 95 + let fs = Eio.Stdenv.fs env in 96 + let repo = Git.Repository.open_repo ~fs (Fpath.v repo_path) in 97 + let checked, errors = Git.Subtree.verify repo ~prefix () in 98 + Fmt.pr "Checked %d cache entries@." checked; 99 + match errors with 100 + | [] -> 101 + Fmt.pr "%a cache is valid@." 102 + Tty.(Style.styled Style.(fg Color.green) Fmt.string) 103 + "✓"; 104 + `Ok () 105 + | errs -> 106 + Fmt.pr "%a Found %d errors:@." 107 + Tty.(Style.styled Style.(fg Color.red) Fmt.string) 108 + "✗" (List.length errs); 109 + List.iter 110 + (fun Git.Subtree.{ original; split; reason } -> 111 + let short h = String.sub (Git.Hash.to_hex h) 0 7 in 112 + Fmt.pr " %s -> %s: %s@." (short original) (short split) reason) 113 + errs; 114 + `Error (false, "cache validation failed") 115 + 116 + let verify_term = 117 + Term.( 118 + ret 119 + (const (fun () repo_path prefix -> verify ~repo_path ~prefix ()) 120 + $ setup $ repo_path $ split_prefix)) 121 + 122 + let verify_cmd = 123 + let info = 124 + Cmd.info "verify" ~doc:"Verify subtree cache integrity" 125 + ~man: 126 + [ 127 + `S Manpage.s_description; 128 + `P 129 + "Validate the cached mappings for a subtree prefix. Checks that \ 130 + split commits have correct trees and that subtree merge commits \ 131 + preserve mainline parents."; 132 + `S Manpage.s_examples; 133 + `P "Verify the cache for $(b,monopam):"; 134 + `Pre " $(tname) monopam"; 135 + ] 136 + in 137 + Cmd.v info verify_term 138 + 139 + (** {1 Check command} *) 140 + 141 + let check_prefix = 142 + let doc = 143 + "Package prefix to check. Commits with git-subtree-dir for other prefixes \ 144 + are flagged as issues." 145 + in 146 + Arg.(required & pos 0 (some string) None & info [] ~docv:"PREFIX" ~doc) 147 + 148 + let check ~repo_path ~prefix () = 149 + Eio_posix.run @@ fun env -> 150 + let fs = Eio.Stdenv.fs env in 151 + let repo = Git.Repository.open_repo ~fs (Fpath.v repo_path) in 152 + match Git.Repository.head repo with 153 + | None -> 154 + pp_error Fmt.stderr "No HEAD found."; 155 + `Error (false, "no HEAD") 156 + | Some head_hash -> ( 157 + let checked, issues = Git.Subtree.check repo ~prefix ~head:head_hash () in 158 + Fmt.pr "Checked %d commits@." checked; 159 + match issues with 160 + | [] -> 161 + Fmt.pr "%a no unrelated merge commits found@." 162 + Tty.(Style.styled Style.(fg Color.green) Fmt.string) 163 + "✓"; 164 + `Ok () 165 + | _ -> 166 + Fmt.pr "%a Found %d unrelated merge commits:@." 167 + Tty.(Style.styled Style.(fg Color.red) Fmt.string) 168 + "✗" (List.length issues); 169 + List.iter 170 + (fun Git.Subtree.{ commit; message; subtree_dir } -> 171 + let short = String.sub (Git.Hash.to_hex commit) 0 7 in 172 + let dir = Option.value subtree_dir ~default:"?" in 173 + let msg_line = 174 + match String.index_opt message '\n' with 175 + | Some i -> String.sub message 0 i 176 + | None -> message 177 + in 178 + Fmt.pr " %s [%s] %s@." short dir msg_line) 179 + issues; 180 + `Error (false, "found unrelated merge commits")) 181 + 182 + let check_term = 183 + Term.( 184 + ret 185 + (const (fun () repo_path prefix -> check ~repo_path ~prefix ()) 186 + $ setup $ repo_path $ check_prefix)) 187 + 188 + let check_cmd = 189 + let info = 190 + Cmd.info "check" ~doc:"Detect unrelated subtree merge commits" 191 + ~man: 192 + [ 193 + `S Manpage.s_description; 194 + `P 195 + "Scan commit history and detect subtree merge commits that are \ 196 + unrelated to the specified prefix. These commits have \ 197 + git-subtree-dir metadata for a different package and typically \ 198 + indicate polluted history from earlier syncs."; 199 + `S Manpage.s_examples; 200 + `P "Check monopam history for pollution:"; 201 + `Pre " $(tname) monopam"; 202 + ] 203 + in 204 + Cmd.v info check_term 205 + 206 + (** {1 Fix command} *) 207 + 208 + let fix ~repo_path ~prefix () = 209 + Eio_posix.run @@ fun env -> 210 + let fs = Eio.Stdenv.fs env in 211 + let repo = Git.Repository.open_repo ~fs (Fpath.v repo_path) in 212 + match Git.Repository.head repo with 213 + | None -> 214 + pp_error Fmt.stderr "No HEAD found."; 215 + `Error (false, "no HEAD") 216 + | Some head_hash -> ( 217 + Log.info (fun m -> 218 + m "Fixing history for prefix '%s' from %a" prefix Git.Hash.pp 219 + head_hash); 220 + match Git.Subtree.fix repo ~prefix ~head:head_hash () with 221 + | Error (`Msg msg) -> 222 + pp_error Fmt.stderr msg; 223 + `Error (false, msg) 224 + | Ok None -> 225 + Log.warn (fun m -> m "No commits remain after filtering."); 226 + `Ok () 227 + | Ok (Some new_hash) -> 228 + Fmt.pr "%s@." (Git.Hash.to_hex new_hash); 229 + `Ok ()) 230 + 231 + let fix_term = 232 + Term.( 233 + ret 234 + (const (fun () repo_path prefix -> fix ~repo_path ~prefix ()) 235 + $ setup $ repo_path $ check_prefix)) 236 + 237 + let fix_cmd = 238 + let info = 239 + Cmd.info "fix" ~doc:"Remove unrelated subtree merge commits from history" 240 + ~man: 241 + [ 242 + `S Manpage.s_description; 243 + `P 244 + "Rewrite commit history to remove unrelated subtree merge commits. \ 245 + These are commits with git-subtree-dir metadata for a different \ 246 + package where the tree is unchanged from the first parent."; 247 + `P 248 + "This creates new commit objects - the original commits remain in \ 249 + the repository. Use the output hash to update refs as needed."; 250 + `S Manpage.s_examples; 251 + `P "Fix monopam history and update HEAD:"; 252 + `Pre " git update-ref HEAD $(git-mono fix monopam)"; 253 + `P "Fix and force push to origin:"; 254 + `Pre " git push -f origin $(git-mono fix monopam):main"; 255 + ] 256 + in 257 + Cmd.v info fix_term 258 + 259 + let split_cmd = 260 + let info = 261 + Cmd.info "split" ~doc:"Extract subtree history into standalone commits" 262 + ~man: 263 + [ 264 + `S Manpage.s_description; 265 + `P 266 + "Extract the history of a subdirectory into a standalone commit \ 267 + chain. Each commit that touches the prefix is rewritten so the \ 268 + subtree becomes the root tree, preserving author, committer, and \ 269 + message."; 270 + `P 271 + "This is a fast, native replacement for $(b,git subtree split), \ 272 + avoiding the per-commit subprocess overhead of the shell script."; 273 + `P 274 + "A persistent cache is stored in $(b,.git/subtree-cache/<prefix>). \ 275 + Subsequent runs only process new commits, making incremental \ 276 + splits near-instantaneous."; 277 + `S Manpage.s_examples; 278 + `P "Split the $(b,ocaml-git) subdirectory:"; 279 + `Pre " $(tname) ocaml-git"; 280 + `P "Split from a specific branch:"; 281 + `Pre " $(tname) --rev feature-branch ocaml-git"; 282 + `P "Use in a push workflow (equivalent to $(b,git subtree push)):"; 283 + `Pre " git push <remote> \\$(git-mono split lib):refs/heads/main"; 284 + `S "COMPLEXITY"; 285 + `P 286 + "Let $(i,n) be the number of commits, $(i,d) the prefix depth, and \ 287 + $(i,k) the max parents per commit."; 288 + `P 289 + "First run: O($(i,n) x ($(i,d) + $(i,k))). Cached run: O(1). \ 290 + Incremental ($(i,m) new commits): O($(i,m) x ($(i,d) + $(i,k)))."; 291 + ] 292 + in 293 + Cmd.v info split_term 294 + 295 + (** {1 Main command} *) 296 + 297 + let main_cmd = 298 + let info = 299 + Cmd.info "git-mono" ~version:Monopam_info.version 300 + ~doc:"Fast git operations for monorepos" 301 + ~man: 302 + [ 303 + `S Manpage.s_description; 304 + `P 305 + "$(iname) provides fast, native implementations of git operations \ 306 + commonly used in monorepo workflows. It reads git objects \ 307 + directly, bypassing the overhead of subprocess spawning."; 308 + `S "SEE ALSO"; 309 + `P "$(b,git-subtree)(1), $(b,monopam)(1)"; 310 + ] 311 + in 312 + Cmd.group info [ split_cmd; verify_cmd; check_cmd; fix_cmd ] 313 + 314 + let () = exit (Cmd.eval main_cmd)
+44
dune-project
··· 1 + (lang dune 3.21) 2 + (name git) 3 + (version 4.0.0) 4 + (formatting (enabled_for ocaml)) 5 + 6 + (generate_opam_files true) 7 + 8 + (license ISC) 9 + (maintainers "Thomas Gazagnaire <thomas@gazagnaire.org>") 10 + (authors 11 + "Thomas Gazagnaire <thomas@gazagnaire.org>" 12 + "Romain Calascibetta <romain.calascibetta@gmail.com>") 13 + (source (tangled gazagnaire.org/ocaml-git)) 14 + (documentation https://mirage.github.io/ocaml-git/) 15 + 16 + (package 17 + (name git) 18 + (synopsis "Git format in pure OCaml") 19 + (description " 20 + Pure OCaml implementation of Git object formats: blobs, trees, commits, 21 + tags, and references. Provides type-safe encoding and decoding of all 22 + Git objects. 23 + 24 + This is a simplified rewrite derived from mirage/ocaml-git and 25 + robur-coop/carton, with minimal dependencies.") 26 + (depends 27 + (ocaml (>= 5.1.0)) 28 + (bytesrw (>= 0.1.0)) 29 + conf-zlib 30 + (digestif (>= 1.2.0)) 31 + (eio (>= 1.0)) 32 + (eio_posix (>= 1.0)) 33 + (fmt (>= 0.9.0)) 34 + (fpath (>= 0.7.0)) 35 + (cmdliner (>= 1.3.0)) 36 + (logs (>= 0.7.0)) 37 + (vlog (>= 0.1)) 38 + (tty (>= 0.1)) 39 + (memtrace (>= 0.2)) 40 + monopam-info 41 + (ptime (>= 1.0.0)) 42 + (requests (>= 0.1.0)) 43 + (uri (>= 4.0.0)) 44 + (alcotest (and :with-test (>= 1.7.0)))))
+27
fuzz/dune
··· 1 + (executable 2 + (name fuzz) 3 + (modules fuzz fuzz_index fuzz_config fuzz_tree) 4 + (libraries git crowbar)) 5 + 6 + (executable 7 + (name gen_corpus) 8 + (modules gen_corpus) 9 + (libraries unix)) 10 + 11 + (rule 12 + (alias runtest) 13 + (enabled_if (<> %{profile} afl)) 14 + (deps fuzz.exe) 15 + (action 16 + (run %{exe:fuzz.exe}))) 17 + 18 + (rule 19 + (alias fuzz) 20 + (enabled_if 21 + (= %{profile} afl)) 22 + (deps 23 + (source_tree corpus) 24 + fuzz.exe 25 + gen_corpus.exe) 26 + (action 27 + (echo "AFL fuzzer built: %{exe:fuzz.exe}\n")))
+2
fuzz/fuzz.ml
··· 1 + let () = 2 + Crowbar.run "git" [ Fuzz_config.suite; Fuzz_index.suite; Fuzz_tree.suite ]
+185
fuzz/fuzz_config.ml
··· 1 + (** Fuzz tests for Git config parsing. *) 2 + 3 + open Crowbar 4 + 5 + let truncate ?(max_len = 4096) buf = 6 + if String.length buf > max_len then String.sub buf 0 max_len else buf 7 + 8 + (** Parse - must not crash on arbitrary input. *) 9 + let test_parse_crash_safety buf = 10 + let buf = truncate buf in 11 + let _ = Git.Config.of_string buf in 12 + () 13 + 14 + (** Roundtrip - parsed config must serialize and re-parse. *) 15 + let test_roundtrip buf = 16 + let buf = truncate buf in 17 + let config = Git.Config.of_string buf in 18 + let serialized = Git.Config.to_string config in 19 + let reparsed = Git.Config.of_string serialized in 20 + let orig_sections = Git.Config.all_sections config in 21 + let reparsed_sections = Git.Config.all_sections reparsed in 22 + if List.length orig_sections <> List.length reparsed_sections then 23 + fail "section count mismatch after roundtrip" 24 + 25 + (** Set/get consistency - value must be retrievable after set. *) 26 + let test_set_get sec_buf key_buf value_buf = 27 + (* Sanitize section name to be valid *) 28 + let sec_name = truncate ~max_len:32 sec_buf in 29 + let sec_name = 30 + String.map 31 + (fun c -> 32 + if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') then c else 'x') 33 + sec_name 34 + in 35 + let sec_name = if String.length sec_name = 0 then "core" else sec_name in 36 + (* Sanitize key name *) 37 + let key = truncate ~max_len:32 key_buf in 38 + let key = 39 + String.map 40 + (fun c -> 41 + if 42 + (c >= 'a' && c <= 'z') 43 + || (c >= 'A' && c <= 'Z') 44 + || (c >= '0' && c <= '9') 45 + then c 46 + else 'x') 47 + key 48 + in 49 + let key = if String.length key = 0 then "key" else key in 50 + (* Sanitize value (no newlines) *) 51 + let value = truncate ~max_len:256 value_buf in 52 + let value = String.map (fun c -> if c = '\n' then ' ' else c) value in 53 + let section = Git.Config.section sec_name in 54 + let config = Git.Config.set Git.Config.empty ~section ~key ~value in 55 + match Git.Config.find config section key with 56 + | None -> fail "key not found after set" 57 + | Some v -> 58 + let v = String.trim v in 59 + let value = String.trim value in 60 + if v <> value then failf "value mismatch: got '%s', expected '%s'" v value 61 + 62 + (** Unset - value must not be retrievable after unset. *) 63 + let test_unset sec_buf key_buf = 64 + let sec_name = truncate ~max_len:32 sec_buf in 65 + let sec_name = 66 + String.map 67 + (fun c -> 68 + if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') then c else 'x') 69 + sec_name 70 + in 71 + let sec_name = if String.length sec_name = 0 then "core" else sec_name in 72 + let key = truncate ~max_len:32 key_buf in 73 + let key = String.map (fun c -> if c >= 'a' && c <= 'z' then c else 'x') key in 74 + let key = if String.length key = 0 then "key" else key in 75 + let section = Git.Config.section sec_name in 76 + let config = Git.Config.set Git.Config.empty ~section ~key ~value:"test" in 77 + let config = Git.Config.unset config ~section ~key in 78 + if Option.is_some (Git.Config.find config section key) then 79 + fail "key still present after unset" 80 + 81 + (** Boolean parsing - true/false/yes/no/on/off/1/0 all work. *) 82 + let test_bool_values n = 83 + let values = [| "true"; "false"; "yes"; "no"; "on"; "off"; "1"; "0" |] in 84 + let expected = [| true; false; true; false; true; false; true; false |] in 85 + let idx = n mod 8 in 86 + let value = values.(idx) in 87 + let section = Git.Config.section "test" in 88 + let config = Git.Config.set Git.Config.empty ~section ~key:"flag" ~value in 89 + match Git.Config.bool config section "flag" with 90 + | None -> fail "failed to parse boolean" 91 + | Some parsed -> 92 + if parsed <> expected.(idx) then 93 + failf "boolean mismatch for '%s': got %b, expected %b" value parsed 94 + expected.(idx) 95 + 96 + (** Integer parsing - valid integers parse correctly. *) 97 + let test_int_values n = 98 + let section = Git.Config.section "test" in 99 + let value = string_of_int n in 100 + let config = Git.Config.set Git.Config.empty ~section ~key:"count" ~value in 101 + match Git.Config.int config section "count" with 102 + | None -> fail "failed to parse integer" 103 + | Some parsed -> 104 + if parsed <> n then failf "integer mismatch: got %d, expected %d" parsed n 105 + 106 + (** Add supports multi-valued keys. *) 107 + let test_multivalue n = 108 + let section = Git.Config.section "test" in 109 + let count = (n mod 5) + 1 in 110 + let config = 111 + List.fold_left 112 + (fun cfg i -> 113 + Git.Config.add cfg ~section ~key:"item" ~value:(string_of_int i)) 114 + Git.Config.empty (List.init count Fun.id) 115 + in 116 + let all = Git.Config.all config section "item" in 117 + if List.length all <> count then 118 + failf "multivalue count mismatch: got %d, expected %d" (List.length all) 119 + count 120 + 121 + (** Section with subsection. *) 122 + let test_subsection name_buf sub_buf = 123 + let name = truncate ~max_len:32 name_buf in 124 + let name = 125 + String.map (fun c -> if c >= 'a' && c <= 'z' then c else 'x') name 126 + in 127 + let name = if String.length name = 0 then "remote" else name in 128 + let sub = truncate ~max_len:32 sub_buf in 129 + let sub = 130 + String.map (fun c -> if c = '"' || c = '\\' || c = '\n' then '_' else c) sub 131 + in 132 + let sub = if String.length sub = 0 then "origin" else sub in 133 + let section = Git.Config.section_sub name sub in 134 + let config = 135 + Git.Config.set Git.Config.empty ~section ~key:"url" 136 + ~value:"https://example.com" 137 + in 138 + match Git.Config.find config section "url" with 139 + | None -> fail "key not found in subsection" 140 + | Some v -> 141 + if v <> "https://example.com" then fail "value mismatch in subsection" 142 + 143 + (** Get remotes from config. *) 144 + let test_get_remotes () = 145 + let config_content = 146 + {|[remote "origin"] 147 + url = https://github.com/user/repo.git 148 + [remote "upstream"] 149 + url = https://github.com/other/repo.git 150 + |} 151 + in 152 + let config = Git.Config.of_string config_content in 153 + let remotes = Git.Config.remotes config in 154 + if List.length remotes <> 2 then 155 + failf "expected 2 remotes, got %d" (List.length remotes) 156 + 157 + (** Get branches from config. *) 158 + let test_get_branches () = 159 + let config_content = 160 + {|[branch "main"] 161 + remote = origin 162 + merge = refs/heads/main 163 + [branch "feature"] 164 + remote = upstream 165 + |} 166 + in 167 + let config = Git.Config.of_string config_content in 168 + let branches = Git.Config.branches config in 169 + if List.length branches <> 2 then 170 + failf "expected 2 branches, got %d" (List.length branches) 171 + 172 + let suite = 173 + ( "config", 174 + [ 175 + test_case "parse crash safety" [ bytes ] test_parse_crash_safety; 176 + test_case "roundtrip" [ bytes ] test_roundtrip; 177 + test_case "set/get" [ bytes; bytes; bytes ] test_set_get; 178 + test_case "unset" [ bytes; bytes ] test_unset; 179 + test_case "bool values" [ uint8 ] test_bool_values; 180 + test_case "int values" [ int ] test_int_values; 181 + test_case "multivalue" [ uint8 ] test_multivalue; 182 + test_case "subsection" [ bytes; bytes ] test_subsection; 183 + test_case "get_remotes" [ const () ] test_get_remotes; 184 + test_case "get_branches" [ const () ] test_get_branches; 185 + ] )
+4
fuzz/fuzz_config.mli
··· 1 + (** Fuzz tests for {\!Config}. *) 2 + 3 + val suite : string * Crowbar.test_case list 4 + (** Test suite. *)
+162
fuzz/fuzz_index.ml
··· 1 + (** Fuzz tests for Git index parsing. *) 2 + 3 + open Crowbar 4 + 5 + let truncate ?(max_len = 4096) buf = 6 + if String.length buf > max_len then String.sub buf 0 max_len else buf 7 + 8 + (** Parse - must not crash on arbitrary input. *) 9 + let test_parse_crash_safety buf = 10 + let buf = truncate buf in 11 + let _ = Git.Index.of_string buf in 12 + () 13 + 14 + (** Roundtrip - valid values must round-trip. *) 15 + let test_roundtrip buf = 16 + let buf = truncate buf in 17 + match Git.Index.of_string buf with 18 + | Error _ -> () (* Invalid input is fine *) 19 + | Ok original -> ( 20 + let encoded = Git.Index.to_string original in 21 + match Git.Index.of_string encoded with 22 + | Error _ -> fail "re-parse failed after serialization" 23 + | Ok decoded -> 24 + let orig_entries = Git.Index.entries original in 25 + let decoded_entries = Git.Index.entries decoded in 26 + if List.length orig_entries <> List.length decoded_entries then 27 + fail "entry count mismatch after roundtrip") 28 + 29 + (** Add/find consistency - entry must be findable after add. *) 30 + let test_add_find name_buf hash_buf = 31 + let name = truncate ~max_len:256 name_buf in 32 + let name = 33 + if String.length name = 0 then "file.txt" 34 + else String.map (fun c -> if c = '\x00' || c = '/' then '_' else c) name 35 + in 36 + let hash_buf = 37 + if String.length hash_buf < 20 then 38 + hash_buf ^ String.make (20 - String.length hash_buf) '\x00' 39 + else String.sub hash_buf 0 20 40 + in 41 + let hash = Git.Hash.of_raw_string hash_buf in 42 + let entry : Git.Index.entry = 43 + { 44 + ctime_s = 0l; 45 + ctime_ns = 0l; 46 + mtime_s = 0l; 47 + mtime_ns = 0l; 48 + dev = 0l; 49 + ino = 0l; 50 + mode = Git.Index.Regular; 51 + uid = 0l; 52 + gid = 0l; 53 + size = 0l; 54 + hash; 55 + flags = 0; 56 + name; 57 + } 58 + in 59 + let idx = Git.Index.add Git.Index.empty entry in 60 + match Git.Index.find idx name with 61 + | None -> fail "entry not found after add" 62 + | Some found -> if found.name <> name then fail "name mismatch after find" 63 + 64 + (** Remove - entry must not be findable after remove. *) 65 + let test_remove name_buf = 66 + let name = truncate ~max_len:256 name_buf in 67 + let name = 68 + if String.length name = 0 then "file.txt" 69 + else String.map (fun c -> if c = '\x00' || c = '/' then '_' else c) name 70 + in 71 + let hash = Git.Hash.of_raw_string (String.make 20 '\x00') in 72 + let entry : Git.Index.entry = 73 + { 74 + ctime_s = 0l; 75 + ctime_ns = 0l; 76 + mtime_s = 0l; 77 + mtime_ns = 0l; 78 + dev = 0l; 79 + ino = 0l; 80 + mode = Git.Index.Regular; 81 + uid = 0l; 82 + gid = 0l; 83 + size = 0l; 84 + hash; 85 + flags = 0; 86 + name; 87 + } 88 + in 89 + let idx = Git.Index.add Git.Index.empty entry in 90 + let idx = Git.Index.remove idx name in 91 + if Git.Index.mem idx name then fail "entry still present after remove" 92 + 93 + (** Entries are sorted by path. *) 94 + let test_sorted n = 95 + let make_entry name = 96 + let hash = Git.Hash.of_raw_string (String.make 20 '\x00') in 97 + ({ 98 + ctime_s = 0l; 99 + ctime_ns = 0l; 100 + mtime_s = 0l; 101 + mtime_ns = 0l; 102 + dev = 0l; 103 + ino = 0l; 104 + mode = Git.Index.Regular; 105 + uid = 0l; 106 + gid = 0l; 107 + size = 0l; 108 + hash; 109 + flags = 0; 110 + name; 111 + } 112 + : Git.Index.entry) 113 + in 114 + let names = 115 + List.init ((n mod 10) + 1) (fun i -> Fmt.str "file%03d.txt" (9 - i)) 116 + in 117 + let idx = 118 + List.fold_left 119 + (fun idx name -> Git.Index.add idx (make_entry name)) 120 + Git.Index.empty names 121 + in 122 + let result_names = 123 + List.map (fun (e : Git.Index.entry) -> e.name) (Git.Index.entries idx) 124 + in 125 + let sorted_names = List.sort String.compare result_names in 126 + if result_names <> sorted_names then fail "entries not sorted" 127 + 128 + (** Clear removes all entries. *) 129 + let test_clear () = 130 + let hash = Git.Hash.of_raw_string (String.make 20 '\x00') in 131 + let entry : Git.Index.entry = 132 + { 133 + ctime_s = 0l; 134 + ctime_ns = 0l; 135 + mtime_s = 0l; 136 + mtime_ns = 0l; 137 + dev = 0l; 138 + ino = 0l; 139 + mode = Git.Index.Regular; 140 + uid = 0l; 141 + gid = 0l; 142 + size = 0l; 143 + hash; 144 + flags = 0; 145 + name = "test.txt"; 146 + } 147 + in 148 + let idx = Git.Index.add Git.Index.empty entry in 149 + let idx = Git.Index.clear idx in 150 + if List.length (Git.Index.entries idx) <> 0 then 151 + fail "clear didn't remove entries" 152 + 153 + let suite = 154 + ( "index", 155 + [ 156 + test_case "parse crash safety" [ bytes ] test_parse_crash_safety; 157 + test_case "roundtrip" [ bytes ] test_roundtrip; 158 + test_case "add/find" [ bytes; bytes ] test_add_find; 159 + test_case "remove" [ bytes ] test_remove; 160 + test_case "sorted" [ uint8 ] test_sorted; 161 + test_case "clear" [ const () ] test_clear; 162 + ] )
+4
fuzz/fuzz_index.mli
··· 1 + (** Fuzz tests for {\!Index}. *) 2 + 3 + val suite : string * Crowbar.test_case list 4 + (** Test suite. *)
+112
fuzz/fuzz_tree.ml
··· 1 + (** Fuzz tests for Git tree operations. 2 + 3 + Uses a model-based approach: a [Map.Make(String)] acts as the reference 4 + implementation. A random sequence of [Add] and [Remove] operations is 5 + applied to both the real [Git.Tree.t] and the model; invariants are checked 6 + after every step. *) 7 + 8 + open Crowbar 9 + 10 + (* A small name pool maximises repeated-name collisions, exercising the 11 + deduplication path that was missing before the [add] fix. *) 12 + let small_names = [| "a"; "b"; "c"; "d"; "e"; "ab"; "foo"; "bar" |] 13 + 14 + let sanitize_name s = 15 + if String.length s = 0 then "x" 16 + else 17 + let s = String.sub s 0 (min 32 (String.length s)) in 18 + String.map (fun c -> if Char.code c = 0 then 'x' else c) s 19 + 20 + let gen_name = 21 + choose 22 + [ 23 + map [ uint8 ] (fun i -> small_names.(i mod Array.length small_names)); 24 + map [ bytes ] (fun s -> sanitize_name s); 25 + ] 26 + 27 + let gen_hash = 28 + map [ bytes ] (fun s -> 29 + let len = String.length s in 30 + let raw = 31 + if len >= 20 then String.sub s 0 20 32 + else s ^ String.make (20 - len) '\x00' 33 + in 34 + Git.Hash.of_raw_string raw) 35 + 36 + type op = Add of string * Git.Hash.t | Remove of string 37 + 38 + let gen_op = 39 + choose 40 + [ 41 + map [ gen_name; gen_hash ] (fun name hash -> Add (name, hash)); 42 + map [ gen_name ] (fun name -> Remove name); 43 + ] 44 + 45 + (* Reference model *) 46 + module Ref = Map.Make (String) 47 + 48 + let apply_op_ref ref = function 49 + | Add (name, hash) -> Ref.add name hash ref 50 + | Remove name -> Ref.remove name ref 51 + 52 + let apply_op_tree tree = function 53 + | Add (name, hash) -> 54 + Git.Tree.add (Git.Tree.entry ~perm:`Normal ~name hash) tree 55 + | Remove name -> Git.Tree.remove ~name tree 56 + 57 + let check_invariants tree ref_map = 58 + let entries = Git.Tree.to_list tree in 59 + let names = List.map (fun (e : Git.Tree.entry) -> e.name) entries in 60 + (* No duplicate names *) 61 + let unique = List.sort_uniq String.compare names in 62 + if List.length names <> List.length unique then 63 + fail "duplicate names in to_list"; 64 + (* Names are sorted *) 65 + if names <> List.sort String.compare names then fail "entries not sorted"; 66 + (* Size matches model *) 67 + if List.length entries <> Ref.cardinal ref_map then 68 + fail 69 + (Fmt.str "size mismatch: tree=%d model=%d" (List.length entries) 70 + (Ref.cardinal ref_map)); 71 + (* Every tree entry agrees with model *) 72 + List.iter 73 + (fun (e : Git.Tree.entry) -> 74 + match Ref.find_opt e.name ref_map with 75 + | None -> fail (Fmt.str "entry %S in tree but absent from model" e.name) 76 + | Some h -> 77 + if not (Git.Hash.equal e.hash h) then 78 + fail (Fmt.str "hash mismatch for entry %S" e.name)) 79 + entries 80 + 81 + (** Apply ops to tree and model simultaneously, checking invariants at each 82 + step. *) 83 + let test_ops_agree ops = 84 + ignore 85 + (List.fold_left 86 + (fun (tree, ref_map) op -> 87 + let tree = apply_op_tree tree op in 88 + let ref_map = apply_op_ref ref_map op in 89 + check_invariants tree ref_map; 90 + (tree, ref_map)) 91 + (Git.Tree.empty, Ref.empty) 92 + ops) 93 + 94 + (** After any sequence of ops, the tree must round-trip through the git binary 95 + format with the same entry count. *) 96 + let test_roundtrip_after_ops ops = 97 + let tree = List.fold_left apply_op_tree Git.Tree.empty ops in 98 + let s = Git.Tree.to_string tree in 99 + match Git.Tree.of_string s with 100 + | Error (`Msg m) -> fail (Fmt.str "roundtrip parse failed: %s" m) 101 + | Ok tree' -> 102 + let n = List.length (Git.Tree.to_list tree) in 103 + let n' = List.length (Git.Tree.to_list tree') in 104 + if n <> n' then 105 + fail (Fmt.str "roundtrip entry count: before=%d after=%d" n n') 106 + 107 + let suite = 108 + ( "tree", 109 + [ 110 + test_case "ops agree with model" [ list gen_op ] test_ops_agree; 111 + test_case "roundtrip after ops" [ list gen_op ] test_roundtrip_after_ops; 112 + ] )
+17
fuzz/gen_corpus.ml
··· 1 + (** Generate seed corpus for fuzz testing. *) 2 + 3 + let () = 4 + (try Unix.mkdir "corpus" 0o755 5 + with Unix.Unix_error (Unix.EEXIST, _, _) -> ()); 6 + let write name data = 7 + let oc = open_out_bin (Filename.concat "corpus" name) in 8 + output_string oc data; 9 + close_out oc 10 + in 11 + write "seed_000" ""; 12 + write "seed_001" "\x00\x00\x00\x00"; 13 + write "seed_002" "DIRC\x00\x00\x00\x02"; 14 + write "seed_003" "[core]\n\tbare = false\n"; 15 + write "seed_004" "blob 5\x00hello"; 16 + write "seed_005" (String.make 20 '\xab'); 17 + write "seed_006" "tree 0\x00"
+59
git.opam
··· 1 + # This file is generated by dune, edit dune-project instead 2 + opam-version: "2.0" 3 + version: "4.0.0" 4 + synopsis: "Git format in pure OCaml" 5 + description: """ 6 + 7 + Pure OCaml implementation of Git object formats: blobs, trees, commits, 8 + tags, and references. Provides type-safe encoding and decoding of all 9 + Git objects. 10 + 11 + This is a simplified rewrite derived from mirage/ocaml-git and 12 + robur-coop/carton, with minimal dependencies.""" 13 + maintainer: ["Thomas Gazagnaire <thomas@gazagnaire.org>"] 14 + authors: [ 15 + "Thomas Gazagnaire <thomas@gazagnaire.org>" 16 + "Romain Calascibetta <romain.calascibetta@gmail.com>" 17 + ] 18 + license: "ISC" 19 + homepage: "https://tangled.org/gazagnaire.org/ocaml-git" 20 + doc: "https://mirage.github.io/ocaml-git/" 21 + bug-reports: "https://tangled.org/gazagnaire.org/ocaml-git/issues" 22 + depends: [ 23 + "dune" {>= "3.21"} 24 + "ocaml" {>= "5.1.0"} 25 + "bytesrw" {>= "0.1.0"} 26 + "conf-zlib" 27 + "digestif" {>= "1.2.0"} 28 + "eio" {>= "1.0"} 29 + "eio_posix" {>= "1.0"} 30 + "fmt" {>= "0.9.0"} 31 + "fpath" {>= "0.7.0"} 32 + "cmdliner" {>= "1.3.0"} 33 + "logs" {>= "0.7.0"} 34 + "vlog" {>= "0.1"} 35 + "tty" {>= "0.1"} 36 + "memtrace" {>= "0.2"} 37 + "monopam-info" 38 + "ptime" {>= "1.0.0"} 39 + "requests" {>= "0.1.0"} 40 + "uri" {>= "4.0.0"} 41 + "alcotest" {with-test & >= "1.7.0"} 42 + "odoc" {with-doc} 43 + ] 44 + build: [ 45 + ["dune" "subst"] {dev} 46 + [ 47 + "dune" 48 + "build" 49 + "-p" 50 + name 51 + "-j" 52 + jobs 53 + "@install" 54 + "@runtest" {with-test} 55 + "@doc" {with-doc} 56 + ] 57 + ] 58 + dev-repo: "git+https://tangled.org/gazagnaire.org/ocaml-git" 59 + x-maintenance-intent: ["(latest)"]
+31
lib/blob.ml
··· 1 + (* Copyright (c) 2013-2017 Thomas Gazagnaire <thomas@gazagnaire.org> 2 + Copyright (c) 2017-2024 Romain Calascibetta <romain.calascibetta@gmail.com> 3 + Copyright (c) 2024-2026 Thomas Gazagnaire <thomas@gazagnaire.org> 4 + 5 + Permission to use, copy, modify, and distribute this software for any 6 + purpose with or without fee is hereby granted, provided that the above 7 + copyright notice and this permission notice appear in all copies. 8 + 9 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. *) 16 + 17 + (** Git blob objects (file contents). *) 18 + 19 + type t = string 20 + 21 + let of_string s = s 22 + let to_string t = t 23 + let length t = String.length t 24 + let pp ppf t = Fmt.string ppf t 25 + let equal = String.equal 26 + let compare = String.compare 27 + let hash = Hashtbl.hash 28 + let digest t = Hash.digest_string ~kind:`Blob t 29 + 30 + module Set = Set.Make (String) 31 + module Map = Map.Make (String)
+47
lib/blob.mli
··· 1 + (* Copyright (c) 2013-2017 Thomas Gazagnaire <thomas@gazagnaire.org> 2 + Copyright (c) 2017-2024 Romain Calascibetta <romain.calascibetta@gmail.com> 3 + Copyright (c) 2024-2026 Thomas Gazagnaire <thomas@gazagnaire.org> 4 + 5 + Permission to use, copy, modify, and distribute this software for any 6 + purpose with or without fee is hereby granted, provided that the above 7 + copyright notice and this permission notice appear in all copies. 8 + 9 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. *) 16 + 17 + (** Git blob objects (file contents). *) 18 + 19 + type t 20 + (** The type of blobs. *) 21 + 22 + val of_string : string -> t 23 + (** Create a blob from a string. *) 24 + 25 + val to_string : t -> string 26 + (** Get the blob contents as a string. *) 27 + 28 + val length : t -> int 29 + (** The length of the blob in bytes. *) 30 + 31 + val pp : t Fmt.t 32 + (** Pretty-print a blob. *) 33 + 34 + val equal : t -> t -> bool 35 + (** Equality on blobs. *) 36 + 37 + val compare : t -> t -> int 38 + (** Total ordering on blobs. *) 39 + 40 + val hash : t -> int 41 + (** Hash function for use with Hashtbl. *) 42 + 43 + val digest : t -> Hash.t 44 + (** Compute the git hash of a blob. *) 45 + 46 + module Set : Set.S with type elt = t 47 + module Map : Map.S with type key = t
+282
lib/commit.ml
··· 1 + (* Copyright (c) 2013-2017 Thomas Gazagnaire <thomas@gazagnaire.org> 2 + Copyright (c) 2017-2024 Romain Calascibetta <romain.calascibetta@gmail.com> 3 + Copyright (c) 2024-2026 Thomas Gazagnaire <thomas@gazagnaire.org> 4 + 5 + Permission to use, copy, modify, and distribute this software for any 6 + purpose with or without fee is hereby granted, provided that the above 7 + copyright notice and this permission notice appear in all copies. 8 + 9 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. *) 16 + 17 + (** Git commit objects. *) 18 + 19 + type t = { 20 + tree : Hash.t; 21 + parents : Hash.t list; 22 + author : User.t; 23 + committer : User.t; 24 + extra : (string * string list) list; 25 + message : string option; 26 + } 27 + 28 + let v ~tree ~author ~committer ?(parents = []) ?(extra = []) message = 29 + { tree; parents; author; committer; extra; message } 30 + 31 + let tree t = t.tree 32 + let parents t = t.parents 33 + let author t = t.author 34 + let committer t = t.committer 35 + let message t = t.message 36 + let extra t = t.extra 37 + 38 + let pp ppf t = 39 + Fmt.pf ppf "@[<v>tree %a@," Hash.pp t.tree; 40 + List.iter (fun p -> Fmt.pf ppf "parent %a@," Hash.pp p) t.parents; 41 + Fmt.pf ppf "author %a@," User.pp t.author; 42 + Fmt.pf ppf "committer %a@," User.pp t.committer; 43 + List.iter 44 + (fun (k, vs) -> List.iter (fun v -> Fmt.pf ppf "%s %s@," k v) vs) 45 + t.extra; 46 + (match t.message with Some msg -> Fmt.pf ppf "@,%s" msg | None -> ()); 47 + Fmt.pf ppf "@]" 48 + 49 + let equal a b = 50 + Hash.equal a.tree b.tree 51 + && List.equal Hash.equal a.parents b.parents 52 + && User.equal a.author b.author 53 + && User.equal a.committer b.committer 54 + 55 + let compare a b = 56 + match Hash.compare a.tree b.tree with 57 + | 0 -> ( 58 + match List.compare Hash.compare a.parents b.parents with 59 + | 0 -> User.compare a.author b.author 60 + | n -> n) 61 + | n -> n 62 + 63 + let hash t = Hashtbl.hash t 64 + 65 + let compare_by_date a b = 66 + Int64.compare (User.date a.author) (User.date b.author) 67 + 68 + (** Encode commit to git format. *) 69 + let to_string t = 70 + let buf = Buffer.create 512 in 71 + Buffer.add_string buf "tree "; 72 + Buffer.add_string buf (Hash.to_hex t.tree); 73 + Buffer.add_char buf '\n'; 74 + List.iter 75 + (fun p -> 76 + Buffer.add_string buf "parent "; 77 + Buffer.add_string buf (Hash.to_hex p); 78 + Buffer.add_char buf '\n') 79 + t.parents; 80 + Buffer.add_string buf "author "; 81 + Buffer.add_string buf (User.to_string t.author); 82 + Buffer.add_char buf '\n'; 83 + Buffer.add_string buf "committer "; 84 + Buffer.add_string buf (User.to_string t.committer); 85 + Buffer.add_char buf '\n'; 86 + List.iter 87 + (fun (key, values) -> 88 + List.iter 89 + (fun value -> 90 + Buffer.add_string buf key; 91 + Buffer.add_char buf ' '; 92 + Buffer.add_string buf value; 93 + Buffer.add_char buf '\n') 94 + values) 95 + t.extra; 96 + (match t.message with 97 + | Some msg -> 98 + Buffer.add_char buf '\n'; 99 + Buffer.add_string buf msg 100 + | None -> ()); 101 + Buffer.contents buf 102 + 103 + (** Parse a single header line. *) 104 + let parse_header line = 105 + match String.index_opt line ' ' with 106 + | None -> None 107 + | Some pos -> 108 + let key = String.sub line 0 pos in 109 + let value = String.sub line (pos + 1) (String.length line - pos - 1) in 110 + Some (key, value) 111 + 112 + (** Parse commit from git format. *) 113 + let of_string s = 114 + let lines = String.split_on_char '\n' s in 115 + let rec parse_headers acc tree parents author committer extra = function 116 + | [] -> Error (`Msg "Unexpected end of commit") 117 + | "" :: rest -> ( 118 + (* Empty line marks start of message *) 119 + let message = 120 + match rest with [] -> None | _ -> Some (String.concat "\n" rest) 121 + in 122 + match (tree, author, committer) with 123 + | Some tree, Some author, Some committer -> 124 + Ok 125 + { 126 + tree; 127 + parents = List.rev parents; 128 + author; 129 + committer; 130 + extra = List.rev extra; 131 + message; 132 + } 133 + | None, _, _ -> Error (`Msg "Missing tree in commit") 134 + | _, None, _ -> Error (`Msg "Missing author in commit") 135 + | _, _, None -> Error (`Msg "Missing committer in commit")) 136 + | line :: rest -> ( 137 + match parse_header line with 138 + | None -> Error (`Msg ("Invalid header line: " ^ line)) 139 + | Some ("tree", hex) -> 140 + let tree = Hash.of_hex hex in 141 + parse_headers acc (Some tree) parents author committer extra rest 142 + | Some ("parent", hex) -> 143 + let parent = Hash.of_hex hex in 144 + parse_headers acc tree (parent :: parents) author committer extra 145 + rest 146 + | Some ("author", user_str) -> ( 147 + match User.of_string user_str with 148 + | Ok user -> 149 + parse_headers acc tree parents (Some user) committer extra rest 150 + | Error _ as e -> e) 151 + | Some ("committer", user_str) -> ( 152 + match User.of_string user_str with 153 + | Ok user -> 154 + parse_headers acc tree parents author (Some user) extra rest 155 + | Error _ as e -> e) 156 + | Some (key, value) -> 157 + (* Handle extra headers like gpgsig *) 158 + let extra = 159 + match List.assoc_opt key extra with 160 + | Some values -> 161 + (key, values @ [ value ]) :: List.remove_assoc key extra 162 + | None -> (key, [ value ]) :: extra 163 + in 164 + parse_headers acc tree parents author committer extra rest) 165 + in 166 + parse_headers [] None [] None None [] lines 167 + 168 + let of_string_exn s = 169 + match of_string s with Ok t -> t | Error (`Msg m) -> failwith m 170 + 171 + (** {1 Reader-based parsing} 172 + 173 + Parse directly from a {!Bytesrw.Bytes.Reader.t} without materialising the 174 + full object into a string. The reader must be positioned at the start of the 175 + commit body (after the loose-object header, if any). *) 176 + 177 + module Reader = Bytesrw.Bytes.Reader 178 + module Slice = Bytesrw.Bytes.Slice 179 + 180 + (** Read until [delim] from a reader. Returns the bytes before the delimiter; 181 + the delimiter is consumed. *) 182 + let read_until reader delim = 183 + let buf = Buffer.create 128 in 184 + let rec loop () = 185 + match Reader.read reader with 186 + | slice when Slice.is_eod slice -> 187 + if Buffer.length buf = 0 then None else Some (Buffer.contents buf) 188 + | slice -> 189 + let bytes = Slice.bytes slice in 190 + let first = Slice.first slice in 191 + let len = Slice.length slice in 192 + let rec scan i = 193 + if i >= first + len then begin 194 + Buffer.add_subbytes buf bytes first len; 195 + loop () 196 + end 197 + else if Char.code delim = Bytes.get_uint8 bytes i then begin 198 + Buffer.add_subbytes buf bytes first (i - first); 199 + (* Push back the remaining bytes after the delimiter *) 200 + let rest_off = i + 1 in 201 + let rest_len = first + len - rest_off in 202 + if rest_len > 0 then 203 + Reader.push_back reader 204 + (Slice.make bytes ~first:rest_off ~length:rest_len); 205 + Some (Buffer.contents buf) 206 + end 207 + else scan (i + 1) 208 + in 209 + scan first 210 + in 211 + loop () 212 + 213 + (** Read all remaining bytes from a reader. *) 214 + let read_rest reader = 215 + let buf = Buffer.create 256 in 216 + Reader.add_to_buffer buf reader; 217 + if Buffer.length buf = 0 then None else Some (Buffer.contents buf) 218 + 219 + let of_reader reader = 220 + let rec parse_headers tree parents author committer extra = 221 + match read_until reader '\n' with 222 + | None -> Error (`Msg "Unexpected end of commit") 223 + | Some "" -> ( 224 + (* Empty line marks start of message *) 225 + let message = read_rest reader in 226 + match (tree, author, committer) with 227 + | Some tree, Some author, Some committer -> 228 + Ok 229 + { 230 + tree; 231 + parents = List.rev parents; 232 + author; 233 + committer; 234 + extra = List.rev extra; 235 + message; 236 + } 237 + | None, _, _ -> Error (`Msg "Missing tree in commit") 238 + | _, None, _ -> Error (`Msg "Missing author in commit") 239 + | _, _, None -> Error (`Msg "Missing committer in commit")) 240 + | Some line -> ( 241 + match parse_header line with 242 + | None -> Error (`Msg ("Invalid header line: " ^ line)) 243 + | Some ("tree", hex) -> 244 + let tree = Hash.of_hex hex in 245 + parse_headers (Some tree) parents author committer extra 246 + | Some ("parent", hex) -> 247 + let parent = Hash.of_hex hex in 248 + parse_headers tree (parent :: parents) author committer extra 249 + | Some ("author", user_str) -> ( 250 + match User.of_string user_str with 251 + | Ok user -> parse_headers tree parents (Some user) committer extra 252 + | Error _ as e -> e) 253 + | Some ("committer", user_str) -> ( 254 + match User.of_string user_str with 255 + | Ok user -> parse_headers tree parents author (Some user) extra 256 + | Error _ as e -> e) 257 + | Some (key, value) -> 258 + let extra = 259 + match List.assoc_opt key extra with 260 + | Some values -> 261 + (key, values @ [ value ]) :: List.remove_assoc key extra 262 + | None -> (key, [ value ]) :: extra 263 + in 264 + parse_headers tree parents author committer extra) 265 + in 266 + parse_headers None [] None None [] 267 + 268 + let digest t = 269 + let s = to_string t in 270 + Hash.digest_string ~kind:`Commit s 271 + 272 + module Set = Set.Make (struct 273 + type nonrec t = t 274 + 275 + let compare = compare 276 + end) 277 + 278 + module Map = Map.Make (struct 279 + type nonrec t = t 280 + 281 + let compare = compare 282 + end)
+83
lib/commit.mli
··· 1 + (* Copyright (c) 2013-2017 Thomas Gazagnaire <thomas@gazagnaire.org> 2 + Copyright (c) 2017-2024 Romain Calascibetta <romain.calascibetta@gmail.com> 3 + Copyright (c) 2024-2026 Thomas Gazagnaire <thomas@gazagnaire.org> 4 + 5 + Permission to use, copy, modify, and distribute this software for any 6 + purpose with or without fee is hereby granted, provided that the above 7 + copyright notice and this permission notice appear in all copies. 8 + 9 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. *) 16 + 17 + (** Git commit objects. *) 18 + 19 + type t 20 + (** The type of commits. *) 21 + 22 + val v : 23 + tree:Hash.t -> 24 + author:User.t -> 25 + committer:User.t -> 26 + ?parents:Hash.t list -> 27 + ?extra:(string * string list) list -> 28 + string option -> 29 + t 30 + (** Create a commit. *) 31 + 32 + val tree : t -> Hash.t 33 + (** The tree hash. *) 34 + 35 + val parents : t -> Hash.t list 36 + (** The parent commit hashes. *) 37 + 38 + val author : t -> User.t 39 + (** The author. *) 40 + 41 + val committer : t -> User.t 42 + (** The committer. *) 43 + 44 + val message : t -> string option 45 + (** The commit message. *) 46 + 47 + val extra : t -> (string * string list) list 48 + (** Extra headers (e.g., gpgsig). *) 49 + 50 + val pp : t Fmt.t 51 + (** Pretty-print a commit. *) 52 + 53 + val equal : t -> t -> bool 54 + (** Equality on commits. *) 55 + 56 + val compare : t -> t -> int 57 + (** Total ordering on commits. *) 58 + 59 + val compare_by_date : t -> t -> int 60 + (** Compare commits by author date. *) 61 + 62 + val hash : t -> int 63 + (** Hash function for use with Hashtbl. *) 64 + 65 + val to_string : t -> string 66 + (** Encode to git format. *) 67 + 68 + val of_string : string -> (t, [ `Msg of string ]) result 69 + (** Parse from git format. *) 70 + 71 + val of_string_exn : string -> t 72 + (** [of_string_exn s] is like {!of_string} but raises on error. *) 73 + 74 + val of_reader : Bytesrw.Bytes.Reader.t -> (t, [ `Msg of string ]) result 75 + (** Parse a commit directly from a {!Bytesrw.Bytes.Reader.t} without 76 + materialising the full object into a string. The reader must be positioned 77 + at the start of the commit body (after the loose-object header, if any). *) 78 + 79 + val digest : t -> Hash.t 80 + (** Compute the git hash of a commit. *) 81 + 82 + module Set : Set.S with type elt = t 83 + module Map : Map.S with type key = t
+530
lib/config.ml
··· 1 + (* Copyright (c) 2013-2017 Thomas Gazagnaire <thomas@gazagnaire.org> 2 + Copyright (c) 2017-2024 Romain Calascibetta <romain.calascibetta@gmail.com> 3 + Copyright (c) 2024-2026 Thomas Gazagnaire <thomas@gazagnaire.org> 4 + Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org> 5 + 6 + Permission to use, copy, modify, and distribute this software for any 7 + purpose with or without fee is hereby granted, provided that the above 8 + copyright notice and this permission notice appear in all copies. 9 + 10 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 11 + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 12 + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 13 + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 14 + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 15 + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 16 + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. *) 17 + 18 + (** Declarative git config file parser. 19 + 20 + Provides typed access to git config files (.git/config) using a declarative 21 + codec approach. Define expected sections and keys with types, then decode 22 + configs into OCaml records. *) 23 + 24 + (** {1 Value Codecs} 25 + 26 + Codecs define how to decode/encode individual config values. *) 27 + 28 + type 'a codec = { 29 + decode : string -> 'a option; 30 + encode : 'a -> string; 31 + kind : string; 32 + } 33 + 34 + let string_codec = { decode = Option.some; encode = Fun.id; kind = "string" } 35 + 36 + let int_codec = 37 + { decode = int_of_string_opt; encode = string_of_int; kind = "integer" } 38 + 39 + let bool_codec = 40 + let decode s = 41 + match String.lowercase_ascii s with 42 + | "true" | "yes" | "on" | "1" -> Some true 43 + | "false" | "no" | "off" | "0" | "" -> Some false 44 + | _ -> None 45 + in 46 + let encode b = if b then "true" else "false" in 47 + { decode; encode; kind = "boolean" } 48 + 49 + (** {1 Low-Level Representation} 50 + 51 + Internal representation of parsed config files. *) 52 + 53 + type section = { name : string; subsection : string option } 54 + type entry = { section : section; key : string; value : string } 55 + type t = entry list 56 + 57 + let pp ppf t = 58 + let pp_entry ppf e = 59 + match e.section.subsection with 60 + | None -> Fmt.pf ppf "%s.%s=%s" e.section.name e.key e.value 61 + | Some sub -> Fmt.pf ppf "%s.%s.%s=%s" e.section.name sub e.key e.value 62 + in 63 + Fmt.pf ppf "@[<v>%a@]" Fmt.(list ~sep:cut pp_entry) t 64 + 65 + (** {1 String Utilities} *) 66 + 67 + let lstrip s = 68 + let len = String.length s in 69 + let rec find_start i = 70 + if i >= len then len 71 + else match s.[i] with ' ' | '\t' -> find_start (i + 1) | _ -> i 72 + in 73 + let start = find_start 0 in 74 + if start = 0 then s else String.sub s start (len - start) 75 + 76 + let rstrip s = 77 + let rec find_end i = 78 + if i < 0 then -1 79 + else 80 + match s.[i] with ' ' | '\t' | '\r' | '\n' -> find_end (i - 1) | _ -> i 81 + in 82 + let end_pos = find_end (String.length s - 1) in 83 + if end_pos = String.length s - 1 then s else String.sub s 0 (end_pos + 1) 84 + 85 + let strip s = lstrip (rstrip s) 86 + let lowercase = String.lowercase_ascii 87 + 88 + (** {1 Parsing} *) 89 + 90 + (** Unescape a string, handling backslash escape sequences. *) 91 + let unescape_string s = 92 + let buf = Buffer.create (String.length s) in 93 + let rec loop i = 94 + if i >= String.length s then Buffer.contents buf 95 + else if s.[i] = '\\' && i + 1 < String.length s then begin 96 + (match s.[i + 1] with 97 + | '\\' -> Buffer.add_char buf '\\' 98 + | '"' -> Buffer.add_char buf '"' 99 + | 'n' -> Buffer.add_char buf '\n' 100 + | 't' -> Buffer.add_char buf '\t' 101 + | c -> 102 + Buffer.add_char buf '\\'; 103 + Buffer.add_char buf c); 104 + loop (i + 2) 105 + end 106 + else begin 107 + Buffer.add_char buf s.[i]; 108 + loop (i + 1) 109 + end 110 + in 111 + loop 0 112 + 113 + (** Find closing quote, skipping escaped quotes. Returns position or None. *) 114 + let closing_quote s = 115 + let rec find i = 116 + if i >= String.length s then None 117 + else if s.[i] = '"' then Some i 118 + else if s.[i] = '\\' && i + 1 < String.length s then find (i + 2) 119 + else find (i + 1) 120 + in 121 + find 0 122 + 123 + let parse_section_header line = 124 + let trimmed = strip line in 125 + let len = String.length trimmed in 126 + if len < 2 || trimmed.[0] <> '[' || trimmed.[len - 1] <> ']' then None 127 + else 128 + let content = String.sub trimmed 1 (len - 2) in 129 + match String.index_opt content '"' with 130 + | None -> 131 + let name = strip content in 132 + if String.length name = 0 then None 133 + else Some { name = lowercase name; subsection = None } 134 + | Some quote_start -> ( 135 + let name = String.sub content 0 quote_start |> strip |> lowercase in 136 + if String.length name = 0 then None 137 + else 138 + let rest = 139 + String.sub content (quote_start + 1) 140 + (String.length content - quote_start - 1) 141 + in 142 + match closing_quote rest with 143 + | None -> None 144 + | Some close_pos -> 145 + let subsection = String.sub rest 0 close_pos in 146 + Some { name; subsection = Some (unescape_string subsection) }) 147 + 148 + let strip_inline_comment s = 149 + (* Strip inline comments (#, ;) from unquoted values *) 150 + let rec find_comment i in_quote = 151 + if i >= String.length s then String.length s 152 + else 153 + match s.[i] with 154 + | '"' -> find_comment (i + 1) (not in_quote) 155 + | '\\' when i + 1 < String.length s -> find_comment (i + 2) in_quote 156 + | ('#' | ';') when not in_quote -> i 157 + | _ -> find_comment (i + 1) in_quote 158 + in 159 + let comment_pos = find_comment 0 false in 160 + if comment_pos = String.length s then s 161 + else String.sub s 0 comment_pos |> rstrip 162 + 163 + let parse_key_value line = 164 + let trimmed = strip line in 165 + match String.index_opt trimmed '=' with 166 + | None -> 167 + if String.length trimmed > 0 then Some (lowercase trimmed, "true") 168 + else None 169 + | Some eq_pos -> 170 + let key = String.sub trimmed 0 eq_pos |> strip |> lowercase in 171 + let raw_value = 172 + String.sub trimmed (eq_pos + 1) (String.length trimmed - eq_pos - 1) 173 + |> lstrip |> strip_inline_comment 174 + in 175 + let value = 176 + let len = String.length raw_value in 177 + if len >= 2 && raw_value.[0] = '"' && raw_value.[len - 1] = '"' then 178 + unescape_string (String.sub raw_value 1 (len - 2)) 179 + else raw_value 180 + in 181 + if String.length key > 0 then Some (key, value) else None 182 + 183 + let is_comment_line line = 184 + let trimmed = lstrip line in 185 + String.length trimmed = 0 186 + || (String.length trimmed > 0 && (trimmed.[0] = '#' || trimmed.[0] = ';')) 187 + 188 + let of_string content = 189 + let lines = String.split_on_char '\n' content in 190 + let rec parse current_section acc = function 191 + | [] -> List.rev acc 192 + | line :: rest -> ( 193 + let line, rest = 194 + if String.length line > 0 && line.[String.length line - 1] = '\\' then 195 + let base = String.sub line 0 (String.length line - 1) in 196 + match rest with 197 + | [] -> (base, []) 198 + | next :: more -> (base ^ strip next, more) 199 + else (line, rest) 200 + in 201 + if is_comment_line line then parse current_section acc rest 202 + else 203 + match parse_section_header line with 204 + | Some section -> parse (Some section) acc rest 205 + | None -> ( 206 + match (current_section, parse_key_value line) with 207 + | Some section, Some (key, value) -> 208 + let entry = { section; key; value } in 209 + parse current_section (entry :: acc) rest 210 + | _ -> parse current_section acc rest)) 211 + in 212 + parse None [] lines 213 + 214 + (** {1 Querying} *) 215 + 216 + let find t (section : section) key = 217 + let key = lowercase key in 218 + List.find_map 219 + (fun e -> 220 + if 221 + e.section.name = section.name 222 + && e.section.subsection = section.subsection 223 + && e.key = key 224 + then Some e.value 225 + else None) 226 + t 227 + 228 + let all t (section : section) key = 229 + let key = lowercase key in 230 + List.filter_map 231 + (fun e -> 232 + if 233 + e.section.name = section.name 234 + && e.section.subsection = section.subsection 235 + && e.key = key 236 + then Some e.value 237 + else None) 238 + t 239 + 240 + let bool t (section : section) key = 241 + match find t section key with None -> None | Some v -> bool_codec.decode v 242 + 243 + let int t (section : section) key = 244 + match find t section key with None -> None | Some v -> int_codec.decode v 245 + 246 + let all_sections t = 247 + let seen = Hashtbl.create 16 in 248 + List.filter_map 249 + (fun e -> 250 + let key = (e.section.name, e.section.subsection) in 251 + if Hashtbl.mem seen key then None 252 + else begin 253 + Hashtbl.add seen key (); 254 + Some e.section 255 + end) 256 + t 257 + 258 + let sections t name = 259 + let name = lowercase name in 260 + List.filter (fun s -> s.name = name) (all_sections t) 261 + 262 + (** {1 Typed Section Schemas} 263 + 264 + Define expected section structure declaratively. *) 265 + 266 + type remote = { 267 + name : string; 268 + url : string option; 269 + push_url : string option; 270 + fetch : string list; 271 + } 272 + (** Remote configuration schema *) 273 + 274 + let remote_of_section t section = 275 + match section.subsection with 276 + | None -> None 277 + | Some name -> 278 + Some 279 + { 280 + name; 281 + url = find t section "url"; 282 + push_url = find t section "pushurl"; 283 + fetch = all t section "fetch"; 284 + } 285 + 286 + let remotes t = sections t "remote" |> List.filter_map (remote_of_section t) 287 + let remote t name = List.find_opt (fun r -> r.name = name) (remotes t) 288 + 289 + type branch = { 290 + name : string; 291 + remote : string option; 292 + merge : string option; 293 + rebase : bool option; 294 + } 295 + (** Branch configuration schema *) 296 + 297 + let branch_of_section t section = 298 + match section.subsection with 299 + | None -> None 300 + | Some name -> 301 + Some 302 + { 303 + name; 304 + remote = find t section "remote"; 305 + merge = find t section "merge"; 306 + rebase = bool t section "rebase"; 307 + } 308 + 309 + let branches t = sections t "branch" |> List.filter_map (branch_of_section t) 310 + let branch t name = List.find_opt (fun b -> b.name = name) (branches t) 311 + 312 + type core = { 313 + bare : bool; 314 + filemode : bool; 315 + ignorecase : bool; 316 + repositoryformatversion : int; 317 + } 318 + (** Core configuration schema *) 319 + 320 + let core_defaults = 321 + { 322 + bare = false; 323 + filemode = true; 324 + ignorecase = false; 325 + repositoryformatversion = 0; 326 + } 327 + 328 + let core t = 329 + let section = { name = "core"; subsection = None } in 330 + { 331 + bare = Option.value ~default:core_defaults.bare (bool t section "bare"); 332 + filemode = 333 + Option.value ~default:core_defaults.filemode (bool t section "filemode"); 334 + ignorecase = 335 + Option.value ~default:core_defaults.ignorecase 336 + (bool t section "ignorecase"); 337 + repositoryformatversion = 338 + Option.value ~default:core_defaults.repositoryformatversion 339 + (int t section "repositoryformatversion"); 340 + } 341 + 342 + type user_config = { name : string option; email : string option } 343 + (** User configuration schema *) 344 + 345 + let user t = 346 + let section = { name = "user"; subsection = None } in 347 + { name = find t section "name"; email = find t section "email" } 348 + 349 + (** {1 Serialization} *) 350 + 351 + let escape_subsection s = 352 + let buf = Buffer.create (String.length s) in 353 + String.iter 354 + (fun c -> 355 + match c with 356 + | '\\' -> Buffer.add_string buf "\\\\" 357 + | '"' -> Buffer.add_string buf "\\\"" 358 + | '\n' -> Buffer.add_string buf "\\n" 359 + | '\t' -> Buffer.add_string buf "\\t" 360 + | c -> Buffer.add_char buf c) 361 + s; 362 + Buffer.contents buf 363 + 364 + let escape_value s = 365 + let needs_quoting = 366 + String.length s = 0 367 + || s.[0] = ' ' 368 + || s.[String.length s - 1] = ' ' 369 + || String.contains s '#' || String.contains s ';' || String.contains s '"' 370 + || String.contains s '\n' 371 + in 372 + if not needs_quoting then s 373 + else 374 + let buf = Buffer.create (String.length s + 2) in 375 + Buffer.add_char buf '"'; 376 + String.iter 377 + (fun c -> 378 + match c with 379 + | '\\' -> Buffer.add_string buf "\\\\" 380 + | '"' -> Buffer.add_string buf "\\\"" 381 + | '\n' -> Buffer.add_string buf "\\n" 382 + | '\t' -> Buffer.add_string buf "\\t" 383 + | c -> Buffer.add_char buf c) 384 + s; 385 + Buffer.add_char buf '"'; 386 + Buffer.contents buf 387 + 388 + let section_to_string section = 389 + match section.subsection with 390 + | None -> Fmt.str "[%s]" section.name 391 + | Some sub -> Fmt.str "[%s \"%s\"]" section.name (escape_subsection sub) 392 + 393 + let to_string t = 394 + let buf = Buffer.create 256 in 395 + let current_section = ref None in 396 + List.iter 397 + (fun entry -> 398 + let section_key = (entry.section.name, entry.section.subsection) in 399 + if !current_section <> Some section_key then begin 400 + if !current_section <> None then Buffer.add_char buf '\n'; 401 + Buffer.add_string buf (section_to_string entry.section); 402 + Buffer.add_char buf '\n'; 403 + current_section := Some section_key 404 + end; 405 + Buffer.add_char buf '\t'; 406 + Buffer.add_string buf entry.key; 407 + Buffer.add_string buf " = "; 408 + Buffer.add_string buf (escape_value entry.value); 409 + Buffer.add_char buf '\n') 410 + t; 411 + Buffer.contents buf 412 + 413 + (** {1 Building} *) 414 + 415 + let empty = [] 416 + let section name = { name = lowercase name; subsection = None } 417 + 418 + let section_sub name subsection = 419 + { name = lowercase name; subsection = Some subsection } 420 + 421 + let add t ~(section : section) ~key ~value = 422 + let key = lowercase key in 423 + t @ [ { section; key; value } ] 424 + 425 + let set t ~(section : section) ~key ~value = 426 + let key = lowercase key in 427 + let found = ref false in 428 + let result = 429 + List.filter_map 430 + (fun e -> 431 + if 432 + e.section.name = section.name 433 + && e.section.subsection = section.subsection 434 + && e.key = key 435 + then begin 436 + if !found then None 437 + else begin 438 + found := true; 439 + Some { e with value } 440 + end 441 + end 442 + else Some e) 443 + t 444 + in 445 + if !found then result else add result ~section ~key ~value 446 + 447 + let unset t ~(section : section) ~key = 448 + let key = lowercase key in 449 + List.filter 450 + (fun e -> 451 + not 452 + (e.section.name = section.name 453 + && e.section.subsection = section.subsection 454 + && e.key = key)) 455 + t 456 + 457 + let unset_all t ~(section : section) ~key = unset t ~section ~key 458 + 459 + (** {1 Typed Building} 460 + 461 + Build configs from typed records. *) 462 + 463 + let of_remote (r : remote) = 464 + let section = section_sub "remote" r.name in 465 + let entries = [] in 466 + let entries = 467 + match r.url with 468 + | Some url -> { section; key = "url"; value = url } :: entries 469 + | None -> entries 470 + in 471 + let entries = 472 + match r.push_url with 473 + | Some url -> { section; key = "pushurl"; value = url } :: entries 474 + | None -> entries 475 + in 476 + let entries = 477 + List.fold_left 478 + (fun acc refspec -> { section; key = "fetch"; value = refspec } :: acc) 479 + entries r.fetch 480 + in 481 + List.rev entries 482 + 483 + let of_branch (b : branch) = 484 + let section = section_sub "branch" b.name in 485 + let entries = [] in 486 + let entries = 487 + match b.remote with 488 + | Some remote -> { section; key = "remote"; value = remote } :: entries 489 + | None -> entries 490 + in 491 + let entries = 492 + match b.merge with 493 + | Some merge -> { section; key = "merge"; value = merge } :: entries 494 + | None -> entries 495 + in 496 + let entries = 497 + match b.rebase with 498 + | Some rebase -> 499 + { section; key = "rebase"; value = bool_codec.encode rebase } :: entries 500 + | None -> entries 501 + in 502 + List.rev entries 503 + 504 + let of_core (c : core) = 505 + let section = section "core" in 506 + [ 507 + { 508 + section; 509 + key = "repositoryformatversion"; 510 + value = string_of_int c.repositoryformatversion; 511 + }; 512 + { section; key = "filemode"; value = bool_codec.encode c.filemode }; 513 + { section; key = "bare"; value = bool_codec.encode c.bare }; 514 + { section; key = "ignorecase"; value = bool_codec.encode c.ignorecase }; 515 + ] 516 + 517 + let of_user (u : user_config) = 518 + let section = section "user" in 519 + let entries = [] in 520 + let entries = 521 + match u.name with 522 + | Some name -> { section; key = "name"; value = name } :: entries 523 + | None -> entries 524 + in 525 + let entries = 526 + match u.email with 527 + | Some email -> { section; key = "email"; value = email } :: entries 528 + | None -> entries 529 + in 530 + List.rev entries
+215
lib/config.mli
··· 1 + (* Copyright (c) 2013-2017 Thomas Gazagnaire <thomas@gazagnaire.org> 2 + Copyright (c) 2017-2024 Romain Calascibetta <romain.calascibetta@gmail.com> 3 + Copyright (c) 2024-2026 Thomas Gazagnaire <thomas@gazagnaire.org> 4 + Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org> 5 + 6 + Permission to use, copy, modify, and distribute this software for any 7 + purpose with or without fee is hereby granted, provided that the above 8 + copyright notice and this permission notice appear in all copies. 9 + 10 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 11 + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 12 + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 13 + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 14 + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 15 + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 16 + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. *) 17 + 18 + (** Declarative git config file parser. 19 + 20 + Parses git config files (.git/config) following git's INI-like format: 21 + 22 + {@ini[ 23 + [core] 24 + bare = false 25 + filemode = true 26 + 27 + [remote "origin"] 28 + url = https://github.com/user/repo.git 29 + fetch = +refs/heads/*:refs/remotes/origin/* 30 + 31 + [branch "main"] 32 + remote = origin 33 + merge = refs/heads/main 34 + ]} 35 + 36 + Features: 37 + - Sections: [[name]] or [[name "subsection"]] 38 + - Case-insensitive keys, case-sensitive subsections 39 + - Multi-valued keys (e.g., [fetch]) 40 + - Comments with [#] or [;] 41 + - Boolean values: [true], [yes], [on], [1] / [false], [no], [off], [0] *) 42 + 43 + (** {1 Value Codecs} 44 + 45 + Codecs define how to decode/encode individual config values. *) 46 + 47 + type 'a codec = { 48 + decode : string -> 'a option; 49 + encode : 'a -> string; 50 + kind : string; 51 + } 52 + (** A bidirectional codec for config values. *) 53 + 54 + val string_codec : string codec 55 + (** String values (identity). *) 56 + 57 + val int_codec : int codec 58 + (** Integer values. *) 59 + 60 + val bool_codec : bool codec 61 + (** [bool_codec] handles boolean values: [true/yes/on/1] and [false/no/off/0]. 62 + *) 63 + 64 + (** {1 Types} *) 65 + 66 + type section = { 67 + name : string; (** Section name (lowercase) *) 68 + subsection : string option; (** Optional subsection (preserves case) *) 69 + } 70 + (** A config section identifier. *) 71 + 72 + type entry = { 73 + section : section; 74 + key : string; (** Key name (lowercase) *) 75 + value : string; 76 + } 77 + (** A single config entry. *) 78 + 79 + type t = entry list 80 + (** A parsed git config file. Entries are in file order. *) 81 + 82 + val pp : t Fmt.t 83 + (** [pp] pretty-prints a config as key=value lines. *) 84 + 85 + (** {1 Parsing} *) 86 + 87 + val of_string : string -> t 88 + (** [of_string content] parses git config content. *) 89 + 90 + val to_string : t -> string 91 + (** [to_string t] serializes the config to a string. *) 92 + 93 + (** {1 Querying} *) 94 + 95 + val find : t -> section -> string -> string option 96 + (** [find t section key] returns the first value for [key] in [section]. *) 97 + 98 + val all : t -> section -> string -> string list 99 + (** [all t section key] returns all values for [key] in [section]. Useful for 100 + multi-valued keys like [fetch]. *) 101 + 102 + val bool : t -> section -> string -> bool option 103 + (** [bool t section key] parses a boolean value. *) 104 + 105 + val int : t -> section -> string -> int option 106 + (** [int t section key] parses an integer value. *) 107 + 108 + val all_sections : t -> section list 109 + (** [all_sections t] returns all unique sections in order of first appearance. 110 + *) 111 + 112 + val sections : t -> string -> section list 113 + (** [sections t name] returns all sections with the given name. For example, 114 + [sections t "remote"] returns all remote sections. *) 115 + 116 + (** {1 Section Constructors} *) 117 + 118 + val section : string -> section 119 + (** [section name] creates a simple section without subsection. *) 120 + 121 + val section_sub : string -> string -> section 122 + (** [section_sub name subsection] creates a section with subsection. *) 123 + 124 + (** {1 Remotes} 125 + 126 + Typed access to remote configuration. *) 127 + 128 + type remote = { 129 + name : string; (** Remote name (e.g., "origin") *) 130 + url : string option; (** Fetch URL *) 131 + push_url : string option; (** Push URL (if different from url) *) 132 + fetch : string list; (** Fetch refspecs *) 133 + } 134 + (** A git remote configuration. *) 135 + 136 + val remotes : t -> remote list 137 + (** [remotes t] returns all configured remotes. *) 138 + 139 + val remote : t -> string -> remote option 140 + (** [remote t name] returns the remote with the given name. *) 141 + 142 + val of_remote : remote -> entry list 143 + (** [of_remote r] creates config entries from a remote. *) 144 + 145 + (** {1 Branches} 146 + 147 + Typed access to branch configuration. *) 148 + 149 + type branch = { 150 + name : string; (** Branch name *) 151 + remote : string option; (** Tracking remote *) 152 + merge : string option; (** Tracking ref *) 153 + rebase : bool option; (** Rebase on pull *) 154 + } 155 + (** A git branch configuration. *) 156 + 157 + val branches : t -> branch list 158 + (** [branches t] returns all configured branches. *) 159 + 160 + val branch : t -> string -> branch option 161 + (** [branch t name] returns the branch with the given name. *) 162 + 163 + val of_branch : branch -> entry list 164 + (** [of_branch b] creates config entries from a branch. *) 165 + 166 + (** {1 Core Config} 167 + 168 + Typed access to core repository configuration. *) 169 + 170 + type core = { 171 + bare : bool; 172 + filemode : bool; 173 + ignorecase : bool; 174 + repositoryformatversion : int; 175 + } 176 + (** Core repository configuration. *) 177 + 178 + val core_defaults : core 179 + (** Default core configuration values. *) 180 + 181 + val core : t -> core 182 + (** [core t] returns core configuration with defaults for missing values. *) 183 + 184 + val of_core : core -> entry list 185 + (** [of_core c] creates config entries from core config. *) 186 + 187 + (** {1 User Config} 188 + 189 + Typed access to user identity configuration. *) 190 + 191 + type user_config = { name : string option; email : string option } 192 + (** User identity configuration. *) 193 + 194 + val user : t -> user_config 195 + (** [user t] returns user configuration. *) 196 + 197 + val of_user : user_config -> entry list 198 + (** [of_user u] creates config entries from user config. *) 199 + 200 + (** {1 Building} *) 201 + 202 + val empty : t 203 + (** [empty] is an empty config. *) 204 + 205 + val add : t -> section:section -> key:string -> value:string -> t 206 + (** [add t ~section ~key ~value] adds an entry. Allows duplicates. *) 207 + 208 + val set : t -> section:section -> key:string -> value:string -> t 209 + (** [set t ~section ~key ~value] sets a value, replacing any existing. *) 210 + 211 + val unset : t -> section:section -> key:string -> t 212 + (** [unset t ~section ~key] removes the first matching entry. *) 213 + 214 + val unset_all : t -> section:section -> key:string -> t 215 + (** [unset_all t ~section ~key] removes all matching entries. *)
+227
lib/diff.ml
··· 1 + (** Tree and commit diff operations. *) 2 + 3 + type change = 4 + | Added of { path : string; hash : Hash.t; perm : Tree.perm } 5 + | Removed of { path : string; hash : Hash.t; perm : Tree.perm } 6 + | Modified of { 7 + path : string; 8 + old_hash : Hash.t; 9 + new_hash : Hash.t; 10 + old_perm : Tree.perm; 11 + new_perm : Tree.perm; 12 + } 13 + 14 + type t = change list 15 + type stats = { additions : int; deletions : int; modifications : int } 16 + 17 + let change_path = function 18 + | Added { path; _ } | Removed { path; _ } | Modified { path; _ } -> path 19 + 20 + let compare_by_path c1 c2 = String.compare (change_path c1) (change_path c2) 21 + let path prefix name = if prefix = "" then name else prefix ^ "/" ^ name 22 + 23 + (** Read a tree from the repository, returning empty list if not found. *) 24 + let read_tree repo hash = 25 + match Repository.read repo hash with 26 + | Ok (Value.Tree tree) -> Ok (Tree.to_list tree) 27 + | Ok _ -> Error (`Msg "expected tree object") 28 + | Error e -> Error e 29 + 30 + (** Check if an entry is a directory (tree). *) 31 + let is_dir (entry : Tree.entry) = entry.perm = `Dir 32 + 33 + (** Recursively collect all entries in a tree as Added changes. *) 34 + let rec collect_added repo prefix entries acc = 35 + List.fold_left 36 + (fun acc (entry : Tree.entry) -> 37 + match acc with 38 + | Error _ -> acc 39 + | Ok changes -> 40 + let path = path prefix entry.name in 41 + if is_dir entry then 42 + (* Recurse into subdirectory *) 43 + match read_tree repo entry.hash with 44 + | Error e -> Error e 45 + | Ok sub_entries -> collect_added repo path sub_entries (Ok changes) 46 + else 47 + Ok (Added { path; hash = entry.hash; perm = entry.perm } :: changes)) 48 + acc entries 49 + 50 + (** Recursively collect all entries in a tree as Removed changes. *) 51 + let rec collect_removed repo prefix entries acc = 52 + List.fold_left 53 + (fun acc (entry : Tree.entry) -> 54 + match acc with 55 + | Error _ -> acc 56 + | Ok changes -> 57 + let path = path prefix entry.name in 58 + if is_dir entry then 59 + match read_tree repo entry.hash with 60 + | Error e -> Error e 61 + | Ok sub_entries -> 62 + collect_removed repo path sub_entries (Ok changes) 63 + else 64 + Ok 65 + (Removed { path; hash = entry.hash; perm = entry.perm } :: changes)) 66 + acc entries 67 + 68 + (** Process a single added entry (file or directory). *) 69 + let process_added_entry repo prefix (entry : Tree.entry) changes = 70 + let path = path prefix entry.name in 71 + if is_dir entry then 72 + match read_tree repo entry.hash with 73 + | Error e -> Error e 74 + | Ok sub -> collect_added repo path sub (Ok changes) 75 + else Ok (Added { path; hash = entry.hash; perm = entry.perm } :: changes) 76 + 77 + (** Process a single removed entry (file or directory). *) 78 + let process_removed_entry repo prefix (entry : Tree.entry) changes = 79 + let path = path prefix entry.name in 80 + if is_dir entry then 81 + match read_tree repo entry.hash with 82 + | Error e -> Error e 83 + | Ok sub -> collect_removed repo path sub (Ok changes) 84 + else Ok (Removed { path; hash = entry.hash; perm = entry.perm } :: changes) 85 + 86 + (** Handle entries with same name but different content/type. *) 87 + let rec process_matched_entries repo prefix old_e new_e changes walk_fn = 88 + let path = path prefix old_e.Tree.name in 89 + match (is_dir old_e, is_dir new_e) with 90 + | true, true -> ( 91 + (* Both directories, recurse *) 92 + match (read_tree repo old_e.hash, read_tree repo new_e.hash) with 93 + | Ok old_entries', Ok new_entries' -> 94 + walk_fn repo path old_entries' new_entries' (Ok changes) 95 + | Error e, _ | _, Error e -> Error e) 96 + | false, false -> 97 + (* Both files, content changed *) 98 + Ok 99 + (Modified 100 + { 101 + path; 102 + old_hash = old_e.hash; 103 + new_hash = new_e.hash; 104 + old_perm = old_e.perm; 105 + new_perm = new_e.perm; 106 + } 107 + :: changes) 108 + | true, false -> ( 109 + (* Dir became file: remove dir contents, add file *) 110 + match read_tree repo old_e.hash with 111 + | Error e -> Error e 112 + | Ok sub -> ( 113 + match collect_removed repo path sub (Ok changes) with 114 + | Error e -> Error e 115 + | Ok changes' -> 116 + Ok 117 + (Added { path; hash = new_e.hash; perm = new_e.perm } 118 + :: changes'))) 119 + | false, true -> ( 120 + (* File became dir: remove file, add dir contents *) 121 + let change = Removed { path; hash = old_e.hash; perm = old_e.perm } in 122 + match read_tree repo new_e.hash with 123 + | Error e -> Error e 124 + | Ok sub -> collect_added repo path sub (Ok (change :: changes))) 125 + 126 + (** Merge-walk two sorted entry lists and compute differences. *) 127 + and walk_trees repo prefix old_entries new_entries acc = 128 + match (old_entries, new_entries, acc) with 129 + | _, _, Error _ -> acc 130 + | [], [], Ok changes -> Ok changes 131 + | [], new_e :: rest, Ok changes -> ( 132 + match process_added_entry repo prefix new_e changes with 133 + | Error e -> Error e 134 + | Ok changes' -> walk_trees repo prefix [] rest (Ok changes')) 135 + | old_e :: rest, [], Ok changes -> ( 136 + match process_removed_entry repo prefix old_e changes with 137 + | Error e -> Error e 138 + | Ok changes' -> walk_trees repo prefix rest [] (Ok changes')) 139 + | old_e :: old_rest, new_e :: new_rest, Ok changes -> ( 140 + let cmp = String.compare old_e.Tree.name new_e.Tree.name in 141 + if cmp < 0 then 142 + match process_removed_entry repo prefix old_e changes with 143 + | Error e -> Error e 144 + | Ok changes' -> 145 + walk_trees repo prefix old_rest new_entries (Ok changes') 146 + else if cmp > 0 then 147 + match process_added_entry repo prefix new_e changes with 148 + | Error e -> Error e 149 + | Ok changes' -> 150 + walk_trees repo prefix old_entries new_rest (Ok changes') 151 + else if Hash.equal old_e.hash new_e.hash && old_e.perm = new_e.perm then 152 + walk_trees repo prefix old_rest new_rest acc 153 + else 154 + match 155 + process_matched_entries repo prefix old_e new_e changes walk_trees 156 + with 157 + | Error e -> Error e 158 + | Ok changes' -> walk_trees repo prefix old_rest new_rest (Ok changes')) 159 + 160 + let trees repo ~old_tree ~new_tree = 161 + let old_entries = read_tree repo old_tree in 162 + let new_entries = read_tree repo new_tree in 163 + match (old_entries, new_entries) with 164 + | Error e, _ | _, Error e -> Error e 165 + | Ok old_e, Ok new_e -> ( 166 + match walk_trees repo "" old_e new_e (Ok []) with 167 + | Error e -> Error e 168 + | Ok changes -> Ok (List.sort compare_by_path changes)) 169 + 170 + let commits repo ~old_commit ~new_commit = 171 + let read_commit_tree hash = 172 + match Repository.read repo hash with 173 + | Ok (Value.Commit commit) -> Ok (Commit.tree commit) 174 + | Ok _ -> Error (`Msg "expected commit object") 175 + | Error e -> Error e 176 + in 177 + match (read_commit_tree old_commit, read_commit_tree new_commit) with 178 + | Error e, _ | _, Error e -> Error e 179 + | Ok old_tree, Ok new_tree -> trees repo ~old_tree ~new_tree 180 + 181 + let tree_to_empty repo tree = 182 + match read_tree repo tree with 183 + | Error e -> Error e 184 + | Ok entries -> ( 185 + match collect_added repo "" entries (Ok []) with 186 + | Error e -> Error e 187 + | Ok changes -> Ok (List.sort compare_by_path changes)) 188 + 189 + let empty_to_tree repo tree = 190 + match read_tree repo tree with 191 + | Error e -> Error e 192 + | Ok entries -> ( 193 + match collect_removed repo "" entries (Ok []) with 194 + | Error e -> Error e 195 + | Ok changes -> Ok (List.sort compare_by_path changes)) 196 + 197 + let filter_by_path ~prefix diff = 198 + let prefix_len = String.length prefix in 199 + List.filter 200 + (fun change -> 201 + let path = change_path change in 202 + String.length path >= prefix_len 203 + && String.sub path 0 prefix_len = prefix 204 + && (String.length path = prefix_len || path.[prefix_len] = '/')) 205 + diff 206 + 207 + let stats diff = 208 + List.fold_left 209 + (fun acc change -> 210 + match change with 211 + | Added _ -> { acc with additions = acc.additions + 1 } 212 + | Removed _ -> { acc with deletions = acc.deletions + 1 } 213 + | Modified _ -> { acc with modifications = acc.modifications + 1 }) 214 + { additions = 0; deletions = 0; modifications = 0 } 215 + diff 216 + 217 + let pp_change ppf change = 218 + match change with 219 + | Added { path; _ } -> Fmt.pf ppf "A %s" path 220 + | Removed { path; _ } -> Fmt.pf ppf "D %s" path 221 + | Modified { path; _ } -> Fmt.pf ppf "M %s" path 222 + 223 + let pp ppf diff = Fmt.pf ppf "@[<v>%a@]" Fmt.(list ~sep:cut pp_change) diff 224 + 225 + let pp_stats ppf stats = 226 + Fmt.pf ppf "%d additions, %d deletions, %d modifications" stats.additions 227 + stats.deletions stats.modifications
+80
lib/diff.mli
··· 1 + (** Tree and commit diff operations. 2 + 3 + This module provides functionality to compare trees and commits, producing a 4 + list of changes between them. *) 5 + 6 + (** {1 Types} *) 7 + 8 + (** A single change in a diff. *) 9 + type change = 10 + | Added of { path : string; hash : Hash.t; perm : Tree.perm } 11 + (** File or directory added *) 12 + | Removed of { path : string; hash : Hash.t; perm : Tree.perm } 13 + (** File or directory removed *) 14 + | Modified of { 15 + path : string; 16 + old_hash : Hash.t; 17 + new_hash : Hash.t; 18 + old_perm : Tree.perm; 19 + new_perm : Tree.perm; 20 + } (** File content changed (and optionally permissions) *) 21 + 22 + type t = change list 23 + (** A diff is a list of changes. *) 24 + 25 + (** {1 Diff Operations} *) 26 + 27 + val trees : 28 + Repository.t -> 29 + old_tree:Hash.t -> 30 + new_tree:Hash.t -> 31 + (t, [ `Msg of string ]) result 32 + (** [trees repo ~old_tree ~new_tree] computes the difference between two trees. 33 + 34 + Returns a list of changes needed to transform [old_tree] into [new_tree]. 35 + The changes are returned in lexicographical order by path. *) 36 + 37 + val commits : 38 + Repository.t -> 39 + old_commit:Hash.t -> 40 + new_commit:Hash.t -> 41 + (t, [ `Msg of string ]) result 42 + (** [commits repo ~old_commit ~new_commit] computes the difference between two 43 + commits by comparing their trees. *) 44 + 45 + val tree_to_empty : Repository.t -> Hash.t -> (t, [ `Msg of string ]) result 46 + (** [tree_to_empty repo tree] returns all entries in the tree as [Added] 47 + changes. Useful for showing the initial commit. *) 48 + 49 + val empty_to_tree : Repository.t -> Hash.t -> (t, [ `Msg of string ]) result 50 + (** [empty_to_tree repo tree] returns all entries in the tree as [Removed] 51 + changes. *) 52 + 53 + (** {1 Filtering} *) 54 + 55 + val filter_by_path : prefix:string -> t -> t 56 + (** [filter_by_path ~prefix diff] returns only changes under the given path 57 + prefix. *) 58 + 59 + (** {1 Statistics} *) 60 + 61 + type stats = { 62 + additions : int; (** Number of files added *) 63 + deletions : int; (** Number of files removed *) 64 + modifications : int; (** Number of files modified *) 65 + } 66 + (** Summary statistics for a diff. *) 67 + 68 + val stats : t -> stats 69 + (** [stats diff] computes summary statistics for the diff. *) 70 + 71 + (** {1 Pretty Printing} *) 72 + 73 + val pp_change : change Fmt.t 74 + (** [pp_change] formats a single change. *) 75 + 76 + val pp : t Fmt.t 77 + (** [pp] formats a diff as a list of changes. *) 78 + 79 + val pp_stats : stats Fmt.t 80 + (** [pp_stats] formats diff statistics. *)
+4
lib/dune
··· 1 + (library 2 + (name git) 3 + (public_name git) 4 + (libraries bytesrw bytesrw.zlib digestif eio fmt fpath ptime requests uri))
+40
lib/git.ml
··· 1 + (* Copyright (c) 2013-2017 Thomas Gazagnaire <thomas@gazagnaire.org> 2 + Copyright (c) 2017-2024 Romain Calascibetta <romain.calascibetta@gmail.com> 3 + Copyright (c) 2024-2026 Thomas Gazagnaire <thomas@gazagnaire.org> 4 + 5 + Permission to use, copy, modify, and distribute this software for any 6 + purpose with or without fee is hereby granted, provided that the above 7 + copyright notice and this permission notice appear in all copies. 8 + 9 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. *) 16 + 17 + (** Git object format implementation in pure OCaml. 18 + 19 + This library provides type-safe encoding and decoding of Git objects: blobs, 20 + trees, commits, and tags. It is derived from mirage/ocaml-git and 21 + robur-coop/carton, simplified for Eio-native usage. *) 22 + 23 + module Hash = Hash 24 + module User = User 25 + module Blob = Blob 26 + module Tree = Tree 27 + module Commit = Commit 28 + module Tag = Tag 29 + module Value = Value 30 + module Reference = Reference 31 + module Pack = Pack 32 + module Pack_index = Pack_index 33 + module Repository = Repository 34 + module Rev_list = Rev_list 35 + module Subtree = Subtree 36 + module Config = Config 37 + module Index = Index 38 + module Remote = Remote 39 + module Diff = Diff 40 + module Worktree = Worktree
+107
lib/git.mli
··· 1 + (* Copyright (c) 2013-2017 Thomas Gazagnaire <thomas@gazagnaire.org> 2 + Copyright (c) 2017-2024 Romain Calascibetta <romain.calascibetta@gmail.com> 3 + Copyright (c) 2024-2026 Thomas Gazagnaire <thomas@gazagnaire.org> 4 + 5 + Permission to use, copy, modify, and distribute this software for any 6 + purpose with or without fee is hereby granted, provided that the above 7 + copyright notice and this permission notice appear in all copies. 8 + 9 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. *) 16 + 17 + (** Git object format implementation in pure OCaml. 18 + 19 + This library provides type-safe encoding and decoding of Git objects: blobs, 20 + trees, commits, and tags. It is derived from 21 + {{:https://github.com/mirage/ocaml-git}mirage/ocaml-git} and 22 + {{:https://github.com/robur-coop/carton}robur-coop/carton}. 23 + 24 + {1 Object types} 25 + 26 + Git stores four types of objects: 27 + - {!Blob}: File contents 28 + - {!Tree}: Directory listings (entries with mode, name, and hash) 29 + - {!Commit}: Snapshots with metadata (tree, parents, author, committer, 30 + message) 31 + - {!Tag}: Named references to objects with optional signature 32 + 33 + {1 Example} 34 + 35 + {[ 36 + (* Create a blob *) 37 + let blob = Git.Blob.of_string "Hello, World!\n" 38 + let blob_hash = Git.Blob.digest blob 39 + 40 + (* Create a tree entry *) 41 + let entry = Git.Tree.entry ~perm:`Normal ~name:"hello.txt" blob_hash 42 + let tree = Git.Tree.v [ entry ] 43 + let tree_hash = Git.Tree.digest tree 44 + 45 + (* Create a commit *) 46 + let author = Git.User.v ~name:"Alice" ~email:"alice@example.com" () 47 + 48 + let commit = 49 + Git.Commit.v ~tree:tree_hash ~author ~committer:author 50 + (Some "Initial commit") 51 + 52 + let commit_hash = Git.Commit.digest commit 53 + ]} *) 54 + 55 + module Hash = Hash 56 + (** Git object hashes (SHA-1). *) 57 + 58 + module User = User 59 + (** Git user information (author/committer). *) 60 + 61 + module Blob = Blob 62 + (** Git blob objects (file contents). *) 63 + 64 + module Tree = Tree 65 + (** Git tree objects (directory listings). *) 66 + 67 + module Commit = Commit 68 + (** Git commit objects. *) 69 + 70 + module Tag = Tag 71 + (** Git tag objects. *) 72 + 73 + module Value = Value 74 + (** Git values (union of all object types). *) 75 + 76 + module Reference = Reference 77 + (** Git references (branches, tags, HEAD). *) 78 + 79 + module Pack = Pack 80 + (** Git pack files (compressed object storage). *) 81 + 82 + module Pack_index = Pack_index 83 + (** Git pack index files (fast object lookup). *) 84 + 85 + module Repository = Repository 86 + (** Git repository access (loose objects + pack files). *) 87 + 88 + module Rev_list = Rev_list 89 + (** Commit graph traversal and topological ordering. *) 90 + 91 + module Subtree = Subtree 92 + (** Fast subtree split with persistent caching. *) 93 + 94 + module Config = Config 95 + (** Git config file parsing (.git/config). *) 96 + 97 + module Index = Index 98 + (** Git index (staging area) operations. *) 99 + 100 + module Remote = Remote 101 + (** Git remote queries via smart HTTP protocol. *) 102 + 103 + module Diff = Diff 104 + (** Git diff operations. *) 105 + 106 + module Worktree = Worktree 107 + (** Git worktree operations. *)
+68
lib/hash.ml
··· 1 + (* Copyright (c) 2013-2017 Thomas Gazagnaire <thomas@gazagnaire.org> 2 + Copyright (c) 2017-2024 Romain Calascibetta <romain.calascibetta@gmail.com> 3 + Copyright (c) 2024-2026 Thomas Gazagnaire <thomas@gazagnaire.org> 4 + 5 + Permission to use, copy, modify, and distribute this software for any 6 + purpose with or without fee is hereby granted, provided that the above 7 + copyright notice and this permission notice appear in all copies. 8 + 9 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. *) 16 + 17 + (** Git object hashes (SHA-1 or SHA-256). *) 18 + 19 + type t = Digestif.SHA1.t 20 + 21 + let digest_size = Digestif.SHA1.digest_size 22 + let pp = Digestif.SHA1.pp 23 + let equal = Digestif.SHA1.equal 24 + let compare = Digestif.SHA1.unsafe_compare 25 + 26 + let of_raw_string s = 27 + if String.length s <> digest_size then 28 + invalid_arg "Hash.of_raw_string: invalid length"; 29 + Digestif.SHA1.of_raw_string s 30 + 31 + let to_raw_string = Digestif.SHA1.to_raw_string 32 + let of_hex s = Digestif.SHA1.of_hex s 33 + let to_hex t = Digestif.SHA1.to_hex t 34 + 35 + let null = 36 + let s = String.make digest_size '\x00' in 37 + of_raw_string s 38 + 39 + let v t = Hashtbl.hash (to_raw_string t) 40 + 41 + module Set = Set.Make (struct 42 + type nonrec t = t 43 + 44 + let compare = compare 45 + end) 46 + 47 + module Map = Map.Make (struct 48 + type nonrec t = t 49 + 50 + let compare = compare 51 + end) 52 + 53 + (** Compute the hash of a git object. *) 54 + let digest ~kind ~length contents = 55 + let kind_str = 56 + match kind with 57 + | `Blob -> "blob" 58 + | `Tree -> "tree" 59 + | `Commit -> "commit" 60 + | `Tag -> "tag" 61 + in 62 + let header = Fmt.str "%s %d\x00" kind_str length in 63 + let ctx = Digestif.SHA1.empty in 64 + let ctx = Digestif.SHA1.feed_string ctx header in 65 + let ctx = Digestif.SHA1.feed_string ctx contents in 66 + Digestif.SHA1.get ctx 67 + 68 + let digest_string ~kind s = digest ~kind ~length:(String.length s) s
+62
lib/hash.mli
··· 1 + (* Copyright (c) 2013-2017 Thomas Gazagnaire <thomas@gazagnaire.org> 2 + Copyright (c) 2017-2024 Romain Calascibetta <romain.calascibetta@gmail.com> 3 + Copyright (c) 2024-2026 Thomas Gazagnaire <thomas@gazagnaire.org> 4 + 5 + Permission to use, copy, modify, and distribute this software for any 6 + purpose with or without fee is hereby granted, provided that the above 7 + copyright notice and this permission notice appear in all copies. 8 + 9 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. *) 16 + 17 + (** Git object hashes (SHA-1). *) 18 + 19 + type t 20 + (** The type of hashes. *) 21 + 22 + val digest_size : int 23 + (** The size of a hash in bytes (20 for SHA-1). *) 24 + 25 + val pp : t Fmt.t 26 + (** Pretty-print a hash in hex format. *) 27 + 28 + val equal : t -> t -> bool 29 + (** Equality on hashes. *) 30 + 31 + val compare : t -> t -> int 32 + (** Total ordering on hashes. *) 33 + 34 + val v : t -> int 35 + (** Hash function for use with Hashtbl. *) 36 + 37 + val of_raw_string : string -> t 38 + (** [of_raw_string s] parses a hash from its raw binary form. 39 + @raise Invalid_argument if [s] has wrong length. *) 40 + 41 + val to_raw_string : t -> string 42 + (** [to_raw_string h] returns the raw binary form of [h]. *) 43 + 44 + val of_hex : string -> t 45 + (** [of_hex s] parses a hash from its hexadecimal representation. 46 + @raise Invalid_argument if [s] is not valid hex. *) 47 + 48 + val to_hex : t -> string 49 + (** [to_hex h] returns the hexadecimal representation of [h]. *) 50 + 51 + val null : t 52 + (** The null hash (all zeros). *) 53 + 54 + val digest : 55 + kind:[< `Blob | `Commit | `Tag | `Tree ] -> length:int -> string -> t 56 + (** [digest ~kind ~length content] computes the git hash of an object. *) 57 + 58 + val digest_string : kind:[< `Blob | `Commit | `Tag | `Tree ] -> string -> t 59 + (** [digest_string ~kind s] computes the git hash of a string. *) 60 + 61 + module Set : Set.S with type elt = t 62 + module Map : Map.S with type key = t
+356
lib/index.ml
··· 1 + (* Copyright (c) 2013-2017 Thomas Gazagnaire <thomas@gazagnaire.org> 2 + Copyright (c) 2017-2024 Romain Calascibetta <romain.calascibetta@gmail.com> 3 + Copyright (c) 2024-2026 Thomas Gazagnaire <thomas@gazagnaire.org> 4 + 5 + Permission to use, copy, modify, and distribute this software for any 6 + purpose with or without fee is hereby granted, provided that the above 7 + copyright notice and this permission notice appear in all copies. 8 + 9 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. *) 16 + 17 + (** Git index (staging area) operations. 18 + 19 + Binary format: 20 + - 4-byte signature "DIRC" 21 + - 4-byte version (2, 3, or 4) 22 + - 32-bit number of entries 23 + - Index entries (sorted by path) 24 + - Extensions (optional) 25 + - 20-byte SHA-1 checksum *) 26 + 27 + type mode = Regular | Executable | Symlink | Gitlink 28 + 29 + type entry = { 30 + ctime_s : int32; 31 + ctime_ns : int32; 32 + mtime_s : int32; 33 + mtime_ns : int32; 34 + dev : int32; 35 + ino : int32; 36 + mode : mode; 37 + uid : int32; 38 + gid : int32; 39 + size : int32; 40 + hash : Hash.t; 41 + flags : int; 42 + name : string; 43 + } 44 + 45 + type t = { version : int; entries : entry list } 46 + 47 + let err_unsupported_version v = 48 + Error (`Msg (Fmt.str "unsupported index version %d" v)) 49 + 50 + let pp ppf t = 51 + Fmt.pf ppf "@[<v>index v%d (%d entries)@]" t.version (List.length t.entries) 52 + 53 + let empty = { version = 2; entries = [] } 54 + 55 + (** {1 Binary reading helpers} *) 56 + 57 + let int32_be s off = 58 + let b0 = Char.code s.[off] in 59 + let b1 = Char.code s.[off + 1] in 60 + let b2 = Char.code s.[off + 2] in 61 + let b3 = Char.code s.[off + 3] in 62 + Int32.of_int ((b0 lsl 24) lor (b1 lsl 16) lor (b2 lsl 8) lor b3) 63 + 64 + let int16_be s off = 65 + let b0 = Char.code s.[off] in 66 + let b1 = Char.code s.[off + 1] in 67 + (b0 lsl 8) lor b1 68 + 69 + (** {1 Binary writing helpers} *) 70 + 71 + let put_int32_be buf n = 72 + Buffer.add_char buf 73 + (Char.chr (Int32.to_int (Int32.shift_right_logical n 24) land 0xff)); 74 + Buffer.add_char buf 75 + (Char.chr (Int32.to_int (Int32.shift_right_logical n 16) land 0xff)); 76 + Buffer.add_char buf 77 + (Char.chr (Int32.to_int (Int32.shift_right_logical n 8) land 0xff)); 78 + Buffer.add_char buf (Char.chr (Int32.to_int n land 0xff)) 79 + 80 + let put_int16_be buf n = 81 + Buffer.add_char buf (Char.chr ((n lsr 8) land 0xff)); 82 + Buffer.add_char buf (Char.chr (n land 0xff)) 83 + 84 + (** {1 Mode encoding} *) 85 + 86 + let mode_of_int = function 87 + | 0o100644 -> Regular 88 + | 0o100755 -> Executable 89 + | 0o120000 -> Symlink 90 + | 0o160000 -> Gitlink 91 + | _ -> Regular (* Default to regular for unknown modes *) 92 + 93 + let int_of_mode = function 94 + | Regular -> 0o100644 95 + | Executable -> 0o100755 96 + | Symlink -> 0o120000 97 + | Gitlink -> 0o160000 98 + 99 + (** {1 Parsing} *) 100 + 101 + (** Find NUL terminator in string starting at given offset. *) 102 + let nul s start = 103 + let rec loop i = 104 + if i >= String.length s then String.length s 105 + else if s.[i] = '\x00' then i 106 + else loop (i + 1) 107 + in 108 + loop start 109 + 110 + (** Calculate padded entry length (8-byte boundary). *) 111 + let padded_entry_len off nul_pos = ((nul_pos - off + 1 + 7) / 8 * 8) + off 112 + 113 + let parse_entry s off version = 114 + if off + 62 > String.length s then Error (`Msg "truncated entry") 115 + else 116 + let ctime_s = int32_be s off in 117 + let ctime_ns = int32_be s (off + 4) in 118 + let mtime_s = int32_be s (off + 8) in 119 + let mtime_ns = int32_be s (off + 12) in 120 + let dev = int32_be s (off + 16) in 121 + let ino = int32_be s (off + 20) in 122 + let mode = mode_of_int (Int32.to_int (int32_be s (off + 24))) in 123 + let uid = int32_be s (off + 28) in 124 + let gid = int32_be s (off + 32) in 125 + let size = int32_be s (off + 36) in 126 + let hash = Hash.of_raw_string (String.sub s (off + 40) 20) in 127 + let flags = int16_be s (off + 60) in 128 + let base_off = off + 62 in 129 + (* Version 3+ has extended flags *) 130 + let base_off = 131 + if version >= 3 && flags land 0x4000 <> 0 then base_off + 2 else base_off 132 + in 133 + let nul_pos = nul s base_off in 134 + let name = String.sub s base_off (nul_pos - base_off) in 135 + let next_off = padded_entry_len off nul_pos in 136 + Ok 137 + ( { 138 + ctime_s; 139 + ctime_ns; 140 + mtime_s; 141 + mtime_ns; 142 + dev; 143 + ino; 144 + mode; 145 + uid; 146 + gid; 147 + size; 148 + hash; 149 + flags; 150 + name; 151 + }, 152 + next_off ) 153 + 154 + let of_string s = 155 + let len = String.length s in 156 + if len < 12 then Error (`Msg "index too short") 157 + else if String.sub s 0 4 <> "DIRC" then Error (`Msg "invalid index signature") 158 + else 159 + let version = Int32.to_int (int32_be s 4) in 160 + if version < 2 || version > 4 then err_unsupported_version version 161 + else 162 + let num_entries = Int32.to_int (int32_be s 8) in 163 + let rec parse_entries acc off remaining = 164 + if remaining = 0 then Ok (List.rev acc, off) 165 + else 166 + match parse_entry s off version with 167 + | Error e -> Error e 168 + | Ok (entry, next_off) -> 169 + parse_entries (entry :: acc) next_off (remaining - 1) 170 + in 171 + match parse_entries [] 12 num_entries with 172 + | Error e -> Error e 173 + | Ok (entries, _off) -> 174 + (* Skip extensions and checksum validation for now *) 175 + Ok { version; entries } 176 + 177 + (** {1 Serialization} *) 178 + 179 + let serialize_entry buf entry = 180 + put_int32_be buf entry.ctime_s; 181 + put_int32_be buf entry.ctime_ns; 182 + put_int32_be buf entry.mtime_s; 183 + put_int32_be buf entry.mtime_ns; 184 + put_int32_be buf entry.dev; 185 + put_int32_be buf entry.ino; 186 + put_int32_be buf (Int32.of_int (int_of_mode entry.mode)); 187 + put_int32_be buf entry.uid; 188 + put_int32_be buf entry.gid; 189 + put_int32_be buf entry.size; 190 + Buffer.add_string buf (Hash.to_raw_string entry.hash); 191 + (* Flags: stage (bits 12-13) and name length (bits 0-11) *) 192 + let name_len = min 0xfff (String.length entry.name) in 193 + let flags = entry.flags land 0x3000 lor name_len in 194 + put_int16_be buf flags; 195 + Buffer.add_string buf entry.name; 196 + Buffer.add_char buf '\x00'; 197 + (* Pad to 8-byte boundary *) 198 + let entry_len = 62 + String.length entry.name + 1 in 199 + let padding = ((entry_len + 7) / 8 * 8) - entry_len in 200 + for _ = 1 to padding do 201 + Buffer.add_char buf '\x00' 202 + done 203 + 204 + let to_string t = 205 + let buf = Buffer.create 4096 in 206 + Buffer.add_string buf "DIRC"; 207 + put_int32_be buf (Int32.of_int t.version); 208 + put_int32_be buf (Int32.of_int (List.length t.entries)); 209 + List.iter (serialize_entry buf) t.entries; 210 + (* Compute and add SHA-1 checksum *) 211 + let content = Buffer.contents buf in 212 + let checksum = Digestif.SHA1.digest_string content in 213 + let checksum_raw = Digestif.SHA1.to_raw_string checksum in 214 + Buffer.add_string buf checksum_raw; 215 + Buffer.contents buf 216 + 217 + (** {1 Entry access} *) 218 + 219 + let entries t = t.entries 220 + let find t path = List.find_opt (fun e -> e.name = path) t.entries 221 + let mem t path = List.exists (fun e -> e.name = path) t.entries 222 + 223 + (** {1 Index modification} *) 224 + 225 + let add t entry = 226 + let entries = List.filter (fun e -> e.name <> entry.name) t.entries in 227 + let entries = entry :: entries in 228 + (* Keep entries sorted by path *) 229 + let entries = List.sort (fun a b -> String.compare a.name b.name) entries in 230 + { t with entries } 231 + 232 + let remove t path = 233 + let entries = List.filter (fun e -> e.name <> path) t.entries in 234 + { t with entries } 235 + 236 + let remove_prefix t prefix = 237 + (* Remove all entries whose path starts with prefix/ or equals prefix *) 238 + let prefix_slash = prefix ^ "/" in 239 + let entries = 240 + List.filter 241 + (fun e -> 242 + e.name <> prefix && not (String.starts_with ~prefix:prefix_slash e.name)) 243 + t.entries 244 + in 245 + { t with entries } 246 + 247 + let clear t = { t with entries = [] } 248 + 249 + (** {1 Working with files} *) 250 + 251 + let entry_of_file ~fs ~root ~path ~hash = 252 + let full_path = Eio.Path.(fs / root / path) in 253 + let stat = 254 + try Some (Eio.Path.stat ~follow:false full_path) 255 + with Eio.Io _ | Unix.Unix_error _ -> None 256 + in 257 + match stat with 258 + | None -> 259 + (* File doesn't exist, create minimal entry *) 260 + { 261 + ctime_s = 0l; 262 + ctime_ns = 0l; 263 + mtime_s = 0l; 264 + mtime_ns = 0l; 265 + dev = 0l; 266 + ino = 0l; 267 + mode = Regular; 268 + uid = 0l; 269 + gid = 0l; 270 + size = 0l; 271 + hash; 272 + flags = 0; 273 + name = path; 274 + } 275 + | Some stat -> 276 + let mode = 277 + if stat.kind = `Regular_file then 278 + (* Check if executable - simplified, assume regular *) 279 + Regular 280 + else if stat.kind = `Symbolic_link then Symlink 281 + else Regular 282 + in 283 + let mtime_s = Int64.to_int32 (Int64.of_float stat.mtime) in 284 + let mtime_ns = 285 + Int32.of_float ((stat.mtime -. Float.floor stat.mtime) *. 1e9) 286 + in 287 + { 288 + ctime_s = mtime_s; 289 + (* Use mtime for ctime as Eio doesn't provide ctime *) 290 + ctime_ns = mtime_ns; 291 + mtime_s; 292 + mtime_ns; 293 + dev = Int64.to_int32 stat.dev; 294 + ino = Int64.to_int32 stat.ino; 295 + mode; 296 + uid = Int64.to_int32 stat.uid; 297 + gid = Int64.to_int32 stat.gid; 298 + size = Int32.of_int (Optint.Int63.to_int stat.size); 299 + hash; 300 + flags = 0; 301 + name = path; 302 + } 303 + 304 + (** {1 Tree building} *) 305 + 306 + module String_map = Map.Make (String) 307 + 308 + type tree_node = 309 + | Blob of Hash.t * Tree.perm 310 + | Subtree of tree_node String_map.t 311 + 312 + let to_tree ~write_tree ~write_blob:_ t = 313 + (* Build a tree structure from flat index entries *) 314 + let root = ref String_map.empty in 315 + List.iter 316 + (fun entry -> 317 + let parts = String.split_on_char '/' entry.name in 318 + let rec insert node parts = 319 + match parts with 320 + | [] -> node 321 + | [ filename ] -> 322 + let perm = 323 + match entry.mode with 324 + | Regular -> `Normal 325 + | Executable -> `Exec 326 + | Symlink -> `Link 327 + | Gitlink -> `Commit 328 + in 329 + String_map.add filename (Blob (entry.hash, perm)) node 330 + | dir :: rest -> 331 + let subtree = 332 + match String_map.find_opt dir node with 333 + | Some (Subtree m) -> m 334 + | _ -> String_map.empty 335 + in 336 + let subtree = insert subtree rest in 337 + String_map.add dir (Subtree subtree) node 338 + in 339 + root := insert !root parts) 340 + t.entries; 341 + (* Convert tree_node to Git trees, bottom-up *) 342 + let rec build_tree node = 343 + let entries = 344 + String_map.fold 345 + (fun name child acc -> 346 + match child with 347 + | Blob (hash, perm) -> Tree.entry ~perm ~name hash :: acc 348 + | Subtree submap -> 349 + let subtree_hash = build_tree submap in 350 + Tree.entry ~perm:`Dir ~name subtree_hash :: acc) 351 + node [] 352 + in 353 + let tree = Tree.v (List.rev entries) in 354 + write_tree tree 355 + in 356 + build_tree !root
+101
lib/index.mli
··· 1 + (* Copyright (c) 2013-2017 Thomas Gazagnaire <thomas@gazagnaire.org> 2 + Copyright (c) 2017-2024 Romain Calascibetta <romain.calascibetta@gmail.com> 3 + Copyright (c) 2024-2026 Thomas Gazagnaire <thomas@gazagnaire.org> 4 + 5 + Permission to use, copy, modify, and distribute this software for any 6 + purpose with or without fee is hereby granted, provided that the above 7 + copyright notice and this permission notice appear in all copies. 8 + 9 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. *) 16 + 17 + (** Git index (staging area) operations. 18 + 19 + The index file (.git/index) is a binary format that represents the staging 20 + area - files that will be included in the next commit. *) 21 + 22 + (** {1 Types} *) 23 + 24 + type mode = Regular | Executable | Symlink | Gitlink (** Submodule *) 25 + 26 + type entry = { 27 + ctime_s : int32; 28 + ctime_ns : int32; 29 + mtime_s : int32; 30 + mtime_ns : int32; 31 + dev : int32; 32 + ino : int32; 33 + mode : mode; 34 + uid : int32; 35 + gid : int32; 36 + size : int32; 37 + hash : Hash.t; 38 + flags : int; (** Stage and other flags *) 39 + name : string; 40 + } 41 + (** An index entry representing a staged file. *) 42 + 43 + type t 44 + (** The index (staging area). *) 45 + 46 + val pp : t Fmt.t 47 + (** [pp] pretty-prints an index summary. *) 48 + 49 + (** {1 Parsing and serialization} *) 50 + 51 + val empty : t 52 + (** Empty index. *) 53 + 54 + val of_string : string -> (t, [ `Msg of string ]) result 55 + (** Parse index file content. *) 56 + 57 + val to_string : t -> string 58 + (** Serialize index to binary format. *) 59 + 60 + (** {1 Entry access} *) 61 + 62 + val entries : t -> entry list 63 + (** [entries t] returns all entries sorted by path. *) 64 + 65 + val find : t -> string -> entry option 66 + (** [find t path] returns the entry for [path]. *) 67 + 68 + val mem : t -> string -> bool 69 + (** [mem t path] returns true if [path] is in the index. *) 70 + 71 + (** {1 Index modification} *) 72 + 73 + val add : t -> entry -> t 74 + (** [add t entry] adds or updates an entry. *) 75 + 76 + val remove : t -> string -> t 77 + (** [remove t path] removes the entry for [path]. *) 78 + 79 + val remove_prefix : t -> string -> t 80 + (** [remove_prefix t prefix] removes all entries under [prefix]. *) 81 + 82 + val clear : t -> t 83 + (** [clear t] removes all entries. *) 84 + 85 + (** {1 Working with files} *) 86 + 87 + val entry_of_file : 88 + fs:Eio.Fs.dir_ty Eio.Path.t -> 89 + root:string -> 90 + path:string -> 91 + hash:Hash.t -> 92 + entry 93 + (** [entry_of_file ~fs ~root ~path ~hash] creates an index entry from a file. 94 + [path] is relative to [root]. *) 95 + 96 + (** {1 Tree building} *) 97 + 98 + val to_tree : 99 + write_tree:(Tree.t -> Hash.t) -> write_blob:(string -> Hash.t) -> t -> Hash.t 100 + (** [to_tree ~write_tree ~write_blob t] builds a tree from the index and returns 101 + the root tree hash. *)
+381
lib/pack.ml
··· 1 + (* Copyright (c) 2013-2017 Thomas Gazagnaire <thomas@gazagnaire.org> 2 + Copyright (c) 2017-2024 Romain Calascibetta <romain.calascibetta@gmail.com> 3 + Copyright (c) 2024-2026 Thomas Gazagnaire <thomas@gazagnaire.org> 4 + 5 + Permission to use, copy, modify, and distribute this software for any 6 + purpose with or without fee is hereby granted, provided that the above 7 + copyright notice and this permission notice appear in all copies. 8 + 9 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. *) 16 + 17 + (** Git pack file support. 18 + 19 + Pack files store multiple git objects in a compressed format with delta 20 + encoding for efficient storage. *) 21 + 22 + module Reader = Bytesrw.Bytes.Reader 23 + module Slice = Bytesrw.Bytes.Slice 24 + 25 + (** {1 Error helpers} *) 26 + 27 + let err_invalid_obj_type n = 28 + Error (`Msg (Fmt.str "Invalid pack object type: %d" n)) 29 + 30 + let err_unsupported_version v = 31 + Error (`Msg (Fmt.str "Unsupported pack version: %d" v)) 32 + 33 + (** {1 Pack file format} 34 + 35 + Pack file structure: 36 + - Header: "PACK" (4 bytes) + version (4 bytes, big-endian) + count (4 bytes) 37 + - Objects: variable-length entries 38 + - Footer: SHA-1 checksum (20 bytes) *) 39 + 40 + (** Object types in pack files. *) 41 + type obj_type = 42 + | Commit 43 + | Tree 44 + | Blob 45 + | Tag 46 + | Ofs_delta (** Delta relative to offset in same pack *) 47 + | Ref_delta (** Delta relative to object by hash *) 48 + 49 + let obj_type_to_int = function 50 + | Commit -> 1 51 + | Tree -> 2 52 + | Blob -> 3 53 + | Tag -> 4 54 + | Ofs_delta -> 6 55 + | Ref_delta -> 7 56 + 57 + let obj_type_of_int = function 58 + | 1 -> Ok Commit 59 + | 2 -> Ok Tree 60 + | 3 -> Ok Blob 61 + | 4 -> Ok Tag 62 + | 6 -> Ok Ofs_delta 63 + | 7 -> Ok Ref_delta 64 + | n -> err_invalid_obj_type n 65 + 66 + let pp_obj_type ppf = function 67 + | Commit -> Fmt.string ppf "commit" 68 + | Tree -> Fmt.string ppf "tree" 69 + | Blob -> Fmt.string ppf "blob" 70 + | Tag -> Fmt.string ppf "tag" 71 + | Ofs_delta -> Fmt.string ppf "ofs-delta" 72 + | Ref_delta -> Fmt.string ppf "ref-delta" 73 + 74 + type header = { version : int; count : int } 75 + (** Pack file header. *) 76 + 77 + let pp_header ppf h = 78 + Fmt.pf ppf "{ version = %d; count = %d }" h.version h.count 79 + 80 + type entry_header = { 81 + obj_type : obj_type; 82 + size : int; (** Uncompressed size *) 83 + } 84 + (** Entry header in pack file. *) 85 + 86 + (** {1 Reading pack files} *) 87 + 88 + (** Read big-endian 32-bit integer from string at offset. *) 89 + let int32_be s off = 90 + let b0 = Char.code s.[off] in 91 + let b1 = Char.code s.[off + 1] in 92 + let b2 = Char.code s.[off + 2] in 93 + let b3 = Char.code s.[off + 3] in 94 + (b0 lsl 24) lor (b1 lsl 16) lor (b2 lsl 8) lor b3 95 + 96 + (** {1 Zlib decompression using bytesrw.zlib} 97 + 98 + We use bytesrw.zlib with [~leftover:true] to handle concatenated zlib 99 + streams in pack files. This positions the reader after each stream ends. *) 100 + 101 + (** Create a reader that serves bytes from [data] starting at [first]. Uses 102 + {!Bytes.unsafe_of_string} to avoid copying the string. *) 103 + let reader_of_string_at ~first data = 104 + let bytes = Bytes.unsafe_of_string data in 105 + let length = String.length data - first in 106 + let pos = ref 0 in 107 + let slice_length = min length (64 * 1024) in 108 + let read () = 109 + if !pos >= length then Slice.eod 110 + else 111 + let len = min slice_length (length - !pos) in 112 + let s = Slice.make bytes ~first:(first + !pos) ~length:len in 113 + pos := !pos + len; 114 + s 115 + in 116 + Reader.make ~slice_length read 117 + 118 + (** Shared buffer for decompression output. Avoids allocating a new {!Buffer.t} 119 + per inflate call -- [Buffer.clear] resets the length without freeing the 120 + backing storage, so after the first large decompression the buffer never 121 + reallocates. *) 122 + let inflate_buf = Buffer.create (64 * 1024) 123 + 124 + (** Decompress zlib-compressed data starting at [first] in [data]. Returns 125 + (decompressed_data, bytes_consumed). Zero-copy on input: reads directly from 126 + the string via {!reader_of_string_at}. Reuses a shared output buffer to 127 + avoid per-call allocation. *) 128 + let inflate_with_consumed ?(first = 0) data = 129 + try 130 + let base_reader = reader_of_string_at ~first data in 131 + let decompressed = 132 + Bytesrw_zlib.Zlib.decompress_reads ~leftover:true () base_reader 133 + in 134 + Buffer.clear inflate_buf; 135 + Reader.add_to_buffer inflate_buf decompressed; 136 + let output = Buffer.contents inflate_buf in 137 + Ok (output, Reader.pos base_reader) 138 + with exn -> 139 + Error 140 + (`Msg (Fmt.str "zlib decompression error: %s" (Printexc.to_string exn))) 141 + 142 + (** Decompress zlib-compressed data starting at [first] in [data]. *) 143 + let inflate ?first data = 144 + match inflate_with_consumed ?first data with 145 + | Ok (output, _consumed) -> Ok output 146 + | Error e -> Error e 147 + 148 + (** {1 Delta decoding} 149 + 150 + Delta format: 151 + - Source size (varint) 152 + - Target size (varint) 153 + - Commands: COPY or INSERT *) 154 + 155 + (** Read a variable-length integer from a string at given offset. Returns 156 + (value, new_offset). *) 157 + let read_varint_from_string s off = 158 + let rec loop value shift off = 159 + if off >= String.length s then (value, off) 160 + else 161 + let b = Char.code s.[off] in 162 + let value = value lor ((b land 0x7F) lsl shift) in 163 + if b land 0x80 = 0 then (value, off + 1) 164 + else loop value (shift + 7) (off + 1) 165 + in 166 + loop 0 0 off 167 + 168 + (** Parse copy offset from delta command byte and data. Returns (offset, 169 + next_pos). *) 170 + let parse_copy_offset delta cmd off = 171 + let read_if mask shift off acc = 172 + if cmd land mask <> 0 then 173 + (acc lor (Char.code delta.[off] lsl shift), off + 1) 174 + else (acc, off) 175 + in 176 + let offset, off = read_if 0x01 0 off 0 in 177 + let offset, off = read_if 0x02 8 off offset in 178 + let offset, off = read_if 0x04 16 off offset in 179 + let offset, off = read_if 0x08 24 off offset in 180 + (offset, off) 181 + 182 + (** Parse copy size from delta command byte and data. Returns (size, next_pos). 183 + *) 184 + let parse_copy_size delta cmd off = 185 + let read_if mask shift off acc = 186 + if cmd land mask <> 0 then 187 + (acc lor (Char.code delta.[off] lsl shift), off + 1) 188 + else (acc, off) 189 + in 190 + let size, off = read_if 0x10 0 off 0 in 191 + let size, off = read_if 0x20 8 off size in 192 + let size, off = read_if 0x40 16 off size in 193 + (* Size of 0 means 0x10000 *) 194 + let size = if size = 0 then 0x10000 else size in 195 + (size, off) 196 + 197 + (** Apply delta to source to produce target. *) 198 + let apply_delta ~source ~delta = 199 + let delta_len = String.length delta in 200 + if delta_len < 2 then Error (`Msg "Delta too short") 201 + else 202 + let source_size, off = read_varint_from_string delta 0 in 203 + if source_size <> String.length source then 204 + Error 205 + (`Msg 206 + (Fmt.str "Delta source size mismatch: expected %d, got %d" 207 + source_size (String.length source))) 208 + else 209 + let target_size, off = read_varint_from_string delta off in 210 + let target = Buffer.create target_size in 211 + let rec apply off = 212 + if off >= delta_len then 213 + if Buffer.length target = target_size then Ok (Buffer.contents target) 214 + else 215 + Error 216 + (`Msg 217 + (Fmt.str "Delta target size mismatch: expected %d, got %d" 218 + target_size (Buffer.length target))) 219 + else 220 + let cmd = Char.code delta.[off] in 221 + if cmd = 0 then Error (`Msg "Invalid delta command: 0") 222 + else if cmd land 0x80 <> 0 then begin 223 + (* COPY command *) 224 + let copy_offset, off = parse_copy_offset delta cmd (off + 1) in 225 + let copy_size, off = parse_copy_size delta cmd off in 226 + Buffer.add_substring target source copy_offset copy_size; 227 + apply off 228 + end 229 + else begin 230 + (* INSERT command - cmd is the number of literal bytes *) 231 + Buffer.add_substring target delta (off + 1) cmd; 232 + apply (off + 1 + cmd) 233 + end 234 + in 235 + apply off 236 + 237 + (** Object kind (base types only). *) 238 + let kind_of_obj_type = function 239 + | Commit -> `Commit 240 + | Tree -> `Tree 241 + | Blob -> `Blob 242 + | Tag -> `Tag 243 + | Ofs_delta | Ref_delta -> assert false (* Not base types *) 244 + 245 + (** {1 Pack reading with random access} *) 246 + 247 + type t = { 248 + header : header; 249 + data : string; (** Full pack file data for random access *) 250 + } 251 + (** A pack file opened for reading. *) 252 + 253 + (** Open a pack file from a string. *) 254 + let of_string data = 255 + if String.length data < 12 then Error (`Msg "Pack file too short") 256 + else 257 + let magic = String.sub data 0 4 in 258 + if magic <> "PACK" then Error (`Msg "Invalid pack file: bad magic") 259 + else 260 + let version = int32_be data 4 in 261 + if version <> 2 && version <> 3 then err_unsupported_version version 262 + else 263 + let count = int32_be data 8 in 264 + Ok { header = { version; count }; data } 265 + 266 + (** Read entry header at given offset. Returns (header, data_offset). *) 267 + let read_entry_header_at t offset = 268 + let open Result.Syntax in 269 + if offset >= String.length t.data then Error (`Msg "Offset beyond pack file") 270 + else 271 + let first = Char.code t.data.[offset] in 272 + let type_bits = (first lsr 4) land 0x07 in 273 + let* obj_type = obj_type_of_int type_bits in 274 + let size = first land 0x0F in 275 + let continue = first land 0x80 <> 0 in 276 + if not continue then Ok ({ obj_type; size }, offset + 1) 277 + else 278 + let rec loop size shift off = 279 + let b = Char.code t.data.[off] in 280 + let size = size lor ((b land 0x7F) lsl shift) in 281 + if b land 0x80 = 0 then Ok (size, off + 1) 282 + else loop size (shift + 7) (off + 1) 283 + in 284 + let* size, data_off = loop size 4 (offset + 1) in 285 + Ok ({ obj_type; size }, data_off) 286 + 287 + (** Read OFS_DELTA offset at given position. Returns (offset, next_pos). *) 288 + let read_ofs_offset_at t pos = 289 + let first = Char.code t.data.[pos] in 290 + let offset = first land 0x7F in 291 + if first land 0x80 = 0 then (offset, pos + 1) 292 + else 293 + let rec loop offset pos = 294 + let b = Char.code t.data.[pos] in 295 + let offset = ((offset + 1) lsl 7) lor (b land 0x7F) in 296 + if b land 0x80 = 0 then (offset, pos + 1) else loop offset (pos + 1) 297 + in 298 + loop offset (pos + 1) 299 + 300 + (** Find the base type by following delta chain. *) 301 + let rec base_type t off = 302 + let open Result.Syntax in 303 + let* h, doff = read_entry_header_at t off in 304 + match h.obj_type with 305 + | Commit | Tree | Blob | Tag -> Ok (kind_of_obj_type h.obj_type) 306 + | Ofs_delta -> 307 + let rel, _ = read_ofs_offset_at t doff in 308 + base_type t (off - rel) 309 + | Ref_delta -> Error (`Msg "REF_DELTA in delta chain not supported yet") 310 + 311 + (** Read an object at the given offset, resolving deltas. Returns (kind, data). 312 + *) 313 + let rec read_object_at t offset = 314 + let open Result.Syntax in 315 + let* header, data_off = read_entry_header_at t offset in 316 + match header.obj_type with 317 + | Commit | Tree | Blob | Tag -> 318 + let* data = inflate ~first:data_off t.data in 319 + Ok (kind_of_obj_type header.obj_type, data) 320 + | Ofs_delta -> 321 + let rel_offset, delta_off = read_ofs_offset_at t data_off in 322 + let source_offset = offset - rel_offset in 323 + let* _kind, source = read_object_at t source_offset in 324 + let* delta = inflate ~first:delta_off t.data in 325 + let* target = apply_delta ~source ~delta in 326 + let* kind = base_type t source_offset in 327 + Ok (kind, target) 328 + | Ref_delta -> 329 + (* REF_DELTA requires an index to resolve *) 330 + Error (`Msg "REF_DELTA requires pack index for resolution") 331 + 332 + (** Read an object at the given offset, also returning the offset of the next 333 + entry. This avoids re-decompressing just to find the next position. *) 334 + let read_object_at_with_next t offset = 335 + let open Result.Syntax in 336 + let* header, data_off = read_entry_header_at t offset in 337 + match header.obj_type with 338 + | Commit | Tree | Blob | Tag -> 339 + let* data, consumed = inflate_with_consumed ~first:data_off t.data in 340 + Ok (kind_of_obj_type header.obj_type, data, data_off + consumed) 341 + | Ofs_delta -> 342 + let rel_offset, delta_off = read_ofs_offset_at t data_off in 343 + let source_offset = offset - rel_offset in 344 + let* _kind, source = read_object_at t source_offset in 345 + let* delta, consumed = inflate_with_consumed ~first:delta_off t.data in 346 + let* target = apply_delta ~source ~delta in 347 + let* kind = base_type t source_offset in 348 + Ok (kind, target, delta_off + consumed) 349 + | Ref_delta -> 350 + (* REF_DELTA requires an index to resolve *) 351 + Error (`Msg "REF_DELTA requires pack index for resolution") 352 + 353 + (** Get the number of objects in the pack. *) 354 + let count t = t.header.count 355 + 356 + (** Get the pack version. *) 357 + let version t = t.header.version 358 + 359 + (** {1 Iterating over pack entries} *) 360 + 361 + type 'a entry_handler = 362 + offset:int -> 363 + kind:[ `Commit | `Tree | `Blob | `Tag ] -> 364 + data:string -> 365 + 'a -> 366 + 'a 367 + (** Callback type for pack iteration. *) 368 + 369 + (** Iterate over all objects in a pack file. Each object is decompressed exactly 370 + once; the next entry offset is tracked during decompression. *) 371 + let fold f acc t = 372 + let rec loop acc offset n = 373 + if n >= t.header.count then Ok acc 374 + else 375 + match read_object_at_with_next t offset with 376 + | Error e -> Error e 377 + | Ok (kind, data, next_offset) -> 378 + let acc = f ~offset ~kind ~data acc in 379 + loop acc next_offset (n + 1) 380 + in 381 + loop acc 12 0 (* Start after 12-byte header *)
+122
lib/pack.mli
··· 1 + (* Copyright (c) 2013-2017 Thomas Gazagnaire <thomas@gazagnaire.org> 2 + Copyright (c) 2017-2024 Romain Calascibetta <romain.calascibetta@gmail.com> 3 + Copyright (c) 2024-2026 Thomas Gazagnaire <thomas@gazagnaire.org> 4 + 5 + Permission to use, copy, modify, and distribute this software for any 6 + purpose with or without fee is hereby granted, provided that the above 7 + copyright notice and this permission notice appear in all copies. 8 + 9 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. *) 16 + 17 + (** Git pack file support. 18 + 19 + Pack files store multiple git objects in a compressed format with delta 20 + encoding for efficient storage. 21 + 22 + {2 Pack file format} 23 + 24 + A pack file consists of: 25 + - Header: "PACK" magic (4 bytes) + version (4 bytes) + object count (4 26 + bytes) 27 + - Objects: Variable-length entries, each with type/size header and 28 + zlib-compressed data 29 + - Footer: SHA-1 checksum of the entire file (20 bytes) 30 + 31 + Objects can be stored as: 32 + - Base objects: Full zlib-compressed content (commit, tree, blob, tag) 33 + - Delta objects: Patches relative to other objects (OFS_DELTA, REF_DELTA) *) 34 + 35 + (** {1 Types} *) 36 + 37 + (** Object types in pack files. *) 38 + type obj_type = 39 + | Commit 40 + | Tree 41 + | Blob 42 + | Tag 43 + | Ofs_delta (** Delta relative to offset in same pack *) 44 + | Ref_delta (** Delta relative to object by hash *) 45 + 46 + val obj_type_of_int : int -> (obj_type, [ `Msg of string ]) result 47 + 48 + val obj_type_to_int : obj_type -> int 49 + (** [obj_type_to_int t] converts the object type [t] to its pack file integer 50 + representation (e.g. [Commit] is [1], [Tree] is [2], [Blob] is [3], [Tag] is 51 + [4], [Ofs_delta] is [6], [Ref_delta] is [7]). *) 52 + 53 + val pp_obj_type : obj_type Fmt.t 54 + (** [pp_obj_type] is a pretty-printer for {!type-obj_type} values, printing 55 + lowercase names such as ["commit"], ["tree"], ["blob"], ["tag"], 56 + ["ofs-delta"], and ["ref-delta"]. *) 57 + 58 + type header = { version : int; count : int } 59 + (** Pack file header. *) 60 + 61 + val pp_header : header Fmt.t 62 + 63 + type entry_header = { 64 + obj_type : obj_type; 65 + size : int; (** Uncompressed size *) 66 + } 67 + (** Entry header in pack file. *) 68 + 69 + (** {1 Pack file operations} *) 70 + 71 + type t 72 + (** A pack file opened for reading. *) 73 + 74 + val of_string : string -> (t, [ `Msg of string ]) result 75 + (** Open a pack file from a string containing the full pack data. *) 76 + 77 + val count : t -> int 78 + (** Get the number of objects in the pack. *) 79 + 80 + val version : t -> int 81 + (** Get the pack version (2 or 3). *) 82 + 83 + val read_object_at : 84 + t -> 85 + int -> 86 + ([ `Commit | `Tree | `Blob | `Tag ] * string, [ `Msg of string ]) result 87 + (** Read an object at the given byte offset, resolving delta chains. Returns the 88 + object kind and uncompressed data. *) 89 + 90 + (** {1 Iteration} *) 91 + 92 + type 'a entry_handler = 93 + offset:int -> 94 + kind:[ `Commit | `Tree | `Blob | `Tag ] -> 95 + data:string -> 96 + 'a -> 97 + 'a 98 + (** Callback for iterating over pack entries. *) 99 + 100 + val fold : 'a entry_handler -> 'a -> t -> ('a, [ `Msg of string ]) result 101 + (** Iterate over all objects in the pack file. *) 102 + 103 + (** {1 Delta operations} *) 104 + 105 + val apply_delta : 106 + source:string -> delta:string -> (string, [ `Msg of string ]) result 107 + (** Apply a delta patch to a source object to produce the target object. The 108 + delta format consists of: 109 + - Source size (varint) 110 + - Target size (varint) 111 + - Commands: COPY (from source) or INSERT (literal bytes) *) 112 + 113 + (** {1 Low-level operations} *) 114 + 115 + val inflate : ?first:int -> string -> (string, [ `Msg of string ]) result 116 + (** [inflate ?first s] decompresses zlib-compressed data starting at offset 117 + [first] (default [0]). Uses a zero-copy slice view to avoid copying the 118 + string tail. *) 119 + 120 + val read_entry_header_at : 121 + t -> int -> (entry_header * int, [ `Msg of string ]) result 122 + (** Read entry header at offset. Returns (header, data_offset). *)
+205
lib/pack_index.ml
··· 1 + (* Copyright (c) 2013-2017 Thomas Gazagnaire <thomas@gazagnaire.org> 2 + Copyright (c) 2017-2024 Romain Calascibetta <romain.calascibetta@gmail.com> 3 + Copyright (c) 2024-2026 Thomas Gazagnaire <thomas@gazagnaire.org> 4 + 5 + Permission to use, copy, modify, and distribute this software for any 6 + purpose with or without fee is hereby granted, provided that the above 7 + copyright notice and this permission notice appear in all copies. 8 + 9 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. *) 16 + 17 + (** Git pack index (.idx) file support. 18 + 19 + Pack index files provide fast lookup of objects in pack files by their hash. 20 + This module supports version 2 of the pack index format. *) 21 + 22 + (** {1 Pack index format (version 2)} 23 + 24 + - Header: 4-byte magic (0xff 't' 'O' 'c') + 4-byte version (2) 25 + - Fan-out table: 256 × 4-byte cumulative counts 26 + - Object hashes: N × 20-byte SHA-1 hashes (sorted) 27 + - CRC32 values: N × 4-byte CRCs 28 + - Pack offsets: N × 4-byte offsets (MSB=1 means use large offset table) 29 + - Large offsets: 8-byte offsets for objects at positions >= 2^31 30 + - Pack checksum: 20 bytes 31 + - Index checksum: 20 bytes *) 32 + 33 + type t = { 34 + count : int; (** Number of objects in the pack *) 35 + fanout : int array; (** Fan-out table (256 entries) *) 36 + hashes : string; (** Raw hash data (count * 20 bytes) *) 37 + crcs : string; (** CRC32 data (count * 4 bytes) *) 38 + offsets : string; (** Offset data (count * 4 bytes) *) 39 + large_offsets : string; (** Large offset table *) 40 + pack_checksum : Hash.t; (** Checksum of the pack file *) 41 + index_checksum : Hash.t; (** Checksum of the index file *) 42 + } 43 + (** A parsed pack index. *) 44 + 45 + let err_unsupported_version v = 46 + Error (`Msg (Fmt.str "Unsupported pack index version: %d" v)) 47 + 48 + (** Read big-endian 32-bit integer from string at offset. *) 49 + let int32_be s off = 50 + let b0 = Char.code s.[off] in 51 + let b1 = Char.code s.[off + 1] in 52 + let b2 = Char.code s.[off + 2] in 53 + let b3 = Char.code s.[off + 3] in 54 + (b0 lsl 24) lor (b1 lsl 16) lor (b2 lsl 8) lor b3 55 + 56 + (** Read big-endian 64-bit integer from string at offset. *) 57 + let int64_be s off = 58 + let b0 = Int64.of_int (Char.code s.[off]) in 59 + let b1 = Int64.of_int (Char.code s.[off + 1]) in 60 + let b2 = Int64.of_int (Char.code s.[off + 2]) in 61 + let b3 = Int64.of_int (Char.code s.[off + 3]) in 62 + let b4 = Int64.of_int (Char.code s.[off + 4]) in 63 + let b5 = Int64.of_int (Char.code s.[off + 5]) in 64 + let b6 = Int64.of_int (Char.code s.[off + 6]) in 65 + let b7 = Int64.of_int (Char.code s.[off + 7]) in 66 + Int64.( 67 + logor (shift_left b0 56) 68 + (logor (shift_left b1 48) 69 + (logor (shift_left b2 40) 70 + (logor (shift_left b3 32) 71 + (logor (shift_left b4 24) 72 + (logor (shift_left b5 16) (logor (shift_left b6 8) b7))))))) 73 + 74 + let magic = "\xff\x74\x4f\x63" 75 + 76 + (** Read fan-out table from pack index data. *) 77 + let read_fanout data = 78 + let fanout = Array.make 256 0 in 79 + for i = 0 to 255 do 80 + fanout.(i) <- int32_be data (8 + (i * 4)) 81 + done; 82 + fanout 83 + 84 + (** Extract pack index sections from data. *) 85 + let extract_sections data len count = 86 + let hashes_off = 8 + 1024 in 87 + let crcs_off = hashes_off + (count * 20) in 88 + let offsets_off = crcs_off + (count * 4) in 89 + let large_off = offsets_off + (count * 4) in 90 + let pack_checksum_off = len - 40 in 91 + let min_size = large_off + 40 in 92 + if len < min_size then Error (`Msg "Pack index file truncated") 93 + else 94 + Ok 95 + ( String.sub data hashes_off (count * 20), 96 + String.sub data crcs_off (count * 4), 97 + String.sub data offsets_off (count * 4), 98 + String.sub data large_off (pack_checksum_off - large_off), 99 + Hash.of_raw_string (String.sub data pack_checksum_off 20), 100 + Hash.of_raw_string (String.sub data (len - 20) 20) ) 101 + 102 + (** Parse a pack index from a string. *) 103 + let of_string data = 104 + let len = String.length data in 105 + if len < 1072 then Error (`Msg "Pack index file too short") 106 + else if String.sub data 0 4 <> magic then 107 + Error (`Msg "Invalid pack index: bad magic") 108 + else 109 + let version = int32_be data 4 in 110 + if version <> 2 then err_unsupported_version version 111 + else 112 + let fanout = read_fanout data in 113 + let count = fanout.(255) in 114 + match extract_sections data len count with 115 + | Error e -> Error e 116 + | Ok (hashes, crcs, offsets, large_offsets, pack_checksum, index_checksum) 117 + -> 118 + Ok 119 + { 120 + count; 121 + fanout; 122 + hashes; 123 + crcs; 124 + offsets; 125 + large_offsets; 126 + pack_checksum; 127 + index_checksum; 128 + } 129 + 130 + (** Get the number of objects in the index. *) 131 + let count t = t.count 132 + 133 + (** Get the hash at the given index (0-based). *) 134 + let hash_at t i = 135 + if i < 0 || i >= t.count then invalid_arg "Pack_index.hash_at: out of bounds"; 136 + Hash.of_raw_string (String.sub t.hashes (i * 20) 20) 137 + 138 + (** Get the CRC32 at the given index. *) 139 + let crc_at t i = 140 + if i < 0 || i >= t.count then invalid_arg "Pack_index.crc_at: out of bounds"; 141 + int32_be t.crcs (i * 4) 142 + 143 + (** Get the pack file offset for the object at the given index. *) 144 + let offset_at t i = 145 + if i < 0 || i >= t.count then 146 + invalid_arg "Pack_index.offset_at: out of bounds"; 147 + let off = int32_be t.offsets (i * 4) in 148 + if off land 0x80000000 <> 0 then begin 149 + (* MSB set: look up in large offset table *) 150 + let large_idx = off land 0x7FFFFFFF in 151 + Int64.to_int (int64_be t.large_offsets (large_idx * 8)) 152 + end 153 + else off 154 + 155 + (** Find the index of an object by its hash using binary search. Returns None if 156 + not found. *) 157 + let index t hash = 158 + let hash_raw = Hash.to_raw_string hash in 159 + let first_byte = Char.code hash_raw.[0] in 160 + (* Use fan-out to narrow search range *) 161 + let lo = if first_byte = 0 then 0 else t.fanout.(first_byte - 1) in 162 + let hi = t.fanout.(first_byte) - 1 in 163 + (* Binary search within range *) 164 + let rec search lo hi = 165 + if lo > hi then None 166 + else 167 + let mid = lo + ((hi - lo) / 2) in 168 + let mid_hash = String.sub t.hashes (mid * 20) 20 in 169 + let cmp = String.compare hash_raw mid_hash in 170 + if cmp = 0 then Some mid 171 + else if cmp < 0 then search lo (mid - 1) 172 + else search (mid + 1) hi 173 + in 174 + search lo hi 175 + 176 + (** Find the pack file offset for an object by its hash. Returns None if not 177 + found. *) 178 + let find t hash = 179 + match index t hash with None -> None | Some i -> Some (offset_at t i) 180 + 181 + (** Get the pack file checksum. *) 182 + let pack_checksum t = t.pack_checksum 183 + 184 + (** Get the index file checksum. *) 185 + let index_checksum t = t.index_checksum 186 + 187 + (** Iterate over all entries in the index. *) 188 + let iter f t = 189 + for i = 0 to t.count - 1 do 190 + let hash = hash_at t i in 191 + let offset = offset_at t i in 192 + let crc = crc_at t i in 193 + f ~hash ~offset ~crc 194 + done 195 + 196 + (** Fold over all entries in the index. *) 197 + let fold f acc t = 198 + let acc = ref acc in 199 + for i = 0 to t.count - 1 do 200 + let hash = hash_at t i in 201 + let offset = offset_at t i in 202 + let crc = crc_at t i in 203 + acc := f ~hash ~offset ~crc !acc 204 + done; 205 + !acc
+61
lib/pack_index.mli
··· 1 + (* Copyright (c) 2013-2017 Thomas Gazagnaire <thomas@gazagnaire.org> 2 + Copyright (c) 2017-2024 Romain Calascibetta <romain.calascibetta@gmail.com> 3 + Copyright (c) 2024-2026 Thomas Gazagnaire <thomas@gazagnaire.org> 4 + 5 + Permission to use, copy, modify, and distribute this software for any 6 + purpose with or without fee is hereby granted, provided that the above 7 + copyright notice and this permission notice appear in all copies. 8 + 9 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. *) 16 + 17 + (** Git pack index (.idx) file support. 18 + 19 + Pack index files provide fast lookup of objects in pack files by their hash. 20 + This module supports version 2 of the pack index format. *) 21 + 22 + type t 23 + (** A parsed pack index. *) 24 + 25 + val of_string : string -> (t, [ `Msg of string ]) result 26 + (** Parse a pack index from a string. *) 27 + 28 + val count : t -> int 29 + (** Get the number of objects in the index. *) 30 + 31 + val hash_at : t -> int -> Hash.t 32 + (** [hash_at t i] returns the hash at index [i] (0-based). 33 + @raise Invalid_argument if [i] is out of bounds. *) 34 + 35 + val crc_at : t -> int -> int 36 + (** [crc_at t i] returns the CRC32 at index [i]. 37 + @raise Invalid_argument if [i] is out of bounds. *) 38 + 39 + val offset_at : t -> int -> int 40 + (** [offset_at t i] returns the pack file offset for index [i]. 41 + @raise Invalid_argument if [i] is out of bounds. *) 42 + 43 + val index : t -> Hash.t -> int option 44 + (** [index t hash] finds the index of an object by its hash using binary search. 45 + Returns [None] if not found. *) 46 + 47 + val find : t -> Hash.t -> int option 48 + (** [find t hash] finds the pack file offset for an object by its hash. Returns 49 + [None] if not found. *) 50 + 51 + val pack_checksum : t -> Hash.t 52 + (** Get the pack file checksum stored in the index. *) 53 + 54 + val index_checksum : t -> Hash.t 55 + (** Get the index file checksum. *) 56 + 57 + val iter : (hash:Hash.t -> offset:int -> crc:int -> unit) -> t -> unit 58 + (** Iterate over all entries in the index. *) 59 + 60 + val fold : (hash:Hash.t -> offset:int -> crc:int -> 'a -> 'a) -> 'a -> t -> 'a 61 + (** Fold over all entries in the index. *)
+110
lib/reference.ml
··· 1 + (* Copyright (c) 2015 Daniel C. Bünzli 2 + Copyright (c) 2020-2024 Romain Calascibetta <romain.calascibetta@gmail.com> 3 + Copyright (c) 2024-2026 Thomas Gazagnaire <thomas@gazagnaire.org> 4 + 5 + Permission to use, copy, modify, and distribute this software for any 6 + purpose with or without fee is hereby granted, provided that the above 7 + copyright notice and this permission notice appear in all copies. 8 + 9 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. *) 16 + 17 + (** Git references (branches, tags, HEAD). *) 18 + 19 + type t = string 20 + 21 + let head = "HEAD" 22 + let master = "refs/heads/master" 23 + let main = "refs/heads/main" 24 + 25 + let of_string s = 26 + if String.length s = 0 then Error (`Msg "Empty reference") 27 + else if String.contains s '\x00' then 28 + Error (`Msg "Reference contains null byte") 29 + else if s.[0] = '/' then Error (`Msg "Absolute reference not allowed") 30 + else 31 + (* Collapse consecutive slashes *) 32 + let buf = Buffer.create (String.length s) in 33 + let last_was_slash = ref false in 34 + String.iter 35 + (fun c -> 36 + if c = '/' then ( 37 + if not !last_was_slash then Buffer.add_char buf c; 38 + last_was_slash := true) 39 + else ( 40 + Buffer.add_char buf c; 41 + last_was_slash := false)) 42 + s; 43 + Ok (Buffer.contents buf) 44 + 45 + let of_string_exn s = 46 + match of_string s with Ok r -> r | Error (`Msg m) -> invalid_arg m 47 + 48 + let v = of_string_exn 49 + let to_string t = t 50 + let pp ppf t = Fmt.string ppf t 51 + let equal = String.equal 52 + let compare = String.compare 53 + let hash = Hashtbl.hash 54 + 55 + let segs t = 56 + String.split_on_char '/' t |> List.filter (fun s -> String.length s > 0) 57 + 58 + let ( / ) t seg = 59 + if String.contains seg '\x00' || String.contains seg '/' then 60 + invalid_arg "Invalid segment"; 61 + if t.[String.length t - 1] = '/' then t ^ seg else t ^ "/" ^ seg 62 + 63 + let ( // ) t1 t2 = 64 + if t2.[0] = '/' then t2 65 + else if t1.[String.length t1 - 1] = '/' then t1 ^ t2 66 + else t1 ^ "/" ^ t2 67 + 68 + (** Reference contents: either a direct hash or a symbolic reference. *) 69 + type contents = Hash of Hash.t | Ref of t 70 + 71 + let contents_equal a b = 72 + match (a, b) with 73 + | Hash a, Hash b -> Hash.equal a b 74 + | Ref a, Ref b -> equal a b 75 + | _ -> false 76 + 77 + let contents_compare a b = 78 + match (a, b) with 79 + | Hash a, Hash b -> Hash.compare a b 80 + | Ref a, Ref b -> compare a b 81 + | Hash _, Ref _ -> -1 82 + | Ref _, Hash _ -> 1 83 + 84 + let pp_contents ppf = function 85 + | Hash h -> Hash.pp ppf h 86 + | Ref r -> Fmt.pf ppf "ref: %s" r 87 + 88 + (** Parse reference file contents. *) 89 + let contents_of_string s = 90 + let s = String.trim s in 91 + if String.length s = 0 then Error (`Msg "Empty reference contents") 92 + else if String.length s >= 5 && String.sub s 0 5 = "ref: " then 93 + let ref_path = String.sub s 5 (String.length s - 5) in 94 + Result.map (fun r -> Ref r) (of_string ref_path) 95 + else 96 + (* Try to parse as a hex hash *) 97 + try Ok (Hash (Hash.of_hex s)) 98 + with Invalid_argument _ -> 99 + Error (`Msg ("Invalid reference contents: " ^ s)) 100 + 101 + let contents_of_string_exn s = 102 + match contents_of_string s with Ok c -> c | Error (`Msg m) -> failwith m 103 + 104 + (** Encode reference contents. *) 105 + let contents_to_string = function 106 + | Hash h -> Hash.to_hex h ^ "\n" 107 + | Ref r -> "ref: " ^ r ^ "\n" 108 + 109 + module Set = Set.Make (String) 110 + module Map = Map.Make (String)
+92
lib/reference.mli
··· 1 + (* Copyright (c) 2015 Daniel C. Bünzli 2 + Copyright (c) 2020-2024 Romain Calascibetta <romain.calascibetta@gmail.com> 3 + Copyright (c) 2024-2026 Thomas Gazagnaire <thomas@gazagnaire.org> 4 + 5 + Permission to use, copy, modify, and distribute this software for any 6 + purpose with or without fee is hereby granted, provided that the above 7 + copyright notice and this permission notice appear in all copies. 8 + 9 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. *) 16 + 17 + (** Git references (branches, tags, HEAD). *) 18 + 19 + type t 20 + (** The type of references. *) 21 + 22 + val head : t 23 + (** The HEAD reference. *) 24 + 25 + val master : t 26 + (** The refs/heads/master reference. *) 27 + 28 + val main : t 29 + (** The refs/heads/main reference. *) 30 + 31 + val of_string : string -> (t, [ `Msg of string ]) result 32 + (** Parse a reference from a string. *) 33 + 34 + val of_string_exn : string -> t 35 + (** [of_string_exn s] is like {!of_string} but raises on error. *) 36 + 37 + val v : string -> t 38 + (** [v s] is an alias for {!of_string_exn}. *) 39 + 40 + val to_string : t -> string 41 + (** Convert a reference to a string. *) 42 + 43 + val pp : t Fmt.t 44 + (** Pretty-print a reference. *) 45 + 46 + val equal : t -> t -> bool 47 + (** Equality on references. *) 48 + 49 + val compare : t -> t -> int 50 + (** Total ordering on references. *) 51 + 52 + val hash : t -> int 53 + (** Hash function for use with Hashtbl. *) 54 + 55 + val segs : t -> string list 56 + (** Split a reference into path segments. *) 57 + 58 + val ( / ) : t -> string -> t 59 + (** [t / seg] appends a segment to a reference. 60 + @raise Invalid_argument if segment contains null or slash. *) 61 + 62 + val ( // ) : t -> t -> t 63 + (** [t1 // t2] appends two references. *) 64 + 65 + (** {1 Reference contents} *) 66 + 67 + type contents = 68 + | Hash of Hash.t 69 + | Ref of t 70 + (** Reference contents: either a direct hash or a symbolic reference. *) 71 + 72 + val contents_equal : contents -> contents -> bool 73 + (** Equality on reference contents. *) 74 + 75 + val contents_compare : contents -> contents -> int 76 + (** Total ordering on reference contents. *) 77 + 78 + val pp_contents : contents Fmt.t 79 + (** Pretty-print reference contents. *) 80 + 81 + val contents_of_string : string -> (contents, [ `Msg of string ]) result 82 + (** Parse reference file contents. *) 83 + 84 + val contents_of_string_exn : string -> contents 85 + (** [contents_of_string_exn s] is like {!contents_of_string} but raises on 86 + error. *) 87 + 88 + val contents_to_string : contents -> string 89 + (** Encode reference contents for writing to file. *) 90 + 91 + module Set : Set.S with type elt = t 92 + module Map : Map.S with type key = t
+127
lib/remote.ml
··· 1 + (* Copyright (c) 2024-2026 Thomas Gazagnaire <thomas@gazagnaire.org> 2 + 3 + Permission to use, copy, modify, and distribute this software for any 4 + purpose with or without fee is hereby granted, provided that the above 5 + copyright notice and this permission notice appear in all copies. 6 + 7 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. *) 14 + 15 + (** Git smart HTTP protocol implementation for remote queries. 16 + 17 + This implements the git smart HTTP protocol for lightweight remote 18 + operations like ls-remote without cloning or fetching. 19 + 20 + Reference: https://git-scm.com/docs/http-protocol *) 21 + 22 + type ref_entry = { ref_name : string; hash : Hash.t } 23 + 24 + (* Parse a pkt-line length (4 hex digits) *) 25 + let parse_pkt_len s offset = 26 + if offset + 4 > String.length s then None 27 + else 28 + let hex = String.sub s offset 4 in 29 + try Some (int_of_string ("0x" ^ hex)) with Failure _ -> None 30 + 31 + (* Parse pkt-line formatted data. 32 + Format: 4 hex digits length + content, or "0000" for flush packet *) 33 + let parse_pkt_lines data = 34 + let len = String.length data in 35 + let rec parse acc offset = 36 + if offset >= len then List.rev acc 37 + else 38 + match parse_pkt_len data offset with 39 + | None -> List.rev acc 40 + | Some 0 -> parse acc (offset + 4) (* flush packet *) 41 + | Some pkt_len when pkt_len <= 4 -> List.rev acc 42 + | Some pkt_len -> 43 + let content_len = pkt_len - 4 in 44 + if offset + 4 + content_len > len then List.rev acc 45 + else 46 + let content = String.sub data (offset + 4) content_len in 47 + (* Strip trailing newline if present *) 48 + let content = 49 + if 50 + String.length content > 0 51 + && content.[String.length content - 1] = '\n' 52 + then String.sub content 0 (String.length content - 1) 53 + else content 54 + in 55 + parse (content :: acc) (offset + pkt_len) 56 + in 57 + parse [] 0 58 + 59 + (* Parse a ref line: "sha1 refname\0capabilities" or "sha1 refname" *) 60 + let parse_ref_line line = 61 + (* Skip service announcement lines *) 62 + if String.length line = 0 || line.[0] = '#' then None 63 + else 64 + (* Split on space to get hash and ref *) 65 + match String.index_opt line ' ' with 66 + | None -> None 67 + | Some i -> 68 + let hash_str = String.sub line 0 i in 69 + let rest = String.sub line (i + 1) (String.length line - i - 1) in 70 + (* Strip capabilities after NUL if present *) 71 + let ref_name = 72 + match String.index_opt rest '\x00' with 73 + | Some j -> String.sub rest 0 j 74 + | None -> rest 75 + in 76 + if String.length hash_str = 40 then 77 + try Some { ref_name; hash = Hash.of_hex hash_str } 78 + with Invalid_argument _ -> None 79 + else None 80 + 81 + let git_headers = 82 + Requests.Headers.empty 83 + |> Requests.Headers.set_string "User-Agent" "ocaml-git/4.0" 84 + |> Requests.Headers.set_string "Accept" 85 + "application/x-git-upload-pack-advertisement" 86 + 87 + let normalize_url url = 88 + let s = Uri.to_string url in 89 + if String.length s > 0 && s.[String.length s - 1] = '/' then 90 + String.sub s 0 (String.length s - 1) 91 + else s 92 + 93 + (** Query remote refs using git smart HTTP protocol. Pass an existing [session] 94 + to reuse TLS connection and avoid cert reload. *) 95 + let ls_remote ?session ~sw ~env url = 96 + let url_str = normalize_url url in 97 + let info_refs_url = url_str ^ "/info/refs?service=git-upload-pack" in 98 + try 99 + let session = 100 + match session with Some s -> s | None -> Requests.v ~sw env 101 + in 102 + let resp = Requests.get session ~headers:git_headers info_refs_url in 103 + if not (Requests.Response.ok resp) then None 104 + else 105 + let body = Requests.Response.text resp in 106 + let lines = parse_pkt_lines body in 107 + let refs = List.filter_map parse_ref_line lines in 108 + Some refs 109 + with Eio.Io _ | Failure _ -> None 110 + 111 + (** Get the HEAD ref for a specific branch. Pass an existing [session] to reuse 112 + TLS connection. *) 113 + let head ?session ~sw ~env url ~branch = 114 + match ls_remote ?session ~sw ~env url with 115 + | None -> None 116 + | Some refs -> 117 + let ref_name = "refs/heads/" ^ branch in 118 + List.find_map 119 + (fun r -> if r.ref_name = ref_name then Some r.hash else None) 120 + refs 121 + 122 + (** Check if a remote ref matches a local hash. Pass an existing [session] to 123 + reuse TLS connection. *) 124 + let matches_local ?session ~sw ~env url ~branch ~local_hash = 125 + match head ?session ~sw ~env url ~branch with 126 + | None -> false 127 + | Some remote_hash -> Hash.equal remote_hash local_hash
+104
lib/remote.mli
··· 1 + (* Copyright (c) 2024-2026 Thomas Gazagnaire <thomas@gazagnaire.org> 2 + 3 + Permission to use, copy, modify, and distribute this software for any 4 + purpose with or without fee is hereby granted, provided that the above 5 + copyright notice and this permission notice appear in all copies. 6 + 7 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. *) 14 + 15 + (** Git smart HTTP protocol for remote queries. 16 + 17 + This module implements lightweight remote queries using the git smart HTTP 18 + protocol, avoiding the need to clone or fetch to check remote refs. 19 + 20 + Reference: {{:https://git-scm.com/docs/http-protocol}Git HTTP Protocol} 21 + 22 + {2 Example} 23 + 24 + {[ 25 + Eio_main.run @@ fun env -> 26 + Eio.Switch.run @@ fun sw -> 27 + let url = Uri.of_string "https://github.com/ocaml/ocaml.git" in 28 + match Git.Remote.head ~sw ~env url ~branch:"trunk" with 29 + | Some hash -> Format.printf "HEAD: %a@." Git.Hash.pp hash 30 + | None -> Format.printf "Branch not found or remote unreachable@." 31 + ]} *) 32 + 33 + type ref_entry = { 34 + ref_name : string; (** Full ref name (e.g., "refs/heads/main") *) 35 + hash : Hash.t; (** Commit hash the ref points to *) 36 + } 37 + (** A remote reference entry. *) 38 + 39 + val ls_remote : 40 + ?session:Requests.t -> 41 + sw:Eio.Switch.t -> 42 + env: 43 + < clock : _ Eio.Time.clock 44 + ; net : _ Eio.Net.t 45 + ; fs : Eio.Fs.dir_ty Eio.Path.t 46 + ; .. > -> 47 + Uri.t -> 48 + ref_entry list option 49 + (** [ls_remote ?session ~sw ~env url] queries the remote for all refs. 50 + 51 + Returns [Some refs] where [refs] is a list of ref entries, or [None] if the 52 + query failed (network error, invalid URL, etc.). 53 + 54 + Pass an existing [session] to reuse TLS connection and avoid reloading CA 55 + certificates (which can be slow on macOS). 56 + 57 + This uses the git smart HTTP protocol to query the remote without 58 + downloading any objects, making it very fast for checking if updates are 59 + available. *) 60 + 61 + val head : 62 + ?session:Requests.t -> 63 + sw:Eio.Switch.t -> 64 + env: 65 + < clock : _ Eio.Time.clock 66 + ; net : _ Eio.Net.t 67 + ; fs : Eio.Fs.dir_ty Eio.Path.t 68 + ; .. > -> 69 + Uri.t -> 70 + branch:string -> 71 + Hash.t option 72 + (** [head ?session ~sw ~env url ~branch] returns the commit hash of the 73 + specified branch on the remote. 74 + 75 + Returns [None] if the branch doesn't exist or the remote is unreachable. 76 + 77 + Pass an existing [session] to reuse TLS connection and avoid reloading CA 78 + certificates. 79 + 80 + This is much faster than [git fetch] as it only queries ref information 81 + without downloading any objects. *) 82 + 83 + val matches_local : 84 + ?session:Requests.t -> 85 + sw:Eio.Switch.t -> 86 + env: 87 + < clock : _ Eio.Time.clock 88 + ; net : _ Eio.Net.t 89 + ; fs : Eio.Fs.dir_ty Eio.Path.t 90 + ; .. > -> 91 + Uri.t -> 92 + branch:string -> 93 + local_hash:Hash.t -> 94 + bool 95 + (** [matches_local ?session ~sw ~env url ~branch ~local_hash] returns [true] if 96 + the remote branch points to the same commit as [local_hash]. 97 + 98 + Returns [false] if the remote is unreachable or the hashes differ. 99 + 100 + Pass an existing [session] to reuse TLS connection and avoid reloading CA 101 + certificates. 102 + 103 + This is useful to determine if a fetch is needed - if the remote matches the 104 + local tracking branch, no fetch is necessary. *)
+1427
lib/repository.ml
··· 1 + (* Copyright (c) 2013-2017 Thomas Gazagnaire <thomas@gazagnaire.org> 2 + Copyright (c) 2017-2024 Romain Calascibetta <romain.calascibetta@gmail.com> 3 + Copyright (c) 2024-2026 Thomas Gazagnaire <thomas@gazagnaire.org> 4 + 5 + Permission to use, copy, modify, and distribute this software for any 6 + purpose with or without fee is hereby granted, provided that the above 7 + copyright notice and this permission notice appear in all copies. 8 + 9 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. *) 16 + 17 + (** Git repository access. 18 + 19 + High-level operations on Git repositories, handling both loose objects and 20 + pack files transparently. *) 21 + 22 + module Reader = Bytesrw.Bytes.Reader 23 + 24 + (** {1 Zlib compression for loose objects} *) 25 + 26 + let zlib_compress data = 27 + let reader = Reader.of_string data in 28 + let compressed = Bytesrw_zlib.Zlib.compress_reads () reader in 29 + Reader.to_string compressed 30 + 31 + (** {1 Pack file cache} *) 32 + 33 + type pack_entry = { pack : Pack.t; index : Pack_index.t } 34 + 35 + (** {1 Repository type} *) 36 + 37 + type t = { 38 + fs : Eio.Fs.dir_ty Eio.Path.t; 39 + git_dir : string; 40 + packs : pack_entry list Lazy.t; 41 + } 42 + 43 + let pp ppf t = Fmt.pf ppf "<git-repo %s>" t.git_dir 44 + 45 + let err_branch_not_found name = 46 + Error (`Msg (Fmt.str "branch %s does not exist" name)) 47 + 48 + let err_unresolved_ref r = Error (`Msg (Fmt.str "cannot resolve ref: %s" r)) 49 + 50 + let err_remote_exists name = 51 + Error (`Msg (Fmt.str "remote %s already exists" name)) 52 + 53 + let err_remote_not_found name = 54 + Error (`Msg (Fmt.str "remote %s does not exist" name)) 55 + 56 + let err_ref_not_found r = Error (`Msg (Fmt.str "ref %s not found" r)) 57 + 58 + let err_prefix_not_found p = 59 + Error (`Msg (Fmt.str "prefix %s not found in tree" p)) 60 + 61 + (** {1 Path helpers} *) 62 + 63 + let loose_object_path git_dir hash = 64 + let hex = Hash.to_hex hash in 65 + let dir = String.sub hex 0 2 in 66 + let file = String.sub hex 2 (String.length hex - 2) in 67 + Filename.concat git_dir (Filename.concat "objects" (Filename.concat dir file)) 68 + 69 + let pack_dir git_dir = Filename.concat git_dir "objects/pack" 70 + 71 + (** {1 Pack loading} *) 72 + 73 + let load_packs ~fs git_dir = 74 + let dir = pack_dir git_dir in 75 + let pack_path = Eio.Path.(fs / dir) in 76 + try 77 + let entries = Eio.Path.read_dir pack_path in 78 + let idx_files = 79 + List.filter (fun e -> Filename.check_suffix e ".idx") entries 80 + in 81 + List.filter_map 82 + (fun idx_file -> 83 + let pack_file = Filename.chop_suffix idx_file ".idx" ^ ".pack" in 84 + try 85 + let idx_path = Eio.Path.(pack_path / idx_file) in 86 + let pack_file_path = Eio.Path.(pack_path / pack_file) in 87 + let idx_data = Eio.Path.load idx_path in 88 + let pack_data = Eio.Path.load pack_file_path in 89 + match (Pack_index.of_string idx_data, Pack.of_string pack_data) with 90 + | Ok index, Ok pack -> Some { pack; index } 91 + | _ -> None 92 + with Eio.Io _ -> None) 93 + idx_files 94 + with Eio.Io _ -> [] 95 + 96 + (** {1 Repository operations} *) 97 + 98 + let open_repo ~fs path = 99 + let path_str = Fpath.to_string path in 100 + let git_dir = Filename.concat path_str ".git" in 101 + let packs = lazy (load_packs ~fs:Eio.Path.(fs / "") git_dir) in 102 + { fs; git_dir; packs } 103 + 104 + let open_bare ~fs path = 105 + let path_str = Fpath.to_string path in 106 + let packs = lazy (load_packs ~fs:Eio.Path.(fs / "") path_str) in 107 + { fs; git_dir = path_str; packs } 108 + 109 + (** {1 Object reading} *) 110 + 111 + let read_loose t hash = 112 + let path = loose_object_path t.git_dir hash in 113 + let full_path = Eio.Path.(t.fs / path) in 114 + try 115 + let compressed = Eio.Path.load full_path in 116 + let reader = Reader.of_string compressed in 117 + let decompressed = Bytesrw_zlib.Zlib.decompress_reads () reader in 118 + Value.read decompressed 119 + with Eio.Io _ -> Error (`Msg "object not found") 120 + 121 + let read_from_packs packs hash = 122 + let rec try_packs = function 123 + | [] -> None 124 + | entry :: rest -> ( 125 + match Pack_index.find entry.index hash with 126 + | None -> try_packs rest 127 + | Some offset -> ( 128 + match Pack.read_object_at entry.pack offset with 129 + | Ok (kind, data) -> Some (kind, data) 130 + | Error _ -> try_packs rest)) 131 + in 132 + try_packs packs 133 + 134 + let read t hash = 135 + match read_loose t hash with 136 + | Ok value -> Ok value 137 + | Error _ -> ( 138 + match read_from_packs (Lazy.force t.packs) hash with 139 + | Some (kind, data) -> 140 + let reader = Reader.of_string data in 141 + Value.of_reader ~kind reader 142 + | None -> Error (`Msg "object not found")) 143 + 144 + let exists t hash = 145 + let path = loose_object_path t.git_dir hash in 146 + let full_path = Eio.Path.(t.fs / path) in 147 + Eio.Path.is_file full_path 148 + || Option.is_some (read_from_packs (Lazy.force t.packs) hash) 149 + 150 + (** {1 Object writing} *) 151 + 152 + let write t value = 153 + let hash = Value.digest value in 154 + let path = loose_object_path t.git_dir hash in 155 + let full_path = Eio.Path.(t.fs / path) in 156 + (* Skip if object already exists *) 157 + if not (Eio.Path.is_file full_path) then begin 158 + (* Create directory if needed *) 159 + let dir = Filename.dirname path in 160 + let dir_path = Eio.Path.(t.fs / dir) in 161 + (try Eio.Path.mkdir ~perm:0o755 dir_path with Eio.Io _ -> ()); 162 + (* Write compressed object *) 163 + let data = Value.to_string value in 164 + let compressed = zlib_compress data in 165 + Eio.Path.save ~create:(`Or_truncate 0o444) full_path compressed 166 + end; 167 + hash 168 + 169 + let write_blob t data = write t (Value.blob (Blob.of_string data)) 170 + let write_tree t tree = write t (Value.tree tree) 171 + let write_commit t commit = write t (Value.commit commit) 172 + let write_tag t tag = write t (Value.tag tag) 173 + 174 + (** {1 Reference operations} *) 175 + 176 + let read_ref t name = 177 + let path = Filename.concat t.git_dir name in 178 + let full_path = Eio.Path.(t.fs / path) in 179 + try 180 + let content = Eio.Path.load full_path in 181 + let content = String.trim content in 182 + (* Check for symbolic ref *) 183 + if String.length content > 5 && String.sub content 0 5 = "ref: " then 184 + let target = String.sub content 5 (String.length content - 5) in 185 + let target_path = Eio.Path.(t.fs / Filename.concat t.git_dir target) in 186 + try 187 + let target_content = Eio.Path.load target_path in 188 + Some (Hash.of_hex (String.trim target_content)) 189 + with Eio.Io _ | Invalid_argument _ -> None 190 + else Some (Hash.of_hex content) 191 + with Eio.Io _ | Invalid_argument _ -> None 192 + 193 + let write_ref t name hash = 194 + let path = Filename.concat t.git_dir name in 195 + let full_path = Eio.Path.(t.fs / path) in 196 + let dir = Filename.dirname path in 197 + let dir_path = Eio.Path.(t.fs / dir) in 198 + (try Eio.Path.mkdir ~perm:0o755 dir_path with Eio.Io _ -> ()); 199 + let content = Hash.to_hex hash ^ "\n" in 200 + Eio.Path.save ~create:(`Or_truncate 0o644) full_path content 201 + 202 + let delete_ref t name = 203 + let path = Filename.concat t.git_dir name in 204 + let full_path = Eio.Path.(t.fs / path) in 205 + try Eio.Path.unlink full_path with Eio.Io _ -> () 206 + 207 + let list_refs t = 208 + let refs_dir = Filename.concat t.git_dir "refs" in 209 + let refs_path = Eio.Path.(t.fs / refs_dir) in 210 + let rec collect_refs path prefix acc = 211 + try 212 + let entries = Eio.Path.read_dir path in 213 + List.fold_left 214 + (fun acc entry -> 215 + let entry_path = Eio.Path.(path / entry) in 216 + let ref_name = if prefix = "" then entry else prefix ^ "/" ^ entry in 217 + if Eio.Path.is_directory entry_path then 218 + collect_refs entry_path ref_name acc 219 + else ("refs/" ^ ref_name) :: acc) 220 + acc entries 221 + with Eio.Io _ -> acc 222 + in 223 + collect_refs refs_path "" [] 224 + 225 + (** {1 Repository initialization} *) 226 + 227 + let git_dir t = Fpath.v t.git_dir 228 + let fs t = t.fs 229 + 230 + (** {1 Repository queries} *) 231 + 232 + let is_repo ~fs path = 233 + let path_str = Fpath.to_string path in 234 + let git_path = Eio.Path.(fs / path_str / ".git") in 235 + try Eio.Path.is_directory git_path with Eio.Io _ -> false 236 + 237 + let head t = read_ref t "HEAD" 238 + 239 + let current_branch t = 240 + let path = Filename.concat t.git_dir "HEAD" in 241 + let full_path = Eio.Path.(t.fs / path) in 242 + try 243 + let content = String.trim (Eio.Path.load full_path) in 244 + if 245 + String.length content > 16 && String.sub content 0 16 = "ref: refs/heads/" 246 + then Some (String.sub content 16 (String.length content - 16)) 247 + else None (* detached HEAD *) 248 + with Eio.Io _ -> None 249 + 250 + let rename_branch t ~new_name = 251 + match current_branch t with 252 + | None -> Error (`Msg "cannot rename branch in detached HEAD state") 253 + | Some old_name -> ( 254 + let old_ref = "refs/heads/" ^ old_name in 255 + let new_ref = "refs/heads/" ^ new_name in 256 + match read_ref t old_ref with 257 + | None -> err_branch_not_found old_name 258 + | Some hash -> 259 + (* Write the new ref *) 260 + write_ref t new_ref hash; 261 + (* Delete the old ref *) 262 + delete_ref t old_ref; 263 + (* Update HEAD to point to new branch *) 264 + let head_path = Filename.concat t.git_dir "HEAD" in 265 + let full_path = Eio.Path.(t.fs / head_path) in 266 + Eio.Path.save ~create:(`Or_truncate 0o644) full_path 267 + ("ref: " ^ new_ref ^ "\n"); 268 + Ok ()) 269 + 270 + let advance_head t commit_hash = 271 + match current_branch t with 272 + | Some branch -> write_ref t ("refs/heads/" ^ branch) commit_hash 273 + | None -> 274 + (* Detached HEAD - update HEAD directly *) 275 + let head_path = Filename.concat t.git_dir "HEAD" in 276 + let full_path = Eio.Path.(t.fs / head_path) in 277 + Eio.Path.save ~create:(`Or_truncate 0o644) full_path 278 + (Hash.to_hex commit_hash ^ "\n") 279 + 280 + (** {1 Log operations} *) 281 + 282 + type log_entry = { 283 + hash : string; 284 + author : string; 285 + date : string; 286 + subject : string; 287 + body : string; 288 + } 289 + 290 + let format_date timestamp tz_opt = 291 + (* Format timestamp as ISO 8601 using ptime *) 292 + let tz_offset_s = 293 + match tz_opt with 294 + | Some tz -> 295 + let sign = match tz.User.sign with `Plus -> 1 | `Minus -> -1 in 296 + sign * ((tz.hours * 3600) + (tz.minutes * 60)) 297 + | None -> 0 298 + in 299 + let span = Ptime.Span.of_int_s (Int64.to_int timestamp) in 300 + match Ptime.of_span span with 301 + | Some t -> 302 + let tz_str = 303 + match tz_opt with 304 + | Some tz -> 305 + let sign = match tz.User.sign with `Plus -> "+" | `Minus -> "-" in 306 + Fmt.str "%s%02d%02d" sign tz.hours tz.minutes 307 + | None -> "+0000" 308 + in 309 + let (y, m, d), ((hh, mm, ss), _) = Ptime.to_date_time ~tz_offset_s t in 310 + Fmt.str "%04d-%02d-%02dT%02d:%02d:%02d%s" y m d hh mm ss tz_str 311 + | None -> "1970-01-01T00:00:00+0000" 312 + 313 + let commit_to_log_entry hash commit = 314 + let author = Commit.author commit in 315 + let message = Option.value ~default:"" (Commit.message commit) in 316 + let subject, body = 317 + match String.index_opt message '\n' with 318 + | None -> (String.trim message, "") 319 + | Some i -> 320 + let subj = String.sub message 0 i |> String.trim in 321 + let rest = String.sub message (i + 1) (String.length message - i - 1) in 322 + (subj, String.trim rest) 323 + in 324 + { 325 + hash = Hash.to_hex hash; 326 + author = User.name author; 327 + date = format_date (User.date author) (User.tz_offset author); 328 + subject; 329 + body; 330 + } 331 + 332 + let log t ?(max_count = max_int) head = 333 + (* Walk backwards from head, collecting log entries *) 334 + let visited = Hashtbl.create 256 in 335 + let result = ref [] in 336 + let count = ref 0 in 337 + let queue = Queue.create () in 338 + Queue.push head queue; 339 + while (not (Queue.is_empty queue)) && !count < max_count do 340 + let h = Queue.pop queue in 341 + if not (Hashtbl.mem visited h) then begin 342 + Hashtbl.replace visited h (); 343 + match read t h with 344 + | Ok (Value.Commit commit) -> 345 + result := commit_to_log_entry h commit :: !result; 346 + incr count; 347 + (* Add parents to queue (reverse to maintain order) *) 348 + List.iter (fun p -> Queue.push p queue) (Commit.parents commit) 349 + | _ -> () 350 + end 351 + done; 352 + List.rev !result 353 + 354 + (** Check if a commit modifies files under a given path prefix. Compares the 355 + tree at path between this commit and its first parent. *) 356 + let commit_touches_path t commit ~path = 357 + let tree = Commit.tree commit in 358 + let parents = Commit.parents commit in 359 + let get_entry_hash tree_hash = 360 + if path = "" then Some tree_hash 361 + else 362 + let parts = String.split_on_char '/' path |> List.filter (( <> ) "") in 363 + let rec find hash = function 364 + | [] -> Some hash 365 + | name :: rest -> ( 366 + match read t hash with 367 + | Ok (Value.Tree tree) -> ( 368 + let entries = Tree.to_list tree in 369 + match 370 + List.find_opt (fun (e : Tree.entry) -> e.name = name) entries 371 + with 372 + | Some e -> find e.hash rest 373 + | None -> None) 374 + | _ -> None) 375 + in 376 + find tree_hash parts 377 + in 378 + match parents with 379 + | [] -> 380 + (* Initial commit matches only if path exists in the tree *) 381 + Option.is_some (get_entry_hash tree) 382 + | parent :: _ -> ( 383 + match read t parent with 384 + | Ok (Value.Commit parent_commit) -> 385 + let my_entry = get_entry_hash tree in 386 + let parent_entry = get_entry_hash (Commit.tree parent_commit) in 387 + my_entry <> parent_entry 388 + | _ -> true) 389 + 390 + let log_filtered t ?(max_count = max_int) ?since ?until ?path head = 391 + (* Walk backwards from head, collecting log entries with optional filters *) 392 + let visited = Hashtbl.create 256 in 393 + let result = ref [] in 394 + let count = ref 0 in 395 + let queue = Queue.create () in 396 + Queue.push head queue; 397 + while (not (Queue.is_empty queue)) && !count < max_count do 398 + let h = Queue.pop queue in 399 + if not (Hashtbl.mem visited h) then begin 400 + Hashtbl.replace visited h (); 401 + match read t h with 402 + | Ok (Value.Commit commit) -> 403 + let timestamp = User.date (Commit.author commit) in 404 + (* Check date filters *) 405 + let after_since = 406 + match since with Some s -> timestamp >= s | None -> true 407 + in 408 + let before_until = 409 + match until with Some u -> timestamp <= u | None -> true 410 + in 411 + (* Check path filter *) 412 + let matches_path = 413 + match path with 414 + | Some p -> commit_touches_path t commit ~path:p 415 + | None -> true 416 + in 417 + (* If before since, stop traversing (commits are in reverse chrono order) *) 418 + if not after_since then Queue.clear queue 419 + else begin 420 + if before_until && matches_path then begin 421 + result := commit_to_log_entry h commit :: !result; 422 + incr count 423 + end; 424 + List.iter (fun p -> Queue.push p queue) (Commit.parents commit) 425 + end 426 + | _ -> () 427 + end 428 + done; 429 + List.rev !result 430 + 431 + let resolve_ref t name = 432 + (* Try various ref formats to resolve a name to a hash *) 433 + if name = "HEAD" then read_ref t "HEAD" 434 + else if String.starts_with ~prefix:"refs/" name then read_ref t name 435 + else 436 + (* Try as remote ref first (e.g., "origin/main" -> "refs/remotes/origin/main") *) 437 + match read_ref t ("refs/remotes/" ^ name) with 438 + | Some _ as result -> result 439 + | None -> ( 440 + (* Try as local branch *) 441 + match read_ref t ("refs/heads/" ^ name) with 442 + | Some _ as result -> result 443 + | None -> 444 + (* Try as tag *) 445 + read_ref t ("refs/tags/" ^ name)) 446 + 447 + let log_range t ~base ~head ?(max_count = max_int) () = 448 + (* Get commits reachable from head but not from base *) 449 + let base_ancestors = Hashtbl.create 256 in 450 + (* First collect all ancestors of base *) 451 + let queue = Queue.create () in 452 + Queue.push base queue; 453 + while not (Queue.is_empty queue) do 454 + let h = Queue.pop queue in 455 + if not (Hashtbl.mem base_ancestors h) then begin 456 + Hashtbl.replace base_ancestors h (); 457 + match read t h with 458 + | Ok (Value.Commit commit) -> 459 + List.iter (fun p -> Queue.push p queue) (Commit.parents commit) 460 + | _ -> () 461 + end 462 + done; 463 + (* Then collect commits from head not in base_ancestors *) 464 + let visited = Hashtbl.create 256 in 465 + let result = ref [] in 466 + let count = ref 0 in 467 + Queue.push head queue; 468 + while (not (Queue.is_empty queue)) && !count < max_count do 469 + let h = Queue.pop queue in 470 + if (not (Hashtbl.mem visited h)) && not (Hashtbl.mem base_ancestors h) then begin 471 + Hashtbl.replace visited h (); 472 + match read t h with 473 + | Ok (Value.Commit commit) -> 474 + result := commit_to_log_entry h commit :: !result; 475 + incr count; 476 + List.iter (fun p -> Queue.push p queue) (Commit.parents commit) 477 + | _ -> () 478 + end 479 + done; 480 + List.rev !result 481 + 482 + let log_range_refs t ~base ~tip ?(max_count = max_int) () = 483 + match (resolve_ref t base, resolve_ref t tip) with 484 + | Some base_hash, Some tip_hash -> 485 + Ok (log_range t ~base:base_hash ~head:tip_hash ~max_count ()) 486 + | None, _ -> err_unresolved_ref base 487 + | _, None -> err_unresolved_ref tip 488 + 489 + let is_ancestor t ~ancestor ~descendant = 490 + (* Check if ancestor is reachable from descendant by BFS *) 491 + if Hash.equal ancestor descendant then true 492 + else 493 + let visited = Hashtbl.create 256 in 494 + let queue = Queue.create () in 495 + Queue.push descendant queue; 496 + let found = ref false in 497 + while (not (Queue.is_empty queue)) && not !found do 498 + let h = Queue.pop queue in 499 + if Hash.equal h ancestor then found := true 500 + else if not (Hashtbl.mem visited h) then begin 501 + Hashtbl.replace visited h (); 502 + match read t h with 503 + | Ok (Value.Commit commit) -> 504 + List.iter (fun p -> Queue.push p queue) (Commit.parents commit) 505 + | _ -> () 506 + end 507 + done; 508 + !found 509 + 510 + let count_commits_between t ~base ~head = 511 + List.length (log_range t ~base ~head ()) 512 + 513 + let merge_base t commit1 commit2 = 514 + (* Find common ancestor by collecting ancestors of both and finding 515 + the most recent common one. Simple BFS-based approach. *) 516 + let ancestors1 = Hashtbl.create 256 in 517 + let ancestors2 = Hashtbl.create 256 in 518 + let queue1 = Queue.create () in 519 + let queue2 = Queue.create () in 520 + Queue.push commit1 queue1; 521 + Queue.push commit2 queue2; 522 + let result = ref None in 523 + (* Alternate BFS from both sides until we find a common ancestor *) 524 + while 525 + !result = None && not (Queue.is_empty queue1 && Queue.is_empty queue2) 526 + do 527 + (* Process one step from queue1 *) 528 + if !result = None && not (Queue.is_empty queue1) then begin 529 + let h = Queue.pop queue1 in 530 + if not (Hashtbl.mem ancestors1 h) then begin 531 + Hashtbl.replace ancestors1 h (); 532 + if Hashtbl.mem ancestors2 h then result := Some h 533 + else 534 + match read t h with 535 + | Ok (Value.Commit commit) -> 536 + List.iter (fun p -> Queue.push p queue1) (Commit.parents commit) 537 + | _ -> () 538 + end 539 + end; 540 + (* Process one step from queue2 *) 541 + if !result = None && not (Queue.is_empty queue2) then begin 542 + let h = Queue.pop queue2 in 543 + if not (Hashtbl.mem ancestors2 h) then begin 544 + Hashtbl.replace ancestors2 h (); 545 + if Hashtbl.mem ancestors1 h then result := Some h 546 + else 547 + match read t h with 548 + | Ok (Value.Commit commit) -> 549 + List.iter (fun p -> Queue.push p queue2) (Commit.parents commit) 550 + | _ -> () 551 + end 552 + end 553 + done; 554 + !result 555 + 556 + (** {1 Repository initialization} *) 557 + 558 + let init ~fs path = 559 + let path_str = Fpath.to_string path in 560 + let git_dir = Filename.concat path_str ".git" in 561 + let git_path = Eio.Path.(fs / git_dir) in 562 + (* Create .git structure — mkdirs handles missing parent directories *) 563 + Eio.Path.mkdirs ~exists_ok:true ~perm:0o755 git_path; 564 + Eio.Path.mkdir ~perm:0o755 Eio.Path.(git_path / "objects"); 565 + Eio.Path.mkdir ~perm:0o755 Eio.Path.(git_path / "refs"); 566 + Eio.Path.mkdir ~perm:0o755 Eio.Path.(git_path / "refs" / "heads"); 567 + (* Write HEAD *) 568 + Eio.Path.save ~create:(`Or_truncate 0o644) 569 + Eio.Path.(git_path / "HEAD") 570 + "ref: refs/heads/main\n"; 571 + open_repo ~fs path 572 + 573 + (** {1 Config operations} *) 574 + 575 + let read_config t = 576 + let path = Filename.concat t.git_dir "config" in 577 + let full_path = Eio.Path.(t.fs / path) in 578 + try 579 + let content = Eio.Path.load full_path in 580 + Some (Config.of_string content) 581 + with Eio.Io _ -> None 582 + 583 + let write_config t config = 584 + let path = Filename.concat t.git_dir "config" in 585 + let full_path = Eio.Path.(t.fs / path) in 586 + let content = Config.to_string config in 587 + Eio.Path.save ~create:(`Or_truncate 0o644) full_path content 588 + 589 + let list_remotes t = 590 + match read_config t with 591 + | Some config -> 592 + List.map (fun (r : Config.remote) -> r.name) (Config.remotes config) 593 + | None -> [] 594 + 595 + let remote t name = 596 + match read_config t with 597 + | Some config -> Config.remote config name 598 + | None -> None 599 + 600 + let remote_url t name = 601 + match remote t name with Some r -> r.Config.url | None -> None 602 + 603 + let push_url t name = 604 + match remote t name with 605 + | Some r -> ( 606 + match r.Config.push_url with Some url -> Some url | None -> r.Config.url) 607 + | None -> None 608 + 609 + let add_remote t ~name ~url ?push_url ?(fetch = []) () = 610 + let config = match read_config t with Some c -> c | None -> Config.empty in 611 + (* Check if remote already exists *) 612 + if Option.is_some (Config.remote config name) then err_remote_exists name 613 + else 614 + let fetch = 615 + if fetch = [] then [ Fmt.str "+refs/heads/*:refs/remotes/%s/*" name ] 616 + else fetch 617 + in 618 + let remote : Config.remote = { name; url = Some url; push_url; fetch } in 619 + let entries = Config.of_remote remote in 620 + let new_config = List.fold_left (fun c e -> c @ [ e ]) config entries in 621 + write_config t new_config; 622 + Ok () 623 + 624 + let remove_remote t name = 625 + match read_config t with 626 + | None -> Error (`Msg "no config file") 627 + | Some config -> 628 + if Option.is_none (Config.remote config name) then 629 + err_remote_not_found name 630 + else 631 + let section = Config.section_sub "remote" name in 632 + (* Remove all entries for this remote section *) 633 + let new_config = 634 + List.filter 635 + (fun (e : Config.entry) -> 636 + not 637 + (e.section.name = section.name 638 + && e.section.subsection = section.subsection)) 639 + config 640 + in 641 + write_config t new_config; 642 + Ok () 643 + 644 + let set_remote_url t ~name ~url = 645 + match read_config t with 646 + | None -> Error (`Msg "no config file") 647 + | Some config -> 648 + let section = Config.section_sub "remote" name in 649 + (* Update the url entry for this remote *) 650 + let new_config = 651 + List.map 652 + (fun (e : Config.entry) -> 653 + if 654 + e.section.name = section.name 655 + && e.section.subsection = section.subsection 656 + && e.key = "url" 657 + then { e with value = url } 658 + else e) 659 + config 660 + in 661 + write_config t new_config; 662 + Ok () 663 + 664 + let set_push_url t ~name ~url = 665 + match read_config t with 666 + | None -> Error (`Msg "no config file") 667 + | Some config -> 668 + let section = Config.section_sub "remote" name in 669 + (* Check if remote exists *) 670 + let remote_exists = 671 + List.exists 672 + (fun (e : Config.entry) -> 673 + e.section.name = section.name 674 + && e.section.subsection = section.subsection) 675 + config 676 + in 677 + if not remote_exists then err_remote_not_found name 678 + else 679 + (* Check if pushurl already exists *) 680 + let has_pushurl = 681 + List.exists 682 + (fun (e : Config.entry) -> 683 + e.section.name = section.name 684 + && e.section.subsection = section.subsection 685 + && e.key = "pushurl") 686 + config 687 + in 688 + let new_config = 689 + if has_pushurl then 690 + (* Update existing pushurl *) 691 + List.map 692 + (fun (e : Config.entry) -> 693 + if 694 + e.section.name = section.name 695 + && e.section.subsection = section.subsection 696 + && e.key = "pushurl" 697 + then { e with value = url } 698 + else e) 699 + config 700 + else 701 + (* Add pushurl after url entry *) 702 + List.fold_right 703 + (fun (e : Config.entry) acc -> 704 + if 705 + e.section.name = section.name 706 + && e.section.subsection = section.subsection 707 + && e.key = "url" 708 + then e :: { e with key = "pushurl"; value = url } :: acc 709 + else e :: acc) 710 + config [] 711 + in 712 + write_config t new_config; 713 + Ok () 714 + 715 + let ensure_remote t ~name ~url = 716 + match remote_url t name with 717 + | Some existing_url when existing_url = url -> Ok () 718 + | Some _ -> set_remote_url t ~name ~url 719 + | None -> add_remote t ~name ~url () 720 + 721 + (** {1 Index operations} *) 722 + 723 + let read_index t = 724 + let path = Filename.concat t.git_dir "index" in 725 + let full_path = Eio.Path.(t.fs / path) in 726 + try 727 + let content = Eio.Path.load full_path in 728 + Index.of_string content 729 + with Eio.Io _ -> Ok Index.empty 730 + 731 + let write_index t index = 732 + let path = Filename.concat t.git_dir "index" in 733 + let full_path = Eio.Path.(t.fs / path) in 734 + let content = Index.to_string index in 735 + Eio.Path.save ~create:(`Or_truncate 0o644) full_path content 736 + 737 + let work_dir_of t = 738 + if 739 + String.length t.git_dir > 5 740 + && String.sub t.git_dir (String.length t.git_dir - 5) 5 = "/.git" 741 + then String.sub t.git_dir 0 (String.length t.git_dir - 5) 742 + else Filename.dirname t.git_dir 743 + 744 + let add_to_index t paths = 745 + let result = read_index t in 746 + match result with 747 + | Error e -> Error e 748 + | Ok index -> 749 + let work_dir = work_dir_of t in 750 + let index = 751 + List.fold_left 752 + (fun idx path -> 753 + let full_path = Eio.Path.(t.fs / work_dir / path) in 754 + try 755 + let content = Eio.Path.load full_path in 756 + let hash = write_blob t content in 757 + let entry = 758 + Index.entry_of_file ~fs:t.fs ~root:work_dir ~path ~hash 759 + in 760 + Index.add idx entry 761 + with Eio.Io _ -> idx (* Skip files that don't exist *)) 762 + index paths 763 + in 764 + write_index t index; 765 + Ok () 766 + 767 + let remove_from_index t prefix = 768 + match read_index t with 769 + | Error e -> Error e 770 + | Ok index -> 771 + let index = Index.remove_prefix index prefix in 772 + write_index t index; 773 + Ok () 774 + 775 + let add_all t = 776 + let work_dir = work_dir_of t in 777 + (* Recursively collect all files in working directory *) 778 + let rec collect_files dir prefix acc = 779 + let full_dir = Eio.Path.(t.fs / dir) in 780 + try 781 + let entries = Eio.Path.read_dir full_dir in 782 + List.fold_left 783 + (fun acc name -> 784 + if name = ".git" then acc 785 + else 786 + let path = if prefix = "" then name else prefix ^ "/" ^ name in 787 + let full_path = Eio.Path.(full_dir / name) in 788 + match Eio.Path.kind ~follow:false full_path with 789 + | `Directory -> collect_files (dir ^ "/" ^ name) path acc 790 + | `Regular_file -> path :: acc 791 + | _ -> acc) 792 + acc entries 793 + with Eio.Io _ -> acc 794 + in 795 + let files = collect_files work_dir "" [] in 796 + (* Read current index *) 797 + match read_index t with 798 + | Error e -> Error e 799 + | Ok index -> 800 + (* Build new index: add/update all files, track which paths we've seen *) 801 + let seen = Hashtbl.create 256 in 802 + let new_index = 803 + List.fold_left 804 + (fun idx path -> 805 + Hashtbl.replace seen path (); 806 + let full_path = Eio.Path.(t.fs / work_dir / path) in 807 + try 808 + let content = Eio.Path.load full_path in 809 + let hash = write_blob t content in 810 + let entry = 811 + Index.entry_of_file ~fs:t.fs ~root:work_dir ~path ~hash 812 + in 813 + Index.add idx entry 814 + with Eio.Io _ -> idx) 815 + index files 816 + in 817 + (* Remove entries for files that no longer exist *) 818 + let final_index = 819 + List.fold_left 820 + (fun idx (entry : Index.entry) -> 821 + if Hashtbl.mem seen entry.name then idx 822 + else Index.remove idx entry.name) 823 + new_index (Index.entries new_index) 824 + in 825 + write_index t final_index; 826 + Ok () 827 + 828 + let commit_index t ~author ~committer ?message () = 829 + match read_index t with 830 + | Error e -> Error e 831 + | Ok index when List.length (Index.entries index) = 0 -> 832 + Error (`Msg "nothing to commit (empty index)") 833 + | Ok index -> 834 + (* Build tree from index *) 835 + let tree_hash = 836 + Index.to_tree ~write_tree:(write_tree t) ~write_blob:(write_blob t) 837 + index 838 + in 839 + (* Get parent commit *) 840 + let parents = match head t with None -> [] | Some h -> [ h ] in 841 + (* Create commit *) 842 + let commit = 843 + Commit.v ~tree:tree_hash ~author ~committer ~parents message 844 + in 845 + let commit_hash = write_commit t commit in 846 + (* Update HEAD (or the branch it points to) *) 847 + advance_head t commit_hash; 848 + Ok commit_hash 849 + 850 + let commit t ~message = 851 + (* Get user info from config *) 852 + match read_config t with 853 + | None -> Error (`Msg "no git config found") 854 + | Some config -> ( 855 + let user_config = Config.user config in 856 + match (user_config.name, user_config.email) with 857 + | None, _ -> Error (`Msg "user.name not set in git config") 858 + | _, None -> Error (`Msg "user.email not set in git config") 859 + | Some name, Some email -> 860 + let date = Int64.of_float (Unix.gettimeofday ()) in 861 + let user = User.v ~name ~email ~date () in 862 + commit_index t ~author:user ~committer:user ~message ()) 863 + 864 + let rm t ~recursive path = 865 + let work_dir = work_dir_of t in 866 + (* Remove from index *) 867 + (match read_index t with 868 + | Error _ -> () 869 + | Ok index -> 870 + let new_index = 871 + if recursive then 872 + (* Remove all entries under this path *) 873 + let prefix = if path = "" then "" else path ^ "/" in 874 + List.fold_left 875 + (fun idx (entry : Index.entry) -> 876 + if entry.name = path || String.starts_with ~prefix entry.name then 877 + Index.remove idx entry.name 878 + else idx) 879 + index (Index.entries index) 880 + else Index.remove index path 881 + in 882 + write_index t new_index); 883 + (* Remove from working tree *) 884 + let full_path = Eio.Path.(t.fs / work_dir / path) in 885 + (try 886 + if recursive then 887 + let rec remove_recursive p = 888 + match Eio.Path.kind ~follow:false p with 889 + | `Directory -> 890 + let entries = Eio.Path.read_dir p in 891 + List.iter 892 + (fun name -> remove_recursive Eio.Path.(p / name)) 893 + entries; 894 + Eio.Path.rmdir p 895 + | _ -> Eio.Path.unlink p 896 + in 897 + remove_recursive full_path 898 + else Eio.Path.unlink full_path 899 + with Eio.Io _ -> ()); 900 + Ok () 901 + 902 + (** {1 Checkout operations} *) 903 + 904 + let rec checkout_tree t ~work_dir ~prefix tree_hash = 905 + match read t tree_hash with 906 + | Error e -> Error e 907 + | Ok (Value.Tree tree) -> 908 + let entries = Tree.to_list tree in 909 + let rec process_entries = function 910 + | [] -> Ok () 911 + | (entry : Tree.entry) :: rest -> ( 912 + let path = 913 + if prefix = "" then entry.name else prefix ^ "/" ^ entry.name 914 + in 915 + let full_path = Eio.Path.(t.fs / work_dir / path) in 916 + match entry.perm with 917 + | `Dir -> ( 918 + (* Create directory and recurse *) 919 + (try Eio.Path.mkdir ~perm:0o755 full_path with Eio.Io _ -> ()); 920 + match checkout_tree t ~work_dir ~prefix:path entry.hash with 921 + | Error e -> Error e 922 + | Ok () -> process_entries rest) 923 + | `Normal | `Everybody | `Exec -> ( 924 + (* Write file *) 925 + match read t entry.hash with 926 + | Error e -> Error e 927 + | Ok (Value.Blob blob) -> 928 + let perm = 929 + match entry.perm with `Exec -> 0o755 | _ -> 0o644 930 + in 931 + let dir = Filename.dirname path in 932 + if dir <> "." && dir <> "" then begin 933 + let dir_path = Eio.Path.(t.fs / work_dir / dir) in 934 + try Eio.Path.mkdir ~perm:0o755 dir_path 935 + with Eio.Io _ -> () 936 + end; 937 + Eio.Path.save ~create:(`Or_truncate perm) full_path 938 + (Blob.to_string blob); 939 + process_entries rest 940 + | Ok _ -> Error (`Msg "expected blob")) 941 + | `Link -> ( 942 + (* Create symlink *) 943 + match read t entry.hash with 944 + | Error e -> Error e 945 + | Ok (Value.Blob blob) -> ( 946 + let target = Blob.to_string blob in 947 + (try Eio.Path.unlink full_path with Eio.Io _ -> ()); 948 + try 949 + Eio.Path.symlink ~link_to:target full_path; 950 + process_entries rest 951 + with Eio.Io _ -> Error (`Msg "failed to create symlink")) 952 + | Ok _ -> Error (`Msg "expected blob for symlink")) 953 + | `Commit -> 954 + (* Submodule - skip for now *) 955 + process_entries rest) 956 + in 957 + process_entries entries 958 + | Ok _ -> Error (`Msg "expected tree") 959 + 960 + let checkout t commit_hash = 961 + let work_dir = work_dir_of t in 962 + match read t commit_hash with 963 + | Error e -> Error e 964 + | Ok (Value.Commit commit) -> 965 + let tree_hash = Commit.tree commit in 966 + checkout_tree t ~work_dir ~prefix:"" tree_hash 967 + | Ok _ -> Error (`Msg "expected commit") 968 + 969 + let checkout_prefix t commit_hash ~prefix = 970 + let work_dir = work_dir_of t in 971 + match read t commit_hash with 972 + | Error e -> Error e 973 + | Ok (Value.Commit commit) -> ( 974 + let tree_hash = Commit.tree commit in 975 + (* Walk tree to find subtree at prefix (inlined from Subtree.tree_at_prefix 976 + to avoid circular dependency) *) 977 + let segments = 978 + String.split_on_char '/' prefix |> List.filter (fun s -> s <> "") 979 + in 980 + let rec walk hash = function 981 + | [] -> Some hash 982 + | seg :: rest -> ( 983 + match read t hash with 984 + | Ok (Value.Tree tree) -> ( 985 + match Tree.find ~name:seg tree with 986 + | Some entry when entry.perm = `Dir -> walk entry.hash rest 987 + | _ -> None) 988 + | _ -> None) 989 + in 990 + match walk tree_hash segments with 991 + | None -> err_prefix_not_found prefix 992 + | Some sub_tree_hash -> 993 + let dir_path = Eio.Path.(t.fs / work_dir / prefix) in 994 + (try Eio.Path.mkdir ~perm:0o755 dir_path with Eio.Io _ -> ()); 995 + checkout_tree t ~work_dir ~prefix sub_tree_hash) 996 + | Ok _ -> Error (`Msg "expected commit") 997 + 998 + let checkout_ref t ref_name = 999 + match read_ref t ref_name with 1000 + | None -> err_ref_not_found ref_name 1001 + | Some hash -> checkout t hash 1002 + 1003 + (** {1 Dirty check} *) 1004 + 1005 + let is_dirty t = 1006 + (* A repo is dirty if there are uncommitted changes. 1007 + We check: 1008 + 1. If there are untracked files in the working directory 1009 + 2. If the index differs from HEAD 1010 + 3. If the working tree differs from the index 1011 + For now, use a simpler heuristic: check if HEAD exists and matches current tree. *) 1012 + match head t with 1013 + | None -> false (* No commits yet = empty, not dirty *) 1014 + | Some head_hash -> ( 1015 + match read t head_hash with 1016 + | Ok (Value.Commit commit) -> 1017 + (* TODO: Compare tree with working directory *) 1018 + (* For now, always return false as we can't properly check without index support *) 1019 + ignore (Commit.tree commit); 1020 + false 1021 + | _ -> true) 1022 + 1023 + (** {1 Ahead/behind computation} *) 1024 + 1025 + type ahead_behind = { ahead : int; behind : int } 1026 + 1027 + let ahead_behind t ?(remote = "origin") ?(branch = "main") () = 1028 + match head t with 1029 + | None -> None 1030 + | Some local_head -> ( 1031 + let remote_ref = "refs/remotes/" ^ remote ^ "/" ^ branch in 1032 + match read_ref t remote_ref with 1033 + | None -> None 1034 + | Some remote_head -> 1035 + if Hash.equal local_head remote_head then 1036 + Some { ahead = 0; behind = 0 } 1037 + else 1038 + let ahead = 1039 + List.length (log_range t ~base:remote_head ~head:local_head ()) 1040 + in 1041 + let behind = 1042 + List.length (log_range t ~base:local_head ~head:remote_head ()) 1043 + in 1044 + Some { ahead; behind }) 1045 + 1046 + (** {1 Tree hash lookup} *) 1047 + 1048 + let tree_hash_at_path t ~rev ~path = 1049 + (* Resolve ref to commit *) 1050 + let ref_name = if rev = "HEAD" then "HEAD" else "refs/heads/" ^ rev in 1051 + match read_ref t ref_name with 1052 + | None -> None 1053 + | Some commit_hash -> ( 1054 + match read t commit_hash with 1055 + | Ok (Value.Commit c) -> 1056 + let tree_hash = Commit.tree c in 1057 + if path = "" then Some tree_hash 1058 + else 1059 + (* Navigate into the path *) 1060 + let rec find_in_tree hash path_parts = 1061 + match read t hash with 1062 + | Ok (Value.Tree tree) -> ( 1063 + match path_parts with 1064 + | [] -> Some hash 1065 + | dir :: rest -> ( 1066 + let entries = Tree.to_list tree in 1067 + match 1068 + List.find_opt 1069 + (fun (e : Tree.entry) -> e.name = dir) 1070 + entries 1071 + with 1072 + | Some e -> find_in_tree e.hash rest 1073 + | None -> None)) 1074 + | _ -> None 1075 + in 1076 + let path_parts = 1077 + String.split_on_char '/' path |> List.filter (fun s -> s <> "") 1078 + in 1079 + find_in_tree tree_hash path_parts 1080 + | _ -> None) 1081 + 1082 + (** {1 Subtree operations} *) 1083 + 1084 + (** Parse a subtree merge/squash commit message to extract the upstream commit. 1085 + Messages look like: "Squashed 'prefix/' changes from abc123..def456" or 1086 + "Squashed 'prefix/' content from commit abc123" Returns the end commit (most 1087 + recent) if found. *) 1088 + let parse_subtree_message subject = 1089 + (* Helper to extract hex commit hash starting at position *) 1090 + let extract_hex s start = 1091 + let len = String.length s in 1092 + let rec find_end i = 1093 + if i >= len then i 1094 + else 1095 + match s.[i] with '0' .. '9' | 'a' .. 'f' -> find_end (i + 1) | _ -> i 1096 + in 1097 + let end_pos = find_end start in 1098 + if end_pos > start then Some (String.sub s start (end_pos - start)) 1099 + else None 1100 + in 1101 + (* Pattern 1: "Squashed 'prefix/' changes from abc123..def456" *) 1102 + if String.starts_with ~prefix:"Squashed '" subject then 1103 + match String.index_opt subject '.' with 1104 + | Some i when i + 1 < String.length subject && subject.[i + 1] = '.' -> 1105 + extract_hex subject (i + 2) 1106 + | _ -> ( 1107 + (* Pattern 2: "Squashed 'prefix/' content from commit abc123" *) 1108 + match String.split_on_char ' ' subject |> List.rev with 1109 + | last :: "commit" :: "from" :: _ -> extract_hex last 0 1110 + | _ -> None) 1111 + else if String.starts_with ~prefix:"Add '" subject then 1112 + (* Pattern 3: "Add 'prefix/' from commit abc123" *) 1113 + match String.split_on_char ' ' subject |> List.rev with 1114 + | last :: "commit" :: "from" :: _ -> extract_hex last 0 1115 + | _ -> None 1116 + else None 1117 + 1118 + let subtree_last_upstream_commit t ~prefix = 1119 + (* Search through commits for subtree-related messages *) 1120 + match head t with 1121 + | None -> None 1122 + | Some head_hash -> 1123 + let squash_pattern = Fmt.str "Squashed '%s/'" prefix in 1124 + let add_pattern = Fmt.str "Add '%s/'" prefix in 1125 + (* Walk commits looking for matching message *) 1126 + let visited = Hashtbl.create 256 in 1127 + let queue = Queue.create () in 1128 + Queue.push head_hash queue; 1129 + let result = ref None in 1130 + while !result = None && not (Queue.is_empty queue) do 1131 + let h = Queue.pop queue in 1132 + if not (Hashtbl.mem visited h) then begin 1133 + Hashtbl.replace visited h (); 1134 + match read t h with 1135 + | Ok (Value.Commit commit) -> 1136 + let subject = 1137 + match Commit.message commit with 1138 + | Some msg -> ( 1139 + match String.index_opt msg '\n' with 1140 + | Some i -> String.sub msg 0 i 1141 + | None -> msg) 1142 + | None -> "" 1143 + in 1144 + if 1145 + String.starts_with ~prefix:squash_pattern subject 1146 + || String.starts_with ~prefix:add_pattern subject 1147 + then result := parse_subtree_message subject 1148 + else 1149 + List.iter (fun p -> Queue.push p queue) (Commit.parents commit) 1150 + | _ -> () 1151 + end 1152 + done; 1153 + !result 1154 + 1155 + let has_subtree_history t ~prefix = 1156 + subtree_last_upstream_commit t ~prefix |> Option.is_some 1157 + 1158 + (** {1 Patch operations} *) 1159 + 1160 + (* Inline tree diff types and functions to avoid cycle with Diff module *) 1161 + type file_change = 1162 + | Added of { path : string; hash : Hash.t } 1163 + | Removed of { path : string; hash : Hash.t } 1164 + | Modified of { path : string; old_hash : Hash.t; new_hash : Hash.t } 1165 + 1166 + let change_path = function 1167 + | Added { path; _ } | Removed { path; _ } | Modified { path; _ } -> path 1168 + 1169 + let compare_changes c1 c2 = String.compare (change_path c1) (change_path c2) 1170 + 1171 + let rec collect_tree_entries t prefix tree_hash = 1172 + match read t tree_hash with 1173 + | Error _ -> [] 1174 + | Ok (Value.Tree tree) -> 1175 + List.concat_map 1176 + (fun (e : Tree.entry) -> 1177 + let path = if prefix = "" then e.name else prefix ^ "/" ^ e.name in 1178 + if e.perm = `Dir then collect_tree_entries t path e.hash 1179 + else [ (path, e.hash) ]) 1180 + (Tree.to_list tree) 1181 + | Ok _ -> [] 1182 + 1183 + let diff_trees_simple t ~old_tree ~new_tree = 1184 + let old_files = collect_tree_entries t "" old_tree in 1185 + let new_files = collect_tree_entries t "" new_tree in 1186 + let old_map = List.to_seq old_files |> Hashtbl.of_seq in 1187 + let new_map = List.to_seq new_files |> Hashtbl.of_seq in 1188 + let changes = ref [] in 1189 + (* Find removed and modified *) 1190 + Hashtbl.iter 1191 + (fun path old_hash -> 1192 + match Hashtbl.find_opt new_map path with 1193 + | None -> changes := Removed { path; hash = old_hash } :: !changes 1194 + | Some new_hash -> 1195 + if not (Hash.equal old_hash new_hash) then 1196 + changes := Modified { path; old_hash; new_hash } :: !changes) 1197 + old_map; 1198 + (* Find added *) 1199 + Hashtbl.iter 1200 + (fun path hash -> 1201 + if not (Hashtbl.mem old_map path) then 1202 + changes := Added { path; hash } :: !changes) 1203 + new_map; 1204 + List.sort compare_changes !changes 1205 + 1206 + let diff_tree_to_empty_simple t tree_hash = 1207 + collect_tree_entries t "" tree_hash 1208 + |> List.map (fun (path, hash) -> Added { path; hash }) 1209 + |> List.sort compare_changes 1210 + 1211 + (** Compute unified diff between two strings *) 1212 + let unified_diff ~old_content ~new_content ~old_path ~new_path = 1213 + let old_lines = String.split_on_char '\n' old_content in 1214 + let new_lines = String.split_on_char '\n' new_content in 1215 + let n = List.length old_lines in 1216 + let m = List.length new_lines in 1217 + if n = 0 && m = 0 then "" 1218 + else 1219 + let old_arr = Array.of_list old_lines in 1220 + let new_arr = Array.of_list new_lines in 1221 + let buf = Buffer.create 1024 in 1222 + Buffer.add_string buf (Fmt.str "diff --git a/%s b/%s\n" old_path new_path); 1223 + Buffer.add_string buf (Fmt.str "--- a/%s\n" old_path); 1224 + Buffer.add_string buf (Fmt.str "+++ b/%s\n" new_path); 1225 + (* Simple diff: show all old as removed, all new as added *) 1226 + if n > 0 || m > 0 then begin 1227 + Buffer.add_string buf (Fmt.str "@@ -%d,%d +%d,%d @@\n" 1 n 1 m); 1228 + for i = 0 to n - 1 do 1229 + Buffer.add_string buf "-"; 1230 + Buffer.add_string buf old_arr.(i); 1231 + Buffer.add_char buf '\n' 1232 + done; 1233 + for i = 0 to m - 1 do 1234 + Buffer.add_string buf "+"; 1235 + Buffer.add_string buf new_arr.(i); 1236 + Buffer.add_char buf '\n' 1237 + done 1238 + end; 1239 + Buffer.contents buf 1240 + 1241 + let format_patch_header buf c commit = 1242 + Buffer.add_string buf (Fmt.str "commit %s\n" commit); 1243 + Buffer.add_string buf 1244 + (Fmt.str "Author: %s\n" (User.to_string (Commit.author c))); 1245 + Buffer.add_string buf (Fmt.str "Date: %Ld\n" (User.date (Commit.author c))); 1246 + Buffer.add_char buf '\n'; 1247 + (match Commit.message c with 1248 + | Some msg -> 1249 + String.split_on_char '\n' msg 1250 + |> List.iter (fun line -> 1251 + Buffer.add_string buf " "; 1252 + Buffer.add_string buf line; 1253 + Buffer.add_char buf '\n') 1254 + | None -> ()); 1255 + Buffer.add_char buf '\n' 1256 + 1257 + let format_stat_line buf change = 1258 + match change with 1259 + | Added { path; _ } -> Buffer.add_string buf (Fmt.str " %s | new file\n" path) 1260 + | Removed { path; _ } -> 1261 + Buffer.add_string buf (Fmt.str " %s | deleted\n" path) 1262 + | Modified { path; _ } -> 1263 + Buffer.add_string buf (Fmt.str " %s | modified\n" path) 1264 + 1265 + let format_change_diff t buf change = 1266 + match change with 1267 + | Added { path; hash } -> ( 1268 + match read t hash with 1269 + | Ok (Value.Blob blob) -> 1270 + let diff = 1271 + unified_diff ~old_content:"" ~new_content:(Blob.to_string blob) 1272 + ~old_path:path ~new_path:path 1273 + in 1274 + Buffer.add_string buf diff 1275 + | _ -> ()) 1276 + | Removed { path; hash } -> ( 1277 + match read t hash with 1278 + | Ok (Value.Blob blob) -> 1279 + let diff = 1280 + unified_diff ~old_content:(Blob.to_string blob) ~new_content:"" 1281 + ~old_path:path ~new_path:path 1282 + in 1283 + Buffer.add_string buf diff 1284 + | _ -> ()) 1285 + | Modified { path; old_hash; new_hash } -> ( 1286 + match (read t old_hash, read t new_hash) with 1287 + | Ok (Value.Blob old_b), Ok (Value.Blob new_b) -> 1288 + let diff = 1289 + unified_diff ~old_content:(Blob.to_string old_b) 1290 + ~new_content:(Blob.to_string new_b) ~old_path:path ~new_path:path 1291 + in 1292 + Buffer.add_string buf diff 1293 + | _ -> ()) 1294 + 1295 + let show_patch t ~commit = 1296 + match read t (Hash.of_hex commit) with 1297 + | Error e -> Error e 1298 + | Ok (Value.Commit c) -> 1299 + let buf = Buffer.create 4096 in 1300 + format_patch_header buf c commit; 1301 + let parent_tree = 1302 + match Commit.parents c with 1303 + | [] -> None 1304 + | p :: _ -> ( 1305 + match read t p with 1306 + | Ok (Value.Commit pc) -> Some (Commit.tree pc) 1307 + | _ -> None) 1308 + in 1309 + let new_tree = Commit.tree c in 1310 + let changes = 1311 + match parent_tree with 1312 + | Some old_tree -> diff_trees_simple t ~old_tree ~new_tree 1313 + | None -> diff_tree_to_empty_simple t new_tree 1314 + in 1315 + List.iter (format_stat_line buf) changes; 1316 + Buffer.add_char buf '\n'; 1317 + List.iter (format_change_diff t buf) changes; 1318 + Ok (Buffer.contents buf) 1319 + | Ok _ -> Error (`Msg "not a commit") 1320 + 1321 + (** {1 Cherry-pick operations} *) 1322 + 1323 + let mkdir_p t work_dir path = 1324 + let rec aux p = 1325 + let parent = Filename.dirname p in 1326 + if parent <> "." && parent <> "" then aux parent; 1327 + let full = Eio.Path.(t.fs / work_dir / p) in 1328 + try Eio.Path.mkdir ~perm:0o755 full with Eio.Io _ -> () 1329 + in 1330 + aux path 1331 + 1332 + let apply_change t work_dir change = 1333 + match change with 1334 + | Added { path; hash } -> ( 1335 + match read t hash with 1336 + | Ok (Value.Blob blob) -> 1337 + let full_path = Eio.Path.(t.fs / work_dir / path) in 1338 + let dir = Filename.dirname path in 1339 + if dir <> "." && dir <> "" then mkdir_p t work_dir dir; 1340 + Eio.Path.save ~create:(`Or_truncate 0o644) full_path 1341 + (Blob.to_string blob); 1342 + Ok () 1343 + | _ -> Error (`Msg ("cannot read blob: " ^ path))) 1344 + | Removed { path; _ } -> 1345 + let full_path = Eio.Path.(t.fs / work_dir / path) in 1346 + (try Eio.Path.unlink full_path with Eio.Io _ -> ()); 1347 + Ok () 1348 + | Modified { path; new_hash; _ } -> ( 1349 + match read t new_hash with 1350 + | Ok (Value.Blob blob) -> 1351 + let full_path = Eio.Path.(t.fs / work_dir / path) in 1352 + Eio.Path.save ~create:(`Or_truncate 0o644) full_path 1353 + (Blob.to_string blob); 1354 + Ok () 1355 + | _ -> Error (`Msg ("cannot read blob: " ^ path))) 1356 + 1357 + let cherry_pick t ~commit:commit_hash = 1358 + match read t (Hash.of_hex commit_hash) with 1359 + | Error e -> Error e 1360 + | Ok (Value.Commit c) -> ( 1361 + let parent_tree = 1362 + match Commit.parents c with 1363 + | [] -> None 1364 + | p :: _ -> ( 1365 + match read t p with 1366 + | Ok (Value.Commit pc) -> Some (Commit.tree pc) 1367 + | _ -> None) 1368 + in 1369 + let changes = 1370 + match parent_tree with 1371 + | Some old_tree -> 1372 + diff_trees_simple t ~old_tree ~new_tree:(Commit.tree c) 1373 + | None -> diff_tree_to_empty_simple t (Commit.tree c) 1374 + in 1375 + let work_dir = work_dir_of t in 1376 + let rec apply_all = function 1377 + | [] -> Ok () 1378 + | change :: rest -> ( 1379 + match apply_change t work_dir change with 1380 + | Error e -> Error e 1381 + | Ok () -> apply_all rest) 1382 + in 1383 + match apply_all changes with 1384 + | Error e -> Error e 1385 + | Ok () -> ( 1386 + match add_all t with 1387 + | Error e -> Error e 1388 + | Ok () -> 1389 + let message = 1390 + match Commit.message c with Some m -> m | None -> "" 1391 + in 1392 + commit t ~message)) 1393 + | Ok _ -> Error (`Msg "not a commit") 1394 + 1395 + (** {1 Merge operations} *) 1396 + 1397 + let merge t ~ref_name ~ff_only = 1398 + (* Resolve the ref to merge *) 1399 + match resolve_ref t ref_name with 1400 + | None -> Error (`Msg ("cannot resolve ref: " ^ ref_name)) 1401 + | Some their_hash -> ( 1402 + match head t with 1403 + | None -> Error (`Msg "no HEAD") 1404 + | Some our_hash -> 1405 + if Hash.equal our_hash their_hash then Ok () (* Already up to date *) 1406 + else 1407 + (* Check if fast-forward is possible *) 1408 + let can_ff = 1409 + is_ancestor t ~ancestor:our_hash ~descendant:their_hash 1410 + in 1411 + if can_ff then begin 1412 + (* Fast-forward: just update HEAD *) 1413 + advance_head t their_hash; 1414 + (* Checkout the new tree *) 1415 + match checkout t their_hash with 1416 + | Error e -> Error e 1417 + | Ok () -> Ok () 1418 + end 1419 + else if ff_only then 1420 + Error (`Msg "not a fast-forward and --ff-only specified") 1421 + else 1422 + (* Real merge needed - not implemented yet *) 1423 + Error (`Msg "non-fast-forward merge not yet implemented")) 1424 + 1425 + (** {1 Worktree operations} *) 1426 + 1427 + let worktree t = Worktree.v ~fs:t.fs ~git_dir:t.git_dir
+346
lib/repository.mli
··· 1 + (* Copyright (c) 2013-2017 Thomas Gazagnaire <thomas@gazagnaire.org> 2 + Copyright (c) 2017-2024 Romain Calascibetta <romain.calascibetta@gmail.com> 3 + Copyright (c) 2024-2026 Thomas Gazagnaire <thomas@gazagnaire.org> 4 + 5 + Permission to use, copy, modify, and distribute this software for any 6 + purpose with or without fee is hereby granted, provided that the above 7 + copyright notice and this permission notice appear in all copies. 8 + 9 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. *) 16 + 17 + (** Git repository access. 18 + 19 + High-level operations on Git repositories, handling both loose objects and 20 + pack files transparently. *) 21 + 22 + type t 23 + (** A Git repository. *) 24 + 25 + val pp : t Fmt.t 26 + (** [pp] pretty-prints a repository. *) 27 + 28 + (** {1 Opening repositories} *) 29 + 30 + val open_repo : fs:Eio.Fs.dir_ty Eio.Path.t -> Fpath.t -> t 31 + (** [open_repo ~fs path] opens the Git repository at [path] (expects a .git 32 + subdirectory). *) 33 + 34 + val open_bare : fs:Eio.Fs.dir_ty Eio.Path.t -> Fpath.t -> t 35 + (** [open_bare ~fs path] opens a bare Git repository at [path]. *) 36 + 37 + val init : fs:Eio.Fs.dir_ty Eio.Path.t -> Fpath.t -> t 38 + (** [init ~fs path] creates a new Git repository at [path]. *) 39 + 40 + (** {1 Object reading} *) 41 + 42 + val read : t -> Hash.t -> (Value.t, [ `Msg of string ]) result 43 + (** [read t hash] reads an object from the repository. Checks loose objects 44 + first, then pack files. *) 45 + 46 + val exists : t -> Hash.t -> bool 47 + (** [exists t hash] checks if an object exists in the repository. *) 48 + 49 + (** {1 Object writing} *) 50 + 51 + val write : t -> Value.t -> Hash.t 52 + (** [write t value] writes an object to the repository as a loose object. 53 + Returns the hash. *) 54 + 55 + val write_blob : t -> string -> Hash.t 56 + (** [write_blob t data] writes a blob with the given content. *) 57 + 58 + val write_tree : t -> Tree.t -> Hash.t 59 + (** [write_tree t tree] writes a tree object. *) 60 + 61 + val write_commit : t -> Commit.t -> Hash.t 62 + (** [write_commit t commit] writes a commit object. *) 63 + 64 + val write_tag : t -> Tag.t -> Hash.t 65 + (** [write_tag t tag] writes a tag object. *) 66 + 67 + (** {1 Reference operations} *) 68 + 69 + val read_ref : t -> string -> Hash.t option 70 + (** [read_ref t name] reads a reference. Follows symbolic refs. *) 71 + 72 + val write_ref : t -> string -> Hash.t -> unit 73 + (** [write_ref t name hash] writes a reference. *) 74 + 75 + val delete_ref : t -> string -> unit 76 + (** [delete_ref t name] deletes a reference. *) 77 + 78 + val list_refs : t -> string list 79 + (** [list_refs t] lists all references in the repository. *) 80 + 81 + (** {1 Repository internals} *) 82 + 83 + val git_dir : t -> Fpath.t 84 + (** [git_dir t] returns the path to the .git directory. *) 85 + 86 + val fs : t -> Eio.Fs.dir_ty Eio.Path.t 87 + (** [fs t] returns the filesystem capability associated with the repository. *) 88 + 89 + (** {1 Repository queries} *) 90 + 91 + val is_repo : fs:Eio.Fs.dir_ty Eio.Path.t -> Fpath.t -> bool 92 + (** [is_repo ~fs path] returns true if [path] is a Git repository (has a .git 93 + subdirectory). *) 94 + 95 + val head : t -> Hash.t option 96 + (** [head t] returns the commit hash pointed to by HEAD. *) 97 + 98 + val current_branch : t -> string option 99 + (** [current_branch t] returns the current branch name, or [None] if in detached 100 + HEAD state. *) 101 + 102 + val rename_branch : t -> new_name:string -> (unit, [ `Msg of string ]) result 103 + (** [rename_branch t ~new_name] renames the current branch to [new_name]. 104 + Returns [Error] if in detached HEAD state. *) 105 + 106 + val advance_head : t -> Hash.t -> unit 107 + (** [advance_head t hash] updates HEAD to point to [hash]. If HEAD points to a 108 + branch, updates the branch ref. If in detached HEAD state, updates HEAD 109 + directly. *) 110 + 111 + (** {1 Log operations} *) 112 + 113 + type log_entry = { 114 + hash : string; 115 + author : string; 116 + date : string; 117 + subject : string; 118 + body : string; 119 + } 120 + (** A commit log entry. *) 121 + 122 + val log : t -> ?max_count:int -> Hash.t -> log_entry list 123 + (** [log t ?max_count head] returns commits reachable from [head], most recent 124 + first. *) 125 + 126 + val log_filtered : 127 + t -> 128 + ?max_count:int -> 129 + ?since:int64 -> 130 + ?until:int64 -> 131 + ?path:string -> 132 + Hash.t -> 133 + log_entry list 134 + (** [log_filtered t ?max_count ?since ?until ?path head] returns commits 135 + reachable from [head] with optional filters. 136 + 137 + @param since Only include commits after this Unix timestamp. 138 + @param until Only include commits before this Unix timestamp. 139 + @param path Only include commits that modify files under this path. *) 140 + 141 + val log_range : 142 + t -> base:Hash.t -> head:Hash.t -> ?max_count:int -> unit -> log_entry list 143 + (** [log_range t ~base ~head ?max_count ()] returns commits reachable from 144 + [head] but not from [base]. *) 145 + 146 + val resolve_ref : t -> string -> Hash.t option 147 + (** [resolve_ref t name] resolves a ref name to a hash. Handles: 148 + - "HEAD" - reads HEAD directly. 149 + - "refs/..." - reads the full ref path. 150 + - "origin/main" - tries refs/remotes/origin/main. 151 + - "main" - tries refs/heads/main, then refs/remotes/origin/main, then 152 + refs/tags/main. *) 153 + 154 + val log_range_refs : 155 + t -> 156 + base:string -> 157 + tip:string -> 158 + ?max_count:int -> 159 + unit -> 160 + (log_entry list, [ `Msg of string ]) result 161 + (** [log_range_refs t ~base ~tip ?max_count ()] returns commits reachable from 162 + [tip] but not from [base], where [base] and [tip] are ref names (e.g., 163 + "HEAD", "origin/main"). Returns [Error] if refs cannot be resolved. *) 164 + 165 + val is_ancestor : t -> ancestor:Hash.t -> descendant:Hash.t -> bool 166 + (** [is_ancestor t ~ancestor ~descendant] returns true if [ancestor] is an 167 + ancestor of [descendant]. *) 168 + 169 + val count_commits_between : t -> base:Hash.t -> head:Hash.t -> int 170 + (** [count_commits_between t ~base ~head] returns the number of commits 171 + reachable from [head] but not from [base]. *) 172 + 173 + val merge_base : t -> Hash.t -> Hash.t -> Hash.t option 174 + (** [merge_base t commit1 commit2] returns the best common ancestor of the two 175 + commits, or [None] if they have no common ancestor. *) 176 + 177 + (** {1 Config operations} *) 178 + 179 + val read_config : t -> Config.t option 180 + (** [read_config t] reads the repository's config file. *) 181 + 182 + val write_config : t -> Config.t -> unit 183 + (** [write_config t config] writes the config file. *) 184 + 185 + val list_remotes : t -> string list 186 + (** [list_remotes t] returns the names of all configured remotes. *) 187 + 188 + val remote : t -> string -> Config.remote option 189 + (** [remote t name] returns the remote configuration. *) 190 + 191 + val remote_url : t -> string -> string option 192 + (** [remote_url t name] returns the URL for the named remote. *) 193 + 194 + val push_url : t -> string -> string option 195 + (** [push_url t name] returns the push URL for the named remote, falling back to 196 + the regular URL if no push URL is set. *) 197 + 198 + val add_remote : 199 + t -> 200 + name:string -> 201 + url:string -> 202 + ?push_url:string -> 203 + ?fetch:string list -> 204 + unit -> 205 + (unit, [ `Msg of string ]) result 206 + (** [add_remote t ~name ~url ?push_url ?fetch ()] adds a new remote. If [fetch] 207 + is not provided, defaults to [+refs/heads/*:refs/remotes/<name>/*]. Returns 208 + [Error] if the remote already exists. *) 209 + 210 + val remove_remote : t -> string -> (unit, [ `Msg of string ]) result 211 + (** [remove_remote t name] removes the named remote. Returns [Error] if the 212 + remote doesn't exist. *) 213 + 214 + val set_remote_url : 215 + t -> name:string -> url:string -> (unit, [ `Msg of string ]) result 216 + (** [set_remote_url t ~name ~url] updates the URL for an existing remote. *) 217 + 218 + val set_push_url : 219 + t -> name:string -> url:string -> (unit, [ `Msg of string ]) result 220 + (** [set_push_url t ~name ~url] sets the push URL for an existing remote. *) 221 + 222 + val ensure_remote : 223 + t -> name:string -> url:string -> (unit, [ `Msg of string ]) result 224 + (** [ensure_remote t ~name ~url] ensures a remote exists with the given URL. 225 + Adds the remote if it doesn't exist, updates the URL if it differs. *) 226 + 227 + (** {1 Index operations} *) 228 + 229 + val read_index : t -> (Index.t, [ `Msg of string ]) result 230 + (** [read_index t] reads the index file. Returns empty index if none exists. *) 231 + 232 + val write_index : t -> Index.t -> unit 233 + (** [write_index t index] writes the index file. *) 234 + 235 + val add_to_index : t -> string list -> (unit, [ `Msg of string ]) result 236 + (** [add_to_index t paths] adds files to the index. Paths are relative to the 237 + working directory. *) 238 + 239 + val remove_from_index : t -> string -> (unit, [ `Msg of string ]) result 240 + (** [remove_from_index t prefix] removes all entries under [prefix] from the 241 + index. *) 242 + 243 + val add_all : t -> (unit, [ `Msg of string ]) result 244 + (** [add_all t] stages all changes in the working directory (equivalent to 245 + [git add -A]). Adds new and modified files, removes deleted files. *) 246 + 247 + val commit : t -> message:string -> (Hash.t, [ `Msg of string ]) result 248 + (** [commit t ~message] creates a commit from the current index using the 249 + author/committer from git config. Returns the commit hash. *) 250 + 251 + val rm : t -> recursive:bool -> string -> (unit, [ `Msg of string ]) result 252 + (** [rm t ~recursive path] removes [path] from the index and working tree. If 253 + [recursive] is true, removes all entries under [path]. *) 254 + 255 + val commit_index : 256 + t -> 257 + author:User.t -> 258 + committer:User.t -> 259 + ?message:string -> 260 + unit -> 261 + (Hash.t, [ `Msg of string ]) result 262 + (** [commit_index t ~author ~committer ?message ()] creates a commit from the 263 + current index and updates HEAD. Returns the commit hash. *) 264 + 265 + (** {1 Checkout operations} *) 266 + 267 + val checkout : t -> Hash.t -> (unit, [ `Msg of string ]) result 268 + (** [checkout t commit_hash] checks out the tree from the given commit to the 269 + working directory. *) 270 + 271 + val checkout_prefix : 272 + t -> Hash.t -> prefix:string -> (unit, [ `Msg of string ]) result 273 + (** [checkout_prefix t commit_hash ~prefix] checks out only the files under 274 + [prefix] from the given commit to the working directory. *) 275 + 276 + val checkout_ref : t -> string -> (unit, [ `Msg of string ]) result 277 + (** [checkout_ref t ref_name] checks out the tree from the named ref. *) 278 + 279 + (** {1 Working tree status} *) 280 + 281 + val is_dirty : t -> bool 282 + (** [is_dirty t] returns [true] if the repository has uncommitted changes. Note: 283 + This is a simplified implementation that may not detect all dirty states. *) 284 + 285 + (** {1 Ahead/behind computation} *) 286 + 287 + type ahead_behind = { ahead : int; behind : int } 288 + (** Commits ahead/behind relative to a remote branch. *) 289 + 290 + val ahead_behind : 291 + t -> ?remote:string -> ?branch:string -> unit -> ahead_behind option 292 + (** [ahead_behind t ?remote ?branch ()] computes how many commits the local HEAD 293 + is ahead/behind the remote tracking branch. Returns [None] if HEAD doesn't 294 + exist. 295 + @param remote Remote name (default: "origin"). 296 + @param branch Branch name (default: "main"). *) 297 + 298 + (** {1 Tree hash lookup} *) 299 + 300 + val tree_hash_at_path : t -> rev:string -> path:string -> Hash.t option 301 + (** [tree_hash_at_path t ~rev ~path] returns the tree hash at [path] within the 302 + tree of [rev]. If [path] is empty, returns the root tree hash. 303 + @param rev Reference name (e.g., "HEAD", "main"). 304 + @param path Path within the tree (e.g., "src/lib"). *) 305 + 306 + (** {1 Subtree operations} *) 307 + 308 + val subtree_last_upstream_commit : t -> prefix:string -> string option 309 + (** [subtree_last_upstream_commit t ~prefix] finds the upstream commit SHA that 310 + the subtree was last synced from. 311 + 312 + Searches commit history for the most recent subtree merge/squash commit for 313 + the given prefix and extracts the upstream commit reference. 314 + 315 + @param prefix Subtree directory name (e.g., "ocaml-bytesrw"). *) 316 + 317 + val has_subtree_history : t -> prefix:string -> bool 318 + (** [has_subtree_history t ~prefix] returns true if the prefix has subtree 319 + commit history (i.e., was added via git subtree add). Returns false for 320 + fresh local packages that were never part of a subtree. *) 321 + 322 + (** {1 Patch operations} *) 323 + 324 + val show_patch : t -> commit:string -> (string, [ `Msg of string ]) result 325 + (** [show_patch t ~commit] returns the patch for [commit] as a string, including 326 + commit info, stat summary, and unified diffs. *) 327 + 328 + (** {1 Cherry-pick operations} *) 329 + 330 + val cherry_pick : t -> commit:string -> (Hash.t, [ `Msg of string ]) result 331 + (** [cherry_pick t ~commit] applies the changes from [commit] to the current 332 + HEAD and creates a new commit with the same message. Returns the new commit 333 + hash. *) 334 + 335 + (** {1 Merge operations} *) 336 + 337 + val merge : 338 + t -> ref_name:string -> ff_only:bool -> (unit, [ `Msg of string ]) result 339 + (** [merge t ~ref_name ~ff_only] merges [ref_name] into the current HEAD. If 340 + [ff_only] is true, only fast-forward merges are allowed. Currently only 341 + fast-forward merges are implemented. *) 342 + 343 + (** {1 Worktree operations} *) 344 + 345 + val worktree : t -> Worktree.t 346 + (** [worktree t] returns a worktree manager for the repository. *)
+198
lib/rev_list.ml
··· 1 + (* Copyright (c) 2024-2026 Thomas Gazagnaire <thomas@gazagnaire.org> 2 + 3 + Permission to use, copy, modify, and distribute this software for any 4 + purpose with or without fee is hereby granted, provided that the above 5 + copyright notice and this permission notice appear in all copies. 6 + 7 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. *) 14 + 15 + type commit_info = { hash : Hash.t; parents : Hash.t list } 16 + 17 + (** Compute in-degrees for all commits in the graph. *) 18 + let compute_in_degrees graph = 19 + let in_degree = Hashtbl.create (Hashtbl.length graph) in 20 + Hashtbl.iter (fun h _ -> Hashtbl.replace in_degree h 0) graph; 21 + Hashtbl.iter 22 + (fun _ parents -> 23 + List.iter 24 + (fun p -> 25 + if Hashtbl.mem graph p then 26 + let d = Hashtbl.find in_degree p in 27 + Hashtbl.replace in_degree p (d + 1)) 28 + parents) 29 + graph; 30 + in_degree 31 + 32 + (** Run Kahn's algorithm on graph with computed in-degrees. *) 33 + let kahn_sort graph in_degree = 34 + let roots = Queue.create () in 35 + Hashtbl.iter (fun h d -> if d = 0 then Queue.push h roots) in_degree; 36 + let result = ref [] in 37 + while not (Queue.is_empty roots) do 38 + let h = Queue.pop roots in 39 + let parents = 40 + match Hashtbl.find_opt graph h with Some p -> p | None -> [] 41 + in 42 + result := { hash = h; parents } :: !result; 43 + List.iter 44 + (fun p -> 45 + if Hashtbl.mem graph p then begin 46 + let d = Hashtbl.find in_degree p - 1 in 47 + Hashtbl.replace in_degree p d; 48 + if d = 0 then Queue.push p roots 49 + end) 50 + parents 51 + done; 52 + !result 53 + 54 + let topo_sort_reverse repo head ~stop = 55 + let visited = Hashtbl.create 256 in 56 + let graph = Hashtbl.create 256 in 57 + let error = ref None in 58 + let queue = Queue.create () in 59 + Queue.push head queue; 60 + while (not (Queue.is_empty queue)) && Option.is_none !error do 61 + let h = Queue.pop queue in 62 + if not (Hashtbl.mem visited h || stop h) then begin 63 + Hashtbl.replace visited h (); 64 + match Repository.read repo h with 65 + | Ok (Value.Commit commit) -> 66 + let parents = Commit.parents commit in 67 + Hashtbl.replace graph h parents; 68 + List.iter (fun p -> Queue.push p queue) parents 69 + | Ok _ -> error := Some (`Msg (Fmt.str "%a is not a commit" Hash.pp h)) 70 + | Error e -> error := Some e 71 + end 72 + done; 73 + match !error with 74 + | Some e -> Error e 75 + | None -> 76 + let in_degree = compute_in_degrees graph in 77 + Ok (kahn_sort graph in_degree) 78 + 79 + (** {1 Commit graph queries} *) 80 + 81 + let is_ancestor repo ~ancestor ~descendant = 82 + (* Check if [ancestor] is reachable from [descendant] via parent traversal *) 83 + if Hash.equal ancestor descendant then true 84 + else 85 + let visited = Hashtbl.create 256 in 86 + let queue = Queue.create () in 87 + Queue.push descendant queue; 88 + let found = ref false in 89 + while (not (Queue.is_empty queue)) && not !found do 90 + let h = Queue.pop queue in 91 + if Hash.equal h ancestor then found := true 92 + else if not (Hashtbl.mem visited h) then begin 93 + Hashtbl.replace visited h (); 94 + match Repository.read repo h with 95 + | Ok (Value.Commit commit) -> 96 + List.iter (fun p -> Queue.push p queue) (Commit.parents commit) 97 + | _ -> () 98 + end 99 + done; 100 + !found 101 + 102 + (** Expand one step from queue, checking for intersection with other visited 103 + set. Returns Some hash if intersection found, None otherwise. *) 104 + let expand_bfs repo queue visited other_visited = 105 + if Queue.is_empty queue then None 106 + else 107 + let h = Queue.pop queue in 108 + if Hashtbl.mem other_visited h then Some h 109 + else 110 + match Repository.read repo h with 111 + | Ok (Value.Commit commit) -> 112 + List.fold_left 113 + (fun acc p -> 114 + match acc with 115 + | Some _ -> acc 116 + | None -> 117 + if not (Hashtbl.mem visited p) then begin 118 + Hashtbl.replace visited p (); 119 + if Hashtbl.mem other_visited p then Some p 120 + else begin 121 + Queue.push p queue; 122 + None 123 + end 124 + end 125 + else None) 126 + None (Commit.parents commit) 127 + | _ -> None 128 + 129 + let merge_base repo commit1 commit2 = 130 + let visited1 = Hashtbl.create 256 in 131 + let visited2 = Hashtbl.create 256 in 132 + let queue1 = Queue.create () in 133 + let queue2 = Queue.create () in 134 + Queue.push commit1 queue1; 135 + Queue.push commit2 queue2; 136 + Hashtbl.replace visited1 commit1 (); 137 + Hashtbl.replace visited2 commit2 (); 138 + let result = ref None in 139 + while 140 + Option.is_none !result 141 + && not (Queue.is_empty queue1 && Queue.is_empty queue2) 142 + do 143 + (match expand_bfs repo queue1 visited1 visited2 with 144 + | Some h -> result := Some h 145 + | None -> ()); 146 + if Option.is_none !result then 147 + match expand_bfs repo queue2 visited2 visited1 with 148 + | Some h -> result := Some h 149 + | None -> () 150 + done; 151 + !result 152 + 153 + let count_commits_between repo ~base ~head = 154 + (* Count commits reachable from head but not from base *) 155 + if Hash.equal base head then 0 156 + else 157 + let base_ancestors = Hashtbl.create 256 in 158 + (* First, collect all ancestors of base *) 159 + let queue = Queue.create () in 160 + Queue.push base queue; 161 + while not (Queue.is_empty queue) do 162 + let h = Queue.pop queue in 163 + if not (Hashtbl.mem base_ancestors h) then begin 164 + Hashtbl.replace base_ancestors h (); 165 + match Repository.read repo h with 166 + | Ok (Value.Commit commit) -> 167 + List.iter (fun p -> Queue.push p queue) (Commit.parents commit) 168 + | _ -> () 169 + end 170 + done; 171 + (* Then count commits from head not in base_ancestors *) 172 + let count = ref 0 in 173 + let visited = Hashtbl.create 256 in 174 + Queue.push head queue; 175 + while not (Queue.is_empty queue) do 176 + let h = Queue.pop queue in 177 + if (not (Hashtbl.mem visited h)) && not (Hashtbl.mem base_ancestors h) 178 + then begin 179 + Hashtbl.replace visited h (); 180 + incr count; 181 + match Repository.read repo h with 182 + | Ok (Value.Commit commit) -> 183 + List.iter (fun p -> Queue.push p queue) (Commit.parents commit) 184 + | _ -> () 185 + end 186 + done; 187 + !count 188 + 189 + type ahead_behind = { ahead : int; behind : int } 190 + 191 + let ahead_behind repo ~local ~remote = 192 + (* Find how many commits local is ahead/behind remote *) 193 + match merge_base repo local remote with 194 + | None -> { ahead = 0; behind = 0 } (* No common ancestor *) 195 + | Some base -> 196 + let ahead = count_commits_between repo ~base ~head:local in 197 + let behind = count_commits_between repo ~base ~head:remote in 198 + { ahead; behind }
+68
lib/rev_list.mli
··· 1 + (* Copyright (c) 2024-2026 Thomas Gazagnaire <thomas@gazagnaire.org> 2 + 3 + Permission to use, copy, modify, and distribute this software for any 4 + purpose with or without fee is hereby granted, provided that the above 5 + copyright notice and this permission notice appear in all copies. 6 + 7 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. *) 14 + 15 + (** Commit graph traversal and topological ordering. 16 + 17 + Implements the equivalent of [git rev-list --topo-order --reverse] by 18 + reading commit objects directly from the repository. 19 + 20 + {b Complexity.} Let [n] be the number of commits reachable from [head] (not 21 + stopped by the predicate), [e] the total number of parent edges (bounded by 22 + [k * n] where [k] is the max parents per commit, typically [k <= 2]). 23 + 24 + - {b BFS traversal:} [O(n)] commit reads, [O(n)] hash table operations. 25 + - {b Topological sort:} [O(n + e)] via Kahn's algorithm. 26 + - {b Space:} [O(n + e)] for the in-memory graph. *) 27 + 28 + type commit_info = { 29 + hash : Hash.t; (** The commit hash. *) 30 + parents : Hash.t list; (** Parent commit hashes. *) 31 + } 32 + (** A commit with its parent references. *) 33 + 34 + val topo_sort_reverse : 35 + Repository.t -> 36 + Hash.t -> 37 + stop:(Hash.t -> bool) -> 38 + (commit_info list, [ `Msg of string ]) result 39 + (** [topo_sort_reverse repo head ~stop] returns commits reachable from [head] in 40 + reverse topological order (ancestors first, [head] last). 41 + 42 + Traversal stops at commits where [stop hash] returns true; those commits are 43 + not included in the result. This allows skipping already-processed history 44 + when a cache is available. 45 + 46 + {b Time:} [O(n + e)] where [n] is the number of visited commits and [e] the 47 + number of parent edges. {b Space:} [O(n + e)]. *) 48 + 49 + (** {1 Commit graph queries} *) 50 + 51 + val is_ancestor : Repository.t -> ancestor:Hash.t -> descendant:Hash.t -> bool 52 + (** [is_ancestor repo ~ancestor ~descendant] returns true if [ancestor] is 53 + reachable from [descendant] by following parent links. *) 54 + 55 + val merge_base : Repository.t -> Hash.t -> Hash.t -> Hash.t option 56 + (** [merge_base repo commit1 commit2] finds the best common ancestor of two 57 + commits using bidirectional BFS. Returns [None] if no common ancestor. *) 58 + 59 + val count_commits_between : Repository.t -> base:Hash.t -> head:Hash.t -> int 60 + (** [count_commits_between repo ~base ~head] returns the number of commits 61 + reachable from [head] but not from [base]. *) 62 + 63 + type ahead_behind = { ahead : int; behind : int } 64 + (** Commit count comparison between local and remote refs. *) 65 + 66 + val ahead_behind : Repository.t -> local:Hash.t -> remote:Hash.t -> ahead_behind 67 + (** [ahead_behind repo ~local ~remote] computes how many commits [local] is 68 + ahead/behind [remote]. *)
+611
lib/subtree.ml
··· 1 + (* Copyright (c) 2024-2026 Thomas Gazagnaire <thomas@gazagnaire.org> 2 + 3 + Permission to use, copy, modify, and distribute this software for any 4 + purpose with or without fee is hereby granted, provided that the above 5 + copyright notice and this permission notice appear in all copies. 6 + 7 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. *) 14 + 15 + let src = Logs.Src.create "git.subtree" ~doc:"Git subtree operations" 16 + 17 + module L = (val Logs.src_log src : Logs.LOG) 18 + 19 + (** {1 Persistent cache} *) 20 + 21 + module Cache = struct 22 + type t = { tbl : (Hash.t, Hash.t) Hashtbl.t } 23 + 24 + let empty () = { tbl = Hashtbl.create 256 } 25 + let find t h = Hashtbl.find_opt t.tbl h 26 + let add t old_hash new_hash = Hashtbl.replace t.tbl old_hash new_hash 27 + let mem t h = Hashtbl.mem t.tbl h 28 + 29 + let cache_dir_name prefix = 30 + (* Normalize prefix to a flat filename for cache storage. *) 31 + let s = String.map (fun c -> if c = '/' then '_' else c) prefix in 32 + (* Avoid "." as a filename (would conflict with the directory itself). *) 33 + if s = "." || s = ".." then "_root_" else s 34 + 35 + let load repo ~prefix = 36 + let t = empty () in 37 + let git_dir = Fpath.to_string (Repository.git_dir repo) in 38 + let fs = Repository.fs repo in 39 + let dir = cache_dir_name prefix in 40 + let path = Eio.Path.(fs / git_dir / "subtree-cache" / dir) in 41 + (try 42 + let data = Eio.Path.load path in 43 + let lines = String.split_on_char '\n' data in 44 + List.iter 45 + (fun line -> 46 + if String.length line >= 81 then begin 47 + (* Format: <40-hex-old> <40-hex-new> *) 48 + let old_hex = String.sub line 0 40 in 49 + let new_hex = String.sub line 41 40 in 50 + Hashtbl.replace t.tbl (Hash.of_hex old_hex) (Hash.of_hex new_hex) 51 + end) 52 + lines 53 + with Eio.Io _ | Invalid_argument _ -> ()); 54 + t 55 + 56 + let save repo ~prefix t = 57 + let git_dir = Fpath.to_string (Repository.git_dir repo) in 58 + let fs = Repository.fs repo in 59 + let dir = cache_dir_name prefix in 60 + let cache_dir = Eio.Path.(fs / git_dir / "subtree-cache") in 61 + (try Eio.Path.mkdir ~perm:0o755 cache_dir with Eio.Io _ -> ()); 62 + let path = Eio.Path.(cache_dir / dir) in 63 + let buf = Buffer.create (Hashtbl.length t.tbl * 82) in 64 + Hashtbl.iter 65 + (fun old_hash new_hash -> 66 + Buffer.add_string buf (Hash.to_hex old_hash); 67 + Buffer.add_char buf ' '; 68 + Buffer.add_string buf (Hash.to_hex new_hash); 69 + Buffer.add_char buf '\n') 70 + t.tbl; 71 + Eio.Path.save ~create:(`Or_truncate 0o644) path (Buffer.contents buf) 72 + 73 + let iter t f = Hashtbl.iter f t.tbl 74 + let length t = Hashtbl.length t.tbl 75 + 76 + let clear repo ~prefix = 77 + let git_dir = Fpath.to_string (Repository.git_dir repo) in 78 + let fs = Repository.fs repo in 79 + let dir = cache_dir_name prefix in 80 + let path = Eio.Path.(fs / git_dir / "subtree-cache" / dir) in 81 + try Eio.Path.unlink path with Eio.Io _ -> () 82 + end 83 + 84 + (** {1 Tree operations} *) 85 + 86 + let tree_at_prefix repo tree_hash prefix = 87 + (* Split prefix into path segments. O(d) where d = depth. *) 88 + let segments = 89 + String.split_on_char '/' prefix |> List.filter (fun s -> s <> "") 90 + in 91 + (* Walk the tree path, reading one tree object per segment. 92 + Each Tree.find is O(e) where e = number of entries (linear scan). *) 93 + let rec walk hash = function 94 + | [] -> Some hash 95 + | seg :: rest -> ( 96 + match Repository.read repo hash with 97 + | Ok (Value.Tree tree) -> ( 98 + match Tree.find ~name:seg tree with 99 + | Some entry when entry.perm = `Dir -> walk entry.hash rest 100 + | _ -> None) 101 + | _ -> None) 102 + in 103 + walk tree_hash segments 104 + 105 + (** {1 Split} *) 106 + 107 + (** Extract a metadata value from commit message. Looks for "<key>: <value>" 108 + pattern in the message lines. *) 109 + let extract_metadata key message = 110 + match message with 111 + | None -> None 112 + | Some msg -> 113 + let prefix = key ^ ": " in 114 + let prefix_len = String.length prefix in 115 + let rec find_in_lines = function 116 + | [] -> None 117 + | line :: rest -> 118 + let line = String.trim line in 119 + if 120 + String.length line > prefix_len 121 + && String.sub line 0 prefix_len = prefix 122 + then 123 + Some 124 + (String.sub line prefix_len (String.length line - prefix_len)) 125 + else find_in_lines rest 126 + in 127 + find_in_lines (String.split_on_char '\n' msg) 128 + 129 + (** Extract subtree directory from commit message if present. *) 130 + let extract_subtree_dir message = extract_metadata "git-subtree-dir" message 131 + 132 + (** Check if subtree is unchanged from first parent (copy_or_skip optimization). 133 + For merge commits, skip if the subtree didn't change from the first parent - 134 + this filters out unrelated cross-package merges from the history. *) 135 + let should_skip_commit repo new_parents sub_hash = 136 + match new_parents with 137 + | first_parent :: _ -> ( 138 + match Repository.read repo first_parent with 139 + | Ok (Value.Commit pc) -> 140 + let dominated = Hash.equal sub_hash (Commit.tree pc) in 141 + if not dominated then 142 + L.debug (fun m -> 143 + m "not skipping: sub=%s parent_tree=%s" (Hash.to_hex sub_hash) 144 + (Hash.to_hex (Commit.tree pc))); 145 + dominated 146 + | Ok _ -> 147 + L.debug (fun m -> m "not skipping: not a commit"); 148 + false 149 + | Error _ -> 150 + L.debug (fun m -> m "not skipping: read error"); 151 + false) 152 + | [] -> false 153 + 154 + (** Process a single commit for split operation. *) 155 + let process_split_commit repo cache prefix { Rev_list.hash; parents } = 156 + match Repository.read repo hash with 157 + | Error _ -> Cache.add cache hash Hash.null 158 + | Ok (Value.Commit commit) -> ( 159 + let tree_hash = Commit.tree commit in 160 + match tree_at_prefix repo tree_hash prefix with 161 + | None -> Cache.add cache hash Hash.null 162 + | Some sub_hash -> 163 + (* Map all parents through cache *) 164 + let new_parents = 165 + List.filter_map 166 + (fun p -> 167 + match Cache.find cache p with 168 + | Some h when not (Hash.equal h Hash.null) -> Some h 169 + | _ -> None) 170 + parents 171 + in 172 + if should_skip_commit repo new_parents sub_hash then 173 + Cache.add cache hash (List.hd new_parents) 174 + else 175 + let new_commit = 176 + Commit.v ~tree:sub_hash ~author:(Commit.author commit) 177 + ~committer:(Commit.committer commit) ~parents:new_parents 178 + ~extra:(Commit.extra commit) (Commit.message commit) 179 + in 180 + Cache.add cache hash (Repository.write_commit repo new_commit)) 181 + | _ -> Cache.add cache hash Hash.null 182 + 183 + let split repo ~prefix ~head () = 184 + let cache = Cache.load repo ~prefix in 185 + match Cache.find cache head with 186 + | Some h -> Ok (if Hash.equal h Hash.null then None else Some h) 187 + | None -> ( 188 + match Rev_list.topo_sort_reverse repo head ~stop:(Cache.mem cache) with 189 + | Error e -> Error e 190 + | Ok commits -> 191 + List.iter (process_split_commit repo cache prefix) commits; 192 + Cache.save repo ~prefix cache; 193 + Ok 194 + (match Cache.find cache head with 195 + | Some h when Hash.equal h Hash.null -> None 196 + | other -> other)) 197 + 198 + type verify_error = { original : Hash.t; split : Hash.t; reason : string } 199 + 200 + let verify repo ~prefix () = 201 + let cache = Cache.load repo ~prefix in 202 + let errors = ref [] in 203 + let checked = ref 0 in 204 + Cache.iter cache (fun orig split -> 205 + incr checked; 206 + if not (Hash.equal split Hash.null) then 207 + match (Repository.read repo orig, Repository.read repo split) with 208 + | Ok (Value.Commit orig_c), Ok (Value.Commit split_c) -> ( 209 + (* Check tree matches subtree at prefix *) 210 + match tree_at_prefix repo (Commit.tree orig_c) prefix with 211 + | None -> 212 + errors := 213 + { 214 + original = orig; 215 + split; 216 + reason = "original has no subtree at prefix"; 217 + } 218 + :: !errors 219 + | Some expected_tree -> 220 + if not (Hash.equal expected_tree (Commit.tree split_c)) then begin 221 + let short h = String.sub (Hash.to_hex h) 0 7 in 222 + errors := 223 + { 224 + original = orig; 225 + split; 226 + reason = 227 + Fmt.str "tree mismatch: expected %s, got %s" 228 + (short expected_tree) 229 + (short (Commit.tree split_c)); 230 + } 231 + :: !errors 232 + end) 233 + | Error _, _ -> 234 + errors := 235 + { original = orig; split; reason = "cannot read original commit" } 236 + :: !errors 237 + | _, Error _ -> 238 + errors := 239 + { original = orig; split; reason = "cannot read split commit" } 240 + :: !errors 241 + | _ -> ()); 242 + (!checked, List.rev !errors) 243 + 244 + (** {1 Add} *) 245 + 246 + let insert_tree_at_prefix repo base_tree_hash prefix subtree_hash = 247 + (* Split prefix into path segments. *) 248 + let segments = 249 + String.split_on_char '/' prefix |> List.filter (fun s -> s <> "") 250 + in 251 + (* Recursively build trees from the deepest level up. 252 + For each level, we need to either modify an existing tree or create a new one. *) 253 + let rec build_trees current_tree_hash = function 254 + | [] -> 255 + (* No more segments - replace with subtree *) 256 + Ok subtree_hash 257 + | [ name ] -> ( 258 + (* Last segment - insert subtree here *) 259 + match Repository.read repo current_tree_hash with 260 + | Error e -> Error e 261 + | Ok (Value.Tree tree) -> 262 + let new_tree = 263 + tree |> Tree.remove ~name 264 + |> Tree.add (Tree.entry ~perm:`Dir ~name subtree_hash) 265 + in 266 + Ok (Repository.write_tree repo new_tree) 267 + | _ -> Error (`Msg "Expected tree object")) 268 + | name :: rest -> ( 269 + (* Intermediate segment - descend or create *) 270 + match Repository.read repo current_tree_hash with 271 + | Error e -> Error e 272 + | Ok (Value.Tree tree) -> ( 273 + let existing_entry = Tree.find ~name tree in 274 + let child_hash = 275 + match existing_entry with 276 + | Some entry when entry.perm = `Dir -> entry.hash 277 + | _ -> 278 + (* No existing dir or not a dir - use empty tree *) 279 + Repository.write_tree repo Tree.empty 280 + in 281 + match build_trees child_hash rest with 282 + | Error e -> Error e 283 + | Ok new_child_hash -> 284 + let new_tree = 285 + tree |> Tree.remove ~name 286 + |> Tree.add (Tree.entry ~perm:`Dir ~name new_child_hash) 287 + in 288 + Ok (Repository.write_tree repo new_tree)) 289 + | _ -> Error (`Msg "Expected tree object")) 290 + in 291 + match segments with 292 + | [] -> 293 + (* Empty prefix means replace root tree entirely *) 294 + Ok subtree_hash 295 + | _ -> build_trees base_tree_hash segments 296 + 297 + (** Build a nested tree structure from a list of path segments. *) 298 + let build_nested_tree repo remote_tree segments = 299 + let rec build = function 300 + | [] -> remote_tree 301 + | [ name ] -> 302 + Repository.write_tree repo 303 + (Tree.v [ Tree.entry ~perm:`Dir ~name remote_tree ]) 304 + | name :: rest -> 305 + let child = build rest in 306 + Repository.write_tree repo 307 + (Tree.v [ Tree.entry ~perm:`Dir ~name child ]) 308 + in 309 + build segments 310 + 311 + (** Default message for add/merge operations. *) 312 + let default_add_message op prefix commit = 313 + Fmt.str "%s '%s' from commit %s\n" op prefix (Hash.to_hex commit) 314 + 315 + let add repo ~prefix ~commit ~author ~committer ?message () = 316 + match Repository.read repo commit with 317 + | Error e -> Error e 318 + | Ok (Value.Commit remote_commit) -> ( 319 + let remote_tree = Commit.tree remote_commit in 320 + let msg = 321 + Option.value message ~default:(default_add_message "Add" prefix commit) 322 + in 323 + match Repository.head repo with 324 + | None -> 325 + let segments = 326 + String.split_on_char '/' prefix |> List.filter (( <> ) "") 327 + in 328 + let root_tree = build_nested_tree repo remote_tree segments in 329 + let new_commit = 330 + Commit.v ~tree:root_tree ~author ~committer ~parents:[ commit ] 331 + (Some msg) 332 + in 333 + let new_hash = Repository.write_commit repo new_commit in 334 + Repository.advance_head repo new_hash; 335 + Ok new_hash 336 + | Some head_hash -> ( 337 + match Repository.read repo head_hash with 338 + | Error e -> Error e 339 + | Ok (Value.Commit head_commit) -> ( 340 + match 341 + insert_tree_at_prefix repo (Commit.tree head_commit) prefix 342 + remote_tree 343 + with 344 + | Error e -> Error e 345 + | Ok new_tree -> 346 + let new_commit = 347 + Commit.v ~tree:new_tree ~author ~committer 348 + ~parents:[ head_hash; commit ] (Some msg) 349 + in 350 + let new_hash = Repository.write_commit repo new_commit in 351 + Repository.advance_head repo new_hash; 352 + Ok new_hash) 353 + | _ -> Error (`Msg "HEAD does not point to a commit"))) 354 + | _ -> Error (`Msg "Not a commit object") 355 + 356 + let merge repo ~prefix ~commit ~author ~committer ?message () = 357 + (* Get the tree from the commit we're merging *) 358 + match Repository.read repo commit with 359 + | Error e -> Error e 360 + | Ok (Value.Commit remote_commit) -> ( 361 + let remote_tree = Commit.tree remote_commit in 362 + (* Get current HEAD *) 363 + match Repository.head repo with 364 + | None -> Error (`Msg "No HEAD - use add for initial subtree") 365 + | Some head_hash -> ( 366 + match Repository.read repo head_hash with 367 + | Error e -> Error e 368 + | Ok (Value.Commit head_commit) -> ( 369 + let base_tree = Commit.tree head_commit in 370 + (* Check that subtree exists at prefix *) 371 + match tree_at_prefix repo base_tree prefix with 372 + | None -> Error (`Msg ("Subtree not found at prefix: " ^ prefix)) 373 + | Some _ -> ( 374 + (* Replace the subtree at prefix with the remote tree *) 375 + match 376 + insert_tree_at_prefix repo base_tree prefix remote_tree 377 + with 378 + | Error e -> Error e 379 + | Ok new_tree -> 380 + let message = 381 + match message with 382 + | Some m -> m 383 + | None -> 384 + Fmt.str "Merge '%s' from commit %s\n" prefix 385 + (Hash.to_hex commit) 386 + in 387 + (* Create merge commit with two parents *) 388 + let new_commit = 389 + Commit.v ~tree:new_tree ~author ~committer 390 + ~parents:[ head_hash; commit ] (Some message) 391 + in 392 + let new_hash = Repository.write_commit repo new_commit in 393 + Repository.advance_head repo new_hash; 394 + Ok new_hash)) 395 + | _ -> Error (`Msg "HEAD does not point to a commit"))) 396 + | _ -> Error (`Msg "Not a commit object") 397 + 398 + (** {1 Check and Fix} *) 399 + 400 + type issue = { commit : Hash.t; message : string; subtree_dir : string option } 401 + 402 + (** Check if a commit message indicates a subtree merge for a different package. 403 + *) 404 + let is_unrelated_merge ~prefix message = 405 + match extract_subtree_dir message with 406 + | None -> None (* Not a subtree merge *) 407 + | Some dir -> 408 + if 409 + String.equal dir prefix 410 + || String.starts_with ~prefix:(prefix ^ "/") dir 411 + || String.starts_with ~prefix:(dir ^ "/") prefix 412 + then None (* Related to our prefix *) 413 + else Some dir 414 + 415 + let check repo ~prefix ~head () = 416 + let issues = ref [] in 417 + let checked = ref 0 in 418 + (* Walk the commit history *) 419 + let rec walk visited hash = 420 + if Hash.equal hash Hash.null || Hashtbl.mem visited hash then () 421 + else begin 422 + Hashtbl.add visited hash (); 423 + match Repository.read repo hash with 424 + | Ok (Value.Commit commit) -> 425 + incr checked; 426 + let message = Commit.message commit in 427 + (* Check if this is an unrelated subtree merge *) 428 + (match message with 429 + | Some msg -> ( 430 + match is_unrelated_merge ~prefix message with 431 + | Some dir -> 432 + issues := 433 + { commit = hash; message = msg; subtree_dir = Some dir } 434 + :: !issues 435 + | None -> ()) 436 + | None -> ()); 437 + (* Continue walking parents *) 438 + List.iter (walk visited) (Commit.parents commit) 439 + | _ -> () 440 + end 441 + in 442 + let visited = Hashtbl.create 1024 in 443 + walk visited head; 444 + (!checked, List.rev !issues) 445 + 446 + (** Process a single commit for fix rewriting. Determines if the commit is a 447 + self-merge, unrelated merge, or regular commit, and either skips it or 448 + rewrites it with remapped parents. *) 449 + let rewrite_commit repo ~prefix ~cache commit hash parents = 450 + let message = Commit.message commit in 451 + let tree = Commit.tree commit in 452 + (* Get remapped parents, filtering out null hashes *) 453 + let new_parents = 454 + List.filter_map 455 + (fun p -> 456 + match Hashtbl.find_opt cache p with 457 + | Some h when not (Hash.equal h Hash.null) -> Some h 458 + | _ -> None) 459 + parents 460 + in 461 + (* Check subtree merge type *) 462 + let subtree_dir = extract_subtree_dir message in 463 + let is_unrelated = 464 + match subtree_dir with 465 + | None -> false 466 + | Some dir -> 467 + not 468 + (String.equal dir prefix 469 + || String.starts_with ~prefix:(prefix ^ "/") dir 470 + || String.starts_with ~prefix:(dir ^ "/") prefix) 471 + in 472 + let is_self_merge = 473 + match subtree_dir with 474 + | None -> false 475 + | Some dir -> 476 + String.equal dir prefix 477 + || String.starts_with ~prefix:(prefix ^ "/") dir 478 + || String.starts_with ~prefix:(dir ^ "/") prefix 479 + in 480 + (* Determine action: 481 + - Self-merges: skip if tree unchanged from first parent (linearize) 482 + - Unrelated merges: skip if tree unchanged from first parent 483 + - Otherwise: keep the commit with remapped parents *) 484 + let action = 485 + if is_self_merge || is_unrelated then 486 + (* Skip if tree unchanged from first parent *) 487 + match new_parents with 488 + | first :: _ -> ( 489 + match Repository.read repo first with 490 + | Ok (Value.Commit pc) when Hash.equal tree (Commit.tree pc) -> 491 + `Skip_to first 492 + | _ -> `Keep) 493 + | [] -> `Keep 494 + else `Keep 495 + in 496 + match action with 497 + | `Skip_to parent -> Hashtbl.add cache hash parent 498 + | `Keep -> 499 + let new_commit = 500 + Commit.v ~tree ~author:(Commit.author commit) 501 + ~committer:(Commit.committer commit) ~parents:new_parents 502 + ~extra:(Commit.extra commit) message 503 + in 504 + Hashtbl.add cache hash (Repository.write_commit repo new_commit) 505 + 506 + let fix repo ~prefix ~head () = 507 + (* Rewrite history, removing subtree merge commits: 508 + 1. Unrelated merges (git-subtree-dir for a different prefix) - skip if tree 509 + unchanged from first parent 510 + 2. Self-merges (git-subtree-dir matches our prefix) - follow mainline parent 511 + to linearize history *) 512 + let cache = Hashtbl.create 1024 in 513 + (* Process commits in reverse topological order *) 514 + match Rev_list.topo_sort_reverse repo head ~stop:(fun _ -> false) with 515 + | Error e -> Error e 516 + | Ok commits -> 517 + List.iter 518 + (fun { Rev_list.hash; parents } -> 519 + match Repository.read repo hash with 520 + | Ok (Value.Commit commit) -> 521 + rewrite_commit repo ~prefix ~cache commit hash parents 522 + | Ok (Value.Blob _ | Value.Tree _ | Value.Tag _) -> 523 + Hashtbl.add cache hash Hash.null 524 + | Error _ -> Hashtbl.add cache hash Hash.null) 525 + commits; 526 + Ok (Hashtbl.find_opt cache head) 527 + 528 + type mono_issue = { 529 + mono_commit : Hash.t; 530 + mono_message : string; 531 + is_empty : bool; 532 + } 533 + 534 + let check_mono repo ~head () = 535 + match Rev_list.topo_sort_reverse repo head ~stop:(fun _ -> false) with 536 + | Error _ -> (0, []) 537 + | Ok commits -> 538 + let issues = ref [] in 539 + let count = ref 0 in 540 + List.iter 541 + (fun { Rev_list.hash; parents } -> 542 + incr count; 543 + match Repository.read repo hash with 544 + | Ok (Value.Commit commit) -> 545 + let message = Option.value ~default:"" (Commit.message commit) in 546 + let tree = Commit.tree commit in 547 + let is_empty = 548 + match parents with 549 + | first :: _ -> ( 550 + match Repository.read repo first with 551 + | Ok (Value.Commit pc) -> Hash.equal tree (Commit.tree pc) 552 + | _ -> false) 553 + | [] -> false 554 + in 555 + if is_empty then 556 + issues := 557 + { mono_commit = hash; mono_message = message; is_empty } 558 + :: !issues 559 + | _ -> ()) 560 + commits; 561 + (!count, List.rev !issues) 562 + 563 + let fix_mono repo ~head () = 564 + (* Rewrite history, removing all empty commits. *) 565 + let cache = Hashtbl.create 1024 in 566 + match Rev_list.topo_sort_reverse repo head ~stop:(fun _ -> false) with 567 + | Error e -> Error e 568 + | Ok commits -> 569 + List.iter 570 + (fun { Rev_list.hash; parents } -> 571 + match Repository.read repo hash with 572 + | Ok (Value.Commit commit) -> 573 + let message = Commit.message commit in 574 + let tree = Commit.tree commit in 575 + (* Get remapped parents, filtering out null hashes *) 576 + let new_parents = 577 + List.filter_map 578 + (fun p -> 579 + match Hashtbl.find_opt cache p with 580 + | Some h when not (Hash.equal h Hash.null) -> Some h 581 + | None -> Some p (* Parent not in cache, keep original *) 582 + | _ -> None) 583 + parents 584 + in 585 + (* Check if this is an empty commit (tree unchanged from first parent) *) 586 + let is_empty = 587 + match new_parents with 588 + | first :: _ -> ( 589 + match Repository.read repo first with 590 + | Ok (Value.Commit pc) -> Hash.equal tree (Commit.tree pc) 591 + | _ -> false) 592 + | [] -> false 593 + in 594 + if is_empty then 595 + (* Skip to first parent *) 596 + match new_parents with 597 + | first :: _ -> Hashtbl.add cache hash first 598 + | [] -> Hashtbl.add cache hash Hash.null 599 + else 600 + (* Keep the commit with remapped parents *) 601 + let new_commit = 602 + Commit.v ~tree ~author:(Commit.author commit) 603 + ~committer:(Commit.committer commit) ~parents:new_parents 604 + ~extra:(Commit.extra commit) message 605 + in 606 + Hashtbl.add cache hash (Repository.write_commit repo new_commit) 607 + | Ok (Value.Blob _ | Value.Tree _ | Value.Tag _) -> 608 + Hashtbl.add cache hash Hash.null 609 + | Error _ -> Hashtbl.add cache hash Hash.null) 610 + commits; 611 + Ok (Hashtbl.find_opt cache head)
+244
lib/subtree.mli
··· 1 + (* Copyright (c) 2024-2026 Thomas Gazagnaire <thomas@gazagnaire.org> 2 + 3 + Permission to use, copy, modify, and distribute this software for any 4 + purpose with or without fee is hereby granted, provided that the above 5 + copyright notice and this permission notice appear in all copies. 6 + 7 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. *) 14 + 15 + (** Fast subtree split for git repositories. 16 + 17 + Implements the [git subtree split] algorithm natively in OCaml, avoiding the 18 + per-commit subprocess overhead of the shell script implementation. Uses 19 + persistent caching for incremental performance. 20 + 21 + {b Complexity analysis.} Let [n] be the number of commits reachable from 22 + HEAD, [c] the number of commits already in the cache, and [d] the depth of 23 + the prefix path (number of [/]-separated segments). Let [k] be the maximum 24 + number of parents per commit (usually [k <= 2]). 25 + 26 + - {b First run:} [O((n - c) * (d + k))] object reads, [O((n - c) * k)] hash 27 + comparisons, [O(n - c)] commit writes (worst case). 28 + - {b Cached run:} [O(1)] — cache lookup for HEAD returns immediately. 29 + - {b Topological sort:} [O(n - c)] using Kahn's algorithm with BFS 30 + traversal. 31 + - {b Cache I/O:} [O(n)] for load/save (linear scan of the cache file). 32 + - {b Space:} [O(n)] for the commit graph and cache in memory. *) 33 + 34 + (** {1 Persistent cache} *) 35 + 36 + module Cache : sig 37 + type t 38 + (** A cache mapping original commit hashes to their split counterparts. 39 + 40 + Invariant: if [find t h = Some h'], then [h'] is the split commit for [h] 41 + and the entire ancestry of [h] is also cached. The sentinel value 42 + [Hash.null] means "this commit has no content at the prefix". *) 43 + 44 + val empty : unit -> t 45 + (** [empty ()] creates an empty cache. [O(1)]. *) 46 + 47 + val load : Repository.t -> prefix:string -> t 48 + (** [load repo ~prefix] loads the cache for [prefix] from 49 + [.git/subtree-cache/<prefix>]. Returns an empty cache if the file does not 50 + exist. [O(n)] where [n] is the number of cached entries. *) 51 + 52 + val save : Repository.t -> prefix:string -> t -> unit 53 + (** [save repo ~prefix t] persists the cache to disk. [O(n)]. *) 54 + 55 + val find : t -> Hash.t -> Hash.t option 56 + (** [find t h] returns the cached split hash for [h], or [None]. [O(1)] 57 + amortized (hash table lookup). *) 58 + 59 + val add : t -> Hash.t -> Hash.t -> unit 60 + (** [add t old_hash new_hash] records a mapping. [O(1)] amortized. *) 61 + 62 + val mem : t -> Hash.t -> bool 63 + (** [mem t h] returns [true] if [h] has a cached mapping. [O(1)] amortized. *) 64 + 65 + val iter : t -> (Hash.t -> Hash.t -> unit) -> unit 66 + (** [iter t f] applies [f] to each mapping in the cache. *) 67 + 68 + val length : t -> int 69 + (** [length t] returns the number of entries in the cache. *) 70 + 71 + val clear : Repository.t -> prefix:string -> unit 72 + (** [clear repo ~prefix] removes the cache file for [prefix]. *) 73 + end 74 + 75 + (** {1 Tree operations} *) 76 + 77 + val tree_at_prefix : Repository.t -> Hash.t -> string -> Hash.t option 78 + (** [tree_at_prefix repo tree_hash prefix] resolves the tree object at [prefix] 79 + within the tree [tree_hash]. The prefix is [/]-separated (e.g., 80 + ["src/lib"]). 81 + 82 + [O(d)] object reads where [d] is the depth of the prefix path. Each read is 83 + [O(e)] where [e] is the number of entries in the tree (binary search within 84 + the sorted entry list). *) 85 + 86 + (** {1 Split} *) 87 + 88 + val split : 89 + Repository.t -> 90 + prefix:string -> 91 + head:Hash.t -> 92 + unit -> 93 + (Hash.t option, [ `Msg of string ]) result 94 + (** [split repo ~prefix ~head ()] extracts the history of [prefix] into a 95 + standalone commit chain. Each commit that touches [prefix] is rewritten so 96 + that the subtree at [prefix] becomes the root tree, preserving author, 97 + committer, message, and extra headers. 98 + 99 + Returns [Ok (Some hash)] with the new head, or [Ok None] if no commit 100 + touches the prefix. 101 + 102 + Uses a persistent cache stored in [.git/subtree-cache/<prefix>]: 103 + - On a full cache hit (HEAD already cached): [O(1)]. 104 + - On a cold cache: [O(n * (d + k))] where [n] = commit count, [d] = prefix 105 + depth, [k] = max parents per commit. 106 + - Incremental (m new commits since last split): [O(m * (d + k))]. 107 + 108 + The resulting commit hashes are compatible with [git subtree split]: the 109 + same tree objects are reused, and commit metadata is preserved verbatim, so 110 + identical inputs produce identical SHA-1s. *) 111 + 112 + type verify_error = { 113 + original : Hash.t; (** The original monorepo commit. *) 114 + split : Hash.t; (** The split commit in the cache. *) 115 + reason : string; (** Description of the validation failure. *) 116 + } 117 + (** Verification error details. *) 118 + 119 + val verify : Repository.t -> prefix:string -> unit -> int * verify_error list 120 + (** [verify repo ~prefix ()] validates the cache for [prefix]. 121 + 122 + Returns [(checked, errors)] where [checked] is the number of cache entries 123 + examined and [errors] contains any validation failures found. 124 + 125 + Checks performed: 126 + - Split commit tree matches the subtree at [prefix] in the original 127 + - Subtree merge commits include the mainline as a parent 128 + 129 + This is a lightweight O(n) check where n is the cache size. *) 130 + 131 + (** {1 Add} *) 132 + 133 + val add : 134 + Repository.t -> 135 + prefix:string -> 136 + commit:Hash.t -> 137 + author:User.t -> 138 + committer:User.t -> 139 + ?message:string -> 140 + unit -> 141 + (Hash.t, [ `Msg of string ]) result 142 + (** [add repo ~prefix ~commit ~author ~committer ?message ()] incorporates the 143 + tree from [commit] at [prefix] in the current repository, creating a merge 144 + commit. 145 + 146 + If [message] is not provided, a default message is generated. 147 + 148 + The resulting commit has two parents: the current HEAD and [commit]. This 149 + preserves the history of the added subtree for future [split] operations. 150 + 151 + Returns [Ok hash] with the new commit hash, or [Error] if something goes 152 + wrong (e.g., [commit] is not a valid commit object). 153 + 154 + If the repository has no HEAD (empty repository), creates an initial commit 155 + with the subtree at [prefix] and [commit] as the sole parent. *) 156 + 157 + val merge : 158 + Repository.t -> 159 + prefix:string -> 160 + commit:Hash.t -> 161 + author:User.t -> 162 + committer:User.t -> 163 + ?message:string -> 164 + unit -> 165 + (Hash.t, [ `Msg of string ]) result 166 + (** [merge repo ~prefix ~commit ~author ~committer ?message ()] merges updates 167 + from [commit] into an existing subtree at [prefix]. 168 + 169 + This is the native equivalent of [git subtree pull]. The subtree at [prefix] 170 + is replaced with the tree from [commit], and a merge commit is created with 171 + the current HEAD and [commit] as parents. 172 + 173 + Returns [Error] if the subtree doesn't exist at [prefix] (use [add] for new 174 + subtrees). *) 175 + 176 + (** {1 Check and Fix} *) 177 + 178 + type issue = { 179 + commit : Hash.t; (** The problematic commit. *) 180 + message : string; (** The commit message. *) 181 + subtree_dir : string option; (** The git-subtree-dir if present. *) 182 + } 183 + (** Issue found during history check. *) 184 + 185 + val check : 186 + Repository.t -> prefix:string -> head:Hash.t -> unit -> int * issue list 187 + (** [check repo ~prefix ~head ()] scans the commit history reachable from [head] 188 + and detects unrelated subtree merge commits. 189 + 190 + A commit is considered unrelated if it has a [git-subtree-dir] metadata that 191 + doesn't match [prefix]. 192 + 193 + Returns [(checked, issues)] where [checked] is the number of commits 194 + examined and [issues] contains the problematic commits. *) 195 + 196 + val fix : 197 + Repository.t -> 198 + prefix:string -> 199 + head:Hash.t -> 200 + unit -> 201 + (Hash.t option, [ `Msg of string ]) result 202 + (** [fix repo ~prefix ~head ()] rewrites the commit history reachable from 203 + [head], removing unrelated subtree merge commits. 204 + 205 + A commit is removed if: 206 + - It has a [git-subtree-dir] metadata for a different prefix 207 + - Its tree is unchanged from its first parent (no actual changes) 208 + 209 + Returns [Ok (Some new_head)] with the rewritten head, or [Ok None] if the 210 + history is empty after filtering. *) 211 + 212 + (** {1 Empty commit cleanup} *) 213 + 214 + type mono_issue = { 215 + mono_commit : Hash.t; (** The empty commit. *) 216 + mono_message : string; (** The commit message. *) 217 + is_empty : bool; (** Always true (kept for consistency). *) 218 + } 219 + (** Empty commit found during history check. *) 220 + 221 + val check_mono : Repository.t -> head:Hash.t -> unit -> int * mono_issue list 222 + (** [check_mono repo ~head ()] scans the commit history reachable from [head] 223 + and detects empty commits. 224 + 225 + A commit is considered empty if its tree is identical to its first parent's 226 + tree (no actual file changes). 227 + 228 + Returns [(checked, issues)] where [checked] is the number of commits 229 + examined and [issues] contains the empty commits. *) 230 + 231 + val fix_mono : 232 + Repository.t -> 233 + head:Hash.t -> 234 + unit -> 235 + (Hash.t option, [ `Msg of string ]) result 236 + (** [fix_mono repo ~head ()] rewrites the commit history reachable from [head], 237 + removing all empty commits. 238 + 239 + A commit is removed if its tree is unchanged from its first parent (no 240 + actual file changes). The commit is replaced by its first parent in the 241 + history. 242 + 243 + Returns [Ok (Some new_head)] with the rewritten head, or [Ok None] if the 244 + history is empty after filtering. *)
+171
lib/tag.ml
··· 1 + (* Copyright (c) 2013-2017 Thomas Gazagnaire <thomas@gazagnaire.org> 2 + Copyright (c) 2017-2024 Romain Calascibetta <romain.calascibetta@gmail.com> 3 + Copyright (c) 2024-2026 Thomas Gazagnaire <thomas@gazagnaire.org> 4 + 5 + Permission to use, copy, modify, and distribute this software for any 6 + purpose with or without fee is hereby granted, provided that the above 7 + copyright notice and this permission notice appear in all copies. 8 + 9 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. *) 16 + 17 + (** Git tag objects. *) 18 + 19 + type kind = Blob | Commit | Tag | Tree 20 + 21 + let kind_to_string = function 22 + | Blob -> "blob" 23 + | Commit -> "commit" 24 + | Tag -> "tag" 25 + | Tree -> "tree" 26 + 27 + let kind_of_string = function 28 + | "blob" -> Ok Blob 29 + | "commit" -> Ok Commit 30 + | "tag" -> Ok Tag 31 + | "tree" -> Ok Tree 32 + | s -> Error (`Msg ("Invalid tag kind: " ^ s)) 33 + 34 + let kind_of_string_exn s = 35 + match kind_of_string s with Ok k -> k | Error (`Msg m) -> invalid_arg m 36 + 37 + type t = { 38 + obj : Hash.t; 39 + kind : kind; 40 + tag : string; 41 + tagger : User.t option; 42 + message : string option; 43 + } 44 + 45 + let v obj kind ?tagger ~tag message = { obj; kind; tag; tagger; message } 46 + let obj t = t.obj 47 + let kind t = t.kind 48 + let name t = t.tag 49 + let tagger t = t.tagger 50 + let message t = t.message 51 + 52 + let pp ppf t = 53 + Fmt.pf ppf "@[<v>object %a@," Hash.pp t.obj; 54 + Fmt.pf ppf "type %s@," (kind_to_string t.kind); 55 + Fmt.pf ppf "tag %s@," t.tag; 56 + (match t.tagger with 57 + | Some tagger -> Fmt.pf ppf "tagger %a@," User.pp tagger 58 + | None -> ()); 59 + (match t.message with Some msg -> Fmt.pf ppf "@,%s" msg | None -> ()); 60 + Fmt.pf ppf "@]" 61 + 62 + let equal a b = 63 + Hash.equal a.obj b.obj && a.kind = b.kind && String.equal a.tag b.tag 64 + 65 + let compare a b = 66 + match Hash.compare a.obj b.obj with 67 + | 0 -> ( 68 + match Stdlib.compare a.kind b.kind with 69 + | 0 -> String.compare a.tag b.tag 70 + | n -> n) 71 + | n -> n 72 + 73 + let hash t = Hashtbl.hash t 74 + 75 + (** Encode tag to git format. *) 76 + let to_string t = 77 + let buf = Buffer.create 256 in 78 + Buffer.add_string buf "object "; 79 + Buffer.add_string buf (Hash.to_hex t.obj); 80 + Buffer.add_char buf '\n'; 81 + Buffer.add_string buf "type "; 82 + Buffer.add_string buf (kind_to_string t.kind); 83 + Buffer.add_char buf '\n'; 84 + Buffer.add_string buf "tag "; 85 + Buffer.add_string buf t.tag; 86 + Buffer.add_char buf '\n'; 87 + (match t.tagger with 88 + | Some tagger -> 89 + Buffer.add_string buf "tagger "; 90 + Buffer.add_string buf (User.to_string tagger); 91 + Buffer.add_char buf '\n' 92 + | None -> ()); 93 + (match t.message with 94 + | Some msg -> 95 + Buffer.add_char buf '\n'; 96 + Buffer.add_string buf msg 97 + | None -> ()); 98 + Buffer.contents buf 99 + 100 + (** Parse a single header line. *) 101 + let parse_header line = 102 + match String.index_opt line ' ' with 103 + | None -> None 104 + | Some pos -> 105 + let key = String.sub line 0 pos in 106 + let value = String.sub line (pos + 1) (String.length line - pos - 1) in 107 + Some (key, value) 108 + 109 + (** Parse tag from git format. *) 110 + let of_string s = 111 + let lines = String.split_on_char '\n' s in 112 + let rec parse_headers obj kind tag tagger = function 113 + | [] -> ( 114 + (* No message *) 115 + match (obj, kind, tag) with 116 + | Some obj, Some kind, Some tag -> 117 + Ok { obj; kind; tag; tagger; message = None } 118 + | None, _, _ -> Error (`Msg "Missing object in tag") 119 + | _, None, _ -> Error (`Msg "Missing type in tag") 120 + | _, _, None -> Error (`Msg "Missing tag name in tag")) 121 + | "" :: rest -> ( 122 + (* Empty line marks start of message *) 123 + let message = 124 + match rest with [] -> None | _ -> Some (String.concat "\n" rest) 125 + in 126 + match (obj, kind, tag) with 127 + | Some obj, Some kind, Some tag -> 128 + Ok { obj; kind; tag; tagger; message } 129 + | None, _, _ -> Error (`Msg "Missing object in tag") 130 + | _, None, _ -> Error (`Msg "Missing type in tag") 131 + | _, _, None -> Error (`Msg "Missing tag name in tag")) 132 + | line :: rest -> ( 133 + match parse_header line with 134 + | None -> Error (`Msg ("Invalid header line: " ^ line)) 135 + | Some ("object", hex) -> 136 + let obj = Hash.of_hex hex in 137 + parse_headers (Some obj) kind tag tagger rest 138 + | Some ("type", type_str) -> ( 139 + match kind_of_string type_str with 140 + | Ok k -> parse_headers obj (Some k) tag tagger rest 141 + | Error _ as e -> e) 142 + | Some ("tag", tag_name) -> 143 + parse_headers obj kind (Some tag_name) tagger rest 144 + | Some ("tagger", user_str) -> ( 145 + match User.of_string user_str with 146 + | Ok user -> parse_headers obj kind tag (Some user) rest 147 + | Error _ as e -> e) 148 + | Some (_key, _value) -> 149 + (* Skip unknown headers *) 150 + parse_headers obj kind tag tagger rest) 151 + in 152 + parse_headers None None None None lines 153 + 154 + let of_string_exn s = 155 + match of_string s with Ok t -> t | Error (`Msg m) -> failwith m 156 + 157 + let digest t = 158 + let s = to_string t in 159 + Hash.digest_string ~kind:`Tag s 160 + 161 + module Set = Set.Make (struct 162 + type nonrec t = t 163 + 164 + let compare = compare 165 + end) 166 + 167 + module Map = Map.Make (struct 168 + type nonrec t = t 169 + 170 + let compare = compare 171 + end)
+80
lib/tag.mli
··· 1 + (* Copyright (c) 2013-2017 Thomas Gazagnaire <thomas@gazagnaire.org> 2 + Copyright (c) 2017-2024 Romain Calascibetta <romain.calascibetta@gmail.com> 3 + Copyright (c) 2024-2026 Thomas Gazagnaire <thomas@gazagnaire.org> 4 + 5 + Permission to use, copy, modify, and distribute this software for any 6 + purpose with or without fee is hereby granted, provided that the above 7 + copyright notice and this permission notice appear in all copies. 8 + 9 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. *) 16 + 17 + (** Git tag objects. *) 18 + 19 + type kind = 20 + | Blob 21 + | Commit 22 + | Tag 23 + | Tree (** The kind of object being tagged. *) 24 + 25 + val kind_to_string : kind -> string 26 + (** Convert kind to string. *) 27 + 28 + val kind_of_string : string -> (kind, [ `Msg of string ]) result 29 + (** Parse kind from string. *) 30 + 31 + val kind_of_string_exn : string -> kind 32 + (** [kind_of_string_exn s] is like {!kind_of_string} but raises on error. *) 33 + 34 + type t 35 + (** The type of tags. *) 36 + 37 + val v : Hash.t -> kind -> ?tagger:User.t -> tag:string -> string option -> t 38 + (** Create a tag. *) 39 + 40 + val obj : t -> Hash.t 41 + (** The tagged object hash. *) 42 + 43 + val kind : t -> kind 44 + (** The kind of tagged object. *) 45 + 46 + val name : t -> string 47 + (** The tag name. *) 48 + 49 + val tagger : t -> User.t option 50 + (** The tagger (may be absent). *) 51 + 52 + val message : t -> string option 53 + (** The tag message. *) 54 + 55 + val pp : t Fmt.t 56 + (** Pretty-print a tag. *) 57 + 58 + val equal : t -> t -> bool 59 + (** Equality on tags. *) 60 + 61 + val compare : t -> t -> int 62 + (** Total ordering on tags. *) 63 + 64 + val hash : t -> int 65 + (** Hash function for use with Hashtbl. *) 66 + 67 + val to_string : t -> string 68 + (** Encode to git format. *) 69 + 70 + val of_string : string -> (t, [ `Msg of string ]) result 71 + (** Parse from git format. *) 72 + 73 + val of_string_exn : string -> t 74 + (** [of_string_exn s] is like {!of_string} but raises on error. *) 75 + 76 + val digest : t -> Hash.t 77 + (** Compute the git hash of a tag. *) 78 + 79 + module Set : Set.S with type elt = t 80 + module Map : Map.S with type key = t
+104
lib/tree.mli
··· 1 + (* Copyright (c) 2013-2017 Thomas Gazagnaire <thomas@gazagnaire.org> 2 + Copyright (c) 2017-2024 Romain Calascibetta <romain.calascibetta@gmail.com> 3 + Copyright (c) 2024-2026 Thomas Gazagnaire <thomas@gazagnaire.org> 4 + 5 + Permission to use, copy, modify, and distribute this software for any 6 + purpose with or without fee is hereby granted, provided that the above 7 + copyright notice and this permission notice appear in all copies. 8 + 9 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. *) 16 + 17 + (** Git tree objects (directory listings). *) 18 + 19 + type perm = [ `Normal | `Everybody | `Exec | `Link | `Dir | `Commit ] 20 + (** File permissions in a tree entry. *) 21 + 22 + val perm_to_string : perm -> string 23 + (** Convert permission to git octal format. *) 24 + 25 + val perm_of_string : string -> (perm, [ `Msg of string ]) result 26 + (** Parse permission from git octal format. *) 27 + 28 + val perm_of_string_exn : string -> perm 29 + (** [perm_of_string_exn s] is like {!perm_of_string} but raises on error. *) 30 + 31 + type entry = { perm : perm; name : string; hash : Hash.t } 32 + (** A tree entry: permission, name, and hash of the object. *) 33 + 34 + val entry : perm:perm -> name:string -> Hash.t -> entry 35 + (** Create a tree entry. 36 + @raise Invalid_argument if name contains null byte. *) 37 + 38 + val pp_entry : entry Fmt.t 39 + (** Pretty-print a tree entry. *) 40 + 41 + type t 42 + (** The type of trees. *) 43 + 44 + val empty : t 45 + (** The empty tree. *) 46 + 47 + val is_empty : t -> bool 48 + (** Test if a tree is empty. *) 49 + 50 + val v : entry list -> t 51 + (** Create a tree from a list of entries (sorted automatically). *) 52 + 53 + val add : entry -> t -> t 54 + (** Add an entry to a tree. *) 55 + 56 + val remove : name:string -> t -> t 57 + (** Remove an entry by name. *) 58 + 59 + val find : name:string -> t -> entry option 60 + (** Find an entry by name. *) 61 + 62 + val to_list : t -> entry list 63 + (** Get the list of entries. *) 64 + 65 + val of_list : entry list -> t 66 + (** Create a tree from a list of entries. *) 67 + 68 + val hashes : t -> Hash.t list 69 + (** Get all hashes in the tree. *) 70 + 71 + val iter : (entry -> unit) -> t -> unit 72 + (** Iterate over entries. *) 73 + 74 + val pp : t Fmt.t 75 + (** Pretty-print a tree. *) 76 + 77 + val equal : t -> t -> bool 78 + (** Equality on trees. *) 79 + 80 + val compare : t -> t -> int 81 + (** Total ordering on trees. *) 82 + 83 + val hash : t -> int 84 + (** Hash function for use with Hashtbl. *) 85 + 86 + val to_string : t -> string 87 + (** Encode to git binary format. *) 88 + 89 + val of_string : string -> (t, [ `Msg of string ]) result 90 + (** Parse from git binary format. *) 91 + 92 + val of_string_exn : string -> t 93 + (** [of_string_exn s] is like {!of_string} but raises on error. *) 94 + 95 + val of_reader : Bytesrw.Bytes.Reader.t -> (t, [ `Msg of string ]) result 96 + (** Parse a tree directly from a {!Bytesrw.Bytes.Reader.t} without materialising 97 + the full object into a string. The reader must be positioned at the start of 98 + the tree body (after the loose-object header, if any). *) 99 + 100 + val digest : t -> Hash.t 101 + (** Compute the git hash of a tree. *) 102 + 103 + module Set : Set.S with type elt = t 104 + module Map : Map.S with type key = t
+102
lib/user.ml
··· 1 + (* Copyright (c) 2013-2017 Thomas Gazagnaire <thomas@gazagnaire.org> 2 + Copyright (c) 2017-2024 Romain Calascibetta <romain.calascibetta@gmail.com> 3 + Copyright (c) 2024-2026 Thomas Gazagnaire <thomas@gazagnaire.org> 4 + 5 + Permission to use, copy, modify, and distribute this software for any 6 + purpose with or without fee is hereby granted, provided that the above 7 + copyright notice and this permission notice appear in all copies. 8 + 9 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. *) 16 + 17 + (** Git user information (author/committer). *) 18 + 19 + type tz_offset = { sign : [ `Plus | `Minus ]; hours : int; minutes : int } 20 + type t = { name : string; email : string; date : int64 * tz_offset option } 21 + 22 + let v ~name ~email ~date ?tz_offset () = 23 + { name; email; date = (date, tz_offset) } 24 + 25 + let name t = t.name 26 + let email t = t.email 27 + let date t = fst t.date 28 + let tz_offset t = snd t.date 29 + 30 + let pp_tz_offset ppf { sign; hours; minutes } = 31 + let sign_char = match sign with `Plus -> '+' | `Minus -> '-' in 32 + Fmt.pf ppf "%c%02d%02d" sign_char hours minutes 33 + 34 + let pp ppf t = 35 + Fmt.pf ppf "%s <%s> %Ld" t.name t.email (fst t.date); 36 + match snd t.date with 37 + | Some tz -> Fmt.pf ppf " %a" pp_tz_offset tz 38 + | None -> Fmt.pf ppf " +0000" 39 + 40 + let equal a b = 41 + String.equal a.name b.name 42 + && String.equal a.email b.email 43 + && Int64.equal (fst a.date) (fst b.date) 44 + 45 + let compare a b = 46 + match String.compare a.name b.name with 47 + | 0 -> ( 48 + match String.compare a.email b.email with 49 + | 0 -> Int64.compare (fst a.date) (fst b.date) 50 + | n -> n) 51 + | n -> n 52 + 53 + (** Encode user to git format: "Name <email> timestamp timezone" *) 54 + let to_string t = 55 + let tz_str = 56 + match snd t.date with 57 + | Some { sign; hours; minutes } -> 58 + let sign_char = match sign with `Plus -> '+' | `Minus -> '-' in 59 + Fmt.str "%c%02d%02d" sign_char hours minutes 60 + | None -> "+0000" 61 + in 62 + Fmt.str "%s <%s> %Ld %s" t.name t.email (fst t.date) tz_str 63 + 64 + (** Parse user from git format. *) 65 + let of_string s = 66 + (* Format: "Name <email> timestamp timezone" *) 67 + match String.rindex_opt s '<' with 68 + | None -> Error (`Msg ("Invalid user format: " ^ s)) 69 + | Some lt_pos -> ( 70 + match String.index_from_opt s lt_pos '>' with 71 + | None -> Error (`Msg ("Invalid user format: " ^ s)) 72 + | Some gt_pos -> ( 73 + let name = String.trim (String.sub s 0 lt_pos) in 74 + let email = String.sub s (lt_pos + 1) (gt_pos - lt_pos - 1) in 75 + let rest = String.sub s (gt_pos + 2) (String.length s - gt_pos - 2) in 76 + let parts = String.split_on_char ' ' rest in 77 + match parts with 78 + | [ timestamp; tz ] -> ( 79 + match Int64.of_string_opt timestamp with 80 + | None -> Error (`Msg ("Invalid timestamp: " ^ timestamp)) 81 + | Some ts -> 82 + let tz_offset = 83 + if String.length tz >= 5 then 84 + let sign = 85 + match tz.[0] with 86 + | '+' -> `Plus 87 + | '-' -> `Minus 88 + | _ -> `Plus 89 + in 90 + let hours = 91 + int_of_string_opt (String.sub tz 1 2) 92 + |> Option.value ~default:0 93 + in 94 + let minutes = 95 + int_of_string_opt (String.sub tz 3 2) 96 + |> Option.value ~default:0 97 + in 98 + Some { sign; hours; minutes } 99 + else None 100 + in 101 + Ok { name; email; date = (ts, tz_offset) }) 102 + | _ -> Error (`Msg ("Invalid user date format: " ^ rest))))
+55
lib/user.mli
··· 1 + (* Copyright (c) 2013-2017 Thomas Gazagnaire <thomas@gazagnaire.org> 2 + Copyright (c) 2017-2024 Romain Calascibetta <romain.calascibetta@gmail.com> 3 + Copyright (c) 2024-2026 Thomas Gazagnaire <thomas@gazagnaire.org> 4 + 5 + Permission to use, copy, modify, and distribute this software for any 6 + purpose with or without fee is hereby granted, provided that the above 7 + copyright notice and this permission notice appear in all copies. 8 + 9 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. *) 16 + 17 + (** Git user information (author/committer). *) 18 + 19 + type tz_offset = { sign : [ `Plus | `Minus ]; hours : int; minutes : int } 20 + (** Timezone offset. *) 21 + 22 + type t 23 + (** The type of users. *) 24 + 25 + val v : 26 + name:string -> email:string -> date:int64 -> ?tz_offset:tz_offset -> unit -> t 27 + (** [v ~name ~email ~date ?tz_offset ()] creates a user. [date] is a Unix 28 + timestamp (seconds since epoch). *) 29 + 30 + val name : t -> string 31 + (** The user's name. *) 32 + 33 + val email : t -> string 34 + (** The user's email. *) 35 + 36 + val date : t -> int64 37 + (** The timestamp (Unix epoch). *) 38 + 39 + val tz_offset : t -> tz_offset option 40 + (** The timezone offset. *) 41 + 42 + val pp : t Fmt.t 43 + (** Pretty-print a user. *) 44 + 45 + val equal : t -> t -> bool 46 + (** Equality on users. *) 47 + 48 + val compare : t -> t -> int 49 + (** Total ordering on users. *) 50 + 51 + val to_string : t -> string 52 + (** Encode to git format: "Name <email> timestamp timezone". *) 53 + 54 + val of_string : string -> (t, [ `Msg of string ]) result 55 + (** Parse from git format. *)
+242
lib/value.ml
··· 1 + (* Copyright (c) 2013-2017 Thomas Gazagnaire <thomas@gazagnaire.org> 2 + Copyright (c) 2017-2024 Romain Calascibetta <romain.calascibetta@gmail.com> 3 + Copyright (c) 2024-2026 Thomas Gazagnaire <thomas@gazagnaire.org> 4 + 5 + Permission to use, copy, modify, and distribute this software for any 6 + purpose with or without fee is hereby granted, provided that the above 7 + copyright notice and this permission notice appear in all copies. 8 + 9 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. *) 16 + 17 + (** Git values (union of all object types). *) 18 + 19 + module Reader = Bytesrw.Bytes.Reader 20 + module Writer = Bytesrw.Bytes.Writer 21 + module Slice = Bytesrw.Bytes.Slice 22 + 23 + type t = Blob of Blob.t | Commit of Commit.t | Tree of Tree.t | Tag of Tag.t 24 + 25 + let blob b = Blob b 26 + let commit c = Commit c 27 + let tree t = Tree t 28 + let tag t = Tag t 29 + 30 + let kind = function 31 + | Blob _ -> `Blob 32 + | Commit _ -> `Commit 33 + | Tree _ -> `Tree 34 + | Tag _ -> `Tag 35 + 36 + let pp ppf = function 37 + | Blob b -> Fmt.pf ppf "(Blob %a)" Blob.pp b 38 + | Commit c -> Fmt.pf ppf "(Commit %a)" Commit.pp c 39 + | Tree t -> Fmt.pf ppf "(Tree %a)" Tree.pp t 40 + | Tag t -> Fmt.pf ppf "(Tag %a)" Tag.pp t 41 + 42 + let equal a b = 43 + match (a, b) with 44 + | Blob a, Blob b -> Blob.equal a b 45 + | Commit a, Commit b -> Commit.equal a b 46 + | Tree a, Tree b -> Tree.equal a b 47 + | Tag a, Tag b -> Tag.equal a b 48 + | _ -> false 49 + 50 + let compare a b = 51 + match (a, b) with 52 + | Blob a, Blob b -> Blob.compare a b 53 + | Commit a, Commit b -> Commit.compare a b 54 + | Tree a, Tree b -> Tree.compare a b 55 + | Tag a, Tag b -> Tag.compare a b 56 + | Blob _, _ -> -1 57 + | _, Blob _ -> 1 58 + | Commit _, _ -> -1 59 + | _, Commit _ -> 1 60 + | Tree _, _ -> -1 61 + | _, Tree _ -> 1 62 + 63 + let hash = function 64 + | Blob b -> Blob.hash b 65 + | Commit c -> Commit.hash c 66 + | Tree t -> Tree.hash t 67 + | Tag t -> Tag.hash t 68 + 69 + let digest = function 70 + | Blob b -> Blob.digest b 71 + | Commit c -> Commit.digest c 72 + | Tree t -> Tree.digest t 73 + | Tag t -> Tag.digest t 74 + 75 + (** Get the raw content of a value (without header). *) 76 + let to_string_without_header = function 77 + | Blob b -> Blob.to_string b 78 + | Commit c -> Commit.to_string c 79 + | Tree t -> Tree.to_string t 80 + | Tag t -> Tag.to_string t 81 + 82 + let length v = String.length (to_string_without_header v) 83 + 84 + (** Get the raw content of a value with git header. Format: "type 85 + length\x00content" *) 86 + let to_string v = 87 + let content = to_string_without_header v in 88 + let kind_str = 89 + match kind v with 90 + | `Blob -> "blob" 91 + | `Commit -> "commit" 92 + | `Tree -> "tree" 93 + | `Tag -> "tag" 94 + in 95 + Fmt.str "%s %d\x00%s" kind_str (String.length content) content 96 + 97 + (** Parse a value from raw content (without header). *) 98 + let of_string ~kind content = 99 + match kind with 100 + | `Blob -> Ok (Blob (Blob.of_string content)) 101 + | `Commit -> Result.map commit (Commit.of_string content) 102 + | `Tree -> Result.map tree (Tree.of_string content) 103 + | `Tag -> Result.map tag (Tag.of_string content) 104 + 105 + let of_string_exn ~kind content = 106 + match of_string ~kind content with Ok v -> v | Error (`Msg m) -> failwith m 107 + 108 + (** Parse a value from raw content with git header. *) 109 + let of_string_with_header s = 110 + (* Find the space after type *) 111 + match String.index_opt s ' ' with 112 + | None -> Error (`Msg "Invalid git object: missing space after type") 113 + | Some sp_pos -> ( 114 + let type_str = String.sub s 0 sp_pos in 115 + (* Find the null byte after length *) 116 + match String.index_from_opt s (sp_pos + 1) '\x00' with 117 + | None -> Error (`Msg "Invalid git object: missing null after length") 118 + | Some null_pos -> ( 119 + let length_str = String.sub s (sp_pos + 1) (null_pos - sp_pos - 1) in 120 + let content_start = null_pos + 1 in 121 + let content = 122 + String.sub s content_start (String.length s - content_start) 123 + in 124 + (* Verify length *) 125 + match int_of_string_opt length_str with 126 + | None -> Error (`Msg ("Invalid length: " ^ length_str)) 127 + | Some expected_len -> 128 + if String.length content <> expected_len then 129 + Error 130 + (`Msg 131 + (Fmt.str "Length mismatch: expected %d, got %d" 132 + expected_len (String.length content))) 133 + else 134 + let kind = 135 + match type_str with 136 + | "blob" -> Ok `Blob 137 + | "commit" -> Ok `Commit 138 + | "tree" -> Ok `Tree 139 + | "tag" -> Ok `Tag 140 + | _ -> Error (`Msg ("Unknown object type: " ^ type_str)) 141 + in 142 + Result.bind kind (fun kind -> of_string ~kind content))) 143 + 144 + let of_string_with_header_exn s = 145 + match of_string_with_header s with Ok v -> v | Error (`Msg m) -> failwith m 146 + 147 + (** {1 Bytesrw support} *) 148 + 149 + (** Read until a specific byte is found *) 150 + let read_until reader byte = 151 + let buf = Buffer.create 64 in 152 + let rec loop () = 153 + match Reader.read reader with 154 + | slice when Slice.is_eod slice -> Error (`Msg "unexpected end of data") 155 + | slice -> ( 156 + let str = Slice.to_string slice in 157 + match String.index_opt str byte with 158 + | Some pos -> 159 + Buffer.add_substring buf str 0 pos; 160 + if pos + 1 < String.length str then begin 161 + let leftover = 162 + String.sub str (pos + 1) (String.length str - pos - 1) 163 + in 164 + Reader.push_back reader (Slice.of_string leftover) 165 + end; 166 + Ok (Buffer.contents buf) 167 + | None -> 168 + Buffer.add_string buf str; 169 + loop ()) 170 + in 171 + loop () 172 + 173 + (** Read a git object from a reader. Returns (kind, length, content). *) 174 + let read_header reader = 175 + let open Result.Syntax in 176 + let* type_str = read_until reader ' ' in 177 + let* length_str = read_until reader '\x00' in 178 + let* kind = 179 + match type_str with 180 + | "blob" -> Ok `Blob 181 + | "commit" -> Ok `Commit 182 + | "tree" -> Ok `Tree 183 + | "tag" -> Ok `Tag 184 + | _ -> Error (`Msg ("Unknown object type: " ^ type_str)) 185 + in 186 + let* length = 187 + match int_of_string_opt length_str with 188 + | Some n -> Ok n 189 + | None -> Error (`Msg ("Invalid length: " ^ length_str)) 190 + in 191 + Ok (kind, length) 192 + 193 + (** Parse from a reader, dispatching to reader-based parsers for commit and tree 194 + to avoid materialising the full content string. Blobs and tags still go 195 + through [of_string] (blobs are opaque bytes; tags are rare). *) 196 + let of_reader ~kind reader = 197 + match kind with 198 + | `Commit -> Result.map commit (Commit.of_reader reader) 199 + | `Tree -> Result.map tree (Tree.of_reader reader) 200 + | `Blob -> 201 + let content = Reader.to_string reader in 202 + Ok (Blob (Blob.of_string content)) 203 + | `Tag -> 204 + let content = Reader.to_string reader in 205 + Result.map tag (Tag.of_string content) 206 + 207 + (** Read a git object from a bytesrw reader. *) 208 + let read reader = 209 + let open Result.Syntax in 210 + let* kind, _length = read_header reader in 211 + of_reader ~kind reader 212 + 213 + (** Write a git object to a bytesrw writer. *) 214 + let write writer v = 215 + let content = to_string_without_header v in 216 + let kind_str = 217 + match kind v with 218 + | `Blob -> "blob" 219 + | `Commit -> "commit" 220 + | `Tree -> "tree" 221 + | `Tag -> "tag" 222 + in 223 + let header = Fmt.str "%s %d\x00" kind_str (String.length content) in 224 + Writer.write writer (Slice.of_string header); 225 + Writer.write writer (Slice.of_string content) 226 + 227 + (** Write only the content (without header) to a bytesrw writer. *) 228 + let write_content writer v = 229 + let content = to_string_without_header v in 230 + Writer.write writer (Slice.of_string content) 231 + 232 + module Set = Set.Make (struct 233 + type nonrec t = t 234 + 235 + let compare = compare 236 + end) 237 + 238 + module Map = Map.Make (struct 239 + type nonrec t = t 240 + 241 + let compare = compare 242 + end)
+111
lib/value.mli
··· 1 + (* Copyright (c) 2013-2017 Thomas Gazagnaire <thomas@gazagnaire.org> 2 + Copyright (c) 2017-2024 Romain Calascibetta <romain.calascibetta@gmail.com> 3 + Copyright (c) 2024-2026 Thomas Gazagnaire <thomas@gazagnaire.org> 4 + 5 + Permission to use, copy, modify, and distribute this software for any 6 + purpose with or without fee is hereby granted, provided that the above 7 + copyright notice and this permission notice appear in all copies. 8 + 9 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. *) 16 + 17 + (** Git values (union of all object types). *) 18 + 19 + type t = 20 + | Blob of Blob.t 21 + | Commit of Commit.t 22 + | Tree of Tree.t 23 + | Tag of Tag.t (** The type of git values. *) 24 + 25 + val blob : Blob.t -> t 26 + (** Create a blob value. *) 27 + 28 + val commit : Commit.t -> t 29 + (** Create a commit value. *) 30 + 31 + val tree : Tree.t -> t 32 + (** Create a tree value. *) 33 + 34 + val tag : Tag.t -> t 35 + (** Create a tag value. *) 36 + 37 + val kind : t -> [ `Blob | `Commit | `Tree | `Tag ] 38 + (** Get the kind of a value. *) 39 + 40 + val pp : t Fmt.t 41 + (** Pretty-print a value. *) 42 + 43 + val equal : t -> t -> bool 44 + (** Equality on values. *) 45 + 46 + val compare : t -> t -> int 47 + (** Total ordering on values. *) 48 + 49 + val hash : t -> int 50 + (** Hash function for use with Hashtbl. *) 51 + 52 + val digest : t -> Hash.t 53 + (** Compute the git hash of a value. *) 54 + 55 + val length : t -> int 56 + (** The length of the value content (without header). *) 57 + 58 + val to_string_without_header : t -> string 59 + (** Encode to git format without the header. *) 60 + 61 + val to_string : t -> string 62 + (** Encode to git format with header ("type length\x00content"). *) 63 + 64 + val of_string : 65 + kind:[ `Blob | `Commit | `Tree | `Tag ] -> 66 + string -> 67 + (t, [ `Msg of string ]) result 68 + (** Parse from git format (content only, no header). *) 69 + 70 + val of_string_exn : kind:[ `Blob | `Commit | `Tree | `Tag ] -> string -> t 71 + (** [of_string_exn ~kind s] is like {!of_string} but raises on error. *) 72 + 73 + val of_string_with_header : string -> (t, [ `Msg of string ]) result 74 + (** Parse from git format with header. *) 75 + 76 + val of_string_with_header_exn : string -> t 77 + (** [of_string_with_header_exn s] is like {!of_string_with_header} but raises on 78 + error. *) 79 + 80 + (** {1 Bytesrw support} *) 81 + 82 + module Reader = Bytesrw.Bytes.Reader 83 + module Writer = Bytesrw.Bytes.Writer 84 + module Slice = Bytesrw.Bytes.Slice 85 + 86 + val read_header : 87 + Reader.t -> 88 + ([ `Blob | `Commit | `Tree | `Tag ] * int, [ `Msg of string ]) result 89 + (** Read a git object header from a reader. Returns the kind and content length. 90 + *) 91 + 92 + val of_reader : 93 + kind:[ `Blob | `Commit | `Tree | `Tag ] -> 94 + Reader.t -> 95 + (t, [ `Msg of string ]) result 96 + (** [of_reader ~kind r] parses a value directly from a reader positioned at the 97 + start of the object body. Commits and trees are parsed without materialising 98 + the full content string; blobs and tags still go through {!of_string}. *) 99 + 100 + val read : Reader.t -> (t, [ `Msg of string ]) result 101 + (** Read a git object from a bytesrw reader (parses the header then the body). 102 + *) 103 + 104 + val write : Writer.t -> t -> unit 105 + (** Write a git object to a bytesrw writer (with header). *) 106 + 107 + val write_content : Writer.t -> t -> unit 108 + (** Write only the content (without header) to a bytesrw writer. *) 109 + 110 + module Set : Set.S with type elt = t 111 + module Map : Map.S with type key = t
+179
lib/worktree.ml
··· 1 + (* Copyright (c) 2024-2026 Thomas Gazagnaire <thomas@gazagnaire.org> 2 + 3 + Permission to use, copy, modify, and distribute this software for any 4 + purpose with or without fee is hereby granted, provided that the above 5 + copyright notice and this permission notice appear in all copies. 6 + 7 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. *) 14 + 15 + type entry = { path : Fpath.t; head : Hash.t; branch : string option } 16 + type t = { fs : Eio.Fs.dir_ty Eio.Path.t; git_dir : string } 17 + 18 + let v ~fs ~git_dir = { fs; git_dir } 19 + 20 + let err_worktree_not_found path = 21 + Error (`Msg (Fmt.str "worktree '%a' not found" Fpath.pp path)) 22 + 23 + (* Get the main worktree path from .git directory *) 24 + let main_worktree_path t = 25 + (* .git dir is like /path/to/repo/.git, so parent is the worktree *) 26 + if 27 + String.length t.git_dir > 5 28 + && String.sub t.git_dir (String.length t.git_dir - 5) 5 = "/.git" 29 + then String.sub t.git_dir 0 (String.length t.git_dir - 5) 30 + else Filename.dirname t.git_dir 31 + 32 + (* Get worktree name from path *) 33 + let name path = Fpath.basename path 34 + 35 + (* Parse HEAD file to get branch name *) 36 + let parse_head_file content = 37 + let content = String.trim content in 38 + if String.length content > 16 && String.sub content 0 16 = "ref: refs/heads/" 39 + then Some (String.sub content 16 (String.length content - 16)) 40 + else None 41 + 42 + (* Read linked worktree entry from .git/worktrees/<name> *) 43 + let read_worktree_entry t name = 44 + let wt_git_dir = Filename.concat t.git_dir ("worktrees/" ^ name) in 45 + let gitdir_path = Eio.Path.(t.fs / wt_git_dir / "gitdir") in 46 + let head_path = Eio.Path.(t.fs / wt_git_dir / "HEAD") in 47 + try 48 + let gitdir_content = String.trim (Eio.Path.load gitdir_path) in 49 + (* gitdir contains path to the worktree's .git file, the worktree is the parent *) 50 + let wt_path = 51 + if Filename.check_suffix gitdir_content "/.git" then 52 + String.sub gitdir_content 0 (String.length gitdir_content - 5) 53 + else Filename.dirname gitdir_content 54 + in 55 + let head_content = Eio.Path.load head_path in 56 + let branch = parse_head_file head_content in 57 + let head_hash = 58 + match branch with 59 + | Some b -> ( 60 + (* Read from the shared refs *) 61 + let ref_path = Eio.Path.(t.fs / t.git_dir / "refs" / "heads" / b) in 62 + try 63 + let hash_str = String.trim (Eio.Path.load ref_path) in 64 + Hash.of_hex hash_str 65 + with Eio.Io _ | Invalid_argument _ -> 66 + (* Fallback: parse HEAD as direct hash *) 67 + Hash.of_hex (String.trim head_content)) 68 + | None -> Hash.of_hex (String.trim head_content) 69 + in 70 + match Fpath.of_string wt_path with 71 + | Ok path -> Some { path; head = head_hash; branch } 72 + | Error _ -> None 73 + with Eio.Io _ | Invalid_argument _ -> None 74 + 75 + let list t ~head ~current_branch = 76 + (* First, add the main worktree *) 77 + let main_path = main_worktree_path t in 78 + let main_entry = 79 + match Fpath.of_string main_path with 80 + | Ok path -> 81 + (* For the main worktree, use a zero hash if HEAD doesn't exist yet *) 82 + let head_hash = 83 + match head with 84 + | Some h -> h 85 + | None -> Hash.of_hex (String.make 40 '0') 86 + in 87 + Some { path; head = head_hash; branch = current_branch } 88 + | Error _ -> None 89 + in 90 + (* Then list linked worktrees from .git/worktrees/ *) 91 + let worktrees_dir = Filename.concat t.git_dir "worktrees" in 92 + let worktrees_path = Eio.Path.(t.fs / worktrees_dir) in 93 + let linked_entries = 94 + try 95 + let entries = Eio.Path.read_dir worktrees_path in 96 + List.filter_map (read_worktree_entry t) entries 97 + with Eio.Io _ -> [] 98 + in 99 + match main_entry with Some e -> e :: linked_entries | None -> linked_entries 100 + 101 + let exists t ~path = 102 + (* We need to get head and current_branch, but for exists check we can pass None *) 103 + let worktrees = list t ~head:None ~current_branch:None in 104 + List.exists (fun e -> Fpath.equal e.path path) worktrees 105 + 106 + let write_ref t name hash = 107 + let path = Filename.concat t.git_dir name in 108 + let full_path = Eio.Path.(t.fs / path) in 109 + let dir = Filename.dirname path in 110 + let dir_path = Eio.Path.(t.fs / dir) in 111 + (try Eio.Path.mkdir ~perm:0o755 dir_path with Eio.Io _ -> ()); 112 + let content = Hash.to_hex hash ^ "\n" in 113 + Eio.Path.save ~create:(`Or_truncate 0o644) full_path content 114 + 115 + let add t ~head ~path ~branch = 116 + let name = name path in 117 + let wt_git_dir = Filename.concat t.git_dir ("worktrees/" ^ name) in 118 + let wt_git_dir_path = Eio.Path.(t.fs / wt_git_dir) in 119 + let wt_path_str = Fpath.to_string path in 120 + let wt_path = Eio.Path.(t.fs / wt_path_str) in 121 + (* Create the worktree directory *) 122 + (try Eio.Path.mkdirs ~perm:0o755 wt_path with Eio.Io _ -> ()); 123 + (* Create .git/worktrees/<name> directory *) 124 + (try Eio.Path.mkdirs ~perm:0o755 wt_git_dir_path with Eio.Io _ -> ()); 125 + (* Write gitdir file (path to the worktree's .git file) *) 126 + let gitdir_content = wt_path_str ^ "/.git\n" in 127 + Eio.Path.save ~create:(`Or_truncate 0o644) 128 + Eio.Path.(wt_git_dir_path / "gitdir") 129 + gitdir_content; 130 + (* Write HEAD file (pointing to the new branch) *) 131 + let head_content = "ref: refs/heads/" ^ branch ^ "\n" in 132 + Eio.Path.save ~create:(`Or_truncate 0o644) 133 + Eio.Path.(wt_git_dir_path / "HEAD") 134 + head_content; 135 + (* Write commondir file (relative path to main .git) *) 136 + Eio.Path.save ~create:(`Or_truncate 0o644) 137 + Eio.Path.(wt_git_dir_path / "commondir") 138 + "..\n"; 139 + (* Create the branch in the main repo *) 140 + write_ref t ("refs/heads/" ^ branch) head; 141 + (* Write .git file in the worktree *) 142 + let git_file_content = "gitdir: " ^ wt_git_dir ^ "\n" in 143 + Eio.Path.save ~create:(`Or_truncate 0o644) 144 + Eio.Path.(wt_path / ".git") 145 + git_file_content; 146 + Ok () 147 + 148 + let remove t ~path ~force = 149 + if not (exists t ~path) then err_worktree_not_found path 150 + else 151 + let name = name path in 152 + let wt_git_dir = Filename.concat t.git_dir ("worktrees/" ^ name) in 153 + let wt_git_dir_path = Eio.Path.(t.fs / wt_git_dir) in 154 + let wt_path_str = Fpath.to_string path in 155 + let wt_path = Eio.Path.(t.fs / wt_path_str) in 156 + (* Check if worktree is the main worktree *) 157 + if String.equal (main_worktree_path t) wt_path_str then 158 + Error (`Msg "cannot remove main worktree") 159 + else begin 160 + (* TODO: Check for uncommitted changes if not force *) 161 + ignore force; 162 + (* Remove the .git/worktrees/<name> directory *) 163 + let rec remove_dir path = 164 + try 165 + let entries = Eio.Path.read_dir path in 166 + List.iter 167 + (fun entry -> 168 + let entry_path = Eio.Path.(path / entry) in 169 + if Eio.Path.is_directory entry_path then remove_dir entry_path 170 + else Eio.Path.unlink entry_path) 171 + entries; 172 + Eio.Path.rmdir path 173 + with Eio.Io _ -> () 174 + in 175 + remove_dir wt_git_dir_path; 176 + (* Remove the worktree directory *) 177 + remove_dir wt_path; 178 + Ok () 179 + end
+54
lib/worktree.mli
··· 1 + (* Copyright (c) 2024-2026 Thomas Gazagnaire <thomas@gazagnaire.org> 2 + 3 + Permission to use, copy, modify, and distribute this software for any 4 + purpose with or without fee is hereby granted, provided that the above 5 + copyright notice and this permission notice appear in all copies. 6 + 7 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. *) 14 + 15 + (** Git worktree operations. 16 + 17 + This module provides operations for managing git worktrees - separate 18 + working directories that share the same git repository. *) 19 + 20 + type entry = { 21 + path : Fpath.t; (** Absolute path to the worktree *) 22 + head : Hash.t; (** HEAD commit hash *) 23 + branch : string option; (** Branch name if not detached *) 24 + } 25 + (** A git worktree entry. *) 26 + 27 + (** {1 Low-level interface} 28 + 29 + These functions operate on the raw git directory structure. *) 30 + 31 + type t 32 + (** A worktree manager. *) 33 + 34 + val v : fs:Eio.Fs.dir_ty Eio.Path.t -> git_dir:string -> t 35 + (** [v ~fs ~git_dir] creates a worktree manager. *) 36 + 37 + val add : 38 + t -> 39 + head:Hash.t -> 40 + path:Fpath.t -> 41 + branch:string -> 42 + (unit, [ `Msg of string ]) result 43 + (** [add t ~head ~path ~branch] creates a new worktree at [path] with a new 44 + branch [branch] starting at [head]. *) 45 + 46 + val remove : 47 + t -> path:Fpath.t -> force:bool -> (unit, [ `Msg of string ]) result 48 + (** [remove t ~path ~force] removes a worktree at [path]. *) 49 + 50 + val list : t -> head:Hash.t option -> current_branch:string option -> entry list 51 + (** [list t ~head ~current_branch] returns all worktrees. *) 52 + 53 + val exists : t -> path:Fpath.t -> bool 54 + (** [exists t ~path] returns true if a worktree exists at [path]. *)
+7
test/dune
··· 1 + (test 2 + (name test) 3 + (libraries git test_helpers alcotest bytesrw bytesrw.zlib eio_main) 4 + (deps pack-testzone-0.pack pack-testzone-0.idx)) 5 + 6 + (cram 7 + (deps %{bin:git-mono}))
+3
test/helpers/dune
··· 1 + (library 2 + (name test_helpers) 3 + (libraries git alcotest eio eio_main fpath))
+31
test/helpers/test_helpers.ml
··· 1 + (* Copyright (c) 2024-2026 Thomas Gazagnaire <thomas@gazagnaire.org> 2 + 3 + Permission to use, copy, modify, and distribute this software for any 4 + purpose with or without fee is hereby granted, provided that the above 5 + copyright notice and this permission notice appear in all copies. 6 + 7 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. *) 14 + 15 + let hash = Alcotest.testable Git.Hash.pp Git.Hash.equal 16 + 17 + let with_temp_repo f = 18 + Eio_main.run @@ fun env -> 19 + let fs = Eio.Stdenv.fs env in 20 + let tmp_dir_str = Filename.temp_dir "git_test" "" in 21 + let tmp_dir = Fpath.v tmp_dir_str in 22 + Fun.protect 23 + ~finally:(fun () -> ignore (Sys.command ("rm -rf " ^ tmp_dir_str))) 24 + (fun () -> f fs tmp_dir) 25 + 26 + let commit ~repo ~tree ~parents ~message = 27 + let author = Git.User.v ~name:"Test" ~email:"test@test.com" ~date:0L () in 28 + let commit = 29 + Git.Commit.v ~tree ~author ~committer:author ~parents (Some message) 30 + in 31 + Git.Repository.write_commit repo commit
+17
test/helpers/test_helpers.mli
··· 1 + (** Shared test utilities. *) 2 + 3 + val hash : Git.Hash.t Alcotest.testable 4 + 5 + val with_temp_repo : (Eio.Fs.dir_ty Eio.Path.t -> Fpath.t -> 'a) -> 'a 6 + (** [with_temp_repo f] creates a temporary git repository and calls 7 + [f dir repo_path] with the Eio directory and Fpath to the repo. The 8 + temporary directory is cleaned up after [f] returns. *) 9 + 10 + val commit : 11 + repo:Git.Repository.t -> 12 + tree:Git.Hash.t -> 13 + parents:Git.Hash.t list -> 14 + message:string -> 15 + Git.Hash.t 16 + (** [commit ~repo ~tree ~parents ~message] creates a new commit object in [repo] 17 + with the given [tree], [parents], and [message], and returns its hash. *)
test/pack-testzone-0.idx

This is a binary file and will not be displayed.

test/pack-testzone-0.pack

This is a binary file and will not be displayed.

+648
test/split.t
··· 1 + git-mono split tests 2 + ===================== 3 + 4 + Setup: configure git and disable colors 5 + 6 + $ export NO_COLOR=1 7 + $ export GIT_AUTHOR_NAME="Test User" 8 + $ export GIT_AUTHOR_EMAIL="test@example.com" 9 + $ export GIT_AUTHOR_DATE="2025-01-01T00:00:00+00:00" 10 + $ export GIT_COMMITTER_NAME="Test User" 11 + $ export GIT_COMMITTER_EMAIL="test@example.com" 12 + $ export GIT_COMMITTER_DATE="2025-01-01T00:00:00+00:00" 13 + 14 + Help and version 15 + ----------------- 16 + 17 + $ git-mono --version 18 + 4.0.0 19 + 20 + Error: missing PREFIX argument 21 + ------------------------------- 22 + 23 + $ git-mono split 2>&1 24 + Usage: git-mono split [--help] [OPTION]… PREFIX 25 + git-mono: required argument PREFIX is missing 26 + [124] 27 + 28 + Error: not a git repository 29 + ---------------------------- 30 + 31 + $ mkdir empty && cd empty 32 + $ git-mono split lib 2>&1 33 + error: Could not resolve revision 'HEAD'. 34 + git-mono: could not resolve revision 'HEAD' 35 + [124] 36 + $ cd .. 37 + 38 + Single commit with prefix 39 + -------------------------- 40 + 41 + $ mkdir repo1 && cd repo1 42 + $ git init -q 43 + $ mkdir lib 44 + $ echo "hello" > lib/foo.ml 45 + $ git add . && git commit -q -m "add lib/foo.ml" 46 + $ HASH=$(git-mono split lib) 47 + $ test -n "$HASH" && echo "got hash" 48 + got hash 49 + 50 + Verify the split commit has lib/ contents at root 51 + 52 + $ git ls-tree "$HASH" --name-only 53 + foo.ml 54 + $ git show "$HASH:foo.ml" 55 + hello 56 + $ cd .. 57 + 58 + Linear history 59 + -------------- 60 + 61 + $ mkdir repo2 && cd repo2 62 + $ git init -q 63 + $ mkdir lib 64 + $ echo "v1" > lib/a.ml 65 + $ git add . && git commit -q -m "first" 66 + $ echo "v2" > lib/a.ml 67 + $ git add . && git commit -q -m "second" 68 + $ echo "v3" > lib/a.ml 69 + $ git add . && git commit -q -m "third" 70 + $ HASH=$(git-mono split lib) 71 + 72 + The split commit chain should have 3 commits 73 + 74 + $ git rev-list "$HASH" | wc -l | tr -d ' ' 75 + 3 76 + 77 + Each split commit message matches the original 78 + 79 + $ git log --format="%s" "$HASH" 80 + third 81 + second 82 + first 83 + 84 + The latest tree should have a.ml with content "v3" 85 + 86 + $ git show "$HASH:a.ml" 87 + v3 88 + $ cd .. 89 + 90 + No commits touch prefix 91 + ------------------------ 92 + 93 + $ mkdir repo3 && cd repo3 94 + $ git init -q 95 + $ echo "root" > README.md 96 + $ git add . && git commit -q -m "root only" 97 + $ git-mono split nonexistent 2>&1 98 + git-mono: [WARNING] No commits touch prefix 'nonexistent'. 99 + $ cd .. 100 + 101 + Cache: second run returns same hash 102 + ------------------------------------- 103 + 104 + $ mkdir repo4 && cd repo4 105 + $ git init -q 106 + $ mkdir lib 107 + $ echo "cached" > lib/x.ml 108 + $ git add . && git commit -q -m "initial" 109 + $ HASH1=$(git-mono split lib) 110 + $ HASH2=$(git-mono split lib) 111 + $ test "$HASH1" = "$HASH2" && echo "cache hit: same hash" 112 + cache hit: same hash 113 + 114 + Verify cache file exists 115 + 116 + $ test -f .git/subtree-cache/lib && echo "cache file exists" 117 + cache file exists 118 + $ cd .. 119 + 120 + Incremental split 121 + ----------------- 122 + 123 + $ mkdir repo5 && cd repo5 124 + $ git init -q 125 + $ mkdir lib 126 + $ echo "v1" > lib/a.ml 127 + $ git add . && git commit -q -m "first" 128 + $ HASH1=$(git-mono split lib) 129 + 130 + Add another commit 131 + 132 + $ echo "v2" > lib/a.ml 133 + $ git add . && git commit -q -m "second" 134 + $ HASH2=$(git-mono split lib) 135 + 136 + Hashes differ 137 + 138 + $ test "$HASH1" != "$HASH2" && echo "hashes differ" 139 + hashes differ 140 + 141 + New split has 2 commits, not 1 142 + 143 + $ git rev-list "$HASH2" | wc -l | tr -d ' ' 144 + 2 145 + 146 + The parent of the new split head is the old split head 147 + 148 + $ PARENT=$(git rev-parse "$HASH2^") 149 + $ test "$PARENT" = "$HASH1" && echo "parent matches" 150 + parent matches 151 + $ cd .. 152 + 153 + Merge commits 154 + ------------- 155 + 156 + $ mkdir repo6 && cd repo6 157 + $ git init -q 158 + $ mkdir lib 159 + $ echo "base" > lib/a.ml 160 + $ git add . && git commit -q -m "base" 161 + 162 + Create a branch and make changes 163 + 164 + $ git checkout -q -b feature 165 + $ echo "feature" > lib/b.ml 166 + $ git add . && git commit -q -m "add b.ml on feature" 167 + 168 + Go back to main and make a different change 169 + 170 + $ git checkout -q main 2>/dev/null || git checkout -q master 171 + $ echo "main" > lib/c.ml 172 + $ git add . && git commit -q -m "add c.ml on main" 173 + 174 + Merge 175 + 176 + $ git merge -q --no-edit feature 177 + 178 + Split should produce commits with merge parents 179 + 180 + $ HASH=$(git-mono split lib) 181 + $ git rev-list "$HASH" | wc -l | tr -d ' ' 182 + 4 183 + 184 + The head commit should be a merge (2 parents) 185 + 186 + $ git cat-file -p "$HASH" | grep "^parent" | wc -l | tr -d ' ' 187 + 2 188 + $ cd .. 189 + 190 + Nested prefix 191 + -------------- 192 + 193 + $ mkdir repo7 && cd repo7 194 + $ git init -q 195 + $ mkdir -p src/lib 196 + $ echo "deep" > src/lib/deep.ml 197 + $ git add . && git commit -q -m "nested" 198 + $ HASH=$(git-mono split src/lib) 199 + $ git ls-tree "$HASH" --name-only 200 + deep.ml 201 + $ cd .. 202 + 203 + Commits that don't touch the prefix are skipped 204 + ------------------------------------------------- 205 + 206 + $ mkdir repo8 && cd repo8 207 + $ git init -q 208 + $ mkdir lib 209 + $ echo "v1" > lib/a.ml 210 + $ git add . && git commit -q -m "touch lib" 211 + $ echo "unrelated" > README.md 212 + $ git add . && git commit -q -m "touch root only" 213 + $ echo "v2" > lib/a.ml 214 + $ git add . && git commit -q -m "touch lib again" 215 + $ HASH=$(git-mono split lib) 216 + 217 + Only 2 split commits (the one touching root only is squashed since subtree didn't change) 218 + 219 + $ git rev-list "$HASH" | wc -l | tr -d ' ' 220 + 2 221 + 222 + Messages are preserved from the commits that touch lib 223 + 224 + $ git log --format="%s" "$HASH" 225 + touch lib again 226 + touch lib 227 + $ cd .. 228 + 229 + Split from a specific revision 230 + ------------------------------- 231 + 232 + $ mkdir repo9 && cd repo9 233 + $ git init -q 234 + $ mkdir lib 235 + $ echo "v1" > lib/a.ml 236 + $ git add . && git commit -q -m "first" 237 + $ FIRST=$(git rev-parse HEAD) 238 + $ echo "v2" > lib/a.ml 239 + $ git add . && git commit -q -m "second" 240 + $ HASH_HEAD=$(git-mono split lib) 241 + $ HASH_FIRST=$(git-mono split --rev "$FIRST" lib) 242 + 243 + The rev-specific split should have fewer commits 244 + 245 + $ git rev-list "$HASH_FIRST" | wc -l | tr -d ' ' 246 + 1 247 + $ git rev-list "$HASH_HEAD" | wc -l | tr -d ' ' 248 + 2 249 + $ cd .. 250 + 251 + Split from a branch name 252 + ------------------------- 253 + 254 + $ mkdir repo10 && cd repo10 255 + $ git init -q 256 + $ mkdir lib 257 + $ echo "main" > lib/a.ml 258 + $ git add . && git commit -q -m "on main" 259 + $ git checkout -q -b other 260 + $ echo "other" > lib/b.ml 261 + $ git add . && git commit -q -m "on other" 262 + $ git checkout -q main 2>/dev/null || git checkout -q master 263 + $ HASH=$(git-mono split --rev other lib) 264 + $ git show "$HASH:b.ml" 265 + other 266 + $ cd .. 267 + 268 + Bad revision 269 + ------------ 270 + 271 + $ mkdir repo11 && cd repo11 272 + $ git init -q 273 + $ git-mono split --rev nonexistent lib 2>&1 274 + error: Could not resolve revision 'nonexistent'. 275 + git-mono: could not resolve revision 'nonexistent' 276 + [124] 277 + $ cd .. 278 + 279 + Quiet mode (-q) suppresses warnings 280 + ------------------------------------- 281 + 282 + $ mkdir repo12 && cd repo12 283 + $ git init -q 284 + $ echo "root" > README.md 285 + $ git add . && git commit -q -m "root only" 286 + $ git-mono split -q nonexistent 2>&1 287 + $ cd .. 288 + 289 + Verbose mode shows info messages 290 + --------------------------------- 291 + 292 + $ mkdir repo13 && cd repo13 293 + $ git init -q 294 + $ mkdir lib 295 + $ echo "hello" > lib/a.ml 296 + $ git add . && git commit -q -m "initial" 297 + $ git-mono split -v lib 2>&1 | grep -c "Splitting prefix" 298 + 1 299 + $ cd .. 300 + 301 + Multiple prefixes use separate caches 302 + --------------------------------------- 303 + 304 + $ mkdir repo14 && cd repo14 305 + $ git init -q 306 + $ mkdir lib bin 307 + $ echo "lib" > lib/a.ml 308 + $ echo "bin" > bin/main.ml 309 + $ git add . && git commit -q -m "initial" 310 + $ HASH_LIB=$(git-mono split lib) 311 + $ HASH_BIN=$(git-mono split bin) 312 + 313 + Different hashes (different content) 314 + 315 + $ test "$HASH_LIB" != "$HASH_BIN" && echo "different hashes" 316 + different hashes 317 + 318 + Separate cache files 319 + 320 + $ test -f .git/subtree-cache/lib && echo "lib cache exists" 321 + lib cache exists 322 + $ test -f .git/subtree-cache/bin && echo "bin cache exists" 323 + bin cache exists 324 + $ cd .. 325 + 326 + Author and committer are preserved 327 + ------------------------------------ 328 + 329 + $ mkdir repo15 && cd repo15 330 + $ git init -q 331 + $ mkdir lib 332 + $ echo "hello" > lib/a.ml 333 + $ git add . 334 + $ GIT_AUTHOR_NAME="Alice Author" GIT_AUTHOR_EMAIL="alice@example.com" \ 335 + > GIT_COMMITTER_NAME="Bob Committer" GIT_COMMITTER_EMAIL="bob@example.com" \ 336 + > git commit -q -m "authored commit" 337 + $ HASH=$(git-mono split lib) 338 + $ git cat-file -p "$HASH" | grep "^author " | sed 's/ [0-9]* [+-][0-9]*//' 339 + author Alice Author <alice@example.com> 340 + $ git cat-file -p "$HASH" | grep "^committer " | sed 's/ [0-9]* [+-][0-9]*//' 341 + committer Bob Committer <bob@example.com> 342 + $ cd .. 343 + 344 + Dirty working tree does not affect split 345 + ------------------------------------------ 346 + 347 + $ mkdir repo16 && cd repo16 348 + $ git init -q 349 + $ mkdir lib 350 + $ echo "committed" > lib/a.ml 351 + $ git add . && git commit -q -m "initial" 352 + $ HASH1=$(git-mono split lib) 353 + 354 + Make dirty changes (not committed) 355 + 356 + $ echo "dirty" > lib/a.ml 357 + $ echo "new file" > lib/b.ml 358 + 359 + Split should still work and return same hash (reads object store, not working tree) 360 + 361 + $ HASH2=$(git-mono split lib) 362 + $ test "$HASH1" = "$HASH2" && echo "dirty tree ignored" 363 + dirty tree ignored 364 + $ cd .. 365 + 366 + Using -C to specify repository path 367 + -------------------------------------- 368 + 369 + $ mkdir repo17 && cd repo17 370 + $ git init -q 371 + $ mkdir lib 372 + $ echo "remote" > lib/a.ml 373 + $ git add . && git commit -q -m "initial" 374 + $ cd .. 375 + $ HASH=$(git-mono split -C repo17 lib) 376 + $ test -n "$HASH" && echo "got hash via -C" 377 + got hash via -C 378 + 379 + Large tree: split only extracts the right subtree 380 + --------------------------------------------------- 381 + 382 + $ mkdir repo19 && cd repo19 383 + $ git init -q 384 + $ mkdir -p lib bin doc test 385 + $ echo "lib" > lib/a.ml 386 + $ echo "bin" > bin/main.ml 387 + $ echo "doc" > doc/README.md 388 + $ echo "test" > test/test.ml 389 + $ git add . && git commit -q -m "initial" 390 + $ HASH=$(git-mono split lib) 391 + $ git ls-tree "$HASH" --name-only 392 + a.ml 393 + $ cd .. 394 + 395 + Commit message is preserved 396 + ----------------------------- 397 + 398 + $ mkdir repo20 && cd repo20 399 + $ git init -q 400 + $ mkdir lib 401 + $ echo "hello" > lib/a.ml 402 + $ git add . 403 + $ git commit -q -m "Detailed commit message" 404 + $ HASH=$(git-mono split lib) 405 + $ git log -1 --format="%s" "$HASH" 406 + Detailed commit message 407 + $ cd .. 408 + 409 + Prefix appearing partway through history 410 + ------------------------------------------ 411 + 412 + $ mkdir repo21 && cd repo21 413 + $ git init -q 414 + $ echo "root" > README.md 415 + $ git add . && git commit -q -m "no lib yet" 416 + $ mkdir lib 417 + $ echo "v1" > lib/a.ml 418 + $ git add . && git commit -q -m "add lib" 419 + $ echo "v2" > lib/a.ml 420 + $ git add . && git commit -q -m "update lib" 421 + $ HASH=$(git-mono split lib) 422 + 423 + Only 2 commits in split (the "no lib yet" commit is skipped) 424 + 425 + $ git rev-list "$HASH" | wc -l | tr -d ' ' 426 + 2 427 + $ git log --format="%s" "$HASH" 428 + update lib 429 + add lib 430 + $ cd .. 431 + 432 + Multiple files in prefix 433 + ------------------------- 434 + 435 + $ mkdir repo22 && cd repo22 436 + $ git init -q 437 + $ mkdir lib 438 + $ echo "a" > lib/a.ml 439 + $ echo "b" > lib/b.ml 440 + $ echo "c" > lib/c.ml 441 + $ git add . && git commit -q -m "initial" 442 + $ HASH=$(git-mono split lib) 443 + $ git ls-tree "$HASH" --name-only | sort 444 + a.ml 445 + b.ml 446 + c.ml 447 + $ cd .. 448 + 449 + Nested directories in prefix 450 + ------------------------------- 451 + 452 + $ mkdir repo23 && cd repo23 453 + $ git init -q 454 + $ mkdir -p lib/src lib/test 455 + $ echo "code" > lib/src/main.ml 456 + $ echo "test" > lib/test/test.ml 457 + $ git add . && git commit -q -m "initial" 458 + $ HASH=$(git-mono split lib) 459 + $ git ls-tree "$HASH" --name-only 460 + src 461 + test 462 + $ git ls-tree "$HASH:src" --name-only 463 + main.ml 464 + $ cd .. 465 + 466 + Cache file format: pairs of hex hashes 467 + ---------------------------------------- 468 + 469 + $ mkdir repo24 && cd repo24 470 + $ git init -q 471 + $ mkdir lib 472 + $ echo "hello" > lib/a.ml 473 + $ git add . && git commit -q -m "initial" 474 + $ git-mono split lib > /dev/null 475 + $ wc -l < .git/subtree-cache/lib | tr -d ' ' 476 + 1 477 + $ awk '{ print length($1), length($2) }' .git/subtree-cache/lib 478 + 40 40 479 + $ cd .. 480 + 481 + Second split with same tree but different message creates new commit 482 + --------------------------------------------------------------------- 483 + 484 + $ mkdir repo25 && cd repo25 485 + $ git init -q 486 + $ mkdir lib 487 + $ echo "same" > lib/a.ml 488 + $ git add . && git commit -q -m "commit A" 489 + $ echo "different" > lib/a.ml 490 + $ git add . && git commit -q -m "commit B" 491 + $ echo "same" > lib/a.ml 492 + $ git add . && git commit -q -m "commit C (same tree as A)" 493 + $ HASH=$(git-mono split lib) 494 + 495 + All 3 commits are present because the tree changes between adjacent parents 496 + 497 + $ git rev-list "$HASH" | wc -l | tr -d ' ' 498 + 3 499 + $ cd .. 500 + 501 + Idempotency: running split multiple times produces identical results 502 + ---------------------------------------------------------------------- 503 + 504 + $ mkdir repo26 && cd repo26 505 + $ git init -q 506 + $ mkdir lib 507 + $ echo "v1" > lib/a.ml 508 + $ git add . && git commit -q -m "first" 509 + $ echo "v2" > lib/a.ml 510 + $ git add . && git commit -q -m "second" 511 + $ echo "v3" > lib/a.ml 512 + $ git add . && git commit -q -m "third" 513 + 514 + First split (cold cache) 515 + 516 + $ HASH1=$(git-mono split lib) 517 + 518 + Second split (warm cache) 519 + 520 + $ HASH2=$(git-mono split lib) 521 + 522 + Third split (still warm cache) 523 + 524 + $ HASH3=$(git-mono split lib) 525 + 526 + All three runs produce the same hash 527 + 528 + $ test "$HASH1" = "$HASH2" && test "$HASH2" = "$HASH3" && echo "idempotent" 529 + idempotent 530 + 531 + $ cd .. 532 + 533 + Incremental: add commits one at a time 534 + ---------------------------------------- 535 + 536 + $ mkdir repo27 && cd repo27 537 + $ git init -q 538 + $ mkdir lib 539 + 540 + $ echo "v1" > lib/a.ml 541 + $ git add . && git commit -q -m "first" 542 + $ HASH_A=$(git-mono split lib) 543 + 544 + $ echo "v2" > lib/a.ml 545 + $ git add . && git commit -q -m "second" 546 + $ HASH_B=$(git-mono split lib) 547 + 548 + $ echo "v3" > lib/a.ml 549 + $ git add . && git commit -q -m "third" 550 + $ HASH_C=$(git-mono split lib) 551 + 552 + Each incremental split extends the chain 553 + 554 + $ git rev-list "$HASH_A" | wc -l | tr -d ' ' 555 + 1 556 + $ git rev-list "$HASH_B" | wc -l | tr -d ' ' 557 + 2 558 + $ git rev-list "$HASH_C" | wc -l | tr -d ' ' 559 + 3 560 + 561 + The chain is consistent: each split head's parent is the previous split head 562 + 563 + $ test "$(git rev-parse "$HASH_B^")" = "$HASH_A" && echo "B parent = A" 564 + B parent = A 565 + $ test "$(git rev-parse "$HASH_C^")" = "$HASH_B" && echo "C parent = B" 566 + C parent = B 567 + 568 + All split commits are reachable from the latest 569 + 570 + $ git merge-base --is-ancestor "$HASH_A" "$HASH_C" && echo "A is ancestor of C" 571 + A is ancestor of C 572 + $ cd .. 573 + 574 + Bidirectional incremental: split two branches independently 575 + ------------------------------------------------------------ 576 + 577 + $ mkdir repo28 && cd repo28 578 + $ git init -q 579 + $ mkdir lib 580 + $ echo "base" > lib/a.ml 581 + $ git add . && git commit -q -m "base commit" 582 + $ HASH_BASE=$(git-mono split lib) 583 + 584 + Create two branches from the same base 585 + 586 + $ git checkout -q -b branch-x 587 + $ echo "x" > lib/x.ml 588 + $ git add . && git commit -q -m "add x" 589 + $ HASH_X=$(git-mono split --rev branch-x lib) 590 + 591 + $ git checkout -q main 2>/dev/null || git checkout -q master 592 + $ echo "y" > lib/y.ml 593 + $ git add . && git commit -q -m "add y" 594 + $ HASH_Y=$(git-mono split lib) 595 + 596 + Both branches share the same split base 597 + 598 + $ test "$(git rev-parse "$HASH_X^")" = "$HASH_BASE" && echo "X parent = base" 599 + X parent = base 600 + $ test "$(git rev-parse "$HASH_Y^")" = "$HASH_BASE" && echo "Y parent = base" 601 + Y parent = base 602 + 603 + Now merge and split again 604 + 605 + $ git merge -q --no-edit branch-x 606 + $ HASH_MERGE=$(git-mono split lib) 607 + 608 + The merge split has both branch splits as parents 609 + 610 + $ git cat-file -p "$HASH_MERGE" | grep "^parent" | wc -l | tr -d ' ' 611 + 2 612 + 613 + The merge split is a descendant of both branch splits 614 + 615 + $ git merge-base --is-ancestor "$HASH_X" "$HASH_MERGE" && echo "X ancestor of merge" 616 + X ancestor of merge 617 + $ git merge-base --is-ancestor "$HASH_Y" "$HASH_MERGE" && echo "Y ancestor of merge" 618 + Y ancestor of merge 619 + $ cd .. 620 + 621 + Incremental after cache: only new commits are processed 622 + --------------------------------------------------------- 623 + 624 + $ mkdir repo29 && cd repo29 625 + $ git init -q 626 + $ mkdir lib 627 + $ echo "v1" > lib/a.ml 628 + $ git add . && git commit -q -m "first" 629 + $ echo "v2" > lib/a.ml 630 + $ git add . && git commit -q -m "second" 631 + $ git-mono split lib > /dev/null 632 + 633 + Cache should have 2 entries 634 + 635 + $ wc -l < .git/subtree-cache/lib | tr -d ' ' 636 + 2 637 + 638 + Add a new commit and re-split 639 + 640 + $ echo "v3" > lib/a.ml 641 + $ git add . && git commit -q -m "third" 642 + $ git-mono split lib > /dev/null 643 + 644 + Cache should now have 3 entries (only 1 new entry added) 645 + 646 + $ wc -l < .git/subtree-cache/lib | tr -d ' ' 647 + 3 648 + $ cd ..
+36
test/test.ml
··· 1 + (* Copyright (c) 2024-2026 Thomas Gazagnaire <thomas@gazagnaire.org> 2 + 3 + Permission to use, copy, modify, and distribute this software for any 4 + purpose with or without fee is hereby granted, provided that the above 5 + copyright notice and this permission notice appear in all copies. 6 + 7 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. *) 14 + 15 + let () = 16 + Alcotest.run "git" 17 + [ 18 + Test_hash.suite; 19 + Test_blob.suite; 20 + Test_user.suite; 21 + Test_tree.suite; 22 + Test_commit.suite; 23 + Test_tag.suite; 24 + Test_value.suite; 25 + Test_reference.suite; 26 + Test_pack.suite; 27 + Test_pack_index.suite; 28 + Test_config.suite; 29 + Test_index.suite; 30 + Test_repository.suite; 31 + Test_rev_list.suite; 32 + Test_subtree.suite; 33 + Test_worktree.suite; 34 + Test_diff.suite; 35 + Test_remote.suite; 36 + ]
+28
test/test_blob.ml
··· 1 + (* Copyright (c) 2024-2026 Thomas Gazagnaire <thomas@gazagnaire.org> 2 + 3 + Permission to use, copy, modify, and distribute this software for any 4 + purpose with or without fee is hereby granted, provided that the above 5 + copyright notice and this permission notice appear in all copies. 6 + 7 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. *) 14 + 15 + let test_digest () = 16 + (* git hash-object -t blob --stdin <<< "Hello, World!" gives this hash *) 17 + let content = "Hello, World!\n" in 18 + let blob = Git.Blob.of_string content in 19 + let hash = Git.Blob.digest blob in 20 + (* Verify roundtrip *) 21 + Alcotest.(check string) "content" content (Git.Blob.to_string blob); 22 + Alcotest.(check int) "length" 14 (Git.Blob.length blob); 23 + (* The hash should be deterministic *) 24 + let hash2 = Git.Blob.digest blob in 25 + Alcotest.(check (testable Git.Hash.pp Git.Hash.equal)) "same hash" hash hash2 26 + 27 + let tests = [ Alcotest.test_case "digest" `Quick test_digest ] 28 + let suite = ("blob", tests)
+3
test/test_blob.mli
··· 1 + (** Blob tests. *) 2 + 3 + val suite : string * unit Alcotest.test_case list
+61
test/test_commit.ml
··· 1 + (* Copyright (c) 2024-2026 Thomas Gazagnaire <thomas@gazagnaire.org> 2 + 3 + Permission to use, copy, modify, and distribute this software for any 4 + purpose with or without fee is hereby granted, provided that the above 5 + copyright notice and this permission notice appear in all copies. 6 + 7 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. *) 14 + 15 + let hash = Test_helpers.hash 16 + 17 + let test_roundtrip () = 18 + let tree_hash = Git.Hash.of_hex "da39a3ee5e6b4b0d3255bfef95601890afd80709" in 19 + let author = 20 + Git.User.v ~name:"Alice" ~email:"alice@example.com" ~date:1234567890L () 21 + in 22 + let commit = 23 + Git.Commit.v ~tree:tree_hash ~author ~committer:author 24 + (Some "Initial commit\n") 25 + in 26 + let s = Git.Commit.to_string commit in 27 + match Git.Commit.of_string s with 28 + | Ok commit' -> 29 + Alcotest.(check hash) "tree" tree_hash (Git.Commit.tree commit'); 30 + Alcotest.(check (list hash)) "parents" [] (Git.Commit.parents commit'); 31 + Alcotest.(check (option string)) 32 + "message" (Some "Initial commit\n") 33 + (Git.Commit.message commit') 34 + | Error (`Msg m) -> Alcotest.fail m 35 + 36 + let test_with_parents () = 37 + let tree_hash = Git.Hash.of_hex "da39a3ee5e6b4b0d3255bfef95601890afd80709" in 38 + let parent1 = Git.Hash.of_hex "0000000000000000000000000000000000000001" in 39 + let parent2 = Git.Hash.of_hex "0000000000000000000000000000000000000002" in 40 + let author = 41 + Git.User.v ~name:"Alice" ~email:"alice@example.com" ~date:1234567890L () 42 + in 43 + let commit = 44 + Git.Commit.v ~tree:tree_hash ~author ~committer:author 45 + ~parents:[ parent1; parent2 ] (Some "Merge commit\n") 46 + in 47 + let s = Git.Commit.to_string commit in 48 + match Git.Commit.of_string s with 49 + | Ok commit' -> 50 + Alcotest.(check (list hash)) 51 + "parents" [ parent1; parent2 ] 52 + (Git.Commit.parents commit') 53 + | Error (`Msg m) -> Alcotest.fail m 54 + 55 + let tests = 56 + [ 57 + Alcotest.test_case "roundtrip" `Quick test_roundtrip; 58 + Alcotest.test_case "with_parents" `Quick test_with_parents; 59 + ] 60 + 61 + let suite = ("commit", tests)
+3
test/test_commit.mli
··· 1 + (** Commit tests. *) 2 + 3 + val suite : string * unit Alcotest.test_case list
+408
test/test_config.ml
··· 1 + (* Copyright (c) 2024-2026 Thomas Gazagnaire <thomas@gazagnaire.org> 2 + 3 + Permission to use, copy, modify, and distribute this software for any 4 + purpose with or without fee is hereby granted, provided that the above 5 + copyright notice and this permission notice appear in all copies. 6 + 7 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. *) 14 + 15 + let with_temp_repo = Test_helpers.with_temp_repo 16 + 17 + let test_parse () = 18 + let config_content = 19 + {|[core] 20 + repositoryformatversion = 0 21 + filemode = true 22 + bare = false 23 + [remote "origin"] 24 + url = https://github.com/user/repo.git 25 + fetch = +refs/heads/*:refs/remotes/origin/* 26 + pushurl = git@github.com:user/repo.git 27 + [branch "main"] 28 + remote = origin 29 + merge = refs/heads/main 30 + |} 31 + in 32 + let config = Git.Config.of_string config_content in 33 + let core = Git.Config.section "core" in 34 + Alcotest.(check (option string)) 35 + "core.bare" (Some "false") 36 + (Git.Config.find config core "bare"); 37 + let remotes = Git.Config.remotes config in 38 + Alcotest.(check int) "1 remote" 1 (List.length remotes); 39 + let origin = List.hd remotes in 40 + Alcotest.(check string) "remote name" "origin" origin.name; 41 + Alcotest.(check (option string)) 42 + "remote url" (Some "https://github.com/user/repo.git") origin.url; 43 + Alcotest.(check (option string)) 44 + "remote pushurl" (Some "git@github.com:user/repo.git") origin.push_url; 45 + let branches = Git.Config.branches config in 46 + Alcotest.(check int) "1 branch" 1 (List.length branches); 47 + let main = List.hd branches in 48 + Alcotest.(check string) "branch name" "main" main.name; 49 + Alcotest.(check (option string)) "branch remote" (Some "origin") main.remote; 50 + Alcotest.(check (option string)) 51 + "branch merge" (Some "refs/heads/main") main.merge 52 + 53 + let test_section_subsection () = 54 + let config_content = 55 + {|[remote "origin"] 56 + url = https://github.com/foo/bar.git 57 + [remote "upstream"] 58 + url = https://github.com/other/bar.git 59 + [branch "main"] 60 + remote = origin 61 + [branch "feature"] 62 + remote = upstream 63 + rebase = true 64 + |} 65 + in 66 + let config = Git.Config.of_string config_content in 67 + let origin_section = Git.Config.section_sub "remote" "origin" in 68 + Alcotest.(check (option string)) 69 + "origin url" (Some "https://github.com/foo/bar.git") 70 + (Git.Config.find config origin_section "url"); 71 + let upstream_section = Git.Config.section_sub "remote" "upstream" in 72 + Alcotest.(check (option string)) 73 + "upstream url" (Some "https://github.com/other/bar.git") 74 + (Git.Config.find config upstream_section "url"); 75 + let remote_sections = Git.Config.sections config "remote" in 76 + Alcotest.(check int) "2 remote sections" 2 (List.length remote_sections); 77 + let branch_sections = Git.Config.sections config "branch" in 78 + Alcotest.(check int) "2 branch sections" 2 (List.length branch_sections) 79 + 80 + let test_comments () = 81 + let config_content = 82 + {|[core] 83 + # This is a comment 84 + bare = false ; inline comment 85 + [remote "origin"] 86 + url = https://example.com 87 + |} 88 + in 89 + let config = Git.Config.of_string config_content in 90 + let core = Git.Config.section "core" in 91 + Alcotest.(check (option string)) 92 + "bare without comment" (Some "false") 93 + (Git.Config.find config core "bare") 94 + 95 + let test_boolean_values () = 96 + let config_content = 97 + {|[test] 98 + yes_val = yes 99 + no_val = no 100 + on_val = on 101 + off_val = off 102 + true_val = true 103 + false_val = false 104 + one_val = 1 105 + zero_val = 0 106 + empty_val = 107 + bare_key 108 + |} 109 + in 110 + let config = Git.Config.of_string config_content in 111 + let test = Git.Config.section "test" in 112 + Alcotest.(check (option bool)) 113 + "yes" (Some true) 114 + (Git.Config.bool config test "yes_val"); 115 + Alcotest.(check (option bool)) 116 + "on" (Some true) 117 + (Git.Config.bool config test "on_val"); 118 + Alcotest.(check (option bool)) 119 + "true" (Some true) 120 + (Git.Config.bool config test "true_val"); 121 + Alcotest.(check (option bool)) 122 + "1" (Some true) 123 + (Git.Config.bool config test "one_val"); 124 + Alcotest.(check (option bool)) 125 + "bare key" (Some true) 126 + (Git.Config.bool config test "bare_key"); 127 + Alcotest.(check (option bool)) 128 + "no" (Some false) 129 + (Git.Config.bool config test "no_val"); 130 + Alcotest.(check (option bool)) 131 + "off" (Some false) 132 + (Git.Config.bool config test "off_val"); 133 + Alcotest.(check (option bool)) 134 + "false" (Some false) 135 + (Git.Config.bool config test "false_val"); 136 + Alcotest.(check (option bool)) 137 + "0" (Some false) 138 + (Git.Config.bool config test "zero_val"); 139 + Alcotest.(check (option bool)) 140 + "empty" (Some false) 141 + (Git.Config.bool config test "empty_val") 142 + 143 + let test_multivalue () = 144 + let config_content = 145 + {|[remote "origin"] 146 + url = https://github.com/foo/bar.git 147 + fetch = +refs/heads/*:refs/remotes/origin/* 148 + fetch = +refs/tags/*:refs/tags/* 149 + fetch = +refs/notes/*:refs/notes/* 150 + |} 151 + in 152 + let config = Git.Config.of_string config_content in 153 + let origin = Git.Config.section_sub "remote" "origin" in 154 + Alcotest.(check (option string)) 155 + "first fetch" (Some "+refs/heads/*:refs/remotes/origin/*") 156 + (Git.Config.find config origin "fetch"); 157 + let all_fetch = Git.Config.all config origin "fetch" in 158 + Alcotest.(check int) "3 fetch refspecs" 3 (List.length all_fetch); 159 + Alcotest.(check (list string)) 160 + "all fetch values" 161 + [ 162 + "+refs/heads/*:refs/remotes/origin/*"; 163 + "+refs/tags/*:refs/tags/*"; 164 + "+refs/notes/*:refs/notes/*"; 165 + ] 166 + all_fetch 167 + 168 + let test_escape_sequences () = 169 + let config_content = 170 + {|[remote "foo\"bar"] 171 + url = "value with \"quotes\"" 172 + [section "path\\with\\backslash"] 173 + key = "line1\nline2" 174 + |} 175 + in 176 + let config = Git.Config.of_string config_content in 177 + let sections = Git.Config.sections config "remote" in 178 + Alcotest.(check int) "1 remote" 1 (List.length sections); 179 + let remote = List.hd sections in 180 + Alcotest.(check (option string)) 181 + "subsection with quote" (Some "foo\"bar") remote.subsection; 182 + Alcotest.(check (option string)) 183 + "quoted value" (Some "value with \"quotes\"") 184 + (Git.Config.find config remote "url"); 185 + let path_sections = Git.Config.sections config "section" in 186 + Alcotest.(check int) "1 section" 1 (List.length path_sections); 187 + let path_section = List.hd path_sections in 188 + Alcotest.(check (option string)) 189 + "subsection with backslash" (Some "path\\with\\backslash") 190 + path_section.subsection; 191 + Alcotest.(check (option string)) 192 + "value with newline" (Some "line1\nline2") 193 + (Git.Config.find config path_section "key") 194 + 195 + let test_core () = 196 + let config_content = 197 + {|[core] 198 + repositoryformatversion = 1 199 + filemode = false 200 + bare = true 201 + ignorecase = true 202 + |} 203 + in 204 + let config = Git.Config.of_string config_content in 205 + let core = Git.Config.core config in 206 + Alcotest.(check int) "version" 1 core.repositoryformatversion; 207 + Alcotest.(check bool) "filemode" false core.filemode; 208 + Alcotest.(check bool) "bare" true core.bare; 209 + Alcotest.(check bool) "ignorecase" true core.ignorecase 210 + 211 + let test_core_defaults () = 212 + let config = Git.Config.of_string "[other]\n\tkey = value\n" in 213 + let core = Git.Config.core config in 214 + Alcotest.(check int) "default version" 0 core.repositoryformatversion; 215 + Alcotest.(check bool) "default filemode" true core.filemode; 216 + Alcotest.(check bool) "default bare" false core.bare; 217 + Alcotest.(check bool) "default ignorecase" false core.ignorecase 218 + 219 + let test_user () = 220 + let config_content = 221 + {|[user] 222 + name = Alice Smith 223 + email = alice@example.com 224 + |} 225 + in 226 + let config = Git.Config.of_string config_content in 227 + let user = Git.Config.user config in 228 + Alcotest.(check (option string)) "name" (Some "Alice Smith") user.name; 229 + Alcotest.(check (option string)) "email" (Some "alice@example.com") user.email 230 + 231 + let test_roundtrip () = 232 + let core = 233 + { 234 + Git.Config.bare = false; 235 + filemode = true; 236 + ignorecase = false; 237 + repositoryformatversion = 0; 238 + } 239 + in 240 + let remote = 241 + { 242 + Git.Config.name = "origin"; 243 + url = Some "https://example.com"; 244 + push_url = None; 245 + fetch = [ "+refs/heads/*:refs/remotes/origin/*" ]; 246 + } 247 + in 248 + let branch = 249 + { 250 + Git.Config.name = "main"; 251 + remote = Some "origin"; 252 + merge = Some "refs/heads/main"; 253 + rebase = Some true; 254 + } 255 + in 256 + let config = 257 + Git.Config.of_core core 258 + @ Git.Config.of_remote remote 259 + @ Git.Config.of_branch branch 260 + in 261 + let serialized = Git.Config.to_string config in 262 + let parsed = Git.Config.of_string serialized in 263 + let parsed_core = Git.Config.core parsed in 264 + Alcotest.(check bool) "roundtrip bare" core.bare parsed_core.bare; 265 + let parsed_remotes = Git.Config.remotes parsed in 266 + Alcotest.(check int) "roundtrip 1 remote" 1 (List.length parsed_remotes); 267 + let parsed_origin = List.hd parsed_remotes in 268 + Alcotest.(check string) "roundtrip remote name" "origin" parsed_origin.name; 269 + let parsed_branches = Git.Config.branches parsed in 270 + Alcotest.(check int) "roundtrip 1 branch" 1 (List.length parsed_branches); 271 + let parsed_main = List.hd parsed_branches in 272 + Alcotest.(check (option bool)) 273 + "roundtrip rebase" (Some true) parsed_main.rebase 274 + 275 + let test_set_unset () = 276 + let config = Git.Config.empty in 277 + let core = Git.Config.section "core" in 278 + let config = Git.Config.set config ~section:core ~key:"bare" ~value:"false" in 279 + let config = 280 + Git.Config.set config ~section:core ~key:"filemode" ~value:"true" 281 + in 282 + Alcotest.(check (option string)) 283 + "bare" (Some "false") 284 + (Git.Config.find config core "bare"); 285 + Alcotest.(check (option string)) 286 + "filemode" (Some "true") 287 + (Git.Config.find config core "filemode"); 288 + let config = Git.Config.set config ~section:core ~key:"bare" ~value:"true" in 289 + Alcotest.(check (option string)) 290 + "bare updated" (Some "true") 291 + (Git.Config.find config core "bare"); 292 + let config = Git.Config.unset config ~section:core ~key:"bare" in 293 + Alcotest.(check (option string)) 294 + "bare unset" None 295 + (Git.Config.find config core "bare"); 296 + Alcotest.(check (option string)) 297 + "filemode still there" (Some "true") 298 + (Git.Config.find config core "filemode") 299 + 300 + let test_repo_remotes () = 301 + with_temp_repo @@ fun fs tmp_dir -> 302 + let repo = Git.Repository.init ~fs tmp_dir in 303 + let git_dir = Fpath.to_string (Git.Repository.git_dir repo) in 304 + let config_content = 305 + {|[core] 306 + bare = false 307 + [remote "origin"] 308 + url = https://github.com/user/repo.git 309 + [remote "upstream"] 310 + url = https://github.com/other/repo.git 311 + |} 312 + in 313 + let config_path = Eio.Path.(fs / git_dir / "config") in 314 + Eio.Path.save ~create:(`Or_truncate 0o644) config_path config_content; 315 + let remotes = Git.Repository.list_remotes repo in 316 + Alcotest.(check int) "2 remotes" 2 (List.length remotes); 317 + Alcotest.(check bool) "has origin" true (List.mem "origin" remotes); 318 + Alcotest.(check bool) "has upstream" true (List.mem "upstream" remotes); 319 + Alcotest.(check (option string)) 320 + "origin url" (Some "https://github.com/user/repo.git") 321 + (Git.Repository.remote_url repo "origin"); 322 + Alcotest.(check (option string)) 323 + "unknown remote" None 324 + (Git.Repository.remote_url repo "nonexistent") 325 + 326 + let test_add_remote () = 327 + with_temp_repo @@ fun fs tmp_dir -> 328 + let repo = Git.Repository.init ~fs tmp_dir in 329 + let result = 330 + Git.Repository.add_remote repo ~name:"origin" 331 + ~url:"https://github.com/user/repo.git" () 332 + in 333 + Alcotest.(check bool) "add_remote succeeds" true (Result.is_ok result); 334 + let remotes = Git.Repository.list_remotes repo in 335 + Alcotest.(check int) "1 remote" 1 (List.length remotes); 336 + Alcotest.(check bool) "has origin" true (List.mem "origin" remotes); 337 + Alcotest.(check (option string)) 338 + "origin url" (Some "https://github.com/user/repo.git") 339 + (Git.Repository.remote_url repo "origin"); 340 + let remote = Git.Repository.remote repo "origin" in 341 + Alcotest.(check bool) "remote found" true (Option.is_some remote); 342 + let remote = Option.get remote in 343 + Alcotest.(check int) "1 fetch refspec" 1 (List.length remote.fetch); 344 + Alcotest.(check string) 345 + "default fetch" "+refs/heads/*:refs/remotes/origin/*" (List.hd remote.fetch); 346 + let result = 347 + Git.Repository.add_remote repo ~name:"origin" 348 + ~url:"https://other.com/repo.git" () 349 + in 350 + Alcotest.(check bool) "duplicate fails" true (Result.is_error result) 351 + 352 + let test_add_remote_push_url () = 353 + with_temp_repo @@ fun fs tmp_dir -> 354 + let repo = Git.Repository.init ~fs tmp_dir in 355 + let result = 356 + Git.Repository.add_remote repo ~name:"origin" 357 + ~url:"https://github.com/user/repo.git" 358 + ~push_url:"git@github.com:user/repo.git" () 359 + in 360 + Alcotest.(check bool) "add_remote succeeds" true (Result.is_ok result); 361 + Alcotest.(check (option string)) 362 + "push url" (Some "git@github.com:user/repo.git") 363 + (Git.Repository.push_url repo "origin") 364 + 365 + let test_remove_remote () = 366 + with_temp_repo @@ fun fs tmp_dir -> 367 + let repo = Git.Repository.init ~fs tmp_dir in 368 + let _ = 369 + Git.Repository.add_remote repo ~name:"origin" 370 + ~url:"https://github.com/user/repo.git" () 371 + in 372 + let _ = 373 + Git.Repository.add_remote repo ~name:"upstream" 374 + ~url:"https://github.com/other/repo.git" () 375 + in 376 + Alcotest.(check int) 377 + "2 remotes" 2 378 + (List.length (Git.Repository.list_remotes repo)); 379 + let result = Git.Repository.remove_remote repo "upstream" in 380 + Alcotest.(check bool) "remove succeeds" true (Result.is_ok result); 381 + let remotes = Git.Repository.list_remotes repo in 382 + Alcotest.(check int) "1 remote" 1 (List.length remotes); 383 + Alcotest.(check bool) "has origin" true (List.mem "origin" remotes); 384 + Alcotest.(check bool) "no upstream" false (List.mem "upstream" remotes); 385 + let result = Git.Repository.remove_remote repo "nonexistent" in 386 + Alcotest.(check bool) "remove nonexistent fails" true (Result.is_error result) 387 + 388 + let tests = 389 + [ 390 + Alcotest.test_case "parse" `Quick test_parse; 391 + Alcotest.test_case "section_subsection" `Quick test_section_subsection; 392 + Alcotest.test_case "comments" `Quick test_comments; 393 + Alcotest.test_case "boolean_values" `Quick test_boolean_values; 394 + Alcotest.test_case "multivalue" `Quick test_multivalue; 395 + Alcotest.test_case "escape_sequences" `Quick test_escape_sequences; 396 + Alcotest.test_case "core" `Quick test_core; 397 + Alcotest.test_case "core_defaults" `Quick test_core_defaults; 398 + Alcotest.test_case "user" `Quick test_user; 399 + Alcotest.test_case "roundtrip" `Quick test_roundtrip; 400 + Alcotest.test_case "set_unset" `Quick test_set_unset; 401 + Alcotest.test_case "repo_remotes" `Quick test_repo_remotes; 402 + Alcotest.test_case "add_remote" `Quick test_add_remote; 403 + Alcotest.test_case "add_remote_with_push_url" `Quick 404 + test_add_remote_push_url; 405 + Alcotest.test_case "remove_remote" `Quick test_remove_remote; 406 + ] 407 + 408 + let suite = ("config", tests)
+3
test/test_config.mli
··· 1 + (** Config tests. *) 2 + 3 + val suite : string * unit Alcotest.test_case list
+80
test/test_diff.ml
··· 1 + (* Copyright (c) 2024-2026 Thomas Gazagnaire <thomas@gazagnaire.org> 2 + 3 + Permission to use, copy, modify, and distribute this software for any 4 + purpose with or without fee is hereby granted, provided that the above 5 + copyright notice and this permission notice appear in all copies. 6 + 7 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. *) 14 + 15 + let test_stats_empty () = 16 + let s = Git.Diff.stats [] in 17 + Alcotest.(check int) "additions" 0 s.additions; 18 + Alcotest.(check int) "deletions" 0 s.deletions; 19 + Alcotest.(check int) "modifications" 0 s.modifications 20 + 21 + let test_stats_mixed () = 22 + let h = Git.Hash.of_hex (String.make 40 'a') in 23 + let changes = 24 + [ 25 + Git.Diff.Added { path = "a.txt"; hash = h; perm = `Normal }; 26 + Git.Diff.Added { path = "b.txt"; hash = h; perm = `Normal }; 27 + Git.Diff.Removed { path = "c.txt"; hash = h; perm = `Normal }; 28 + Git.Diff.Modified 29 + { 30 + path = "d.txt"; 31 + old_hash = h; 32 + new_hash = h; 33 + old_perm = `Normal; 34 + new_perm = `Normal; 35 + }; 36 + ] 37 + in 38 + let s = Git.Diff.stats changes in 39 + Alcotest.(check int) "additions" 2 s.additions; 40 + Alcotest.(check int) "deletions" 1 s.deletions; 41 + Alcotest.(check int) "modifications" 1 s.modifications 42 + 43 + let test_filter_by_path_empty () = 44 + let result = Git.Diff.filter_by_path ~prefix:"src" [] in 45 + Alcotest.(check int) "empty list" 0 (List.length result) 46 + 47 + let test_filter_by_path () = 48 + let h = Git.Hash.of_hex (String.make 40 'b') in 49 + let changes = 50 + [ 51 + Git.Diff.Added { path = "src/a.ml"; hash = h; perm = `Normal }; 52 + Git.Diff.Added { path = "test/b.ml"; hash = h; perm = `Normal }; 53 + Git.Diff.Added { path = "src/lib/c.ml"; hash = h; perm = `Normal }; 54 + ] 55 + in 56 + let filtered = Git.Diff.filter_by_path ~prefix:"src" changes in 57 + Alcotest.(check int) "filtered count" 2 (List.length filtered) 58 + 59 + let test_pp_change () = 60 + let h = Git.Hash.of_hex (String.make 40 'c') in 61 + let change = Git.Diff.Added { path = "foo.ml"; hash = h; perm = `Normal } in 62 + let s = Fmt.to_to_string Git.Diff.pp_change change in 63 + Alcotest.(check bool) "contains path" true (String.length s > 0) 64 + 65 + let test_pp_stats () = 66 + let s = { Git.Diff.additions = 1; deletions = 2; modifications = 3 } in 67 + let out = Fmt.to_to_string Git.Diff.pp_stats s in 68 + Alcotest.(check bool) "non-empty" true (String.length out > 0) 69 + 70 + let tests = 71 + [ 72 + Alcotest.test_case "stats_empty" `Quick test_stats_empty; 73 + Alcotest.test_case "stats_mixed" `Quick test_stats_mixed; 74 + Alcotest.test_case "filter_by_path_empty" `Quick test_filter_by_path_empty; 75 + Alcotest.test_case "filter_by_path" `Quick test_filter_by_path; 76 + Alcotest.test_case "pp_change" `Quick test_pp_change; 77 + Alcotest.test_case "pp_stats" `Quick test_pp_stats; 78 + ] 79 + 80 + let suite = ("diff", tests)
+3
test/test_diff.mli
··· 1 + (** Diff tests. *) 2 + 3 + val suite : string * unit Alcotest.test_case list
+27
test/test_hash.ml
··· 1 + (* Copyright (c) 2024-2026 Thomas Gazagnaire <thomas@gazagnaire.org> 2 + 3 + Permission to use, copy, modify, and distribute this software for any 4 + purpose with or without fee is hereby granted, provided that the above 5 + copyright notice and this permission notice appear in all copies. 6 + 7 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. *) 14 + 15 + let hash = Test_helpers.hash 16 + 17 + let test_roundtrip () = 18 + let hex = "da39a3ee5e6b4b0d3255bfef95601890afd80709" in 19 + let h = Git.Hash.of_hex hex in 20 + Alcotest.(check string) "hex roundtrip" hex (Git.Hash.to_hex h); 21 + let raw = Git.Hash.to_raw_string h in 22 + Alcotest.(check int) "raw length" 20 (String.length raw); 23 + let h' = Git.Hash.of_raw_string raw in 24 + Alcotest.(check hash) "raw roundtrip" h h' 25 + 26 + let tests = [ Alcotest.test_case "roundtrip" `Quick test_roundtrip ] 27 + let suite = ("hash", tests)
+3
test/test_hash.mli
··· 1 + (** Hash tests. *) 2 + 3 + val suite : string * unit Alcotest.test_case list
+357
test/test_index.ml
··· 1 + (* Copyright (c) 2024-2026 Thomas Gazagnaire <thomas@gazagnaire.org> 2 + 3 + Permission to use, copy, modify, and distribute this software for any 4 + purpose with or without fee is hereby granted, provided that the above 5 + copyright notice and this permission notice appear in all copies. 6 + 7 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. *) 14 + 15 + let hash = Test_helpers.hash 16 + let with_temp_repo = Test_helpers.with_temp_repo 17 + 18 + let test_empty () = 19 + let idx = Git.Index.empty in 20 + Alcotest.(check int) 21 + "empty has 0 entries" 0 22 + (List.length (Git.Index.entries idx)); 23 + let serialized = Git.Index.to_string idx in 24 + Alcotest.(check bool) 25 + "starts with DIRC" true 26 + (String.sub serialized 0 4 = "DIRC") 27 + 28 + let test_roundtrip () = 29 + let hash1 = Git.Hash.of_hex "1234567890abcdef1234567890abcdef12345678" in 30 + let hash2 = Git.Hash.of_hex "abcdef1234567890abcdef1234567890abcdef12" in 31 + let entry1 : Git.Index.entry = 32 + { 33 + ctime_s = 1000l; 34 + ctime_ns = 0l; 35 + mtime_s = 1000l; 36 + mtime_ns = 0l; 37 + dev = 0l; 38 + ino = 0l; 39 + mode = Git.Index.Regular; 40 + uid = 0l; 41 + gid = 0l; 42 + size = 100l; 43 + hash = hash1; 44 + flags = 0; 45 + name = "file.txt"; 46 + } 47 + in 48 + let entry2 : Git.Index.entry = 49 + { 50 + ctime_s = 2000l; 51 + ctime_ns = 0l; 52 + mtime_s = 2000l; 53 + mtime_ns = 0l; 54 + dev = 0l; 55 + ino = 0l; 56 + mode = Git.Index.Executable; 57 + uid = 0l; 58 + gid = 0l; 59 + size = 200l; 60 + hash = hash2; 61 + flags = 0; 62 + name = "script.sh"; 63 + } 64 + in 65 + let idx = Git.Index.add (Git.Index.add Git.Index.empty entry1) entry2 in 66 + Alcotest.(check int) "2 entries" 2 (List.length (Git.Index.entries idx)); 67 + let serialized = Git.Index.to_string idx in 68 + match Git.Index.of_string serialized with 69 + | Error (`Msg e) -> Alcotest.fail e 70 + | Ok parsed -> 71 + Alcotest.(check int) 72 + "parsed has 2 entries" 2 73 + (List.length (Git.Index.entries parsed)); 74 + let entries = Git.Index.entries parsed in 75 + let e1 = List.hd entries in 76 + let e2 = List.nth entries 1 in 77 + Alcotest.(check string) "first entry name" "file.txt" e1.name; 78 + Alcotest.(check string) "second entry name" "script.sh" e2.name; 79 + Alcotest.(check string) 80 + "first entry hash" (Git.Hash.to_hex hash1) (Git.Hash.to_hex e1.hash) 81 + 82 + let test_add_remove () = 83 + let hash = Git.Hash.of_hex "1234567890abcdef1234567890abcdef12345678" in 84 + let entry : Git.Index.entry = 85 + { 86 + ctime_s = 0l; 87 + ctime_ns = 0l; 88 + mtime_s = 0l; 89 + mtime_ns = 0l; 90 + dev = 0l; 91 + ino = 0l; 92 + mode = Git.Index.Regular; 93 + uid = 0l; 94 + gid = 0l; 95 + size = 0l; 96 + hash; 97 + flags = 0; 98 + name = "test.txt"; 99 + } 100 + in 101 + let idx = Git.Index.empty in 102 + Alcotest.(check bool) "not in empty" false (Git.Index.mem idx "test.txt"); 103 + let idx = Git.Index.add idx entry in 104 + Alcotest.(check bool) "after add" true (Git.Index.mem idx "test.txt"); 105 + let idx = Git.Index.remove idx "test.txt" in 106 + Alcotest.(check bool) "after remove" false (Git.Index.mem idx "test.txt") 107 + 108 + let test_find () = 109 + let hash = Git.Hash.of_hex "1234567890abcdef1234567890abcdef12345678" in 110 + let entry : Git.Index.entry = 111 + { 112 + ctime_s = 0l; 113 + ctime_ns = 0l; 114 + mtime_s = 0l; 115 + mtime_ns = 0l; 116 + dev = 0l; 117 + ino = 0l; 118 + mode = Git.Index.Executable; 119 + uid = 0l; 120 + gid = 0l; 121 + size = 42l; 122 + hash; 123 + flags = 0; 124 + name = "bin/tool"; 125 + } 126 + in 127 + let idx = Git.Index.add Git.Index.empty entry in 128 + match Git.Index.find idx "bin/tool" with 129 + | None -> Alcotest.fail "entry not found" 130 + | Some found -> 131 + Alcotest.(check int32) "size" 42l found.size; 132 + Alcotest.(check bool) 133 + "executable mode" true 134 + (found.mode = Git.Index.Executable) 135 + 136 + let test_sorted () = 137 + let make_entry name = 138 + let hash = Git.Hash.of_hex "1234567890abcdef1234567890abcdef12345678" in 139 + ({ 140 + ctime_s = 0l; 141 + ctime_ns = 0l; 142 + mtime_s = 0l; 143 + mtime_ns = 0l; 144 + dev = 0l; 145 + ino = 0l; 146 + mode = Git.Index.Regular; 147 + uid = 0l; 148 + gid = 0l; 149 + size = 0l; 150 + hash; 151 + flags = 0; 152 + name; 153 + } 154 + : Git.Index.entry) 155 + in 156 + let idx = Git.Index.empty in 157 + let idx = Git.Index.add idx (make_entry "z.txt") in 158 + let idx = Git.Index.add idx (make_entry "a.txt") in 159 + let idx = Git.Index.add idx (make_entry "m.txt") in 160 + let names = 161 + List.map (fun (e : Git.Index.entry) -> e.name) (Git.Index.entries idx) 162 + in 163 + Alcotest.(check (list string)) "sorted" [ "a.txt"; "m.txt"; "z.txt" ] names 164 + 165 + let test_remove_prefix () = 166 + let make_entry name = 167 + let hash = Git.Hash.of_hex "1234567890abcdef1234567890abcdef12345678" in 168 + ({ 169 + ctime_s = 0l; 170 + ctime_ns = 0l; 171 + mtime_s = 0l; 172 + mtime_ns = 0l; 173 + dev = 0l; 174 + ino = 0l; 175 + mode = Git.Index.Regular; 176 + uid = 0l; 177 + gid = 0l; 178 + size = 0l; 179 + hash; 180 + flags = 0; 181 + name; 182 + } 183 + : Git.Index.entry) 184 + in 185 + let idx = Git.Index.empty in 186 + let idx = Git.Index.add idx (make_entry "README.md") in 187 + let idx = Git.Index.add idx (make_entry "packages/foo/opam") in 188 + let idx = Git.Index.add idx (make_entry "packages/foo/foo.dev/opam") in 189 + let idx = Git.Index.add idx (make_entry "packages/bar/opam") in 190 + let idx = Git.Index.add idx (make_entry "src/main.ml") in 191 + Alcotest.(check int) "5 entries" 5 (List.length (Git.Index.entries idx)); 192 + let idx = Git.Index.remove_prefix idx "packages/foo" in 193 + let names = 194 + List.map (fun (e : Git.Index.entry) -> e.name) (Git.Index.entries idx) 195 + in 196 + Alcotest.(check (list string)) 197 + "after remove" 198 + [ "README.md"; "packages/bar/opam"; "src/main.ml" ] 199 + names; 200 + let idx = Git.Index.remove_prefix idx "packages" in 201 + let names = 202 + List.map (fun (e : Git.Index.entry) -> e.name) (Git.Index.entries idx) 203 + in 204 + Alcotest.(check (list string)) 205 + "after remove packages" 206 + [ "README.md"; "src/main.ml" ] 207 + names 208 + 209 + let test_to_tree () = 210 + with_temp_repo @@ fun fs tmp_dir -> 211 + let repo = Git.Repository.init ~fs tmp_dir in 212 + let hash1 = Git.Repository.write_blob repo "content 1" in 213 + let hash2 = Git.Repository.write_blob repo "content 2" in 214 + let hash3 = Git.Repository.write_blob repo "content 3" in 215 + let make_entry name hash = 216 + ({ 217 + ctime_s = 0l; 218 + ctime_ns = 0l; 219 + mtime_s = 0l; 220 + mtime_ns = 0l; 221 + dev = 0l; 222 + ino = 0l; 223 + mode = Git.Index.Regular; 224 + uid = 0l; 225 + gid = 0l; 226 + size = 0l; 227 + hash; 228 + flags = 0; 229 + name; 230 + } 231 + : Git.Index.entry) 232 + in 233 + let idx = Git.Index.empty in 234 + let idx = Git.Index.add idx (make_entry "README.md" hash1) in 235 + let idx = Git.Index.add idx (make_entry "src/main.ml" hash2) in 236 + let idx = Git.Index.add idx (make_entry "src/lib/util.ml" hash3) in 237 + let tree_hash = 238 + Git.Index.to_tree 239 + ~write_tree:(Git.Repository.write_tree repo) 240 + ~write_blob:(Git.Repository.write_blob repo) 241 + idx 242 + in 243 + match Git.Repository.read repo tree_hash with 244 + | Error _ -> Alcotest.fail "failed to read tree" 245 + | Ok (Git.Value.Tree tree) -> 246 + let entries = Git.Tree.to_list tree in 247 + Alcotest.(check int) "2 entries at root" 2 (List.length entries); 248 + let names = List.map (fun (e : Git.Tree.entry) -> e.name) entries in 249 + Alcotest.(check bool) "has README" true (List.mem "README.md" names); 250 + Alcotest.(check bool) "has src" true (List.mem "src" names) 251 + | Ok _ -> Alcotest.fail "expected tree" 252 + 253 + let test_commit_index () = 254 + with_temp_repo @@ fun fs tmp_dir -> 255 + let repo = Git.Repository.init ~fs tmp_dir in 256 + let hash = Git.Repository.write_blob repo "Hello, World!" in 257 + let entry : Git.Index.entry = 258 + { 259 + ctime_s = 0l; 260 + ctime_ns = 0l; 261 + mtime_s = 0l; 262 + mtime_ns = 0l; 263 + dev = 0l; 264 + ino = 0l; 265 + mode = Git.Index.Regular; 266 + uid = 0l; 267 + gid = 0l; 268 + size = 13l; 269 + hash; 270 + flags = 0; 271 + name = "hello.txt"; 272 + } 273 + in 274 + let index = Git.Index.add Git.Index.empty entry in 275 + Git.Repository.write_index repo index; 276 + let author = 277 + Git.User.v ~name:"Test" ~email:"test@example.com" ~date:1700000000L () 278 + in 279 + match 280 + Git.Repository.commit_index repo ~author ~committer:author 281 + ~message:"Initial commit" () 282 + with 283 + | Error (`Msg e) -> Alcotest.fail e 284 + | Ok commit_hash -> ( 285 + match Git.Repository.read repo commit_hash with 286 + | Error _ -> Alcotest.fail "couldn't read commit" 287 + | Ok (Git.Value.Commit commit) -> ( 288 + Alcotest.(check (option string)) 289 + "message" (Some "Initial commit") 290 + (Git.Commit.message commit); 291 + match Git.Repository.head repo with 292 + | None -> Alcotest.fail "HEAD not updated" 293 + | Some h -> 294 + Alcotest.(check string) 295 + "HEAD points to commit" 296 + (Git.Hash.to_hex commit_hash) 297 + (Git.Hash.to_hex h)) 298 + | Ok _ -> Alcotest.fail "expected commit") 299 + 300 + let test_checkout () = 301 + with_temp_repo @@ fun fs tmp_dir -> 302 + let repo = Git.Repository.init ~fs tmp_dir in 303 + let readme_hash = Git.Repository.write_blob repo "# Hello World" in 304 + let main_hash = 305 + Git.Repository.write_blob repo "let () = print_endline \"Hello\"" 306 + in 307 + let src_tree = 308 + Git.Tree.v [ Git.Tree.entry ~perm:`Normal ~name:"main.ml" main_hash ] 309 + in 310 + let src_hash = Git.Repository.write_tree repo src_tree in 311 + let root_tree = 312 + Git.Tree.v 313 + [ 314 + Git.Tree.entry ~perm:`Normal ~name:"README.md" readme_hash; 315 + Git.Tree.entry ~perm:`Dir ~name:"src" src_hash; 316 + ] 317 + in 318 + let root_hash = Git.Repository.write_tree repo root_tree in 319 + let author = 320 + Git.User.v ~name:"Test" ~email:"test@example.com" ~date:1700000000L () 321 + in 322 + let commit = 323 + Git.Commit.v ~tree:root_hash ~author ~committer:author (Some "Initial") 324 + in 325 + let commit_hash = Git.Repository.write_commit repo commit in 326 + match Git.Repository.checkout repo commit_hash with 327 + | Error (`Msg e) -> Alcotest.fail e 328 + | Ok () -> 329 + let tmp_dir_str = Fpath.to_string tmp_dir in 330 + let readme_path = Eio.Path.(fs / tmp_dir_str / "README.md") in 331 + let main_path = Eio.Path.(fs / tmp_dir_str / "src" / "main.ml") in 332 + Alcotest.(check bool) 333 + "README.md exists" true 334 + (Eio.Path.is_file readme_path); 335 + Alcotest.(check bool) 336 + "src/main.ml exists" true 337 + (Eio.Path.is_file main_path); 338 + let readme_content = Eio.Path.load readme_path in 339 + Alcotest.(check string) "README content" "# Hello World" readme_content; 340 + let main_content = Eio.Path.load main_path in 341 + Alcotest.(check string) 342 + "main.ml content" "let () = print_endline \"Hello\"" main_content 343 + 344 + let tests = 345 + [ 346 + Alcotest.test_case "empty" `Quick test_empty; 347 + Alcotest.test_case "roundtrip" `Quick test_roundtrip; 348 + Alcotest.test_case "add_remove" `Quick test_add_remove; 349 + Alcotest.test_case "find" `Quick test_find; 350 + Alcotest.test_case "sorted" `Quick test_sorted; 351 + Alcotest.test_case "remove_prefix" `Quick test_remove_prefix; 352 + Alcotest.test_case "to_tree" `Quick test_to_tree; 353 + Alcotest.test_case "commit_index" `Quick test_commit_index; 354 + Alcotest.test_case "checkout" `Quick test_checkout; 355 + ] 356 + 357 + let suite = ("index", tests)
+3
test/test_index.mli
··· 1 + (** Index tests. *) 2 + 3 + val suite : string * unit Alcotest.test_case list
+289
test/test_pack.ml
··· 1 + (* Copyright (c) 2024-2026 Thomas Gazagnaire <thomas@gazagnaire.org> 2 + 3 + Permission to use, copy, modify, and distribute this software for any 4 + purpose with or without fee is hereby granted, provided that the above 5 + copyright notice and this permission notice appear in all copies. 6 + 7 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. *) 14 + 15 + let pack_file = 16 + let ic = open_in_bin "pack-testzone-0.pack" in 17 + let len = in_channel_length ic in 18 + let data = really_input_string ic len in 19 + close_in ic; 20 + data 21 + 22 + let index_file = 23 + let ic = open_in_bin "pack-testzone-0.idx" in 24 + let len = in_channel_length ic in 25 + let data = really_input_string ic len in 26 + close_in ic; 27 + data 28 + 29 + let test_header () = 30 + match Git.Pack.of_string pack_file with 31 + | Ok pack -> 32 + Alcotest.(check int) "version" 2 (Git.Pack.version pack); 33 + (* pack-testzone-0 has 192 objects *) 34 + Alcotest.(check int) "count" 192 (Git.Pack.count pack) 35 + | Error (`Msg m) -> Alcotest.fail m 36 + 37 + let test_read_first_object () = 38 + match Git.Pack.of_string pack_file with 39 + | Ok pack -> ( 40 + (* Read first object at offset 12 (after header) *) 41 + match Git.Pack.read_object_at pack 12 with 42 + | Ok (kind, data) -> 43 + (* Just verify we got some data *) 44 + Alcotest.(check bool) "has data" true (String.length data > 0); 45 + Alcotest.(check bool) 46 + "valid kind" true 47 + (match kind with `Commit | `Tree | `Blob | `Tag -> true) 48 + | Error (`Msg m) -> Alcotest.fail m) 49 + | Error (`Msg m) -> Alcotest.fail m 50 + 51 + let test_zlib_inflate () = 52 + (* Simple zlib test - compress and decompress *) 53 + let original = "Hello, World! This is a test string for zlib compression." in 54 + (* Create zlib-compressed data *) 55 + let compressed = 56 + let reader = Bytesrw.Bytes.Reader.of_string original in 57 + let compressed_reader = Bytesrw_zlib.Zlib.compress_reads () reader in 58 + Bytesrw.Bytes.Reader.to_string compressed_reader 59 + in 60 + match Git.Pack.inflate compressed with 61 + | Ok decompressed -> Alcotest.(check string) "roundtrip" original decompressed 62 + | Error (`Msg m) -> Alcotest.fail m 63 + 64 + let test_delta () = 65 + (* Test delta application with a simple example *) 66 + let source = "Hello, World!" in 67 + (* Delta format: source_size, target_size, then commands *) 68 + (* Source size = 13 (0x0D), Target size = 14 (0x0E) *) 69 + (* Command: COPY offset=0, size=13: cmd=0x90 (0x80|0x10), size=0x0D *) 70 + (* - bit 7 (0x80): COPY command 71 + - bit 4 (0x10): size byte 0 present 72 + - offset is 0 by default (no offset bytes) *) 73 + (* Command: INSERT 1 byte '!' = 0x01 '!' *) 74 + let delta = 75 + String.concat "" 76 + [ 77 + "\x0D"; 78 + (* source size = 13 *) 79 + "\x0E"; 80 + (* target size = 14 *) 81 + "\x90\x0D"; 82 + (* COPY: cmd=0x90, size=13 *) 83 + "\x01!"; 84 + (* INSERT: 1 byte '!' *) 85 + ] 86 + in 87 + match Git.Pack.apply_delta ~source ~delta with 88 + | Ok target -> Alcotest.(check string) "delta applied" "Hello, World!!" target 89 + | Error (`Msg m) -> Alcotest.fail m 90 + 91 + let test_fold () = 92 + (* Test that fold iterates over all objects exactly once *) 93 + match Git.Pack.of_string pack_file with 94 + | Ok pack -> ( 95 + let result = 96 + Git.Pack.fold 97 + (fun ~offset ~kind ~data acc -> 98 + Alcotest.(check bool) "has data" true (String.length data > 0); 99 + Alcotest.(check bool) "valid offset" true (offset >= 12); 100 + Alcotest.(check bool) 101 + "valid kind" true 102 + (match kind with `Commit | `Tree | `Blob | `Tag -> true); 103 + acc + 1) 104 + 0 pack 105 + in 106 + match result with 107 + | Ok count -> Alcotest.(check int) "fold count matches header" 192 count 108 + | Error (`Msg m) -> Alcotest.fail m) 109 + | Error (`Msg m) -> Alcotest.fail m 110 + 111 + let test_fold_matches_index () = 112 + (* Verify fold visits objects at the same offsets as the index *) 113 + match (Git.Pack.of_string pack_file, Git.Pack_index.of_string index_file) with 114 + | Ok pack, Ok idx -> 115 + (* Collect all offsets from fold *) 116 + let fold_offsets = 117 + match 118 + Git.Pack.fold 119 + (fun ~offset ~kind:_ ~data:_ acc -> offset :: acc) 120 + [] pack 121 + with 122 + | Ok offsets -> List.sort compare offsets 123 + | Error (`Msg m) -> Alcotest.fail m 124 + in 125 + (* Collect all offsets from index *) 126 + let index_offsets = 127 + let offsets = ref [] in 128 + Git.Pack_index.iter 129 + (fun ~hash:_ ~offset ~crc:_ -> offsets := offset :: !offsets) 130 + idx; 131 + List.sort compare !offsets 132 + in 133 + Alcotest.(check int) 134 + "same count" 135 + (List.length index_offsets) 136 + (List.length fold_offsets); 137 + Alcotest.(check (list int)) "same offsets" index_offsets fold_offsets 138 + | Error (`Msg m), _ -> Alcotest.fail ("pack: " ^ m) 139 + | _, Error (`Msg m) -> Alcotest.fail ("index: " ^ m) 140 + 141 + (* Helper to run git commands in a directory *) 142 + let run_git dir args = 143 + let cmd = 144 + String.concat " " 145 + ([ "git"; "-C"; dir ] @ List.map Filename.quote args @ [ "2>/dev/null" ]) 146 + in 147 + let ic = Unix.open_process_in cmd in 148 + let output = In_channel.input_all ic in 149 + let _ = Unix.close_process_in ic in 150 + String.trim output 151 + 152 + let test_from_git () = 153 + (* Create a fresh repo with git, generate a pack, and verify we can read it *) 154 + let tmp_dir = Filename.temp_dir "git_pack_test" "" in 155 + Fun.protect 156 + ~finally:(fun () -> ignore (Sys.command ("rm -rf " ^ tmp_dir))) 157 + (fun () -> 158 + (* Initialize repo *) 159 + ignore (run_git tmp_dir [ "init" ]); 160 + ignore (run_git tmp_dir [ "config"; "user.email"; "test@test.com" ]); 161 + ignore (run_git tmp_dir [ "config"; "user.name"; "Test" ]); 162 + (* Create some content *) 163 + let file1 = Filename.concat tmp_dir "file1.txt" in 164 + let file2 = Filename.concat tmp_dir "file2.txt" in 165 + let oc = open_out file1 in 166 + output_string oc "Hello, World!\n"; 167 + close_out oc; 168 + ignore (run_git tmp_dir [ "add"; "file1.txt" ]); 169 + ignore (run_git tmp_dir [ "commit"; "-m"; "First commit" ]); 170 + (* Second commit *) 171 + let oc = open_out file2 in 172 + output_string oc "Second file content\n"; 173 + close_out oc; 174 + ignore (run_git tmp_dir [ "add"; "file2.txt" ]); 175 + ignore (run_git tmp_dir [ "commit"; "-m"; "Second commit" ]); 176 + (* Create pack file *) 177 + ignore (run_git tmp_dir [ "gc"; "--aggressive" ]); 178 + (* Find the pack file *) 179 + let pack_dir = Filename.concat tmp_dir ".git/objects/pack" in 180 + let files = Sys.readdir pack_dir in 181 + let pack_file_path = 182 + Array.to_list files 183 + |> List.find (fun f -> Filename.check_suffix f ".pack") 184 + |> Filename.concat pack_dir 185 + in 186 + (* Read and parse the pack *) 187 + let ic = open_in_bin pack_file_path in 188 + let data = In_channel.input_all ic in 189 + close_in ic; 190 + match Git.Pack.of_string data with 191 + | Ok pack -> ( 192 + (* Should have at least: 2 commits, 2 trees, 2 blobs *) 193 + Alcotest.(check bool) "has objects" true (Git.Pack.count pack >= 4); 194 + (* Verify we can fold over all objects *) 195 + let result = 196 + Git.Pack.fold 197 + (fun ~offset:_ ~kind:_ ~data acc -> acc + String.length data) 198 + 0 pack 199 + in 200 + match result with 201 + | Ok total_size -> 202 + Alcotest.(check bool) "has content" true (total_size > 0) 203 + | Error (`Msg m) -> Alcotest.fail ("fold: " ^ m)) 204 + | Error (`Msg m) -> Alcotest.fail m) 205 + 206 + let test_with_deltas () = 207 + (* Create objects that will result in delta encoding *) 208 + let tmp_dir = Filename.temp_dir "git_delta_test" "" in 209 + Fun.protect 210 + ~finally:(fun () -> ignore (Sys.command ("rm -rf " ^ tmp_dir))) 211 + (fun () -> 212 + (* Initialize repo *) 213 + ignore (run_git tmp_dir [ "init" ]); 214 + ignore (run_git tmp_dir [ "config"; "user.email"; "test@test.com" ]); 215 + ignore (run_git tmp_dir [ "config"; "user.name"; "Test" ]); 216 + (* Create a large file that will be delta-compressed when modified *) 217 + let file = Filename.concat tmp_dir "large.txt" in 218 + let oc = open_out file in 219 + let ppf = Format.formatter_of_out_channel oc in 220 + (* Write 10KB of content - enough to trigger delta compression *) 221 + for i = 1 to 500 do 222 + Fmt.pf ppf "Line %d: This is some repetitive content.\n" i 223 + done; 224 + Format.pp_print_flush ppf (); 225 + close_out oc; 226 + ignore (run_git tmp_dir [ "add"; "large.txt" ]); 227 + ignore (run_git tmp_dir [ "commit"; "-m"; "Add large file" ]); 228 + (* Modify just a few lines - this will create a delta *) 229 + let oc = open_out file in 230 + let ppf = Format.formatter_of_out_channel oc in 231 + for i = 1 to 500 do 232 + if i = 250 then Fmt.pf ppf "Line %d: MODIFIED CONTENT HERE!\n" i 233 + else Fmt.pf ppf "Line %d: This is some repetitive content.\n" i 234 + done; 235 + Format.pp_print_flush ppf (); 236 + close_out oc; 237 + ignore (run_git tmp_dir [ "add"; "large.txt" ]); 238 + ignore (run_git tmp_dir [ "commit"; "-m"; "Modify large file" ]); 239 + (* Create pack with aggressive delta compression *) 240 + ignore 241 + (run_git tmp_dir [ "repack"; "-a"; "-d"; "--depth=50"; "--window=250" ]); 242 + (* Find and read the pack *) 243 + let pack_dir = Filename.concat tmp_dir ".git/objects/pack" in 244 + let files = Sys.readdir pack_dir in 245 + let pack_file_path = 246 + Array.to_list files 247 + |> List.find (fun f -> Filename.check_suffix f ".pack") 248 + |> Filename.concat pack_dir 249 + in 250 + let ic = open_in_bin pack_file_path in 251 + let data = In_channel.input_all ic in 252 + close_in ic; 253 + match Git.Pack.of_string data with 254 + | Ok pack -> ( 255 + (* Fold should handle deltas correctly *) 256 + let blobs = ref [] in 257 + let result = 258 + Git.Pack.fold 259 + (fun ~offset:_ ~kind ~data acc -> 260 + (match kind with `Blob -> blobs := data :: !blobs | _ -> ()); 261 + acc + 1) 262 + 0 pack 263 + in 264 + match result with 265 + | Ok count -> 266 + Alcotest.(check bool) "parsed all" true (count >= 4); 267 + (* Verify we got the blob content *) 268 + let has_line_content = 269 + List.exists 270 + (fun b -> String.length b > 5 && String.sub b 0 5 = "Line ") 271 + !blobs 272 + in 273 + Alcotest.(check bool) "found blob content" true has_line_content 274 + | Error (`Msg m) -> Alcotest.fail ("fold: " ^ m)) 275 + | Error (`Msg m) -> Alcotest.fail m) 276 + 277 + let tests = 278 + [ 279 + Alcotest.test_case "header" `Quick test_header; 280 + Alcotest.test_case "read_first_object" `Quick test_read_first_object; 281 + Alcotest.test_case "zlib_inflate" `Quick test_zlib_inflate; 282 + Alcotest.test_case "delta" `Quick test_delta; 283 + Alcotest.test_case "fold" `Quick test_fold; 284 + Alcotest.test_case "fold_matches_index" `Quick test_fold_matches_index; 285 + Alcotest.test_case "from_git" `Slow test_from_git; 286 + Alcotest.test_case "with_deltas" `Slow test_with_deltas; 287 + ] 288 + 289 + let suite = ("pack", tests)
+3
test/test_pack.mli
··· 1 + (** Pack tests. *) 2 + 3 + val suite : string * unit Alcotest.test_case list
+91
test/test_pack_index.ml
··· 1 + (* Copyright (c) 2024-2026 Thomas Gazagnaire <thomas@gazagnaire.org> 2 + 3 + Permission to use, copy, modify, and distribute this software for any 4 + purpose with or without fee is hereby granted, provided that the above 5 + copyright notice and this permission notice appear in all copies. 6 + 7 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. *) 14 + 15 + let index_file = 16 + let ic = open_in_bin "pack-testzone-0.idx" in 17 + let len = in_channel_length ic in 18 + let data = really_input_string ic len in 19 + close_in ic; 20 + data 21 + 22 + let pack_file = 23 + let ic = open_in_bin "pack-testzone-0.pack" in 24 + let len = in_channel_length ic in 25 + let data = really_input_string ic len in 26 + close_in ic; 27 + data 28 + 29 + let test_header () = 30 + match Git.Pack_index.of_string index_file with 31 + | Ok idx -> 32 + (* pack-testzone-0 has 192 objects *) 33 + Alcotest.(check int) "count" 192 (Git.Pack_index.count idx) 34 + | Error (`Msg m) -> Alcotest.fail m 35 + 36 + let test_lookup () = 37 + match Git.Pack_index.of_string index_file with 38 + | Ok idx -> ( 39 + (* Get first hash and verify we can look it up *) 40 + let first_hash = Git.Pack_index.hash_at idx 0 in 41 + (match Git.Pack_index.find idx first_hash with 42 + | Some offset -> Alcotest.(check bool) "offset >= 12" true (offset >= 12) 43 + | None -> Alcotest.fail "first hash not found"); 44 + (* Verify offset_at matches find *) 45 + let expected_offset = Git.Pack_index.offset_at idx 0 in 46 + match Git.Pack_index.find idx first_hash with 47 + | Some offset -> 48 + Alcotest.(check int) "offset matches" expected_offset offset 49 + | None -> Alcotest.fail "first hash not found") 50 + | Error (`Msg m) -> Alcotest.fail m 51 + 52 + let test_iter () = 53 + match Git.Pack_index.of_string index_file with 54 + | Ok idx -> 55 + let count = ref 0 in 56 + Git.Pack_index.iter 57 + (fun ~hash:_ ~offset ~crc:_ -> 58 + incr count; 59 + (* All offsets should be >= 12 (after pack header) *) 60 + Alcotest.(check bool) "valid offset" true (offset >= 12)) 61 + idx; 62 + Alcotest.(check int) "iter count" 192 !count 63 + | Error (`Msg m) -> Alcotest.fail m 64 + 65 + let test_with_pack () = 66 + (* Test reading objects using the index *) 67 + match (Git.Pack.of_string pack_file, Git.Pack_index.of_string index_file) with 68 + | Ok pack, Ok idx -> 69 + (* Read first few objects using offsets from index *) 70 + for i = 0 to min 9 (Git.Pack_index.count idx - 1) do 71 + let offset = Git.Pack_index.offset_at idx i in 72 + match Git.Pack.read_object_at pack offset with 73 + | Ok (kind, data) -> 74 + Alcotest.(check bool) "has data" true (String.length data > 0); 75 + Alcotest.(check bool) 76 + "valid kind" true 77 + (match kind with `Commit | `Tree | `Blob | `Tag -> true) 78 + | Error (`Msg m) -> 79 + Alcotest.failf "object %d at offset %d: %s" i offset m 80 + done 81 + | Error (`Msg m), _ -> Alcotest.fail ("pack: " ^ m) 82 + | _, Error (`Msg m) -> Alcotest.fail ("index: " ^ m) 83 + 84 + let suite = 85 + ( "pack_index", 86 + [ 87 + Alcotest.test_case "header" `Quick test_header; 88 + Alcotest.test_case "lookup" `Quick test_lookup; 89 + Alcotest.test_case "iter" `Quick test_iter; 90 + Alcotest.test_case "with_pack" `Quick test_with_pack; 91 + ] )
+3
test/test_pack_index.mli
··· 1 + (** Pack index tests. *) 2 + 3 + val suite : string * unit Alcotest.test_case list
+51
test/test_reference.ml
··· 1 + (* Copyright (c) 2024-2026 Thomas Gazagnaire <thomas@gazagnaire.org> 2 + 3 + Permission to use, copy, modify, and distribute this software for any 4 + purpose with or without fee is hereby granted, provided that the above 5 + copyright notice and this permission notice appear in all copies. 6 + 7 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. *) 14 + 15 + let hash = Test_helpers.hash 16 + 17 + let test_basic () = 18 + let r = Git.Reference.v "refs/heads/main" in 19 + Alcotest.(check string) 20 + "to_string" "refs/heads/main" 21 + (Git.Reference.to_string r); 22 + Alcotest.(check (list string)) 23 + "segs" 24 + [ "refs"; "heads"; "main" ] 25 + (Git.Reference.segs r) 26 + 27 + let test_contents () = 28 + let h = Git.Hash.of_hex "da39a3ee5e6b4b0d3255bfef95601890afd80709" in 29 + let hash_contents = Git.Reference.Hash h in 30 + let s = Git.Reference.contents_to_string hash_contents in 31 + (match Git.Reference.contents_of_string s with 32 + | Ok (Git.Reference.Hash h') -> Alcotest.(check hash) "hash" h h' 33 + | Ok (Git.Reference.Ref _) -> Alcotest.fail "expected hash" 34 + | Error (`Msg m) -> Alcotest.fail m); 35 + let ref_contents = Git.Reference.Ref (Git.Reference.v "refs/heads/main") in 36 + let s = Git.Reference.contents_to_string ref_contents in 37 + match Git.Reference.contents_of_string s with 38 + | Ok (Git.Reference.Ref r) -> 39 + Alcotest.(check string) 40 + "ref" "refs/heads/main" 41 + (Git.Reference.to_string r) 42 + | Ok (Git.Reference.Hash _) -> Alcotest.fail "expected ref" 43 + | Error (`Msg m) -> Alcotest.fail m 44 + 45 + let tests = 46 + [ 47 + Alcotest.test_case "basic" `Quick test_basic; 48 + Alcotest.test_case "contents" `Quick test_contents; 49 + ] 50 + 51 + let suite = ("reference", tests)
+3
test/test_reference.mli
··· 1 + (** Reference tests. *) 2 + 3 + val suite : string * unit Alcotest.test_case list
+29
test/test_remote.ml
··· 1 + (* Copyright (c) 2024-2026 Thomas Gazagnaire <thomas@gazagnaire.org> 2 + 3 + Permission to use, copy, modify, and distribute this software for any 4 + purpose with or without fee is hereby granted, provided that the above 5 + copyright notice and this permission notice appear in all copies. 6 + 7 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. *) 14 + 15 + (* Remote operations require network access. These tests verify basic 16 + type construction and module availability. *) 17 + 18 + let test_ref_entry_fields () = 19 + let h = Git.Hash.of_hex (String.make 40 'a') in 20 + let entry : Git.Remote.ref_entry = 21 + { ref_name = "refs/heads/main"; hash = h } 22 + in 23 + Alcotest.(check string) "ref_name" "refs/heads/main" entry.ref_name; 24 + Alcotest.(check (testable Git.Hash.pp Git.Hash.equal)) "hash" h entry.hash 25 + 26 + let tests = 27 + [ Alcotest.test_case "ref_entry_fields" `Quick test_ref_entry_fields ] 28 + 29 + let suite = ("remote", tests)
+3
test/test_remote.mli
··· 1 + (** Remote tests. *) 2 + 3 + val suite : string * unit Alcotest.test_case list
+1261
test/test_repository.ml
··· 1 + (* Copyright (c) 2024-2026 Thomas Gazagnaire <thomas@gazagnaire.org> 2 + 3 + Permission to use, copy, modify, and distribute this software for any 4 + purpose with or without fee is hereby granted, provided that the above 5 + copyright notice and this permission notice appear in all copies. 6 + 7 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. *) 14 + 15 + let hash = Test_helpers.hash 16 + let with_temp_repo = Test_helpers.with_temp_repo 17 + let commit = Test_helpers.commit 18 + let unwrap = function Ok x -> x | Error (`Msg e) -> Alcotest.fail e 19 + 20 + let string_contains ~needle haystack = 21 + let n = String.length needle in 22 + let h = String.length haystack in 23 + if n > h then false 24 + else 25 + let rec check i = 26 + if i + n > h then false 27 + else if String.sub haystack i n = needle then true 28 + else check (i + 1) 29 + in 30 + check 0 31 + 32 + (* Basic repository tests *) 33 + 34 + let test_is_repo () = 35 + with_temp_repo @@ fun fs tmp_dir -> 36 + Alcotest.(check bool) "not a repo" false (Git.Repository.is_repo ~fs tmp_dir); 37 + let _repo = Git.Repository.init ~fs tmp_dir in 38 + Alcotest.(check bool) "is a repo" true (Git.Repository.is_repo ~fs tmp_dir) 39 + 40 + let test_current_branch () = 41 + with_temp_repo @@ fun fs tmp_dir -> 42 + let repo = Git.Repository.init ~fs tmp_dir in 43 + Alcotest.(check (option string)) 44 + "default branch" (Some "main") 45 + (Git.Repository.current_branch repo) 46 + 47 + (* Log tests *) 48 + 49 + let test_log () = 50 + with_temp_repo @@ fun fs tmp_dir -> 51 + let repo = Git.Repository.init ~fs tmp_dir in 52 + let tree = Git.Repository.write_tree repo (Git.Tree.v []) in 53 + let c1 = 54 + commit ~repo ~tree ~parents:[] ~message:"First commit\n\nBody of first" 55 + in 56 + let c2 = commit ~repo ~tree ~parents:[ c1 ] ~message:"Second commit" in 57 + let c3 = commit ~repo ~tree ~parents:[ c2 ] ~message:"Third commit" in 58 + let entries = Git.Repository.log repo c3 in 59 + Alcotest.(check int) "3 entries" 3 (List.length entries); 60 + Alcotest.(check string) 61 + "first subject" "Third commit" (List.nth entries 0).subject; 62 + Alcotest.(check string) 63 + "second subject" "Second commit" (List.nth entries 1).subject; 64 + Alcotest.(check string) 65 + "third subject" "First commit" (List.nth entries 2).subject; 66 + Alcotest.(check string) "third body" "Body of first" (List.nth entries 2).body 67 + 68 + let test_log_max_count () = 69 + with_temp_repo @@ fun fs tmp_dir -> 70 + let repo = Git.Repository.init ~fs tmp_dir in 71 + let tree = Git.Repository.write_tree repo (Git.Tree.v []) in 72 + let c1 = commit ~repo ~tree ~parents:[] ~message:"c1" in 73 + let c2 = commit ~repo ~tree ~parents:[ c1 ] ~message:"c2" in 74 + let c3 = commit ~repo ~tree ~parents:[ c2 ] ~message:"c3" in 75 + let entries = Git.Repository.log repo ~max_count:2 c3 in 76 + Alcotest.(check int) "2 entries" 2 (List.length entries) 77 + 78 + let test_log_range () = 79 + with_temp_repo @@ fun fs tmp_dir -> 80 + let repo = Git.Repository.init ~fs tmp_dir in 81 + let tree = Git.Repository.write_tree repo (Git.Tree.v []) in 82 + let c1 = commit ~repo ~tree ~parents:[] ~message:"c1" in 83 + let c2 = commit ~repo ~tree ~parents:[ c1 ] ~message:"c2" in 84 + let c3 = commit ~repo ~tree ~parents:[ c2 ] ~message:"c3" in 85 + let c4 = commit ~repo ~tree ~parents:[ c3 ] ~message:"c4" in 86 + let entries = Git.Repository.log_range repo ~base:c1 ~head:c4 () in 87 + Alcotest.(check int) "3 entries in range" 3 (List.length entries) 88 + 89 + (* Helper to create commit with specific timestamp *) 90 + let commit_at ~repo ~tree ~parents ~message ~timestamp = 91 + let author = 92 + Git.User.v ~name:"Test" ~email:"test@test.com" ~date:timestamp () 93 + in 94 + let commit = 95 + Git.Commit.v ~tree ~author ~committer:author ~parents (Some message) 96 + in 97 + Git.Repository.write_commit repo commit 98 + 99 + (* log_filtered tests *) 100 + 101 + let test_log_filtered_since () = 102 + with_temp_repo @@ fun fs tmp_dir -> 103 + let repo = Git.Repository.init ~fs tmp_dir in 104 + let tree = Git.Repository.write_tree repo (Git.Tree.v []) in 105 + let c1 = commit_at ~repo ~tree ~parents:[] ~message:"c1" ~timestamp:1000L in 106 + let c2 = 107 + commit_at ~repo ~tree ~parents:[ c1 ] ~message:"c2" ~timestamp:2000L 108 + in 109 + let c3 = 110 + commit_at ~repo ~tree ~parents:[ c2 ] ~message:"c3" ~timestamp:3000L 111 + in 112 + let entries = Git.Repository.log_filtered repo ~since:2000L c3 in 113 + Alcotest.(check int) "2 entries since 2000" 2 (List.length entries) 114 + 115 + let test_log_filtered_until () = 116 + with_temp_repo @@ fun fs tmp_dir -> 117 + let repo = Git.Repository.init ~fs tmp_dir in 118 + let tree = Git.Repository.write_tree repo (Git.Tree.v []) in 119 + let c1 = commit_at ~repo ~tree ~parents:[] ~message:"c1" ~timestamp:1000L in 120 + let c2 = 121 + commit_at ~repo ~tree ~parents:[ c1 ] ~message:"c2" ~timestamp:2000L 122 + in 123 + let c3 = 124 + commit_at ~repo ~tree ~parents:[ c2 ] ~message:"c3" ~timestamp:3000L 125 + in 126 + let entries = Git.Repository.log_filtered repo ~until:2000L c3 in 127 + Alcotest.(check int) "2 entries until 2000" 2 (List.length entries) 128 + 129 + let test_log_filtered_path () = 130 + with_temp_repo @@ fun fs tmp_dir -> 131 + let repo = Git.Repository.init ~fs tmp_dir in 132 + (* Create tree with file "a.txt" *) 133 + let blob_a = Git.Repository.write_blob repo "content a" in 134 + let tree_a = 135 + Git.Repository.write_tree repo 136 + (Git.Tree.v [ { name = "a.txt"; perm = `Normal; hash = blob_a } ]) 137 + in 138 + (* Create tree with files "a.txt" and "b.txt" *) 139 + let blob_b = Git.Repository.write_blob repo "content b" in 140 + let tree_ab = 141 + Git.Repository.write_tree repo 142 + (Git.Tree.v 143 + [ 144 + { name = "a.txt"; perm = `Normal; hash = blob_a }; 145 + { name = "b.txt"; perm = `Normal; hash = blob_b }; 146 + ]) 147 + in 148 + (* Create tree with modified "a.txt" and "b.txt" *) 149 + let blob_a2 = Git.Repository.write_blob repo "content a modified" in 150 + let tree_ab2 = 151 + Git.Repository.write_tree repo 152 + (Git.Tree.v 153 + [ 154 + { name = "a.txt"; perm = `Normal; hash = blob_a2 }; 155 + { name = "b.txt"; perm = `Normal; hash = blob_b }; 156 + ]) 157 + in 158 + let c1 = commit ~repo ~tree:tree_a ~parents:[] ~message:"add a" in 159 + let c2 = commit ~repo ~tree:tree_ab ~parents:[ c1 ] ~message:"add b" in 160 + let c3 = commit ~repo ~tree:tree_ab2 ~parents:[ c2 ] ~message:"modify a" in 161 + (* Filter for commits touching "a.txt" - should be c1 and c3 *) 162 + let entries = Git.Repository.log_filtered repo ~path:"a.txt" c3 in 163 + Alcotest.(check int) "2 commits touch a.txt" 2 (List.length entries); 164 + (* Filter for commits touching "b.txt" - should be c2 only *) 165 + let entries_b = Git.Repository.log_filtered repo ~path:"b.txt" c3 in 166 + Alcotest.(check int) "1 commit touches b.txt" 1 (List.length entries_b) 167 + 168 + let test_log_filtered_combined () = 169 + with_temp_repo @@ fun fs tmp_dir -> 170 + let repo = Git.Repository.init ~fs tmp_dir in 171 + let tree = Git.Repository.write_tree repo (Git.Tree.v []) in 172 + let c1 = commit_at ~repo ~tree ~parents:[] ~message:"c1" ~timestamp:1000L in 173 + let c2 = 174 + commit_at ~repo ~tree ~parents:[ c1 ] ~message:"c2" ~timestamp:2000L 175 + in 176 + let c3 = 177 + commit_at ~repo ~tree ~parents:[ c2 ] ~message:"c3" ~timestamp:3000L 178 + in 179 + let c4 = 180 + commit_at ~repo ~tree ~parents:[ c3 ] ~message:"c4" ~timestamp:4000L 181 + in 182 + let entries = Git.Repository.log_filtered repo ~since:2000L ~until:3000L c4 in 183 + Alcotest.(check int) "2 entries in range" 2 (List.length entries) 184 + 185 + (* Resolve ref tests *) 186 + 187 + let test_resolve_ref_head () = 188 + with_temp_repo @@ fun fs tmp_dir -> 189 + let repo = Git.Repository.init ~fs tmp_dir in 190 + let tree = Git.Repository.write_tree repo (Git.Tree.v []) in 191 + let c1 = commit ~repo ~tree ~parents:[] ~message:"c1" in 192 + Git.Repository.write_ref repo "refs/heads/main" c1; 193 + Alcotest.(check (option hash)) 194 + "resolve HEAD" (Some c1) 195 + (Git.Repository.resolve_ref repo "HEAD") 196 + 197 + let test_resolve_ref_branch () = 198 + with_temp_repo @@ fun fs tmp_dir -> 199 + let repo = Git.Repository.init ~fs tmp_dir in 200 + let tree = Git.Repository.write_tree repo (Git.Tree.v []) in 201 + let c1 = commit ~repo ~tree ~parents:[] ~message:"c1" in 202 + Git.Repository.write_ref repo "refs/heads/feature" c1; 203 + Alcotest.(check (option hash)) 204 + "resolve branch" (Some c1) 205 + (Git.Repository.resolve_ref repo "feature") 206 + 207 + let test_resolve_ref_remote () = 208 + with_temp_repo @@ fun fs tmp_dir -> 209 + let repo = Git.Repository.init ~fs tmp_dir in 210 + let tree = Git.Repository.write_tree repo (Git.Tree.v []) in 211 + let c1 = commit ~repo ~tree ~parents:[] ~message:"c1" in 212 + (* Create remote ref directory structure *) 213 + let git_dir = Fpath.to_string (Git.Repository.git_dir repo) in 214 + let remote_dir = Eio.Path.(fs / git_dir / "refs" / "remotes" / "origin") in 215 + Eio.Path.mkdirs ~perm:0o755 remote_dir; 216 + Git.Repository.write_ref repo "refs/remotes/origin/main" c1; 217 + Alcotest.(check (option hash)) 218 + "resolve origin/main" (Some c1) 219 + (Git.Repository.resolve_ref repo "origin/main") 220 + 221 + let test_resolve_ref_not_found () = 222 + with_temp_repo @@ fun fs tmp_dir -> 223 + let repo = Git.Repository.init ~fs tmp_dir in 224 + Alcotest.(check (option hash)) 225 + "nonexistent ref" None 226 + (Git.Repository.resolve_ref repo "nonexistent") 227 + 228 + (* log_range_refs tests *) 229 + 230 + let test_log_range_refs_basic () = 231 + with_temp_repo @@ fun fs tmp_dir -> 232 + let repo = Git.Repository.init ~fs tmp_dir in 233 + let tree = Git.Repository.write_tree repo (Git.Tree.v []) in 234 + let c1 = commit ~repo ~tree ~parents:[] ~message:"c1" in 235 + let c2 = commit ~repo ~tree ~parents:[ c1 ] ~message:"c2" in 236 + let c3 = commit ~repo ~tree ~parents:[ c2 ] ~message:"c3" in 237 + Git.Repository.write_ref repo "refs/heads/main" c3; 238 + Git.Repository.write_ref repo "refs/heads/base" c1; 239 + match Git.Repository.log_range_refs repo ~base:"base" ~tip:"main" () with 240 + | Ok entries -> 241 + Alcotest.(check int) "2 entries in range" 2 (List.length entries) 242 + | Error (`Msg msg) -> Alcotest.fail msg 243 + 244 + let test_log_range_refs_head () = 245 + with_temp_repo @@ fun fs tmp_dir -> 246 + let repo = Git.Repository.init ~fs tmp_dir in 247 + let tree = Git.Repository.write_tree repo (Git.Tree.v []) in 248 + let c1 = commit ~repo ~tree ~parents:[] ~message:"c1" in 249 + let c2 = commit ~repo ~tree ~parents:[ c1 ] ~message:"c2" in 250 + Git.Repository.write_ref repo "refs/heads/main" c2; 251 + Git.Repository.write_ref repo "refs/heads/old" c1; 252 + match Git.Repository.log_range_refs repo ~base:"old" ~tip:"HEAD" () with 253 + | Ok entries -> Alcotest.(check int) "1 entry" 1 (List.length entries) 254 + | Error (`Msg msg) -> Alcotest.fail msg 255 + 256 + let test_log_range_refs_error () = 257 + with_temp_repo @@ fun fs tmp_dir -> 258 + let repo = Git.Repository.init ~fs tmp_dir in 259 + match 260 + Git.Repository.log_range_refs repo ~base:"nonexistent" ~tip:"HEAD" () 261 + with 262 + | Ok _ -> Alcotest.fail "should fail" 263 + | Error (`Msg _) -> () 264 + 265 + (* Advance head tests *) 266 + 267 + let test_advance_head_updates_branch () = 268 + with_temp_repo @@ fun fs tmp_dir -> 269 + let repo = Git.Repository.init ~fs tmp_dir in 270 + let tree = Git.Repository.write_tree repo (Git.Tree.v []) in 271 + let c1 = commit ~repo ~tree ~parents:[] ~message:"initial" in 272 + Git.Repository.write_ref repo "refs/heads/main" c1; 273 + Git.Repository.write_ref repo "HEAD" c1; 274 + let git_dir = Fpath.to_string (Git.Repository.git_dir repo) in 275 + let head_path = Eio.Path.(fs / git_dir / "HEAD") in 276 + Eio.Path.save ~create:(`Or_truncate 0o644) head_path "ref: refs/heads/main\n"; 277 + Alcotest.(check (option string)) 278 + "on main" (Some "main") 279 + (Git.Repository.current_branch repo); 280 + let c2 = commit ~repo ~tree ~parents:[ c1 ] ~message:"second" in 281 + Git.Repository.advance_head repo c2; 282 + Alcotest.(check (option string)) 283 + "still on main" (Some "main") 284 + (Git.Repository.current_branch repo); 285 + Alcotest.(check (option hash)) 286 + "main updated" (Some c2) 287 + (Git.Repository.read_ref repo "refs/heads/main"); 288 + Alcotest.(check (option hash)) 289 + "HEAD updated" (Some c2) (Git.Repository.head repo) 290 + 291 + let test_advance_head_detached () = 292 + with_temp_repo @@ fun fs tmp_dir -> 293 + let repo = Git.Repository.init ~fs tmp_dir in 294 + let tree = Git.Repository.write_tree repo (Git.Tree.v []) in 295 + let c1 = commit ~repo ~tree ~parents:[] ~message:"initial" in 296 + let git_dir = Fpath.to_string (Git.Repository.git_dir repo) in 297 + let head_path = Eio.Path.(fs / git_dir / "HEAD") in 298 + Eio.Path.save ~create:(`Or_truncate 0o644) head_path 299 + (Git.Hash.to_hex c1 ^ "\n"); 300 + Alcotest.(check (option string)) 301 + "detached" None 302 + (Git.Repository.current_branch repo); 303 + let c2 = commit ~repo ~tree ~parents:[ c1 ] ~message:"second" in 304 + Git.Repository.advance_head repo c2; 305 + Alcotest.(check (option string)) 306 + "still detached" None 307 + (Git.Repository.current_branch repo); 308 + Alcotest.(check (option hash)) 309 + "HEAD updated" (Some c2) (Git.Repository.head repo) 310 + 311 + (* Repository ancestry tests *) 312 + 313 + let test_repo_ancestor_same_commit () = 314 + with_temp_repo @@ fun fs tmp_dir -> 315 + let repo = Git.Repository.init ~fs tmp_dir in 316 + let tree = Git.Repository.write_tree repo (Git.Tree.v []) in 317 + let c1 = commit ~repo ~tree ~parents:[] ~message:"c1" in 318 + Alcotest.(check bool) 319 + "same commit" true 320 + (Git.Repository.is_ancestor repo ~ancestor:c1 ~descendant:c1) 321 + 322 + let test_repo_ancestor_direct_parent () = 323 + with_temp_repo @@ fun fs tmp_dir -> 324 + let repo = Git.Repository.init ~fs tmp_dir in 325 + let tree = Git.Repository.write_tree repo (Git.Tree.v []) in 326 + let c1 = commit ~repo ~tree ~parents:[] ~message:"c1" in 327 + let c2 = commit ~repo ~tree ~parents:[ c1 ] ~message:"c2" in 328 + Alcotest.(check bool) 329 + "parent is ancestor" true 330 + (Git.Repository.is_ancestor repo ~ancestor:c1 ~descendant:c2); 331 + Alcotest.(check bool) 332 + "child not ancestor" false 333 + (Git.Repository.is_ancestor repo ~ancestor:c2 ~descendant:c1) 334 + 335 + let test_repo_is_ancestor_distant () = 336 + with_temp_repo @@ fun fs tmp_dir -> 337 + let repo = Git.Repository.init ~fs tmp_dir in 338 + let tree = Git.Repository.write_tree repo (Git.Tree.v []) in 339 + let c1 = commit ~repo ~tree ~parents:[] ~message:"c1" in 340 + let c2 = commit ~repo ~tree ~parents:[ c1 ] ~message:"c2" in 341 + let c3 = commit ~repo ~tree ~parents:[ c2 ] ~message:"c3" in 342 + let c4 = commit ~repo ~tree ~parents:[ c3 ] ~message:"c4" in 343 + let c5 = commit ~repo ~tree ~parents:[ c4 ] ~message:"c5" in 344 + let c6 = commit ~repo ~tree ~parents:[ c5 ] ~message:"c6" in 345 + let c7 = commit ~repo ~tree ~parents:[ c6 ] ~message:"c7" in 346 + let c8 = commit ~repo ~tree ~parents:[ c7 ] ~message:"c8" in 347 + let c9 = commit ~repo ~tree ~parents:[ c8 ] ~message:"c9" in 348 + let c10 = commit ~repo ~tree ~parents:[ c9 ] ~message:"c10" in 349 + Alcotest.(check bool) 350 + "c1 ancestor of c10" true 351 + (Git.Repository.is_ancestor repo ~ancestor:c1 ~descendant:c10); 352 + Alcotest.(check bool) 353 + "c3 ancestor of c10" true 354 + (Git.Repository.is_ancestor repo ~ancestor:c3 ~descendant:c10); 355 + Alcotest.(check bool) 356 + "c10 not ancestor of c1" false 357 + (Git.Repository.is_ancestor repo ~ancestor:c10 ~descendant:c1); 358 + Alcotest.(check bool) 359 + "c5 ancestor of c7" true 360 + (Git.Repository.is_ancestor repo ~ancestor:c5 ~descendant:c7) 361 + 362 + let test_repo_is_ancestor_diverged () = 363 + with_temp_repo @@ fun fs tmp_dir -> 364 + let repo = Git.Repository.init ~fs tmp_dir in 365 + let tree = Git.Repository.write_tree repo (Git.Tree.v []) in 366 + let c1 = commit ~repo ~tree ~parents:[] ~message:"c1" in 367 + let c2 = commit ~repo ~tree ~parents:[ c1 ] ~message:"c2" in 368 + let c3 = commit ~repo ~tree ~parents:[ c2 ] ~message:"c3" in 369 + let c4 = commit ~repo ~tree ~parents:[ c3 ] ~message:"c4" in 370 + let c5 = commit ~repo ~tree ~parents:[ c2 ] ~message:"c5" in 371 + let c6 = commit ~repo ~tree ~parents:[ c5 ] ~message:"c6" in 372 + Alcotest.(check bool) 373 + "c4 not ancestor of c6" false 374 + (Git.Repository.is_ancestor repo ~ancestor:c4 ~descendant:c6); 375 + Alcotest.(check bool) 376 + "c6 not ancestor of c4" false 377 + (Git.Repository.is_ancestor repo ~ancestor:c6 ~descendant:c4); 378 + Alcotest.(check bool) 379 + "c3 not ancestor of c6" false 380 + (Git.Repository.is_ancestor repo ~ancestor:c3 ~descendant:c6); 381 + Alcotest.(check bool) 382 + "c5 not ancestor of c4" false 383 + (Git.Repository.is_ancestor repo ~ancestor:c5 ~descendant:c4); 384 + Alcotest.(check bool) 385 + "c2 ancestor of c4" true 386 + (Git.Repository.is_ancestor repo ~ancestor:c2 ~descendant:c4); 387 + Alcotest.(check bool) 388 + "c2 ancestor of c6" true 389 + (Git.Repository.is_ancestor repo ~ancestor:c2 ~descendant:c6) 390 + 391 + let test_repo_ancestor_after_merge () = 392 + with_temp_repo @@ fun fs tmp_dir -> 393 + let repo = Git.Repository.init ~fs tmp_dir in 394 + let tree = Git.Repository.write_tree repo (Git.Tree.v []) in 395 + let c1 = commit ~repo ~tree ~parents:[] ~message:"c1" in 396 + let c2 = commit ~repo ~tree ~parents:[ c1 ] ~message:"c2" in 397 + let c3 = commit ~repo ~tree ~parents:[ c2 ] ~message:"c3" in 398 + let c4 = commit ~repo ~tree ~parents:[ c2 ] ~message:"c4" in 399 + let c5 = commit ~repo ~tree ~parents:[ c3; c4 ] ~message:"c5-merge" in 400 + Alcotest.(check bool) 401 + "c3 ancestor of c5" true 402 + (Git.Repository.is_ancestor repo ~ancestor:c3 ~descendant:c5); 403 + Alcotest.(check bool) 404 + "c4 ancestor of c5" true 405 + (Git.Repository.is_ancestor repo ~ancestor:c4 ~descendant:c5); 406 + Alcotest.(check bool) 407 + "c2 ancestor of c5" true 408 + (Git.Repository.is_ancestor repo ~ancestor:c2 ~descendant:c5); 409 + Alcotest.(check bool) 410 + "c1 ancestor of c5" true 411 + (Git.Repository.is_ancestor repo ~ancestor:c1 ~descendant:c5); 412 + Alcotest.(check bool) 413 + "c5 not ancestor of c3" false 414 + (Git.Repository.is_ancestor repo ~ancestor:c5 ~descendant:c3) 415 + 416 + let test_repo_count_commits_zero () = 417 + with_temp_repo @@ fun fs tmp_dir -> 418 + let repo = Git.Repository.init ~fs tmp_dir in 419 + let tree = Git.Repository.write_tree repo (Git.Tree.v []) in 420 + let c1 = commit ~repo ~tree ~parents:[] ~message:"c1" in 421 + Alcotest.(check int) 422 + "same commit" 0 423 + (Git.Repository.count_commits_between repo ~base:c1 ~head:c1) 424 + 425 + let test_repo_count_commits_adjacent () = 426 + with_temp_repo @@ fun fs tmp_dir -> 427 + let repo = Git.Repository.init ~fs tmp_dir in 428 + let tree = Git.Repository.write_tree repo (Git.Tree.v []) in 429 + let c1 = commit ~repo ~tree ~parents:[] ~message:"c1" in 430 + let c2 = commit ~repo ~tree ~parents:[ c1 ] ~message:"c2" in 431 + Alcotest.(check int) 432 + "adjacent" 1 433 + (Git.Repository.count_commits_between repo ~base:c1 ~head:c2) 434 + 435 + let test_repo_count_commits_linear () = 436 + with_temp_repo @@ fun fs tmp_dir -> 437 + let repo = Git.Repository.init ~fs tmp_dir in 438 + let tree = Git.Repository.write_tree repo (Git.Tree.v []) in 439 + let c1 = commit ~repo ~tree ~parents:[] ~message:"c1" in 440 + let c2 = commit ~repo ~tree ~parents:[ c1 ] ~message:"c2" in 441 + let c3 = commit ~repo ~tree ~parents:[ c2 ] ~message:"c3" in 442 + let c4 = commit ~repo ~tree ~parents:[ c3 ] ~message:"c4" in 443 + let c5 = commit ~repo ~tree ~parents:[ c4 ] ~message:"c5" in 444 + Alcotest.(check int) 445 + "c1..c5 = 4" 4 446 + (Git.Repository.count_commits_between repo ~base:c1 ~head:c5); 447 + Alcotest.(check int) 448 + "c2..c5 = 3" 3 449 + (Git.Repository.count_commits_between repo ~base:c2 ~head:c5); 450 + Alcotest.(check int) 451 + "c3..c5 = 2" 2 452 + (Git.Repository.count_commits_between repo ~base:c3 ~head:c5); 453 + Alcotest.(check int) 454 + "c4..c5 = 1" 1 455 + (Git.Repository.count_commits_between repo ~base:c4 ~head:c5) 456 + 457 + let test_repo_count_commits_merged () = 458 + with_temp_repo @@ fun fs tmp_dir -> 459 + let repo = Git.Repository.init ~fs tmp_dir in 460 + let tree = Git.Repository.write_tree repo (Git.Tree.v []) in 461 + let c1 = commit ~repo ~tree ~parents:[] ~message:"c1" in 462 + let c2 = commit ~repo ~tree ~parents:[ c1 ] ~message:"c2" in 463 + let c3 = commit ~repo ~tree ~parents:[ c2 ] ~message:"c3" in 464 + let c4 = commit ~repo ~tree ~parents:[ c2 ] ~message:"c4" in 465 + let c5 = commit ~repo ~tree ~parents:[ c3; c4 ] ~message:"c5-merge" in 466 + let c6 = commit ~repo ~tree ~parents:[ c5 ] ~message:"c6" in 467 + Alcotest.(check int) 468 + "c2..c6 through merge" 4 469 + (Git.Repository.count_commits_between repo ~base:c2 ~head:c6); 470 + Alcotest.(check int) 471 + "c3..c6" 3 472 + (Git.Repository.count_commits_between repo ~base:c3 ~head:c6) 473 + 474 + let test_repo_merge_base_same () = 475 + with_temp_repo @@ fun fs tmp_dir -> 476 + let repo = Git.Repository.init ~fs tmp_dir in 477 + let tree = Git.Repository.write_tree repo (Git.Tree.v []) in 478 + let c1 = commit ~repo ~tree ~parents:[] ~message:"c1" in 479 + Alcotest.(check (option hash)) 480 + "same commit" (Some c1) 481 + (Git.Repository.merge_base repo c1 c1) 482 + 483 + let test_repo_merge_base_linear () = 484 + with_temp_repo @@ fun fs tmp_dir -> 485 + let repo = Git.Repository.init ~fs tmp_dir in 486 + let tree = Git.Repository.write_tree repo (Git.Tree.v []) in 487 + let c1 = commit ~repo ~tree ~parents:[] ~message:"c1" in 488 + let c2 = commit ~repo ~tree ~parents:[ c1 ] ~message:"c2" in 489 + let c3 = commit ~repo ~tree ~parents:[ c2 ] ~message:"c3" in 490 + let c4 = commit ~repo ~tree ~parents:[ c3 ] ~message:"c4" in 491 + Alcotest.(check (option hash)) 492 + "c1 and c4" (Some c1) 493 + (Git.Repository.merge_base repo c1 c4); 494 + Alcotest.(check (option hash)) 495 + "c2 and c4" (Some c2) 496 + (Git.Repository.merge_base repo c2 c4); 497 + Alcotest.(check (option hash)) 498 + "c4 and c2 (reversed)" (Some c2) 499 + (Git.Repository.merge_base repo c4 c2) 500 + 501 + let test_repo_merge_base_diamond () = 502 + with_temp_repo @@ fun fs tmp_dir -> 503 + let repo = Git.Repository.init ~fs tmp_dir in 504 + let tree = Git.Repository.write_tree repo (Git.Tree.v []) in 505 + let c1 = commit ~repo ~tree ~parents:[] ~message:"c1" in 506 + let c2 = commit ~repo ~tree ~parents:[ c1 ] ~message:"c2" in 507 + let c3 = commit ~repo ~tree ~parents:[ c2 ] ~message:"c3" in 508 + let c4 = commit ~repo ~tree ~parents:[ c2 ] ~message:"c4" in 509 + let c5 = commit ~repo ~tree ~parents:[ c3 ] ~message:"c5" in 510 + let c6 = commit ~repo ~tree ~parents:[ c4 ] ~message:"c6" in 511 + Alcotest.(check (option hash)) 512 + "c5 and c6" (Some c2) 513 + (Git.Repository.merge_base repo c5 c6); 514 + Alcotest.(check (option hash)) 515 + "c3 and c4" (Some c2) 516 + (Git.Repository.merge_base repo c3 c4); 517 + Alcotest.(check (option hash)) 518 + "c3 and c6" (Some c2) 519 + (Git.Repository.merge_base repo c3 c6) 520 + 521 + let test_repo_mergebase_deep_divergence () = 522 + with_temp_repo @@ fun fs tmp_dir -> 523 + let repo = Git.Repository.init ~fs tmp_dir in 524 + let tree = Git.Repository.write_tree repo (Git.Tree.v []) in 525 + let c1 = commit ~repo ~tree ~parents:[] ~message:"c1" in 526 + let c2 = commit ~repo ~tree ~parents:[ c1 ] ~message:"c2" in 527 + let c3 = commit ~repo ~tree ~parents:[ c2 ] ~message:"c3" in 528 + let c4 = commit ~repo ~tree ~parents:[ c3 ] ~message:"c4" in 529 + let c5 = commit ~repo ~tree ~parents:[ c4 ] ~message:"c5" in 530 + let c6 = commit ~repo ~tree ~parents:[ c2 ] ~message:"c6" in 531 + let c7 = commit ~repo ~tree ~parents:[ c6 ] ~message:"c7" in 532 + let c8 = commit ~repo ~tree ~parents:[ c7 ] ~message:"c8" in 533 + let c9 = commit ~repo ~tree ~parents:[ c8 ] ~message:"c9" in 534 + let c10 = commit ~repo ~tree ~parents:[ c9 ] ~message:"c10" in 535 + Alcotest.(check (option hash)) 536 + "c5 and c10" (Some c2) 537 + (Git.Repository.merge_base repo c5 c10); 538 + Alcotest.(check (option hash)) 539 + "c10 and c5 (reversed)" (Some c2) 540 + (Git.Repository.merge_base repo c10 c5) 541 + 542 + let test_repo_mergebase_after_merge () = 543 + with_temp_repo @@ fun fs tmp_dir -> 544 + let repo = Git.Repository.init ~fs tmp_dir in 545 + let tree = Git.Repository.write_tree repo (Git.Tree.v []) in 546 + let c1 = commit ~repo ~tree ~parents:[] ~message:"c1" in 547 + let c2 = commit ~repo ~tree ~parents:[ c1 ] ~message:"c2" in 548 + let c3 = commit ~repo ~tree ~parents:[ c2 ] ~message:"c3" in 549 + let c4 = commit ~repo ~tree ~parents:[ c2 ] ~message:"c4" in 550 + let c5 = commit ~repo ~tree ~parents:[ c3; c4 ] ~message:"c5-merge" in 551 + let c6 = commit ~repo ~tree ~parents:[ c5 ] ~message:"c6" in 552 + Alcotest.(check (option hash)) 553 + "c6 and c4" (Some c4) 554 + (Git.Repository.merge_base repo c6 c4); 555 + Alcotest.(check (option hash)) 556 + "c6 and c3" (Some c3) 557 + (Git.Repository.merge_base repo c6 c3) 558 + 559 + let test_repo_mergebase_criss_cross () = 560 + with_temp_repo @@ fun fs tmp_dir -> 561 + let repo = Git.Repository.init ~fs tmp_dir in 562 + let tree = Git.Repository.write_tree repo (Git.Tree.v []) in 563 + let c1 = commit ~repo ~tree ~parents:[] ~message:"c1" in 564 + let c2 = commit ~repo ~tree ~parents:[ c1 ] ~message:"c2" in 565 + let c3 = commit ~repo ~tree ~parents:[ c1 ] ~message:"c3" in 566 + let c4 = commit ~repo ~tree ~parents:[ c2 ] ~message:"c4" in 567 + let c5 = commit ~repo ~tree ~parents:[ c3 ] ~message:"c5" in 568 + let c6 = commit ~repo ~tree ~parents:[ c4; c5 ] ~message:"c6-merge" in 569 + let c7 = commit ~repo ~tree ~parents:[ c6; c5 ] ~message:"c7-merge" in 570 + Alcotest.(check (option hash)) 571 + "c4 and c5" (Some c1) 572 + (Git.Repository.merge_base repo c4 c5); 573 + Alcotest.(check (option hash)) 574 + "c6 and c5" (Some c5) 575 + (Git.Repository.merge_base repo c6 c5); 576 + Alcotest.(check (option hash)) 577 + "c7 and c4" (Some c4) 578 + (Git.Repository.merge_base repo c7 c4) 579 + 580 + let test_repo_mergebase_no_common () = 581 + with_temp_repo @@ fun fs tmp_dir -> 582 + let repo = Git.Repository.init ~fs tmp_dir in 583 + let tree = Git.Repository.write_tree repo (Git.Tree.v []) in 584 + let a1 = commit ~repo ~tree ~parents:[] ~message:"a1" in 585 + let a2 = commit ~repo ~tree ~parents:[ a1 ] ~message:"a2" in 586 + let b1 = commit ~repo ~tree ~parents:[] ~message:"b1" in 587 + let b2 = commit ~repo ~tree ~parents:[ b1 ] ~message:"b2" in 588 + Alcotest.(check (option hash)) 589 + "no common ancestor" None 590 + (Git.Repository.merge_base repo a2 b2) 591 + 592 + (* set_push_url tests *) 593 + 594 + let test_set_push_url_basic () = 595 + with_temp_repo @@ fun fs tmp_dir -> 596 + let repo = Git.Repository.init ~fs tmp_dir in 597 + (match 598 + Git.Repository.add_remote repo ~name:"origin" 599 + ~url:"git@github.com:user/repo.git" () 600 + with 601 + | Ok () -> () 602 + | Error (`Msg msg) -> Alcotest.fail msg); 603 + (match 604 + Git.Repository.set_push_url repo ~name:"origin" 605 + ~url:"git@github.com:user/repo-push.git" 606 + with 607 + | Ok () -> () 608 + | Error (`Msg msg) -> Alcotest.fail msg); 609 + Alcotest.(check (option string)) 610 + "push URL set" (Some "git@github.com:user/repo-push.git") 611 + (Git.Repository.push_url repo "origin") 612 + 613 + let test_set_push_url_update () = 614 + with_temp_repo @@ fun fs tmp_dir -> 615 + let repo = Git.Repository.init ~fs tmp_dir in 616 + (match 617 + Git.Repository.add_remote repo ~name:"origin" 618 + ~url:"git@github.com:user/repo.git" 619 + ~push_url:"git@github.com:user/old-push.git" () 620 + with 621 + | Ok () -> () 622 + | Error (`Msg msg) -> Alcotest.fail msg); 623 + (match 624 + Git.Repository.set_push_url repo ~name:"origin" 625 + ~url:"git@github.com:user/new-push.git" 626 + with 627 + | Ok () -> () 628 + | Error (`Msg msg) -> Alcotest.fail msg); 629 + Alcotest.(check (option string)) 630 + "push URL updated" (Some "git@github.com:user/new-push.git") 631 + (Git.Repository.push_url repo "origin") 632 + 633 + let test_set_pushurl_no_remote () = 634 + with_temp_repo @@ fun fs tmp_dir -> 635 + let repo = Git.Repository.init ~fs tmp_dir in 636 + match 637 + Git.Repository.set_push_url repo ~name:"nonexistent" 638 + ~url:"git@github.com:user/repo.git" 639 + with 640 + | Ok () -> Alcotest.fail "should have failed" 641 + | Error (`Msg _) -> () 642 + 643 + let test_set_pushurl_preserves_config () = 644 + with_temp_repo @@ fun fs tmp_dir -> 645 + let repo = Git.Repository.init ~fs tmp_dir in 646 + (match 647 + Git.Repository.add_remote repo ~name:"origin" 648 + ~url:"git@github.com:user/repo.git" () 649 + with 650 + | Ok () -> () 651 + | Error (`Msg msg) -> Alcotest.fail msg); 652 + (match 653 + Git.Repository.set_push_url repo ~name:"origin" 654 + ~url:"git@github.com:user/push.git" 655 + with 656 + | Ok () -> () 657 + | Error (`Msg msg) -> Alcotest.fail msg); 658 + Alcotest.(check (option string)) 659 + "fetch URL unchanged" (Some "git@github.com:user/repo.git") 660 + (Git.Repository.remote_url repo "origin") 661 + 662 + (* rename_branch tests *) 663 + 664 + let test_rename_branch_basic () = 665 + with_temp_repo @@ fun fs tmp_dir -> 666 + let repo = Git.Repository.init ~fs tmp_dir in 667 + let tree = Git.Repository.write_tree repo (Git.Tree.v []) in 668 + let c1 = commit ~repo ~tree ~parents:[] ~message:"initial" in 669 + Git.Repository.write_ref repo "refs/heads/main" c1; 670 + let head_path = Eio.Path.(fs / Fpath.to_string tmp_dir / ".git" / "HEAD") in 671 + Eio.Path.save ~create:(`Or_truncate 0o644) head_path "ref: refs/heads/main\n"; 672 + (match Git.Repository.rename_branch repo ~new_name:"new-branch" with 673 + | Ok () -> () 674 + | Error (`Msg msg) -> Alcotest.fail msg); 675 + Alcotest.(check (option hash)) 676 + "new branch has commit" (Some c1) 677 + (Git.Repository.read_ref repo "refs/heads/new-branch"); 678 + Alcotest.(check (option hash)) 679 + "old branch deleted" None 680 + (Git.Repository.read_ref repo "refs/heads/main"); 681 + Alcotest.(check (option string)) 682 + "current branch" (Some "new-branch") 683 + (Git.Repository.current_branch repo) 684 + 685 + let test_rename_branch_detached () = 686 + with_temp_repo @@ fun fs tmp_dir -> 687 + let repo = Git.Repository.init ~fs tmp_dir in 688 + let tree = Git.Repository.write_tree repo (Git.Tree.v []) in 689 + let c1 = commit ~repo ~tree ~parents:[] ~message:"initial" in 690 + let head_path = Eio.Path.(fs / Fpath.to_string tmp_dir / ".git" / "HEAD") in 691 + Eio.Path.save ~create:(`Or_truncate 0o644) head_path 692 + (Git.Hash.to_hex c1 ^ "\n"); 693 + match Git.Repository.rename_branch repo ~new_name:"new-branch" with 694 + | Ok () -> Alcotest.fail "should have failed in detached HEAD" 695 + | Error (`Msg msg) -> 696 + Alcotest.(check bool) 697 + "error mentions detached" true 698 + (String.length msg > 0) 699 + 700 + let test_rename_branch_preserves_history () = 701 + with_temp_repo @@ fun fs tmp_dir -> 702 + let repo = Git.Repository.init ~fs tmp_dir in 703 + let tree = Git.Repository.write_tree repo (Git.Tree.v []) in 704 + let c1 = commit ~repo ~tree ~parents:[] ~message:"c1" in 705 + let c2 = commit ~repo ~tree ~parents:[ c1 ] ~message:"c2" in 706 + let c3 = commit ~repo ~tree ~parents:[ c2 ] ~message:"c3" in 707 + Git.Repository.write_ref repo "refs/heads/feature" c3; 708 + let head_path = Eio.Path.(fs / Fpath.to_string tmp_dir / ".git" / "HEAD") in 709 + Eio.Path.save ~create:(`Or_truncate 0o644) head_path 710 + "ref: refs/heads/feature\n"; 711 + (match Git.Repository.rename_branch repo ~new_name:"renamed-feature" with 712 + | Ok () -> () 713 + | Error (`Msg msg) -> Alcotest.fail msg); 714 + let log = Git.Repository.log repo c3 in 715 + Alcotest.(check int) "3 commits in history" 3 (List.length log) 716 + 717 + let test_rename_branch_multiple () = 718 + with_temp_repo @@ fun fs tmp_dir -> 719 + let repo = Git.Repository.init ~fs tmp_dir in 720 + let tree = Git.Repository.write_tree repo (Git.Tree.v []) in 721 + let c1 = commit ~repo ~tree ~parents:[] ~message:"initial" in 722 + Git.Repository.write_ref repo "refs/heads/main" c1; 723 + let head_path = Eio.Path.(fs / Fpath.to_string tmp_dir / ".git" / "HEAD") in 724 + Eio.Path.save ~create:(`Or_truncate 0o644) head_path "ref: refs/heads/main\n"; 725 + (match Git.Repository.rename_branch repo ~new_name:"branch-a" with 726 + | Ok () -> () 727 + | Error (`Msg msg) -> Alcotest.fail msg); 728 + (match Git.Repository.rename_branch repo ~new_name:"branch-b" with 729 + | Ok () -> () 730 + | Error (`Msg msg) -> Alcotest.fail msg); 731 + (match Git.Repository.rename_branch repo ~new_name:"branch-c" with 732 + | Ok () -> () 733 + | Error (`Msg msg) -> Alcotest.fail msg); 734 + Alcotest.(check (option string)) 735 + "current branch" (Some "branch-c") 736 + (Git.Repository.current_branch repo); 737 + Alcotest.(check (option hash)) 738 + "branch-c has commit" (Some c1) 739 + (Git.Repository.read_ref repo "refs/heads/branch-c"); 740 + Alcotest.(check (option hash)) 741 + "branch-a deleted" None 742 + (Git.Repository.read_ref repo "refs/heads/branch-a"); 743 + Alcotest.(check (option hash)) 744 + "branch-b deleted" None 745 + (Git.Repository.read_ref repo "refs/heads/branch-b") 746 + 747 + (* tree_hash_at_path tests *) 748 + 749 + let test_treehash_at_path_root () = 750 + with_temp_repo @@ fun fs tmp_dir -> 751 + let repo = Git.Repository.init ~fs tmp_dir in 752 + let blob = Git.Repository.write_blob repo "content" in 753 + let tree = 754 + Git.Repository.write_tree repo 755 + (Git.Tree.v [ { name = "file.txt"; perm = `Normal; hash = blob } ]) 756 + in 757 + let c1 = commit ~repo ~tree ~parents:[] ~message:"initial" in 758 + Git.Repository.write_ref repo "refs/heads/main" c1; 759 + let head_path = Eio.Path.(fs / Fpath.to_string tmp_dir / ".git" / "HEAD") in 760 + Eio.Path.save ~create:(`Or_truncate 0o644) head_path "ref: refs/heads/main\n"; 761 + Alcotest.(check (option hash)) 762 + "root tree" (Some tree) 763 + (Git.Repository.tree_hash_at_path repo ~rev:"main" ~path:"") 764 + 765 + let test_treehash_at_path_subdir () = 766 + with_temp_repo @@ fun fs tmp_dir -> 767 + let repo = Git.Repository.init ~fs tmp_dir in 768 + let blob = Git.Repository.write_blob repo "content" in 769 + let sub_tree = 770 + Git.Repository.write_tree repo 771 + (Git.Tree.v [ { name = "file.txt"; perm = `Normal; hash = blob } ]) 772 + in 773 + let root_tree = 774 + Git.Repository.write_tree repo 775 + (Git.Tree.v [ { name = "subdir"; perm = `Dir; hash = sub_tree } ]) 776 + in 777 + let c1 = commit ~repo ~tree:root_tree ~parents:[] ~message:"initial" in 778 + Git.Repository.write_ref repo "refs/heads/main" c1; 779 + let head_path = Eio.Path.(fs / Fpath.to_string tmp_dir / ".git" / "HEAD") in 780 + Eio.Path.save ~create:(`Or_truncate 0o644) head_path "ref: refs/heads/main\n"; 781 + Alcotest.(check (option hash)) 782 + "subdir tree" (Some sub_tree) 783 + (Git.Repository.tree_hash_at_path repo ~rev:"main" ~path:"subdir") 784 + 785 + let test_treehash_at_path_missing () = 786 + with_temp_repo @@ fun fs tmp_dir -> 787 + let repo = Git.Repository.init ~fs tmp_dir in 788 + let tree = Git.Repository.write_tree repo (Git.Tree.v []) in 789 + let c1 = commit ~repo ~tree ~parents:[] ~message:"initial" in 790 + Git.Repository.write_ref repo "refs/heads/main" c1; 791 + let head_path = Eio.Path.(fs / Fpath.to_string tmp_dir / ".git" / "HEAD") in 792 + Eio.Path.save ~create:(`Or_truncate 0o644) head_path "ref: refs/heads/main\n"; 793 + Alcotest.(check (option hash)) 794 + "not found" None 795 + (Git.Repository.tree_hash_at_path repo ~rev:"main" ~path:"nonexistent") 796 + 797 + (* subtree_last_upstream_commit tests *) 798 + 799 + let test_subtree_last_upstream_squash () = 800 + with_temp_repo @@ fun fs tmp_dir -> 801 + let repo = Git.Repository.init ~fs tmp_dir in 802 + let tree = Git.Repository.write_tree repo (Git.Tree.v []) in 803 + let author = Git.User.v ~name:"Test" ~email:"test@test.com" ~date:0L () in 804 + (* Create a commit with subtree squash message *) 805 + let msg = "Squashed 'my-package/' changes from abc123..def456" in 806 + let commit = Git.Commit.v ~tree ~author ~committer:author (Some msg) in 807 + let c1 = Git.Repository.write_commit repo commit in 808 + Git.Repository.write_ref repo "refs/heads/main" c1; 809 + let head_path = Eio.Path.(fs / Fpath.to_string tmp_dir / ".git" / "HEAD") in 810 + Eio.Path.save ~create:(`Or_truncate 0o644) head_path "ref: refs/heads/main\n"; 811 + Alcotest.(check (option string)) 812 + "finds upstream commit" (Some "def456") 813 + (Git.Repository.subtree_last_upstream_commit repo ~prefix:"my-package") 814 + 815 + let test_subtree_last_upstream_add () = 816 + with_temp_repo @@ fun fs tmp_dir -> 817 + let repo = Git.Repository.init ~fs tmp_dir in 818 + let tree = Git.Repository.write_tree repo (Git.Tree.v []) in 819 + let author = Git.User.v ~name:"Test" ~email:"test@test.com" ~date:0L () in 820 + (* Create a commit with subtree add message *) 821 + let msg = "Add 'lib/' from commit abc1234567890" in 822 + let commit = Git.Commit.v ~tree ~author ~committer:author (Some msg) in 823 + let c1 = Git.Repository.write_commit repo commit in 824 + Git.Repository.write_ref repo "refs/heads/main" c1; 825 + let head_path = Eio.Path.(fs / Fpath.to_string tmp_dir / ".git" / "HEAD") in 826 + Eio.Path.save ~create:(`Or_truncate 0o644) head_path "ref: refs/heads/main\n"; 827 + Alcotest.(check (option string)) 828 + "finds upstream commit" (Some "abc1234567890") 829 + (Git.Repository.subtree_last_upstream_commit repo ~prefix:"lib") 830 + 831 + let test_subtree_last_upstream_missing () = 832 + with_temp_repo @@ fun fs tmp_dir -> 833 + let repo = Git.Repository.init ~fs tmp_dir in 834 + let tree = Git.Repository.write_tree repo (Git.Tree.v []) in 835 + let c1 = commit ~repo ~tree ~parents:[] ~message:"Regular commit" in 836 + Git.Repository.write_ref repo "refs/heads/main" c1; 837 + let head_path = Eio.Path.(fs / Fpath.to_string tmp_dir / ".git" / "HEAD") in 838 + Eio.Path.save ~create:(`Or_truncate 0o644) head_path "ref: refs/heads/main\n"; 839 + Alcotest.(check (option string)) 840 + "not found" None 841 + (Git.Repository.subtree_last_upstream_commit repo ~prefix:"nonexistent") 842 + 843 + (* has_subtree_history tests *) 844 + 845 + let test_has_subtree_history_true () = 846 + with_temp_repo @@ fun fs tmp_dir -> 847 + let repo = Git.Repository.init ~fs tmp_dir in 848 + let tree = Git.Repository.write_tree repo (Git.Tree.v []) in 849 + let author = Git.User.v ~name:"Test" ~email:"test@test.com" ~date:0L () in 850 + let msg = "Squashed 'pkg/' changes from a..b" in 851 + let commit = Git.Commit.v ~tree ~author ~committer:author (Some msg) in 852 + let c1 = Git.Repository.write_commit repo commit in 853 + Git.Repository.write_ref repo "refs/heads/main" c1; 854 + let head_path = Eio.Path.(fs / Fpath.to_string tmp_dir / ".git" / "HEAD") in 855 + Eio.Path.save ~create:(`Or_truncate 0o644) head_path "ref: refs/heads/main\n"; 856 + Alcotest.(check bool) 857 + "has history" true 858 + (Git.Repository.has_subtree_history repo ~prefix:"pkg") 859 + 860 + let test_has_subtree_history_false () = 861 + with_temp_repo @@ fun fs tmp_dir -> 862 + let repo = Git.Repository.init ~fs tmp_dir in 863 + let tree = Git.Repository.write_tree repo (Git.Tree.v []) in 864 + let c1 = commit ~repo ~tree ~parents:[] ~message:"Regular commit" in 865 + Git.Repository.write_ref repo "refs/heads/main" c1; 866 + let head_path = Eio.Path.(fs / Fpath.to_string tmp_dir / ".git" / "HEAD") in 867 + Eio.Path.save ~create:(`Or_truncate 0o644) head_path "ref: refs/heads/main\n"; 868 + Alcotest.(check bool) 869 + "no history" false 870 + (Git.Repository.has_subtree_history repo ~prefix:"nonexistent") 871 + 872 + (* add_all tests *) 873 + 874 + let test_add_all_new_file () = 875 + with_temp_repo @@ fun fs tmp_dir -> 876 + let repo = Git.Repository.init ~fs tmp_dir in 877 + let work_dir = Eio.Path.(fs / Fpath.to_string tmp_dir) in 878 + Eio.Path.save ~create:(`Or_truncate 0o644) 879 + Eio.Path.(work_dir / "test.txt") 880 + "hello"; 881 + unwrap (Git.Repository.add_all repo); 882 + let idx = unwrap (Git.Repository.read_index repo) in 883 + let entries = Git.Index.entries idx in 884 + Alcotest.(check int) "one entry" 1 (List.length entries); 885 + Alcotest.(check string) "name" "test.txt" (List.hd entries).name 886 + 887 + let test_add_all_nested_dirs () = 888 + with_temp_repo @@ fun fs tmp_dir -> 889 + let repo = Git.Repository.init ~fs tmp_dir in 890 + let work_dir = Eio.Path.(fs / Fpath.to_string tmp_dir) in 891 + Eio.Path.mkdir ~perm:0o755 Eio.Path.(work_dir / "src"); 892 + Eio.Path.mkdir ~perm:0o755 Eio.Path.(work_dir / "src" / "lib"); 893 + Eio.Path.save ~create:(`Or_truncate 0o644) 894 + Eio.Path.(work_dir / "src" / "lib" / "foo.ml") 895 + "let x = 1"; 896 + Eio.Path.save ~create:(`Or_truncate 0o644) 897 + Eio.Path.(work_dir / "src" / "main.ml") 898 + "let () = ()"; 899 + unwrap (Git.Repository.add_all repo); 900 + let idx = unwrap (Git.Repository.read_index repo) in 901 + let entries = Git.Index.entries idx in 902 + Alcotest.(check int) "two entries" 2 (List.length entries); 903 + let names = List.map (fun (e : Git.Index.entry) -> e.name) entries in 904 + Alcotest.(check bool) "has foo.ml" true (List.mem "src/lib/foo.ml" names); 905 + Alcotest.(check bool) "has main.ml" true (List.mem "src/main.ml" names) 906 + 907 + let test_add_all_removes_deleted () = 908 + with_temp_repo @@ fun fs tmp_dir -> 909 + let repo = Git.Repository.init ~fs tmp_dir in 910 + let work_dir = Eio.Path.(fs / Fpath.to_string tmp_dir) in 911 + Eio.Path.save ~create:(`Or_truncate 0o644) 912 + Eio.Path.(work_dir / "file.txt") 913 + "content"; 914 + unwrap (Git.Repository.add_all repo); 915 + Eio.Path.unlink Eio.Path.(work_dir / "file.txt"); 916 + unwrap (Git.Repository.add_all repo); 917 + let idx = unwrap (Git.Repository.read_index repo) in 918 + Alcotest.(check int) "no entries" 0 (List.length (Git.Index.entries idx)) 919 + 920 + let test_add_all_updates_modified () = 921 + with_temp_repo @@ fun fs tmp_dir -> 922 + let repo = Git.Repository.init ~fs tmp_dir in 923 + let work_dir = Eio.Path.(fs / Fpath.to_string tmp_dir) in 924 + Eio.Path.save ~create:(`Or_truncate 0o644) 925 + Eio.Path.(work_dir / "file.txt") 926 + "v1"; 927 + unwrap (Git.Repository.add_all repo); 928 + let hash1 = 929 + (List.hd (Git.Index.entries (unwrap (Git.Repository.read_index repo)))).hash 930 + in 931 + Eio.Path.save ~create:(`Or_truncate 0o644) 932 + Eio.Path.(work_dir / "file.txt") 933 + "v2"; 934 + unwrap (Git.Repository.add_all repo); 935 + let hash2 = 936 + (List.hd (Git.Index.entries (unwrap (Git.Repository.read_index repo)))).hash 937 + in 938 + Alcotest.(check bool) "hash changed" false (Git.Hash.equal hash1 hash2) 939 + 940 + let test_addall_ignores_git_dir () = 941 + with_temp_repo @@ fun fs tmp_dir -> 942 + let repo = Git.Repository.init ~fs tmp_dir in 943 + let work_dir = Eio.Path.(fs / Fpath.to_string tmp_dir) in 944 + Eio.Path.save ~create:(`Or_truncate 0o644) 945 + Eio.Path.(work_dir / ".git" / "test") 946 + "ignored"; 947 + Eio.Path.save ~create:(`Or_truncate 0o644) 948 + Eio.Path.(work_dir / "normal.txt") 949 + "included"; 950 + unwrap (Git.Repository.add_all repo); 951 + let idx = unwrap (Git.Repository.read_index repo) in 952 + let entries = Git.Index.entries idx in 953 + Alcotest.(check int) "one entry" 1 (List.length entries); 954 + Alcotest.(check string) "only normal.txt" "normal.txt" (List.hd entries).name 955 + 956 + (* commit tests *) 957 + 958 + let setup_config repo = 959 + let config = 960 + Git.Config.( 961 + empty 962 + |> set ~section:(section "user") ~key:"name" ~value:"Test User" 963 + |> set ~section:(section "user") ~key:"email" ~value:"test@test.com") 964 + in 965 + Git.Repository.write_config repo config 966 + 967 + let test_commit_basic () = 968 + with_temp_repo @@ fun fs tmp_dir -> 969 + let repo = Git.Repository.init ~fs tmp_dir in 970 + setup_config repo; 971 + let work_dir = Eio.Path.(fs / Fpath.to_string tmp_dir) in 972 + Eio.Path.save ~create:(`Or_truncate 0o644) 973 + Eio.Path.(work_dir / "file.txt") 974 + "content"; 975 + unwrap (Git.Repository.add_all repo); 976 + let commit_hash = 977 + unwrap (Git.Repository.commit repo ~message:"Initial commit") 978 + in 979 + match unwrap (Git.Repository.read repo commit_hash) with 980 + | Git.Value.Commit c -> 981 + Alcotest.(check (option string)) 982 + "message" (Some "Initial commit") (Git.Commit.message c); 983 + Alcotest.(check string) 984 + "author" "Test User" 985 + (Git.User.name (Git.Commit.author c)) 986 + | _ -> Alcotest.fail "expected commit" 987 + 988 + let test_commit_updates_head () = 989 + with_temp_repo @@ fun fs tmp_dir -> 990 + let repo = Git.Repository.init ~fs tmp_dir in 991 + setup_config repo; 992 + let work_dir = Eio.Path.(fs / Fpath.to_string tmp_dir) in 993 + Eio.Path.save ~create:(`Or_truncate 0o644) 994 + Eio.Path.(work_dir / "file.txt") 995 + "content"; 996 + unwrap (Git.Repository.add_all repo); 997 + let commit_hash = unwrap (Git.Repository.commit repo ~message:"test") in 998 + Alcotest.(check (option hash)) 999 + "HEAD updated" (Some commit_hash) (Git.Repository.head repo) 1000 + 1001 + let test_commit_no_config () = 1002 + with_temp_repo @@ fun fs tmp_dir -> 1003 + let repo = Git.Repository.init ~fs tmp_dir in 1004 + let work_dir = Eio.Path.(fs / Fpath.to_string tmp_dir) in 1005 + Eio.Path.save ~create:(`Or_truncate 0o644) 1006 + Eio.Path.(work_dir / "file.txt") 1007 + "content"; 1008 + unwrap (Git.Repository.add_all repo); 1009 + match Git.Repository.commit repo ~message:"test" with 1010 + | Error (`Msg msg) -> 1011 + Alcotest.(check bool) "error mentions config" true (String.length msg > 0) 1012 + | Ok _ -> Alcotest.fail "expected error" 1013 + 1014 + let test_commit_no_user_name () = 1015 + with_temp_repo @@ fun fs tmp_dir -> 1016 + let repo = Git.Repository.init ~fs tmp_dir in 1017 + let config = 1018 + Git.Config.( 1019 + empty |> set ~section:(section "user") ~key:"email" ~value:"test@test.com") 1020 + in 1021 + Git.Repository.write_config repo config; 1022 + let work_dir = Eio.Path.(fs / Fpath.to_string tmp_dir) in 1023 + Eio.Path.save ~create:(`Or_truncate 0o644) 1024 + Eio.Path.(work_dir / "file.txt") 1025 + "content"; 1026 + unwrap (Git.Repository.add_all repo); 1027 + match Git.Repository.commit repo ~message:"test" with 1028 + | Error (`Msg msg) -> 1029 + Alcotest.(check bool) 1030 + "error mentions user.name" true 1031 + (string_contains ~needle:"user.name" msg) 1032 + | Ok _ -> Alcotest.fail "expected error" 1033 + 1034 + let test_commit_multiple () = 1035 + with_temp_repo @@ fun fs tmp_dir -> 1036 + let repo = Git.Repository.init ~fs tmp_dir in 1037 + setup_config repo; 1038 + let work_dir = Eio.Path.(fs / Fpath.to_string tmp_dir) in 1039 + Eio.Path.save ~create:(`Or_truncate 0o644) 1040 + Eio.Path.(work_dir / "file.txt") 1041 + "v1"; 1042 + unwrap (Git.Repository.add_all repo); 1043 + let hash1 = unwrap (Git.Repository.commit repo ~message:"c1") in 1044 + Eio.Path.save ~create:(`Or_truncate 0o644) 1045 + Eio.Path.(work_dir / "file.txt") 1046 + "v2"; 1047 + unwrap (Git.Repository.add_all repo); 1048 + let hash2 = unwrap (Git.Repository.commit repo ~message:"c2") in 1049 + match unwrap (Git.Repository.read repo hash2) with 1050 + | Git.Value.Commit c -> 1051 + Alcotest.(check (list hash)) "parent" [ hash1 ] (Git.Commit.parents c) 1052 + | _ -> Alcotest.fail "expected commit" 1053 + 1054 + (* rm tests *) 1055 + 1056 + let test_rm_single_file () = 1057 + with_temp_repo @@ fun fs tmp_dir -> 1058 + let repo = Git.Repository.init ~fs tmp_dir in 1059 + let work_dir = Eio.Path.(fs / Fpath.to_string tmp_dir) in 1060 + Eio.Path.save ~create:(`Or_truncate 0o644) 1061 + Eio.Path.(work_dir / "keep.txt") 1062 + "keep"; 1063 + Eio.Path.save ~create:(`Or_truncate 0o644) 1064 + Eio.Path.(work_dir / "delete.txt") 1065 + "delete"; 1066 + unwrap (Git.Repository.add_all repo); 1067 + unwrap (Git.Repository.rm repo ~recursive:false "delete.txt"); 1068 + let idx = unwrap (Git.Repository.read_index repo) in 1069 + let entries = Git.Index.entries idx in 1070 + Alcotest.(check int) "one entry" 1 (List.length entries); 1071 + Alcotest.(check string) "keep.txt" "keep.txt" (List.hd entries).name; 1072 + Alcotest.(check bool) 1073 + "file deleted" false 1074 + (Eio.Path.is_file Eio.Path.(work_dir / "delete.txt")) 1075 + 1076 + let test_rm_recursive () = 1077 + with_temp_repo @@ fun fs tmp_dir -> 1078 + let repo = Git.Repository.init ~fs tmp_dir in 1079 + let work_dir = Eio.Path.(fs / Fpath.to_string tmp_dir) in 1080 + Eio.Path.mkdir ~perm:0o755 Eio.Path.(work_dir / "dir"); 1081 + Eio.Path.save ~create:(`Or_truncate 0o644) 1082 + Eio.Path.(work_dir / "dir" / "a.txt") 1083 + "a"; 1084 + Eio.Path.save ~create:(`Or_truncate 0o644) 1085 + Eio.Path.(work_dir / "dir" / "b.txt") 1086 + "b"; 1087 + Eio.Path.save ~create:(`Or_truncate 0o644) 1088 + Eio.Path.(work_dir / "keep.txt") 1089 + "keep"; 1090 + unwrap (Git.Repository.add_all repo); 1091 + unwrap (Git.Repository.rm repo ~recursive:true "dir"); 1092 + let idx = unwrap (Git.Repository.read_index repo) in 1093 + let entries = Git.Index.entries idx in 1094 + Alcotest.(check int) "one entry" 1 (List.length entries); 1095 + Alcotest.(check string) "keep.txt" "keep.txt" (List.hd entries).name; 1096 + Alcotest.(check bool) 1097 + "dir deleted" false 1098 + (Eio.Path.is_directory Eio.Path.(work_dir / "dir")) 1099 + 1100 + let test_rm_preserves_others () = 1101 + with_temp_repo @@ fun fs tmp_dir -> 1102 + let repo = Git.Repository.init ~fs tmp_dir in 1103 + let work_dir = Eio.Path.(fs / Fpath.to_string tmp_dir) in 1104 + Eio.Path.save ~create:(`Or_truncate 0o644) 1105 + Eio.Path.(work_dir / "foo.txt") 1106 + "foo"; 1107 + Eio.Path.save ~create:(`Or_truncate 0o644) 1108 + Eio.Path.(work_dir / "foobar.txt") 1109 + "foobar"; 1110 + unwrap (Git.Repository.add_all repo); 1111 + unwrap (Git.Repository.rm repo ~recursive:false "foo.txt"); 1112 + let idx = unwrap (Git.Repository.read_index repo) in 1113 + let entries = Git.Index.entries idx in 1114 + Alcotest.(check int) "one entry" 1 (List.length entries); 1115 + Alcotest.(check string) "foobar.txt" "foobar.txt" (List.hd entries).name 1116 + 1117 + let test_rm_nonexistent () = 1118 + with_temp_repo @@ fun fs tmp_dir -> 1119 + let repo = Git.Repository.init ~fs tmp_dir in 1120 + unwrap (Git.Repository.rm repo ~recursive:false "nonexistent.txt") 1121 + 1122 + let add_all_tests = 1123 + [ 1124 + Alcotest.test_case "new_file" `Quick test_add_all_new_file; 1125 + Alcotest.test_case "nested_dirs" `Quick test_add_all_nested_dirs; 1126 + Alcotest.test_case "removes_deleted" `Quick test_add_all_removes_deleted; 1127 + Alcotest.test_case "updates_modified" `Quick test_add_all_updates_modified; 1128 + Alcotest.test_case "ignores_git_dir" `Quick test_addall_ignores_git_dir; 1129 + ] 1130 + 1131 + let commit_tests = 1132 + [ 1133 + Alcotest.test_case "basic" `Quick test_commit_basic; 1134 + Alcotest.test_case "updates_head" `Quick test_commit_updates_head; 1135 + Alcotest.test_case "no_config" `Quick test_commit_no_config; 1136 + Alcotest.test_case "no_user_name" `Quick test_commit_no_user_name; 1137 + Alcotest.test_case "multiple" `Quick test_commit_multiple; 1138 + ] 1139 + 1140 + let rm_tests = 1141 + [ 1142 + Alcotest.test_case "single_file" `Quick test_rm_single_file; 1143 + Alcotest.test_case "recursive" `Quick test_rm_recursive; 1144 + Alcotest.test_case "preserves_others" `Quick test_rm_preserves_others; 1145 + Alcotest.test_case "nonexistent" `Quick test_rm_nonexistent; 1146 + ] 1147 + 1148 + let tests = 1149 + [ 1150 + Alcotest.test_case "is_repo" `Quick test_is_repo; 1151 + Alcotest.test_case "current_branch" `Quick test_current_branch; 1152 + ] 1153 + 1154 + let log_tests = 1155 + [ 1156 + Alcotest.test_case "log" `Quick test_log; 1157 + Alcotest.test_case "log_max_count" `Quick test_log_max_count; 1158 + Alcotest.test_case "log_range" `Quick test_log_range; 1159 + ] 1160 + 1161 + let log_filtered_tests = 1162 + [ 1163 + Alcotest.test_case "since" `Quick test_log_filtered_since; 1164 + Alcotest.test_case "until" `Quick test_log_filtered_until; 1165 + Alcotest.test_case "path" `Quick test_log_filtered_path; 1166 + Alcotest.test_case "combined" `Quick test_log_filtered_combined; 1167 + ] 1168 + 1169 + let resolve_ref_tests = 1170 + [ 1171 + Alcotest.test_case "head" `Quick test_resolve_ref_head; 1172 + Alcotest.test_case "branch" `Quick test_resolve_ref_branch; 1173 + Alcotest.test_case "remote" `Quick test_resolve_ref_remote; 1174 + Alcotest.test_case "not_found" `Quick test_resolve_ref_not_found; 1175 + ] 1176 + 1177 + let log_range_refs_tests = 1178 + [ 1179 + Alcotest.test_case "basic" `Quick test_log_range_refs_basic; 1180 + Alcotest.test_case "head" `Quick test_log_range_refs_head; 1181 + Alcotest.test_case "error" `Quick test_log_range_refs_error; 1182 + ] 1183 + 1184 + let advance_head_tests = 1185 + [ 1186 + Alcotest.test_case "updates_branch" `Quick test_advance_head_updates_branch; 1187 + Alcotest.test_case "detached" `Quick test_advance_head_detached; 1188 + ] 1189 + 1190 + let ancestry_tests = 1191 + [ 1192 + Alcotest.test_case "is_ancestor_same_commit" `Quick 1193 + test_repo_ancestor_same_commit; 1194 + Alcotest.test_case "is_ancestor_direct_parent" `Quick 1195 + test_repo_ancestor_direct_parent; 1196 + Alcotest.test_case "is_ancestor_distant" `Quick 1197 + test_repo_is_ancestor_distant; 1198 + Alcotest.test_case "is_ancestor_diverged" `Quick 1199 + test_repo_is_ancestor_diverged; 1200 + Alcotest.test_case "is_ancestor_after_merge" `Quick 1201 + test_repo_ancestor_after_merge; 1202 + Alcotest.test_case "count_commits_zero" `Quick test_repo_count_commits_zero; 1203 + Alcotest.test_case "count_commits_adjacent" `Quick 1204 + test_repo_count_commits_adjacent; 1205 + Alcotest.test_case "count_commits_linear" `Quick 1206 + test_repo_count_commits_linear; 1207 + Alcotest.test_case "count_commits_with_merge" `Quick 1208 + test_repo_count_commits_merged; 1209 + Alcotest.test_case "merge_base_same" `Quick test_repo_merge_base_same; 1210 + Alcotest.test_case "merge_base_linear" `Quick test_repo_merge_base_linear; 1211 + Alcotest.test_case "merge_base_diamond" `Quick test_repo_merge_base_diamond; 1212 + Alcotest.test_case "merge_base_deep_divergence" `Quick 1213 + test_repo_mergebase_deep_divergence; 1214 + Alcotest.test_case "merge_base_after_merge" `Quick 1215 + test_repo_mergebase_after_merge; 1216 + Alcotest.test_case "merge_base_criss_cross" `Quick 1217 + test_repo_mergebase_criss_cross; 1218 + Alcotest.test_case "merge_base_no_common" `Quick 1219 + test_repo_mergebase_no_common; 1220 + ] 1221 + 1222 + let set_push_url_tests = 1223 + [ 1224 + Alcotest.test_case "basic" `Quick test_set_push_url_basic; 1225 + Alcotest.test_case "update" `Quick test_set_push_url_update; 1226 + Alcotest.test_case "no_remote" `Quick test_set_pushurl_no_remote; 1227 + Alcotest.test_case "preserves_config" `Quick 1228 + test_set_pushurl_preserves_config; 1229 + ] 1230 + 1231 + let rename_branch_tests = 1232 + [ 1233 + Alcotest.test_case "basic" `Quick test_rename_branch_basic; 1234 + Alcotest.test_case "detached" `Quick test_rename_branch_detached; 1235 + Alcotest.test_case "preserves_history" `Quick 1236 + test_rename_branch_preserves_history; 1237 + Alcotest.test_case "multiple" `Quick test_rename_branch_multiple; 1238 + ] 1239 + 1240 + let tree_hash_at_path_tests = 1241 + [ 1242 + Alcotest.test_case "root" `Quick test_treehash_at_path_root; 1243 + Alcotest.test_case "subdir" `Quick test_treehash_at_path_subdir; 1244 + Alcotest.test_case "not_found" `Quick test_treehash_at_path_missing; 1245 + ] 1246 + 1247 + let subtree_history_tests = 1248 + [ 1249 + Alcotest.test_case "squash" `Quick test_subtree_last_upstream_squash; 1250 + Alcotest.test_case "add" `Quick test_subtree_last_upstream_add; 1251 + Alcotest.test_case "not_found" `Quick test_subtree_last_upstream_missing; 1252 + Alcotest.test_case "has_history_true" `Quick test_has_subtree_history_true; 1253 + Alcotest.test_case "has_history_false" `Quick test_has_subtree_history_false; 1254 + ] 1255 + 1256 + let suite = 1257 + ( "repository", 1258 + tests @ log_tests @ log_filtered_tests @ resolve_ref_tests 1259 + @ log_range_refs_tests @ advance_head_tests @ ancestry_tests 1260 + @ set_push_url_tests @ rename_branch_tests @ tree_hash_at_path_tests 1261 + @ subtree_history_tests @ add_all_tests @ commit_tests @ rm_tests )
+3
test/test_repository.mli
··· 1 + (** Repository tests. *) 2 + 3 + val suite : string * unit Alcotest.test_case list
+92
test/test_rev_list.ml
··· 1 + (* Copyright (c) 2024-2026 Thomas Gazagnaire <thomas@gazagnaire.org> 2 + 3 + Permission to use, copy, modify, and distribute this software for any 4 + purpose with or without fee is hereby granted, provided that the above 5 + copyright notice and this permission notice appear in all copies. 6 + 7 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. *) 14 + 15 + let hash = Test_helpers.hash 16 + let with_temp_repo = Test_helpers.with_temp_repo 17 + let commit = Test_helpers.commit 18 + 19 + let test_is_ancestor () = 20 + with_temp_repo @@ fun fs tmp_dir -> 21 + let repo = Git.Repository.init ~fs tmp_dir in 22 + let tree = Git.Repository.write_tree repo (Git.Tree.v []) in 23 + let c1 = commit ~repo ~tree ~parents:[] ~message:"c1" in 24 + let c2 = commit ~repo ~tree ~parents:[ c1 ] ~message:"c2" in 25 + let c3 = commit ~repo ~tree ~parents:[ c2 ] ~message:"c3" in 26 + Alcotest.(check bool) 27 + "c1 ancestor of c3" true 28 + (Git.Rev_list.is_ancestor repo ~ancestor:c1 ~descendant:c3); 29 + Alcotest.(check bool) 30 + "c3 not ancestor of c1" false 31 + (Git.Rev_list.is_ancestor repo ~ancestor:c3 ~descendant:c1); 32 + Alcotest.(check bool) 33 + "c2 ancestor of c2" true 34 + (Git.Rev_list.is_ancestor repo ~ancestor:c2 ~descendant:c2) 35 + 36 + let test_merge_base () = 37 + with_temp_repo @@ fun fs tmp_dir -> 38 + let repo = Git.Repository.init ~fs tmp_dir in 39 + let tree = Git.Repository.write_tree repo (Git.Tree.v []) in 40 + let c1 = commit ~repo ~tree ~parents:[] ~message:"c1" in 41 + let c2 = commit ~repo ~tree ~parents:[ c1 ] ~message:"c2" in 42 + let c3 = commit ~repo ~tree ~parents:[ c2 ] ~message:"c3" in 43 + let c4 = commit ~repo ~tree ~parents:[ c2 ] ~message:"c4" in 44 + Alcotest.(check (option hash)) 45 + "merge-base c3 c4" (Some c2) 46 + (Git.Rev_list.merge_base repo c3 c4); 47 + Alcotest.(check (option hash)) 48 + "merge-base c1 c3" (Some c1) 49 + (Git.Rev_list.merge_base repo c1 c3) 50 + 51 + let test_count_commits_between () = 52 + with_temp_repo @@ fun fs tmp_dir -> 53 + let repo = Git.Repository.init ~fs tmp_dir in 54 + let tree = Git.Repository.write_tree repo (Git.Tree.v []) in 55 + let c1 = commit ~repo ~tree ~parents:[] ~message:"c1" in 56 + let c2 = commit ~repo ~tree ~parents:[ c1 ] ~message:"c2" in 57 + let c3 = commit ~repo ~tree ~parents:[ c2 ] ~message:"c3" in 58 + let c4 = commit ~repo ~tree ~parents:[ c3 ] ~message:"c4" in 59 + Alcotest.(check int) 60 + "c1..c4 = 3" 3 61 + (Git.Rev_list.count_commits_between repo ~base:c1 ~head:c4); 62 + Alcotest.(check int) 63 + "c2..c4 = 2" 2 64 + (Git.Rev_list.count_commits_between repo ~base:c2 ~head:c4); 65 + Alcotest.(check int) 66 + "c1..c1 = 0" 0 67 + (Git.Rev_list.count_commits_between repo ~base:c1 ~head:c1) 68 + 69 + let test_ahead_behind () = 70 + with_temp_repo @@ fun fs tmp_dir -> 71 + let repo = Git.Repository.init ~fs tmp_dir in 72 + let tree = Git.Repository.write_tree repo (Git.Tree.v []) in 73 + let c1 = commit ~repo ~tree ~parents:[] ~message:"c1" in 74 + let c2 = commit ~repo ~tree ~parents:[ c1 ] ~message:"c2" in 75 + let c3 = commit ~repo ~tree ~parents:[ c2 ] ~message:"c3" in 76 + let c4 = commit ~repo ~tree ~parents:[ c3 ] ~message:"c4" in 77 + let c5 = commit ~repo ~tree ~parents:[ c2 ] ~message:"c5" in 78 + let c6 = commit ~repo ~tree ~parents:[ c5 ] ~message:"c6" in 79 + let c7 = commit ~repo ~tree ~parents:[ c6 ] ~message:"c7" in 80 + let ab = Git.Rev_list.ahead_behind repo ~local:c4 ~remote:c7 in 81 + Alcotest.(check int) "ahead" 2 ab.ahead; 82 + Alcotest.(check int) "behind" 3 ab.behind 83 + 84 + let suite = 85 + ( "rev_list", 86 + [ 87 + Alcotest.test_case "is_ancestor" `Quick test_is_ancestor; 88 + Alcotest.test_case "merge_base" `Quick test_merge_base; 89 + Alcotest.test_case "count_commits_between" `Quick 90 + test_count_commits_between; 91 + Alcotest.test_case "ahead_behind" `Quick test_ahead_behind; 92 + ] )
+3
test/test_rev_list.mli
··· 1 + (** Rev_list tests. *) 2 + 3 + val suite : string * unit Alcotest.test_case list
+256
test/test_subtree.ml
··· 1 + (* Copyright (c) 2024-2026 Thomas Gazagnaire <thomas@gazagnaire.org> 2 + 3 + Permission to use, copy, modify, and distribute this software for any 4 + purpose with or without fee is hereby granted, provided that the above 5 + copyright notice and this permission notice appear in all copies. 6 + 7 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. *) 14 + 15 + let hash = Test_helpers.hash 16 + let with_temp_repo = Test_helpers.with_temp_repo 17 + 18 + let test_add () = 19 + with_temp_repo @@ fun fs tmp_dir -> 20 + let repo = Git.Repository.init ~fs tmp_dir in 21 + let author = 22 + Git.User.v ~name:"Test" ~email:"test@example.com" ~date:1700000000L () 23 + in 24 + let remote_blob = Git.Repository.write_blob repo "Remote file content" in 25 + let remote_tree = 26 + Git.Tree.v [ Git.Tree.entry ~perm:`Normal ~name:"remote.txt" remote_blob ] 27 + in 28 + let remote_tree_hash = Git.Repository.write_tree repo remote_tree in 29 + let remote_commit = 30 + Git.Commit.v ~tree:remote_tree_hash ~author ~committer:author 31 + (Some "Remote commit") 32 + in 33 + let remote_commit_hash = Git.Repository.write_commit repo remote_commit in 34 + let main_blob = Git.Repository.write_blob repo "Main file content" in 35 + let main_tree = 36 + Git.Tree.v [ Git.Tree.entry ~perm:`Normal ~name:"main.txt" main_blob ] 37 + in 38 + let main_tree_hash = Git.Repository.write_tree repo main_tree in 39 + let main_commit = 40 + Git.Commit.v ~tree:main_tree_hash ~author ~committer:author 41 + (Some "Main commit") 42 + in 43 + let main_commit_hash = Git.Repository.write_commit repo main_commit in 44 + Git.Repository.write_ref repo "HEAD" main_commit_hash; 45 + match 46 + Git.Subtree.add repo ~prefix:"external" ~commit:remote_commit_hash ~author 47 + ~committer:author () 48 + with 49 + | Error (`Msg e) -> Alcotest.fail ("subtree add failed: " ^ e) 50 + | Ok merge_hash -> ( 51 + match Git.Repository.read repo merge_hash with 52 + | Error (`Msg e) -> Alcotest.fail e 53 + | Ok (Git.Value.Commit merge_commit) -> ( 54 + let parents = Git.Commit.parents merge_commit in 55 + Alcotest.(check int) "two parents" 2 (List.length parents); 56 + Alcotest.(check hash) 57 + "first parent is main" main_commit_hash (List.hd parents); 58 + Alcotest.(check hash) 59 + "second parent is remote" remote_commit_hash (List.nth parents 1); 60 + let merge_tree_hash = Git.Commit.tree merge_commit in 61 + match Git.Repository.read repo merge_tree_hash with 62 + | Error (`Msg e) -> Alcotest.fail e 63 + | Ok (Git.Value.Tree merge_tree) -> ( 64 + Alcotest.(check bool) 65 + "has main.txt" true 66 + (Option.is_some (Git.Tree.find ~name:"main.txt" merge_tree)); 67 + match Git.Tree.find ~name:"external" merge_tree with 68 + | None -> Alcotest.fail "missing external dir" 69 + | Some ext_entry -> ( 70 + Alcotest.(check bool) 71 + "external is dir" true (ext_entry.perm = `Dir); 72 + match Git.Repository.read repo ext_entry.hash with 73 + | Error (`Msg e) -> Alcotest.fail e 74 + | Ok (Git.Value.Tree ext_tree) -> 75 + Alcotest.(check bool) 76 + "has remote.txt" true 77 + (Option.is_some 78 + (Git.Tree.find ~name:"remote.txt" ext_tree)) 79 + | _ -> Alcotest.fail "expected tree")) 80 + | _ -> Alcotest.fail "expected tree") 81 + | _ -> Alcotest.fail "expected commit") 82 + 83 + let test_add_nested_prefix () = 84 + with_temp_repo @@ fun fs tmp_dir -> 85 + let repo = Git.Repository.init ~fs tmp_dir in 86 + let author = 87 + Git.User.v ~name:"Test" ~email:"test@example.com" ~date:1700000000L () 88 + in 89 + let remote_blob = Git.Repository.write_blob repo "Nested content" in 90 + let remote_tree = 91 + Git.Tree.v [ Git.Tree.entry ~perm:`Normal ~name:"nested.txt" remote_blob ] 92 + in 93 + let remote_tree_hash = Git.Repository.write_tree repo remote_tree in 94 + let remote_commit = 95 + Git.Commit.v ~tree:remote_tree_hash ~author ~committer:author 96 + (Some "Remote") 97 + in 98 + let remote_commit_hash = Git.Repository.write_commit repo remote_commit in 99 + let main_blob = Git.Repository.write_blob repo "Main" in 100 + let main_tree = 101 + Git.Tree.v [ Git.Tree.entry ~perm:`Normal ~name:"main.txt" main_blob ] 102 + in 103 + let main_tree_hash = Git.Repository.write_tree repo main_tree in 104 + let main_commit = 105 + Git.Commit.v ~tree:main_tree_hash ~author ~committer:author (Some "Main") 106 + in 107 + let main_commit_hash = Git.Repository.write_commit repo main_commit in 108 + Git.Repository.write_ref repo "HEAD" main_commit_hash; 109 + match 110 + Git.Subtree.add repo ~prefix:"vendor/libs/external" 111 + ~commit:remote_commit_hash ~author ~committer:author () 112 + with 113 + | Error (`Msg e) -> Alcotest.fail ("subtree add failed: " ^ e) 114 + | Ok merge_hash -> ( 115 + match Git.Repository.read repo merge_hash with 116 + | Error (`Msg e) -> Alcotest.fail e 117 + | Ok (Git.Value.Commit merge_commit) -> ( 118 + let tree_hash = Git.Commit.tree merge_commit in 119 + match 120 + Git.Subtree.tree_at_prefix repo tree_hash "vendor/libs/external" 121 + with 122 + | None -> Alcotest.fail "could not find vendor/libs/external" 123 + | Some ext_hash -> ( 124 + match Git.Repository.read repo ext_hash with 125 + | Error (`Msg e) -> Alcotest.fail e 126 + | Ok (Git.Value.Tree ext_tree) -> 127 + Alcotest.(check bool) 128 + "has nested.txt" true 129 + (Option.is_some (Git.Tree.find ~name:"nested.txt" ext_tree)) 130 + | _ -> Alcotest.fail "expected tree")) 131 + | _ -> Alcotest.fail "expected commit") 132 + 133 + let test_add_empty_repo () = 134 + with_temp_repo @@ fun fs tmp_dir -> 135 + let repo = Git.Repository.init ~fs tmp_dir in 136 + let author = 137 + Git.User.v ~name:"Test" ~email:"test@example.com" ~date:1700000000L () 138 + in 139 + let remote_blob = Git.Repository.write_blob repo "Content" in 140 + let remote_tree = 141 + Git.Tree.v [ Git.Tree.entry ~perm:`Normal ~name:"file.txt" remote_blob ] 142 + in 143 + let remote_tree_hash = Git.Repository.write_tree repo remote_tree in 144 + let remote_commit = 145 + Git.Commit.v ~tree:remote_tree_hash ~author ~committer:author 146 + (Some "Remote") 147 + in 148 + let remote_commit_hash = Git.Repository.write_commit repo remote_commit in 149 + match 150 + Git.Subtree.add repo ~prefix:"external" ~commit:remote_commit_hash ~author 151 + ~committer:author () 152 + with 153 + | Error (`Msg e) -> Alcotest.fail ("subtree add failed: " ^ e) 154 + | Ok commit_hash -> ( 155 + match Git.Repository.read repo commit_hash with 156 + | Error (`Msg e) -> Alcotest.fail e 157 + | Ok (Git.Value.Commit commit) -> ( 158 + let parents = Git.Commit.parents commit in 159 + Alcotest.(check int) "one parent" 1 (List.length parents); 160 + Alcotest.(check hash) 161 + "parent is remote" remote_commit_hash (List.hd parents); 162 + let tree_hash = Git.Commit.tree commit in 163 + match Git.Repository.read repo tree_hash with 164 + | Error (`Msg e) -> Alcotest.fail e 165 + | Ok (Git.Value.Tree tree) -> ( 166 + match Git.Tree.find ~name:"external" tree with 167 + | None -> Alcotest.fail "missing external dir" 168 + | Some ext_entry -> 169 + Alcotest.(check bool) 170 + "external is dir" true (ext_entry.perm = `Dir)) 171 + | _ -> Alcotest.fail "expected tree") 172 + | _ -> Alcotest.fail "expected commit") 173 + 174 + let test_merge () = 175 + with_temp_repo @@ fun fs tmp_dir -> 176 + let repo = Git.Repository.init ~fs tmp_dir in 177 + let author = 178 + Git.User.v ~name:"Test" ~email:"test@example.com" ~date:1700000000L () 179 + in 180 + let remote_blob1 = Git.Repository.write_blob repo "Version 1" in 181 + let remote_tree1 = 182 + Git.Tree.v [ Git.Tree.entry ~perm:`Normal ~name:"file.txt" remote_blob1 ] 183 + in 184 + let remote_tree1_hash = Git.Repository.write_tree repo remote_tree1 in 185 + let remote_commit1 = 186 + Git.Commit.v ~tree:remote_tree1_hash ~author ~committer:author 187 + (Some "Remote v1") 188 + in 189 + let remote_commit1_hash = Git.Repository.write_commit repo remote_commit1 in 190 + let main_blob = Git.Repository.write_blob repo "Main" in 191 + let main_tree = 192 + Git.Tree.v [ Git.Tree.entry ~perm:`Normal ~name:"main.txt" main_blob ] 193 + in 194 + let main_tree_hash = Git.Repository.write_tree repo main_tree in 195 + let main_commit = 196 + Git.Commit.v ~tree:main_tree_hash ~author ~committer:author (Some "Main") 197 + in 198 + let main_commit_hash = Git.Repository.write_commit repo main_commit in 199 + Git.Repository.write_ref repo "HEAD" main_commit_hash; 200 + (match 201 + Git.Subtree.add repo ~prefix:"external" ~commit:remote_commit1_hash ~author 202 + ~committer:author () 203 + with 204 + | Error (`Msg e) -> Alcotest.fail ("add failed: " ^ e) 205 + | Ok _ -> ()); 206 + let remote_blob2 = Git.Repository.write_blob repo "Version 2 - updated" in 207 + let remote_tree2 = 208 + Git.Tree.v 209 + [ 210 + Git.Tree.entry ~perm:`Normal ~name:"file.txt" remote_blob2; 211 + Git.Tree.entry ~perm:`Normal ~name:"new.txt" 212 + (Git.Repository.write_blob repo "New file"); 213 + ] 214 + in 215 + let remote_tree2_hash = Git.Repository.write_tree repo remote_tree2 in 216 + let remote_commit2 = 217 + Git.Commit.v ~tree:remote_tree2_hash ~author ~committer:author 218 + ~parents:[ remote_commit1_hash ] (Some "Remote v2") 219 + in 220 + let remote_commit2_hash = Git.Repository.write_commit repo remote_commit2 in 221 + match 222 + Git.Subtree.merge repo ~prefix:"external" ~commit:remote_commit2_hash 223 + ~author ~committer:author () 224 + with 225 + | Error (`Msg e) -> Alcotest.fail ("merge failed: " ^ e) 226 + | Ok merge_hash -> ( 227 + match Git.Repository.read repo merge_hash with 228 + | Error (`Msg e) -> Alcotest.fail e 229 + | Ok (Git.Value.Commit merge_commit) -> ( 230 + let parents = Git.Commit.parents merge_commit in 231 + Alcotest.(check int) "two parents" 2 (List.length parents); 232 + let tree_hash = Git.Commit.tree merge_commit in 233 + match Git.Subtree.tree_at_prefix repo tree_hash "external" with 234 + | None -> Alcotest.fail "subtree not found" 235 + | Some ext_hash -> ( 236 + match Git.Repository.read repo ext_hash with 237 + | Error (`Msg e) -> Alcotest.fail e 238 + | Ok (Git.Value.Tree ext_tree) -> 239 + Alcotest.(check bool) 240 + "has file.txt" true 241 + (Option.is_some (Git.Tree.find ~name:"file.txt" ext_tree)); 242 + Alcotest.(check bool) 243 + "has new.txt" true 244 + (Option.is_some (Git.Tree.find ~name:"new.txt" ext_tree)) 245 + | _ -> Alcotest.fail "expected tree")) 246 + | _ -> Alcotest.fail "expected commit") 247 + 248 + let tests = 249 + [ 250 + Alcotest.test_case "add" `Quick test_add; 251 + Alcotest.test_case "add_nested_prefix" `Quick test_add_nested_prefix; 252 + Alcotest.test_case "add_empty_repo" `Quick test_add_empty_repo; 253 + Alcotest.test_case "merge" `Quick test_merge; 254 + ] 255 + 256 + let suite = ("subtree", tests)
+3
test/test_subtree.mli
··· 1 + (** Subtree tests. *) 2 + 3 + val suite : string * unit Alcotest.test_case list
+36
test/test_tag.ml
··· 1 + (* Copyright (c) 2024-2026 Thomas Gazagnaire <thomas@gazagnaire.org> 2 + 3 + Permission to use, copy, modify, and distribute this software for any 4 + purpose with or without fee is hereby granted, provided that the above 5 + copyright notice and this permission notice appear in all copies. 6 + 7 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. *) 14 + 15 + let hash = Test_helpers.hash 16 + 17 + let test_roundtrip () = 18 + let obj_hash = Git.Hash.of_hex "da39a3ee5e6b4b0d3255bfef95601890afd80709" in 19 + let tagger = 20 + Git.User.v ~name:"Alice" ~email:"alice@example.com" ~date:1234567890L () 21 + in 22 + let tag = 23 + Git.Tag.v obj_hash Git.Tag.Commit ~tagger ~tag:"v1.0.0" 24 + (Some "Release v1.0.0\n") 25 + in 26 + let s = Git.Tag.to_string tag in 27 + match Git.Tag.of_string s with 28 + | Ok tag' -> 29 + Alcotest.(check hash) "object" obj_hash (Git.Tag.obj tag'); 30 + Alcotest.(check string) "tag" "v1.0.0" (Git.Tag.name tag'); 31 + Alcotest.(check (option string)) 32 + "message" (Some "Release v1.0.0\n") (Git.Tag.message tag') 33 + | Error (`Msg m) -> Alcotest.fail m 34 + 35 + let tests = [ Alcotest.test_case "roundtrip" `Quick test_roundtrip ] 36 + let suite = ("tag", tests)
+3
test/test_tag.mli
··· 1 + (** Tag tests. *) 2 + 3 + val suite : string * unit Alcotest.test_case list
+58
test/test_tree.ml
··· 1 + (* Copyright (c) 2024-2026 Thomas Gazagnaire <thomas@gazagnaire.org> 2 + 3 + Permission to use, copy, modify, and distribute this software for any 4 + purpose with or without fee is hereby granted, provided that the above 5 + copyright notice and this permission notice appear in all copies. 6 + 7 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. *) 14 + 15 + let hash = Test_helpers.hash 16 + 17 + let test_entry () = 18 + let h = Git.Hash.of_hex "da39a3ee5e6b4b0d3255bfef95601890afd80709" in 19 + let entry = Git.Tree.entry ~perm:`Normal ~name:"test.txt" h in 20 + Alcotest.(check string) "name" "test.txt" entry.name; 21 + Alcotest.(check hash) "hash" h entry.hash 22 + 23 + let test_roundtrip () = 24 + let h1 = Git.Hash.of_hex "da39a3ee5e6b4b0d3255bfef95601890afd80709" in 25 + let h2 = Git.Hash.of_hex "0000000000000000000000000000000000000001" in 26 + let entries = 27 + [ 28 + Git.Tree.entry ~perm:`Normal ~name:"file.txt" h1; 29 + Git.Tree.entry ~perm:`Dir ~name:"subdir" h2; 30 + ] 31 + in 32 + let tree = Git.Tree.v entries in 33 + let s = Git.Tree.to_string tree in 34 + match Git.Tree.of_string s with 35 + | Ok tree' -> 36 + let entries' = Git.Tree.to_list tree' in 37 + Alcotest.(check int) "entry count" 2 (List.length entries') 38 + | Error (`Msg m) -> Alcotest.fail m 39 + 40 + let test_add_dedup () = 41 + let h1 = Git.Hash.of_hex "da39a3ee5e6b4b0d3255bfef95601890afd80709" in 42 + let h2 = Git.Hash.of_hex "0000000000000000000000000000000000000002" in 43 + let e1 = Git.Tree.entry ~perm:`Normal ~name:"file.txt" h1 in 44 + let e2 = Git.Tree.entry ~perm:`Normal ~name:"file.txt" h2 in 45 + let tree = Git.Tree.add e1 (Git.Tree.v []) in 46 + let tree = Git.Tree.add e2 tree in 47 + let entries = Git.Tree.to_list tree in 48 + Alcotest.(check int) "no duplicate after add" 1 (List.length entries); 49 + Alcotest.(check hash) "entry replaced" h2 (List.hd entries).hash 50 + 51 + let tests = 52 + [ 53 + Alcotest.test_case "entry" `Quick test_entry; 54 + Alcotest.test_case "roundtrip" `Quick test_roundtrip; 55 + Alcotest.test_case "add deduplicates" `Quick test_add_dedup; 56 + ] 57 + 58 + let suite = ("tree", tests)
+3
test/test_tree.mli
··· 1 + (** Tree tests. *) 2 + 3 + val suite : string * unit Alcotest.test_case list
+33
test/test_user.ml
··· 1 + (* Copyright (c) 2024-2026 Thomas Gazagnaire <thomas@gazagnaire.org> 2 + 3 + Permission to use, copy, modify, and distribute this software for any 4 + purpose with or without fee is hereby granted, provided that the above 5 + copyright notice and this permission notice appear in all copies. 6 + 7 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. *) 14 + 15 + let test_roundtrip () = 16 + let user = 17 + Git.User.v ~name:"Alice" ~email:"alice@example.com" ~date:1234567890L 18 + ~tz_offset:{ sign = `Plus; hours = 1; minutes = 0 } 19 + () 20 + in 21 + let s = Git.User.to_string user in 22 + Alcotest.(check string) 23 + "format" "Alice <alice@example.com> 1234567890 +0100" s; 24 + match Git.User.of_string s with 25 + | Ok user' -> 26 + Alcotest.(check string) "name" (Git.User.name user) (Git.User.name user'); 27 + Alcotest.(check string) 28 + "email" (Git.User.email user) (Git.User.email user'); 29 + Alcotest.(check int64) "date" (Git.User.date user) (Git.User.date user') 30 + | Error (`Msg m) -> Alcotest.fail m 31 + 32 + let tests = [ Alcotest.test_case "roundtrip" `Quick test_roundtrip ] 33 + let suite = ("user", tests)
+3
test/test_user.mli
··· 1 + (** User tests. *) 2 + 3 + val suite : string * unit Alcotest.test_case list
+83
test/test_value.ml
··· 1 + (* Copyright (c) 2024-2026 Thomas Gazagnaire <thomas@gazagnaire.org> 2 + 3 + Permission to use, copy, modify, and distribute this software for any 4 + purpose with or without fee is hereby granted, provided that the above 5 + copyright notice and this permission notice appear in all copies. 6 + 7 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. *) 14 + 15 + let hash = Test_helpers.hash 16 + 17 + let test_roundtrip () = 18 + let content = "test content" in 19 + let blob = Git.Blob.of_string content in 20 + let value = Git.Value.blob blob in 21 + let s = Git.Value.to_string value in 22 + match Git.Value.of_string_with_header s with 23 + | Ok value' -> ( 24 + match value' with 25 + | Git.Value.Blob b -> 26 + Alcotest.(check string) "content" content (Git.Blob.to_string b) 27 + | _ -> Alcotest.fail "expected blob") 28 + | Error (`Msg m) -> Alcotest.fail m 29 + 30 + let test_bytesrw_roundtrip () = 31 + let content = "bytesrw test content" in 32 + let blob = Git.Blob.of_string content in 33 + let value = Git.Value.blob blob in 34 + (* Write to buffer *) 35 + let buf = Buffer.create 64 in 36 + let writer = Bytesrw.Bytes.Writer.of_buffer buf in 37 + Git.Value.write writer value; 38 + let written = Buffer.contents buf in 39 + (* Read back *) 40 + let reader = Bytesrw.Bytes.Reader.of_string written in 41 + match Git.Value.read reader with 42 + | Ok value' -> ( 43 + match value' with 44 + | Git.Value.Blob b -> 45 + Alcotest.(check string) "content" content (Git.Blob.to_string b) 46 + | _ -> Alcotest.fail "expected blob") 47 + | Error (`Msg m) -> Alcotest.fail m 48 + 49 + let test_bytesrw_commit () = 50 + let tree_hash = Git.Hash.of_hex "da39a3ee5e6b4b0d3255bfef95601890afd80709" in 51 + let author = 52 + Git.User.v ~name:"Alice" ~email:"alice@example.com" ~date:1234567890L () 53 + in 54 + let commit = 55 + Git.Commit.v ~tree:tree_hash ~author ~committer:author 56 + (Some "Test commit\n") 57 + in 58 + let value = Git.Value.commit commit in 59 + (* Write to buffer *) 60 + let buf = Buffer.create 256 in 61 + let writer = Bytesrw.Bytes.Writer.of_buffer buf in 62 + Git.Value.write writer value; 63 + let written = Buffer.contents buf in 64 + (* Read back *) 65 + let reader = Bytesrw.Bytes.Reader.of_string written in 66 + match Git.Value.read reader with 67 + | Ok value' -> ( 68 + match value' with 69 + | Git.Value.Commit c -> 70 + Alcotest.(check hash) "tree" tree_hash (Git.Commit.tree c); 71 + Alcotest.(check (option string)) 72 + "message" (Some "Test commit\n") (Git.Commit.message c) 73 + | _ -> Alcotest.fail "expected commit") 74 + | Error (`Msg m) -> Alcotest.fail m 75 + 76 + let tests = 77 + [ 78 + Alcotest.test_case "roundtrip" `Quick test_roundtrip; 79 + Alcotest.test_case "bytesrw_roundtrip" `Quick test_bytesrw_roundtrip; 80 + Alcotest.test_case "bytesrw_commit" `Quick test_bytesrw_commit; 81 + ] 82 + 83 + let suite = ("value", tests)
+3
test/test_value.mli
··· 1 + (** Value tests. *) 2 + 3 + val suite : string * unit Alcotest.test_case list
+172
test/test_worktree.ml
··· 1 + (* Copyright (c) 2024-2026 Thomas Gazagnaire <thomas@gazagnaire.org> 2 + 3 + Permission to use, copy, modify, and distribute this software for any 4 + purpose with or without fee is hereby granted, provided that the above 5 + copyright notice and this permission notice appear in all copies. 6 + 7 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. *) 14 + 15 + let hash = Test_helpers.hash 16 + let with_temp_repo = Test_helpers.with_temp_repo 17 + let commit = Test_helpers.commit 18 + 19 + let test_list_main () = 20 + with_temp_repo @@ fun fs tmp_dir -> 21 + let repo = Git.Repository.init ~fs tmp_dir in 22 + let wt = Git.Repository.worktree repo in 23 + let worktrees = 24 + Git.Worktree.list wt ~head:(Git.Repository.head repo) 25 + ~current_branch:(Git.Repository.current_branch repo) 26 + in 27 + Alcotest.(check int) "one worktree" 1 (List.length worktrees); 28 + let main = List.hd worktrees in 29 + Alcotest.(check bool) "main path matches" true (Fpath.equal main.path tmp_dir) 30 + 31 + let test_add_basic () = 32 + with_temp_repo @@ fun fs tmp_dir -> 33 + let repo = Git.Repository.init ~fs tmp_dir in 34 + let wt = Git.Repository.worktree repo in 35 + let tree = Git.Repository.write_tree repo (Git.Tree.v []) in 36 + let c1 = commit ~repo ~tree ~parents:[] ~message:"initial" in 37 + Git.Repository.write_ref repo "refs/heads/main" c1; 38 + let head_path = Eio.Path.(fs / Fpath.to_string tmp_dir / ".git" / "HEAD") in 39 + Eio.Path.save ~create:(`Or_truncate 0o644) head_path "ref: refs/heads/main\n"; 40 + let wt_path = Fpath.(tmp_dir / "worktrees" / "feature1") in 41 + (match Git.Worktree.add wt ~head:c1 ~path:wt_path ~branch:"feature1" with 42 + | Ok () -> () 43 + | Error (`Msg msg) -> Alcotest.fail msg); 44 + Alcotest.(check bool) 45 + "worktree exists" true 46 + (Git.Worktree.exists wt ~path:wt_path); 47 + Alcotest.(check (option hash)) 48 + "branch created" (Some c1) 49 + (Git.Repository.read_ref repo "refs/heads/feature1") 50 + 51 + let test_exists_false () = 52 + with_temp_repo @@ fun fs tmp_dir -> 53 + let repo = Git.Repository.init ~fs tmp_dir in 54 + let wt = Git.Repository.worktree repo in 55 + let nonexistent = Fpath.(tmp_dir / "nonexistent") in 56 + Alcotest.(check bool) 57 + "non-existent worktree" false 58 + (Git.Worktree.exists wt ~path:nonexistent) 59 + 60 + let test_remove () = 61 + with_temp_repo @@ fun fs tmp_dir -> 62 + let repo = Git.Repository.init ~fs tmp_dir in 63 + let wt = Git.Repository.worktree repo in 64 + let tree = Git.Repository.write_tree repo (Git.Tree.v []) in 65 + let c1 = commit ~repo ~tree ~parents:[] ~message:"initial" in 66 + Git.Repository.write_ref repo "refs/heads/main" c1; 67 + let head_path = Eio.Path.(fs / Fpath.to_string tmp_dir / ".git" / "HEAD") in 68 + Eio.Path.save ~create:(`Or_truncate 0o644) head_path "ref: refs/heads/main\n"; 69 + let wt_path = Fpath.(tmp_dir / "worktrees" / "feature2") in 70 + (match Git.Worktree.add wt ~head:c1 ~path:wt_path ~branch:"feature2" with 71 + | Ok () -> () 72 + | Error (`Msg msg) -> Alcotest.fail msg); 73 + Alcotest.(check bool) 74 + "worktree exists before remove" true 75 + (Git.Worktree.exists wt ~path:wt_path); 76 + (match Git.Worktree.remove wt ~path:wt_path ~force:false with 77 + | Ok () -> () 78 + | Error (`Msg msg) -> Alcotest.fail msg); 79 + Alcotest.(check bool) 80 + "worktree gone after remove" false 81 + (Git.Worktree.exists wt ~path:wt_path) 82 + 83 + let test_remove_main_fails () = 84 + with_temp_repo @@ fun fs tmp_dir -> 85 + let repo = Git.Repository.init ~fs tmp_dir in 86 + let wt = Git.Repository.worktree repo in 87 + match Git.Worktree.remove wt ~path:tmp_dir ~force:false with 88 + | Ok () -> Alcotest.fail "should not be able to remove main worktree" 89 + | Error (`Msg _) -> () 90 + 91 + let test_list_multiple () = 92 + with_temp_repo @@ fun fs tmp_dir -> 93 + let repo = Git.Repository.init ~fs tmp_dir in 94 + let wt = Git.Repository.worktree repo in 95 + let tree = Git.Repository.write_tree repo (Git.Tree.v []) in 96 + let c1 = commit ~repo ~tree ~parents:[] ~message:"initial" in 97 + Git.Repository.write_ref repo "refs/heads/main" c1; 98 + let head_path = Eio.Path.(fs / Fpath.to_string tmp_dir / ".git" / "HEAD") in 99 + Eio.Path.save ~create:(`Or_truncate 0o644) head_path "ref: refs/heads/main\n"; 100 + let wt1 = Fpath.(tmp_dir / "worktrees" / "wt1") in 101 + let wt2 = Fpath.(tmp_dir / "worktrees" / "wt2") in 102 + let wt3 = Fpath.(tmp_dir / "worktrees" / "wt3") in 103 + (match Git.Worktree.add wt ~head:c1 ~path:wt1 ~branch:"wt1" with 104 + | Ok () -> () 105 + | Error (`Msg msg) -> Alcotest.fail msg); 106 + (match Git.Worktree.add wt ~head:c1 ~path:wt2 ~branch:"wt2" with 107 + | Ok () -> () 108 + | Error (`Msg msg) -> Alcotest.fail msg); 109 + (match Git.Worktree.add wt ~head:c1 ~path:wt3 ~branch:"wt3" with 110 + | Ok () -> () 111 + | Error (`Msg msg) -> Alcotest.fail msg); 112 + let worktrees = 113 + Git.Worktree.list wt ~head:(Git.Repository.head repo) 114 + ~current_branch:(Git.Repository.current_branch repo) 115 + in 116 + Alcotest.(check int) "4 worktrees" 4 (List.length worktrees); 117 + Alcotest.(check bool) "wt1 exists" true (Git.Worktree.exists wt ~path:wt1); 118 + Alcotest.(check bool) "wt2 exists" true (Git.Worktree.exists wt ~path:wt2); 119 + Alcotest.(check bool) "wt3 exists" true (Git.Worktree.exists wt ~path:wt3) 120 + 121 + let test_entry_branch () = 122 + with_temp_repo @@ fun fs tmp_dir -> 123 + let repo = Git.Repository.init ~fs tmp_dir in 124 + let wt = Git.Repository.worktree repo in 125 + let tree = Git.Repository.write_tree repo (Git.Tree.v []) in 126 + let c1 = commit ~repo ~tree ~parents:[] ~message:"initial" in 127 + Git.Repository.write_ref repo "refs/heads/main" c1; 128 + let head_path = Eio.Path.(fs / Fpath.to_string tmp_dir / ".git" / "HEAD") in 129 + Eio.Path.save ~create:(`Or_truncate 0o644) head_path "ref: refs/heads/main\n"; 130 + let wt_path = Fpath.(tmp_dir / "worktrees" / "my-feature") in 131 + (match Git.Worktree.add wt ~head:c1 ~path:wt_path ~branch:"my-feature" with 132 + | Ok () -> () 133 + | Error (`Msg msg) -> Alcotest.fail msg); 134 + let worktrees = 135 + Git.Worktree.list wt ~head:(Git.Repository.head repo) 136 + ~current_branch:(Git.Repository.current_branch repo) 137 + in 138 + let wt_entry = 139 + List.find_opt 140 + (fun (e : Git.Worktree.entry) -> Fpath.equal e.path wt_path) 141 + worktrees 142 + in 143 + match wt_entry with 144 + | None -> Alcotest.fail "worktree not found in list" 145 + | Some entry -> 146 + Alcotest.(check (option string)) 147 + "branch name" (Some "my-feature") entry.branch 148 + 149 + let test_add_needs_head () = 150 + with_temp_repo @@ fun fs tmp_dir -> 151 + let repo = Git.Repository.init ~fs tmp_dir in 152 + let wt = Git.Repository.worktree repo in 153 + let tree = Git.Repository.write_tree repo (Git.Tree.v []) in 154 + let c1 = commit ~repo ~tree ~parents:[] ~message:"initial" in 155 + let wt_path = Fpath.(tmp_dir / "worktrees" / "feature") in 156 + match Git.Worktree.add wt ~head:c1 ~path:wt_path ~branch:"feature" with 157 + | Ok () -> Alcotest.(check bool) "worktree created" true true 158 + | Error (`Msg msg) -> Alcotest.fail msg 159 + 160 + let tests = 161 + [ 162 + Alcotest.test_case "list_main" `Quick test_list_main; 163 + Alcotest.test_case "add_basic" `Quick test_add_basic; 164 + Alcotest.test_case "exists_false" `Quick test_exists_false; 165 + Alcotest.test_case "remove" `Quick test_remove; 166 + Alcotest.test_case "remove_main_fails" `Quick test_remove_main_fails; 167 + Alcotest.test_case "list_multiple" `Quick test_list_multiple; 168 + Alcotest.test_case "entry_branch" `Quick test_entry_branch; 169 + Alcotest.test_case "add_needs_head" `Quick test_add_needs_head; 170 + ] 171 + 172 + let suite = ("worktree", tests)
+3
test/test_worktree.mli
··· 1 + (** Worktree tests. *) 2 + 3 + val suite : string * unit Alcotest.test_case list