1use std::collections::BTreeMap;
27use std::fs;
28use std::io::{self, Read};
29use std::path::PathBuf;
30
31use crate::atomic::write_atomic;
32use crate::hash::{self, HASH_LEN, Hash};
33use crate::layout::RepoLayout;
34use crate::object::{EntryMode, Object};
35use crate::store::{MAX_TREE_DEPTH, ObjectStore, StoreError};
36
37pub const MAGIC: [u8; 4] = *b"MKIX";
39pub const FORMAT_VERSION: u8 = 0x03;
41pub const MAX_INDEX_BYTES: u64 = 64 * 1024 * 1024;
43pub const MAX_PATH_LEN: usize = 4096;
45
46pub const INDEX_FILE: &str = ".mkit/index";
48
49#[repr(u8)]
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum EntryStatus {
53 Removed = 0x00,
55 Blob = 0x01,
57 Tree = 0x02,
59 Symlink = 0x03,
61 Executable = 0x04,
63}
64
65impl EntryStatus {
66 #[must_use]
68 pub fn from_byte(b: u8) -> Option<Self> {
69 match b {
70 0x00 => Some(Self::Removed),
71 0x01 => Some(Self::Blob),
72 0x02 => Some(Self::Tree),
73 0x03 => Some(Self::Symlink),
74 0x04 => Some(Self::Executable),
75 _ => None,
76 }
77 }
78}
79
80#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct IndexEntry {
83 pub path: String,
85 pub status: EntryStatus,
87 pub object_hash: Hash,
89 pub mtime_ns: u64,
93 pub size: u64,
96 pub ino: u64,
100 pub ctime_ns: u64,
104}
105
106#[derive(Debug, Default, Clone)]
119pub struct Index {
120 pub entries: Vec<IndexEntry>,
122 by_path: BTreeMap<String, usize>,
132}
133
134impl PartialEq for Index {
140 fn eq(&self, other: &Self) -> bool {
141 self.entries == other.entries
142 }
143}
144
145impl Eq for Index {}
146
147impl Index {
148 #[must_use]
150 pub const fn new() -> Self {
151 Self {
152 entries: Vec::new(),
153 by_path: BTreeMap::new(),
154 }
155 }
156
157 pub(crate) fn from_entries(entries: Vec<IndexEntry>) -> Self {
170 let mut idx = Self {
171 entries,
172 by_path: BTreeMap::new(),
173 };
174 idx.rebuild_path_index();
175 idx
176 }
177
178 #[must_use]
180 pub fn find_entry(&self, path: &str) -> Option<usize> {
181 self.by_path.get(path).copied()
182 }
183
184 #[must_use]
190 pub fn tracks_path_or_descendant(&self, path: &str) -> bool {
191 if let Some(&pos) = self.by_path.get(path)
192 && self.entries[pos].status != EntryStatus::Removed
193 {
194 return true;
195 }
196 let mut prefix = String::with_capacity(path.len() + 1);
197 prefix.push_str(path);
198 prefix.push('/');
199 self.by_path
200 .range(prefix.clone()..)
201 .take_while(|(p, _)| p.starts_with(prefix.as_str()))
202 .any(|(_, &pos)| self.entries[pos].status != EntryStatus::Removed)
203 }
204
205 #[must_use]
216 pub fn has_tracked_file_at(&self, path: &str) -> bool {
217 self.find_entry(path)
218 .is_some_and(|i| self.entries[i].status != EntryStatus::Removed)
219 }
220
221 #[must_use]
223 pub fn staged_count(&self) -> usize {
224 self.entries
225 .iter()
226 .filter(|e| e.status != EntryStatus::Removed)
227 .count()
228 }
229
230 pub fn upsert_entry(&mut self, entry: IndexEntry) {
235 if let Some(&pos) = self.by_path.get(entry.path.as_str()) {
236 self.entries[pos] = entry;
237 } else {
238 let pos = self.entries.len();
239 self.by_path.insert(entry.path.clone(), pos);
240 self.entries.push(entry);
241 }
242 self.debug_assert_consistent();
243 }
244
245 pub fn remove_entry_at(&mut self, pos: usize) -> IndexEntry {
254 let removed = self.entries.remove(pos);
255 self.rebuild_path_index();
256 removed
257 }
258
259 pub fn remove_path(&mut self, path: &str) -> Option<IndexEntry> {
263 self.find_entry(path).map(|pos| self.remove_entry_at(pos))
264 }
265
266 pub fn retain_entries(&mut self, keep: impl FnMut(&IndexEntry) -> bool) {
269 self.entries.retain(keep);
270 self.rebuild_path_index();
271 }
272
273 pub fn remove_directory_conflicts(&mut self, path: &str) {
284 let has_ancestor_conflict =
285 ancestor_prefixes(path).any(|anc| self.by_path.contains_key(anc));
286 let descendant_prefix = format!("{path}/");
287 let has_descendant_conflict = self
288 .by_path
289 .range(descendant_prefix.clone()..)
290 .next()
291 .is_some_and(|(p, _)| p.starts_with(descendant_prefix.as_str()));
292 if !has_ancestor_conflict && !has_descendant_conflict {
293 return;
294 }
295 self.entries.retain(|entry| {
296 entry.path == path
297 || !(path_descends_from(&entry.path, path) || path_descends_from(path, &entry.path))
298 });
299 self.rebuild_path_index();
300 }
301
302 fn rebuild_path_index(&mut self) {
306 self.by_path.clear();
307 for (i, e) in self.entries.iter().enumerate() {
308 self.by_path.insert(e.path.clone(), i);
309 }
310 self.debug_assert_consistent();
311 }
312
313 #[cfg(debug_assertions)]
321 fn debug_assert_consistent(&self) {
322 debug_assert_eq!(
323 self.by_path.len(),
324 self.entries.len(),
325 "Index path map desynced from entries (missed upsert/remove/retain?)"
326 );
327 for (i, e) in self.entries.iter().enumerate() {
328 debug_assert_eq!(
329 self.by_path.get(e.path.as_str()),
330 Some(&i),
331 "Index path map has a stale/dangling position for '{}'",
332 e.path
333 );
334 }
335 }
336
337 #[cfg(not(debug_assertions))]
338 fn debug_assert_consistent(&self) {}
339
340 #[must_use]
346 pub fn serialize(&self) -> Vec<u8> {
347 let body: usize = self
350 .entries
351 .iter()
352 .map(|e| 1 + HASH_LEN + 8 + 8 + 8 + 8 + 2 + e.path.len())
353 .sum();
354 let mut out = Vec::with_capacity(9 + body + HASH_LEN);
355 out.extend_from_slice(&MAGIC);
356 out.push(FORMAT_VERSION);
357 let count = u32::try_from(self.entries.len()).expect("index entry count fits in u32");
358 out.extend_from_slice(&count.to_le_bytes());
359 for entry in &self.entries {
360 out.push(entry.status as u8);
361 out.extend_from_slice(&entry.object_hash);
362 out.extend_from_slice(&entry.mtime_ns.to_le_bytes());
363 out.extend_from_slice(&entry.size.to_le_bytes());
364 out.extend_from_slice(&entry.ino.to_le_bytes());
365 out.extend_from_slice(&entry.ctime_ns.to_le_bytes());
366 let path_len =
367 u16::try_from(entry.path.len()).expect("index entry path length fits in u16");
368 out.extend_from_slice(&path_len.to_le_bytes());
369 out.extend_from_slice(entry.path.as_bytes());
370 }
371 out.extend_from_slice(&hash::hash(&out));
372 out
373 }
374}
375
376fn ancestor_prefixes(path: &str) -> impl Iterator<Item = &str> {
381 path.match_indices('/').map(move |(i, _)| &path[..i])
382}
383
384fn path_descends_from(path: &str, base: &str) -> bool {
390 path.len() > base.len()
391 && path.starts_with(base)
392 && path.as_bytes().get(base.len()) == Some(&b'/')
393}
394
395#[derive(Debug, thiserror::Error)]
397pub enum IndexError {
398 #[error("index file has wrong magic (expected MKIX)")]
400 BadMagic,
401 #[error("unsupported index version: {0:#x}")]
403 UnsupportedVersion(u8),
404 #[error("index entry has unknown status byte {0:#x}")]
406 BadStatus(u8),
407 #[error("index file is corrupt")]
409 Corrupt,
410 #[error("index file too large (>{MAX_INDEX_BYTES} bytes)")]
412 TooLarge,
413 #[error("invalid index path '{0}'")]
415 InvalidPath(String),
416 #[error("duplicate index path '{0}'")]
418 DuplicatePath(String),
419 #[error("removed index entry '{0}' has nonzero object_hash")]
422 RemovedHasHash(String),
423 #[error("index path is not valid UTF-8")]
425 InvalidPathEncoding,
426 #[error(transparent)]
428 Io(#[from] io::Error),
429 #[error(transparent)]
431 Store(#[from] StoreError),
432 #[error("object is not a tree")]
434 NotTree,
435 #[error("tree nesting exceeds {} levels", MAX_TREE_DEPTH)]
438 TreeTooDeep,
439}
440
441pub type IndexResult<T> = Result<T, IndexError>;
443
444pub fn deserialize(data: &[u8]) -> IndexResult<Index> {
453 if data.len() as u64 > MAX_INDEX_BYTES {
454 return Err(IndexError::TooLarge);
455 }
456 if data.len() < 9 {
457 return Err(IndexError::Corrupt);
458 }
459 if data[0..4] != MAGIC {
460 return Err(IndexError::BadMagic);
461 }
462 let version = data[4];
463 if version != FORMAT_VERSION {
464 return Err(IndexError::UnsupportedVersion(version));
465 }
466 if data.len() < 9 + HASH_LEN {
467 return Err(IndexError::Corrupt);
468 }
469 let (data, checksum) = data.split_at(data.len() - HASH_LEN);
470 if hash::hash(data).as_slice() != checksum {
471 return Err(IndexError::Corrupt);
472 }
473 let min_entry_len = 1 + HASH_LEN + 32 + 2;
475 let count = u32::from_le_bytes([data[5], data[6], data[7], data[8]]) as usize;
476 if (count as u64).saturating_mul(min_entry_len as u64) > data.len() as u64 {
483 return Err(IndexError::Corrupt);
484 }
485 let mut entries = Vec::with_capacity(count.min(1024)); let mut seen_paths = std::collections::HashSet::with_capacity(count.min(1024));
487 let mut offset = 9usize;
488 for _ in 0..count {
489 if offset + min_entry_len > data.len() {
490 return Err(IndexError::Corrupt);
491 }
492 let status =
493 EntryStatus::from_byte(data[offset]).ok_or(IndexError::BadStatus(data[offset]))?;
494 offset += 1;
495 let mut object_hash = [0u8; HASH_LEN];
496 object_hash.copy_from_slice(&data[offset..offset + HASH_LEN]);
497 offset += HASH_LEN;
498 let mut next_u64 = || {
499 let v = u64::from_le_bytes(data[offset..offset + 8].try_into().expect("8 bytes"));
500 offset += 8;
501 v
502 };
503 let (mtime_ns, size, ino, ctime_ns) = (next_u64(), next_u64(), next_u64(), next_u64());
504 let path_len = u16::from_le_bytes([data[offset], data[offset + 1]]) as usize;
505 offset += 2;
506 if path_len > MAX_PATH_LEN {
507 return Err(IndexError::Corrupt);
508 }
509 if offset + path_len > data.len() {
510 return Err(IndexError::Corrupt);
511 }
512 let path_bytes = &data[offset..offset + path_len];
513 let path = core::str::from_utf8(path_bytes)
514 .map_err(|_| IndexError::InvalidPathEncoding)?
515 .to_string();
516 offset += path_len;
517 if !validate_index_path(&path) {
518 return Err(IndexError::InvalidPath(path));
519 }
520 if !seen_paths.insert(path.clone()) {
521 return Err(IndexError::DuplicatePath(path));
522 }
523 if status == EntryStatus::Removed && object_hash != hash::ZERO {
524 return Err(IndexError::RemovedHasHash(path));
525 }
526 entries.push(IndexEntry {
527 path,
528 status,
529 object_hash,
530 mtime_ns,
531 size,
532 ino,
533 ctime_ns,
534 });
535 }
536 if offset != data.len() {
537 return Err(IndexError::Corrupt);
538 }
539 Ok(Index::from_entries(entries))
540}
541
542pub fn read_index(layout: &RepoLayout) -> IndexResult<Index> {
546 let path = layout.index_file();
547 let file = match fs::File::open(&path) {
548 Ok(f) => f,
549 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Index::new()),
550 Err(e) => return Err(IndexError::Io(e)),
551 };
552 let meta = file.metadata()?;
553 if meta.len() == 0 {
554 return Err(IndexError::Corrupt);
555 }
556 if meta.len() > MAX_INDEX_BYTES {
557 return Err(IndexError::TooLarge);
558 }
559 let mut bytes = Vec::new();
560 file.take(MAX_INDEX_BYTES + 1).read_to_end(&mut bytes)?;
561 if bytes.len() as u64 > MAX_INDEX_BYTES {
562 return Err(IndexError::TooLarge);
563 }
564 let mut idx = deserialize(&bytes)?;
565 let index_mtime_ns = crate::worktree::mtime_nanos(&meta);
575 let index_ns_precise = !index_mtime_ns.is_multiple_of(1_000_000_000);
583 for e in &mut idx.entries {
584 if e.mtime_ns == 0 {
585 continue;
586 }
587 let window = if index_ns_precise && !e.mtime_ns.is_multiple_of(1_000_000_000) {
588 RACY_WINDOW_NS / 100
589 } else {
590 RACY_WINDOW_NS
591 };
592 if e.mtime_ns >= index_mtime_ns.saturating_sub(window) {
593 e.mtime_ns = 0;
594 e.size = 0;
595 e.ino = 0;
596 e.ctime_ns = 0;
597 }
598 }
599 Ok(idx)
600}
601
602const RACY_WINDOW_NS: u64 = 1_000_000_000;
609
610pub fn write_index(layout: &RepoLayout, idx: &Index) -> IndexResult<()> {
620 let path = layout.index_file();
621 let bytes = idx.serialize();
622 if bytes.len() as u64 > MAX_INDEX_BYTES {
623 return Err(IndexError::TooLarge);
624 }
625 write_atomic(&path, &bytes, true)?;
626 Ok(())
627}
628
629pub fn from_tree(store: &ObjectStore, tree_hash: Hash) -> IndexResult<Index> {
640 let mut entries = Vec::new();
641 push_tree_entries(store, tree_hash, "", &mut entries, 0)?;
642 Ok(Index::from_entries(entries))
643}
644
645fn push_tree_entries(
646 store: &ObjectStore,
647 tree_hash: Hash,
648 prefix: &str,
649 entries: &mut Vec<IndexEntry>,
650 depth: usize,
651) -> IndexResult<()> {
652 if depth > MAX_TREE_DEPTH {
653 return Err(IndexError::TreeTooDeep);
654 }
655 let Object::Tree(tree) = store.read_object(&tree_hash)? else {
656 return Err(IndexError::NotTree);
657 };
658 for entry in tree.entries {
659 let name = String::from_utf8(entry.name).map_err(|_| IndexError::InvalidPathEncoding)?;
660 let path = if prefix.is_empty() {
661 name
662 } else {
663 format!("{prefix}/{name}")
664 };
665 match entry.mode {
666 EntryMode::Tree => {
667 push_tree_entries(store, entry.object_hash, &path, entries, depth + 1)?;
668 }
669 EntryMode::Blob | EntryMode::Executable | EntryMode::Symlink => {
670 if !validate_index_path(&path) {
671 return Err(IndexError::InvalidPath(path));
672 }
673 let status = match entry.mode {
674 EntryMode::Blob => EntryStatus::Blob,
675 EntryMode::Executable => EntryStatus::Executable,
676 EntryMode::Symlink => EntryStatus::Symlink,
677 EntryMode::Tree => unreachable!("handled above"),
678 };
679 entries.push(IndexEntry {
680 path,
681 status,
682 object_hash: entry.object_hash,
683 mtime_ns: 0,
686 size: 0,
687 ino: 0,
688 ctime_ns: 0,
689 });
690 }
691 }
692 }
693 Ok(())
694}
695
696#[must_use]
698pub fn index_path(layout: &RepoLayout) -> PathBuf {
699 layout.index_file()
700}
701
702#[must_use]
705pub fn validate_index_path(path: &str) -> bool {
706 if path.is_empty() {
707 return false;
708 }
709 if path.starts_with('/') {
710 return false;
711 }
712 if path.len() > MAX_PATH_LEN {
713 return false;
714 }
715 if path == ".mkit" || path == ".git" {
716 return false;
717 }
718 if path.starts_with(".mkit/") || path.starts_with(".git/") {
719 return false;
720 }
721 for part in path.split('/') {
722 if part.is_empty() {
723 return false;
724 }
725 if part == "." || part == ".." {
726 return false;
727 }
728 for &c in part.as_bytes() {
729 if c == 0 || c == b'\\' {
730 return false;
731 }
732 }
733 }
734 true
735}
736
737#[cfg(test)]
738mod tests {
739 use super::*;
740 use crate::hash;
741 use tempfile::TempDir;
742
743 fn seed_hash(s: &str) -> Hash {
744 hash::hash(s.as_bytes())
745 }
746
747 #[test]
748 fn empty_index_round_trip() {
749 let idx = Index::new();
750 let bytes = idx.serialize();
751 assert_eq!(bytes.len(), 41);
753 assert_eq!(&bytes[0..4], &MAGIC);
754 assert_eq!(bytes[4], FORMAT_VERSION);
755 assert_eq!(&bytes[5..9], &0u32.to_le_bytes());
756 let parsed = deserialize(&bytes).unwrap();
757 assert_eq!(parsed, idx);
758 }
759
760 #[test]
766 fn single_entry_pinned_bytes() {
767 let h = seed_hash("hello");
768 let idx = Index::from_entries(vec![IndexEntry {
769 path: "hello.txt".to_string(),
770 status: EntryStatus::Blob,
771 object_hash: h,
772 mtime_ns: 0x0102_0304_0506_0708,
773 size: 11,
774 ino: 0x0A0B_0C0D_0E0F_1011,
775 ctime_ns: 0x1112_1314_1516_1718,
776 }]);
777 let bytes = idx.serialize();
778 assert_eq!(bytes.len(), 117);
779 let mut expected = Vec::new();
780 expected.extend_from_slice(b"MKIX");
781 expected.push(0x03); expected.extend_from_slice(&1u32.to_le_bytes());
783 expected.push(0x01); expected.extend_from_slice(&h);
785 expected.extend_from_slice(&0x0102_0304_0506_0708u64.to_le_bytes());
786 expected.extend_from_slice(&11u64.to_le_bytes());
787 expected.extend_from_slice(&0x0A0B_0C0D_0E0F_1011u64.to_le_bytes());
788 expected.extend_from_slice(&0x1112_1314_1516_1718u64.to_le_bytes());
789 expected.extend_from_slice(&9u16.to_le_bytes());
790 expected.extend_from_slice(b"hello.txt");
791 expected.extend_from_slice(&hash::hash(&expected));
792 assert_eq!(bytes, expected, "byte layout is pinned");
793 assert_eq!(deserialize(&bytes).unwrap(), idx);
794 }
795
796 #[test]
797 fn rejects_count_overflow_at_min_entry_bytes() {
798 let mut bytes = Vec::new();
801 bytes.extend_from_slice(b"MKIX");
802 bytes.push(FORMAT_VERSION);
803 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
804 bytes.extend_from_slice(&hash::hash(&bytes));
805 assert!(matches!(deserialize(&bytes), Err(IndexError::Corrupt)));
806 let mut short = Vec::new();
808 short.extend_from_slice(b"MKIX");
809 short.push(FORMAT_VERSION);
810 short.extend_from_slice(&1u32.to_le_bytes());
811 short.extend_from_slice(&[0u8; 60]);
812 short.extend_from_slice(&hash::hash(&short));
813 assert!(matches!(deserialize(&short), Err(IndexError::Corrupt)));
814 }
815
816 #[test]
817 fn rejects_unknown_version_0x04() {
818 let mut bytes = Vec::new();
819 bytes.extend_from_slice(b"MKIX");
820 bytes.push(0x04);
821 bytes.extend_from_slice(&0u32.to_le_bytes());
822 assert!(matches!(
823 deserialize(&bytes),
824 Err(IndexError::UnsupportedVersion(0x04))
825 ));
826 }
827
828 #[test]
829 fn rejects_obsolete_versions_without_rewriting_staging() {
830 let dir = TempDir::new().unwrap();
831 let layout = RepoLayout::single(dir.path());
832 fs::create_dir_all(layout.worktree_state_dir()).unwrap();
833 for version in [1, 2] {
834 let mut bytes = Index::new().serialize();
835 bytes[4] = version;
836 fs::write(layout.index_file(), &bytes).unwrap();
837 assert!(
838 matches!(read_index(&layout), Err(IndexError::UnsupportedVersion(v)) if v == version)
839 );
840 assert_eq!(fs::read(layout.index_file()).unwrap(), bytes);
841 }
842 }
843
844 #[test]
849 fn read_index_invalidates_racy_entries() {
850 let dir = TempDir::new().unwrap();
851 let layout = RepoLayout::single(dir.path());
852 let now_ns = u64::try_from(
853 std::time::SystemTime::now()
854 .duration_since(std::time::UNIX_EPOCH)
855 .unwrap()
856 .as_nanos(),
857 )
858 .unwrap();
859 let idx = Index::from_entries(vec![
860 IndexEntry {
861 path: "racy.txt".to_string(),
862 status: EntryStatus::Blob,
863 object_hash: seed_hash("racy"),
864 mtime_ns: now_ns,
865 size: 4,
866 ino: 0,
867 ctime_ns: 0,
868 },
869 IndexEntry {
870 path: "settled.txt".to_string(),
871 status: EntryStatus::Blob,
872 object_hash: seed_hash("settled"),
873 mtime_ns: now_ns - 10_000_000_000, size: 7,
875 ino: 0,
876 ctime_ns: 0,
877 },
878 ]);
879 write_index(&layout, &idx).unwrap();
880 let f = fs::File::options()
885 .write(true)
886 .open(index_path(&layout))
887 .unwrap();
888 f.set_times(
889 fs::FileTimes::new()
890 .set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_nanos(now_ns)),
891 )
892 .unwrap();
893 drop(f);
894 let read = read_index(&layout).unwrap();
895 let racy = &read.entries[read.find_entry("racy.txt").unwrap()];
896 let settled = &read.entries[read.find_entry("settled.txt").unwrap()];
897 assert_eq!(
898 racy.mtime_ns, 0,
899 "an entry touched within the racy window must lose its cache"
900 );
901 assert_eq!(racy.size, 0);
902 assert_eq!(settled.mtime_ns, now_ns - 10_000_000_000);
903 assert_eq!(settled.size, 7);
904 }
905
906 #[test]
911 fn coarse_entry_mtime_keeps_one_second_window() {
912 let dir = TempDir::new().unwrap();
913 let layout = RepoLayout::single(dir.path());
914 let base_ns: u64 = 1_700_000_000_000_000_000; let idx = Index::from_entries(vec![
916 IndexEntry {
917 path: "coarse.txt".to_string(),
918 status: EntryStatus::Blob,
919 object_hash: seed_hash("coarse"),
920 mtime_ns: base_ns - 1_000_000_000,
923 size: 4,
924 ino: 0,
925 ctime_ns: 0,
926 },
927 IndexEntry {
928 path: "precise.txt".to_string(),
929 status: EntryStatus::Blob,
930 object_hash: seed_hash("precise"),
931 mtime_ns: base_ns - 1_000_000_000 + 123,
934 size: 7,
935 ino: 0,
936 ctime_ns: 0,
937 },
938 ]);
939 write_index(&layout, &idx).unwrap();
940 let f = fs::File::options()
942 .write(true)
943 .open(index_path(&layout))
944 .unwrap();
945 f.set_times(fs::FileTimes::new().set_modified(
946 std::time::UNIX_EPOCH + std::time::Duration::from_nanos(base_ns - 500_000_000 + 777),
947 ))
948 .unwrap();
949 drop(f);
950
951 let read = read_index(&layout).unwrap();
952 let coarse = &read.entries[read.find_entry("coarse.txt").unwrap()];
953 let precise = &read.entries[read.find_entry("precise.txt").unwrap()];
954 assert_eq!(
955 coarse.mtime_ns, 0,
956 "coarse-mtime entry within 1s of the index write must be racy"
957 );
958 assert_ne!(
959 precise.mtime_ns, 0,
960 "ns-precise entry outside the 10ms window keeps its cache"
961 );
962 }
963
964 #[test]
965 fn tracks_path_or_descendant_matches_self_and_ancestors() {
966 let mut idx = Index::new();
967 idx.upsert_entry(IndexEntry {
968 path: "src/lib.rs".to_string(),
969 status: EntryStatus::Blob,
970 object_hash: seed_hash("lib"),
971 mtime_ns: 0,
972 size: 0,
973 ino: 0,
974 ctime_ns: 0,
975 });
976 idx.upsert_entry(IndexEntry {
977 path: "removed.txt".to_string(),
978 status: EntryStatus::Removed,
979 object_hash: hash::ZERO,
980 mtime_ns: 0,
981 size: 0,
982 ino: 0,
983 ctime_ns: 0,
984 });
985 assert!(idx.tracks_path_or_descendant("src/lib.rs"));
987 assert!(idx.tracks_path_or_descendant("src"));
988 assert!(!idx.tracks_path_or_descendant("sr"));
990 assert!(!idx.tracks_path_or_descendant("docs"));
992 assert!(!idx.tracks_path_or_descendant("removed.txt"));
993 }
994
995 #[test]
996 fn has_tracked_file_at_exact_only_and_not_removed() {
997 let mut idx = Index::new();
998 idx.upsert_entry(IndexEntry {
999 path: "f".to_string(),
1000 status: EntryStatus::Blob,
1001 object_hash: seed_hash("f"),
1002 mtime_ns: 0,
1003 size: 0,
1004 ino: 0,
1005 ctime_ns: 0,
1006 });
1007 idx.upsert_entry(IndexEntry {
1008 path: "gone".to_string(),
1009 status: EntryStatus::Removed,
1010 object_hash: hash::ZERO,
1011 mtime_ns: 0,
1012 size: 0,
1013 ino: 0,
1014 ctime_ns: 0,
1015 });
1016 assert!(idx.has_tracked_file_at("f"));
1018 idx.upsert_entry(IndexEntry {
1021 path: "dir/inner.txt".to_string(),
1022 status: EntryStatus::Blob,
1023 object_hash: seed_hash("inner"),
1024 mtime_ns: 0,
1025 size: 0,
1026 ino: 0,
1027 ctime_ns: 0,
1028 });
1029 assert!(!idx.has_tracked_file_at("dir"));
1030 assert!(idx.has_tracked_file_at("dir/inner.txt"));
1031 assert!(!idx.has_tracked_file_at("gone"));
1034 assert!(!idx.has_tracked_file_at("other"));
1036 }
1037
1038 #[test]
1039 fn multi_entry_round_trip_with_all_statuses() {
1040 let mut idx = Index::new();
1041 idx.entries.push(IndexEntry {
1042 path: "a.txt".into(),
1043 status: EntryStatus::Blob,
1044 object_hash: seed_hash("a"),
1045 mtime_ns: 0,
1046 size: 0,
1047 ino: 0,
1048 ctime_ns: 0,
1049 });
1050 idx.entries.push(IndexEntry {
1051 path: "b/sub".into(),
1052 status: EntryStatus::Tree,
1053 object_hash: seed_hash("b"),
1054 mtime_ns: 0,
1055 size: 0,
1056 ino: 0,
1057 ctime_ns: 0,
1058 });
1059 idx.entries.push(IndexEntry {
1060 path: "c.link".into(),
1061 status: EntryStatus::Symlink,
1062 object_hash: seed_hash("c"),
1063 mtime_ns: 0,
1064 size: 0,
1065 ino: 0,
1066 ctime_ns: 0,
1067 });
1068 idx.entries.push(IndexEntry {
1069 path: "scripts/build".into(),
1070 status: EntryStatus::Executable,
1071 object_hash: seed_hash("d"),
1072 mtime_ns: 0,
1073 size: 0,
1074 ino: 0,
1075 ctime_ns: 0,
1076 });
1077 idx.entries.push(IndexEntry {
1078 path: "old.txt".into(),
1079 status: EntryStatus::Removed,
1080 object_hash: [0u8; HASH_LEN],
1081 mtime_ns: 0,
1082 size: 0,
1083 ino: 0,
1084 ctime_ns: 0,
1085 });
1086 let bytes = idx.serialize();
1087 let parsed = deserialize(&bytes).unwrap();
1088 assert_eq!(parsed, idx);
1089 }
1090
1091 #[test]
1092 fn checksum_rejects_a_bit_flip_in_an_otherwise_valid_staged_hash() {
1093 let idx = Index::from_entries(vec![IndexEntry {
1094 path: "staged".into(),
1095 status: EntryStatus::Blob,
1096 object_hash: seed_hash("A"),
1097 mtime_ns: 0,
1098 size: 0,
1099 ino: 0,
1100 ctime_ns: 0,
1101 }]);
1102 let mut bytes = idx.serialize();
1103 bytes[10] ^= 1;
1104 assert!(matches!(deserialize(&bytes), Err(IndexError::Corrupt)));
1105 }
1106
1107 #[test]
1108 fn rejects_bad_magic() {
1109 let mut bytes = Index::new().serialize();
1110 bytes[0] = b'X';
1111 let err = deserialize(&bytes).unwrap_err();
1112 assert!(matches!(err, IndexError::BadMagic));
1113 }
1114
1115 #[test]
1116 fn rejects_zmix_magic_explicitly() {
1117 let bytes = [
1121 0x5A,
1122 0x4D,
1123 0x49,
1124 0x58, FORMAT_VERSION,
1126 0,
1127 0,
1128 0,
1129 0,
1130 ];
1131 let err = deserialize(&bytes).unwrap_err();
1132 assert!(matches!(err, IndexError::BadMagic));
1133 }
1134
1135 #[test]
1136 fn rejects_unsupported_version() {
1137 let mut bytes = Index::new().serialize();
1138 bytes[4] = 0xFF;
1139 let err = deserialize(&bytes).unwrap_err();
1140 assert!(matches!(err, IndexError::UnsupportedVersion(0xFF)));
1141 }
1142
1143 #[test]
1144 fn rejects_truncated_header() {
1145 let err = deserialize(b"MKIX").unwrap_err();
1146 assert!(matches!(err, IndexError::Corrupt));
1147 }
1148
1149 #[test]
1150 fn rejects_truncated_entry() {
1151 let mut idx = Index::new();
1152 idx.entries.push(IndexEntry {
1153 path: "a".into(),
1154 status: EntryStatus::Blob,
1155 object_hash: seed_hash("a"),
1156 mtime_ns: 0,
1157 size: 0,
1158 ino: 0,
1159 ctime_ns: 0,
1160 });
1161 let mut bytes = idx.serialize();
1162 bytes.truncate(bytes.len() - 1); let err = deserialize(&bytes).unwrap_err();
1164 assert!(matches!(err, IndexError::Corrupt));
1165 }
1166
1167 #[test]
1168 fn rejects_trailing_bytes_after_declared_entries() {
1169 let mut idx = Index::new();
1170 idx.entries.push(IndexEntry {
1171 path: "a".into(),
1172 status: EntryStatus::Blob,
1173 object_hash: seed_hash("a"),
1174 mtime_ns: 0,
1175 size: 0,
1176 ino: 0,
1177 ctime_ns: 0,
1178 });
1179 let mut bytes = idx.serialize();
1180 bytes.extend_from_slice(b"junk");
1181 let err = deserialize(&bytes).unwrap_err();
1182 assert!(matches!(err, IndexError::Corrupt));
1183 }
1184
1185 #[test]
1186 fn rejects_invalid_path_on_deserialize() {
1187 let mut bytes = Vec::new();
1188 bytes.extend_from_slice(&MAGIC);
1189 bytes.push(FORMAT_VERSION);
1190 bytes.extend_from_slice(&1u32.to_le_bytes());
1191 bytes.push(EntryStatus::Blob as u8);
1192 bytes.extend_from_slice(&[0u8; HASH_LEN]);
1193 bytes.extend_from_slice(&[0u8; 32]); let path = b"../escape";
1195 let path_len = u16::try_from(path.len()).unwrap();
1196 bytes.extend_from_slice(&path_len.to_le_bytes());
1197 bytes.extend_from_slice(path);
1198 bytes.extend_from_slice(&hash::hash(&bytes));
1199 let err = deserialize(&bytes).unwrap_err();
1200 assert!(matches!(err, IndexError::InvalidPath(path) if path == "../escape"));
1201 }
1202
1203 #[test]
1204 fn rejects_duplicate_paths_on_deserialize() {
1205 let mut idx = Index::new();
1206 idx.entries.push(IndexEntry {
1207 path: "same.txt".into(),
1208 status: EntryStatus::Blob,
1209 object_hash: seed_hash("a"),
1210 mtime_ns: 0,
1211 size: 0,
1212 ino: 0,
1213 ctime_ns: 0,
1214 });
1215 idx.entries.push(IndexEntry {
1216 path: "same.txt".into(),
1217 status: EntryStatus::Executable,
1218 object_hash: seed_hash("b"),
1219 mtime_ns: 0,
1220 size: 0,
1221 ino: 0,
1222 ctime_ns: 0,
1223 });
1224 let err = deserialize(&idx.serialize()).unwrap_err();
1225 assert!(matches!(err, IndexError::DuplicatePath(path) if path == "same.txt"));
1226 }
1227
1228 #[test]
1229 fn rejects_path_len_overflow() {
1230 let mut bytes = Vec::new();
1232 bytes.extend_from_slice(&MAGIC);
1233 bytes.push(FORMAT_VERSION);
1234 bytes.extend_from_slice(&1u32.to_le_bytes());
1235 bytes.push(EntryStatus::Blob as u8);
1236 bytes.extend_from_slice(&[0u8; HASH_LEN]);
1237 bytes.extend_from_slice(&1000u16.to_le_bytes());
1238 bytes.push(b'a');
1239 let err = deserialize(&bytes).unwrap_err();
1240 assert!(matches!(err, IndexError::Corrupt));
1241 }
1242
1243 #[test]
1244 fn rejects_unknown_status_byte() {
1245 let mut bytes = Vec::new();
1246 bytes.extend_from_slice(&MAGIC);
1247 bytes.push(FORMAT_VERSION);
1248 bytes.extend_from_slice(&1u32.to_le_bytes());
1249 bytes.push(0x77); bytes.extend_from_slice(&[0u8; HASH_LEN]);
1251 bytes.extend_from_slice(&[0u8; 32]); bytes.extend_from_slice(&0u16.to_le_bytes());
1253 bytes.extend_from_slice(&hash::hash(&bytes));
1254 let err = deserialize(&bytes).unwrap_err();
1255 assert!(matches!(err, IndexError::BadStatus(0x77)));
1256 }
1257
1258 #[test]
1259 fn rejects_removed_entry_with_nonzero_hash() {
1260 let mut bytes = Vec::new();
1263 bytes.extend_from_slice(&MAGIC);
1264 bytes.push(FORMAT_VERSION);
1265 bytes.extend_from_slice(&1u32.to_le_bytes());
1266 bytes.push(EntryStatus::Removed as u8);
1267 bytes.extend_from_slice(&seed_hash("nonzero")); bytes.extend_from_slice(&[0u8; 32]); let path = b"removed.txt";
1270 bytes.extend_from_slice(&11u16.to_le_bytes());
1271 bytes.extend_from_slice(path);
1272 bytes.extend_from_slice(&hash::hash(&bytes));
1273 let err = deserialize(&bytes).unwrap_err();
1274 assert!(matches!(err, IndexError::RemovedHasHash(p) if p == "removed.txt"));
1275 }
1276
1277 #[test]
1278 fn removed_entry_with_zero_hash_round_trips() {
1279 let mut idx = Index::new();
1280 idx.entries.push(IndexEntry {
1281 path: "gone.txt".into(),
1282 status: EntryStatus::Removed,
1283 object_hash: hash::ZERO,
1284 mtime_ns: 0,
1285 size: 0,
1286 ino: 0,
1287 ctime_ns: 0,
1288 });
1289 let round_tripped = deserialize(&idx.serialize()).unwrap();
1290 assert_eq!(round_tripped.entries[0].status, EntryStatus::Removed);
1291 assert_eq!(round_tripped.entries[0].object_hash, hash::ZERO);
1292 }
1293
1294 #[test]
1295 fn write_and_read_round_trip_via_disk() {
1296 let dir = TempDir::new().unwrap();
1297 fs::create_dir_all(dir.path().join(".mkit")).unwrap();
1298 let mut idx = Index::new();
1299 idx.entries.push(IndexEntry {
1300 path: "test.txt".into(),
1301 status: EntryStatus::Blob,
1302 object_hash: seed_hash("c"),
1303 mtime_ns: 0,
1304 size: 0,
1305 ino: 0,
1306 ctime_ns: 0,
1307 });
1308 let layout = RepoLayout::single(dir.path());
1309 write_index(&layout, &idx).unwrap();
1310 let read = read_index(&layout).unwrap();
1311 assert_eq!(read, idx);
1312 }
1313
1314 #[test]
1315 fn read_missing_file_returns_empty_index() {
1316 let dir = TempDir::new().unwrap();
1317 let idx = read_index(&RepoLayout::single(dir.path())).unwrap();
1318 assert!(idx.entries.is_empty());
1319 }
1320
1321 #[test]
1322 fn read_zero_length_file_rejects_corruption() {
1323 let dir = TempDir::new().unwrap();
1324 fs::create_dir_all(dir.path().join(".mkit")).unwrap();
1325 fs::write(dir.path().join(INDEX_FILE), b"").unwrap();
1326 assert!(matches!(
1327 read_index(&RepoLayout::single(dir.path())),
1328 Err(IndexError::Corrupt)
1329 ));
1330 }
1331
1332 #[test]
1333 fn read_oversize_file_rejected() {
1334 let dir = TempDir::new().unwrap();
1335 fs::create_dir_all(dir.path().join(".mkit")).unwrap();
1336 let path = dir.path().join(INDEX_FILE);
1337 let f = fs::OpenOptions::new()
1339 .write(true)
1340 .create(true)
1341 .truncate(true)
1342 .open(&path)
1343 .unwrap();
1344 f.set_len(MAX_INDEX_BYTES + 1).unwrap();
1345 drop(f);
1346 let err = read_index(&RepoLayout::single(dir.path())).unwrap_err();
1347 assert!(matches!(err, IndexError::TooLarge));
1348 }
1349
1350 #[test]
1351 fn staged_count_excludes_removed() {
1352 let mut idx = Index::new();
1353 idx.entries.push(IndexEntry {
1354 path: "a".into(),
1355 status: EntryStatus::Blob,
1356 object_hash: seed_hash("a"),
1357 mtime_ns: 0,
1358 size: 0,
1359 ino: 0,
1360 ctime_ns: 0,
1361 });
1362 idx.entries.push(IndexEntry {
1363 path: "b".into(),
1364 status: EntryStatus::Removed,
1365 object_hash: [0u8; HASH_LEN],
1366 mtime_ns: 0,
1367 size: 0,
1368 ino: 0,
1369 ctime_ns: 0,
1370 });
1371 idx.entries.push(IndexEntry {
1372 path: "c".into(),
1373 status: EntryStatus::Blob,
1374 object_hash: seed_hash("c"),
1375 mtime_ns: 0,
1376 size: 0,
1377 ino: 0,
1378 ctime_ns: 0,
1379 });
1380 assert_eq!(idx.staged_count(), 2);
1381 }
1382
1383 #[test]
1384 fn rejects_bogus_huge_count_before_loop() {
1385 let mut bytes = Vec::new();
1390 bytes.extend_from_slice(&MAGIC);
1391 bytes.push(FORMAT_VERSION);
1392 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
1393 let err = deserialize(&bytes).unwrap_err();
1395 assert!(matches!(err, IndexError::Corrupt));
1396 }
1397
1398 #[test]
1399 fn validate_path_basic() {
1400 assert!(validate_index_path("a.txt"));
1401 assert!(validate_index_path("src/main.rs"));
1402 assert!(validate_index_path(".mkitignore"));
1403 assert!(!validate_index_path(""));
1404 assert!(!validate_index_path("/abs"));
1405 assert!(!validate_index_path("../escape"));
1406 assert!(!validate_index_path("a/../b"));
1407 assert!(!validate_index_path(".mkit"));
1408 assert!(!validate_index_path(".git"));
1409 assert!(!validate_index_path(".mkit/objects"));
1410 assert!(!validate_index_path(".git/HEAD"));
1411 assert!(!validate_index_path("a\\b"));
1412 assert!(!validate_index_path("a//b"));
1413 }
1414
1415 #[test]
1416 fn from_tree_flattens_tree_entries() {
1417 use crate::object::{Blob, EntryMode, Object, Tree, TreeEntry};
1418 use crate::serialize;
1419 use crate::store::ObjectStore;
1420
1421 fn put(store: &ObjectStore, obj: &Object) -> Hash {
1422 let bytes = serialize::serialize(obj).unwrap();
1423 store.write(&bytes).unwrap()
1424 }
1425
1426 let dir = TempDir::new().unwrap();
1427 let store = ObjectStore::init(&RepoLayout::single(dir.path())).unwrap();
1428 let file = put(
1429 &store,
1430 &Object::Blob(Blob {
1431 data: b"file".to_vec(),
1432 }),
1433 );
1434 let exec = put(
1435 &store,
1436 &Object::Blob(Blob {
1437 data: b"exec".to_vec(),
1438 }),
1439 );
1440 let link = put(
1441 &store,
1442 &Object::Blob(Blob {
1443 data: b"target".to_vec(),
1444 }),
1445 );
1446 let sub = put(
1447 &store,
1448 &Object::Tree(Tree {
1449 entries: vec![TreeEntry {
1450 name: b"run".to_vec(),
1451 mode: EntryMode::Executable,
1452 object_hash: exec,
1453 }],
1454 }),
1455 );
1456 let root = put(
1457 &store,
1458 &Object::Tree(Tree {
1459 entries: vec![
1460 TreeEntry {
1461 name: b"file.txt".to_vec(),
1462 mode: EntryMode::Blob,
1463 object_hash: file,
1464 },
1465 TreeEntry {
1466 name: b"link".to_vec(),
1467 mode: EntryMode::Symlink,
1468 object_hash: link,
1469 },
1470 TreeEntry {
1471 name: b"sub".to_vec(),
1472 mode: EntryMode::Tree,
1473 object_hash: sub,
1474 },
1475 ],
1476 }),
1477 );
1478
1479 let idx = from_tree(&store, root).unwrap();
1480 assert_eq!(idx.entries.len(), 3);
1481 assert_eq!(idx.entries[0].path, "file.txt");
1482 assert_eq!(idx.entries[0].status, EntryStatus::Blob);
1483 assert_eq!(idx.entries[1].path, "link");
1484 assert_eq!(idx.entries[1].status, EntryStatus::Symlink);
1485 assert_eq!(idx.entries[2].path, "sub/run");
1486 assert_eq!(idx.entries[2].status, EntryStatus::Executable);
1487 }
1488
1489 #[test]
1490 fn from_tree_round_trips_through_worktree_builder() {
1491 use crate::object::{Blob, EntryMode, Object, Tree, TreeEntry};
1492 use crate::serialize;
1493 use crate::store::ObjectStore;
1494
1495 fn put(store: &ObjectStore, obj: &Object) -> Hash {
1496 let bytes = serialize::serialize(obj).unwrap();
1497 store.write(&bytes).unwrap()
1498 }
1499
1500 let dir = TempDir::new().unwrap();
1501 let store = ObjectStore::init(&RepoLayout::single(dir.path())).unwrap();
1502 let blob = put(
1503 &store,
1504 &Object::Blob(Blob {
1505 data: b"content".to_vec(),
1506 }),
1507 );
1508 let tree = put(
1509 &store,
1510 &Object::Tree(Tree {
1511 entries: vec![TreeEntry {
1512 name: b"a.txt".to_vec(),
1513 mode: EntryMode::Blob,
1514 object_hash: blob,
1515 }],
1516 }),
1517 );
1518
1519 let idx = from_tree(&store, tree).unwrap();
1520 let rebuilt = crate::worktree::build_tree_from_index(&store, &idx).unwrap();
1521 assert_eq!(rebuilt, tree);
1522 }
1523
1524 fn blob_entry(path: &str) -> IndexEntry {
1527 IndexEntry {
1528 path: path.to_string(),
1529 status: EntryStatus::Blob,
1530 object_hash: seed_hash(path),
1531 mtime_ns: 0,
1532 size: 0,
1533 ino: 0,
1534 ctime_ns: 0,
1535 }
1536 }
1537
1538 #[test]
1543 fn upsert_entry_inserts_new_path() {
1544 let mut idx = Index::new();
1545 idx.upsert_entry(blob_entry("a.txt"));
1546 idx.upsert_entry(blob_entry("b.txt"));
1547 assert_eq!(idx.entries.len(), 2);
1548 assert_eq!(idx.find_entry("a.txt"), Some(0));
1549 assert_eq!(idx.find_entry("b.txt"), Some(1));
1550 assert_eq!(idx.find_entry("missing"), None);
1551 }
1552
1553 #[test]
1556 fn upsert_entry_replaces_existing_path() {
1557 let mut idx = Index::new();
1558 idx.upsert_entry(blob_entry("a.txt"));
1559 idx.upsert_entry(blob_entry("b.txt"));
1560 let mut replacement = blob_entry("a.txt");
1561 replacement.status = EntryStatus::Executable;
1562 idx.upsert_entry(replacement);
1563 assert_eq!(idx.entries.len(), 2, "replace must not grow the index");
1564 let pos = idx.find_entry("a.txt").unwrap();
1565 assert_eq!(idx.entries[pos].status, EntryStatus::Executable);
1566 assert_eq!(idx.find_entry("b.txt"), Some(1));
1568 }
1569
1570 #[test]
1573 fn remove_path_updates_positions_of_later_entries() {
1574 let mut idx = Index::new();
1575 idx.upsert_entry(blob_entry("a.txt"));
1576 idx.upsert_entry(blob_entry("b.txt"));
1577 idx.upsert_entry(blob_entry("c.txt"));
1578 let removed = idx.remove_path("a.txt").expect("a.txt was tracked");
1579 assert_eq!(removed.path, "a.txt");
1580 assert_eq!(idx.entries.len(), 2);
1581 assert_eq!(idx.find_entry("a.txt"), None);
1582 assert_eq!(idx.entries[idx.find_entry("b.txt").unwrap()].path, "b.txt");
1583 assert_eq!(idx.entries[idx.find_entry("c.txt").unwrap()].path, "c.txt");
1584 assert!(idx.remove_path("a.txt").is_none());
1586 }
1587
1588 #[test]
1591 fn retain_entries_rebuilds_positions() {
1592 let mut idx = Index::new();
1593 for p in ["a.txt", "b.txt", "c.txt", "d.txt"] {
1594 idx.upsert_entry(blob_entry(p));
1595 }
1596 idx.retain_entries(|e| e.path != "b.txt" && e.path != "c.txt");
1597 assert_eq!(idx.entries.len(), 2);
1598 assert_eq!(idx.entries[idx.find_entry("a.txt").unwrap()].path, "a.txt");
1599 assert_eq!(idx.entries[idx.find_entry("d.txt").unwrap()].path, "d.txt");
1600 assert_eq!(idx.find_entry("b.txt"), None);
1601 assert_eq!(idx.find_entry("c.txt"), None);
1602 }
1603
1604 #[test]
1607 fn remove_directory_conflicts_is_noop_without_conflict() {
1608 let mut idx = Index::new();
1609 idx.upsert_entry(blob_entry("src/lib.rs"));
1610 idx.remove_directory_conflicts("src/main.rs");
1611 assert_eq!(idx.entries.len(), 1);
1612 assert!(idx.find_entry("src/lib.rs").is_some());
1613 }
1614
1615 #[test]
1618 fn remove_directory_conflicts_evicts_tracked_ancestor() {
1619 let mut idx = Index::new();
1620 idx.upsert_entry(blob_entry("a"));
1621 idx.remove_directory_conflicts("a/b");
1622 assert_eq!(idx.find_entry("a"), None);
1623 assert_eq!(idx.entries.len(), 0);
1624 }
1625
1626 #[test]
1629 fn remove_directory_conflicts_evicts_tracked_descendant() {
1630 let mut idx = Index::new();
1631 idx.upsert_entry(blob_entry("a/b"));
1632 idx.upsert_entry(blob_entry("a/c"));
1633 idx.upsert_entry(blob_entry("unrelated.txt"));
1634 idx.remove_directory_conflicts("a");
1635 assert_eq!(idx.find_entry("a/b"), None);
1636 assert_eq!(idx.find_entry("a/c"), None);
1637 assert!(idx.find_entry("unrelated.txt").is_some());
1638 assert_eq!(idx.entries.len(), 1);
1639 }
1640
1641 #[test]
1644 fn remove_directory_conflicts_keeps_exact_match() {
1645 let mut idx = Index::new();
1646 idx.upsert_entry(blob_entry("a.txt"));
1647 idx.remove_directory_conflicts("a.txt");
1648 assert!(idx.find_entry("a.txt").is_some());
1649 }
1650
1651 #[test]
1655 fn deserialize_populates_path_index() {
1656 let mut built = Index::new();
1657 built.upsert_entry(blob_entry("a.txt"));
1658 built.upsert_entry(blob_entry("dir/b.txt"));
1659 let round_tripped = deserialize(&built.serialize()).unwrap();
1660 assert_eq!(round_tripped.find_entry("a.txt"), Some(0));
1661 assert_eq!(round_tripped.find_entry("dir/b.txt"), Some(1));
1662 assert!(round_tripped.tracks_path_or_descendant("dir"));
1663 }
1664
1665 #[test]
1669 fn equality_ignores_path_index_population() {
1670 let mut via_field_push = Index::new();
1671 via_field_push.entries.push(blob_entry("a.txt"));
1672 let mut via_upsert = Index::new();
1673 via_upsert.upsert_entry(blob_entry("a.txt"));
1674 assert_eq!(via_field_push, via_upsert);
1675 }
1676}