1use std::path::Path;
4use std::sync::Arc;
5use std::sync::atomic::{AtomicBool, Ordering};
6use std::time::Duration;
7
8use notify::{EventKind, RecommendedWatcher, RecursiveMode};
9use notify_debouncer_full::{DebounceEventResult, Debouncer, NoCache, new_debouncer_opt};
10use tokio::sync::mpsc::UnboundedSender;
11
12use crate::event::AppEvent;
13
14const DEBOUNCE: Duration = Duration::from_millis(200);
15
16pub struct WatcherHandle {
18 pub healthy: Arc<AtomicBool>,
19 _debouncer: Debouncer<RecommendedWatcher, NoCache>,
20}
21
22pub fn spawn_watcher(
25 repo_root: &Path,
26 git_dir: &Path,
27 tx: UnboundedSender<AppEvent>,
28) -> notify::Result<WatcherHandle> {
29 let healthy = Arc::new(AtomicBool::new(true));
30 let flag = Arc::clone(&healthy);
31 let root = repo_root.to_path_buf();
32 let metadata_dir = git_dir.to_path_buf();
33 let handler = move |result: DebounceEventResult| match result {
34 Ok(events) => {
35 let hit = events.iter().any(|event| {
36 !matches!(event.kind, EventKind::Access(_))
37 && event
38 .paths
39 .iter()
40 .any(|path| relevant(path, &root, &metadata_dir))
41 });
42 if hit {
43 let _ = tx.send(AppEvent::RepoChanged);
44 }
45 }
46 Err(_) => flag.store(false, Ordering::Relaxed),
47 };
48 let mut debouncer = new_debouncer_opt::<_, RecommendedWatcher, NoCache>(
51 DEBOUNCE,
52 None,
53 handler,
54 NoCache::new(),
55 notify::Config::default(),
56 )?;
57 debouncer.watch(repo_root, RecursiveMode::Recursive)?;
58 for extra in ["HEAD", "refs", "index"] {
59 let path = git_dir.join(extra);
60 if !path.exists() {
61 continue;
62 }
63 let mode = if path.is_dir() {
64 RecursiveMode::Recursive
65 } else {
66 RecursiveMode::NonRecursive
67 };
68 let _ = debouncer.watch(&path, mode);
70 }
71 Ok(WatcherHandle {
72 healthy,
73 _debouncer: debouncer,
74 })
75}
76
77fn relevant(path: &Path, repo_root: &Path, git_dir: &Path) -> bool {
82 if let Ok(rel) = path.strip_prefix(git_dir) {
83 return !(is_lockfile(path) || rel.starts_with("objects"));
84 }
85 let rel = path.strip_prefix(repo_root).unwrap_or(path);
86 if rel.starts_with(".jj/repo/op_heads") {
87 return true;
88 }
89 if rel.starts_with(".git") && is_lockfile(path) {
90 return false;
91 }
92 !(rel.starts_with(".diffler") || rel.starts_with(".jj") || rel.starts_with(".git/objects"))
93}
94
95fn is_lockfile(path: &Path) -> bool {
96 path.extension()
97 .is_some_and(|ext| ext.eq_ignore_ascii_case("lock"))
98}
99
100#[cfg(test)]
101mod tests {
102 use super::*;
103
104 fn relevant_in(root: &str, rel: &str) -> bool {
106 let root = Path::new(root);
107 relevant(&root.join(rel), root, &root.join(".git"))
108 }
109
110 #[test]
111 fn source_and_git_metadata_paths_are_relevant() {
112 assert!(relevant_in("/repo", "src/lib.rs"));
113 assert!(relevant_in("/repo", "README.md"));
114 assert!(relevant_in("/repo", ".git/HEAD"));
115 assert!(relevant_in("/repo", ".git/index"));
116 assert!(relevant_in("/repo", ".git/refs/heads/main"));
117 }
118
119 #[test]
120 fn session_store_paths_are_filtered() {
121 assert!(!relevant_in("/repo", ".diffler/session.json"));
122 assert!(!relevant_in("/repo", ".diffler/config.toml"));
123 }
124
125 #[test]
126 fn jj_state_paths_are_filtered() {
127 assert!(!relevant_in("/repo", ".jj/working_copy/tree_state"));
128 assert!(!relevant_in("/repo", ".jj/repo/op_store/operations/abc123"));
129 assert!(!relevant_in("/repo", ".jj/repo/index/segments"));
130 }
131
132 #[test]
133 fn a_jj_operation_surfaces_through_op_heads() {
134 assert!(relevant_in("/repo", ".jj/repo/op_heads/heads/abc123"));
135 }
136
137 #[test]
138 fn a_jj_operation_surfaces_when_the_git_store_sits_inside_jj() {
139 let root = Path::new("/repo");
140 let store = root.join(".jj/repo/store/git");
141 assert!(relevant(
142 &root.join(".jj/repo/op_heads/heads/abc123"),
143 root,
144 &store
145 ));
146 assert!(relevant(&store.join("refs/heads/topic"), root, &store));
147 assert!(!relevant(&store.join("objects/ab/cdef"), root, &store));
148 }
149
150 #[test]
151 fn a_jj_write_still_surfaces_through_what_it_exports_to_git() {
152 assert!(relevant_in("/repo", ".git/HEAD"));
153 assert!(relevant_in("/repo", ".git/refs/heads/feature"));
154 assert!(relevant_in("/repo", ".git/index"));
155 }
156
157 #[test]
158 fn git_objects_are_filtered() {
159 assert!(!relevant_in("/repo", ".git/objects/ab/cdef0123"));
160 assert!(!relevant_in("/repo", ".git/objects/pack/pack-1.idx"));
161 }
162
163 #[test]
164 fn git_lockfiles_under_git_dir_are_filtered() {
165 assert!(!relevant_in("/repo", ".git/index.lock"));
166 assert!(!relevant_in("/repo", ".git/MERGE_HEAD.lock"));
167 }
168
169 #[test]
170 fn project_lockfiles_are_relevant() {
171 assert!(relevant_in("/repo", "Cargo.lock"));
172 assert!(relevant_in("/repo", "uv.lock"));
173 assert!(relevant_in("/repo", "Cargo.lock.bak"));
174 }
175
176 #[test]
177 fn a_path_outside_the_root_stays_relevant() {
178 assert!(relevant(
179 Path::new("/elsewhere/src/a.rs"),
180 Path::new("/repo"),
181 Path::new("/repo/.git"),
182 ));
183 }
184
185 #[test]
186 fn external_gitdir_metadata_events_pass_through() {
187 let root = Path::new("/checkouts/wt");
188 let gitdir = Path::new("/main/.git/worktrees/wt");
189 assert!(relevant(&gitdir.join("HEAD"), root, gitdir));
190 assert!(relevant(&gitdir.join("index"), root, gitdir));
191 assert!(relevant(&gitdir.join("refs/heads/topic"), root, gitdir));
192 }
193
194 #[test]
195 fn external_gitdir_lockfiles_and_objects_are_filtered() {
196 let root = Path::new("/checkouts/wt");
197 let gitdir = Path::new("/main/.git/worktrees/wt");
198 assert!(!relevant(&gitdir.join("index.lock"), root, gitdir));
199 assert!(!relevant(&gitdir.join("HEAD.lock"), root, gitdir));
200 assert!(!relevant(&gitdir.join("objects/ab/cdef"), root, gitdir));
201 }
202
203 #[test]
204 fn spawn_watcher_reports_healthy_on_a_real_repo() {
205 let dir = tempfile::tempdir().expect("tempdir");
206 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
207 let handle = spawn_watcher(dir.path(), &dir.path().join(".git"), tx).expect("watcher");
208 assert!(handle.healthy.load(Ordering::Relaxed));
209 }
210
211 #[tokio::test]
214 async fn spawn_watcher_emits_repo_changed_for_relevant_writes_only() {
215 let dir = tempfile::tempdir().expect("tempdir");
216 let root = dir.path().canonicalize().expect("canonical root");
217 let git_dir = root.join(".git");
218 std::fs::create_dir_all(&git_dir).expect("gitdir");
219 std::fs::create_dir_all(root.join(".diffler")).expect("diffler dir");
220
221 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
222 let _handle = spawn_watcher(&root, &git_dir, tx).expect("watcher");
223
224 for _ in 0..10 {
226 if tokio::time::timeout(Duration::from_millis(500), rx.recv())
227 .await
228 .is_err()
229 {
230 break;
231 }
232 }
233
234 std::fs::write(root.join(".diffler/session.json"), "{}").expect("write");
235 let irrelevant = tokio::time::timeout(Duration::from_millis(800), rx.recv()).await;
236 assert!(
237 irrelevant.is_err(),
238 "a write under .diffler must not surface as RepoChanged, got {irrelevant:?}"
239 );
240
241 std::fs::write(root.join("src.rs"), "fn main() {}").expect("write");
243 let event = tokio::time::timeout(Duration::from_secs(10), rx.recv())
244 .await
245 .expect("RepoChanged within timeout")
246 .expect("channel stays open");
247 assert!(matches!(event, AppEvent::RepoChanged));
248 }
249}