//! archive usecase: generate a repository archive (streaming-stub). use bytes::Bytes; use storage::object_store::path::Path as StorePath; use crate::GitRepository; use crate::archive::impls::ArchiveOps; use crate::archive::types::{ArchiveFormat, ArchiveOptions, ArchiveResult}; /// Map an archive format to its storage file extension. fn fmt_ext(format: ArchiveFormat) -> &'static str { match format { ArchiveFormat::Tar => "tar", ArchiveFormat::TarGz => "tar.gz", ArchiveFormat::Zip => "zip", ArchiveFormat::TarZst => "tar.zst", } } /// Map an archive format to the corresponding `git archive --format` value. fn fmt_opt(format: ArchiveFormat) -> &'static str { match format { ArchiveFormat::Tar => "tar", ArchiveFormat::TarGz => "tar.gz", ArchiveFormat::Zip => "zip", ArchiveFormat::TarZst => "tar.zst", } } /// Run `future` to completion on a dedicated runtime, returning its output. fn block_on(fut: F) -> F::Output { tokio::runtime::Builder::new_current_thread() .enable_all() .build() .expect("failed to build tokio runtime") .block_on(fut) } impl ArchiveOps for GitRepository { fn archive_archive( &self, repo: &str, rev: &str, opts: &ArchiveOptions, ) -> Result { let repo2 = self.repo()?; let path = repo2.path().to_path_buf(); crate::validate::validate_short_name("revision", rev)?; let format = opts.format; let ext = fmt_ext(format); let storage_path = StorePath::from(format!("{repo}/archive/{rev}.{ext}")); let storage = self.storage.clone(); let cache_key = format!("{}:{}:{}", repo, rev, ext); // 1. Object-pool cache hit: fastest path, no storage roundtrip. if let Some(hit) = self.cache.object_pool_get_sync::(&cache_key) { tracing::info!(rev, format = ?format, size = hit.size, "archive object-pool hit"); return Ok(hit); } // 2. Storage hit: metadata present in object store. let exists = block_on(storage.exists(&storage_path)) .map_err(|e| crate::Error::Other(format!("storage exists failed: {e}")))?; if exists { let size = block_on(storage.head(&storage_path)) .ok() .map(|m| m.size as i64) .unwrap_or(0); let v = ArchiveResult { format, size, cache_key: cache_key.clone(), }; self.cache .set_sync_with_ttl(&cache_key, &v, crate::usecase_cache::IMMUTABLE_TTL); tracing::info!(rev, format = ?format, size, "archive storage hit"); return Ok(v); } // 3. Cache miss: generate the archive and persist it to storage. let bytes = crate::gitcli::run_bytes(&path, &["archive", "--format", fmt_opt(format), rev])?; let size = bytes.len() as i64; block_on(storage.put(&storage_path, Bytes::from(bytes))) .map_err(|e| crate::Error::Other(format!("storage put failed: {e}")))?; let v = ArchiveResult { format, size, cache_key: cache_key.clone(), }; self.cache .set_sync_with_ttl(&cache_key, &v, crate::usecase_cache::IMMUTABLE_TTL); tracing::info!(rev, format = ?format, size, "archive generated and cached"); Ok(v) } } #[cfg(test)] mod tests { use super::*; use crate::GitRepository; use crate::object::cache::ObjectCache; use cache::CacheBuilder; use storage::config::StorageSettings; use storage::local::LocalStorage; fn make_repo(dir: &std::path::Path, name: &str) -> GitRepository { let cache_cfg = cache::config::cache::AppCacheSettings::default(); let cache = block_on(CacheBuilder::from_config(cache_cfg).build()) .expect("cache build") .expect("cache enabled"); let cache = std::sync::Arc::new(cache); let oc = ObjectCache { prefix: "test".to_string(), cache, expire_ttl: 300, }; let mut settings = StorageSettings::default(); settings.set_local( storage::config::LocalStorageSettings::builder() .root_dir(dir.join("storage").to_path_buf()) .build(), ); let storage: std::sync::Arc> = std::sync::Arc::new( Box::new(LocalStorage::from_config(&settings).expect("storage")), ); GitRepository::new(dir.join(name), oc, storage) } #[test] fn archive_generates_and_serves_from_cache() { let tmp = std::env::temp_dir().join(format!("git-archive-test-{}", std::process::id())); let _ = std::fs::remove_dir_all(&tmp); let repo = make_repo(&tmp, "main"); // Create a bare repo with a single commit via plumbing. crate::gitcli::run(&repo.path, &["init", "-q", "--bare", "-b", "main"]).unwrap(); crate::gitcli::run(&repo.path, &["config", "user.email", "t@t"]).unwrap(); crate::gitcli::run(&repo.path, &["config", "user.name", "t"]).unwrap(); let blob = run_stdin(&repo.path, &["hash-object", "-w", "--stdin"], b"hello\n"); let tree = run_stdin( &repo.path, &["mktree", "-z"], &format!("100644 blob {blob}\tf.txt\0").into_bytes(), ); let commit = run_stdin(&repo.path, &["commit-tree", &tree, "-m", "init"], b""); crate::gitcli::run(&repo.path, &["update-ref", "refs/heads/main", &commit]).unwrap(); let opts = ArchiveOptions { format: ArchiveFormat::TarGz, path_filter: None, }; let first = ArchiveOps::archive_archive(&repo, "main", "HEAD", &opts).unwrap(); assert!(first.size > 0); let second = ArchiveOps::archive_archive(&repo, "main", "HEAD", &opts).unwrap(); assert_eq!(first.size, second.size); let sp = StorePath::from("main/archive/HEAD.tar.gz"); assert!(block_on(repo.storage.exists(&sp)).unwrap()); let _ = std::fs::remove_dir_all(&tmp); } fn run_stdin(repo_path: &std::path::Path, args: &[&str], input: &[u8]) -> String { let out = duct::cmd("git", args) .env("GIT_DIR", repo_path) .env("GIT_TERMINAL_PROMPT", "0") .stdin_bytes(input) .stdout_capture() .stderr_capture() .run() .unwrap(); assert!( out.status.success(), "git {:?} failed: {:?}", args, out.stderr ); String::from_utf8_lossy(&out.stdout).trim().to_string() } }