use std::path::{Path, PathBuf};
use std::sync::mpsc;
use std::time::Duration;
use anyhow::{Context, Result};
use ignore::gitignore::{Gitignore, GitignoreBuilder};
use notify::{RecursiveMode, Watcher};
use crate::pipeline;
enum Batch {
Paths(Vec<PathBuf>),
Rescan,
}
fn ignore_matcher(repo: &Path) -> Gitignore {
let mut builder = GitignoreBuilder::new(repo);
for name in [
".gitignore",
".ignore",
".sinterignore",
".git/info/exclude",
] {
builder.add(repo.join(name));
}
builder.build().unwrap_or_else(|_| Gitignore::empty())
}
fn triggers_rebuild(repo: &Path, matcher: &Gitignore, path: &Path) -> bool {
let rel = path.strip_prefix(repo).unwrap_or(path);
if matches!(rel.to_str(), Some(".sinterignore" | ".sinter.toml")) {
return true;
}
if rel
.components()
.any(|c| c.as_os_str().to_string_lossy().starts_with('.'))
{
return false;
}
!matcher
.matched_path_or_any_parents(rel, path.is_dir())
.is_ignore()
}
pub fn run(repo: &Path) -> Result<()> {
let repo = repo.canonicalize()?;
let report = pipeline::build(&repo, None)?;
pipeline::print_report(&report);
let matcher = ignore_matcher(&repo);
let (tx, rx) = mpsc::channel();
let mut watcher = notify::recommended_watcher(move |event: notify::Result<notify::Event>| {
let _ = match event {
Ok(event) if event.need_rescan() => tx.send(Batch::Rescan),
Ok(event) => tx.send(Batch::Paths(event.paths)),
Err(_) => tx.send(Batch::Rescan),
};
})
.context("create file watcher")?;
watcher.watch(&repo, RecursiveMode::Recursive)?;
println!("watching {} (ctrl-c to stop)", repo.display());
loop {
let Ok(first) = rx.recv() else {
return Ok(());
};
let mut full = false;
let mut paths: Vec<PathBuf> = Vec::new();
let mut batch = first;
loop {
match batch {
Batch::Rescan => full = true,
Batch::Paths(more) => paths.extend(more),
}
match rx.recv_timeout(Duration::from_millis(200)) {
Ok(more) => batch = more,
Err(_) => break,
}
}
paths.sort();
paths.dedup();
if paths.iter().any(|path| {
path.strip_prefix(&repo)
.ok()
.and_then(Path::to_str)
.is_some_and(|rel| matches!(rel, ".sinterignore" | ".sinter.toml"))
}) {
full = true;
}
paths.retain(|p| triggers_rebuild(&repo, &matcher, p));
if !full && paths.is_empty() {
continue;
}
let changed = if full { None } else { Some(paths.as_slice()) };
match pipeline::build(&repo, changed) {
Ok(report) if report.changed > 0 || report.removed > 0 => {
pipeline::print_report(&report)
}
Ok(_) => {}
Err(e) => eprintln!("sinter watch: update failed: {e:#}"),
}
}
}
#[cfg(test)]
mod tests {
use super::{ignore_matcher, triggers_rebuild};
#[test]
fn event_filtering_respects_gitignore_and_hidden_paths() {
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
std::fs::write(repo.join(".gitignore"), "target/\nnode_modules/\n*.log\n").unwrap();
let matcher = ignore_matcher(repo);
assert!(triggers_rebuild(repo, &matcher, &repo.join("src/main.rs")));
assert!(!triggers_rebuild(
repo,
&matcher,
&repo.join("target/debug/deps/foo.d")
));
assert!(!triggers_rebuild(
repo,
&matcher,
&repo.join("node_modules/left-pad/index.js")
));
assert!(!triggers_rebuild(repo, &matcher, &repo.join("build.log")));
assert!(!triggers_rebuild(
repo,
&matcher,
&repo.join(".git/index.lock")
));
assert!(!triggers_rebuild(
repo,
&matcher,
&repo.join(".sinter/graph.redb")
));
assert!(triggers_rebuild(
repo,
&matcher,
&repo.join(".sinterignore")
));
assert!(triggers_rebuild(repo, &matcher, &repo.join(".sinter.toml")));
}
}