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 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 self.build_changeset(raw_changes, git_state_changed, last_git_state)
364 .map(Some)
365 }
366
367 pub fn poll_changes(
380 &self,
381 last_git_state: Option<&LastIndexedGitState>,
382 ) -> Result<Option<ChangeSet>> {
383 let mut raw_changes: Vec<RawChange> = Vec::new();
384
385 loop {
386 match self.receiver.try_recv() {
387 Ok(Ok(event)) => {
388 collect_raw_changes(&event, &mut raw_changes);
389 }
390 Ok(Err(e)) => {
391 log::warn!("Source-tree watcher error: {e}");
392 }
393 Err(TryRecvError::Empty) => break,
394 Err(TryRecvError::Disconnected) => {
395 anyhow::bail!("Source-tree watcher channel disconnected");
396 }
397 }
398 }
399
400 let git_state_changed = self.git_state.poll_changed();
401
402 if raw_changes.is_empty() && !git_state_changed {
403 return Ok(None);
404 }
405
406 self.build_changeset(raw_changes, git_state_changed, last_git_state)
407 .map(Some)
408 }
409
410 fn build_changeset(
417 &self,
418 raw_changes: Vec<RawChange>,
419 git_state_changed: bool,
420 last_git_state: Option<&LastIndexedGitState>,
421 ) -> Result<ChangeSet> {
422 let filtered: Vec<RawChange> = raw_changes
425 .into_iter()
426 .filter(|change| {
427 let path = change.path();
428 !is_under_git_dir(path, &self.root)
429 && !is_under_sqry_dir(path, &self.root)
430 && !self.is_gitignored(path)
431 && !is_editor_temporary(path)
432 })
433 .collect();
434
435 let coalesced = coalesce_rename_pairs(filtered);
437
438 let mut deduped: HashMap<PathBuf, &RawChange> = HashMap::new();
440 for change in &coalesced {
441 deduped.insert(change.path().to_path_buf(), change);
442 }
443
444 let changed_files: Vec<PathBuf> = deduped.into_keys().collect();
445
446 let git_change_class = if git_state_changed {
448 last_git_state.map(|last| self.git_state.classify(last))
449 } else {
450 None
451 };
452
453 Ok(ChangeSet {
454 changed_files,
455 git_state_changed,
456 git_change_class,
457 })
458 }
459
460 fn is_gitignored(&self, path: &Path) -> bool {
462 let is_dir = path.is_dir();
463 let rel = path.strip_prefix(&self.root).unwrap_or(path);
465 self.ignore_matcher
466 .matched_path_or_any_parents(rel, is_dir)
467 .is_ignore()
468 }
469}
470
471fn build_gitignore_matcher(root: &Path) -> Gitignore {
474 let mut builder = GitignoreBuilder::new(root);
475
476 let gitignore_path = root.join(".gitignore");
478 if gitignore_path.is_file()
479 && let Some(err) = builder.add(&gitignore_path)
480 {
481 log::warn!("Error parsing {}: {err}", gitignore_path.display());
482 }
483
484 let mut dirs_to_scan = vec![root.to_path_buf()];
487 let mut depth = 0;
488 const MAX_DEPTH: usize = 20;
489
490 while !dirs_to_scan.is_empty() && depth < MAX_DEPTH {
491 let mut next_dirs = Vec::new();
492 for dir in &dirs_to_scan {
493 let entries = match std::fs::read_dir(dir) {
494 Ok(e) => e,
495 Err(_) => continue,
496 };
497 for entry in entries.flatten() {
498 let path = entry.path();
499 if path.is_dir() {
500 if path.file_name().is_some_and(|n| n == ".git") {
502 continue;
503 }
504 let sub_gitignore = path.join(".gitignore");
506 if sub_gitignore.is_file()
507 && let Some(err) = builder.add(&sub_gitignore)
508 {
509 log::warn!("Error parsing {}: {err}", sub_gitignore.display());
510 }
511 next_dirs.push(path);
512 }
513 }
514 }
515 dirs_to_scan = next_dirs;
516 depth += 1;
517 }
518
519 match builder.build() {
520 Ok(matcher) => matcher,
521 Err(e) => {
522 log::warn!("Failed to build gitignore matcher: {e}; using empty matcher");
523 Gitignore::empty()
524 }
525 }
526}
527
528fn is_under_git_dir(path: &Path, root: &Path) -> bool {
530 let git_dir = root.join(".git");
531 path.starts_with(&git_dir)
532}
533
534fn is_under_sqry_dir(path: &Path, root: &Path) -> bool {
536 let sqry_dir = root.join(".sqry");
537 path.starts_with(&sqry_dir)
538}
539
540fn is_editor_temporary(path: &Path) -> bool {
544 let Some(file_name) = path.file_name().and_then(|n| n.to_str()) else {
545 return false;
546 };
547
548 if (file_name.ends_with(".swp") || file_name.ends_with(".swo"))
550 && let Some(stem) = path.file_stem().and_then(|s| s.to_str())
551 && stem.starts_with('.')
552 {
553 return true;
554 }
555
556 if file_name.ends_with('~') {
558 return true;
559 }
560
561 if file_name.starts_with('#') && file_name.ends_with('#') {
563 return true;
564 }
565
566 if file_name.starts_with(".#") {
568 return true;
569 }
570
571 if file_name.ends_with(".bak") {
573 return true;
574 }
575
576 if file_name.ends_with("___jb_tmp___") || file_name.ends_with("___jb_old___") {
578 return true;
579 }
580
581 false
582}
583
584fn collect_raw_changes(event: &Event, out: &mut Vec<RawChange>) {
586 match event.kind {
587 EventKind::Create(_) => {
588 for path in &event.paths {
589 if path.is_file() {
590 out.push(RawChange::Create(path.clone()));
591 }
592 }
593 }
594 EventKind::Modify(_) => {
595 for path in &event.paths {
596 out.push(RawChange::Modify(path.clone()));
599 }
600 }
601 EventKind::Remove(_) => {
602 for path in &event.paths {
603 out.push(RawChange::Remove(path.clone()));
604 }
605 }
606 _ => {
607 }
609 }
610}
611
612fn coalesce_rename_pairs(changes: Vec<RawChange>) -> Vec<RawChange> {
625 if changes.len() < 2 {
626 return changes;
627 }
628
629 let mut result: Vec<RawChange> = Vec::with_capacity(changes.len());
630 let mut consumed: Vec<bool> = vec![false; changes.len()];
631
632 for i in 0..changes.len() {
633 if consumed[i] {
634 continue;
635 }
636
637 if let RawChange::Remove(ref remove_path) = changes[i] {
638 let mut found_create = false;
640 for j in (i + 1)..changes.len() {
641 if consumed[j] {
642 continue;
643 }
644 if let RawChange::Create(ref create_path) = changes[j]
645 && create_path == remove_path
646 {
647 result.push(RawChange::Modify(remove_path.clone()));
649 consumed[i] = true;
650 consumed[j] = true;
651 found_create = true;
652 break;
653 }
654 }
655 if !found_create {
656 result.push(changes[i].clone());
657 consumed[i] = true;
658 }
659 } else {
660 result.push(changes[i].clone());
661 consumed[i] = true;
662 }
663 }
664
665 result
666}
667
668#[cfg(test)]
669mod tests {
670 use super::*;
671 use std::fs;
672 use std::process::Command;
673 use std::thread;
674 use tempfile::TempDir;
675
676 fn event_timeout() -> Duration {
678 let base = if cfg!(target_os = "macos") {
679 Duration::from_secs(3)
680 } else {
681 Duration::from_secs(2)
682 };
683 if std::env::var("CI").is_ok() {
684 base * 2
685 } else {
686 base
687 }
688 }
689
690 fn init_repo(dir: &Path) {
691 run_git(dir, &["init", "-q", "-b", "main"]);
692 run_git(dir, &["config", "user.email", "test@sqry.dev"]);
693 run_git(dir, &["config", "user.name", "Sqry Test"]);
694 run_git(dir, &["config", "commit.gpgsign", "false"]);
695 fs::write(dir.join("a.txt"), b"alpha\n").unwrap();
696 run_git(dir, &["add", "a.txt"]);
697 run_git(dir, &["commit", "-q", "-m", "initial"]);
698 }
699
700 fn run_git(dir: &Path, args: &[&str]) {
701 let status = Command::new("git")
702 .arg("-C")
703 .arg(dir)
704 .args(args)
705 .status()
706 .expect("git command failed to launch");
707 assert!(status.success(), "git {args:?} failed in {}", dir.display());
708 }
709
710 fn wait_for_poll<F>(timeout: Duration, mut predicate: F) -> bool
711 where
712 F: FnMut() -> bool,
713 {
714 let deadline = Instant::now() + timeout;
715 loop {
716 if predicate() {
717 return true;
718 }
719 if Instant::now() >= deadline {
720 return false;
721 }
722 thread::sleep(Duration::from_millis(50));
723 }
724 }
725
726 #[test]
731 fn editor_temp_vim_swp() {
732 assert!(is_editor_temporary(Path::new("/tmp/.foo.swp")));
733 assert!(is_editor_temporary(Path::new("/tmp/.foo.swo")));
734 assert!(!is_editor_temporary(Path::new("/tmp/foo.swp")));
736 }
737
738 #[test]
739 fn editor_temp_emacs_backup() {
740 assert!(is_editor_temporary(Path::new("/tmp/foo.rs~")));
741 assert!(is_editor_temporary(Path::new("/tmp/#foo.rs#")));
742 assert!(is_editor_temporary(Path::new("/tmp/.#foo.rs")));
743 }
744
745 #[test]
746 fn editor_temp_vscode_bak() {
747 assert!(is_editor_temporary(Path::new("/tmp/foo.rs.bak")));
748 }
749
750 #[test]
751 fn editor_temp_jetbrains() {
752 assert!(is_editor_temporary(Path::new("/tmp/foo.rs___jb_tmp___")));
753 assert!(is_editor_temporary(Path::new("/tmp/foo.rs___jb_old___")));
754 }
755
756 #[test]
757 fn non_temp_files_pass_through() {
758 assert!(!is_editor_temporary(Path::new("/tmp/foo.rs")));
759 assert!(!is_editor_temporary(Path::new("/tmp/Makefile")));
760 assert!(!is_editor_temporary(Path::new("/tmp/README.md")));
761 }
762
763 #[test]
768 fn git_dir_detection() {
769 let root = Path::new("/repo");
770 assert!(is_under_git_dir(Path::new("/repo/.git/HEAD"), root));
771 assert!(is_under_git_dir(
772 Path::new("/repo/.git/refs/heads/main"),
773 root
774 ));
775 assert!(!is_under_git_dir(Path::new("/repo/src/main.rs"), root));
776 assert!(!is_under_git_dir(Path::new("/repo/.gitignore"), root));
777 }
778
779 #[test]
784 fn sqry_dir_detection() {
785 let root = Path::new("/repo");
786 assert!(is_under_sqry_dir(
787 Path::new("/repo/.sqry/graph/snapshot.sqry"),
788 root
789 ));
790 assert!(is_under_sqry_dir(
791 Path::new("/repo/.sqry/analysis/adjacency.csr"),
792 root
793 ));
794 assert!(!is_under_sqry_dir(Path::new("/repo/src/main.rs"), root));
795 assert!(!is_under_sqry_dir(Path::new("/repo/.sqry-workspace"), root));
796 }
797
798 #[test]
803 fn coalesce_empty() {
804 let result = coalesce_rename_pairs(vec![]);
805 assert!(result.is_empty());
806 }
807
808 #[test]
809 fn coalesce_single_event_passthrough() {
810 let changes = vec![RawChange::Modify(PathBuf::from("foo.rs"))];
811 let result = coalesce_rename_pairs(changes);
812 assert_eq!(result.len(), 1);
813 assert!(matches!(&result[0], RawChange::Modify(p) if p == Path::new("foo.rs")));
814 }
815
816 #[test]
817 fn coalesce_remove_create_same_path_becomes_modify() {
818 let changes = vec![
819 RawChange::Remove(PathBuf::from("foo.rs")),
820 RawChange::Create(PathBuf::from("foo.rs")),
821 ];
822 let result = coalesce_rename_pairs(changes);
823 assert_eq!(result.len(), 1);
824 assert!(
825 matches!(&result[0], RawChange::Modify(p) if p == Path::new("foo.rs")),
826 "Remove+Create should coalesce into Modify"
827 );
828 }
829
830 #[test]
831 fn coalesce_remove_create_different_paths_no_coalesce() {
832 let changes = vec![
833 RawChange::Remove(PathBuf::from("old.rs")),
834 RawChange::Create(PathBuf::from("new.rs")),
835 ];
836 let result = coalesce_rename_pairs(changes);
837 assert_eq!(result.len(), 2);
838 }
839
840 #[test]
841 fn coalesce_interleaved_events() {
842 let changes = vec![
844 RawChange::Remove(PathBuf::from("a.rs")),
845 RawChange::Modify(PathBuf::from("b.rs")),
846 RawChange::Create(PathBuf::from("a.rs")),
847 ];
848 let result = coalesce_rename_pairs(changes);
849 assert_eq!(result.len(), 2);
850 assert!(
852 result
853 .iter()
854 .any(|c| matches!(c, RawChange::Modify(p) if p == Path::new("a.rs")))
855 );
856 assert!(
857 result
858 .iter()
859 .any(|c| matches!(c, RawChange::Modify(p) if p == Path::new("b.rs")))
860 );
861 }
862
863 #[test]
864 fn coalesce_multiple_rename_pairs() {
865 let changes = vec![
866 RawChange::Remove(PathBuf::from("a.rs")),
867 RawChange::Remove(PathBuf::from("b.rs")),
868 RawChange::Create(PathBuf::from("a.rs")),
869 RawChange::Create(PathBuf::from("b.rs")),
870 ];
871 let result = coalesce_rename_pairs(changes);
872 assert_eq!(result.len(), 2);
873 assert!(result.iter().all(|c| matches!(c, RawChange::Modify(_))));
874 }
875
876 #[test]
881 fn gitignore_filters_target_directory() {
882 let tmp = TempDir::new().unwrap();
883 fs::write(tmp.path().join(".gitignore"), "target/\n*.log\n").unwrap();
884 let matcher = build_gitignore_matcher(tmp.path());
885
886 assert!(
887 matcher
888 .matched_path_or_any_parents("target/debug/foo", false)
889 .is_ignore(),
890 "target/ contents should be ignored"
891 );
892 assert!(
893 matcher
894 .matched_path_or_any_parents("build.log", false)
895 .is_ignore(),
896 "*.log should be ignored"
897 );
898 assert!(
899 !matcher
900 .matched_path_or_any_parents("src/main.rs", false)
901 .is_ignore(),
902 "src/main.rs should not be ignored"
903 );
904 }
905
906 #[test]
907 fn gitignore_nested_rules() {
908 let tmp = TempDir::new().unwrap();
909 fs::write(tmp.path().join(".gitignore"), "*.o\n").unwrap();
910 fs::create_dir_all(tmp.path().join("vendor")).unwrap();
911 fs::write(tmp.path().join("vendor/.gitignore"), "*.vendored\n").unwrap();
912
913 let matcher = build_gitignore_matcher(tmp.path());
914
915 assert!(
916 matcher
917 .matched_path_or_any_parents("foo.o", false)
918 .is_ignore()
919 );
920 assert!(
921 matcher
922 .matched_path_or_any_parents("vendor/lib.vendored", false)
923 .is_ignore()
924 );
925 }
926
927 #[test]
932 #[cfg_attr(target_os = "macos", ignore = "FSEvents timing flaky in CI")]
933 fn watcher_detects_source_file_change() {
934 let tmp = TempDir::new().unwrap();
935 init_repo(tmp.path());
936 fs::write(tmp.path().join(".gitignore"), "*.log\ntarget/\n").unwrap();
937 run_git(tmp.path(), &["add", ".gitignore"]);
938 run_git(tmp.path(), &["commit", "-q", "-m", "add gitignore"]);
939
940 let watcher = SourceTreeWatcher::new(tmp.path()).unwrap();
941
942 thread::sleep(Duration::from_millis(100));
944
945 fs::write(tmp.path().join("a.txt"), b"modified\n").unwrap();
947
948 let detected = wait_for_poll(event_timeout(), || {
949 let cs = watcher.poll_changes(None).unwrap();
950 cs.is_some_and(|cs| !cs.changed_files.is_empty())
951 });
952
953 assert!(detected, "Watcher should detect source file modification");
954 }
955
956 #[test]
957 #[cfg_attr(target_os = "macos", ignore = "FSEvents timing flaky in CI")]
958 fn watcher_filters_gitignored_files() {
959 let tmp = TempDir::new().unwrap();
960 init_repo(tmp.path());
961 fs::write(tmp.path().join(".gitignore"), "*.log\ntarget/\n").unwrap();
962 run_git(tmp.path(), &["add", ".gitignore"]);
963 run_git(tmp.path(), &["commit", "-q", "-m", "add gitignore"]);
964
965 let watcher = SourceTreeWatcher::new(tmp.path()).unwrap();
966 thread::sleep(Duration::from_millis(100));
967
968 fs::write(tmp.path().join("build.log"), b"log output\n").unwrap();
970
971 thread::sleep(Duration::from_millis(50));
973 fs::write(tmp.path().join("a.txt"), b"modified\n").unwrap();
974
975 let mut saw_log = false;
976 let saw_source = wait_for_poll(event_timeout(), || {
977 if let Some(cs) = watcher.poll_changes(None).unwrap() {
978 for path in &cs.changed_files {
979 if path.extension().is_some_and(|e| e == "log") {
980 saw_log = true;
981 }
982 }
983 cs.changed_files
984 .iter()
985 .any(|p| p.file_name().is_some_and(|n| n == "a.txt"))
986 } else {
987 false
988 }
989 });
990
991 assert!(saw_source, "Watcher should detect a.txt change");
992 assert!(!saw_log, "Watcher should filter out *.log files");
993 }
994
995 #[test]
996 #[cfg_attr(target_os = "macos", ignore = "FSEvents timing flaky in CI")]
997 fn watcher_filters_editor_temporaries() {
998 let tmp = TempDir::new().unwrap();
999 init_repo(tmp.path());
1000
1001 let watcher = SourceTreeWatcher::new(tmp.path()).unwrap();
1002 thread::sleep(Duration::from_millis(100));
1003
1004 fs::write(tmp.path().join(".foo.swp"), b"vim swap\n").unwrap();
1006 fs::write(tmp.path().join("bar.rs~"), b"emacs backup\n").unwrap();
1007 fs::write(tmp.path().join("baz.rs.bak"), b"vscode bak\n").unwrap();
1008
1009 thread::sleep(Duration::from_millis(50));
1011 fs::write(tmp.path().join("a.txt"), b"modified\n").unwrap();
1012
1013 let mut saw_temp = false;
1014 let saw_source = wait_for_poll(event_timeout(), || {
1015 if let Some(cs) = watcher.poll_changes(None).unwrap() {
1016 for path in &cs.changed_files {
1017 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
1018 if name.ends_with(".swp") || name.ends_with('~') || name.ends_with(".bak") {
1019 saw_temp = true;
1020 }
1021 }
1022 cs.changed_files
1023 .iter()
1024 .any(|p| p.file_name().is_some_and(|n| n == "a.txt"))
1025 } else {
1026 false
1027 }
1028 });
1029
1030 assert!(saw_source, "Watcher should detect a.txt change");
1031 assert!(!saw_temp, "Watcher should filter out editor temporaries");
1032 }
1033
1034 #[test]
1035 #[cfg_attr(target_os = "macos", ignore = "FSEvents timing flaky in CI")]
1036 fn watcher_git_state_composition() {
1037 let tmp = TempDir::new().unwrap();
1038 init_repo(tmp.path());
1039
1040 let watcher = SourceTreeWatcher::new(tmp.path()).unwrap();
1041 let baseline = watcher.git_state().current_state();
1042
1043 thread::sleep(Duration::from_millis(200));
1045 let _ = watcher.poll_changes(None);
1046
1047 fs::write(tmp.path().join("a.txt"), b"changed\n").unwrap();
1049 run_git(tmp.path(), &["commit", "-q", "-am", "edit"]);
1050
1051 thread::sleep(Duration::from_millis(300));
1052
1053 let found = wait_for_poll(event_timeout(), || {
1056 if let Some(cs) = watcher.poll_changes(Some(&baseline)).unwrap() {
1057 if cs.git_state_changed {
1062 assert!(
1063 cs.git_change_class.is_some(),
1064 "git_change_class must be set when git_state_changed is true"
1065 );
1066 return true;
1067 }
1068 return !cs.changed_files.is_empty();
1070 }
1071 false
1072 });
1073
1074 assert!(
1075 found,
1076 "Should detect changes after commit with tree modification"
1077 );
1078 }
1079
1080 #[test]
1085 fn changeset_is_empty_when_no_changes() {
1086 let cs = ChangeSet {
1087 changed_files: vec![],
1088 git_state_changed: false,
1089 git_change_class: None,
1090 };
1091 assert!(cs.is_empty());
1092 assert!(!cs.requires_full_rebuild());
1093 }
1094
1095 #[test]
1096 fn changeset_requires_full_rebuild_on_branch_switch() {
1097 let cs = ChangeSet {
1098 changed_files: vec![],
1099 git_state_changed: true,
1100 git_change_class: Some(GitChangeClass::BranchSwitch),
1101 };
1102 assert!(!cs.is_empty());
1103 assert!(cs.requires_full_rebuild());
1104 }
1105
1106 #[test]
1107 fn changeset_requires_full_rebuild_on_tree_diverged() {
1108 let cs = ChangeSet {
1109 changed_files: vec![],
1110 git_state_changed: true,
1111 git_change_class: Some(GitChangeClass::TreeDiverged),
1112 };
1113 assert!(cs.requires_full_rebuild());
1114 }
1115
1116 #[test]
1117 fn changeset_no_rebuild_on_local_commit() {
1118 let cs = ChangeSet {
1119 changed_files: vec![],
1120 git_state_changed: true,
1121 git_change_class: Some(GitChangeClass::LocalCommit),
1122 };
1123 assert!(!cs.requires_full_rebuild());
1124 }
1125
1126 #[test]
1127 fn changeset_no_rebuild_on_noise() {
1128 let cs = ChangeSet {
1129 changed_files: vec![],
1130 git_state_changed: true,
1131 git_change_class: Some(GitChangeClass::Noise),
1132 };
1133 assert!(!cs.requires_full_rebuild());
1134 }
1135
1136 #[test]
1141 fn classify_gc_as_noise_through_source_tree_watcher() {
1142 let tmp = TempDir::new().unwrap();
1143 init_repo(tmp.path());
1144 fs::write(tmp.path().join("b.txt"), b"bravo\n").unwrap();
1146 run_git(tmp.path(), &["add", "b.txt"]);
1147 run_git(tmp.path(), &["reset", "--hard", "HEAD"]);
1148
1149 let watcher = SourceTreeWatcher::new(tmp.path()).unwrap();
1150 let baseline = watcher.git_state().current_state();
1151 thread::sleep(Duration::from_millis(200));
1153 let _ = watcher.poll_changes(None);
1154
1155 run_git(tmp.path(), &["gc", "--quiet", "--prune=now"]);
1156 thread::sleep(Duration::from_millis(300));
1157
1158 let class = watcher.git_state().classify(&baseline);
1160 assert_eq!(class, GitChangeClass::Noise);
1161 }
1162
1163 #[test]
1164 fn classify_staging_as_noise_through_source_tree_watcher() {
1165 let tmp = TempDir::new().unwrap();
1166 init_repo(tmp.path());
1167
1168 let watcher = SourceTreeWatcher::new(tmp.path()).unwrap();
1169 let baseline = watcher.git_state().current_state();
1170 thread::sleep(Duration::from_millis(200));
1171 let _ = watcher.poll_changes(None);
1172
1173 fs::write(tmp.path().join("c.txt"), b"charlie\n").unwrap();
1174 run_git(tmp.path(), &["add", "c.txt"]);
1175 run_git(tmp.path(), &["reset", "HEAD", "c.txt"]);
1176
1177 let class = watcher.git_state().classify(&baseline);
1178 assert_eq!(class, GitChangeClass::Noise);
1179 }
1180
1181 #[test]
1182 fn classify_branch_switch_through_source_tree_watcher() {
1183 let tmp = TempDir::new().unwrap();
1184 init_repo(tmp.path());
1185
1186 let watcher = SourceTreeWatcher::new(tmp.path()).unwrap();
1187 let baseline = watcher.git_state().current_state();
1188
1189 run_git(tmp.path(), &["checkout", "-q", "-b", "feature"]);
1190 let class = watcher.git_state().classify(&baseline);
1191 assert_eq!(class, GitChangeClass::BranchSwitch);
1192 assert!(class.requires_full_rebuild());
1193 }
1194
1195 #[test]
1200 #[cfg_attr(target_os = "macos", ignore = "FSEvents timing flaky in CI")]
1201 fn bulk_checkout_100_files_single_changeset() {
1202 let tmp = TempDir::new().unwrap();
1203 init_repo(tmp.path());
1204
1205 run_git(tmp.path(), &["checkout", "-q", "-b", "many-files"]);
1207 let src_dir = tmp.path().join("src");
1208 fs::create_dir_all(&src_dir).unwrap();
1209 for i in 0..120 {
1210 fs::write(
1211 src_dir.join(format!("file_{i}.rs")),
1212 format!("// file {i}\n"),
1213 )
1214 .unwrap();
1215 }
1216 run_git(tmp.path(), &["add", "."]);
1217 run_git(tmp.path(), &["commit", "-q", "-m", "add 120 files"]);
1218
1219 run_git(tmp.path(), &["checkout", "-q", "main"]);
1221
1222 let watcher = SourceTreeWatcher::new(tmp.path()).unwrap();
1223 let baseline = watcher.git_state().current_state();
1224 thread::sleep(Duration::from_millis(200));
1225 let _ = watcher.poll_changes(None);
1226
1227 run_git(tmp.path(), &["checkout", "-q", "many-files"]);
1229 thread::sleep(Duration::from_millis(500));
1230
1231 let cs = watcher.poll_changes(Some(&baseline)).unwrap();
1233 assert!(cs.is_some(), "Should detect checkout across 120 files");
1234 let cs = cs.unwrap();
1235
1236 if cs.git_state_changed {
1238 assert!(
1239 cs.git_change_class
1240 .is_some_and(GitChangeClass::requires_full_rebuild),
1241 "100+ file checkout should trigger full rebuild"
1242 );
1243 }
1244 }
1245
1246 #[test]
1251 #[cfg_attr(target_os = "macos", ignore = "FSEvents timing flaky in CI")]
1252 fn stash_pop_produces_changesets() {
1253 let tmp = TempDir::new().unwrap();
1254 init_repo(tmp.path());
1255
1256 let watcher = SourceTreeWatcher::new(tmp.path()).unwrap();
1257 thread::sleep(Duration::from_millis(200));
1258 let _ = watcher.poll_changes(None);
1259
1260 fs::write(tmp.path().join("a.txt"), b"stash-me\n").unwrap();
1262 thread::sleep(Duration::from_millis(300));
1263
1264 let cs1 = watcher.poll_changes(None).unwrap();
1266 assert!(
1267 cs1.is_some_and(|cs| !cs.changed_files.is_empty()),
1268 "Edit should produce first changeset"
1269 );
1270
1271 run_git(tmp.path(), &["stash"]);
1273 thread::sleep(Duration::from_millis(300));
1274
1275 let cs2 = watcher.poll_changes(None).unwrap();
1277 assert!(cs2.is_some(), "Stash should produce changeset");
1278
1279 run_git(tmp.path(), &["stash", "pop"]);
1281 thread::sleep(Duration::from_millis(300));
1282
1283 let cs3 = watcher.poll_changes(None).unwrap();
1285 assert!(cs3.is_some(), "Stash pop should produce changeset");
1286 }
1287
1288 #[test]
1293 fn gc_zero_source_events() {
1294 let tmp = TempDir::new().unwrap();
1295 init_repo(tmp.path());
1296 for i in 0..10 {
1298 fs::write(tmp.path().join(format!("f{i}.txt")), format!("{i}\n")).unwrap();
1299 run_git(tmp.path(), &["add", "."]);
1300 run_git(tmp.path(), &["commit", "-q", "-m", &format!("commit {i}")]);
1301 }
1302
1303 let watcher = SourceTreeWatcher::new(tmp.path()).unwrap();
1304 let baseline = watcher.git_state().current_state();
1305 thread::sleep(Duration::from_millis(200));
1306 let _ = watcher.poll_changes(None);
1307
1308 run_git(tmp.path(), &["gc", "--quiet", "--prune=now"]);
1309 thread::sleep(Duration::from_millis(300));
1310
1311 let cs = watcher.poll_changes(Some(&baseline)).unwrap();
1315 if let Some(cs) = cs {
1316 assert!(
1317 cs.changed_files.is_empty(),
1318 "gc should not produce source-file events, got: {:?}",
1319 cs.changed_files
1320 );
1321 if cs.git_state_changed {
1325 assert!(
1326 cs.git_state_changed,
1327 "git_state_changed must be true when git events observed"
1328 );
1329 assert_eq!(
1330 cs.git_change_class,
1331 Some(GitChangeClass::Noise),
1332 "gc git events should classify as Noise"
1333 );
1334 }
1335 }
1336 }
1337
1338 #[test]
1343 #[cfg_attr(target_os = "macos", ignore = "FSEvents timing flaky in CI")]
1344 fn commit_no_additional_changeset() {
1345 let tmp = TempDir::new().unwrap();
1346 init_repo(tmp.path());
1347
1348 let watcher = SourceTreeWatcher::new(tmp.path()).unwrap();
1349 thread::sleep(Duration::from_millis(200));
1350 let _ = watcher.poll_changes(None);
1351
1352 fs::write(tmp.path().join("a.txt"), b"edited\n").unwrap();
1354 thread::sleep(Duration::from_millis(300));
1355 let cs1 = watcher.poll_changes(None).unwrap();
1356 assert!(
1357 cs1.is_some_and(|cs| !cs.changed_files.is_empty()),
1358 "Edit should produce changeset"
1359 );
1360
1361 let baseline = watcher.git_state().current_state();
1366 run_git(tmp.path(), &["add", "a.txt"]);
1367 run_git(tmp.path(), &["commit", "-q", "-m", "commit edit"]);
1368 thread::sleep(Duration::from_millis(300));
1369
1370 let cs2 = watcher.poll_changes(Some(&baseline)).unwrap();
1371 if let Some(cs2) = cs2 {
1372 let has_source_change = cs2
1375 .changed_files
1376 .iter()
1377 .any(|p| p.file_name().is_some_and(|n| n == "a.txt"));
1378 assert!(
1379 !has_source_change,
1380 "Commit should not re-report a.txt as changed"
1381 );
1382 }
1383 }
1384
1385 #[test]
1390 #[cfg_attr(target_os = "macos", ignore = "FSEvents timing flaky in CI")]
1391 fn poll_changes_reports_git_state_changed_on_git_only_events() {
1392 let tmp = TempDir::new().unwrap();
1399 init_repo(tmp.path());
1400
1401 let watcher = SourceTreeWatcher::new(tmp.path()).unwrap();
1402 let baseline = watcher.git_state().current_state();
1403 thread::sleep(Duration::from_millis(200));
1404 let _ = watcher.poll_changes(None); run_git(tmp.path(), &["checkout", "-q", "-b", "other"]);
1408 thread::sleep(Duration::from_millis(300));
1409
1410 let found = wait_for_poll(event_timeout(), || {
1412 if let Some(cs) = watcher.poll_changes(Some(&baseline)).unwrap()
1413 && cs.git_state_changed
1414 {
1415 assert!(
1416 cs.git_change_class.is_some(),
1417 "git_change_class must be set when git_state_changed is true"
1418 );
1419 return true;
1420 }
1421 false
1422 });
1423
1424 assert!(
1425 found,
1426 "poll_changes must report git_state_changed=true for branch switch"
1427 );
1428 }
1429
1430 #[test]
1435 fn wait_for_changes_cancellable_returns_none_on_pre_event_cancel() {
1436 let tmp = TempDir::new().expect("tempdir");
1440 init_repo(tmp.path());
1441
1442 let watcher = SourceTreeWatcher::new(tmp.path()).expect("watcher");
1443 let cancelled = std::sync::Arc::new(AtomicBool::new(false));
1444
1445 let cancel_signal = std::sync::Arc::clone(&cancelled);
1446 let handle = thread::spawn(move || {
1447 thread::sleep(Duration::from_millis(50));
1449 cancel_signal.store(true, Ordering::Release);
1450 });
1451
1452 let started = Instant::now();
1453 let result = watcher.wait_for_changes_cancellable(
1454 Duration::from_secs(60), None,
1456 &cancelled,
1457 Duration::from_millis(20),
1458 );
1459 let elapsed = started.elapsed();
1460 handle.join().unwrap();
1461
1462 assert!(
1463 matches!(result, Ok(None)),
1464 "pre-event cancellation must produce Ok(None), got {result:?}"
1465 );
1466 assert!(
1467 elapsed < Duration::from_secs(2),
1468 "cancellation must terminate quickly; took {elapsed:?}"
1469 );
1470 }
1471
1472 #[test]
1473 fn wait_for_changes_cancellable_returns_none_on_mid_debounce_cancel() {
1474 let tmp = TempDir::new().expect("tempdir");
1478 init_repo(tmp.path());
1479
1480 let watcher = SourceTreeWatcher::new(tmp.path()).expect("watcher");
1481 let cancelled = std::sync::Arc::new(AtomicBool::new(false));
1482
1483 fs::write(tmp.path().join("a.txt"), b"modified\n").unwrap();
1485
1486 let cancel_signal = std::sync::Arc::clone(&cancelled);
1487 let handle = thread::spawn(move || {
1488 thread::sleep(Duration::from_millis(500));
1492 cancel_signal.store(true, Ordering::Release);
1493 });
1494
1495 let started = Instant::now();
1496 let result = watcher.wait_for_changes_cancellable(
1497 Duration::from_secs(60),
1498 None,
1499 &cancelled,
1500 Duration::from_millis(20),
1501 );
1502 let elapsed = started.elapsed();
1503 handle.join().unwrap();
1504
1505 assert!(
1506 matches!(result, Ok(None)),
1507 "mid-debounce cancellation must produce Ok(None), got {result:?}"
1508 );
1509 assert!(
1510 elapsed < Duration::from_secs(3),
1511 "cancellation must terminate quickly; took {elapsed:?}"
1512 );
1513 }
1514}