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
424 .into_iter()
425 .filter(|change| {
426 let path = change.path();
427 !is_under_git_dir(path, &self.root)
428 && !self.is_gitignored(path)
429 && !is_editor_temporary(path)
430 })
431 .collect();
432
433 let coalesced = coalesce_rename_pairs(filtered);
435
436 let mut deduped: HashMap<PathBuf, &RawChange> = HashMap::new();
438 for change in &coalesced {
439 deduped.insert(change.path().to_path_buf(), change);
440 }
441
442 let changed_files: Vec<PathBuf> = deduped.into_keys().collect();
443
444 let git_change_class = if git_state_changed {
446 last_git_state.map(|last| self.git_state.classify(last))
447 } else {
448 None
449 };
450
451 Ok(ChangeSet {
452 changed_files,
453 git_state_changed,
454 git_change_class,
455 })
456 }
457
458 fn is_gitignored(&self, path: &Path) -> bool {
460 let is_dir = path.is_dir();
461 let rel = path.strip_prefix(&self.root).unwrap_or(path);
463 self.ignore_matcher
464 .matched_path_or_any_parents(rel, is_dir)
465 .is_ignore()
466 }
467}
468
469fn build_gitignore_matcher(root: &Path) -> Gitignore {
472 let mut builder = GitignoreBuilder::new(root);
473
474 let gitignore_path = root.join(".gitignore");
476 if gitignore_path.is_file()
477 && let Some(err) = builder.add(&gitignore_path)
478 {
479 log::warn!("Error parsing {}: {err}", gitignore_path.display());
480 }
481
482 let mut dirs_to_scan = vec![root.to_path_buf()];
485 let mut depth = 0;
486 const MAX_DEPTH: usize = 20;
487
488 while !dirs_to_scan.is_empty() && depth < MAX_DEPTH {
489 let mut next_dirs = Vec::new();
490 for dir in &dirs_to_scan {
491 let entries = match std::fs::read_dir(dir) {
492 Ok(e) => e,
493 Err(_) => continue,
494 };
495 for entry in entries.flatten() {
496 let path = entry.path();
497 if path.is_dir() {
498 if path.file_name().is_some_and(|n| n == ".git") {
500 continue;
501 }
502 let sub_gitignore = path.join(".gitignore");
504 if sub_gitignore.is_file()
505 && let Some(err) = builder.add(&sub_gitignore)
506 {
507 log::warn!("Error parsing {}: {err}", sub_gitignore.display());
508 }
509 next_dirs.push(path);
510 }
511 }
512 }
513 dirs_to_scan = next_dirs;
514 depth += 1;
515 }
516
517 match builder.build() {
518 Ok(matcher) => matcher,
519 Err(e) => {
520 log::warn!("Failed to build gitignore matcher: {e}; using empty matcher");
521 Gitignore::empty()
522 }
523 }
524}
525
526fn is_under_git_dir(path: &Path, root: &Path) -> bool {
528 let git_dir = root.join(".git");
529 path.starts_with(&git_dir)
530}
531
532fn is_editor_temporary(path: &Path) -> bool {
536 let Some(file_name) = path.file_name().and_then(|n| n.to_str()) else {
537 return false;
538 };
539
540 if (file_name.ends_with(".swp") || file_name.ends_with(".swo"))
542 && let Some(stem) = path.file_stem().and_then(|s| s.to_str())
543 && stem.starts_with('.')
544 {
545 return true;
546 }
547
548 if file_name.ends_with('~') {
550 return true;
551 }
552
553 if file_name.starts_with('#') && file_name.ends_with('#') {
555 return true;
556 }
557
558 if file_name.starts_with(".#") {
560 return true;
561 }
562
563 if file_name.ends_with(".bak") {
565 return true;
566 }
567
568 if file_name.ends_with("___jb_tmp___") || file_name.ends_with("___jb_old___") {
570 return true;
571 }
572
573 false
574}
575
576fn collect_raw_changes(event: &Event, out: &mut Vec<RawChange>) {
578 match event.kind {
579 EventKind::Create(_) => {
580 for path in &event.paths {
581 if path.is_file() {
582 out.push(RawChange::Create(path.clone()));
583 }
584 }
585 }
586 EventKind::Modify(_) => {
587 for path in &event.paths {
588 out.push(RawChange::Modify(path.clone()));
591 }
592 }
593 EventKind::Remove(_) => {
594 for path in &event.paths {
595 out.push(RawChange::Remove(path.clone()));
596 }
597 }
598 _ => {
599 }
601 }
602}
603
604fn coalesce_rename_pairs(changes: Vec<RawChange>) -> Vec<RawChange> {
617 if changes.len() < 2 {
618 return changes;
619 }
620
621 let mut result: Vec<RawChange> = Vec::with_capacity(changes.len());
622 let mut consumed: Vec<bool> = vec![false; changes.len()];
623
624 for i in 0..changes.len() {
625 if consumed[i] {
626 continue;
627 }
628
629 if let RawChange::Remove(ref remove_path) = changes[i] {
630 let mut found_create = false;
632 for j in (i + 1)..changes.len() {
633 if consumed[j] {
634 continue;
635 }
636 if let RawChange::Create(ref create_path) = changes[j]
637 && create_path == remove_path
638 {
639 result.push(RawChange::Modify(remove_path.clone()));
641 consumed[i] = true;
642 consumed[j] = true;
643 found_create = true;
644 break;
645 }
646 }
647 if !found_create {
648 result.push(changes[i].clone());
649 consumed[i] = true;
650 }
651 } else {
652 result.push(changes[i].clone());
653 consumed[i] = true;
654 }
655 }
656
657 result
658}
659
660#[cfg(test)]
661mod tests {
662 use super::*;
663 use std::fs;
664 use std::process::Command;
665 use std::thread;
666 use tempfile::TempDir;
667
668 fn event_timeout() -> Duration {
670 let base = if cfg!(target_os = "macos") {
671 Duration::from_secs(3)
672 } else {
673 Duration::from_secs(2)
674 };
675 if std::env::var("CI").is_ok() {
676 base * 2
677 } else {
678 base
679 }
680 }
681
682 fn init_repo(dir: &Path) {
683 run_git(dir, &["init", "-q", "-b", "main"]);
684 run_git(dir, &["config", "user.email", "test@sqry.dev"]);
685 run_git(dir, &["config", "user.name", "Sqry Test"]);
686 run_git(dir, &["config", "commit.gpgsign", "false"]);
687 fs::write(dir.join("a.txt"), b"alpha\n").unwrap();
688 run_git(dir, &["add", "a.txt"]);
689 run_git(dir, &["commit", "-q", "-m", "initial"]);
690 }
691
692 fn run_git(dir: &Path, args: &[&str]) {
693 let status = Command::new("git")
694 .arg("-C")
695 .arg(dir)
696 .args(args)
697 .status()
698 .expect("git command failed to launch");
699 assert!(status.success(), "git {args:?} failed in {}", dir.display());
700 }
701
702 fn wait_for_poll<F>(timeout: Duration, mut predicate: F) -> bool
703 where
704 F: FnMut() -> bool,
705 {
706 let deadline = Instant::now() + timeout;
707 loop {
708 if predicate() {
709 return true;
710 }
711 if Instant::now() >= deadline {
712 return false;
713 }
714 thread::sleep(Duration::from_millis(50));
715 }
716 }
717
718 #[test]
723 fn editor_temp_vim_swp() {
724 assert!(is_editor_temporary(Path::new("/tmp/.foo.swp")));
725 assert!(is_editor_temporary(Path::new("/tmp/.foo.swo")));
726 assert!(!is_editor_temporary(Path::new("/tmp/foo.swp")));
728 }
729
730 #[test]
731 fn editor_temp_emacs_backup() {
732 assert!(is_editor_temporary(Path::new("/tmp/foo.rs~")));
733 assert!(is_editor_temporary(Path::new("/tmp/#foo.rs#")));
734 assert!(is_editor_temporary(Path::new("/tmp/.#foo.rs")));
735 }
736
737 #[test]
738 fn editor_temp_vscode_bak() {
739 assert!(is_editor_temporary(Path::new("/tmp/foo.rs.bak")));
740 }
741
742 #[test]
743 fn editor_temp_jetbrains() {
744 assert!(is_editor_temporary(Path::new("/tmp/foo.rs___jb_tmp___")));
745 assert!(is_editor_temporary(Path::new("/tmp/foo.rs___jb_old___")));
746 }
747
748 #[test]
749 fn non_temp_files_pass_through() {
750 assert!(!is_editor_temporary(Path::new("/tmp/foo.rs")));
751 assert!(!is_editor_temporary(Path::new("/tmp/Makefile")));
752 assert!(!is_editor_temporary(Path::new("/tmp/README.md")));
753 }
754
755 #[test]
760 fn git_dir_detection() {
761 let root = Path::new("/repo");
762 assert!(is_under_git_dir(Path::new("/repo/.git/HEAD"), root));
763 assert!(is_under_git_dir(
764 Path::new("/repo/.git/refs/heads/main"),
765 root
766 ));
767 assert!(!is_under_git_dir(Path::new("/repo/src/main.rs"), root));
768 assert!(!is_under_git_dir(Path::new("/repo/.gitignore"), root));
769 }
770
771 #[test]
776 fn coalesce_empty() {
777 let result = coalesce_rename_pairs(vec![]);
778 assert!(result.is_empty());
779 }
780
781 #[test]
782 fn coalesce_single_event_passthrough() {
783 let changes = vec![RawChange::Modify(PathBuf::from("foo.rs"))];
784 let result = coalesce_rename_pairs(changes);
785 assert_eq!(result.len(), 1);
786 assert!(matches!(&result[0], RawChange::Modify(p) if p == Path::new("foo.rs")));
787 }
788
789 #[test]
790 fn coalesce_remove_create_same_path_becomes_modify() {
791 let changes = vec![
792 RawChange::Remove(PathBuf::from("foo.rs")),
793 RawChange::Create(PathBuf::from("foo.rs")),
794 ];
795 let result = coalesce_rename_pairs(changes);
796 assert_eq!(result.len(), 1);
797 assert!(
798 matches!(&result[0], RawChange::Modify(p) if p == Path::new("foo.rs")),
799 "Remove+Create should coalesce into Modify"
800 );
801 }
802
803 #[test]
804 fn coalesce_remove_create_different_paths_no_coalesce() {
805 let changes = vec![
806 RawChange::Remove(PathBuf::from("old.rs")),
807 RawChange::Create(PathBuf::from("new.rs")),
808 ];
809 let result = coalesce_rename_pairs(changes);
810 assert_eq!(result.len(), 2);
811 }
812
813 #[test]
814 fn coalesce_interleaved_events() {
815 let changes = vec![
817 RawChange::Remove(PathBuf::from("a.rs")),
818 RawChange::Modify(PathBuf::from("b.rs")),
819 RawChange::Create(PathBuf::from("a.rs")),
820 ];
821 let result = coalesce_rename_pairs(changes);
822 assert_eq!(result.len(), 2);
823 assert!(
825 result
826 .iter()
827 .any(|c| matches!(c, RawChange::Modify(p) if p == Path::new("a.rs")))
828 );
829 assert!(
830 result
831 .iter()
832 .any(|c| matches!(c, RawChange::Modify(p) if p == Path::new("b.rs")))
833 );
834 }
835
836 #[test]
837 fn coalesce_multiple_rename_pairs() {
838 let changes = vec![
839 RawChange::Remove(PathBuf::from("a.rs")),
840 RawChange::Remove(PathBuf::from("b.rs")),
841 RawChange::Create(PathBuf::from("a.rs")),
842 RawChange::Create(PathBuf::from("b.rs")),
843 ];
844 let result = coalesce_rename_pairs(changes);
845 assert_eq!(result.len(), 2);
846 assert!(result.iter().all(|c| matches!(c, RawChange::Modify(_))));
847 }
848
849 #[test]
854 fn gitignore_filters_target_directory() {
855 let tmp = TempDir::new().unwrap();
856 fs::write(tmp.path().join(".gitignore"), "target/\n*.log\n").unwrap();
857 let matcher = build_gitignore_matcher(tmp.path());
858
859 assert!(
860 matcher
861 .matched_path_or_any_parents("target/debug/foo", false)
862 .is_ignore(),
863 "target/ contents should be ignored"
864 );
865 assert!(
866 matcher
867 .matched_path_or_any_parents("build.log", false)
868 .is_ignore(),
869 "*.log should be ignored"
870 );
871 assert!(
872 !matcher
873 .matched_path_or_any_parents("src/main.rs", false)
874 .is_ignore(),
875 "src/main.rs should not be ignored"
876 );
877 }
878
879 #[test]
880 fn gitignore_nested_rules() {
881 let tmp = TempDir::new().unwrap();
882 fs::write(tmp.path().join(".gitignore"), "*.o\n").unwrap();
883 fs::create_dir_all(tmp.path().join("vendor")).unwrap();
884 fs::write(tmp.path().join("vendor/.gitignore"), "*.vendored\n").unwrap();
885
886 let matcher = build_gitignore_matcher(tmp.path());
887
888 assert!(
889 matcher
890 .matched_path_or_any_parents("foo.o", false)
891 .is_ignore()
892 );
893 assert!(
894 matcher
895 .matched_path_or_any_parents("vendor/lib.vendored", false)
896 .is_ignore()
897 );
898 }
899
900 #[test]
905 #[cfg_attr(target_os = "macos", ignore = "FSEvents timing flaky in CI")]
906 fn watcher_detects_source_file_change() {
907 let tmp = TempDir::new().unwrap();
908 init_repo(tmp.path());
909 fs::write(tmp.path().join(".gitignore"), "*.log\ntarget/\n").unwrap();
910 run_git(tmp.path(), &["add", ".gitignore"]);
911 run_git(tmp.path(), &["commit", "-q", "-m", "add gitignore"]);
912
913 let watcher = SourceTreeWatcher::new(tmp.path()).unwrap();
914
915 thread::sleep(Duration::from_millis(100));
917
918 fs::write(tmp.path().join("a.txt"), b"modified\n").unwrap();
920
921 let detected = wait_for_poll(event_timeout(), || {
922 let cs = watcher.poll_changes(None).unwrap();
923 cs.is_some_and(|cs| !cs.changed_files.is_empty())
924 });
925
926 assert!(detected, "Watcher should detect source file modification");
927 }
928
929 #[test]
930 #[cfg_attr(target_os = "macos", ignore = "FSEvents timing flaky in CI")]
931 fn watcher_filters_gitignored_files() {
932 let tmp = TempDir::new().unwrap();
933 init_repo(tmp.path());
934 fs::write(tmp.path().join(".gitignore"), "*.log\ntarget/\n").unwrap();
935 run_git(tmp.path(), &["add", ".gitignore"]);
936 run_git(tmp.path(), &["commit", "-q", "-m", "add gitignore"]);
937
938 let watcher = SourceTreeWatcher::new(tmp.path()).unwrap();
939 thread::sleep(Duration::from_millis(100));
940
941 fs::write(tmp.path().join("build.log"), b"log output\n").unwrap();
943
944 thread::sleep(Duration::from_millis(50));
946 fs::write(tmp.path().join("a.txt"), b"modified\n").unwrap();
947
948 let mut saw_log = false;
949 let saw_source = wait_for_poll(event_timeout(), || {
950 if let Some(cs) = watcher.poll_changes(None).unwrap() {
951 for path in &cs.changed_files {
952 if path.extension().is_some_and(|e| e == "log") {
953 saw_log = true;
954 }
955 }
956 cs.changed_files
957 .iter()
958 .any(|p| p.file_name().is_some_and(|n| n == "a.txt"))
959 } else {
960 false
961 }
962 });
963
964 assert!(saw_source, "Watcher should detect a.txt change");
965 assert!(!saw_log, "Watcher should filter out *.log files");
966 }
967
968 #[test]
969 #[cfg_attr(target_os = "macos", ignore = "FSEvents timing flaky in CI")]
970 fn watcher_filters_editor_temporaries() {
971 let tmp = TempDir::new().unwrap();
972 init_repo(tmp.path());
973
974 let watcher = SourceTreeWatcher::new(tmp.path()).unwrap();
975 thread::sleep(Duration::from_millis(100));
976
977 fs::write(tmp.path().join(".foo.swp"), b"vim swap\n").unwrap();
979 fs::write(tmp.path().join("bar.rs~"), b"emacs backup\n").unwrap();
980 fs::write(tmp.path().join("baz.rs.bak"), b"vscode bak\n").unwrap();
981
982 thread::sleep(Duration::from_millis(50));
984 fs::write(tmp.path().join("a.txt"), b"modified\n").unwrap();
985
986 let mut saw_temp = false;
987 let saw_source = wait_for_poll(event_timeout(), || {
988 if let Some(cs) = watcher.poll_changes(None).unwrap() {
989 for path in &cs.changed_files {
990 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
991 if name.ends_with(".swp") || name.ends_with('~') || name.ends_with(".bak") {
992 saw_temp = true;
993 }
994 }
995 cs.changed_files
996 .iter()
997 .any(|p| p.file_name().is_some_and(|n| n == "a.txt"))
998 } else {
999 false
1000 }
1001 });
1002
1003 assert!(saw_source, "Watcher should detect a.txt change");
1004 assert!(!saw_temp, "Watcher should filter out editor temporaries");
1005 }
1006
1007 #[test]
1008 #[cfg_attr(target_os = "macos", ignore = "FSEvents timing flaky in CI")]
1009 fn watcher_git_state_composition() {
1010 let tmp = TempDir::new().unwrap();
1011 init_repo(tmp.path());
1012
1013 let watcher = SourceTreeWatcher::new(tmp.path()).unwrap();
1014 let baseline = watcher.git_state().current_state();
1015
1016 thread::sleep(Duration::from_millis(200));
1018 let _ = watcher.poll_changes(None);
1019
1020 fs::write(tmp.path().join("a.txt"), b"changed\n").unwrap();
1022 run_git(tmp.path(), &["commit", "-q", "-am", "edit"]);
1023
1024 thread::sleep(Duration::from_millis(300));
1025
1026 let found = wait_for_poll(event_timeout(), || {
1029 if let Some(cs) = watcher.poll_changes(Some(&baseline)).unwrap() {
1030 if cs.git_state_changed {
1035 assert!(
1036 cs.git_change_class.is_some(),
1037 "git_change_class must be set when git_state_changed is true"
1038 );
1039 return true;
1040 }
1041 return !cs.changed_files.is_empty();
1043 }
1044 false
1045 });
1046
1047 assert!(
1048 found,
1049 "Should detect changes after commit with tree modification"
1050 );
1051 }
1052
1053 #[test]
1058 fn changeset_is_empty_when_no_changes() {
1059 let cs = ChangeSet {
1060 changed_files: vec![],
1061 git_state_changed: false,
1062 git_change_class: None,
1063 };
1064 assert!(cs.is_empty());
1065 assert!(!cs.requires_full_rebuild());
1066 }
1067
1068 #[test]
1069 fn changeset_requires_full_rebuild_on_branch_switch() {
1070 let cs = ChangeSet {
1071 changed_files: vec![],
1072 git_state_changed: true,
1073 git_change_class: Some(GitChangeClass::BranchSwitch),
1074 };
1075 assert!(!cs.is_empty());
1076 assert!(cs.requires_full_rebuild());
1077 }
1078
1079 #[test]
1080 fn changeset_requires_full_rebuild_on_tree_diverged() {
1081 let cs = ChangeSet {
1082 changed_files: vec![],
1083 git_state_changed: true,
1084 git_change_class: Some(GitChangeClass::TreeDiverged),
1085 };
1086 assert!(cs.requires_full_rebuild());
1087 }
1088
1089 #[test]
1090 fn changeset_no_rebuild_on_local_commit() {
1091 let cs = ChangeSet {
1092 changed_files: vec![],
1093 git_state_changed: true,
1094 git_change_class: Some(GitChangeClass::LocalCommit),
1095 };
1096 assert!(!cs.requires_full_rebuild());
1097 }
1098
1099 #[test]
1100 fn changeset_no_rebuild_on_noise() {
1101 let cs = ChangeSet {
1102 changed_files: vec![],
1103 git_state_changed: true,
1104 git_change_class: Some(GitChangeClass::Noise),
1105 };
1106 assert!(!cs.requires_full_rebuild());
1107 }
1108
1109 #[test]
1114 fn classify_gc_as_noise_through_source_tree_watcher() {
1115 let tmp = TempDir::new().unwrap();
1116 init_repo(tmp.path());
1117 fs::write(tmp.path().join("b.txt"), b"bravo\n").unwrap();
1119 run_git(tmp.path(), &["add", "b.txt"]);
1120 run_git(tmp.path(), &["reset", "--hard", "HEAD"]);
1121
1122 let watcher = SourceTreeWatcher::new(tmp.path()).unwrap();
1123 let baseline = watcher.git_state().current_state();
1124 thread::sleep(Duration::from_millis(200));
1126 let _ = watcher.poll_changes(None);
1127
1128 run_git(tmp.path(), &["gc", "--quiet", "--prune=now"]);
1129 thread::sleep(Duration::from_millis(300));
1130
1131 let class = watcher.git_state().classify(&baseline);
1133 assert_eq!(class, GitChangeClass::Noise);
1134 }
1135
1136 #[test]
1137 fn classify_staging_as_noise_through_source_tree_watcher() {
1138 let tmp = TempDir::new().unwrap();
1139 init_repo(tmp.path());
1140
1141 let watcher = SourceTreeWatcher::new(tmp.path()).unwrap();
1142 let baseline = watcher.git_state().current_state();
1143 thread::sleep(Duration::from_millis(200));
1144 let _ = watcher.poll_changes(None);
1145
1146 fs::write(tmp.path().join("c.txt"), b"charlie\n").unwrap();
1147 run_git(tmp.path(), &["add", "c.txt"]);
1148 run_git(tmp.path(), &["reset", "HEAD", "c.txt"]);
1149
1150 let class = watcher.git_state().classify(&baseline);
1151 assert_eq!(class, GitChangeClass::Noise);
1152 }
1153
1154 #[test]
1155 fn classify_branch_switch_through_source_tree_watcher() {
1156 let tmp = TempDir::new().unwrap();
1157 init_repo(tmp.path());
1158
1159 let watcher = SourceTreeWatcher::new(tmp.path()).unwrap();
1160 let baseline = watcher.git_state().current_state();
1161
1162 run_git(tmp.path(), &["checkout", "-q", "-b", "feature"]);
1163 let class = watcher.git_state().classify(&baseline);
1164 assert_eq!(class, GitChangeClass::BranchSwitch);
1165 assert!(class.requires_full_rebuild());
1166 }
1167
1168 #[test]
1173 #[cfg_attr(target_os = "macos", ignore = "FSEvents timing flaky in CI")]
1174 fn bulk_checkout_100_files_single_changeset() {
1175 let tmp = TempDir::new().unwrap();
1176 init_repo(tmp.path());
1177
1178 run_git(tmp.path(), &["checkout", "-q", "-b", "many-files"]);
1180 let src_dir = tmp.path().join("src");
1181 fs::create_dir_all(&src_dir).unwrap();
1182 for i in 0..120 {
1183 fs::write(
1184 src_dir.join(format!("file_{i}.rs")),
1185 format!("// file {i}\n"),
1186 )
1187 .unwrap();
1188 }
1189 run_git(tmp.path(), &["add", "."]);
1190 run_git(tmp.path(), &["commit", "-q", "-m", "add 120 files"]);
1191
1192 run_git(tmp.path(), &["checkout", "-q", "main"]);
1194
1195 let watcher = SourceTreeWatcher::new(tmp.path()).unwrap();
1196 let baseline = watcher.git_state().current_state();
1197 thread::sleep(Duration::from_millis(200));
1198 let _ = watcher.poll_changes(None);
1199
1200 run_git(tmp.path(), &["checkout", "-q", "many-files"]);
1202 thread::sleep(Duration::from_millis(500));
1203
1204 let cs = watcher.poll_changes(Some(&baseline)).unwrap();
1206 assert!(cs.is_some(), "Should detect checkout across 120 files");
1207 let cs = cs.unwrap();
1208
1209 if cs.git_state_changed {
1211 assert!(
1212 cs.git_change_class
1213 .is_some_and(GitChangeClass::requires_full_rebuild),
1214 "100+ file checkout should trigger full rebuild"
1215 );
1216 }
1217 }
1218
1219 #[test]
1224 #[cfg_attr(target_os = "macos", ignore = "FSEvents timing flaky in CI")]
1225 fn stash_pop_produces_changesets() {
1226 let tmp = TempDir::new().unwrap();
1227 init_repo(tmp.path());
1228
1229 let watcher = SourceTreeWatcher::new(tmp.path()).unwrap();
1230 thread::sleep(Duration::from_millis(200));
1231 let _ = watcher.poll_changes(None);
1232
1233 fs::write(tmp.path().join("a.txt"), b"stash-me\n").unwrap();
1235 thread::sleep(Duration::from_millis(300));
1236
1237 let cs1 = watcher.poll_changes(None).unwrap();
1239 assert!(
1240 cs1.is_some_and(|cs| !cs.changed_files.is_empty()),
1241 "Edit should produce first changeset"
1242 );
1243
1244 run_git(tmp.path(), &["stash"]);
1246 thread::sleep(Duration::from_millis(300));
1247
1248 let cs2 = watcher.poll_changes(None).unwrap();
1250 assert!(cs2.is_some(), "Stash should produce changeset");
1251
1252 run_git(tmp.path(), &["stash", "pop"]);
1254 thread::sleep(Duration::from_millis(300));
1255
1256 let cs3 = watcher.poll_changes(None).unwrap();
1258 assert!(cs3.is_some(), "Stash pop should produce changeset");
1259 }
1260
1261 #[test]
1266 fn gc_zero_source_events() {
1267 let tmp = TempDir::new().unwrap();
1268 init_repo(tmp.path());
1269 for i in 0..10 {
1271 fs::write(tmp.path().join(format!("f{i}.txt")), format!("{i}\n")).unwrap();
1272 run_git(tmp.path(), &["add", "."]);
1273 run_git(tmp.path(), &["commit", "-q", "-m", &format!("commit {i}")]);
1274 }
1275
1276 let watcher = SourceTreeWatcher::new(tmp.path()).unwrap();
1277 let baseline = watcher.git_state().current_state();
1278 thread::sleep(Duration::from_millis(200));
1279 let _ = watcher.poll_changes(None);
1280
1281 run_git(tmp.path(), &["gc", "--quiet", "--prune=now"]);
1282 thread::sleep(Duration::from_millis(300));
1283
1284 let cs = watcher.poll_changes(Some(&baseline)).unwrap();
1288 if let Some(cs) = cs {
1289 assert!(
1290 cs.changed_files.is_empty(),
1291 "gc should not produce source-file events, got: {:?}",
1292 cs.changed_files
1293 );
1294 if cs.git_state_changed {
1298 assert!(
1299 cs.git_state_changed,
1300 "git_state_changed must be true when git events observed"
1301 );
1302 assert_eq!(
1303 cs.git_change_class,
1304 Some(GitChangeClass::Noise),
1305 "gc git events should classify as Noise"
1306 );
1307 }
1308 }
1309 }
1310
1311 #[test]
1316 #[cfg_attr(target_os = "macos", ignore = "FSEvents timing flaky in CI")]
1317 fn commit_no_additional_changeset() {
1318 let tmp = TempDir::new().unwrap();
1319 init_repo(tmp.path());
1320
1321 let watcher = SourceTreeWatcher::new(tmp.path()).unwrap();
1322 thread::sleep(Duration::from_millis(200));
1323 let _ = watcher.poll_changes(None);
1324
1325 fs::write(tmp.path().join("a.txt"), b"edited\n").unwrap();
1327 thread::sleep(Duration::from_millis(300));
1328 let cs1 = watcher.poll_changes(None).unwrap();
1329 assert!(
1330 cs1.is_some_and(|cs| !cs.changed_files.is_empty()),
1331 "Edit should produce changeset"
1332 );
1333
1334 let baseline = watcher.git_state().current_state();
1339 run_git(tmp.path(), &["add", "a.txt"]);
1340 run_git(tmp.path(), &["commit", "-q", "-m", "commit edit"]);
1341 thread::sleep(Duration::from_millis(300));
1342
1343 let cs2 = watcher.poll_changes(Some(&baseline)).unwrap();
1344 if let Some(cs2) = cs2 {
1345 let has_source_change = cs2
1348 .changed_files
1349 .iter()
1350 .any(|p| p.file_name().is_some_and(|n| n == "a.txt"));
1351 assert!(
1352 !has_source_change,
1353 "Commit should not re-report a.txt as changed"
1354 );
1355 }
1356 }
1357
1358 #[test]
1363 #[cfg_attr(target_os = "macos", ignore = "FSEvents timing flaky in CI")]
1364 fn poll_changes_reports_git_state_changed_on_git_only_events() {
1365 let tmp = TempDir::new().unwrap();
1372 init_repo(tmp.path());
1373
1374 let watcher = SourceTreeWatcher::new(tmp.path()).unwrap();
1375 let baseline = watcher.git_state().current_state();
1376 thread::sleep(Duration::from_millis(200));
1377 let _ = watcher.poll_changes(None); run_git(tmp.path(), &["checkout", "-q", "-b", "other"]);
1381 thread::sleep(Duration::from_millis(300));
1382
1383 let found = wait_for_poll(event_timeout(), || {
1385 if let Some(cs) = watcher.poll_changes(Some(&baseline)).unwrap()
1386 && cs.git_state_changed
1387 {
1388 assert!(
1389 cs.git_change_class.is_some(),
1390 "git_change_class must be set when git_state_changed is true"
1391 );
1392 return true;
1393 }
1394 false
1395 });
1396
1397 assert!(
1398 found,
1399 "poll_changes must report git_state_changed=true for branch switch"
1400 );
1401 }
1402
1403 #[test]
1408 fn wait_for_changes_cancellable_returns_none_on_pre_event_cancel() {
1409 let tmp = TempDir::new().expect("tempdir");
1413 init_repo(tmp.path());
1414
1415 let watcher = SourceTreeWatcher::new(tmp.path()).expect("watcher");
1416 let cancelled = std::sync::Arc::new(AtomicBool::new(false));
1417
1418 let cancel_signal = std::sync::Arc::clone(&cancelled);
1419 let handle = thread::spawn(move || {
1420 thread::sleep(Duration::from_millis(50));
1422 cancel_signal.store(true, Ordering::Release);
1423 });
1424
1425 let started = Instant::now();
1426 let result = watcher.wait_for_changes_cancellable(
1427 Duration::from_secs(60), None,
1429 &cancelled,
1430 Duration::from_millis(20),
1431 );
1432 let elapsed = started.elapsed();
1433 handle.join().unwrap();
1434
1435 assert!(
1436 matches!(result, Ok(None)),
1437 "pre-event cancellation must produce Ok(None), got {result:?}"
1438 );
1439 assert!(
1440 elapsed < Duration::from_secs(2),
1441 "cancellation must terminate quickly; took {elapsed:?}"
1442 );
1443 }
1444
1445 #[test]
1446 fn wait_for_changes_cancellable_returns_none_on_mid_debounce_cancel() {
1447 let tmp = TempDir::new().expect("tempdir");
1451 init_repo(tmp.path());
1452
1453 let watcher = SourceTreeWatcher::new(tmp.path()).expect("watcher");
1454 let cancelled = std::sync::Arc::new(AtomicBool::new(false));
1455
1456 fs::write(tmp.path().join("a.txt"), b"modified\n").unwrap();
1458
1459 let cancel_signal = std::sync::Arc::clone(&cancelled);
1460 let handle = thread::spawn(move || {
1461 thread::sleep(Duration::from_millis(500));
1465 cancel_signal.store(true, Ordering::Release);
1466 });
1467
1468 let started = Instant::now();
1469 let result = watcher.wait_for_changes_cancellable(
1470 Duration::from_secs(60),
1471 None,
1472 &cancelled,
1473 Duration::from_millis(20),
1474 );
1475 let elapsed = started.elapsed();
1476 handle.join().unwrap();
1477
1478 assert!(
1479 matches!(result, Ok(None)),
1480 "mid-debounce cancellation must produce Ok(None), got {result:?}"
1481 );
1482 assert!(
1483 elapsed < Duration::from_secs(3),
1484 "cancellation must terminate quickly; took {elapsed:?}"
1485 );
1486 }
1487}