use std::collections::BTreeSet;
use std::fs::File;
use std::fs::TryLockError;
use std::path::Path;
use std::path::PathBuf;
use git2::FetchOptions;
use git2::RemoteCallbacks;
use git2::Repository;
use serde::Deserialize;
use serde::Serialize;
use thiserror::Error;
use url::Url;
const SPARSE_META_EXT: &str = ".sparse.json";
const LOCK_EXT: &str = ".lock";
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(transparent)]
struct SparseMeta(BTreeSet<String>);
#[derive(Clone, Debug, Default)]
pub(crate) struct GitTreeStats {
pub files: usize,
pub bytes: u64,
}
#[derive(Debug, Error)]
pub enum GitError {
#[error("git operation failed")]
Git(#[source] git2::Error),
#[error("i/o error at `{path}`")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("sparse-checkout metadata error at `{path}`")]
Json {
path: PathBuf,
#[source]
source: serde_json::Error,
},
#[error("cache leaf path `{0}` has no parent directory")]
RootLeaf(PathBuf),
#[error("remote at `{url}` advertised {count} refs, exceeding the limit of {limit}")]
RefLimitExceeded {
url: String,
count: usize,
limit: usize,
},
#[error(
"module subtree `{path}` exceeds tree limits (files: {files}, bytes: {bytes}, \
max_files: {}, max_bytes: {})",
max_files.map(|v| v.to_string()).as_deref().unwrap_or("unlimited"),
max_bytes.map(|v| v.to_string()).as_deref().unwrap_or("unlimited"),
)]
TreeLimitExceeded {
path: String,
files: usize,
bytes: u64,
max_files: Option<usize>,
max_bytes: Option<u64>,
},
}
fn default_credentials(
url: &str,
username: Option<&str>,
allowed: git2::CredentialType,
) -> Result<git2::Cred, git2::Error> {
if let Ok(config) = git2::Config::open_default()
&& let Ok(cred) = git2::Cred::credential_helper(&config, url, username)
{
return Ok(cred);
}
if allowed.contains(git2::CredentialType::SSH_KEY) {
return git2::Cred::ssh_key_from_agent(username.unwrap_or("git"));
}
git2::Cred::default()
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum CredentialMode {
Enabled,
Disabled,
}
pub(crate) fn default_callbacks<'cb>(mode: CredentialMode) -> RemoteCallbacks<'cb> {
let mut cb = RemoteCallbacks::new();
if mode == CredentialMode::Enabled {
cb.credentials(default_credentials);
}
cb
}
pub(crate) fn default_fetch_options<'fo>(mode: CredentialMode) -> FetchOptions<'fo> {
let mut opts = FetchOptions::new();
opts.remote_callbacks(default_callbacks(mode));
opts
}
pub(crate) fn connect_remote(
url: &Url,
direction: git2::Direction,
mode: CredentialMode,
) -> Result<git2::Remote<'_>, GitError> {
let mut remote = git2::Remote::create_detached(url.as_str()).map_err(GitError::Git)?;
remote
.connect_auth(direction, Some(default_callbacks(mode)), None)
.map_err(GitError::Git)?;
Ok(remote)
}
pub(crate) fn disconnect_remote(remote: &mut git2::Remote<'_>) {
let _ = remote.disconnect();
}
pub(crate) fn list_advertised_refs(
url: &Url,
max_refs: usize,
mode: CredentialMode,
) -> Result<Vec<(String, String)>, GitError> {
let mut remote = connect_remote(url, git2::Direction::Fetch, mode)?;
let advertised = remote.list().map_err(GitError::Git)?;
if advertised.len() > max_refs {
let count = advertised.len();
disconnect_remote(&mut remote);
return Err(GitError::RefLimitExceeded {
url: url.to_string(),
count,
limit: max_refs,
});
}
let pairs = advertised
.iter()
.map(|h| (h.name().to_string(), h.oid().to_string()))
.collect();
disconnect_remote(&mut remote);
Ok(pairs)
}
pub(crate) fn inspect_subtree_stats(
repo: &Repository,
oid: git2::Oid,
path: &str,
) -> Result<GitTreeStats, GitError> {
let commit = repo.find_commit(oid).map_err(GitError::Git)?;
let root_tree = commit.tree().map_err(GitError::Git)?;
let subtree = if path.is_empty() || path == "." {
root_tree
} else {
let entry = root_tree.get_path(Path::new(path)).map_err(GitError::Git)?;
repo.find_tree(entry.id()).map_err(GitError::Git)?
};
let mut stats = GitTreeStats::default();
subtree
.walk(git2::TreeWalkMode::PreOrder, |_, entry| {
if entry.kind() == Some(git2::ObjectType::Blob) {
stats.files += 1;
if let Ok(blob) = repo.find_blob(entry.id()) {
stats.bytes = stats.bytes.saturating_add(blob.size() as u64);
}
}
git2::TreeWalkResult::Ok
})
.map_err(GitError::Git)?;
Ok(stats)
}
pub(crate) fn enforce_tree_limits(
repo: &Repository,
oid: git2::Oid,
paths: &[String],
max_files: Option<usize>,
max_bytes: Option<u64>,
) -> Result<(), GitError> {
if max_files.is_none() && max_bytes.is_none() {
return Ok(());
}
for path in paths {
let stats = inspect_subtree_stats(repo, oid, path)?;
let files_exceeded = max_files.is_some_and(|limit| stats.files > limit);
let bytes_exceeded = max_bytes.is_some_and(|limit| stats.bytes > limit);
if files_exceeded || bytes_exceeded {
return Err(GitError::TreeLimitExceeded {
path: path.clone(),
files: stats.files,
bytes: stats.bytes,
max_files,
max_bytes,
});
}
}
Ok(())
}
fn apply_sparse_checkout(repo: &Repository, paths: &[String]) -> Result<(), GitError> {
let head_commit = repo
.head()
.map_err(GitError::Git)?
.peel_to_commit()
.map_err(GitError::Git)?;
let tree = head_commit.tree().map_err(GitError::Git)?;
let mut checkout = git2::build::CheckoutBuilder::new();
checkout.force().recreate_missing(true);
for p in paths {
checkout.path(format!("{p}/**"));
}
repo.checkout_tree(tree.as_object(), Some(&mut checkout))
.map_err(GitError::Git)?;
Ok(())
}
fn sparse_meta_path(leaf: &Path) -> PathBuf {
let name = leaf
.file_name()
.map(|n| n.to_string_lossy())
.unwrap_or_default();
leaf.with_file_name(format!(".{name}{SPARSE_META_EXT}"))
}
fn save_sparse_meta(leaf: &Path, paths: &[String]) -> Result<(), GitError> {
let meta = SparseMeta(paths.iter().cloned().collect());
let path = sparse_meta_path(leaf);
let bytes = serde_json::to_vec_pretty(&meta).map_err(|source| GitError::Json {
path: path.clone(),
source,
})?;
std::fs::write(&path, bytes).map_err(|source| GitError::Io { path, source })
}
fn load_sparse_meta(leaf: &Path) -> Result<SparseMeta, GitError> {
let path = sparse_meta_path(leaf);
let bytes = match std::fs::read(&path) {
Ok(b) => b,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Ok(SparseMeta::default());
}
Err(source) => return Err(GitError::Io { path, source }),
};
serde_json::from_slice(&bytes).map_err(|source| GitError::Json { path, source })
}
pub(crate) fn clone_with_sparse_checkout<I, S>(
url: &Url,
commit: &str,
leaf: &Path,
paths: I,
mode: CredentialMode,
max_files: Option<usize>,
max_bytes: Option<u64>,
) -> Result<(), GitError>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let owned: Vec<String> = paths.into_iter().map(|s| s.as_ref().to_string()).collect();
let parent = leaf
.parent()
.ok_or_else(|| GitError::RootLeaf(leaf.to_path_buf()))?;
std::fs::create_dir_all(parent).map_err(|source| GitError::Io {
path: parent.to_path_buf(),
source,
})?;
let mut fetch_opts = default_fetch_options(mode);
if url.scheme() != "file" {
fetch_opts.depth(1);
}
let mut empty_checkout = git2::build::CheckoutBuilder::new();
empty_checkout.disable_filters(true).dry_run();
let mut builder = git2::build::RepoBuilder::new();
builder
.fetch_options(fetch_opts)
.with_checkout(empty_checkout)
.clone_local(git2::build::CloneLocal::Auto)
.bare(false);
let repo = builder.clone(url.as_str(), leaf).map_err(GitError::Git)?;
let oid = git2::Oid::from_str(commit).map_err(GitError::Git)?;
if repo.set_head_detached(oid).is_err() {
let mut fetch_opts = default_fetch_options(mode);
let refspec = format!("+{commit}:refs/fetched/{commit}");
repo.remote_anonymous(url.as_str())
.map_err(GitError::Git)?
.fetch(&[&refspec], Some(&mut fetch_opts), None)
.map_err(GitError::Git)?;
repo.set_head_detached(oid).map_err(GitError::Git)?;
}
enforce_tree_limits(&repo, oid, &owned, max_files, max_bytes)?;
apply_sparse_checkout(&repo, &owned)?;
save_sparse_meta(leaf, &owned)?;
Ok(())
}
pub(crate) fn extend_sparse_checkout<I, S>(
leaf: &Path,
paths: I,
max_files: Option<usize>,
max_bytes: Option<u64>,
) -> Result<(), GitError>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let repo = Repository::open(leaf).map_err(GitError::Git)?;
let existing = load_sparse_meta(leaf)?.0;
let mut all = existing.clone();
let mut new_paths = Vec::new();
for p in paths {
let s = p.as_ref().to_string();
if !existing.contains(&s) {
new_paths.push(s.clone());
}
all.insert(s);
}
if !new_paths.is_empty() {
let head_oid = repo
.head()
.map_err(GitError::Git)?
.peel_to_commit()
.map_err(GitError::Git)?
.id();
enforce_tree_limits(&repo, head_oid, &new_paths, max_files, max_bytes)?;
}
let all_owned: Vec<String> = all.into_iter().collect();
apply_sparse_checkout(&repo, &all_owned)?;
save_sparse_meta(leaf, &all_owned)?;
Ok(())
}
fn lock_cache_leaf(leaf: &Path) -> Result<File, GitError> {
let parent = leaf.parent().unwrap();
std::fs::create_dir_all(parent).map_err(|source| GitError::Io {
path: parent.to_path_buf(),
source,
})?;
let name = leaf.file_name().unwrap().to_string_lossy();
let lock_path = parent.join(format!(".{name}{LOCK_EXT}"));
let file = File::create(&lock_path).map_err(|source| GitError::Io {
path: lock_path.clone(),
source,
})?;
match file.try_lock() {
Ok(()) => {}
Err(TryLockError::WouldBlock) => {
tracing::info!(
"waiting to acquire exclusive lock on Git cache leaf `{leaf}`",
leaf = leaf.display(),
);
file.lock().map_err(|source| GitError::Io {
path: lock_path.clone(),
source,
})?;
}
Err(TryLockError::Error(source)) => {
return Err(GitError::Io {
path: lock_path,
source,
});
}
}
Ok(file)
}
pub(crate) fn ensure_materialized<I, S>(
leaf: &Path,
url: &Url,
commit: &str,
paths: I,
mode: CredentialMode,
max_files: Option<usize>,
max_bytes: Option<u64>,
) -> Result<(), GitError>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let _lock = lock_cache_leaf(leaf)?;
if leaf.exists() {
extend_sparse_checkout(leaf, paths, max_files, max_bytes)
} else {
let result =
clone_with_sparse_checkout(url, commit, leaf, paths, mode, max_files, max_bytes);
if result.is_err()
&& leaf.exists()
&& let Err(error) = std::fs::remove_dir_all(leaf)
{
tracing::warn!(
path = %leaf.display(),
%error,
"failed to clean up cache leaf after a failed clone",
);
}
result
}
}
#[cfg(test)]
mod tests {
use std::fs;
use git2::Repository;
use git2::Signature;
use tempfile::tempdir;
use super::*;
fn build_upstream(files: &[(&str, &[u8])]) -> (tempfile::TempDir, String) {
let upstream = tempdir().unwrap();
let repo = Repository::init(upstream.path()).unwrap();
for (rel, bytes) in files {
let abs = upstream.path().join(rel);
if let Some(parent) = abs.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(&abs, bytes).unwrap();
}
let mut index = repo.index().unwrap();
index
.add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)
.unwrap();
index.write().unwrap();
let tree_oid = index.write_tree().unwrap();
let tree = repo.find_tree(tree_oid).unwrap();
let sig = Signature::now("test", "test@example.com").unwrap();
let oid = repo
.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
.unwrap();
(upstream, oid.to_string())
}
#[test]
fn clones_with_sparse_checkout_to_subset_of_paths() {
let (upstream, sha) = build_upstream(&[
(
"csvkit/module.json",
br#"{"name":"csvkit","version":"1.0.0","license":"MIT"}"#,
),
("csvkit/index.wdl", b"workflow w {}"),
(
"spellbook/module.json",
br#"{"name":"spellbook","version":"1.0.0","license":"MIT"}"#,
),
("spellbook/index.wdl", b"workflow w {}"),
]);
let dest = tempdir().unwrap();
let leaf = dest.path().join("leaf");
let url = Url::from_directory_path(upstream.path()).unwrap();
clone_with_sparse_checkout(
&url,
&sha,
&leaf,
["csvkit"],
CredentialMode::Enabled,
None,
None,
)
.unwrap();
assert!(leaf.join("csvkit").join("module.json").exists());
assert!(!leaf.join("spellbook").exists());
let meta = load_sparse_meta(&leaf).unwrap();
assert_eq!(
meta.0.iter().cloned().collect::<Vec<_>>(),
vec!["csvkit".to_string()]
);
}
#[test]
fn ref_count_limit_is_enforced() {
let (upstream, _sha) = build_upstream(&[(
"module.json",
br#"{"name":"x","version":"1.0.0","license":"MIT"}"#,
)]);
let url = Url::from_directory_path(upstream.path()).unwrap();
let err = list_advertised_refs(&url, 0, CredentialMode::Enabled).unwrap_err();
assert!(
matches!(err, GitError::RefLimitExceeded { .. }),
"got: {err}"
);
}
#[test]
fn ensure_materialized_clones_then_extends() {
let (upstream, sha) = build_upstream(&[
(
"csvkit/module.json",
br#"{"name":"csvkit","version":"1.0.0","license":"MIT"}"#,
),
("csvkit/index.wdl", b"workflow w {}"),
(
"spellbook/module.json",
br#"{"name":"spellbook","version":"1.0.0","license":"MIT"}"#,
),
("spellbook/index.wdl", b"workflow w {}"),
]);
let dest = tempdir().unwrap();
let leaf = dest.path().join("leaf");
let url = Url::from_directory_path(upstream.path()).unwrap();
ensure_materialized(
&leaf,
&url,
&sha,
["csvkit"],
CredentialMode::Enabled,
None,
None,
)
.unwrap();
assert!(leaf.join("csvkit").join("module.json").exists());
assert!(!leaf.join("spellbook").exists());
ensure_materialized(
&leaf,
&url,
&sha,
["spellbook"],
CredentialMode::Enabled,
None,
None,
)
.unwrap();
assert!(leaf.join("csvkit").join("module.json").exists());
assert!(leaf.join("spellbook").join("module.json").exists());
}
#[test]
fn extend_adds_a_second_module_folder() {
let (upstream, sha) = build_upstream(&[
(
"csvkit/module.json",
br#"{"name":"csvkit","version":"1.0.0","license":"MIT"}"#,
),
("csvkit/index.wdl", b"workflow w {}"),
(
"spellbook/module.json",
br#"{"name":"spellbook","version":"1.0.0","license":"MIT"}"#,
),
("spellbook/index.wdl", b"workflow w {}"),
]);
let dest = tempdir().unwrap();
let leaf = dest.path().join("leaf");
let url = Url::from_directory_path(upstream.path()).unwrap();
clone_with_sparse_checkout(
&url,
&sha,
&leaf,
["csvkit"],
CredentialMode::Enabled,
None,
None,
)
.unwrap();
assert!(!leaf.join("spellbook").exists());
extend_sparse_checkout(&leaf, ["spellbook"], None, None).unwrap();
assert!(leaf.join("spellbook").join("module.json").exists());
assert!(leaf.join("csvkit").join("module.json").exists());
let meta = load_sparse_meta(&leaf).unwrap();
let mut paths: Vec<_> = meta.0.into_iter().collect();
paths.sort();
assert_eq!(paths, vec!["csvkit".to_string(), "spellbook".to_string()]);
}
#[test]
fn inspect_subtree_stats_counts_blobs() {
let (upstream, sha) = build_upstream(&[
("mod/a.wdl", b"task a {}"),
("mod/b.wdl", b"task b {}"),
("mod/sub/c.wdl", b"task c {}"),
]);
let repo = Repository::open(upstream.path()).unwrap();
let oid = git2::Oid::from_str(&sha).unwrap();
let stats = inspect_subtree_stats(&repo, oid, "mod").unwrap();
assert_eq!(stats.files, 3);
assert_eq!(
stats.bytes,
b"task a {}".len() as u64 + b"task b {}".len() as u64 + b"task c {}".len() as u64
);
}
#[test]
fn tree_file_limit_blocks_clone() {
let (upstream, sha) = build_upstream(&[
("mod/a.wdl", b"task a {}"),
("mod/b.wdl", b"task b {}"),
("mod/c.wdl", b"task c {}"),
]);
let dest = tempdir().unwrap();
let leaf = dest.path().join("leaf");
let url = Url::from_directory_path(upstream.path()).unwrap();
let err = clone_with_sparse_checkout(
&url,
&sha,
&leaf,
["mod"],
CredentialMode::Enabled,
Some(2),
None,
)
.unwrap_err();
assert!(
matches!(err, GitError::TreeLimitExceeded { files: 3, .. }),
"got: {err}"
);
}
#[test]
fn tree_byte_limit_blocks_clone() {
let (upstream, sha) = build_upstream(&[("mod/big.wdl", &[0u8; 1024])]);
let dest = tempdir().unwrap();
let leaf = dest.path().join("leaf");
let url = Url::from_directory_path(upstream.path()).unwrap();
let err = clone_with_sparse_checkout(
&url,
&sha,
&leaf,
["mod"],
CredentialMode::Enabled,
None,
Some(512),
)
.unwrap_err();
assert!(
matches!(err, GitError::TreeLimitExceeded { bytes: 1024, .. }),
"got: {err}"
);
}
#[test]
fn tree_limits_pass_when_within_bounds() {
let (upstream, sha) =
build_upstream(&[("mod/a.wdl", b"task a {}"), ("mod/b.wdl", b"task b {}")]);
let dest = tempdir().unwrap();
let leaf = dest.path().join("leaf");
let url = Url::from_directory_path(upstream.path()).unwrap();
clone_with_sparse_checkout(
&url,
&sha,
&leaf,
["mod"],
CredentialMode::Enabled,
Some(100),
Some(100_000),
)
.unwrap();
assert!(leaf.join("mod").join("a.wdl").exists());
}
#[test]
fn tree_limits_enforced_on_extend() {
let (upstream, sha) = build_upstream(&[
("small/a.wdl", b"x"),
("big/a.wdl", b"task a {}"),
("big/b.wdl", b"task b {}"),
("big/c.wdl", b"task c {}"),
]);
let dest = tempdir().unwrap();
let leaf = dest.path().join("leaf");
let url = Url::from_directory_path(upstream.path()).unwrap();
clone_with_sparse_checkout(
&url,
&sha,
&leaf,
["small"],
CredentialMode::Enabled,
None,
None,
)
.unwrap();
let err = extend_sparse_checkout(&leaf, ["big"], Some(2), None).unwrap_err();
assert!(
matches!(err, GitError::TreeLimitExceeded { files: 3, .. }),
"got: {err}"
);
}
#[test]
fn clones_commit_not_reachable_from_default_head() {
let upstream = tempdir().unwrap();
let repo = Repository::init(upstream.path()).unwrap();
let sig = Signature::now("test", "test@example.com").unwrap();
let mod_a = upstream.path().join("mod_a");
fs::create_dir_all(&mod_a).unwrap();
fs::write(mod_a.join("a.txt"), b"main").unwrap();
let mut index = repo.index().unwrap();
index
.add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)
.unwrap();
index.write().unwrap();
let tree = repo.find_tree(index.write_tree().unwrap()).unwrap();
let main_oid = repo
.commit(Some("HEAD"), &sig, &sig, "main commit", &tree, &[])
.unwrap();
let main_commit = repo.find_commit(main_oid).unwrap();
repo.branch("other", &main_commit, false).unwrap();
repo.set_head("refs/heads/other").unwrap();
let mod_b = upstream.path().join("mod_b");
fs::create_dir_all(&mod_b).unwrap();
fs::write(mod_b.join("b.txt"), b"other").unwrap();
let mut index = repo.index().unwrap();
index
.add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)
.unwrap();
index.write().unwrap();
let tree = repo.find_tree(index.write_tree().unwrap()).unwrap();
let other_oid = repo
.commit(
Some("refs/heads/other"),
&sig,
&sig,
"other commit",
&tree,
&[&main_commit],
)
.unwrap();
repo.set_head("refs/heads/main").unwrap();
let leaf = tempdir().unwrap();
let leaf_path = leaf.path().join("checkout");
let url = Url::from_file_path(upstream.path()).unwrap();
clone_with_sparse_checkout(
&url,
&other_oid.to_string(),
&leaf_path,
["mod_b"],
CredentialMode::Enabled,
None,
None,
)
.unwrap();
assert!(
leaf_path.join("mod_b").join("b.txt").exists(),
"checkout should contain the file from the non-default branch"
);
}
}