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    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).is_ok_and(|rel| {
118                            let rel_str = rel.to_string_lossy();
119                            rel_str == ".git/HEAD"
120                                || rel_str.starts_with(".git/refs/")
121                                || rel_str == ".git/index"
122                        })
123                    });
124
125                    if is_git_ref_change {
126                        let _ = event_tx.send(CompanionEvent::GitRefChanged);
127                        continue;
128                    }
129
130                    // Convert notify event kind to our event type
131                    use notify::EventKind;
132                    for path in &event.paths {
133                        // Skip gitignored files (including .git/)
134                        if Self::is_ignored(path, repo_path, gitignore) {
135                            continue;
136                        }
137
138                        let companion_event = match event.kind {
139                            EventKind::Create(_) => Some(CompanionEvent::FileCreated(path.clone())),
140                            EventKind::Modify(_) => {
141                                Some(CompanionEvent::FileModified(path.clone()))
142                            }
143                            EventKind::Remove(_) => Some(CompanionEvent::FileDeleted(path.clone())),
144                            _ => None,
145                        };
146
147                        if let Some(e) = companion_event {
148                            let _ = event_tx.send(e);
149                        }
150                    }
151                }
152            }
153            Err(errors) => {
154                for error in errors {
155                    let _ = event_tx.send(CompanionEvent::WatcherError(error.to_string()));
156                }
157            }
158        }
159    }
160
161    /// Check if a path should be ignored (gitignored or .git internal)
162    fn is_ignored(path: &Path, repo_path: &Path, gitignore: &Gitignore) -> bool {
163        // Get relative path
164        let Ok(rel_path) = path.strip_prefix(repo_path) else {
165            return false;
166        };
167
168        // Check if it's a directory (for gitignore matching)
169        let is_dir = path.is_dir();
170
171        // Check gitignore
172        gitignore.matched(rel_path, is_dir).is_ignore()
173    }
174
175    /// Get the repository path being watched
176    pub fn repo_path(&self) -> &Path {
177        &self.repo_path
178    }
179}