use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, LazyLock, Mutex, PoisonError, Weak};
use git2::{Oid, Repository};
use tokio::sync::{OnceCell, Semaphore};
use super::shared_repo::RepoPool;
const MAX_CONCURRENT_COMPUTATIONS: usize = 4;
const WALK_MEMO_CAPACITY: usize = 4096;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(super) struct Divergence {
pub(super) ahead_behind: Option<(usize, usize)>,
pub(super) main_behind: Option<usize>,
pub(super) shallow: bool,
}
type WalkKey = (PathBuf, Oid, Oid);
struct WalkMemo {
capacity: usize,
entries: Mutex<HashMap<WalkKey, (usize, usize)>>,
}
impl WalkMemo {
fn with_capacity(capacity: usize) -> Self {
Self {
capacity,
entries: Mutex::new(HashMap::new()),
}
}
fn get_or_walk(
&self,
key: WalkKey,
walk: impl FnOnce() -> Option<(usize, usize)>,
) -> Option<(usize, usize)> {
if let Some(hit) = self.lock().get(&key) {
return Some(*hit);
}
let counts = walk()?;
let mut entries = self.lock();
if entries.len() >= self.capacity {
entries.clear();
}
entries.insert(key, counts);
Some(counts)
}
fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<WalkKey, (usize, usize)>> {
self.entries.lock().unwrap_or_else(PoisonError::into_inner)
}
#[cfg(test)]
fn len(&self) -> usize {
self.lock().len()
}
}
static WALKS: LazyLock<WalkMemo> = LazyLock::new(|| WalkMemo::with_capacity(WALK_MEMO_CAPACITY));
pub(super) fn graph_ahead_behind(
repo: &Repository,
local: Oid,
upstream: Oid,
) -> Option<(usize, usize)> {
graph_ahead_behind_in(&WALKS, repo, local, upstream)
}
fn graph_ahead_behind_in(
memo: &WalkMemo,
repo: &Repository,
local: Oid,
upstream: Oid,
) -> Option<(usize, usize)> {
if is_shallow(repo) {
return walk_shallow(repo, local, upstream);
}
let key = (repo.commondir().to_path_buf(), local, upstream);
memo.get_or_walk(key, || repo.graph_ahead_behind(local, upstream).ok())
}
pub(super) fn is_shallow(repo: &Repository) -> bool {
repo.is_shallow()
|| std::fs::metadata(repo.commondir().join("shallow"))
.is_ok_and(|marker| marker.is_file() && marker.len() > 0)
}
fn walk_shallow(repo: &Repository, local: Oid, upstream: Oid) -> Option<(usize, usize)> {
if !repo.is_worktree() {
return repo.graph_ahead_behind(local, upstream).ok();
}
match Repository::open(repo.commondir()) {
Ok(common) => common.graph_ahead_behind(local, upstream).ok(),
Err(e) => {
let path = repo.commondir().display();
tracing::debug!("cannot open {path} to walk a shallow repository: {e}");
None
}
}
}
#[derive(Default)]
struct Flight {
result: OnceCell<Divergence>,
started: AtomicBool,
}
pub(super) struct AheadBehindCoordinator {
in_flight: Mutex<HashMap<PathBuf, Arc<Flight>>>,
permits: Arc<Semaphore>,
limit: usize,
pool: Mutex<Weak<RepoPool>>,
}
impl AheadBehindCoordinator {
pub(super) fn new() -> Self {
Self::with_concurrency(MAX_CONCURRENT_COMPUTATIONS)
}
pub(super) fn with_concurrency(limit: usize) -> Self {
Self {
in_flight: Mutex::new(HashMap::new()),
permits: Arc::new(Semaphore::new(limit)),
limit,
pool: Mutex::new(Weak::new()),
}
}
pub(super) fn pool(&self) -> Arc<RepoPool> {
let mut weak = self.pool.lock().unwrap_or_else(PoisonError::into_inner);
if let Some(pool) = weak.upgrade() {
return pool;
}
let pool = Arc::new(RepoPool::new(self.limit));
*weak = Arc::downgrade(&pool);
pool
}
pub(super) async fn get_or_compute<F>(&self, path: PathBuf, compute: F) -> Divergence
where
F: FnOnce(PathBuf) -> Divergence + Send + 'static,
{
let flight = self.join_or_start(&path);
let _forget = Forget {
in_flight: &self.in_flight,
path: &path,
flight: &flight,
};
*flight
.result
.get_or_init(|| self.compute_bounded(Arc::clone(&flight), path.clone(), compute))
.await
}
fn join_or_start(&self, path: &PathBuf) -> Arc<Flight> {
let mut in_flight = self.lock();
if let Some(queued) = in_flight.get(path) {
if !queued.started.load(Ordering::SeqCst) {
return Arc::clone(queued);
}
}
let fresh = Arc::new(Flight::default());
in_flight.insert(path.clone(), Arc::clone(&fresh));
fresh
}
async fn compute_bounded<F>(&self, flight: Arc<Flight>, path: PathBuf, compute: F) -> Divergence
where
F: FnOnce(PathBuf) -> Divergence + Send + 'static,
{
let Ok(permit) = Arc::clone(&self.permits).acquire_owned().await else {
return Divergence::default();
};
let joined = tokio::task::spawn_blocking(move || {
let _permit = permit;
flight.started.store(true, Ordering::SeqCst);
compute(path)
})
.await;
joined.unwrap_or_else(|e| {
tracing::warn!("ahead/behind computation failed: {e}");
Divergence::default()
})
}
fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<PathBuf, Arc<Flight>>> {
self.in_flight
.lock()
.unwrap_or_else(PoisonError::into_inner)
}
#[cfg(test)]
fn in_flight_len(&self) -> usize {
self.lock().len()
}
}
struct Forget<'a> {
in_flight: &'a Mutex<HashMap<PathBuf, Arc<Flight>>>,
path: &'a PathBuf,
flight: &'a Arc<Flight>,
}
impl Drop for Forget<'_> {
fn drop(&mut self) {
let mut in_flight = self
.in_flight
.lock()
.unwrap_or_else(PoisonError::into_inner);
if in_flight
.get(self.path)
.is_some_and(|current| Arc::ptr_eq(current, self.flight))
{
in_flight.remove(self.path);
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use std::path::Path;
use std::sync::atomic::AtomicUsize;
use std::time::{Duration, Instant};
use super::*;
fn divergence(ahead: usize) -> Divergence {
Divergence {
ahead_behind: Some((ahead, 0)),
..Divergence::default()
}
}
async fn wait_until(flag: &AtomicBool) {
for _ in 0..400 {
if flag.load(Ordering::SeqCst) {
return;
}
tokio::time::sleep(Duration::from_millis(5)).await;
}
panic!("timed out waiting for a computation to start"); }
async fn hold_a_permit(
coordinator: &Arc<AheadBehindCoordinator>,
path: &str,
hold: Duration,
) -> tokio::task::JoinHandle<Divergence> {
let running = Arc::new(AtomicBool::new(false));
let flag = Arc::clone(&running);
let coordinator = Arc::clone(coordinator);
let path = PathBuf::from(path);
let task = tokio::spawn(async move {
coordinator
.get_or_compute(path, move |_| {
flag.store(true, Ordering::SeqCst);
std::thread::sleep(hold);
divergence(0)
})
.await
});
wait_until(&running).await;
task
}
#[tokio::test]
async fn requests_queued_for_one_path_share_one_computation() {
let coordinator = Arc::new(AheadBehindCoordinator::with_concurrency(1));
let blocker = hold_a_permit(&coordinator, "/blocker", Duration::from_millis(150)).await;
let calls = Arc::new(AtomicUsize::new(0));
let results = futures::future::join_all((0..8).map(|_| {
let calls = Arc::clone(&calls);
coordinator.get_or_compute(PathBuf::from("/repo/a"), move |_| {
calls.fetch_add(1, Ordering::SeqCst);
divergence(7)
})
}))
.await;
blocker.await.unwrap();
assert_eq!(calls.load(Ordering::SeqCst), 1, "work was duplicated");
assert!(results.iter().all(|r| *r == divergence(7)));
assert_eq!(coordinator.in_flight_len(), 0, "an entry leaked");
}
#[tokio::test]
async fn a_request_never_joins_a_computation_that_has_already_started() {
let coordinator = Arc::new(AheadBehindCoordinator::with_concurrency(4));
let leader = hold_a_permit(&coordinator, "/repo/a", Duration::from_millis(150)).await;
let later = coordinator
.get_or_compute(PathBuf::from("/repo/a"), |_| divergence(2))
.await;
assert_eq!(later, divergence(2), "the later request got a stale answer");
assert_eq!(leader.await.unwrap(), divergence(0));
assert_eq!(coordinator.in_flight_len(), 0, "an entry leaked");
}
#[tokio::test]
async fn distinct_paths_are_computed_independently() {
let coordinator = AheadBehindCoordinator::new();
let calls = Arc::new(AtomicUsize::new(0));
futures::future::join_all(["/a", "/b", "/c"].map(|p| {
let calls = Arc::clone(&calls);
coordinator.get_or_compute(PathBuf::from(p), move |_| {
calls.fetch_add(1, Ordering::SeqCst);
std::thread::sleep(Duration::from_millis(30));
divergence(7)
})
}))
.await;
assert_eq!(calls.load(Ordering::SeqCst), 3);
}
#[tokio::test]
async fn a_later_request_recomputes_instead_of_reusing_a_finished_result() {
let coordinator = AheadBehindCoordinator::new();
let path = PathBuf::from("/repo/a");
let first = coordinator
.get_or_compute(path.clone(), |_| divergence(1))
.await;
let second = coordinator.get_or_compute(path, |_| divergence(2)).await;
assert_eq!(first, divergence(1));
assert_eq!(second, divergence(2));
}
#[tokio::test]
async fn no_more_than_the_cap_run_at_once() {
let coordinator = AheadBehindCoordinator::with_concurrency(2);
let running = Arc::new(AtomicUsize::new(0));
let peak = Arc::new(AtomicUsize::new(0));
futures::future::join_all((0..8).map(|i| {
let running = Arc::clone(&running);
let peak = Arc::clone(&peak);
coordinator.get_or_compute(PathBuf::from(format!("/repo/{i}")), move |_| {
let now = running.fetch_add(1, Ordering::SeqCst) + 1;
peak.fetch_max(now, Ordering::SeqCst);
std::thread::sleep(Duration::from_millis(30));
running.fetch_sub(1, Ordering::SeqCst);
divergence(0)
})
}))
.await;
let peak = peak.load(Ordering::SeqCst);
assert!(peak <= 2, "{peak} computations ran at once, cap is 2");
assert_eq!(running.load(Ordering::SeqCst), 0);
}
#[tokio::test]
async fn a_waiter_takes_over_when_the_queued_caller_is_dropped() {
let coordinator = Arc::new(AheadBehindCoordinator::with_concurrency(1));
let started = Instant::now();
let blocker = hold_a_permit(&coordinator, "/blocker", Duration::from_millis(150)).await;
let waiter = {
let coordinator = Arc::clone(&coordinator);
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(30)).await;
coordinator
.get_or_compute(PathBuf::from("/repo/a"), |_| divergence(2))
.await
})
};
let abandoned = tokio::time::timeout(
Duration::from_millis(60),
coordinator.get_or_compute(PathBuf::from("/repo/a"), |_| divergence(1)),
)
.await;
assert!(abandoned.is_err(), "the leader should still be queued");
let result = tokio::time::timeout(Duration::from_secs(5), waiter)
.await
.expect("the waiter must not hang")
.unwrap();
assert_eq!(result, divergence(2), "the waiter ran its own computation");
assert!(
started.elapsed() >= Duration::from_millis(150),
"the waiter ran before the blocker released the only permit"
);
blocker.await.unwrap();
assert_eq!(coordinator.in_flight_len(), 0, "an entry leaked");
}
#[tokio::test]
async fn a_dropped_caller_does_not_free_the_permit_its_thread_still_holds() {
let coordinator = Arc::new(AheadBehindCoordinator::with_concurrency(1));
let started = Instant::now();
let abandoned = hold_a_permit(&coordinator, "/repo/a", Duration::from_millis(150)).await;
abandoned.abort();
let next = coordinator
.get_or_compute(PathBuf::from("/repo/b"), |_| divergence(2))
.await;
assert_eq!(next, divergence(2));
assert!(
started.elapsed() >= Duration::from_millis(150),
"a second computation ran while the abandoned one held the only permit"
);
}
#[tokio::test]
async fn a_closed_semaphore_yields_an_empty_answer_and_leaks_nothing() {
let coordinator = AheadBehindCoordinator::with_concurrency(1);
coordinator.permits.close();
let result = coordinator
.get_or_compute(PathBuf::from("/repo/a"), |_| divergence(1))
.await;
assert_eq!(result, Divergence::default());
assert_eq!(coordinator.in_flight_len(), 0, "an entry leaked");
}
#[tokio::test]
async fn a_panicking_computation_yields_an_empty_answer_and_frees_its_permit() {
let coordinator = AheadBehindCoordinator::with_concurrency(1);
let failed = coordinator
.get_or_compute(PathBuf::from("/repo/a"), |_| panic!("boom"))
.await;
assert_eq!(failed, Divergence::default());
assert_eq!(coordinator.in_flight_len(), 0, "an entry leaked");
let next = tokio::time::timeout(
Duration::from_secs(5),
coordinator.get_or_compute(PathBuf::from("/repo/b"), |_| divergence(2)),
)
.await
.expect("the only permit must have been released");
assert_eq!(next, divergence(2));
}
#[test]
fn walk_memo_runs_the_walk_once_per_key() {
let memo = WalkMemo::with_capacity(8);
let walks = AtomicUsize::new(0);
let key = (PathBuf::from("/r/.git"), Oid::ZERO_SHA1, Oid::ZERO_SHA1);
for _ in 0..3 {
let counts = memo.get_or_walk(key.clone(), || {
walks.fetch_add(1, Ordering::SeqCst);
Some((2, 3))
});
assert_eq!(counts, Some((2, 3)));
}
assert_eq!(walks.load(Ordering::SeqCst), 1);
}
#[test]
fn walk_memo_does_not_remember_a_failed_walk() {
let memo = WalkMemo::with_capacity(8);
let key = (PathBuf::from("/r/.git"), Oid::ZERO_SHA1, Oid::ZERO_SHA1);
assert_eq!(memo.get_or_walk(key.clone(), || None), None);
assert_eq!(memo.len(), 0);
assert_eq!(memo.get_or_walk(key, || Some((1, 1))), Some((1, 1)));
}
#[test]
fn walk_memo_starts_over_at_capacity_instead_of_growing() {
let memo = WalkMemo::with_capacity(2);
let key = |n: u8| {
(
PathBuf::from(format!("/r{n}/.git")),
Oid::ZERO_SHA1,
Oid::ZERO_SHA1,
)
};
for n in 0..5 {
memo.get_or_walk(key(n), || Some((usize::from(n), 0)));
assert!(memo.len() <= 2, "grew to {}", memo.len());
}
assert_eq!(memo.get_or_walk(key(4), || None), Some((4, 0)));
}
#[test]
fn graph_ahead_behind_tracks_new_commits_and_matches_libgit2() {
let memo = WalkMemo::with_capacity(8);
let dir = tempfile::tempdir().unwrap();
let (repo, base, first, second) = three_commits(dir.path());
assert_eq!(
graph_ahead_behind_in(&memo, &repo, first, base),
Some((1, 0))
);
assert_eq!(
graph_ahead_behind_in(&memo, &repo, first, base),
Some((1, 0))
);
assert_eq!(memo.len(), 1, "the repeat is a hit, not a second entry");
assert_eq!(
graph_ahead_behind_in(&memo, &repo, second, base),
Some((2, 0))
);
assert_eq!(
graph_ahead_behind_in(&memo, &repo, second, base),
repo.graph_ahead_behind(second, base).ok()
);
}
#[test]
fn a_shallow_repository_bypasses_the_memo() {
let memo = WalkMemo::with_capacity(8);
let dir = tempfile::tempdir().unwrap();
let (repo, base, first, _second) = three_commits(dir.path());
std::fs::write(repo.path().join("shallow"), format!("{base}\n")).unwrap();
assert!(repo.is_shallow());
assert!(graph_ahead_behind_in(&memo, &repo, first, base).is_some());
assert_eq!(memo.len(), 0, "a shallow repository was memoized");
}
fn add_linked_worktree(repo: &Repository, base: Oid, parent: &Path, name: &str) -> PathBuf {
let path = parent.join(name);
repo.branch(name, &repo.find_commit(base).unwrap(), 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();
path
}
fn mark_shallow(repo: &Repository, tip: Oid) {
std::fs::write(repo.commondir().join("shallow"), format!("{tip}\n")).unwrap();
}
fn forget_object(repo: &Repository, id: Oid) {
let hex = id.to_string();
let path = repo.path().join("objects").join(&hex[..2]).join(&hex[2..]);
std::fs::remove_file(path).unwrap();
}
#[test]
fn a_linked_worktree_of_a_shallow_repository_is_shallow() {
let dir = tempfile::tempdir().unwrap();
let wts = tempfile::tempdir().unwrap();
let (repo, base, first, _second) = three_commits(dir.path());
let linked =
Repository::open(add_linked_worktree(&repo, base, wts.path(), "feature")).unwrap();
assert!(!is_shallow(&repo) && !is_shallow(&linked));
mark_shallow(&repo, first);
assert!(is_shallow(&repo));
assert!(is_shallow(&linked));
std::fs::write(repo.commondir().join("shallow"), "").unwrap();
assert!(!is_shallow(&repo) && !is_shallow(&linked));
}
#[test]
fn libgit2_ignores_the_common_dirs_shallow_marker_from_a_linked_worktree() {
let dir = tempfile::tempdir().unwrap();
let wts = tempfile::tempdir().unwrap();
let (repo, base, first, second) = three_commits(dir.path());
let path = add_linked_worktree(&repo, base, wts.path(), "feature");
mark_shallow(&repo, first);
forget_object(&repo, base);
let linked = Repository::open(path).unwrap();
let common = Repository::open(repo.commondir()).unwrap();
assert!(!linked.is_shallow());
assert!(linked.graph_ahead_behind(second, first).is_err());
assert!(common.is_shallow());
assert_eq!(common.graph_ahead_behind(second, first).unwrap(), (1, 0));
}
#[test]
fn a_linked_worktree_of_a_shallow_repository_bypasses_the_memo() {
let memo = WalkMemo::with_capacity(8);
let dir = tempfile::tempdir().unwrap();
let wts = tempfile::tempdir().unwrap();
let (repo, base, first, second) = three_commits(dir.path());
let linked =
Repository::open(add_linked_worktree(&repo, base, wts.path(), "feature")).unwrap();
mark_shallow(&repo, first);
assert!(graph_ahead_behind_in(&memo, &linked, second, first).is_some());
assert_eq!(memo.len(), 0, "a shallow repository was memoized");
}
#[test]
fn a_linked_worktree_of_a_shallow_repository_is_walked_with_the_cut_applied() {
let memo = WalkMemo::with_capacity(8);
let dir = tempfile::tempdir().unwrap();
let wts = tempfile::tempdir().unwrap();
let (repo, base, first, _second) = three_commits(dir.path());
let linked =
Repository::open(add_linked_worktree(&repo, base, wts.path(), "feature")).unwrap();
mark_shallow(&repo, first);
let complete = Repository::open(repo.commondir()).unwrap();
assert!(complete.is_shallow());
assert_eq!(complete.graph_ahead_behind(first, base).unwrap(), (1, 1));
assert_eq!(
graph_ahead_behind_in(&memo, &linked, first, base),
Some((1, 1))
);
}
#[test]
fn a_linked_worktree_of_a_shallow_clone_still_gets_counts_without_the_cut_commits() {
let memo = WalkMemo::with_capacity(8);
let dir = tempfile::tempdir().unwrap();
let wts = tempfile::tempdir().unwrap();
let (repo, base, first, second) = three_commits(dir.path());
let path = add_linked_worktree(&repo, base, wts.path(), "feature");
mark_shallow(&repo, first);
forget_object(&repo, base);
let linked = Repository::open(path).unwrap();
assert_eq!(
graph_ahead_behind_in(&memo, &linked, second, first),
Some((1, 0))
);
}
#[test]
fn a_common_dir_that_will_not_open_leaves_a_shallow_worktree_without_counts() {
let memo = WalkMemo::with_capacity(8);
let dir = tempfile::tempdir().unwrap();
let wts = tempfile::tempdir().unwrap();
let (repo, base, first, second) = three_commits(dir.path());
let linked =
Repository::open(add_linked_worktree(&repo, base, wts.path(), "feature")).unwrap();
mark_shallow(&repo, first);
std::fs::remove_file(repo.commondir().join("HEAD")).unwrap();
assert!(Repository::open(repo.commondir()).is_err());
let mut counts = Some((0, 0));
let logs = crate::test_support::capture_at(tracing::Level::DEBUG, || {
counts = graph_ahead_behind_in(&memo, &linked, second, first);
});
assert_eq!(counts, None);
assert_eq!(memo.len(), 0);
let logged = format!(
"cannot open {} to walk a shallow repository",
repo.commondir().display()
);
assert!(
logs.contains(&logged),
"the failed open was not logged with its path: {logs}"
);
}
fn three_commits(dir: &std::path::Path) -> (Repository, Oid, Oid, Oid) {
let repo = Repository::init(dir).unwrap();
let sig = git2::Signature::now("t", "t@example.invalid").unwrap();
let tree = repo
.find_tree(repo.treebuilder(None).unwrap().write().unwrap())
.unwrap();
let base = repo.commit(None, &sig, &sig, "base", &tree, &[]).unwrap();
let first = {
let parent = repo.find_commit(base).unwrap();
repo.commit(None, &sig, &sig, "one", &tree, &[&parent])
.unwrap()
};
let second = {
let parent = repo.find_commit(first).unwrap();
repo.commit(None, &sig, &sig, "two", &tree, &[&parent])
.unwrap()
};
drop(tree);
(repo, base, first, second)
}
#[test]
fn overlapping_holders_share_one_pool_and_it_is_released_with_the_last() {
let coordinator = AheadBehindCoordinator::with_concurrency(3);
let first = coordinator.pool();
let second = coordinator.pool();
assert!(Arc::ptr_eq(&first, &second));
let weak = Arc::downgrade(&first);
drop(first);
assert!(weak.upgrade().is_some(), "the second holder still has it");
drop(second);
assert!(weak.upgrade().is_none(), "the last holder released it");
let next = coordinator.pool();
assert_eq!(Arc::strong_count(&next), 1);
}
}