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