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.
···840840 advance_head t commit_hash;
841841 Ok commit_hash
842842843843+let commit t ~message =
844844+ (* Get user info from config *)
845845+ match read_config t with
846846+ | None -> Error (`Msg "no git config found")
847847+ | Some config -> (
848848+ let user_config = Config.get_user config in
849849+ match (user_config.name, user_config.email) with
850850+ | None, _ -> Error (`Msg "user.name not set in git config")
851851+ | _, None -> Error (`Msg "user.email not set in git config")
852852+ | Some name, Some email ->
853853+ let date = Int64.of_float (Unix.gettimeofday ()) in
854854+ let user = User.v ~name ~email ~date () in
855855+ commit_index t ~author:user ~committer:user ~message ())
856856+857857+let rm t ~recursive path =
858858+ let work_dir =
859859+ if
860860+ String.length t.git_dir > 5
861861+ && String.sub t.git_dir (String.length t.git_dir - 5) 5 = "/.git"
862862+ then String.sub t.git_dir 0 (String.length t.git_dir - 5)
863863+ else Filename.dirname t.git_dir
864864+ in
865865+ (* Remove from index *)
866866+ (match read_index t with
867867+ | Error _ -> ()
868868+ | Ok index ->
869869+ let new_index =
870870+ if recursive then
871871+ (* Remove all entries under this path *)
872872+ let prefix = if path = "" then "" else path ^ "/" in
873873+ List.fold_left
874874+ (fun idx (entry : Index.entry) ->
875875+ if entry.name = path || String.starts_with ~prefix entry.name then
876876+ Index.remove idx entry.name
877877+ else idx)
878878+ index (Index.entries index)
879879+ else Index.remove index path
880880+ in
881881+ write_index t new_index);
882882+ (* Remove from working tree *)
883883+ let full_path = Eio.Path.(t.fs / work_dir / path) in
884884+ (try
885885+ if recursive then
886886+ let rec remove_recursive p =
887887+ match Eio.Path.kind ~follow:false p with
888888+ | `Directory ->
889889+ let entries = Eio.Path.read_dir p in
890890+ List.iter
891891+ (fun name -> remove_recursive Eio.Path.(p / name))
892892+ entries;
893893+ Eio.Path.rmdir p
894894+ | _ -> Eio.Path.unlink p
895895+ in
896896+ remove_recursive full_path
897897+ else Eio.Path.unlink full_path
898898+ with Eio.Io _ -> ());
899899+ Ok ()
900900+843901(** {1 Checkout operations} *)
844902845903let rec checkout_tree t ~work_dir ~prefix tree_hash =
+8
lib/repository.mli
···241241(** [add_all t] stages all changes in the working directory (equivalent to
242242 [git add -A]). Adds new and modified files, removes deleted files. *)
243243244244+val commit : t -> message:string -> (Hash.t, [ `Msg of string ]) result
245245+(** [commit t ~message] creates a commit from the current index using the
246246+ author/committer from git config. Returns the commit hash. *)
247247+248248+val rm : t -> recursive:bool -> string -> (unit, [ `Msg of string ]) result
249249+(** [rm t ~recursive path] removes [path] from the index and working tree. If
250250+ [recursive] is true, removes all entries under [path]. *)
251251+244252val commit_index :
245253 t ->
246254 author:User.t ->
+324
test/test_repository.ml
···1515let hash = Test_common.hash
1616let with_temp_repo = Test_common.with_temp_repo
1717let make_commit = Test_common.make_commit
1818+let unwrap = function Ok x -> x | Error (`Msg e) -> Alcotest.fail e
1919+2020+let string_contains ~needle haystack =
2121+ let n = String.length needle in
2222+ let h = String.length haystack in
2323+ if n > h then false
2424+ else
2525+ let rec check i =
2626+ if i + n > h then false
2727+ else if String.sub haystack i n = needle then true
2828+ else check (i + 1)
2929+ in
3030+ check 0
18311932(* Basic repository tests *)
2033···930943 Alcotest.(check bool)
931944 "no history" false
932945 (Git.Repository.has_subtree_history repo ~prefix:"nonexistent")
946946+947947+(* add_all tests *)
948948+949949+let test_add_all_new_file () =
950950+ with_temp_repo @@ fun fs tmp_dir ->
951951+ let repo = Git.Repository.init ~fs tmp_dir in
952952+ let work_dir = Eio.Path.(fs / Fpath.to_string tmp_dir) in
953953+ Eio.Path.save ~create:(`Or_truncate 0o644)
954954+ Eio.Path.(work_dir / "test.txt")
955955+ "hello";
956956+ unwrap (Git.Repository.add_all repo);
957957+ let idx = unwrap (Git.Repository.read_index repo) in
958958+ let entries = Git.Index.entries idx in
959959+ Alcotest.(check int) "one entry" 1 (List.length entries);
960960+ Alcotest.(check string) "name" "test.txt" (List.hd entries).name
961961+962962+let test_add_all_nested_dirs () =
963963+ with_temp_repo @@ fun fs tmp_dir ->
964964+ let repo = Git.Repository.init ~fs tmp_dir in
965965+ let work_dir = Eio.Path.(fs / Fpath.to_string tmp_dir) in
966966+ Eio.Path.mkdir ~perm:0o755 Eio.Path.(work_dir / "src");
967967+ Eio.Path.mkdir ~perm:0o755 Eio.Path.(work_dir / "src" / "lib");
968968+ Eio.Path.save ~create:(`Or_truncate 0o644)
969969+ Eio.Path.(work_dir / "src" / "lib" / "foo.ml")
970970+ "let x = 1";
971971+ Eio.Path.save ~create:(`Or_truncate 0o644)
972972+ Eio.Path.(work_dir / "src" / "main.ml")
973973+ "let () = ()";
974974+ unwrap (Git.Repository.add_all repo);
975975+ let idx = unwrap (Git.Repository.read_index repo) in
976976+ let entries = Git.Index.entries idx in
977977+ Alcotest.(check int) "two entries" 2 (List.length entries);
978978+ let names = List.map (fun (e : Git.Index.entry) -> e.name) entries in
979979+ Alcotest.(check bool) "has foo.ml" true (List.mem "src/lib/foo.ml" names);
980980+ Alcotest.(check bool) "has main.ml" true (List.mem "src/main.ml" names)
981981+982982+let test_add_all_removes_deleted () =
983983+ with_temp_repo @@ fun fs tmp_dir ->
984984+ let repo = Git.Repository.init ~fs tmp_dir in
985985+ let work_dir = Eio.Path.(fs / Fpath.to_string tmp_dir) in
986986+ Eio.Path.save ~create:(`Or_truncate 0o644)
987987+ Eio.Path.(work_dir / "file.txt")
988988+ "content";
989989+ unwrap (Git.Repository.add_all repo);
990990+ Eio.Path.unlink Eio.Path.(work_dir / "file.txt");
991991+ unwrap (Git.Repository.add_all repo);
992992+ let idx = unwrap (Git.Repository.read_index repo) in
993993+ Alcotest.(check int) "no entries" 0 (List.length (Git.Index.entries idx))
994994+995995+let test_add_all_updates_modified () =
996996+ with_temp_repo @@ fun fs tmp_dir ->
997997+ let repo = Git.Repository.init ~fs tmp_dir in
998998+ let work_dir = Eio.Path.(fs / Fpath.to_string tmp_dir) in
999999+ Eio.Path.save ~create:(`Or_truncate 0o644)
10001000+ Eio.Path.(work_dir / "file.txt")
10011001+ "v1";
10021002+ unwrap (Git.Repository.add_all repo);
10031003+ let hash1 =
10041004+ (List.hd (Git.Index.entries (unwrap (Git.Repository.read_index repo)))).hash
10051005+ in
10061006+ Eio.Path.save ~create:(`Or_truncate 0o644)
10071007+ Eio.Path.(work_dir / "file.txt")
10081008+ "v2";
10091009+ unwrap (Git.Repository.add_all repo);
10101010+ let hash2 =
10111011+ (List.hd (Git.Index.entries (unwrap (Git.Repository.read_index repo)))).hash
10121012+ in
10131013+ Alcotest.(check bool) "hash changed" false (Git.Hash.equal hash1 hash2)
10141014+10151015+let test_add_all_ignores_git_dir () =
10161016+ with_temp_repo @@ fun fs tmp_dir ->
10171017+ let repo = Git.Repository.init ~fs tmp_dir in
10181018+ let work_dir = Eio.Path.(fs / Fpath.to_string tmp_dir) in
10191019+ Eio.Path.save ~create:(`Or_truncate 0o644)
10201020+ Eio.Path.(work_dir / ".git" / "test")
10211021+ "ignored";
10221022+ Eio.Path.save ~create:(`Or_truncate 0o644)
10231023+ Eio.Path.(work_dir / "normal.txt")
10241024+ "included";
10251025+ unwrap (Git.Repository.add_all repo);
10261026+ let idx = unwrap (Git.Repository.read_index repo) in
10271027+ let entries = Git.Index.entries idx in
10281028+ Alcotest.(check int) "one entry" 1 (List.length entries);
10291029+ Alcotest.(check string) "only normal.txt" "normal.txt" (List.hd entries).name
10301030+10311031+(* commit tests *)
10321032+10331033+let setup_config repo =
10341034+ let config =
10351035+ Git.Config.(
10361036+ empty
10371037+ |> set ~section:(section "user") ~key:"name" ~value:"Test User"
10381038+ |> set ~section:(section "user") ~key:"email" ~value:"test@test.com")
10391039+ in
10401040+ Git.Repository.write_config repo config
10411041+10421042+let test_commit_basic () =
10431043+ with_temp_repo @@ fun fs tmp_dir ->
10441044+ let repo = Git.Repository.init ~fs tmp_dir in
10451045+ setup_config repo;
10461046+ let work_dir = Eio.Path.(fs / Fpath.to_string tmp_dir) in
10471047+ Eio.Path.save ~create:(`Or_truncate 0o644)
10481048+ Eio.Path.(work_dir / "file.txt")
10491049+ "content";
10501050+ (match Git.Repository.add_all repo with
10511051+ | Error (`Msg e) -> Alcotest.fail e
10521052+ | Ok () -> ());
10531053+ match Git.Repository.commit repo ~message:"Initial commit" with
10541054+ | Error (`Msg e) -> Alcotest.fail e
10551055+ | Ok hash -> (
10561056+ (* Verify commit exists *)
10571057+ match Git.Repository.read repo hash with
10581058+ | Error (`Msg e) -> Alcotest.fail e
10591059+ | Ok (Git.Value.Commit c) ->
10601060+ Alcotest.(check (option string))
10611061+ "message" (Some "Initial commit") (Git.Commit.message c);
10621062+ Alcotest.(check string)
10631063+ "author" "Test User"
10641064+ (Git.User.name (Git.Commit.author c))
10651065+ | Ok _ -> Alcotest.fail "expected commit")
10661066+10671067+let test_commit_updates_head () =
10681068+ with_temp_repo @@ fun fs tmp_dir ->
10691069+ let repo = Git.Repository.init ~fs tmp_dir in
10701070+ setup_config repo;
10711071+ let work_dir = Eio.Path.(fs / Fpath.to_string tmp_dir) in
10721072+ Eio.Path.save ~create:(`Or_truncate 0o644)
10731073+ Eio.Path.(work_dir / "file.txt")
10741074+ "content";
10751075+ (match Git.Repository.add_all repo with
10761076+ | Error (`Msg e) -> Alcotest.fail e
10771077+ | Ok () -> ());
10781078+ match Git.Repository.commit repo ~message:"test" with
10791079+ | Error (`Msg e) -> Alcotest.fail e
10801080+ | Ok commit_hash ->
10811081+ Alcotest.(check (option hash))
10821082+ "HEAD updated" (Some commit_hash) (Git.Repository.head repo)
10831083+10841084+let test_commit_no_config () =
10851085+ with_temp_repo @@ fun fs tmp_dir ->
10861086+ let repo = Git.Repository.init ~fs tmp_dir in
10871087+ let work_dir = Eio.Path.(fs / Fpath.to_string tmp_dir) in
10881088+ Eio.Path.save ~create:(`Or_truncate 0o644)
10891089+ Eio.Path.(work_dir / "file.txt")
10901090+ "content";
10911091+ (match Git.Repository.add_all repo with
10921092+ | Error (`Msg e) -> Alcotest.fail e
10931093+ | Ok () -> ());
10941094+ match Git.Repository.commit repo ~message:"test" with
10951095+ | Error (`Msg msg) ->
10961096+ Alcotest.(check bool) "error mentions config" true (String.length msg > 0)
10971097+ | Ok _ -> Alcotest.fail "expected error"
10981098+10991099+let test_commit_no_user_name () =
11001100+ with_temp_repo @@ fun fs tmp_dir ->
11011101+ let repo = Git.Repository.init ~fs tmp_dir in
11021102+ (* Set only email, not name *)
11031103+ let config =
11041104+ Git.Config.(
11051105+ empty |> set ~section:(section "user") ~key:"email" ~value:"test@test.com")
11061106+ in
11071107+ Git.Repository.write_config repo config;
11081108+ let work_dir = Eio.Path.(fs / Fpath.to_string tmp_dir) in
11091109+ Eio.Path.save ~create:(`Or_truncate 0o644)
11101110+ Eio.Path.(work_dir / "file.txt")
11111111+ "content";
11121112+ (match Git.Repository.add_all repo with
11131113+ | Error (`Msg e) -> Alcotest.fail e
11141114+ | Ok () -> ());
11151115+ match Git.Repository.commit repo ~message:"test" with
11161116+ | Error (`Msg msg) ->
11171117+ Alcotest.(check bool)
11181118+ "error mentions user.name" true
11191119+ (string_contains ~needle:"user.name" msg)
11201120+ | Ok _ -> Alcotest.fail "expected error"
11211121+11221122+let test_commit_multiple () =
11231123+ with_temp_repo @@ fun fs tmp_dir ->
11241124+ let repo = Git.Repository.init ~fs tmp_dir in
11251125+ setup_config repo;
11261126+ let work_dir = Eio.Path.(fs / Fpath.to_string tmp_dir) in
11271127+ (* First commit *)
11281128+ Eio.Path.save ~create:(`Or_truncate 0o644)
11291129+ Eio.Path.(work_dir / "file.txt")
11301130+ "v1";
11311131+ (match Git.Repository.add_all repo with
11321132+ | Error (`Msg e) -> Alcotest.fail e
11331133+ | Ok () -> ());
11341134+ let hash1 =
11351135+ match Git.Repository.commit repo ~message:"c1" with
11361136+ | Error (`Msg e) -> Alcotest.fail e
11371137+ | Ok h -> h
11381138+ in
11391139+ (* Second commit *)
11401140+ Eio.Path.save ~create:(`Or_truncate 0o644)
11411141+ Eio.Path.(work_dir / "file.txt")
11421142+ "v2";
11431143+ (match Git.Repository.add_all repo with
11441144+ | Error (`Msg e) -> Alcotest.fail e
11451145+ | Ok () -> ());
11461146+ let hash2 =
11471147+ match Git.Repository.commit repo ~message:"c2" with
11481148+ | Error (`Msg e) -> Alcotest.fail e
11491149+ | Ok h -> h
11501150+ in
11511151+ (* Verify parent *)
11521152+ match Git.Repository.read repo hash2 with
11531153+ | Error (`Msg e) -> Alcotest.fail e
11541154+ | Ok (Git.Value.Commit c) ->
11551155+ Alcotest.(check (list hash)) "parent" [ hash1 ] (Git.Commit.parents c)
11561156+ | Ok _ -> Alcotest.fail "expected commit"
11571157+11581158+(* rm tests *)
11591159+11601160+let test_rm_single_file () =
11611161+ with_temp_repo @@ fun fs tmp_dir ->
11621162+ let repo = Git.Repository.init ~fs tmp_dir in
11631163+ let work_dir = Eio.Path.(fs / Fpath.to_string tmp_dir) in
11641164+ (* Create and stage files *)
11651165+ Eio.Path.save ~create:(`Or_truncate 0o644)
11661166+ Eio.Path.(work_dir / "keep.txt")
11671167+ "keep";
11681168+ Eio.Path.save ~create:(`Or_truncate 0o644)
11691169+ Eio.Path.(work_dir / "delete.txt")
11701170+ "delete";
11711171+ (match Git.Repository.add_all repo with
11721172+ | Error (`Msg e) -> Alcotest.fail e
11731173+ | Ok () -> ());
11741174+ (* Remove one file *)
11751175+ (match Git.Repository.rm repo ~recursive:false "delete.txt" with
11761176+ | Error (`Msg e) -> Alcotest.fail e
11771177+ | Ok () -> ());
11781178+ (* Check index *)
11791179+ (match Git.Repository.read_index repo with
11801180+ | Error (`Msg e) -> Alcotest.fail e
11811181+ | Ok idx ->
11821182+ let entries = Git.Index.entries idx in
11831183+ Alcotest.(check int) "one entry" 1 (List.length entries);
11841184+ Alcotest.(check string) "keep.txt" "keep.txt" (List.hd entries).name);
11851185+ (* Check working tree *)
11861186+ Alcotest.(check bool)
11871187+ "file deleted" false
11881188+ (Eio.Path.is_file Eio.Path.(work_dir / "delete.txt"))
11891189+11901190+let test_rm_recursive () =
11911191+ with_temp_repo @@ fun fs tmp_dir ->
11921192+ let repo = Git.Repository.init ~fs tmp_dir in
11931193+ let work_dir = Eio.Path.(fs / Fpath.to_string tmp_dir) in
11941194+ (* Create nested structure *)
11951195+ Eio.Path.mkdir ~perm:0o755 Eio.Path.(work_dir / "dir");
11961196+ Eio.Path.save ~create:(`Or_truncate 0o644)
11971197+ Eio.Path.(work_dir / "dir" / "a.txt")
11981198+ "a";
11991199+ Eio.Path.save ~create:(`Or_truncate 0o644)
12001200+ Eio.Path.(work_dir / "dir" / "b.txt")
12011201+ "b";
12021202+ Eio.Path.save ~create:(`Or_truncate 0o644)
12031203+ Eio.Path.(work_dir / "keep.txt")
12041204+ "keep";
12051205+ (match Git.Repository.add_all repo with
12061206+ | Error (`Msg e) -> Alcotest.fail e
12071207+ | Ok () -> ());
12081208+ (* Remove directory *)
12091209+ (match Git.Repository.rm repo ~recursive:true "dir" with
12101210+ | Error (`Msg e) -> Alcotest.fail e
12111211+ | Ok () -> ());
12121212+ (* Check index *)
12131213+ (match Git.Repository.read_index repo with
12141214+ | Error (`Msg e) -> Alcotest.fail e
12151215+ | Ok idx ->
12161216+ let entries = Git.Index.entries idx in
12171217+ Alcotest.(check int) "one entry" 1 (List.length entries);
12181218+ Alcotest.(check string) "keep.txt" "keep.txt" (List.hd entries).name);
12191219+ (* Check working tree *)
12201220+ Alcotest.(check bool)
12211221+ "dir deleted" false
12221222+ (Eio.Path.is_directory Eio.Path.(work_dir / "dir"))
12231223+12241224+let test_rm_preserves_others () =
12251225+ with_temp_repo @@ fun fs tmp_dir ->
12261226+ let repo = Git.Repository.init ~fs tmp_dir in
12271227+ let work_dir = Eio.Path.(fs / Fpath.to_string tmp_dir) in
12281228+ (* Create files with similar prefixes *)
12291229+ Eio.Path.save ~create:(`Or_truncate 0o644)
12301230+ Eio.Path.(work_dir / "foo.txt")
12311231+ "foo";
12321232+ Eio.Path.save ~create:(`Or_truncate 0o644)
12331233+ Eio.Path.(work_dir / "foobar.txt")
12341234+ "foobar";
12351235+ (match Git.Repository.add_all repo with
12361236+ | Error (`Msg e) -> Alcotest.fail e
12371237+ | Ok () -> ());
12381238+ (* Remove only foo.txt *)
12391239+ (match Git.Repository.rm repo ~recursive:false "foo.txt" with
12401240+ | Error (`Msg e) -> Alcotest.fail e
12411241+ | Ok () -> ());
12421242+ (* Check index still has foobar.txt *)
12431243+ match Git.Repository.read_index repo with
12441244+ | Error (`Msg e) -> Alcotest.fail e
12451245+ | Ok idx ->
12461246+ let entries = Git.Index.entries idx in
12471247+ Alcotest.(check int) "one entry" 1 (List.length entries);
12481248+ Alcotest.(check string) "foobar.txt" "foobar.txt" (List.hd entries).name
12491249+12501250+let test_rm_nonexistent () =
12511251+ with_temp_repo @@ fun fs tmp_dir ->
12521252+ let repo = Git.Repository.init ~fs tmp_dir in
12531253+ (* Remove nonexistent file (should succeed silently) *)
12541254+ match Git.Repository.rm repo ~recursive:false "nonexistent.txt" with
12551255+ | Error (`Msg e) -> Alcotest.fail ("unexpected error: " ^ e)
12561256+ | Ok () -> ()
93312579341258let tests =
9351259 [