1use 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#[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#[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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
62#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
63pub struct ObjectKindCounts {
64 pub commits: u64,
66 pub trees: u64,
68 pub blobs: u64,
70 pub tags: u64,
72}
73
74impl ObjectKindCounts {
75 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 #[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#[derive(Debug, Clone, Default)]
113pub struct IngestedPack {
114 pub object_ids: HashSet<ObjectId>,
116 pub pack_path: Option<PathBuf>,
122 pub object_counts: ObjectKindCounts,
124 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#[derive(Debug, Clone)]
142pub struct IngestPackOptions {
143 pub fix_thin: bool,
146 pub threads: Option<usize>,
148 pub skip_post_index_verify: bool,
151 pub merged_config: Option<crate::config::ConfigSet>,
153 pub environment: crate::environment::Environment,
155 pub mark_promisor_pack: bool,
157 pub keep_message: Option<String>,
164 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
241pub 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
264pub 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
291fn 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
308pub 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
341pub 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
368pub 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
379pub 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
401pub 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
421pub 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 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 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 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 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 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
631pub(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 #[cfg(unix)]
655 if let Some(dir) = pack_path.parent() {
656 sync(dir).map_err(Error::Io)?;
657 }
658 Ok(())
659}
660
661fn 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
680fn 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
767fn 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") .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 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 #[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 #[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"); 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") .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") .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") .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") .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 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") .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 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 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 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}