use crate::cache::{ByteLruCache, CacheWeight, TtlLruCache};
use std::cell::RefCell;
use std::collections::HashMap;
use std::ffi::{OsStr, OsString};
use std::fs::{self, File, OpenOptions};
use std::io::{Cursor, Read, Seek, SeekFrom, Write};
use std::mem::size_of;
use std::os::unix::ffi::OsStringExt;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use gix::bstr::ByteSlice;
use gix::hash::oid;
use gix::object::tree::EntryKind;
const DEFAULT_CACHE_SIZE_MB: usize = 256;
const DEFAULT_REVISION_CACHE_ENTRIES: usize = 256;
const DEFAULT_REVISION_CACHE_TTL: Duration = Duration::from_secs(1);
const DEFAULT_SMALL_BLOB_MAX_BYTES: usize = 128 * 1024;
const MAX_LFS_POINTER_BYTES: u64 = 8 * 1024;
thread_local! {
static THREAD_LOCAL_REPOSITORIES: RefCell<HashMap<usize, gix::Repository>> =
RefCell::new(HashMap::new());
}
static NEXT_REPOSITORY_KEY: AtomicUsize = AtomicUsize::new(1);
pub type BlobReader = Box<dyn ReadSeek + Send>;
pub trait ReadSeek: Read + Seek {}
impl<T> ReadSeek for T where T: Read + Seek {}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum LfsState {
Pointer,
Resolved,
}
#[derive(Debug)]
pub struct OpenedSubmodule<S> {
pub store: S,
pub commit: CommitInfo,
pub tree: TreeInfo,
}
pub trait RevResolver: Send + Sync {
fn resolve_head(&self) -> Result<CommitInfo, StoreError>;
fn resolve_revision(&self, spec: &[u8]) -> Result<CommitInfo, StoreError>;
fn resolve_commit_prefix(&self, prefix: &[u8]) -> Result<CommitInfo, StoreError>;
fn list_references(&self) -> Result<Vec<GitReference>, StoreError>;
}
pub trait ObjectStore: Send + Sync {
fn open(path: &Path) -> Result<Self, StoreError>
where
Self: Sized;
fn root_tree(&self, commit: &CommitInfo) -> Result<TreeInfo, StoreError>;
fn list_tree(&self, tree: &TreeInfo) -> Result<Vec<TreeEntry>, StoreError>;
fn blob_len(&self, oid: &GitObjectId) -> Result<u64, StoreError>;
fn open_blob(&self, oid: &GitObjectId) -> Result<BlobReader, StoreError>;
fn read_symlink_target(&self, oid: &GitObjectId) -> Result<Vec<u8>, StoreError>;
fn lfs_state(&self, oid: &GitObjectId) -> Result<Option<LfsState>, StoreError>;
fn open_submodule(
&self,
path: &[u8],
commit: &GitObjectId,
) -> Result<Option<OpenedSubmodule<Self>>, StoreError>
where
Self: Sized;
fn history_entries(&self, path: &[u8]) -> Result<Vec<HistoryEntry>, StoreError>;
fn history_prefix_exists(&self, prefix: &[u8]) -> Result<bool, StoreError>;
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct GitObjectId(String);
impl GitObjectId {
pub fn as_hex(&self) -> &str {
&self.0
}
fn from_gix(oid: &oid) -> Self {
Self(oid.to_hex().to_string())
}
fn to_gix(&self) -> Result<gix::ObjectId, StoreError> {
gix::ObjectId::from_hex(self.0.as_bytes()).map_err(|source| StoreError::InvalidObjectId {
hex: self.0.clone(),
source,
})
}
}
impl std::fmt::Display for GitObjectId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CommitInfo {
pub oid: GitObjectId,
pub committer_time_seconds: i64,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TreeInfo {
pub oid: GitObjectId,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GitReference {
pub full_name: Vec<u8>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct HistoryEntry {
pub commit: CommitInfo,
pub path: Vec<u8>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum GitObjectKind {
Tree,
Blob,
Commit,
}
impl GitObjectKind {
fn from_entry_kind(kind: EntryKind) -> Self {
match kind {
EntryKind::Tree => Self::Tree,
EntryKind::Blob | EntryKind::BlobExecutable | EntryKind::Link => Self::Blob,
EntryKind::Commit => Self::Commit,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TreeEntry {
pub name: Vec<u8>,
pub oid: GitObjectId,
pub mode: u32,
pub kind: GitObjectKind,
}
impl TreeEntry {
fn approximate_size(&self) -> usize {
size_of::<Self>() + self.name.len() + self.oid.as_hex().len()
}
}
impl CacheWeight for Vec<TreeEntry> {
fn cache_weight(&self) -> usize {
size_of::<Vec<TreeEntry>>() + self.iter().map(TreeEntry::approximate_size).sum::<usize>()
}
}
impl CacheWeight for Arc<[u8]> {
fn cache_weight(&self) -> usize {
size_of::<Arc<[u8]>>() + self.len()
}
}
#[derive(Debug, thiserror::Error)]
pub enum StoreError {
#[error("failed to open repository at {path}: {source}")]
OpenRepository {
path: PathBuf,
source: anyhow::Error,
},
#[error("failed to resolve HEAD: {source}")]
ResolveHead {
source: anyhow::Error,
},
#[error("revision did not resolve to a commit: {spec}: {source}")]
ResolveRevision {
spec: String,
source: anyhow::Error,
},
#[error("revision not found: {spec}: {source}")]
RevisionNotFound {
spec: String,
source: anyhow::Error,
},
#[error("revision is ambiguous: {spec}: {source}")]
AmbiguousRevision {
spec: String,
source: anyhow::Error,
},
#[error("failed to enumerate references: {source}")]
ListReferences {
source: anyhow::Error,
},
#[error("failed to read commit {oid}: {source}")]
ReadCommit {
oid: GitObjectId,
source: anyhow::Error,
},
#[error("failed to read tree {oid}: {source}")]
ReadTree {
oid: GitObjectId,
source: anyhow::Error,
},
#[error("failed to read blob {oid}: {source}")]
ReadBlob {
oid: GitObjectId,
source: anyhow::Error,
},
#[error("invalid object id {hex}: {source}")]
InvalidObjectId {
hex: String,
source: gix::hash::decode::Error,
},
#[error("failed to decode tree {oid}: {source}")]
DecodeTree {
oid: GitObjectId,
source: gix::objs::decode::Error,
},
#[error("failed to decode commit {oid}: {source}")]
DecodeCommit {
oid: GitObjectId,
source: gix::objs::decode::Error,
},
#[error("failed to resolve history for {path}: {source}")]
HistoryLookup {
path: String,
source: anyhow::Error,
},
}
#[derive(Clone, Copy, Debug)]
struct CacheLayout {
tree_cache_bytes: usize,
blob_cache_bytes: usize,
small_blob_threshold: usize,
revision_cache_entries: usize,
revision_ttl: Duration,
}
impl CacheLayout {
fn from_cache_size_mb(cache_size_mb: usize) -> Self {
let object_cache_bytes = cache_size_mb.saturating_mul(1024 * 1024);
let tree_cache_bytes = object_cache_bytes / 2;
let blob_cache_bytes = object_cache_bytes.saturating_sub(tree_cache_bytes);
let small_blob_threshold = blob_cache_bytes.min(DEFAULT_SMALL_BLOB_MAX_BYTES);
Self {
tree_cache_bytes,
blob_cache_bytes,
small_blob_threshold,
revision_cache_entries: DEFAULT_REVISION_CACHE_ENTRIES,
revision_ttl: DEFAULT_REVISION_CACHE_TTL,
}
}
}
#[derive(Debug)]
struct RepositoryCaches {
trees: Mutex<ByteLruCache<GitObjectId, Vec<TreeEntry>>>,
small_blobs: Mutex<ByteLruCache<GitObjectId, Arc<[u8]>>>,
revisions: Mutex<TtlLruCache<RevisionCacheKey, CommitInfo>>,
}
impl RepositoryCaches {
fn new(layout: CacheLayout) -> Self {
Self {
trees: Mutex::new(ByteLruCache::new(layout.tree_cache_bytes)),
small_blobs: Mutex::new(ByteLruCache::new(layout.blob_cache_bytes)),
revisions: Mutex::new(TtlLruCache::new(
layout.revision_cache_entries,
layout.revision_ttl,
)),
}
}
}
#[derive(Debug, Default)]
struct RepositoryMetrics {
tree_cache_hits: AtomicU64,
tree_cache_misses: AtomicU64,
tree_backend_reads: AtomicU64,
blob_cache_hits: AtomicU64,
blob_cache_misses: AtomicU64,
blob_backend_reads: AtomicU64,
blob_streamed_opens: AtomicU64,
revision_cache_hits: AtomicU64,
revision_cache_misses: AtomicU64,
revision_backend_resolves: AtomicU64,
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
enum RevisionCacheKey {
Head,
Revision(Vec<u8>),
CommitPrefix(Vec<u8>),
}
#[derive(Debug)]
struct TempBlobFile {
file: File,
path: PathBuf,
}
impl Read for TempBlobFile {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
self.file.read(buf)
}
}
impl Seek for TempBlobFile {
fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
self.file.seek(pos)
}
}
impl Drop for TempBlobFile {
fn drop(&mut self) {
let _ = fs::remove_file(&self.path);
}
}
#[derive(Debug)]
struct LfsPointer {
oid: String,
}
#[derive(Debug)]
struct ResolvedLfsObject {
path: PathBuf,
size: u64,
}
#[derive(Clone, Debug)]
struct SnapshotRevision {
name: Vec<u8>,
commit: CommitInfo,
}
#[derive(Clone, Debug)]
struct ReferenceSnapshot {
head: CommitInfo,
references: Vec<GitReference>,
revisions: Vec<SnapshotRevision>,
}
impl ReferenceSnapshot {
fn resolve_named_commit(&self, name: &[u8]) -> Option<CommitInfo> {
self.revisions
.iter()
.find(|revision| revision.name == name)
.map(|revision| revision.commit.clone())
}
}
#[derive(Debug)]
pub struct GixRepository {
key: usize,
repo: gix::ThreadSafeRepository,
repo_path: PathBuf,
git_dir: PathBuf,
cache_layout: CacheLayout,
caches: RepositoryCaches,
metrics: RepositoryMetrics,
temp_blob_counter: AtomicU64,
handle_creations: AtomicUsize,
lfs_enabled: bool,
reference_snapshot: Option<ReferenceSnapshot>,
}
impl GixRepository {
pub fn open_with_options(
path: &Path,
cache_size_mb: usize,
lfs_enabled: bool,
ref_snapshot: bool,
) -> Result<Self, StoreError> {
let repo = gix::ThreadSafeRepository::discover(path).map_err(|source| {
StoreError::OpenRepository {
path: path.to_path_buf(),
source: anyhow::Error::new(source),
}
})?;
Self::new(
repo,
CacheLayout::from_cache_size_mb(cache_size_mb),
lfs_enabled,
ref_snapshot,
)
}
pub fn open_with_cache_size_mb(path: &Path, cache_size_mb: usize) -> Result<Self, StoreError> {
Self::open_with_options(path, cache_size_mb, false, false)
}
fn new(
repo: gix::ThreadSafeRepository,
cache_layout: CacheLayout,
lfs_enabled: bool,
ref_snapshot: bool,
) -> Result<Self, StoreError> {
let repo_path = repo.work_dir().unwrap_or_else(|| repo.path()).to_path_buf();
let git_dir = repo.path().to_path_buf();
let mut store = Self {
key: NEXT_REPOSITORY_KEY.fetch_add(1, Ordering::Relaxed),
repo,
repo_path,
git_dir,
cache_layout,
caches: RepositoryCaches::new(cache_layout),
metrics: RepositoryMetrics::default(),
temp_blob_counter: AtomicU64::new(0),
handle_creations: AtomicUsize::new(0),
lfs_enabled,
reference_snapshot: None,
};
if ref_snapshot {
store.reference_snapshot = Some(store.capture_reference_snapshot()?);
}
Ok(store)
}
fn with_thread_local_repo<T, F>(&self, op: F) -> T
where
F: FnOnce(&gix::Repository) -> T,
{
THREAD_LOCAL_REPOSITORIES.with(|repositories| {
let mut repositories = repositories.borrow_mut();
let repo = repositories.entry(self.key).or_insert_with(|| {
self.handle_creations.fetch_add(1, Ordering::Relaxed);
self.repo.to_thread_local()
});
op(repo)
})
}
fn cached_tree_entries(&self, tree: &TreeInfo) -> Result<Vec<TreeEntry>, StoreError> {
{
let mut cache = match self.caches.trees.lock() {
Ok(cache) => cache,
Err(poisoned) => poisoned.into_inner(),
};
if let Some(entries) = cache.get_cloned(&tree.oid) {
self.metrics.tree_cache_hits.fetch_add(1, Ordering::Relaxed);
return Ok(entries);
}
}
self.metrics
.tree_cache_misses
.fetch_add(1, Ordering::Relaxed);
self.metrics
.tree_backend_reads
.fetch_add(1, Ordering::Relaxed);
let entries = self.with_thread_local_repo(|repo| {
let object_id = tree.oid.to_gix()?;
let tree_object = repo
.find_tree(object_id)
.map_err(|source| StoreError::ReadTree {
oid: tree.oid.clone(),
source: anyhow::Error::new(source),
})?;
let decoded = tree_object
.decode()
.map_err(|source| StoreError::DecodeTree {
oid: tree.oid.clone(),
source,
})?;
Ok(decoded
.entries
.into_iter()
.map(|entry| TreeEntry {
name: entry.filename.iter().copied().collect(),
oid: GitObjectId::from_gix(entry.oid),
mode: u32::from(*entry.mode),
kind: GitObjectKind::from_entry_kind(entry.mode.kind()),
})
.collect::<Vec<_>>())
})?;
let mut cache = match self.caches.trees.lock() {
Ok(cache) => cache,
Err(poisoned) => poisoned.into_inner(),
};
let _ = cache.insert(tree.oid.clone(), entries.clone());
Ok(entries)
}
fn cached_small_blob(&self, oid: &GitObjectId) -> Option<Arc<[u8]>> {
let mut cache = match self.caches.small_blobs.lock() {
Ok(cache) => cache,
Err(poisoned) => poisoned.into_inner(),
};
cache.get_cloned(oid)
}
fn cache_small_blob(&self, oid: &GitObjectId, bytes: Arc<[u8]>) {
let mut cache = match self.caches.small_blobs.lock() {
Ok(cache) => cache,
Err(poisoned) => poisoned.into_inner(),
};
let _ = cache.insert(oid.clone(), bytes);
}
fn read_blob_len(&self, oid: &GitObjectId) -> Result<u64, StoreError> {
self.with_thread_local_repo(|repo| {
let object_id = oid.to_gix()?;
repo.find_header(object_id)
.map(|header| header.size())
.map_err(|source| StoreError::ReadBlob {
oid: oid.clone(),
source: anyhow::Error::new(source),
})
})
}
fn read_blob_bytes_uncached(&self, oid: &GitObjectId) -> Result<Vec<u8>, StoreError> {
self.metrics
.blob_backend_reads
.fetch_add(1, Ordering::Relaxed);
self.with_thread_local_repo(|repo| {
let object_id = oid.to_gix()?;
let mut blob = repo
.find_blob(object_id)
.map_err(|source| StoreError::ReadBlob {
oid: oid.clone(),
source: anyhow::Error::new(source),
})?;
Ok(blob.take_data())
})
}
fn read_cached_blob_bytes(&self, oid: &GitObjectId) -> Result<Arc<[u8]>, StoreError> {
if let Some(bytes) = self.cached_small_blob(oid) {
self.metrics.blob_cache_hits.fetch_add(1, Ordering::Relaxed);
return Ok(bytes);
}
self.metrics
.blob_cache_misses
.fetch_add(1, Ordering::Relaxed);
let bytes: Arc<[u8]> = Arc::from(self.read_blob_bytes_uncached(oid)?);
if bytes.len() <= self.cache_layout.small_blob_threshold {
self.cache_small_blob(oid, Arc::clone(&bytes));
}
Ok(bytes)
}
fn open_large_blob(&self, oid: &GitObjectId) -> Result<BlobReader, StoreError> {
self.metrics
.blob_streamed_opens
.fetch_add(1, Ordering::Relaxed);
let unique = self.temp_blob_counter.fetch_add(1, Ordering::Relaxed);
let temp_path = std::env::temp_dir().join(format!(
"timefs-blob-{}-{}-{unique}",
std::process::id(),
oid.as_hex()
));
let mut file = OpenOptions::new()
.write(true)
.read(true)
.create_new(true)
.open(&temp_path)
.map_err(|source| StoreError::ReadBlob {
oid: oid.clone(),
source: anyhow::Error::new(source),
})?;
let mut child = Command::new("git")
.arg("-C")
.arg(&self.repo_path)
.args(["cat-file", "blob", oid.as_hex()])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|source| StoreError::ReadBlob {
oid: oid.clone(),
source: anyhow::Error::new(source),
})?;
let copy_result = (|| -> Result<(), StoreError> {
let mut stdout = child.stdout.take().ok_or_else(|| StoreError::ReadBlob {
oid: oid.clone(),
source: anyhow::anyhow!("git cat-file did not provide a stdout pipe"),
})?;
std::io::copy(&mut stdout, &mut file).map_err(|source| StoreError::ReadBlob {
oid: oid.clone(),
source: anyhow::Error::new(source),
})?;
file.flush().map_err(|source| StoreError::ReadBlob {
oid: oid.clone(),
source: anyhow::Error::new(source),
})?;
Ok(())
})();
let mut stderr = Vec::new();
if let Some(mut pipe) = child.stderr.take() {
pipe.read_to_end(&mut stderr)
.map_err(|source| StoreError::ReadBlob {
oid: oid.clone(),
source: anyhow::Error::new(source),
})?;
}
let status = child.wait().map_err(|source| StoreError::ReadBlob {
oid: oid.clone(),
source: anyhow::Error::new(source),
})?;
if let Err(error) = copy_result {
let _ = fs::remove_file(&temp_path);
return Err(error);
}
if !status.success() {
let _ = fs::remove_file(&temp_path);
return Err(StoreError::ReadBlob {
oid: oid.clone(),
source: anyhow::anyhow!(
"git cat-file blob exited with {status}: {}",
String::from_utf8_lossy(&stderr).trim()
),
});
}
file.seek(SeekFrom::Start(0))
.map_err(|source| StoreError::ReadBlob {
oid: oid.clone(),
source: anyhow::Error::new(source),
})?;
Ok(Box::new(TempBlobFile {
file,
path: temp_path,
}))
}
fn lfs_pointer(&self, oid: &GitObjectId) -> Result<Option<LfsPointer>, StoreError> {
let len = self.read_blob_len(oid)?;
if len > MAX_LFS_POINTER_BYTES {
return Ok(None);
}
let bytes = self.read_cached_blob_bytes(oid)?;
Ok(parse_lfs_pointer(bytes.as_ref()))
}
fn locate_resolved_lfs_object(
&self,
oid: &GitObjectId,
) -> Result<Option<ResolvedLfsObject>, StoreError> {
if !self.lfs_enabled {
return Ok(None);
}
let Some(pointer) = self.lfs_pointer(oid)? else {
return Ok(None);
};
let path = self
.git_dir
.join("lfs")
.join("objects")
.join(&pointer.oid[..2])
.join(&pointer.oid[2..4])
.join(&pointer.oid);
let metadata = match fs::metadata(&path) {
Ok(metadata) => metadata,
Err(source) if source.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(source) => {
return Err(StoreError::ReadBlob {
oid: oid.clone(),
source: anyhow::Error::new(source),
});
}
};
Ok(Some(ResolvedLfsObject {
path,
size: metadata.len(),
}))
}
fn open_resolved_lfs_object(
&self,
oid: &GitObjectId,
resolved: ResolvedLfsObject,
) -> Result<BlobReader, StoreError> {
let file = File::open(&resolved.path).map_err(|source| StoreError::ReadBlob {
oid: oid.clone(),
source: anyhow::Error::new(source),
})?;
Ok(Box::new(file))
}
fn cached_revision(&self, key: &RevisionCacheKey) -> Option<CommitInfo> {
let mut cache = match self.caches.revisions.lock() {
Ok(cache) => cache,
Err(poisoned) => poisoned.into_inner(),
};
cache.get_cloned(key)
}
fn cache_revision(&self, key: RevisionCacheKey, commit: CommitInfo) {
let mut cache = match self.caches.revisions.lock() {
Ok(cache) => cache,
Err(poisoned) => poisoned.into_inner(),
};
cache.insert(key, commit);
}
fn capture_reference_snapshot(&self) -> Result<ReferenceSnapshot, StoreError> {
let head = self.resolve_head_live()?;
let references = self.list_references_live()?;
let mut revisions = Vec::new();
revisions.push(SnapshotRevision {
name: b"HEAD".to_vec(),
commit: head.clone(),
});
for reference in &references {
let commit = self.resolve_revision_live(&reference.full_name)?;
revisions.push(SnapshotRevision {
name: reference.full_name.clone(),
commit: commit.clone(),
});
if let Some(logical_name) = logical_reference_name(&reference.full_name) {
revisions.push(SnapshotRevision {
name: logical_name.to_vec(),
commit,
});
}
}
revisions.sort_by(|left, right| {
right
.name
.len()
.cmp(&left.name.len())
.then_with(|| left.name.cmp(&right.name))
});
revisions.dedup_by(|left, right| left.name == right.name);
Ok(ReferenceSnapshot {
head,
references,
revisions,
})
}
fn resolve_head_live(&self) -> Result<CommitInfo, StoreError> {
self.metrics
.revision_backend_resolves
.fetch_add(1, Ordering::Relaxed);
self.with_thread_local_repo(|repo| {
let commit = repo
.head_commit()
.map_err(|source| StoreError::ResolveHead {
source: anyhow::Error::new(source),
})?;
let time = commit.time().map_err(|source| StoreError::ReadCommit {
oid: GitObjectId::from_gix(commit.id.as_ref()),
source: anyhow::Error::new(source),
})?;
Ok(CommitInfo {
oid: GitObjectId::from_gix(commit.id.as_ref()),
committer_time_seconds: time.seconds,
})
})
}
fn resolve_head_uncached(&self) -> Result<CommitInfo, StoreError> {
if let Some(snapshot) = &self.reference_snapshot {
return Ok(snapshot.head.clone());
}
self.resolve_head_live()
}
fn resolve_revision_live(&self, spec: &[u8]) -> Result<CommitInfo, StoreError> {
self.metrics
.revision_backend_resolves
.fetch_add(1, Ordering::Relaxed);
self.with_thread_local_repo(|repo| {
let id = repo
.rev_parse_single(spec.as_bstr())
.map_err(|source| map_revision_error(repo, spec, source))?;
self.commit_info_from_id(spec, id)
})
}
fn resolve_revision_uncached(&self, spec: &[u8]) -> Result<CommitInfo, StoreError> {
let Some(snapshot) = &self.reference_snapshot else {
return self.resolve_revision_live(spec);
};
let (base, suffix) = split_revision_base(spec);
let commit = if let Some(commit) = snapshot.resolve_named_commit(base) {
commit
} else if looks_like_object_prefix(base) {
self.resolve_commit_prefix_live(base)?
} else {
return Err(StoreError::RevisionNotFound {
spec: display_spec(spec),
source: anyhow::anyhow!("not present in ref snapshot"),
});
};
if suffix.is_empty() {
return Ok(commit);
}
let mut rewritten = commit.oid.as_hex().as_bytes().to_vec();
rewritten.extend_from_slice(suffix);
self.resolve_revision_live(&rewritten)
}
fn resolve_commit_prefix_live(&self, prefix: &[u8]) -> Result<CommitInfo, StoreError> {
self.metrics
.revision_backend_resolves
.fetch_add(1, Ordering::Relaxed);
self.with_thread_local_repo(|repo| {
let parsed = gix::revision::Spec::from_bstr(
prefix.as_bstr(),
repo,
gix::revision::spec::parse::Options {
refs_hint: gix::revision::spec::parse::RefsHint::PreferObject,
object_kind_hint: Some(gix::revision::spec::parse::ObjectKindHint::Commit),
},
)
.map_err(|source| map_revision_error(repo, prefix, source))?;
let id = parsed
.single()
.ok_or(gix::revision::spec::parse::single::Error::RangedRev {
spec: prefix.as_bstr().to_owned(),
})
.map_err(|source| map_revision_error(repo, prefix, source))?;
self.commit_info_from_id(prefix, id)
})
}
fn resolve_commit_prefix_uncached(&self, prefix: &[u8]) -> Result<CommitInfo, StoreError> {
self.resolve_commit_prefix_live(prefix)
}
fn commit_info_from_id(&self, spec: &[u8], id: gix::Id<'_>) -> Result<CommitInfo, StoreError> {
let object = id.object().map_err(|source| StoreError::ReadCommit {
oid: GitObjectId::from_gix(id.as_ref()),
source: anyhow::Error::new(source),
})?;
let commit = object
.peel_to_kind(gix::object::Kind::Commit)
.map_err(|source| StoreError::ResolveRevision {
spec: display_spec(spec),
source: anyhow::Error::new(source),
})?
.into_commit();
let time = commit.time().map_err(|source| StoreError::ReadCommit {
oid: GitObjectId::from_gix(commit.id.as_ref()),
source: anyhow::Error::new(source),
})?;
Ok(CommitInfo {
oid: GitObjectId::from_gix(commit.id.as_ref()),
committer_time_seconds: time.seconds,
})
}
fn read_commit_info(&self, oid: &GitObjectId) -> Result<CommitInfo, StoreError> {
self.with_thread_local_repo(|repo| {
let object_id = oid.to_gix()?;
let commit = repo
.find_commit(object_id)
.map_err(|source| StoreError::ReadCommit {
oid: oid.clone(),
source: anyhow::Error::new(source),
})?;
let time = commit.time().map_err(|source| StoreError::ReadCommit {
oid: oid.clone(),
source: anyhow::Error::new(source),
})?;
Ok(CommitInfo {
oid: oid.clone(),
committer_time_seconds: time.seconds,
})
})
}
fn run_git_bytes<I, S>(&self, args: I, history_path: &[u8]) -> Result<Vec<u8>, StoreError>
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
let output = Command::new("git")
.arg("-C")
.arg(&self.repo_path)
.args(args)
.arg("--")
.arg(OsString::from_vec(history_path.to_vec()))
.output()
.map_err(|source| StoreError::HistoryLookup {
path: display_spec(history_path),
source: anyhow::Error::new(source),
})?;
if output.status.success() {
Ok(output.stdout)
} else {
Err(StoreError::HistoryLookup {
path: display_spec(history_path),
source: anyhow::anyhow!(
"git exited with {}: {}",
output.status,
String::from_utf8_lossy(&output.stderr).trim()
),
})
}
}
fn read_history_entries(&self, path: &[u8]) -> Result<Vec<HistoryEntry>, StoreError> {
let mut args = vec![
OsString::from("log"),
OsString::from("--follow"),
OsString::from("-z"),
OsString::from("--format=commit:%H"),
OsString::from("--name-status"),
OsString::from("--diff-filter=AMCRT"),
];
if let Some(snapshot) = &self.reference_snapshot {
args.push(OsString::from(snapshot.head.oid.as_hex()));
}
let stdout = self.run_git_bytes(args, path)?;
let mut tokens = stdout
.split(|byte| *byte == 0)
.filter(|token| !token.is_empty());
let mut entries = Vec::new();
let mut saw_exact_match = false;
while let Some(commit_token) = tokens.next() {
let Some(hex) = commit_token.strip_prefix(b"commit:") else {
continue;
};
let commit_hex =
String::from_utf8(hex.to_vec()).map_err(|source| StoreError::HistoryLookup {
path: display_spec(path),
source: anyhow::Error::new(source),
})?;
let commit_oid = GitObjectId(commit_hex);
let commit = self.read_commit_info(&commit_oid)?;
let Some(status_token) = tokens.next() else {
break;
};
let status = status_token.strip_prefix(b"\n").unwrap_or(status_token);
let Some(kind) = status.first().copied() else {
continue;
};
let Some(first_path) = tokens.next() else {
break;
};
let path_at_commit = match kind {
b'R' | b'C' => {
let Some(second_path) = tokens.next() else {
break;
};
second_path.to_vec()
}
_ => first_path.to_vec(),
};
if path_at_commit == path {
saw_exact_match = true;
}
entries.push(HistoryEntry {
commit,
path: path_at_commit,
});
}
entries.reverse();
if saw_exact_match {
Ok(entries)
} else {
Ok(Vec::new())
}
}
fn read_history_prefix_exists(&self, prefix: &[u8]) -> Result<bool, StoreError> {
let mut args = vec![
OsString::from("log"),
OsString::from("-z"),
OsString::from("--format="),
OsString::from("--name-only"),
];
if let Some(snapshot) = &self.reference_snapshot {
args.push(OsString::from(snapshot.head.oid.as_hex()));
}
let stdout = self.run_git_bytes(args, prefix)?;
let mut slash_prefix = prefix.to_vec();
slash_prefix.push(b'/');
Ok(stdout
.split(|byte| *byte == 0)
.map(|token| token.strip_prefix(b"\n").unwrap_or(token))
.any(|token| token.starts_with(&slash_prefix)))
}
}
impl ObjectStore for GixRepository {
fn open(path: &Path) -> Result<Self, StoreError> {
Self::open_with_cache_size_mb(path, DEFAULT_CACHE_SIZE_MB)
}
fn root_tree(&self, commit: &CommitInfo) -> Result<TreeInfo, StoreError> {
self.with_thread_local_repo(|repo| {
let object_id = commit.oid.to_gix()?;
let commit_object =
repo.find_commit(object_id)
.map_err(|source| StoreError::ReadCommit {
oid: commit.oid.clone(),
source: anyhow::Error::new(source),
})?;
let tree = commit_object
.tree()
.map_err(|source| StoreError::ReadCommit {
oid: commit.oid.clone(),
source: anyhow::Error::new(source),
})?;
Ok(TreeInfo {
oid: GitObjectId::from_gix(tree.id.as_ref()),
})
})
}
fn list_tree(&self, tree: &TreeInfo) -> Result<Vec<TreeEntry>, StoreError> {
self.cached_tree_entries(tree)
}
fn blob_len(&self, oid: &GitObjectId) -> Result<u64, StoreError> {
if let Some(resolved) = self.locate_resolved_lfs_object(oid)? {
return Ok(resolved.size);
}
self.read_blob_len(oid)
}
fn open_blob(&self, oid: &GitObjectId) -> Result<BlobReader, StoreError> {
if let Some(resolved) = self.locate_resolved_lfs_object(oid)? {
return self.open_resolved_lfs_object(oid, resolved);
}
let len = self.read_blob_len(oid)?;
if usize::try_from(len)
.ok()
.is_some_and(|size| size <= self.cache_layout.small_blob_threshold)
{
let bytes = self.read_cached_blob_bytes(oid)?;
return Ok(Box::new(Cursor::new(bytes)));
}
self.open_large_blob(oid)
}
fn read_symlink_target(&self, oid: &GitObjectId) -> Result<Vec<u8>, StoreError> {
Ok(self.read_cached_blob_bytes(oid)?.as_ref().to_vec())
}
fn lfs_state(&self, oid: &GitObjectId) -> Result<Option<LfsState>, StoreError> {
let Some(_) = self.lfs_pointer(oid)? else {
return Ok(None);
};
if self.locate_resolved_lfs_object(oid)?.is_some() {
Ok(Some(LfsState::Resolved))
} else {
Ok(Some(LfsState::Pointer))
}
}
fn open_submodule(
&self,
path: &[u8],
commit: &GitObjectId,
) -> Result<Option<OpenedSubmodule<Self>>, StoreError> {
let submodule_worktree = self.repo_path.join(OsString::from_vec(path.to_vec()));
let git_marker = submodule_worktree.join(".git");
let locatable = match git_marker.try_exists() {
Ok(locatable) => locatable,
Err(source) => {
return Err(StoreError::OpenRepository {
path: submodule_worktree.clone(),
source: anyhow::Error::new(source),
});
}
};
if !locatable {
return Ok(None);
}
let submodule_repo =
gix::ThreadSafeRepository::discover(&submodule_worktree).map_err(|source| {
StoreError::OpenRepository {
path: submodule_worktree.clone(),
source: anyhow::Error::new(source),
}
})?;
let store = Self::new(submodule_repo, self.cache_layout, self.lfs_enabled, false)?;
let pinned_commit = store.read_commit_info(commit)?;
let tree = store.root_tree(&pinned_commit)?;
Ok(Some(OpenedSubmodule {
store,
commit: pinned_commit,
tree,
}))
}
fn history_entries(&self, path: &[u8]) -> Result<Vec<HistoryEntry>, StoreError> {
self.read_history_entries(path)
}
fn history_prefix_exists(&self, prefix: &[u8]) -> Result<bool, StoreError> {
self.read_history_prefix_exists(prefix)
}
}
impl RevResolver for GixRepository {
fn resolve_head(&self) -> Result<CommitInfo, StoreError> {
let key = RevisionCacheKey::Head;
if let Some(commit) = self.cached_revision(&key) {
self.metrics
.revision_cache_hits
.fetch_add(1, Ordering::Relaxed);
return Ok(commit);
}
self.metrics
.revision_cache_misses
.fetch_add(1, Ordering::Relaxed);
let commit = self.resolve_head_uncached()?;
self.cache_revision(key, commit.clone());
Ok(commit)
}
fn resolve_revision(&self, spec: &[u8]) -> Result<CommitInfo, StoreError> {
let key = RevisionCacheKey::Revision(spec.to_vec());
if let Some(commit) = self.cached_revision(&key) {
self.metrics
.revision_cache_hits
.fetch_add(1, Ordering::Relaxed);
return Ok(commit);
}
self.metrics
.revision_cache_misses
.fetch_add(1, Ordering::Relaxed);
let commit = self.resolve_revision_uncached(spec)?;
self.cache_revision(key, commit.clone());
Ok(commit)
}
fn resolve_commit_prefix(&self, prefix: &[u8]) -> Result<CommitInfo, StoreError> {
let key = RevisionCacheKey::CommitPrefix(prefix.to_vec());
if let Some(commit) = self.cached_revision(&key) {
self.metrics
.revision_cache_hits
.fetch_add(1, Ordering::Relaxed);
return Ok(commit);
}
self.metrics
.revision_cache_misses
.fetch_add(1, Ordering::Relaxed);
let commit = self.resolve_commit_prefix_uncached(prefix)?;
self.cache_revision(key, commit.clone());
Ok(commit)
}
fn list_references(&self) -> Result<Vec<GitReference>, StoreError> {
if let Some(snapshot) = &self.reference_snapshot {
return Ok(snapshot.references.clone());
}
self.list_references_live()
}
}
impl GixRepository {
fn list_references_live(&self) -> Result<Vec<GitReference>, StoreError> {
self.with_thread_local_repo(|repo| {
let platform = repo
.references()
.map_err(|source| StoreError::ListReferences {
source: anyhow::Error::new(source),
})?;
let iter = platform
.all()
.map_err(|source| StoreError::ListReferences {
source: anyhow::Error::new(source),
})?;
let mut references = Vec::new();
for reference in iter {
let reference = reference.map_err(|source| StoreError::ListReferences {
source: anyhow::Error::msg(source.to_string()),
})?;
let name = reference.name().as_bstr().to_vec();
if name.starts_with(b"refs/heads/")
|| name.starts_with(b"refs/tags/")
|| name.starts_with(b"refs/remotes/")
{
references.push(GitReference { full_name: name });
}
}
references.sort_by(|left, right| left.full_name.cmp(&right.full_name));
Ok(references)
})
}
}
fn display_spec(spec: &[u8]) -> String {
String::from_utf8_lossy(spec).into_owned()
}
fn logical_reference_name(full_name: &[u8]) -> Option<&[u8]> {
full_name
.strip_prefix(b"refs/heads/")
.or_else(|| full_name.strip_prefix(b"refs/tags/"))
.or_else(|| full_name.strip_prefix(b"refs/remotes/"))
}
fn split_revision_base(spec: &[u8]) -> (&[u8], &[u8]) {
match spec.iter().position(|byte| *byte == b'^' || *byte == b'~') {
Some(index) => spec.split_at(index),
None => (spec, &[]),
}
}
fn looks_like_object_prefix(spec: &[u8]) -> bool {
spec.len() >= 4 && spec.iter().all(u8::is_ascii_hexdigit)
}
fn parse_lfs_pointer(bytes: &[u8]) -> Option<LfsPointer> {
let text = std::str::from_utf8(bytes).ok()?;
let mut saw_version = false;
let mut oid = None;
for line in text.lines() {
if line == "version https://git-lfs.github.com/spec/v1" {
saw_version = true;
continue;
}
if let Some(value) = line.strip_prefix("oid sha256:") {
if value.len() == 64 && value.as_bytes().iter().all(u8::is_ascii_hexdigit) {
oid = Some(value.to_ascii_lowercase());
} else {
return None;
}
}
}
saw_version.then_some(LfsPointer { oid: oid? })
}
fn map_revision_error(
repo: &gix::Repository,
spec: &[u8],
error: impl Into<gix::revision::spec::parse::single::Error>,
) -> StoreError {
use gix::revision::spec::parse::single::Error as SingleError;
use gix::revision::spec::parse::Error as ParseError;
let error = error.into();
let spec_text = display_spec(spec);
match error {
SingleError::RangedRev { spec } => StoreError::ResolveRevision {
spec: spec.to_string(),
source: anyhow::anyhow!("revision resolved to multiple objects"),
},
SingleError::Parse(parse_error) => match parse_error {
ParseError::PrefixNotFound { .. }
| ParseError::PathNotFound { .. }
| ParseError::SingleNotFound => StoreError::RevisionNotFound {
spec: spec_text,
source: anyhow::Error::new(parse_error),
},
ParseError::AncestorOutOfRange { .. } | ParseError::ParentOutOfRange { .. } => {
if repo.is_shallow() {
StoreError::RevisionNotFound {
spec: spec_text,
source: anyhow::anyhow!("repository is shallow"),
}
} else {
StoreError::RevisionNotFound {
spec: spec_text,
source: anyhow::Error::new(parse_error),
}
}
}
ParseError::AmbiguousPrefix { .. } | ParseError::AmbiguousRefAndObject { .. } => {
StoreError::AmbiguousRevision {
spec: spec_text,
source: anyhow::Error::new(parse_error),
}
}
ParseError::FindReference(_) => StoreError::RevisionNotFound {
spec: spec_text,
source: anyhow::Error::new(parse_error),
},
other => StoreError::ResolveRevision {
spec: spec_text,
source: anyhow::Error::new(other),
},
},
}
}
#[cfg(test)]
mod tests {
use super::{
GitObjectId, GitObjectKind, GitReference, GixRepository, ObjectStore, RevResolver,
TreeEntry, DEFAULT_SMALL_BLOB_MAX_BYTES,
};
use std::ffi::{OsStr, OsString};
use std::fs;
use std::io::Read;
use std::os::unix::ffi::OsStringExt;
use std::os::unix::fs::{symlink, PermissionsExt};
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::{Arc, Barrier};
use std::time::{SystemTime, UNIX_EPOCH};
#[test]
fn root_tree_listing_matches_git_ls_tree_head() {
let fixture = FixtureRepository::new();
let store = GixRepository::open(fixture.path()).expect("fixture repo should open");
let head = store.resolve_head().expect("HEAD should resolve");
let root = store.root_tree(&head).expect("root tree should resolve");
let actual = store.list_tree(&root).expect("tree listing should succeed");
assert_eq!(actual, fixture.git_ls_tree("HEAD"));
}
#[test]
fn blob_read_matches_git_show_byte_for_byte() {
let fixture = FixtureRepository::new();
let store = GixRepository::open(fixture.path()).expect("fixture repo should open");
let blob_oid = fixture.git_rev_parse("HEAD:dir/data.bin");
let mut reader = store.open_blob(&blob_oid).expect("blob should open");
let mut actual = Vec::new();
reader
.read_to_end(&mut actual)
.expect("blob reader should read to completion");
assert_eq!(actual, fixture.git_show("HEAD:dir/data.bin"));
}
#[test]
fn symlink_target_matches_git_blob_content() {
let fixture = FixtureRepository::new();
let store = GixRepository::open(fixture.path()).expect("fixture repo should open");
let link_oid = fixture.git_rev_parse("HEAD:link-to-script");
let actual = store
.read_symlink_target(&link_oid)
.expect("symlink target should be readable");
assert_eq!(actual, b"run.sh".to_vec());
}
#[test]
fn concurrent_reads_succeed_with_per_thread_handles() {
let fixture = FixtureRepository::new();
let store =
Arc::new(GixRepository::open(fixture.path()).expect("fixture repo should open"));
let blob_oid = fixture.git_rev_parse("HEAD:dir/data.bin");
let expected = fixture.git_show("HEAD:dir/data.bin");
let barrier = Arc::new(Barrier::new(5));
let before = store
.handle_creations
.load(std::sync::atomic::Ordering::Relaxed);
let threads: Vec<_> = (0..4)
.map(|_| {
let store = Arc::clone(&store);
let blob_oid = blob_oid.clone();
let barrier = Arc::clone(&barrier);
std::thread::spawn(move || {
barrier.wait();
let head = store.resolve_head()?;
let root = store.root_tree(&head)?;
let _ = store.list_tree(&root)?;
let mut reader = store.open_blob(&blob_oid)?;
let mut actual = Vec::new();
reader
.read_to_end(&mut actual)
.map_err(anyhow::Error::new)?;
Ok::<Vec<u8>, anyhow::Error>(actual)
})
})
.collect();
barrier.wait();
for thread in threads {
let actual = thread
.join()
.expect("reader thread should not panic")
.expect("reader thread should succeed");
assert_eq!(actual, expected);
}
let created = store
.handle_creations
.load(std::sync::atomic::Ordering::Relaxed)
- before;
assert!(
created >= 4,
"expected at least one thread-local handle per worker thread, observed {created}"
);
}
#[test]
fn revision_resolution_matches_git_rev_parse() {
let fixture = FixtureRepository::new();
let store = GixRepository::open(fixture.path()).expect("fixture repo should open");
let tag = store.resolve_revision(b"v1.0").expect("tag should resolve");
let branch = store
.resolve_revision(b"topic")
.expect("branch should resolve");
let parent = store
.resolve_revision(b"HEAD~1")
.expect("HEAD~1 should resolve");
assert_eq!(tag.oid, fixture.git_rev_parse_commit("v1.0"));
assert_eq!(branch.oid, fixture.git_rev_parse_commit("topic"));
assert_eq!(parent.oid, fixture.git_rev_parse_commit("HEAD~1"));
}
#[test]
fn abbreviated_commit_resolution_matches_git_rev_parse() {
let fixture = FixtureRepository::new();
let store = GixRepository::open(fixture.path()).expect("fixture repo should open");
let short = fixture.git_rev_parse_short("HEAD~1");
let commit = store
.resolve_commit_prefix(short.as_bytes())
.expect("short hash should resolve");
assert_eq!(commit.oid, fixture.git_rev_parse_commit("HEAD~1"));
}
#[test]
fn reference_enumeration_matches_git_for_each_ref() {
let fixture = FixtureRepository::new();
let store = GixRepository::open(fixture.path()).expect("fixture repo should open");
let actual = store
.list_references()
.expect("reference enumeration should succeed");
assert_eq!(
actual,
fixture
.git_for_each_ref()
.into_iter()
.map(|full_name| GitReference { full_name })
.collect::<Vec<_>>()
);
}
#[test]
fn parsed_tree_listings_hit_the_tree_cache_after_the_first_read() {
let fixture = FixtureRepository::new();
let store = GixRepository::open_with_cache_size_mb(fixture.path(), 1)
.expect("fixture repo should open");
let head = store.resolve_head().expect("HEAD should resolve");
let root = store.root_tree(&head).expect("root tree should resolve");
let before = snapshot_metrics(&store);
let first = store
.list_tree(&root)
.expect("first tree listing should succeed");
let after_first = snapshot_metrics(&store);
let second = store
.list_tree(&root)
.expect("second tree listing should succeed");
let after_second = snapshot_metrics(&store);
assert_eq!(first, second);
assert_eq!(
after_first.tree_backend_reads - before.tree_backend_reads,
1
);
assert_eq!(
after_second.tree_backend_reads,
after_first.tree_backend_reads
);
assert!(after_second.tree_cache_hits > after_first.tree_cache_hits);
}
#[test]
fn small_blob_reads_reuse_the_blob_cache_while_large_blobs_stream() {
let fixture = FixtureRepository::new();
let store = GixRepository::open_with_cache_size_mb(fixture.path(), 1)
.expect("fixture repo should open");
let small_blob = fixture.git_rev_parse("HEAD:README.md");
let large_blob = fixture.git_rev_parse("HEAD:large.bin");
let first_small = read_all(
store
.open_blob(&small_blob)
.expect("small blob should open"),
);
assert_eq!(first_small, fixture.git_show("HEAD:README.md"));
let after_small_first = snapshot_metrics(&store);
let second_small = read_all(
store
.open_blob(&small_blob)
.expect("small blob should reopen"),
);
assert_eq!(second_small, fixture.git_show("HEAD:README.md"));
let after_small_second = snapshot_metrics(&store);
assert_eq!(
after_small_second.blob_backend_reads,
after_small_first.blob_backend_reads
);
assert!(after_small_second.blob_cache_hits > after_small_first.blob_cache_hits);
let first_large = read_all(
store
.open_blob(&large_blob)
.expect("large blob should open"),
);
assert_eq!(first_large, fixture.git_show("HEAD:large.bin"));
let after_large_first = snapshot_metrics(&store);
let second_large = read_all(
store
.open_blob(&large_blob)
.expect("large blob should reopen"),
);
assert_eq!(second_large, fixture.git_show("HEAD:large.bin"));
let after_large_second = snapshot_metrics(&store);
assert_eq!(
after_large_second.blob_streamed_opens - after_large_first.blob_streamed_opens,
1
);
}
#[test]
fn revision_resolution_hits_the_short_ttl_cache() {
let fixture = FixtureRepository::new();
let store = GixRepository::open_with_cache_size_mb(fixture.path(), 1)
.expect("fixture repo should open");
let before = snapshot_metrics(&store);
let first = store
.resolve_revision(b"topic")
.expect("first revision resolution should succeed");
let after_first = snapshot_metrics(&store);
let second = store
.resolve_revision(b"topic")
.expect("second revision resolution should succeed");
let after_second = snapshot_metrics(&store);
assert_eq!(first, second);
assert_eq!(
after_first.revision_backend_resolves - before.revision_backend_resolves,
1
);
assert_eq!(
after_second.revision_backend_resolves,
after_first.revision_backend_resolves
);
assert!(after_second.revision_cache_hits > after_first.revision_cache_hits);
}
#[test]
fn object_caches_stay_within_the_configured_budget() {
let fixture = BulkFixtureRepository::new();
let store = GixRepository::open_with_cache_size_mb(fixture.path(), 1)
.expect("fixture repo should open");
let head = store.resolve_head().expect("HEAD should resolve");
let root = store.root_tree(&head).expect("root tree should resolve");
for entry in store
.list_tree(&root)
.expect("root tree listing should succeed")
{
if entry.kind == GitObjectKind::Blob {
let _ = read_all(store.open_blob(&entry.oid).expect("bulk blob should open"));
}
}
let metrics = snapshot_metrics(&store);
assert!(metrics.tree_cache_weight <= store.cache_layout.tree_cache_bytes);
assert!(metrics.blob_cache_weight <= store.cache_layout.blob_cache_bytes);
}
struct FixtureRepository {
root: PathBuf,
}
impl FixtureRepository {
fn new() -> Self {
let root = unique_temp_path("timefs-store-fixture");
fs::create_dir_all(root.join("dir"))
.expect("fixture directory creation should succeed");
let fixture = Self { root };
fixture.git(["init", "-b", "main"]);
fixture.git(["config", "user.name", "Timefs Tests"]);
fixture.git(["config", "user.email", "timefs-tests@example.com"]);
fs::write(fixture.root.join("README.md"), b"timefs fixture\n")
.expect("README write should succeed");
fs::write(fixture.root.join("dir/data.bin"), [0_u8, 1, 2, 3, 0, 255])
.expect("binary write should succeed");
let large_blob: Vec<u8> = (0..(DEFAULT_SMALL_BLOB_MAX_BYTES * 2))
.map(|index| u8::try_from(index % 251).expect("modulus keeps the byte in range"))
.collect();
fs::write(fixture.root.join("large.bin"), large_blob)
.expect("large blob write should succeed");
fs::write(fixture.root.join("run.sh"), b"#!/bin/sh\necho timefs\n")
.expect("script write should succeed");
symlink("run.sh", fixture.root.join("link-to-script"))
.expect("symlink creation should succeed");
let mut permissions = fs::metadata(fixture.root.join("run.sh"))
.expect("script metadata should be readable")
.permissions();
permissions.set_mode(0o755);
fs::set_permissions(fixture.root.join("run.sh"), permissions)
.expect("script permissions should be settable");
fixture.git(["add", "."]);
fixture.git(["commit", "-m", "Create fixture repository"]);
fixture.git(["tag", "v1.0"]);
fs::write(fixture.root.join("README.md"), b"timefs fixture v2\n")
.expect("updated README write should succeed");
fs::write(fixture.root.join("topic.txt"), b"branch and tag fixture\n")
.expect("topic marker write should succeed");
fixture.git(["add", "."]);
fixture.git(["commit", "-m", "Update main branch"]);
fixture.git(["branch", "topic"]);
fixture.git(["branch", "feature/x"]);
fs::write(fixture.root.join("later.txt"), b"current HEAD\n")
.expect("later marker write should succeed");
fixture.git(["add", "."]);
fixture.git(["commit", "-m", "Advance HEAD"]);
fixture
}
fn path(&self) -> &Path {
&self.root
}
fn git<I, S>(&self, args: I) -> Output
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
let output = Command::new("git")
.args(args)
.current_dir(&self.root)
.output()
.expect("git should be executable in tests");
if output.status.success() {
output
} else {
panic!(
"git command failed with status {}: {}",
output.status,
String::from_utf8_lossy(&output.stderr)
);
}
}
fn git_ls_tree(&self, rev: &str) -> Vec<TreeEntry> {
parse_ls_tree_entries(&self.git(["ls-tree", "-z", rev]).stdout)
}
fn git_rev_parse(&self, spec: &str) -> GitObjectId {
let output = self.git(["rev-parse", spec]);
let hex = String::from_utf8(output.stdout)
.expect("rev-parse output should be valid UTF-8")
.trim()
.to_owned();
GitObjectId(hex)
}
fn git_rev_parse_commit(&self, spec: &str) -> GitObjectId {
self.git_rev_parse(&format!("{spec}^{{commit}}"))
}
fn git_rev_parse_short(&self, spec: &str) -> String {
let output = self.git(["rev-parse", "--short", spec]);
String::from_utf8(output.stdout)
.expect("short rev-parse output should be valid UTF-8")
.trim()
.to_owned()
}
fn git_show(&self, spec: &str) -> Vec<u8> {
self.git(["show", spec]).stdout
}
fn git_for_each_ref(&self) -> Vec<Vec<u8>> {
self.git([
"for-each-ref",
"--format=%(refname)",
"refs/heads",
"refs/tags",
"refs/remotes",
])
.stdout
.split(|byte| *byte == b'\n')
.filter(|line| !line.is_empty())
.map(|line| line.to_vec())
.collect()
}
}
impl Drop for FixtureRepository {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.root);
}
}
struct BulkFixtureRepository {
root: PathBuf,
}
impl BulkFixtureRepository {
fn new() -> Self {
let root = unique_temp_path("timefs-store-bulk-fixture");
fs::create_dir_all(&root).expect("fixture directory creation should succeed");
let fixture = Self { root };
fixture.git(["init", "-b", "main"]);
fixture.git(["config", "user.name", "Timefs Tests"]);
fixture.git(["config", "user.email", "timefs-tests@example.com"]);
for index in 0..128_usize {
let path = fixture.root.join(format!("blob-{index:03}.bin"));
let contents: Vec<u8> = (0..16_384_usize)
.map(|offset| {
u8::try_from((index + offset) % 251)
.expect("modulus keeps the byte in range")
})
.collect();
fs::write(path, contents).expect("bulk blob write should succeed");
}
fixture.git(["add", "."]);
fixture.git(["commit", "-m", "Create bulk cache fixture"]);
fixture
}
fn path(&self) -> &Path {
&self.root
}
fn git<I, S>(&self, args: I) -> Output
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
let output = Command::new("git")
.args(args)
.current_dir(&self.root)
.output()
.expect("git should be executable in tests");
if output.status.success() {
output
} else {
panic!(
"git command failed with status {}: {}",
output.status,
String::from_utf8_lossy(&output.stderr)
);
}
}
}
impl Drop for BulkFixtureRepository {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.root);
}
}
#[derive(Clone, Copy, Debug)]
struct MetricSnapshot {
tree_cache_hits: u64,
tree_backend_reads: u64,
blob_cache_hits: u64,
blob_backend_reads: u64,
blob_streamed_opens: u64,
revision_cache_hits: u64,
revision_backend_resolves: u64,
tree_cache_weight: usize,
blob_cache_weight: usize,
}
fn snapshot_metrics(store: &GixRepository) -> MetricSnapshot {
let tree_cache_weight = match store.caches.trees.lock() {
Ok(cache) => cache.current_weight(),
Err(poisoned) => poisoned.into_inner().current_weight(),
};
let blob_cache_weight = match store.caches.small_blobs.lock() {
Ok(cache) => cache.current_weight(),
Err(poisoned) => poisoned.into_inner().current_weight(),
};
MetricSnapshot {
tree_cache_hits: store
.metrics
.tree_cache_hits
.load(std::sync::atomic::Ordering::Relaxed),
tree_backend_reads: store
.metrics
.tree_backend_reads
.load(std::sync::atomic::Ordering::Relaxed),
blob_cache_hits: store
.metrics
.blob_cache_hits
.load(std::sync::atomic::Ordering::Relaxed),
blob_backend_reads: store
.metrics
.blob_backend_reads
.load(std::sync::atomic::Ordering::Relaxed),
blob_streamed_opens: store
.metrics
.blob_streamed_opens
.load(std::sync::atomic::Ordering::Relaxed),
revision_cache_hits: store
.metrics
.revision_cache_hits
.load(std::sync::atomic::Ordering::Relaxed),
revision_backend_resolves: store
.metrics
.revision_backend_resolves
.load(std::sync::atomic::Ordering::Relaxed),
tree_cache_weight,
blob_cache_weight,
}
}
fn read_all(mut reader: super::BlobReader) -> Vec<u8> {
let mut actual = Vec::new();
reader
.read_to_end(&mut actual)
.expect("blob reader should read to completion");
actual
}
fn parse_ls_tree_entries(output: &[u8]) -> Vec<TreeEntry> {
output
.split(|byte| *byte == 0)
.filter(|record| !record.is_empty())
.map(parse_ls_tree_entry)
.collect()
}
fn parse_ls_tree_entry(record: &[u8]) -> TreeEntry {
let tab = record
.iter()
.position(|byte| *byte == b'\t')
.expect("ls-tree records contain a tab separator");
let (header, name) = record.split_at(tab);
let name = name[1..].to_vec();
let header = std::str::from_utf8(header).expect("ls-tree headers are valid UTF-8");
let mut parts = header.split_whitespace();
let mode = u32::from_str_radix(parts.next().expect("mode should be present"), 8)
.expect("mode should be valid octal");
let kind = match parts.next().expect("kind should be present") {
"tree" => GitObjectKind::Tree,
"blob" => GitObjectKind::Blob,
"commit" => GitObjectKind::Commit,
other => panic!("unexpected git ls-tree kind: {other}"),
};
let oid = GitObjectId(parts.next().expect("oid should be present").to_owned());
TreeEntry {
name,
oid,
mode,
kind,
}
}
fn unique_temp_path(prefix: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time should be after the Unix epoch")
.as_nanos();
let mut path = std::env::temp_dir();
let suffix = format!("{prefix}-{nanos}");
path.push(OsString::from_vec(suffix.into_bytes()));
path
}
}