use std::time::Duration;
use tokio::time::timeout;
use vcs_core::Repo;
use vcs_testkit::{GitSandbox, JjSandbox, TempDir};
use vcs_watch::{RepoEvent, RepoWatcher};
async fn wait_for(
watcher: &mut RepoWatcher,
overall: Duration,
pred: impl Fn(&RepoEvent) -> bool,
) -> bool {
let deadline = tokio::time::Instant::now() + overall;
loop {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
return false;
}
match timeout(remaining, watcher.recv()).await {
Ok(Some(change)) => {
if change.events.iter().any(&pred) {
return true;
}
}
Ok(None) | Err(_) => return false,
}
}
}
fn fast(repo: Repo) -> impl std::future::Future<Output = vcs_watch::Result<RepoWatcher>> {
RepoWatcher::builder(repo)
.debounce(Duration::from_millis(50))
.build()
}
#[tokio::test(flavor = "multi_thread")]
#[ignore = "requires the git binary"]
async fn git_branch_create_emits_branch_created() {
let sandbox = GitSandbox::init("watch-git-branch");
sandbox.commit_file("seed.txt", "seed\n", "initial");
let repo = Repo::discover(sandbox.path()).expect("open");
let mut watcher = fast(repo).await.expect("watcher");
sandbox.git(&["branch", "feature"]);
assert!(
wait_for(&mut watcher, Duration::from_secs(10), |e| {
matches!(e, RepoEvent::BranchCreated { name, .. } if name == "feature")
})
.await,
"expected a BranchCreated(feature) event"
);
}
#[tokio::test(flavor = "multi_thread")]
#[ignore = "requires the git binary"]
async fn git_worktree_sees_branch_created_from_main() {
let sandbox = GitSandbox::init("watch-git-wt");
sandbox.commit_file("seed.txt", "seed\n", "initial");
let wt_parent = TempDir::new("watch-git-wt-linked");
let wt_path = wt_parent.path().join("wt");
sandbox.git(&[
"worktree",
"add",
"-q",
"-b",
"wt-branch",
wt_path.to_str().expect("utf8 worktree path"),
]);
let repo = Repo::discover(&wt_path).expect("open worktree");
let mut watcher = fast(repo).await.expect("watcher");
sandbox.git(&["branch", "feature"]);
assert!(
wait_for(&mut watcher, Duration::from_secs(10), |e| {
matches!(e, RepoEvent::BranchCreated { name, .. } if name == "feature")
})
.await,
"worktree watcher must see a branch created in the shared git dir"
);
}
#[tokio::test(flavor = "multi_thread")]
#[ignore = "requires the git binary"]
async fn git_working_tree_edit_emits_working_copy_changed() {
let sandbox = GitSandbox::init("watch-git-wc");
sandbox.commit_file("seed.txt", "seed\n", "initial");
let repo = Repo::discover(sandbox.path()).expect("open");
let mut watcher = RepoWatcher::builder(repo)
.working_tree(true)
.debounce(Duration::from_millis(50))
.build()
.await
.expect("watcher");
sandbox.write("dirty.txt", "x\n");
assert!(
wait_for(&mut watcher, Duration::from_secs(10), |e| {
matches!(e, RepoEvent::WorkingCopyChanged { dirty: true, .. })
})
.await,
"expected a WorkingCopyChanged(dirty) event"
);
}
#[tokio::test(flavor = "multi_thread")]
#[ignore = "requires the jj binary"]
async fn jj_bookmark_create_emits_branch_created() {
let sandbox = JjSandbox::init("watch-jj-bm");
sandbox.write("seed.txt", "seed\n");
sandbox.describe("initial");
let repo = Repo::discover(sandbox.path()).expect("open");
let mut watcher = fast(repo).await.expect("watcher");
sandbox.bookmark("feature");
assert!(
wait_for(&mut watcher, Duration::from_secs(10), |e| {
matches!(e, RepoEvent::BranchCreated { name, .. } if name == "feature")
})
.await,
"expected a BranchCreated(feature) event on jj"
);
}
#[tokio::test(flavor = "multi_thread")]
#[ignore = "requires the jj binary"]
async fn jj_read_only_requery_records_no_operation_and_moves_nothing() {
let sandbox = JjSandbox::init("watch-jj-readonly");
sandbox.write("seed.txt", "seed\n");
sandbox.describe("initial");
sandbox.new_change("work");
let repo = Repo::discover(sandbox.path()).expect("open");
let mut watcher = RepoWatcher::builder(repo)
.working_tree(true)
.debounce(Duration::from_millis(50))
.build()
.await
.expect("watcher");
let op_before = sandbox.op_head();
let at_before = sandbox.at_commit();
let requeries_before = watcher.stats().requeries;
for i in 0..5 {
sandbox.write("dirty.txt", &format!("edit {i}\n"));
tokio::time::sleep(Duration::from_millis(80)).await;
}
let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
while watcher.stats().requeries <= requeries_before {
assert!(
tokio::time::Instant::now() < deadline,
"re-queries never ran (requeries stuck at {requeries_before})"
);
tokio::time::sleep(Duration::from_millis(50)).await;
}
assert_eq!(
sandbox.op_head(),
op_before,
"a read-only re-query must not record a jj operation"
);
assert_eq!(
sandbox.at_commit(),
at_before,
"a read-only re-query must not move `@`"
);
let quiet = timeout(Duration::from_millis(500), watcher.recv()).await;
assert!(
quiet.is_err(),
"the default read-only watcher must not surface an unsnapshotted bare edit \
as an event, got {quiet:?}"
);
}
#[tokio::test(flavor = "multi_thread")]
#[ignore = "requires the jj binary"]
async fn jj_snapshot_working_copy_opt_in_observes_bare_edit() {
let sandbox = JjSandbox::init("watch-jj-snapshot");
sandbox.write("seed.txt", "seed\n");
sandbox.describe("initial");
sandbox.new_change("work");
let repo = Repo::discover(sandbox.path()).expect("open");
let mut watcher = RepoWatcher::builder(repo)
.working_tree(true)
.snapshot_working_copy(true)
.debounce(Duration::from_millis(50))
.build()
.await
.expect("watcher");
sandbox.write("dirty.txt", "x\n");
assert!(
wait_for(&mut watcher, Duration::from_secs(10), |e| {
matches!(e, RepoEvent::WorkingCopyChanged { dirty: true, .. })
})
.await,
"opt-in snapshot_working_copy must observe a bare working-tree edit"
);
}
#[tokio::test(flavor = "multi_thread")]
#[ignore = "requires the git binary"]
async fn drop_stops_the_watch() {
let sandbox = GitSandbox::init("watch-drop");
sandbox.commit_file("seed.txt", "seed\n", "initial");
let repo = Repo::discover(sandbox.path()).expect("open");
let mut watcher = fast(repo).await.expect("watcher");
let quiet = timeout(Duration::from_millis(300), watcher.recv()).await;
assert!(quiet.is_err(), "no events expected on a quiescent repo");
drop(watcher); }