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).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 use notify::EventKind;
132 for path in &event.paths {
133 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 fn is_ignored(path: &Path, repo_path: &Path, gitignore: &Gitignore) -> bool {
163 let Ok(rel_path) = path.strip_prefix(repo_path) else {
165 return false;
166 };
167
168 let is_dir = path.is_dir();
170
171 gitignore.matched(rel_path, is_dir).is_ignore()
173 }
174
175 pub fn repo_path(&self) -> &Path {
177 &self.repo_path
178 }
179}