0ecef20
//! 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<F: std::future::Future>(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<ArchiveResult, crate::Error> {
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::<ArchiveResult>(&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<Box<dyn storage::StorageRepository>> = 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()
}
}