Skip to main content

sley_worktree/
index.rs

1//! Index mutation: add/update-index, refresh, split-index, resolve-undo, cacheinfo/index-info, and write-tree.
2//!
3//! Split out of `lib.rs` in the wave-47 mechanical refactor: a pure code move
4//! (no function body changed); all items are re-exported from `lib.rs`.
5use super::*;
6use crate::attributes::*;
7use crate::checkout::*;
8use crate::filter::*;
9use crate::ignore::*;
10use crate::index_io::*;
11use crate::status::*;
12use crate::types_admin::*;
13
14/// git's `INDEX_FORMAT_DEFAULT` (read-cache.c).
15const INDEX_FORMAT_DEFAULT: u32 = 3;
16
17/// Pick the base version for a freshly-created index, mirroring git's
18/// `get_index_format_default`: `GIT_INDEX_VERSION` wins when set (warning +
19/// default on a malformed / out-of-range value), otherwise `feature.manyFiles`
20/// (→4) then an `index.version` override (warning on out-of-range). The writer's
21/// `normalize_index_version_for_extended_flags` later collapses 2/3 by
22/// extended-flag need; a chosen version 4 is preserved.
23fn fresh_index_default_version(git_dir: &Path) -> u32 {
24    if let Some(raw) = env::var_os("GIT_INDEX_VERSION") {
25        let raw = raw.to_string_lossy();
26        return match raw.parse::<u32>() {
27            Ok(version) if (2..=4).contains(&version) => version,
28            _ => {
29                eprintln!(
30                    "warning: GIT_INDEX_VERSION set, but the value is invalid.\nUsing version {INDEX_FORMAT_DEFAULT}"
31                );
32                INDEX_FORMAT_DEFAULT
33            }
34        };
35    }
36    let config = sley_config::read_repo_config(git_dir, None).unwrap_or_default();
37    let mut version = if config
38        .get_bool("feature", None, "manyFiles")
39        .unwrap_or(false)
40    {
41        4
42    } else {
43        INDEX_FORMAT_DEFAULT
44    };
45    if let Some(raw) = config.get("index", None, "version") {
46        match raw.trim().parse::<i64>() {
47            Ok(value) if (2..=4).contains(&value) => version = value as u32,
48            _ => {
49                eprintln!(
50                    "warning: index.version set, but the value is invalid.\nUsing version {INDEX_FORMAT_DEFAULT}"
51                );
52                return INDEX_FORMAT_DEFAULT;
53            }
54        }
55    }
56    version
57}
58
59/// Whether `config` requests a null trailing index hash. git's repo-settings:
60/// `feature.manyFiles` defaults skip-hash on, and an explicit `index.skipHash`
61/// (last) overrides it.
62pub fn index_skip_hash_from_config(config: &GitConfig) -> bool {
63    let many_files = config
64        .get_bool("feature", None, "manyFiles")
65        .unwrap_or(false);
66    config
67        .get_bool("index", None, "skipHash")
68        .unwrap_or(many_files)
69}
70
71/// Overwrite the trailing `format.raw_len()` checksum bytes with zeroes (the
72/// null oid), for an `index.skipHash` write.
73fn zero_trailing_index_hash(bytes: &mut [u8], format: ObjectFormat) {
74    let raw = format.raw_len();
75    let len = bytes.len();
76    if len >= raw {
77        bytes[len - raw..].fill(0);
78    }
79}
80
81/// Load the repository index, or materialize a fresh empty index whose version
82/// is chosen by [`fresh_index_default_version`]. Used by the `add` /
83/// `update-index` entrypoints so a brand-new index honors
84/// `GIT_INDEX_VERSION` / `index.version` / `feature.manyFiles`, like git.
85fn read_index_or_fresh(git_dir: &Path, format: ObjectFormat) -> Result<Index> {
86    match read_repository_index(git_dir, format)? {
87        Some(index) => Ok(index),
88        None => {
89            let mut index = empty_index();
90            index.version = fresh_index_default_version(git_dir);
91            Ok(index)
92        }
93    }
94}
95
96pub fn add_paths_to_index(
97    worktree_root: impl AsRef<Path>,
98    git_dir: impl AsRef<Path>,
99    format: ObjectFormat,
100    paths: &[PathBuf],
101) -> Result<UpdateIndexResult> {
102    update_index_paths(
103        worktree_root,
104        git_dir,
105        format,
106        paths,
107        UpdateIndexOptions {
108            add: true,
109            remove: false,
110            force_remove: false,
111            chmod: None,
112            info_only: false,
113            ignore_skip_worktree_entries: false,
114            allow_skip_worktree_entries: false,
115        },
116    )
117}
118
119pub fn update_index_paths(
120    worktree_root: impl AsRef<Path>,
121    git_dir: impl AsRef<Path>,
122    format: ObjectFormat,
123    paths: &[PathBuf],
124    options: UpdateIndexOptions,
125) -> Result<UpdateIndexResult> {
126    let git_dir = git_dir.as_ref();
127    let index = read_index_or_fresh(git_dir, format)?;
128    update_index_paths_with_index(worktree_root, git_dir, format, index, paths, options)
129}
130
131pub fn update_index_paths_with_index(
132    worktree_root: impl AsRef<Path>,
133    git_dir: impl AsRef<Path>,
134    format: ObjectFormat,
135    index: Index,
136    paths: &[PathBuf],
137    options: UpdateIndexOptions,
138) -> Result<UpdateIndexResult> {
139    let ordered = ordered_paths_from_plain(paths, options);
140    update_index_paths_impl(
141        worktree_root.as_ref(),
142        git_dir.as_ref(),
143        format,
144        index,
145        &ordered,
146        options,
147        None,
148        false,
149    )
150}
151
152/// Stamp a single uniform mode (from a batch-wide [`UpdateIndexOptions`]) onto
153/// every path. Used by the `git add`-style callers that genuinely apply one
154/// mode to all paths; the positional `git update-index <flag> <path>...` path
155/// instead snapshots a distinct mode per path in the CLI parse walk.
156pub(crate) fn ordered_paths_from_plain(
157    paths: &[PathBuf],
158    options: UpdateIndexOptions,
159) -> Vec<UpdateIndexPath> {
160    let mode = options.path_mode();
161    paths
162        .iter()
163        .map(|path| UpdateIndexPath {
164            path: path.clone(),
165            mode,
166        })
167        .collect()
168}
169
170/// Stage an ordered list of paths, each carrying its own `--chmod` state, and
171/// (under `verbose`) print the `add`/`remove`/`chmod` action lines inline in
172/// command-line order. This is the entry point `git update-index <path>...`
173/// uses so that `--chmod=+x A --chmod=-x B --verbose` produces the interleaved
174/// `add 'A'` / `chmod +x 'A'` / `add 'B'` / `chmod -x 'B'` output git emits.
175pub fn update_index_ordered_paths_filtered(
176    worktree_root: impl AsRef<Path>,
177    git_dir: impl AsRef<Path>,
178    format: ObjectFormat,
179    paths: &[UpdateIndexPath],
180    options: UpdateIndexOptions,
181    config: &GitConfig,
182    verbose: bool,
183) -> Result<UpdateIndexResult> {
184    let git_dir = git_dir.as_ref();
185    let index = read_index_or_fresh(git_dir, format)?;
186    update_index_ordered_paths_filtered_with_index(
187        worktree_root,
188        git_dir,
189        format,
190        index,
191        paths,
192        options,
193        config,
194        verbose,
195    )
196}
197
198pub fn update_index_ordered_paths_filtered_with_index(
199    worktree_root: impl AsRef<Path>,
200    git_dir: impl AsRef<Path>,
201    format: ObjectFormat,
202    index: Index,
203    paths: &[UpdateIndexPath],
204    options: UpdateIndexOptions,
205    config: &GitConfig,
206    verbose: bool,
207) -> Result<UpdateIndexResult> {
208    update_index_paths_impl(
209        worktree_root.as_ref(),
210        git_dir.as_ref(),
211        format,
212        index,
213        paths,
214        options,
215        Some(config),
216        verbose,
217    )
218}
219
220/// Like [`add_paths_to_index`], but runs the configured content filters
221/// (`core.autocrlf`/`text`/`eol` EOL conversion and `filter.<name>.clean`
222/// drivers) on each file's contents before hashing it into the object store.
223///
224/// `config` is the repository config used to resolve the filters; pass the
225/// parsed `<git_dir>/config` (the orchestrator typically already has this).
226pub fn add_paths_to_index_filtered(
227    worktree_root: impl AsRef<Path>,
228    git_dir: impl AsRef<Path>,
229    format: ObjectFormat,
230    paths: &[PathBuf],
231    config: &GitConfig,
232) -> Result<UpdateIndexResult> {
233    update_index_paths_filtered(
234        worktree_root,
235        git_dir,
236        format,
237        paths,
238        UpdateIndexOptions {
239            add: true,
240            remove: false,
241            force_remove: false,
242            chmod: None,
243            info_only: false,
244            ignore_skip_worktree_entries: false,
245            allow_skip_worktree_entries: false,
246        },
247        config,
248    )
249}
250
251/// Like [`update_index_paths`], but applies the clean-side content filters (see
252/// [`apply_clean_filter`]) to file contents before they are hashed/written.
253pub fn update_index_paths_filtered(
254    worktree_root: impl AsRef<Path>,
255    git_dir: impl AsRef<Path>,
256    format: ObjectFormat,
257    paths: &[PathBuf],
258    options: UpdateIndexOptions,
259    config: &GitConfig,
260) -> Result<UpdateIndexResult> {
261    let git_dir = git_dir.as_ref();
262    let index = read_index_or_fresh(git_dir, format)?;
263    update_index_paths_filtered_with_index(
264        worktree_root,
265        git_dir,
266        format,
267        index,
268        paths,
269        options,
270        config,
271    )
272}
273
274pub fn update_index_paths_filtered_with_index(
275    worktree_root: impl AsRef<Path>,
276    git_dir: impl AsRef<Path>,
277    format: ObjectFormat,
278    index: Index,
279    paths: &[PathBuf],
280    options: UpdateIndexOptions,
281    config: &GitConfig,
282) -> Result<UpdateIndexResult> {
283    let ordered = ordered_paths_from_plain(paths, options);
284    update_index_paths_impl(
285        worktree_root.as_ref(),
286        git_dir.as_ref(),
287        format,
288        index,
289        &ordered,
290        options,
291        Some(config),
292        false,
293    )
294}
295
296pub fn add_update_all_tracked_filtered(
297    worktree_root: impl AsRef<Path>,
298    git_dir: impl AsRef<Path>,
299    format: ObjectFormat,
300    clean_config: &GitConfig,
301) -> Result<Vec<AddUpdateTrackedAction>> {
302    let worktree_root = worktree_root.as_ref();
303    let git_dir = git_dir.as_ref();
304    let index_path = repository_index_path(git_dir);
305    if !index_path.exists() {
306        return Ok(Vec::new());
307    }
308    let mut index = Index::parse(&fs::read(&index_path)?, format)?;
309    let index_mtime = fs::metadata(&index_path)
310        .ok()
311        .and_then(|metadata| file_mtime_parts(&metadata));
312    let stat_cache = IndexStatCache::from_index_mtime_only(index_mtime);
313    let prechecks =
314        tracked_only_non_clean_prechecks_parallel(worktree_root, &index, &stat_cache, false)?;
315    // Unmerged paths are skipped by the stage-0-only precheck above, but git's
316    // bare `add -u` resolves them too. Collect each conflicted path once (the
317    // index is sorted by (path, stage), so consecutive dedup yields unique
318    // paths) and stage the worktree content after the regular pass.
319    let unmerged_paths: Vec<Vec<u8>> = {
320        let mut paths = index
321            .entries
322            .iter()
323            .filter(|entry| entry.stage() != Stage::Normal)
324            .map(|entry| entry.path.as_bytes().to_vec())
325            .collect::<Vec<_>>();
326        paths.dedup();
327        paths
328    };
329    if prechecks.is_empty() && unmerged_paths.is_empty() {
330        return Ok(Vec::new());
331    }
332
333    let pending = prechecks
334        .into_iter()
335        .map(|precheck| match precheck {
336            TrackedOnlyPrecheck::Deleted(idx) => {
337                (precheck, index.entries[idx].path.as_bytes().to_vec())
338            }
339            TrackedOnlyPrecheck::Slow(idx) => {
340                (precheck, index.entries[idx].path.as_bytes().to_vec())
341            }
342        })
343        .collect::<Vec<_>>();
344    let odb = FileObjectDatabase::from_git_dir(git_dir, format);
345    let mut actions = Vec::new();
346    let mut index_dirty = false;
347    let mut clean_filter = None;
348    let trust_filemode = trust_executable_bit(clean_config);
349    for (precheck, path) in pending {
350        match precheck {
351            TrackedOnlyPrecheck::Deleted(_) => {
352                if remove_index_entries_with_path(&mut index.entries, &path) {
353                    actions.push(AddUpdateTrackedAction::Remove(path));
354                    index_dirty = true;
355                }
356            }
357            TrackedOnlyPrecheck::Slow(_) => {
358                let (action, dirty) = add_update_tracked_path(
359                    worktree_root,
360                    git_dir,
361                    format,
362                    Some(clean_config),
363                    trust_filemode,
364                    &odb,
365                    &stat_cache,
366                    &mut clean_filter,
367                    &mut index,
368                    &path,
369                )?;
370                index_dirty |= dirty;
371                if let Some(action) = action {
372                    actions.push(action);
373                }
374            }
375        }
376    }
377
378    for path in unmerged_paths {
379        let (action, dirty) = add_update_tracked_path(
380            worktree_root,
381            git_dir,
382            format,
383            Some(clean_config),
384            trust_filemode,
385            &odb,
386            &stat_cache,
387            &mut clean_filter,
388            &mut index,
389            &path,
390        )?;
391        index_dirty |= dirty;
392        if let Some(action) = action {
393            actions.push(action);
394        }
395    }
396
397    if index_dirty {
398        normalize_index_version_for_extended_flags(&mut index);
399        index.extensions = index_extensions_without_cache_tree(&index.extensions);
400        write_repository_index_ref(git_dir, format, &index)?;
401    }
402    Ok(actions)
403}
404
405pub fn add_exact_tracked_path_from_disk(
406    worktree_root: impl AsRef<Path>,
407    git_dir: impl AsRef<Path>,
408    format: ObjectFormat,
409    git_path: &[u8],
410    ignore_removal: bool,
411    config_parameters_env: Option<&str>,
412) -> Result<AddExactTrackedPathResult> {
413    let worktree_root = worktree_root.as_ref();
414    let git_dir = git_dir.as_ref();
415    let index_path = repository_index_path(git_dir);
416    let index_metadata = match fs::metadata(&index_path) {
417        Ok(metadata) => metadata,
418        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
419            return Ok(AddExactTrackedPathResult::Unsupported);
420        }
421        Err(err) => return Err(err.into()),
422    };
423    let mut index_bytes = fs::read(&index_path)?;
424    let Some(raw) = raw_exact_index_entry(&index_bytes, format, git_path)? else {
425        return Ok(AddExactTrackedPathResult::Unsupported);
426    };
427    if !raw_exact_entry_can_patch(&raw, git_path) {
428        return Ok(AddExactTrackedPathResult::Unsupported);
429    }
430    if !raw_index_extensions_are_filterable(&index_bytes, raw.entries_end, raw.checksum_offset) {
431        return Ok(AddExactTrackedPathResult::Unsupported);
432    }
433
434    let entry = raw.entry.clone();
435    if entry.stage() != Stage::Normal
436        || index_entry_skip_worktree(&entry)
437        || sley_index::is_gitlink(entry.mode)
438    {
439        return Ok(AddExactTrackedPathResult::Unsupported);
440    }
441    let absolute = worktree_root.join(repo_path_to_os_path(git_path)?);
442    let metadata = match fs::symlink_metadata(&absolute) {
443        Ok(metadata) => metadata,
444        Err(err)
445            if matches!(
446                err.kind(),
447                std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
448            ) =>
449        {
450            return Ok(if ignore_removal {
451                AddExactTrackedPathResult::Handled(None)
452            } else {
453                AddExactTrackedPathResult::Unsupported
454            });
455        }
456        Err(err) => return Err(err.into()),
457    };
458    let file_type = metadata.file_type();
459    if metadata.is_dir() || !(file_type.is_file() || file_type.is_symlink()) {
460        return Ok(AddExactTrackedPathResult::Unsupported);
461    }
462    let index_mtime = file_mtime_parts(&index_metadata);
463    let stat_cache = IndexStatCache::from_index_mtime_only(index_mtime);
464    if stat_cache.reuse_index_entry(&entry, &metadata).is_some() {
465        return Ok(AddExactTrackedPathResult::Handled(None));
466    }
467
468    let odb = FileObjectDatabase::from_git_dir(git_dir, format);
469    let is_symlink = file_type.is_symlink();
470    let body = if is_symlink {
471        symlink_target_bytes(&absolute)?
472    } else {
473        let body = fs::read(&absolute)?;
474        // Resolve the effective config WITH command-line `-c` / `--config-env`
475        // overrides folded in (e.g. upstream t0027's `git -c core.autocrlf=true
476        // add`); the plain repo-config reader would drop them and the fast path
477        // would convert/warn against the wrong EOL policy.
478        let config =
479            sley_config::read_repo_config(git_dir, config_parameters_env).unwrap_or_default();
480        let mut clean_filter = None;
481        let clean_filter =
482            tracked_only_clean_filter_with_config(&mut clean_filter, worktree_root, &config);
483        clean_filter.read_attributes_for_path(worktree_root, git_path)?;
484        let checks =
485            clean_filter
486                .matcher
487                .attributes_for_path(git_path, &clean_filter.requested, false);
488        // git's index update folds in `global_conv_flags_eol`, so `git add`
489        // emits the `core.safecrlf` round-trip warning (default: warn). The
490        // current index blob (`entry.oid`) drives the auto-crlf
491        // `has_crlf_in_index` decision. Mirror the slow `add_update_tracked_path`
492        // path here so the exact-patch fast path does not silently drop the
493        // warning (upstream t0020 'safecrlf: print warning only once').
494        let conv_flags = ConvFlags::from_config(&clean_filter.config);
495        let index_blob = match conv_flags {
496            ConvFlags::Off => SafeCrlfIndexBlob::None,
497            _ => SafeCrlfIndexBlob::Lookup {
498                odb: &odb,
499                oid: entry.oid,
500            },
501        };
502        apply_clean_filter_cow_inner(
503            &clean_filter.config,
504            &checks,
505            git_path,
506            &body,
507            conv_flags,
508            index_blob,
509            true,
510        )?
511        .into_owned()
512    };
513    let object = EncodedObject::new(ObjectType::Blob, body);
514    let oid = object.object_id(format)?;
515    if oid != entry.oid || entry.is_intent_to_add() {
516        odb.write_object(object)?;
517    }
518
519    let config = sley_config::read_repo_config(git_dir, config_parameters_env).unwrap_or_default();
520    let trust_filemode = trust_executable_bit(&config);
521    let mut updated_entry =
522        index_entry_from_metadata_with_filemode(entry.path.clone(), oid, &metadata, trust_filemode);
523    if is_symlink {
524        updated_entry.mode = 0o120000;
525    }
526    if updated_entry == entry {
527        return Ok(AddExactTrackedPathResult::Handled(None));
528    }
529    if !raw_updated_entry_can_patch(&entry, &updated_entry, git_path) {
530        return Ok(AddExactTrackedPathResult::Unsupported);
531    }
532    patch_raw_index_entry(&mut index_bytes, format, &raw, &updated_entry)?;
533    fs::write(index_path, index_bytes)?;
534    let changed = updated_entry.oid != entry.oid || updated_entry.mode != entry.mode;
535    Ok(AddExactTrackedPathResult::Handled(
536        changed.then(|| AddUpdateTrackedAction::Add(git_path.to_vec())),
537    ))
538}
539
540pub fn add_exact_tracked_path_with_index(
541    worktree_root: impl AsRef<Path>,
542    git_dir: impl AsRef<Path>,
543    format: ObjectFormat,
544    mut index: Index,
545    git_path: &[u8],
546) -> Result<Option<AddUpdateTrackedAction>> {
547    let worktree_root = worktree_root.as_ref();
548    let git_dir = git_dir.as_ref();
549    let range = index_entries_path_range(&index.entries, git_path);
550    if range.len() != 1 {
551        return Ok(None);
552    }
553    let entry = &index.entries[range.start];
554    if entry.stage() != Stage::Normal || index_entry_skip_worktree(entry) {
555        return Ok(None);
556    }
557    let index_path = repository_index_path(git_dir);
558    let index_mtime = fs::metadata(&index_path)
559        .ok()
560        .and_then(|metadata| file_mtime_parts(&metadata));
561    let stat_cache = IndexStatCache::from_index_mtime_only(index_mtime);
562    let odb = FileObjectDatabase::from_git_dir(git_dir, format);
563    let trust_filemode = trust_executable_bit_from_git_dir(git_dir, None);
564    let mut clean_filter = None;
565    let (action, dirty) = add_update_tracked_path(
566        worktree_root,
567        git_dir,
568        format,
569        None,
570        trust_filemode,
571        &odb,
572        &stat_cache,
573        &mut clean_filter,
574        &mut index,
575        git_path,
576    )?;
577    if dirty {
578        normalize_index_version_for_extended_flags(&mut index);
579        index.extensions = index_extensions_without_cache_tree(&index.extensions);
580        write_repository_index_ref(git_dir, format, &index)?;
581    }
582    Ok(action)
583}
584
585pub(crate) struct RawExactIndexEntry {
586    version: u32,
587    entry: IndexEntry,
588    entry_start: usize,
589    entries_end: usize,
590    checksum_offset: usize,
591}
592
593pub(crate) fn raw_exact_index_entry(
594    bytes: &[u8],
595    format: ObjectFormat,
596    git_path: &[u8],
597) -> Result<Option<RawExactIndexEntry>> {
598    let hash_len = format.raw_len();
599    if bytes.len() < 12 + hash_len {
600        return Err(GitError::InvalidFormat("index header too short".into()));
601    }
602    let checksum_offset = bytes.len() - hash_len;
603    let actual_checksum = sley_core::digest_bytes(format, &bytes[..checksum_offset])?;
604    let expected_checksum = ObjectId::from_raw(format, &bytes[checksum_offset..])?;
605    if actual_checksum != expected_checksum {
606        return Err(GitError::InvalidFormat(format!(
607            "index checksum mismatch: expected {expected_checksum}, got {actual_checksum}"
608        )));
609    }
610    if &bytes[..4] != b"DIRC" {
611        return Err(GitError::InvalidFormat("missing DIRC signature".into()));
612    }
613    let version = u32_from_be(&bytes[4..8]);
614    if !(2..=3).contains(&version) {
615        return Ok(None);
616    }
617    let count = u32_from_be(&bytes[8..12]) as usize;
618    let mut offset = 12;
619    let mut found = None;
620    for _ in 0..count {
621        let entry_header_len = 40 + hash_len + 2;
622        if checksum_offset.saturating_sub(offset) < entry_header_len {
623            return Err(GitError::InvalidFormat("truncated index entry".into()));
624        }
625        let start = offset;
626        let oid_start = offset + 40;
627        let oid_end = oid_start + hash_len;
628        let flags = u16_from_be(&bytes[oid_end..oid_end + 2]);
629        offset = oid_end + 2;
630        let flags_extended = if flags & INDEX_FLAG_EXTENDED != 0 {
631            if checksum_offset.saturating_sub(offset) < 2 {
632                return Err(GitError::InvalidFormat(
633                    "truncated index extended flags".into(),
634                ));
635            }
636            let flags_extended = u16_from_be(&bytes[offset..offset + 2]);
637            offset += 2;
638            flags_extended
639        } else {
640            0
641        };
642        let path_start = offset;
643        while bytes.get(offset).copied() != Some(0) {
644            offset += 1;
645            if offset >= checksum_offset {
646                return Err(GitError::InvalidFormat("unterminated index path".into()));
647            }
648        }
649        let path = &bytes[path_start..offset];
650        offset += 1;
651        while (offset - start) % 8 != 0 {
652            offset += 1;
653            if offset > checksum_offset {
654                return Err(GitError::InvalidFormat("truncated index padding".into()));
655            }
656        }
657        if path == git_path {
658            if found.is_some() {
659                return Ok(None);
660            }
661            let oid = ObjectId::from_raw(format, &bytes[oid_start..oid_end])?;
662            found = Some(RawExactIndexEntry {
663                version,
664                entry: IndexEntry {
665                    ctime_seconds: u32_from_be(&bytes[start..start + 4]),
666                    ctime_nanoseconds: u32_from_be(&bytes[start + 4..start + 8]),
667                    mtime_seconds: u32_from_be(&bytes[start + 8..start + 12]),
668                    mtime_nanoseconds: u32_from_be(&bytes[start + 12..start + 16]),
669                    dev: u32_from_be(&bytes[start + 16..start + 20]),
670                    ino: u32_from_be(&bytes[start + 20..start + 24]),
671                    mode: u32_from_be(&bytes[start + 24..start + 28]),
672                    uid: u32_from_be(&bytes[start + 28..start + 32]),
673                    gid: u32_from_be(&bytes[start + 32..start + 36]),
674                    size: u32_from_be(&bytes[start + 36..start + 40]),
675                    oid,
676                    flags,
677                    flags_extended,
678                    path: BString::from(path),
679                },
680                entry_start: start,
681                entries_end: 0,
682                checksum_offset,
683            });
684        } else if found.is_none() && path > git_path {
685            return Ok(None);
686        }
687    }
688    if let Some(mut found) = found {
689        found.entries_end = offset;
690        Ok(Some(found))
691    } else {
692        Ok(None)
693    }
694}
695
696pub(crate) fn raw_exact_entry_can_patch(raw: &RawExactIndexEntry, git_path: &[u8]) -> bool {
697    raw.version == 2
698        && raw.entry.flags_extended == 0
699        && raw.entry.flags & INDEX_FLAG_EXTENDED == 0
700        && raw.entry.flags == index_flags(git_path.len(), 0)
701        && raw.entry.path.as_bytes() == git_path
702}
703
704pub(crate) fn raw_updated_entry_can_patch(
705    previous: &IndexEntry,
706    updated: &IndexEntry,
707    git_path: &[u8],
708) -> bool {
709    updated.path.as_bytes() == git_path
710        && updated.flags_extended == 0
711        && updated.flags & INDEX_FLAG_EXTENDED == 0
712        && updated.flags == previous.flags
713}
714
715pub(crate) fn raw_index_extensions_are_filterable(
716    bytes: &[u8],
717    entries_end: usize,
718    checksum_offset: usize,
719) -> bool {
720    let mut offset = entries_end;
721    while offset < checksum_offset {
722        if checksum_offset.saturating_sub(offset) < 8 {
723            return false;
724        }
725        let size = u32_from_be(&bytes[offset + 4..offset + 8]) as usize;
726        let Some(end) = offset
727            .checked_add(8)
728            .and_then(|offset| offset.checked_add(size))
729        else {
730            return false;
731        };
732        if end > checksum_offset {
733            return false;
734        }
735        offset = end;
736    }
737    true
738}
739
740pub(crate) fn patch_raw_index_entry(
741    bytes: &mut Vec<u8>,
742    format: ObjectFormat,
743    raw: &RawExactIndexEntry,
744    entry: &IndexEntry,
745) -> Result<()> {
746    let hash_len = format.raw_len();
747    let start = raw.entry_start;
748    bytes[start..start + 4].copy_from_slice(&entry.ctime_seconds.to_be_bytes());
749    bytes[start + 4..start + 8].copy_from_slice(&entry.ctime_nanoseconds.to_be_bytes());
750    bytes[start + 8..start + 12].copy_from_slice(&entry.mtime_seconds.to_be_bytes());
751    bytes[start + 12..start + 16].copy_from_slice(&entry.mtime_nanoseconds.to_be_bytes());
752    bytes[start + 16..start + 20].copy_from_slice(&entry.dev.to_be_bytes());
753    bytes[start + 20..start + 24].copy_from_slice(&entry.ino.to_be_bytes());
754    bytes[start + 24..start + 28].copy_from_slice(&entry.mode.to_be_bytes());
755    bytes[start + 28..start + 32].copy_from_slice(&entry.uid.to_be_bytes());
756    bytes[start + 32..start + 36].copy_from_slice(&entry.gid.to_be_bytes());
757    bytes[start + 36..start + 40].copy_from_slice(&entry.size.to_be_bytes());
758    bytes[start + 40..start + 40 + hash_len].copy_from_slice(entry.oid.as_bytes());
759    bytes[start + 40 + hash_len..start + 40 + hash_len + 2]
760        .copy_from_slice(&entry.flags.to_be_bytes());
761
762    let mut extension_offset = raw.entries_end;
763    let mut removed_cache_tree = false;
764    let mut rewritten = Vec::new();
765    while extension_offset < raw.checksum_offset {
766        let signature = &bytes[extension_offset..extension_offset + 4];
767        let size = u32_from_be(&bytes[extension_offset + 4..extension_offset + 8]) as usize;
768        let end = extension_offset + 8 + size;
769        if signature == b"TREE" {
770            removed_cache_tree = true;
771        } else {
772            rewritten.extend_from_slice(&bytes[extension_offset..end]);
773        }
774        extension_offset = end;
775    }
776
777    if removed_cache_tree {
778        bytes.truncate(raw.entries_end);
779        bytes.extend_from_slice(&rewritten);
780        let checksum = sley_core::digest_bytes(format, bytes)?;
781        bytes.extend_from_slice(checksum.as_bytes());
782    } else {
783        let checksum = sley_core::digest_bytes(format, &bytes[..raw.checksum_offset])?;
784        bytes[raw.checksum_offset..raw.checksum_offset + hash_len]
785            .copy_from_slice(checksum.as_bytes());
786    }
787    Ok(())
788}
789
790pub(crate) fn u32_from_be(bytes: &[u8]) -> u32 {
791    u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])
792}
793
794pub(crate) fn u16_from_be(bytes: &[u8]) -> u16 {
795    u16::from_be_bytes([bytes[0], bytes[1]])
796}
797
798pub(crate) fn add_update_tracked_path(
799    worktree_root: &Path,
800    git_dir: &Path,
801    format: ObjectFormat,
802    clean_config: Option<&GitConfig>,
803    trust_filemode: bool,
804    odb: &FileObjectDatabase,
805    stat_cache: &IndexStatCache,
806    clean_filter: &mut Option<TrackedOnlyCleanFilter>,
807    index: &mut Index,
808    git_path: &[u8],
809) -> Result<(Option<AddUpdateTrackedAction>, bool)> {
810    let range = index_entries_path_range(&index.entries, git_path);
811    if range.is_empty() {
812        return Ok((None, false));
813    }
814    let entry = index.entries[range.start].clone();
815    // git's `add -u` (and `-A`) also resolves unmerged paths: `add_files_to_cache`
816    // stages the worktree content over every conflict stage, collapsing the path
817    // to a single stage-0 entry (or removing it when the file is gone). For such a
818    // path the cached `entry` is one of the higher stages, so the stat-cache reuse
819    // fast path below must be skipped and the staged result must always replace the
820    // whole path range. `replace_index_entries_with_entry` already splices out all
821    // stages, so reusing this function gives identical clean-filter/symlink/gitlink
822    // handling for the resolution.
823    let is_unmerged = entry.stage() != Stage::Normal;
824    let absolute = worktree_root.join(repo_path_to_os_path(git_path)?);
825    let metadata = match fs::symlink_metadata(&absolute) {
826        Ok(metadata) => metadata,
827        Err(err)
828            if matches!(
829                err.kind(),
830                std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
831            ) =>
832        {
833            if remove_index_entries_with_path(&mut index.entries, git_path) {
834                return Ok((
835                    Some(AddUpdateTrackedAction::Remove(git_path.to_vec())),
836                    true,
837                ));
838            }
839            return Ok((None, false));
840        }
841        Err(err) => return Err(err.into()),
842    };
843    if metadata.is_dir() {
844        if !sley_index::is_gitlink(entry.mode) {
845            return Ok((None, false));
846        }
847        let oid = sley_diff_merge::gitlink_head_oid(&absolute, format).unwrap_or(entry.oid);
848        let mut updated_entry = index_entry_from_metadata_with_filemode(
849            entry.path.clone(),
850            oid,
851            &metadata,
852            trust_filemode,
853        );
854        updated_entry.mode = sley_index::GITLINK_MODE;
855        let changed =
856            is_unmerged || updated_entry.oid != entry.oid || updated_entry.mode != entry.mode;
857        if updated_entry != entry {
858            replace_index_entries_with_entry(&mut index.entries, updated_entry);
859            return Ok((
860                changed.then(|| AddUpdateTrackedAction::Add(git_path.to_vec())),
861                true,
862            ));
863        }
864        return Ok((None, false));
865    }
866    if !(metadata.is_file() || metadata.file_type().is_symlink()) {
867        return Ok((None, false));
868    }
869    if !is_unmerged && stat_cache.reuse_index_entry(&entry, &metadata).is_some() {
870        return Ok((None, false));
871    }
872
873    let is_symlink = metadata.file_type().is_symlink();
874    let body = if is_symlink {
875        symlink_target_bytes(&absolute)?
876    } else {
877        let body = fs::read(&absolute)?;
878        let clean_filter = match clean_config {
879            Some(config) => {
880                tracked_only_clean_filter_with_config(clean_filter, worktree_root, config)
881            }
882            None => tracked_only_clean_filter(clean_filter, worktree_root, git_dir),
883        };
884        clean_filter.read_attributes_for_path(worktree_root, git_path)?;
885        let checks =
886            clean_filter
887                .matcher
888                .attributes_for_path(git_path, &clean_filter.requested, false);
889        // git's `add -u` index update folds in `global_conv_flags_eol`, so emit
890        // the `core.safecrlf` round-trip warning (default: warn). The current
891        // index blob (`entry.oid`) drives the auto-crlf `has_crlf_in_index`
892        // decision.
893        let conv_flags = ConvFlags::from_config(&clean_filter.config);
894        let index_blob = match conv_flags {
895            ConvFlags::Off => SafeCrlfIndexBlob::None,
896            _ => SafeCrlfIndexBlob::Lookup {
897                odb,
898                oid: entry.oid,
899            },
900        };
901        apply_clean_filter_cow_inner(
902            &clean_filter.config,
903            &checks,
904            git_path,
905            &body,
906            conv_flags,
907            index_blob,
908            true,
909        )?
910        .into_owned()
911    };
912    let object = EncodedObject::new(ObjectType::Blob, body);
913    let oid = object.object_id(format)?;
914    if oid != entry.oid || entry.is_intent_to_add() {
915        odb.write_object(object)?;
916    }
917    let mut updated_entry =
918        index_entry_from_metadata_with_filemode(entry.path.clone(), oid, &metadata, trust_filemode);
919    if is_symlink {
920        updated_entry.mode = 0o120000;
921    }
922    let changed = is_unmerged || updated_entry.oid != entry.oid || updated_entry.mode != entry.mode;
923    if updated_entry != entry {
924        replace_index_entries_with_entry(&mut index.entries, updated_entry);
925        return Ok((
926            changed.then(|| AddUpdateTrackedAction::Add(git_path.to_vec())),
927            true,
928        ));
929    }
930    Ok((None, false))
931}
932
933pub(crate) enum UpdateIndexCleanFilter {
934    Full(AttributeMatcher),
935    PathLocal,
936}
937
938pub(crate) fn index_entries_path_range(
939    entries: &[IndexEntry],
940    path: &[u8],
941) -> std::ops::Range<usize> {
942    let mut start = match entries.binary_search_by(|entry| entry.path.as_bytes().cmp(path)) {
943        Ok(index) => index,
944        Err(insert) => return insert..insert,
945    };
946    while start > 0 && entries[start - 1].path.as_bytes() == path {
947        start -= 1;
948    }
949    let mut end = start;
950    while end < entries.len() && entries[end].path.as_bytes() == path {
951        end += 1;
952    }
953    start..end
954}
955
956pub(crate) fn remove_index_entries_with_path(entries: &mut Vec<IndexEntry>, path: &[u8]) -> bool {
957    let range = index_entries_path_range(entries, path);
958    if range.is_empty() {
959        return false;
960    }
961    entries.drain(range);
962    true
963}
964
965/// Remove every index entry whose path lives *under* `name/` (a strict
966/// directory-prefix collision). Mirrors git's `has_file_name`
967/// (read-cache.c): when a *file* entry `a/b` is being added, any entry
968/// `a/b/...` already in the index would produce a tree that records `a/b`
969/// both as a blob and as a tree — `write-tree` would emit a malformed tree.
970/// Entries are sorted by path, so the conflicting children form a contiguous
971/// run immediately after `name`'s insertion point.
972pub(crate) fn remove_index_entries_under_dir(entries: &mut Vec<IndexEntry>, name: &[u8]) {
973    let start = match entries.binary_search_by(|entry| entry.path.as_bytes().cmp(name)) {
974        Ok(found) => found + 1,
975        Err(insert) => insert,
976    };
977    let mut end = start;
978    while end < entries.len() {
979        let candidate = entries[end].path.as_bytes();
980        // `candidate` is under `name/` iff it is strictly longer, shares the
981        // `name` prefix, and the next byte is the path separator.
982        if candidate.len() > name.len()
983            && candidate[name.len()] == b'/'
984            && candidate[..name.len()] == *name
985        {
986            end += 1;
987        } else {
988            break;
989        }
990    }
991    if end > start {
992        entries.drain(start..end);
993    }
994}
995
996/// Remove any *file* entry that is a strict directory-prefix of `name` (e.g.
997/// when adding `a/b/c`, drop a file entry `a/b` or `a`). Mirrors git's
998/// `has_dir_name` (read-cache.c): such an entry would make the resulting tree
999/// record the prefix both as a blob and as the directory containing `name`.
1000/// We walk every parent directory of `name`, longest first; the moment a
1001/// real subdirectory already exists at a prefix, no shorter prefix can
1002/// conflict, so we stop early (git's "already matches the sub-directory"
1003/// trivial optimization).
1004pub(crate) fn remove_index_dir_name_conflicts(entries: &mut Vec<IndexEntry>, name: &[u8]) {
1005    let mut slash = name.len();
1006    // Walk back over each '/' (longest parent dir first) until the path has no
1007    // more components.
1008    while let Some(pos) = name[..slash].iter().rposition(|&byte| byte == b'/') {
1009        slash = pos;
1010        let prefix = &name[..slash];
1011        match entries.binary_search_by(|entry| entry.path.as_bytes().cmp(prefix)) {
1012            Ok(found) => {
1013                // A file entry sits exactly at this directory prefix — drop it.
1014                entries.remove(found);
1015            }
1016            Err(insert) => {
1017                // No file at `prefix`. If a child `prefix/...` already exists,
1018                // the directory is established and nothing at this prefix (or
1019                // any shorter one) can conflict; stop.
1020                if insert < entries.len() {
1021                    let candidate = entries[insert].path.as_bytes();
1022                    if candidate.len() > prefix.len()
1023                        && candidate[prefix.len()] == b'/'
1024                        && candidate[..prefix.len()] == *prefix
1025                    {
1026                        break;
1027                    }
1028                }
1029            }
1030        }
1031    }
1032}
1033
1034pub(crate) fn replace_index_entries_with_entry(entries: &mut Vec<IndexEntry>, entry: IndexEntry) {
1035    let path = entry.path.as_bytes().to_vec();
1036    // Enforce directory/file replacement *before* computing the insert
1037    // position: git's `add_index_entry_with_check` removes the conflicting
1038    // entries, then recomputes where the new entry lands. Adding the entry
1039    // as a file drops any `path/...` children; adding it drops any file that
1040    // is a directory-prefix of `path`. Skipping this leaves a D/F-corrupt
1041    // index that `write-tree` turns into a malformed tree.
1042    remove_index_entries_under_dir(entries, &path);
1043    remove_index_dir_name_conflicts(entries, &path);
1044    let range = index_entries_path_range(entries, &path);
1045    if range.is_empty() {
1046        entries.insert(range.start, entry);
1047    } else {
1048        entries.splice(range, [entry]);
1049    }
1050}
1051
1052pub(crate) fn write_index_blob_object(
1053    odb: &FileObjectDatabase,
1054    format: ObjectFormat,
1055    object: EncodedObject,
1056    large_policy: LargeObjectPolicy,
1057    pending_large: &mut Vec<(ObjectId, EncodedObject)>,
1058) -> Result<ObjectId> {
1059    let oid = object.object_id(format)?;
1060    if object.object_type == ObjectType::Blob && object.body.len() as u64 >= large_policy.threshold
1061    {
1062        if !odb.contains(&oid)? {
1063            pending_large.push((oid, object));
1064        }
1065        return Ok(oid);
1066    }
1067    odb.write_object(object)
1068}
1069
1070pub(crate) fn write_pending_large_blobs(
1071    odb: &FileObjectDatabase,
1072    objects: &[(ObjectId, EncodedObject)],
1073    policy: LargeObjectPolicy,
1074) -> Result<()> {
1075    let Some(limit) = policy.pack_size_limit else {
1076        return odb.write_blobs_as_pack(objects, policy.compression_level);
1077    };
1078    let mut start = 0usize;
1079    let mut current_size = 0u64;
1080    for (idx, (_, object)) in objects.iter().enumerate() {
1081        let estimate = object.body.len() as u64 + 32;
1082        if idx > start && current_size.saturating_add(estimate) > limit {
1083            odb.write_blobs_as_pack(&objects[start..idx], policy.compression_level)?;
1084            start = idx;
1085            current_size = 0;
1086        }
1087        current_size = current_size.saturating_add(estimate);
1088    }
1089    if start < objects.len() {
1090        odb.write_blobs_as_pack(&objects[start..], policy.compression_level)?;
1091    }
1092    Ok(())
1093}
1094
1095pub(crate) fn update_index_paths_impl(
1096    worktree_root: &Path,
1097    git_dir: &Path,
1098    format: ObjectFormat,
1099    mut index: Index,
1100    paths: &[UpdateIndexPath],
1101    options: UpdateIndexOptions,
1102    clean_config: Option<&GitConfig>,
1103    verbose: bool,
1104) -> Result<UpdateIndexResult> {
1105    let odb = FileObjectDatabase::from_git_dir(git_dir, format);
1106    let mut large_policy = LargeObjectPolicy::from_config(git_dir, None)?;
1107    if let Some(config) = clean_config {
1108        large_policy.compression_level = pack_compression_level(config);
1109        large_policy.pack_size_limit = config
1110            .get("pack", None, "packSizeLimit")
1111            .and_then(sley_config::parse_config_int)
1112            .and_then(|value| (value > 0).then_some(value as u64))
1113            .or(large_policy.pack_size_limit);
1114    }
1115    let trust_filemode = clean_config
1116        .map(trust_executable_bit)
1117        .unwrap_or_else(|| trust_executable_bit_from_git_dir(git_dir, None));
1118    let trust_symlinks = clean_config
1119        .map(trust_symlinks)
1120        .unwrap_or_else(|| trust_symlinks_from_git_dir(git_dir, None));
1121    if options.allow_skip_worktree_entries {
1122        expand_sparse_index(&mut index, &odb, format)?;
1123    }
1124    let sparse_checkout_active = sparse_checkout_config_enabled(git_dir)
1125        || index.is_sparse()
1126        || index.entries.iter().any(IndexEntry::is_sparse_dir);
1127    // For small batches, read only each path's `.gitattributes` chain; a
1128    // whole-worktree matcher can dominate `add -u` when only a few files are
1129    // dirty in a huge checkout. Large batches still amortize the full matcher.
1130    let clean_filter = match clean_config {
1131        Some(_) if paths.len() >= 64 => Some(UpdateIndexCleanFilter::Full(
1132            AttributeMatcher::from_worktree_root(worktree_root)?,
1133        )),
1134        Some(_) => Some(UpdateIndexCleanFilter::PathLocal),
1135        None => None,
1136    };
1137    // git's index-update path (object-file.c `get_conv_flags`) folds in
1138    // `global_conv_flags_eol`, so `git add`/`commit` emit the `core.safecrlf`
1139    // round-trip warning (default: warn). It only applies when content filters
1140    // run at all (i.e. when we have a config).
1141    let conv_flags = clean_config.map_or(ConvFlags::Off, ConvFlags::from_config);
1142    let non_atomic_chmod_errors = clean_config.is_some() && options.add && options.remove;
1143    let requested_filter_attrs = filter_attribute_names();
1144    let mut updated = Vec::new();
1145    let mut reports: Vec<String> = Vec::new();
1146    let mut untracked_cache_invalidation_paths = Vec::new();
1147    let mut pending_large = Vec::new();
1148    let mut chmod_error = false;
1149    for update_path in paths {
1150        let path = &update_path.path;
1151        // Each path carries the sticky mode that was in effect when it was
1152        // parsed on the command line (git processes argv left-to-right). Read
1153        // the action from the path's own mode, NOT a batch-wide flag, so
1154        // `--add foo --force-remove bar` adds foo and force-removes bar.
1155        let path_mode = update_path.mode;
1156        let path_chmod = path_mode.chmod;
1157        let absolute = if path.is_absolute() {
1158            path.clone()
1159        } else {
1160            worktree_root.join(path)
1161        };
1162        let absolute = normalize_absolute_path_lexically(&absolute);
1163        let relative = absolute.strip_prefix(worktree_root).map_err(|_| {
1164            GitError::InvalidPath(format!("path {} is outside worktree", path.display()))
1165        })?;
1166        let git_path = git_path_bytes(relative)?;
1167        if index_sparse_dir_contains_path(&index, &git_path) {
1168            expand_sparse_index(&mut index, &odb, format)?;
1169        }
1170        let existing_range = index_entries_path_range(&index.entries, &git_path);
1171        if path_mode.force_remove {
1172            record_resolve_undo_for_range(&mut index, format, &git_path, existing_range)?;
1173            remove_index_entries_with_path(&mut index.entries, &git_path);
1174            untracked_cache_invalidation_paths.push(git_path.clone());
1175            // git's update_one() reports `remove` for a --force-remove path.
1176            reports.push(format!("remove '{}'", String::from_utf8_lossy(&git_path)));
1177            continue;
1178        }
1179        // lstat (not stat): a symlink must be inspected as the link itself, never
1180        // followed to its target. `Path::exists`/`fs::metadata` both stat through
1181        // the link, which makes a symlink-to-directory look like a directory
1182        // (fs::read then fails with "Is a directory") and a symlink-to-file get
1183        // staged with the target's content + a regular-file mode. git stages a
1184        // symlink as mode 120000 whose blob is the link target string, regardless
1185        // of what (if anything) the target resolves to.
1186        let symlink_metadata = match fs::symlink_metadata(&absolute) {
1187            Ok(metadata) => Some(metadata),
1188            // ENOTDIR (a leading path component is now a file, e.g. staging the
1189            // stale `a/b/c` entry after `a/b` became a regular file in a D/F
1190            // flip) means the path no longer exists as a file — git's lstat
1191            // returns ENOTDIR here and treats it exactly like ENOENT. Fold both
1192            // into the "missing" arm so the `--remove` path drops the stale
1193            // entry instead of aborting the whole add with an I/O error.
1194            Err(err)
1195                if matches!(
1196                    err.kind(),
1197                    std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
1198                ) =>
1199            {
1200                None
1201            }
1202            Err(err) => return Err(err.into()),
1203        };
1204        if !options.allow_skip_worktree_entries
1205            && index.entries[existing_range.clone()]
1206                .iter()
1207                .any(index_entry_skip_worktree)
1208        {
1209            if path_mode.remove {
1210                if !options.ignore_skip_worktree_entries {
1211                    index.entries.drain(existing_range);
1212                }
1213                continue;
1214            }
1215            if symlink_metadata.is_none()
1216                || options.ignore_skip_worktree_entries
1217                || !sparse_checkout_active
1218            {
1219                continue;
1220            }
1221        }
1222        let Some(metadata) = symlink_metadata else {
1223            if path_mode.remove {
1224                record_resolve_undo_for_range(&mut index, format, &git_path, existing_range)?;
1225                remove_index_entries_with_path(&mut index.entries, &git_path);
1226                untracked_cache_invalidation_paths.push(git_path.clone());
1227                // git's update_one() unconditionally reports `add '<path>'`
1228                // after process_path(), even when the missing file was removed
1229                // from the index via the `--remove` (not --force-remove) path.
1230                reports.push(format!("add '{}'", String::from_utf8_lossy(&git_path)));
1231                continue;
1232            }
1233            print_update_index_path_error(&git_path, "does not exist and --remove not passed");
1234            return Err(GitError::Exit(128));
1235        };
1236        if !path_mode.add && index_entries_path_range(&index.entries, &git_path).is_empty() {
1237            print_update_index_path_error(
1238                &git_path,
1239                "cannot add to the index - missing --add option?",
1240            );
1241            return Err(GitError::Exit(128));
1242        }
1243        if metadata.is_dir() {
1244            if path_mode.remove
1245                && !existing_range.is_empty()
1246                && sley_diff_merge::gitlink_head_oid(&absolute, format).is_none()
1247            {
1248                record_resolve_undo_for_range(
1249                    &mut index,
1250                    format,
1251                    &git_path,
1252                    existing_range.clone(),
1253                )?;
1254                remove_index_entries_with_path(&mut index.entries, &git_path);
1255                untracked_cache_invalidation_paths.push(git_path.clone());
1256                reports.push(format!("add '{}'", String::from_utf8_lossy(&git_path)));
1257                continue;
1258            }
1259            // A directory is stageable only as a gitlink: when it is an
1260            // embedded repository with a commit checked out, git records a
1261            // mode-160000 entry whose oid is that commit (no object is
1262            // written). Otherwise it errors — with upstream's exact messages
1263            // for the embedded-repo-without-commit and plain-directory cases
1264            // (object-file.c index_path / builtin/update-index.c
1265            // process_directory).
1266            let display = String::from_utf8_lossy(&git_path).into_owned();
1267            let has_dot_git = absolute.join(".git").exists();
1268            if let Some(submodule_format) = embedded_repo_object_format(&absolute)
1269                && submodule_format != format
1270            {
1271                eprintln!("fatal: cannot add a submodule of a different hash algorithm");
1272                return Err(GitError::Exit(128));
1273            }
1274            let Some(head_oid) = sley_diff_merge::gitlink_head_oid(&absolute, format) else {
1275                if has_dot_git {
1276                    if clean_config.is_some() {
1277                        let display_dir = if display.ends_with('/') {
1278                            display.clone()
1279                        } else {
1280                            format!("{display}/")
1281                        };
1282                        eprintln!("error: '{display_dir}' does not have a commit checked out");
1283                        eprintln!("error: unable to index file '{display_dir}'");
1284                        eprintln!("fatal: adding files failed");
1285                    } else {
1286                        eprintln!("error: '{display}' does not have a commit checked out");
1287                        eprintln!("fatal: Unable to process path {display}");
1288                    }
1289                } else {
1290                    eprintln!("error: {display}: is a directory - add files inside instead");
1291                    eprintln!("fatal: Unable to process path {display}");
1292                }
1293                return Err(GitError::Exit(128));
1294            };
1295            if path_chmod.is_some() {
1296                eprintln!(
1297                    "fatal: git update-index: cannot chmod {}x '{display}'",
1298                    if path_chmod == Some(true) { '+' } else { '-' },
1299                );
1300                return Err(GitError::Exit(128));
1301            }
1302            let mut entry = index_entry_from_metadata_with_filemode(
1303                git_path.clone(),
1304                head_oid,
1305                &metadata,
1306                trust_filemode,
1307            );
1308            entry.mode = sley_index::GITLINK_MODE;
1309            reports.push(format!("add '{display}'"));
1310            record_resolve_undo_for_range(&mut index, format, &git_path, existing_range.clone())?;
1311            replace_index_entries_with_entry(&mut index.entries, entry);
1312            untracked_cache_invalidation_paths.push(git_path.clone());
1313            updated.push(head_oid);
1314            continue;
1315        }
1316        let is_symlink = metadata.file_type().is_symlink();
1317        let body = if is_symlink {
1318            // The blob is the raw link target bytes; clean filters never apply to
1319            // a symlink (git treats it as binary content, not a text path).
1320            symlink_target_bytes(&absolute)?
1321        } else {
1322            let body = fs::read(&absolute)?;
1323            // The safecrlf auto-crlf decision needs the path's *current* index
1324            // blob (git's `has_crlf_in_index`); the stage-0 entry, if any, has it.
1325            let index_blob = match conv_flags {
1326                ConvFlags::Off => SafeCrlfIndexBlob::None,
1327                _ => stage0_oid_in_range(&index.entries, existing_range.clone()).map_or(
1328                    SafeCrlfIndexBlob::None,
1329                    |oid| SafeCrlfIndexBlob::Lookup { odb: &odb, oid },
1330                ),
1331            };
1332            match (clean_config, &clean_filter) {
1333                (Some(config), Some(UpdateIndexCleanFilter::Full(matcher))) => {
1334                    // Identical to `apply_clean_filter`, but reuses the batch's
1335                    // matcher instead of rebuilding it (and re-walking the tree)
1336                    // for this path.
1337                    let checks =
1338                        matcher.attributes_for_path(&git_path, &requested_filter_attrs, false);
1339                    apply_clean_filter_cow_inner(
1340                        config, &checks, &git_path, &body, conv_flags, index_blob, true,
1341                    )?
1342                    .into_owned()
1343                }
1344                (Some(config), Some(UpdateIndexCleanFilter::PathLocal)) => {
1345                    let checks = filter_attribute_checks(worktree_root, &git_path)?;
1346                    apply_clean_filter_cow_inner(
1347                        config, &checks, &git_path, &body, conv_flags, index_blob, true,
1348                    )?
1349                    .into_owned()
1350                }
1351                _ => body,
1352            }
1353        };
1354        let object = EncodedObject::new(ObjectType::Blob, body);
1355        let oid = if path_mode.info_only {
1356            object.object_id(format)?
1357        } else {
1358            write_index_blob_object(&odb, format, object, large_policy, &mut pending_large)?
1359        };
1360        let mut entry = index_entry_from_metadata_with_filemode(
1361            git_path.clone(),
1362            oid,
1363            &metadata,
1364            trust_filemode,
1365        );
1366        if is_symlink {
1367            entry.mode = 0o120000;
1368        }
1369        if let Some(mode) = preferred_unmerged_mode_for_untrusted_worktree(
1370            &index.entries[existing_range.clone()],
1371            trust_filemode,
1372            trust_symlinks,
1373        ) {
1374            entry.mode = mode;
1375        }
1376        // git's update_one() reports `add` for every staged path (whether the
1377        // entry is new or an update), then chmod_path() reports the chmod after.
1378        reports.push(format!("add '{}'", String::from_utf8_lossy(&git_path)));
1379        if let Some(executable) = path_chmod {
1380            // git's chmod_path() refuses to flip the executable bit on anything
1381            // that is not a regular file (a symlink/gitlink has no such bit). It
1382            // writes the blob first, reports the error, and still writes the
1383            // other index updates.
1384            if is_symlink {
1385                eprintln!(
1386                    "fatal: git update-index: cannot chmod {}x '{}'",
1387                    if executable { '+' } else { '-' },
1388                    String::from_utf8_lossy(&git_path)
1389                );
1390                if !non_atomic_chmod_errors {
1391                    return Err(GitError::Exit(128));
1392                }
1393                chmod_error = true;
1394            } else {
1395                entry.mode = if executable { 0o100755 } else { 0o100644 };
1396                reports.push(format!(
1397                    "chmod {}x '{}'",
1398                    if executable { '+' } else { '-' },
1399                    String::from_utf8_lossy(&git_path)
1400                ));
1401            }
1402        }
1403        record_resolve_undo_for_range(&mut index, format, &git_path, existing_range.clone())?;
1404        replace_index_entries_with_entry(&mut index.entries, entry);
1405        untracked_cache_invalidation_paths.push(git_path);
1406        updated.push(oid);
1407    }
1408    normalize_index_version_for_extended_flags(&mut index);
1409    update_cache_tree_for_git_paths(
1410        &mut index,
1411        format,
1412        &odb,
1413        &untracked_cache_invalidation_paths,
1414    )?;
1415    invalidate_untracked_cache_for_git_paths(
1416        &mut index,
1417        format,
1418        &untracked_cache_invalidation_paths,
1419    )?;
1420    if !pending_large.is_empty() {
1421        write_pending_large_blobs(&odb, &pending_large, large_policy)?;
1422    }
1423    // git's `index.skipHash` / `feature.manyFiles` decide whether the trailing
1424    // checksum is written. `clean_config` carries the full effective config
1425    // (file + command-line `-c`) for the add/update-index callers.
1426    let skip_hash = clean_config
1427        .map(index_skip_hash_from_config)
1428        .unwrap_or(false);
1429    write_repository_index_ref_skip_hash(git_dir, format, &index, skip_hash)?;
1430    if verbose {
1431        let mut stdout = std::io::stdout().lock();
1432        for line in &reports {
1433            writeln!(stdout, "{line}")?;
1434        }
1435        stdout.flush()?;
1436    }
1437    if chmod_error {
1438        return Err(GitError::Exit(128));
1439    }
1440    Ok(UpdateIndexResult {
1441        entries: index.entries.len(),
1442        updated,
1443    })
1444}
1445
1446pub fn refresh_index_paths(
1447    worktree_root: impl AsRef<Path>,
1448    git_dir: impl AsRef<Path>,
1449    format: ObjectFormat,
1450    paths: &[PathBuf],
1451    quiet: bool,
1452    ignore_missing: bool,
1453    really_refresh: bool,
1454) -> Result<UpdateIndexResult> {
1455    refresh_index_paths_with_options(
1456        worktree_root,
1457        git_dir,
1458        format,
1459        paths,
1460        quiet,
1461        ignore_missing,
1462        /* ignore_submodules */ false,
1463        /* allow_unmerged */ false,
1464        really_refresh,
1465    )
1466}
1467
1468pub fn refresh_index_paths_with_options(
1469    worktree_root: impl AsRef<Path>,
1470    git_dir: impl AsRef<Path>,
1471    format: ObjectFormat,
1472    paths: &[PathBuf],
1473    quiet: bool,
1474    ignore_missing: bool,
1475    ignore_submodules: bool,
1476    allow_unmerged: bool,
1477    really_refresh: bool,
1478) -> Result<UpdateIndexResult> {
1479    let worktree_root = worktree_root.as_ref();
1480    let git_dir = git_dir.as_ref();
1481    let index_path = repository_index_path(git_dir);
1482    if !index_path.exists() {
1483        return Ok(UpdateIndexResult {
1484            entries: 0,
1485            updated: Vec::new(),
1486        });
1487    }
1488    let mut index = Index::parse(&fs::read(&index_path)?, format)?;
1489    let trust_filemode = trust_executable_bit_from_git_dir(git_dir, None);
1490    // git's `update-index --refresh` trusts the cached stat: a stage-0 entry
1491    // whose size+mtime still match the worktree file (and is not racily clean) is
1492    // known unchanged, so its content is NOT re-read or re-hashed
1493    // (read-cache.c `refresh_cache_ent` → `ie_match_stat`). Without this shortcut
1494    // sley re-hashed every tracked file on every refresh — the 3.2x slowdown in
1495    // sley#27. We build the cache from the same parsed index + the index file's
1496    // own mtime (the racy-clean reference) so no extra parse is needed.
1497    let index_mtime = fs::metadata(&index_path)
1498        .ok()
1499        .and_then(|metadata| file_mtime_parts(&metadata));
1500    let stat_cache = IndexStatCache::from_index_mtime_only(index_mtime);
1501    let selected_paths = paths
1502        .iter()
1503        .map(|path| {
1504            let absolute = if path.is_absolute() {
1505                path.clone()
1506            } else {
1507                worktree_root.join(path)
1508            };
1509            let relative = absolute.strip_prefix(worktree_root).map_err(|_| {
1510                GitError::InvalidPath(format!("path {} is outside worktree", path.display()))
1511            })?;
1512            git_path_bytes(relative)
1513        })
1514        .collect::<Result<Vec<_>>>()?;
1515    let selected_paths = selected_paths.into_iter().collect::<BTreeSet<_>>();
1516    if selected_paths.is_empty()
1517        && !really_refresh
1518        && !index
1519            .entries
1520            .iter()
1521            .any(|entry| entry.flags & INDEX_FLAG_ASSUME_UNCHANGED != 0)
1522    {
1523        return refresh_all_index_paths_parallel(
1524            worktree_root,
1525            git_dir,
1526            format,
1527            index,
1528            stat_cache,
1529            quiet,
1530            ignore_missing,
1531            ignore_submodules,
1532            allow_unmerged,
1533            trust_filemode,
1534        );
1535    }
1536    let mut needs_update = false;
1537    let mut index_dirty = false;
1538    for entry in &mut index.entries {
1539        if index_entry_stage(entry) != 0 {
1540            continue;
1541        }
1542        if entry.flags & INDEX_FLAG_ASSUME_UNCHANGED != 0 {
1543            if !really_refresh {
1544                continue;
1545            }
1546            entry.flags &= !INDEX_FLAG_ASSUME_UNCHANGED;
1547            index_dirty = true;
1548        }
1549        let absolute = worktree_root.join(repo_path_to_os_path(entry.path.as_bytes())?);
1550        let Ok(metadata) = fs::metadata(&absolute) else {
1551            if ignore_missing {
1552                continue;
1553            }
1554            if !quiet {
1555                print_update_index_needs_update(entry.path.as_bytes());
1556            }
1557            needs_update = true;
1558            continue;
1559        };
1560        // git's `refresh_cache_ent` runs `ie_match_stat`, whose `S_IFGITLINK`
1561        // arm never re-reads content: a gitlink whose worktree path is a
1562        // directory is up to date (an unpopulated/HEAD-matching submodule), so
1563        // `--refresh` leaves it untouched and silent. Only a gitlink that is no
1564        // longer a directory (replaced by a file, or removed) is `TYPE_CHANGED`.
1565        // This is the single `sley_index::gitlink_stat_verdict` rule; without it
1566        // the `!is_file()` guard below mis-flagged every populated submodule as
1567        // "needs update". The populated-HEAD comparison is deliberately left to
1568        // status/diff (the unpopulated default is clean).
1569        if sley_index::is_gitlink(entry.mode) {
1570            match sley_index::gitlink_stat_verdict(&metadata) {
1571                sley_index::GitlinkStatVerdict::Populated => {
1572                    if ignore_submodules {
1573                        continue;
1574                    }
1575                    if let Some(oid) = sley_diff_merge::gitlink_head_oid(&absolute, format)
1576                        && oid != entry.oid
1577                    {
1578                        if !quiet {
1579                            print_update_index_needs_update(entry.path.as_bytes());
1580                        }
1581                        needs_update = true;
1582                    }
1583                    continue;
1584                }
1585                sley_index::GitlinkStatVerdict::TypeChanged => {
1586                    if !quiet {
1587                        print_update_index_needs_update(entry.path.as_bytes());
1588                    }
1589                    needs_update = true;
1590                    continue;
1591                }
1592            }
1593        }
1594        if !metadata.is_file() {
1595            if !quiet {
1596                print_update_index_needs_update(entry.path.as_bytes());
1597            }
1598            needs_update = true;
1599            continue;
1600        }
1601        // Stat shortcut: when the cached stat proves the file is unchanged since
1602        // it was staged, its content hashes to the cached oid by construction
1603        // (see `IndexStatCache`'s safety invariant). Skip the read+hash and just
1604        // refresh the stat fields from current metadata — byte-identical to the
1605        // clean arm below, since the oid stamped is the cached one and the
1606        // metadata is the same one that re-stamp would read.
1607        if stat_cache.reuse_index_entry(entry, &metadata).is_some() {
1608            continue;
1609        }
1610        let body = fs::read(&absolute)?;
1611        let object = EncodedObject::new(ObjectType::Blob, body);
1612        let oid = object.object_id(format)?;
1613        if oid != entry.oid || file_mode_with_trust(&metadata, trust_filemode) != entry.mode {
1614            if !quiet {
1615                print_update_index_needs_update(entry.path.as_bytes());
1616            }
1617            needs_update = true;
1618            if really_refresh
1619                && !selected_paths.is_empty()
1620                && selected_paths.contains(entry.path.as_bytes())
1621            {
1622                let updated_entry = index_entry_from_metadata_with_filemode(
1623                    entry.path.clone(),
1624                    oid,
1625                    &metadata,
1626                    trust_filemode,
1627                );
1628                if updated_entry != *entry {
1629                    *entry = updated_entry;
1630                    index_dirty = true;
1631                }
1632            }
1633            continue;
1634        }
1635        let updated_entry = index_entry_from_metadata_with_filemode(
1636            entry.path.clone(),
1637            oid,
1638            &metadata,
1639            trust_filemode,
1640        );
1641        if updated_entry != *entry {
1642            *entry = updated_entry;
1643            index_dirty = true;
1644        }
1645    }
1646    if index_dirty {
1647        write_repository_index_ref(git_dir, format, &index)?;
1648    }
1649    if needs_update && !quiet {
1650        return Err(GitError::Exit(1));
1651    }
1652    Ok(UpdateIndexResult {
1653        entries: index.entries.len(),
1654        updated: Vec::new(),
1655    })
1656}
1657
1658pub(crate) fn refresh_all_index_paths_parallel(
1659    worktree_root: &Path,
1660    git_dir: &Path,
1661    format: ObjectFormat,
1662    mut index: Index,
1663    stat_cache: IndexStatCache,
1664    quiet: bool,
1665    ignore_missing: bool,
1666    ignore_submodules: bool,
1667    _allow_unmerged: bool,
1668    trust_filemode: bool,
1669) -> Result<UpdateIndexResult> {
1670    let prechecks =
1671        tracked_only_non_clean_prechecks_parallel(worktree_root, &index, &stat_cache, false)?;
1672    let mut needs_update = false;
1673    let mut index_dirty = false;
1674    for precheck in prechecks {
1675        match precheck {
1676            TrackedOnlyPrecheck::Deleted(idx) => {
1677                if ignore_missing {
1678                    continue;
1679                }
1680                if !quiet {
1681                    print_update_index_needs_update(index.entries[idx].path.as_bytes());
1682                }
1683                needs_update = true;
1684            }
1685            TrackedOnlyPrecheck::Slow(idx) => {
1686                let entry = &mut index.entries[idx];
1687                let path = entry.path.as_bytes().to_vec();
1688                let absolute = worktree_root.join(repo_path_to_os_path(&path)?);
1689                let Ok(metadata) = fs::metadata(&absolute) else {
1690                    if ignore_missing {
1691                        continue;
1692                    }
1693                    if !quiet {
1694                        print_update_index_needs_update(&path);
1695                    }
1696                    needs_update = true;
1697                    continue;
1698                };
1699                // Gitlink: never re-read; a directory on disk is up to date (the
1700                // single `sley_index::gitlink_stat_verdict` rule, matching the
1701                // serial path above). Only a type-changed gitlink needs update.
1702                if sley_index::is_gitlink(entry.mode) {
1703                    match sley_index::gitlink_stat_verdict(&metadata) {
1704                        sley_index::GitlinkStatVerdict::Populated => {
1705                            if ignore_submodules {
1706                                continue;
1707                            }
1708                            if let Some(oid) = sley_diff_merge::gitlink_head_oid(&absolute, format)
1709                                && oid != entry.oid
1710                            {
1711                                if !quiet {
1712                                    print_update_index_needs_update(&path);
1713                                }
1714                                needs_update = true;
1715                            }
1716                            continue;
1717                        }
1718                        sley_index::GitlinkStatVerdict::TypeChanged => {
1719                            if !quiet {
1720                                print_update_index_needs_update(&path);
1721                            }
1722                            needs_update = true;
1723                            continue;
1724                        }
1725                    }
1726                }
1727                if !metadata.is_file() {
1728                    if !quiet {
1729                        print_update_index_needs_update(&path);
1730                    }
1731                    needs_update = true;
1732                    continue;
1733                }
1734                if stat_cache.reuse_index_entry(entry, &metadata).is_some() {
1735                    continue;
1736                }
1737                let body = fs::read(&absolute)?;
1738                let object = EncodedObject::new(ObjectType::Blob, body);
1739                let oid = object.object_id(format)?;
1740                if oid != entry.oid || file_mode_with_trust(&metadata, trust_filemode) != entry.mode
1741                {
1742                    if !quiet {
1743                        print_update_index_needs_update(&path);
1744                    }
1745                    needs_update = true;
1746                    continue;
1747                }
1748                let updated_entry = index_entry_from_metadata_with_filemode(
1749                    entry.path.clone(),
1750                    oid,
1751                    &metadata,
1752                    trust_filemode,
1753                );
1754                if updated_entry != *entry {
1755                    *entry = updated_entry;
1756                    index_dirty = true;
1757                }
1758            }
1759        }
1760    }
1761    if index_dirty {
1762        write_repository_index_ref(git_dir, format, &index)?;
1763    }
1764    if needs_update && !quiet {
1765        return Err(GitError::Exit(1));
1766    }
1767    Ok(UpdateIndexResult {
1768        entries: index.entries.len(),
1769        updated: Vec::new(),
1770    })
1771}
1772
1773pub fn update_index_again(
1774    worktree_root: impl AsRef<Path>,
1775    git_dir: impl AsRef<Path>,
1776    format: ObjectFormat,
1777    paths: &[PathBuf],
1778    options: UpdateIndexOptions,
1779) -> Result<UpdateIndexResult> {
1780    let worktree_root = worktree_root.as_ref();
1781    let git_dir = git_dir.as_ref();
1782    let index_path = repository_index_path(git_dir);
1783    if !index_path.exists() {
1784        return Ok(UpdateIndexResult {
1785            entries: 0,
1786            updated: Vec::new(),
1787        });
1788    }
1789    let index = Index::parse(&fs::read(&index_path)?, format)?;
1790    let db = FileObjectDatabase::from_git_dir(git_dir, format);
1791    let head_entries = head_tree_entries(git_dir, format, &db)?;
1792    let selected_paths = selected_git_paths(worktree_root, paths)?;
1793    let mut again_paths = Vec::new();
1794    for entry in &index.entries {
1795        if index_entry_stage(entry) != 0 {
1796            continue;
1797        }
1798        if !selected_paths.is_empty() && !git_path_selected(entry.path.as_bytes(), &selected_paths)
1799        {
1800            continue;
1801        }
1802        let differs_from_head = match head_entries.get(entry.path.as_bytes()) {
1803            Some(head_entry) => head_entry.oid != entry.oid || head_entry.mode != entry.mode,
1804            None => true,
1805        };
1806        if differs_from_head {
1807            again_paths.push(worktree_root.join(repo_path_to_os_path(entry.path.as_bytes())?));
1808        }
1809    }
1810    if again_paths.is_empty() {
1811        return Ok(UpdateIndexResult {
1812            entries: index.entries.len(),
1813            updated: Vec::new(),
1814        });
1815    }
1816    update_index_paths(worktree_root, git_dir, format, &again_paths, options)
1817}
1818
1819pub fn set_index_assume_unchanged_paths(
1820    worktree_root: impl AsRef<Path>,
1821    git_dir: impl AsRef<Path>,
1822    format: ObjectFormat,
1823    paths: &[PathBuf],
1824    assume_unchanged: bool,
1825) -> Result<UpdateIndexResult> {
1826    let worktree_root = worktree_root.as_ref();
1827    let git_dir = git_dir.as_ref();
1828    let index_path = repository_index_path(git_dir);
1829    let mut index = if index_path.exists() {
1830        Index::parse(&fs::read(&index_path)?, format)?
1831    } else {
1832        Index {
1833            version: 2,
1834            entries: Vec::new(),
1835            extensions: Vec::new(),
1836            checksum: None,
1837        }
1838    };
1839    let sparse = active_sparse_checkout(git_dir)?;
1840    let db = FileObjectDatabase::from_git_dir(git_dir, format);
1841    if index.is_sparse() {
1842        expand_sparse_index(&mut index, &db, format)?;
1843    }
1844    let selected_paths = paths
1845        .iter()
1846        .map(|path| {
1847            let absolute = if path.is_absolute() {
1848                path.clone()
1849            } else {
1850                worktree_root.join(path)
1851            };
1852            let relative = absolute.strip_prefix(worktree_root).map_err(|_| {
1853                GitError::InvalidPath(format!("path {} is outside worktree", path.display()))
1854            })?;
1855            git_path_bytes(relative)
1856        })
1857        .collect::<Result<Vec<_>>>()?;
1858    for path in selected_paths {
1859        if let Some(entry) = index.entries.iter_mut().find(|entry| entry.path == path) {
1860            if assume_unchanged {
1861                entry.flags |= INDEX_FLAG_ASSUME_UNCHANGED;
1862            } else {
1863                entry.flags &= !INDEX_FLAG_ASSUME_UNCHANGED;
1864            }
1865        }
1866    }
1867    normalize_index_version_for_extended_flags(&mut index);
1868    if let Some((sparse, mode)) = sparse
1869        && sparse.sparse_index
1870    {
1871        let matcher = SparseMatcher::new(&sparse, mode);
1872        collapse_to_sparse_index(&mut index, &matcher, &db, format)?;
1873    }
1874    write_repository_index_ref(git_dir, format, &index)?;
1875    Ok(UpdateIndexResult {
1876        entries: index.entries.len(),
1877        updated: Vec::new(),
1878    })
1879}
1880
1881pub(crate) fn selected_git_paths(
1882    worktree_root: &Path,
1883    paths: &[PathBuf],
1884) -> Result<BTreeSet<Vec<u8>>> {
1885    paths
1886        .iter()
1887        .map(|path| {
1888            let absolute = if path.is_absolute() {
1889                path.clone()
1890            } else {
1891                worktree_root.join(path)
1892            };
1893            let relative = absolute.strip_prefix(worktree_root).map_err(|_| {
1894                GitError::InvalidPath(format!("path {} is outside worktree", path.display()))
1895            })?;
1896            git_path_bytes(relative)
1897        })
1898        .collect()
1899}
1900
1901pub(crate) fn git_path_selected(path: &[u8], selected_paths: &BTreeSet<Vec<u8>>) -> bool {
1902    selected_paths
1903        .iter()
1904        .any(|selected| path == selected || index_entry_is_under_path(path, selected))
1905}
1906
1907pub fn set_index_skip_worktree_paths(
1908    worktree_root: impl AsRef<Path>,
1909    git_dir: impl AsRef<Path>,
1910    format: ObjectFormat,
1911    paths: &[PathBuf],
1912    skip_worktree: bool,
1913) -> Result<UpdateIndexResult> {
1914    let worktree_root = worktree_root.as_ref();
1915    let git_dir = git_dir.as_ref();
1916    let index_path = repository_index_path(git_dir);
1917    let mut index = if index_path.exists() {
1918        Index::parse(&fs::read(&index_path)?, format)?
1919    } else {
1920        Index {
1921            version: 2,
1922            entries: Vec::new(),
1923            extensions: Vec::new(),
1924            checksum: None,
1925        }
1926    };
1927    let sparse = active_sparse_checkout(git_dir)?;
1928    let db = FileObjectDatabase::from_git_dir(git_dir, format);
1929    if index.is_sparse() {
1930        expand_sparse_index(&mut index, &db, format)?;
1931    }
1932    let selected_paths = paths
1933        .iter()
1934        .map(|path| {
1935            let absolute = if path.is_absolute() {
1936                path.clone()
1937            } else {
1938                worktree_root.join(path)
1939            };
1940            let relative = absolute.strip_prefix(worktree_root).map_err(|_| {
1941                GitError::InvalidPath(format!("path {} is outside worktree", path.display()))
1942            })?;
1943            git_path_bytes(relative)
1944        })
1945        .collect::<Result<Vec<_>>>()?;
1946    for path in selected_paths {
1947        if let Some(entry) = index.entries.iter_mut().find(|entry| entry.path == path) {
1948            if skip_worktree {
1949                entry.flags |= INDEX_FLAG_EXTENDED;
1950                entry.flags_extended |= INDEX_EXTENDED_FLAG_SKIP_WORKTREE;
1951            } else {
1952                entry.flags_extended &= !INDEX_EXTENDED_FLAG_SKIP_WORKTREE;
1953                if entry.flags_extended == 0 {
1954                    entry.flags &= !INDEX_FLAG_EXTENDED;
1955                }
1956            }
1957        }
1958    }
1959    normalize_index_version_for_extended_flags(&mut index);
1960    if let Some((sparse, mode)) = sparse
1961        && sparse.sparse_index
1962    {
1963        let matcher = SparseMatcher::new(&sparse, mode);
1964        collapse_to_sparse_index(&mut index, &matcher, &db, format)?;
1965    }
1966    write_repository_index_ref(git_dir, format, &index)?;
1967    Ok(UpdateIndexResult {
1968        entries: index.entries.len(),
1969        updated: Vec::new(),
1970    })
1971}
1972
1973pub fn set_index_fsmonitor_valid_paths(
1974    worktree_root: impl AsRef<Path>,
1975    git_dir: impl AsRef<Path>,
1976    format: ObjectFormat,
1977    paths: &[PathBuf],
1978    _fsmonitor_valid: bool,
1979) -> Result<UpdateIndexResult> {
1980    let worktree_root = worktree_root.as_ref();
1981    let git_dir = git_dir.as_ref();
1982    let index_path = repository_index_path(git_dir);
1983    let index = if index_path.exists() {
1984        Index::parse(&fs::read(&index_path)?, format)?
1985    } else {
1986        Index {
1987            version: 2,
1988            entries: Vec::new(),
1989            extensions: Vec::new(),
1990            checksum: None,
1991        }
1992    };
1993    let selected_paths = paths
1994        .iter()
1995        .map(|path| {
1996            let absolute = if path.is_absolute() {
1997                path.clone()
1998            } else {
1999                worktree_root.join(path)
2000            };
2001            let relative = absolute.strip_prefix(worktree_root).map_err(|_| {
2002                GitError::InvalidPath(format!("path {} is outside worktree", path.display()))
2003            })?;
2004            git_path_bytes(relative)
2005        })
2006        .collect::<Result<Vec<_>>>()?;
2007    for path in selected_paths {
2008        if !index.entries.iter().any(|entry| entry.path == path) {
2009            eprintln!(
2010                "fatal: Unable to mark file {}",
2011                String::from_utf8_lossy(&path)
2012            );
2013            return Err(GitError::Exit(128));
2014        }
2015    }
2016    Ok(UpdateIndexResult {
2017        entries: index.entries.len(),
2018        updated: Vec::new(),
2019    })
2020}
2021
2022pub fn set_index_version(
2023    git_dir: impl AsRef<Path>,
2024    format: ObjectFormat,
2025    version: u32,
2026    verbose: bool,
2027) -> Result<UpdateIndexResult> {
2028    if !matches!(version, 2..=4) {
2029        return Err(GitError::Unsupported(format!(
2030            "update-index currently supports --index-version 2, 3, or 4, got {version}"
2031        )));
2032    }
2033    let git_dir = git_dir.as_ref();
2034    let index_path = repository_index_path(git_dir);
2035    let mut index = if index_path.exists() {
2036        Index::parse(&fs::read(&index_path)?, format)?
2037    } else {
2038        Index {
2039            version: 2,
2040            entries: Vec::new(),
2041            extensions: Vec::new(),
2042            checksum: None,
2043        }
2044    };
2045    // git reports the transition unconditionally under --verbose, even when the
2046    // requested version equals the current one ("was 4, set to 4").
2047    let previous = index.version;
2048    if verbose {
2049        println!("index-version: was {previous}, set to {version}");
2050    }
2051    index.version = version;
2052    normalize_index_version_for_extended_flags(&mut index);
2053    write_repository_index_ref(git_dir, format, &index)?;
2054    Ok(UpdateIndexResult {
2055        entries: index.entries.len(),
2056        updated: Vec::new(),
2057    })
2058}
2059
2060pub fn force_write_index(
2061    git_dir: impl AsRef<Path>,
2062    format: ObjectFormat,
2063) -> Result<UpdateIndexResult> {
2064    let git_dir = git_dir.as_ref();
2065    let index_path = repository_index_path(git_dir);
2066    let mut index = if index_path.exists() {
2067        Index::parse(&fs::read(&index_path)?, format)?
2068    } else {
2069        Index {
2070            version: 2,
2071            entries: Vec::new(),
2072            extensions: Vec::new(),
2073            checksum: None,
2074        }
2075    };
2076    normalize_index_version_for_extended_flags(&mut index);
2077    write_repository_index_ref(git_dir, format, &index)?;
2078    Ok(UpdateIndexResult {
2079        entries: index.entries.len(),
2080        updated: Vec::new(),
2081    })
2082}
2083
2084pub fn enable_untracked_cache(
2085    worktree_root: impl AsRef<Path>,
2086    git_dir: impl AsRef<Path>,
2087    format: ObjectFormat,
2088) -> Result<()> {
2089    let worktree_root = worktree_root.as_ref();
2090    let git_dir = git_dir.as_ref();
2091    let index_path = repository_index_path(git_dir);
2092    let mut index = if index_path.exists() {
2093        Index::parse(&fs::read(&index_path)?, format)?
2094    } else {
2095        empty_index()
2096    };
2097    let ident = untracked_cache_ident(worktree_root);
2098    let dir_flags = untracked_cache_dir_flags(StatusUntrackedMode::Normal);
2099    let cache = match index.untracked_cache(format)? {
2100        Some(mut cache) if cache.ident == ident => {
2101            cache.dir_flags = dir_flags;
2102            cache
2103        }
2104        _ => UntrackedCache::new(format, ident, dir_flags),
2105    };
2106    index.set_untracked_cache(format, Some(&cache))?;
2107    write_repository_index_ref(git_dir, format, &index)?;
2108    Ok(())
2109}
2110
2111pub fn disable_untracked_cache(git_dir: impl AsRef<Path>, format: ObjectFormat) -> Result<()> {
2112    let git_dir = git_dir.as_ref();
2113    let index_path = repository_index_path(git_dir);
2114    if !index_path.exists() {
2115        return Ok(());
2116    }
2117    let mut index = Index::parse(&fs::read(&index_path)?, format)?;
2118    index.set_untracked_cache(format, None)?;
2119    write_repository_index_ref(git_dir, format, &index)?;
2120    Ok(())
2121}
2122
2123pub fn refresh_untracked_cache_after_status(
2124    worktree_root: impl AsRef<Path>,
2125    git_dir: impl AsRef<Path>,
2126    format: ObjectFormat,
2127    config: &GitConfig,
2128    untracked_mode: StatusUntrackedMode,
2129) -> Result<()> {
2130    if matches!(untracked_mode, StatusUntrackedMode::None) {
2131        return Ok(());
2132    }
2133    let worktree_root = worktree_root.as_ref();
2134    let git_dir = git_dir.as_ref();
2135    let index_path = repository_index_path(git_dir);
2136    let untracked_cache_setting = config.get("core", None, "untrackedCache");
2137    match untracked_cache_setting {
2138        Some("keep") | None => {
2139            if !repository_index_has_extension(git_dir, format, b"UNTR")? {
2140                return Ok(());
2141            }
2142        }
2143        Some("false" | "no" | "off" | "0") | Some("true" | "yes" | "on" | "1") => {}
2144        Some(_) => {
2145            if !repository_index_has_extension(git_dir, format, b"UNTR")? {
2146                return Ok(());
2147            }
2148        }
2149    }
2150    let mut index = if index_path.exists() {
2151        Index::parse(&fs::read(&index_path)?, format)?
2152    } else {
2153        empty_index()
2154    };
2155    match untracked_cache_setting {
2156        Some("false") | Some("no") | Some("off") | Some("0") => {
2157            index.set_untracked_cache(format, None)?;
2158            write_repository_index_ref(git_dir, format, &index)?;
2159            return Ok(());
2160        }
2161        Some("true") | Some("yes") | Some("on") | Some("1") => {}
2162        Some("keep") | None => {
2163            if index.untracked_cache(format)?.is_none() {
2164                return Ok(());
2165            }
2166        }
2167        Some(_) => {
2168            if index.untracked_cache(format)?.is_none() {
2169                return Ok(());
2170            }
2171        }
2172    }
2173    let old_cache = index.untracked_cache(format).ok().flatten();
2174    let ident = untracked_cache_ident(worktree_root);
2175    if old_cache.as_ref().is_some_and(|cache| cache.ident != ident) {
2176        eprintln!("warning: untracked cache is disabled on this system or location");
2177        emit_untracked_cache_bypass_trace();
2178        return Ok(());
2179    }
2180    let cache = build_untracked_cache(worktree_root, git_dir, format, &index, untracked_mode)?;
2181    emit_untracked_cache_trace(old_cache.as_ref(), &cache);
2182    index.set_untracked_cache(format, Some(&cache))?;
2183    write_repository_index_ref(git_dir, format, &index)?;
2184    Ok(())
2185}
2186
2187pub(crate) fn repository_index_has_extension(
2188    git_dir: &Path,
2189    format: ObjectFormat,
2190    signature: &[u8; 4],
2191) -> Result<bool> {
2192    let index_path = repository_index_path(git_dir);
2193    if !index_path.exists() {
2194        return Ok(false);
2195    }
2196    let bytes = read_borrowed_index_bytes(&index_path)?;
2197    sley_index::Index::bytes_have_extension(bytes.as_ref(), format, signature)
2198}
2199
2200pub fn emit_untracked_cache_bypass_trace() {
2201    sley_core::trace2::perf_read_directory_data("path", "");
2202}
2203
2204pub(crate) fn index_extensions_without_cache_tree(extensions: &[u8]) -> Vec<u8> {
2205    let mut offset = 0;
2206    let mut filtered = Vec::new();
2207    while offset < extensions.len() {
2208        if extensions.len().saturating_sub(offset) < 8 {
2209            return Vec::new();
2210        }
2211        let signature = &extensions[offset..offset + 4];
2212        let size = u32::from_be_bytes([
2213            extensions[offset + 4],
2214            extensions[offset + 5],
2215            extensions[offset + 6],
2216            extensions[offset + 7],
2217        ]) as usize;
2218        let end = offset + 8 + size;
2219        if end > extensions.len() {
2220            return Vec::new();
2221        }
2222        if signature != b"TREE" {
2223            filtered.extend_from_slice(&extensions[offset..end]);
2224        }
2225        offset = end;
2226    }
2227    filtered
2228}
2229
2230pub(crate) fn update_cache_tree_for_git_paths(
2231    index: &mut Index,
2232    format: ObjectFormat,
2233    odb: &FileObjectDatabase,
2234    paths: &[Vec<u8>],
2235) -> Result<()> {
2236    if paths.is_empty() {
2237        return Ok(());
2238    }
2239    match index.cache_tree(format) {
2240        Ok(Some(mut cache_tree)) if !cache_tree_has_invalid_untouched(&cache_tree, b"", paths) => {
2241            for path in paths {
2242                invalidate_cache_tree_path(&mut cache_tree, path);
2243            }
2244            index.set_cache_tree(Some(&cache_tree))?;
2245            return Ok(());
2246        }
2247        Ok(_) => {}
2248        Err(_) => {
2249            index.extensions = index_extensions_without_cache_tree(&index.extensions);
2250            return Ok(());
2251        }
2252    }
2253    let cache_tree = match build_cache_tree_for_index_update(&index.entries, b"", paths, odb) {
2254        Ok(cache_tree) => cache_tree,
2255        Err(_) => {
2256            index.extensions = index_extensions_without_cache_tree(&index.extensions);
2257            return Ok(());
2258        }
2259    };
2260    index.set_cache_tree(Some(&cache_tree))?;
2261    Ok(())
2262}
2263
2264/// Rebuild the index cache-tree (`TREE`) extension from the current entries.
2265///
2266/// This is for commands that have just materialized a complete staged tree
2267/// (read-tree, checkout/reset, write-tree/commit). Incremental index mutators
2268/// should use [`update_cache_tree_for_git_paths`] so untouched valid subtrees
2269/// can be preserved and touched paths become invalid, matching git-add.
2270pub fn refresh_cache_tree(index: &mut Index, odb: &FileObjectDatabase) {
2271    match build_cache_tree_for_index_update(&index.entries, b"", &[], odb) {
2272        Ok(cache_tree) => {
2273            if index.set_cache_tree(Some(&cache_tree)).is_err() {
2274                index.extensions = index_extensions_without_cache_tree(&index.extensions);
2275            }
2276        }
2277        Err(_) => {
2278            index.extensions = index_extensions_without_cache_tree(&index.extensions);
2279        }
2280    }
2281}
2282
2283pub fn refresh_repository_cache_tree(
2284    git_dir: &Path,
2285    format: ObjectFormat,
2286    odb: &FileObjectDatabase,
2287) -> Result<()> {
2288    let index_path = repository_index_path(git_dir);
2289    let Ok(bytes) = fs::read(&index_path) else {
2290        return Ok(());
2291    };
2292    let mut index = Index::parse(&bytes, format)?;
2293    if index.split_index_link(format)?.is_some() {
2294        return Ok(());
2295    }
2296    refresh_cache_tree(&mut index, odb);
2297    write_repository_index_ref(git_dir, format, &index)
2298}
2299
2300fn invalidate_cache_tree_path(tree: &mut CacheTree, path: &[u8]) {
2301    tree.entry_count = -1;
2302    tree.oid = None;
2303
2304    let mut rest = path;
2305    while rest.first() == Some(&b'/') {
2306        rest = &rest[1..];
2307    }
2308    if rest.is_empty() {
2309        return;
2310    }
2311    let split = rest
2312        .iter()
2313        .position(|byte| *byte == b'/')
2314        .unwrap_or(rest.len());
2315    let component = &rest[..split];
2316    let remaining = if split == rest.len() {
2317        &[][..]
2318    } else {
2319        &rest[split + 1..]
2320    };
2321    if let Some(child) = tree
2322        .subtrees
2323        .iter_mut()
2324        .find(|child| child.name.as_slice() == component)
2325    {
2326        invalidate_cache_tree_path(&mut child.tree, remaining);
2327    }
2328}
2329
2330fn cache_tree_has_invalid_untouched(tree: &CacheTree, prefix: &[u8], paths: &[Vec<u8>]) -> bool {
2331    if !cache_tree_prefix_touched(prefix, paths) && (tree.entry_count < 0 || tree.oid.is_none()) {
2332        return true;
2333    }
2334    tree.subtrees.iter().any(|child| {
2335        let mut child_prefix = Vec::with_capacity(prefix.len() + child.name.len() + 1);
2336        child_prefix.extend_from_slice(prefix);
2337        child_prefix.extend_from_slice(&child.name);
2338        child_prefix.push(b'/');
2339        cache_tree_has_invalid_untouched(&child.tree, &child_prefix, paths)
2340    })
2341}
2342
2343fn build_cache_tree_for_index_update(
2344    entries: &[IndexEntry],
2345    prefix: &[u8],
2346    invalid_paths: &[Vec<u8>],
2347    odb: &FileObjectDatabase,
2348) -> Result<CacheTree> {
2349    let mut invalid = cache_tree_prefix_touched(prefix, invalid_paths);
2350    let mut subtrees = Vec::new();
2351    let mut tree_entries = Vec::new();
2352    let mut index = 0usize;
2353    while index < entries.len() {
2354        let entry = &entries[index];
2355        if entry.stage() != Stage::Normal || entry.is_intent_to_add() {
2356            invalid = true;
2357            index += 1;
2358            continue;
2359        }
2360        let path = entry.path.as_bytes();
2361        let Some(remainder) = path.strip_prefix(prefix) else {
2362            return Err(GitError::InvalidPath(format!(
2363                "invalid index path {}",
2364                String::from_utf8_lossy(path)
2365            )));
2366        };
2367        if remainder.is_empty() || remainder[0] == b'/' {
2368            return Err(GitError::InvalidPath(format!(
2369                "invalid index path {}",
2370                String::from_utf8_lossy(path)
2371            )));
2372        }
2373        if entry.mode == SPARSE_DIR_MODE
2374            && let Some(name) = remainder.strip_suffix(b"/")
2375            && !name.is_empty()
2376            && !name.contains(&b'/')
2377        {
2378            if !invalid {
2379                tree_entries.push(TreeEntry {
2380                    mode: SPARSE_DIR_MODE,
2381                    name: BString::from(name),
2382                    oid: entry.oid,
2383                });
2384            }
2385            index += 1;
2386            continue;
2387        }
2388        if let Some(slash) = remainder.iter().position(|byte| *byte == b'/') {
2389            let name = &remainder[..slash];
2390            if name.is_empty() {
2391                return Err(GitError::InvalidPath(format!(
2392                    "invalid index path {}",
2393                    String::from_utf8_lossy(path)
2394                )));
2395            }
2396            let start = index;
2397            index += 1;
2398            while index < entries.len()
2399                && same_tree_component(entries[index].path.as_bytes(), prefix, name)?
2400            {
2401                index += 1;
2402            }
2403            let mut child_prefix = Vec::with_capacity(prefix.len() + name.len() + 1);
2404            child_prefix.extend_from_slice(prefix);
2405            child_prefix.extend_from_slice(name);
2406            child_prefix.push(b'/');
2407            let child_tree = build_cache_tree_for_index_update(
2408                &entries[start..index],
2409                &child_prefix,
2410                invalid_paths,
2411                odb,
2412            )?;
2413            if !invalid {
2414                if let Some(oid) = child_tree.oid {
2415                    tree_entries.push(TreeEntry {
2416                        mode: 0o040000,
2417                        name: BString::from(name),
2418                        oid,
2419                    });
2420                } else {
2421                    invalid = true;
2422                }
2423            }
2424            subtrees.push(sley_index::CacheTreeChild {
2425                name: name.to_vec(),
2426                tree: child_tree,
2427            });
2428            continue;
2429        }
2430        if !invalid {
2431            tree_entries.push(TreeEntry {
2432                mode: entry.mode,
2433                name: BString::from(remainder),
2434                oid: entry.oid,
2435            });
2436        }
2437        index += 1;
2438    }
2439
2440    if invalid {
2441        return Ok(CacheTree {
2442            entry_count: -1,
2443            oid: None,
2444            subtrees,
2445        });
2446    }
2447    tree_entries.sort_by(|left, right| {
2448        git_tree_entry_cmp(
2449            left.name.as_bytes(),
2450            left.mode,
2451            right.name.as_bytes(),
2452            right.mode,
2453        )
2454    });
2455    let oid = odb.write_object(EncodedObject::new(
2456        ObjectType::Tree,
2457        Tree {
2458            entries: tree_entries,
2459        }
2460        .write(),
2461    ))?;
2462    let entry_count = i32::try_from(entries.len())
2463        .map_err(|_| GitError::InvalidFormat("cache-tree entry count overflow".into()))?;
2464    Ok(CacheTree {
2465        entry_count,
2466        oid: Some(oid),
2467        subtrees,
2468    })
2469}
2470
2471fn cache_tree_prefix_touched(prefix: &[u8], paths: &[Vec<u8>]) -> bool {
2472    if paths.is_empty() {
2473        return false;
2474    }
2475    if prefix.is_empty() {
2476        return true;
2477    }
2478    let dir = prefix.strip_suffix(b"/").unwrap_or(prefix);
2479    paths
2480        .iter()
2481        .any(|path| path.as_slice() == dir || path.starts_with(prefix))
2482}
2483
2484#[derive(Clone)]
2485pub(crate) struct ResolveUndoRecord {
2486    pub(crate) path: Vec<u8>,
2487    pub(crate) stages: [Option<(u32, ObjectId)>; 3],
2488}
2489
2490pub(crate) fn record_resolve_undo_for_path(
2491    index: &mut Index,
2492    format: ObjectFormat,
2493    path: &[u8],
2494    entries: &[IndexEntry],
2495) -> Result<()> {
2496    let mut stages = [None, None, None];
2497    for entry in entries {
2498        match entry.stage() {
2499            Stage::Base => stages[0] = Some((entry.mode, entry.oid)),
2500            Stage::Ours => stages[1] = Some((entry.mode, entry.oid)),
2501            Stage::Theirs => stages[2] = Some((entry.mode, entry.oid)),
2502            Stage::Normal => {}
2503        }
2504    }
2505    if stages.iter().all(Option::is_none) {
2506        return Ok(());
2507    }
2508    let mut records = parse_resolve_undo_records(index.extension(b"REUC")?, format)?;
2509    records.retain(|record| record.path.as_slice() != path);
2510    records.push(ResolveUndoRecord {
2511        path: path.to_vec(),
2512        stages,
2513    });
2514    records.sort_by(|left, right| left.path.cmp(&right.path));
2515    set_resolve_undo_extension(index, &records)
2516}
2517
2518pub(crate) fn record_resolve_undo_for_range(
2519    index: &mut Index,
2520    format: ObjectFormat,
2521    path: &[u8],
2522    range: Range<usize>,
2523) -> Result<()> {
2524    if range.is_empty() {
2525        return Ok(());
2526    }
2527    let entries = index.entries[range].to_vec();
2528    record_resolve_undo_for_path(index, format, path, &entries)
2529}
2530
2531pub(crate) fn parse_resolve_undo_records(
2532    body: Option<&[u8]>,
2533    format: ObjectFormat,
2534) -> Result<Vec<ResolveUndoRecord>> {
2535    let Some(body) = body else {
2536        return Ok(Vec::new());
2537    };
2538    let mut records = Vec::new();
2539    let mut offset = 0usize;
2540    while offset < body.len() {
2541        let path_end = body[offset..]
2542            .iter()
2543            .position(|byte| *byte == 0)
2544            .ok_or_else(|| GitError::InvalidFormat("truncated REUC path".into()))?
2545            + offset;
2546        let path = body[offset..path_end].to_vec();
2547        offset = path_end + 1;
2548
2549        let mut modes = [0u32; 3];
2550        for mode in &mut modes {
2551            let mode_end = body[offset..]
2552                .iter()
2553                .position(|byte| *byte == 0)
2554                .ok_or_else(|| GitError::InvalidFormat("truncated REUC mode".into()))?
2555                + offset;
2556            let text = std::str::from_utf8(&body[offset..mode_end])
2557                .map_err(|_| GitError::InvalidFormat("invalid REUC mode".into()))?;
2558            *mode = u32::from_str_radix(text, 8)
2559                .map_err(|_| GitError::InvalidFormat("invalid REUC mode".into()))?;
2560            offset = mode_end + 1;
2561        }
2562
2563        let mut stages = [None, None, None];
2564        for (idx, mode) in modes.into_iter().enumerate() {
2565            if mode == 0 {
2566                continue;
2567            }
2568            let end = offset
2569                .checked_add(format.raw_len())
2570                .ok_or_else(|| GitError::InvalidFormat("REUC oid length overflow".into()))?;
2571            if end > body.len() {
2572                return Err(GitError::InvalidFormat("truncated REUC oid".into()));
2573            }
2574            stages[idx] = Some((mode, ObjectId::from_raw(format, &body[offset..end])?));
2575            offset = end;
2576        }
2577        records.push(ResolveUndoRecord { path, stages });
2578    }
2579    Ok(records)
2580}
2581
2582pub(crate) fn set_resolve_undo_extension(
2583    index: &mut Index,
2584    records: &[ResolveUndoRecord],
2585) -> Result<()> {
2586    let mut body = Vec::new();
2587    for record in records {
2588        body.extend_from_slice(&record.path);
2589        body.push(0);
2590        for stage in record.stages {
2591            match stage {
2592                Some((mode, _)) => body.extend_from_slice(format!("{mode:o}").as_bytes()),
2593                None => body.push(b'0'),
2594            }
2595            body.push(0);
2596        }
2597        for (_, oid) in record.stages.into_iter().flatten() {
2598            body.extend_from_slice(oid.as_bytes());
2599        }
2600    }
2601
2602    let chunks = index.extension_chunks()?;
2603    let mut rebuilt = Vec::with_capacity(index.extensions.len() + body.len() + 8);
2604    let mut replaced = false;
2605    for (signature, chunk_body) in chunks {
2606        if &signature == b"REUC" {
2607            if !body.is_empty() {
2608                append_index_extension(&mut rebuilt, b"REUC", &body)?;
2609            }
2610            replaced = true;
2611        } else {
2612            append_index_extension(&mut rebuilt, &signature, chunk_body)?;
2613        }
2614    }
2615    if !replaced && !body.is_empty() {
2616        append_index_extension(&mut rebuilt, b"REUC", &body)?;
2617    }
2618    index.extensions = rebuilt;
2619    Ok(())
2620}
2621
2622pub fn clear_resolve_undo(git_dir: impl AsRef<Path>, format: ObjectFormat) -> Result<()> {
2623    let git_dir = git_dir.as_ref();
2624    let index_path = repository_index_path(git_dir);
2625    match fs::read(&index_path) {
2626        Ok(bytes) => {
2627            let mut index = Index::parse(&bytes, format)?;
2628            set_resolve_undo_extension(&mut index, &[])?;
2629            write_repository_index_ref(git_dir, format, &index)
2630        }
2631        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
2632        Err(err) => Err(err.into()),
2633    }
2634}
2635
2636pub(crate) fn append_index_extension(
2637    out: &mut Vec<u8>,
2638    signature: &[u8; 4],
2639    body: &[u8],
2640) -> Result<()> {
2641    let len = u32::try_from(body.len())
2642        .map_err(|_| GitError::InvalidFormat("index extension body too large".into()))?;
2643    out.extend_from_slice(signature);
2644    out.extend_from_slice(&len.to_be_bytes());
2645    out.extend_from_slice(body);
2646    Ok(())
2647}
2648
2649pub(crate) fn index_extensions_without_split_index_link(extensions: &[u8]) -> Vec<u8> {
2650    let mut offset = 0;
2651    let mut filtered = Vec::new();
2652    while offset < extensions.len() {
2653        if extensions.len().saturating_sub(offset) < 8 {
2654            filtered.extend_from_slice(&extensions[offset..]);
2655            break;
2656        }
2657        let signature = &extensions[offset..offset + 4];
2658        let len = u32::from_be_bytes([
2659            extensions[offset + 4],
2660            extensions[offset + 5],
2661            extensions[offset + 6],
2662            extensions[offset + 7],
2663        ]) as usize;
2664        let end = offset.saturating_add(8).saturating_add(len);
2665        if end > extensions.len() {
2666            filtered.extend_from_slice(&extensions[offset..]);
2667            break;
2668        }
2669        if signature != b"link" {
2670            filtered.extend_from_slice(&extensions[offset..end]);
2671        }
2672        offset = end;
2673    }
2674    filtered
2675}
2676
2677pub(crate) fn preserved_index_extensions(git_dir: &Path, format: ObjectFormat) -> Result<Vec<u8>> {
2678    let index_path = repository_index_path(git_dir);
2679    match fs::read(&index_path) {
2680        Ok(bytes) if bytes.is_empty() => Ok(Vec::new()),
2681        Ok(bytes) => {
2682            let index = Index::parse(&bytes, format)?;
2683            Ok(index_extensions_without_cache_tree_or_resolve_undo(
2684                &index.extensions,
2685            ))
2686        }
2687        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
2688        Err(err) => Err(err.into()),
2689    }
2690}
2691
2692pub(crate) fn index_extensions_without_cache_tree_or_resolve_undo(extensions: &[u8]) -> Vec<u8> {
2693    let mut filtered = Vec::new();
2694    let mut offset = 0usize;
2695    while offset + 8 <= extensions.len() {
2696        let signature = &extensions[offset..offset + 4];
2697        let len = u32::from_be_bytes([
2698            extensions[offset + 4],
2699            extensions[offset + 5],
2700            extensions[offset + 6],
2701            extensions[offset + 7],
2702        ]) as usize;
2703        let end = offset + 8 + len;
2704        if end > extensions.len() {
2705            filtered.extend_from_slice(&extensions[offset..]);
2706            break;
2707        }
2708        if signature != b"TREE" && signature != b"REUC" {
2709            filtered.extend_from_slice(&extensions[offset..end]);
2710        }
2711        offset = end;
2712    }
2713    filtered
2714}
2715
2716pub(crate) fn repository_index_is_split(git_dir: &Path, format: ObjectFormat) -> Result<bool> {
2717    let index_path = repository_index_path(git_dir);
2718    match fs::read(index_path) {
2719        Ok(bytes) if bytes.is_empty() => Ok(false),
2720        Ok(bytes) => Ok(Index::parse(&bytes, format)?
2721            .split_index_link(format)?
2722            .is_some()),
2723        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false),
2724        Err(err) => Err(err.into()),
2725    }
2726}
2727
2728pub(crate) fn git_test_split_index_enabled() -> bool {
2729    env::var("GIT_TEST_SPLIT_INDEX")
2730        .ok()
2731        .is_some_and(|value| !matches!(value.as_str(), "" | "0" | "false" | "False" | "FALSE"))
2732}
2733
2734pub fn write_repository_index(git_dir: &Path, format: ObjectFormat, index: Index) -> Result<()> {
2735    let split = index.split_index_link(format)?.is_some()
2736        || repository_index_is_split(git_dir, format)?
2737        || git_test_split_index_enabled();
2738    write_repository_index_ref_with_split(git_dir, format, &index, split)
2739}
2740
2741pub fn write_repository_index_ref(
2742    git_dir: &Path,
2743    format: ObjectFormat,
2744    index: &Index,
2745) -> Result<()> {
2746    let split = index.split_index_link(format)?.is_some()
2747        || repository_index_is_split(git_dir, format)?
2748        || git_test_split_index_enabled();
2749    write_repository_index_ref_with_split(git_dir, format, index, split)
2750}
2751
2752/// As [`write_repository_index_ref`], but `skip_hash` writes a null trailing
2753/// checksum (git's `index.skipHash` / `feature.manyFiles`).
2754pub fn write_repository_index_ref_skip_hash(
2755    git_dir: &Path,
2756    format: ObjectFormat,
2757    index: &Index,
2758    skip_hash: bool,
2759) -> Result<()> {
2760    let split = index.split_index_link(format)?.is_some()
2761        || repository_index_is_split(git_dir, format)?
2762        || git_test_split_index_enabled();
2763    write_repository_index_ref_with_split_skip_hash(git_dir, format, index, split, skip_hash)
2764}
2765
2766pub(crate) fn write_repository_index_ref_with_split(
2767    git_dir: &Path,
2768    format: ObjectFormat,
2769    index: &Index,
2770    split: bool,
2771) -> Result<()> {
2772    write_repository_index_ref_with_split_skip_hash(git_dir, format, index, split, false)
2773}
2774
2775/// As [`write_repository_index_ref_with_split`], but `skip_hash` leaves the
2776/// trailing checksum as the null oid (git's `index.skipHash`). Only the
2777/// non-split write honors it (the case `add`/`update-index` exercise); the
2778/// reader accepts a null trailing hash regardless (see verify_hdr).
2779pub(crate) fn write_repository_index_ref_with_split_skip_hash(
2780    git_dir: &Path,
2781    format: ObjectFormat,
2782    index: &Index,
2783    split: bool,
2784    skip_hash: bool,
2785) -> Result<()> {
2786    let index_path = repository_index_path(git_dir);
2787    if !split || alternate_index_output_path(git_dir, &index_path) {
2788        let smudged_entries = racily_clean_entry_indexes_before_write(git_dir, format, index)?;
2789        let extensions = if index.split_index_link(format)?.is_some() {
2790            Cow::Owned(index_extensions_without_split_index_link(&index.extensions))
2791        } else {
2792            Cow::Borrowed(index.extensions.as_slice())
2793        };
2794        let mut bytes = if smudged_entries.is_empty() && matches!(extensions, Cow::Borrowed(_)) {
2795            index.write(format)?
2796        } else {
2797            write_index_with_entry_size_overrides(format, index, &smudged_entries, &extensions)?
2798        };
2799        if skip_hash {
2800            zero_trailing_index_hash(&mut bytes, format);
2801        }
2802        fs::write(&index_path, bytes)?;
2803        apply_index_shared_file_mode(git_dir, &index_path, None)?;
2804        return Ok(());
2805    }
2806
2807    if let Some(link) = index.split_index_link(format)?
2808        && !link.base_oid.is_null()
2809        && let Some(base) = read_shared_index_for_link(git_dir, &index_path, format, &link)?
2810        && !split_index_delta_exceeds_threshold(git_dir, index, &base)
2811    {
2812        let (entries, link) = split_index_delta_entries(index, &base, &link)?;
2813        let extensions = index_extensions_without_split_index_link(
2814            &index_extensions_without_cache_tree(&index.extensions),
2815        );
2816        let mut primary = Index {
2817            version: index.version,
2818            entries,
2819            extensions,
2820            checksum: None,
2821        };
2822        primary.set_split_index_link(Some(&link))?;
2823        fs::write(&index_path, primary.write(format)?)?;
2824        apply_index_shared_file_mode(git_dir, &index_path, None)?;
2825        return Ok(());
2826    }
2827
2828    let mode_source = fs::metadata(&index_path)
2829        .ok()
2830        .map(|metadata| metadata.permissions());
2831    let mut shared = index.clone();
2832    smudge_racily_clean_entries_before_write(git_dir, format, &mut shared)?;
2833    shared.clear_split_index_link()?;
2834    shared.extensions = index_extensions_without_cache_tree(&shared.extensions);
2835    let shared_bytes = shared.write(format)?;
2836    let shared_oid = index_checksum_from_bytes(format, &shared_bytes)?;
2837    let shared_path = git_dir.join(format!("sharedindex.{shared_oid}"));
2838    if !shared_path.exists() {
2839        fs::write(&shared_path, &shared_bytes)?;
2840    }
2841    apply_index_shared_file_mode(git_dir, &shared_path, mode_source.as_ref())?;
2842    clean_shared_index_files(git_dir, shared_oid)?;
2843
2844    let mut primary = Index {
2845        version: index.version,
2846        entries: Vec::new(),
2847        extensions: Vec::new(),
2848        checksum: None,
2849    };
2850    primary.set_split_index_link(Some(&SplitIndexLink::new(shared_oid)))?;
2851    fs::write(&index_path, primary.write(format)?)?;
2852    apply_index_shared_file_mode(git_dir, &index_path, mode_source.as_ref())?;
2853    Ok(())
2854}
2855
2856pub(crate) fn alternate_index_output_path(git_dir: &Path, index_path: &Path) -> bool {
2857    env::var_os("GIT_INDEX_FILE").is_some() && index_path != git_dir.join("index")
2858}
2859
2860pub(crate) fn clean_shared_index_files(git_dir: &Path, current_oid: ObjectId) -> Result<()> {
2861    let Some(expire_before) = shared_index_expire_before(git_dir) else {
2862        return Ok(());
2863    };
2864    let current_name = format!("sharedindex.{current_oid}");
2865    let mut expired = Vec::new();
2866    for entry in fs::read_dir(git_dir)? {
2867        let entry = entry?;
2868        let name = entry.file_name();
2869        let Some(name) = name.to_str() else {
2870            continue;
2871        };
2872        if !name.starts_with("sharedindex.") || name == current_name {
2873            continue;
2874        }
2875        let metadata = entry.metadata()?;
2876        let Ok(modified) = metadata.modified() else {
2877            continue;
2878        };
2879        if modified <= expire_before {
2880            expired.push((modified, entry.path()));
2881        }
2882    }
2883    expired.sort_by_key(|(modified, _)| *modified);
2884    let delete_count = expired.len().saturating_sub(1);
2885    for (_, path) in expired.into_iter().take(delete_count) {
2886        let _ = fs::remove_file(path);
2887    }
2888    Ok(())
2889}
2890
2891pub(crate) fn shared_index_expire_before(git_dir: &Path) -> Option<SystemTime> {
2892    let value = sley_config::read_repo_config(git_dir, None)
2893        .ok()
2894        .and_then(|config| {
2895            config
2896                .get("splitIndex", None, "sharedIndexExpire")
2897                .map(str::to_string)
2898        })
2899        .unwrap_or_else(|| "2.weeks.ago".to_string());
2900    let value = value.trim();
2901    if value.eq_ignore_ascii_case("never") {
2902        return None;
2903    }
2904    if value.eq_ignore_ascii_case("now") {
2905        return Some(SystemTime::now());
2906    }
2907    if let Some(days) = value
2908        .strip_suffix(".days.ago")
2909        .or_else(|| value.strip_suffix(".day.ago"))
2910        .and_then(|days| days.parse::<u64>().ok())
2911    {
2912        return SystemTime::now().checked_sub(Duration::from_secs(days * 24 * 60 * 60));
2913    }
2914    if let Some(weeks) = value
2915        .strip_suffix(".weeks.ago")
2916        .or_else(|| value.strip_suffix(".week.ago"))
2917        .and_then(|weeks| weeks.parse::<u64>().ok())
2918    {
2919        return SystemTime::now().checked_sub(Duration::from_secs(weeks * 7 * 24 * 60 * 60));
2920    }
2921    SystemTime::now().checked_sub(Duration::from_secs(14 * 24 * 60 * 60))
2922}
2923
2924pub(crate) fn apply_index_shared_file_mode(
2925    git_dir: &Path,
2926    path: &Path,
2927    mode_source: Option<&fs::Permissions>,
2928) -> Result<()> {
2929    #[cfg(unix)]
2930    {
2931        use std::os::unix::fs::PermissionsExt;
2932
2933        let current = fs::metadata(path)?.permissions();
2934        let source_mode = mode_source
2935            .map(fs::Permissions::mode)
2936            .unwrap_or_else(|| current.mode());
2937        let mode = sley_config::read_repo_config(git_dir, None)
2938            .ok()
2939            .and_then(|config| {
2940                config
2941                    .get("core", None, "sharedRepository")
2942                    .and_then(|value| shared_repository_file_mode(value, source_mode))
2943            })
2944            .unwrap_or(source_mode & 0o7777);
2945        fs::set_permissions(path, fs::Permissions::from_mode(mode))?;
2946    }
2947    #[cfg(not(unix))]
2948    {
2949        let _ = git_dir;
2950        let _ = path;
2951        let _ = mode_source;
2952    }
2953    Ok(())
2954}
2955
2956#[cfg(unix)]
2957pub(crate) fn shared_repository_file_mode(value: &str, source_mode: u32) -> Option<u32> {
2958    match value {
2959        "umask" | "false" | "no" | "off" | "0" => None,
2960        "group" | "true" | "yes" | "on" | "1" => Some((source_mode | 0o660) & 0o7777),
2961        "all" | "world" | "everybody" | "2" | "3" => Some((source_mode | 0o664) & 0o7777),
2962        value => {
2963            let parsed = u32::from_str_radix(value, 8).ok()?;
2964            (parsed & 0o600 == 0o600).then_some(parsed & 0o666)
2965        }
2966    }
2967}
2968
2969pub(crate) fn read_shared_index_for_link(
2970    git_dir: &Path,
2971    index_path: &Path,
2972    format: ObjectFormat,
2973    link: &SplitIndexLink,
2974) -> Result<Option<Index>> {
2975    let name = format!("sharedindex.{}", link.base_oid);
2976    let bytes = match fs::read(git_dir.join(&name)) {
2977        Ok(bytes) => bytes,
2978        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
2979            let alternate = index_path
2980                .parent()
2981                .unwrap_or_else(|| Path::new("."))
2982                .join(&name);
2983            match fs::read(alternate) {
2984                Ok(bytes) => bytes,
2985                Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
2986                Err(err) => return Err(err.into()),
2987            }
2988        }
2989        Err(err) => return Err(err.into()),
2990    };
2991    let base = Index::parse(&bytes, format)?;
2992    if base.checksum != Some(link.base_oid) {
2993        return Ok(None);
2994    }
2995    Ok(Some(base))
2996}
2997
2998pub(crate) fn split_index_delta_exceeds_threshold(
2999    git_dir: &Path,
3000    index: &Index,
3001    base: &Index,
3002) -> bool {
3003    let max_percent = sley_config::read_repo_config(git_dir, None)
3004        .ok()
3005        .and_then(|config| {
3006            config
3007                .get("splitIndex", None, "maxPercentChange")
3008                .and_then(|value| value.parse::<i64>().ok())
3009        })
3010        .unwrap_or(20);
3011    match max_percent {
3012        0 => return true,
3013        100.. => return false,
3014        value if value < 0 => {}
3015        _ => {}
3016    }
3017    let not_shared = count_entries_not_shared_with_base(index, base);
3018    (index.entries.len() as i64) * max_percent < (not_shared as i64) * 100
3019}
3020
3021pub(crate) fn count_entries_not_shared_with_base(index: &Index, base: &Index) -> usize {
3022    index
3023        .entries
3024        .iter()
3025        .filter(|entry| {
3026            base.entries
3027                .binary_search_by(|base_entry| compare_index_key(base_entry, entry))
3028                .is_err()
3029        })
3030        .count()
3031}
3032
3033pub(crate) fn split_index_delta_entries(
3034    index: &Index,
3035    base: &Index,
3036    previous_link: &SplitIndexLink,
3037) -> Result<(Vec<IndexEntry>, SplitIndexLink)> {
3038    let mut delete_positions = Vec::new();
3039    let mut replace_positions = Vec::new();
3040    let mut replacements = Vec::new();
3041    let mut additions = Vec::new();
3042    let mut base_pos = 0usize;
3043    let mut index_pos = 0usize;
3044    while base_pos < base.entries.len() && index_pos < index.entries.len() {
3045        match compare_index_key(&base.entries[base_pos], &index.entries[index_pos]) {
3046            Ordering::Equal => {
3047                if previous_link
3048                    .delete_positions
3049                    .binary_search(&(base_pos as u32))
3050                    .is_ok()
3051                {
3052                    delete_positions.push(base_pos as u32);
3053                    additions.push(index.entries[index_pos].clone());
3054                } else if !index_entry_content_eq(
3055                    &base.entries[base_pos],
3056                    &index.entries[index_pos],
3057                ) {
3058                    replace_positions.push(base_pos as u32);
3059                    let mut replacement = index.entries[index_pos].clone();
3060                    replacement.path = BString::from(Vec::<u8>::new());
3061                    replacement.refresh_name_length();
3062                    replacements.push(replacement);
3063                }
3064                base_pos += 1;
3065                index_pos += 1;
3066            }
3067            Ordering::Less => {
3068                delete_positions.push(base_pos as u32);
3069                base_pos += 1;
3070            }
3071            Ordering::Greater => {
3072                additions.push(index.entries[index_pos].clone());
3073                index_pos += 1;
3074            }
3075        }
3076    }
3077    while base_pos < base.entries.len() {
3078        delete_positions.push(base_pos as u32);
3079        base_pos += 1;
3080    }
3081    while index_pos < index.entries.len() {
3082        additions.push(index.entries[index_pos].clone());
3083        index_pos += 1;
3084    }
3085    replacements.extend(additions);
3086    Ok((
3087        replacements,
3088        SplitIndexLink {
3089            base_oid: previous_link.base_oid,
3090            delete_positions,
3091            replace_positions,
3092        },
3093    ))
3094}
3095
3096pub(crate) fn compare_index_key(left: &IndexEntry, right: &IndexEntry) -> Ordering {
3097    left.path
3098        .as_bytes()
3099        .cmp(right.path.as_bytes())
3100        .then_with(|| left.stage().as_u16().cmp(&right.stage().as_u16()))
3101}
3102
3103pub(crate) fn index_entry_content_eq(left: &IndexEntry, right: &IndexEntry) -> bool {
3104    const ONDISK_FLAGS: u16 = sley_index::INDEX_FLAG_STAGE_MASK
3105        | sley_index::INDEX_FLAG_VALID
3106        | sley_index::INDEX_FLAG_EXTENDED;
3107    left.ctime_seconds == right.ctime_seconds
3108        && left.ctime_nanoseconds == right.ctime_nanoseconds
3109        && left.mtime_seconds == right.mtime_seconds
3110        && left.mtime_nanoseconds == right.mtime_nanoseconds
3111        && left.dev == right.dev
3112        && left.ino == right.ino
3113        && left.mode == right.mode
3114        && left.uid == right.uid
3115        && left.gid == right.gid
3116        && left.size == right.size
3117        && left.oid == right.oid
3118        && (left.flags & ONDISK_FLAGS) == (right.flags & ONDISK_FLAGS)
3119        && left.flags_extended == right.flags_extended
3120}
3121
3122pub(crate) fn write_index_with_entry_size_overrides(
3123    format: ObjectFormat,
3124    index: &Index,
3125    zero_size_entries: &[usize],
3126    extensions: &[u8],
3127) -> Result<Vec<u8>> {
3128    if !(2..=4).contains(&index.version) {
3129        return Err(GitError::Unsupported(
3130            "canonical writer currently emits index v2/v3/v4".into(),
3131        ));
3132    }
3133    let mut out = Vec::new();
3134    out.extend_from_slice(b"DIRC");
3135    out.extend_from_slice(&index.version.to_be_bytes());
3136    out.extend_from_slice(&(index.entries.len() as u32).to_be_bytes());
3137    let mut previous_path = Vec::new();
3138    for (position, entry) in index.entries.iter().enumerate() {
3139        let start = out.len();
3140        out.extend_from_slice(&entry.ctime_seconds.to_be_bytes());
3141        out.extend_from_slice(&entry.ctime_nanoseconds.to_be_bytes());
3142        out.extend_from_slice(&entry.mtime_seconds.to_be_bytes());
3143        out.extend_from_slice(&entry.mtime_nanoseconds.to_be_bytes());
3144        out.extend_from_slice(&entry.dev.to_be_bytes());
3145        out.extend_from_slice(&entry.ino.to_be_bytes());
3146        out.extend_from_slice(&entry.mode.to_be_bytes());
3147        out.extend_from_slice(&entry.uid.to_be_bytes());
3148        out.extend_from_slice(&entry.gid.to_be_bytes());
3149        let size = if zero_size_entries.binary_search(&position).is_ok() {
3150            0
3151        } else {
3152            entry.size
3153        };
3154        out.extend_from_slice(&size.to_be_bytes());
3155        if entry.oid.format() != format {
3156            return Err(GitError::Unsupported(format!(
3157                "index writer expects {} ids",
3158                format.name()
3159            )));
3160        }
3161        out.extend_from_slice(entry.oid.as_bytes());
3162        let has_extended_flags =
3163            entry.flags & INDEX_FLAG_EXTENDED != 0 || entry.flags_extended != 0;
3164        if has_extended_flags && index.version < 3 {
3165            return Err(GitError::Unsupported(
3166                "index extended flags require version 3".into(),
3167            ));
3168        }
3169        let flags = if has_extended_flags {
3170            entry.flags | INDEX_FLAG_EXTENDED
3171        } else {
3172            entry.flags & !INDEX_FLAG_EXTENDED
3173        };
3174        out.extend_from_slice(&flags.to_be_bytes());
3175        if has_extended_flags {
3176            out.extend_from_slice(&entry.flags_extended.to_be_bytes());
3177        }
3178        if index.version == 4 {
3179            let common_prefix_len = common_prefix_len(&previous_path, entry.path.as_bytes());
3180            let strip_len = previous_path.len() - common_prefix_len;
3181            encode_index_v4_path_strip_len(strip_len, &mut out);
3182            out.extend_from_slice(&entry.path.as_bytes()[common_prefix_len..]);
3183            out.push(0);
3184            previous_path = entry.path.as_bytes().to_vec();
3185        } else {
3186            out.extend_from_slice(entry.path.as_bytes());
3187            out.push(0);
3188            while (out.len() - start) % 8 != 0 {
3189                out.push(0);
3190            }
3191        }
3192    }
3193    out.extend_from_slice(extensions);
3194    let checksum = sley_core::digest_bytes(format, &out)?;
3195    out.extend_from_slice(checksum.as_bytes());
3196    Ok(out)
3197}
3198
3199pub(crate) fn encode_index_v4_path_strip_len(strip_len: usize, out: &mut Vec<u8>) {
3200    let mut bytes = Vec::new();
3201    bytes.push((strip_len & 0x7f) as u8);
3202    let mut value = strip_len >> 7;
3203    while value != 0 {
3204        value -= 1;
3205        bytes.push(((value & 0x7f) as u8) | 0x80);
3206        value >>= 7;
3207    }
3208    for byte in bytes.iter().rev() {
3209        out.push(*byte);
3210    }
3211}
3212
3213pub(crate) fn common_prefix_len(left: &[u8], right: &[u8]) -> usize {
3214    left.iter()
3215        .zip(right.iter())
3216        .take_while(|(left, right)| left == right)
3217        .count()
3218}
3219
3220pub(crate) fn index_checksum_from_bytes(format: ObjectFormat, bytes: &[u8]) -> Result<ObjectId> {
3221    let hash_len = format.raw_len();
3222    if bytes.len() < hash_len {
3223        return Err(GitError::InvalidFormat(
3224            "index too short for checksum".into(),
3225        ));
3226    }
3227    ObjectId::from_raw(format, &bytes[bytes.len() - hash_len..])
3228}
3229
3230pub fn enable_split_index(
3231    git_dir: impl AsRef<Path>,
3232    format: ObjectFormat,
3233) -> Result<UpdateIndexResult> {
3234    let git_dir = git_dir.as_ref();
3235    let mut index = read_repository_index(git_dir, format)?.unwrap_or_else(empty_index);
3236    normalize_index_version_for_extended_flags(&mut index);
3237    write_repository_index_ref_with_split(git_dir, format, &index, true)?;
3238    Ok(UpdateIndexResult {
3239        entries: index.entries.len(),
3240        updated: Vec::new(),
3241    })
3242}
3243
3244pub fn disable_split_index(
3245    git_dir: impl AsRef<Path>,
3246    format: ObjectFormat,
3247) -> Result<UpdateIndexResult> {
3248    let git_dir = git_dir.as_ref();
3249    if !repository_index_path(git_dir).exists() {
3250        return Ok(UpdateIndexResult {
3251            entries: 0,
3252            updated: Vec::new(),
3253        });
3254    }
3255    let mut index = read_repository_index(git_dir, format)?.unwrap_or_else(empty_index);
3256    normalize_index_version_for_extended_flags(&mut index);
3257    write_repository_index_ref_with_split(git_dir, format, &index, false)?;
3258    Ok(UpdateIndexResult {
3259        entries: index.entries.len(),
3260        updated: Vec::new(),
3261    })
3262}
3263
3264pub(crate) fn smudge_racily_clean_entries_before_write(
3265    git_dir: &Path,
3266    format: ObjectFormat,
3267    index: &mut Index,
3268) -> Result<()> {
3269    for position in racily_clean_entry_indexes_before_write(git_dir, format, index)? {
3270        index.entries[position].size = 0;
3271    }
3272    Ok(())
3273}
3274
3275pub(crate) fn racily_clean_entry_indexes_before_write(
3276    git_dir: &Path,
3277    format: ObjectFormat,
3278    index: &Index,
3279) -> Result<Vec<usize>> {
3280    let index_path = repository_index_path(git_dir);
3281    let Some(index_mtime) = fs::metadata(&index_path)
3282        .ok()
3283        .and_then(|metadata| sley_index::file_mtime_parts(&metadata))
3284    else {
3285        return Ok(Vec::new());
3286    };
3287    if index_mtime == (0, 0) {
3288        return Ok(Vec::new());
3289    }
3290    let Some(worktree_root) = (match worktree_root_for_git_dir(git_dir) {
3291        Ok(worktree_root) => worktree_root,
3292        Err(_) => return Ok(Vec::new()),
3293    }) else {
3294        return Ok(Vec::new());
3295    };
3296    let mut smudged = Vec::new();
3297    for (position, entry) in index.entries.iter().enumerate() {
3298        if index_entry_stage(entry) != 0 || sley_index::is_gitlink(entry.mode) {
3299            continue;
3300        }
3301        let entry_mtime = (
3302            u64::from(entry.mtime_seconds),
3303            u64::from(entry.mtime_nanoseconds),
3304        );
3305        if entry_mtime == (0, 0) || index_mtime > entry_mtime {
3306            continue;
3307        }
3308        let absolute = worktree_root.join(repo_path_to_os_path(entry.path.as_bytes())?);
3309        let Ok(metadata) = fs::symlink_metadata(&absolute) else {
3310            continue;
3311        };
3312        if entry.mode != worktree_entry_mode(&metadata)
3313            || !worktree_entry_is_uptodate(entry, &metadata)
3314        {
3315            continue;
3316        }
3317        let body = if metadata.file_type().is_symlink() {
3318            symlink_target_bytes(&absolute)?
3319        } else if metadata.is_file() {
3320            fs::read(&absolute)?
3321        } else {
3322            continue;
3323        };
3324        let oid = EncodedObject::new(ObjectType::Blob, body).object_id(format)?;
3325        if oid != entry.oid {
3326            smudged.push(position);
3327        }
3328    }
3329    Ok(smudged)
3330}
3331
3332pub(crate) fn invalidate_untracked_cache_for_git_paths(
3333    index: &mut Index,
3334    format: ObjectFormat,
3335    paths: &[Vec<u8>],
3336) -> Result<()> {
3337    if paths.is_empty() {
3338        return Ok(());
3339    }
3340    let Some(mut cache) = index.untracked_cache(format)? else {
3341        return Ok(());
3342    };
3343    let Some(root) = cache.root.as_mut() else {
3344        return Ok(());
3345    };
3346    for path in paths {
3347        invalidate_untracked_cache_dir_for_path(root, path);
3348    }
3349    index.set_untracked_cache(format, Some(&cache))
3350}
3351
3352pub(crate) fn invalidate_untracked_cache_dir_for_path(root: &mut UntrackedCacheDir, path: &[u8]) {
3353    invalidate_untracked_cache_node(root);
3354    let mut current = root;
3355    let mut components = path.split(|byte| *byte == b'/').peekable();
3356    while let Some(component) = components.next() {
3357        if component.is_empty() || components.peek().is_none() {
3358            break;
3359        }
3360        let Some(child) = current.dirs.iter_mut().find(|dir| dir.name == component) else {
3361            break;
3362        };
3363        invalidate_untracked_cache_node(child);
3364        current = child;
3365    }
3366}
3367
3368pub(crate) fn invalidate_untracked_cache_node(node: &mut UntrackedCacheDir) {
3369    node.valid = false;
3370    node.untracked.clear();
3371}
3372
3373pub fn update_index_cacheinfo(
3374    git_dir: impl AsRef<Path>,
3375    format: ObjectFormat,
3376    entries: &[CacheInfoEntry],
3377    add: bool,
3378    verbose: bool,
3379) -> Result<UpdateIndexResult> {
3380    let git_dir = git_dir.as_ref();
3381    let index_path = repository_index_path(git_dir);
3382    let mut index = if index_path.exists() {
3383        Index::parse(&fs::read(&index_path)?, format)?
3384    } else {
3385        Index {
3386            version: 2,
3387            entries: Vec::new(),
3388            extensions: Vec::new(),
3389            checksum: None,
3390        }
3391    };
3392    let mut updated = Vec::new();
3393    let mut reports: Vec<String> = Vec::new();
3394    let mut untracked_cache_invalidation_paths = Vec::new();
3395    for cacheinfo in entries {
3396        if !add
3397            && !index
3398                .entries
3399                .iter()
3400                .any(|existing| existing.path == cacheinfo.path)
3401        {
3402            let path = String::from_utf8_lossy(&cacheinfo.path);
3403            eprintln!("error: {path}: cannot add to the index - missing --add option?");
3404            eprintln!("fatal: git update-index: --cacheinfo cannot add {path}");
3405            return Err(GitError::Exit(128));
3406        }
3407        let flags = index_flags(cacheinfo.path.len(), cacheinfo.stage);
3408        let entry = IndexEntry {
3409            ctime_seconds: 0,
3410            ctime_nanoseconds: 0,
3411            mtime_seconds: 0,
3412            mtime_nanoseconds: 0,
3413            dev: 0,
3414            ino: 0,
3415            mode: cacheinfo.mode,
3416            uid: 0,
3417            gid: 0,
3418            size: 0,
3419            oid: cacheinfo.oid,
3420            flags,
3421            flags_extended: 0,
3422            path: BString::from(cacheinfo.path.as_slice()),
3423        };
3424        index.entries.retain(|existing| {
3425            existing.path != cacheinfo.path || index_entry_stage(existing) != cacheinfo.stage
3426        });
3427        index.entries.push(entry);
3428        untracked_cache_invalidation_paths.push(cacheinfo.path.clone());
3429        updated.push(cacheinfo.oid);
3430        // git's add_cacheinfo() calls report("add '%s'") *after* the entry is
3431        // staged, regardless of whether the subsequent index write succeeds.
3432        reports.push(format!(
3433            "add '{}'",
3434            String::from_utf8_lossy(&cacheinfo.path)
3435        ));
3436    }
3437    index
3438        .entries
3439        .sort_by(|left, right| left.path.cmp(&right.path));
3440    // git refuses to write an index entry whose object id is the null oid:
3441    // do_write_index() emits `error: cache entry has null sha1: <path>` and
3442    // returns nonzero, leaving the on-disk index untouched. The verbose `add`
3443    // line has already been printed by then.
3444    let null_entry = index.entries.iter().find(|entry| entry.oid.is_null());
3445    if let Some(entry) = null_entry {
3446        if verbose {
3447            flush_update_index_reports(&reports)?;
3448        }
3449        eprintln!(
3450            "error: cache entry has null sha1: {}",
3451            String::from_utf8_lossy(&entry.path)
3452        );
3453        return Err(GitError::Exit(128));
3454    }
3455    if !untracked_cache_invalidation_paths.is_empty() {
3456        index.extensions = index_extensions_without_cache_tree(&index.extensions);
3457    }
3458    invalidate_untracked_cache_for_git_paths(
3459        &mut index,
3460        format,
3461        &untracked_cache_invalidation_paths,
3462    )?;
3463    write_repository_index_ref(git_dir, format, &index)?;
3464    if verbose {
3465        flush_update_index_reports(&reports)?;
3466    }
3467    Ok(UpdateIndexResult {
3468        entries: index.entries.len(),
3469        updated,
3470    })
3471}
3472
3473pub(crate) fn flush_update_index_reports(reports: &[String]) -> Result<()> {
3474    let mut stdout = std::io::stdout().lock();
3475    for line in reports {
3476        writeln!(stdout, "{line}")?;
3477    }
3478    stdout.flush()?;
3479    Ok(())
3480}
3481
3482pub fn update_index_index_info(
3483    git_dir: impl AsRef<Path>,
3484    format: ObjectFormat,
3485    records: &[IndexInfoRecord],
3486) -> Result<UpdateIndexResult> {
3487    let git_dir = git_dir.as_ref();
3488    let index_path = repository_index_path(git_dir);
3489    let mut index = if index_path.exists() {
3490        Index::parse(&fs::read(&index_path)?, format)?
3491    } else {
3492        Index {
3493            version: 2,
3494            entries: Vec::new(),
3495            extensions: Vec::new(),
3496            checksum: None,
3497        }
3498    };
3499    let mut updated = Vec::new();
3500    let mut untracked_cache_invalidation_paths = Vec::new();
3501    for record in records {
3502        match record {
3503            IndexInfoRecord::Remove { path } => {
3504                index.entries.retain(|existing| existing.path != *path);
3505                untracked_cache_invalidation_paths.push(path.clone());
3506            }
3507            IndexInfoRecord::Add(cacheinfo) => {
3508                let flags = index_flags(cacheinfo.path.len(), cacheinfo.stage);
3509                let entry = IndexEntry {
3510                    ctime_seconds: 0,
3511                    ctime_nanoseconds: 0,
3512                    mtime_seconds: 0,
3513                    mtime_nanoseconds: 0,
3514                    dev: 0,
3515                    ino: 0,
3516                    mode: cacheinfo.mode,
3517                    uid: 0,
3518                    gid: 0,
3519                    size: 0,
3520                    oid: cacheinfo.oid,
3521                    flags,
3522                    flags_extended: 0,
3523                    path: BString::from(cacheinfo.path.as_slice()),
3524                };
3525                if cacheinfo.stage == 0 {
3526                    index
3527                        .entries
3528                        .retain(|existing| existing.path != cacheinfo.path);
3529                } else {
3530                    index.entries.retain(|existing| {
3531                        existing.path != cacheinfo.path
3532                            || index_entry_stage(existing) != cacheinfo.stage
3533                    });
3534                }
3535                index.entries.push(entry);
3536                untracked_cache_invalidation_paths.push(cacheinfo.path.clone());
3537                updated.push(cacheinfo.oid);
3538            }
3539        }
3540    }
3541    index.entries.sort_by(|left, right| {
3542        left.path
3543            .cmp(&right.path)
3544            .then_with(|| index_entry_stage(left).cmp(&index_entry_stage(right)))
3545    });
3546    if !untracked_cache_invalidation_paths.is_empty() {
3547        index.extensions = index_extensions_without_cache_tree(&index.extensions);
3548    }
3549    invalidate_untracked_cache_for_git_paths(
3550        &mut index,
3551        format,
3552        &untracked_cache_invalidation_paths,
3553    )?;
3554    write_repository_index_ref(git_dir, format, &index)?;
3555    Ok(UpdateIndexResult {
3556        entries: index.entries.len(),
3557        updated,
3558    })
3559}
3560
3561pub(crate) fn index_flags(path_len: usize, stage: u16) -> u16 {
3562    ((stage & 0x3) << 12) | ((path_len.min(0xfff) as u16) & 0x0fff)
3563}
3564
3565pub(crate) const INDEX_FLAG_ASSUME_UNCHANGED: u16 = 0x8000;
3566pub(crate) const INDEX_FLAG_EXTENDED: u16 = 0x4000;
3567pub(crate) const INDEX_EXTENDED_FLAG_SKIP_WORKTREE: u16 = 0x4000;
3568
3569pub(crate) fn normalize_index_version_for_extended_flags(index: &mut Index) {
3570    let has_extended_flags = index
3571        .entries
3572        .iter()
3573        .any(|entry| entry.flags & INDEX_FLAG_EXTENDED != 0 || entry.flags_extended != 0);
3574    if has_extended_flags && index.version < 3 {
3575        index.version = 3;
3576    } else if !has_extended_flags && index.version == 3 {
3577        index.version = 2;
3578    }
3579}
3580
3581pub(crate) fn index_entry_stage(entry: &IndexEntry) -> u16 {
3582    (entry.flags >> 12) & 0x3
3583}
3584
3585/// The oid of the stage-0 entry in `range` (the path's currently-tracked blob),
3586/// if any. Used by the safecrlf check to fetch `has_crlf_in_index`.
3587pub(crate) fn stage0_oid_in_range(
3588    entries: &[IndexEntry],
3589    range: std::ops::Range<usize>,
3590) -> Option<ObjectId> {
3591    entries[range]
3592        .iter()
3593        .find(|entry| index_entry_stage(entry) == 0)
3594        .map(|entry| entry.oid)
3595}
3596
3597pub(crate) fn index_entry_skip_worktree(entry: &IndexEntry) -> bool {
3598    entry.flags & INDEX_FLAG_EXTENDED != 0
3599        && entry.flags_extended & INDEX_EXTENDED_FLAG_SKIP_WORKTREE != 0
3600}
3601
3602pub(crate) fn print_update_index_path_error(path: &[u8], message: &str) {
3603    let path = String::from_utf8_lossy(path);
3604    eprintln!("error: {path}: {message}");
3605    eprintln!("fatal: Unable to process path {path}");
3606}
3607
3608pub(crate) fn print_update_index_needs_update(path: &[u8]) {
3609    let path = String::from_utf8_lossy(path);
3610    println!("{path}: needs update");
3611}
3612
3613pub fn write_tree_from_index(git_dir: impl AsRef<Path>, format: ObjectFormat) -> Result<ObjectId> {
3614    write_tree_from_index_with_options(git_dir, format, WriteTreeOptions::default())
3615}
3616
3617pub fn write_tree_from_index_with_odb(
3618    git_dir: impl AsRef<Path>,
3619    format: ObjectFormat,
3620    odb: &FileObjectDatabase,
3621) -> Result<ObjectId> {
3622    write_tree_from_index_with_options_and_odb(
3623        git_dir.as_ref(),
3624        format,
3625        WriteTreeOptions::default(),
3626        odb,
3627    )
3628}
3629
3630pub fn write_tree_from_index_with_options(
3631    git_dir: impl AsRef<Path>,
3632    format: ObjectFormat,
3633    options: WriteTreeOptions,
3634) -> Result<ObjectId> {
3635    let git_dir = git_dir.as_ref();
3636    let odb = FileObjectDatabase::from_git_dir(git_dir, format);
3637    write_tree_from_index_with_options_and_odb(git_dir, format, options, &odb)
3638}
3639
3640pub(crate) fn write_tree_from_index_with_options_and_odb(
3641    git_dir: &Path,
3642    format: ObjectFormat,
3643    options: WriteTreeOptions,
3644    odb: &FileObjectDatabase,
3645) -> Result<ObjectId> {
3646    let index_path = repository_index_path(git_dir);
3647    // A repository with no index file yet (fresh init, nothing staged) is an
3648    // empty index: `git write-tree` / `git commit --allow-empty` produce the
3649    // empty tree rather than erroring.
3650    let index_bytes = match fs::read(&index_path) {
3651        Ok(bytes) => bytes,
3652        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
3653            let mut checker = odb.presence_checker();
3654            let empty: &[WriteTreeEntry<'_>] = &[];
3655            return write_tree_entries_stream(
3656                empty,
3657                b"",
3658                None,
3659                odb,
3660                &mut checker,
3661                options.missing_ok,
3662            );
3663        }
3664        Err(err) => return Err(err.into()),
3665    };
3666    let mut checker = odb.presence_checker();
3667    let oid = if Index::bytes_have_extension(&index_bytes, format, b"link")? {
3668        let index = sley_index::read_repository_index(git_dir, format)?;
3669        write_tree_from_owned_index(&index, format, &options, odb, &mut checker)?
3670    } else {
3671        match BorrowedIndex::parse(&index_bytes, format) {
3672            Ok(index) => {
3673                write_tree_from_borrowed_index(&index, format, &options, odb, &mut checker)
3674            }
3675            Err(GitError::Unsupported(_)) => {
3676                let index = Index::parse(&index_bytes, format)?;
3677                write_tree_from_owned_index(&index, format, &options, odb, &mut checker)
3678            }
3679            Err(err) => Err(err),
3680        }?
3681    };
3682    if options.prefix.is_none() {
3683        refresh_repository_cache_tree(git_dir, format, odb)?;
3684    }
3685    Ok(oid)
3686}
3687
3688pub(crate) fn write_tree_from_borrowed_index(
3689    index: &BorrowedIndex<'_>,
3690    format: ObjectFormat,
3691    options: &WriteTreeOptions,
3692    odb: &FileObjectDatabase,
3693    checker: &mut ObjectPresenceChecker,
3694) -> Result<ObjectId> {
3695    let cache_tree = if options.prefix.is_none() {
3696        index.cache_tree(format).ok().flatten()
3697    } else {
3698        None
3699    };
3700    if options.prefix.is_none() && !index.entries.iter().any(|entry| entry.is_intent_to_add()) {
3701        return write_tree_entries_stream(
3702            &index.entries,
3703            b"",
3704            cache_tree.as_ref(),
3705            odb,
3706            checker,
3707            options.missing_ok,
3708        );
3709    }
3710    // intent-to-add entries (`git add -N`, `git reset -N`) are placeholders that do
3711    // NOT belong in a written tree — git's cache_tree_update skips CE_INTENT_TO_ADD.
3712    // Drop them before building, so `write-tree` succeeds and the tree omits them
3713    // (their empty-blob oid is also typically absent from the odb).
3714    let entries = write_tree_entries_for_prefix(
3715        index
3716            .entries
3717            .iter()
3718            .filter(|entry| !entry.is_intent_to_add()),
3719        options.prefix.as_deref(),
3720    )?;
3721    write_tree_entries_stream(
3722        &entries,
3723        b"",
3724        cache_tree.as_ref(),
3725        odb,
3726        checker,
3727        options.missing_ok,
3728    )
3729}
3730
3731pub(crate) fn write_tree_from_owned_index(
3732    index: &Index,
3733    format: ObjectFormat,
3734    options: &WriteTreeOptions,
3735    odb: &FileObjectDatabase,
3736    checker: &mut ObjectPresenceChecker,
3737) -> Result<ObjectId> {
3738    let cache_tree = if options.prefix.is_none() {
3739        index.cache_tree(format).ok().flatten()
3740    } else {
3741        None
3742    };
3743    if options.prefix.is_none() && !index.entries.iter().any(|entry| entry.is_intent_to_add()) {
3744        return write_tree_entries_stream(
3745            &index.entries,
3746            b"",
3747            cache_tree.as_ref(),
3748            odb,
3749            checker,
3750            options.missing_ok,
3751        );
3752    }
3753    let entries = write_tree_entries_for_prefix(
3754        index
3755            .entries
3756            .iter()
3757            .filter(|entry| !entry.is_intent_to_add()),
3758        options.prefix.as_deref(),
3759    )?;
3760    write_tree_entries_stream(
3761        &entries,
3762        b"",
3763        cache_tree.as_ref(),
3764        odb,
3765        checker,
3766        options.missing_ok,
3767    )
3768}
3769
3770#[derive(Clone, Copy)]
3771pub(crate) struct WriteTreeEntry<'a> {
3772    pub(crate) path: &'a [u8],
3773    pub(crate) mode: u32,
3774    pub(crate) oid: ObjectId,
3775}
3776
3777pub(crate) trait WriteTreeIndexEntry {
3778    fn write_tree_path(&self) -> &[u8];
3779    fn write_tree_mode(&self) -> u32;
3780    fn write_tree_oid(&self) -> ObjectId;
3781}
3782
3783impl WriteTreeIndexEntry for IndexEntry {
3784    fn write_tree_path(&self) -> &[u8] {
3785        self.path.as_bytes()
3786    }
3787
3788    fn write_tree_mode(&self) -> u32 {
3789        self.mode
3790    }
3791
3792    fn write_tree_oid(&self) -> ObjectId {
3793        self.oid
3794    }
3795}
3796
3797impl WriteTreeIndexEntry for IndexEntryRef<'_> {
3798    fn write_tree_path(&self) -> &[u8] {
3799        self.path
3800    }
3801
3802    fn write_tree_mode(&self) -> u32 {
3803        self.mode
3804    }
3805
3806    fn write_tree_oid(&self) -> ObjectId {
3807        self.oid
3808    }
3809}
3810
3811impl WriteTreeIndexEntry for WriteTreeEntry<'_> {
3812    fn write_tree_path(&self) -> &[u8] {
3813        self.path
3814    }
3815
3816    fn write_tree_mode(&self) -> u32 {
3817        self.mode
3818    }
3819
3820    fn write_tree_oid(&self) -> ObjectId {
3821        self.oid
3822    }
3823}
3824
3825pub(crate) fn write_tree_entries_for_prefix<'a, E>(
3826    entries: impl IntoIterator<Item = &'a E>,
3827    prefix: Option<&[u8]>,
3828) -> Result<Vec<WriteTreeEntry<'a>>>
3829where
3830    E: WriteTreeIndexEntry + 'a,
3831{
3832    let Some(prefix) = prefix else {
3833        return Ok(entries
3834            .into_iter()
3835            .map(|entry| WriteTreeEntry {
3836                path: entry.write_tree_path(),
3837                mode: entry.write_tree_mode(),
3838                oid: entry.write_tree_oid(),
3839            })
3840            .collect());
3841    };
3842    let trimmed_len = prefix
3843        .iter()
3844        .rposition(|byte| *byte != b'/')
3845        .map(|idx| idx + 1)
3846        .unwrap_or(0);
3847    let trimmed = &prefix[..trimmed_len];
3848    if trimmed.is_empty() {
3849        return Ok(entries
3850            .into_iter()
3851            .map(|entry| WriteTreeEntry {
3852                path: entry.write_tree_path(),
3853                mode: entry.write_tree_mode(),
3854                oid: entry.write_tree_oid(),
3855            })
3856            .collect());
3857    }
3858    let mut prefixed = Vec::new();
3859    for entry in entries {
3860        let Some(remainder) = entry.write_tree_path().strip_prefix(trimmed) else {
3861            continue;
3862        };
3863        let Some(stripped) = remainder.strip_prefix(b"/") else {
3864            continue;
3865        };
3866        if stripped.is_empty() {
3867            continue;
3868        }
3869        prefixed.push(WriteTreeEntry {
3870            path: stripped,
3871            mode: entry.write_tree_mode(),
3872            oid: entry.write_tree_oid(),
3873        });
3874    }
3875    if prefixed.is_empty() {
3876        eprintln!(
3877            "fatal: git-write-tree: prefix {} not found",
3878            String::from_utf8_lossy(prefix)
3879        );
3880        return Err(GitError::Exit(128));
3881    }
3882    Ok(prefixed)
3883}
3884
3885pub(crate) fn write_tree_entries_stream<E>(
3886    entries: &[E],
3887    prefix: &[u8],
3888    cache_tree: Option<&CacheTree>,
3889    odb: &FileObjectDatabase,
3890    checker: &mut ObjectPresenceChecker,
3891    missing_ok: bool,
3892) -> Result<ObjectId>
3893where
3894    E: WriteTreeIndexEntry,
3895{
3896    if let Some(oid) = valid_cache_tree_oid(cache_tree, entries.len()) {
3897        return Ok(oid);
3898    }
3899
3900    let mut tree_entries = Vec::new();
3901    let mut index = 0usize;
3902    while index < entries.len() {
3903        let entry = &entries[index];
3904        let path = entry.write_tree_path();
3905        let Some(remainder) = path.strip_prefix(prefix) else {
3906            return Err(GitError::InvalidPath(format!(
3907                "invalid index path {}",
3908                String::from_utf8_lossy(path)
3909            )));
3910        };
3911        if remainder.is_empty() || remainder[0] == b'/' {
3912            return Err(GitError::InvalidPath(format!(
3913                "invalid index path {}",
3914                String::from_utf8_lossy(path)
3915            )));
3916        }
3917
3918        if entry.write_tree_mode() == SPARSE_DIR_MODE
3919            && let Some(name) = remainder.strip_suffix(b"/")
3920            && !name.is_empty()
3921            && !name.contains(&b'/')
3922        {
3923            let oid = entry.write_tree_oid();
3924            if !missing_ok && !checker.contains(&oid)? {
3925                eprintln!(
3926                    "error: invalid object {:o} {} for '{}'",
3927                    SPARSE_DIR_MODE,
3928                    oid,
3929                    String::from_utf8_lossy(path)
3930                );
3931                eprintln!("fatal: git-write-tree: error building trees");
3932                return Err(GitError::Exit(128));
3933            }
3934            tree_entries.push(TreeEntry {
3935                mode: SPARSE_DIR_MODE,
3936                name: BString::from(name),
3937                oid,
3938            });
3939            index += 1;
3940            continue;
3941        }
3942
3943        if let Some(slash) = remainder.iter().position(|byte| *byte == b'/') {
3944            let name = &remainder[..slash];
3945            if name.is_empty() {
3946                return Err(GitError::InvalidPath(format!(
3947                    "invalid index path {}",
3948                    String::from_utf8_lossy(path)
3949                )));
3950            }
3951            let start = index;
3952            let child_cache = cache_tree.and_then(|tree| {
3953                tree.subtrees
3954                    .iter()
3955                    .find(|child| child.name.as_slice() == name)
3956                    .map(|child| &child.tree)
3957            });
3958            if let Some(cached_count) = valid_cache_tree_entry_count(child_cache) {
3959                let end = start.saturating_add(cached_count);
3960                if cached_count > 0
3961                    && end <= entries.len()
3962                    && same_tree_component(entries[end - 1].write_tree_path(), prefix, name)?
3963                    && (end == entries.len()
3964                        || !same_tree_component(entries[end].write_tree_path(), prefix, name)?)
3965                {
3966                    index = end;
3967                } else {
3968                    index += 1;
3969                    while index < entries.len()
3970                        && same_tree_component(entries[index].write_tree_path(), prefix, name)?
3971                    {
3972                        index += 1;
3973                    }
3974                }
3975            } else {
3976                index += 1;
3977                while index < entries.len()
3978                    && same_tree_component(entries[index].write_tree_path(), prefix, name)?
3979                {
3980                    index += 1;
3981                }
3982            }
3983            if let Some(oid) = valid_cache_tree_oid(child_cache, index - start) {
3984                tree_entries.push(TreeEntry {
3985                    mode: 0o040000,
3986                    name: BString::from(name),
3987                    oid,
3988                });
3989                continue;
3990            }
3991            let mut child_prefix = Vec::with_capacity(prefix.len() + name.len() + 1);
3992            child_prefix.extend_from_slice(prefix);
3993            child_prefix.extend_from_slice(name);
3994            child_prefix.push(b'/');
3995            let oid = write_tree_entries_stream(
3996                &entries[start..index],
3997                &child_prefix,
3998                child_cache,
3999                odb,
4000                checker,
4001                missing_ok,
4002            )?;
4003            tree_entries.push(TreeEntry {
4004                mode: 0o040000,
4005                name: BString::from(name),
4006                oid,
4007            });
4008            continue;
4009        }
4010
4011        let mode = entry.write_tree_mode();
4012        let oid = entry.write_tree_oid();
4013        if !missing_ok && !sley_index::is_gitlink(mode) && !checker.contains(&oid)? {
4014            eprintln!(
4015                "error: invalid object {:o} {} for '{}'",
4016                mode,
4017                oid,
4018                String::from_utf8_lossy(path)
4019            );
4020            eprintln!("fatal: git-write-tree: error building trees");
4021            return Err(GitError::Exit(128));
4022        }
4023        tree_entries.push(TreeEntry {
4024            mode,
4025            name: BString::from(remainder),
4026            oid,
4027        });
4028        index += 1;
4029    }
4030
4031    tree_entries.sort_by(|left, right| {
4032        git_tree_entry_cmp(
4033            left.name.as_bytes(),
4034            left.mode,
4035            right.name.as_bytes(),
4036            right.mode,
4037        )
4038    });
4039    odb.write_object(EncodedObject::new(
4040        ObjectType::Tree,
4041        Tree {
4042            entries: tree_entries,
4043        }
4044        .write(),
4045    ))
4046}
4047
4048pub(crate) fn index_entry_tree_oid_without_cache(
4049    index: &Index,
4050    format: ObjectFormat,
4051) -> Result<ObjectId> {
4052    let entries = write_tree_entries_for_prefix(
4053        index
4054            .entries
4055            .iter()
4056            .filter(|entry| entry.stage() == Stage::Normal && !entry.is_intent_to_add()),
4057        None,
4058    )?;
4059    write_tree_entries_oid_without_cache(&entries, b"", format)
4060}
4061
4062pub(crate) fn borrowed_index_entry_tree_oid_without_cache(
4063    index: &BorrowedIndex<'_>,
4064    format: ObjectFormat,
4065) -> Result<ObjectId> {
4066    let entries = write_tree_entries_for_prefix(
4067        index
4068            .entries
4069            .iter()
4070            .filter(|entry| entry.stage() == Stage::Normal && !entry.is_intent_to_add()),
4071        None,
4072    )?;
4073    write_tree_entries_oid_without_cache(&entries, b"", format)
4074}
4075
4076pub(crate) fn write_tree_entries_oid_without_cache<E>(
4077    entries: &[E],
4078    prefix: &[u8],
4079    format: ObjectFormat,
4080) -> Result<ObjectId>
4081where
4082    E: WriteTreeIndexEntry,
4083{
4084    let mut tree_entries = Vec::new();
4085    let mut index = 0usize;
4086    while index < entries.len() {
4087        let entry = &entries[index];
4088        let path = entry.write_tree_path();
4089        let Some(remainder) = path.strip_prefix(prefix) else {
4090            return Err(GitError::InvalidPath(format!(
4091                "invalid index path {}",
4092                String::from_utf8_lossy(path)
4093            )));
4094        };
4095        if remainder.is_empty() || remainder[0] == b'/' {
4096            return Err(GitError::InvalidPath(format!(
4097                "invalid index path {}",
4098                String::from_utf8_lossy(path)
4099            )));
4100        }
4101
4102        if entry.write_tree_mode() == SPARSE_DIR_MODE
4103            && let Some(name) = remainder.strip_suffix(b"/")
4104            && !name.is_empty()
4105            && !name.contains(&b'/')
4106        {
4107            tree_entries.push(TreeEntry {
4108                mode: SPARSE_DIR_MODE,
4109                name: BString::from(name),
4110                oid: entry.write_tree_oid(),
4111            });
4112            index += 1;
4113            continue;
4114        }
4115
4116        if let Some(slash) = remainder.iter().position(|byte| *byte == b'/') {
4117            let name = &remainder[..slash];
4118            if name.is_empty() {
4119                return Err(GitError::InvalidPath(format!(
4120                    "invalid index path {}",
4121                    String::from_utf8_lossy(path)
4122                )));
4123            }
4124            let start = index;
4125            index += 1;
4126            while index < entries.len()
4127                && same_tree_component(entries[index].write_tree_path(), prefix, name)?
4128            {
4129                index += 1;
4130            }
4131            let mut child_prefix = Vec::with_capacity(prefix.len() + name.len() + 1);
4132            child_prefix.extend_from_slice(prefix);
4133            child_prefix.extend_from_slice(name);
4134            child_prefix.push(b'/');
4135            let oid = write_tree_entries_oid_without_cache(
4136                &entries[start..index],
4137                &child_prefix,
4138                format,
4139            )?;
4140            tree_entries.push(TreeEntry {
4141                mode: 0o040000,
4142                name: BString::from(name),
4143                oid,
4144            });
4145            continue;
4146        }
4147
4148        tree_entries.push(TreeEntry {
4149            mode: entry.write_tree_mode(),
4150            name: BString::from(remainder),
4151            oid: entry.write_tree_oid(),
4152        });
4153        index += 1;
4154    }
4155
4156    tree_entries.sort_by(|left, right| {
4157        git_tree_entry_cmp(
4158            left.name.as_bytes(),
4159            left.mode,
4160            right.name.as_bytes(),
4161            right.mode,
4162        )
4163    });
4164    EncodedObject::new(
4165        ObjectType::Tree,
4166        Tree {
4167            entries: tree_entries,
4168        }
4169        .write(),
4170    )
4171    .object_id(format)
4172}
4173
4174pub(crate) fn valid_cache_tree_oid(
4175    tree: Option<&CacheTree>,
4176    entry_count: usize,
4177) -> Option<ObjectId> {
4178    let tree = tree?;
4179    if valid_cache_tree_entry_count(Some(tree))? != entry_count {
4180        return None;
4181    }
4182    tree.oid
4183}
4184
4185pub(crate) fn valid_cache_tree_entry_count(tree: Option<&CacheTree>) -> Option<usize> {
4186    let tree = tree?;
4187    if tree.entry_count < 0 || tree.oid.is_none() {
4188        return None;
4189    }
4190    Some(tree.entry_count as usize)
4191}
4192
4193pub(crate) fn same_tree_component(path: &[u8], prefix: &[u8], name: &[u8]) -> Result<bool> {
4194    let Some(remainder) = path.strip_prefix(prefix) else {
4195        return Err(GitError::InvalidPath(format!(
4196            "invalid index path {}",
4197            String::from_utf8_lossy(path)
4198        )));
4199    };
4200    Ok(remainder.starts_with(name) && remainder.get(name.len()) == Some(&b'/'))
4201}