use std::collections::HashSet;
use std::ffi::OsStr;
use std::io::ErrorKind;
use std::ops::Deref;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Mutex, MutexGuard, PoisonError};
use std::time::{Duration, Instant};
use git2::{ErrorCode, Reference, Repository};
use super::divergence::Divergence;
pub(super) fn divergence(pool: &RepoPool, folder: &Path) -> Option<Divergence> {
divergence_at(pool, locate(folder))
}
fn divergence_at(pool: &RepoPool, located: Located) -> Option<Divergence> {
let location = match located {
Located::NotARepo => return Some(Divergence::default()),
Located::Unclear => return None,
Located::Repo(location) => location,
};
pool.with_repo(&location.commondir, |repo| {
divergence_in(repo, location.worktree.as_deref())
})?
}
fn divergence_in(repo: &Repository, worktree: Option<&str>) -> Option<Divergence> {
match head_of(repo, worktree) {
Head::Ref(head) => Some(super::head_divergence(repo, Some(head))),
Head::Unborn => Some(super::head_divergence(repo, None)),
Head::Unreadable => None,
}
}
#[derive(Debug, PartialEq, Eq)]
struct Location {
commondir: PathBuf,
worktree: Option<String>,
}
#[derive(Debug, PartialEq, Eq)]
enum Located {
NotARepo,
Unclear,
Repo(Location),
}
fn locate(folder: &Path) -> Located {
match Repository::discover_path(folder, std::iter::empty::<&OsStr>()) {
Ok(gitdir) => locate_in(&gitdir),
Err(_) => Located::NotARepo,
}
}
fn locate_in(gitdir: &Path) -> Located {
match std::fs::read_to_string(gitdir.join("commondir")) {
Ok(relative) => {
let name = gitdir.file_name().and_then(OsStr::to_str);
match (name, std::fs::canonicalize(gitdir.join(relative.trim()))) {
(Some(name), Ok(commondir)) => Located::Repo(Location {
commondir,
worktree: Some(name.to_string()),
}),
_ => Located::Unclear,
}
}
Err(e) if e.kind() == ErrorKind::NotFound => {
std::fs::canonicalize(gitdir).map_or(Located::Unclear, |commondir| {
Located::Repo(Location {
commondir,
worktree: None,
})
})
}
Err(_) => Located::Unclear,
}
}
enum Head<'r> {
Ref(Reference<'r>),
Unborn,
Unreadable,
}
fn head_from(resolved: Result<Reference<'_>, git2::Error>) -> Head<'_> {
match resolved {
Ok(head) => Head::Ref(head),
Err(e) if matches!(e.code(), ErrorCode::UnbornBranch | ErrorCode::NotFound) => Head::Unborn,
Err(_) => Head::Unreadable,
}
}
fn head_of<'r>(repo: &'r Repository, worktree: Option<&str>) -> Head<'r> {
let Some(name) = worktree else {
return head_from(repo.head());
};
match repo.find_reference(&format!("worktrees/{name}/HEAD")) {
Ok(pseudo) => head_from(pseudo.resolve()),
Err(_) => Head::Unreadable,
}
}
fn config_differs_per_worktree(repo: &Repository) -> bool {
let Ok(config) = repo.config() else {
return true;
};
if config
.get_bool("extensions.worktreeConfig")
.unwrap_or(false)
{
return true;
}
let mut conditional = false;
let walked = config
.entries(Some(r"^includeif\."))
.and_then(|entries| entries.for_each(|_| conditional = true));
walked.is_err() || conditional
}
const MAX_HANDLE_AGE: Duration = Duration::from_secs(30);
pub(super) struct RepoPool {
limit: usize,
max_age: Duration,
state: Mutex<PoolState>,
opens: AtomicUsize,
}
struct Idle {
commondir: PathBuf,
opened: Instant,
repo: Repository,
}
#[derive(Default)]
struct PoolState {
idle: Vec<Idle>,
in_use: usize,
refused: HashSet<PathBuf>,
}
impl PoolState {
fn evict_to_fit(&mut self, limit: usize) -> Vec<Repository> {
let excess = (self.in_use + self.idle.len())
.saturating_sub(limit)
.min(self.idle.len());
self.idle.drain(..excess).map(|idle| idle.repo).collect()
}
fn drain_aged(&mut self, max_age: Duration) -> Vec<Repository> {
let (aged, fresh): (Vec<_>, Vec<_>) = std::mem::take(&mut self.idle)
.into_iter()
.partition(|idle| idle.opened.elapsed() >= max_age);
self.idle = fresh;
aged.into_iter().map(|idle| idle.repo).collect()
}
}
impl RepoPool {
pub(super) fn new(limit: usize) -> Self {
Self::with_max_age(limit, MAX_HANDLE_AGE)
}
fn with_max_age(limit: usize, max_age: Duration) -> Self {
Self {
limit,
max_age,
state: Mutex::new(PoolState::default()),
opens: AtomicUsize::new(0),
}
}
pub(super) fn with_repo<R>(
&self,
commondir: &Path,
f: impl FnOnce(&Repository) -> R,
) -> Option<R> {
let lease = self.lease(commondir)?;
let result = f(&lease);
lease.give_back();
Some(result)
}
fn lease(&self, commondir: &Path) -> Option<Lease<'_>> {
let (reused, evicted) = {
let mut state = self.lock();
if state.refused.contains(commondir) {
return None;
}
state.in_use += 1;
let mut evicted = state.drain_aged(self.max_age);
let reused = state
.idle
.iter()
.position(|idle| idle.commondir == commondir)
.map(|at| state.idle.remove(at));
if reused.is_none() {
evicted.extend(state.evict_to_fit(self.limit));
}
(reused, evicted)
};
drop(evicted);
let slot = Slot {
pool: self,
armed: true,
};
let (opened, repo) = match reused {
Some(idle) if !idle.repo.is_shallow() => (idle.opened, idle.repo),
Some(_) => {
self.refuse(commondir);
return None;
}
None => (Instant::now(), self.open(commondir)?),
};
Some(Lease {
slot,
commondir: commondir.to_path_buf(),
opened,
repo,
})
}
fn open(&self, commondir: &Path) -> Option<Repository> {
self.opens.fetch_add(1, Ordering::Relaxed);
let opened = Repository::open(commondir)
.ok()
.filter(|repo| !config_differs_per_worktree(repo) && !repo.is_shallow());
if opened.is_none() {
self.refuse(commondir);
}
opened
}
fn refuse(&self, commondir: &Path) {
self.lock().refused.insert(commondir.to_path_buf());
}
fn lock(&self) -> MutexGuard<'_, PoolState> {
self.state.lock().unwrap_or_else(PoisonError::into_inner)
}
#[cfg(test)]
pub(super) fn opens(&self) -> usize {
self.opens.load(Ordering::Relaxed)
}
#[cfg(test)]
fn live(&self) -> usize {
let state = self.lock();
state.in_use + state.idle.len()
}
}
struct Slot<'p> {
pool: &'p RepoPool,
armed: bool,
}
impl Slot<'_> {
fn disarm(mut self) {
self.armed = false;
}
}
impl Drop for Slot<'_> {
fn drop(&mut self) {
if self.armed {
let mut state = self.pool.lock();
state.in_use = state.in_use.saturating_sub(1);
}
}
}
struct Lease<'p> {
slot: Slot<'p>,
commondir: PathBuf,
opened: Instant,
repo: Repository,
}
impl Deref for Lease<'_> {
type Target = Repository;
fn deref(&self) -> &Repository {
&self.repo
}
}
impl Lease<'_> {
fn give_back(self) {
let Lease {
slot,
commondir,
opened,
repo,
} = self;
let evicted = {
let mut state = slot.pool.lock();
state.in_use = state.in_use.saturating_sub(1);
state.idle.push(Idle {
commondir,
opened,
repo,
});
state.evict_to_fit(slot.pool.limit)
};
slot.disarm();
drop(evicted);
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use std::panic::{catch_unwind, AssertUnwindSafe};
use super::*;
fn signature() -> git2::Signature<'static> {
git2::Signature::now("Test", "test@example.com").unwrap()
}
fn repo_with_commit(dir: &Path) -> Repository {
let repo = Repository::init(dir).unwrap();
{
let tree = repo
.find_tree(repo.treebuilder(None).unwrap().write().unwrap())
.unwrap();
let sig = signature();
repo.commit(Some("refs/heads/main"), &sig, &sig, "A", &tree, &[])
.unwrap();
}
repo.set_head("refs/heads/main").unwrap();
repo
}
fn add_linked(repo: &Repository, name: &str, path: &Path) {
let tip = repo.head().unwrap().peel_to_commit().unwrap();
repo.branch(name, &tip, false).unwrap();
let reference = repo.find_reference(&format!("refs/heads/{name}")).unwrap();
let mut opts = git2::WorktreeAddOptions::new();
opts.reference(Some(&reference));
repo.worktree(name, path, Some(&opts)).unwrap();
}
fn commondir_of(repo: &Repository) -> PathBuf {
std::fs::canonicalize(repo.path()).unwrap()
}
#[test]
fn locate_names_the_main_checkout_and_a_linked_worktree() {
let main_dir = tempfile::tempdir().unwrap();
let repo = repo_with_commit(main_dir.path());
let wt_dir = tempfile::tempdir().unwrap();
let wt = wt_dir.path().join("feature");
add_linked(&repo, "feature", &wt);
let commondir = commondir_of(&repo);
assert_eq!(
locate(main_dir.path()),
Located::Repo(Location {
commondir: commondir.clone(),
worktree: None
})
);
let linked = Located::Repo(Location {
commondir,
worktree: Some("feature".to_string()),
});
assert_eq!(locate(&wt), linked);
std::fs::create_dir(wt.join("sub")).unwrap();
assert_eq!(locate(&wt.join("sub")), linked);
}
#[test]
fn locate_reports_a_folder_outside_any_repository() {
let plain = tempfile::tempdir().unwrap();
assert_eq!(locate(plain.path()), Located::NotARepo);
assert_eq!(locate(&plain.path().join("missing")), Located::NotARepo);
}
#[test]
fn locate_in_is_unclear_when_the_layout_cannot_be_read() {
let gitdir = tempfile::tempdir().unwrap();
std::fs::create_dir(gitdir.path().join("commondir")).unwrap();
assert_eq!(locate_in(gitdir.path()), Located::Unclear);
let dangling = tempfile::tempdir().unwrap();
std::fs::write(dangling.path().join("commondir"), "../does-not-exist\n").unwrap();
assert_eq!(locate_in(dangling.path()), Located::Unclear);
assert_eq!(locate_in(&gitdir.path().join("missing")), Located::Unclear);
}
#[test]
fn head_of_reads_every_head_shape_through_the_common_dir() {
let main_dir = tempfile::tempdir().unwrap();
let repo = repo_with_commit(main_dir.path());
let wts = tempfile::tempdir().unwrap();
for name in ["attached", "detached", "unborn"] {
add_linked(&repo, name, &wts.path().join(name));
}
let tip = repo.head().unwrap().target().unwrap();
Repository::open(wts.path().join("detached"))
.unwrap()
.set_head_detached(tip)
.unwrap();
Repository::open(wts.path().join("unborn"))
.unwrap()
.set_head("refs/heads/not-yet")
.unwrap();
let Head::Ref(main) = head_of(&repo, None) else {
panic!("the main HEAD should resolve");
};
assert_eq!(main.name(), Ok("refs/heads/main"));
let Head::Ref(attached) = head_of(&repo, Some("attached")) else {
panic!("an attached HEAD should resolve");
};
assert_eq!(attached.name(), Ok("refs/heads/attached"));
let Head::Ref(detached) = head_of(&repo, Some("detached")) else {
panic!("a detached HEAD should resolve");
};
assert!(!detached.is_branch());
assert_eq!(detached.target(), Some(tip));
assert!(matches!(head_of(&repo, Some("unborn")), Head::Unborn));
assert!(matches!(head_of(&repo, Some("ghost")), Head::Unreadable));
let empty_dir = tempfile::tempdir().unwrap();
let empty = Repository::init(empty_dir.path()).unwrap();
assert!(matches!(head_of(&empty, None), Head::Unborn));
}
#[test]
fn a_pool_is_usable_from_several_threads() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<RepoPool>();
}
#[test]
fn a_handle_is_reused_for_the_same_common_dir() {
let dir = tempfile::tempdir().unwrap();
let repo = repo_with_commit(dir.path());
let pool = RepoPool::new(4);
for _ in 0..5 {
assert_eq!(pool.with_repo(&commondir_of(&repo), |_| ()), Some(()));
}
assert_eq!(pool.opens(), 1);
assert_eq!(pool.live(), 1);
}
#[test]
fn live_handles_never_exceed_the_limit() {
let dirs: Vec<_> = (0..3).map(|_| tempfile::tempdir().unwrap()).collect();
let commondirs: Vec<_> = dirs
.iter()
.map(|dir| commondir_of(&repo_with_commit(dir.path())))
.collect();
let pool = RepoPool::new(2);
pool.with_repo(&commondirs[0], |_| ()).unwrap();
pool.with_repo(&commondirs[1], |_| ()).unwrap();
assert_eq!(pool.live(), 2);
pool.with_repo(&commondirs[2], |_| assert_eq!(pool.live(), 2))
.unwrap();
assert_eq!(pool.live(), 2);
assert_eq!(pool.opens(), 3);
pool.with_repo(&commondirs[2], |_| ()).unwrap();
assert_eq!(pool.opens(), 3);
pool.with_repo(&commondirs[0], |_| ()).unwrap();
assert_eq!(pool.opens(), 4);
assert_eq!(pool.live(), 2);
}
#[test]
fn a_failed_open_frees_its_capacity_and_is_not_retried() {
let dir = tempfile::tempdir().unwrap();
let pool = RepoPool::new(1);
assert!(pool
.with_repo(&dir.path().join("missing"), |_| ())
.is_none());
assert!(pool
.with_repo(&dir.path().join("missing"), |_| ())
.is_none());
assert_eq!(pool.live(), 0);
assert_eq!(pool.opens(), 1);
}
#[test]
fn a_panicking_computation_frees_its_capacity() {
let dir = tempfile::tempdir().unwrap();
let repo = repo_with_commit(dir.path());
let pool = RepoPool::new(1);
let outcome = catch_unwind(AssertUnwindSafe(|| {
pool.with_repo(&commondir_of(&repo), |_| panic!("computation failed"))
}));
assert!(outcome.is_err());
assert_eq!(pool.live(), 0);
assert!(pool.with_repo(&commondir_of(&repo), |_| ()).is_some());
}
#[test]
fn a_repo_whose_config_can_differ_per_worktree_is_refused_once() {
for (key, value) in [
("extensions.worktreeConfig", "true"),
("includeIf.onbranch:feature/**.path", "extra.cfg"),
("includeIf.gitdir:/somewhere/.path", "extra.cfg"),
] {
let dir = tempfile::tempdir().unwrap();
let repo = repo_with_commit(dir.path());
repo.config().unwrap().set_str(key, value).unwrap();
let pool = RepoPool::new(4);
assert!(
pool.with_repo(&commondir_of(&repo), |_| ()).is_none(),
"{key}"
);
assert!(
pool.with_repo(&commondir_of(&repo), |_| ()).is_none(),
"{key}"
);
assert_eq!(pool.opens(), 1, "{key}");
assert_eq!(pool.live(), 0, "{key}");
}
}
#[test]
fn an_unconditional_include_does_not_stop_a_repo_being_shared() {
let dir = tempfile::tempdir().unwrap();
let repo = repo_with_commit(dir.path());
repo.config()
.unwrap()
.set_str("include.path", "extra.cfg")
.unwrap();
let pool = RepoPool::new(4);
assert_eq!(pool.with_repo(&commondir_of(&repo), |_| ()), Some(()));
}
#[test]
fn a_handle_is_not_reused_once_it_reaches_its_age_limit() {
let dir = tempfile::tempdir().unwrap();
let repo = repo_with_commit(dir.path());
let pool = RepoPool::with_max_age(4, Duration::ZERO);
for _ in 0..3 {
assert_eq!(pool.with_repo(&commondir_of(&repo), |_| ()), Some(()));
}
assert_eq!(pool.opens(), 3);
assert_eq!(pool.live(), 1);
}
#[test]
fn reuse_does_not_renew_a_handles_age() {
let dir = tempfile::tempdir().unwrap();
let repo = repo_with_commit(dir.path());
let pool = RepoPool::new(4);
let commondir = commondir_of(&repo);
pool.with_repo(&commondir, |_| ()).unwrap();
let opened = pool.lock().idle[0].opened;
pool.with_repo(&commondir, |_| ()).unwrap();
assert_eq!(pool.opens(), 1);
assert_eq!(pool.lock().idle[0].opened, opened);
}
#[test]
fn only_a_missing_commit_is_an_empty_head() {
let error = |code| git2::Error::new(code, git2::ErrorClass::Reference, "test");
assert!(matches!(
head_from(Err(error(ErrorCode::UnbornBranch))),
Head::Unborn
));
assert!(matches!(
head_from(Err(error(ErrorCode::NotFound))),
Head::Unborn
));
for code in [
ErrorCode::GenericError,
ErrorCode::Locked,
ErrorCode::Invalid,
] {
assert!(matches!(head_from(Err(error(code))), Head::Unreadable));
}
}
fn two_commits(dir: &Path) -> (Repository, git2::Oid, git2::Oid) {
let repo = repo_with_commit(dir);
let a = repo.head().unwrap().target().unwrap();
let b = {
let parent = repo.find_commit(a).unwrap();
let tree = repo
.find_tree(repo.treebuilder(None).unwrap().write().unwrap())
.unwrap();
let sig = signature();
repo.commit(Some("refs/heads/main"), &sig, &sig, "B", &tree, &[&parent])
.unwrap()
};
(repo, a, b)
}
#[test]
fn libgit2_reads_shallow_grafts_when_a_repository_is_opened() {
let dir = tempfile::tempdir().unwrap();
let (repo, a, b) = two_commits(dir.path());
let opened_before = Repository::open(repo.path()).unwrap();
std::fs::write(repo.path().join("shallow"), format!("{b}\n")).unwrap();
let opened_after = Repository::open(repo.path()).unwrap();
assert!(opened_before.is_shallow() && opened_after.is_shallow());
assert_eq!(opened_before.graph_ahead_behind(b, a).unwrap(), (1, 0));
assert_eq!(opened_after.graph_ahead_behind(b, a).unwrap(), (1, 1));
}
#[test]
fn a_shallow_repo_is_not_served() {
let dir = tempfile::tempdir().unwrap();
let (repo, _, b) = two_commits(dir.path());
std::fs::write(repo.path().join("shallow"), format!("{b}\n")).unwrap();
let pool = RepoPool::new(4);
assert!(pool.with_repo(&commondir_of(&repo), |_| ()).is_none());
assert!(pool.with_repo(&commondir_of(&repo), |_| ()).is_none());
assert_eq!(pool.opens(), 1);
assert_eq!(pool.live(), 0);
}
#[test]
fn a_repo_that_becomes_shallow_is_no_longer_served() {
let dir = tempfile::tempdir().unwrap();
let (repo, _, b) = two_commits(dir.path());
let pool = RepoPool::new(4);
assert_eq!(pool.with_repo(&commondir_of(&repo), |_| ()), Some(()));
std::fs::write(repo.path().join("shallow"), format!("{b}\n")).unwrap();
assert!(pool.with_repo(&commondir_of(&repo), |_| ()).is_none());
assert_eq!(pool.live(), 0);
assert_eq!(pool.opens(), 1, "declined without reopening");
}
#[test]
fn whatever_cannot_be_read_exactly_defers_to_the_discover_path() {
let dir = tempfile::tempdir().unwrap();
let repo = repo_with_commit(dir.path());
let pool = RepoPool::new(4);
let at = |worktree: Option<&str>, commondir: PathBuf| {
divergence_at(
&pool,
Located::Repo(Location {
commondir,
worktree: worktree.map(str::to_string),
}),
)
};
assert_eq!(divergence_at(&pool, Located::Unclear), None);
assert_eq!(at(None, dir.path().join("missing")), None);
assert_eq!(at(Some("ghost"), commondir_of(&repo)), None);
assert_eq!(
divergence_at(&pool, Located::NotARepo),
Some(Divergence::default())
);
let empty_dir = tempfile::tempdir().unwrap();
let empty = Repository::init(empty_dir.path()).unwrap();
assert_eq!(at(None, commondir_of(&empty)), Some(Divergence::default()));
}
}