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> {
43 let repo_path = repo_path.to_path_buf();
44 let repo_path_clone = repo_path.clone();
45
46 let gitignore = Self::build_gitignore(&repo_path);
48
49 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 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 fn build_gitignore(repo_path: &Path) -> Arc<Gitignore> {
72 let mut builder = GitignoreBuilder::new(repo_path);
73
74 let gitignore_path = repo_path.join(".gitignore");
76 if gitignore_path.exists() {
77 let _ = builder.add(&gitignore_path);
78 }
79
80 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 let _ = builder.add_line(None, ".git/");
90
91 Arc::new(builder.build().unwrap_or_else(|_| {
92 let mut fallback = GitignoreBuilder::new(repo_path);
94 let _ = fallback.add_line(None, ".git/");
95 fallback.build().unwrap_or_else(|_| {
97 GitignoreBuilder::new(repo_path)
99 .build()
100 .expect("empty GitignoreBuilder should always build")
101 })
102 }))
103 }
104
105 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 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 use notify::EventKind;
134 for path in &event.paths {
135 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 fn is_ignored(path: &Path, repo_path: &Path, gitignore: &Gitignore) -> bool {
165 let Ok(rel_path) = path.strip_prefix(repo_path) else {
167 return false;
168 };
169
170 let is_dir = path.is_dir();
172
173 gitignore.matched(rel_path, is_dir).is_ignore()
175 }
176
177 pub fn repo_path(&self) -> &Path {
179 &self.repo_path
180 }
181}