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