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