Skip to main content

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    ///
43    /// # Errors
44    ///
45    /// Returns an error when the watcher or debouncer cannot be created.
46    pub fn new(repo_path: &Path, event_tx: mpsc::UnboundedSender<CompanionEvent>) -> Result<Self> {
47        let repo_path = repo_path.to_path_buf();
48        let repo_path_clone = repo_path.clone();
49
50        // Build gitignore matcher
51        let gitignore = Self::build_gitignore(&repo_path);
52
53        // Create debouncer with 500ms delay
54        let mut debouncer = new_debouncer(
55            Duration::from_millis(500),
56            None,
57            move |result: DebounceEventResult| {
58                Self::handle_events(result, &repo_path_clone, &gitignore, &event_tx);
59            },
60        )
61        .context("Failed to create file watcher debouncer")?;
62
63        // Watch the repository recursively
64        debouncer
65            .watch(&repo_path, RecursiveMode::Recursive)
66            .context("Failed to start watching repository")?;
67
68        Ok(Self {
69            _watcher: debouncer,
70            repo_path,
71        })
72    }
73
74    /// Build a gitignore matcher from repo's .gitignore files
75    fn build_gitignore(repo_path: &Path) -> Arc<Gitignore> {
76        let mut builder = GitignoreBuilder::new(repo_path);
77
78        // Add root .gitignore
79        let gitignore_path = repo_path.join(".gitignore");
80        if gitignore_path.exists() {
81            let _ = builder.add(&gitignore_path);
82        }
83
84        // Add global gitignore if available
85        if let Some(home) = dirs::home_dir() {
86            let global_ignore = home.join(".gitignore_global");
87            if global_ignore.exists() {
88                let _ = builder.add(&global_ignore);
89            }
90        }
91
92        // Always ignore .git directory
93        let _ = builder.add_line(None, ".git/");
94
95        Arc::new(builder.build().unwrap_or_else(|_| {
96            // Fallback: just ignore .git - an empty builder should always succeed
97            let mut fallback = GitignoreBuilder::new(repo_path);
98            let _ = fallback.add_line(None, ".git/");
99            // SAFETY: A fresh builder with just ".git/" should never fail to build
100            fallback.build().unwrap_or_else(|_| {
101                // Final fallback: completely empty gitignore (matches nothing)
102                GitignoreBuilder::new(repo_path)
103                    .build()
104                    .expect("empty GitignoreBuilder should always build")
105            })
106        }))
107    }
108
109    /// Handle debounced file events
110    fn handle_events(
111        result: DebounceEventResult,
112        repo_path: &Path,
113        gitignore: &Gitignore,
114        event_tx: &mpsc::UnboundedSender<CompanionEvent>,
115    ) {
116        match result {
117            Ok(events) => {
118                for event in events {
119                    // Check for git ref changes (HEAD, refs, index)
120                    let is_git_ref_change = event.paths.iter().any(|p| {
121                        p.strip_prefix(repo_path).is_ok_and(|rel| {
122                            let rel_str = rel.to_string_lossy();
123                            rel_str == ".git/HEAD"
124                                || rel_str.starts_with(".git/refs/")
125                                || rel_str == ".git/index"
126                        })
127                    });
128
129                    if is_git_ref_change {
130                        let _ = event_tx.send(CompanionEvent::GitRefChanged);
131                        continue;
132                    }
133
134                    // Convert notify event kind to our event type
135                    use notify::EventKind;
136                    for path in &event.paths {
137                        // Skip gitignored files (including .git/)
138                        if Self::is_ignored(path, repo_path, gitignore) {
139                            continue;
140                        }
141
142                        let companion_event = match event.kind {
143                            EventKind::Create(_) => Some(CompanionEvent::FileCreated(path.clone())),
144                            EventKind::Modify(_) => {
145                                Some(CompanionEvent::FileModified(path.clone()))
146                            }
147                            EventKind::Remove(_) => Some(CompanionEvent::FileDeleted(path.clone())),
148                            _ => None,
149                        };
150
151                        if let Some(e) = companion_event {
152                            let _ = event_tx.send(e);
153                        }
154                    }
155                }
156            }
157            Err(errors) => {
158                for error in errors {
159                    let _ = event_tx.send(CompanionEvent::WatcherError(error.to_string()));
160                }
161            }
162        }
163    }
164
165    /// Check if a path should be ignored (gitignored or .git internal)
166    fn is_ignored(path: &Path, repo_path: &Path, gitignore: &Gitignore) -> bool {
167        // Get relative path
168        let Ok(rel_path) = path.strip_prefix(repo_path) else {
169            return false;
170        };
171
172        // Check if it's a directory (for gitignore matching)
173        let is_dir = path.is_dir();
174
175        // Check gitignore
176        gitignore.matched(rel_path, is_dir).is_ignore()
177    }
178
179    /// Get the repository path being watched
180    #[must_use]
181    pub fn repo_path(&self) -> &Path {
182        &self.repo_path
183    }
184}