use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::Result;
use tokio::sync::mpsc;
use tokio::time::Instant;
use super::client::WorktreesClient;
use super::view_model::AheadBehindState;
use super::wire::AheadBehindEntryWire;
const RETRY_BASE: Duration = Duration::from_secs(2);
const MAX_RETRY_DELAY: Duration = Duration::from_secs(30);
const SHALLOW_RECHECK: Duration = Duration::from_secs(30);
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Inputs {
pub branch: Option<String>,
pub head_sha: Option<String>,
pub upstream_sha: Option<String>,
pub main_sha: Option<String>,
}
impl Inputs {
fn expects_a_row(&self) -> bool {
self.branch.is_some() && (self.upstream_sha.is_some() || self.main_sha.is_some())
}
}
static NO_INPUTS: Inputs = Inputs {
branch: None,
head_sha: None,
upstream_sha: None,
main_sha: None,
};
struct Entry {
state: AheadBehindState,
recheck: Option<Recheck>,
}
impl Entry {
fn settled(state: AheadBehindState) -> Self {
Self {
state,
recheck: None,
}
}
}
#[derive(Debug, Clone, Copy)]
struct Recheck {
at: Instant,
attempts: u32,
}
pub struct AheadBehindCache {
client: WorktreesClient,
inputs: HashMap<PathBuf, Inputs>,
entries: HashMap<PathBuf, Entry>,
pending: HashSet<PathBuf>,
results_tx: mpsc::UnboundedSender<FetchResult>,
results_rx: mpsc::UnboundedReceiver<FetchResult>,
wanted: Vec<PathBuf>,
failures: u32,
retry_at: Option<Instant>,
retry_base: Duration,
shallow_recheck: Duration,
}
struct FetchResult {
requested: Vec<(PathBuf, Inputs)>,
results: Result<HashMap<PathBuf, AheadBehindEntryWire>>,
}
impl AheadBehindCache {
pub fn new(client: WorktreesClient) -> Self {
let (results_tx, results_rx) = mpsc::unbounded_channel();
Self {
client,
inputs: HashMap::new(),
entries: HashMap::new(),
pending: HashSet::new(),
results_tx,
results_rx,
wanted: Vec::new(),
failures: 0,
retry_at: None,
retry_base: RETRY_BASE,
shallow_recheck: SHALLOW_RECHECK,
}
}
pub fn set_visible(&mut self, paths: &[PathBuf]) {
self.wanted = paths.to_vec();
let visible: HashSet<&PathBuf> = paths.iter().collect();
self.entries.retain(|path, _| visible.contains(path));
let now = Instant::now();
let to_fetch: Vec<PathBuf> = paths
.iter()
.filter(|p| !self.pending.contains(*p) && self.is_due(p, now))
.cloned()
.collect();
if !to_fetch.is_empty() {
self.spawn_fetch(to_fetch);
}
}
fn is_due(&self, path: &Path, now: Instant) -> bool {
self.entries
.get(path)
.is_none_or(|entry| entry.recheck.is_some_and(|recheck| recheck.at <= now))
}
pub fn observe(&mut self, rows: impl IntoIterator<Item = (PathBuf, Inputs)>) {
let observed: HashMap<PathBuf, Inputs> = rows.into_iter().collect();
for (path, inputs) in &observed {
if self.inputs.get(path) != Some(inputs) {
self.entries.remove(path);
}
}
self.inputs = observed;
}
fn inputs_of(&self, path: &Path) -> &Inputs {
self.inputs.get(path).unwrap_or(&NO_INPUTS)
}
fn spawn_fetch(&mut self, paths: Vec<PathBuf>) {
for path in &paths {
self.pending.insert(path.clone());
}
let client = self.client.clone();
let tx = self.results_tx.clone();
let requested: Vec<(PathBuf, Inputs)> = paths
.iter()
.map(|path| (path.clone(), self.inputs_of(path).clone()))
.collect();
tokio::spawn(async move {
let results = client.fetch_ahead_behind(&paths).await;
let _ = tx.send(FetchResult { requested, results });
});
}
pub async fn changed(&mut self) {
let wake = self.next_wake();
tokio::select! {
batch = self.results_rx.recv() => {
if let Some(batch) = batch {
self.merge(batch);
}
}
() = wait_until(wake) => self.retry(),
}
}
fn merge(&mut self, FetchResult { requested, results }: FetchResult) {
match results {
Ok(results) => {
self.failures = 0;
let mut superseded = false;
for (path, sent) in requested {
self.pending.remove(&path);
if *self.inputs_of(&path) != sent {
tracing::debug!(
"worktrees ui: discarding ahead-behind result for {}: \
its refs moved while the fetch was in flight",
path.display()
);
superseded = true;
continue;
}
let entry = settle(
results.get(&path),
&sent,
self.entries.get(&path),
Instant::now(),
(self.retry_base, self.shallow_recheck),
);
if let Some(recheck) = entry.recheck.filter(|r| r.attempts > 0) {
tracing::debug!(
"worktrees ui: ahead-behind row for {} omitted although its \
refs promise one ({} in a row), asking again in {:?}",
path.display(),
recheck.attempts,
recheck.at.saturating_duration_since(Instant::now()),
);
}
self.entries.insert(path, entry);
}
if superseded {
self.requeue();
}
}
Err(e) => {
self.failures = self.failures.saturating_add(1);
let due = Instant::now() + self.retry_delay();
self.retry_at = Some(self.retry_at.map_or(due, |at| at.min(due)));
tracing::debug!(
"worktrees ui: ahead-behind fetch for {} path(s) failed \
({} in a row), retrying in {:?}: {e:#}",
requested.len(),
self.failures,
self.retry_delay(),
);
for (path, _) in &requested {
self.pending.remove(path);
if let Some(recheck) = self
.entries
.get_mut(path)
.and_then(|entry| entry.recheck.as_mut())
{
recheck.at = recheck.at.max(due);
}
}
}
}
}
fn retry(&mut self) {
if self.retry_at.is_some_and(|at| at <= Instant::now()) {
self.retry_at = None;
}
self.requeue();
}
fn next_wake(&self) -> Option<Instant> {
let rechecks = self
.entries
.iter()
.filter(|(path, _)| !self.pending.contains(*path))
.filter_map(|(_, entry)| entry.recheck.map(|recheck| recheck.at));
self.retry_at.into_iter().chain(rechecks).min()
}
#[cfg(test)]
pub(super) fn is_pending(&self, path: &Path) -> bool {
self.pending.contains(path)
}
fn requeue(&mut self) {
let wanted = std::mem::take(&mut self.wanted);
self.set_visible(&wanted);
}
fn retry_delay(&self) -> Duration {
backoff(self.retry_base, self.failures)
}
pub fn get(&self, path: &Path) -> AheadBehindState {
if let Some(entry) = self.entries.get(path) {
return entry.state;
}
if self.pending.contains(path) {
AheadBehindState::Loading
} else {
AheadBehindState::Unknown
}
}
}
fn backoff(base: Duration, failures: u32) -> Duration {
let doublings = failures.saturating_sub(1).min(16);
base.saturating_mul(1 << doublings).min(MAX_RETRY_DELAY)
}
async fn wait_until(at: Option<Instant>) {
match at {
Some(at) => tokio::time::sleep_until(at).await,
None => std::future::pending().await,
}
}
fn settle(
reply: Option<&AheadBehindEntryWire>,
inputs: &Inputs,
previous: Option<&Entry>,
now: Instant,
(retry_base, shallow_recheck): (Duration, Duration),
) -> Entry {
let state = state_for(reply);
if state == AheadBehindState::Unavailable && inputs.expects_a_row() {
let attempts = previous
.and_then(|entry| entry.recheck)
.map_or(0, |recheck| recheck.attempts)
.saturating_add(1);
return Entry {
state: AheadBehindState::Unknown,
recheck: Some(Recheck {
at: now + backoff(retry_base, attempts),
attempts,
}),
};
}
if reply.is_some_and(|row| row.shallow) {
return Entry {
state,
recheck: Some(Recheck {
at: now + shallow_recheck,
attempts: 0,
}),
};
}
Entry::settled(state)
}
fn state_for(entry: Option<&AheadBehindEntryWire>) -> AheadBehindState {
match entry.copied() {
Some(AheadBehindEntryWire {
ahead: Some(ahead),
behind: Some(behind),
main_behind,
..
}) => AheadBehindState::Known {
ahead,
behind,
main_behind,
},
Some(AheadBehindEntryWire {
main_behind: Some(main_behind),
..
}) => AheadBehindState::MainOnly { main_behind },
_ => AheadBehindState::Unavailable,
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use std::time::Duration;
use serde_json::json;
use super::*;
use crate::daemon::testutil::fake_daemon_replies;
fn cache() -> AheadBehindCache {
AheadBehindCache::new(WorktreesClient::new("/tmp/nonexistent-omni-dev-test.sock"))
}
async fn settle(cache: &mut AheadBehindCache) {
tokio::time::timeout(Duration::from_secs(5), cache.changed())
.await
.expect("a fetch batch should have landed");
}
fn asked(path: &Path) -> Vec<(PathBuf, Inputs)> {
vec![(path.to_path_buf(), Inputs::default())]
}
fn at_head(path: &Path, head: &str) -> Vec<(PathBuf, Inputs)> {
vec![(
path.to_path_buf(),
Inputs {
head_sha: Some(head.to_string()),
..Inputs::default()
},
)]
}
fn counts_reply(ahead: usize, behind: usize) -> serde_json::Value {
json!({ "ok": true, "payload": { "results": {
"/repo/wt": { "ahead": ahead, "behind": behind }
}}})
}
fn wire(
ahead: Option<usize>,
behind: Option<usize>,
main_behind: Option<usize>,
) -> AheadBehindEntryWire {
AheadBehindEntryWire {
ahead,
behind,
main_behind,
shallow: false,
}
}
#[test]
fn state_for_an_omitted_row_is_unavailable() {
assert_eq!(state_for(None), AheadBehindState::Unavailable);
}
#[test]
fn state_for_a_row_with_both_counts_is_known() {
assert_eq!(
state_for(Some(&wire(Some(2), Some(1), Some(5)))),
AheadBehindState::Known {
ahead: 2,
behind: 1,
main_behind: Some(5)
}
);
assert_eq!(
state_for(Some(&wire(Some(0), Some(0), None))),
AheadBehindState::Known {
ahead: 0,
behind: 0,
main_behind: None
}
);
}
#[test]
fn state_for_a_row_with_only_main_behind_is_main_only() {
assert_eq!(
state_for(Some(&wire(None, None, Some(7)))),
AheadBehindState::MainOnly { main_behind: 7 }
);
assert_eq!(
state_for(Some(&wire(None, None, Some(0)))),
AheadBehindState::MainOnly { main_behind: 0 }
);
}
#[test]
fn state_for_a_row_with_counts_keeps_main_behind_inside_known() {
assert!(matches!(
state_for(Some(&wire(Some(1), Some(0), Some(4)))),
AheadBehindState::Known {
main_behind: Some(4),
..
}
));
}
#[test]
fn state_for_half_a_pair_is_not_known() {
assert_eq!(
state_for(Some(&wire(Some(2), None, None))),
AheadBehindState::Unavailable
);
assert_eq!(
state_for(Some(&wire(None, Some(1), None))),
AheadBehindState::Unavailable
);
assert_eq!(
state_for(Some(&wire(Some(2), None, Some(4)))),
AheadBehindState::MainOnly { main_behind: 4 }
);
}
#[test]
fn unfetched_path_is_unknown() {
let cache = cache();
assert_eq!(cache.get(Path::new("/repo/wt")), AheadBehindState::Unknown);
}
#[tokio::test]
async fn set_visible_marks_newly_visible_paths_loading() {
let mut cache = cache();
cache.set_visible(&[PathBuf::from("/repo/wt")]);
assert_eq!(cache.get(Path::new("/repo/wt")), AheadBehindState::Loading);
}
#[tokio::test]
async fn set_visible_drops_entries_for_paths_no_longer_visible() {
let mut cache = cache();
cache.entries.insert(
PathBuf::from("/repo/gone"),
Entry::settled(AheadBehindState::Known {
ahead: 1,
behind: 0,
main_behind: None,
}),
);
cache.set_visible(&[PathBuf::from("/repo/still-here")]);
assert_eq!(
cache.get(Path::new("/repo/gone")),
AheadBehindState::Unknown
);
}
#[tokio::test]
async fn set_visible_does_not_re_fetch_an_already_pending_path() {
let mut cache = cache();
cache.set_visible(&[PathBuf::from("/repo/wt")]);
assert_eq!(cache.pending.len(), 1);
cache.set_visible(&[PathBuf::from("/repo/wt")]);
assert_eq!(cache.pending.len(), 1);
}
#[tokio::test]
async fn changed_merges_a_completed_batch_and_clears_pending() {
let mut cache = cache();
let path = PathBuf::from("/repo/wt");
cache.pending.insert(path.clone());
let mut results = HashMap::new();
results.insert(
path.clone(),
AheadBehindEntryWire {
ahead: Some(2),
behind: Some(1),
main_behind: Some(5),
shallow: false,
},
);
cache
.results_tx
.send(FetchResult {
requested: asked(&path),
results: Ok(results),
})
.unwrap();
cache.changed().await;
assert_eq!(
cache.get(&path),
AheadBehindState::Known {
ahead: 2,
behind: 1,
main_behind: Some(5)
}
);
assert!(!cache.pending.contains(&path));
}
#[tokio::test]
async fn changed_keeps_a_row_that_carries_only_main_behind() {
let (_dir, sock, _server) = fake_daemon_replies(vec![json!({
"ok": true,
"payload": { "results": { "/repo/no-upstream": { "main_behind": 3 } } }
})]);
let mut cache = AheadBehindCache::new(WorktreesClient::new(sock));
let path = PathBuf::from("/repo/no-upstream");
cache.set_visible(std::slice::from_ref(&path));
settle(&mut cache).await;
assert_eq!(
cache.get(&path),
AheadBehindState::MainOnly { main_behind: 3 }
);
}
#[tokio::test]
async fn changed_treats_a_missing_result_entry_as_unavailable_not_zero() {
let mut cache = cache();
let path = PathBuf::from("/repo/no-upstream");
cache.pending.insert(path.clone());
cache
.results_tx
.send(FetchResult {
requested: asked(&path),
results: Ok(HashMap::new()),
})
.unwrap();
cache.changed().await;
assert_eq!(cache.get(&path), AheadBehindState::Unavailable);
cache.set_visible(std::slice::from_ref(&path));
assert!(cache.pending.is_empty());
assert_eq!(cache.get(&path), AheadBehindState::Unavailable);
}
#[tokio::test]
async fn changed_caches_nothing_for_a_failed_fetch() {
let mut cache = cache();
let failed = PathBuf::from("/repo/wt");
let settled = PathBuf::from("/repo/settled");
let known = AheadBehindState::Known {
ahead: 1,
behind: 0,
main_behind: None,
};
cache.entries.insert(settled.clone(), Entry::settled(known));
cache.pending.insert(failed.clone());
cache
.results_tx
.send(FetchResult {
requested: asked(&failed),
results: Err(anyhow::anyhow!("daemon unreachable")),
})
.unwrap();
cache.changed().await;
assert!(!cache.pending.contains(&failed));
assert!(!cache.entries.contains_key(&failed));
assert_eq!(cache.get(&failed), AheadBehindState::Unknown);
assert_eq!(cache.get(&settled), known);
cache.set_visible(std::slice::from_ref(&failed));
assert_eq!(cache.get(&failed), AheadBehindState::Loading);
}
#[tokio::test]
async fn a_fetch_that_failed_is_re_asked_once_the_daemon_recovers() {
let (_dir, sock, _server) = fake_daemon_replies(vec![
json!({ "ok": false, "error": "busy" }),
json!({ "ok": true, "payload": { "results": {
"/repo/wt": { "ahead": 2, "behind": 1 }
}}}),
]);
let mut cache = AheadBehindCache::new(WorktreesClient::new(sock));
let path = PathBuf::from("/repo/wt");
cache.set_visible(std::slice::from_ref(&path));
assert_eq!(cache.get(&path), AheadBehindState::Loading);
settle(&mut cache).await;
assert_eq!(cache.get(&path), AheadBehindState::Unknown);
cache.set_visible(std::slice::from_ref(&path));
assert_eq!(cache.get(&path), AheadBehindState::Loading);
settle(&mut cache).await;
assert_eq!(
cache.get(&path),
AheadBehindState::Known {
ahead: 2,
behind: 1,
main_behind: None
}
);
}
#[tokio::test]
async fn a_failed_fetch_is_retried_without_another_set_visible_call() {
let (_dir, sock, _server) = fake_daemon_replies(vec![
json!({ "ok": false, "error": "busy" }),
json!({ "ok": true, "payload": { "results": {
"/repo/wt": { "ahead": 2, "behind": 1 }
}}}),
]);
let mut cache = AheadBehindCache::new(WorktreesClient::new(sock));
cache.retry_base = Duration::from_millis(5);
let path = PathBuf::from("/repo/wt");
cache.set_visible(std::slice::from_ref(&path));
settle(&mut cache).await;
assert_eq!(cache.get(&path), AheadBehindState::Unknown);
assert!(cache.retry_at.is_some(), "a failure schedules a retry");
settle(&mut cache).await;
assert_eq!(cache.get(&path), AheadBehindState::Loading);
assert!(cache.retry_at.is_none(), "the retry was consumed");
settle(&mut cache).await;
assert_eq!(
cache.get(&path),
AheadBehindState::Known {
ahead: 2,
behind: 1,
main_behind: None
}
);
assert_eq!(cache.failures, 0, "a good batch ends the run of failures");
}
#[tokio::test]
async fn a_run_of_failures_backs_off_and_a_success_ends_it() {
let mut cache = cache();
let path = PathBuf::from("/repo/wt");
let send = |cache: &AheadBehindCache, results| {
cache
.results_tx
.send(FetchResult {
requested: asked(&path),
results,
})
.unwrap();
};
send(&cache, Err(anyhow::anyhow!("busy")));
cache.changed().await;
let first = cache.retry_at.unwrap();
send(&cache, Err(anyhow::anyhow!("busy")));
cache.changed().await;
assert_eq!(cache.failures, 2);
assert_eq!(cache.retry_at, Some(first));
send(&cache, Ok(HashMap::new()));
cache.changed().await;
assert_eq!(cache.failures, 0);
}
#[test]
fn retry_delay_doubles_per_consecutive_failure_up_to_the_cap() {
let mut cache = cache();
let delays: Vec<Duration> = (1..=6)
.map(|failures| {
cache.failures = failures;
cache.retry_delay()
})
.collect();
assert_eq!(delays, [2, 4, 8, 16, 30, 30].map(Duration::from_secs));
cache.failures = u32::MAX;
assert_eq!(cache.retry_delay(), MAX_RETRY_DELAY);
}
#[tokio::test]
async fn nothing_is_scheduled_until_a_fetch_fails() {
let mut cache = cache();
assert!(cache.retry_at.is_none());
let waited = tokio::time::timeout(Duration::from_millis(30), cache.changed()).await;
assert!(waited.is_err());
}
#[tokio::test]
async fn changed_only_resolves_the_paths_its_own_batch_requested() {
let mut cache = cache();
let a = PathBuf::from("/repo/a");
let b = PathBuf::from("/repo/b");
cache.pending.insert(a.clone());
cache.pending.insert(b.clone());
let mut results = HashMap::new();
results.insert(
a.clone(),
AheadBehindEntryWire {
ahead: Some(1),
behind: Some(0),
main_behind: None,
shallow: false,
},
);
cache
.results_tx
.send(FetchResult {
requested: asked(&a),
results: Ok(results),
})
.unwrap();
cache.changed().await;
assert!(matches!(cache.get(&a), AheadBehindState::Known { .. }));
assert_eq!(cache.get(&b), AheadBehindState::Loading);
}
#[test]
fn observe_drops_the_entry_of_a_worktree_whose_inputs_moved() {
let mut cache = cache();
let path = PathBuf::from("/repo/wt");
let known = AheadBehindState::Known {
ahead: 1,
behind: 0,
main_behind: None,
};
cache.observe(at_head(&path, "aaa"));
cache.entries.insert(path.clone(), Entry::settled(known));
cache.observe(at_head(&path, "aaa"));
assert_eq!(cache.get(&path), known);
cache.observe(at_head(&path, "bbb"));
assert_eq!(cache.get(&path), AheadBehindState::Unknown);
}
#[test]
fn observe_drops_the_entry_when_only_the_branch_changed() {
let mut cache = cache();
let path = PathBuf::from("/repo/wt");
let on = |branch: &str| {
vec![(
path.clone(),
Inputs {
branch: Some(branch.to_string()),
head_sha: Some("aaa".to_string()),
..Inputs::default()
},
)]
};
cache.observe(on("one"));
cache
.entries
.insert(path.clone(), Entry::settled(AheadBehindState::Unavailable));
cache.observe(on("one"));
assert_eq!(cache.get(&path), AheadBehindState::Unavailable);
cache.observe(on("two"));
assert_eq!(cache.get(&path), AheadBehindState::Unknown);
}
#[test]
fn observe_drops_an_entry_for_a_worktree_it_had_not_seen() {
let mut cache = cache();
let path = PathBuf::from("/repo/wt");
cache
.entries
.insert(path.clone(), Entry::settled(AheadBehindState::Unavailable));
cache.observe(at_head(&path, "aaa"));
assert_eq!(cache.get(&path), AheadBehindState::Unknown);
}
#[test]
fn observe_forgets_a_worktree_that_left_the_snapshot() {
let mut cache = cache();
let path = PathBuf::from("/repo/wt");
cache.observe(at_head(&path, "aaa"));
assert!(cache.inputs.contains_key(&path));
cache.observe(at_head(Path::new("/repo/other"), "ccc"));
assert!(!cache.inputs.contains_key(&path));
assert!(cache.inputs.contains_key(Path::new("/repo/other")));
}
#[tokio::test]
async fn a_result_for_refs_that_moved_in_flight_is_discarded_and_re_asked() {
let (_dir, sock, _server) =
fake_daemon_replies(vec![counts_reply(1, 0), counts_reply(2, 0)]);
let mut cache = AheadBehindCache::new(WorktreesClient::new(sock));
let path = PathBuf::from("/repo/wt");
cache.observe(at_head(&path, "aaa"));
cache.set_visible(std::slice::from_ref(&path));
assert_eq!(cache.get(&path), AheadBehindState::Loading);
cache.observe(at_head(&path, "bbb"));
cache.set_visible(std::slice::from_ref(&path));
assert_eq!(cache.pending.len(), 1);
settle(&mut cache).await;
assert!(!cache.entries.contains_key(&path));
assert_eq!(cache.get(&path), AheadBehindState::Loading);
settle(&mut cache).await;
assert_eq!(
cache.get(&path),
AheadBehindState::Known {
ahead: 2,
behind: 0,
main_behind: None
}
);
}
#[tokio::test]
async fn a_result_whose_inputs_did_not_move_is_stored_and_one_that_did_is_not() {
let mut cache = cache();
let moved = PathBuf::from("/repo/moved");
let steady = PathBuf::from("/repo/steady");
let both = |moved_head: &str| {
vec![
(
moved.clone(),
Inputs {
head_sha: Some(moved_head.to_string()),
..Inputs::default()
},
),
(
steady.clone(),
Inputs {
head_sha: Some("s".to_string()),
..Inputs::default()
},
),
]
};
cache.observe(both("old"));
let requested = both("old");
cache.pending.extend([moved.clone(), steady.clone()]);
cache.observe(both("new"));
let counts = AheadBehindEntryWire {
ahead: Some(1),
behind: Some(0),
main_behind: None,
shallow: false,
};
cache
.results_tx
.send(FetchResult {
requested,
results: Ok(HashMap::from([
(moved.clone(), counts),
(steady.clone(), counts),
])),
})
.unwrap();
cache.wanted = vec![moved.clone(), steady.clone()];
cache.changed().await;
assert!(matches!(cache.get(&steady), AheadBehindState::Known { .. }));
assert!(!cache.entries.contains_key(&moved));
assert_eq!(cache.get(&moved), AheadBehindState::Loading);
}
fn expecting(path: &Path) -> Vec<(PathBuf, Inputs)> {
vec![(
path.to_path_buf(),
Inputs {
branch: Some("main".to_string()),
head_sha: Some("aaa".to_string()),
upstream_sha: Some("bbb".to_string()),
main_sha: None,
},
)]
}
async fn deliver_omitted(
cache: &mut AheadBehindCache,
path: &Path,
inputs: Vec<(PathBuf, Inputs)>,
) {
cache.pending.insert(path.to_path_buf());
cache
.results_tx
.send(FetchResult {
requested: inputs,
results: Ok(HashMap::new()),
})
.unwrap();
cache.changed().await;
}
#[test]
fn a_row_is_expected_only_for_a_branch_with_something_to_compare_against() {
let inputs = |branch: Option<&str>, upstream: Option<&str>, main: Option<&str>| Inputs {
branch: branch.map(str::to_string),
head_sha: Some("h".to_string()),
upstream_sha: upstream.map(str::to_string),
main_sha: main.map(str::to_string),
};
assert!(inputs(Some("b"), Some("u"), None).expects_a_row());
assert!(inputs(Some("b"), None, Some("m")).expects_a_row());
assert!(inputs(Some("b"), Some("u"), Some("m")).expects_a_row());
assert!(!inputs(Some("b"), None, None).expects_a_row());
assert!(!inputs(None, Some("u"), Some("m")).expects_a_row());
assert!(!Inputs::default().expects_a_row());
}
#[tokio::test]
async fn an_omitted_row_the_snapshot_expected_is_not_settled() {
let mut cache = cache();
let path = PathBuf::from("/repo/wt");
cache.observe(expecting(&path));
deliver_omitted(&mut cache, &path, expecting(&path)).await;
assert_eq!(cache.get(&path), AheadBehindState::Unknown);
assert!(!cache.pending.contains(&path));
let recheck = cache.entries[&path].recheck.expect("an unsettled entry");
assert_eq!(recheck.attempts, 1);
cache.set_visible(std::slice::from_ref(&path));
assert!(cache.pending.is_empty(), "re-asked on a frame");
assert_eq!(cache.get(&path), AheadBehindState::Unknown);
}
#[tokio::test]
async fn an_omitted_row_with_nothing_expected_stays_settled_and_schedules_nothing() {
let mut cache = cache();
let path = PathBuf::from("/repo/wt");
let inputs = vec![(
path.clone(),
Inputs {
branch: Some("topic".to_string()),
head_sha: Some("aaa".to_string()),
..Inputs::default()
},
)];
cache.observe(inputs.clone());
deliver_omitted(&mut cache, &path, inputs).await;
assert_eq!(cache.get(&path), AheadBehindState::Unavailable);
assert!(cache.entries[&path].recheck.is_none());
assert!(
cache.next_wake().is_none(),
"a settled row is never re-asked"
);
cache.set_visible(std::slice::from_ref(&path));
assert!(cache.pending.is_empty());
}
#[tokio::test]
async fn an_expected_row_that_stayed_omitted_is_re_asked_on_the_timer_alone() {
let (_dir, sock, _server) = fake_daemon_replies(vec![
json!({ "ok": true, "payload": { "results": {} } }),
counts_reply(2, 1),
]);
let mut cache = AheadBehindCache::new(WorktreesClient::new(sock));
cache.retry_base = Duration::from_millis(5);
let path = PathBuf::from("/repo/wt");
cache.observe(expecting(&path));
cache.set_visible(std::slice::from_ref(&path));
settle(&mut cache).await;
assert_eq!(cache.get(&path), AheadBehindState::Unknown);
settle(&mut cache).await;
assert!(cache.pending.contains(&path));
assert_eq!(cache.get(&path), AheadBehindState::Unknown);
settle(&mut cache).await;
assert_eq!(
cache.get(&path),
AheadBehindState::Known {
ahead: 2,
behind: 1,
main_behind: None
}
);
assert!(cache.entries[&path].recheck.is_none(), "now settled");
}
#[tokio::test]
async fn a_persistently_omitted_row_backs_off_per_path_and_a_moved_input_restarts_it() {
let mut cache = cache();
let path = PathBuf::from("/repo/wt");
let other = PathBuf::from("/repo/other");
cache.observe(expecting(&path));
for expected in 1..=4 {
deliver_omitted(&mut cache, &path, expecting(&path)).await;
assert_eq!(cache.entries[&path].recheck.unwrap().attempts, expected);
}
let delays: Vec<Duration> = (1..=6).map(|n| backoff(RETRY_BASE, n)).collect();
assert_eq!(delays, [2, 4, 8, 16, 30, 30].map(Duration::from_secs));
cache.pending.insert(other.clone());
cache
.results_tx
.send(FetchResult {
requested: asked(&other),
results: Ok(HashMap::new()),
})
.unwrap();
cache.changed().await;
assert_eq!(cache.entries[&path].recheck.unwrap().attempts, 4);
let mut moved = expecting(&path);
moved[0].1.head_sha = Some("ccc".to_string());
cache.observe(moved.clone());
assert!(!cache.entries.contains_key(&path));
deliver_omitted(&mut cache, &path, moved).await;
assert_eq!(cache.entries[&path].recheck.unwrap().attempts, 1);
}
#[tokio::test]
async fn the_wake_ignores_pending_and_invisible_rows_and_keeps_a_batch_retry() {
let mut cache = cache();
let path = PathBuf::from("/repo/wt");
let due = Instant::now();
let unsettled = |at| Entry {
state: AheadBehindState::Unknown,
recheck: Some(Recheck { at, attempts: 1 }),
};
assert!(cache.next_wake().is_none());
cache.entries.insert(path.clone(), unsettled(due));
assert_eq!(cache.next_wake(), Some(due));
cache.pending.insert(path.clone());
assert!(cache.next_wake().is_none());
cache.pending.clear();
let later = due + Duration::from_secs(60);
cache.retry_at = Some(later);
assert_eq!(cache.next_wake(), Some(due));
cache.wanted = vec![path.clone()];
cache.retry();
assert_eq!(cache.retry_at, Some(later));
assert!(
cache.pending.contains(&path),
"the due recheck was re-asked"
);
cache.pending.clear();
cache.entries.insert(path.clone(), unsettled(due));
cache.set_visible(&[PathBuf::from("/repo/elsewhere")]);
assert!(!cache.entries.contains_key(&path));
assert_eq!(cache.next_wake(), Some(later));
}
async fn deliver_row(
cache: &mut AheadBehindCache,
path: &Path,
inputs: Vec<(PathBuf, Inputs)>,
row: AheadBehindEntryWire,
) {
cache.pending.insert(path.to_path_buf());
cache
.results_tx
.send(FetchResult {
requested: inputs,
results: Ok(HashMap::from([(path.to_path_buf(), row)])),
})
.unwrap();
cache.changed().await;
}
fn row(shallow: bool) -> AheadBehindEntryWire {
AheadBehindEntryWire {
ahead: Some(1),
behind: Some(0),
main_behind: None,
shallow,
}
}
#[tokio::test]
async fn a_shallow_row_is_shown_but_not_settled() {
let mut cache = cache();
let path = PathBuf::from("/repo/wt");
cache.observe(at_head(&path, "aaa"));
deliver_row(&mut cache, &path, at_head(&path, "aaa"), row(true)).await;
assert_eq!(
cache.get(&path),
AheadBehindState::Known {
ahead: 1,
behind: 0,
main_behind: None
}
);
let recheck = cache.entries[&path]
.recheck
.expect("a shallow row rechecks");
assert_eq!(recheck.attempts, 0);
assert!(recheck.at > Instant::now() + Duration::from_secs(20));
assert_eq!(cache.next_wake(), Some(recheck.at));
cache.set_visible(std::slice::from_ref(&path));
assert!(cache.pending.is_empty());
}
#[tokio::test]
async fn a_complete_clones_row_is_never_re_asked() {
let mut cache = cache();
let path = PathBuf::from("/repo/wt");
cache.observe(at_head(&path, "aaa"));
deliver_row(&mut cache, &path, at_head(&path, "aaa"), row(false)).await;
assert!(cache.entries[&path].recheck.is_none());
assert!(cache.next_wake().is_none());
}
#[tokio::test]
async fn a_shallow_row_is_re_asked_on_the_timer_and_keeps_showing_meanwhile() {
let (_dir, sock, _server) = fake_daemon_replies(vec![
json!({ "ok": true, "payload": { "results": {
"/repo/wt": { "ahead": 1, "behind": 0, "shallow": true }
}}}),
json!({ "ok": true, "payload": { "results": {
"/repo/wt": { "ahead": 5, "behind": 0, "shallow": true }
}}}),
]);
let mut cache = AheadBehindCache::new(WorktreesClient::new(sock));
cache.shallow_recheck = Duration::from_millis(5);
let path = PathBuf::from("/repo/wt");
let counts = |ahead| AheadBehindState::Known {
ahead,
behind: 0,
main_behind: None,
};
cache.observe(at_head(&path, "aaa"));
cache.set_visible(std::slice::from_ref(&path));
settle(&mut cache).await;
assert_eq!(cache.get(&path), counts(1));
settle(&mut cache).await;
assert!(cache.pending.contains(&path));
assert_eq!(cache.get(&path), counts(1));
settle(&mut cache).await;
assert_eq!(cache.get(&path), counts(5));
assert!(cache.entries[&path].recheck.is_some());
}
#[tokio::test]
async fn a_shallow_clone_that_was_unshallowed_becomes_settled() {
let mut cache = cache();
let path = PathBuf::from("/repo/wt");
cache.observe(at_head(&path, "aaa"));
deliver_row(&mut cache, &path, at_head(&path, "aaa"), row(true)).await;
assert!(cache.entries[&path].recheck.is_some());
deliver_row(&mut cache, &path, at_head(&path, "aaa"), row(false)).await;
assert!(cache.entries[&path].recheck.is_none());
assert!(cache.next_wake().is_none());
}
#[tokio::test]
async fn a_moved_input_still_drops_a_shallow_row_at_once() {
let mut cache = cache();
let path = PathBuf::from("/repo/wt");
cache.observe(at_head(&path, "aaa"));
deliver_row(&mut cache, &path, at_head(&path, "aaa"), row(true)).await;
cache.observe(at_head(&path, "bbb"));
assert!(!cache.entries.contains_key(&path));
assert!(cache.next_wake().is_none());
}
#[tokio::test]
async fn a_failed_re_ask_of_an_unsettled_row_waits_out_the_backoff_instead_of_spinning() {
let mut cache = cache();
let path = PathBuf::from("/repo/wt");
let overdue = Instant::now();
cache.entries.insert(
path.clone(),
Entry {
state: AheadBehindState::Unknown,
recheck: Some(Recheck {
at: overdue,
attempts: 1,
}),
},
);
cache.pending.insert(path.clone());
cache
.results_tx
.send(FetchResult {
requested: asked(&path),
results: Err(anyhow::anyhow!("daemon unreachable")),
})
.unwrap();
cache.changed().await;
let wake = cache.next_wake().expect("a retry is scheduled");
assert!(wake > Instant::now(), "the timer would fire at once");
let held = cache.entries[&path].recheck.unwrap();
assert!(held.at > overdue, "the recheck was held to the backoff");
assert_eq!(held.attempts, 1, "a failed fetch is not another omission");
let waited = tokio::time::timeout(Duration::from_millis(30), cache.changed()).await;
assert!(waited.is_err());
}
#[test]
fn a_path_the_tree_has_not_reported_has_the_default_inputs() {
assert_eq!(NO_INPUTS, Inputs::default());
}
}