Skip to main content

diffler/
watch.rs

1//! Debounced filesystem events on the repo become `AppEvent::RepoChanged`.
2
3use 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
16/// When notify reports errors, the app falls back to periodic polling.
17pub struct WatcherHandle {
18    pub healthy: Arc<AtomicBool>,
19    _debouncer: Debouncer<RecommendedWatcher, NoCache>,
20}
21
22/// We also watch HEAD, refs and index under `git_dir`, for a linked worktree
23/// whose gitdir lives outside the workdir.
24pub 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    // the file-id cache walks the whole tree on registration (seconds with
49    // node_modules) and buys only rename correlation, which we never use
50    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        // an in-tree gitdir duplicates the root watch, which the debounce absorbs
69        let _ = debouncer.watch(&path, mode);
70    }
71    Ok(WatcherHandle {
72        healthy,
73        _debouncer: debouncer,
74    })
75}
76
77/// We filter `.diffler/`, git's lockfiles and object database, and `.jj/`,
78/// which every jj command rewrites, except `.jj/repo/op_heads`, where each
79/// operation lands and a repo with no `.git` has no other signal. Project
80/// lockfiles stay relevant.
81fn 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    /// `relevant` for a plain repo layout: gitdir at `<root>/.git`.
105    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    // macOS FSEvents reports canonicalized paths, so we canonicalize the root,
212    // or `relevant` treats every event as relevant.
213    #[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        // FSEvents can deliver the setup writes above, so we drain until a quiet window
225        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        // a generous timeout, since notify latency varies on slow CI runners
242        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}