use crate::watch::git_state::{GitChangeClass, GitStateWatcher, LastIndexedGitState};
use anyhow::{Context, Result};
use ignore::gitignore::{Gitignore, GitignoreBuilder};
use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher as NotifyWatcher};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{Receiver, TryRecvError, channel};
use std::time::{Duration, Instant};
#[derive(Debug, Clone)]
pub struct ChangeSet {
pub changed_files: Vec<PathBuf>,
pub git_state_changed: bool,
pub git_change_class: Option<GitChangeClass>,
}
impl ChangeSet {
#[must_use]
pub fn is_empty(&self) -> bool {
self.changed_files.is_empty() && !self.git_state_changed
}
#[must_use]
pub fn requires_full_rebuild(&self) -> bool {
self.git_change_class
.is_some_and(GitChangeClass::requires_full_rebuild)
}
}
#[derive(Debug, Clone)]
enum RawChange {
Create(PathBuf),
Modify(PathBuf),
Remove(PathBuf),
}
impl RawChange {
fn path(&self) -> &Path {
match self {
Self::Create(p) | Self::Modify(p) | Self::Remove(p) => p,
}
}
}
pub struct SourceTreeWatcher {
_watcher: RecommendedWatcher,
receiver: Receiver<Result<Event, notify::Error>>,
root: PathBuf,
ignore_matcher: Gitignore,
git_state: GitStateWatcher,
}
impl SourceTreeWatcher {
pub fn new(root: &Path) -> Result<Self> {
let root = std::fs::canonicalize(root)
.with_context(|| format!("Failed to canonicalize root: {}", root.display()))?;
let ignore_matcher = build_gitignore_matcher(&root);
let (tx, rx) = channel();
let mut watcher = notify::recommended_watcher(move |res| {
let _ = tx.send(res);
})
.context("Failed to create source-tree watcher")?;
watcher
.watch(&root, RecursiveMode::Recursive)
.with_context(|| format!("Failed to watch source tree: {}", root.display()))?;
let git_state = GitStateWatcher::new(&root)
.with_context(|| format!("Failed to create git-state watcher at {}", root.display()))?;
log::info!("SourceTreeWatcher started for: {}", root.display());
Ok(Self {
_watcher: watcher,
receiver: rx,
root,
ignore_matcher,
git_state,
})
}
#[must_use]
pub fn root(&self) -> &Path {
&self.root
}
#[must_use]
pub fn git_state(&self) -> &GitStateWatcher {
&self.git_state
}
pub fn wait_for_changes(
&self,
debounce: Duration,
last_git_state: Option<&LastIndexedGitState>,
) -> Result<ChangeSet> {
let mut raw_changes: Vec<RawChange> = Vec::new();
let first_event = self
.receiver
.recv()
.context("Source-tree watcher channel disconnected")?;
if let Ok(event) = first_event {
collect_raw_changes(&event, &mut raw_changes);
}
let mut deadline = Instant::now() + debounce;
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
break;
}
match self
.receiver
.recv_timeout(remaining.min(Duration::from_millis(10)))
{
Ok(Ok(event)) => {
collect_raw_changes(&event, &mut raw_changes);
deadline = Instant::now() + debounce;
}
Ok(Err(e)) => {
log::warn!("Source-tree watcher error: {e}");
deadline = Instant::now() + debounce;
}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
if Instant::now() >= deadline {
break;
}
}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
log::error!("Source-tree watcher channel disconnected during debounce");
break;
}
}
}
let git_state_changed = self.git_state.poll_changed();
self.build_changeset(raw_changes, git_state_changed, last_git_state)
}
pub fn wait_for_changes_cancellable(
&self,
debounce: Duration,
last_git_state: Option<&LastIndexedGitState>,
cancelled: &AtomicBool,
cancel_poll_period: Duration,
) -> Result<Option<ChangeSet>> {
let mut raw_changes: Vec<RawChange> = Vec::new();
loop {
if cancelled.load(Ordering::Acquire) {
return Ok(None);
}
match self.receiver.recv_timeout(cancel_poll_period) {
Ok(Ok(event)) => {
collect_raw_changes(&event, &mut raw_changes);
break;
}
Ok(Err(e)) => {
log::warn!("Source-tree watcher error: {e}");
break;
}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
anyhow::bail!("Source-tree watcher channel disconnected before first event");
}
}
}
let mut deadline = Instant::now() + debounce;
loop {
if cancelled.load(Ordering::Acquire) {
return Ok(None);
}
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
break;
}
let slice = remaining
.min(Duration::from_millis(10))
.min(cancel_poll_period);
match self.receiver.recv_timeout(slice) {
Ok(Ok(event)) => {
collect_raw_changes(&event, &mut raw_changes);
deadline = Instant::now() + debounce;
}
Ok(Err(e)) => {
log::warn!("Source-tree watcher error: {e}");
deadline = Instant::now() + debounce;
}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
if Instant::now() >= deadline {
break;
}
}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
log::error!("Source-tree watcher channel disconnected during debounce");
break;
}
}
}
let git_state_changed = self.git_state.poll_changed();
self.build_changeset(raw_changes, git_state_changed, last_git_state)
.map(Some)
}
pub fn poll_changes(
&self,
last_git_state: Option<&LastIndexedGitState>,
) -> Result<Option<ChangeSet>> {
let mut raw_changes: Vec<RawChange> = Vec::new();
loop {
match self.receiver.try_recv() {
Ok(Ok(event)) => {
collect_raw_changes(&event, &mut raw_changes);
}
Ok(Err(e)) => {
log::warn!("Source-tree watcher error: {e}");
}
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => {
anyhow::bail!("Source-tree watcher channel disconnected");
}
}
}
let git_state_changed = self.git_state.poll_changed();
if raw_changes.is_empty() && !git_state_changed {
return Ok(None);
}
self.build_changeset(raw_changes, git_state_changed, last_git_state)
.map(Some)
}
fn build_changeset(
&self,
raw_changes: Vec<RawChange>,
git_state_changed: bool,
last_git_state: Option<&LastIndexedGitState>,
) -> Result<ChangeSet> {
let filtered: Vec<RawChange> = raw_changes
.into_iter()
.filter(|change| {
let path = change.path();
!is_under_git_dir(path, &self.root)
&& !self.is_gitignored(path)
&& !is_editor_temporary(path)
})
.collect();
let coalesced = coalesce_rename_pairs(filtered);
let mut deduped: HashMap<PathBuf, &RawChange> = HashMap::new();
for change in &coalesced {
deduped.insert(change.path().to_path_buf(), change);
}
let changed_files: Vec<PathBuf> = deduped.into_keys().collect();
let git_change_class = if git_state_changed {
last_git_state.map(|last| self.git_state.classify(last))
} else {
None
};
Ok(ChangeSet {
changed_files,
git_state_changed,
git_change_class,
})
}
fn is_gitignored(&self, path: &Path) -> bool {
let is_dir = path.is_dir();
let rel = path.strip_prefix(&self.root).unwrap_or(path);
self.ignore_matcher
.matched_path_or_any_parents(rel, is_dir)
.is_ignore()
}
}
fn build_gitignore_matcher(root: &Path) -> Gitignore {
let mut builder = GitignoreBuilder::new(root);
let gitignore_path = root.join(".gitignore");
if gitignore_path.is_file()
&& let Some(err) = builder.add(&gitignore_path)
{
log::warn!("Error parsing {}: {err}", gitignore_path.display());
}
let mut dirs_to_scan = vec![root.to_path_buf()];
let mut depth = 0;
const MAX_DEPTH: usize = 20;
while !dirs_to_scan.is_empty() && depth < MAX_DEPTH {
let mut next_dirs = Vec::new();
for dir in &dirs_to_scan {
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => continue,
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
if path.file_name().is_some_and(|n| n == ".git") {
continue;
}
let sub_gitignore = path.join(".gitignore");
if sub_gitignore.is_file()
&& let Some(err) = builder.add(&sub_gitignore)
{
log::warn!("Error parsing {}: {err}", sub_gitignore.display());
}
next_dirs.push(path);
}
}
}
dirs_to_scan = next_dirs;
depth += 1;
}
match builder.build() {
Ok(matcher) => matcher,
Err(e) => {
log::warn!("Failed to build gitignore matcher: {e}; using empty matcher");
Gitignore::empty()
}
}
}
fn is_under_git_dir(path: &Path, root: &Path) -> bool {
let git_dir = root.join(".git");
path.starts_with(&git_dir)
}
fn is_editor_temporary(path: &Path) -> bool {
let Some(file_name) = path.file_name().and_then(|n| n.to_str()) else {
return false;
};
if (file_name.ends_with(".swp") || file_name.ends_with(".swo"))
&& let Some(stem) = path.file_stem().and_then(|s| s.to_str())
&& stem.starts_with('.')
{
return true;
}
if file_name.ends_with('~') {
return true;
}
if file_name.starts_with('#') && file_name.ends_with('#') {
return true;
}
if file_name.starts_with(".#") {
return true;
}
if file_name.ends_with(".bak") {
return true;
}
if file_name.ends_with("___jb_tmp___") || file_name.ends_with("___jb_old___") {
return true;
}
false
}
fn collect_raw_changes(event: &Event, out: &mut Vec<RawChange>) {
match event.kind {
EventKind::Create(_) => {
for path in &event.paths {
if path.is_file() {
out.push(RawChange::Create(path.clone()));
}
}
}
EventKind::Modify(_) => {
for path in &event.paths {
out.push(RawChange::Modify(path.clone()));
}
}
EventKind::Remove(_) => {
for path in &event.paths {
out.push(RawChange::Remove(path.clone()));
}
}
_ => {
}
}
}
fn coalesce_rename_pairs(changes: Vec<RawChange>) -> Vec<RawChange> {
if changes.len() < 2 {
return changes;
}
let mut result: Vec<RawChange> = Vec::with_capacity(changes.len());
let mut consumed: Vec<bool> = vec![false; changes.len()];
for i in 0..changes.len() {
if consumed[i] {
continue;
}
if let RawChange::Remove(ref remove_path) = changes[i] {
let mut found_create = false;
for j in (i + 1)..changes.len() {
if consumed[j] {
continue;
}
if let RawChange::Create(ref create_path) = changes[j]
&& create_path == remove_path
{
result.push(RawChange::Modify(remove_path.clone()));
consumed[i] = true;
consumed[j] = true;
found_create = true;
break;
}
}
if !found_create {
result.push(changes[i].clone());
consumed[i] = true;
}
} else {
result.push(changes[i].clone());
consumed[i] = true;
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::process::Command;
use std::thread;
use tempfile::TempDir;
fn event_timeout() -> Duration {
let base = if cfg!(target_os = "macos") {
Duration::from_secs(3)
} else {
Duration::from_secs(2)
};
if std::env::var("CI").is_ok() {
base * 2
} else {
base
}
}
fn init_repo(dir: &Path) {
run_git(dir, &["init", "-q", "-b", "main"]);
run_git(dir, &["config", "user.email", "test@sqry.dev"]);
run_git(dir, &["config", "user.name", "Sqry Test"]);
run_git(dir, &["config", "commit.gpgsign", "false"]);
fs::write(dir.join("a.txt"), b"alpha\n").unwrap();
run_git(dir, &["add", "a.txt"]);
run_git(dir, &["commit", "-q", "-m", "initial"]);
}
fn run_git(dir: &Path, args: &[&str]) {
let status = Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.status()
.expect("git command failed to launch");
assert!(status.success(), "git {args:?} failed in {}", dir.display());
}
fn wait_for_poll<F>(timeout: Duration, mut predicate: F) -> bool
where
F: FnMut() -> bool,
{
let deadline = Instant::now() + timeout;
loop {
if predicate() {
return true;
}
if Instant::now() >= deadline {
return false;
}
thread::sleep(Duration::from_millis(50));
}
}
#[test]
fn editor_temp_vim_swp() {
assert!(is_editor_temporary(Path::new("/tmp/.foo.swp")));
assert!(is_editor_temporary(Path::new("/tmp/.foo.swo")));
assert!(!is_editor_temporary(Path::new("/tmp/foo.swp")));
}
#[test]
fn editor_temp_emacs_backup() {
assert!(is_editor_temporary(Path::new("/tmp/foo.rs~")));
assert!(is_editor_temporary(Path::new("/tmp/#foo.rs#")));
assert!(is_editor_temporary(Path::new("/tmp/.#foo.rs")));
}
#[test]
fn editor_temp_vscode_bak() {
assert!(is_editor_temporary(Path::new("/tmp/foo.rs.bak")));
}
#[test]
fn editor_temp_jetbrains() {
assert!(is_editor_temporary(Path::new("/tmp/foo.rs___jb_tmp___")));
assert!(is_editor_temporary(Path::new("/tmp/foo.rs___jb_old___")));
}
#[test]
fn non_temp_files_pass_through() {
assert!(!is_editor_temporary(Path::new("/tmp/foo.rs")));
assert!(!is_editor_temporary(Path::new("/tmp/Makefile")));
assert!(!is_editor_temporary(Path::new("/tmp/README.md")));
}
#[test]
fn git_dir_detection() {
let root = Path::new("/repo");
assert!(is_under_git_dir(Path::new("/repo/.git/HEAD"), root));
assert!(is_under_git_dir(
Path::new("/repo/.git/refs/heads/main"),
root
));
assert!(!is_under_git_dir(Path::new("/repo/src/main.rs"), root));
assert!(!is_under_git_dir(Path::new("/repo/.gitignore"), root));
}
#[test]
fn coalesce_empty() {
let result = coalesce_rename_pairs(vec![]);
assert!(result.is_empty());
}
#[test]
fn coalesce_single_event_passthrough() {
let changes = vec![RawChange::Modify(PathBuf::from("foo.rs"))];
let result = coalesce_rename_pairs(changes);
assert_eq!(result.len(), 1);
assert!(matches!(&result[0], RawChange::Modify(p) if p == Path::new("foo.rs")));
}
#[test]
fn coalesce_remove_create_same_path_becomes_modify() {
let changes = vec![
RawChange::Remove(PathBuf::from("foo.rs")),
RawChange::Create(PathBuf::from("foo.rs")),
];
let result = coalesce_rename_pairs(changes);
assert_eq!(result.len(), 1);
assert!(
matches!(&result[0], RawChange::Modify(p) if p == Path::new("foo.rs")),
"Remove+Create should coalesce into Modify"
);
}
#[test]
fn coalesce_remove_create_different_paths_no_coalesce() {
let changes = vec![
RawChange::Remove(PathBuf::from("old.rs")),
RawChange::Create(PathBuf::from("new.rs")),
];
let result = coalesce_rename_pairs(changes);
assert_eq!(result.len(), 2);
}
#[test]
fn coalesce_interleaved_events() {
let changes = vec![
RawChange::Remove(PathBuf::from("a.rs")),
RawChange::Modify(PathBuf::from("b.rs")),
RawChange::Create(PathBuf::from("a.rs")),
];
let result = coalesce_rename_pairs(changes);
assert_eq!(result.len(), 2);
assert!(
result
.iter()
.any(|c| matches!(c, RawChange::Modify(p) if p == Path::new("a.rs")))
);
assert!(
result
.iter()
.any(|c| matches!(c, RawChange::Modify(p) if p == Path::new("b.rs")))
);
}
#[test]
fn coalesce_multiple_rename_pairs() {
let changes = vec![
RawChange::Remove(PathBuf::from("a.rs")),
RawChange::Remove(PathBuf::from("b.rs")),
RawChange::Create(PathBuf::from("a.rs")),
RawChange::Create(PathBuf::from("b.rs")),
];
let result = coalesce_rename_pairs(changes);
assert_eq!(result.len(), 2);
assert!(result.iter().all(|c| matches!(c, RawChange::Modify(_))));
}
#[test]
fn gitignore_filters_target_directory() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join(".gitignore"), "target/\n*.log\n").unwrap();
let matcher = build_gitignore_matcher(tmp.path());
assert!(
matcher
.matched_path_or_any_parents("target/debug/foo", false)
.is_ignore(),
"target/ contents should be ignored"
);
assert!(
matcher
.matched_path_or_any_parents("build.log", false)
.is_ignore(),
"*.log should be ignored"
);
assert!(
!matcher
.matched_path_or_any_parents("src/main.rs", false)
.is_ignore(),
"src/main.rs should not be ignored"
);
}
#[test]
fn gitignore_nested_rules() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join(".gitignore"), "*.o\n").unwrap();
fs::create_dir_all(tmp.path().join("vendor")).unwrap();
fs::write(tmp.path().join("vendor/.gitignore"), "*.vendored\n").unwrap();
let matcher = build_gitignore_matcher(tmp.path());
assert!(
matcher
.matched_path_or_any_parents("foo.o", false)
.is_ignore()
);
assert!(
matcher
.matched_path_or_any_parents("vendor/lib.vendored", false)
.is_ignore()
);
}
#[test]
#[cfg_attr(target_os = "macos", ignore = "FSEvents timing flaky in CI")]
fn watcher_detects_source_file_change() {
let tmp = TempDir::new().unwrap();
init_repo(tmp.path());
fs::write(tmp.path().join(".gitignore"), "*.log\ntarget/\n").unwrap();
run_git(tmp.path(), &["add", ".gitignore"]);
run_git(tmp.path(), &["commit", "-q", "-m", "add gitignore"]);
let watcher = SourceTreeWatcher::new(tmp.path()).unwrap();
thread::sleep(Duration::from_millis(100));
fs::write(tmp.path().join("a.txt"), b"modified\n").unwrap();
let detected = wait_for_poll(event_timeout(), || {
let cs = watcher.poll_changes(None).unwrap();
cs.is_some_and(|cs| !cs.changed_files.is_empty())
});
assert!(detected, "Watcher should detect source file modification");
}
#[test]
#[cfg_attr(target_os = "macos", ignore = "FSEvents timing flaky in CI")]
fn watcher_filters_gitignored_files() {
let tmp = TempDir::new().unwrap();
init_repo(tmp.path());
fs::write(tmp.path().join(".gitignore"), "*.log\ntarget/\n").unwrap();
run_git(tmp.path(), &["add", ".gitignore"]);
run_git(tmp.path(), &["commit", "-q", "-m", "add gitignore"]);
let watcher = SourceTreeWatcher::new(tmp.path()).unwrap();
thread::sleep(Duration::from_millis(100));
fs::write(tmp.path().join("build.log"), b"log output\n").unwrap();
thread::sleep(Duration::from_millis(50));
fs::write(tmp.path().join("a.txt"), b"modified\n").unwrap();
let mut saw_log = false;
let saw_source = wait_for_poll(event_timeout(), || {
if let Some(cs) = watcher.poll_changes(None).unwrap() {
for path in &cs.changed_files {
if path.extension().is_some_and(|e| e == "log") {
saw_log = true;
}
}
cs.changed_files
.iter()
.any(|p| p.file_name().is_some_and(|n| n == "a.txt"))
} else {
false
}
});
assert!(saw_source, "Watcher should detect a.txt change");
assert!(!saw_log, "Watcher should filter out *.log files");
}
#[test]
#[cfg_attr(target_os = "macos", ignore = "FSEvents timing flaky in CI")]
fn watcher_filters_editor_temporaries() {
let tmp = TempDir::new().unwrap();
init_repo(tmp.path());
let watcher = SourceTreeWatcher::new(tmp.path()).unwrap();
thread::sleep(Duration::from_millis(100));
fs::write(tmp.path().join(".foo.swp"), b"vim swap\n").unwrap();
fs::write(tmp.path().join("bar.rs~"), b"emacs backup\n").unwrap();
fs::write(tmp.path().join("baz.rs.bak"), b"vscode bak\n").unwrap();
thread::sleep(Duration::from_millis(50));
fs::write(tmp.path().join("a.txt"), b"modified\n").unwrap();
let mut saw_temp = false;
let saw_source = wait_for_poll(event_timeout(), || {
if let Some(cs) = watcher.poll_changes(None).unwrap() {
for path in &cs.changed_files {
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if name.ends_with(".swp") || name.ends_with('~') || name.ends_with(".bak") {
saw_temp = true;
}
}
cs.changed_files
.iter()
.any(|p| p.file_name().is_some_and(|n| n == "a.txt"))
} else {
false
}
});
assert!(saw_source, "Watcher should detect a.txt change");
assert!(!saw_temp, "Watcher should filter out editor temporaries");
}
#[test]
#[cfg_attr(target_os = "macos", ignore = "FSEvents timing flaky in CI")]
fn watcher_git_state_composition() {
let tmp = TempDir::new().unwrap();
init_repo(tmp.path());
let watcher = SourceTreeWatcher::new(tmp.path()).unwrap();
let baseline = watcher.git_state().current_state();
thread::sleep(Duration::from_millis(200));
let _ = watcher.poll_changes(None);
fs::write(tmp.path().join("a.txt"), b"changed\n").unwrap();
run_git(tmp.path(), &["commit", "-q", "-am", "edit"]);
thread::sleep(Duration::from_millis(300));
let found = wait_for_poll(event_timeout(), || {
if let Some(cs) = watcher.poll_changes(Some(&baseline)).unwrap() {
if cs.git_state_changed {
assert!(
cs.git_change_class.is_some(),
"git_change_class must be set when git_state_changed is true"
);
return true;
}
return !cs.changed_files.is_empty();
}
false
});
assert!(
found,
"Should detect changes after commit with tree modification"
);
}
#[test]
fn changeset_is_empty_when_no_changes() {
let cs = ChangeSet {
changed_files: vec![],
git_state_changed: false,
git_change_class: None,
};
assert!(cs.is_empty());
assert!(!cs.requires_full_rebuild());
}
#[test]
fn changeset_requires_full_rebuild_on_branch_switch() {
let cs = ChangeSet {
changed_files: vec![],
git_state_changed: true,
git_change_class: Some(GitChangeClass::BranchSwitch),
};
assert!(!cs.is_empty());
assert!(cs.requires_full_rebuild());
}
#[test]
fn changeset_requires_full_rebuild_on_tree_diverged() {
let cs = ChangeSet {
changed_files: vec![],
git_state_changed: true,
git_change_class: Some(GitChangeClass::TreeDiverged),
};
assert!(cs.requires_full_rebuild());
}
#[test]
fn changeset_no_rebuild_on_local_commit() {
let cs = ChangeSet {
changed_files: vec![],
git_state_changed: true,
git_change_class: Some(GitChangeClass::LocalCommit),
};
assert!(!cs.requires_full_rebuild());
}
#[test]
fn changeset_no_rebuild_on_noise() {
let cs = ChangeSet {
changed_files: vec![],
git_state_changed: true,
git_change_class: Some(GitChangeClass::Noise),
};
assert!(!cs.requires_full_rebuild());
}
#[test]
fn classify_gc_as_noise_through_source_tree_watcher() {
let tmp = TempDir::new().unwrap();
init_repo(tmp.path());
fs::write(tmp.path().join("b.txt"), b"bravo\n").unwrap();
run_git(tmp.path(), &["add", "b.txt"]);
run_git(tmp.path(), &["reset", "--hard", "HEAD"]);
let watcher = SourceTreeWatcher::new(tmp.path()).unwrap();
let baseline = watcher.git_state().current_state();
thread::sleep(Duration::from_millis(200));
let _ = watcher.poll_changes(None);
run_git(tmp.path(), &["gc", "--quiet", "--prune=now"]);
thread::sleep(Duration::from_millis(300));
let class = watcher.git_state().classify(&baseline);
assert_eq!(class, GitChangeClass::Noise);
}
#[test]
fn classify_staging_as_noise_through_source_tree_watcher() {
let tmp = TempDir::new().unwrap();
init_repo(tmp.path());
let watcher = SourceTreeWatcher::new(tmp.path()).unwrap();
let baseline = watcher.git_state().current_state();
thread::sleep(Duration::from_millis(200));
let _ = watcher.poll_changes(None);
fs::write(tmp.path().join("c.txt"), b"charlie\n").unwrap();
run_git(tmp.path(), &["add", "c.txt"]);
run_git(tmp.path(), &["reset", "HEAD", "c.txt"]);
let class = watcher.git_state().classify(&baseline);
assert_eq!(class, GitChangeClass::Noise);
}
#[test]
fn classify_branch_switch_through_source_tree_watcher() {
let tmp = TempDir::new().unwrap();
init_repo(tmp.path());
let watcher = SourceTreeWatcher::new(tmp.path()).unwrap();
let baseline = watcher.git_state().current_state();
run_git(tmp.path(), &["checkout", "-q", "-b", "feature"]);
let class = watcher.git_state().classify(&baseline);
assert_eq!(class, GitChangeClass::BranchSwitch);
assert!(class.requires_full_rebuild());
}
#[test]
#[cfg_attr(target_os = "macos", ignore = "FSEvents timing flaky in CI")]
fn bulk_checkout_100_files_single_changeset() {
let tmp = TempDir::new().unwrap();
init_repo(tmp.path());
run_git(tmp.path(), &["checkout", "-q", "-b", "many-files"]);
let src_dir = tmp.path().join("src");
fs::create_dir_all(&src_dir).unwrap();
for i in 0..120 {
fs::write(
src_dir.join(format!("file_{i}.rs")),
format!("// file {i}\n"),
)
.unwrap();
}
run_git(tmp.path(), &["add", "."]);
run_git(tmp.path(), &["commit", "-q", "-m", "add 120 files"]);
run_git(tmp.path(), &["checkout", "-q", "main"]);
let watcher = SourceTreeWatcher::new(tmp.path()).unwrap();
let baseline = watcher.git_state().current_state();
thread::sleep(Duration::from_millis(200));
let _ = watcher.poll_changes(None);
run_git(tmp.path(), &["checkout", "-q", "many-files"]);
thread::sleep(Duration::from_millis(500));
let cs = watcher.poll_changes(Some(&baseline)).unwrap();
assert!(cs.is_some(), "Should detect checkout across 120 files");
let cs = cs.unwrap();
if cs.git_state_changed {
assert!(
cs.git_change_class
.is_some_and(GitChangeClass::requires_full_rebuild),
"100+ file checkout should trigger full rebuild"
);
}
}
#[test]
#[cfg_attr(target_os = "macos", ignore = "FSEvents timing flaky in CI")]
fn stash_pop_produces_changesets() {
let tmp = TempDir::new().unwrap();
init_repo(tmp.path());
let watcher = SourceTreeWatcher::new(tmp.path()).unwrap();
thread::sleep(Duration::from_millis(200));
let _ = watcher.poll_changes(None);
fs::write(tmp.path().join("a.txt"), b"stash-me\n").unwrap();
thread::sleep(Duration::from_millis(300));
let cs1 = watcher.poll_changes(None).unwrap();
assert!(
cs1.is_some_and(|cs| !cs.changed_files.is_empty()),
"Edit should produce first changeset"
);
run_git(tmp.path(), &["stash"]);
thread::sleep(Duration::from_millis(300));
let cs2 = watcher.poll_changes(None).unwrap();
assert!(cs2.is_some(), "Stash should produce changeset");
run_git(tmp.path(), &["stash", "pop"]);
thread::sleep(Duration::from_millis(300));
let cs3 = watcher.poll_changes(None).unwrap();
assert!(cs3.is_some(), "Stash pop should produce changeset");
}
#[test]
fn gc_zero_source_events() {
let tmp = TempDir::new().unwrap();
init_repo(tmp.path());
for i in 0..10 {
fs::write(tmp.path().join(format!("f{i}.txt")), format!("{i}\n")).unwrap();
run_git(tmp.path(), &["add", "."]);
run_git(tmp.path(), &["commit", "-q", "-m", &format!("commit {i}")]);
}
let watcher = SourceTreeWatcher::new(tmp.path()).unwrap();
let baseline = watcher.git_state().current_state();
thread::sleep(Duration::from_millis(200));
let _ = watcher.poll_changes(None);
run_git(tmp.path(), &["gc", "--quiet", "--prune=now"]);
thread::sleep(Duration::from_millis(300));
let cs = watcher.poll_changes(Some(&baseline)).unwrap();
if let Some(cs) = cs {
assert!(
cs.changed_files.is_empty(),
"gc should not produce source-file events, got: {:?}",
cs.changed_files
);
if cs.git_state_changed {
assert!(
cs.git_state_changed,
"git_state_changed must be true when git events observed"
);
assert_eq!(
cs.git_change_class,
Some(GitChangeClass::Noise),
"gc git events should classify as Noise"
);
}
}
}
#[test]
#[cfg_attr(target_os = "macos", ignore = "FSEvents timing flaky in CI")]
fn commit_no_additional_changeset() {
let tmp = TempDir::new().unwrap();
init_repo(tmp.path());
let watcher = SourceTreeWatcher::new(tmp.path()).unwrap();
thread::sleep(Duration::from_millis(200));
let _ = watcher.poll_changes(None);
fs::write(tmp.path().join("a.txt"), b"edited\n").unwrap();
thread::sleep(Duration::from_millis(300));
let cs1 = watcher.poll_changes(None).unwrap();
assert!(
cs1.is_some_and(|cs| !cs.changed_files.is_empty()),
"Edit should produce changeset"
);
let baseline = watcher.git_state().current_state();
run_git(tmp.path(), &["add", "a.txt"]);
run_git(tmp.path(), &["commit", "-q", "-m", "commit edit"]);
thread::sleep(Duration::from_millis(300));
let cs2 = watcher.poll_changes(Some(&baseline)).unwrap();
if let Some(cs2) = cs2 {
let has_source_change = cs2
.changed_files
.iter()
.any(|p| p.file_name().is_some_and(|n| n == "a.txt"));
assert!(
!has_source_change,
"Commit should not re-report a.txt as changed"
);
}
}
#[test]
#[cfg_attr(target_os = "macos", ignore = "FSEvents timing flaky in CI")]
fn poll_changes_reports_git_state_changed_on_git_only_events() {
let tmp = TempDir::new().unwrap();
init_repo(tmp.path());
let watcher = SourceTreeWatcher::new(tmp.path()).unwrap();
let baseline = watcher.git_state().current_state();
thread::sleep(Duration::from_millis(200));
let _ = watcher.poll_changes(None);
run_git(tmp.path(), &["checkout", "-q", "-b", "other"]);
thread::sleep(Duration::from_millis(300));
let found = wait_for_poll(event_timeout(), || {
if let Some(cs) = watcher.poll_changes(Some(&baseline)).unwrap()
&& cs.git_state_changed
{
assert!(
cs.git_change_class.is_some(),
"git_change_class must be set when git_state_changed is true"
);
return true;
}
false
});
assert!(
found,
"poll_changes must report git_state_changed=true for branch switch"
);
}
#[test]
fn wait_for_changes_cancellable_returns_none_on_pre_event_cancel() {
let tmp = TempDir::new().expect("tempdir");
init_repo(tmp.path());
let watcher = SourceTreeWatcher::new(tmp.path()).expect("watcher");
let cancelled = std::sync::Arc::new(AtomicBool::new(false));
let cancel_signal = std::sync::Arc::clone(&cancelled);
let handle = thread::spawn(move || {
thread::sleep(Duration::from_millis(50));
cancel_signal.store(true, Ordering::Release);
});
let started = Instant::now();
let result = watcher.wait_for_changes_cancellable(
Duration::from_secs(60), None,
&cancelled,
Duration::from_millis(20),
);
let elapsed = started.elapsed();
handle.join().unwrap();
assert!(
matches!(result, Ok(None)),
"pre-event cancellation must produce Ok(None), got {result:?}"
);
assert!(
elapsed < Duration::from_secs(2),
"cancellation must terminate quickly; took {elapsed:?}"
);
}
#[test]
fn wait_for_changes_cancellable_returns_none_on_mid_debounce_cancel() {
let tmp = TempDir::new().expect("tempdir");
init_repo(tmp.path());
let watcher = SourceTreeWatcher::new(tmp.path()).expect("watcher");
let cancelled = std::sync::Arc::new(AtomicBool::new(false));
fs::write(tmp.path().join("a.txt"), b"modified\n").unwrap();
let cancel_signal = std::sync::Arc::clone(&cancelled);
let handle = thread::spawn(move || {
thread::sleep(Duration::from_millis(500));
cancel_signal.store(true, Ordering::Release);
});
let started = Instant::now();
let result = watcher.wait_for_changes_cancellable(
Duration::from_secs(60),
None,
&cancelled,
Duration::from_millis(20),
);
let elapsed = started.elapsed();
handle.join().unwrap();
assert!(
matches!(result, Ok(None)),
"mid-debounce cancellation must produce Ok(None), got {result:?}"
);
assert!(
elapsed < Duration::from_secs(3),
"cancellation must terminate quickly; took {elapsed:?}"
);
}
}