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