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