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        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        self.build_changeset(raw_changes, git_state_changed, last_git_state)
364            .map(Some)
365    }
366
367    /// Non-blocking poll for changes.
368    ///
369    /// Drains all pending events from the channel, applies debounce
370    /// coalescing, and returns a [`ChangeSet`]. Returns `Ok(None)` if no
371    /// events are pending.
372    ///
373    /// If `last_git_state` is provided, the git-state classifier runs against
374    /// it when git events were observed.
375    ///
376    /// # Errors
377    ///
378    /// Returns an error if the watcher channel is in an unrecoverable state.
379    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    /// Builds a [`ChangeSet`] from raw changes, applying gitignore filtering,
411    /// editor temp filtering, `.git/` exclusion, and rename coalescing.
412    ///
413    /// `git_state_changed` is passed in from the caller to avoid double-draining
414    /// the git-state channel (poll_changed drains on first call; a second call
415    /// would return `false` and lose the signal).
416    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        // 1. Filter out .git/ paths, sqry internal artifacts, gitignored
423        // paths, and editor temps.
424        let filtered: Vec<RawChange> = raw_changes
425            .into_iter()
426            .filter(|change| {
427                let path = change.path();
428                !is_under_git_dir(path, &self.root)
429                    && !is_under_sqry_dir(path, &self.root)
430                    && !self.is_gitignored(path)
431                    && !is_editor_temporary(path)
432            })
433            .collect();
434
435        // 2. Coalesce Remove+Create pairs into Modify (Windows rename pattern).
436        let coalesced = coalesce_rename_pairs(filtered);
437
438        // 3. Deduplicate: keep last change per path.
439        let mut deduped: HashMap<PathBuf, &RawChange> = HashMap::new();
440        for change in &coalesced {
441            deduped.insert(change.path().to_path_buf(), change);
442        }
443
444        let changed_files: Vec<PathBuf> = deduped.into_keys().collect();
445
446        // 4. Classify git state if events were observed.
447        let git_change_class = if git_state_changed {
448            last_git_state.map(|last| self.git_state.classify(last))
449        } else {
450            None
451        };
452
453        Ok(ChangeSet {
454            changed_files,
455            git_state_changed,
456            git_change_class,
457        })
458    }
459
460    /// Returns `true` if the path is excluded by the `.gitignore` matcher.
461    fn is_gitignored(&self, path: &Path) -> bool {
462        let is_dir = path.is_dir();
463        // Try to make the path relative to root for matching.
464        let rel = path.strip_prefix(&self.root).unwrap_or(path);
465        self.ignore_matcher
466            .matched_path_or_any_parents(rel, is_dir)
467            .is_ignore()
468    }
469}
470
471/// Builds a [`Gitignore`] matcher by walking up from `root` and loading all
472/// `.gitignore` files found. Falls back to an empty matcher on error.
473fn build_gitignore_matcher(root: &Path) -> Gitignore {
474    let mut builder = GitignoreBuilder::new(root);
475
476    // Load root .gitignore.
477    let gitignore_path = root.join(".gitignore");
478    if gitignore_path.is_file()
479        && let Some(err) = builder.add(&gitignore_path)
480    {
481        log::warn!("Error parsing {}: {err}", gitignore_path.display());
482    }
483
484    // Walk subdirectories for nested .gitignore files (breadth-first, bounded).
485    // We use a simple iterative walk to avoid pulling in another dependency.
486    let mut dirs_to_scan = vec![root.to_path_buf()];
487    let mut depth = 0;
488    const MAX_DEPTH: usize = 20;
489
490    while !dirs_to_scan.is_empty() && depth < MAX_DEPTH {
491        let mut next_dirs = Vec::new();
492        for dir in &dirs_to_scan {
493            let entries = match std::fs::read_dir(dir) {
494                Ok(e) => e,
495                Err(_) => continue,
496            };
497            for entry in entries.flatten() {
498                let path = entry.path();
499                if path.is_dir() {
500                    // Skip .git directory itself.
501                    if path.file_name().is_some_and(|n| n == ".git") {
502                        continue;
503                    }
504                    // Check for .gitignore in this subdirectory.
505                    let sub_gitignore = path.join(".gitignore");
506                    if sub_gitignore.is_file()
507                        && let Some(err) = builder.add(&sub_gitignore)
508                    {
509                        log::warn!("Error parsing {}: {err}", sub_gitignore.display());
510                    }
511                    next_dirs.push(path);
512                }
513            }
514        }
515        dirs_to_scan = next_dirs;
516        depth += 1;
517    }
518
519    match builder.build() {
520        Ok(matcher) => matcher,
521        Err(e) => {
522            log::warn!("Failed to build gitignore matcher: {e}; using empty matcher");
523            Gitignore::empty()
524        }
525    }
526}
527
528/// Returns `true` if `path` is under the `.git/` directory of `root`.
529fn is_under_git_dir(path: &Path, root: &Path) -> bool {
530    let git_dir = root.join(".git");
531    path.starts_with(&git_dir)
532}
533
534/// Returns `true` if `path` is under sqry's internal `.sqry/` directory.
535fn is_under_sqry_dir(path: &Path, root: &Path) -> bool {
536    let sqry_dir = root.join(".sqry");
537    path.starts_with(&sqry_dir)
538}
539
540/// Returns `true` if the path looks like a common editor temporary file.
541///
542/// Checks file name patterns for Vim, Emacs, VS Code, and JetBrains editors.
543fn is_editor_temporary(path: &Path) -> bool {
544    let Some(file_name) = path.file_name().and_then(|n| n.to_str()) else {
545        return false;
546    };
547
548    // Vim: .foo.swp, .foo.swo
549    if (file_name.ends_with(".swp") || file_name.ends_with(".swo"))
550        && let Some(stem) = path.file_stem().and_then(|s| s.to_str())
551        && stem.starts_with('.')
552    {
553        return true;
554    }
555
556    // Emacs backup: foo~
557    if file_name.ends_with('~') {
558        return true;
559    }
560
561    // Emacs auto-save: #foo#
562    if file_name.starts_with('#') && file_name.ends_with('#') {
563        return true;
564    }
565
566    // Emacs lock: .#foo
567    if file_name.starts_with(".#") {
568        return true;
569    }
570
571    // VS Code safe-save: .bak suffix on the renamed-away original
572    if file_name.ends_with(".bak") {
573        return true;
574    }
575
576    // JetBrains atomic save temporaries
577    if file_name.ends_with("___jb_tmp___") || file_name.ends_with("___jb_old___") {
578        return true;
579    }
580
581    false
582}
583
584/// Extracts [`RawChange`] entries from a notify [`Event`].
585fn collect_raw_changes(event: &Event, out: &mut Vec<RawChange>) {
586    match event.kind {
587        EventKind::Create(_) => {
588            for path in &event.paths {
589                if path.is_file() {
590                    out.push(RawChange::Create(path.clone()));
591                }
592            }
593        }
594        EventKind::Modify(_) => {
595            for path in &event.paths {
596                // For modify events, accept even if the file doesn't exist
597                // anymore (race with deletion).
598                out.push(RawChange::Modify(path.clone()));
599            }
600        }
601        EventKind::Remove(_) => {
602            for path in &event.paths {
603                out.push(RawChange::Remove(path.clone()));
604            }
605        }
606        _ => {
607            // Access, metadata, other — not relevant for rebuild decisions.
608        }
609    }
610}
611
612/// Coalesces Remove + Create pairs on the same path into a single Modify.
613///
614/// This handles the Windows `ReadDirectoryChangesW` pattern where an atomic
615/// rename (used by Vim, JetBrains, VS Code) is reported as a Remove of the
616/// old file followed by a Create of the new file at the same path.
617///
618/// The algorithm is sequential: for each Remove, it looks ahead for a Create
619/// on the same path. If found, both are replaced by a single Modify. Events
620/// that don't participate in a pair pass through unchanged.
621///
622/// This also handles Unix rename-over patterns where notify may report
623/// separate Remove/Create events for the same destination path.
624fn coalesce_rename_pairs(changes: Vec<RawChange>) -> Vec<RawChange> {
625    if changes.len() < 2 {
626        return changes;
627    }
628
629    let mut result: Vec<RawChange> = Vec::with_capacity(changes.len());
630    let mut consumed: Vec<bool> = vec![false; changes.len()];
631
632    for i in 0..changes.len() {
633        if consumed[i] {
634            continue;
635        }
636
637        if let RawChange::Remove(ref remove_path) = changes[i] {
638            // Look ahead for a matching Create on the same path.
639            let mut found_create = false;
640            for j in (i + 1)..changes.len() {
641                if consumed[j] {
642                    continue;
643                }
644                if let RawChange::Create(ref create_path) = changes[j]
645                    && create_path == remove_path
646                {
647                    // Coalesce into Modify.
648                    result.push(RawChange::Modify(remove_path.clone()));
649                    consumed[i] = true;
650                    consumed[j] = true;
651                    found_create = true;
652                    break;
653                }
654            }
655            if !found_create {
656                result.push(changes[i].clone());
657                consumed[i] = true;
658            }
659        } else {
660            result.push(changes[i].clone());
661            consumed[i] = true;
662        }
663    }
664
665    result
666}
667
668#[cfg(test)]
669mod tests {
670    use super::*;
671    use std::fs;
672    use std::process::Command;
673    use std::thread;
674    use tempfile::TempDir;
675
676    /// Timeout for waiting for watcher events; generous for CI.
677    fn event_timeout() -> Duration {
678        let base = if cfg!(target_os = "macos") {
679            Duration::from_secs(3)
680        } else {
681            Duration::from_secs(2)
682        };
683        if std::env::var("CI").is_ok() {
684            base * 2
685        } else {
686            base
687        }
688    }
689
690    fn init_repo(dir: &Path) {
691        run_git(dir, &["init", "-q", "-b", "main"]);
692        run_git(dir, &["config", "user.email", "test@sqry.dev"]);
693        run_git(dir, &["config", "user.name", "Sqry Test"]);
694        run_git(dir, &["config", "commit.gpgsign", "false"]);
695        fs::write(dir.join("a.txt"), b"alpha\n").unwrap();
696        run_git(dir, &["add", "a.txt"]);
697        run_git(dir, &["commit", "-q", "-m", "initial"]);
698    }
699
700    fn run_git(dir: &Path, args: &[&str]) {
701        let status = Command::new("git")
702            .arg("-C")
703            .arg(dir)
704            .args(args)
705            .status()
706            .expect("git command failed to launch");
707        assert!(status.success(), "git {args:?} failed in {}", dir.display());
708    }
709
710    fn wait_for_poll<F>(timeout: Duration, mut predicate: F) -> bool
711    where
712        F: FnMut() -> bool,
713    {
714        let deadline = Instant::now() + timeout;
715        loop {
716            if predicate() {
717                return true;
718            }
719            if Instant::now() >= deadline {
720                return false;
721            }
722            thread::sleep(Duration::from_millis(50));
723        }
724    }
725
726    // -----------------------------------------------------------------------
727    // Unit tests: is_editor_temporary
728    // -----------------------------------------------------------------------
729
730    #[test]
731    fn editor_temp_vim_swp() {
732        assert!(is_editor_temporary(Path::new("/tmp/.foo.swp")));
733        assert!(is_editor_temporary(Path::new("/tmp/.foo.swo")));
734        // Regular .swp without leading dot is not a Vim swap file.
735        assert!(!is_editor_temporary(Path::new("/tmp/foo.swp")));
736    }
737
738    #[test]
739    fn editor_temp_emacs_backup() {
740        assert!(is_editor_temporary(Path::new("/tmp/foo.rs~")));
741        assert!(is_editor_temporary(Path::new("/tmp/#foo.rs#")));
742        assert!(is_editor_temporary(Path::new("/tmp/.#foo.rs")));
743    }
744
745    #[test]
746    fn editor_temp_vscode_bak() {
747        assert!(is_editor_temporary(Path::new("/tmp/foo.rs.bak")));
748    }
749
750    #[test]
751    fn editor_temp_jetbrains() {
752        assert!(is_editor_temporary(Path::new("/tmp/foo.rs___jb_tmp___")));
753        assert!(is_editor_temporary(Path::new("/tmp/foo.rs___jb_old___")));
754    }
755
756    #[test]
757    fn non_temp_files_pass_through() {
758        assert!(!is_editor_temporary(Path::new("/tmp/foo.rs")));
759        assert!(!is_editor_temporary(Path::new("/tmp/Makefile")));
760        assert!(!is_editor_temporary(Path::new("/tmp/README.md")));
761    }
762
763    // -----------------------------------------------------------------------
764    // Unit tests: is_under_git_dir
765    // -----------------------------------------------------------------------
766
767    #[test]
768    fn git_dir_detection() {
769        let root = Path::new("/repo");
770        assert!(is_under_git_dir(Path::new("/repo/.git/HEAD"), root));
771        assert!(is_under_git_dir(
772            Path::new("/repo/.git/refs/heads/main"),
773            root
774        ));
775        assert!(!is_under_git_dir(Path::new("/repo/src/main.rs"), root));
776        assert!(!is_under_git_dir(Path::new("/repo/.gitignore"), root));
777    }
778
779    // -----------------------------------------------------------------------
780    // Unit tests: is_under_sqry_dir
781    // -----------------------------------------------------------------------
782
783    #[test]
784    fn sqry_dir_detection() {
785        let root = Path::new("/repo");
786        assert!(is_under_sqry_dir(
787            Path::new("/repo/.sqry/graph/snapshot.sqry"),
788            root
789        ));
790        assert!(is_under_sqry_dir(
791            Path::new("/repo/.sqry/analysis/adjacency.csr"),
792            root
793        ));
794        assert!(!is_under_sqry_dir(Path::new("/repo/src/main.rs"), root));
795        assert!(!is_under_sqry_dir(Path::new("/repo/.sqry-workspace"), root));
796    }
797
798    // -----------------------------------------------------------------------
799    // Unit tests: coalesce_rename_pairs
800    // -----------------------------------------------------------------------
801
802    #[test]
803    fn coalesce_empty() {
804        let result = coalesce_rename_pairs(vec![]);
805        assert!(result.is_empty());
806    }
807
808    #[test]
809    fn coalesce_single_event_passthrough() {
810        let changes = vec![RawChange::Modify(PathBuf::from("foo.rs"))];
811        let result = coalesce_rename_pairs(changes);
812        assert_eq!(result.len(), 1);
813        assert!(matches!(&result[0], RawChange::Modify(p) if p == Path::new("foo.rs")));
814    }
815
816    #[test]
817    fn coalesce_remove_create_same_path_becomes_modify() {
818        let changes = vec![
819            RawChange::Remove(PathBuf::from("foo.rs")),
820            RawChange::Create(PathBuf::from("foo.rs")),
821        ];
822        let result = coalesce_rename_pairs(changes);
823        assert_eq!(result.len(), 1);
824        assert!(
825            matches!(&result[0], RawChange::Modify(p) if p == Path::new("foo.rs")),
826            "Remove+Create should coalesce into Modify"
827        );
828    }
829
830    #[test]
831    fn coalesce_remove_create_different_paths_no_coalesce() {
832        let changes = vec![
833            RawChange::Remove(PathBuf::from("old.rs")),
834            RawChange::Create(PathBuf::from("new.rs")),
835        ];
836        let result = coalesce_rename_pairs(changes);
837        assert_eq!(result.len(), 2);
838    }
839
840    #[test]
841    fn coalesce_interleaved_events() {
842        // Remove(a) + Modify(b) + Create(a) → Modify(a), Modify(b)
843        let changes = vec![
844            RawChange::Remove(PathBuf::from("a.rs")),
845            RawChange::Modify(PathBuf::from("b.rs")),
846            RawChange::Create(PathBuf::from("a.rs")),
847        ];
848        let result = coalesce_rename_pairs(changes);
849        assert_eq!(result.len(), 2);
850        // a.rs should be coalesced to Modify.
851        assert!(
852            result
853                .iter()
854                .any(|c| matches!(c, RawChange::Modify(p) if p == Path::new("a.rs")))
855        );
856        assert!(
857            result
858                .iter()
859                .any(|c| matches!(c, RawChange::Modify(p) if p == Path::new("b.rs")))
860        );
861    }
862
863    #[test]
864    fn coalesce_multiple_rename_pairs() {
865        let changes = vec![
866            RawChange::Remove(PathBuf::from("a.rs")),
867            RawChange::Remove(PathBuf::from("b.rs")),
868            RawChange::Create(PathBuf::from("a.rs")),
869            RawChange::Create(PathBuf::from("b.rs")),
870        ];
871        let result = coalesce_rename_pairs(changes);
872        assert_eq!(result.len(), 2);
873        assert!(result.iter().all(|c| matches!(c, RawChange::Modify(_))));
874    }
875
876    // -----------------------------------------------------------------------
877    // Unit tests: gitignore matching
878    // -----------------------------------------------------------------------
879
880    #[test]
881    fn gitignore_filters_target_directory() {
882        let tmp = TempDir::new().unwrap();
883        fs::write(tmp.path().join(".gitignore"), "target/\n*.log\n").unwrap();
884        let matcher = build_gitignore_matcher(tmp.path());
885
886        assert!(
887            matcher
888                .matched_path_or_any_parents("target/debug/foo", false)
889                .is_ignore(),
890            "target/ contents should be ignored"
891        );
892        assert!(
893            matcher
894                .matched_path_or_any_parents("build.log", false)
895                .is_ignore(),
896            "*.log should be ignored"
897        );
898        assert!(
899            !matcher
900                .matched_path_or_any_parents("src/main.rs", false)
901                .is_ignore(),
902            "src/main.rs should not be ignored"
903        );
904    }
905
906    #[test]
907    fn gitignore_nested_rules() {
908        let tmp = TempDir::new().unwrap();
909        fs::write(tmp.path().join(".gitignore"), "*.o\n").unwrap();
910        fs::create_dir_all(tmp.path().join("vendor")).unwrap();
911        fs::write(tmp.path().join("vendor/.gitignore"), "*.vendored\n").unwrap();
912
913        let matcher = build_gitignore_matcher(tmp.path());
914
915        assert!(
916            matcher
917                .matched_path_or_any_parents("foo.o", false)
918                .is_ignore()
919        );
920        assert!(
921            matcher
922                .matched_path_or_any_parents("vendor/lib.vendored", false)
923                .is_ignore()
924        );
925    }
926
927    // -----------------------------------------------------------------------
928    // Integration tests: SourceTreeWatcher
929    // -----------------------------------------------------------------------
930
931    #[test]
932    #[cfg_attr(target_os = "macos", ignore = "FSEvents timing flaky in CI")]
933    fn watcher_detects_source_file_change() {
934        let tmp = TempDir::new().unwrap();
935        init_repo(tmp.path());
936        fs::write(tmp.path().join(".gitignore"), "*.log\ntarget/\n").unwrap();
937        run_git(tmp.path(), &["add", ".gitignore"]);
938        run_git(tmp.path(), &["commit", "-q", "-m", "add gitignore"]);
939
940        let watcher = SourceTreeWatcher::new(tmp.path()).unwrap();
941
942        // Give watcher time to initialize.
943        thread::sleep(Duration::from_millis(100));
944
945        // Modify a source file.
946        fs::write(tmp.path().join("a.txt"), b"modified\n").unwrap();
947
948        let detected = wait_for_poll(event_timeout(), || {
949            let cs = watcher.poll_changes(None).unwrap();
950            cs.is_some_and(|cs| !cs.changed_files.is_empty())
951        });
952
953        assert!(detected, "Watcher should detect source file modification");
954    }
955
956    #[test]
957    #[cfg_attr(target_os = "macos", ignore = "FSEvents timing flaky in CI")]
958    fn watcher_filters_gitignored_files() {
959        let tmp = TempDir::new().unwrap();
960        init_repo(tmp.path());
961        fs::write(tmp.path().join(".gitignore"), "*.log\ntarget/\n").unwrap();
962        run_git(tmp.path(), &["add", ".gitignore"]);
963        run_git(tmp.path(), &["commit", "-q", "-m", "add gitignore"]);
964
965        let watcher = SourceTreeWatcher::new(tmp.path()).unwrap();
966        thread::sleep(Duration::from_millis(100));
967
968        // Write a .log file (gitignored).
969        fs::write(tmp.path().join("build.log"), b"log output\n").unwrap();
970
971        // Also write a source file so we know the watcher is working.
972        thread::sleep(Duration::from_millis(50));
973        fs::write(tmp.path().join("a.txt"), b"modified\n").unwrap();
974
975        let mut saw_log = false;
976        let saw_source = wait_for_poll(event_timeout(), || {
977            if let Some(cs) = watcher.poll_changes(None).unwrap() {
978                for path in &cs.changed_files {
979                    if path.extension().is_some_and(|e| e == "log") {
980                        saw_log = true;
981                    }
982                }
983                cs.changed_files
984                    .iter()
985                    .any(|p| p.file_name().is_some_and(|n| n == "a.txt"))
986            } else {
987                false
988            }
989        });
990
991        assert!(saw_source, "Watcher should detect a.txt change");
992        assert!(!saw_log, "Watcher should filter out *.log files");
993    }
994
995    #[test]
996    #[cfg_attr(target_os = "macos", ignore = "FSEvents timing flaky in CI")]
997    fn watcher_filters_editor_temporaries() {
998        let tmp = TempDir::new().unwrap();
999        init_repo(tmp.path());
1000
1001        let watcher = SourceTreeWatcher::new(tmp.path()).unwrap();
1002        thread::sleep(Duration::from_millis(100));
1003
1004        // Write editor temp files.
1005        fs::write(tmp.path().join(".foo.swp"), b"vim swap\n").unwrap();
1006        fs::write(tmp.path().join("bar.rs~"), b"emacs backup\n").unwrap();
1007        fs::write(tmp.path().join("baz.rs.bak"), b"vscode bak\n").unwrap();
1008
1009        // Also write a real source file.
1010        thread::sleep(Duration::from_millis(50));
1011        fs::write(tmp.path().join("a.txt"), b"modified\n").unwrap();
1012
1013        let mut saw_temp = false;
1014        let saw_source = wait_for_poll(event_timeout(), || {
1015            if let Some(cs) = watcher.poll_changes(None).unwrap() {
1016                for path in &cs.changed_files {
1017                    let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
1018                    if name.ends_with(".swp") || name.ends_with('~') || name.ends_with(".bak") {
1019                        saw_temp = true;
1020                    }
1021                }
1022                cs.changed_files
1023                    .iter()
1024                    .any(|p| p.file_name().is_some_and(|n| n == "a.txt"))
1025            } else {
1026                false
1027            }
1028        });
1029
1030        assert!(saw_source, "Watcher should detect a.txt change");
1031        assert!(!saw_temp, "Watcher should filter out editor temporaries");
1032    }
1033
1034    #[test]
1035    #[cfg_attr(target_os = "macos", ignore = "FSEvents timing flaky in CI")]
1036    fn watcher_git_state_composition() {
1037        let tmp = TempDir::new().unwrap();
1038        init_repo(tmp.path());
1039
1040        let watcher = SourceTreeWatcher::new(tmp.path()).unwrap();
1041        let baseline = watcher.git_state().current_state();
1042
1043        // Drain initial events.
1044        thread::sleep(Duration::from_millis(200));
1045        let _ = watcher.poll_changes(None);
1046
1047        // Make a commit that changes the tree.
1048        fs::write(tmp.path().join("a.txt"), b"changed\n").unwrap();
1049        run_git(tmp.path(), &["commit", "-q", "-am", "edit"]);
1050
1051        thread::sleep(Duration::from_millis(300));
1052
1053        // A commit that changes the tree should produce a ChangeSet.
1054        // Use wait_for_poll to handle event timing.
1055        let found = wait_for_poll(event_timeout(), || {
1056            if let Some(cs) = watcher.poll_changes(Some(&baseline)).unwrap() {
1057                // Must detect git_state_changed=true (regression: double-drain
1058                // used to lose this). Classification depends on whether the
1059                // source-file edit or the commit events arrive first, but
1060                // git_change_class must be set when git_state_changed is true.
1061                if cs.git_state_changed {
1062                    assert!(
1063                        cs.git_change_class.is_some(),
1064                        "git_change_class must be set when git_state_changed is true"
1065                    );
1066                    return true;
1067                }
1068                // Source-file changes without git events are also valid here.
1069                return !cs.changed_files.is_empty();
1070            }
1071            false
1072        });
1073
1074        assert!(
1075            found,
1076            "Should detect changes after commit with tree modification"
1077        );
1078    }
1079
1080    // -----------------------------------------------------------------------
1081    // ChangeSet API tests
1082    // -----------------------------------------------------------------------
1083
1084    #[test]
1085    fn changeset_is_empty_when_no_changes() {
1086        let cs = ChangeSet {
1087            changed_files: vec![],
1088            git_state_changed: false,
1089            git_change_class: None,
1090        };
1091        assert!(cs.is_empty());
1092        assert!(!cs.requires_full_rebuild());
1093    }
1094
1095    #[test]
1096    fn changeset_requires_full_rebuild_on_branch_switch() {
1097        let cs = ChangeSet {
1098            changed_files: vec![],
1099            git_state_changed: true,
1100            git_change_class: Some(GitChangeClass::BranchSwitch),
1101        };
1102        assert!(!cs.is_empty());
1103        assert!(cs.requires_full_rebuild());
1104    }
1105
1106    #[test]
1107    fn changeset_requires_full_rebuild_on_tree_diverged() {
1108        let cs = ChangeSet {
1109            changed_files: vec![],
1110            git_state_changed: true,
1111            git_change_class: Some(GitChangeClass::TreeDiverged),
1112        };
1113        assert!(cs.requires_full_rebuild());
1114    }
1115
1116    #[test]
1117    fn changeset_no_rebuild_on_local_commit() {
1118        let cs = ChangeSet {
1119            changed_files: vec![],
1120            git_state_changed: true,
1121            git_change_class: Some(GitChangeClass::LocalCommit),
1122        };
1123        assert!(!cs.requires_full_rebuild());
1124    }
1125
1126    #[test]
1127    fn changeset_no_rebuild_on_noise() {
1128        let cs = ChangeSet {
1129            changed_files: vec![],
1130            git_state_changed: true,
1131            git_change_class: Some(GitChangeClass::Noise),
1132        };
1133        assert!(!cs.requires_full_rebuild());
1134    }
1135
1136    // -----------------------------------------------------------------------
1137    // Git scenario tests
1138    // -----------------------------------------------------------------------
1139
1140    #[test]
1141    fn classify_gc_as_noise_through_source_tree_watcher() {
1142        let tmp = TempDir::new().unwrap();
1143        init_repo(tmp.path());
1144        // Generate loose objects.
1145        fs::write(tmp.path().join("b.txt"), b"bravo\n").unwrap();
1146        run_git(tmp.path(), &["add", "b.txt"]);
1147        run_git(tmp.path(), &["reset", "--hard", "HEAD"]);
1148
1149        let watcher = SourceTreeWatcher::new(tmp.path()).unwrap();
1150        let baseline = watcher.git_state().current_state();
1151        // Drain setup events.
1152        thread::sleep(Duration::from_millis(200));
1153        let _ = watcher.poll_changes(None);
1154
1155        run_git(tmp.path(), &["gc", "--quiet", "--prune=now"]);
1156        thread::sleep(Duration::from_millis(300));
1157
1158        // The git state classifier should see this as Noise.
1159        let class = watcher.git_state().classify(&baseline);
1160        assert_eq!(class, GitChangeClass::Noise);
1161    }
1162
1163    #[test]
1164    fn classify_staging_as_noise_through_source_tree_watcher() {
1165        let tmp = TempDir::new().unwrap();
1166        init_repo(tmp.path());
1167
1168        let watcher = SourceTreeWatcher::new(tmp.path()).unwrap();
1169        let baseline = watcher.git_state().current_state();
1170        thread::sleep(Duration::from_millis(200));
1171        let _ = watcher.poll_changes(None);
1172
1173        fs::write(tmp.path().join("c.txt"), b"charlie\n").unwrap();
1174        run_git(tmp.path(), &["add", "c.txt"]);
1175        run_git(tmp.path(), &["reset", "HEAD", "c.txt"]);
1176
1177        let class = watcher.git_state().classify(&baseline);
1178        assert_eq!(class, GitChangeClass::Noise);
1179    }
1180
1181    #[test]
1182    fn classify_branch_switch_through_source_tree_watcher() {
1183        let tmp = TempDir::new().unwrap();
1184        init_repo(tmp.path());
1185
1186        let watcher = SourceTreeWatcher::new(tmp.path()).unwrap();
1187        let baseline = watcher.git_state().current_state();
1188
1189        run_git(tmp.path(), &["checkout", "-q", "-b", "feature"]);
1190        let class = watcher.git_state().classify(&baseline);
1191        assert_eq!(class, GitChangeClass::BranchSwitch);
1192        assert!(class.requires_full_rebuild());
1193    }
1194
1195    // -----------------------------------------------------------------------
1196    // Bulk git scenario: checkout across 100+ file diff
1197    // -----------------------------------------------------------------------
1198
1199    #[test]
1200    #[cfg_attr(target_os = "macos", ignore = "FSEvents timing flaky in CI")]
1201    fn bulk_checkout_100_files_single_changeset() {
1202        let tmp = TempDir::new().unwrap();
1203        init_repo(tmp.path());
1204
1205        // Create 120 files on a feature branch.
1206        run_git(tmp.path(), &["checkout", "-q", "-b", "many-files"]);
1207        let src_dir = tmp.path().join("src");
1208        fs::create_dir_all(&src_dir).unwrap();
1209        for i in 0..120 {
1210            fs::write(
1211                src_dir.join(format!("file_{i}.rs")),
1212                format!("// file {i}\n"),
1213            )
1214            .unwrap();
1215        }
1216        run_git(tmp.path(), &["add", "."]);
1217        run_git(tmp.path(), &["commit", "-q", "-m", "add 120 files"]);
1218
1219        // Switch back to main (no 120 files).
1220        run_git(tmp.path(), &["checkout", "-q", "main"]);
1221
1222        let watcher = SourceTreeWatcher::new(tmp.path()).unwrap();
1223        let baseline = watcher.git_state().current_state();
1224        thread::sleep(Duration::from_millis(200));
1225        let _ = watcher.poll_changes(None);
1226
1227        // Checkout back to the branch with 120 files.
1228        run_git(tmp.path(), &["checkout", "-q", "many-files"]);
1229        thread::sleep(Duration::from_millis(500));
1230
1231        // Poll should yield a single changeset.
1232        let cs = watcher.poll_changes(Some(&baseline)).unwrap();
1233        assert!(cs.is_some(), "Should detect checkout across 120 files");
1234        let cs = cs.unwrap();
1235
1236        // Git state should classify as BranchSwitch.
1237        if cs.git_state_changed {
1238            assert!(
1239                cs.git_change_class
1240                    .is_some_and(GitChangeClass::requires_full_rebuild),
1241                "100+ file checkout should trigger full rebuild"
1242            );
1243        }
1244    }
1245
1246    // -----------------------------------------------------------------------
1247    // Bulk git scenario: stash + pop produces 2 changesets
1248    // -----------------------------------------------------------------------
1249
1250    #[test]
1251    #[cfg_attr(target_os = "macos", ignore = "FSEvents timing flaky in CI")]
1252    fn stash_pop_produces_changesets() {
1253        let tmp = TempDir::new().unwrap();
1254        init_repo(tmp.path());
1255
1256        let watcher = SourceTreeWatcher::new(tmp.path()).unwrap();
1257        thread::sleep(Duration::from_millis(200));
1258        let _ = watcher.poll_changes(None);
1259
1260        // Make a working-tree change.
1261        fs::write(tmp.path().join("a.txt"), b"stash-me\n").unwrap();
1262        thread::sleep(Duration::from_millis(300));
1263
1264        // Poll: first changeset (the edit).
1265        let cs1 = watcher.poll_changes(None).unwrap();
1266        assert!(
1267            cs1.is_some_and(|cs| !cs.changed_files.is_empty()),
1268            "Edit should produce first changeset"
1269        );
1270
1271        // Stash.
1272        run_git(tmp.path(), &["stash"]);
1273        thread::sleep(Duration::from_millis(300));
1274
1275        // Poll: second changeset (stash reverts working tree).
1276        let cs2 = watcher.poll_changes(None).unwrap();
1277        assert!(cs2.is_some(), "Stash should produce changeset");
1278
1279        // Pop.
1280        run_git(tmp.path(), &["stash", "pop"]);
1281        thread::sleep(Duration::from_millis(300));
1282
1283        // Poll: third changeset (pop restores working tree).
1284        let cs3 = watcher.poll_changes(None).unwrap();
1285        assert!(cs3.is_some(), "Stash pop should produce changeset");
1286    }
1287
1288    // -----------------------------------------------------------------------
1289    // Bulk git scenario: gc produces zero relevant events
1290    // -----------------------------------------------------------------------
1291
1292    #[test]
1293    fn gc_zero_source_events() {
1294        let tmp = TempDir::new().unwrap();
1295        init_repo(tmp.path());
1296        // Create some loose objects.
1297        for i in 0..10 {
1298            fs::write(tmp.path().join(format!("f{i}.txt")), format!("{i}\n")).unwrap();
1299            run_git(tmp.path(), &["add", "."]);
1300            run_git(tmp.path(), &["commit", "-q", "-m", &format!("commit {i}")]);
1301        }
1302
1303        let watcher = SourceTreeWatcher::new(tmp.path()).unwrap();
1304        let baseline = watcher.git_state().current_state();
1305        thread::sleep(Duration::from_millis(200));
1306        let _ = watcher.poll_changes(None);
1307
1308        run_git(tmp.path(), &["gc", "--quiet", "--prune=now"]);
1309        thread::sleep(Duration::from_millis(300));
1310
1311        // Only git-state events should arrive, and classified as Noise.
1312        // gc may or may not produce events depending on OS-level notify
1313        // batching, so None is acceptable (gc produced no observed events).
1314        let cs = watcher.poll_changes(Some(&baseline)).unwrap();
1315        if let Some(cs) = cs {
1316            assert!(
1317                cs.changed_files.is_empty(),
1318                "gc should not produce source-file events, got: {:?}",
1319                cs.changed_files
1320            );
1321            // When git events ARE observed, they must classify as Noise and
1322            // git_state_changed must be true (regression: double-drain bug
1323            // used to lose this signal).
1324            if cs.git_state_changed {
1325                assert!(
1326                    cs.git_state_changed,
1327                    "git_state_changed must be true when git events observed"
1328                );
1329                assert_eq!(
1330                    cs.git_change_class,
1331                    Some(GitChangeClass::Noise),
1332                    "gc git events should classify as Noise"
1333                );
1334            }
1335        }
1336    }
1337
1338    // -----------------------------------------------------------------------
1339    // Bulk git scenario: commit of previously-edited file — zero additional
1340    // -----------------------------------------------------------------------
1341
1342    #[test]
1343    #[cfg_attr(target_os = "macos", ignore = "FSEvents timing flaky in CI")]
1344    fn commit_no_additional_changeset() {
1345        let tmp = TempDir::new().unwrap();
1346        init_repo(tmp.path());
1347
1348        let watcher = SourceTreeWatcher::new(tmp.path()).unwrap();
1349        thread::sleep(Duration::from_millis(200));
1350        let _ = watcher.poll_changes(None);
1351
1352        // Edit a file — this produces the first changeset.
1353        fs::write(tmp.path().join("a.txt"), b"edited\n").unwrap();
1354        thread::sleep(Duration::from_millis(300));
1355        let cs1 = watcher.poll_changes(None).unwrap();
1356        assert!(
1357            cs1.is_some_and(|cs| !cs.changed_files.is_empty()),
1358            "Edit should produce changeset"
1359        );
1360
1361        // Now commit the edit. The source-tree watcher should NOT produce
1362        // additional source-file events (git events are classified as
1363        // LocalCommit or TreeDiverged depending on whether the baseline
1364        // already captured the tree).
1365        let baseline = watcher.git_state().current_state();
1366        run_git(tmp.path(), &["add", "a.txt"]);
1367        run_git(tmp.path(), &["commit", "-q", "-m", "commit edit"]);
1368        thread::sleep(Duration::from_millis(300));
1369
1370        let cs2 = watcher.poll_changes(Some(&baseline)).unwrap();
1371        if let Some(cs2) = cs2 {
1372            // Any source-file changes should be from .git/ internals that
1373            // leak through (should be filtered), not from a.txt itself.
1374            let has_source_change = cs2
1375                .changed_files
1376                .iter()
1377                .any(|p| p.file_name().is_some_and(|n| n == "a.txt"));
1378            assert!(
1379                !has_source_change,
1380                "Commit should not re-report a.txt as changed"
1381            );
1382        }
1383    }
1384
1385    // -----------------------------------------------------------------------
1386    // Regression: poll_changes must not double-drain git_state channel
1387    // -----------------------------------------------------------------------
1388
1389    #[test]
1390    #[cfg_attr(target_os = "macos", ignore = "FSEvents timing flaky in CI")]
1391    fn poll_changes_reports_git_state_changed_on_git_only_events() {
1392        // Regression test: poll_changes() used to call git_state.poll_changed()
1393        // twice — once in the early-exit guard and once in build_changeset —
1394        // which drained the git channel on the first call and returned
1395        // git_state_changed=false on the second. This test ensures that a
1396        // pure git-state event (branch switch with no source-file edits)
1397        // produces a ChangeSet with git_state_changed=true.
1398        let tmp = TempDir::new().unwrap();
1399        init_repo(tmp.path());
1400
1401        let watcher = SourceTreeWatcher::new(tmp.path()).unwrap();
1402        let baseline = watcher.git_state().current_state();
1403        thread::sleep(Duration::from_millis(200));
1404        let _ = watcher.poll_changes(None); // drain init
1405
1406        // Pure git operation: create and switch to a new branch.
1407        run_git(tmp.path(), &["checkout", "-q", "-b", "other"]);
1408        thread::sleep(Duration::from_millis(300));
1409
1410        // poll_changes must report git_state_changed=true.
1411        let found = wait_for_poll(event_timeout(), || {
1412            if let Some(cs) = watcher.poll_changes(Some(&baseline)).unwrap()
1413                && cs.git_state_changed
1414            {
1415                assert!(
1416                    cs.git_change_class.is_some(),
1417                    "git_change_class must be set when git_state_changed is true"
1418                );
1419                return true;
1420            }
1421            false
1422        });
1423
1424        assert!(
1425            found,
1426            "poll_changes must report git_state_changed=true for branch switch"
1427        );
1428    }
1429
1430    // -----------------------------------------------------------------
1431    // wait_for_changes_cancellable — cooperative cancellation
1432    // -----------------------------------------------------------------
1433
1434    #[test]
1435    fn wait_for_changes_cancellable_returns_none_on_pre_event_cancel() {
1436        // Cancellation observed BEFORE any filesystem event arrives.
1437        // The watcher must return Ok(None) within a few poll cycles
1438        // rather than blocking indefinitely on an empty recv().
1439        let tmp = TempDir::new().expect("tempdir");
1440        init_repo(tmp.path());
1441
1442        let watcher = SourceTreeWatcher::new(tmp.path()).expect("watcher");
1443        let cancelled = std::sync::Arc::new(AtomicBool::new(false));
1444
1445        let cancel_signal = std::sync::Arc::clone(&cancelled);
1446        let handle = thread::spawn(move || {
1447            // Give the watcher a moment to enter the first-event wait.
1448            thread::sleep(Duration::from_millis(50));
1449            cancel_signal.store(true, Ordering::Release);
1450        });
1451
1452        let started = Instant::now();
1453        let result = watcher.wait_for_changes_cancellable(
1454            Duration::from_secs(60), // long debounce: must NOT be reached
1455            None,
1456            &cancelled,
1457            Duration::from_millis(20),
1458        );
1459        let elapsed = started.elapsed();
1460        handle.join().unwrap();
1461
1462        assert!(
1463            matches!(result, Ok(None)),
1464            "pre-event cancellation must produce Ok(None), got {result:?}"
1465        );
1466        assert!(
1467            elapsed < Duration::from_secs(2),
1468            "cancellation must terminate quickly; took {elapsed:?}"
1469        );
1470    }
1471
1472    #[test]
1473    fn wait_for_changes_cancellable_returns_none_on_mid_debounce_cancel() {
1474        // Event arrives → watcher enters sliding debounce. Cancellation
1475        // observed during the debounce must still return Ok(None),
1476        // discarding the partial accumulation (workspace is terminating).
1477        let tmp = TempDir::new().expect("tempdir");
1478        init_repo(tmp.path());
1479
1480        let watcher = SourceTreeWatcher::new(tmp.path()).expect("watcher");
1481        let cancelled = std::sync::Arc::new(AtomicBool::new(false));
1482
1483        // Fire one event to put the watcher into the debounce phase.
1484        fs::write(tmp.path().join("a.txt"), b"modified\n").unwrap();
1485
1486        let cancel_signal = std::sync::Arc::clone(&cancelled);
1487        let handle = thread::spawn(move || {
1488            // Allow the watcher to enter the debounce window before
1489            // cancelling. 500 ms ≫ the 20 ms poll period below but ≪
1490            // the 60 s debounce window.
1491            thread::sleep(Duration::from_millis(500));
1492            cancel_signal.store(true, Ordering::Release);
1493        });
1494
1495        let started = Instant::now();
1496        let result = watcher.wait_for_changes_cancellable(
1497            Duration::from_secs(60),
1498            None,
1499            &cancelled,
1500            Duration::from_millis(20),
1501        );
1502        let elapsed = started.elapsed();
1503        handle.join().unwrap();
1504
1505        assert!(
1506            matches!(result, Ok(None)),
1507            "mid-debounce cancellation must produce Ok(None), got {result:?}"
1508        );
1509        assert!(
1510            elapsed < Duration::from_secs(3),
1511            "cancellation must terminate quickly; took {elapsed:?}"
1512        );
1513    }
1514}