Skip to main content

sqry_core/watch/
source_tree.rs

1//! Recursive source-tree watcher with `.gitignore` filtering and git state
2//! composition.
3//!
4//! [`SourceTreeWatcher`] watches the entire project working tree for file
5//! modifications and reports debounced [`ChangeSet`]s. It composes two
6//! internal watchers:
7//!
8//! 1. A **source-tree watcher** (notify, recursive) that monitors non-ignored
9//!    source files and filters out `.git/` internals, `.sqry/` artifacts,
10//!    editor temporaries, and `.gitignore`-excluded paths.
11//! 2. A [`GitStateWatcher`] that monitors `.git/` internals and classifies
12//!    changes into [`GitChangeClass`] categories so the daemon can decide
13//!    whether a full rebuild is needed.
14//!
15//! # Debounce strategy — sliding window
16//!
17//! After the first event arrives, the watcher waits for a *quiet period*: a
18//! duration of silence after the most-recently-received event. If new events
19//! keep arriving, the window slides forward. Once the quiet period elapses
20//! with no new events, all collected events are merged and returned as a
21//! single [`ChangeSet`].
22//!
23//! # Windows rename coalescing
24//!
25//! On Windows, `ReadDirectoryChangesW` reports atomic renames (used by Vim,
26//! `JetBrains`, VS Code) as separate Remove + Create pairs. The debounce
27//! loop includes a coalescing pass that detects a Remove immediately followed
28//! by a Create for the **same canonical path** and collapses them into a
29//! single logical Modify. This ensures editor save patterns normalize to
30//! "exactly one changed file" across all platforms.
31//!
32//! # Editor temporary file filtering
33//!
34//! In addition to `.gitignore` rules, the watcher applies hard-coded filters
35//! for common editor temporaries that may not appear in `.gitignore`:
36//!
37//! - Vim: `.*.swp`, `.*.swo`, `*~`
38//! - Emacs: `*~`, `#*#`, `.#*`
39//! - VS Code: `.bak` suffix (from safe-save rename dance)
40//! - `JetBrains`: `___jb_tmp___`, `___jb_old___` suffixes
41//!
42//! These filters run *after* `.gitignore` matching so that a deliberate
43//! `.gitignore` override (`!*.swp`) is respected.
44
45use 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/// Result of waiting for source-tree and git-state changes.
56///
57/// A `ChangeSet` aggregates all non-ignored file paths that changed during a
58/// single debounce window, plus the result of a git-state classification
59/// against the caller's last-indexed snapshot.
60#[derive(Debug, Clone)]
61pub struct ChangeSet {
62    /// Deduplicated, canonicalized source files that were created, modified,
63    /// or deleted during the debounce window. Paths are relative to the
64    /// repository root when possible, absolute otherwise.
65    pub changed_files: Vec<PathBuf>,
66    /// `true` if the [`GitStateWatcher`] observed at least one event in
67    /// `.git/` during this window. Callers should inspect
68    /// [`git_change_class`](Self::git_change_class) to decide whether a full
69    /// rebuild is needed.
70    pub git_state_changed: bool,
71    /// Classification of the git state change (if any). `None` when
72    /// `git_state_changed` is `false` or when no `LastIndexedGitState`
73    /// was provided for comparison.
74    pub git_change_class: Option<GitChangeClass>,
75}
76
77impl ChangeSet {
78    /// Returns `true` if neither source files nor git state changed.
79    #[must_use]
80    pub fn is_empty(&self) -> bool {
81        self.changed_files.is_empty() && !self.git_state_changed
82    }
83
84    /// Returns `true` if the git state change requires a full rebuild.
85    #[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/// Raw event collected during the debounce window before deduplication.
93#[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
108/// Recursive source-tree watcher with `.gitignore` filtering and git state
109/// detection.
110///
111/// Unlike [`super::FileWatcher`] (which watches a single directory for index
112/// file invalidation), `SourceTreeWatcher` monitors the full project tree and
113/// is designed for the `sqryd` daemon's rebuild loop.
114pub struct SourceTreeWatcher {
115    /// Underlying notify watcher for source files.
116    _watcher: RecommendedWatcher,
117    /// Channel for receiving source-tree file system events.
118    receiver: Receiver<Result<Event, notify::Error>>,
119    /// Absolute path to the repository root.
120    root: PathBuf,
121    /// Compiled `.gitignore` matcher.
122    ignore_matcher: Gitignore,
123    /// Git-state watcher (`.git/` internals).
124    git_state: GitStateWatcher,
125}
126
127impl SourceTreeWatcher {
128    /// Creates a new source-tree watcher rooted at `root`.
129    ///
130    /// The watcher recursively monitors all files under `root`, filtering out
131    /// `.gitignore`-excluded paths, `.git/` internals (delegated to the
132    /// internal [`GitStateWatcher`]), and common editor temporaries.
133    ///
134    /// # Errors
135    ///
136    /// Returns an error if:
137    /// - The notify watcher cannot be created or attached.
138    /// - The git-state watcher cannot be created (missing `.git`).
139    /// - The `.gitignore` file exists but is malformed (logged as warning,
140    ///   not fatal — an empty matcher is used as fallback).
141    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        // Build gitignore matcher from all .gitignore files in the tree.
146        let ignore_matcher = build_gitignore_matcher(&root);
147
148        // Source-tree notify watcher.
149        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        // Git-state watcher.
160        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    /// Returns the repository root this watcher is monitoring.
175    #[must_use]
176    pub fn root(&self) -> &Path {
177        &self.root
178    }
179
180    /// Returns a reference to the internal [`GitStateWatcher`].
181    #[must_use]
182    pub fn git_state(&self) -> &GitStateWatcher {
183        &self.git_state
184    }
185
186    /// Blocking wait for changes with sliding-window debounce.
187    ///
188    /// Blocks until at least one relevant event arrives, then continues
189    /// collecting events until `debounce` elapses with no new events.
190    /// Returns a [`ChangeSet`] with all changes observed during the window.
191    ///
192    /// If `last_git_state` is provided, the git-state classifier runs against
193    /// it and the result is stored in [`ChangeSet::git_change_class`].
194    ///
195    /// # Errors
196    ///
197    /// Returns an error if the underlying watcher channel disconnects.
198    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        // Block until first event.
206        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        // Sliding-window: keep draining until `debounce` of silence.
215        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                    // Slide the window forward.
228                    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                    // Check if we've passed the deadline.
236                    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    /// Debounced wait for changes with cooperative cancellation.
252    ///
253    /// Behaves like [`Self::wait_for_changes`] but polls `cancelled` on a
254    /// `cancel_poll_period` cadence so callers can terminate a blocking
255    /// watcher thread without waiting for a real filesystem event. If
256    /// `cancelled.load(Ordering::Acquire) == true` is observed at any
257    /// checkpoint — including before the first event arrives, or during
258    /// the sliding debounce window — this returns `Ok(None)` promptly,
259    /// discarding any raw changes accumulated so far (the workspace is
260    /// assumed to be terminating).
261    ///
262    /// On a non-empty debounce window completing without cancellation,
263    /// returns `Ok(Some(cs))`.
264    ///
265    /// # Parameters
266    ///
267    /// - `debounce`: sliding quiet-period length (same semantics as
268    ///   [`Self::wait_for_changes`]).
269    /// - `last_git_state`: optional baseline for git-state
270    ///   classification; `None` produces `git_change_class = None`.
271    /// - `cancelled`: shared cancellation flag. Read with
272    ///   `Ordering::Acquire` so writes on the evicting thread (typically
273    ///   under the workspace-manager write lock) synchronise with this
274    ///   reader.
275    /// - `cancel_poll_period`: how often to check `cancelled` while
276    ///   waiting for the first event and during the sliding window.
277    ///   Production callers use ~100 ms; tests can use ~10 ms for fast
278    ///   termination.
279    ///
280    /// # Errors
281    ///
282    /// Returns `Err` if the underlying notify channel disconnects (the
283    /// watcher is unrecoverable).
284    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        // Phase 1 — wait for first event while polling cancellation.
294        //
295        // Replace the unconditional `recv()` from `wait_for_changes`
296        // with a bounded `recv_timeout` loop that checks `cancelled`
297        // on every tick. Without this an evicted workspace's watcher
298        // thread would sit forever in `recv()` on a quiet repo.
299        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                    // Notify reported an error event. Per `wait_for_changes`
310                    // semantics we log and treat as having observed
311                    // something — break out of the first-event wait.
312                    log::warn!("Source-tree watcher error: {e}");
313                    break;
314                }
315                Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
316                    // Fall through — loop iterates and checks `cancelled`.
317                }
318                Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
319                    anyhow::bail!("Source-tree watcher channel disconnected before first event");
320                }
321            }
322        }
323
324        // Phase 2 — sliding-window debounce with cancellation polling.
325        //
326        // The existing `wait_for_changes` already uses a 10 ms inner
327        // `recv_timeout` slice; we additionally check `cancelled` on
328        // every slice so a late eviction still terminates quickly.
329        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    /// Non-blocking poll for changes.
371    ///
372    /// Drains all pending events from the channel, applies debounce
373    /// coalescing, and returns a [`ChangeSet`]. Returns `Ok(None)` if no
374    /// events are pending.
375    ///
376    /// If `last_git_state` is provided, the git-state classifier runs against
377    /// it when git events were observed.
378    ///
379    /// # Errors
380    ///
381    /// Returns an error if the watcher channel is in an unrecoverable state.
382    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    /// Builds a [`ChangeSet`] from raw changes, applying gitignore filtering,
417    /// editor temp filtering, `.git/` exclusion, and rename coalescing.
418    ///
419    /// `git_state_changed` is passed in from the caller to avoid double-draining
420    /// the git-state channel (`poll_changed` drains on first call; a second call
421    /// would return `false` and lose the signal).
422    fn build_changeset(
423        &self,
424        raw_changes: Vec<RawChange>,
425        git_state_changed: bool,
426        last_git_state: Option<&LastIndexedGitState>,
427    ) -> ChangeSet {
428        // 1. Filter out .git/ paths, sqry internal artifacts, gitignored
429        // paths, and editor temps.
430        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        // 2. Coalesce Remove+Create pairs into Modify (Windows rename pattern).
442        let coalesced = coalesce_rename_pairs(filtered);
443
444        // 3. Deduplicate: keep last change per path.
445        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        // 4. Classify git state if events were observed.
453        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    /// Returns `true` if the path is excluded by the `.gitignore` matcher.
467    fn is_gitignored(&self, path: &Path) -> bool {
468        let is_dir = path.is_dir();
469        // Try to make the path relative to root for matching.
470        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
477/// Builds a [`Gitignore`] matcher by walking up from `root` and loading all
478/// `.gitignore` files found. Falls back to an empty matcher on error.
479fn build_gitignore_matcher(root: &Path) -> Gitignore {
480    const MAX_DEPTH: usize = 20;
481
482    let mut builder = GitignoreBuilder::new(root);
483
484    // Load root .gitignore.
485    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    // Walk subdirectories for nested .gitignore files (breadth-first, bounded).
493    // We use a simple iterative walk to avoid pulling in another dependency.
494    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                    // Skip .git directory itself.
507                    if path.file_name().is_some_and(|n| n == ".git") {
508                        continue;
509                    }
510                    // Check for .gitignore in this subdirectory.
511                    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
534/// Returns `true` if `path` is under the `.git/` directory of `root`.
535fn is_under_git_dir(path: &Path, root: &Path) -> bool {
536    let git_dir = root.join(".git");
537    path.starts_with(&git_dir)
538}
539
540/// Returns `true` if `path` is under sqry's internal `.sqry/` directory.
541fn is_under_sqry_dir(path: &Path, root: &Path) -> bool {
542    let sqry_dir = root.join(".sqry");
543    path.starts_with(&sqry_dir)
544}
545
546/// Returns `true` if the path looks like a common editor temporary file.
547///
548/// Checks file name patterns for Vim, Emacs, VS Code, and `JetBrains` editors.
549fn 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    // Vim: .foo.swp, .foo.swo
555    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    // Emacs backup: foo~
566    if file_name.ends_with('~') {
567        return true;
568    }
569
570    // Emacs auto-save: #foo#
571    if file_name.starts_with('#') && file_name.ends_with('#') {
572        return true;
573    }
574
575    // Emacs lock: .#foo
576    if file_name.starts_with(".#") {
577        return true;
578    }
579
580    // VS Code safe-save: .bak suffix on the renamed-away original
581    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    // JetBrains atomic save temporaries
590    if file_name.ends_with("___jb_tmp___") || file_name.ends_with("___jb_old___") {
591        return true;
592    }
593
594    false
595}
596
597/// Extracts [`RawChange`] entries from a notify [`Event`].
598fn 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                // For modify events, accept even if the file doesn't exist
610                // anymore (race with deletion).
611                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            // Access, metadata, other — not relevant for rebuild decisions.
621        }
622    }
623}
624
625/// Coalesces Remove + Create pairs on the same path into a single Modify.
626///
627/// This handles the Windows `ReadDirectoryChangesW` pattern where an atomic
628/// rename (used by Vim, `JetBrains`, VS Code) is reported as a Remove of the
629/// old file followed by a Create of the new file at the same path.
630///
631/// The algorithm is sequential: for each Remove, it looks ahead for a Create
632/// on the same path. If found, both are replaced by a single Modify. Events
633/// that don't participate in a pair pass through unchanged.
634///
635/// This also handles Unix rename-over patterns where notify may report
636/// separate Remove/Create events for the same destination path.
637fn 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            // Look ahead for a matching Create on the same path.
652            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                    // Coalesce into Modify.
661                    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    /// Timeout for waiting for watcher events; generous for CI.
690    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    // -----------------------------------------------------------------------
740    // Unit tests: is_editor_temporary
741    // -----------------------------------------------------------------------
742
743    #[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        // Regular .swp without leading dot is not a Vim swap file.
748        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    // -----------------------------------------------------------------------
777    // Unit tests: is_under_git_dir
778    // -----------------------------------------------------------------------
779
780    #[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    // -----------------------------------------------------------------------
793    // Unit tests: is_under_sqry_dir
794    // -----------------------------------------------------------------------
795
796    #[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    // -----------------------------------------------------------------------
812    // Unit tests: coalesce_rename_pairs
813    // -----------------------------------------------------------------------
814
815    #[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        // Remove(a) + Modify(b) + Create(a) → Modify(a), Modify(b)
856        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        // a.rs should be coalesced to Modify.
864        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    // -----------------------------------------------------------------------
890    // Unit tests: gitignore matching
891    // -----------------------------------------------------------------------
892
893    #[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    // -----------------------------------------------------------------------
941    // Integration tests: SourceTreeWatcher
942    // -----------------------------------------------------------------------
943
944    #[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        // Give watcher time to initialize.
956        thread::sleep(Duration::from_millis(100));
957
958        // Modify a source file.
959        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        // Write a .log file (gitignored).
982        fs::write(tmp.path().join("build.log"), b"log output\n").unwrap();
983
984        // Also write a source file so we know the watcher is working.
985        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        // Write editor temp files.
1018        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        // Also write a real source file.
1023        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        // Drain initial events.
1057        thread::sleep(Duration::from_millis(200));
1058        let _ = watcher.poll_changes(None);
1059
1060        // Make a commit that changes the tree.
1061        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        // A commit that changes the tree should produce a ChangeSet.
1067        // Use wait_for_poll to handle event timing.
1068        let found = wait_for_poll(event_timeout(), || {
1069            if let Some(cs) = watcher.poll_changes(Some(&baseline)).unwrap() {
1070                // Must detect git_state_changed=true (regression: double-drain
1071                // used to lose this). Classification depends on whether the
1072                // source-file edit or the commit events arrive first, but
1073                // git_change_class must be set when git_state_changed is true.
1074                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                // Source-file changes without git events are also valid here.
1082                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    // -----------------------------------------------------------------------
1094    // ChangeSet API tests
1095    // -----------------------------------------------------------------------
1096
1097    #[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    // -----------------------------------------------------------------------
1150    // Git scenario tests
1151    // -----------------------------------------------------------------------
1152
1153    #[test]
1154    fn classify_gc_as_noise_through_source_tree_watcher() {
1155        let tmp = TempDir::new().unwrap();
1156        init_repo(tmp.path());
1157        // Generate loose objects.
1158        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        // Drain setup events.
1165        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        // The git state classifier should see this as Noise.
1172        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    // -----------------------------------------------------------------------
1209    // Bulk git scenario: checkout across 100+ file diff
1210    // -----------------------------------------------------------------------
1211
1212    #[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        // Create 120 files on a feature branch.
1219        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        // Switch back to main (no 120 files).
1233        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        // Checkout back to the branch with 120 files.
1241        run_git(tmp.path(), &["checkout", "-q", "many-files"]);
1242        thread::sleep(Duration::from_millis(500));
1243
1244        // Poll should yield a single changeset.
1245        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        // Git state should classify as BranchSwitch.
1250        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    // -----------------------------------------------------------------------
1260    // Bulk git scenario: stash + pop produces 2 changesets
1261    // -----------------------------------------------------------------------
1262
1263    #[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        // Make a working-tree change.
1274        fs::write(tmp.path().join("a.txt"), b"stash-me\n").unwrap();
1275        thread::sleep(Duration::from_millis(300));
1276
1277        // Poll: first changeset (the edit).
1278        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        // Stash.
1285        run_git(tmp.path(), &["stash"]);
1286        thread::sleep(Duration::from_millis(300));
1287
1288        // Poll: second changeset (stash reverts working tree).
1289        let cs2 = watcher.poll_changes(None).unwrap();
1290        assert!(cs2.is_some(), "Stash should produce changeset");
1291
1292        // Pop.
1293        run_git(tmp.path(), &["stash", "pop"]);
1294        thread::sleep(Duration::from_millis(300));
1295
1296        // Poll: third changeset (pop restores working tree).
1297        let cs3 = watcher.poll_changes(None).unwrap();
1298        assert!(cs3.is_some(), "Stash pop should produce changeset");
1299    }
1300
1301    // -----------------------------------------------------------------------
1302    // Bulk git scenario: gc produces zero relevant events
1303    // -----------------------------------------------------------------------
1304
1305    #[test]
1306    fn gc_zero_source_events() {
1307        let tmp = TempDir::new().unwrap();
1308        init_repo(tmp.path());
1309        // Create some loose objects.
1310        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        // Only git-state events should arrive, and classified as Noise.
1325        // gc may or may not produce events depending on OS-level notify
1326        // batching, so None is acceptable (gc produced no observed events).
1327        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            // When git events ARE observed, they must classify as Noise and
1335            // git_state_changed must be true (regression: double-drain bug
1336            // used to lose this signal).
1337            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    // -----------------------------------------------------------------------
1352    // Bulk git scenario: commit of previously-edited file — zero additional
1353    // -----------------------------------------------------------------------
1354
1355    #[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        // Edit a file — this produces the first changeset.
1366        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        // Now commit the edit. The source-tree watcher should NOT produce
1375        // additional source-file events (git events are classified as
1376        // LocalCommit or TreeDiverged depending on whether the baseline
1377        // already captured the tree).
1378        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            // Any source-file changes should be from .git/ internals that
1386            // leak through (should be filtered), not from a.txt itself.
1387            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    // -----------------------------------------------------------------------
1399    // Regression: poll_changes must not double-drain git_state channel
1400    // -----------------------------------------------------------------------
1401
1402    #[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        // Regression test: poll_changes() used to call git_state.poll_changed()
1406        // twice — once in the early-exit guard and once in build_changeset —
1407        // which drained the git channel on the first call and returned
1408        // git_state_changed=false on the second. This test ensures that a
1409        // pure git-state event (branch switch with no source-file edits)
1410        // produces a ChangeSet with git_state_changed=true.
1411        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); // drain init
1418
1419        // Pure git operation: create and switch to a new branch.
1420        run_git(tmp.path(), &["checkout", "-q", "-b", "other"]);
1421        thread::sleep(Duration::from_millis(300));
1422
1423        // poll_changes must report git_state_changed=true.
1424        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    // -----------------------------------------------------------------
1444    // wait_for_changes_cancellable — cooperative cancellation
1445    // -----------------------------------------------------------------
1446
1447    #[test]
1448    fn wait_for_changes_cancellable_returns_none_on_pre_event_cancel() {
1449        // Cancellation observed BEFORE any filesystem event arrives.
1450        // The watcher must return Ok(None) within a few poll cycles
1451        // rather than blocking indefinitely on an empty recv().
1452        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            // Give the watcher a moment to enter the first-event wait.
1461            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), // long debounce: must NOT be reached
1468            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        // Event arrives → watcher enters sliding debounce. Cancellation
1488        // observed during the debounce must still return Ok(None),
1489        // discarding the partial accumulation (workspace is terminating).
1490        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        // Fire one event to put the watcher into the debounce phase.
1497        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            // Allow the watcher to enter the debounce window before
1502            // cancelling. 500 ms ≫ the 20 ms poll period below but ≪
1503            // the 60 s debounce window.
1504            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}