Skip to main content

grit_lib/
split_index.rs

1//! Split index: `link` extension and `sharedindex.<sha1>` (Git `split-index.c`).
2
3use 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/// Split-index metadata carried on an [`Index`] (in-memory; bitmaps cleared after merge/write).
15#[derive(Debug, Clone)]
16pub(crate) struct SplitIndexLink {
17    /// OID of the shared index file (`sharedindex.<hex>`).
18    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
113/// Apply `core.sharedRepository` to the file that was just written.
114///
115/// When `staged_lock_path` is set, bytes were staged in the lock file (flush without commit); do
116/// not chmod the live index path until [`crate::index::IndexLock::commit_replace_index`].
117fn 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
137/// Compare on-disk-relevant fields (Git `compare_ce_content` in `split-index.c`).
138pub(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
182/// Merge split index + shared base into `index.entries` (Git `merge_base_index`).
183pub(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
252/// Parse the `link` extension payload (Git `read_link_extension`).
253pub(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
293/// Serialize `link` extension: base OID plus two EWAH bitmaps (Git always writes both after `prepare_to_write_split_index`).
294pub(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
305/// Resolve path to shared index file (Git `read_index_from`), with fallbacks when `git_dir` does
306/// not match the repo that owns the index (nested trash repo + `GIT_INDEX_FILE`).
307fn 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
358/// Read the trailing checksum of the index file at `path`, which is the oid a split index's
359/// `link` extension names it by.
360fn 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
441/// Whether cache-tree verification should run on index write.
442///
443/// Upstream's `write_locked_index` gates this on `git_env_bool("GIT_TEST_CHECK_CACHE_TREE", 0)`, but
444/// the upstream test harness (`test-lib.sh`) exports the variable as `true` by default — so in
445/// practice the check is *on* unless a test explicitly sets it to a falsy value. Grit mirrors that
446/// effective default: verification runs unless `GIT_TEST_CHECK_CACHE_TREE` is explicitly falsy
447/// (`0`/`false`/`no`/empty). This only ever rejects a genuinely corrupt cache-tree (e.g. one primed
448/// from a tree with duplicate path entries — `t4058-diff-duplicates`); well-formed trees always
449/// verify cleanly.
450pub(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/// Request from `update-index` for the next index write.
542#[derive(Debug, Clone, Copy, Default)]
543pub struct WriteSplitIndexRequest {
544    /// `Some(true)` / `Some(false)` for `--[no-]split-index`; `None` uses config / test env only.
545    pub explicit: Option<bool>,
546}
547
548impl WriteSplitIndexRequest {
549    /// Whether the next write should use split-index format.
550    ///
551    /// Matches Git: `--split-index` still enables split index when `core.splitIndex` is false,
552    /// but emits a warning (see `builtin/update-index.c`).
553    ///
554    /// When `explicit` is `None`, an index that was already split (`split_link` set after load)
555    /// stays split until `--no-split-index` (Git keeps `istate->split_index` across commands).
556    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/// Write split index to `path` under `git_dir`, updating `index` base positions and `split_link`.
604///
605/// With `existing_lock`, the file at `path` is written through that already-held lock instead of
606/// acquiring a new one; it must be the lock for `path`. When `commit_lock` is true the lock is
607/// committed as part of the write; when false the caller must commit via [`IndexLock::commit_replace_index`].
608#[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    // Mirror upstream `write_locked_index`: under GIT_TEST_CHECK_CACHE_TREE, verify the cache-tree
641    // against the index before persisting. A duplicate-entry tree (t4058) produces a cache-tree
642    // whose entry counts exceed the deduplicated index, which must abort the write with the
643    // canonical "corrupted cache-tree" error rather than silently writing a broken index.
644    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    // Git `alternate_index_output`: split index is only written to the repository's primary index
672    // file (`$GIT_DIR/index`). `GIT_INDEX_FILE` pointing elsewhere gets a unified index (t1700 #25).
673    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        // Alternate index paths (GIT_INDEX_FILE, commit hook `next-index-*`) must be written
682        // unified, but the caller's in-memory split metadata must survive for the primary write.
683        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    // A zero base oid is what Git leaves after writing the shared index under `index.skipHash`;
721    // readers treat it as unsplit, so there is no usable shared index to reuse.
722    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    // After a shared-index rebuild, `base_snapshot` matches the merged index exactly; align indices
745    // (e.g. `--no-split-index` then `--split-index`). When reusing an on-disk shared file, do not
746    // remap by path — deleted paths can still exist in the shared index until expiry/rebuild, and
747    // re-adding the same path must stay split-only (`base_index_pos` 0) like Git.
748    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                // Git: mks_tempfile_sm failure falls back to a unified index (no `link` extension).
784                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        // The shared index always carries a real checksum, even under `index.skipHash`: readers
806        // match the link's base oid against the shared file's trailer, and a zero base oid means
807        // "not split", so a skipped trailer would leave the index unreadable.
808        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    // Map each shared-index row to the merged entry that claims it (`ce->index`), like Git
855    // `prepare_to_write_split_index` (path must still match that row).
856    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        // Replacement: stub already pushed above.
912    }
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
978/// Human-readable split-index dump for `test-tool dump-split-index`.
979/// If `index` has a split `link` extension, load the shared index and merge entries.
980pub 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    // The link names the shared file's trailer, which must also match its body.
1007    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
1032/// Format output for `test-tool dump-split-index` (Git reads the index with `do_read_index` only,
1033/// without merging the shared base — stubs and EWAH bitmaps stay intact).
1034pub 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        // Split-index replacement stubs use `CE_STRIP_NAME`: zero-length path on disk (Git still prints the line).
1057        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    /// A split index whose shared file was written by Git before `index.skipHash` was turned on.
1376    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            // Git itself would rebuild the shared index on its next write, which under skipHash
1432            // leaves an index it cannot read; keep it on the shared index grit wrote.
1433            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}