use anyhow::{Context, Result};
use ignore::gitignore::{Gitignore, GitignoreBuilder};
use notify::{RecommendedWatcher, RecursiveMode};
use notify_debouncer_full::{DebounceEventResult, Debouncer, RecommendedCache, new_debouncer};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::mpsc;
#[derive(Debug, Clone)]
pub enum CompanionEvent {
FileCreated(PathBuf),
FileModified(PathBuf),
FileDeleted(PathBuf),
FileRenamed(PathBuf, PathBuf),
GitRefChanged,
WatcherError(String),
}
pub struct FileWatcherService {
_watcher: Debouncer<RecommendedWatcher, RecommendedCache>,
repo_path: PathBuf,
}
impl FileWatcherService {
pub fn new(repo_path: &Path, event_tx: mpsc::UnboundedSender<CompanionEvent>) -> Result<Self> {
let repo_path = repo_path.to_path_buf();
let repo_path_clone = repo_path.clone();
let gitignore = Self::build_gitignore(&repo_path);
let mut debouncer = new_debouncer(
Duration::from_millis(500),
None,
move |result: DebounceEventResult| {
Self::handle_events(result, &repo_path_clone, &gitignore, &event_tx);
},
)
.context("Failed to create file watcher debouncer")?;
debouncer
.watch(&repo_path, RecursiveMode::Recursive)
.context("Failed to start watching repository")?;
Ok(Self {
_watcher: debouncer,
repo_path,
})
}
fn build_gitignore(repo_path: &Path) -> Arc<Gitignore> {
let mut builder = GitignoreBuilder::new(repo_path);
let gitignore_path = repo_path.join(".gitignore");
if gitignore_path.exists() {
let _ = builder.add(&gitignore_path);
}
if let Some(home) = dirs::home_dir() {
let global_ignore = home.join(".gitignore_global");
if global_ignore.exists() {
let _ = builder.add(&global_ignore);
}
}
let _ = builder.add_line(None, ".git/");
Arc::new(builder.build().unwrap_or_else(|_| {
let mut fallback = GitignoreBuilder::new(repo_path);
let _ = fallback.add_line(None, ".git/");
fallback.build().unwrap_or_else(|_| {
GitignoreBuilder::new(repo_path)
.build()
.expect("empty GitignoreBuilder should always build")
})
}))
}
fn handle_events(
result: DebounceEventResult,
repo_path: &Path,
gitignore: &Gitignore,
event_tx: &mpsc::UnboundedSender<CompanionEvent>,
) {
match result {
Ok(events) => {
for event in events {
let is_git_ref_change = event.paths.iter().any(|p| {
p.strip_prefix(repo_path).is_ok_and(|rel| {
let rel_str = rel.to_string_lossy();
rel_str == ".git/HEAD"
|| rel_str.starts_with(".git/refs/")
|| rel_str == ".git/index"
})
});
if is_git_ref_change {
let _ = event_tx.send(CompanionEvent::GitRefChanged);
continue;
}
use notify::EventKind;
for path in &event.paths {
if Self::is_ignored(path, repo_path, gitignore) {
continue;
}
let companion_event = match event.kind {
EventKind::Create(_) => Some(CompanionEvent::FileCreated(path.clone())),
EventKind::Modify(_) => {
Some(CompanionEvent::FileModified(path.clone()))
}
EventKind::Remove(_) => Some(CompanionEvent::FileDeleted(path.clone())),
_ => None,
};
if let Some(e) = companion_event {
let _ = event_tx.send(e);
}
}
}
}
Err(errors) => {
for error in errors {
let _ = event_tx.send(CompanionEvent::WatcherError(error.to_string()));
}
}
}
}
fn is_ignored(path: &Path, repo_path: &Path, gitignore: &Gitignore) -> bool {
let Ok(rel_path) = path.strip_prefix(repo_path) else {
return false;
};
let is_dir = path.is_dir();
gitignore.matched(rel_path, is_dir).is_ignore()
}
#[must_use]
pub fn repo_path(&self) -> &Path {
&self.repo_path
}
}