Skip to main content

grit_lib/
index_pack.rs

1//! Install a received packfile into the object store (`index-pack` path).
2//!
3//! Fetch and clone keep the negotiated pack on disk (`.pack` + `.idx`) instead of
4//! exploding every object into loose storage. Small receive-pack pushes may still
5//! use [`crate::unpack_objects`] via [`crate::receive_pack::should_use_unpack_objects`].
6
7use std::collections::HashSet;
8use std::ops::Deref;
9use std::path::{Path, PathBuf};
10use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
11
12use tempfile::TempPath;
13
14use crate::error::{Error, Result};
15use crate::fs_publish::publish_no_clobber;
16use crate::hash::{verify_trailer, Parallelism};
17use crate::objects::{ObjectId, ObjectKind};
18use crate::odb::store::touch_pack_mtime;
19use crate::odb::Odb;
20use crate::pack::{verify_pack_and_collect, write_v2_pack_index_sorting};
21use crate::pack_index_build::{build_mapped_pack_index, RESIDENT_PACK_BYTES};
22use crate::pack_map::{PackData, ResidentBudget};
23use crate::progress::{poll_cancel, Cancel, ProgressSink};
24use crate::unpack_objects::{IngestedObjects, PackIndexRecord};
25
26pub use crate::pack_index_build::{
27    build_pack_index_records, build_pack_index_records_with_progress,
28};
29
30static INGEST_THREAD_RECORDING: AtomicBool = AtomicBool::new(false);
31static LAST_RESOLVED_INGEST_THREADS: AtomicUsize = AtomicUsize::new(0);
32
33/// Enable recording of resolved index-pack worker counts (integration tests only).
34pub fn set_ingest_thread_recording_for_tests(enabled: bool) {
35    INGEST_THREAD_RECORDING.store(enabled, Ordering::SeqCst);
36    if !enabled {
37        LAST_RESOLVED_INGEST_THREADS.store(0, Ordering::SeqCst);
38    }
39}
40
41/// Last resolved worker count from pack ingest while recording is enabled.
42pub fn last_resolved_ingest_threads_for_tests() -> Option<usize> {
43    if !INGEST_THREAD_RECORDING.load(Ordering::SeqCst) {
44        return None;
45    }
46    let raw = LAST_RESOLVED_INGEST_THREADS.load(Ordering::SeqCst);
47    (raw > 0).then(|| raw - 1)
48}
49
50fn record_resolved_ingest_threads(threads: usize) {
51    if INGEST_THREAD_RECORDING.load(Ordering::SeqCst) {
52        LAST_RESOLVED_INGEST_THREADS.store(threads.saturating_add(1), Ordering::SeqCst);
53    }
54}
55
56/// Per-type object counts for a pack, tallied while it is indexed.
57#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
58pub struct ObjectKindCounts {
59    /// Commit objects.
60    pub commits: u64,
61    /// Tree objects.
62    pub trees: u64,
63    /// Blob objects.
64    pub blobs: u64,
65    /// Annotated tag objects.
66    pub tags: u64,
67}
68
69impl ObjectKindCounts {
70    /// Count one object of `kind`.
71    pub fn add(&mut self, kind: ObjectKind) {
72        match kind {
73            ObjectKind::Commit => self.commits += 1,
74            ObjectKind::Tree => self.trees += 1,
75            ObjectKind::Blob => self.blobs += 1,
76            ObjectKind::Tag => self.tags += 1,
77        }
78    }
79
80    /// Sum of every type.
81    #[must_use]
82    pub fn total(&self) -> u64 {
83        self.commits + self.trees + self.blobs + self.tags
84    }
85}
86
87impl std::ops::AddAssign for ObjectKindCounts {
88    fn add_assign(&mut self, other: Self) {
89        self.commits += other.commits;
90        self.trees += other.trees;
91        self.blobs += other.blobs;
92        self.tags += other.tags;
93    }
94}
95
96impl FromIterator<ObjectKind> for ObjectKindCounts {
97    fn from_iter<I: IntoIterator<Item = ObjectKind>>(iter: I) -> Self {
98        let mut counts = Self::default();
99        for kind in iter {
100            counts.add(kind);
101        }
102        counts
103    }
104}
105
106/// Result of ingesting a received pack into an object database.
107#[derive(Debug, Clone, Default)]
108pub struct IngestedPack {
109    /// Object ids the pack delivered.
110    pub object_ids: HashSet<ObjectId>,
111    /// Absolute path to the published `pack-*.pack` file.
112    ///
113    /// `None` when nothing was published: the pack was empty, or the object database
114    /// has a custom [`OdbBuilder::primary`](crate::odb::OdbBuilder::primary) store and
115    /// the objects were written into it one by one.
116    pub pack_path: Option<PathBuf>,
117    /// How many objects of each type the pack holds.
118    pub object_counts: ObjectKindCounts,
119    /// Size in bytes of the published pack (after completing a thin pack), or of the
120    /// pack stream when the objects went to a custom primary store.
121    pub pack_bytes: u64,
122}
123
124impl From<IngestedObjects> for IngestedPack {
125    fn from(ingested: IngestedObjects) -> Self {
126        Self {
127            object_ids: ingested.object_ids,
128            pack_path: None,
129            object_counts: ingested.object_counts,
130            pack_bytes: ingested.pack_bytes,
131        }
132    }
133}
134
135/// Options controlling how a received pack is ingested.
136#[derive(Debug, Clone)]
137pub struct IngestPackOptions {
138    /// When true, append missing ref-delta bases from `odb` before indexing
139    /// (matches `git index-pack --fix-thin`).
140    pub fix_thin: bool,
141    /// Worker threads for index-pack hashing (`None`/`Some(0)` → read `pack.threads` or all CPUs).
142    pub threads: Option<usize>,
143    /// Skip the redundant post-index [`verify_pack_and_collect`] pass after the
144    /// pack trailer was validated during indexing. Does not disable trailer checks.
145    pub skip_post_index_verify: bool,
146    /// Merged config from the fetch/clone environment (includes command-scope `GIT_CONFIG_*`).
147    pub merged_config: Option<crate::config::ConfigSet>,
148    /// Environment used when [`Self::merged_config`] is unset and config is loaded from disk.
149    pub environment: crate::environment::Environment,
150    /// When true, write an empty sibling `.promisor` file next to the installed pack.
151    pub mark_promisor_pack: bool,
152    /// Rows for a pack built in-process (`oid`, pack offset, per-object CRC32). When set, the
153    /// pack body is not rescanned to rebuild the index (local repack/gc only); together with
154    /// [`Self::skip_post_index_verify`] the pack's trailer is trusted as written too, so the
155    /// body is not read at all.
156    pub trusted_index_rows: Option<Vec<(ObjectId, u64, u32)>>,
157}
158
159impl Default for IngestPackOptions {
160    fn default() -> Self {
161        Self {
162            fix_thin: false,
163            threads: None,
164            skip_post_index_verify: false,
165            merged_config: None,
166            environment: crate::environment::Environment::empty(),
167            mark_promisor_pack: false,
168            trusted_index_rows: None,
169        }
170    }
171}
172
173impl IngestPackOptions {
174    fn index_parallelism(&self, odb: &Odb) -> Result<Parallelism> {
175        if let Some(n) = self.threads {
176            return Ok(Parallelism::resolve(Some(n)));
177        }
178        if let Some(cfg) = &self.merged_config {
179            return crate::parallel::ThreadPolicy::default()
180                .try_resolve(crate::parallel::ParallelOp::IndexPack, cfg)
181                .map(|o| o.parallelism())
182                .map_err(Error::Config);
183        }
184        if let Some(git_dir) = odb.config_git_dir() {
185            let cfg = crate::config::ConfigSet::load(&self.environment, Some(git_dir), true)?;
186            return crate::parallel::ThreadPolicy::default()
187                .try_resolve(crate::parallel::ParallelOp::IndexPack, &cfg)
188                .map(|o| o.parallelism())
189                .map_err(Error::Config);
190        }
191        Ok(Parallelism::resolve(None))
192    }
193}
194
195/// Ingest a pack received from fetch/clone by installing it under `objects/pack/`.
196///
197/// Returns the installed pack identity and indexed object ids.
198///
199/// When `odb` has a custom [`OdbBuilder::primary`](crate::odb::OdbBuilder::primary)
200/// store, the pack is not installed: its objects are resolved and written into that
201/// store (see [`ingest_pack_into`](crate::unpack_objects::ingest_pack_into)), thin-pack
202/// bases are read through `odb`, and [`IngestedPack::pack_path`] is `None`. Only
203/// the pack checksum is verified then; [`IngestPackOptions`] fields that describe the
204/// installed `.pack`/`.idx` pair (threads, promisor marker, trusted rows) do not apply.
205///
206/// # Errors
207///
208/// [`Error::CorruptObject`] when the bytes are not a complete pack, plus any
209/// indexing, delta-resolution, or object-store failure.
210pub fn ingest_received_pack(
211    pack: Vec<u8>,
212    odb: &Odb,
213    opts: &IngestPackOptions,
214) -> Result<IngestedPack> {
215    ingest_received_pack_with_progress(pack, odb, opts, None, None)
216}
217
218/// Like [`ingest_received_pack`], reporting pack indexing progress on `progress` when set.
219pub fn ingest_received_pack_with_progress(
220    pack: Vec<u8>,
221    odb: &Odb,
222    opts: &IngestPackOptions,
223    progress: Option<&mut dyn ProgressSink>,
224    cancel: Option<&dyn Cancel>,
225) -> Result<IngestedPack> {
226    if pack.is_empty() {
227        return Ok(IngestedPack::default());
228    }
229    if pack.len() < 12 || &pack[0..4] != b"PACK" {
230        return Err(Error::CorruptObject(
231            "received data is not a pack stream".to_owned(),
232        ));
233    }
234    verify_trailer(odb.hash_algo(), &pack).map_err(|e| {
235        Error::CorruptObject(format!(
236            "received pack checksum mismatch (download may be incomplete): {e}"
237        ))
238    })?;
239    if !odb.uses_files_primary() {
240        return ingest_into_custom_primary(&mut pack.as_slice(), odb, progress, cancel);
241    }
242    install_pack_bytes_with_progress(pack, odb, opts, progress, cancel)
243}
244
245/// Resolve every object of the pack on `reader` into `odb`'s custom primary store.
246fn ingest_into_custom_primary(
247    reader: &mut dyn std::io::Read,
248    odb: &Odb,
249    progress: Option<&mut dyn ProgressSink>,
250    cancel: Option<&dyn Cancel>,
251) -> Result<IngestedPack> {
252    crate::unpack_objects::ingest_pack_into_odb(
253        reader,
254        odb,
255        &crate::unpack_objects::UnpackOptions::default(),
256        progress,
257        cancel,
258    )
259    .map(IngestedPack::from)
260}
261
262/// Ingest a pack already written to disk (streaming fetch/clone receive path).
263///
264/// # Errors
265///
266/// Same as [`install_pack_path`].
267pub fn ingest_received_pack_path(
268    pack_path: PathBuf,
269    odb: &Odb,
270    opts: &IngestPackOptions,
271) -> Result<IngestedPack> {
272    ingest_received_pack_path_with_progress(pack_path, odb, opts, None, None)
273}
274
275fn wait_for_received_pack_path(path: PathBuf) -> Result<PathBuf> {
276    use std::io::ErrorKind;
277    use std::thread;
278    use std::time::Duration;
279
280    const MAX_ATTEMPTS: u32 = if cfg!(windows) { 12 } else { 4 };
281    for attempt in 0..MAX_ATTEMPTS {
282        if attempt > 0 {
283            thread::sleep(Duration::from_millis(25_u64.saturating_mul(attempt as u64)));
284        }
285        match std::fs::metadata(&path) {
286            Ok(meta) if meta.is_file() => return Ok(path),
287            Ok(_) => break,
288            Err(e) if e.kind() == ErrorKind::NotFound => continue,
289            Err(e) => return Err(Error::Io(e)),
290        }
291    }
292    Err(Error::ReceivedPackTempMissing { path })
293}
294
295/// Like [`ingest_received_pack_path`], reporting pack indexing progress on `progress` when set.
296///
297/// With a custom primary store the objects are written into it as described on
298/// [`ingest_received_pack`], and the file at `pack_path` is removed afterwards
299/// (whether or not ingestion succeeded), just as a files-backed ingest moves it.
300pub fn ingest_received_pack_path_with_progress(
301    pack_path: PathBuf,
302    odb: &Odb,
303    opts: &IngestPackOptions,
304    progress: Option<&mut dyn ProgressSink>,
305    cancel: Option<&dyn Cancel>,
306) -> Result<IngestedPack> {
307    crate::trace::trace_span!("grit.pack.index");
308    let pack_path = wait_for_received_pack_path(pack_path)?;
309    if !odb.uses_files_primary() {
310        let ingested = std::fs::File::open(&pack_path)
311            .map_err(Error::Io)
312            .and_then(|file| {
313                let mut reader = std::io::BufReader::with_capacity(1 << 20, file);
314                ingest_into_custom_primary(&mut reader, odb, progress, cancel)
315            });
316        let _ = std::fs::remove_file(&pack_path);
317        return ingested;
318    }
319    install_pack_path_with_progress(&pack_path, odb, opts, progress, cancel)
320}
321
322/// Write `pack` into `odb`'s `objects/pack/` directory with a v2 index.
323///
324/// Builds under temporary names and publishes the `.pack`/`.idx` pair atomically.
325pub fn install_pack_bytes(
326    pack: Vec<u8>,
327    odb: &Odb,
328    opts: &IngestPackOptions,
329) -> Result<IngestedPack> {
330    install_pack_bytes_with_progress(pack, odb, opts, None, None)
331}
332
333/// Like [`install_pack_bytes`], reporting pack indexing progress on `progress` when set.
334pub fn install_pack_bytes_with_progress(
335    pack: Vec<u8>,
336    odb: &Odb,
337    opts: &IngestPackOptions,
338    progress: Option<&mut dyn ProgressSink>,
339    cancel: Option<&dyn Cancel>,
340) -> Result<IngestedPack> {
341    odb.require_files_primary("index_pack")?;
342    let pack_dir = odb.objects_dir().join("pack");
343    std::fs::create_dir_all(&pack_dir).map_err(Error::Io)?;
344    let tmp = pack_dir.join(format!("tmp_install_{}", unique_install_suffix()));
345    std::fs::write(&tmp, &pack).map_err(Error::Io)?;
346    match install_pack_path_with_progress(&tmp, odb, opts, progress, cancel) {
347        Ok(ingested) => Ok(ingested),
348        Err(e) => {
349            let _ = std::fs::remove_file(&tmp);
350            Err(e)
351        }
352    }
353}
354
355/// Install a pack from `pack_path` into `odb`'s `objects/pack/` directory.
356///
357/// The file is moved into place when possible; callers may pass a temp receive
358/// path under `objects/pack/`.
359///
360/// Several threads or processes may install the same pack into one repository at
361/// once (parallel fetches): each stages under its own name, and a `.pack`/`.idx`
362/// already published under the same content-addressed name is kept, freshened, and
363/// reported as success rather than replaced. A failed or cancelled install removes
364/// only its private staging files, never a published `.pack` or `.idx`, because a
365/// concurrent installer may already have reported success relying on it.
366pub fn install_pack_path(
367    pack_path: &Path,
368    odb: &Odb,
369    opts: &IngestPackOptions,
370) -> Result<IngestedPack> {
371    install_pack_path_with_progress(pack_path, odb, opts, None, None)
372}
373
374/// Like [`install_pack_path`], reporting pack indexing progress on `progress` when set.
375///
376/// When `cancel` is set, the coordinator polls it at phase boundaries; worker threads
377/// during parallel hashing only observe a shared atomic snapshot (see library guide
378/// [Parallelism](https://grit-scm.com/docs/library/parallelism)).
379pub fn install_pack_path_with_progress(
380    pack_path: &Path,
381    odb: &Odb,
382    opts: &IngestPackOptions,
383    mut progress: Option<&mut dyn ProgressSink>,
384    cancel: Option<&dyn Cancel>,
385) -> Result<IngestedPack> {
386    odb.require_files_primary("index_pack")?;
387    let owned = pack_path.to_path_buf();
388    let hb = odb.hash_algo().len();
389    let meta = std::fs::metadata(&owned).map_err(Error::Io)?;
390    let len = meta.len();
391    if len < 12 + hb as u64 {
392        return Err(Error::CorruptObject("pack too small".to_owned()));
393    }
394    let mut header = [0u8; 12];
395    {
396        use std::io::Read;
397        let mut f = std::fs::File::open(&owned).map_err(Error::Io)?;
398        f.read_exact(&mut header).map_err(Error::Io)?;
399    }
400    if &header[0..4] != b"PACK" {
401        return Err(Error::CorruptObject(
402            "received data is not a pack stream".to_owned(),
403        ));
404    }
405    let mut trailer = vec![0u8; hb];
406    {
407        use std::io::{Read, Seek, SeekFrom};
408        let mut f = std::fs::File::open(&owned).map_err(Error::Io)?;
409        f.seek(SeekFrom::End(-(hb as i64))).map_err(Error::Io)?;
410        f.read_exact(&mut trailer).map_err(Error::Io)?;
411    }
412    let pack_hash = ObjectId::from_bytes(&trailer)?;
413    let stem = format!("pack-{}", pack_hash.to_hex());
414    let pack_dir = odb.objects_dir().join("pack");
415    std::fs::create_dir_all(&pack_dir).map_err(Error::Io)?;
416
417    // Unique per install: processes receiving the same pack must not share a stage.
418    let stage = pack_dir.join(format!(".{stem}-install-{}", unique_install_suffix()));
419    let stage_pack = stage.join(format!("{stem}.pack"));
420    let stage_idx = stage.join(format!("{stem}.idx"));
421
422    let install_result = (|| -> Result<IngestedPack> {
423        poll_ingest_cancel(&mut progress, cancel)?;
424        std::fs::create_dir_all(&stage).map_err(Error::Io)?;
425        if std::fs::rename(&owned, &stage_pack).is_err() {
426            std::fs::copy(&owned, &stage_pack).map_err(Error::Io)?;
427            let _ = std::fs::remove_file(&owned);
428        }
429        poll_ingest_cancel(&mut progress, cancel)?;
430        let parallelism = opts.index_parallelism(odb)?;
431        record_resolved_ingest_threads(parallelism.threads().get());
432        let sort_threads = parallelism.threads().get();
433        // A pack built in-process with trusted rows and its trailer checked at
434        // write time is installed without reading its body again.
435        let trusted_as_written = opts
436            .trusted_index_rows
437            .as_deref()
438            .filter(|_| opts.skip_post_index_verify);
439        let mut trailer = trailer;
440        let (mut entries, object_counts) = if let Some(rows) = trusted_as_written {
441            if rows.len()
442                != u32::from_be_bytes([header[8], header[9], header[10], header[11]]) as usize
443            {
444                return Err(Error::CorruptObject(
445                    "trusted index row count does not match pack header".to_owned(),
446                ));
447            }
448            (rows.to_vec(), ObjectKindCounts::default())
449        } else {
450            let indexed = PackData::open(&stage_pack)?;
451            let resident = ResidentBudget::new(&indexed, RESIDENT_PACK_BYTES);
452            let pack_bytes: &[u8] = indexed.deref();
453            let body_len = pack_bytes.len() - hb;
454            if resident.checksum(odb.hash_algo(), &pack_bytes[..body_len]) != pack_bytes[body_len..]
455            {
456                return Err(Error::CorruptObject(
457                    "received pack checksum mismatch (download may be incomplete)".to_owned(),
458                ));
459            }
460            poll_ingest_cancel(&mut progress, cancel)?;
461            trailer = pack_bytes[body_len..].to_vec();
462            if let Some(rows) = &opts.trusted_index_rows {
463                if rows.len()
464                    != u32::from_be_bytes([
465                        pack_bytes[8],
466                        pack_bytes[9],
467                        pack_bytes[10],
468                        pack_bytes[11],
469                    ]) as usize
470                {
471                    return Err(Error::CorruptObject(
472                        "trusted index row count does not match pack header".to_owned(),
473                    ));
474                }
475                drop(indexed);
476                (rows.clone(), ObjectKindCounts::default())
477            } else {
478                let file = std::fs::File::open(&stage_pack).map_err(Error::Io)?;
479                let build = build_mapped_pack_index(
480                    &indexed,
481                    &file,
482                    odb,
483                    parallelism,
484                    false,
485                    progress.as_mut().map(|p| p as &mut dyn ProgressSink),
486                    cancel,
487                )?;
488                drop(file);
489                drop(indexed);
490                let mut records = build.records;
491                if opts.fix_thin && !build.thin_bases.is_empty() {
492                    poll_ingest_cancel(&mut progress, cancel)?;
493                    trailer = append_thin_bases(&stage_pack, odb, &build.thin_bases, &mut records)?;
494                }
495                let object_counts: ObjectKindCounts = records.iter().map(|r| r.kind).collect();
496                let entries: Vec<(ObjectId, u64, u32)> = records
497                    .into_iter()
498                    .map(
499                        |PackIndexRecord {
500                             oid, offset, crc32, ..
501                         }| (oid, offset, crc32),
502                    )
503                    .collect();
504                (entries, object_counts)
505            }
506        };
507        poll_ingest_cancel(&mut progress, cancel)?;
508        write_v2_pack_index_sorting(&stage_idx, &mut entries, &trailer, hb, Some(sort_threads))?;
509        // Rebuild the id set from the index just written, so the entries and the set are
510        // never held at the same time.
511        drop(entries);
512        let index = crate::pack::read_pack_index_no_verify(&stage_idx)?;
513        let mut oids: HashSet<ObjectId> = HashSet::with_capacity(index.len());
514        for i in 0..index.len() {
515            oids.insert(ObjectId::from_bytes(index.oid_at(i))?);
516        }
517        drop(index);
518        poll_ingest_cancel(&mut progress, cancel)?;
519        if !opts.skip_post_index_verify {
520            verify_pack_and_collect(&stage_idx)?;
521        }
522        poll_ingest_cancel(&mut progress, cancel)?;
523        let stem = format!("pack-{}", ObjectId::from_bytes(&trailer)?.to_hex());
524        let final_pack = pack_dir.join(format!("{stem}.pack"));
525        let idx_path = pack_dir.join(format!("{stem}.idx"));
526        // Names are content-addressed: an existing `.pack`/`.idx` holds these bytes, and
527        // a concurrent installer may already rely on a file this install published, so
528        // published files are never rolled back. The pack goes first so a reader never
529        // sees an index without its pack, and there is no cancellation point between
530        // the two so a cancelled install does not strand a pack without its index.
531        let publish = |staged: &Path, final_path: &Path| -> Result<bool> {
532            let staged = TempPath::try_from_path(staged).map_err(Error::Io)?;
533            publish_no_clobber(staged, final_path).map_err(Error::Io)
534        };
535        if !publish(&stage_pack, &final_pack)? {
536            touch_pack_mtime(&final_pack);
537        }
538        publish(&stage_idx, &idx_path)?;
539        if opts.mark_promisor_pack {
540            std::fs::write(final_pack.with_extension("promisor"), b"").map_err(Error::Io)?;
541        }
542        let _ = std::fs::remove_dir(&stage);
543        let pack_bytes = std::fs::metadata(&final_pack).map_err(Error::Io)?.len();
544        Ok(IngestedPack {
545            object_ids: oids,
546            pack_path: Some(final_pack.clone()),
547            object_counts,
548            pack_bytes,
549        })
550    })();
551
552    if install_result.is_err() {
553        cleanup_install_stage(&stage);
554        let _ = std::fs::remove_file(&owned);
555    }
556    let ingested = install_result?;
557    odb.invalidate_packs();
558    Ok(ingested)
559}
560
561/// Complete a thin pack in place (`index-pack --fix-thin`): append each of `bases` from
562/// `odb` as a whole object, bump the header's object count and rewrite the trailer.
563///
564/// Objects are written straight to the file and the new checksum is computed by
565/// streaming the file back, so the pack is never held in memory. A record for each
566/// appended object is pushed onto `records`.
567///
568/// Returns the new pack trailer.
569///
570/// # Errors
571///
572/// Returns [`Error::Io`] on file errors, an object-database error when a base cannot be
573/// read, and [`Error::CorruptObject`] when the object count would overflow.
574fn append_thin_bases(
575    pack_path: &Path,
576    odb: &Odb,
577    bases: &[ObjectId],
578    records: &mut Vec<PackIndexRecord>,
579) -> Result<Vec<u8>> {
580    use std::io::{Read, Seek, SeekFrom, Write};
581
582    let algo = odb.hash_algo();
583    let mut file = std::fs::OpenOptions::new()
584        .read(true)
585        .write(true)
586        .open(pack_path)
587        .map_err(Error::Io)?;
588    let mut header = [0u8; 12];
589    file.read_exact(&mut header).map_err(Error::Io)?;
590    let added = u32::try_from(bases.len()).ok();
591    let count = added
592        .and_then(|added| {
593            u32::from_be_bytes([header[8], header[9], header[10], header[11]]).checked_add(added)
594        })
595        .ok_or_else(|| Error::CorruptObject("thin pack fix: object count overflow".to_owned()))?;
596    header[8..12].copy_from_slice(&count.to_be_bytes());
597
598    let len = file.metadata().map_err(Error::Io)?.len();
599    let mut offset = len - algo.len() as u64;
600    file.seek(SeekFrom::Start(offset)).map_err(Error::Io)?;
601    let mut object = Vec::new();
602    for oid in bases {
603        object.clear();
604        let kind = crate::pack_objects::append_whole_object_from_odb(&mut object, odb, *oid)?;
605        file.write_all(&object).map_err(Error::Io)?;
606        records.push(PackIndexRecord {
607            oid: *oid,
608            offset,
609            crc32: crc32fast::hash(&object),
610            kind,
611        });
612        offset += object.len() as u64;
613    }
614    file.seek(SeekFrom::Start(0)).map_err(Error::Io)?;
615    file.write_all(&header).map_err(Error::Io)?;
616
617    file.seek(SeekFrom::Start(0)).map_err(Error::Io)?;
618    let mut hasher = algo.checksum_hasher();
619    let mut buf = vec![0u8; 1024 * 1024];
620    let mut remaining = offset;
621    while remaining > 0 {
622        let want = usize::try_from(remaining).map_or(buf.len(), |r| r.min(buf.len()));
623        file.read_exact(&mut buf[..want]).map_err(Error::Io)?;
624        hasher.update(&buf[..want]);
625        remaining -= want as u64;
626    }
627    let trailer = hasher.finalize();
628    file.write_all(&trailer).map_err(Error::Io)?;
629    Ok(trailer)
630}
631
632fn poll_ingest_cancel(
633    progress: &mut Option<&mut dyn ProgressSink>,
634    cancel: Option<&dyn Cancel>,
635) -> Result<()> {
636    let Some(c) = cancel else {
637        return Ok(());
638    };
639    if let Some(p) = progress.as_mut() {
640        poll_cancel(p, c)
641    } else if c.is_cancelled() {
642        Err(Error::Cancelled)
643    } else {
644        Ok(())
645    }
646}
647
648/// `<pid>_<seq>`: unique among all installs running on this machine.
649fn unique_install_suffix() -> String {
650    static INSTALL_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
651    let seq = INSTALL_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
652    format!("{}_{seq}", std::process::id())
653}
654
655fn cleanup_install_stage(stage: &Path) {
656    if !stage.exists() {
657        return;
658    }
659    if let Ok(entries) = std::fs::read_dir(stage) {
660        for entry in entries.flatten() {
661            let _ = std::fs::remove_file(entry.path());
662        }
663    }
664    let _ = std::fs::remove_dir(stage);
665}
666
667#[cfg(test)]
668mod tests {
669    use super::*;
670    use crate::objects::ObjectKind;
671    use crate::odb::Odb;
672    use crate::pack::read_pack_index;
673    use crate::unpack_objects::pack_index_records_with_threads;
674    use std::process::Command;
675
676    fn git_index_pack(pack: &[u8]) -> (tempfile::TempDir, Vec<u8>) {
677        let dir = tempfile::tempdir().expect("tempdir");
678        let pack_path = dir.path().join("in.pack");
679        std::fs::write(&pack_path, pack).expect("write");
680        let out = Command::new("git") // hygiene: git index-pack cross-check in cfg(test) module
681            .current_dir(dir.path())
682            .args(["index-pack", "-v", &pack_path.to_string_lossy()])
683            .env("GIT_CONFIG_GLOBAL", "/dev/null")
684            .env("GIT_CONFIG_SYSTEM", "/dev/null")
685            .output()
686            .expect("git index-pack");
687        assert!(
688            out.status.success(),
689            "git index-pack: {}",
690            String::from_utf8_lossy(&out.stderr)
691        );
692        let indexed = std::fs::read(dir.path().join("in.pack")).expect("read indexed pack");
693        (dir, indexed)
694    }
695
696    fn single_blob_pack(data: &[u8]) -> Vec<u8> {
697        use crate::transfer::{build_pack, PackBuildOptions};
698        let tmp = tempfile::tempdir().expect("tempdir");
699        let odb = Odb::new(tmp.path());
700        let oid = odb
701            .write(ObjectKind::Blob, data)
702            .expect("write loose blob for pack build");
703        build_pack(
704            &odb,
705            &[oid],
706            &[],
707            &PackBuildOptions {
708                delta: false,
709                ..Default::default()
710            },
711        )
712        .expect("build pack")
713    }
714
715    #[test]
716    fn object_kind_counts_tally_and_total() {
717        let counts: ObjectKindCounts = [
718            ObjectKind::Commit,
719            ObjectKind::Blob,
720            ObjectKind::Blob,
721            ObjectKind::Tree,
722            ObjectKind::Tag,
723            ObjectKind::Commit,
724        ]
725        .into_iter()
726        .collect();
727        assert_eq!(
728            counts,
729            ObjectKindCounts {
730                commits: 2,
731                trees: 1,
732                blobs: 2,
733                tags: 1,
734            }
735        );
736        assert_eq!(counts.total(), 6);
737        assert_eq!(ObjectKindCounts::default().total(), 0);
738    }
739
740    #[test]
741    fn ingest_counts_objects_by_resolved_kind() {
742        use grit_test_support::objects::{HashAlgo, ObjectKind as PK, PackBuilder};
743        let mut pb = PackBuilder::new(HashAlgo::Sha1);
744        pb.add_full(
745            PK::Commit,
746            b"tree 0000000000000000000000000000000000000000\n\nc\n",
747        );
748        pb.add_full(PK::Tree, b"");
749        let base = b"blob base contents\n";
750        let base_idx = pb.add_full(PK::Blob, base);
751        let target = b"blob base contents, edited\n";
752        let delta = crate::delta_encode::encode_lcp_delta(base, target).expect("delta");
753        // A delta resolves to its base's type, so it counts as a blob.
754        pb.add_ofs_delta(base_idx, &delta, delta.len());
755        pb.add_full(
756            PK::Tag,
757            b"object 0000000000000000000000000000000000000000\n",
758        );
759        let pack = pb.build().bytes;
760
761        let tmp = tempfile::tempdir().expect("tempdir");
762        let objects = tmp.path().join("objects");
763        std::fs::create_dir_all(objects.join("pack")).expect("pack dir");
764        let odb = Odb::new(&objects);
765        let ingested =
766            ingest_received_pack(pack, &odb, &IngestPackOptions::default()).expect("ingest");
767        assert_eq!(
768            ingested.object_counts,
769            ObjectKindCounts {
770                commits: 1,
771                trees: 1,
772                blobs: 2,
773                tags: 1,
774            }
775        );
776        assert_eq!(
777            ingested.object_counts.total(),
778            u64::try_from(ingested.object_ids.len()).expect("count")
779        );
780    }
781
782    /// Regression for large-clone failures (#889): parallel index-pack must skip
783    /// 0-byte zlib members instead of mis-scanning the stream (`unknown packed-object type 0`).
784    #[test]
785    fn parallel_index_pack_zero_byte_blob_among_many() {
786        use crate::hash::Parallelism;
787        use crate::unpack_objects::pack_index_records_with_threads;
788        use grit_test_support::objects::{HashAlgo as FixtureAlgo, ObjectKind as PK, PackBuilder};
789
790        let mut pb = PackBuilder::new(FixtureAlgo::Sha1);
791        for i in 0..200 {
792            pb.add_full(PK::Blob, format!("payload-{i}\n").as_bytes());
793        }
794        pb.add_full(PK::Blob, b"");
795        for i in 200..400 {
796            pb.add_full(PK::Blob, format!("after-empty-{i}\n").as_bytes());
797        }
798        let pack = pb.build().bytes;
799        let tmp = tempfile::tempdir().expect("tempdir");
800        let odb = Odb::new(tmp.path());
801        for threads in [1_usize, 8] {
802            let records =
803                pack_index_records_with_threads(&pack, &odb, Parallelism::resolve(Some(threads)))
804                    .unwrap_or_else(|e| panic!("threads={threads}: {e}"));
805            assert_eq!(records.len(), 401, "threads={threads}");
806        }
807    }
808
809    #[test]
810    fn install_pack_cancelled_during_indexing_cleans_stage() {
811        use crate::progress::{FlagCancel, ProgressPhase, ProgressSink, ProgressUnit};
812        use grit_test_support::objects::{HashAlgo, ObjectKind as PK, PackBuilder};
813        use std::sync::atomic::{AtomicBool, Ordering};
814        use std::sync::Arc;
815
816        struct CancelAtIndexing {
817            flag: FlagCancel,
818            saw_indexing: Arc<AtomicBool>,
819        }
820
821        impl ProgressSink for CancelAtIndexing {
822            fn start(&mut self, phase: ProgressPhase, total: Option<u64>) {
823                if phase == ProgressPhase::IndexingObjects {
824                    self.saw_indexing.store(true, Ordering::Relaxed);
825                    self.flag.cancel();
826                }
827                let _ = total;
828            }
829            fn start_with_unit(
830                &mut self,
831                phase: ProgressPhase,
832                unit: ProgressUnit,
833                total: Option<u64>,
834            ) {
835                self.start(phase, total);
836                let _ = unit;
837            }
838        }
839
840        let mut pb = PackBuilder::new(HashAlgo::Sha1);
841        for i in 0..400 {
842            pb.add_full(PK::Blob, format!("blob-{i}\n").as_bytes());
843        }
844        let pack = pb.build().bytes;
845        let tmp = tempfile::tempdir().expect("tempdir");
846        let pack_path = tmp.path().join("incoming.pack");
847        std::fs::write(&pack_path, &pack).expect("write pack");
848        let odb = Odb::new(tmp.path());
849        let flag = FlagCancel::new();
850        let saw = Arc::new(AtomicBool::new(false));
851        let mut progress = CancelAtIndexing {
852            flag: flag.clone(),
853            saw_indexing: Arc::clone(&saw),
854        };
855        let err = install_pack_path_with_progress(
856            &pack_path,
857            &odb,
858            &IngestPackOptions {
859                threads: Some(4),
860                ..Default::default()
861            },
862            Some(&mut progress),
863            Some(&flag),
864        )
865        .expect_err("cancel during index-pack");
866        assert!(matches!(err, Error::Cancelled), "got {err:?}");
867        assert!(saw.load(Ordering::Relaxed), "expected indexing phase");
868
869        let pack_dir = tmp.path().join("pack");
870        if pack_dir.exists() {
871            for entry in pack_dir_entries(&pack_dir) {
872                let name = entry
873                    .file_name()
874                    .and_then(|s| s.to_str())
875                    .unwrap_or_default();
876                assert!(
877                    !name.ends_with(".pack") && !name.ends_with(".idx"),
878                    "leftover install artifact: {}",
879                    entry.display()
880                );
881                assert!(
882                    !entry.is_dir() || !name.contains("-install"),
883                    "leftover stage dir: {}",
884                    entry.display()
885                );
886            }
887        }
888    }
889
890    #[test]
891    fn install_rejects_truncated_pack_before_object_scan() {
892        let tmp = tempfile::tempdir().expect("tempdir");
893        let odb = Odb::new(tmp.path());
894        let mut pack = single_blob_pack(b"truncated-before-index");
895        pack.truncate(pack.len().saturating_sub(8));
896        let err = install_pack_bytes(
897            pack,
898            &odb,
899            &IngestPackOptions {
900                fix_thin: false,
901                ..Default::default()
902            },
903        )
904        .expect_err("truncated pack");
905        let msg = err.to_string();
906        assert!(
907            msg.contains("checksum mismatch") || msg.contains("truncated"),
908            "expected checksum/truncated error, got {msg}"
909        );
910    }
911
912    fn pack_dir_entries(pack_dir: &Path) -> Vec<std::path::PathBuf> {
913        std::fs::read_dir(pack_dir)
914            .expect("read pack dir")
915            .filter_map(|e| e.ok())
916            .map(|e| e.path())
917            .collect()
918    }
919
920    /// Manual RSS: `GRIT_CARGO_PACK=/path/to.pack cargo test -p grit-lib --release rss_mmap_index_records -- --ignored --exact`
921    #[test]
922    #[ignore = "manual RSS harness (GRIT_CARGO_PACK)"]
923    fn rss_mmap_index_records() {
924        use crate::hash::Parallelism;
925        use std::env;
926        let path = env::var("GRIT_CARGO_PACK").expect("GRIT_CARGO_PACK"); // hygiene: manual RSS harness env in cfg(test)
927        let mapped = PackData::open(Path::new(&path)).expect("mmap");
928        let tmp = tempfile::tempdir().expect("tempdir");
929        let odb = Odb::new(tmp.path());
930        let _records =
931            pack_index_records_with_threads(mapped.deref(), &odb, Parallelism::resolve(Some(4)))
932                .expect("index records");
933    }
934
935    #[test]
936    fn install_pack_matches_git_index_pack_layout() {
937        let blob = b"clone pack retention fixture\n";
938        let pack = single_blob_pack(blob);
939        let (_git_dir, git_pack) = git_index_pack(&pack);
940
941        let tmp = tempfile::tempdir().expect("tempdir");
942        let odb = Odb::new(tmp.path());
943        install_pack_bytes(
944            git_pack,
945            &odb,
946            &IngestPackOptions {
947                fix_thin: false,
948                ..Default::default()
949            },
950        )
951        .expect("install");
952
953        let pack_dir = tmp.path().join("pack");
954        let packs: Vec<_> = std::fs::read_dir(&pack_dir)
955            .expect("read pack dir")
956            .filter_map(|e| e.ok())
957            .filter(|e| e.path().extension().is_some_and(|x| x == "pack"))
958            .collect();
959        assert_eq!(packs.len(), 1, "expected one pack file");
960        let loose_count = std::fs::read_dir(tmp.path())
961            .expect("read objects")
962            .filter_map(|e| e.ok())
963            .filter(|e| {
964                e.file_name()
965                    .to_str()
966                    .is_some_and(|s| s.len() == 2 && s.chars().all(|c| c.is_ascii_hexdigit()))
967            })
968            .count();
969        assert_eq!(loose_count, 0, "install must not create loose objects");
970
971        let oid = odb.hash(ObjectKind::Blob, blob).expect("hash");
972        let obj = odb.read(&oid).expect("read from pack");
973        assert_eq!(obj.data.as_slice(), blob);
974    }
975
976    fn run_git(cwd: &Path, args: &[&str]) {
977        let out = Command::new("/usr/bin/git") // hygiene: git fixture helper in cfg(test) module
978            .args(args)
979            .current_dir(cwd)
980            .env("GIT_CONFIG_GLOBAL", "/dev/null")
981            .env("GIT_CONFIG_SYSTEM", "/dev/null")
982            .env("GIT_AUTHOR_NAME", "t")
983            .env("GIT_AUTHOR_EMAIL", "t@t")
984            .env("GIT_COMMITTER_NAME", "t")
985            .env("GIT_COMMITTER_EMAIL", "t@t")
986            .output()
987            .expect("spawn git");
988        assert!(
989            out.status.success(),
990            "git {args:?} in {}: {}",
991            cwd.display(),
992            String::from_utf8_lossy(&out.stderr)
993        );
994    }
995
996    #[test]
997    fn local_fetch_ingest_with_fix_thin_from_bare_remote() {
998        use crate::objects::ObjectId;
999        use crate::transfer::{build_pack, open_odb, PackBuildOptions};
1000
1001        let tmp = tempfile::tempdir().expect("tempdir");
1002        let work = tmp.path().join("work");
1003        std::fs::create_dir_all(&work).expect("work");
1004        run_git(&work, &["init", "-q", "-b", "main", "."]);
1005        run_git(&work, &["commit", "-q", "--allow-empty", "-m", "c1"]);
1006        let bare = tmp.path().join("repo.git");
1007        run_git(
1008            tmp.path(),
1009            &[
1010                "clone",
1011                "-q",
1012                "--bare",
1013                work.to_str().unwrap(),
1014                bare.to_str().unwrap(),
1015            ],
1016        );
1017
1018        let consumer = tmp.path().join("consumer");
1019        std::fs::create_dir_all(&consumer).expect("consumer");
1020        run_git(&consumer, &["init", "-q", "-b", "main", "."]);
1021        let local_git = consumer.join(".git");
1022        let local_odb = open_odb(&local_git);
1023        let remote_odb = open_odb(&bare);
1024
1025        let tip_out = Command::new("/usr/bin/git") // hygiene: git fixture cross-check in cfg(test) module
1026            .args(["rev-parse", "refs/heads/main"])
1027            .current_dir(&bare)
1028            .output()
1029            .expect("rev-parse");
1030        assert!(tip_out.status.success());
1031        let tip = ObjectId::from_hex(String::from_utf8_lossy(&tip_out.stdout).trim()).expect("oid");
1032        let pack =
1033            build_pack(&remote_odb, &[tip], &[], &PackBuildOptions::default()).expect("build_pack");
1034        assert!(
1035            !crate::unpack_objects::pack_is_thin(&pack, remote_odb.hash_algo()),
1036            "whole-object local fetch pack must not be classified as thin"
1037        );
1038        let (_git_dir, _indexed) = git_index_pack(&pack);
1039        install_pack_bytes(
1040            pack,
1041            &local_odb,
1042            &IngestPackOptions {
1043                fix_thin: true,
1044                threads: Some(1),
1045                ..Default::default()
1046            },
1047        )
1048        .expect("ingest like fetch_local serial index-pack");
1049        assert!(local_odb.exists(&tip));
1050    }
1051
1052    #[test]
1053    fn fetch_ingest_keeps_pack_despite_receive_unpacklimit_zero() {
1054        let blob = b"unpacklimit must not apply to fetch ingest\n";
1055        let pack = single_blob_pack(blob);
1056        let tmp = tempfile::tempdir().expect("tempdir");
1057        let git_dir = tmp.path().join(".git");
1058        std::fs::create_dir_all(git_dir.join("objects")).expect("objects");
1059        std::fs::write(git_dir.join("config"), "[receive]\n\tunpackLimit = 0\n").expect("config");
1060        let objects = git_dir.join("objects");
1061        let odb = Odb::new(&objects).with_config_git_dir(git_dir.clone());
1062        install_pack_bytes(
1063            pack,
1064            &odb,
1065            &IngestPackOptions {
1066                fix_thin: false,
1067                ..Default::default()
1068            },
1069        )
1070        .expect("install");
1071        let pack_dir = git_dir.join("objects").join("pack");
1072        let packs: Vec<_> = std::fs::read_dir(&pack_dir)
1073            .expect("pack dir")
1074            .filter_map(|e| e.ok())
1075            .filter(|e| e.path().extension().is_some_and(|x| x == "pack"))
1076            .collect();
1077        assert_eq!(packs.len(), 1, "fetch ingest must index pack, not unpack");
1078    }
1079
1080    #[test]
1081    fn v2_index_large_offset_roundtrip() {
1082        let tmp = tempfile::tempdir().expect("tempdir");
1083        let pack_path = tmp.path().join("large.pack");
1084        let pack = single_blob_pack(b"large offset idx fixture");
1085        std::fs::write(&pack_path, &pack).expect("write pack");
1086        let idx_path = pack_path.with_extension("idx");
1087        let fake_oid = ObjectId::from_hex("aabbccddeeff00112233445566778899aabbccdd").unwrap();
1088        let huge_off = 1u64 << 31;
1089        crate::pack::write_v2_pack_index_with_trailer(
1090            &idx_path,
1091            &[(fake_oid, huge_off, 0x1234_5678)],
1092            &pack[pack.len() - 20..],
1093            20,
1094            None,
1095        )
1096        .expect("write idx");
1097        let idx = read_pack_index(&idx_path).expect("read idx");
1098        assert_eq!(idx.len(), 1);
1099        assert_eq!(idx.offset_at(0).expect("off"), huge_off);
1100    }
1101
1102    fn pack_with_junk_before_trailer(valid: &[u8], junk: &[u8]) -> Vec<u8> {
1103        use crate::objects::HashAlgo;
1104
1105        let algo = HashAlgo::Sha1;
1106        let hb = algo.len();
1107        let body = &valid[..valid.len().checked_sub(hb).expect("pack trailer")];
1108        let mut body_with_junk = body.to_vec();
1109        body_with_junk.extend_from_slice(junk);
1110        let trailer = algo.checksum_digest(&body_with_junk);
1111        body_with_junk.extend_from_slice(&trailer);
1112        body_with_junk
1113    }
1114
1115    fn git_index_pack_strict_rejects(pack: &[u8]) {
1116        let dir = tempfile::tempdir().expect("tempdir");
1117        let pack_path = dir.path().join("in.pack");
1118        std::fs::write(&pack_path, pack).expect("write pack");
1119        let out = Command::new("/usr/bin/git") // hygiene: git index-pack strict cross-check in cfg(test) module
1120            .current_dir(dir.path())
1121            .args([
1122                "index-pack",
1123                "--strict",
1124                pack_path.to_string_lossy().as_ref(),
1125            ])
1126            .env("GIT_CONFIG_GLOBAL", "/dev/null")
1127            .env("GIT_CONFIG_SYSTEM", "/dev/null")
1128            .output()
1129            .expect("git index-pack --strict");
1130        assert!(
1131            !out.status.success(),
1132            "git index-pack --strict must reject junk before trailer: {}",
1133            String::from_utf8_lossy(&out.stderr)
1134        );
1135    }
1136
1137    #[test]
1138    fn rejects_extra_bytes_before_pack_trailer() {
1139        use crate::hash::Parallelism;
1140        use crate::unpack_objects::pack_index_records_with_threads;
1141
1142        let valid = single_blob_pack(b"one blob\n");
1143        let corrupted = pack_with_junk_before_trailer(&valid, b"JUNK");
1144        git_index_pack_strict_rejects(&corrupted);
1145
1146        let tmp = tempfile::tempdir().expect("tempdir");
1147        let odb = Odb::new(tmp.path());
1148
1149        for threads in [1_usize, 4] {
1150            let err = pack_index_records_with_threads(
1151                &corrupted,
1152                &odb,
1153                Parallelism::resolve(Some(threads)),
1154            )
1155            .expect_err("index-pack hashing");
1156            assert!(
1157                matches!(err, Error::CorruptObject(_)),
1158                "threads={threads}: {err:?}"
1159            );
1160        }
1161
1162        for threads in [1_usize, 4] {
1163            let err = install_pack_bytes(
1164                corrupted.clone(),
1165                &odb,
1166                &IngestPackOptions {
1167                    fix_thin: false,
1168                    threads: Some(threads),
1169                    ..Default::default()
1170                },
1171            )
1172            .expect_err("install pack");
1173            assert!(
1174                matches!(err, Error::CorruptObject(_)),
1175                "install threads={threads}: {err:?}"
1176            );
1177        }
1178    }
1179
1180    fn pack_with_whole_objects_and_ofs_deltas() -> (Vec<u8>, usize, usize) {
1181        use grit_test_support::objects::{HashAlgo as FixtureAlgo, ObjectKind as PK, PackBuilder};
1182
1183        let mut pb = PackBuilder::new(FixtureAlgo::Sha1);
1184        for i in 0..6 {
1185            pb.add_full(PK::Blob, format!("standalone-{i}\n").as_bytes());
1186        }
1187        let mut base_indices = Vec::new();
1188        for i in 0..4 {
1189            let data = format!("delta-base-{i}\n");
1190            base_indices.push(pb.add_full(PK::Blob, data.as_bytes()));
1191        }
1192        for (i, base_idx) in base_indices.into_iter().enumerate() {
1193            let base = format!("delta-base-{i}\n");
1194            let target = format!("delta-base-{i}-extended\n");
1195            let delta = crate::delta_encode::encode_lcp_delta(base.as_bytes(), target.as_bytes())
1196                .expect("delta");
1197            pb.add_ofs_delta(base_idx, &delta, delta.len());
1198        }
1199        let pack = pb.build().bytes;
1200        let nr_objects = 6 + 4 + 4;
1201        let nr_deltas = 4;
1202        (pack, nr_objects, nr_deltas)
1203    }
1204
1205    #[derive(Default)]
1206    struct IndexPackProgressCapture {
1207        active: Option<crate::progress::ProgressPhase>,
1208        phase_totals: Vec<(crate::progress::ProgressPhase, Option<u64>)>,
1209        phase_max: std::collections::HashMap<crate::progress::ProgressPhase, u64>,
1210        set_samples: Vec<u64>,
1211    }
1212
1213    impl crate::progress::ProgressSink for IndexPackProgressCapture {
1214        fn start(&mut self, phase: crate::progress::ProgressPhase, total: Option<u64>) {
1215            self.active = Some(phase);
1216            self.phase_totals.push((phase, total));
1217        }
1218
1219        fn set(&mut self, current: u64) {
1220            self.set_samples.push(current);
1221            if let Some(phase) = self.active {
1222                let entry = self.phase_max.entry(phase).or_insert(0);
1223                *entry = (*entry).max(current);
1224            }
1225        }
1226
1227        fn finish(&mut self) {
1228            self.active = None;
1229        }
1230    }
1231
1232    #[test]
1233    fn progress_reports_object_and_delta_totals() {
1234        use crate::hash::Parallelism;
1235        use crate::unpack_objects::pack_index_records_with_threads_with_progress;
1236
1237        let (pack, nr_objects, nr_deltas) = pack_with_whole_objects_and_ofs_deltas();
1238        let tmp = tempfile::tempdir().expect("tempdir");
1239        let odb = Odb::new(tmp.path());
1240
1241        for threads in [1_usize, 4] {
1242            let mut cap = IndexPackProgressCapture::default();
1243            let records = pack_index_records_with_threads_with_progress(
1244                &pack,
1245                &odb,
1246                Parallelism::resolve(Some(threads)),
1247                Some(&mut cap),
1248            )
1249            .unwrap_or_else(|e| panic!("threads={threads}: {e}"));
1250            assert_eq!(records.len(), nr_objects, "threads={threads}");
1251            assert_eq!(
1252                cap.phase_max
1253                    .get(&crate::progress::ProgressPhase::IndexingObjects)
1254                    .copied(),
1255                Some(nr_objects as u64),
1256                "threads={threads}"
1257            );
1258            assert_eq!(
1259                cap.phase_max
1260                    .get(&crate::progress::ProgressPhase::ResolvingDeltas)
1261                    .copied(),
1262                Some(nr_deltas as u64),
1263                "threads={threads}"
1264            );
1265            let indexing_total = cap
1266                .phase_totals
1267                .iter()
1268                .find(|(p, _)| *p == crate::progress::ProgressPhase::IndexingObjects)
1269                .and_then(|(_, t)| *t);
1270            assert_eq!(indexing_total, Some(nr_objects as u64), "threads={threads}");
1271            let delta_total = cap
1272                .phase_totals
1273                .iter()
1274                .find(|(p, _)| *p == crate::progress::ProgressPhase::ResolvingDeltas)
1275                .and_then(|(_, t)| *t);
1276            assert_eq!(delta_total, Some(nr_deltas as u64), "threads={threads}");
1277        }
1278    }
1279
1280    #[derive(Default)]
1281    struct MonotonicPhaseCapture {
1282        active: Option<crate::progress::ProgressPhase>,
1283        sets_by_phase: std::collections::HashMap<crate::progress::ProgressPhase, Vec<u64>>,
1284    }
1285
1286    impl crate::progress::ProgressSink for MonotonicPhaseCapture {
1287        fn start(&mut self, phase: crate::progress::ProgressPhase, _total: Option<u64>) {
1288            self.active = Some(phase);
1289            self.sets_by_phase.entry(phase).or_default();
1290        }
1291
1292        fn set(&mut self, current: u64) {
1293            if let Some(phase) = self.active {
1294                self.sets_by_phase.entry(phase).or_default().push(current);
1295            }
1296        }
1297
1298        fn finish(&mut self) {
1299            self.active = None;
1300        }
1301    }
1302
1303    #[test]
1304    fn progress_counts_monotonic() {
1305        use crate::hash::Parallelism;
1306        use crate::unpack_objects::pack_index_records_with_threads_with_progress;
1307
1308        let (pack, _, _) = pack_with_whole_objects_and_ofs_deltas();
1309        let tmp = tempfile::tempdir().expect("tempdir");
1310        let odb = Odb::new(tmp.path());
1311        let mut cap = MonotonicPhaseCapture::default();
1312        pack_index_records_with_threads_with_progress(
1313            &pack,
1314            &odb,
1315            Parallelism::resolve(Some(4)),
1316            Some(&mut cap),
1317        )
1318        .expect("index with progress");
1319
1320        for (phase, samples) in &cap.sets_by_phase {
1321            let mut last = 0_u64;
1322            for &cur in samples {
1323                assert!(
1324                    cur >= last,
1325                    "phase {phase:?} regressed from {last} to {cur}: {samples:?}"
1326                );
1327                last = cur;
1328            }
1329        }
1330    }
1331
1332    fn git_in(dir: &Path, args: &[&str]) -> Vec<u8> {
1333        let out = Command::new("git") // hygiene: git fixture/fsck cross-check in cfg(test) module
1334            .current_dir(dir)
1335            .args(args)
1336            .env("GIT_CONFIG_GLOBAL", "/dev/null")
1337            .env("GIT_CONFIG_SYSTEM", "/dev/null")
1338            .env("GIT_AUTHOR_NAME", "T")
1339            .env("GIT_AUTHOR_EMAIL", "t@example.com")
1340            .env("GIT_COMMITTER_NAME", "T")
1341            .env("GIT_COMMITTER_EMAIL", "t@example.com")
1342            .output()
1343            .expect("git");
1344        assert!(
1345            out.status.success(),
1346            "git {args:?}: {}",
1347            String::from_utf8_lossy(&out.stderr)
1348        );
1349        out.stdout
1350    }
1351
1352    /// A pack holding one commit with its tree and blob, plus the commit id.
1353    fn commit_pack() -> (Vec<u8>, String) {
1354        use std::io::Write;
1355        let src = tempfile::tempdir().expect("src");
1356        git_in(src.path(), &["init", "-q", "-b", "main"]);
1357        std::fs::write(src.path().join("f"), b"shared pack\n").expect("write");
1358        git_in(src.path(), &["add", "f"]);
1359        git_in(src.path(), &["commit", "-qm", "c"]);
1360        let tip = String::from_utf8(git_in(src.path(), &["rev-parse", "HEAD"])).expect("hex");
1361        let tip = tip.trim().to_owned();
1362        let mut child = Command::new("git") // hygiene: git pack fixture in cfg(test) module
1363            .current_dir(src.path())
1364            .args(["pack-objects", "--revs", "--stdout", "-q"])
1365            .stdin(std::process::Stdio::piped())
1366            .stdout(std::process::Stdio::piped())
1367            .spawn()
1368            .expect("pack-objects");
1369        child
1370            .stdin
1371            .take()
1372            .expect("stdin")
1373            .write_all(format!("{tip}\n").as_bytes())
1374            .expect("revs");
1375        let out = child.wait_with_output().expect("pack-objects output");
1376        assert!(out.status.success());
1377        (out.stdout, tip)
1378    }
1379
1380    fn published_files(pack_dir: &Path, ext: &str) -> usize {
1381        pack_dir_entries(pack_dir)
1382            .iter()
1383            .filter(|p| p.extension().is_some_and(|e| e == ext))
1384            .count()
1385    }
1386
1387    fn assert_repo_fsck_clean(repo: &Path, tip: &str) {
1388        git_in(repo, &["update-ref", "refs/heads/main", tip]);
1389        git_in(repo, &["fsck", "--strict"]);
1390    }
1391
1392    #[test]
1393    fn cancel_once_pack_published_never_strands_a_peer_install() {
1394        use std::cell::Cell;
1395
1396        /// Cancels the install as soon as its `.pack` is published, after running a
1397        /// complete peer install of the same pack that relies on that file.
1398        struct PeerThenCancel<'a> {
1399            pack_dir: PathBuf,
1400            objects: PathBuf,
1401            pack: &'a [u8],
1402            peer_ran: Cell<bool>,
1403        }
1404
1405        impl Cancel for PeerThenCancel<'_> {
1406            fn is_cancelled(&self) -> bool {
1407                if self.peer_ran.get() {
1408                    return true;
1409                }
1410                if published_files(&self.pack_dir, "pack") == 0 {
1411                    return false;
1412                }
1413                self.peer_ran.set(true);
1414                let peer_odb = Odb::new(&self.objects);
1415                install_pack_bytes(self.pack.to_vec(), &peer_odb, &IngestPackOptions::default())
1416                    .expect("peer install succeeds");
1417                true
1418            }
1419        }
1420
1421        let (pack, tip) = commit_pack();
1422        let repo = tempfile::tempdir().expect("repo");
1423        git_in(repo.path(), &["init", "-q", "-b", "main"]);
1424        let objects = repo.path().join(".git/objects");
1425        let pack_dir = objects.join("pack");
1426        let cancel = PeerThenCancel {
1427            pack_dir: pack_dir.clone(),
1428            objects: objects.clone(),
1429            pack: &pack,
1430            peer_ran: Cell::new(false),
1431        };
1432        let odb = Odb::new(&objects);
1433        let result = install_pack_bytes_with_progress(
1434            pack.clone(),
1435            &odb,
1436            &IngestPackOptions::default(),
1437            None,
1438            Some(&cancel),
1439        );
1440        match result {
1441            Ok(_) => {}
1442            Err(Error::Cancelled) => assert!(cancel.peer_ran.get()),
1443            Err(e) => panic!("unexpected install error: {e:?}"),
1444        }
1445        assert_eq!(published_files(&pack_dir, "pack"), 1);
1446        assert_eq!(published_files(&pack_dir, "idx"), 1);
1447        assert_repo_fsck_clean(repo.path(), &tip);
1448    }
1449
1450    #[test]
1451    fn error_after_publish_keeps_published_pack_and_index() {
1452        let (pack, tip) = commit_pack();
1453        let repo = tempfile::tempdir().expect("repo");
1454        git_in(repo.path(), &["init", "-q", "-b", "main"]);
1455        let objects = repo.path().join(".git/objects");
1456        let pack_dir = objects.join("pack");
1457        let trailer = ObjectId::from_bytes(&pack[pack.len() - 20..]).expect("trailer");
1458        // A directory where the `.promisor` marker goes makes the install fail only
1459        // after the `.pack`/`.idx` pair is published.
1460        std::fs::create_dir_all(pack_dir.join(format!("pack-{}.promisor", trailer.to_hex())))
1461            .expect("promisor obstacle");
1462        let odb = Odb::new(&objects);
1463        let err = install_pack_bytes(
1464            pack,
1465            &odb,
1466            &IngestPackOptions {
1467                mark_promisor_pack: true,
1468                ..Default::default()
1469            },
1470        )
1471        .expect_err("promisor write fails");
1472        assert!(matches!(err, Error::Io(_)), "got {err:?}");
1473        assert_eq!(published_files(&pack_dir, "pack"), 1);
1474        assert_eq!(published_files(&pack_dir, "idx"), 1);
1475        assert!(
1476            pack_dir_entries(&pack_dir).iter().all(|p| {
1477                let name = p.file_name().and_then(|n| n.to_str()).unwrap_or_default();
1478                !name.starts_with('.') && !name.starts_with("tmp_")
1479            }),
1480            "staging left behind"
1481        );
1482        assert_repo_fsck_clean(repo.path(), &tip);
1483    }
1484
1485    #[test]
1486    fn install_failure_leaves_no_published_pack_or_index() {
1487        let tmp = tempfile::tempdir().expect("tempdir");
1488        let odb = Odb::new(tmp.path());
1489        let mut pack = single_blob_pack(b"truncated");
1490        pack.truncate(pack.len().saturating_sub(8));
1491        let err = install_pack_bytes(
1492            pack,
1493            &odb,
1494            &IngestPackOptions {
1495                fix_thin: false,
1496                ..Default::default()
1497            },
1498        )
1499        .expect_err("truncated pack must fail");
1500        assert!(
1501            matches!(err, Error::CorruptObject(_)),
1502            "expected corrupt object, got {err:?}"
1503        );
1504        let pack_dir = tmp.path().join("pack");
1505        if pack_dir.exists() {
1506            let entries = pack_dir_entries(&pack_dir);
1507            assert!(
1508                entries
1509                    .iter()
1510                    .all(|p| p.extension().is_some_and(|e| e == "pack" || e == "idx")
1511                        || p.file_name()
1512                            .and_then(|n| n.to_str())
1513                            .is_some_and(|n| n.starts_with('.') && n.contains("-install-"))),
1514                "must not publish partial .pack/.idx, got {entries:?}"
1515            );
1516        }
1517    }
1518
1519    #[test]
1520    fn ingest_missing_received_pack_path_is_typed() {
1521        let tmp = tempfile::tempdir().expect("tempdir");
1522        let odb = Odb::new(tmp.path());
1523        let missing = tmp.path().join("no-such-pack");
1524        let err = ingest_received_pack_path(missing.clone(), &odb, &IngestPackOptions::default())
1525            .expect_err("missing spool");
1526        assert!(
1527            matches!(err, Error::ReceivedPackTempMissing { .. }),
1528            "expected typed missing-pack error, got {err:?}"
1529        );
1530    }
1531}