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