Skip to main content

grit_lib/
split_index.rs

1//! Split index: `link` extension and `sharedindex.<sha1>` (Git `split-index.c`).
2
3use std::fs;
4use std::io;
5use std::path::{Path, PathBuf};
6
7use sha1::{Digest, Sha1};
8use sha2::Sha256;
9
10use crate::config::ConfigSet;
11use crate::error::{Error, Result};
12use crate::ewah_bitmap::EwahBitmap;
13use crate::git_date::approx::approxidate_careful;
14use crate::index::{Index, IndexEntry};
15use crate::objects::{HashAlgo, ObjectId};
16
17/// Split-index metadata carried on an [`Index`] (in-memory; bitmaps cleared after merge/write).
18#[derive(Debug, Clone)]
19pub(crate) struct SplitIndexLink {
20    /// OID of the shared index file (`sharedindex.<hex>`).
21    pub base_oid: ObjectId,
22    pub delete_bitmap: Option<EwahBitmap>,
23    pub replace_bitmap: Option<EwahBitmap>,
24}
25
26fn parse_shared_repository_perm(raw: Option<&str>) -> i32 {
27    const PERM_UMASK: i32 = 0;
28    const OLD_PERM_GROUP: i32 = 1;
29    const OLD_PERM_EVERYBODY: i32 = 2;
30    const PERM_GROUP: i32 = 0o660;
31    const PERM_EVERYBODY: i32 = 0o664;
32
33    let Some(value) = raw.map(str::trim).filter(|s| !s.is_empty()) else {
34        return PERM_UMASK;
35    };
36    if value.eq_ignore_ascii_case("umask") {
37        return PERM_UMASK;
38    }
39    if value.eq_ignore_ascii_case("group") {
40        return PERM_GROUP;
41    }
42    if value.eq_ignore_ascii_case("all")
43        || value.eq_ignore_ascii_case("world")
44        || value.eq_ignore_ascii_case("everybody")
45    {
46        return PERM_EVERYBODY;
47    }
48    if !value.is_empty() && value.chars().all(|c| ('0'..='7').contains(&c)) {
49        if let Ok(i) = i32::from_str_radix(value, 8) {
50            return match i {
51                PERM_UMASK => PERM_UMASK,
52                OLD_PERM_GROUP => PERM_GROUP,
53                OLD_PERM_EVERYBODY => PERM_EVERYBODY,
54                _ => {
55                    if (i & 0o600) != 0o600 {
56                        return PERM_UMASK;
57                    }
58                    -(i & 0o666)
59                }
60            };
61        }
62    }
63    if value.eq_ignore_ascii_case("true") {
64        PERM_GROUP
65    } else if value.eq_ignore_ascii_case("false") {
66        PERM_UMASK
67    } else {
68        PERM_UMASK
69    }
70}
71
72#[cfg(unix)]
73fn calc_shared_perm(shared_repo: i32, mode: u32) -> u32 {
74    let tweak = if shared_repo < 0 {
75        (-shared_repo) as u32
76    } else {
77        shared_repo as u32
78    };
79
80    let mut new_mode = if shared_repo < 0 {
81        (mode & !0o777) | tweak
82    } else {
83        mode | tweak
84    };
85
86    if mode & 0o200 == 0 {
87        new_mode &= !0o222;
88    }
89    if mode & 0o100 != 0 {
90        new_mode |= (new_mode & 0o444) >> 2;
91    }
92
93    new_mode
94}
95
96#[cfg(unix)]
97fn adjust_shared_perm_file(path: &Path, shared_repo: i32) -> io::Result<()> {
98    if shared_repo == 0 {
99        return Ok(());
100    }
101    use std::os::unix::fs::PermissionsExt;
102    let meta = fs::metadata(path)?;
103    let old = meta.permissions().mode();
104    let new_mode = calc_shared_perm(shared_repo, old);
105    if (old ^ new_mode) & 0o777 != 0 {
106        let mut p = meta.permissions();
107        p.set_mode(new_mode & 0o777);
108        fs::set_permissions(path, p)?;
109    }
110    Ok(())
111}
112
113#[cfg(not(unix))]
114fn adjust_shared_perm_file(_path: &Path, _shared_repo: i32) -> io::Result<()> {
115    Ok(())
116}
117
118/// Compare on-disk-relevant fields (Git `compare_ce_content` in `split-index.c`).
119pub(crate) fn entries_equal_for_split(a: &IndexEntry, b: &IndexEntry) -> bool {
120    let mask: u16 = 0xF000 | 0x8000;
121    let a_flags = a.flags & mask;
122    let b_flags = b.flags & mask;
123    let ext_mask: u16 = 0x7000;
124    let a_ext = a.flags_extended.unwrap_or(0) & ext_mask;
125    let b_ext = b.flags_extended.unwrap_or(0) & ext_mask;
126    a.ctime_sec == b.ctime_sec
127        && a.ctime_nsec == b.ctime_nsec
128        && a.mtime_sec == b.mtime_sec
129        && a.mtime_nsec == b.mtime_nsec
130        && a.dev == b.dev
131        && a.ino == b.ino
132        && a.mode == b.mode
133        && a.uid == b.uid
134        && a.gid == b.gid
135        && a.size == b.size
136        && a.oid == b.oid
137        && a_flags == b_flags
138        && a_ext == b_ext
139}
140
141fn replace_positions_in_order(link: &SplitIndexLink) -> Vec<usize> {
142    let Some(bm) = &link.replace_bitmap else {
143        return Vec::new();
144    };
145    if bm.bit_size == 0 {
146        return Vec::new();
147    }
148    let mut v = Vec::new();
149    bm.each_set_bit(|p| v.push(p));
150    v
151}
152
153fn bitmap_has_bit(bm: &EwahBitmap, i: usize) -> bool {
154    let mut found = false;
155    bm.each_set_bit(|pos| {
156        if pos == i {
157            found = true;
158        }
159    });
160    found
161}
162
163/// Merge split index + shared base into `index.entries` (Git `merge_base_index`).
164pub(crate) fn merge_split_into_index(
165    index: &mut Index,
166    link: SplitIndexLink,
167    base_entries: Vec<IndexEntry>,
168) -> Result<()> {
169    let saved = std::mem::take(&mut index.entries);
170    let replace_pos = replace_positions_in_order(&link);
171    let stubs: Vec<IndexEntry> = saved
172        .iter()
173        .filter(|e| e.path.is_empty())
174        .cloned()
175        .collect();
176    if stubs.len() != replace_pos.len() {
177        return Err(Error::IndexError(format!(
178            "split index: expected {} replacement stubs, found {}",
179            replace_pos.len(),
180            stubs.len()
181        )));
182    }
183    let mut stub_iter = stubs.into_iter();
184    let rest: Vec<IndexEntry> = saved.into_iter().filter(|e| !e.path.is_empty()).collect();
185
186    let delete = &link.delete_bitmap;
187    let replace = &link.replace_bitmap;
188
189    let mut merged: Vec<IndexEntry> = Vec::new();
190
191    for (i, mut base_e) in base_entries.into_iter().enumerate() {
192        if delete
193            .as_ref()
194            .is_some_and(|b| b.bit_size > 0 && bitmap_has_bit(b, i))
195        {
196            continue;
197        }
198        if replace
199            .as_ref()
200            .is_some_and(|b| b.bit_size > 0 && bitmap_has_bit(b, i))
201        {
202            let Some(rep) = stub_iter.next() else {
203                return Err(Error::IndexError(
204                    "split index: missing replacement entry".to_owned(),
205                ));
206            };
207            let mut e = rep;
208            e.path = base_e.path.clone();
209            e.base_index_pos = (i + 1) as u32;
210            merged.push(e);
211        } else {
212            base_e.base_index_pos = (i + 1) as u32;
213            merged.push(base_e);
214        }
215    }
216
217    if stub_iter.next().is_some() {
218        return Err(Error::IndexError(
219            "split index: too many replacement stubs".to_owned(),
220        ));
221    }
222
223    for mut e in rest {
224        e.base_index_pos = 0;
225        merged.push(e);
226    }
227
228    merged.sort_by(|a, b| a.path.cmp(&b.path).then_with(|| a.stage().cmp(&b.stage())));
229    index.entries = merged;
230    Ok(())
231}
232
233/// Parse the `link` extension payload (Git `read_link_extension`).
234pub(crate) fn parse_link_extension(data: &[u8], algo: HashAlgo) -> Result<SplitIndexLink> {
235    let hash_len = algo.len();
236    if data.len() < hash_len {
237        return Err(Error::IndexError(
238            "corrupt link extension (too short)".to_owned(),
239        ));
240    }
241    let base_oid = ObjectId::from_bytes(&data[..hash_len])?;
242    let mut rest = &data[hash_len..];
243    if rest.is_empty() {
244        return Ok(SplitIndexLink {
245            base_oid,
246            delete_bitmap: None,
247            replace_bitmap: None,
248        });
249    }
250    let Some((del, consumed)) = EwahBitmap::deserialize_prefix(rest) else {
251        return Err(Error::IndexError(
252            "corrupt delete bitmap in link extension".to_owned(),
253        ));
254    };
255    rest = &rest[consumed..];
256    let Some((rep, consumed2)) = EwahBitmap::deserialize_prefix(rest) else {
257        return Err(Error::IndexError(
258            "corrupt replace bitmap in link extension".to_owned(),
259        ));
260    };
261    rest = &rest[consumed2..];
262    if !rest.is_empty() {
263        return Err(Error::IndexError(
264            "garbage at the end of link extension".to_owned(),
265        ));
266    }
267    Ok(SplitIndexLink {
268        base_oid,
269        delete_bitmap: Some(del),
270        replace_bitmap: Some(rep),
271    })
272}
273
274/// Serialize `link` extension: base OID plus two EWAH bitmaps (Git always writes both after `prepare_to_write_split_index`).
275pub(crate) fn serialize_link_extension_payload(
276    base_oid: &ObjectId,
277    delete: &EwahBitmap,
278    replace: &EwahBitmap,
279) -> Vec<u8> {
280    let mut out = base_oid.as_bytes().to_vec();
281    delete.serialize(&mut out);
282    replace.serialize(&mut out);
283    out
284}
285
286/// Resolve path to shared index file (Git `read_index_from`), with fallbacks when `git_dir` does
287/// not match the repo that owns the index (nested trash repo + `GIT_INDEX_FILE`).
288fn resolve_shared_index_file(git_dir: &Path, index_path: &Path, base_oid: &ObjectId) -> PathBuf {
289    let name = format!("sharedindex.{}", base_oid.to_hex());
290    let primary = git_dir.join(&name);
291
292    let try_path = |p: PathBuf| -> Option<PathBuf> {
293        if p.is_file() {
294            Some(p)
295        } else {
296            None
297        }
298    };
299
300    if let Some(p) = try_path(primary.clone()) {
301        return p;
302    }
303    if let Some(parent) = index_path.parent() {
304        if let Some(p) = try_path(parent.join(&name)) {
305            return p;
306        }
307    }
308    if let Ok(cwd) = std::env::current_dir() {
309        let mut dir = cwd.as_path();
310        loop {
311            if let Some(p) = try_path(dir.join(".git").join(&name)) {
312                return p;
313            }
314            let Some(p) = dir.parent() else {
315                break;
316            };
317            dir = p;
318        }
319    }
320    if let Some(d) = index_path.parent() {
321        if let Ok(read) = fs::read_dir(d) {
322            for ent in read.flatten() {
323                let Ok(ft) = ent.file_type() else {
324                    continue;
325                };
326                if !ft.is_dir() {
327                    continue;
328                }
329                if let Some(p) = try_path(ent.path().join(".git").join(&name)) {
330                    return p;
331                }
332            }
333        }
334    }
335    primary
336}
337
338pub(crate) fn hash_index_body(body: &[u8], algo: HashAlgo) -> ObjectId {
339    let digest: Vec<u8> = match algo {
340        HashAlgo::Sha1 => {
341            let mut hasher = Sha1::new();
342            hasher.update(body);
343            hasher.finalize().to_vec()
344        }
345        HashAlgo::Sha256 => {
346            let mut hasher = Sha256::new();
347            hasher.update(body);
348            hasher.finalize().to_vec()
349        }
350    };
351    ObjectId::from_bytes(&digest).unwrap_or_else(|_| unreachable!("digest is a valid OID width"))
352}
353
354#[derive(Debug, Clone, Copy, PartialEq, Eq)]
355pub(crate) enum SplitIndexConfig {
356    Disabled,
357    Unset,
358    Enabled,
359}
360
361pub(crate) fn split_index_config(cfg: &ConfigSet) -> SplitIndexConfig {
362    match cfg.get("core.splitIndex") {
363        None => SplitIndexConfig::Unset,
364        Some(v) => {
365            let t = v.trim();
366            if t.eq_ignore_ascii_case("false") || t == "0" {
367                SplitIndexConfig::Disabled
368            } else if t.eq_ignore_ascii_case("true") || t == "1" {
369                SplitIndexConfig::Enabled
370            } else {
371                SplitIndexConfig::Unset
372            }
373        }
374    }
375}
376
377pub(crate) fn max_percent_split_change(cfg: &ConfigSet) -> i32 {
378    match cfg.get("splitIndex.maxPercentChange") {
379        None => -1,
380        Some(v) => v.trim().parse::<i32>().unwrap_or(-1),
381    }
382}
383
384fn default_max_percent() -> i32 {
385    20
386}
387
388pub(crate) fn should_rebuild_shared_index(index: &Index, cfg: &ConfigSet) -> bool {
389    let max_split = max_percent_split_change(cfg);
390    let max_split = match max_split {
391        -1 => default_max_percent(),
392        0 => return true,
393        100 => return false,
394        n => n,
395    };
396    let mut not_shared = 0u64;
397    for e in &index.entries {
398        if e.base_index_pos == 0 {
399            not_shared += 1;
400        }
401    }
402    let total = index.entries.len() as u64;
403    if total == 0 {
404        return false;
405    }
406    total * (max_split as u64) < not_shared * 100
407}
408
409pub(crate) fn git_test_split_index_env() -> bool {
410    std::env::var("GIT_TEST_SPLIT_INDEX")
411        .ok()
412        .map(|v| {
413            let t = v.trim();
414            t == "1" || t.eq_ignore_ascii_case("true") || t.eq_ignore_ascii_case("yes")
415        })
416        .unwrap_or(false)
417}
418
419/// Whether cache-tree verification should run on index write.
420///
421/// Upstream's `write_locked_index` gates this on `git_env_bool("GIT_TEST_CHECK_CACHE_TREE", 0)`, but
422/// the upstream test harness (`test-lib.sh`) exports the variable as `true` by default — so in
423/// practice the check is *on* unless a test explicitly sets it to a falsy value. Grit mirrors that
424/// effective default: verification runs unless `GIT_TEST_CHECK_CACHE_TREE` is explicitly falsy
425/// (`0`/`false`/`no`/empty). This only ever rejects a genuinely corrupt cache-tree (e.g. one primed
426/// from a tree with duplicate path entries — `t4058-diff-duplicates`); well-formed trees always
427/// verify cleanly.
428pub(crate) fn git_test_check_cache_tree() -> bool {
429    match std::env::var("GIT_TEST_CHECK_CACHE_TREE") {
430        Ok(v) => {
431            let t = v.trim();
432            !(t.is_empty()
433                || t == "0"
434                || t.eq_ignore_ascii_case("false")
435                || t.eq_ignore_ascii_case("no"))
436        }
437        Err(_) => true,
438    }
439}
440
441pub(crate) fn git_test_split_index_force_reorder(base_oid: &ObjectId) -> bool {
442    git_test_split_index_env() && (base_oid.as_bytes()[0] & 15) < 6
443}
444
445pub(crate) fn shared_index_expire_threshold(cfg: &ConfigSet) -> u64 {
446    let raw = cfg
447        .get("splitIndex.sharedIndexExpire")
448        .map(|s| s.trim().to_owned());
449    let spec = raw
450        .as_deref()
451        .filter(|s| !s.is_empty())
452        .unwrap_or("2.weeks.ago");
453    if spec.eq_ignore_ascii_case("never") {
454        return 0;
455    }
456    let mut err = 0;
457    approxidate_careful(spec, Some(&mut err))
458}
459
460fn should_delete_shared_index(path: &Path, expiration: u64) -> bool {
461    if expiration == 0 {
462        return false;
463    }
464    let Ok(meta) = fs::metadata(path) else {
465        return false;
466    };
467    #[cfg(unix)]
468    {
469        use std::os::unix::fs::MetadataExt;
470        meta.mtime() as u64 <= expiration
471    }
472    #[cfg(not(unix))]
473    {
474        let _ = meta;
475        false
476    }
477}
478
479pub(crate) fn clean_stale_shared_index_files(git_dir: &Path, current_hex: &str, cfg: &ConfigSet) {
480    let expiration = shared_index_expire_threshold(cfg);
481    let Ok(read_dir) = fs::read_dir(git_dir) else {
482        return;
483    };
484    for ent in read_dir.flatten() {
485        let name = ent.file_name();
486        let Some(name) = name.to_str() else {
487            continue;
488        };
489        let Some(hex) = name.strip_prefix("sharedindex.") else {
490            continue;
491        };
492        if hex == current_hex {
493            continue;
494        }
495        let path = ent.path();
496        if should_delete_shared_index(&path, expiration) {
497            let _ = fs::remove_file(&path);
498        }
499    }
500}
501
502pub(crate) fn freshen_shared_index(path: &Path) {
503    let _ = filetime_set_to_now(path);
504}
505
506#[cfg(unix)]
507fn filetime_set_to_now(path: &Path) -> io::Result<()> {
508    use std::time::SystemTime;
509    let t = SystemTime::now();
510    let ft = filetime::FileTime::from_system_time(t);
511    filetime::set_file_mtime(path, ft)
512}
513
514#[cfg(not(unix))]
515fn filetime_set_to_now(_path: &Path) -> io::Result<()> {
516    Ok(())
517}
518
519/// Request from `update-index` for the next index write.
520#[derive(Debug, Clone, Copy, Default)]
521pub struct WriteSplitIndexRequest {
522    /// `Some(true)` / `Some(false)` for `--[no-]split-index`; `None` uses config / test env only.
523    pub explicit: Option<bool>,
524}
525
526impl WriteSplitIndexRequest {
527    /// Whether the next write should use split-index format.
528    ///
529    /// Matches Git: `--split-index` still enables split index when `core.splitIndex` is false,
530    /// but emits a warning (see `builtin/update-index.c`).
531    ///
532    /// When `explicit` is `None`, an index that was already split (`split_link` set after load)
533    /// stays split until `--no-split-index` (Git keeps `istate->split_index` across commands).
534    pub fn want_write_split(self, cfg: &ConfigSet, index: &Index) -> bool {
535        match self.explicit {
536            Some(false) => {
537                if matches!(split_index_config(cfg), SplitIndexConfig::Enabled) {
538                    eprintln!(
539                        "warning: core.splitIndex is set to true; remove or change it, if you really want to disable split index"
540                    );
541                }
542                false
543            }
544            Some(true) => {
545                if matches!(split_index_config(cfg), SplitIndexConfig::Disabled) {
546                    eprintln!(
547                        "warning: core.splitIndex is set to false; remove or change it, if you really want to enable split index"
548                    );
549                }
550                true
551            }
552            None => {
553                if matches!(split_index_config(cfg), SplitIndexConfig::Disabled) {
554                    return false;
555                }
556                index.split_link.is_some()
557                    || matches!(split_index_config(cfg), SplitIndexConfig::Enabled)
558                    || git_test_split_index_env()
559            }
560        }
561    }
562}
563
564fn find_entry_pos_sorted(entries: &[IndexEntry], path: &[u8], stage: u8) -> Option<usize> {
565    entries
566        .binary_search_by(|e| {
567            e.path
568                .as_slice()
569                .cmp(path)
570                .then_with(|| e.stage().cmp(&stage))
571        })
572        .ok()
573}
574
575fn load_shared_entries(
576    git_dir: &Path,
577    index_path: &Path,
578    base_oid: &ObjectId,
579) -> Result<Vec<IndexEntry>> {
580    let p = resolve_shared_index_file(git_dir, index_path, base_oid);
581    let data = fs::read(&p).map_err(Error::Io)?;
582    let mut shared = Index::parse(&data)?;
583    for (i, e) in shared.entries.iter_mut().enumerate() {
584        e.base_index_pos = (i + 1) as u32;
585    }
586    Ok(shared.entries)
587}
588
589/// Write split index to `path` under `git_dir`, updating `index` base positions and `split_link`.
590pub(crate) fn write_index_file_split(
591    path: &Path,
592    git_dir: &Path,
593    index: &mut Index,
594    cfg: &ConfigSet,
595    request: WriteSplitIndexRequest,
596    skip_hash: bool,
597) -> Result<()> {
598    // Mirror upstream `write_locked_index`: under GIT_TEST_CHECK_CACHE_TREE, verify the cache-tree
599    // against the index before persisting. A duplicate-entry tree (t4058) produces a cache-tree
600    // whose entry counts exceed the deduplicated index, which must abort the write with the
601    // canonical "corrupted cache-tree" error rather than silently writing a broken index.
602    if git_test_check_cache_tree() {
603        crate::write_tree::verify_cache_tree(index)?;
604    }
605
606    let want_split = request.want_write_split(cfg, index);
607
608    let shared_repo = parse_shared_repository_perm(cfg.get("core.sharedRepository").as_deref());
609
610    if !want_split {
611        index.split_link = None;
612        for e in &mut index.entries {
613            e.base_index_pos = 0;
614        }
615        index.write_to_path(path, skip_hash)?;
616        adjust_shared_perm_file(path, shared_repo).map_err(Error::Io)?;
617        return Ok(());
618    }
619
620    // Git `alternate_index_output`: split index is only written to the repository's primary index
621    // file (`$GIT_DIR/index`). `GIT_INDEX_FILE` pointing elsewhere gets a unified index (t1700 #25).
622    let default_index = git_dir.join("index");
623    let is_primary_index = path == default_index
624        || path
625            .canonicalize()
626            .ok()
627            .zip(default_index.canonicalize().ok())
628            .is_some_and(|(a, b)| a == b);
629    if !is_primary_index {
630        index.split_link = None;
631        for e in &mut index.entries {
632            e.base_index_pos = 0;
633        }
634        index.write_to_path(path, skip_hash)?;
635        adjust_shared_perm_file(path, shared_repo).map_err(Error::Io)?;
636        return Ok(());
637    }
638
639    if index.sparse_directories {
640        return Err(Error::IndexError(
641            "cannot write split index for a sparse index".to_owned(),
642        ));
643    }
644
645    let prev_base = index
646        .split_link
647        .as_ref()
648        .map(|l| l.base_oid)
649        .unwrap_or(ObjectId::zero());
650
651    let mut rebuild = index.split_link.is_none()
652        || should_rebuild_shared_index(index, cfg)
653        || git_test_split_index_force_reorder(&prev_base);
654
655    if git_test_split_index_env() && index.split_link.is_none() {
656        rebuild = true;
657    }
658
659    let base_snapshot: Vec<IndexEntry> = if rebuild {
660        let mut v: Vec<IndexEntry> = index.entries.to_vec();
661        v.sort_by(|a, b| a.path.cmp(&b.path).then_with(|| a.stage().cmp(&b.stage())));
662        for (i, e) in v.iter_mut().enumerate() {
663            e.base_index_pos = (i + 1) as u32;
664        }
665        v
666    } else {
667        let link = index.split_link.as_ref().ok_or_else(|| {
668            Error::IndexError("split index missing base link during reuse".to_owned())
669        })?;
670        load_shared_entries(git_dir, path, &link.base_oid)?
671    };
672
673    // After a shared-index rebuild, `base_snapshot` matches the merged index exactly; align indices
674    // (e.g. `--no-split-index` then `--split-index`). When reusing an on-disk shared file, do not
675    // remap by path — deleted paths can still exist in the shared index until expiry/rebuild, and
676    // re-adding the same path must stay split-only (`base_index_pos` 0) like Git.
677    if rebuild {
678        for e in &mut index.entries {
679            if let Some(i) = base_snapshot
680                .iter()
681                .position(|b| b.path == e.path && b.stage() == e.stage())
682            {
683                e.base_index_pos = (i + 1) as u32;
684            } else {
685                e.base_index_pos = 0;
686            }
687        }
688    }
689
690    let base_oid = if rebuild {
691        let shared_index = Index {
692            version: index.version,
693            entries: base_snapshot.clone(),
694            sparse_directories: false,
695            untracked_cache: None,
696            fsmonitor_last_update: None,
697            resolve_undo: None,
698            split_link: None,
699            cache_tree_root: None,
700            cache_tree: None,
701            hash_algo: index.hash_algo,
702        };
703        let tmp = match tempfile::NamedTempFile::new_in(git_dir) {
704            Ok(t) => t,
705            Err(e) if e.kind() == io::ErrorKind::PermissionDenied => {
706                // Git: mks_tempfile_sm failure falls back to a unified index (no `link` extension).
707                index.split_link = None;
708                for e in &mut index.entries {
709                    e.base_index_pos = 0;
710                }
711                index.write_to_path(path, skip_hash)?;
712                adjust_shared_perm_file(path, shared_repo).map_err(Error::Io)?;
713                return Ok(());
714            }
715            Err(e) => return Err(Error::Io(e)),
716        };
717        let tmp_path = tmp.path().to_path_buf();
718        shared_index.write_to_path(&tmp_path, skip_hash)?;
719        adjust_shared_perm_file(&tmp_path, shared_repo).map_err(Error::Io)?;
720        let file_data = fs::read(&tmp_path).map_err(Error::Io)?;
721        let hash_len = index.hash_algo.len();
722        if file_data.len() < hash_len {
723            return Err(Error::IndexError("shared index temp too short".to_owned()));
724        }
725        let body = &file_data[..file_data.len() - hash_len];
726        let oid = hash_index_body(body, index.hash_algo);
727        let dest = git_dir.join(format!("sharedindex.{}", oid.to_hex()));
728        if let Err(e) = fs::rename(&tmp_path, &dest) {
729            if e.kind() == io::ErrorKind::PermissionDenied {
730                let _ = fs::remove_file(&tmp_path);
731                index.split_link = None;
732                for ent in &mut index.entries {
733                    ent.base_index_pos = 0;
734                }
735                index.write_to_path(path, skip_hash)?;
736                adjust_shared_perm_file(path, shared_repo).map_err(Error::Io)?;
737                return Ok(());
738            }
739            return Err(Error::Io(e));
740        }
741        clean_stale_shared_index_files(git_dir, &oid.to_hex(), cfg);
742        oid
743    } else {
744        let oid = index
745            .split_link
746            .as_ref()
747            .ok_or_else(|| {
748                Error::IndexError("split index missing base link during reuse".to_owned())
749            })?
750            .base_oid;
751        freshen_shared_index(&resolve_shared_index_file(git_dir, path, &oid));
752        oid
753    };
754
755    // Map each shared-index row to the merged entry that claims it (`ce->index`), like Git
756    // `prepare_to_write_split_index` (path must still match that row).
757    let mut merged_by_pos: Vec<Option<usize>> = vec![None; base_snapshot.len()];
758    for (p, e) in index.entries.iter().enumerate() {
759        if e.base_index_pos == 0 {
760            continue;
761        }
762        let i = e.base_index_pos.saturating_sub(1) as usize;
763        if i < base_snapshot.len()
764            && base_snapshot[i].path == e.path
765            && base_snapshot[i].stage() == e.stage()
766        {
767            merged_by_pos[i] = Some(p);
768        }
769    }
770
771    let mut delete_bm = EwahBitmap::new();
772    let mut replace_bm = EwahBitmap::new();
773    let mut main_entries: Vec<IndexEntry> = Vec::new();
774
775    for i in 0..base_snapshot.len() {
776        let b = &base_snapshot[i];
777        if let Some(p) = merged_by_pos[i] {
778            let c = &index.entries[p];
779            if entries_equal_for_split(b, c) {
780                continue;
781            }
782            replace_bm.set_bit_extend(i);
783            let mut stub = c.clone();
784            stub.path.clear();
785            stub.base_index_pos = 0;
786            main_entries.push(stub);
787        } else {
788            delete_bm.set_bit_extend(i);
789        }
790    }
791
792    for e in &index.entries {
793        if e.base_index_pos == 0 {
794            let mut c = e.clone();
795            c.base_index_pos = 0;
796            main_entries.push(c);
797            continue;
798        }
799        let i = e.base_index_pos.saturating_sub(1) as usize;
800        if i >= base_snapshot.len()
801            || base_snapshot[i].path != e.path
802            || base_snapshot[i].stage() != e.stage()
803        {
804            let mut c = e.clone();
805            c.base_index_pos = 0;
806            main_entries.push(c);
807            continue;
808        }
809        if entries_equal_for_split(&base_snapshot[i], e) {
810            continue;
811        }
812        // Replacement: stub already pushed above.
813    }
814
815    main_entries.sort_by(|a, b| a.path.cmp(&b.path).then_with(|| a.stage().cmp(&b.stage())));
816
817    let link = SplitIndexLink {
818        base_oid,
819        delete_bitmap: Some(delete_bm),
820        replace_bitmap: Some(replace_bm),
821    };
822
823    let out_index = Index {
824        version: index.version,
825        entries: main_entries,
826        sparse_directories: false,
827        untracked_cache: index.untracked_cache.clone(),
828        fsmonitor_last_update: index.fsmonitor_last_update.clone(),
829        resolve_undo: None,
830        split_link: Some(link),
831        cache_tree_root: index.cache_tree_root,
832        cache_tree: index.cache_tree.clone(),
833        hash_algo: index.hash_algo,
834    };
835
836    out_index.write_to_path(path, skip_hash)?;
837    adjust_shared_perm_file(path, shared_repo).map_err(Error::Io)?;
838
839    for e in &mut index.entries {
840        if let Some(pos) = find_entry_pos_sorted(&base_snapshot, &e.path, e.stage()) {
841            if entries_equal_for_split(&base_snapshot[pos], e) {
842                e.base_index_pos = (pos + 1) as u32;
843                continue;
844            }
845        }
846        e.base_index_pos = 0;
847    }
848
849    index.split_link = Some(SplitIndexLink {
850        base_oid,
851        delete_bitmap: None,
852        replace_bitmap: None,
853    });
854
855    Ok(())
856}
857
858/// Human-readable split-index dump for `test-tool dump-split-index`.
859/// If `index` has a split `link` extension, load the shared index and merge entries.
860pub fn resolve_split_index_if_needed(
861    index: &mut Index,
862    git_dir: &Path,
863    index_path: &Path,
864) -> Result<()> {
865    let Some(link) = index.split_link.clone() else {
866        return Ok(());
867    };
868    if link.base_oid.is_zero() {
869        return Ok(());
870    }
871    let base_oid = link.base_oid;
872    let shared_path = resolve_shared_index_file(git_dir, index_path, &base_oid);
873    let data = fs::read(&shared_path).map_err(|e| {
874        Error::IndexError(format!(
875            "split index: cannot read shared index {}: {e}",
876            shared_path.display()
877        ))
878    })?;
879    let hash_len = index.hash_algo.len();
880    if data.len() < hash_len {
881        return Err(Error::IndexError(
882            "split index: shared index too short".to_owned(),
883        ));
884    }
885    let body = &data[..data.len() - hash_len];
886    let got = hash_index_body(body, index.hash_algo);
887    if got != base_oid {
888        return Err(Error::IndexError(format!(
889            "broken index, expect {} in {}, got {}",
890            base_oid.to_hex(),
891            shared_path.display(),
892            got.to_hex()
893        )));
894    }
895    freshen_shared_index(&shared_path);
896    let base_entries = Index::parse(&data)?.entries;
897    merge_split_into_index(index, link, base_entries)?;
898    index.split_link = Some(SplitIndexLink {
899        base_oid,
900        delete_bitmap: None,
901        replace_bitmap: None,
902    });
903    Ok(())
904}
905
906/// Format output for `test-tool dump-split-index` (Git reads the index with `do_read_index` only,
907/// without merging the shared base — stubs and EWAH bitmaps stay intact).
908pub fn format_dump_split_index_file(data: &[u8], index: &Index) -> Result<String> {
909    use std::fmt::Write;
910    let hash_len = index.hash_algo.len();
911    if data.len() < hash_len {
912        return Err(Error::IndexError("index too short".to_owned()));
913    }
914    let body = &data[..data.len() - hash_len];
915    let trail = &data[data.len() - hash_len..];
916    let own = if trail.iter().all(|&b| b == 0) {
917        hash_index_body(body, index.hash_algo)
918    } else {
919        ObjectId::from_bytes(trail)?
920    };
921
922    let mut s = String::new();
923    writeln!(s, "own {}", own.to_hex()).map_err(|e| Error::IndexError(e.to_string()))?;
924    let Some(link) = &index.split_link else {
925        writeln!(s, "not a split index").map_err(|e| Error::IndexError(e.to_string()))?;
926        return Ok(s);
927    };
928    writeln!(s, "base {}", link.base_oid.to_hex()).map_err(|e| Error::IndexError(e.to_string()))?;
929    for e in &index.entries {
930        // Split-index replacement stubs use `CE_STRIP_NAME`: zero-length path on disk (Git still prints the line).
931        let path_disp = String::from_utf8_lossy(&e.path);
932        writeln!(
933            s,
934            "{:06o} {} {}\t{}",
935            e.mode,
936            e.oid.to_hex(),
937            e.stage(),
938            path_disp
939        )
940        .map_err(|e| Error::IndexError(e.to_string()))?;
941    }
942    write!(s, "replacements:").map_err(|e| Error::IndexError(e.to_string()))?;
943    if let Some(bm) = &link.replace_bitmap {
944        bm.each_set_bit(|pos| {
945            write!(s, " {}", pos).ok();
946        });
947    }
948    writeln!(s).map_err(|e| Error::IndexError(e.to_string()))?;
949    write!(s, "deletions:").map_err(|e| Error::IndexError(e.to_string()))?;
950    if let Some(bm) = &link.delete_bitmap {
951        bm.each_set_bit(|pos| {
952            write!(s, " {}", pos).ok();
953        });
954    }
955    writeln!(s).map_err(|e| Error::IndexError(e.to_string()))?;
956    Ok(s)
957}