Git object storage and pack files for Eio

ocaml-git: add commit and rm functions with tests

Add high-level commit function that reads author from git config,
and rm function for removing files from index and working tree.
Includes comprehensive tests for both functions.

+390
+58
lib/repository.ml
··· 840 840 advance_head t commit_hash; 841 841 Ok commit_hash 842 842 843 + let commit t ~message = 844 + (* Get user info from config *) 845 + match read_config t with 846 + | None -> Error (`Msg "no git config found") 847 + | Some config -> ( 848 + let user_config = Config.get_user config in 849 + match (user_config.name, user_config.email) with 850 + | None, _ -> Error (`Msg "user.name not set in git config") 851 + | _, None -> Error (`Msg "user.email not set in git config") 852 + | Some name, Some email -> 853 + let date = Int64.of_float (Unix.gettimeofday ()) in 854 + let user = User.v ~name ~email ~date () in 855 + commit_index t ~author:user ~committer:user ~message ()) 856 + 857 + let rm t ~recursive path = 858 + let work_dir = 859 + if 860 + String.length t.git_dir > 5 861 + && String.sub t.git_dir (String.length t.git_dir - 5) 5 = "/.git" 862 + then String.sub t.git_dir 0 (String.length t.git_dir - 5) 863 + else Filename.dirname t.git_dir 864 + in 865 + (* Remove from index *) 866 + (match read_index t with 867 + | Error _ -> () 868 + | Ok index -> 869 + let new_index = 870 + if recursive then 871 + (* Remove all entries under this path *) 872 + let prefix = if path = "" then "" else path ^ "/" in 873 + List.fold_left 874 + (fun idx (entry : Index.entry) -> 875 + if entry.name = path || String.starts_with ~prefix entry.name then 876 + Index.remove idx entry.name 877 + else idx) 878 + index (Index.entries index) 879 + else Index.remove index path 880 + in 881 + write_index t new_index); 882 + (* Remove from working tree *) 883 + let full_path = Eio.Path.(t.fs / work_dir / path) in 884 + (try 885 + if recursive then 886 + let rec remove_recursive p = 887 + match Eio.Path.kind ~follow:false p with 888 + | `Directory -> 889 + let entries = Eio.Path.read_dir p in 890 + List.iter 891 + (fun name -> remove_recursive Eio.Path.(p / name)) 892 + entries; 893 + Eio.Path.rmdir p 894 + | _ -> Eio.Path.unlink p 895 + in 896 + remove_recursive full_path 897 + else Eio.Path.unlink full_path 898 + with Eio.Io _ -> ()); 899 + Ok () 900 + 843 901 (** {1 Checkout operations} *) 844 902 845 903 let rec checkout_tree t ~work_dir ~prefix tree_hash =
+8
lib/repository.mli
··· 241 241 (** [add_all t] stages all changes in the working directory (equivalent to 242 242 [git add -A]). Adds new and modified files, removes deleted files. *) 243 243 244 + val commit : t -> message:string -> (Hash.t, [ `Msg of string ]) result 245 + (** [commit t ~message] creates a commit from the current index using the 246 + author/committer from git config. Returns the commit hash. *) 247 + 248 + val rm : t -> recursive:bool -> string -> (unit, [ `Msg of string ]) result 249 + (** [rm t ~recursive path] removes [path] from the index and working tree. If 250 + [recursive] is true, removes all entries under [path]. *) 251 + 244 252 val commit_index : 245 253 t -> 246 254 author:User.t ->
+324
test/test_repository.ml
··· 15 15 let hash = Test_common.hash 16 16 let with_temp_repo = Test_common.with_temp_repo 17 17 let make_commit = Test_common.make_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 18 31 19 32 (* Basic repository tests *) 20 33 ··· 930 943 Alcotest.(check bool) 931 944 "no history" false 932 945 (Git.Repository.has_subtree_history repo ~prefix:"nonexistent") 946 + 947 + (* add_all tests *) 948 + 949 + let test_add_all_new_file () = 950 + with_temp_repo @@ fun fs tmp_dir -> 951 + let repo = Git.Repository.init ~fs tmp_dir in 952 + let work_dir = Eio.Path.(fs / Fpath.to_string tmp_dir) in 953 + Eio.Path.save ~create:(`Or_truncate 0o644) 954 + Eio.Path.(work_dir / "test.txt") 955 + "hello"; 956 + unwrap (Git.Repository.add_all repo); 957 + let idx = unwrap (Git.Repository.read_index repo) in 958 + let entries = Git.Index.entries idx in 959 + Alcotest.(check int) "one entry" 1 (List.length entries); 960 + Alcotest.(check string) "name" "test.txt" (List.hd entries).name 961 + 962 + let test_add_all_nested_dirs () = 963 + with_temp_repo @@ fun fs tmp_dir -> 964 + let repo = Git.Repository.init ~fs tmp_dir in 965 + let work_dir = Eio.Path.(fs / Fpath.to_string tmp_dir) in 966 + Eio.Path.mkdir ~perm:0o755 Eio.Path.(work_dir / "src"); 967 + Eio.Path.mkdir ~perm:0o755 Eio.Path.(work_dir / "src" / "lib"); 968 + Eio.Path.save ~create:(`Or_truncate 0o644) 969 + Eio.Path.(work_dir / "src" / "lib" / "foo.ml") 970 + "let x = 1"; 971 + Eio.Path.save ~create:(`Or_truncate 0o644) 972 + Eio.Path.(work_dir / "src" / "main.ml") 973 + "let () = ()"; 974 + unwrap (Git.Repository.add_all repo); 975 + let idx = unwrap (Git.Repository.read_index repo) in 976 + let entries = Git.Index.entries idx in 977 + Alcotest.(check int) "two entries" 2 (List.length entries); 978 + let names = List.map (fun (e : Git.Index.entry) -> e.name) entries in 979 + Alcotest.(check bool) "has foo.ml" true (List.mem "src/lib/foo.ml" names); 980 + Alcotest.(check bool) "has main.ml" true (List.mem "src/main.ml" names) 981 + 982 + let test_add_all_removes_deleted () = 983 + with_temp_repo @@ fun fs tmp_dir -> 984 + let repo = Git.Repository.init ~fs tmp_dir in 985 + let work_dir = Eio.Path.(fs / Fpath.to_string tmp_dir) in 986 + Eio.Path.save ~create:(`Or_truncate 0o644) 987 + Eio.Path.(work_dir / "file.txt") 988 + "content"; 989 + unwrap (Git.Repository.add_all repo); 990 + Eio.Path.unlink Eio.Path.(work_dir / "file.txt"); 991 + unwrap (Git.Repository.add_all repo); 992 + let idx = unwrap (Git.Repository.read_index repo) in 993 + Alcotest.(check int) "no entries" 0 (List.length (Git.Index.entries idx)) 994 + 995 + let test_add_all_updates_modified () = 996 + with_temp_repo @@ fun fs tmp_dir -> 997 + let repo = Git.Repository.init ~fs tmp_dir in 998 + let work_dir = Eio.Path.(fs / Fpath.to_string tmp_dir) in 999 + Eio.Path.save ~create:(`Or_truncate 0o644) 1000 + Eio.Path.(work_dir / "file.txt") 1001 + "v1"; 1002 + unwrap (Git.Repository.add_all repo); 1003 + let hash1 = 1004 + (List.hd (Git.Index.entries (unwrap (Git.Repository.read_index repo)))).hash 1005 + in 1006 + Eio.Path.save ~create:(`Or_truncate 0o644) 1007 + Eio.Path.(work_dir / "file.txt") 1008 + "v2"; 1009 + unwrap (Git.Repository.add_all repo); 1010 + let hash2 = 1011 + (List.hd (Git.Index.entries (unwrap (Git.Repository.read_index repo)))).hash 1012 + in 1013 + Alcotest.(check bool) "hash changed" false (Git.Hash.equal hash1 hash2) 1014 + 1015 + let test_add_all_ignores_git_dir () = 1016 + with_temp_repo @@ fun fs tmp_dir -> 1017 + let repo = Git.Repository.init ~fs tmp_dir in 1018 + let work_dir = Eio.Path.(fs / Fpath.to_string tmp_dir) in 1019 + Eio.Path.save ~create:(`Or_truncate 0o644) 1020 + Eio.Path.(work_dir / ".git" / "test") 1021 + "ignored"; 1022 + Eio.Path.save ~create:(`Or_truncate 0o644) 1023 + Eio.Path.(work_dir / "normal.txt") 1024 + "included"; 1025 + unwrap (Git.Repository.add_all repo); 1026 + let idx = unwrap (Git.Repository.read_index repo) in 1027 + let entries = Git.Index.entries idx in 1028 + Alcotest.(check int) "one entry" 1 (List.length entries); 1029 + Alcotest.(check string) "only normal.txt" "normal.txt" (List.hd entries).name 1030 + 1031 + (* commit tests *) 1032 + 1033 + let setup_config repo = 1034 + let config = 1035 + Git.Config.( 1036 + empty 1037 + |> set ~section:(section "user") ~key:"name" ~value:"Test User" 1038 + |> set ~section:(section "user") ~key:"email" ~value:"test@test.com") 1039 + in 1040 + Git.Repository.write_config repo config 1041 + 1042 + let test_commit_basic () = 1043 + with_temp_repo @@ fun fs tmp_dir -> 1044 + let repo = Git.Repository.init ~fs tmp_dir in 1045 + setup_config repo; 1046 + let work_dir = Eio.Path.(fs / Fpath.to_string tmp_dir) in 1047 + Eio.Path.save ~create:(`Or_truncate 0o644) 1048 + Eio.Path.(work_dir / "file.txt") 1049 + "content"; 1050 + (match Git.Repository.add_all repo with 1051 + | Error (`Msg e) -> Alcotest.fail e 1052 + | Ok () -> ()); 1053 + match Git.Repository.commit repo ~message:"Initial commit" with 1054 + | Error (`Msg e) -> Alcotest.fail e 1055 + | Ok hash -> ( 1056 + (* Verify commit exists *) 1057 + match Git.Repository.read repo hash with 1058 + | Error (`Msg e) -> Alcotest.fail e 1059 + | Ok (Git.Value.Commit c) -> 1060 + Alcotest.(check (option string)) 1061 + "message" (Some "Initial commit") (Git.Commit.message c); 1062 + Alcotest.(check string) 1063 + "author" "Test User" 1064 + (Git.User.name (Git.Commit.author c)) 1065 + | Ok _ -> Alcotest.fail "expected commit") 1066 + 1067 + let test_commit_updates_head () = 1068 + with_temp_repo @@ fun fs tmp_dir -> 1069 + let repo = Git.Repository.init ~fs tmp_dir in 1070 + setup_config repo; 1071 + let work_dir = Eio.Path.(fs / Fpath.to_string tmp_dir) in 1072 + Eio.Path.save ~create:(`Or_truncate 0o644) 1073 + Eio.Path.(work_dir / "file.txt") 1074 + "content"; 1075 + (match Git.Repository.add_all repo with 1076 + | Error (`Msg e) -> Alcotest.fail e 1077 + | Ok () -> ()); 1078 + match Git.Repository.commit repo ~message:"test" with 1079 + | Error (`Msg e) -> Alcotest.fail e 1080 + | Ok commit_hash -> 1081 + Alcotest.(check (option hash)) 1082 + "HEAD updated" (Some commit_hash) (Git.Repository.head repo) 1083 + 1084 + let test_commit_no_config () = 1085 + with_temp_repo @@ fun fs tmp_dir -> 1086 + let repo = Git.Repository.init ~fs tmp_dir in 1087 + let work_dir = Eio.Path.(fs / Fpath.to_string tmp_dir) in 1088 + Eio.Path.save ~create:(`Or_truncate 0o644) 1089 + Eio.Path.(work_dir / "file.txt") 1090 + "content"; 1091 + (match Git.Repository.add_all repo with 1092 + | Error (`Msg e) -> Alcotest.fail e 1093 + | Ok () -> ()); 1094 + match Git.Repository.commit repo ~message:"test" with 1095 + | Error (`Msg msg) -> 1096 + Alcotest.(check bool) "error mentions config" true (String.length msg > 0) 1097 + | Ok _ -> Alcotest.fail "expected error" 1098 + 1099 + let test_commit_no_user_name () = 1100 + with_temp_repo @@ fun fs tmp_dir -> 1101 + let repo = Git.Repository.init ~fs tmp_dir in 1102 + (* Set only email, not name *) 1103 + let config = 1104 + Git.Config.( 1105 + empty |> set ~section:(section "user") ~key:"email" ~value:"test@test.com") 1106 + in 1107 + Git.Repository.write_config repo config; 1108 + let work_dir = Eio.Path.(fs / Fpath.to_string tmp_dir) in 1109 + Eio.Path.save ~create:(`Or_truncate 0o644) 1110 + Eio.Path.(work_dir / "file.txt") 1111 + "content"; 1112 + (match Git.Repository.add_all repo with 1113 + | Error (`Msg e) -> Alcotest.fail e 1114 + | Ok () -> ()); 1115 + match Git.Repository.commit repo ~message:"test" with 1116 + | Error (`Msg msg) -> 1117 + Alcotest.(check bool) 1118 + "error mentions user.name" true 1119 + (string_contains ~needle:"user.name" msg) 1120 + | Ok _ -> Alcotest.fail "expected error" 1121 + 1122 + let test_commit_multiple () = 1123 + with_temp_repo @@ fun fs tmp_dir -> 1124 + let repo = Git.Repository.init ~fs tmp_dir in 1125 + setup_config repo; 1126 + let work_dir = Eio.Path.(fs / Fpath.to_string tmp_dir) in 1127 + (* First commit *) 1128 + Eio.Path.save ~create:(`Or_truncate 0o644) 1129 + Eio.Path.(work_dir / "file.txt") 1130 + "v1"; 1131 + (match Git.Repository.add_all repo with 1132 + | Error (`Msg e) -> Alcotest.fail e 1133 + | Ok () -> ()); 1134 + let hash1 = 1135 + match Git.Repository.commit repo ~message:"c1" with 1136 + | Error (`Msg e) -> Alcotest.fail e 1137 + | Ok h -> h 1138 + in 1139 + (* Second commit *) 1140 + Eio.Path.save ~create:(`Or_truncate 0o644) 1141 + Eio.Path.(work_dir / "file.txt") 1142 + "v2"; 1143 + (match Git.Repository.add_all repo with 1144 + | Error (`Msg e) -> Alcotest.fail e 1145 + | Ok () -> ()); 1146 + let hash2 = 1147 + match Git.Repository.commit repo ~message:"c2" with 1148 + | Error (`Msg e) -> Alcotest.fail e 1149 + | Ok h -> h 1150 + in 1151 + (* Verify parent *) 1152 + match Git.Repository.read repo hash2 with 1153 + | Error (`Msg e) -> Alcotest.fail e 1154 + | Ok (Git.Value.Commit c) -> 1155 + Alcotest.(check (list hash)) "parent" [ hash1 ] (Git.Commit.parents c) 1156 + | Ok _ -> Alcotest.fail "expected commit" 1157 + 1158 + (* rm tests *) 1159 + 1160 + let test_rm_single_file () = 1161 + with_temp_repo @@ fun fs tmp_dir -> 1162 + let repo = Git.Repository.init ~fs tmp_dir in 1163 + let work_dir = Eio.Path.(fs / Fpath.to_string tmp_dir) in 1164 + (* Create and stage files *) 1165 + Eio.Path.save ~create:(`Or_truncate 0o644) 1166 + Eio.Path.(work_dir / "keep.txt") 1167 + "keep"; 1168 + Eio.Path.save ~create:(`Or_truncate 0o644) 1169 + Eio.Path.(work_dir / "delete.txt") 1170 + "delete"; 1171 + (match Git.Repository.add_all repo with 1172 + | Error (`Msg e) -> Alcotest.fail e 1173 + | Ok () -> ()); 1174 + (* Remove one file *) 1175 + (match Git.Repository.rm repo ~recursive:false "delete.txt" with 1176 + | Error (`Msg e) -> Alcotest.fail e 1177 + | Ok () -> ()); 1178 + (* Check index *) 1179 + (match Git.Repository.read_index repo with 1180 + | Error (`Msg e) -> Alcotest.fail e 1181 + | Ok idx -> 1182 + let entries = Git.Index.entries idx in 1183 + Alcotest.(check int) "one entry" 1 (List.length entries); 1184 + Alcotest.(check string) "keep.txt" "keep.txt" (List.hd entries).name); 1185 + (* Check working tree *) 1186 + Alcotest.(check bool) 1187 + "file deleted" false 1188 + (Eio.Path.is_file Eio.Path.(work_dir / "delete.txt")) 1189 + 1190 + let test_rm_recursive () = 1191 + with_temp_repo @@ fun fs tmp_dir -> 1192 + let repo = Git.Repository.init ~fs tmp_dir in 1193 + let work_dir = Eio.Path.(fs / Fpath.to_string tmp_dir) in 1194 + (* Create nested structure *) 1195 + Eio.Path.mkdir ~perm:0o755 Eio.Path.(work_dir / "dir"); 1196 + Eio.Path.save ~create:(`Or_truncate 0o644) 1197 + Eio.Path.(work_dir / "dir" / "a.txt") 1198 + "a"; 1199 + Eio.Path.save ~create:(`Or_truncate 0o644) 1200 + Eio.Path.(work_dir / "dir" / "b.txt") 1201 + "b"; 1202 + Eio.Path.save ~create:(`Or_truncate 0o644) 1203 + Eio.Path.(work_dir / "keep.txt") 1204 + "keep"; 1205 + (match Git.Repository.add_all repo with 1206 + | Error (`Msg e) -> Alcotest.fail e 1207 + | Ok () -> ()); 1208 + (* Remove directory *) 1209 + (match Git.Repository.rm repo ~recursive:true "dir" with 1210 + | Error (`Msg e) -> Alcotest.fail e 1211 + | Ok () -> ()); 1212 + (* Check index *) 1213 + (match Git.Repository.read_index repo with 1214 + | Error (`Msg e) -> Alcotest.fail e 1215 + | Ok idx -> 1216 + let entries = Git.Index.entries idx in 1217 + Alcotest.(check int) "one entry" 1 (List.length entries); 1218 + Alcotest.(check string) "keep.txt" "keep.txt" (List.hd entries).name); 1219 + (* Check working tree *) 1220 + Alcotest.(check bool) 1221 + "dir deleted" false 1222 + (Eio.Path.is_directory Eio.Path.(work_dir / "dir")) 1223 + 1224 + let test_rm_preserves_others () = 1225 + with_temp_repo @@ fun fs tmp_dir -> 1226 + let repo = Git.Repository.init ~fs tmp_dir in 1227 + let work_dir = Eio.Path.(fs / Fpath.to_string tmp_dir) in 1228 + (* Create files with similar prefixes *) 1229 + Eio.Path.save ~create:(`Or_truncate 0o644) 1230 + Eio.Path.(work_dir / "foo.txt") 1231 + "foo"; 1232 + Eio.Path.save ~create:(`Or_truncate 0o644) 1233 + Eio.Path.(work_dir / "foobar.txt") 1234 + "foobar"; 1235 + (match Git.Repository.add_all repo with 1236 + | Error (`Msg e) -> Alcotest.fail e 1237 + | Ok () -> ()); 1238 + (* Remove only foo.txt *) 1239 + (match Git.Repository.rm repo ~recursive:false "foo.txt" with 1240 + | Error (`Msg e) -> Alcotest.fail e 1241 + | Ok () -> ()); 1242 + (* Check index still has foobar.txt *) 1243 + match Git.Repository.read_index repo with 1244 + | Error (`Msg e) -> Alcotest.fail e 1245 + | Ok idx -> 1246 + let entries = Git.Index.entries idx in 1247 + Alcotest.(check int) "one entry" 1 (List.length entries); 1248 + Alcotest.(check string) "foobar.txt" "foobar.txt" (List.hd entries).name 1249 + 1250 + let test_rm_nonexistent () = 1251 + with_temp_repo @@ fun fs tmp_dir -> 1252 + let repo = Git.Repository.init ~fs tmp_dir in 1253 + (* Remove nonexistent file (should succeed silently) *) 1254 + match Git.Repository.rm repo ~recursive:false "nonexistent.txt" with 1255 + | Error (`Msg e) -> Alcotest.fail ("unexpected error: " ^ e) 1256 + | Ok () -> () 933 1257 934 1258 let tests = 935 1259 [