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    index.extensions = index_extensions_without_cache_tree(&index.extensions);
1410    invalidate_untracked_cache_for_git_paths(
1411        &mut index,
1412        format,
1413        &untracked_cache_invalidation_paths,
1414    )?;
1415    if !pending_large.is_empty() {
1416        write_pending_large_blobs(&odb, &pending_large, large_policy)?;
1417    }
1418    // git's `index.skipHash` / `feature.manyFiles` decide whether the trailing
1419    // checksum is written. `clean_config` carries the full effective config
1420    // (file + command-line `-c`) for the add/update-index callers.
1421    let skip_hash = clean_config
1422        .map(index_skip_hash_from_config)
1423        .unwrap_or(false);
1424    write_repository_index_ref_skip_hash(git_dir, format, &index, skip_hash)?;
1425    if verbose {
1426        let mut stdout = std::io::stdout().lock();
1427        for line in &reports {
1428            writeln!(stdout, "{line}")?;
1429        }
1430        stdout.flush()?;
1431    }
1432    if chmod_error {
1433        return Err(GitError::Exit(128));
1434    }
1435    Ok(UpdateIndexResult {
1436        entries: index.entries.len(),
1437        updated,
1438    })
1439}
1440
1441pub fn refresh_index_paths(
1442    worktree_root: impl AsRef<Path>,
1443    git_dir: impl AsRef<Path>,
1444    format: ObjectFormat,
1445    paths: &[PathBuf],
1446    quiet: bool,
1447    ignore_missing: bool,
1448    really_refresh: bool,
1449) -> Result<UpdateIndexResult> {
1450    let worktree_root = worktree_root.as_ref();
1451    let git_dir = git_dir.as_ref();
1452    let index_path = repository_index_path(git_dir);
1453    if !index_path.exists() {
1454        return Ok(UpdateIndexResult {
1455            entries: 0,
1456            updated: Vec::new(),
1457        });
1458    }
1459    let mut index = Index::parse(&fs::read(&index_path)?, format)?;
1460    let trust_filemode = trust_executable_bit_from_git_dir(git_dir, None);
1461    // git's `update-index --refresh` trusts the cached stat: a stage-0 entry
1462    // whose size+mtime still match the worktree file (and is not racily clean) is
1463    // known unchanged, so its content is NOT re-read or re-hashed
1464    // (read-cache.c `refresh_cache_ent` → `ie_match_stat`). Without this shortcut
1465    // sley re-hashed every tracked file on every refresh — the 3.2x slowdown in
1466    // sley#27. We build the cache from the same parsed index + the index file's
1467    // own mtime (the racy-clean reference) so no extra parse is needed.
1468    let index_mtime = fs::metadata(&index_path)
1469        .ok()
1470        .and_then(|metadata| file_mtime_parts(&metadata));
1471    let stat_cache = IndexStatCache::from_index_mtime_only(index_mtime);
1472    let selected_paths = paths
1473        .iter()
1474        .map(|path| {
1475            let absolute = if path.is_absolute() {
1476                path.clone()
1477            } else {
1478                worktree_root.join(path)
1479            };
1480            let relative = absolute.strip_prefix(worktree_root).map_err(|_| {
1481                GitError::InvalidPath(format!("path {} is outside worktree", path.display()))
1482            })?;
1483            git_path_bytes(relative)
1484        })
1485        .collect::<Result<Vec<_>>>()?;
1486    let selected_paths = selected_paths.into_iter().collect::<BTreeSet<_>>();
1487    if selected_paths.is_empty()
1488        && !really_refresh
1489        && !index
1490            .entries
1491            .iter()
1492            .any(|entry| entry.flags & INDEX_FLAG_ASSUME_UNCHANGED != 0)
1493    {
1494        return refresh_all_index_paths_parallel(
1495            worktree_root,
1496            git_dir,
1497            format,
1498            index,
1499            stat_cache,
1500            quiet,
1501            ignore_missing,
1502            trust_filemode,
1503        );
1504    }
1505    let mut needs_update = false;
1506    let mut index_dirty = false;
1507    for entry in &mut index.entries {
1508        if index_entry_stage(entry) != 0 {
1509            continue;
1510        }
1511        if entry.flags & INDEX_FLAG_ASSUME_UNCHANGED != 0 {
1512            if !really_refresh {
1513                continue;
1514            }
1515            entry.flags &= !INDEX_FLAG_ASSUME_UNCHANGED;
1516            index_dirty = true;
1517        }
1518        let absolute = worktree_root.join(repo_path_to_os_path(entry.path.as_bytes())?);
1519        let Ok(metadata) = fs::metadata(&absolute) else {
1520            if ignore_missing {
1521                continue;
1522            }
1523            if !quiet {
1524                print_update_index_needs_update(entry.path.as_bytes());
1525            }
1526            needs_update = true;
1527            continue;
1528        };
1529        // git's `refresh_cache_ent` runs `ie_match_stat`, whose `S_IFGITLINK`
1530        // arm never re-reads content: a gitlink whose worktree path is a
1531        // directory is up to date (an unpopulated/HEAD-matching submodule), so
1532        // `--refresh` leaves it untouched and silent. Only a gitlink that is no
1533        // longer a directory (replaced by a file, or removed) is `TYPE_CHANGED`.
1534        // This is the single `sley_index::gitlink_stat_verdict` rule; without it
1535        // the `!is_file()` guard below mis-flagged every populated submodule as
1536        // "needs update". The populated-HEAD comparison is deliberately left to
1537        // status/diff (the unpopulated default is clean).
1538        if sley_index::is_gitlink(entry.mode) {
1539            match sley_index::gitlink_stat_verdict(&metadata) {
1540                sley_index::GitlinkStatVerdict::Populated => continue,
1541                sley_index::GitlinkStatVerdict::TypeChanged => {
1542                    if !quiet {
1543                        print_update_index_needs_update(entry.path.as_bytes());
1544                    }
1545                    needs_update = true;
1546                    continue;
1547                }
1548            }
1549        }
1550        if !metadata.is_file() {
1551            if !quiet {
1552                print_update_index_needs_update(entry.path.as_bytes());
1553            }
1554            needs_update = true;
1555            continue;
1556        }
1557        // Stat shortcut: when the cached stat proves the file is unchanged since
1558        // it was staged, its content hashes to the cached oid by construction
1559        // (see `IndexStatCache`'s safety invariant). Skip the read+hash and just
1560        // refresh the stat fields from current metadata — byte-identical to the
1561        // clean arm below, since the oid stamped is the cached one and the
1562        // metadata is the same one that re-stamp would read.
1563        if stat_cache.reuse_index_entry(entry, &metadata).is_some() {
1564            continue;
1565        }
1566        let body = fs::read(&absolute)?;
1567        let object = EncodedObject::new(ObjectType::Blob, body);
1568        let oid = object.object_id(format)?;
1569        if oid != entry.oid || file_mode_with_trust(&metadata, trust_filemode) != entry.mode {
1570            if !quiet {
1571                print_update_index_needs_update(entry.path.as_bytes());
1572            }
1573            needs_update = true;
1574            if really_refresh
1575                && !selected_paths.is_empty()
1576                && selected_paths.contains(entry.path.as_bytes())
1577            {
1578                let updated_entry = index_entry_from_metadata_with_filemode(
1579                    entry.path.clone(),
1580                    oid,
1581                    &metadata,
1582                    trust_filemode,
1583                );
1584                if updated_entry != *entry {
1585                    *entry = updated_entry;
1586                    index_dirty = true;
1587                }
1588            }
1589            continue;
1590        }
1591        let updated_entry = index_entry_from_metadata_with_filemode(
1592            entry.path.clone(),
1593            oid,
1594            &metadata,
1595            trust_filemode,
1596        );
1597        if updated_entry != *entry {
1598            *entry = updated_entry;
1599            index_dirty = true;
1600        }
1601    }
1602    if index_dirty {
1603        write_repository_index_ref(git_dir, format, &index)?;
1604    }
1605    if needs_update && !quiet {
1606        return Err(GitError::Exit(1));
1607    }
1608    Ok(UpdateIndexResult {
1609        entries: index.entries.len(),
1610        updated: Vec::new(),
1611    })
1612}
1613
1614pub(crate) fn refresh_all_index_paths_parallel(
1615    worktree_root: &Path,
1616    git_dir: &Path,
1617    format: ObjectFormat,
1618    mut index: Index,
1619    stat_cache: IndexStatCache,
1620    quiet: bool,
1621    ignore_missing: bool,
1622    trust_filemode: bool,
1623) -> Result<UpdateIndexResult> {
1624    let prechecks =
1625        tracked_only_non_clean_prechecks_parallel(worktree_root, &index, &stat_cache, false)?;
1626    let mut needs_update = false;
1627    let mut index_dirty = false;
1628    for precheck in prechecks {
1629        match precheck {
1630            TrackedOnlyPrecheck::Deleted(idx) => {
1631                if ignore_missing {
1632                    continue;
1633                }
1634                if !quiet {
1635                    print_update_index_needs_update(index.entries[idx].path.as_bytes());
1636                }
1637                needs_update = true;
1638            }
1639            TrackedOnlyPrecheck::Slow(idx) => {
1640                let entry = &mut index.entries[idx];
1641                let path = entry.path.as_bytes().to_vec();
1642                let absolute = worktree_root.join(repo_path_to_os_path(&path)?);
1643                let Ok(metadata) = fs::metadata(&absolute) else {
1644                    if ignore_missing {
1645                        continue;
1646                    }
1647                    if !quiet {
1648                        print_update_index_needs_update(&path);
1649                    }
1650                    needs_update = true;
1651                    continue;
1652                };
1653                // Gitlink: never re-read; a directory on disk is up to date (the
1654                // single `sley_index::gitlink_stat_verdict` rule, matching the
1655                // serial path above). Only a type-changed gitlink needs update.
1656                if sley_index::is_gitlink(entry.mode) {
1657                    match sley_index::gitlink_stat_verdict(&metadata) {
1658                        sley_index::GitlinkStatVerdict::Populated => continue,
1659                        sley_index::GitlinkStatVerdict::TypeChanged => {
1660                            if !quiet {
1661                                print_update_index_needs_update(&path);
1662                            }
1663                            needs_update = true;
1664                            continue;
1665                        }
1666                    }
1667                }
1668                if !metadata.is_file() {
1669                    if !quiet {
1670                        print_update_index_needs_update(&path);
1671                    }
1672                    needs_update = true;
1673                    continue;
1674                }
1675                if stat_cache.reuse_index_entry(entry, &metadata).is_some() {
1676                    continue;
1677                }
1678                let body = fs::read(&absolute)?;
1679                let object = EncodedObject::new(ObjectType::Blob, body);
1680                let oid = object.object_id(format)?;
1681                if oid != entry.oid || file_mode_with_trust(&metadata, trust_filemode) != entry.mode
1682                {
1683                    if !quiet {
1684                        print_update_index_needs_update(&path);
1685                    }
1686                    needs_update = true;
1687                    continue;
1688                }
1689                let updated_entry = index_entry_from_metadata_with_filemode(
1690                    entry.path.clone(),
1691                    oid,
1692                    &metadata,
1693                    trust_filemode,
1694                );
1695                if updated_entry != *entry {
1696                    *entry = updated_entry;
1697                    index_dirty = true;
1698                }
1699            }
1700        }
1701    }
1702    if index_dirty {
1703        write_repository_index_ref(git_dir, format, &index)?;
1704    }
1705    if needs_update && !quiet {
1706        return Err(GitError::Exit(1));
1707    }
1708    Ok(UpdateIndexResult {
1709        entries: index.entries.len(),
1710        updated: Vec::new(),
1711    })
1712}
1713
1714pub fn update_index_again(
1715    worktree_root: impl AsRef<Path>,
1716    git_dir: impl AsRef<Path>,
1717    format: ObjectFormat,
1718    paths: &[PathBuf],
1719    options: UpdateIndexOptions,
1720) -> Result<UpdateIndexResult> {
1721    let worktree_root = worktree_root.as_ref();
1722    let git_dir = git_dir.as_ref();
1723    let index_path = repository_index_path(git_dir);
1724    if !index_path.exists() {
1725        return Ok(UpdateIndexResult {
1726            entries: 0,
1727            updated: Vec::new(),
1728        });
1729    }
1730    let index = Index::parse(&fs::read(&index_path)?, format)?;
1731    let db = FileObjectDatabase::from_git_dir(git_dir, format);
1732    let head_entries = head_tree_entries(git_dir, format, &db)?;
1733    let selected_paths = selected_git_paths(worktree_root, paths)?;
1734    let mut again_paths = Vec::new();
1735    for entry in &index.entries {
1736        if index_entry_stage(entry) != 0 {
1737            continue;
1738        }
1739        if !selected_paths.is_empty() && !git_path_selected(entry.path.as_bytes(), &selected_paths)
1740        {
1741            continue;
1742        }
1743        let differs_from_head = match head_entries.get(entry.path.as_bytes()) {
1744            Some(head_entry) => head_entry.oid != entry.oid || head_entry.mode != entry.mode,
1745            None => true,
1746        };
1747        if differs_from_head {
1748            again_paths.push(worktree_root.join(repo_path_to_os_path(entry.path.as_bytes())?));
1749        }
1750    }
1751    if again_paths.is_empty() {
1752        return Ok(UpdateIndexResult {
1753            entries: index.entries.len(),
1754            updated: Vec::new(),
1755        });
1756    }
1757    update_index_paths(worktree_root, git_dir, format, &again_paths, options)
1758}
1759
1760pub fn set_index_assume_unchanged_paths(
1761    worktree_root: impl AsRef<Path>,
1762    git_dir: impl AsRef<Path>,
1763    format: ObjectFormat,
1764    paths: &[PathBuf],
1765    assume_unchanged: bool,
1766) -> Result<UpdateIndexResult> {
1767    let worktree_root = worktree_root.as_ref();
1768    let git_dir = git_dir.as_ref();
1769    let index_path = repository_index_path(git_dir);
1770    let mut index = if index_path.exists() {
1771        Index::parse(&fs::read(&index_path)?, format)?
1772    } else {
1773        Index {
1774            version: 2,
1775            entries: Vec::new(),
1776            extensions: Vec::new(),
1777            checksum: None,
1778        }
1779    };
1780    let sparse = active_sparse_checkout(git_dir)?;
1781    let db = FileObjectDatabase::from_git_dir(git_dir, format);
1782    if index.is_sparse() {
1783        expand_sparse_index(&mut index, &db, format)?;
1784    }
1785    let selected_paths = paths
1786        .iter()
1787        .map(|path| {
1788            let absolute = if path.is_absolute() {
1789                path.clone()
1790            } else {
1791                worktree_root.join(path)
1792            };
1793            let relative = absolute.strip_prefix(worktree_root).map_err(|_| {
1794                GitError::InvalidPath(format!("path {} is outside worktree", path.display()))
1795            })?;
1796            git_path_bytes(relative)
1797        })
1798        .collect::<Result<Vec<_>>>()?;
1799    for path in selected_paths {
1800        if let Some(entry) = index.entries.iter_mut().find(|entry| entry.path == path) {
1801            if assume_unchanged {
1802                entry.flags |= INDEX_FLAG_ASSUME_UNCHANGED;
1803            } else {
1804                entry.flags &= !INDEX_FLAG_ASSUME_UNCHANGED;
1805            }
1806        }
1807    }
1808    normalize_index_version_for_extended_flags(&mut index);
1809    if let Some((sparse, mode)) = sparse
1810        && sparse.sparse_index
1811    {
1812        let matcher = SparseMatcher::new(&sparse, mode);
1813        collapse_to_sparse_index(&mut index, &matcher, &db, format)?;
1814    }
1815    write_repository_index_ref(git_dir, format, &index)?;
1816    Ok(UpdateIndexResult {
1817        entries: index.entries.len(),
1818        updated: Vec::new(),
1819    })
1820}
1821
1822pub(crate) fn selected_git_paths(
1823    worktree_root: &Path,
1824    paths: &[PathBuf],
1825) -> Result<BTreeSet<Vec<u8>>> {
1826    paths
1827        .iter()
1828        .map(|path| {
1829            let absolute = if path.is_absolute() {
1830                path.clone()
1831            } else {
1832                worktree_root.join(path)
1833            };
1834            let relative = absolute.strip_prefix(worktree_root).map_err(|_| {
1835                GitError::InvalidPath(format!("path {} is outside worktree", path.display()))
1836            })?;
1837            git_path_bytes(relative)
1838        })
1839        .collect()
1840}
1841
1842pub(crate) fn git_path_selected(path: &[u8], selected_paths: &BTreeSet<Vec<u8>>) -> bool {
1843    selected_paths
1844        .iter()
1845        .any(|selected| path == selected || index_entry_is_under_path(path, selected))
1846}
1847
1848pub fn set_index_skip_worktree_paths(
1849    worktree_root: impl AsRef<Path>,
1850    git_dir: impl AsRef<Path>,
1851    format: ObjectFormat,
1852    paths: &[PathBuf],
1853    skip_worktree: bool,
1854) -> Result<UpdateIndexResult> {
1855    let worktree_root = worktree_root.as_ref();
1856    let git_dir = git_dir.as_ref();
1857    let index_path = repository_index_path(git_dir);
1858    let mut index = if index_path.exists() {
1859        Index::parse(&fs::read(&index_path)?, format)?
1860    } else {
1861        Index {
1862            version: 2,
1863            entries: Vec::new(),
1864            extensions: Vec::new(),
1865            checksum: None,
1866        }
1867    };
1868    let sparse = active_sparse_checkout(git_dir)?;
1869    let db = FileObjectDatabase::from_git_dir(git_dir, format);
1870    if index.is_sparse() {
1871        expand_sparse_index(&mut index, &db, format)?;
1872    }
1873    let selected_paths = paths
1874        .iter()
1875        .map(|path| {
1876            let absolute = if path.is_absolute() {
1877                path.clone()
1878            } else {
1879                worktree_root.join(path)
1880            };
1881            let relative = absolute.strip_prefix(worktree_root).map_err(|_| {
1882                GitError::InvalidPath(format!("path {} is outside worktree", path.display()))
1883            })?;
1884            git_path_bytes(relative)
1885        })
1886        .collect::<Result<Vec<_>>>()?;
1887    for path in selected_paths {
1888        if let Some(entry) = index.entries.iter_mut().find(|entry| entry.path == path) {
1889            if skip_worktree {
1890                entry.flags |= INDEX_FLAG_EXTENDED;
1891                entry.flags_extended |= INDEX_EXTENDED_FLAG_SKIP_WORKTREE;
1892            } else {
1893                entry.flags_extended &= !INDEX_EXTENDED_FLAG_SKIP_WORKTREE;
1894                if entry.flags_extended == 0 {
1895                    entry.flags &= !INDEX_FLAG_EXTENDED;
1896                }
1897            }
1898        }
1899    }
1900    normalize_index_version_for_extended_flags(&mut index);
1901    if let Some((sparse, mode)) = sparse
1902        && sparse.sparse_index
1903    {
1904        let matcher = SparseMatcher::new(&sparse, mode);
1905        collapse_to_sparse_index(&mut index, &matcher, &db, format)?;
1906    }
1907    write_repository_index_ref(git_dir, format, &index)?;
1908    Ok(UpdateIndexResult {
1909        entries: index.entries.len(),
1910        updated: Vec::new(),
1911    })
1912}
1913
1914pub fn set_index_fsmonitor_valid_paths(
1915    worktree_root: impl AsRef<Path>,
1916    git_dir: impl AsRef<Path>,
1917    format: ObjectFormat,
1918    paths: &[PathBuf],
1919    _fsmonitor_valid: bool,
1920) -> Result<UpdateIndexResult> {
1921    let worktree_root = worktree_root.as_ref();
1922    let git_dir = git_dir.as_ref();
1923    let index_path = repository_index_path(git_dir);
1924    let index = if index_path.exists() {
1925        Index::parse(&fs::read(&index_path)?, format)?
1926    } else {
1927        Index {
1928            version: 2,
1929            entries: Vec::new(),
1930            extensions: Vec::new(),
1931            checksum: None,
1932        }
1933    };
1934    let selected_paths = paths
1935        .iter()
1936        .map(|path| {
1937            let absolute = if path.is_absolute() {
1938                path.clone()
1939            } else {
1940                worktree_root.join(path)
1941            };
1942            let relative = absolute.strip_prefix(worktree_root).map_err(|_| {
1943                GitError::InvalidPath(format!("path {} is outside worktree", path.display()))
1944            })?;
1945            git_path_bytes(relative)
1946        })
1947        .collect::<Result<Vec<_>>>()?;
1948    for path in selected_paths {
1949        if !index.entries.iter().any(|entry| entry.path == path) {
1950            eprintln!(
1951                "fatal: Unable to mark file {}",
1952                String::from_utf8_lossy(&path)
1953            );
1954            return Err(GitError::Exit(128));
1955        }
1956    }
1957    Ok(UpdateIndexResult {
1958        entries: index.entries.len(),
1959        updated: Vec::new(),
1960    })
1961}
1962
1963pub fn set_index_version(
1964    git_dir: impl AsRef<Path>,
1965    format: ObjectFormat,
1966    version: u32,
1967    verbose: bool,
1968) -> Result<UpdateIndexResult> {
1969    if !matches!(version, 2..=4) {
1970        return Err(GitError::Unsupported(format!(
1971            "update-index currently supports --index-version 2, 3, or 4, got {version}"
1972        )));
1973    }
1974    let git_dir = git_dir.as_ref();
1975    let index_path = repository_index_path(git_dir);
1976    let mut index = if index_path.exists() {
1977        Index::parse(&fs::read(&index_path)?, format)?
1978    } else {
1979        Index {
1980            version: 2,
1981            entries: Vec::new(),
1982            extensions: Vec::new(),
1983            checksum: None,
1984        }
1985    };
1986    // git reports the transition unconditionally under --verbose, even when the
1987    // requested version equals the current one ("was 4, set to 4").
1988    let previous = index.version;
1989    if verbose {
1990        println!("index-version: was {previous}, set to {version}");
1991    }
1992    index.version = version;
1993    normalize_index_version_for_extended_flags(&mut index);
1994    write_repository_index_ref(git_dir, format, &index)?;
1995    Ok(UpdateIndexResult {
1996        entries: index.entries.len(),
1997        updated: Vec::new(),
1998    })
1999}
2000
2001pub fn force_write_index(
2002    git_dir: impl AsRef<Path>,
2003    format: ObjectFormat,
2004) -> Result<UpdateIndexResult> {
2005    let git_dir = git_dir.as_ref();
2006    let index_path = repository_index_path(git_dir);
2007    let mut index = if index_path.exists() {
2008        Index::parse(&fs::read(&index_path)?, format)?
2009    } else {
2010        Index {
2011            version: 2,
2012            entries: Vec::new(),
2013            extensions: Vec::new(),
2014            checksum: None,
2015        }
2016    };
2017    normalize_index_version_for_extended_flags(&mut index);
2018    write_repository_index_ref(git_dir, format, &index)?;
2019    Ok(UpdateIndexResult {
2020        entries: index.entries.len(),
2021        updated: Vec::new(),
2022    })
2023}
2024
2025pub fn enable_untracked_cache(
2026    worktree_root: impl AsRef<Path>,
2027    git_dir: impl AsRef<Path>,
2028    format: ObjectFormat,
2029) -> Result<()> {
2030    let worktree_root = worktree_root.as_ref();
2031    let git_dir = git_dir.as_ref();
2032    let index_path = repository_index_path(git_dir);
2033    let mut index = if index_path.exists() {
2034        Index::parse(&fs::read(&index_path)?, format)?
2035    } else {
2036        empty_index()
2037    };
2038    let ident = untracked_cache_ident(worktree_root);
2039    let dir_flags = untracked_cache_dir_flags(StatusUntrackedMode::Normal);
2040    let cache = match index.untracked_cache(format)? {
2041        Some(mut cache) if cache.ident == ident => {
2042            cache.dir_flags = dir_flags;
2043            cache
2044        }
2045        _ => UntrackedCache::new(format, ident, dir_flags),
2046    };
2047    index.set_untracked_cache(format, Some(&cache))?;
2048    write_repository_index_ref(git_dir, format, &index)?;
2049    Ok(())
2050}
2051
2052pub fn disable_untracked_cache(git_dir: impl AsRef<Path>, format: ObjectFormat) -> Result<()> {
2053    let git_dir = git_dir.as_ref();
2054    let index_path = repository_index_path(git_dir);
2055    if !index_path.exists() {
2056        return Ok(());
2057    }
2058    let mut index = Index::parse(&fs::read(&index_path)?, format)?;
2059    index.set_untracked_cache(format, None)?;
2060    write_repository_index_ref(git_dir, format, &index)?;
2061    Ok(())
2062}
2063
2064pub fn refresh_untracked_cache_after_status(
2065    worktree_root: impl AsRef<Path>,
2066    git_dir: impl AsRef<Path>,
2067    format: ObjectFormat,
2068    config: &GitConfig,
2069    untracked_mode: StatusUntrackedMode,
2070) -> Result<()> {
2071    if matches!(untracked_mode, StatusUntrackedMode::None) {
2072        return Ok(());
2073    }
2074    let worktree_root = worktree_root.as_ref();
2075    let git_dir = git_dir.as_ref();
2076    let index_path = repository_index_path(git_dir);
2077    let untracked_cache_setting = config.get("core", None, "untrackedCache");
2078    match untracked_cache_setting {
2079        Some("keep") | None => {
2080            if !repository_index_has_extension(git_dir, format, b"UNTR")? {
2081                return Ok(());
2082            }
2083        }
2084        Some("false" | "no" | "off" | "0") | Some("true" | "yes" | "on" | "1") => {}
2085        Some(_) => {
2086            if !repository_index_has_extension(git_dir, format, b"UNTR")? {
2087                return Ok(());
2088            }
2089        }
2090    }
2091    let mut index = if index_path.exists() {
2092        Index::parse(&fs::read(&index_path)?, format)?
2093    } else {
2094        empty_index()
2095    };
2096    match untracked_cache_setting {
2097        Some("false") | Some("no") | Some("off") | Some("0") => {
2098            index.set_untracked_cache(format, None)?;
2099            write_repository_index_ref(git_dir, format, &index)?;
2100            return Ok(());
2101        }
2102        Some("true") | Some("yes") | Some("on") | Some("1") => {}
2103        Some("keep") | None => {
2104            if index.untracked_cache(format)?.is_none() {
2105                return Ok(());
2106            }
2107        }
2108        Some(_) => {
2109            if index.untracked_cache(format)?.is_none() {
2110                return Ok(());
2111            }
2112        }
2113    }
2114    let old_cache = index.untracked_cache(format).ok().flatten();
2115    let ident = untracked_cache_ident(worktree_root);
2116    if old_cache.as_ref().is_some_and(|cache| cache.ident != ident) {
2117        eprintln!("warning: untracked cache is disabled on this system or location");
2118        emit_untracked_cache_bypass_trace();
2119        return Ok(());
2120    }
2121    let cache = build_untracked_cache(worktree_root, git_dir, format, &index, untracked_mode)?;
2122    emit_untracked_cache_trace(old_cache.as_ref(), &cache);
2123    index.set_untracked_cache(format, Some(&cache))?;
2124    write_repository_index_ref(git_dir, format, &index)?;
2125    Ok(())
2126}
2127
2128pub(crate) fn repository_index_has_extension(
2129    git_dir: &Path,
2130    format: ObjectFormat,
2131    signature: &[u8; 4],
2132) -> Result<bool> {
2133    let index_path = repository_index_path(git_dir);
2134    if !index_path.exists() {
2135        return Ok(false);
2136    }
2137    let bytes = read_borrowed_index_bytes(&index_path)?;
2138    sley_index::Index::bytes_have_extension(bytes.as_ref(), format, signature)
2139}
2140
2141pub fn emit_untracked_cache_bypass_trace() {
2142    sley_core::trace2::perf_read_directory_data("path", "");
2143}
2144
2145pub(crate) fn index_extensions_without_cache_tree(extensions: &[u8]) -> Vec<u8> {
2146    let mut offset = 0;
2147    let mut filtered = Vec::new();
2148    while offset < extensions.len() {
2149        if extensions.len().saturating_sub(offset) < 8 {
2150            return Vec::new();
2151        }
2152        let signature = &extensions[offset..offset + 4];
2153        let size = u32::from_be_bytes([
2154            extensions[offset + 4],
2155            extensions[offset + 5],
2156            extensions[offset + 6],
2157            extensions[offset + 7],
2158        ]) as usize;
2159        let end = offset + 8 + size;
2160        if end > extensions.len() {
2161            return Vec::new();
2162        }
2163        if signature != b"TREE" {
2164            filtered.extend_from_slice(&extensions[offset..end]);
2165        }
2166        offset = end;
2167    }
2168    filtered
2169}
2170
2171#[derive(Clone)]
2172pub(crate) struct ResolveUndoRecord {
2173    pub(crate) path: Vec<u8>,
2174    pub(crate) stages: [Option<(u32, ObjectId)>; 3],
2175}
2176
2177pub(crate) fn record_resolve_undo_for_path(
2178    index: &mut Index,
2179    format: ObjectFormat,
2180    path: &[u8],
2181    entries: &[IndexEntry],
2182) -> Result<()> {
2183    let mut stages = [None, None, None];
2184    for entry in entries {
2185        match entry.stage() {
2186            Stage::Base => stages[0] = Some((entry.mode, entry.oid)),
2187            Stage::Ours => stages[1] = Some((entry.mode, entry.oid)),
2188            Stage::Theirs => stages[2] = Some((entry.mode, entry.oid)),
2189            Stage::Normal => {}
2190        }
2191    }
2192    if stages.iter().all(Option::is_none) {
2193        return Ok(());
2194    }
2195    let mut records = parse_resolve_undo_records(index.extension(b"REUC")?, format)?;
2196    records.retain(|record| record.path.as_slice() != path);
2197    records.push(ResolveUndoRecord {
2198        path: path.to_vec(),
2199        stages,
2200    });
2201    records.sort_by(|left, right| left.path.cmp(&right.path));
2202    set_resolve_undo_extension(index, &records)
2203}
2204
2205pub(crate) fn record_resolve_undo_for_range(
2206    index: &mut Index,
2207    format: ObjectFormat,
2208    path: &[u8],
2209    range: Range<usize>,
2210) -> Result<()> {
2211    if range.is_empty() {
2212        return Ok(());
2213    }
2214    let entries = index.entries[range].to_vec();
2215    record_resolve_undo_for_path(index, format, path, &entries)
2216}
2217
2218pub(crate) fn parse_resolve_undo_records(
2219    body: Option<&[u8]>,
2220    format: ObjectFormat,
2221) -> Result<Vec<ResolveUndoRecord>> {
2222    let Some(body) = body else {
2223        return Ok(Vec::new());
2224    };
2225    let mut records = Vec::new();
2226    let mut offset = 0usize;
2227    while offset < body.len() {
2228        let path_end = body[offset..]
2229            .iter()
2230            .position(|byte| *byte == 0)
2231            .ok_or_else(|| GitError::InvalidFormat("truncated REUC path".into()))?
2232            + offset;
2233        let path = body[offset..path_end].to_vec();
2234        offset = path_end + 1;
2235
2236        let mut modes = [0u32; 3];
2237        for mode in &mut modes {
2238            let mode_end = body[offset..]
2239                .iter()
2240                .position(|byte| *byte == 0)
2241                .ok_or_else(|| GitError::InvalidFormat("truncated REUC mode".into()))?
2242                + offset;
2243            let text = std::str::from_utf8(&body[offset..mode_end])
2244                .map_err(|_| GitError::InvalidFormat("invalid REUC mode".into()))?;
2245            *mode = u32::from_str_radix(text, 8)
2246                .map_err(|_| GitError::InvalidFormat("invalid REUC mode".into()))?;
2247            offset = mode_end + 1;
2248        }
2249
2250        let mut stages = [None, None, None];
2251        for (idx, mode) in modes.into_iter().enumerate() {
2252            if mode == 0 {
2253                continue;
2254            }
2255            let end = offset
2256                .checked_add(format.raw_len())
2257                .ok_or_else(|| GitError::InvalidFormat("REUC oid length overflow".into()))?;
2258            if end > body.len() {
2259                return Err(GitError::InvalidFormat("truncated REUC oid".into()));
2260            }
2261            stages[idx] = Some((mode, ObjectId::from_raw(format, &body[offset..end])?));
2262            offset = end;
2263        }
2264        records.push(ResolveUndoRecord { path, stages });
2265    }
2266    Ok(records)
2267}
2268
2269pub(crate) fn set_resolve_undo_extension(
2270    index: &mut Index,
2271    records: &[ResolveUndoRecord],
2272) -> Result<()> {
2273    let mut body = Vec::new();
2274    for record in records {
2275        body.extend_from_slice(&record.path);
2276        body.push(0);
2277        for stage in record.stages {
2278            match stage {
2279                Some((mode, _)) => body.extend_from_slice(format!("{mode:o}").as_bytes()),
2280                None => body.push(b'0'),
2281            }
2282            body.push(0);
2283        }
2284        for (_, oid) in record.stages.into_iter().flatten() {
2285            body.extend_from_slice(oid.as_bytes());
2286        }
2287    }
2288
2289    let chunks = index.extension_chunks()?;
2290    let mut rebuilt = Vec::with_capacity(index.extensions.len() + body.len() + 8);
2291    let mut replaced = false;
2292    for (signature, chunk_body) in chunks {
2293        if &signature == b"REUC" {
2294            if !body.is_empty() {
2295                append_index_extension(&mut rebuilt, b"REUC", &body)?;
2296            }
2297            replaced = true;
2298        } else {
2299            append_index_extension(&mut rebuilt, &signature, chunk_body)?;
2300        }
2301    }
2302    if !replaced && !body.is_empty() {
2303        append_index_extension(&mut rebuilt, b"REUC", &body)?;
2304    }
2305    index.extensions = rebuilt;
2306    Ok(())
2307}
2308
2309pub fn clear_resolve_undo(git_dir: impl AsRef<Path>, format: ObjectFormat) -> Result<()> {
2310    let git_dir = git_dir.as_ref();
2311    let index_path = repository_index_path(git_dir);
2312    match fs::read(&index_path) {
2313        Ok(bytes) => {
2314            let mut index = Index::parse(&bytes, format)?;
2315            set_resolve_undo_extension(&mut index, &[])?;
2316            write_repository_index_ref(git_dir, format, &index)
2317        }
2318        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
2319        Err(err) => Err(err.into()),
2320    }
2321}
2322
2323pub(crate) fn append_index_extension(
2324    out: &mut Vec<u8>,
2325    signature: &[u8; 4],
2326    body: &[u8],
2327) -> Result<()> {
2328    let len = u32::try_from(body.len())
2329        .map_err(|_| GitError::InvalidFormat("index extension body too large".into()))?;
2330    out.extend_from_slice(signature);
2331    out.extend_from_slice(&len.to_be_bytes());
2332    out.extend_from_slice(body);
2333    Ok(())
2334}
2335
2336pub(crate) fn index_extensions_without_split_index_link(extensions: &[u8]) -> Vec<u8> {
2337    let mut offset = 0;
2338    let mut filtered = Vec::new();
2339    while offset < extensions.len() {
2340        if extensions.len().saturating_sub(offset) < 8 {
2341            filtered.extend_from_slice(&extensions[offset..]);
2342            break;
2343        }
2344        let signature = &extensions[offset..offset + 4];
2345        let len = u32::from_be_bytes([
2346            extensions[offset + 4],
2347            extensions[offset + 5],
2348            extensions[offset + 6],
2349            extensions[offset + 7],
2350        ]) as usize;
2351        let end = offset.saturating_add(8).saturating_add(len);
2352        if end > extensions.len() {
2353            filtered.extend_from_slice(&extensions[offset..]);
2354            break;
2355        }
2356        if signature != b"link" {
2357            filtered.extend_from_slice(&extensions[offset..end]);
2358        }
2359        offset = end;
2360    }
2361    filtered
2362}
2363
2364pub(crate) fn preserved_index_extensions(git_dir: &Path, format: ObjectFormat) -> Result<Vec<u8>> {
2365    let index_path = repository_index_path(git_dir);
2366    match fs::read(&index_path) {
2367        Ok(bytes) => {
2368            let index = Index::parse(&bytes, format)?;
2369            Ok(index_extensions_without_cache_tree_or_resolve_undo(
2370                &index.extensions,
2371            ))
2372        }
2373        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
2374        Err(err) => Err(err.into()),
2375    }
2376}
2377
2378pub(crate) fn index_extensions_without_cache_tree_or_resolve_undo(extensions: &[u8]) -> Vec<u8> {
2379    let mut filtered = Vec::new();
2380    let mut offset = 0usize;
2381    while offset + 8 <= extensions.len() {
2382        let signature = &extensions[offset..offset + 4];
2383        let len = u32::from_be_bytes([
2384            extensions[offset + 4],
2385            extensions[offset + 5],
2386            extensions[offset + 6],
2387            extensions[offset + 7],
2388        ]) as usize;
2389        let end = offset + 8 + len;
2390        if end > extensions.len() {
2391            filtered.extend_from_slice(&extensions[offset..]);
2392            break;
2393        }
2394        if signature != b"TREE" && signature != b"REUC" {
2395            filtered.extend_from_slice(&extensions[offset..end]);
2396        }
2397        offset = end;
2398    }
2399    filtered
2400}
2401
2402pub(crate) fn repository_index_is_split(git_dir: &Path, format: ObjectFormat) -> Result<bool> {
2403    let index_path = repository_index_path(git_dir);
2404    match fs::read(index_path) {
2405        Ok(bytes) => Ok(Index::parse(&bytes, format)?
2406            .split_index_link(format)?
2407            .is_some()),
2408        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false),
2409        Err(err) => Err(err.into()),
2410    }
2411}
2412
2413pub(crate) fn git_test_split_index_enabled() -> bool {
2414    env::var("GIT_TEST_SPLIT_INDEX")
2415        .ok()
2416        .is_some_and(|value| !matches!(value.as_str(), "" | "0" | "false" | "False" | "FALSE"))
2417}
2418
2419pub fn write_repository_index(git_dir: &Path, format: ObjectFormat, index: Index) -> Result<()> {
2420    let split = index.split_index_link(format)?.is_some()
2421        || repository_index_is_split(git_dir, format)?
2422        || git_test_split_index_enabled();
2423    write_repository_index_ref_with_split(git_dir, format, &index, split)
2424}
2425
2426pub fn write_repository_index_ref(
2427    git_dir: &Path,
2428    format: ObjectFormat,
2429    index: &Index,
2430) -> Result<()> {
2431    let split = index.split_index_link(format)?.is_some()
2432        || repository_index_is_split(git_dir, format)?
2433        || git_test_split_index_enabled();
2434    write_repository_index_ref_with_split(git_dir, format, index, split)
2435}
2436
2437/// As [`write_repository_index_ref`], but `skip_hash` writes a null trailing
2438/// checksum (git's `index.skipHash` / `feature.manyFiles`).
2439pub fn write_repository_index_ref_skip_hash(
2440    git_dir: &Path,
2441    format: ObjectFormat,
2442    index: &Index,
2443    skip_hash: bool,
2444) -> Result<()> {
2445    let split = index.split_index_link(format)?.is_some()
2446        || repository_index_is_split(git_dir, format)?
2447        || git_test_split_index_enabled();
2448    write_repository_index_ref_with_split_skip_hash(git_dir, format, index, split, skip_hash)
2449}
2450
2451pub(crate) fn write_repository_index_ref_with_split(
2452    git_dir: &Path,
2453    format: ObjectFormat,
2454    index: &Index,
2455    split: bool,
2456) -> Result<()> {
2457    write_repository_index_ref_with_split_skip_hash(git_dir, format, index, split, false)
2458}
2459
2460/// As [`write_repository_index_ref_with_split`], but `skip_hash` leaves the
2461/// trailing checksum as the null oid (git's `index.skipHash`). Only the
2462/// non-split write honors it (the case `add`/`update-index` exercise); the
2463/// reader accepts a null trailing hash regardless (see verify_hdr).
2464pub(crate) fn write_repository_index_ref_with_split_skip_hash(
2465    git_dir: &Path,
2466    format: ObjectFormat,
2467    index: &Index,
2468    split: bool,
2469    skip_hash: bool,
2470) -> Result<()> {
2471    let index_path = repository_index_path(git_dir);
2472    if !split || alternate_index_output_path(git_dir, &index_path) {
2473        let smudged_entries = racily_clean_entry_indexes_before_write(git_dir, format, index)?;
2474        let extensions = if index.split_index_link(format)?.is_some() {
2475            Cow::Owned(index_extensions_without_split_index_link(&index.extensions))
2476        } else {
2477            Cow::Borrowed(index.extensions.as_slice())
2478        };
2479        let mut bytes = if smudged_entries.is_empty() && matches!(extensions, Cow::Borrowed(_)) {
2480            index.write(format)?
2481        } else {
2482            write_index_with_entry_size_overrides(format, index, &smudged_entries, &extensions)?
2483        };
2484        if skip_hash {
2485            zero_trailing_index_hash(&mut bytes, format);
2486        }
2487        fs::write(&index_path, bytes)?;
2488        apply_index_shared_file_mode(git_dir, &index_path, None)?;
2489        return Ok(());
2490    }
2491
2492    if let Some(link) = index.split_index_link(format)?
2493        && !link.base_oid.is_null()
2494        && let Some(base) = read_shared_index_for_link(git_dir, &index_path, format, &link)?
2495        && !split_index_delta_exceeds_threshold(git_dir, index, &base)
2496    {
2497        let (entries, link) = split_index_delta_entries(index, &base, &link)?;
2498        let extensions = index_extensions_without_split_index_link(
2499            &index_extensions_without_cache_tree(&index.extensions),
2500        );
2501        let mut primary = Index {
2502            version: index.version,
2503            entries,
2504            extensions,
2505            checksum: None,
2506        };
2507        primary.set_split_index_link(Some(&link))?;
2508        fs::write(&index_path, primary.write(format)?)?;
2509        apply_index_shared_file_mode(git_dir, &index_path, None)?;
2510        return Ok(());
2511    }
2512
2513    let mode_source = fs::metadata(&index_path)
2514        .ok()
2515        .map(|metadata| metadata.permissions());
2516    let mut shared = index.clone();
2517    smudge_racily_clean_entries_before_write(git_dir, format, &mut shared)?;
2518    shared.clear_split_index_link()?;
2519    shared.extensions = index_extensions_without_cache_tree(&shared.extensions);
2520    let shared_bytes = shared.write(format)?;
2521    let shared_oid = index_checksum_from_bytes(format, &shared_bytes)?;
2522    let shared_path = git_dir.join(format!("sharedindex.{shared_oid}"));
2523    if !shared_path.exists() {
2524        fs::write(&shared_path, &shared_bytes)?;
2525    }
2526    apply_index_shared_file_mode(git_dir, &shared_path, mode_source.as_ref())?;
2527    clean_shared_index_files(git_dir, shared_oid)?;
2528
2529    let mut primary = Index {
2530        version: index.version,
2531        entries: Vec::new(),
2532        extensions: Vec::new(),
2533        checksum: None,
2534    };
2535    primary.set_split_index_link(Some(&SplitIndexLink::new(shared_oid)))?;
2536    fs::write(&index_path, primary.write(format)?)?;
2537    apply_index_shared_file_mode(git_dir, &index_path, mode_source.as_ref())?;
2538    Ok(())
2539}
2540
2541pub(crate) fn alternate_index_output_path(git_dir: &Path, index_path: &Path) -> bool {
2542    env::var_os("GIT_INDEX_FILE").is_some() && index_path != git_dir.join("index")
2543}
2544
2545pub(crate) fn clean_shared_index_files(git_dir: &Path, current_oid: ObjectId) -> Result<()> {
2546    let Some(expire_before) = shared_index_expire_before(git_dir) else {
2547        return Ok(());
2548    };
2549    let current_name = format!("sharedindex.{current_oid}");
2550    let mut expired = Vec::new();
2551    for entry in fs::read_dir(git_dir)? {
2552        let entry = entry?;
2553        let name = entry.file_name();
2554        let Some(name) = name.to_str() else {
2555            continue;
2556        };
2557        if !name.starts_with("sharedindex.") || name == current_name {
2558            continue;
2559        }
2560        let metadata = entry.metadata()?;
2561        let Ok(modified) = metadata.modified() else {
2562            continue;
2563        };
2564        if modified <= expire_before {
2565            expired.push((modified, entry.path()));
2566        }
2567    }
2568    expired.sort_by_key(|(modified, _)| *modified);
2569    let delete_count = expired.len().saturating_sub(1);
2570    for (_, path) in expired.into_iter().take(delete_count) {
2571        let _ = fs::remove_file(path);
2572    }
2573    Ok(())
2574}
2575
2576pub(crate) fn shared_index_expire_before(git_dir: &Path) -> Option<SystemTime> {
2577    let value = sley_config::read_repo_config(git_dir, None)
2578        .ok()
2579        .and_then(|config| {
2580            config
2581                .get("splitIndex", None, "sharedIndexExpire")
2582                .map(str::to_string)
2583        })
2584        .unwrap_or_else(|| "2.weeks.ago".to_string());
2585    let value = value.trim();
2586    if value.eq_ignore_ascii_case("never") {
2587        return None;
2588    }
2589    if value.eq_ignore_ascii_case("now") {
2590        return Some(SystemTime::now());
2591    }
2592    if let Some(days) = value
2593        .strip_suffix(".days.ago")
2594        .or_else(|| value.strip_suffix(".day.ago"))
2595        .and_then(|days| days.parse::<u64>().ok())
2596    {
2597        return SystemTime::now().checked_sub(Duration::from_secs(days * 24 * 60 * 60));
2598    }
2599    if let Some(weeks) = value
2600        .strip_suffix(".weeks.ago")
2601        .or_else(|| value.strip_suffix(".week.ago"))
2602        .and_then(|weeks| weeks.parse::<u64>().ok())
2603    {
2604        return SystemTime::now().checked_sub(Duration::from_secs(weeks * 7 * 24 * 60 * 60));
2605    }
2606    SystemTime::now().checked_sub(Duration::from_secs(14 * 24 * 60 * 60))
2607}
2608
2609pub(crate) fn apply_index_shared_file_mode(
2610    git_dir: &Path,
2611    path: &Path,
2612    mode_source: Option<&fs::Permissions>,
2613) -> Result<()> {
2614    #[cfg(unix)]
2615    {
2616        use std::os::unix::fs::PermissionsExt;
2617
2618        let current = fs::metadata(path)?.permissions();
2619        let source_mode = mode_source
2620            .map(fs::Permissions::mode)
2621            .unwrap_or_else(|| current.mode());
2622        let mode = sley_config::read_repo_config(git_dir, None)
2623            .ok()
2624            .and_then(|config| {
2625                config
2626                    .get("core", None, "sharedRepository")
2627                    .and_then(|value| shared_repository_file_mode(value, source_mode))
2628            })
2629            .unwrap_or(source_mode & 0o7777);
2630        fs::set_permissions(path, fs::Permissions::from_mode(mode))?;
2631    }
2632    #[cfg(not(unix))]
2633    {
2634        let _ = git_dir;
2635        let _ = path;
2636        let _ = mode_source;
2637    }
2638    Ok(())
2639}
2640
2641#[cfg(unix)]
2642pub(crate) fn shared_repository_file_mode(value: &str, source_mode: u32) -> Option<u32> {
2643    match value {
2644        "umask" | "false" | "no" | "off" | "0" => None,
2645        "group" | "true" | "yes" | "on" | "1" => Some((source_mode | 0o660) & 0o7777),
2646        "all" | "world" | "everybody" | "2" | "3" => Some((source_mode | 0o664) & 0o7777),
2647        value => {
2648            let parsed = u32::from_str_radix(value, 8).ok()?;
2649            (parsed & 0o600 == 0o600).then_some(parsed & 0o666)
2650        }
2651    }
2652}
2653
2654pub(crate) fn read_shared_index_for_link(
2655    git_dir: &Path,
2656    index_path: &Path,
2657    format: ObjectFormat,
2658    link: &SplitIndexLink,
2659) -> Result<Option<Index>> {
2660    let name = format!("sharedindex.{}", link.base_oid);
2661    let bytes = match fs::read(git_dir.join(&name)) {
2662        Ok(bytes) => bytes,
2663        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
2664            let alternate = index_path
2665                .parent()
2666                .unwrap_or_else(|| Path::new("."))
2667                .join(&name);
2668            match fs::read(alternate) {
2669                Ok(bytes) => bytes,
2670                Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
2671                Err(err) => return Err(err.into()),
2672            }
2673        }
2674        Err(err) => return Err(err.into()),
2675    };
2676    let base = Index::parse(&bytes, format)?;
2677    if base.checksum != Some(link.base_oid) {
2678        return Ok(None);
2679    }
2680    Ok(Some(base))
2681}
2682
2683pub(crate) fn split_index_delta_exceeds_threshold(
2684    git_dir: &Path,
2685    index: &Index,
2686    base: &Index,
2687) -> bool {
2688    let max_percent = sley_config::read_repo_config(git_dir, None)
2689        .ok()
2690        .and_then(|config| {
2691            config
2692                .get("splitIndex", None, "maxPercentChange")
2693                .and_then(|value| value.parse::<i64>().ok())
2694        })
2695        .unwrap_or(20);
2696    match max_percent {
2697        0 => return true,
2698        100.. => return false,
2699        value if value < 0 => {}
2700        _ => {}
2701    }
2702    let not_shared = count_entries_not_shared_with_base(index, base);
2703    (index.entries.len() as i64) * max_percent < (not_shared as i64) * 100
2704}
2705
2706pub(crate) fn count_entries_not_shared_with_base(index: &Index, base: &Index) -> usize {
2707    index
2708        .entries
2709        .iter()
2710        .filter(|entry| {
2711            base.entries
2712                .binary_search_by(|base_entry| compare_index_key(base_entry, entry))
2713                .is_err()
2714        })
2715        .count()
2716}
2717
2718pub(crate) fn split_index_delta_entries(
2719    index: &Index,
2720    base: &Index,
2721    previous_link: &SplitIndexLink,
2722) -> Result<(Vec<IndexEntry>, SplitIndexLink)> {
2723    let mut delete_positions = Vec::new();
2724    let mut replace_positions = Vec::new();
2725    let mut replacements = Vec::new();
2726    let mut additions = Vec::new();
2727    let mut base_pos = 0usize;
2728    let mut index_pos = 0usize;
2729    while base_pos < base.entries.len() && index_pos < index.entries.len() {
2730        match compare_index_key(&base.entries[base_pos], &index.entries[index_pos]) {
2731            Ordering::Equal => {
2732                if previous_link
2733                    .delete_positions
2734                    .binary_search(&(base_pos as u32))
2735                    .is_ok()
2736                {
2737                    delete_positions.push(base_pos as u32);
2738                    additions.push(index.entries[index_pos].clone());
2739                } else if !index_entry_content_eq(
2740                    &base.entries[base_pos],
2741                    &index.entries[index_pos],
2742                ) {
2743                    replace_positions.push(base_pos as u32);
2744                    let mut replacement = index.entries[index_pos].clone();
2745                    replacement.path = BString::from(Vec::<u8>::new());
2746                    replacement.refresh_name_length();
2747                    replacements.push(replacement);
2748                }
2749                base_pos += 1;
2750                index_pos += 1;
2751            }
2752            Ordering::Less => {
2753                delete_positions.push(base_pos as u32);
2754                base_pos += 1;
2755            }
2756            Ordering::Greater => {
2757                additions.push(index.entries[index_pos].clone());
2758                index_pos += 1;
2759            }
2760        }
2761    }
2762    while base_pos < base.entries.len() {
2763        delete_positions.push(base_pos as u32);
2764        base_pos += 1;
2765    }
2766    while index_pos < index.entries.len() {
2767        additions.push(index.entries[index_pos].clone());
2768        index_pos += 1;
2769    }
2770    replacements.extend(additions);
2771    Ok((
2772        replacements,
2773        SplitIndexLink {
2774            base_oid: previous_link.base_oid,
2775            delete_positions,
2776            replace_positions,
2777        },
2778    ))
2779}
2780
2781pub(crate) fn compare_index_key(left: &IndexEntry, right: &IndexEntry) -> Ordering {
2782    left.path
2783        .as_bytes()
2784        .cmp(right.path.as_bytes())
2785        .then_with(|| left.stage().as_u16().cmp(&right.stage().as_u16()))
2786}
2787
2788pub(crate) fn index_entry_content_eq(left: &IndexEntry, right: &IndexEntry) -> bool {
2789    const ONDISK_FLAGS: u16 = sley_index::INDEX_FLAG_STAGE_MASK
2790        | sley_index::INDEX_FLAG_VALID
2791        | sley_index::INDEX_FLAG_EXTENDED;
2792    left.ctime_seconds == right.ctime_seconds
2793        && left.ctime_nanoseconds == right.ctime_nanoseconds
2794        && left.mtime_seconds == right.mtime_seconds
2795        && left.mtime_nanoseconds == right.mtime_nanoseconds
2796        && left.dev == right.dev
2797        && left.ino == right.ino
2798        && left.mode == right.mode
2799        && left.uid == right.uid
2800        && left.gid == right.gid
2801        && left.size == right.size
2802        && left.oid == right.oid
2803        && (left.flags & ONDISK_FLAGS) == (right.flags & ONDISK_FLAGS)
2804        && left.flags_extended == right.flags_extended
2805}
2806
2807pub(crate) fn write_index_with_entry_size_overrides(
2808    format: ObjectFormat,
2809    index: &Index,
2810    zero_size_entries: &[usize],
2811    extensions: &[u8],
2812) -> Result<Vec<u8>> {
2813    if !(2..=4).contains(&index.version) {
2814        return Err(GitError::Unsupported(
2815            "canonical writer currently emits index v2/v3/v4".into(),
2816        ));
2817    }
2818    let mut out = Vec::new();
2819    out.extend_from_slice(b"DIRC");
2820    out.extend_from_slice(&index.version.to_be_bytes());
2821    out.extend_from_slice(&(index.entries.len() as u32).to_be_bytes());
2822    let mut previous_path = Vec::new();
2823    for (position, entry) in index.entries.iter().enumerate() {
2824        let start = out.len();
2825        out.extend_from_slice(&entry.ctime_seconds.to_be_bytes());
2826        out.extend_from_slice(&entry.ctime_nanoseconds.to_be_bytes());
2827        out.extend_from_slice(&entry.mtime_seconds.to_be_bytes());
2828        out.extend_from_slice(&entry.mtime_nanoseconds.to_be_bytes());
2829        out.extend_from_slice(&entry.dev.to_be_bytes());
2830        out.extend_from_slice(&entry.ino.to_be_bytes());
2831        out.extend_from_slice(&entry.mode.to_be_bytes());
2832        out.extend_from_slice(&entry.uid.to_be_bytes());
2833        out.extend_from_slice(&entry.gid.to_be_bytes());
2834        let size = if zero_size_entries.binary_search(&position).is_ok() {
2835            0
2836        } else {
2837            entry.size
2838        };
2839        out.extend_from_slice(&size.to_be_bytes());
2840        if entry.oid.format() != format {
2841            return Err(GitError::Unsupported(format!(
2842                "index writer expects {} ids",
2843                format.name()
2844            )));
2845        }
2846        out.extend_from_slice(entry.oid.as_bytes());
2847        let has_extended_flags =
2848            entry.flags & INDEX_FLAG_EXTENDED != 0 || entry.flags_extended != 0;
2849        if has_extended_flags && index.version < 3 {
2850            return Err(GitError::Unsupported(
2851                "index extended flags require version 3".into(),
2852            ));
2853        }
2854        let flags = if has_extended_flags {
2855            entry.flags | INDEX_FLAG_EXTENDED
2856        } else {
2857            entry.flags & !INDEX_FLAG_EXTENDED
2858        };
2859        out.extend_from_slice(&flags.to_be_bytes());
2860        if has_extended_flags {
2861            out.extend_from_slice(&entry.flags_extended.to_be_bytes());
2862        }
2863        if index.version == 4 {
2864            let common_prefix_len = common_prefix_len(&previous_path, entry.path.as_bytes());
2865            let strip_len = previous_path.len() - common_prefix_len;
2866            encode_index_v4_path_strip_len(strip_len, &mut out);
2867            out.extend_from_slice(&entry.path.as_bytes()[common_prefix_len..]);
2868            out.push(0);
2869            previous_path = entry.path.as_bytes().to_vec();
2870        } else {
2871            out.extend_from_slice(entry.path.as_bytes());
2872            out.push(0);
2873            while (out.len() - start) % 8 != 0 {
2874                out.push(0);
2875            }
2876        }
2877    }
2878    out.extend_from_slice(extensions);
2879    let checksum = sley_core::digest_bytes(format, &out)?;
2880    out.extend_from_slice(checksum.as_bytes());
2881    Ok(out)
2882}
2883
2884pub(crate) fn encode_index_v4_path_strip_len(strip_len: usize, out: &mut Vec<u8>) {
2885    let mut bytes = Vec::new();
2886    bytes.push((strip_len & 0x7f) as u8);
2887    let mut value = strip_len >> 7;
2888    while value != 0 {
2889        value -= 1;
2890        bytes.push(((value & 0x7f) as u8) | 0x80);
2891        value >>= 7;
2892    }
2893    for byte in bytes.iter().rev() {
2894        out.push(*byte);
2895    }
2896}
2897
2898pub(crate) fn common_prefix_len(left: &[u8], right: &[u8]) -> usize {
2899    left.iter()
2900        .zip(right.iter())
2901        .take_while(|(left, right)| left == right)
2902        .count()
2903}
2904
2905pub(crate) fn index_checksum_from_bytes(format: ObjectFormat, bytes: &[u8]) -> Result<ObjectId> {
2906    let hash_len = format.raw_len();
2907    if bytes.len() < hash_len {
2908        return Err(GitError::InvalidFormat(
2909            "index too short for checksum".into(),
2910        ));
2911    }
2912    ObjectId::from_raw(format, &bytes[bytes.len() - hash_len..])
2913}
2914
2915pub fn enable_split_index(
2916    git_dir: impl AsRef<Path>,
2917    format: ObjectFormat,
2918) -> Result<UpdateIndexResult> {
2919    let git_dir = git_dir.as_ref();
2920    let mut index = read_repository_index(git_dir, format)?.unwrap_or_else(empty_index);
2921    normalize_index_version_for_extended_flags(&mut index);
2922    write_repository_index_ref_with_split(git_dir, format, &index, true)?;
2923    Ok(UpdateIndexResult {
2924        entries: index.entries.len(),
2925        updated: Vec::new(),
2926    })
2927}
2928
2929pub fn disable_split_index(
2930    git_dir: impl AsRef<Path>,
2931    format: ObjectFormat,
2932) -> Result<UpdateIndexResult> {
2933    let git_dir = git_dir.as_ref();
2934    if !repository_index_path(git_dir).exists() {
2935        return Ok(UpdateIndexResult {
2936            entries: 0,
2937            updated: Vec::new(),
2938        });
2939    }
2940    let mut index = read_repository_index(git_dir, format)?.unwrap_or_else(empty_index);
2941    normalize_index_version_for_extended_flags(&mut index);
2942    write_repository_index_ref_with_split(git_dir, format, &index, false)?;
2943    Ok(UpdateIndexResult {
2944        entries: index.entries.len(),
2945        updated: Vec::new(),
2946    })
2947}
2948
2949pub(crate) fn smudge_racily_clean_entries_before_write(
2950    git_dir: &Path,
2951    format: ObjectFormat,
2952    index: &mut Index,
2953) -> Result<()> {
2954    for position in racily_clean_entry_indexes_before_write(git_dir, format, index)? {
2955        index.entries[position].size = 0;
2956    }
2957    Ok(())
2958}
2959
2960pub(crate) fn racily_clean_entry_indexes_before_write(
2961    git_dir: &Path,
2962    format: ObjectFormat,
2963    index: &Index,
2964) -> Result<Vec<usize>> {
2965    let index_path = repository_index_path(git_dir);
2966    let Some(index_mtime) = fs::metadata(&index_path)
2967        .ok()
2968        .and_then(|metadata| sley_index::file_mtime_parts(&metadata))
2969    else {
2970        return Ok(Vec::new());
2971    };
2972    if index_mtime == (0, 0) {
2973        return Ok(Vec::new());
2974    }
2975    let Some(worktree_root) = (match worktree_root_for_git_dir(git_dir) {
2976        Ok(worktree_root) => worktree_root,
2977        Err(_) => return Ok(Vec::new()),
2978    }) else {
2979        return Ok(Vec::new());
2980    };
2981    let mut smudged = Vec::new();
2982    for (position, entry) in index.entries.iter().enumerate() {
2983        if index_entry_stage(entry) != 0 || sley_index::is_gitlink(entry.mode) {
2984            continue;
2985        }
2986        let entry_mtime = (
2987            u64::from(entry.mtime_seconds),
2988            u64::from(entry.mtime_nanoseconds),
2989        );
2990        if entry_mtime == (0, 0) || index_mtime > entry_mtime {
2991            continue;
2992        }
2993        let absolute = worktree_root.join(repo_path_to_os_path(entry.path.as_bytes())?);
2994        let Ok(metadata) = fs::symlink_metadata(&absolute) else {
2995            continue;
2996        };
2997        if entry.mode != worktree_entry_mode(&metadata)
2998            || !worktree_entry_is_uptodate(entry, &metadata)
2999        {
3000            continue;
3001        }
3002        let body = if metadata.file_type().is_symlink() {
3003            symlink_target_bytes(&absolute)?
3004        } else if metadata.is_file() {
3005            fs::read(&absolute)?
3006        } else {
3007            continue;
3008        };
3009        let oid = EncodedObject::new(ObjectType::Blob, body).object_id(format)?;
3010        if oid != entry.oid {
3011            smudged.push(position);
3012        }
3013    }
3014    Ok(smudged)
3015}
3016
3017pub(crate) fn invalidate_untracked_cache_for_git_paths(
3018    index: &mut Index,
3019    format: ObjectFormat,
3020    paths: &[Vec<u8>],
3021) -> Result<()> {
3022    if paths.is_empty() {
3023        return Ok(());
3024    }
3025    let Some(mut cache) = index.untracked_cache(format)? else {
3026        return Ok(());
3027    };
3028    let Some(root) = cache.root.as_mut() else {
3029        return Ok(());
3030    };
3031    for path in paths {
3032        invalidate_untracked_cache_dir_for_path(root, path);
3033    }
3034    index.set_untracked_cache(format, Some(&cache))
3035}
3036
3037pub(crate) fn invalidate_untracked_cache_dir_for_path(root: &mut UntrackedCacheDir, path: &[u8]) {
3038    invalidate_untracked_cache_node(root);
3039    let mut current = root;
3040    let mut components = path.split(|byte| *byte == b'/').peekable();
3041    while let Some(component) = components.next() {
3042        if component.is_empty() || components.peek().is_none() {
3043            break;
3044        }
3045        let Some(child) = current.dirs.iter_mut().find(|dir| dir.name == component) else {
3046            break;
3047        };
3048        invalidate_untracked_cache_node(child);
3049        current = child;
3050    }
3051}
3052
3053pub(crate) fn invalidate_untracked_cache_node(node: &mut UntrackedCacheDir) {
3054    node.valid = false;
3055    node.untracked.clear();
3056}
3057
3058pub fn update_index_cacheinfo(
3059    git_dir: impl AsRef<Path>,
3060    format: ObjectFormat,
3061    entries: &[CacheInfoEntry],
3062    add: bool,
3063    verbose: bool,
3064) -> Result<UpdateIndexResult> {
3065    let git_dir = git_dir.as_ref();
3066    let index_path = repository_index_path(git_dir);
3067    let mut index = if index_path.exists() {
3068        Index::parse(&fs::read(&index_path)?, format)?
3069    } else {
3070        Index {
3071            version: 2,
3072            entries: Vec::new(),
3073            extensions: Vec::new(),
3074            checksum: None,
3075        }
3076    };
3077    let mut updated = Vec::new();
3078    let mut reports: Vec<String> = Vec::new();
3079    let mut untracked_cache_invalidation_paths = Vec::new();
3080    for cacheinfo in entries {
3081        if !add
3082            && !index
3083                .entries
3084                .iter()
3085                .any(|existing| existing.path == cacheinfo.path)
3086        {
3087            let path = String::from_utf8_lossy(&cacheinfo.path);
3088            eprintln!("error: {path}: cannot add to the index - missing --add option?");
3089            eprintln!("fatal: git update-index: --cacheinfo cannot add {path}");
3090            return Err(GitError::Exit(128));
3091        }
3092        let flags = index_flags(cacheinfo.path.len(), cacheinfo.stage);
3093        let entry = IndexEntry {
3094            ctime_seconds: 0,
3095            ctime_nanoseconds: 0,
3096            mtime_seconds: 0,
3097            mtime_nanoseconds: 0,
3098            dev: 0,
3099            ino: 0,
3100            mode: cacheinfo.mode,
3101            uid: 0,
3102            gid: 0,
3103            size: 0,
3104            oid: cacheinfo.oid,
3105            flags,
3106            flags_extended: 0,
3107            path: BString::from(cacheinfo.path.as_slice()),
3108        };
3109        index.entries.retain(|existing| {
3110            existing.path != cacheinfo.path || index_entry_stage(existing) != cacheinfo.stage
3111        });
3112        index.entries.push(entry);
3113        untracked_cache_invalidation_paths.push(cacheinfo.path.clone());
3114        updated.push(cacheinfo.oid);
3115        // git's add_cacheinfo() calls report("add '%s'") *after* the entry is
3116        // staged, regardless of whether the subsequent index write succeeds.
3117        reports.push(format!(
3118            "add '{}'",
3119            String::from_utf8_lossy(&cacheinfo.path)
3120        ));
3121    }
3122    index
3123        .entries
3124        .sort_by(|left, right| left.path.cmp(&right.path));
3125    // git refuses to write an index entry whose object id is the null oid:
3126    // do_write_index() emits `error: cache entry has null sha1: <path>` and
3127    // returns nonzero, leaving the on-disk index untouched. The verbose `add`
3128    // line has already been printed by then.
3129    let null_entry = index.entries.iter().find(|entry| entry.oid.is_null());
3130    if let Some(entry) = null_entry {
3131        if verbose {
3132            flush_update_index_reports(&reports)?;
3133        }
3134        eprintln!(
3135            "error: cache entry has null sha1: {}",
3136            String::from_utf8_lossy(&entry.path)
3137        );
3138        return Err(GitError::Exit(128));
3139    }
3140    invalidate_untracked_cache_for_git_paths(
3141        &mut index,
3142        format,
3143        &untracked_cache_invalidation_paths,
3144    )?;
3145    write_repository_index_ref(git_dir, format, &index)?;
3146    if verbose {
3147        flush_update_index_reports(&reports)?;
3148    }
3149    Ok(UpdateIndexResult {
3150        entries: index.entries.len(),
3151        updated,
3152    })
3153}
3154
3155pub(crate) fn flush_update_index_reports(reports: &[String]) -> Result<()> {
3156    let mut stdout = std::io::stdout().lock();
3157    for line in reports {
3158        writeln!(stdout, "{line}")?;
3159    }
3160    stdout.flush()?;
3161    Ok(())
3162}
3163
3164pub fn update_index_index_info(
3165    git_dir: impl AsRef<Path>,
3166    format: ObjectFormat,
3167    records: &[IndexInfoRecord],
3168) -> Result<UpdateIndexResult> {
3169    let git_dir = git_dir.as_ref();
3170    let index_path = repository_index_path(git_dir);
3171    let mut index = if index_path.exists() {
3172        Index::parse(&fs::read(&index_path)?, format)?
3173    } else {
3174        Index {
3175            version: 2,
3176            entries: Vec::new(),
3177            extensions: Vec::new(),
3178            checksum: None,
3179        }
3180    };
3181    let mut updated = Vec::new();
3182    let mut untracked_cache_invalidation_paths = Vec::new();
3183    for record in records {
3184        match record {
3185            IndexInfoRecord::Remove { path } => {
3186                index.entries.retain(|existing| existing.path != *path);
3187                untracked_cache_invalidation_paths.push(path.clone());
3188            }
3189            IndexInfoRecord::Add(cacheinfo) => {
3190                let flags = index_flags(cacheinfo.path.len(), cacheinfo.stage);
3191                let entry = IndexEntry {
3192                    ctime_seconds: 0,
3193                    ctime_nanoseconds: 0,
3194                    mtime_seconds: 0,
3195                    mtime_nanoseconds: 0,
3196                    dev: 0,
3197                    ino: 0,
3198                    mode: cacheinfo.mode,
3199                    uid: 0,
3200                    gid: 0,
3201                    size: 0,
3202                    oid: cacheinfo.oid,
3203                    flags,
3204                    flags_extended: 0,
3205                    path: BString::from(cacheinfo.path.as_slice()),
3206                };
3207                if cacheinfo.stage == 0 {
3208                    index
3209                        .entries
3210                        .retain(|existing| existing.path != cacheinfo.path);
3211                } else {
3212                    index.entries.retain(|existing| {
3213                        existing.path != cacheinfo.path
3214                            || index_entry_stage(existing) != cacheinfo.stage
3215                    });
3216                }
3217                index.entries.push(entry);
3218                untracked_cache_invalidation_paths.push(cacheinfo.path.clone());
3219                updated.push(cacheinfo.oid);
3220            }
3221        }
3222    }
3223    index.entries.sort_by(|left, right| {
3224        left.path
3225            .cmp(&right.path)
3226            .then_with(|| index_entry_stage(left).cmp(&index_entry_stage(right)))
3227    });
3228    invalidate_untracked_cache_for_git_paths(
3229        &mut index,
3230        format,
3231        &untracked_cache_invalidation_paths,
3232    )?;
3233    write_repository_index_ref(git_dir, format, &index)?;
3234    Ok(UpdateIndexResult {
3235        entries: index.entries.len(),
3236        updated,
3237    })
3238}
3239
3240pub(crate) fn index_flags(path_len: usize, stage: u16) -> u16 {
3241    ((stage & 0x3) << 12) | ((path_len.min(0xfff) as u16) & 0x0fff)
3242}
3243
3244pub(crate) const INDEX_FLAG_ASSUME_UNCHANGED: u16 = 0x8000;
3245pub(crate) const INDEX_FLAG_EXTENDED: u16 = 0x4000;
3246pub(crate) const INDEX_EXTENDED_FLAG_SKIP_WORKTREE: u16 = 0x4000;
3247
3248pub(crate) fn normalize_index_version_for_extended_flags(index: &mut Index) {
3249    let has_extended_flags = index
3250        .entries
3251        .iter()
3252        .any(|entry| entry.flags & INDEX_FLAG_EXTENDED != 0 || entry.flags_extended != 0);
3253    if has_extended_flags && index.version < 3 {
3254        index.version = 3;
3255    } else if !has_extended_flags && index.version == 3 {
3256        index.version = 2;
3257    }
3258}
3259
3260pub(crate) fn index_entry_stage(entry: &IndexEntry) -> u16 {
3261    (entry.flags >> 12) & 0x3
3262}
3263
3264/// The oid of the stage-0 entry in `range` (the path's currently-tracked blob),
3265/// if any. Used by the safecrlf check to fetch `has_crlf_in_index`.
3266pub(crate) fn stage0_oid_in_range(
3267    entries: &[IndexEntry],
3268    range: std::ops::Range<usize>,
3269) -> Option<ObjectId> {
3270    entries[range]
3271        .iter()
3272        .find(|entry| index_entry_stage(entry) == 0)
3273        .map(|entry| entry.oid)
3274}
3275
3276pub(crate) fn index_entry_skip_worktree(entry: &IndexEntry) -> bool {
3277    entry.flags & INDEX_FLAG_EXTENDED != 0
3278        && entry.flags_extended & INDEX_EXTENDED_FLAG_SKIP_WORKTREE != 0
3279}
3280
3281pub(crate) fn print_update_index_path_error(path: &[u8], message: &str) {
3282    let path = String::from_utf8_lossy(path);
3283    eprintln!("error: {path}: {message}");
3284    eprintln!("fatal: Unable to process path {path}");
3285}
3286
3287pub(crate) fn print_update_index_needs_update(path: &[u8]) {
3288    let path = String::from_utf8_lossy(path);
3289    println!("{path}: needs update");
3290}
3291
3292pub fn write_tree_from_index(git_dir: impl AsRef<Path>, format: ObjectFormat) -> Result<ObjectId> {
3293    write_tree_from_index_with_options(git_dir, format, WriteTreeOptions::default())
3294}
3295
3296pub fn write_tree_from_index_with_odb(
3297    git_dir: impl AsRef<Path>,
3298    format: ObjectFormat,
3299    odb: &FileObjectDatabase,
3300) -> Result<ObjectId> {
3301    write_tree_from_index_with_options_and_odb(
3302        git_dir.as_ref(),
3303        format,
3304        WriteTreeOptions::default(),
3305        odb,
3306    )
3307}
3308
3309pub fn write_tree_from_index_with_options(
3310    git_dir: impl AsRef<Path>,
3311    format: ObjectFormat,
3312    options: WriteTreeOptions,
3313) -> Result<ObjectId> {
3314    let git_dir = git_dir.as_ref();
3315    let odb = FileObjectDatabase::from_git_dir(git_dir, format);
3316    write_tree_from_index_with_options_and_odb(git_dir, format, options, &odb)
3317}
3318
3319pub(crate) fn write_tree_from_index_with_options_and_odb(
3320    git_dir: &Path,
3321    format: ObjectFormat,
3322    options: WriteTreeOptions,
3323    odb: &FileObjectDatabase,
3324) -> Result<ObjectId> {
3325    let index_path = repository_index_path(git_dir);
3326    // A repository with no index file yet (fresh init, nothing staged) is an
3327    // empty index: `git write-tree` / `git commit --allow-empty` produce the
3328    // empty tree rather than erroring.
3329    let index_bytes = match fs::read(&index_path) {
3330        Ok(bytes) => bytes,
3331        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
3332            let mut checker = odb.presence_checker();
3333            let empty: &[WriteTreeEntry<'_>] = &[];
3334            return write_tree_entries_stream(
3335                empty,
3336                b"",
3337                None,
3338                odb,
3339                &mut checker,
3340                options.missing_ok,
3341            );
3342        }
3343        Err(err) => return Err(err.into()),
3344    };
3345    let mut checker = odb.presence_checker();
3346    if Index::bytes_have_extension(&index_bytes, format, b"link")? {
3347        let index = sley_index::read_repository_index(git_dir, format)?;
3348        return write_tree_from_owned_index(&index, format, &options, odb, &mut checker);
3349    }
3350    match BorrowedIndex::parse(&index_bytes, format) {
3351        Ok(index) => write_tree_from_borrowed_index(&index, format, &options, odb, &mut checker),
3352        Err(GitError::Unsupported(_)) => {
3353            let index = Index::parse(&index_bytes, format)?;
3354            write_tree_from_owned_index(&index, format, &options, odb, &mut checker)
3355        }
3356        Err(err) => Err(err),
3357    }
3358}
3359
3360pub(crate) fn write_tree_from_borrowed_index(
3361    index: &BorrowedIndex<'_>,
3362    format: ObjectFormat,
3363    options: &WriteTreeOptions,
3364    odb: &FileObjectDatabase,
3365    checker: &mut ObjectPresenceChecker,
3366) -> Result<ObjectId> {
3367    let cache_tree = if options.prefix.is_none() {
3368        index.cache_tree(format).ok().flatten()
3369    } else {
3370        None
3371    };
3372    if options.prefix.is_none() && !index.entries.iter().any(|entry| entry.is_intent_to_add()) {
3373        return write_tree_entries_stream(
3374            &index.entries,
3375            b"",
3376            cache_tree.as_ref(),
3377            odb,
3378            checker,
3379            options.missing_ok,
3380        );
3381    }
3382    // intent-to-add entries (`git add -N`, `git reset -N`) are placeholders that do
3383    // NOT belong in a written tree — git's cache_tree_update skips CE_INTENT_TO_ADD.
3384    // Drop them before building, so `write-tree` succeeds and the tree omits them
3385    // (their empty-blob oid is also typically absent from the odb).
3386    let entries = write_tree_entries_for_prefix(
3387        index
3388            .entries
3389            .iter()
3390            .filter(|entry| !entry.is_intent_to_add()),
3391        options.prefix.as_deref(),
3392    )?;
3393    write_tree_entries_stream(
3394        &entries,
3395        b"",
3396        cache_tree.as_ref(),
3397        odb,
3398        checker,
3399        options.missing_ok,
3400    )
3401}
3402
3403pub(crate) fn write_tree_from_owned_index(
3404    index: &Index,
3405    format: ObjectFormat,
3406    options: &WriteTreeOptions,
3407    odb: &FileObjectDatabase,
3408    checker: &mut ObjectPresenceChecker,
3409) -> Result<ObjectId> {
3410    let cache_tree = if options.prefix.is_none() {
3411        index.cache_tree(format).ok().flatten()
3412    } else {
3413        None
3414    };
3415    if options.prefix.is_none() && !index.entries.iter().any(|entry| entry.is_intent_to_add()) {
3416        return write_tree_entries_stream(
3417            &index.entries,
3418            b"",
3419            cache_tree.as_ref(),
3420            odb,
3421            checker,
3422            options.missing_ok,
3423        );
3424    }
3425    let entries = write_tree_entries_for_prefix(
3426        index
3427            .entries
3428            .iter()
3429            .filter(|entry| !entry.is_intent_to_add()),
3430        options.prefix.as_deref(),
3431    )?;
3432    write_tree_entries_stream(
3433        &entries,
3434        b"",
3435        cache_tree.as_ref(),
3436        odb,
3437        checker,
3438        options.missing_ok,
3439    )
3440}
3441
3442#[derive(Clone, Copy)]
3443pub(crate) struct WriteTreeEntry<'a> {
3444    pub(crate) path: &'a [u8],
3445    pub(crate) mode: u32,
3446    pub(crate) oid: ObjectId,
3447}
3448
3449pub(crate) trait WriteTreeIndexEntry {
3450    fn write_tree_path(&self) -> &[u8];
3451    fn write_tree_mode(&self) -> u32;
3452    fn write_tree_oid(&self) -> ObjectId;
3453}
3454
3455impl WriteTreeIndexEntry for IndexEntry {
3456    fn write_tree_path(&self) -> &[u8] {
3457        self.path.as_bytes()
3458    }
3459
3460    fn write_tree_mode(&self) -> u32 {
3461        self.mode
3462    }
3463
3464    fn write_tree_oid(&self) -> ObjectId {
3465        self.oid
3466    }
3467}
3468
3469impl WriteTreeIndexEntry for IndexEntryRef<'_> {
3470    fn write_tree_path(&self) -> &[u8] {
3471        self.path
3472    }
3473
3474    fn write_tree_mode(&self) -> u32 {
3475        self.mode
3476    }
3477
3478    fn write_tree_oid(&self) -> ObjectId {
3479        self.oid
3480    }
3481}
3482
3483impl WriteTreeIndexEntry for WriteTreeEntry<'_> {
3484    fn write_tree_path(&self) -> &[u8] {
3485        self.path
3486    }
3487
3488    fn write_tree_mode(&self) -> u32 {
3489        self.mode
3490    }
3491
3492    fn write_tree_oid(&self) -> ObjectId {
3493        self.oid
3494    }
3495}
3496
3497pub(crate) fn write_tree_entries_for_prefix<'a, E>(
3498    entries: impl IntoIterator<Item = &'a E>,
3499    prefix: Option<&[u8]>,
3500) -> Result<Vec<WriteTreeEntry<'a>>>
3501where
3502    E: WriteTreeIndexEntry + 'a,
3503{
3504    let Some(prefix) = prefix else {
3505        return Ok(entries
3506            .into_iter()
3507            .map(|entry| WriteTreeEntry {
3508                path: entry.write_tree_path(),
3509                mode: entry.write_tree_mode(),
3510                oid: entry.write_tree_oid(),
3511            })
3512            .collect());
3513    };
3514    let trimmed_len = prefix
3515        .iter()
3516        .rposition(|byte| *byte != b'/')
3517        .map(|idx| idx + 1)
3518        .unwrap_or(0);
3519    let trimmed = &prefix[..trimmed_len];
3520    if trimmed.is_empty() {
3521        return Ok(entries
3522            .into_iter()
3523            .map(|entry| WriteTreeEntry {
3524                path: entry.write_tree_path(),
3525                mode: entry.write_tree_mode(),
3526                oid: entry.write_tree_oid(),
3527            })
3528            .collect());
3529    }
3530    let mut prefixed = Vec::new();
3531    for entry in entries {
3532        let Some(remainder) = entry.write_tree_path().strip_prefix(trimmed) else {
3533            continue;
3534        };
3535        let Some(stripped) = remainder.strip_prefix(b"/") else {
3536            continue;
3537        };
3538        if stripped.is_empty() {
3539            continue;
3540        }
3541        prefixed.push(WriteTreeEntry {
3542            path: stripped,
3543            mode: entry.write_tree_mode(),
3544            oid: entry.write_tree_oid(),
3545        });
3546    }
3547    if prefixed.is_empty() {
3548        eprintln!(
3549            "fatal: git-write-tree: prefix {} not found",
3550            String::from_utf8_lossy(prefix)
3551        );
3552        return Err(GitError::Exit(128));
3553    }
3554    Ok(prefixed)
3555}
3556
3557pub(crate) fn write_tree_entries_stream<E>(
3558    entries: &[E],
3559    prefix: &[u8],
3560    cache_tree: Option<&CacheTree>,
3561    odb: &FileObjectDatabase,
3562    checker: &mut ObjectPresenceChecker,
3563    missing_ok: bool,
3564) -> Result<ObjectId>
3565where
3566    E: WriteTreeIndexEntry,
3567{
3568    if let Some(oid) = valid_cache_tree_oid(cache_tree, entries.len()) {
3569        return Ok(oid);
3570    }
3571
3572    let mut tree_entries = Vec::new();
3573    let mut index = 0usize;
3574    while index < entries.len() {
3575        let entry = &entries[index];
3576        let path = entry.write_tree_path();
3577        let Some(remainder) = path.strip_prefix(prefix) else {
3578            return Err(GitError::InvalidPath(format!(
3579                "invalid index path {}",
3580                String::from_utf8_lossy(path)
3581            )));
3582        };
3583        if remainder.is_empty() || remainder[0] == b'/' {
3584            return Err(GitError::InvalidPath(format!(
3585                "invalid index path {}",
3586                String::from_utf8_lossy(path)
3587            )));
3588        }
3589
3590        if entry.write_tree_mode() == SPARSE_DIR_MODE
3591            && let Some(name) = remainder.strip_suffix(b"/")
3592            && !name.is_empty()
3593            && !name.contains(&b'/')
3594        {
3595            let oid = entry.write_tree_oid();
3596            if !missing_ok && !checker.contains(&oid)? {
3597                eprintln!(
3598                    "error: invalid object {:o} {} for '{}'",
3599                    SPARSE_DIR_MODE,
3600                    oid,
3601                    String::from_utf8_lossy(path)
3602                );
3603                eprintln!("fatal: git-write-tree: error building trees");
3604                return Err(GitError::Exit(128));
3605            }
3606            tree_entries.push(TreeEntry {
3607                mode: SPARSE_DIR_MODE,
3608                name: BString::from(name),
3609                oid,
3610            });
3611            index += 1;
3612            continue;
3613        }
3614
3615        if let Some(slash) = remainder.iter().position(|byte| *byte == b'/') {
3616            let name = &remainder[..slash];
3617            if name.is_empty() {
3618                return Err(GitError::InvalidPath(format!(
3619                    "invalid index path {}",
3620                    String::from_utf8_lossy(path)
3621                )));
3622            }
3623            let start = index;
3624            let child_cache = cache_tree.and_then(|tree| {
3625                tree.subtrees
3626                    .iter()
3627                    .find(|child| child.name.as_slice() == name)
3628                    .map(|child| &child.tree)
3629            });
3630            if let Some(cached_count) = valid_cache_tree_entry_count(child_cache) {
3631                let end = start.saturating_add(cached_count);
3632                if cached_count > 0
3633                    && end <= entries.len()
3634                    && same_tree_component(entries[end - 1].write_tree_path(), prefix, name)?
3635                    && (end == entries.len()
3636                        || !same_tree_component(entries[end].write_tree_path(), prefix, name)?)
3637                {
3638                    index = end;
3639                } else {
3640                    index += 1;
3641                    while index < entries.len()
3642                        && same_tree_component(entries[index].write_tree_path(), prefix, name)?
3643                    {
3644                        index += 1;
3645                    }
3646                }
3647            } else {
3648                index += 1;
3649                while index < entries.len()
3650                    && same_tree_component(entries[index].write_tree_path(), prefix, name)?
3651                {
3652                    index += 1;
3653                }
3654            }
3655            if let Some(oid) = valid_cache_tree_oid(child_cache, index - start) {
3656                tree_entries.push(TreeEntry {
3657                    mode: 0o040000,
3658                    name: BString::from(name),
3659                    oid,
3660                });
3661                continue;
3662            }
3663            let mut child_prefix = Vec::with_capacity(prefix.len() + name.len() + 1);
3664            child_prefix.extend_from_slice(prefix);
3665            child_prefix.extend_from_slice(name);
3666            child_prefix.push(b'/');
3667            let oid = write_tree_entries_stream(
3668                &entries[start..index],
3669                &child_prefix,
3670                child_cache,
3671                odb,
3672                checker,
3673                missing_ok,
3674            )?;
3675            tree_entries.push(TreeEntry {
3676                mode: 0o040000,
3677                name: BString::from(name),
3678                oid,
3679            });
3680            continue;
3681        }
3682
3683        let mode = entry.write_tree_mode();
3684        let oid = entry.write_tree_oid();
3685        if !missing_ok && !sley_index::is_gitlink(mode) && !checker.contains(&oid)? {
3686            eprintln!(
3687                "error: invalid object {:o} {} for '{}'",
3688                mode,
3689                oid,
3690                String::from_utf8_lossy(path)
3691            );
3692            eprintln!("fatal: git-write-tree: error building trees");
3693            return Err(GitError::Exit(128));
3694        }
3695        tree_entries.push(TreeEntry {
3696            mode,
3697            name: BString::from(remainder),
3698            oid,
3699        });
3700        index += 1;
3701    }
3702
3703    tree_entries.sort_by(|left, right| {
3704        git_tree_entry_cmp(
3705            left.name.as_bytes(),
3706            left.mode,
3707            right.name.as_bytes(),
3708            right.mode,
3709        )
3710    });
3711    odb.write_object(EncodedObject::new(
3712        ObjectType::Tree,
3713        Tree {
3714            entries: tree_entries,
3715        }
3716        .write(),
3717    ))
3718}
3719
3720pub(crate) fn valid_cache_tree_oid(
3721    tree: Option<&CacheTree>,
3722    entry_count: usize,
3723) -> Option<ObjectId> {
3724    let tree = tree?;
3725    if valid_cache_tree_entry_count(Some(tree))? != entry_count {
3726        return None;
3727    }
3728    tree.oid
3729}
3730
3731pub(crate) fn valid_cache_tree_entry_count(tree: Option<&CacheTree>) -> Option<usize> {
3732    let tree = tree?;
3733    if tree.entry_count < 0 || tree.oid.is_none() {
3734        return None;
3735    }
3736    Some(tree.entry_count as usize)
3737}
3738
3739pub(crate) fn same_tree_component(path: &[u8], prefix: &[u8], name: &[u8]) -> Result<bool> {
3740    let Some(remainder) = path.strip_prefix(prefix) else {
3741        return Err(GitError::InvalidPath(format!(
3742            "invalid index path {}",
3743            String::from_utf8_lossy(path)
3744        )));
3745    };
3746    Ok(remainder.starts_with(name) && remainder.get(name.len()) == Some(&b'/'))
3747}