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