1use crate::watch::git_state::{GitChangeClass, GitStateWatcher, LastIndexedGitState};
46use anyhow::{Context, Result};
47use ignore::gitignore::{Gitignore, GitignoreBuilder};
48use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher as NotifyWatcher};
49use std::collections::HashMap;
50use std::path::{Path, PathBuf};
51use std::sync::atomic::{AtomicBool, Ordering};
52use std::sync::mpsc::{Receiver, TryRecvError, channel};
53use std::time::{Duration, Instant};
54
55#[derive(Debug, Clone)]
61pub struct ChangeSet {
62 pub changed_files: Vec<PathBuf>,
66 pub git_state_changed: bool,
71 pub git_change_class: Option<GitChangeClass>,
75}
76
77impl ChangeSet {
78 #[must_use]
80 pub fn is_empty(&self) -> bool {
81 self.changed_files.is_empty() && !self.git_state_changed
82 }
83
84 #[must_use]
86 pub fn requires_full_rebuild(&self) -> bool {
87 self.git_change_class
88 .is_some_and(GitChangeClass::requires_full_rebuild)
89 }
90}
91
92#[derive(Debug, Clone)]
94enum RawChange {
95 Create(PathBuf),
96 Modify(PathBuf),
97 Remove(PathBuf),
98}
99
100impl RawChange {
101 fn path(&self) -> &Path {
102 match self {
103 Self::Create(p) | Self::Modify(p) | Self::Remove(p) => p,
104 }
105 }
106}
107
108pub struct SourceTreeWatcher {
115 _watcher: RecommendedWatcher,
117 receiver: Receiver<Result<Event, notify::Error>>,
119 root: PathBuf,
121 ignore_matcher: Gitignore,
123 git_state: GitStateWatcher,
125}
126
127impl SourceTreeWatcher {
128 pub fn new(root: &Path) -> Result<Self> {
142 let root = std::fs::canonicalize(root)
143 .with_context(|| format!("Failed to canonicalize root: {}", root.display()))?;
144
145 let ignore_matcher = build_gitignore_matcher(&root);
147
148 let (tx, rx) = channel();
150 let mut watcher = notify::recommended_watcher(move |res| {
151 let _ = tx.send(res);
152 })
153 .context("Failed to create source-tree watcher")?;
154
155 watcher
156 .watch(&root, RecursiveMode::Recursive)
157 .with_context(|| format!("Failed to watch source tree: {}", root.display()))?;
158
159 let git_state = GitStateWatcher::new(&root)
161 .with_context(|| format!("Failed to create git-state watcher at {}", root.display()))?;
162
163 log::info!("SourceTreeWatcher started for: {}", root.display());
164
165 Ok(Self {
166 _watcher: watcher,
167 receiver: rx,
168 root,
169 ignore_matcher,
170 git_state,
171 })
172 }
173
174 #[must_use]
176 pub fn root(&self) -> &Path {
177 &self.root
178 }
179
180 #[must_use]
182 pub fn git_state(&self) -> &GitStateWatcher {
183 &self.git_state
184 }
185
186 pub fn wait_for_changes(
199 &self,
200 debounce: Duration,
201 last_git_state: Option<&LastIndexedGitState>,
202 ) -> Result<ChangeSet> {
203 let mut raw_changes: Vec<RawChange> = Vec::new();
204
205 let first_event = self
207 .receiver
208 .recv()
209 .context("Source-tree watcher channel disconnected")?;
210 if let Ok(event) = first_event {
211 collect_raw_changes(&event, &mut raw_changes);
212 }
213
214 let mut deadline = Instant::now() + debounce;
216 loop {
217 let remaining = deadline.saturating_duration_since(Instant::now());
218 if remaining.is_zero() {
219 break;
220 }
221 match self
222 .receiver
223 .recv_timeout(remaining.min(Duration::from_millis(10)))
224 {
225 Ok(Ok(event)) => {
226 collect_raw_changes(&event, &mut raw_changes);
227 deadline = Instant::now() + debounce;
229 }
230 Ok(Err(e)) => {
231 log::warn!("Source-tree watcher error: {e}");
232 deadline = Instant::now() + debounce;
233 }
234 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
235 if Instant::now() >= deadline {
237 break;
238 }
239 }
240 Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
241 log::error!("Source-tree watcher channel disconnected during debounce");
242 break;
243 }
244 }
245 }
246
247 let git_state_changed = self.git_state.poll_changed();
248 Ok(self.build_changeset(raw_changes, git_state_changed, last_git_state))
249 }
250
251 pub fn wait_for_changes_cancellable(
285 &self,
286 debounce: Duration,
287 last_git_state: Option<&LastIndexedGitState>,
288 cancelled: &AtomicBool,
289 cancel_poll_period: Duration,
290 ) -> Result<Option<ChangeSet>> {
291 let mut raw_changes: Vec<RawChange> = Vec::new();
292
293 loop {
300 if cancelled.load(Ordering::Acquire) {
301 return Ok(None);
302 }
303 match self.receiver.recv_timeout(cancel_poll_period) {
304 Ok(Ok(event)) => {
305 collect_raw_changes(&event, &mut raw_changes);
306 break;
307 }
308 Ok(Err(e)) => {
309 log::warn!("Source-tree watcher error: {e}");
313 break;
314 }
315 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
316 }
318 Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
319 anyhow::bail!("Source-tree watcher channel disconnected before first event");
320 }
321 }
322 }
323
324 let mut deadline = Instant::now() + debounce;
330 loop {
331 if cancelled.load(Ordering::Acquire) {
332 return Ok(None);
333 }
334 let remaining = deadline.saturating_duration_since(Instant::now());
335 if remaining.is_zero() {
336 break;
337 }
338 let slice = remaining
339 .min(Duration::from_millis(10))
340 .min(cancel_poll_period);
341 match self.receiver.recv_timeout(slice) {
342 Ok(Ok(event)) => {
343 collect_raw_changes(&event, &mut raw_changes);
344 deadline = Instant::now() + debounce;
345 }
346 Ok(Err(e)) => {
347 log::warn!("Source-tree watcher error: {e}");
348 deadline = Instant::now() + debounce;
349 }
350 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
351 if Instant::now() >= deadline {
352 break;
353 }
354 }
355 Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
356 log::error!("Source-tree watcher channel disconnected during debounce");
357 break;
358 }
359 }
360 }
361
362 let git_state_changed = self.git_state.poll_changed();
363 Ok(Some(self.build_changeset(
364 raw_changes,
365 git_state_changed,
366 last_git_state,
367 )))
368 }
369
370 pub fn poll_changes(
383 &self,
384 last_git_state: Option<&LastIndexedGitState>,
385 ) -> Result<Option<ChangeSet>> {
386 let mut raw_changes: Vec<RawChange> = Vec::new();
387
388 loop {
389 match self.receiver.try_recv() {
390 Ok(Ok(event)) => {
391 collect_raw_changes(&event, &mut raw_changes);
392 }
393 Ok(Err(e)) => {
394 log::warn!("Source-tree watcher error: {e}");
395 }
396 Err(TryRecvError::Empty) => break,
397 Err(TryRecvError::Disconnected) => {
398 anyhow::bail!("Source-tree watcher channel disconnected");
399 }
400 }
401 }
402
403 let git_state_changed = self.git_state.poll_changed();
404
405 if raw_changes.is_empty() && !git_state_changed {
406 return Ok(None);
407 }
408
409 Ok(Some(self.build_changeset(
410 raw_changes,
411 git_state_changed,
412 last_git_state,
413 )))
414 }
415
416 fn build_changeset(
423 &self,
424 raw_changes: Vec<RawChange>,
425 git_state_changed: bool,
426 last_git_state: Option<&LastIndexedGitState>,
427 ) -> ChangeSet {
428 let filtered: Vec<RawChange> = raw_changes
431 .into_iter()
432 .filter(|change| {
433 let path = change.path();
434 !is_under_git_dir(path, &self.root)
435 && !is_under_sqry_dir(path, &self.root)
436 && !self.is_gitignored(path)
437 && !is_editor_temporary(path)
438 })
439 .collect();
440
441 let coalesced = coalesce_rename_pairs(filtered);
443
444 let mut deduped: HashMap<PathBuf, &RawChange> = HashMap::new();
446 for change in &coalesced {
447 deduped.insert(change.path().to_path_buf(), change);
448 }
449
450 let changed_files: Vec<PathBuf> = deduped.into_keys().collect();
451
452 let git_change_class = if git_state_changed {
454 last_git_state.map(|last| self.git_state.classify(last))
455 } else {
456 None
457 };
458
459 ChangeSet {
460 changed_files,
461 git_state_changed,
462 git_change_class,
463 }
464 }
465
466 fn is_gitignored(&self, path: &Path) -> bool {
468 let is_dir = path.is_dir();
469 let rel = path.strip_prefix(&self.root).unwrap_or(path);
471 self.ignore_matcher
472 .matched_path_or_any_parents(rel, is_dir)
473 .is_ignore()
474 }
475}
476
477fn build_gitignore_matcher(root: &Path) -> Gitignore {
480 const MAX_DEPTH: usize = 20;
481
482 let mut builder = GitignoreBuilder::new(root);
483
484 let gitignore_path = root.join(".gitignore");
486 if gitignore_path.is_file()
487 && let Some(err) = builder.add(&gitignore_path)
488 {
489 log::warn!("Error parsing {}: {err}", gitignore_path.display());
490 }
491
492 let mut dirs_to_scan = vec![root.to_path_buf()];
495 let mut depth = 0;
496
497 while !dirs_to_scan.is_empty() && depth < MAX_DEPTH {
498 let mut next_dirs = Vec::new();
499 for dir in &dirs_to_scan {
500 let Ok(entries) = std::fs::read_dir(dir) else {
501 continue;
502 };
503 for entry in entries.flatten() {
504 let path = entry.path();
505 if path.is_dir() {
506 if path.file_name().is_some_and(|n| n == ".git") {
508 continue;
509 }
510 let sub_gitignore = path.join(".gitignore");
512 if sub_gitignore.is_file()
513 && let Some(err) = builder.add(&sub_gitignore)
514 {
515 log::warn!("Error parsing {}: {err}", sub_gitignore.display());
516 }
517 next_dirs.push(path);
518 }
519 }
520 }
521 dirs_to_scan = next_dirs;
522 depth += 1;
523 }
524
525 match builder.build() {
526 Ok(matcher) => matcher,
527 Err(e) => {
528 log::warn!("Failed to build gitignore matcher: {e}; using empty matcher");
529 Gitignore::empty()
530 }
531 }
532}
533
534fn is_under_git_dir(path: &Path, root: &Path) -> bool {
536 let git_dir = root.join(".git");
537 path.starts_with(&git_dir)
538}
539
540fn is_under_sqry_dir(path: &Path, root: &Path) -> bool {
542 let sqry_dir = root.join(".sqry");
543 path.starts_with(&sqry_dir)
544}
545
546fn is_editor_temporary(path: &Path) -> bool {
550 let Some(file_name) = path.file_name().and_then(|n| n.to_str()) else {
551 return false;
552 };
553
554 if path
556 .extension()
557 .and_then(|ext| ext.to_str())
558 .is_some_and(|ext| ext.eq_ignore_ascii_case("swp") || ext.eq_ignore_ascii_case("swo"))
559 && let Some(stem) = path.file_stem().and_then(|s| s.to_str())
560 && stem.starts_with('.')
561 {
562 return true;
563 }
564
565 if file_name.ends_with('~') {
567 return true;
568 }
569
570 if file_name.starts_with('#') && file_name.ends_with('#') {
572 return true;
573 }
574
575 if file_name.starts_with(".#") {
577 return true;
578 }
579
580 if path
582 .extension()
583 .and_then(|ext| ext.to_str())
584 .is_some_and(|ext| ext.eq_ignore_ascii_case("bak"))
585 {
586 return true;
587 }
588
589 if file_name.ends_with("___jb_tmp___") || file_name.ends_with("___jb_old___") {
591 return true;
592 }
593
594 false
595}
596
597fn collect_raw_changes(event: &Event, out: &mut Vec<RawChange>) {
599 match event.kind {
600 EventKind::Create(_) => {
601 for path in &event.paths {
602 if path.is_file() {
603 out.push(RawChange::Create(path.clone()));
604 }
605 }
606 }
607 EventKind::Modify(_) => {
608 for path in &event.paths {
609 out.push(RawChange::Modify(path.clone()));
612 }
613 }
614 EventKind::Remove(_) => {
615 for path in &event.paths {
616 out.push(RawChange::Remove(path.clone()));
617 }
618 }
619 _ => {
620 }
622 }
623}
624
625fn coalesce_rename_pairs(changes: Vec<RawChange>) -> Vec<RawChange> {
638 if changes.len() < 2 {
639 return changes;
640 }
641
642 let mut result: Vec<RawChange> = Vec::with_capacity(changes.len());
643 let mut consumed: Vec<bool> = vec![false; changes.len()];
644
645 for i in 0..changes.len() {
646 if consumed[i] {
647 continue;
648 }
649
650 if let RawChange::Remove(ref remove_path) = changes[i] {
651 let mut found_create = false;
653 for j in (i + 1)..changes.len() {
654 if consumed[j] {
655 continue;
656 }
657 if let RawChange::Create(ref create_path) = changes[j]
658 && create_path == remove_path
659 {
660 result.push(RawChange::Modify(remove_path.clone()));
662 consumed[i] = true;
663 consumed[j] = true;
664 found_create = true;
665 break;
666 }
667 }
668 if !found_create {
669 result.push(changes[i].clone());
670 consumed[i] = true;
671 }
672 } else {
673 result.push(changes[i].clone());
674 consumed[i] = true;
675 }
676 }
677
678 result
679}
680
681#[cfg(test)]
682mod tests {
683 use super::*;
684 use std::fs;
685 use std::process::Command;
686 use std::thread;
687 use tempfile::TempDir;
688
689 fn event_timeout() -> Duration {
691 let base = if cfg!(target_os = "macos") {
692 Duration::from_secs(3)
693 } else {
694 Duration::from_secs(2)
695 };
696 if std::env::var("CI").is_ok() {
697 base * 2
698 } else {
699 base
700 }
701 }
702
703 fn init_repo(dir: &Path) {
704 run_git(dir, &["init", "-q", "-b", "main"]);
705 run_git(dir, &["config", "user.email", "test@sqry.dev"]);
706 run_git(dir, &["config", "user.name", "Sqry Test"]);
707 run_git(dir, &["config", "commit.gpgsign", "false"]);
708 fs::write(dir.join("a.txt"), b"alpha\n").unwrap();
709 run_git(dir, &["add", "a.txt"]);
710 run_git(dir, &["commit", "-q", "-m", "initial"]);
711 }
712
713 fn run_git(dir: &Path, args: &[&str]) {
714 let status = Command::new("git")
715 .arg("-C")
716 .arg(dir)
717 .args(args)
718 .status()
719 .expect("git command failed to launch");
720 assert!(status.success(), "git {args:?} failed in {}", dir.display());
721 }
722
723 fn wait_for_poll<F>(timeout: Duration, mut predicate: F) -> bool
724 where
725 F: FnMut() -> bool,
726 {
727 let deadline = Instant::now() + timeout;
728 loop {
729 if predicate() {
730 return true;
731 }
732 if Instant::now() >= deadline {
733 return false;
734 }
735 thread::sleep(Duration::from_millis(50));
736 }
737 }
738
739 #[test]
744 fn editor_temp_vim_swp() {
745 assert!(is_editor_temporary(Path::new("/tmp/.foo.swp")));
746 assert!(is_editor_temporary(Path::new("/tmp/.foo.swo")));
747 assert!(!is_editor_temporary(Path::new("/tmp/foo.swp")));
749 }
750
751 #[test]
752 fn editor_temp_emacs_backup() {
753 assert!(is_editor_temporary(Path::new("/tmp/foo.rs~")));
754 assert!(is_editor_temporary(Path::new("/tmp/#foo.rs#")));
755 assert!(is_editor_temporary(Path::new("/tmp/.#foo.rs")));
756 }
757
758 #[test]
759 fn editor_temp_vscode_bak() {
760 assert!(is_editor_temporary(Path::new("/tmp/foo.rs.bak")));
761 }
762
763 #[test]
764 fn editor_temp_jetbrains() {
765 assert!(is_editor_temporary(Path::new("/tmp/foo.rs___jb_tmp___")));
766 assert!(is_editor_temporary(Path::new("/tmp/foo.rs___jb_old___")));
767 }
768
769 #[test]
770 fn non_temp_files_pass_through() {
771 assert!(!is_editor_temporary(Path::new("/tmp/foo.rs")));
772 assert!(!is_editor_temporary(Path::new("/tmp/Makefile")));
773 assert!(!is_editor_temporary(Path::new("/tmp/README.md")));
774 }
775
776 #[test]
781 fn git_dir_detection() {
782 let root = Path::new("/repo");
783 assert!(is_under_git_dir(Path::new("/repo/.git/HEAD"), root));
784 assert!(is_under_git_dir(
785 Path::new("/repo/.git/refs/heads/main"),
786 root
787 ));
788 assert!(!is_under_git_dir(Path::new("/repo/src/main.rs"), root));
789 assert!(!is_under_git_dir(Path::new("/repo/.gitignore"), root));
790 }
791
792 #[test]
797 fn sqry_dir_detection() {
798 let root = Path::new("/repo");
799 assert!(is_under_sqry_dir(
800 Path::new("/repo/.sqry/graph/snapshot.sqry"),
801 root
802 ));
803 assert!(is_under_sqry_dir(
804 Path::new("/repo/.sqry/analysis/adjacency.csr"),
805 root
806 ));
807 assert!(!is_under_sqry_dir(Path::new("/repo/src/main.rs"), root));
808 assert!(!is_under_sqry_dir(Path::new("/repo/.sqry-workspace"), root));
809 }
810
811 #[test]
816 fn coalesce_empty() {
817 let result = coalesce_rename_pairs(vec![]);
818 assert!(result.is_empty());
819 }
820
821 #[test]
822 fn coalesce_single_event_passthrough() {
823 let changes = vec![RawChange::Modify(PathBuf::from("foo.rs"))];
824 let result = coalesce_rename_pairs(changes);
825 assert_eq!(result.len(), 1);
826 assert!(matches!(&result[0], RawChange::Modify(p) if p == Path::new("foo.rs")));
827 }
828
829 #[test]
830 fn coalesce_remove_create_same_path_becomes_modify() {
831 let changes = vec![
832 RawChange::Remove(PathBuf::from("foo.rs")),
833 RawChange::Create(PathBuf::from("foo.rs")),
834 ];
835 let result = coalesce_rename_pairs(changes);
836 assert_eq!(result.len(), 1);
837 assert!(
838 matches!(&result[0], RawChange::Modify(p) if p == Path::new("foo.rs")),
839 "Remove+Create should coalesce into Modify"
840 );
841 }
842
843 #[test]
844 fn coalesce_remove_create_different_paths_no_coalesce() {
845 let changes = vec![
846 RawChange::Remove(PathBuf::from("old.rs")),
847 RawChange::Create(PathBuf::from("new.rs")),
848 ];
849 let result = coalesce_rename_pairs(changes);
850 assert_eq!(result.len(), 2);
851 }
852
853 #[test]
854 fn coalesce_interleaved_events() {
855 let changes = vec![
857 RawChange::Remove(PathBuf::from("a.rs")),
858 RawChange::Modify(PathBuf::from("b.rs")),
859 RawChange::Create(PathBuf::from("a.rs")),
860 ];
861 let result = coalesce_rename_pairs(changes);
862 assert_eq!(result.len(), 2);
863 assert!(
865 result
866 .iter()
867 .any(|c| matches!(c, RawChange::Modify(p) if p == Path::new("a.rs")))
868 );
869 assert!(
870 result
871 .iter()
872 .any(|c| matches!(c, RawChange::Modify(p) if p == Path::new("b.rs")))
873 );
874 }
875
876 #[test]
877 fn coalesce_multiple_rename_pairs() {
878 let changes = vec![
879 RawChange::Remove(PathBuf::from("a.rs")),
880 RawChange::Remove(PathBuf::from("b.rs")),
881 RawChange::Create(PathBuf::from("a.rs")),
882 RawChange::Create(PathBuf::from("b.rs")),
883 ];
884 let result = coalesce_rename_pairs(changes);
885 assert_eq!(result.len(), 2);
886 assert!(result.iter().all(|c| matches!(c, RawChange::Modify(_))));
887 }
888
889 #[test]
894 fn gitignore_filters_target_directory() {
895 let tmp = TempDir::new().unwrap();
896 fs::write(tmp.path().join(".gitignore"), "target/\n*.log\n").unwrap();
897 let matcher = build_gitignore_matcher(tmp.path());
898
899 assert!(
900 matcher
901 .matched_path_or_any_parents("target/debug/foo", false)
902 .is_ignore(),
903 "target/ contents should be ignored"
904 );
905 assert!(
906 matcher
907 .matched_path_or_any_parents("build.log", false)
908 .is_ignore(),
909 "*.log should be ignored"
910 );
911 assert!(
912 !matcher
913 .matched_path_or_any_parents("src/main.rs", false)
914 .is_ignore(),
915 "src/main.rs should not be ignored"
916 );
917 }
918
919 #[test]
920 fn gitignore_nested_rules() {
921 let tmp = TempDir::new().unwrap();
922 fs::write(tmp.path().join(".gitignore"), "*.o\n").unwrap();
923 fs::create_dir_all(tmp.path().join("vendor")).unwrap();
924 fs::write(tmp.path().join("vendor/.gitignore"), "*.vendored\n").unwrap();
925
926 let matcher = build_gitignore_matcher(tmp.path());
927
928 assert!(
929 matcher
930 .matched_path_or_any_parents("foo.o", false)
931 .is_ignore()
932 );
933 assert!(
934 matcher
935 .matched_path_or_any_parents("vendor/lib.vendored", false)
936 .is_ignore()
937 );
938 }
939
940 #[test]
945 #[cfg_attr(target_os = "macos", ignore = "FSEvents timing flaky in CI")]
946 fn watcher_detects_source_file_change() {
947 let tmp = TempDir::new().unwrap();
948 init_repo(tmp.path());
949 fs::write(tmp.path().join(".gitignore"), "*.log\ntarget/\n").unwrap();
950 run_git(tmp.path(), &["add", ".gitignore"]);
951 run_git(tmp.path(), &["commit", "-q", "-m", "add gitignore"]);
952
953 let watcher = SourceTreeWatcher::new(tmp.path()).unwrap();
954
955 thread::sleep(Duration::from_millis(100));
957
958 fs::write(tmp.path().join("a.txt"), b"modified\n").unwrap();
960
961 let detected = wait_for_poll(event_timeout(), || {
962 let cs = watcher.poll_changes(None).unwrap();
963 cs.is_some_and(|cs| !cs.changed_files.is_empty())
964 });
965
966 assert!(detected, "Watcher should detect source file modification");
967 }
968
969 #[test]
970 #[cfg_attr(target_os = "macos", ignore = "FSEvents timing flaky in CI")]
971 fn watcher_filters_gitignored_files() {
972 let tmp = TempDir::new().unwrap();
973 init_repo(tmp.path());
974 fs::write(tmp.path().join(".gitignore"), "*.log\ntarget/\n").unwrap();
975 run_git(tmp.path(), &["add", ".gitignore"]);
976 run_git(tmp.path(), &["commit", "-q", "-m", "add gitignore"]);
977
978 let watcher = SourceTreeWatcher::new(tmp.path()).unwrap();
979 thread::sleep(Duration::from_millis(100));
980
981 fs::write(tmp.path().join("build.log"), b"log output\n").unwrap();
983
984 thread::sleep(Duration::from_millis(50));
986 fs::write(tmp.path().join("a.txt"), b"modified\n").unwrap();
987
988 let mut saw_log = false;
989 let saw_source = wait_for_poll(event_timeout(), || {
990 if let Some(cs) = watcher.poll_changes(None).unwrap() {
991 for path in &cs.changed_files {
992 if path.extension().is_some_and(|e| e == "log") {
993 saw_log = true;
994 }
995 }
996 cs.changed_files
997 .iter()
998 .any(|p| p.file_name().is_some_and(|n| n == "a.txt"))
999 } else {
1000 false
1001 }
1002 });
1003
1004 assert!(saw_source, "Watcher should detect a.txt change");
1005 assert!(!saw_log, "Watcher should filter out *.log files");
1006 }
1007
1008 #[test]
1009 #[cfg_attr(target_os = "macos", ignore = "FSEvents timing flaky in CI")]
1010 fn watcher_filters_editor_temporaries() {
1011 let tmp = TempDir::new().unwrap();
1012 init_repo(tmp.path());
1013
1014 let watcher = SourceTreeWatcher::new(tmp.path()).unwrap();
1015 thread::sleep(Duration::from_millis(100));
1016
1017 fs::write(tmp.path().join(".foo.swp"), b"vim swap\n").unwrap();
1019 fs::write(tmp.path().join("bar.rs~"), b"emacs backup\n").unwrap();
1020 fs::write(tmp.path().join("baz.rs.bak"), b"vscode bak\n").unwrap();
1021
1022 thread::sleep(Duration::from_millis(50));
1024 fs::write(tmp.path().join("a.txt"), b"modified\n").unwrap();
1025
1026 let mut saw_temp = false;
1027 let saw_source = wait_for_poll(event_timeout(), || {
1028 if let Some(cs) = watcher.poll_changes(None).unwrap() {
1029 for path in &cs.changed_files {
1030 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
1031 if name.ends_with(".swp") || name.ends_with('~') || name.ends_with(".bak") {
1032 saw_temp = true;
1033 }
1034 }
1035 cs.changed_files
1036 .iter()
1037 .any(|p| p.file_name().is_some_and(|n| n == "a.txt"))
1038 } else {
1039 false
1040 }
1041 });
1042
1043 assert!(saw_source, "Watcher should detect a.txt change");
1044 assert!(!saw_temp, "Watcher should filter out editor temporaries");
1045 }
1046
1047 #[test]
1048 #[cfg_attr(target_os = "macos", ignore = "FSEvents timing flaky in CI")]
1049 fn watcher_git_state_composition() {
1050 let tmp = TempDir::new().unwrap();
1051 init_repo(tmp.path());
1052
1053 let watcher = SourceTreeWatcher::new(tmp.path()).unwrap();
1054 let baseline = watcher.git_state().current_state();
1055
1056 thread::sleep(Duration::from_millis(200));
1058 let _ = watcher.poll_changes(None);
1059
1060 fs::write(tmp.path().join("a.txt"), b"changed\n").unwrap();
1062 run_git(tmp.path(), &["commit", "-q", "-am", "edit"]);
1063
1064 thread::sleep(Duration::from_millis(300));
1065
1066 let found = wait_for_poll(event_timeout(), || {
1069 if let Some(cs) = watcher.poll_changes(Some(&baseline)).unwrap() {
1070 if cs.git_state_changed {
1075 assert!(
1076 cs.git_change_class.is_some(),
1077 "git_change_class must be set when git_state_changed is true"
1078 );
1079 return true;
1080 }
1081 return !cs.changed_files.is_empty();
1083 }
1084 false
1085 });
1086
1087 assert!(
1088 found,
1089 "Should detect changes after commit with tree modification"
1090 );
1091 }
1092
1093 #[test]
1098 fn changeset_is_empty_when_no_changes() {
1099 let cs = ChangeSet {
1100 changed_files: vec![],
1101 git_state_changed: false,
1102 git_change_class: None,
1103 };
1104 assert!(cs.is_empty());
1105 assert!(!cs.requires_full_rebuild());
1106 }
1107
1108 #[test]
1109 fn changeset_requires_full_rebuild_on_branch_switch() {
1110 let cs = ChangeSet {
1111 changed_files: vec![],
1112 git_state_changed: true,
1113 git_change_class: Some(GitChangeClass::BranchSwitch),
1114 };
1115 assert!(!cs.is_empty());
1116 assert!(cs.requires_full_rebuild());
1117 }
1118
1119 #[test]
1120 fn changeset_requires_full_rebuild_on_tree_diverged() {
1121 let cs = ChangeSet {
1122 changed_files: vec![],
1123 git_state_changed: true,
1124 git_change_class: Some(GitChangeClass::TreeDiverged),
1125 };
1126 assert!(cs.requires_full_rebuild());
1127 }
1128
1129 #[test]
1130 fn changeset_no_rebuild_on_local_commit() {
1131 let cs = ChangeSet {
1132 changed_files: vec![],
1133 git_state_changed: true,
1134 git_change_class: Some(GitChangeClass::LocalCommit),
1135 };
1136 assert!(!cs.requires_full_rebuild());
1137 }
1138
1139 #[test]
1140 fn changeset_no_rebuild_on_noise() {
1141 let cs = ChangeSet {
1142 changed_files: vec![],
1143 git_state_changed: true,
1144 git_change_class: Some(GitChangeClass::Noise),
1145 };
1146 assert!(!cs.requires_full_rebuild());
1147 }
1148
1149 #[test]
1154 fn classify_gc_as_noise_through_source_tree_watcher() {
1155 let tmp = TempDir::new().unwrap();
1156 init_repo(tmp.path());
1157 fs::write(tmp.path().join("b.txt"), b"bravo\n").unwrap();
1159 run_git(tmp.path(), &["add", "b.txt"]);
1160 run_git(tmp.path(), &["reset", "--hard", "HEAD"]);
1161
1162 let watcher = SourceTreeWatcher::new(tmp.path()).unwrap();
1163 let baseline = watcher.git_state().current_state();
1164 thread::sleep(Duration::from_millis(200));
1166 let _ = watcher.poll_changes(None);
1167
1168 run_git(tmp.path(), &["gc", "--quiet", "--prune=now"]);
1169 thread::sleep(Duration::from_millis(300));
1170
1171 let class = watcher.git_state().classify(&baseline);
1173 assert_eq!(class, GitChangeClass::Noise);
1174 }
1175
1176 #[test]
1177 fn classify_staging_as_noise_through_source_tree_watcher() {
1178 let tmp = TempDir::new().unwrap();
1179 init_repo(tmp.path());
1180
1181 let watcher = SourceTreeWatcher::new(tmp.path()).unwrap();
1182 let baseline = watcher.git_state().current_state();
1183 thread::sleep(Duration::from_millis(200));
1184 let _ = watcher.poll_changes(None);
1185
1186 fs::write(tmp.path().join("c.txt"), b"charlie\n").unwrap();
1187 run_git(tmp.path(), &["add", "c.txt"]);
1188 run_git(tmp.path(), &["reset", "HEAD", "c.txt"]);
1189
1190 let class = watcher.git_state().classify(&baseline);
1191 assert_eq!(class, GitChangeClass::Noise);
1192 }
1193
1194 #[test]
1195 fn classify_branch_switch_through_source_tree_watcher() {
1196 let tmp = TempDir::new().unwrap();
1197 init_repo(tmp.path());
1198
1199 let watcher = SourceTreeWatcher::new(tmp.path()).unwrap();
1200 let baseline = watcher.git_state().current_state();
1201
1202 run_git(tmp.path(), &["checkout", "-q", "-b", "feature"]);
1203 let class = watcher.git_state().classify(&baseline);
1204 assert_eq!(class, GitChangeClass::BranchSwitch);
1205 assert!(class.requires_full_rebuild());
1206 }
1207
1208 #[test]
1213 #[cfg_attr(target_os = "macos", ignore = "FSEvents timing flaky in CI")]
1214 fn bulk_checkout_100_files_single_changeset() {
1215 let tmp = TempDir::new().unwrap();
1216 init_repo(tmp.path());
1217
1218 run_git(tmp.path(), &["checkout", "-q", "-b", "many-files"]);
1220 let src_dir = tmp.path().join("src");
1221 fs::create_dir_all(&src_dir).unwrap();
1222 for i in 0..120 {
1223 fs::write(
1224 src_dir.join(format!("file_{i}.rs")),
1225 format!("// file {i}\n"),
1226 )
1227 .unwrap();
1228 }
1229 run_git(tmp.path(), &["add", "."]);
1230 run_git(tmp.path(), &["commit", "-q", "-m", "add 120 files"]);
1231
1232 run_git(tmp.path(), &["checkout", "-q", "main"]);
1234
1235 let watcher = SourceTreeWatcher::new(tmp.path()).unwrap();
1236 let baseline = watcher.git_state().current_state();
1237 thread::sleep(Duration::from_millis(200));
1238 let _ = watcher.poll_changes(None);
1239
1240 run_git(tmp.path(), &["checkout", "-q", "many-files"]);
1242 thread::sleep(Duration::from_millis(500));
1243
1244 let cs = watcher.poll_changes(Some(&baseline)).unwrap();
1246 assert!(cs.is_some(), "Should detect checkout across 120 files");
1247 let cs = cs.unwrap();
1248
1249 if cs.git_state_changed {
1251 assert!(
1252 cs.git_change_class
1253 .is_some_and(GitChangeClass::requires_full_rebuild),
1254 "100+ file checkout should trigger full rebuild"
1255 );
1256 }
1257 }
1258
1259 #[test]
1264 #[cfg_attr(target_os = "macos", ignore = "FSEvents timing flaky in CI")]
1265 fn stash_pop_produces_changesets() {
1266 let tmp = TempDir::new().unwrap();
1267 init_repo(tmp.path());
1268
1269 let watcher = SourceTreeWatcher::new(tmp.path()).unwrap();
1270 thread::sleep(Duration::from_millis(200));
1271 let _ = watcher.poll_changes(None);
1272
1273 fs::write(tmp.path().join("a.txt"), b"stash-me\n").unwrap();
1275 thread::sleep(Duration::from_millis(300));
1276
1277 let cs1 = watcher.poll_changes(None).unwrap();
1279 assert!(
1280 cs1.is_some_and(|cs| !cs.changed_files.is_empty()),
1281 "Edit should produce first changeset"
1282 );
1283
1284 run_git(tmp.path(), &["stash"]);
1286 thread::sleep(Duration::from_millis(300));
1287
1288 let cs2 = watcher.poll_changes(None).unwrap();
1290 assert!(cs2.is_some(), "Stash should produce changeset");
1291
1292 run_git(tmp.path(), &["stash", "pop"]);
1294 thread::sleep(Duration::from_millis(300));
1295
1296 let cs3 = watcher.poll_changes(None).unwrap();
1298 assert!(cs3.is_some(), "Stash pop should produce changeset");
1299 }
1300
1301 #[test]
1306 fn gc_zero_source_events() {
1307 let tmp = TempDir::new().unwrap();
1308 init_repo(tmp.path());
1309 for i in 0..10 {
1311 fs::write(tmp.path().join(format!("f{i}.txt")), format!("{i}\n")).unwrap();
1312 run_git(tmp.path(), &["add", "."]);
1313 run_git(tmp.path(), &["commit", "-q", "-m", &format!("commit {i}")]);
1314 }
1315
1316 let watcher = SourceTreeWatcher::new(tmp.path()).unwrap();
1317 let baseline = watcher.git_state().current_state();
1318 thread::sleep(Duration::from_millis(200));
1319 let _ = watcher.poll_changes(None);
1320
1321 run_git(tmp.path(), &["gc", "--quiet", "--prune=now"]);
1322 thread::sleep(Duration::from_millis(300));
1323
1324 let cs = watcher.poll_changes(Some(&baseline)).unwrap();
1328 if let Some(cs) = cs {
1329 assert!(
1330 cs.changed_files.is_empty(),
1331 "gc should not produce source-file events, got: {:?}",
1332 cs.changed_files
1333 );
1334 if cs.git_state_changed {
1338 assert!(
1339 cs.git_state_changed,
1340 "git_state_changed must be true when git events observed"
1341 );
1342 assert_eq!(
1343 cs.git_change_class,
1344 Some(GitChangeClass::Noise),
1345 "gc git events should classify as Noise"
1346 );
1347 }
1348 }
1349 }
1350
1351 #[test]
1356 #[cfg_attr(target_os = "macos", ignore = "FSEvents timing flaky in CI")]
1357 fn commit_no_additional_changeset() {
1358 let tmp = TempDir::new().unwrap();
1359 init_repo(tmp.path());
1360
1361 let watcher = SourceTreeWatcher::new(tmp.path()).unwrap();
1362 thread::sleep(Duration::from_millis(200));
1363 let _ = watcher.poll_changes(None);
1364
1365 fs::write(tmp.path().join("a.txt"), b"edited\n").unwrap();
1367 thread::sleep(Duration::from_millis(300));
1368 let cs1 = watcher.poll_changes(None).unwrap();
1369 assert!(
1370 cs1.is_some_and(|cs| !cs.changed_files.is_empty()),
1371 "Edit should produce changeset"
1372 );
1373
1374 let baseline = watcher.git_state().current_state();
1379 run_git(tmp.path(), &["add", "a.txt"]);
1380 run_git(tmp.path(), &["commit", "-q", "-m", "commit edit"]);
1381 thread::sleep(Duration::from_millis(300));
1382
1383 let cs2 = watcher.poll_changes(Some(&baseline)).unwrap();
1384 if let Some(cs2) = cs2 {
1385 let has_source_change = cs2
1388 .changed_files
1389 .iter()
1390 .any(|p| p.file_name().is_some_and(|n| n == "a.txt"));
1391 assert!(
1392 !has_source_change,
1393 "Commit should not re-report a.txt as changed"
1394 );
1395 }
1396 }
1397
1398 #[test]
1403 #[cfg_attr(target_os = "macos", ignore = "FSEvents timing flaky in CI")]
1404 fn poll_changes_reports_git_state_changed_on_git_only_events() {
1405 let tmp = TempDir::new().unwrap();
1412 init_repo(tmp.path());
1413
1414 let watcher = SourceTreeWatcher::new(tmp.path()).unwrap();
1415 let baseline = watcher.git_state().current_state();
1416 thread::sleep(Duration::from_millis(200));
1417 let _ = watcher.poll_changes(None); run_git(tmp.path(), &["checkout", "-q", "-b", "other"]);
1421 thread::sleep(Duration::from_millis(300));
1422
1423 let found = wait_for_poll(event_timeout(), || {
1425 if let Some(cs) = watcher.poll_changes(Some(&baseline)).unwrap()
1426 && cs.git_state_changed
1427 {
1428 assert!(
1429 cs.git_change_class.is_some(),
1430 "git_change_class must be set when git_state_changed is true"
1431 );
1432 return true;
1433 }
1434 false
1435 });
1436
1437 assert!(
1438 found,
1439 "poll_changes must report git_state_changed=true for branch switch"
1440 );
1441 }
1442
1443 #[test]
1448 fn wait_for_changes_cancellable_returns_none_on_pre_event_cancel() {
1449 let tmp = TempDir::new().expect("tempdir");
1453 init_repo(tmp.path());
1454
1455 let watcher = SourceTreeWatcher::new(tmp.path()).expect("watcher");
1456 let cancelled = std::sync::Arc::new(AtomicBool::new(false));
1457
1458 let cancel_signal = std::sync::Arc::clone(&cancelled);
1459 let handle = thread::spawn(move || {
1460 thread::sleep(Duration::from_millis(50));
1462 cancel_signal.store(true, Ordering::Release);
1463 });
1464
1465 let started = Instant::now();
1466 let result = watcher.wait_for_changes_cancellable(
1467 Duration::from_secs(60), None,
1469 &cancelled,
1470 Duration::from_millis(20),
1471 );
1472 let elapsed = started.elapsed();
1473 handle.join().unwrap();
1474
1475 assert!(
1476 matches!(result, Ok(None)),
1477 "pre-event cancellation must produce Ok(None), got {result:?}"
1478 );
1479 assert!(
1480 elapsed < Duration::from_secs(2),
1481 "cancellation must terminate quickly; took {elapsed:?}"
1482 );
1483 }
1484
1485 #[test]
1486 fn wait_for_changes_cancellable_returns_none_on_mid_debounce_cancel() {
1487 let tmp = TempDir::new().expect("tempdir");
1491 init_repo(tmp.path());
1492
1493 let watcher = SourceTreeWatcher::new(tmp.path()).expect("watcher");
1494 let cancelled = std::sync::Arc::new(AtomicBool::new(false));
1495
1496 fs::write(tmp.path().join("a.txt"), b"modified\n").unwrap();
1498
1499 let cancel_signal = std::sync::Arc::clone(&cancelled);
1500 let handle = thread::spawn(move || {
1501 thread::sleep(Duration::from_millis(500));
1505 cancel_signal.store(true, Ordering::Release);
1506 });
1507
1508 let started = Instant::now();
1509 let result = watcher.wait_for_changes_cancellable(
1510 Duration::from_secs(60),
1511 None,
1512 &cancelled,
1513 Duration::from_millis(20),
1514 );
1515 let elapsed = started.elapsed();
1516 handle.join().unwrap();
1517
1518 assert!(
1519 matches!(result, Ok(None)),
1520 "mid-debounce cancellation must produce Ok(None), got {result:?}"
1521 );
1522 assert!(
1523 elapsed < Duration::from_secs(3),
1524 "cancellation must terminate quickly; took {elapsed:?}"
1525 );
1526 }
1527}