git_iris/companion/
watcher.rs1use 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#[derive(Debug, Clone)]
17pub enum CompanionEvent {
18 FileCreated(PathBuf),
20 FileModified(PathBuf),
22 FileDeleted(PathBuf),
24 FileRenamed(PathBuf, PathBuf),
26 GitRefChanged,
28 WatcherError(String),
30}
31
32pub struct FileWatcherService {
34 _watcher: Debouncer<RecommendedWatcher, RecommendedCache>,
36 repo_path: PathBuf,
38}
39
40impl FileWatcherService {
41 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 let gitignore = Self::build_gitignore(&repo_path);
52
53 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 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 fn build_gitignore(repo_path: &Path) -> Arc<Gitignore> {
76 let mut builder = GitignoreBuilder::new(repo_path);
77
78 let gitignore_path = repo_path.join(".gitignore");
80 if gitignore_path.exists() {
81 let _ = builder.add(&gitignore_path);
82 }
83
84 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 let _ = builder.add_line(None, ".git/");
94
95 Arc::new(builder.build().unwrap_or_else(|_| {
96 let mut fallback = GitignoreBuilder::new(repo_path);
98 let _ = fallback.add_line(None, ".git/");
99 fallback.build().unwrap_or_else(|_| {
101 GitignoreBuilder::new(repo_path)
103 .build()
104 .expect("empty GitignoreBuilder should always build")
105 })
106 }))
107 }
108
109 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 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 use notify::EventKind;
136 for path in &event.paths {
137 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 fn is_ignored(path: &Path, repo_path: &Path, gitignore: &Gitignore) -> bool {
167 let Ok(rel_path) = path.strip_prefix(repo_path) else {
169 return false;
170 };
171
172 let is_dir = path.is_dir();
174
175 gitignore.matched(rel_path, is_dir).is_ignore()
177 }
178
179 #[must_use]
181 pub fn repo_path(&self) -> &Path {
182 &self.repo_path
183 }
184}