#![cfg_attr(docsrs, feature(doc_cfg))]
#![deny(rustdoc::broken_intra_doc_links)]
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering};
use std::time::Duration;
use notify::{RecursiveMode, Watcher};
use tokio::sync::mpsc;
use vcs_core::{BackendKind, VcsRepo};
mod error;
mod event;
pub use error::{Error, Result, WatchError};
pub use event::{RepoChange, RepoEvent};
pub use vcs_core::{OperationState, RepoSnapshot};
pub use processkit;
const DEFAULT_DEBOUNCE: Duration = Duration::from_millis(250);
const DEFAULT_MAX_WAIT: Duration = Duration::from_secs(1);
const MAX_WAIT_CEILING: Duration = Duration::from_secs(60 * 60 * 24 * 365);
pub const DEFAULT_REQUERY_TIMEOUT: Duration = Duration::from_secs(30);
const OUTPUT_CAPACITY: usize = 64;
const REQUERY_RETRY_LIMIT: u32 = 3;
const REQUERY_RETRY_BACKOFF: Duration = Duration::from_millis(100);
const REQUERY_RETRY_BACKOFF_MAX: Duration = Duration::from_secs(5);
#[derive(Clone, Copy)]
enum WatchSignal {
Change,
BackendFailed,
}
struct LoopConfig {
debounce: Duration,
max_wait: Duration,
requery_timeout: Option<Duration>,
snapshot_working_copy: bool,
output_capacity: usize,
retry_limit: u32,
retry_backoff: Duration,
}
pub struct Builder {
repo: Box<dyn VcsRepo>,
working_tree: bool,
snapshot_working_copy: bool,
debounce: Duration,
max_wait: Duration,
requery_timeout: Option<Duration>,
}
impl Builder {
pub fn working_tree(mut self, yes: bool) -> Self {
self.working_tree = yes;
self
}
pub fn snapshot_working_copy(mut self, yes: bool) -> Self {
self.snapshot_working_copy = yes;
self
}
pub fn debounce(mut self, window: Duration) -> Self {
self.debounce = window;
self
}
pub fn max_wait(mut self, ceiling: Duration) -> Self {
self.max_wait = ceiling;
self
}
pub fn requery_timeout(mut self, timeout: Option<Duration>) -> Self {
self.requery_timeout = timeout;
self
}
pub async fn build(self) -> Result<RepoWatcher> {
let root = self.repo.root().to_path_buf();
let state_dirs = state_dirs(self.repo.kind(), &root)?;
let (raw_tx, raw_rx) = mpsc::channel::<WatchSignal>(1);
let stats = Arc::new(StatsInner::default());
let cb_stats = Arc::clone(&stats);
let watch_failed = Arc::new(AtomicBool::new(false));
let cb_watch_failed = Arc::clone(&watch_failed);
let mut watcher =
notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
if res.is_err() {
cb_stats.note_watch_error();
if !cb_watch_failed.swap(true, Ordering::AcqRel) {
cb_stats.note_terminal_failure();
}
}
let signal = if res.is_err() {
WatchSignal::BackendFailed
} else {
WatchSignal::Change
};
let _ = raw_tx.try_send(signal);
})?;
if self.working_tree {
watcher.watch(&root, RecursiveMode::Recursive)?;
for dir in &state_dirs {
if !dir.starts_with(&root) {
watcher.watch(dir, RecursiveMode::Recursive)?;
}
}
} else {
for dir in &state_dirs {
watcher.watch(dir, RecursiveMode::Recursive)?;
}
}
let (snapshot, branches) = capture_baseline(
&*self.repo,
self.requery_timeout,
self.snapshot_working_copy,
)
.await?;
let baseline = snapshot.clone();
let prev = event::WatchState::from_snapshot(&snapshot, branches);
let config = LoopConfig {
debounce: self.debounce,
max_wait: self.max_wait,
requery_timeout: self.requery_timeout,
snapshot_working_copy: self.snapshot_working_copy,
output_capacity: OUTPUT_CAPACITY,
retry_limit: REQUERY_RETRY_LIMIT,
retry_backoff: REQUERY_RETRY_BACKOFF,
};
let (out_tx, out_rx) = mpsc::channel::<RepoChange>(config.output_capacity);
let task = tokio::spawn(watch_loop(
self.repo,
raw_rx,
out_tx,
prev,
config,
Arc::clone(&stats),
watch_failed,
));
Ok(RepoWatcher {
rx: out_rx,
current: baseline,
stats,
_watcher: watcher,
task,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum WatcherErrorKind {
Snapshot,
Branches,
Timeout,
}
#[derive(Debug, Clone, Copy)]
#[non_exhaustive]
pub struct WatcherStats {
pub requeries: u64,
pub changes: u64,
pub skipped: u64,
pub retries: u64,
pub recoveries: u64,
pub terminal_failures: u64,
pub last_error: Option<WatcherErrorKind>,
pub watch_errors: u64,
}
#[derive(Default)]
struct StatsInner {
requeries: AtomicU64,
changes: AtomicU64,
skipped: AtomicU64,
last_error: AtomicU8,
watch_errors: AtomicU64,
retries: AtomicU64,
recoveries: AtomicU64,
terminal_failures: AtomicU64,
}
impl StatsInner {
fn note_requery(&self) {
self.requeries.fetch_add(1, Ordering::Relaxed);
}
fn note_change(&self) {
self.changes.fetch_add(1, Ordering::Relaxed);
}
fn note_watch_error(&self) {
self.watch_errors.fetch_add(1, Ordering::Relaxed);
}
fn note_retry(&self) {
self.retries.fetch_add(1, Ordering::Relaxed);
}
fn note_recovery(&self) {
self.recoveries.fetch_add(1, Ordering::Relaxed);
}
fn note_terminal_failure(&self) {
self.terminal_failures.fetch_add(1, Ordering::Relaxed);
}
fn note_skip(&self, kind: WatcherErrorKind) {
self.skipped.fetch_add(1, Ordering::Relaxed);
let code = match kind {
WatcherErrorKind::Snapshot => 1,
WatcherErrorKind::Branches => 2,
WatcherErrorKind::Timeout => 3,
};
self.last_error.store(code, Ordering::Relaxed);
}
fn snapshot(&self) -> WatcherStats {
let last_error = match self.last_error.load(Ordering::Relaxed) {
1 => Some(WatcherErrorKind::Snapshot),
2 => Some(WatcherErrorKind::Branches),
3 => Some(WatcherErrorKind::Timeout),
_ => None,
};
WatcherStats {
requeries: self.requeries.load(Ordering::Relaxed),
changes: self.changes.load(Ordering::Relaxed),
skipped: self.skipped.load(Ordering::Relaxed),
retries: self.retries.load(Ordering::Relaxed),
recoveries: self.recoveries.load(Ordering::Relaxed),
terminal_failures: self.terminal_failures.load(Ordering::Relaxed),
last_error,
watch_errors: self.watch_errors.load(Ordering::Relaxed),
}
}
}
pub struct RepoWatcher {
rx: mpsc::Receiver<RepoChange>,
current: RepoSnapshot,
stats: Arc<StatsInner>,
_watcher: notify::RecommendedWatcher,
task: tokio::task::JoinHandle<()>,
}
impl RepoWatcher {
pub fn builder(repo: impl VcsRepo + 'static) -> Builder {
Builder {
repo: Box::new(repo),
working_tree: false,
snapshot_working_copy: false,
debounce: DEFAULT_DEBOUNCE,
max_wait: DEFAULT_MAX_WAIT,
requery_timeout: Some(DEFAULT_REQUERY_TIMEOUT),
}
}
pub async fn watch(repo: impl VcsRepo + 'static) -> Result<RepoWatcher> {
Self::builder(repo).build().await
}
pub async fn recv(&mut self) -> Option<RepoChange> {
let change = self.rx.recv().await?;
self.current = change.snapshot.clone();
Some(change)
}
pub fn current(&self) -> &RepoSnapshot {
&self.current
}
pub fn stats(&self) -> WatcherStats {
self.stats.snapshot()
}
}
#[cfg(feature = "stream")]
#[cfg_attr(docsrs, doc(cfg(feature = "stream")))]
impl futures_core::Stream for RepoWatcher {
type Item = RepoChange;
fn poll_next(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Option<RepoChange>> {
let this = self.get_mut();
match this.rx.poll_recv(cx) {
std::task::Poll::Ready(Some(change)) => {
this.current = change.snapshot.clone();
std::task::Poll::Ready(Some(change))
}
other => other,
}
}
}
impl Drop for RepoWatcher {
fn drop(&mut self) {
self.task.abort();
}
}
async fn read_state(
repo: &dyn VcsRepo,
snapshot_working_copy: bool,
) -> vcs_core::Result<(vcs_core::RepoSnapshot, Vec<String>)> {
if snapshot_working_copy {
let snapshot = repo.snapshot().await?;
let branches = repo.local_branches().await?;
Ok((snapshot, branches))
} else {
let snapshot = repo.snapshot_readonly().await?;
let branches = repo.local_branches_readonly().await?;
Ok((snapshot, branches))
}
}
async fn capture_baseline(
repo: &dyn VcsRepo,
requery_timeout: Option<Duration>,
snapshot_working_copy: bool,
) -> Result<(vcs_core::RepoSnapshot, Vec<String>)> {
let query = async {
read_state(repo, snapshot_working_copy)
.await
.map_err(Error::from)
};
match requery_timeout {
Some(limit) => match tokio::time::timeout(limit, query).await {
Ok(result) => result,
Err(_elapsed) => Err(Error::Io(std::io::Error::new(
std::io::ErrorKind::TimedOut,
format!("baseline snapshot exceeded the {limit:?} requery_timeout"),
))),
},
None => query.await,
}
}
async fn watch_loop(
repo: Box<dyn VcsRepo>,
mut raw_rx: mpsc::Receiver<WatchSignal>,
out_tx: mpsc::Sender<RepoChange>,
mut prev: event::WatchState,
config: LoopConfig,
stats: Arc<StatsInner>,
watch_failed: Arc<AtomicBool>,
) {
'watch: loop {
match raw_rx.recv().await {
None | Some(WatchSignal::BackendFailed) => return,
Some(WatchSignal::Change) if watch_failed.load(Ordering::Acquire) => return,
Some(WatchSignal::Change) => {}
}
if drain(&mut raw_rx) || watch_failed.load(Ordering::Acquire) {
return;
}
let deadline = tokio::time::Instant::now() + config.max_wait.min(MAX_WAIT_CEILING);
loop {
tokio::select! {
biased;
sig = raw_rx.recv() => {
match sig {
None | Some(WatchSignal::BackendFailed) => return,
Some(WatchSignal::Change) => {}
}
if watch_failed.load(Ordering::Acquire) {
return;
}
if drain(&mut raw_rx) {
return;
}
if tokio::time::Instant::now() >= deadline {
break; }
}
_ = tokio::time::sleep_until(deadline) => break, _ = tokio::time::sleep(config.debounce) => break, }
}
let mut retry = 0;
let (snapshot, branches) = loop {
stats.note_requery();
let requery = async {
let (snapshot, branches) = if config.snapshot_working_copy {
let snapshot = repo
.snapshot()
.await
.map_err(|e| (WatcherErrorKind::Snapshot, e))?;
let branches = repo
.local_branches()
.await
.map_err(|e| (WatcherErrorKind::Branches, e))?;
(snapshot, branches)
} else {
let snapshot = repo
.snapshot_readonly()
.await
.map_err(|e| (WatcherErrorKind::Snapshot, e))?;
let branches = repo
.local_branches_readonly()
.await
.map_err(|e| (WatcherErrorKind::Branches, e))?;
(snapshot, branches)
};
Ok::<_, (WatcherErrorKind, vcs_core::Error)>((snapshot, branches))
};
let outcome = match config.requery_timeout {
Some(limit) => match tokio::time::timeout(limit, requery).await {
Ok(result) => result.map_err(Some),
Err(_elapsed) => {
stats.note_skip(WatcherErrorKind::Timeout);
#[cfg(feature = "tracing")]
tracing::debug!(
timeout = ?limit,
retry,
"vcs-watch: re-query exceeded its deadline; scheduling retry"
);
Err(None)
}
},
None => requery.await.map_err(Some),
};
let result = match outcome {
Ok(pair) => Some(pair),
Err(Some((kind, _e))) => {
stats.note_skip(kind);
#[cfg(feature = "tracing")]
tracing::debug!(
error = %_e,
retry,
"vcs-watch: re-query failed; scheduling retry"
);
None
}
Err(None) => None,
};
if let Some(pair) = result {
if retry > 0 {
stats.note_recovery();
}
break pair;
}
if retry >= config.retry_limit {
continue 'watch;
}
stats.note_retry();
let delay = retry_backoff(config.retry_backoff, retry);
retry += 1;
let deadline = tokio::time::Instant::now() + delay;
loop {
tokio::select! {
signal = raw_rx.recv() => match signal {
None | Some(WatchSignal::BackendFailed) => return,
Some(WatchSignal::Change) => {
if drain(&mut raw_rx) || watch_failed.load(Ordering::Acquire) {
return;
}
}
},
_ = tokio::time::sleep_until(deadline) => break,
}
}
};
if watch_failed.load(Ordering::Acquire) {
return;
}
let next = event::WatchState::from_snapshot(&snapshot, branches);
let events = event::diff(&prev, &next);
prev = next;
if events.is_empty() {
continue;
}
if out_tx.send(RepoChange { snapshot, events }).await.is_err() {
return; }
stats.note_change();
}
}
fn drain(raw_rx: &mut mpsc::Receiver<WatchSignal>) -> bool {
let mut failed = false;
while let Ok(signal) = raw_rx.try_recv() {
failed |= matches!(signal, WatchSignal::BackendFailed);
}
failed
}
fn retry_backoff(base: Duration, retry: u32) -> Duration {
base.saturating_mul(1_u32.checked_shl(retry).unwrap_or(u32::MAX))
.min(REQUERY_RETRY_BACKOFF_MAX)
}
fn state_dirs(kind: BackendKind, root: &Path) -> Result<Vec<PathBuf>> {
let primary_state_dir = state_dir(kind, root)?;
let mut dirs = vec![primary_state_dir.clone()];
let mut add_git_dirs = |git_dir: PathBuf| {
if !dirs.iter().any(|dir| normalize(dir) == normalize(&git_dir)) {
dirs.push(git_dir.clone());
}
if let Some(shared) = common_dir(&git_dir)
&& !dirs.iter().any(|dir| normalize(dir) == normalize(&shared))
{
dirs.push(shared);
}
};
match kind {
BackendKind::Git => add_git_dirs(primary_state_dir),
BackendKind::Jj if root.join(".git").exists() => {
add_git_dirs(state_dir(BackendKind::Git, root)?)
}
_ => {}
}
Ok(dirs)
}
fn state_dir(kind: BackendKind, root: &Path) -> Result<PathBuf> {
match kind {
BackendKind::Jj => Ok(root.join(".jj")),
BackendKind::Git => {
let dot_git = root.join(".git");
if dot_git.is_file() {
let content = std::fs::read_to_string(&dot_git)?;
if let Some(rest) = content.trim().strip_prefix("gitdir:") {
let p = PathBuf::from(rest.trim());
return Ok(if p.is_absolute() { p } else { root.join(p) });
}
}
Ok(dot_git)
}
_ => Ok(root.to_path_buf()),
}
}
fn common_dir(state_dir: &Path) -> Option<PathBuf> {
let commondir = state_dir.join("commondir");
let content = std::fs::read_to_string(&commondir).ok()?;
let rel = content.trim();
if rel.is_empty() {
return None;
}
let p = PathBuf::from(rel);
let joined = if p.is_absolute() {
p
} else {
state_dir.join(p)
};
Some(lexically_normalized(&joined))
}
fn lexically_normalized(p: &Path) -> PathBuf {
use std::path::Component;
let mut out = PathBuf::new();
for comp in p.components() {
match comp {
Component::ParentDir => {
if !out.pop() {
out.push(comp);
}
}
Component::CurDir => {}
other => out.push(other),
}
}
out
}
fn normalize(p: &Path) -> PathBuf {
let canonical = p.canonicalize().unwrap_or_else(|_| p.to_path_buf());
#[cfg(windows)]
{
let s = canonical.to_string_lossy();
if let Some(rest) = s.strip_prefix(r"\\?\")
&& !rest.starts_with("UNC\\")
{
return PathBuf::from(rest.to_string());
}
}
canonical
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
pub(crate) struct Scratch(pub(crate) PathBuf);
impl Scratch {
pub(crate) fn new() -> Self {
let p = std::env::temp_dir().join(format!(
"vcs-watch-commondir-{}-{}",
std::process::id(),
COUNTER.fetch_add(1, Ordering::Relaxed)
));
std::fs::create_dir_all(&p).expect("create scratch dir");
Scratch(p)
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[test]
fn no_commondir_file_yields_none() {
let scratch = Scratch::new();
let git_dir = scratch.0.join(".git");
std::fs::create_dir_all(&git_dir).expect("mkdir .git");
assert_eq!(common_dir(&git_dir), None);
}
#[test]
fn relative_commondir_resolves_to_shared_git_dir() {
let scratch = Scratch::new();
let shared = scratch.0.join(".git");
let private = shared.join("worktrees").join("wt");
std::fs::create_dir_all(&private).expect("mkdir private gitdir");
std::fs::write(private.join("commondir"), "../..\n").expect("write commondir");
let resolved = common_dir(&private).expect("Some(shared dir)");
assert_eq!(resolved, lexically_normalized(&shared));
assert!(
!resolved.to_string_lossy().contains(".."),
"the `..` segments must be resolved, got {}",
resolved.display()
);
}
#[test]
fn absolute_commondir_is_used_verbatim() {
let scratch = Scratch::new();
let shared = scratch.0.join("shared-git");
let private = scratch.0.join("private");
std::fs::create_dir_all(&private).expect("mkdir private");
std::fs::write(private.join("commondir"), format!("{}\n", shared.display()))
.expect("write commondir");
assert_eq!(common_dir(&private), Some(lexically_normalized(&shared)));
}
#[test]
fn state_dirs_includes_private_and_shared_for_worktree() {
let scratch = Scratch::new();
let root = scratch.0.join("wt-worktree");
let shared = scratch.0.join(".git");
let private = shared.join("worktrees").join("wt");
std::fs::create_dir_all(&private).expect("mkdir private gitdir");
std::fs::create_dir_all(&root).expect("mkdir worktree root");
std::fs::write(private.join("commondir"), "../..\n").expect("write commondir");
std::fs::write(
root.join(".git"),
format!("gitdir: {}\n", private.display()),
)
.expect("write gitlink");
let dirs = state_dirs(BackendKind::Git, &root).expect("state_dirs");
assert_eq!(dirs.len(), 2, "private + shared, got {dirs:?}");
assert_eq!(normalize(&dirs[0]), normalize(&private));
assert_eq!(normalize(&dirs[1]), normalize(&shared));
}
#[test]
fn state_dirs_includes_git_dir_for_colocated_jj_repo() {
let scratch = Scratch::new();
let root = scratch.0.join("colocated");
std::fs::create_dir_all(root.join(".jj")).expect("mkdir .jj");
std::fs::create_dir_all(root.join(".git")).expect("mkdir .git");
let dirs = state_dirs(BackendKind::Jj, &root).expect("state_dirs");
assert_eq!(dirs, vec![root.join(".jj"), root.join(".git")]);
}
#[test]
fn state_dirs_excludes_missing_git_dir_for_pure_jj_repo() {
let scratch = Scratch::new();
let root = scratch.0.join("pure-jj");
std::fs::create_dir_all(root.join(".jj")).expect("mkdir .jj");
let dirs = state_dirs(BackendKind::Jj, &root).expect("state_dirs");
assert_eq!(dirs, vec![root.join(".jj")]);
}
#[test]
fn self_referential_commondir_is_deduped() {
let scratch = Scratch::new();
let git_dir = scratch.0.join(".git");
std::fs::create_dir_all(&git_dir).expect("mkdir .git");
std::fs::write(git_dir.join("commondir"), ".\n").expect("write commondir");
let root = scratch.0.join("root");
std::fs::create_dir_all(&root).expect("mkdir root");
std::fs::write(
root.join(".git"),
format!("gitdir: {}\n", git_dir.display()),
)
.expect("write gitlink");
let dirs = state_dirs(BackendKind::Git, &root).expect("state_dirs");
assert_eq!(dirs.len(), 1, "self-reference deduped, got {dirs:?}");
}
#[test]
fn stats_counts_watch_errors_independently() {
let stats = StatsInner::default();
assert_eq!(stats.snapshot().watch_errors, 0);
stats.note_watch_error();
stats.note_watch_error();
let snap = stats.snapshot();
assert_eq!(snap.watch_errors, 2, "watch errors counted");
assert_eq!(
(snap.requeries, snap.changes, snap.skipped),
(0, 0, 0),
"other counters unaffected"
);
assert_eq!(
(snap.retries, snap.recoveries, snap.terminal_failures),
(0, 0, 0),
"retry lifecycle counters unaffected"
);
assert!(snap.last_error.is_none());
}
}
#[cfg(test)]
mod pipeline_tests {
use super::tests::Scratch;
use super::*;
use processkit::ProcessRunner;
use processkit::testing::{Reply, ScriptedRunner};
use vcs_core::Repo;
use vcs_core::vcs_git::Git;
fn v2(head: &str) -> String {
format!("# branch.oid {head}\0# branch.head main\0")
}
fn scripted(gitdir: &Path, head: &str) -> ScriptedRunner {
ScriptedRunner::new()
.on(["git", "status"], Reply::ok(v2(head)))
.on(
["git", "rev-parse"],
Reply::ok(format!("{}\n", gitdir.display())),
)
.on(["git", "branch"], Reply::ok("* main\n"))
}
fn scripted_repo(gitdir: &Path, head: &str) -> Box<dyn VcsRepo> {
Box::new(Repo::from_git(
"/r",
"/r",
Git::with_runner(scripted(gitdir, head)),
))
}
async fn baseline(gitdir: &Path, head: &str) -> event::WatchState {
let repo = scripted_repo(gitdir, head);
let snap = repo.snapshot().await.expect("baseline snapshot");
let branches = repo.local_branches().await.expect("baseline branches");
event::WatchState::from_snapshot(&snap, branches)
}
fn defaults() -> LoopConfig {
LoopConfig {
debounce: Duration::from_millis(250),
max_wait: Duration::from_secs(1),
requery_timeout: Some(Duration::from_secs(30)),
snapshot_working_copy: false,
output_capacity: 64,
retry_limit: REQUERY_RETRY_LIMIT,
retry_backoff: REQUERY_RETRY_BACKOFF,
}
}
struct Harness {
sig: mpsc::Sender<WatchSignal>,
out: mpsc::Receiver<RepoChange>,
stats: Arc<StatsInner>,
watch_failed: Arc<AtomicBool>,
task: tokio::task::JoinHandle<()>,
}
impl Harness {
fn signal(&self) {
let _ = self.sig.try_send(WatchSignal::Change);
}
fn backend_failed(&self) {
self.stats.note_watch_error();
if !self.watch_failed.swap(true, Ordering::AcqRel) {
self.stats.note_terminal_failure();
}
let _ = self.sig.try_send(WatchSignal::BackendFailed);
}
}
fn spawn_loop(repo: Box<dyn VcsRepo>, prev: event::WatchState, config: LoopConfig) -> Harness {
let (sig, raw_rx) = mpsc::channel(1);
let (out_tx, out) = mpsc::channel(config.output_capacity);
let stats = Arc::new(StatsInner::default());
let watch_failed = Arc::new(AtomicBool::new(false));
let task = tokio::spawn(watch_loop(
repo,
raw_rx,
out_tx,
prev,
config,
Arc::clone(&stats),
Arc::clone(&watch_failed),
));
Harness {
sig,
out,
stats,
watch_failed,
task,
}
}
async fn settle() {
for _ in 0..32 {
tokio::task::yield_now().await;
}
}
#[tokio::test(start_paused = true)]
async fn debounce_coalesces_burst() {
let scratch = Scratch::new();
let prev = baseline(&scratch.0, "aaa").await;
let mut h = spawn_loop(scripted_repo(&scratch.0, "bbb"), prev, defaults());
for _ in 0..5 {
h.signal();
tokio::time::advance(Duration::from_millis(10)).await;
}
let change = h.out.recv().await.expect("one coalesced change");
assert!(
change
.events
.iter()
.any(|e| matches!(e, RepoEvent::HeadMoved { .. })),
"expected HeadMoved, got {:?}",
change.events
);
tokio::time::advance(Duration::from_secs(5)).await;
settle().await;
assert!(
h.out.try_recv().is_err(),
"burst must coalesce to one change"
);
let stats = h.stats.snapshot();
assert_eq!((stats.requeries, stats.changes), (1, 1));
}
#[tokio::test(start_paused = true)]
async fn max_wait_caps_continuous_signals() {
let scratch = Scratch::new();
let prev = baseline(&scratch.0, "aaa").await;
let h_config = defaults();
let mut h = spawn_loop(scripted_repo(&scratch.0, "bbb"), prev, h_config);
let pump_sig = h.sig.clone();
let pump = tokio::spawn(async move {
loop {
if let Err(mpsc::error::TrySendError::Closed(WatchSignal::Change)) =
pump_sig.try_send(WatchSignal::Change)
{
return;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
});
let change = tokio::time::timeout(Duration::from_secs(2), h.out.recv())
.await
.expect("the ceiling must fire within max_wait")
.expect("change");
assert!(
change
.events
.iter()
.any(|e| matches!(e, RepoEvent::HeadMoved { .. })),
"got {:?}",
change.events
);
pump.abort();
}
#[tokio::test(start_paused = true)]
async fn max_wait_duration_max_does_not_panic_the_loop() {
let scratch = Scratch::new();
let prev = baseline(&scratch.0, "aaa").await;
let config = LoopConfig {
max_wait: Duration::MAX,
..defaults()
};
let mut h = spawn_loop(scripted_repo(&scratch.0, "bbb"), prev, config);
h.signal();
tokio::time::advance(Duration::from_millis(300)).await; let change = h
.out
.recv()
.await
.expect("the loop survives a Duration::MAX max_wait and still re-queries");
assert!(!change.events.is_empty(), "got {:?}", change.events);
}
#[tokio::test(start_paused = true)]
async fn quiet_gap_triggers_requery() {
let scratch = Scratch::new();
let prev = baseline(&scratch.0, "aaa").await;
let mut h = spawn_loop(scripted_repo(&scratch.0, "bbb"), prev, defaults());
h.signal();
let change = h.out.recv().await.expect("change after the quiet gap");
assert!(
change
.events
.iter()
.any(|e| matches!(e, RepoEvent::HeadMoved { .. }))
);
}
#[tokio::test(start_paused = true)]
async fn no_change_yields_no_emission() {
let scratch = Scratch::new();
let prev = baseline(&scratch.0, "aaa").await;
let mut h = spawn_loop(scripted_repo(&scratch.0, "aaa"), prev, defaults());
h.signal();
settle().await; tokio::time::advance(Duration::from_millis(300)).await; settle().await;
let stats = h.stats.snapshot();
assert_eq!((stats.requeries, stats.changes, stats.skipped), (1, 0, 0));
assert!(
h.out.try_recv().is_err(),
"no events for an unchanged state"
);
}
struct FlakyStatus {
fails_left: AtomicU64,
gitdir: PathBuf,
head: &'static str,
}
#[async_trait::async_trait]
impl ProcessRunner for FlakyStatus {
async fn output_string(
&self,
command: &processkit::Command,
) -> processkit::Result<processkit::ProcessResult<String>> {
let is_status = command.arguments().first().map(|a| a == "status") == Some(true);
if is_status && self.fails_left.load(Ordering::Relaxed) > 0 {
self.fails_left.fetch_sub(1, Ordering::Relaxed);
return Err(processkit::Error::exit(
"git",
128,
"",
"fatal: Unable to create '.git/index.lock'",
));
}
scripted(&self.gitdir, self.head)
.output_string(command)
.await
}
}
#[tokio::test(start_paused = true)]
async fn transient_failure_skips_then_recovers() {
let scratch = Scratch::new();
let prev = baseline(&scratch.0, "aaa").await;
let repo = Box::new(Repo::from_git(
"/r",
"/r",
Git::with_runner(FlakyStatus {
fails_left: AtomicU64::new(1),
gitdir: scratch.0.clone(),
head: "bbb",
}),
));
let mut h = spawn_loop(repo, prev, defaults());
h.signal();
settle().await; tokio::time::advance(Duration::from_millis(300)).await;
settle().await; let stats = h.stats.snapshot();
assert_eq!((stats.requeries, stats.skipped, stats.changes), (1, 1, 0));
assert_eq!(stats.last_error, Some(WatcherErrorKind::Snapshot));
assert!(h.out.try_recv().is_err());
h.signal();
let change = h.out.recv().await.expect("recovered change");
assert!(
change
.events
.iter()
.any(|e| matches!(e, RepoEvent::HeadMoved { .. }))
);
let stats = h.stats.snapshot();
assert_eq!((stats.requeries, stats.changes), (2, 1));
}
struct Sleepy {
delay: Duration,
gitdir: PathBuf,
head: &'static str,
}
#[async_trait::async_trait]
impl ProcessRunner for Sleepy {
async fn output_string(
&self,
command: &processkit::Command,
) -> processkit::Result<processkit::ProcessResult<String>> {
tokio::time::sleep(self.delay).await;
scripted(&self.gitdir, self.head)
.output_string(command)
.await
}
}
struct SlowFirstStatus {
slow_left: AtomicBool,
delay: Duration,
gitdir: PathBuf,
head: &'static str,
}
#[async_trait::async_trait]
impl ProcessRunner for SlowFirstStatus {
async fn output_string(
&self,
command: &processkit::Command,
) -> processkit::Result<processkit::ProcessResult<String>> {
let is_status = command.arguments().first().map(|a| a == "status") == Some(true);
if is_status && self.slow_left.swap(false, Ordering::Relaxed) {
tokio::time::sleep(self.delay).await;
}
scripted(&self.gitdir, self.head)
.output_string(command)
.await
}
}
#[tokio::test(start_paused = true)]
async fn timeout_on_last_signal_recovers_via_backoff_retry() {
let scratch = Scratch::new();
let prev = baseline(&scratch.0, "aaa").await;
let repo = Box::new(Repo::from_git(
"/r",
"/r",
Git::with_runner(SlowFirstStatus {
slow_left: AtomicBool::new(true),
delay: Duration::from_secs(10),
gitdir: scratch.0.clone(),
head: "bbb",
}),
));
let config = LoopConfig {
requery_timeout: Some(Duration::from_secs(5)),
retry_backoff: Duration::from_secs(1),
..defaults()
};
let mut h = spawn_loop(repo, prev, config);
h.signal();
settle().await;
tokio::time::advance(Duration::from_millis(300)).await;
settle().await;
tokio::time::advance(Duration::from_secs(5)).await;
settle().await;
assert_eq!(
(h.stats.snapshot().requeries, h.stats.snapshot().retries),
(1, 1)
);
assert!(h.out.try_recv().is_err());
tokio::time::advance(Duration::from_secs(1)).await;
settle().await;
let change = h.out.try_recv().expect("retry emits the missed change");
assert!(
change
.events
.iter()
.any(|e| matches!(e, RepoEvent::HeadMoved { .. }))
);
let stats = h.stats.snapshot();
assert_eq!((stats.requeries, stats.skipped, stats.retries), (2, 1, 1));
assert_eq!((stats.recoveries, stats.changes), (1, 1));
}
#[tokio::test(start_paused = true)]
async fn persistent_requery_failure_exhausts_retries_without_busy_loop() {
let scratch = Scratch::new();
let prev = baseline(&scratch.0, "aaa").await;
let repo = Box::new(Repo::from_git(
"/r",
"/r",
Git::with_runner(FlakyStatus {
fails_left: AtomicU64::new(100),
gitdir: scratch.0.clone(),
head: "bbb",
}),
));
let config = LoopConfig {
retry_limit: 2,
retry_backoff: Duration::from_millis(100),
..defaults()
};
let h = spawn_loop(repo, prev, config);
h.signal();
settle().await;
tokio::time::advance(Duration::from_millis(300)).await;
settle().await;
tokio::time::advance(Duration::from_millis(100)).await;
settle().await;
tokio::time::advance(Duration::from_millis(200)).await;
settle().await;
let stats = h.stats.snapshot();
assert_eq!((stats.requeries, stats.skipped, stats.retries), (3, 3, 2));
assert_eq!(stats.recoveries, 0);
tokio::time::advance(Duration::from_secs(60 * 60)).await;
settle().await;
assert_eq!(
h.stats.snapshot().requeries,
3,
"exhaustion must park on the signal receiver"
);
}
#[tokio::test(start_paused = true)]
async fn drop_teardown_during_retry_backoff() {
let scratch = Scratch::new();
let prev = baseline(&scratch.0, "aaa").await;
let repo = Box::new(Repo::from_git(
"/r",
"/r",
Git::with_runner(FlakyStatus {
fails_left: AtomicU64::new(1),
gitdir: scratch.0.clone(),
head: "bbb",
}),
));
let config = LoopConfig {
retry_backoff: Duration::from_secs(60 * 60),
..defaults()
};
let Harness {
sig,
mut out,
stats,
watch_failed: _,
task,
} = spawn_loop(repo, prev, config);
sig.try_send(WatchSignal::Change).expect("send");
settle().await;
tokio::time::advance(Duration::from_millis(300)).await;
settle().await;
assert_eq!((stats.snapshot().skipped, stats.snapshot().retries), (1, 1));
drop(sig);
task.await.expect("loop exits while retry timer is pending");
assert!(out.recv().await.is_none());
}
#[tokio::test(start_paused = true)]
async fn permanent_backend_failure_closes_main_channel() {
let scratch = Scratch::new();
let prev = baseline(&scratch.0, "aaa").await;
let mut h = spawn_loop(scripted_repo(&scratch.0, "bbb"), prev, defaults());
h.backend_failed();
assert!(h.out.recv().await.is_none(), "backend death closes recv");
let stats = h.stats.snapshot();
assert_eq!((stats.watch_errors, stats.terminal_failures), (1, 1));
assert_eq!((stats.retries, stats.recoveries), (0, 0));
}
#[tokio::test(start_paused = true)]
async fn requery_timeout_skips_as_transient() {
let scratch = Scratch::new();
let prev = baseline(&scratch.0, "aaa").await;
let repo = Box::new(Repo::from_git(
"/r",
"/r",
Git::with_runner(Sleepy {
delay: Duration::from_secs(10),
gitdir: scratch.0.clone(),
head: "bbb",
}),
));
let config = LoopConfig {
requery_timeout: Some(Duration::from_secs(5)),
..defaults()
};
let mut h = spawn_loop(repo, prev, config);
h.signal();
settle().await; tokio::time::advance(Duration::from_millis(300)).await; settle().await; tokio::time::advance(Duration::from_secs(6)).await; settle().await;
let stats = h.stats.snapshot();
assert_eq!((stats.requeries, stats.skipped, stats.changes), (1, 1, 0));
assert_eq!(stats.last_error, Some(WatcherErrorKind::Timeout));
assert!(h.out.try_recv().is_err());
h.signal();
settle().await;
tokio::time::advance(Duration::from_millis(300)).await;
settle().await;
tokio::time::advance(Duration::from_secs(6)).await;
settle().await;
assert_eq!(h.stats.snapshot().requeries, 2);
}
#[tokio::test(start_paused = true)]
async fn baseline_capture_honors_requery_timeout() {
let scratch = Scratch::new();
let repo = Repo::from_git(
"/r",
"/r",
Git::with_runner(Sleepy {
delay: Duration::from_secs(10),
gitdir: scratch.0.clone(),
head: "bbb",
}),
);
let err = capture_baseline(&repo, Some(Duration::from_secs(5)), false)
.await
.expect_err("a wedged baseline must time out, not hang");
assert!(
matches!(&err, Error::Io(e) if e.kind() == std::io::ErrorKind::TimedOut),
"expected an Io TimedOut, got {err:?}"
);
assert!(err.is_transient(), "a baseline timeout is transient");
let ok = capture_baseline(&repo, None, false).await;
assert!(ok.is_ok(), "an unbounded baseline still succeeds: {ok:?}");
}
#[tokio::test(start_paused = true)]
async fn drop_teardown_mid_debounce() {
let scratch = Scratch::new();
let prev = baseline(&scratch.0, "aaa").await;
let Harness {
sig,
mut out,
stats: _,
watch_failed: _,
task,
} = spawn_loop(scripted_repo(&scratch.0, "bbb"), prev, defaults());
sig.try_send(WatchSignal::Change).expect("send");
tokio::time::advance(Duration::from_millis(100)).await; drop(sig);
tokio::time::timeout(Duration::from_secs(1), task)
.await
.expect("loop ends promptly")
.expect("loop task joins cleanly");
assert!(out.recv().await.is_none(), "output closes with the loop");
}
struct VaryingHead {
statuses: AtomicU64,
gitdir: PathBuf,
}
#[async_trait::async_trait]
impl ProcessRunner for VaryingHead {
async fn output_string(
&self,
command: &processkit::Command,
) -> processkit::Result<processkit::ProcessResult<String>> {
let is_status = command.arguments().first().map(|a| a == "status") == Some(true);
let n = if is_status {
self.statuses.fetch_add(1, Ordering::Relaxed)
} else {
self.statuses.load(Ordering::Relaxed)
};
scripted(&self.gitdir, &format!("h{n}"))
.output_string(command)
.await
}
}
#[tokio::test(start_paused = true)]
async fn backpressure_parks_loop() {
let scratch = Scratch::new();
let prev = baseline(&scratch.0, "base").await;
let repo = Box::new(Repo::from_git(
"/r",
"/r",
Git::with_runner(VaryingHead {
statuses: AtomicU64::new(0),
gitdir: scratch.0.clone(),
}),
));
let config = LoopConfig {
output_capacity: 1,
..defaults()
};
let mut h = spawn_loop(repo, prev, config);
h.signal();
settle().await; tokio::time::advance(Duration::from_millis(300)).await;
settle().await; h.signal();
settle().await;
tokio::time::advance(Duration::from_millis(300)).await;
settle().await;
let stats = h.stats.snapshot();
assert_eq!(
(stats.requeries, stats.changes),
(2, 1),
"second emission must be parked on the full channel"
);
let first = h.out.recv().await.expect("first change");
assert!(
first
.events
.iter()
.any(|e| matches!(e, RepoEvent::HeadMoved { .. }))
);
let second = h.out.recv().await.expect("second change");
assert!(
second
.events
.iter()
.any(|e| matches!(e, RepoEvent::HeadMoved { .. }))
);
settle().await;
assert_eq!(h.stats.snapshot().changes, 2);
}
#[cfg(feature = "stream")]
#[tokio::test(start_paused = true)]
async fn stream_yields_changes_and_advances_current() {
use tokio_stream::StreamExt;
let scratch = Scratch::new();
let prev = baseline(&scratch.0, "aaa").await;
let h = spawn_loop(scripted_repo(&scratch.0, "bbb"), prev, defaults());
let baseline_snap = scripted_repo(&scratch.0, "aaa")
.snapshot()
.await
.expect("baseline snapshot");
let mut watcher = RepoWatcher {
rx: h.out,
current: baseline_snap,
stats: h.stats,
_watcher: notify::recommended_watcher(|_res| {}).expect("idle watcher"),
task: h.task,
};
assert_eq!(watcher.current().head.as_deref(), Some("aaa"));
let _ = h.sig.try_send(WatchSignal::Change);
let change = watcher.next().await.expect("stream item");
assert!(
change
.events
.iter()
.any(|e| matches!(e, RepoEvent::HeadMoved { .. })),
"got {:?}",
change.events
);
assert_eq!(watcher.current().head.as_deref(), Some("bbb"));
}
}
#[doc = include_str!("../docs/watch.md")]
#[allow(rustdoc::broken_intra_doc_links)]
pub mod guide {}