git_iris/companion/
watcher.rs

1//! File watcher service for Iris Companion
2//!
3//! Monitors the repository for file changes using the `notify` crate
4//! with debouncing and gitignore filtering.
5
6use anyhow::{Context, Result};
7use ignore::gitignore::{Gitignore, GitignoreBuilder};
8use notify::{RecommendedWatcher, RecursiveMode};
9use notify_debouncer_full::{DebounceEventResult, Debouncer, RecommendedCache, new_debouncer};
10use std::path::{Path, PathBuf};
11use std::sync::Arc;
12use std::time::Duration;
13use tokio::sync::mpsc;
14
15/// Events emitted by the companion file watcher
16#[derive(Debug, Clone)]
17pub enum CompanionEvent {
18    /// A file was created
19    FileCreated(PathBuf),
20    /// A file was modified
21    FileModified(PathBuf),
22    /// A file was deleted
23    FileDeleted(PathBuf),
24    /// A file was renamed (old path, new path)
25    FileRenamed(PathBuf, PathBuf),
26    /// Git ref changed (branch switch, commit, etc.)
27    GitRefChanged,
28    /// Watcher error occurred
29    WatcherError(String),
30}
31
32/// File watcher service that monitors repository changes
33pub struct FileWatcherService {
34    /// The debounced watcher
35    _watcher: Debouncer<RecommendedWatcher, RecommendedCache>,
36    /// Repository root path
37    repo_path: PathBuf,
38}
39
40impl FileWatcherService {
41    /// Create a new file watcher for the given repository
42    pub fn new(repo_path: &Path, event_tx: mpsc::UnboundedSender<CompanionEvent>) -> Result<Self> {
43        let repo_path = repo_path.to_path_buf();
44        let repo_path_clone = repo_path.clone();
45
46        // Build gitignore matcher
47        let gitignore = Self::build_gitignore(&repo_path);
48
49        // Create debouncer with 500ms delay
50        let mut debouncer = new_debouncer(
51            Duration::from_millis(500),
52            None,
53            move |result: DebounceEventResult| {
54                Self::handle_events(result, &repo_path_clone, &gitignore, &event_tx);
55            },
56        )
57        .context("Failed to create file watcher debouncer")?;
58
59        // Watch the repository recursively
60        debouncer
61            .watch(&repo_path, RecursiveMode::Recursive)
62            .context("Failed to start watching repository")?;
63
64        Ok(Self {
65            _watcher: debouncer,
66            repo_path,
67        })
68    }
69
70    /// Build a gitignore matcher from repo's .gitignore files
71    fn build_gitignore(repo_path: &Path) -> Arc<Gitignore> {
72        let mut builder = GitignoreBuilder::new(repo_path);
73
74        // Add root .gitignore
75        let gitignore_path = repo_path.join(".gitignore");
76        if gitignore_path.exists() {
77            let _ = builder.add(&gitignore_path);
78        }
79
80        // Add global gitignore if available
81        if let Some(home) = dirs::home_dir() {
82            let global_ignore = home.join(".gitignore_global");
83            if global_ignore.exists() {
84                let _ = builder.add(&global_ignore);
85            }
86        }
87
88        // Always ignore .git directory
89        let _ = builder.add_line(None, ".git/");
90
91        Arc::new(builder.build().unwrap_or_else(|_| {
92            // Fallback: just ignore .git - an empty builder should always succeed
93            let mut fallback = GitignoreBuilder::new(repo_path);
94            let _ = fallback.add_line(None, ".git/");
95            // SAFETY: A fresh builder with just ".git/" should never fail to build
96            fallback.build().unwrap_or_else(|_| {
97                // Final fallback: completely empty gitignore (matches nothing)
98                GitignoreBuilder::new(repo_path)
99                    .build()
100                    .expect("empty GitignoreBuilder should always build")
101            })
102        }))
103    }
104
105    /// Handle debounced file events
106    fn handle_events(
107        result: DebounceEventResult,
108        repo_path: &Path,
109        gitignore: &Gitignore,
110        event_tx: &mpsc::UnboundedSender<CompanionEvent>,
111    ) {
112        match result {
113            Ok(events) => {
114                for event in events {
115                    // Check for git ref changes (HEAD, refs, index)
116                    let is_git_ref_change = event.paths.iter().any(|p| {
117                        p.strip_prefix(repo_path)
118                            .map(|rel| {
119                                let rel_str = rel.to_string_lossy();
120                                rel_str == ".git/HEAD"
121                                    || rel_str.starts_with(".git/refs/")
122                                    || rel_str == ".git/index"
123                            })
124                            .unwrap_or(false)
125                    });
126
127                    if is_git_ref_change {
128                        let _ = event_tx.send(CompanionEvent::GitRefChanged);
129                        continue;
130                    }
131
132                    // Convert notify event kind to our event type
133                    use notify::EventKind;
134                    for path in &event.paths {
135                        // Skip gitignored files (including .git/)
136                        if Self::is_ignored(path, repo_path, gitignore) {
137                            continue;
138                        }
139
140                        let companion_event = match event.kind {
141                            EventKind::Create(_) => Some(CompanionEvent::FileCreated(path.clone())),
142                            EventKind::Modify(_) => {
143                                Some(CompanionEvent::FileModified(path.clone()))
144                            }
145                            EventKind::Remove(_) => Some(CompanionEvent::FileDeleted(path.clone())),
146                            _ => None,
147                        };
148
149                        if let Some(e) = companion_event {
150                            let _ = event_tx.send(e);
151                        }
152                    }
153                }
154            }
155            Err(errors) => {
156                for error in errors {
157                    let _ = event_tx.send(CompanionEvent::WatcherError(error.to_string()));
158                }
159            }
160        }
161    }
162
163    /// Check if a path should be ignored (gitignored or .git internal)
164    fn is_ignored(path: &Path, repo_path: &Path, gitignore: &Gitignore) -> bool {
165        // Get relative path
166        let Ok(rel_path) = path.strip_prefix(repo_path) else {
167            return false;
168        };
169
170        // Check if it's a directory (for gitignore matching)
171        let is_dir = path.is_dir();
172
173        // Check gitignore
174        gitignore.matched(rel_path, is_dir).is_ignore()
175    }
176
177    /// Get the repository path being watched
178    pub fn repo_path(&self) -> &Path {
179        &self.repo_path
180    }
181}