1use crate::config::ConfigSet;
4use crate::error::{Error, Result};
5use crate::ewah_bitmap::EwahBitmap;
6use crate::git_date::approx::approxidate_careful;
7use crate::git_path::CanonicalizeExt;
8use crate::index::{Index, IndexEntry};
9use crate::objects::{HashAlgo, ObjectId};
10use std::fs;
11use std::io;
12use std::path::{Path, PathBuf};
13
14#[derive(Debug, Clone)]
16pub(crate) struct SplitIndexLink {
17 pub base_oid: ObjectId,
19 pub delete_bitmap: Option<EwahBitmap>,
20 pub replace_bitmap: Option<EwahBitmap>,
21}
22
23fn parse_shared_repository_perm(raw: Option<&str>) -> i32 {
24 const PERM_UMASK: i32 = 0;
25 const OLD_PERM_GROUP: i32 = 1;
26 const OLD_PERM_EVERYBODY: i32 = 2;
27 const PERM_GROUP: i32 = 0o660;
28 const PERM_EVERYBODY: i32 = 0o664;
29
30 let Some(value) = raw.map(str::trim).filter(|s| !s.is_empty()) else {
31 return PERM_UMASK;
32 };
33 if value.eq_ignore_ascii_case("umask") {
34 return PERM_UMASK;
35 }
36 if value.eq_ignore_ascii_case("group") {
37 return PERM_GROUP;
38 }
39 if value.eq_ignore_ascii_case("all")
40 || value.eq_ignore_ascii_case("world")
41 || value.eq_ignore_ascii_case("everybody")
42 {
43 return PERM_EVERYBODY;
44 }
45 if !value.is_empty() && value.chars().all(|c| ('0'..='7').contains(&c)) {
46 if let Ok(i) = i32::from_str_radix(value, 8) {
47 return match i {
48 PERM_UMASK => PERM_UMASK,
49 OLD_PERM_GROUP => PERM_GROUP,
50 OLD_PERM_EVERYBODY => PERM_EVERYBODY,
51 _ => {
52 if (i & 0o600) != 0o600 {
53 return PERM_UMASK;
54 }
55 -(i & 0o666)
56 }
57 };
58 }
59 }
60 if value.eq_ignore_ascii_case("true") {
61 PERM_GROUP
62 } else {
63 PERM_UMASK
64 }
65}
66
67#[cfg(unix)]
68fn calc_shared_perm(shared_repo: i32, mode: u32) -> u32 {
69 let tweak = if shared_repo < 0 {
70 (-shared_repo) as u32
71 } else {
72 shared_repo as u32
73 };
74
75 let mut new_mode = if shared_repo < 0 {
76 (mode & !0o777) | tweak
77 } else {
78 mode | tweak
79 };
80
81 if mode & 0o200 == 0 {
82 new_mode &= !0o222;
83 }
84 if mode & 0o100 != 0 {
85 new_mode |= (new_mode & 0o444) >> 2;
86 }
87
88 new_mode
89}
90
91#[cfg(unix)]
92fn adjust_shared_perm_file(path: &Path, shared_repo: i32) -> io::Result<()> {
93 if shared_repo == 0 {
94 return Ok(());
95 }
96 use std::os::unix::fs::PermissionsExt;
97 let meta = fs::metadata(path)?;
98 let old = meta.permissions().mode();
99 let new_mode = calc_shared_perm(shared_repo, old);
100 if (old ^ new_mode) & 0o777 != 0 {
101 let mut p = meta.permissions();
102 p.set_mode(new_mode & 0o777);
103 fs::set_permissions(path, p)?;
104 }
105 Ok(())
106}
107
108#[cfg(not(unix))]
109fn adjust_shared_perm_file(_path: &Path, _shared_repo: i32) -> io::Result<()> {
110 Ok(())
111}
112
113fn adjust_shared_perm_written_index(
118 index_path: &Path,
119 staged_lock_path: Option<&Path>,
120 shared_repo: i32,
121) -> Result<()> {
122 let target = staged_lock_path.unwrap_or(index_path);
123 adjust_shared_perm_file(target, shared_repo).map_err(Error::Io)
124}
125
126fn staged_lock_path_buf_for_perm(
127 existing_lock: Option<&crate::index::IndexLock>,
128 commit_lock: bool,
129) -> Option<PathBuf> {
130 if commit_lock {
131 None
132 } else {
133 existing_lock.map(|lock| lock.lock_path.clone())
134 }
135}
136
137pub(crate) fn entries_equal_for_split(a: &IndexEntry, b: &IndexEntry) -> bool {
139 let mask: u16 = 0xF000 | 0x8000;
140 let a_flags = a.flags & mask;
141 let b_flags = b.flags & mask;
142 let ext_mask: u16 = 0x7000;
143 let a_ext = a.flags_extended.unwrap_or(0) & ext_mask;
144 let b_ext = b.flags_extended.unwrap_or(0) & ext_mask;
145 a.ctime_sec == b.ctime_sec
146 && a.ctime_nsec == b.ctime_nsec
147 && a.mtime_sec == b.mtime_sec
148 && a.mtime_nsec == b.mtime_nsec
149 && a.dev == b.dev
150 && a.ino == b.ino
151 && a.mode == b.mode
152 && a.uid == b.uid
153 && a.gid == b.gid
154 && a.size == b.size
155 && a.oid == b.oid
156 && a_flags == b_flags
157 && a_ext == b_ext
158}
159
160fn replace_positions_in_order(link: &SplitIndexLink) -> Vec<usize> {
161 let Some(bm) = &link.replace_bitmap else {
162 return Vec::new();
163 };
164 if bm.bit_size == 0 {
165 return Vec::new();
166 }
167 let mut v = Vec::new();
168 bm.each_set_bit(|p| v.push(p));
169 v
170}
171
172fn bitmap_has_bit(bm: &EwahBitmap, i: usize) -> bool {
173 let mut found = false;
174 bm.each_set_bit(|pos| {
175 if pos == i {
176 found = true;
177 }
178 });
179 found
180}
181
182pub(crate) fn merge_split_into_index(
184 index: &mut Index,
185 link: SplitIndexLink,
186 base_entries: Vec<IndexEntry>,
187) -> Result<()> {
188 let saved = std::mem::take(&mut index.entries);
189 let replace_pos = replace_positions_in_order(&link);
190 let stubs: Vec<IndexEntry> = saved
191 .iter()
192 .filter(|e| e.path.is_empty())
193 .cloned()
194 .collect();
195 if stubs.len() != replace_pos.len() {
196 return Err(Error::IndexError(format!(
197 "split index: expected {} replacement stubs, found {}",
198 replace_pos.len(),
199 stubs.len()
200 )));
201 }
202 let mut stub_iter = stubs.into_iter();
203 let rest: Vec<IndexEntry> = saved.into_iter().filter(|e| !e.path.is_empty()).collect();
204
205 let delete = &link.delete_bitmap;
206 let replace = &link.replace_bitmap;
207
208 let mut merged: Vec<IndexEntry> = Vec::new();
209
210 for (i, mut base_e) in base_entries.into_iter().enumerate() {
211 if delete
212 .as_ref()
213 .is_some_and(|b| b.bit_size > 0 && bitmap_has_bit(b, i))
214 {
215 continue;
216 }
217 if replace
218 .as_ref()
219 .is_some_and(|b| b.bit_size > 0 && bitmap_has_bit(b, i))
220 {
221 let Some(rep) = stub_iter.next() else {
222 return Err(Error::IndexError(
223 "split index: missing replacement entry".to_owned(),
224 ));
225 };
226 let mut e = rep;
227 e.path = base_e.path.clone();
228 e.base_index_pos = (i + 1) as u32;
229 merged.push(e);
230 } else {
231 base_e.base_index_pos = (i + 1) as u32;
232 merged.push(base_e);
233 }
234 }
235
236 if stub_iter.next().is_some() {
237 return Err(Error::IndexError(
238 "split index: too many replacement stubs".to_owned(),
239 ));
240 }
241
242 for mut e in rest {
243 e.base_index_pos = 0;
244 merged.push(e);
245 }
246
247 merged.sort_by(|a, b| a.path.cmp(&b.path).then_with(|| a.stage().cmp(&b.stage())));
248 index.entries = merged;
249 Ok(())
250}
251
252pub(crate) fn parse_link_extension(data: &[u8], algo: HashAlgo) -> Result<SplitIndexLink> {
254 let hash_len = algo.len();
255 if data.len() < hash_len {
256 return Err(Error::IndexError(
257 "corrupt link extension (too short)".to_owned(),
258 ));
259 }
260 let base_oid = ObjectId::from_bytes(&data[..hash_len])?;
261 let mut rest = &data[hash_len..];
262 if rest.is_empty() {
263 return Ok(SplitIndexLink {
264 base_oid,
265 delete_bitmap: None,
266 replace_bitmap: None,
267 });
268 }
269 let Some((del, consumed)) = EwahBitmap::deserialize_prefix(rest) else {
270 return Err(Error::IndexError(
271 "corrupt delete bitmap in link extension".to_owned(),
272 ));
273 };
274 rest = &rest[consumed..];
275 let Some((rep, consumed2)) = EwahBitmap::deserialize_prefix(rest) else {
276 return Err(Error::IndexError(
277 "corrupt replace bitmap in link extension".to_owned(),
278 ));
279 };
280 rest = &rest[consumed2..];
281 if !rest.is_empty() {
282 return Err(Error::IndexError(
283 "garbage at the end of link extension".to_owned(),
284 ));
285 }
286 Ok(SplitIndexLink {
287 base_oid,
288 delete_bitmap: Some(del),
289 replace_bitmap: Some(rep),
290 })
291}
292
293pub(crate) fn serialize_link_extension_payload(
295 base_oid: &ObjectId,
296 delete: &EwahBitmap,
297 replace: &EwahBitmap,
298) -> Vec<u8> {
299 let mut out = base_oid.as_bytes().to_vec();
300 delete.serialize(&mut out);
301 replace.serialize(&mut out);
302 out
303}
304
305fn resolve_shared_index_file(git_dir: &Path, index_path: &Path, base_oid: &ObjectId) -> PathBuf {
308 let name = format!("sharedindex.{}", base_oid.to_hex());
309 let primary = git_dir.join(&name);
310
311 let try_path = |p: PathBuf| -> Option<PathBuf> {
312 if p.is_file() {
313 Some(p)
314 } else {
315 None
316 }
317 };
318
319 if let Some(p) = try_path(primary.clone()) {
320 return p;
321 }
322 if let Some(parent) = index_path.parent() {
323 if let Some(p) = try_path(parent.join(&name)) {
324 return p;
325 }
326 }
327 {
328 let cwd = crate::environment::Environment::empty().cwd.clone();
329 let mut dir = cwd.as_path();
330 loop {
331 if let Some(p) = try_path(dir.join(".git").join(&name)) {
332 return p;
333 }
334 let Some(p) = dir.parent() else {
335 break;
336 };
337 dir = p;
338 }
339 }
340 if let Some(d) = index_path.parent() {
341 if let Ok(read) = fs::read_dir(d) {
342 for ent in read.flatten() {
343 let Ok(ft) = ent.file_type() else {
344 continue;
345 };
346 if !ft.is_dir() {
347 continue;
348 }
349 if let Some(p) = try_path(ent.path().join(".git").join(&name)) {
350 return p;
351 }
352 }
353 }
354 }
355 primary
356}
357
358fn read_index_trailer(path: &Path, algo: HashAlgo) -> Result<ObjectId> {
361 use std::io::{Read, Seek, SeekFrom};
362 let hash_len = algo.len();
363 let mut file = fs::File::open(path).map_err(Error::Io)?;
364 file.seek(SeekFrom::End(-(hash_len as i64)))
365 .map_err(Error::Io)?;
366 let mut trailer = vec![0u8; hash_len];
367 file.read_exact(&mut trailer).map_err(Error::Io)?;
368 ObjectId::from_bytes(&trailer)
369}
370
371pub(crate) fn hash_index_body(body: &[u8], algo: HashAlgo) -> ObjectId {
372 ObjectId::from_bytes(&algo.checksum_digest(body))
373 .unwrap_or_else(|_| unreachable!("checksum digest length matches object id"))
374}
375
376#[derive(Debug, Clone, Copy, PartialEq, Eq)]
377pub(crate) enum SplitIndexConfig {
378 Disabled,
379 Unset,
380 Enabled,
381}
382
383pub(crate) fn split_index_config(cfg: &ConfigSet) -> SplitIndexConfig {
384 match cfg.get("core.splitIndex") {
385 None => SplitIndexConfig::Unset,
386 Some(v) => {
387 let t = v.trim();
388 if t.eq_ignore_ascii_case("false") || t == "0" {
389 SplitIndexConfig::Disabled
390 } else if t.eq_ignore_ascii_case("true") || t == "1" {
391 SplitIndexConfig::Enabled
392 } else {
393 SplitIndexConfig::Unset
394 }
395 }
396 }
397}
398
399pub(crate) fn max_percent_split_change(cfg: &ConfigSet) -> i32 {
400 match cfg.get("splitIndex.maxPercentChange") {
401 None => -1,
402 Some(v) => v.trim().parse::<i32>().unwrap_or(-1),
403 }
404}
405
406fn default_max_percent() -> i32 {
407 20
408}
409
410pub(crate) fn should_rebuild_shared_index(index: &Index, cfg: &ConfigSet) -> bool {
411 let max_split = max_percent_split_change(cfg);
412 let max_split = match max_split {
413 -1 => default_max_percent(),
414 0 => return true,
415 100 => return false,
416 n => n,
417 };
418 let mut not_shared = 0u64;
419 for e in &index.entries {
420 if e.base_index_pos == 0 {
421 not_shared += 1;
422 }
423 }
424 let total = index.entries.len() as u64;
425 if total == 0 {
426 return false;
427 }
428 total * (max_split as u64) < not_shared * 100
429}
430
431pub(crate) fn git_test_split_index_env() -> bool {
432 crate::environment::Environment::empty()
433 .var("GIT_TEST_SPLIT_INDEX")
434 .map(|v| {
435 let t = v.trim();
436 t == "1" || t.eq_ignore_ascii_case("true") || t.eq_ignore_ascii_case("yes")
437 })
438 .unwrap_or(false)
439}
440
441pub(crate) fn git_test_check_cache_tree() -> bool {
451 match crate::environment::Environment::empty().var("GIT_TEST_CHECK_CACHE_TREE") {
452 Some(v) => {
453 let t = v.trim();
454 !(t.is_empty()
455 || t == "0"
456 || t.eq_ignore_ascii_case("false")
457 || t.eq_ignore_ascii_case("no"))
458 }
459 None => true,
460 }
461}
462
463pub(crate) fn git_test_split_index_force_reorder(base_oid: &ObjectId) -> bool {
464 git_test_split_index_env() && (base_oid.as_bytes()[0] & 15) < 6
465}
466
467pub(crate) fn shared_index_expire_threshold(cfg: &ConfigSet) -> u64 {
468 let raw = cfg
469 .get("splitIndex.sharedIndexExpire")
470 .map(|s| s.trim().to_owned());
471 let spec = raw
472 .as_deref()
473 .filter(|s| !s.is_empty())
474 .unwrap_or("2.weeks.ago");
475 if spec.eq_ignore_ascii_case("never") {
476 return 0;
477 }
478 let mut err = 0;
479 approxidate_careful(spec, Some(&mut err))
480}
481
482fn should_delete_shared_index(path: &Path, expiration: u64) -> bool {
483 if expiration == 0 {
484 return false;
485 }
486 let Ok(meta) = fs::metadata(path) else {
487 return false;
488 };
489 #[cfg(unix)]
490 {
491 use std::os::unix::fs::MetadataExt;
492 meta.mtime() as u64 <= expiration
493 }
494 #[cfg(not(unix))]
495 {
496 let _ = meta;
497 false
498 }
499}
500
501pub(crate) fn clean_stale_shared_index_files(git_dir: &Path, current_hex: &str, cfg: &ConfigSet) {
502 let expiration = shared_index_expire_threshold(cfg);
503 let Ok(read_dir) = fs::read_dir(git_dir) else {
504 return;
505 };
506 for ent in read_dir.flatten() {
507 let name = ent.file_name();
508 let Some(name) = name.to_str() else {
509 continue;
510 };
511 let Some(hex) = name.strip_prefix("sharedindex.") else {
512 continue;
513 };
514 if hex == current_hex {
515 continue;
516 }
517 let path = ent.path();
518 if should_delete_shared_index(&path, expiration) {
519 let _ = fs::remove_file(&path);
520 }
521 }
522}
523
524pub(crate) fn freshen_shared_index(path: &Path) {
525 let _ = filetime_set_to_now(path);
526}
527
528#[cfg(unix)]
529fn filetime_set_to_now(path: &Path) -> io::Result<()> {
530 use std::time::{Duration, UNIX_EPOCH};
531 let t = UNIX_EPOCH + Duration::from_secs(crate::git_date::tm::process_wall_clock_sec() as u64);
532 let ft = filetime::FileTime::from_system_time(t);
533 filetime::set_file_mtime(path, ft)
534}
535
536#[cfg(not(unix))]
537fn filetime_set_to_now(_path: &Path) -> io::Result<()> {
538 Ok(())
539}
540
541#[derive(Debug, Clone, Copy, Default)]
543pub struct WriteSplitIndexRequest {
544 pub explicit: Option<bool>,
546}
547
548impl WriteSplitIndexRequest {
549 pub fn want_write_split(self, cfg: &ConfigSet, index: &Index) -> bool {
557 match self.explicit {
558 Some(false) => {
559 let _ = matches!(split_index_config(cfg), SplitIndexConfig::Enabled);
560 false
561 }
562 Some(true) => {
563 let _ = matches!(split_index_config(cfg), SplitIndexConfig::Disabled);
564 true
565 }
566 None => {
567 if matches!(split_index_config(cfg), SplitIndexConfig::Disabled) {
568 return false;
569 }
570 index.split_link.is_some()
571 || matches!(split_index_config(cfg), SplitIndexConfig::Enabled)
572 || git_test_split_index_env()
573 }
574 }
575 }
576}
577
578fn find_entry_pos_sorted(entries: &[IndexEntry], path: &[u8], stage: u8) -> Option<usize> {
579 entries
580 .binary_search_by(|e| {
581 e.path
582 .as_slice()
583 .cmp(path)
584 .then_with(|| e.stage().cmp(&stage))
585 })
586 .ok()
587}
588
589fn load_shared_entries(
590 git_dir: &Path,
591 index_path: &Path,
592 base_oid: &ObjectId,
593) -> Result<Vec<IndexEntry>> {
594 let p = resolve_shared_index_file(git_dir, index_path, base_oid);
595 let data = fs::read(&p).map_err(Error::Io)?;
596 let mut shared = Index::parse(&data)?;
597 for (i, e) in shared.entries.iter_mut().enumerate() {
598 e.base_index_pos = (i + 1) as u32;
599 }
600 Ok(shared.entries)
601}
602
603#[allow(clippy::too_many_arguments)]
609fn write_index_through_lock(
610 index: &mut Index,
611 path: &Path,
612 skip_hash: bool,
613 cfg: &ConfigSet,
614 existing_lock: Option<&mut crate::index::IndexLock>,
615 commit_lock: bool,
616 thread_policy: crate::parallel::ThreadPolicy,
617) -> Result<()> {
618 index.write_to_path_with_config_and_lock_mode_policy(
619 path,
620 skip_hash,
621 Some(cfg),
622 existing_lock,
623 commit_lock,
624 thread_policy,
625 )
626}
627
628#[allow(clippy::too_many_arguments)]
629pub(crate) fn write_index_file_split(
630 path: &Path,
631 git_dir: &Path,
632 index: &mut Index,
633 cfg: &ConfigSet,
634 request: WriteSplitIndexRequest,
635 skip_hash: bool,
636 thread_policy: crate::parallel::ThreadPolicy,
637 existing_lock: Option<&mut crate::index::IndexLock>,
638 commit_lock: bool,
639) -> Result<()> {
640 if git_test_check_cache_tree() {
645 crate::write_tree::verify_cache_tree(index)?;
646 }
647
648 let want_split = request.want_write_split(cfg, index);
649
650 let shared_repo = parse_shared_repository_perm(cfg.get("core.sharedRepository").as_deref());
651
652 if !want_split {
653 index.split_link = None;
654 for e in &mut index.entries {
655 e.base_index_pos = 0;
656 }
657 let perm_lock = staged_lock_path_buf_for_perm(existing_lock.as_deref(), commit_lock);
658 write_index_through_lock(
659 index,
660 path,
661 skip_hash,
662 cfg,
663 existing_lock,
664 commit_lock,
665 thread_policy,
666 )?;
667 adjust_shared_perm_written_index(path, perm_lock.as_deref(), shared_repo)?;
668 return Ok(());
669 }
670
671 let default_index = git_dir.join("index");
674 let is_primary_index = path == default_index
675 || path
676 .canonicalize_simplified()
677 .ok()
678 .zip(default_index.canonicalize_simplified().ok())
679 .is_some_and(|(a, b)| a == b);
680 if !is_primary_index {
681 let saved_link = index.split_link.clone();
684 let saved_base_pos: Vec<u32> = index.entries.iter().map(|e| e.base_index_pos).collect();
685 index.split_link = None;
686 for e in &mut index.entries {
687 e.base_index_pos = 0;
688 }
689 let perm_lock = staged_lock_path_buf_for_perm(existing_lock.as_deref(), commit_lock);
690 let write_result = write_index_through_lock(
691 index,
692 path,
693 skip_hash,
694 cfg,
695 existing_lock,
696 commit_lock,
697 thread_policy,
698 );
699 index.split_link = saved_link;
700 for (e, pos) in index.entries.iter_mut().zip(saved_base_pos) {
701 e.base_index_pos = pos;
702 }
703 write_result?;
704 adjust_shared_perm_written_index(path, perm_lock.as_deref(), shared_repo)?;
705 return Ok(());
706 }
707
708 if index.sparse_directories {
709 return Err(Error::IndexError(
710 "cannot write split index for a sparse index".to_owned(),
711 ));
712 }
713
714 let prev_base = index
715 .split_link
716 .as_ref()
717 .map(|l| l.base_oid)
718 .unwrap_or(ObjectId::zero());
719
720 let mut rebuild = prev_base.is_zero()
723 || should_rebuild_shared_index(index, cfg)
724 || git_test_split_index_force_reorder(&prev_base);
725
726 if git_test_split_index_env() && index.split_link.is_none() {
727 rebuild = true;
728 }
729
730 let base_snapshot: Vec<IndexEntry> = if rebuild {
731 let mut v: Vec<IndexEntry> = index.entries.to_vec();
732 v.sort_by(|a, b| a.path.cmp(&b.path).then_with(|| a.stage().cmp(&b.stage())));
733 for (i, e) in v.iter_mut().enumerate() {
734 e.base_index_pos = (i + 1) as u32;
735 }
736 v
737 } else {
738 let link = index.split_link.as_ref().ok_or_else(|| {
739 Error::IndexError("split index missing base link during reuse".to_owned())
740 })?;
741 load_shared_entries(git_dir, path, &link.base_oid)?
742 };
743
744 if rebuild {
749 for e in &mut index.entries {
750 e.base_index_pos = find_entry_pos_sorted(&base_snapshot, &e.path, e.stage())
751 .map_or(0, |i| (i + 1) as u32);
752 }
753 }
754
755 let base_oid = if rebuild {
756 let mut shared_index = Index {
757 version: index.version,
758 entries: base_snapshot.clone(),
759 entries_sorted: true,
760 sparse_directories: false,
761 untracked_cache: None,
762 fsmonitor_last_update: None,
763 fsmonitor_dirty: None,
764 fsmonitor_raw_extension: None,
765 resolve_undo: None,
766 split_link: None,
767 cache_tree_root: None,
768 cache_tree: None,
769 hash_algo: index.hash_algo,
770 source_mtime: None,
771 source_checksum: None,
772 source_file_len: None,
773 defer_untracked_cache_invalidation: false,
774 skip_racy_smudge_on_write: false,
775 content_verified: std::collections::HashMap::new(),
776 fsmonitor_refresh_ran: false,
777 suppress_pre_write_index_housekeeping: false,
778 entry_modes_repaired_on_load: false,
779 };
780 let tmp = match tempfile::NamedTempFile::new_in(git_dir) {
781 Ok(t) => t,
782 Err(e) if e.kind() == io::ErrorKind::PermissionDenied => {
783 index.split_link = None;
785 for e in &mut index.entries {
786 e.base_index_pos = 0;
787 }
788 let perm_lock =
789 staged_lock_path_buf_for_perm(existing_lock.as_deref(), commit_lock);
790 write_index_through_lock(
791 index,
792 path,
793 skip_hash,
794 cfg,
795 existing_lock,
796 commit_lock,
797 thread_policy,
798 )?;
799 adjust_shared_perm_written_index(path, perm_lock.as_deref(), shared_repo)?;
800 return Ok(());
801 }
802 Err(e) => return Err(Error::Io(e)),
803 };
804 let tmp_path = tmp.path().to_path_buf();
805 shared_index.write_to_path_with_config_and_policy(
809 &tmp_path,
810 false,
811 Some(cfg),
812 thread_policy,
813 )?;
814 adjust_shared_perm_file(&tmp_path, shared_repo).map_err(Error::Io)?;
815 let oid = read_index_trailer(&tmp_path, index.hash_algo)?;
816 let dest = git_dir.join(format!("sharedindex.{}", oid.to_hex()));
817 if let Err(e) = fs::rename(&tmp_path, &dest) {
818 if e.kind() == io::ErrorKind::PermissionDenied {
819 let _ = fs::remove_file(&tmp_path);
820 index.split_link = None;
821 for ent in &mut index.entries {
822 ent.base_index_pos = 0;
823 }
824 let perm_lock =
825 staged_lock_path_buf_for_perm(existing_lock.as_deref(), commit_lock);
826 write_index_through_lock(
827 index,
828 path,
829 skip_hash,
830 cfg,
831 existing_lock,
832 commit_lock,
833 thread_policy,
834 )?;
835 adjust_shared_perm_written_index(path, perm_lock.as_deref(), shared_repo)?;
836 return Ok(());
837 }
838 return Err(Error::Io(e));
839 }
840 clean_stale_shared_index_files(git_dir, &oid.to_hex(), cfg);
841 oid
842 } else {
843 let oid = index
844 .split_link
845 .as_ref()
846 .ok_or_else(|| {
847 Error::IndexError("split index missing base link during reuse".to_owned())
848 })?
849 .base_oid;
850 freshen_shared_index(&resolve_shared_index_file(git_dir, path, &oid));
851 oid
852 };
853
854 let mut merged_by_pos: Vec<Option<usize>> = vec![None; base_snapshot.len()];
857 for (p, e) in index.entries.iter().enumerate() {
858 if e.base_index_pos == 0 {
859 continue;
860 }
861 let i = e.base_index_pos.saturating_sub(1) as usize;
862 if i < base_snapshot.len()
863 && base_snapshot[i].path == e.path
864 && base_snapshot[i].stage() == e.stage()
865 {
866 merged_by_pos[i] = Some(p);
867 }
868 }
869
870 let mut delete_bm = EwahBitmap::new();
871 let mut replace_bm = EwahBitmap::new();
872 let mut main_entries: Vec<IndexEntry> = Vec::new();
873
874 for i in 0..base_snapshot.len() {
875 let b = &base_snapshot[i];
876 if let Some(p) = merged_by_pos[i] {
877 let c = &index.entries[p];
878 if entries_equal_for_split(b, c) {
879 continue;
880 }
881 replace_bm.set_bit_extend(i);
882 let mut stub = c.clone();
883 stub.path.clear();
884 stub.base_index_pos = 0;
885 main_entries.push(stub);
886 } else {
887 delete_bm.set_bit_extend(i);
888 }
889 }
890
891 for e in &index.entries {
892 if e.base_index_pos == 0 {
893 let mut c = e.clone();
894 c.base_index_pos = 0;
895 main_entries.push(c);
896 continue;
897 }
898 let i = e.base_index_pos.saturating_sub(1) as usize;
899 if i >= base_snapshot.len()
900 || base_snapshot[i].path != e.path
901 || base_snapshot[i].stage() != e.stage()
902 {
903 let mut c = e.clone();
904 c.base_index_pos = 0;
905 main_entries.push(c);
906 continue;
907 }
908 if entries_equal_for_split(&base_snapshot[i], e) {
909 continue;
910 }
911 }
913
914 main_entries.sort_by(|a, b| a.path.cmp(&b.path).then_with(|| a.stage().cmp(&b.stage())));
915
916 let link = SplitIndexLink {
917 base_oid,
918 delete_bitmap: Some(delete_bm),
919 replace_bitmap: Some(replace_bm),
920 };
921
922 let mut out_index = Index {
923 version: index.version,
924 entries: main_entries,
925 entries_sorted: true,
926 sparse_directories: false,
927 untracked_cache: index.untracked_cache.clone(),
928 fsmonitor_last_update: index.fsmonitor_last_update.clone(),
929 fsmonitor_dirty: index.fsmonitor_dirty.clone(),
930 fsmonitor_raw_extension: index.fsmonitor_raw_extension.clone(),
931 resolve_undo: None,
932 split_link: Some(link),
933 cache_tree_root: index.cache_tree_root,
934 cache_tree: index.cache_tree.clone(),
935 hash_algo: index.hash_algo,
936 source_mtime: index.source_mtime,
937 source_checksum: index.source_checksum.clone(),
938 source_file_len: index.source_file_len,
939 defer_untracked_cache_invalidation: false,
940 skip_racy_smudge_on_write: false,
941 content_verified: std::collections::HashMap::new(),
942 fsmonitor_refresh_ran: index.fsmonitor_refresh_ran,
943 suppress_pre_write_index_housekeeping: index.suppress_pre_write_index_housekeeping,
944 entry_modes_repaired_on_load: index.entry_modes_repaired_on_load,
945 };
946
947 let perm_lock = staged_lock_path_buf_for_perm(existing_lock.as_deref(), commit_lock);
948 write_index_through_lock(
949 &mut out_index,
950 path,
951 skip_hash,
952 cfg,
953 existing_lock,
954 commit_lock,
955 thread_policy,
956 )?;
957 adjust_shared_perm_written_index(path, perm_lock.as_deref(), shared_repo)?;
958
959 for e in &mut index.entries {
960 if let Some(pos) = find_entry_pos_sorted(&base_snapshot, &e.path, e.stage()) {
961 if entries_equal_for_split(&base_snapshot[pos], e) {
962 e.base_index_pos = (pos + 1) as u32;
963 continue;
964 }
965 }
966 e.base_index_pos = 0;
967 }
968
969 index.split_link = Some(SplitIndexLink {
970 base_oid,
971 delete_bitmap: None,
972 replace_bitmap: None,
973 });
974
975 Ok(())
976}
977
978pub fn resolve_split_index_if_needed(
981 index: &mut Index,
982 git_dir: &Path,
983 index_path: &Path,
984) -> Result<()> {
985 let Some(link) = index.split_link.clone() else {
986 return Ok(());
987 };
988 if link.base_oid.is_zero() {
989 return Ok(());
990 }
991 let base_oid = link.base_oid;
992 let shared_path = resolve_shared_index_file(git_dir, index_path, &base_oid);
993 let data = fs::read(&shared_path).map_err(|e| {
994 Error::IndexError(format!(
995 "split index: cannot read shared index {}: {e}",
996 shared_path.display()
997 ))
998 })?;
999 let hash_len = index.hash_algo.len();
1000 if data.len() < hash_len {
1001 return Err(Error::IndexError(
1002 "split index: shared index too short".to_owned(),
1003 ));
1004 }
1005 let (body, trailer) = data.split_at(data.len() - hash_len);
1006 let trailer = ObjectId::from_bytes(trailer)?;
1008 let got = if trailer == base_oid {
1009 hash_index_body(body, index.hash_algo)
1010 } else {
1011 trailer
1012 };
1013 if got != base_oid {
1014 return Err(Error::IndexError(format!(
1015 "broken index, expect {} in {}, got {}",
1016 base_oid.to_hex(),
1017 shared_path.display(),
1018 got.to_hex()
1019 )));
1020 }
1021 freshen_shared_index(&shared_path);
1022 let base_entries = Index::parse_with_algo(&data, index.hash_algo)?.entries;
1023 merge_split_into_index(index, link, base_entries)?;
1024 index.split_link = Some(SplitIndexLink {
1025 base_oid,
1026 delete_bitmap: None,
1027 replace_bitmap: None,
1028 });
1029 Ok(())
1030}
1031
1032pub fn format_dump_split_index_file(data: &[u8], index: &Index) -> Result<String> {
1035 use std::fmt::Write;
1036 let hash_len = index.hash_algo.len();
1037 if data.len() < hash_len {
1038 return Err(Error::IndexError("index too short".to_owned()));
1039 }
1040 let body = &data[..data.len() - hash_len];
1041 let trail = &data[data.len() - hash_len..];
1042 let own = if trail.iter().all(|&b| b == 0) {
1043 hash_index_body(body, index.hash_algo)
1044 } else {
1045 ObjectId::from_bytes(trail)?
1046 };
1047
1048 let mut s = String::new();
1049 writeln!(s, "own {}", own.to_hex()).map_err(|e| Error::IndexError(e.to_string()))?;
1050 let Some(link) = &index.split_link else {
1051 writeln!(s, "not a split index").map_err(|e| Error::IndexError(e.to_string()))?;
1052 return Ok(s);
1053 };
1054 writeln!(s, "base {}", link.base_oid.to_hex()).map_err(|e| Error::IndexError(e.to_string()))?;
1055 for e in &index.entries {
1056 let path_disp = String::from_utf8_lossy(&e.path);
1058 writeln!(
1059 s,
1060 "{:06o} {} {}\t{}",
1061 e.mode,
1062 e.oid.to_hex(),
1063 e.stage(),
1064 path_disp
1065 )
1066 .map_err(|e| Error::IndexError(e.to_string()))?;
1067 }
1068 write!(s, "replacements:").map_err(|e| Error::IndexError(e.to_string()))?;
1069 if let Some(bm) = &link.replace_bitmap {
1070 bm.each_set_bit(|pos| {
1071 write!(s, " {}", pos).ok();
1072 });
1073 }
1074 writeln!(s).map_err(|e| Error::IndexError(e.to_string()))?;
1075 write!(s, "deletions:").map_err(|e| Error::IndexError(e.to_string()))?;
1076 if let Some(bm) = &link.delete_bitmap {
1077 bm.each_set_bit(|pos| {
1078 write!(s, " {}", pos).ok();
1079 });
1080 }
1081 writeln!(s).map_err(|e| Error::IndexError(e.to_string()))?;
1082 Ok(s)
1083}
1084
1085#[cfg(test)]
1086mod tests {
1087 use super::*;
1088 use crate::config::ConfigSet;
1089 use crate::environment::Environment;
1090 use crate::index::{Index, IndexEntry, MODE_REGULAR};
1091 use crate::objects::{HashAlgo, ObjectKind};
1092 use std::fs;
1093
1094 fn sample_entry(path: &str, ino: u32) -> IndexEntry {
1095 IndexEntry {
1096 ctime_sec: 1,
1097 ctime_nsec: 0,
1098 mtime_sec: 2,
1099 mtime_nsec: 0,
1100 dev: 1,
1101 ino,
1102 mode: MODE_REGULAR,
1103 uid: 1,
1104 gid: 1,
1105 size: 3,
1106 oid: HashAlgo::Sha1
1107 .hash_object(ObjectKind::Blob, b"abc")
1108 .expect("hash"),
1109 flags: path.len().min(0xFFF) as u16,
1110 flags_extended: None,
1111 path: path.as_bytes().to_vec(),
1112 base_index_pos: 0,
1113 }
1114 }
1115
1116 #[test]
1117 fn write_split_index_request_keeps_existing_split_without_config() {
1118 let cfg = ConfigSet::new();
1119 let mut idx = Index::empty(2);
1120 idx.split_link = Some(SplitIndexLink {
1121 base_oid: HashAlgo::Sha1
1122 .hash_object(ObjectKind::Blob, b"base")
1123 .expect("hash"),
1124 delete_bitmap: None,
1125 replace_bitmap: None,
1126 });
1127 assert!(WriteSplitIndexRequest { explicit: None }.want_write_split(&cfg, &idx));
1128 }
1129
1130 #[test]
1131 fn entries_equal_for_split_ignores_path_and_namehash() {
1132 let mut a = sample_entry("a.txt", 1);
1133 let mut b = sample_entry("b.txt", 1);
1134 assert!(entries_equal_for_split(&a, &b));
1135 b.mtime_sec = 3;
1136 assert!(!entries_equal_for_split(&a, &b));
1137 a.flags |= 0x2000;
1138 b = a.clone();
1139 b.path = b"b.txt".into();
1140 assert!(entries_equal_for_split(&a, &b));
1141 }
1142
1143 #[test]
1144 fn parse_link_extension_round_trip_and_corrupt() {
1145 let base = HashAlgo::Sha1
1146 .hash_object(ObjectKind::Blob, b"base")
1147 .expect("hash");
1148 let mut del = EwahBitmap::new();
1149 del.set_bit_extend(1);
1150 let mut rep = EwahBitmap::new();
1151 rep.set_bit_extend(3);
1152 let payload = serialize_link_extension_payload(&base, &del, &rep);
1153 let link = parse_link_extension(&payload, HashAlgo::Sha1).expect("parse");
1154 assert_eq!(link.base_oid, base);
1155 assert!(link.delete_bitmap.is_some());
1156 assert!(link.replace_bitmap.is_some());
1157
1158 assert!(parse_link_extension(&[], HashAlgo::Sha1).is_err());
1159 let mut garbage = payload.clone();
1160 garbage.push(1);
1161 assert!(parse_link_extension(&garbage, HashAlgo::Sha1).is_err());
1162 }
1163
1164 #[test]
1165 fn should_rebuild_shared_index_respects_max_percent_change() {
1166 let mut idx = Index::empty(2);
1167 for i in 0..10 {
1168 idx.add_or_replace(sample_entry(&format!("f{i}.txt"), i));
1169 }
1170 for e in &mut idx.entries {
1171 e.base_index_pos = (1) as u32;
1172 }
1173 idx.entries[0].base_index_pos = 0;
1174 idx.entries[1].base_index_pos = 0;
1175 idx.entries[2].base_index_pos = 0;
1176 let dir = tempfile::tempdir().expect("tempdir");
1177 fs::write(
1178 dir.path().join("config"),
1179 "[splitIndex]\n\tmaxPercentChange = 20\n",
1180 )
1181 .expect("write config");
1182 let cfg = ConfigSet::load(&Environment::empty(), Some(dir.path()), true).expect("load");
1183 assert!(should_rebuild_shared_index(&idx, &cfg));
1184 fs::write(
1185 dir.path().join("config"),
1186 "[splitIndex]\n\tmaxPercentChange = 100\n",
1187 )
1188 .expect("write config");
1189 let cfg = ConfigSet::load(&Environment::empty(), Some(dir.path()), true).expect("load");
1190 assert!(!should_rebuild_shared_index(&idx, &cfg));
1191 }
1192
1193 #[cfg(unix)]
1194 #[test]
1195 fn shared_perm_adjustment_targets_lock_file_on_uncommitted_flush() {
1196 use std::os::unix::fs::PermissionsExt;
1197
1198 let dir = tempfile::tempdir().expect("tempdir");
1199 let index_path = dir.path().join("index");
1200 fs::write(&index_path, b"live").expect("write index");
1201 fs::set_permissions(&index_path, fs::Permissions::from_mode(0o644)).expect("index mode");
1202
1203 let lock = crate::index::IndexLock::acquire(&index_path, None).expect("lock");
1204 fs::write(&lock.lock_path, b"staged").expect("write lock");
1205 fs::set_permissions(&lock.lock_path, fs::Permissions::from_mode(0o644)).expect("lock mode");
1206
1207 super::adjust_shared_perm_written_index(&index_path, Some(lock.lock_path.as_path()), 0o660)
1208 .expect("adjust perm");
1209
1210 assert_eq!(
1211 fs::metadata(&index_path)
1212 .expect("index meta")
1213 .permissions()
1214 .mode()
1215 & 0o777,
1216 0o644,
1217 "live index mode must stay unchanged before commit_replace_index"
1218 );
1219 assert_eq!(
1220 fs::metadata(&lock.lock_path)
1221 .expect("lock meta")
1222 .permissions()
1223 .mode()
1224 & 0o777,
1225 0o664,
1226 "sharedRepository perm applies to staged lock bytes"
1227 );
1228 drop(lock);
1229 }
1230
1231 #[test]
1232 fn split_index_config_and_shared_repository_perm() {
1233 let dir = tempfile::tempdir().expect("tempdir");
1234 fs::write(
1235 dir.path().join("config"),
1236 "[core]\n\tsplitIndex = true\n\tsharedRepository = group\n",
1237 )
1238 .expect("write");
1239 let cfg = ConfigSet::load(&Environment::empty(), Some(dir.path()), true).expect("load");
1240 assert!(matches!(
1241 split_index_config(&cfg),
1242 SplitIndexConfig::Enabled
1243 ));
1244 assert_eq!(
1245 parse_shared_repository_perm(cfg.get("core.sharedRepository").as_deref()),
1246 0o660
1247 );
1248 let _ = max_percent_split_change(&cfg);
1249 }
1250
1251 #[test]
1252 fn write_split_index_request_explicit_overrides_config() {
1253 let cfg = ConfigSet::new();
1254 let idx = Index::empty(2);
1255 assert!(WriteSplitIndexRequest {
1256 explicit: Some(true),
1257 }
1258 .want_write_split(&cfg, &idx));
1259 assert!(!WriteSplitIndexRequest {
1260 explicit: Some(false),
1261 }
1262 .want_write_split(&cfg, &idx));
1263 }
1264
1265 #[test]
1266 fn hash_index_body_matches_entry_checksum() {
1267 let mut idx = Index::empty(2);
1268 idx.add_or_replace(sample_entry("a.txt", 1));
1269 let dir = tempfile::tempdir().expect("tempdir");
1270 let path = dir.path().join("index");
1271 idx.write(&path).expect("write index");
1272 let buf = fs::read(&path).expect("read index");
1273 let body = &buf[..buf.len().saturating_sub(20)];
1274 let oid = hash_index_body(body, HashAlgo::Sha1);
1275 assert_ne!(oid, ObjectId::zero());
1276 }
1277
1278 #[test]
1279 fn shared_repository_perm_parses_git_variants() {
1280 assert_eq!(parse_shared_repository_perm(Some("umask")), 0);
1281 assert_eq!(parse_shared_repository_perm(Some("group")), 0o660);
1282 assert_eq!(parse_shared_repository_perm(Some("all")), 0o664);
1283 assert_eq!(parse_shared_repository_perm(Some("true")), 0o660);
1284 assert_eq!(parse_shared_repository_perm(Some("world")), 0o664);
1285 assert_eq!(parse_shared_repository_perm(None), 0);
1286 assert_eq!(parse_shared_repository_perm(Some("664")), -0o664);
1287 assert_eq!(parse_shared_repository_perm(Some("600")), -384);
1288 }
1289
1290 #[test]
1291 fn shared_index_expire_never_and_now() {
1292 let dir = tempfile::tempdir().expect("tempdir");
1293 fs::write(
1294 dir.path().join("config"),
1295 "[splitIndex]\n\tsharedIndexExpire = never\n",
1296 )
1297 .expect("write");
1298 let cfg = ConfigSet::load(&Environment::empty(), Some(dir.path()), true).expect("load");
1299 assert_eq!(shared_index_expire_threshold(&cfg), 0);
1300
1301 fs::write(
1302 dir.path().join("config"),
1303 "[splitIndex]\n\tsharedIndexExpire = now\n",
1304 )
1305 .expect("write");
1306 let cfg = ConfigSet::load(&Environment::empty(), Some(dir.path()), true).expect("load");
1307 assert!(shared_index_expire_threshold(&cfg) > 0);
1308 }
1309
1310 #[test]
1311 fn format_dump_split_index_short_index_errors() {
1312 let idx = Index::empty(2);
1313 let err = format_dump_split_index_file(&[1, 2, 3], &idx).expect_err("too short");
1314 assert!(err.to_string().contains("too short"));
1315 }
1316
1317 #[test]
1318 fn resolve_split_index_missing_shared_file() {
1319 let tmp = tempfile::tempdir().expect("tempdir");
1320 let git_dir = tmp.path().join(".git");
1321 fs::create_dir_all(&git_dir).expect("mkdir");
1322 let mut idx = Index::empty(2);
1323 idx.split_link = Some(SplitIndexLink {
1324 base_oid: HashAlgo::Sha1
1325 .hash_object(ObjectKind::Blob, b"missing")
1326 .expect("hash"),
1327 delete_bitmap: None,
1328 replace_bitmap: None,
1329 });
1330 let index_path = git_dir.join("index");
1331 fs::write(&index_path, b"x").expect("write");
1332 let err = resolve_split_index_if_needed(&mut idx, &git_dir, &index_path).expect_err("read");
1333 assert!(err.to_string().contains("shared index"));
1334 }
1335
1336 fn system_git(root: &Path, args: &[&str]) -> String {
1337 let out = std::process::Command::new("git")
1338 .env("GIT_CONFIG_NOSYSTEM", "1")
1339 .env("GIT_CONFIG_GLOBAL", "/dev/null")
1340 .env("GIT_AUTHOR_NAME", "Test")
1341 .env("GIT_AUTHOR_EMAIL", "test@example.com")
1342 .env("GIT_COMMITTER_NAME", "Test")
1343 .env("GIT_COMMITTER_EMAIL", "test@example.com")
1344 .current_dir(root)
1345 .args(args)
1346 .output()
1347 .expect("run git");
1348 assert!(
1349 out.status.success(),
1350 "git {args:?} failed: {}",
1351 String::from_utf8_lossy(&out.stderr)
1352 );
1353 String::from_utf8(out.stdout).expect("utf8")
1354 }
1355
1356 fn open_repo(root: &Path) -> crate::repo::Repository {
1357 crate::repo::Repository::discover(
1358 Some(root),
1359 &crate::environment::RepositoryOptions::empty(),
1360 )
1361 .expect("open")
1362 }
1363
1364 fn shared_index_names(git_dir: &Path) -> Vec<String> {
1365 let mut names: Vec<String> = fs::read_dir(git_dir)
1366 .expect("read git dir")
1367 .flatten()
1368 .filter_map(|e| e.file_name().to_str().map(str::to_owned))
1369 .filter(|n| n.starts_with("sharedindex."))
1370 .collect();
1371 names.sort();
1372 names
1373 }
1374
1375 fn git_split_index_repo(root: &Path, skip_hash_key: &str) {
1377 system_git(root, &["init", "-q", "-b", "main"]);
1378 for name in ["a.txt", "b.txt"] {
1379 fs::write(root.join(name), b"1\n").expect("write");
1380 }
1381 system_git(root, &["add", "."]);
1382 system_git(root, &["commit", "-qm", "init"]);
1383 system_git(root, &["config", "core.splitIndex", "true"]);
1384 system_git(root, &["update-index", "--split-index"]);
1385 system_git(root, &["config", skip_hash_key, "true"]);
1386 system_git(root, &["status", "--porcelain"]);
1387 }
1388
1389 fn assert_git_reads_index(root: &Path, expected_paths: &str) {
1390 assert_eq!(system_git(root, &["ls-files"]), expected_paths);
1391 system_git(root, &["status", "--porcelain"]);
1392 system_git(root, &["fsck", "--strict", "--no-progress"]);
1393 }
1394
1395 #[cfg_attr(
1396 not(feature = "full-tests"),
1397 ignore = "starts system git or is heavy; run with --features full-tests"
1398 )]
1399 #[test]
1400 fn rebuilt_shared_index_under_skip_hash_is_readable_by_git() {
1401 for key in ["index.skipHash", "feature.manyFiles"] {
1402 let tmp = tempfile::tempdir().expect("tempdir");
1403 let root = tmp.path();
1404 git_split_index_repo(root, key);
1405 system_git(root, &["config", "splitIndex.maxPercentChange", "0"]);
1406 let before = shared_index_names(&root.join(".git"));
1407
1408 let repo = open_repo(root);
1409 let mut index = repo.load_index().expect("load");
1410 assert!(index.remove(b"b.txt"));
1411 repo.write_index(&mut index).expect("write");
1412
1413 let git_dir = repo.git_dir();
1414 let main = fs::read(git_dir.join("index")).expect("read index");
1415 assert!(
1416 main[main.len() - 20..].iter().all(|&b| b == 0),
1417 "{key}: the main index keeps honoring skipHash"
1418 );
1419 let reread = Index::parse(&main).expect("parse");
1420 let link = reread.split_link.expect("index stays split");
1421 assert!(!link.base_oid.is_zero());
1422 let shared_name = format!("sharedindex.{}", link.base_oid.to_hex());
1423 assert_ne!(shared_index_names(git_dir), before, "{key}: rebuilt");
1424 let shared = fs::read(git_dir.join(&shared_name)).expect("shared index named by link");
1425 assert_eq!(
1426 &shared[shared.len() - 20..],
1427 link.base_oid.as_bytes(),
1428 "{key}: shared index trailer must match its name"
1429 );
1430
1431 system_git(root, &["config", "splitIndex.maxPercentChange", "100"]);
1434 assert_git_reads_index(root, "a.txt\n");
1435 assert_eq!(repo.load_index().expect("grit reload").entries.len(), 1);
1436 }
1437 }
1438
1439 #[cfg_attr(
1440 not(feature = "full-tests"),
1441 ignore = "starts system git or is heavy; run with --features full-tests"
1442 )]
1443 #[test]
1444 fn reused_shared_index_under_skip_hash_is_readable_by_git() {
1445 let tmp = tempfile::tempdir().expect("tempdir");
1446 let root = tmp.path();
1447 git_split_index_repo(root, "index.skipHash");
1448 system_git(root, &["config", "splitIndex.maxPercentChange", "100"]);
1449 let before = shared_index_names(&root.join(".git"));
1450
1451 let repo = open_repo(root);
1452 let mut index = repo.load_index().expect("load");
1453 assert!(index.remove(b"b.txt"));
1454 repo.write_index(&mut index).expect("write");
1455
1456 assert_eq!(
1457 shared_index_names(repo.git_dir()),
1458 before,
1459 "shared index reused"
1460 );
1461 assert_git_reads_index(root, "a.txt\n");
1462 }
1463
1464 #[cfg_attr(
1465 not(feature = "full-tests"),
1466 ignore = "starts system git or is heavy; run with --features full-tests"
1467 )]
1468 #[test]
1469 fn null_split_link_rebuilds_a_real_shared_index() {
1470 let tmp = tempfile::tempdir().expect("tempdir");
1471 let root = tmp.path();
1472 git_split_index_repo(root, "index.skipHash");
1473 system_git(root, &["config", "splitIndex.maxPercentChange", "100"]);
1474
1475 let repo = open_repo(root);
1476 let mut index = repo.load_index().expect("load");
1477 index.split_link = Some(SplitIndexLink {
1478 base_oid: ObjectId::zero(),
1479 delete_bitmap: None,
1480 replace_bitmap: None,
1481 });
1482 repo.write_index(&mut index).expect("write");
1483
1484 let link = repo
1485 .load_index()
1486 .expect("reload")
1487 .split_link
1488 .expect("split");
1489 assert!(!link.base_oid.is_zero());
1490 assert_git_reads_index(root, "a.txt\nb.txt\n");
1491 }
1492
1493 #[test]
1494 fn resolve_split_index_rejects_link_not_matching_shared_trailer() {
1495 let tmp = tempfile::tempdir().expect("tempdir");
1496 let git_dir = tmp.path().join(".git");
1497 fs::create_dir_all(&git_dir).expect("mkdir");
1498 let mut shared = Index::empty(2);
1499 shared.entries.push(sample_entry("a.txt", 1));
1500 let staged = git_dir.join("staged");
1501 shared.write_to_path(&staged, true).expect("write shared");
1502 let data = fs::read(&staged).expect("read");
1503 let body_oid = hash_index_body(&data[..data.len() - 20], HashAlgo::Sha1);
1504 let shared_path = git_dir.join(format!("sharedindex.{}", body_oid.to_hex()));
1505 fs::rename(&staged, &shared_path).expect("rename");
1506
1507 let mut idx = Index::empty(2);
1508 idx.split_link = Some(SplitIndexLink {
1509 base_oid: body_oid,
1510 delete_bitmap: None,
1511 replace_bitmap: None,
1512 });
1513 let index_path = git_dir.join("index");
1514 let err = resolve_split_index_if_needed(&mut idx, &git_dir, &index_path)
1515 .expect_err("zero trailer does not match the link");
1516 assert!(err.to_string().contains("broken index"), "{err}");
1517 }
1518
1519 #[test]
1520 fn clean_stale_shared_index_files_honors_expire() {
1521 let dir = tempfile::tempdir().expect("tempdir");
1522 let git_dir = dir.path().join(".git");
1523 fs::create_dir_all(&git_dir).expect("mkdir");
1524 fs::write(
1525 dir.path().join("config"),
1526 "[splitIndex]\n\tsharedIndexExpire = now\n",
1527 )
1528 .expect("write config");
1529 let cfg = ConfigSet::load(&Environment::empty(), Some(dir.path()), true).expect("load");
1530 let stale = git_dir.join("sharedindex.deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
1531 fs::write(&stale, b"stale").expect("write stale");
1532 #[cfg(unix)]
1533 {
1534 use std::os::unix::fs::MetadataExt;
1535 let past = filetime::FileTime::from_unix_time(1_000_000, 0);
1536 filetime::set_file_mtime(&stale, past).expect("backdate");
1537 let _ = stale.metadata().expect("meta").mtime();
1538 }
1539 clean_stale_shared_index_files(&git_dir, "cccccccccccccccccccccccccccccccccccccccc", &cfg);
1540 assert!(!stale.exists(), "expired sharedindex must be removed");
1541 }
1542
1543 #[test]
1544 fn merge_split_into_index_delete_only() {
1545 let base_entries = vec![sample_entry("a.txt", 1), sample_entry("b.txt", 2)];
1546 let mut del = EwahBitmap::new();
1547 del.set_bit_extend(0);
1548 let link = SplitIndexLink {
1549 base_oid: HashAlgo::Sha1
1550 .hash_object(ObjectKind::Blob, b"shared")
1551 .expect("hash"),
1552 delete_bitmap: Some(del),
1553 replace_bitmap: None,
1554 };
1555 let mut index = Index::empty(2);
1556 merge_split_into_index(&mut index, link, base_entries).expect("merge");
1557 assert_eq!(index.entries().len(), 1);
1558 assert_eq!(String::from_utf8_lossy(&index.entries()[0].path), "b.txt");
1559 }
1560
1561 #[test]
1562 fn merge_split_into_index_applies_delete_and_replace() {
1563 let base_entries = vec![
1564 sample_entry("a.txt", 1),
1565 sample_entry("b.txt", 2),
1566 sample_entry("c.txt", 3),
1567 ];
1568 let mut del = EwahBitmap::new();
1569 del.set_bit_extend(1);
1570 let mut rep = EwahBitmap::new();
1571 rep.set_bit_extend(2);
1572 let link = SplitIndexLink {
1573 base_oid: HashAlgo::Sha1
1574 .hash_object(ObjectKind::Blob, b"shared")
1575 .expect("hash"),
1576 delete_bitmap: Some(del),
1577 replace_bitmap: Some(rep),
1578 };
1579 let mut rep_entry = sample_entry("", 99);
1580 rep_entry.oid = HashAlgo::Sha1
1581 .hash_object(ObjectKind::Blob, b"newc")
1582 .expect("hash");
1583 let mut index = Index::empty(2);
1584 index.add_or_replace(rep_entry);
1585 merge_split_into_index(&mut index, link, base_entries).expect("merge");
1586 let paths: Vec<_> = index
1587 .entries()
1588 .iter()
1589 .map(|e| String::from_utf8_lossy(&e.path).into_owned())
1590 .collect();
1591 assert_eq!(paths, vec!["a.txt".to_owned(), "c.txt".to_owned()]);
1592 }
1593}