···11+(** Common utilities for fuzz tests. *)
22+33+let to_bytes buf =
44+ let len = String.length buf in
55+ let b = Bytes.create len in
66+ Bytes.blit_string buf 0 b 0 len;
77+ b
88+99+let truncate ?(max_len = 4096) buf =
1010+ if String.length buf > max_len then String.sub buf 0 max_len else buf
1111+1212+let run () = ()
+98
fuzz/fuzz_heap.ml
···11+(** Fuzz tests for Heap: crash safety and invariants. *)
22+33+open Crowbar
44+55+module B :
66+ Irmin.Heap.BACKEND
77+ with type t = (string, string) Hashtbl.t
88+ and type hash = string
99+ and type block = string = struct
1010+ type t = (string, string) Hashtbl.t
1111+ type hash = string
1212+ type block = string
1313+1414+ let find t h = Hashtbl.find_opt t h
1515+ let put t h d = Hashtbl.replace t h d
1616+ let mem t h = Hashtbl.mem t h
1717+ let batch t l = List.iter (fun (h, d) -> Hashtbl.replace t h d) l
1818+ let ref _ _ = None
1919+ let set_ref _ _ _ = ()
2020+ let del_ref _ _ = ()
2121+ let list_refs _ = []
2222+ let cas_ref _ _ ~test:_ ~set:_ = false
2323+ let flush _ = ()
2424+ let close _ = ()
2525+end
2626+2727+module H = Irmin.Heap.Make (B)
2828+2929+(** Put then find must return the same block. *)
3030+let put_find key value =
3131+ let store = Hashtbl.create 8 in
3232+ let heap = H.v store in
3333+ Irmin.Heap.put heap key value;
3434+ match Irmin.Heap.find heap key with
3535+ | Some v -> check_eq ~eq:String.equal v value
3636+ | None -> fail "put then find returned None"
3737+3838+(** Mem after put must be true. *)
3939+let put_mem key value =
4040+ let store = Hashtbl.create 8 in
4141+ let heap = H.v store in
4242+ Irmin.Heap.put heap key value;
4343+ check (Irmin.Heap.mem heap key)
4444+4545+(** Find on empty heap must return None. *)
4646+let empty key =
4747+ let store = Hashtbl.create 8 in
4848+ let heap = H.v store in
4949+ match Irmin.Heap.find heap key with
5050+ | None -> ()
5151+ | Some _ -> fail "find on empty returned Some"
5252+5353+(** Layer: top wins over bottom. *)
5454+let layer_top_wins key top_val bot_val =
5555+ let ts = Hashtbl.create 8 in
5656+ let bs = Hashtbl.create 8 in
5757+ let top = H.v ts in
5858+ let bot = H.v bs in
5959+ Irmin.Heap.put top key top_val;
6060+ Irmin.Heap.put bot key bot_val;
6161+ let layered = Irmin.Heap.layer top bot in
6262+ match Irmin.Heap.find layered key with
6363+ | Some v -> check_eq ~eq:String.equal v top_val
6464+ | None -> fail "layer find returned None"
6565+6666+(** Recording captures only accessed keys. *)
6767+let recording_subset keys =
6868+ let store = Hashtbl.create 32 in
6969+ let heap = H.v store in
7070+ List.iter (fun k -> Irmin.Heap.put heap k ("v:" ^ k)) keys;
7171+ let wrapped, get_recorded = Irmin.Heap.recording heap in
7272+ (* Read only the first key if any *)
7373+ (match keys with
7474+ | k :: _ -> ignore (Irmin.Heap.find wrapped k)
7575+ | [] -> ());
7676+ let recorded = get_recorded () in
7777+ (* Recorded count must be <= 1 *)
7878+ check (List.length recorded <= 1)
7979+8080+(** of_list roundtrip: find returns what was in the list. *)
8181+let of_list_roundtrip key value =
8282+ let heap = Irmin.Heap.of_list ~equal:String.equal [ (key, value) ] in
8383+ match Irmin.Heap.find heap key with
8484+ | Some v -> check_eq ~eq:String.equal v value
8585+ | None -> fail "of_list find returned None"
8686+8787+let suite =
8888+ ( "heap",
8989+ [
9090+ test_case "put/find" [ bytes; bytes ] put_find;
9191+ test_case "put/mem" [ bytes; bytes ] put_mem;
9292+ test_case "find empty" [ bytes ] empty;
9393+ test_case "layer top wins" [ bytes; bytes; bytes ] layer_top_wins;
9494+ test_case "recording subset" [ list bytes ] recording_subset;
9595+ test_case "of_list roundtrip" [ bytes; bytes ] of_list_roundtrip;
9696+ ] )
9797+9898+let run () = Crowbar.run "heap" [ suite ]
+70
fuzz/fuzz_schema.ml
···11+(** Fuzz tests for Schema: codec crash safety and roundtrip. *)
22+33+open Crowbar
44+open Fuzz_common
55+66+module S = Irmin.SHA256
77+88+(** Tar dir codec: decode must not crash on arbitrary input. *)
99+let decode_crash_safety buf =
1010+ let buf = truncate buf in
1111+ let _ = Irmin_tar.dir_parse buf in
1212+ ()
1313+1414+(** Tar dir roundtrip: encode(decode(valid)) re-decodes to same children. *)
1515+let dir_roundtrip buf =
1616+ let buf = truncate buf in
1717+ match Irmin_tar.dir_parse buf with
1818+ | S.Named [] -> ()
1919+ | children ->
2020+ let encoded = Irmin_tar.dir_serialize children in
2121+ let decoded = Irmin_tar.dir_parse encoded in
2222+ let names_of = function
2323+ | S.Named l -> List.map fst l
2424+ | S.Indexed arr -> List.init (Array.length arr) string_of_int
2525+ in
2626+ let n1 = List.sort String.compare (names_of children) in
2727+ let n2 = List.sort String.compare (names_of decoded) in
2828+ check_eq ~eq:( = ) n1 n2
2929+3030+(** Pattern matching must not crash on arbitrary strings. *)
3131+let pattern_match_safety pat name =
3232+ let pat = truncate ~max_len:64 pat in
3333+ let name = truncate ~max_len:64 name in
3434+ let rule = S.( => ) pat S.opaque in
3535+ ignore (name, rule)
3636+3737+(** of_list heap: find must return exactly what was put. *)
3838+let of_list_find _key value =
3939+ let value = truncate ~max_len:256 value in
4040+ let h = Digestif.SHA256.digest_string value in
4141+ let heap =
4242+ Irmin.Heap.of_list ~equal:Digestif.SHA256.equal [ (h, value) ]
4343+ in
4444+ match Irmin.Heap.find heap h with
4545+ | Some v -> check_eq ~eq:String.equal v value
4646+ | None -> fail "of_list find returned None"
4747+4848+(** Schema get_block on opaque returns the raw block. *)
4949+let opaque_get_block value =
5050+ let value = truncate ~max_len:256 value in
5151+ let h = Digestif.SHA256.digest_string value in
5252+ let heap =
5353+ Irmin.Heap.of_list ~equal:Digestif.SHA256.equal [ (h, value) ]
5454+ in
5555+ let c = S.at heap S.opaque h in
5656+ match S.get_block c with
5757+ | Some v -> check_eq ~eq:String.equal v value
5858+ | None -> fail "get_block returned None"
5959+6060+let suite =
6161+ ( "schema",
6262+ [
6363+ test_case "tar dir decode crash safety" [ bytes ] decode_crash_safety;
6464+ test_case "tar dir roundtrip" [ bytes ] dir_roundtrip;
6565+ test_case "pattern match safety" [ bytes; bytes ] pattern_match_safety;
6666+ test_case "of_list find" [ bytes; bytes ] of_list_find;
6767+ test_case "opaque get_block" [ bytes ] opaque_get_block;
6868+ ] )
6969+7070+let run () = Crowbar.run "schema" [ suite ]
+26
fuzz/gen_corpus.ml
···11+(** Generate seed corpus for AFL fuzzing. *)
22+33+let () =
44+ (try Unix.mkdir "corpus" 0o755
55+ with Unix.Unix_error (Unix.EEXIST, _, _) -> ());
66+ let n = ref 0 in
77+ let write data =
88+ let name = Fmt.str "corpus/seed_%03d" !n in
99+ let oc = open_out_bin name in
1010+ output_string oc data;
1111+ close_out oc;
1212+ incr n
1313+ in
1414+ (* Empty input *)
1515+ write "";
1616+ (* Valid tar dir: name\0 + 32 bytes SHA-256 *)
1717+ write ("hello\x00" ^ String.make 32 '\x00');
1818+ (* Nested tar dir *)
1919+ write ("a\x00" ^ String.make 32 '\x01' ^ "b\x00" ^ String.make 32 '\x02');
2020+ (* Truncated entry *)
2121+ write "abc\x00\x01\x02";
2222+ (* No null separator *)
2323+ write "abcdef";
2424+ (* Single byte *)
2525+ write "\x00";
2626+ Fmt.pr "gen_corpus: wrote %d seed files@." !n
···2424module Hash = Hash
2525module Heap = Heap
2626module Schema = Schema
2727+module Worktree = Worktree
27282829(** Pre-built schema instances for common hash algorithms. Use these instead of
2930 calling {!Schema.Make} directly when plain SHA-1 or SHA-256 suffices. *)
+76
lib/worktree.mli
···11+(** Working tree: checkout, status, and commit against the filesystem.
22+33+ The worktree bridges a content-addressed heap and a directory on disk. An
44+ index file ([.irmin/index]) tracks checked-out files with their hash, mtime,
55+ and size. [status] only re-hashes files whose mtime or size changed.
66+77+ {[
88+ let module W = Worktree.Make (Hash) in
99+ W.checkout ~fs heap ~hash:root ~dir;
1010+ (* edit files on disk normally *)
1111+ let changes = W.status ~fs ~dir in
1212+ W.commit ~fs heap ~branch:"main" ~dir ~message:"update" ~author:"me"
1313+ ]} *)
1414+1515+module Make (H : sig
1616+ type hash
1717+1818+ val hash_equal : hash -> hash -> bool
1919+ (** Hash equality. *)
2020+2121+ val hash_string : string -> hash
2222+ (** Hash a string. *)
2323+2424+ val to_hex : hash -> string
2525+ (** Hex-encode a hash. *)
2626+2727+ val of_hex : string -> hash
2828+ (** Decode a hex-encoded hash. *)
2929+end) : sig
3030+ (** {1 Types} *)
3131+3232+ type change =
3333+ | Added of string (** New file not in the index. *)
3434+ | Modified of string (** File contents changed. *)
3535+ | Deleted of string (** File removed from disk. *)
3636+3737+ val pp_change : change Fmt.t
3838+3939+ (** {1 Index} *)
4040+4141+ type index_entry = { path : string; hash : H.hash; mtime : float; size : int }
4242+ type index = index_entry list
4343+4444+ val read_index : fs:Eio.Fs.dir_ty Eio.Path.t -> dir:Fpath.t -> index
4545+ (** Read the index from [dir/.irmin/index]. Empty if missing. *)
4646+4747+ val write_index : fs:Eio.Fs.dir_ty Eio.Path.t -> dir:Fpath.t -> index -> unit
4848+ (** Write the index to [dir/.irmin/index]. *)
4949+5050+ (** {1 Operations} *)
5151+5252+ val checkout :
5353+ fs:Eio.Fs.dir_ty Eio.Path.t ->
5454+ (H.hash, string) Heap.t ->
5555+ hash:H.hash ->
5656+ dir:Fpath.t ->
5757+ (unit, [ `Msg of string ]) result
5858+ (** [checkout ~fs heap ~hash ~dir] writes the tree at [hash] to [dir] and
5959+ populates the index. *)
6060+6161+ val status : fs:Eio.Fs.dir_ty Eio.Path.t -> dir:Fpath.t -> change list
6262+ (** [status ~fs ~dir] compares [dir] against the index. Fast: only re-hashes
6363+ files whose mtime or size changed. *)
6464+6565+ val commit :
6666+ fs:Eio.Fs.dir_ty Eio.Path.t ->
6767+ (H.hash, string) Heap.t ->
6868+ branch:string ->
6969+ dir:Fpath.t ->
7070+ message:string ->
7171+ author:string ->
7272+ (H.hash, [ `Msg of string ]) result
7373+ (** [commit ~fs heap ~branch ~dir ~message ~author] scans [dir], hashes
7474+ changed files, builds a new tree, creates a commit, and updates the branch
7575+ ref. Returns the commit hash. *)
7676+end
···11-(** Heap tests. Covered by the schema test runner. *)
11+(** Heap tests: find/put/mem, refs, recording, of_list, layer. *)
22+33+module B :
44+ Irmin.Heap.BACKEND
55+ with type t = (string, string) Hashtbl.t
66+ and type hash = string
77+ and type block = string = struct
88+ type t = (string, string) Hashtbl.t
99+ type hash = string
1010+ type block = string
1111+1212+ let find t h = Hashtbl.find_opt t h
1313+ let put t h d = Hashtbl.replace t h d
1414+ let mem t h = Hashtbl.mem t h
1515+ let batch t l = List.iter (fun (h, d) -> Hashtbl.replace t h d) l
1616+1717+ let refs : (string, string) Hashtbl.t = Hashtbl.create 4
1818+1919+ let ref _ name = Hashtbl.find_opt refs name
2020+ let set_ref _ name h = Hashtbl.replace refs name h
2121+ let del_ref _ _ = ()
2222+ let list_refs _ = Hashtbl.fold (fun k _ acc -> k :: acc) refs []
2323+ let cas_ref _ _ ~test:_ ~set:_ = false
2424+ let flush _ = ()
2525+ let close _ = ()
2626+end
2727+2828+module H = Irmin.Heap.Make (B)
2929+3030+let put_find_mem () =
3131+ let store = Hashtbl.create 8 in
3232+ let heap = H.v store in
3333+ Alcotest.(check bool) "mem before put" false (Irmin.Heap.mem heap "k1");
3434+ Irmin.Heap.put heap "k1" "v1";
3535+ Alcotest.(check bool) "mem after put" true (Irmin.Heap.mem heap "k1");
3636+ Alcotest.(check (option string)) "find" (Some "v1") (Irmin.Heap.find heap "k1");
3737+ Alcotest.(check (option string)) "find missing" None (Irmin.Heap.find heap "k2")
3838+3939+let batch () =
4040+ let store = Hashtbl.create 8 in
4141+ let heap = H.v store in
4242+ Irmin.Heap.batch heap [ ("a", "1"); ("b", "2"); ("c", "3") ];
4343+ Alcotest.(check (option string)) "batch a" (Some "1") (Irmin.Heap.find heap "a");
4444+ Alcotest.(check (option string)) "batch b" (Some "2") (Irmin.Heap.find heap "b");
4545+ Alcotest.(check (option string)) "batch c" (Some "3") (Irmin.Heap.find heap "c")
24633-let suite = ("heap", [])
4747+let recording () =
4848+ let store = Hashtbl.create 8 in
4949+ let heap = H.v store in
5050+ Irmin.Heap.put heap "x" "10";
5151+ Irmin.Heap.put heap "y" "20";
5252+ Irmin.Heap.put heap "z" "30";
5353+ let wrapped, get_recorded = Irmin.Heap.recording heap in
5454+ ignore (Irmin.Heap.find wrapped "x");
5555+ ignore (Irmin.Heap.find wrapped "z");
5656+ ignore (Irmin.Heap.find wrapped "missing");
5757+ let recorded = get_recorded () in
5858+ let keys = List.map fst recorded |> List.sort String.compare in
5959+ Alcotest.(check (list string)) "recorded keys" [ "x"; "z" ] keys
6060+6161+let of_list () =
6262+ let heap = Irmin.Heap.of_list ~equal:String.equal [ ("a", "1"); ("b", "2") ] in
6363+ Alcotest.(check (option string)) "find a" (Some "1") (Irmin.Heap.find heap "a");
6464+ Alcotest.(check (option string)) "find b" (Some "2") (Irmin.Heap.find heap "b");
6565+ Alcotest.(check (option string)) "find c" None (Irmin.Heap.find heap "c")
6666+6767+let layer () =
6868+ let top_store = Hashtbl.create 8 in
6969+ let bot_store = Hashtbl.create 8 in
7070+ let top = H.v top_store in
7171+ let bot = H.v bot_store in
7272+ Irmin.Heap.put bot "shared" "from-bottom";
7373+ Irmin.Heap.put bot "bottom-only" "bot";
7474+ Irmin.Heap.put top "shared" "from-top";
7575+ let layered = Irmin.Heap.layer top bot in
7676+ Alcotest.(check (option string))
7777+ "top wins" (Some "from-top") (Irmin.Heap.find layered "shared");
7878+ Alcotest.(check (option string))
7979+ "falls through" (Some "bot")
8080+ (Irmin.Heap.find layered "bottom-only");
8181+ (* bottom hit should cache in top *)
8282+ Alcotest.(check bool) "cached in top" true (Irmin.Heap.mem top "bottom-only")
8383+8484+let overwrite () =
8585+ let store = Hashtbl.create 8 in
8686+ let heap = H.v store in
8787+ Irmin.Heap.put heap "k" "v1";
8888+ Irmin.Heap.put heap "k" "v2";
8989+ Alcotest.(check (option string)) "overwritten" (Some "v2") (Irmin.Heap.find heap "k")
9090+9191+let recording_dedup () =
9292+ let store = Hashtbl.create 8 in
9393+ let heap = H.v store in
9494+ Irmin.Heap.put heap "x" "10";
9595+ let wrapped, get_recorded = Irmin.Heap.recording heap in
9696+ ignore (Irmin.Heap.find wrapped "x");
9797+ ignore (Irmin.Heap.find wrapped "x");
9898+ ignore (Irmin.Heap.find wrapped "x");
9999+ let recorded = get_recorded () in
100100+ Alcotest.(check int) "dedup" 1 (List.length recorded)
101101+102102+let recording_no_miss () =
103103+ let store = Hashtbl.create 8 in
104104+ let heap = H.v store in
105105+ let wrapped, get_recorded = Irmin.Heap.recording heap in
106106+ ignore (Irmin.Heap.find wrapped "missing");
107107+ let recorded = get_recorded () in
108108+ Alcotest.(check int) "miss not recorded" 0 (List.length recorded)
109109+110110+let layer_write_to_top () =
111111+ let top_store = Hashtbl.create 8 in
112112+ let bot_store = Hashtbl.create 8 in
113113+ let top = H.v top_store in
114114+ let bot = H.v bot_store in
115115+ let layered = Irmin.Heap.layer top bot in
116116+ Irmin.Heap.put layered "new" "val";
117117+ Alcotest.(check (option string))
118118+ "written to top" (Some "val") (Irmin.Heap.find top "new");
119119+ Alcotest.(check (option string))
120120+ "not in bottom" None (Irmin.Heap.find bot "new")
121121+122122+let of_list_empty () =
123123+ let heap = Irmin.Heap.of_list ~equal:String.equal [] in
124124+ Alcotest.(check (option string)) "empty find" None (Irmin.Heap.find heap "x");
125125+ Alcotest.(check bool) "empty mem" false (Irmin.Heap.mem heap "x")
126126+127127+(* ===== Fuzz: random put/find sequences ===== *)
128128+129129+let fuzz_put_find () =
130130+ (* Generate random key-value pairs, put them, then verify all are findable *)
131131+ let n = 100 + Random.int 200 in
132132+ let store = Hashtbl.create n in
133133+ let heap = H.v store in
134134+ let pairs = List.init n (fun i -> (Fmt.str "k%d" i, Fmt.str "v%d" i)) in
135135+ List.iter (fun (k, v) -> Irmin.Heap.put heap k v) pairs;
136136+ List.iter
137137+ (fun (k, v) ->
138138+ Alcotest.(check (option string))
139139+ (Fmt.str "find %s" k) (Some v) (Irmin.Heap.find heap k))
140140+ pairs
141141+142142+let fuzz_layer_equivalence () =
143143+ (* A layered heap should return the same as checking top then bottom *)
144144+ let n = 50 in
145145+ let top_store = Hashtbl.create n in
146146+ let bot_store = Hashtbl.create n in
147147+ let top = H.v top_store in
148148+ let bot = H.v bot_store in
149149+ let layered = Irmin.Heap.layer top bot in
150150+ (* Fill both with overlapping keys *)
151151+ for i = 0 to n - 1 do
152152+ Irmin.Heap.put bot (Fmt.str "k%d" i) (Fmt.str "bot%d" i)
153153+ done;
154154+ for i = 0 to n / 2 do
155155+ Irmin.Heap.put top (Fmt.str "k%d" i) (Fmt.str "top%d" i)
156156+ done;
157157+ (* Verify: layered should prefer top, fall through to bottom *)
158158+ for i = 0 to n - 1 do
159159+ let key = Fmt.str "k%d" i in
160160+ let expected =
161161+ match Irmin.Heap.find top key with
162162+ | Some _ as r -> r
163163+ | None -> Irmin.Heap.find bot key
164164+ in
165165+ Alcotest.(check (option string))
166166+ (Fmt.str "layer %s" key) expected
167167+ (Irmin.Heap.find layered key)
168168+ done
169169+170170+let fuzz_recording_subset () =
171171+ (* Recording should capture exactly the blocks that were read *)
172172+ let store = Hashtbl.create 32 in
173173+ let heap = H.v store in
174174+ for i = 0 to 31 do
175175+ Irmin.Heap.put heap (Fmt.str "k%d" i) (Fmt.str "v%d" i)
176176+ done;
177177+ let wrapped, get_recorded = Irmin.Heap.recording heap in
178178+ (* Read a random subset *)
179179+ let read_keys = List.init 10 (fun i -> Fmt.str "k%d" (i * 3)) in
180180+ List.iter (fun k -> ignore (Irmin.Heap.find wrapped k)) read_keys;
181181+ let recorded_keys =
182182+ List.map fst (get_recorded ()) |> List.sort String.compare
183183+ in
184184+ let expected = List.sort String.compare read_keys in
185185+ Alcotest.(check (list string)) "recorded = read" expected recorded_keys
186186+187187+let suite =
188188+ ( "heap",
189189+ [
190190+ Alcotest.test_case "find/put/mem" `Quick put_find_mem;
191191+ Alcotest.test_case "batch" `Quick batch;
192192+ Alcotest.test_case "overwrite" `Quick overwrite;
193193+ Alcotest.test_case "recording" `Quick recording;
194194+ Alcotest.test_case "recording dedup" `Quick recording_dedup;
195195+ Alcotest.test_case "recording miss not recorded" `Quick recording_no_miss;
196196+ Alcotest.test_case "of_list" `Quick of_list;
197197+ Alcotest.test_case "of_list empty" `Quick of_list_empty;
198198+ Alcotest.test_case "layer" `Quick layer;
199199+ Alcotest.test_case "layer write to top" `Quick layer_write_to_top;
200200+ Alcotest.test_case "fuzz: put/find" `Quick fuzz_put_find;
201201+ Alcotest.test_case "fuzz: layer equivalence" `Quick fuzz_layer_equivalence;
202202+ Alcotest.test_case "fuzz: recording subset" `Quick fuzz_recording_subset;
203203+ ] )
+3
test/test_tar.mli
···11+(** Tar codec tests: merkle tree, tar+json composition, merge. *)
22+33+val suite : string * unit Alcotest.test_case list
+132-2
test/test_worktree.ml
···11-(** Worktree tests. *)
11+(** Worktree tests: checkout, status, commit, and adversarial cases. *)
22+33+module Hash = struct
44+ type hash = string
55+66+ let hash_equal = String.equal
77+ let hash_string s = Digestif.SHA256.(to_hex (digest_string s))
88+ let to_hex h = h
99+ let of_hex h = h
1010+end
1111+1212+module W = Irmin.Worktree.Make (Hash)
1313+1414+module B :
1515+ Irmin.Heap.BACKEND
1616+ with type t = (string, string) Hashtbl.t
1717+ and type hash = string
1818+ and type block = string = struct
1919+ type t = (string, string) Hashtbl.t
2020+ type hash = string
2121+ type block = string
2222+2323+ let find t h = Hashtbl.find_opt t h
2424+ let put t h d = Hashtbl.replace t h d
2525+ let mem t h = Hashtbl.mem t h
2626+ let batch t l = List.iter (fun (h, d) -> Hashtbl.replace t h d) l
2727+2828+ let refs : (string, string) Hashtbl.t = Hashtbl.create 4
2929+3030+ let ref _ name = Hashtbl.find_opt refs name
3131+ let set_ref _ name h = Hashtbl.replace refs name h
3232+ let del_ref _ _ = ()
3333+ let list_refs _ = Hashtbl.fold (fun k _ acc -> k :: acc) refs []
3434+ let cas_ref _ _ ~test:_ ~set:_ = false
3535+ let flush _ = ()
3636+ let close _ = ()
3737+end
3838+3939+module H = Irmin.Heap.Make (B)
4040+4141+let with_tmp_dir f =
4242+ Eio_main.run @@ fun env ->
4343+ let fs = Eio.Stdenv.fs env in
4444+ let name = Fmt.str "/tmp/irmin-test-wt-%d" (Random.int 1_000_000) in
4545+ let path = Eio.Path.(fs / name) in
4646+ Eio.Path.mkdirs ~exists_ok:true ~perm:0o755 path;
4747+ Fun.protect
4848+ ~finally:(fun () -> try Eio.Path.rmtree path with Eio.Io _ -> ())
4949+ (fun () -> f fs (Fpath.v name))
5050+5151+let status_empty_dir () =
5252+ with_tmp_dir @@ fun fs dir ->
5353+ let changes = W.status ~fs ~dir in
5454+ Alcotest.(check int) "empty dir = no changes" 0 (List.length changes)
25533-let suite = ("worktree", [])
5656+let commit_and_status () =
5757+ with_tmp_dir @@ fun fs dir ->
5858+ let store = Hashtbl.create 16 in
5959+ let heap = H.v store in
6060+ (* Create a file and commit it *)
6161+ let p = Eio.Path.(fs / Fpath.to_string dir / "hello.txt") in
6262+ Eio.Path.save ~create:(`Or_truncate 0o644) p "Hello, world!";
6363+ (* Write empty index so status sees the file as Added *)
6464+ W.write_index ~fs ~dir [];
6565+ let changes = W.status ~fs ~dir in
6666+ Alcotest.(check int) "1 added before commit" 1 (List.length changes);
6767+ (* Commit *)
6868+ (match
6969+ W.commit ~fs heap ~branch:"main" ~dir ~message:"init" ~author:"test"
7070+ with
7171+ | Ok _hash -> ()
7272+ | Error (`Msg e) -> Alcotest.failf "commit failed: %s" e);
7373+ (* Status should be clean after commit *)
7474+ let changes = W.status ~fs ~dir in
7575+ Alcotest.(check int) "clean after commit" 0 (List.length changes);
7676+ (* Modify the file *)
7777+ Eio.Path.save ~create:(`Or_truncate 0o644) p "Modified!";
7878+ let changes = W.status ~fs ~dir in
7979+ Alcotest.(check int) "1 modified" 1 (List.length changes);
8080+ match changes with
8181+ | [ W.Modified "hello.txt" ] -> ()
8282+ | _ -> Alcotest.fail "expected Modified hello.txt"
8383+8484+let status_added () =
8585+ with_tmp_dir @@ fun fs dir ->
8686+ (* Write index with no files, then add one *)
8787+ W.write_index ~fs ~dir [];
8888+ let p = Eio.Path.(fs / Fpath.to_string dir / "new.txt") in
8989+ Eio.Path.save ~create:(`Or_truncate 0o644) p "new content";
9090+ let changes = W.status ~fs ~dir in
9191+ Alcotest.(check int) "1 added" 1 (List.length changes);
9292+ match changes with
9393+ | [ W.Added "new.txt" ] -> ()
9494+ | _ -> Alcotest.fail "expected Added new.txt"
9595+9696+let status_deleted () =
9797+ with_tmp_dir @@ fun fs dir ->
9898+ (* Write an index entry for a file that doesn't exist on disk *)
9999+ let idx : W.index =
100100+ [
101101+ {
102102+ path = "gone.txt";
103103+ hash = Hash.hash_string "old content";
104104+ mtime = 0.0;
105105+ size = 11;
106106+ };
107107+ ]
108108+ in
109109+ W.write_index ~fs ~dir idx;
110110+ let changes = W.status ~fs ~dir in
111111+ Alcotest.(check int) "1 deleted" 1 (List.length changes);
112112+ match changes with
113113+ | [ W.Deleted "gone.txt" ] -> ()
114114+ | _ -> Alcotest.fail "expected Deleted gone.txt"
115115+116116+let commit_nothing () =
117117+ with_tmp_dir @@ fun fs dir ->
118118+ let store = Hashtbl.create 16 in
119119+ let heap = H.v store in
120120+ W.write_index ~fs ~dir [];
121121+ match W.commit ~fs heap ~branch:"main" ~dir ~message:"empty" ~author:"test" with
122122+ | Error (`Msg _) -> ()
123123+ | Ok _ -> Alcotest.fail "should fail: nothing to commit"
124124+125125+let suite =
126126+ ( "worktree",
127127+ [
128128+ Alcotest.test_case "status empty dir" `Quick status_empty_dir;
129129+ Alcotest.test_case "commit and status" `Quick commit_and_status;
130130+ Alcotest.test_case "status added" `Quick status_added;
131131+ Alcotest.test_case "status deleted" `Quick status_deleted;
132132+ Alcotest.test_case "commit nothing" `Quick commit_nothing;
133133+ ] )