Skip to main content

sqry_core/watch/
git_state.rs

1//! Git-state watcher for the `sqryd` daemon.
2//!
3//! Watches `.git/` internals (`HEAD`, `refs/heads/`, `packed-refs`, `index`)
4//! and classifies observed changes into one of four categories so the daemon
5//! can make correct rebuild decisions:
6//!
7//! - [`GitChangeClass::BranchSwitch`] — the current symbolic ref changed
8//!   (checkout to a different branch or detached HEAD).
9//! - [`GitChangeClass::TreeDiverged`] — the current ref still points to the
10//!   same branch name, but `HEAD^{tree}` no longer matches the last indexed
11//!   tree OID (pull, reset, fast-forward to a remote head, etc.).
12//! - [`GitChangeClass::LocalCommit`] — the current ref advanced but the tree
13//!   is unchanged (e.g. `git commit --amend` with no content change, or a
14//!   commit whose working-tree edits were already observed by the source-tree
15//!   watcher and accounted for).
16//! - [`GitChangeClass::Noise`] — packed-refs rewrite, index bookkeeping, gc
17//!   loose-object churn, reflog appends, or any other change that leaves both
18//!   the current ref name and the tree OID unchanged.
19//!
20//! Only `BranchSwitch` and `TreeDiverged` signal a full rebuild. `LocalCommit`
21//! and `Noise` are reported for telemetry / tracing but do not trigger any
22//! rebuild by themselves — a commit that modified the working tree was
23//! already handled by the source-tree watcher when the edit occurred.
24//!
25//! # Gitdir resolution
26//!
27//! Worktrees and submodules use a plain file named `.git` containing a
28//! single `gitdir: <path>` line pointing at the real gitdir. [`GitStateWatcher::new`]
29//! resolves this transparently before attaching the underlying notify watcher.
30//!
31//! # Fallback
32//!
33//! On any uncertainty (failed `git rev-parse`, unreadable `HEAD`, missing
34//! refs, etc.) [`GitStateWatcher::classify`] returns [`GitChangeClass::BranchSwitch`]
35//! — the "fall back to full rebuild" rule from the sqryd daemon design
36//! amendment (2026-04-09 §B). Correctness over optimization.
37
38use anyhow::{Context, Result};
39use notify::{Event, RecommendedWatcher, RecursiveMode, Watcher as NotifyWatcher};
40use std::path::{Path, PathBuf};
41use std::process::Command;
42use std::sync::mpsc::{Receiver, TryRecvError, channel};
43
44/// Snapshot of the git state at the moment the workspace was last indexed.
45///
46/// This is the authoritative reference point for [`GitStateWatcher::classify`].
47/// It stores the symbolic ref name (or `None` for detached HEAD), the commit
48/// OID the ref resolved to, and the tree OID that commit pointed at.
49///
50/// Tracking the commit OID in addition to the tree OID lets the classifier
51/// distinguish [`GitChangeClass::LocalCommit`] (ref moved, tree identical)
52/// from [`GitChangeClass::Noise`] (nothing substantive changed).
53#[derive(Debug, Clone, Default, PartialEq, Eq)]
54pub struct LastIndexedGitState {
55    /// Symbolic reference name (e.g. `refs/heads/main`), or `None` if HEAD
56    /// is detached.
57    pub head_ref: Option<String>,
58    /// Commit OID that `HEAD` resolved to at indexing time.
59    pub head_commit_oid: Option<String>,
60    /// Tree OID corresponding to `HEAD^{tree}` at indexing time.
61    pub head_tree_oid: Option<String>,
62}
63
64/// Classification of a detected `.git/` change.
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum GitChangeClass {
67    /// The current symbolic ref changed (checkout to another branch or
68    /// detached HEAD). Signals a full rebuild.
69    BranchSwitch,
70    /// The current ref name is unchanged but the tree OID changed
71    /// (pull, reset, fast-forward, force-push landing, etc.). Signals a
72    /// full rebuild.
73    TreeDiverged,
74    /// The current ref advanced (commit OID changed) but the tree OID is
75    /// identical to the last indexed state. No rebuild.
76    LocalCommit,
77    /// Neither the ref name, nor the commit OID, nor the tree OID changed.
78    /// Packed-refs rewrite, index bookkeeping, reflog append, gc churn,
79    /// staging operations, etc. No rebuild.
80    Noise,
81}
82
83impl GitChangeClass {
84    /// Returns `true` if this classification requires a full rebuild.
85    #[must_use]
86    pub fn requires_full_rebuild(self) -> bool {
87        matches!(self, Self::BranchSwitch | Self::TreeDiverged)
88    }
89}
90
91/// Watches `.git/` internals for state changes relevant to indexing.
92///
93/// The watcher attaches to the resolved gitdir (supporting the `.git`-file
94/// worktree layout) and buffers notify events in a channel. Callers drain
95/// the channel with [`Self::poll_changed`] and then re-classify the current
96/// state against their last-indexed snapshot via [`Self::classify`].
97pub struct GitStateWatcher {
98    /// Underlying notify watcher (kept alive for the lifetime of `Self`).
99    _watcher: RecommendedWatcher,
100    /// Channel for receiving raw notify events.
101    receiver: Receiver<Result<Event, notify::Error>>,
102    /// Absolute path to the repository working tree root.
103    repo_root: PathBuf,
104    /// Absolute path to the resolved gitdir (may be outside `repo_root`
105    /// for worktrees / submodules).
106    gitdir: PathBuf,
107}
108
109impl GitStateWatcher {
110    /// Creates a new git-state watcher attached to the resolved gitdir for
111    /// the repository at `repo_root`.
112    ///
113    /// Supports both the conventional `.git/` directory layout and the
114    /// `.git`-file worktree/submodule layout (`gitdir: <path>`).
115    ///
116    /// # Errors
117    ///
118    /// Returns an error if:
119    /// - `repo_root` does not contain a `.git` entry (file or directory).
120    /// - The `.git` file is malformed or its target gitdir cannot be resolved.
121    /// - The underlying notify watcher cannot be created or attached.
122    pub fn new(repo_root: &Path) -> Result<Self> {
123        let repo_root = repo_root.to_path_buf();
124        let gitdir = resolve_gitdir(&repo_root)
125            .with_context(|| format!("Failed to resolve gitdir at {}", repo_root.display()))?;
126
127        let (tx, rx) = channel();
128        let mut watcher = notify::recommended_watcher(move |res| {
129            // Ignore send errors if the receiver has been dropped.
130            let _ = tx.send(res);
131        })
132        .context("Failed to create git-state watcher")?;
133
134        // Watch the gitdir recursively. This covers HEAD, refs/heads/,
135        // refs/remotes/, packed-refs, index, logs/, objects/ churn, etc.
136        // The classifier is responsible for separating signal from noise;
137        // we intentionally over-capture at the watch layer.
138        watcher
139            .watch(&gitdir, RecursiveMode::Recursive)
140            .with_context(|| format!("Failed to watch gitdir: {}", gitdir.display()))?;
141
142        log::info!(
143            "Git-state watcher started for repo {} (gitdir {})",
144            repo_root.display(),
145            gitdir.display(),
146        );
147
148        Ok(Self {
149            _watcher: watcher,
150            receiver: rx,
151            repo_root,
152            gitdir,
153        })
154    }
155
156    /// Returns the resolved gitdir this watcher is attached to.
157    #[must_use]
158    pub fn gitdir(&self) -> &Path {
159        &self.gitdir
160    }
161
162    /// Returns the repository working-tree root this watcher was constructed
163    /// for.
164    #[must_use]
165    pub fn repo_root(&self) -> &Path {
166        &self.repo_root
167    }
168
169    /// Drains all pending events from the underlying notify channel.
170    ///
171    /// Returns `true` if at least one event was observed since the last call
172    /// (including error events), `false` if the channel was empty. Callers
173    /// use this as a "something happened in `.git/`" trigger and then call
174    /// [`Self::classify`] to interpret the change.
175    #[must_use]
176    pub fn poll_changed(&self) -> bool {
177        let mut observed = false;
178        loop {
179            match self.receiver.try_recv() {
180                Ok(Ok(_event)) => {
181                    observed = true;
182                }
183                Ok(Err(e)) => {
184                    log::warn!("Git-state watcher error: {e}");
185                    observed = true;
186                }
187                Err(TryRecvError::Empty) => break,
188                Err(TryRecvError::Disconnected) => {
189                    log::error!("Git-state watcher channel disconnected");
190                    break;
191                }
192            }
193        }
194        observed
195    }
196
197    /// Captures the current git state as a [`LastIndexedGitState`] snapshot.
198    ///
199    /// This is the snapshot callers should persist alongside an index build
200    /// and pass back into [`Self::classify`] on subsequent polls.
201    ///
202    /// The snapshot is acquired with minimal race exposure:
203    ///
204    /// 1. `head_ref` is read from the `.git/HEAD` file directly (file I/O,
205    ///    no subprocess fork) — this completes in microseconds.
206    /// 2. `head_commit_oid` and `head_tree_oid` are obtained from a **single**
207    ///    `git rev-parse HEAD HEAD^{tree}` subprocess, which resolves both
208    ///    values against the same repository state.
209    ///
210    /// The only remaining TOCTOU window is between the file read (step 1)
211    /// and the subprocess (step 2). If `HEAD` moves in that window the
212    /// classifier will see a ref/commit mismatch and fall back to
213    /// [`GitChangeClass::BranchSwitch`] (full rebuild), which is correct by
214    /// the daemon's conservative fallback rule.
215    ///
216    /// On any git command failure this returns a snapshot with `None`
217    /// fields, which guarantees the next [`Self::classify`] call falls back
218    /// to [`GitChangeClass::BranchSwitch`] (full rebuild).
219    #[must_use]
220    pub fn current_state(&self) -> LastIndexedGitState {
221        let head_ref = read_head_ref_from_file(&self.gitdir);
222        let (head_commit_oid, head_tree_oid) = read_commit_and_tree(&self.repo_root);
223        LastIndexedGitState {
224            head_ref,
225            head_commit_oid,
226            head_tree_oid,
227        }
228    }
229
230    /// Classifies the current git state against the last-indexed snapshot.
231    ///
232    /// Returns one of the four [`GitChangeClass`] variants according to the
233    /// rules documented on the module.
234    ///
235    /// On any uncertainty (failed `git rev-parse`, missing fields in either
236    /// the current state or the stored snapshot) this returns
237    /// [`GitChangeClass::BranchSwitch`] — the conservative "fall back to a
238    /// full rebuild" rule.
239    #[must_use]
240    pub fn classify(&self, last: &LastIndexedGitState) -> GitChangeClass {
241        let current = self.current_state();
242
243        // Uncertainty guard: if either side is missing any field we can't
244        // compare against, bail out to the full-rebuild fallback.
245        let (Some(current_ref), Some(current_commit), Some(current_tree)) = (
246            current.head_ref.as_deref(),
247            current.head_commit_oid.as_deref(),
248            current.head_tree_oid.as_deref(),
249        ) else {
250            return GitChangeClass::BranchSwitch;
251        };
252        let (Some(last_ref), Some(last_commit), Some(last_tree)) = (
253            last.head_ref.as_deref(),
254            last.head_commit_oid.as_deref(),
255            last.head_tree_oid.as_deref(),
256        ) else {
257            return GitChangeClass::BranchSwitch;
258        };
259
260        if current_ref != last_ref {
261            return GitChangeClass::BranchSwitch;
262        }
263        if current_tree != last_tree {
264            return GitChangeClass::TreeDiverged;
265        }
266        if current_commit != last_commit {
267            return GitChangeClass::LocalCommit;
268        }
269        GitChangeClass::Noise
270    }
271}
272
273/// Resolves the absolute gitdir for a working-tree root.
274///
275/// Handles both the conventional `<repo>/.git/` directory layout and the
276/// `.git`-file layout used by worktrees and submodules (the file contains
277/// a single line `gitdir: <path>` pointing at the real gitdir).
278fn resolve_gitdir(repo_root: &Path) -> Result<PathBuf> {
279    let dot_git = repo_root.join(".git");
280    let metadata = std::fs::metadata(&dot_git)
281        .with_context(|| format!("No .git entry at {}", dot_git.display()))?;
282    if metadata.is_dir() {
283        return Ok(dot_git);
284    }
285    // `.git` is a file (worktree/submodule). Parse `gitdir: <path>` line.
286    let contents = std::fs::read_to_string(&dot_git)
287        .with_context(|| format!("Failed to read .git file at {}", dot_git.display()))?;
288    for line in contents.lines() {
289        if let Some(rest) = line.strip_prefix("gitdir:") {
290            let raw = rest.trim();
291            let candidate = PathBuf::from(raw);
292            let resolved = if candidate.is_absolute() {
293                candidate
294            } else {
295                repo_root.join(candidate)
296            };
297            let canonical = std::fs::canonicalize(&resolved).with_context(|| {
298                format!(
299                    "Failed to canonicalize worktree gitdir {}",
300                    resolved.display()
301                )
302            })?;
303            return Ok(canonical);
304        }
305    }
306    anyhow::bail!(
307        "Malformed .git file at {}: missing `gitdir:` line",
308        dot_git.display()
309    )
310}
311
312/// Reads the `.git/HEAD` file and returns the symbolic ref it points at
313/// (e.g. `refs/heads/main`), or `None` for detached HEAD / unreadable HEAD.
314///
315/// This is a file read, not a subprocess — intentionally fast and free of
316/// the fork/exec overhead and race windows that come with shelling out.
317fn read_head_ref_from_file(gitdir: &Path) -> Option<String> {
318    let head_path = gitdir.join("HEAD");
319    let contents = match std::fs::read_to_string(&head_path) {
320        Ok(c) => c,
321        Err(e) => {
322            log::error!(
323                "failed to read git HEAD file at {}: {e}",
324                head_path.display()
325            );
326            return None;
327        }
328    };
329    let trimmed = contents.trim();
330    // `ref: refs/heads/main` → symbolic ref. Bare OID → detached HEAD.
331    trimmed
332        .strip_prefix("ref: ")
333        .map(|refname| refname.to_owned())
334}
335
336/// Runs a single `git rev-parse HEAD HEAD^{tree}` subprocess and returns
337/// the (commit OID, tree OID) pair atomically. Both values are resolved
338/// against the same repository state in a single process invocation.
339///
340/// On any failure (missing `git`, non-zero exit, unparseable output) the
341/// corresponding fields are returned as `None`. Spawn failures and non-zero
342/// exits are logged at distinct severity levels so daemon operators can
343/// diagnose a missing `git` binary vs. a transient repository state issue.
344fn read_commit_and_tree(repo_root: &Path) -> (Option<String>, Option<String>) {
345    let output = match Command::new("git")
346        .arg("-C")
347        .arg(repo_root)
348        .args(["rev-parse", "HEAD", "HEAD^{tree}"])
349        .output()
350    {
351        Ok(out) => out,
352        Err(e) => {
353            log::error!(
354                "failed to spawn `git rev-parse` at {}: {e} \
355                 — is `git` on the daemon's PATH?",
356                repo_root.display()
357            );
358            return (None, None);
359        }
360    };
361    if !output.status.success() {
362        log::warn!(
363            "`git rev-parse HEAD HEAD^{{tree}}` returned {} at {} (stderr: {})",
364            output.status,
365            repo_root.display(),
366            String::from_utf8_lossy(&output.stderr).trim(),
367        );
368        return (None, None);
369    }
370    let stdout = String::from_utf8_lossy(&output.stdout);
371    let mut lines = stdout.lines();
372    let commit = lines.next().map(|s| s.trim().to_owned());
373    let tree = lines.next().map(|s| s.trim().to_owned());
374    (commit, tree)
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380    use std::fs;
381    use std::process::Command;
382    use tempfile::TempDir;
383
384    /// Initializes a minimal git repo in `dir` with one commit on `main`.
385    /// Returns the absolute repo root.
386    fn init_repo(dir: &Path) {
387        run_git(dir, &["init", "-q", "-b", "main"]);
388        run_git(dir, &["config", "user.email", "test@sqry.dev"]);
389        run_git(dir, &["config", "user.name", "Sqry Test"]);
390        run_git(dir, &["config", "commit.gpgsign", "false"]);
391        fs::write(dir.join("a.txt"), b"alpha\n").unwrap();
392        run_git(dir, &["add", "a.txt"]);
393        run_git(dir, &["commit", "-q", "-m", "initial"]);
394    }
395
396    fn run_git(dir: &Path, args: &[&str]) {
397        let status = Command::new("git")
398            .arg("-C")
399            .arg(dir)
400            .args(args)
401            .status()
402            .expect("git command failed to launch");
403        assert!(status.success(), "git {args:?} failed in {}", dir.display());
404    }
405
406    #[test]
407    fn gitdir_resolves_conventional_directory_layout() {
408        let tmp = TempDir::new().unwrap();
409        init_repo(tmp.path());
410        let gitdir = resolve_gitdir(tmp.path()).unwrap();
411        assert_eq!(gitdir, tmp.path().join(".git"));
412    }
413
414    #[test]
415    fn gitdir_resolves_worktree_dot_git_file_layout() {
416        // Create a primary repo, then `git worktree add` into a separate
417        // directory whose `.git` will be a file containing `gitdir: ...`.
418        let primary = TempDir::new().unwrap();
419        init_repo(primary.path());
420
421        let work = TempDir::new().unwrap();
422        let work_path = work.path().join("wt");
423        run_git(
424            primary.path(),
425            &[
426                "worktree",
427                "add",
428                "-b",
429                "feature",
430                work_path.to_str().unwrap(),
431            ],
432        );
433
434        // Sanity: .git in the worktree must be a file, not a directory.
435        let dot_git = work_path.join(".git");
436        let md = fs::metadata(&dot_git).unwrap();
437        assert!(md.is_file(), ".git should be a file in the worktree");
438
439        let resolved = resolve_gitdir(&work_path).unwrap();
440        assert!(
441            resolved.is_dir(),
442            "resolved gitdir should be a directory: {}",
443            resolved.display()
444        );
445        // The resolved gitdir must live under the primary repo's gitdir
446        // (typically `<primary>/.git/worktrees/wt`).
447        let primary_gitdir = primary.path().join(".git").join("worktrees").join("wt");
448        let primary_canon = fs::canonicalize(&primary_gitdir).unwrap();
449        assert_eq!(resolved, primary_canon);
450    }
451
452    #[test]
453    fn watcher_attaches_to_conventional_repo() {
454        let tmp = TempDir::new().unwrap();
455        init_repo(tmp.path());
456        let watcher = GitStateWatcher::new(tmp.path()).unwrap();
457        assert_eq!(watcher.repo_root(), tmp.path());
458        assert_eq!(watcher.gitdir(), tmp.path().join(".git"));
459    }
460
461    #[test]
462    fn watcher_attaches_to_worktree_layout() {
463        let primary = TempDir::new().unwrap();
464        init_repo(primary.path());
465        let work = TempDir::new().unwrap();
466        let work_path = work.path().join("wt");
467        run_git(
468            primary.path(),
469            &[
470                "worktree",
471                "add",
472                "-b",
473                "feature",
474                work_path.to_str().unwrap(),
475            ],
476        );
477
478        let watcher = GitStateWatcher::new(&work_path).unwrap();
479        assert_eq!(watcher.repo_root(), work_path);
480        // gitdir resolved into the primary repo.
481        assert!(
482            watcher.gitdir().starts_with(primary.path()),
483            "worktree gitdir should live under primary: got {}",
484            watcher.gitdir().display()
485        );
486    }
487
488    #[test]
489    fn classify_commit_on_same_branch_without_tree_change_is_local_commit() {
490        let tmp = TempDir::new().unwrap();
491        init_repo(tmp.path());
492        let watcher = GitStateWatcher::new(tmp.path()).unwrap();
493        let baseline = watcher.current_state();
494        assert!(baseline.head_ref.as_deref() == Some("refs/heads/main"));
495
496        // An empty commit advances the ref to a new commit OID but keeps
497        // `HEAD^{tree}` identical. That is the canonical "ref moved, tree
498        // unchanged" scenario the classifier calls LocalCommit. (We prefer
499        // this over `git commit --amend --no-edit` because amending in the
500        // same sub-second window can produce a byte-identical commit object
501        // and thus the same OID, collapsing into Noise.)
502        run_git(
503            tmp.path(),
504            &["commit", "-q", "--allow-empty", "-m", "empty commit"],
505        );
506
507        let class = watcher.classify(&baseline);
508        assert_eq!(class, GitChangeClass::LocalCommit);
509        assert!(!class.requires_full_rebuild());
510    }
511
512    /// Detached HEAD: head_ref becomes None, which triggers the uncertainty
513    /// guard and falls back to BranchSwitch. This is intentional — the daemon
514    /// treats detached HEAD as "cannot determine ref, assume the worst."
515    #[test]
516    fn classify_detached_head_falls_back_to_branch_switch() {
517        let tmp = TempDir::new().unwrap();
518        init_repo(tmp.path());
519        let watcher = GitStateWatcher::new(tmp.path()).unwrap();
520        let baseline = watcher.current_state();
521        assert!(baseline.head_ref.is_some());
522
523        // Detach HEAD by checking out the commit OID directly.
524        let oid = baseline.head_commit_oid.as_deref().unwrap();
525        run_git(tmp.path(), &["checkout", "-q", "--detach", oid]);
526
527        let class = watcher.classify(&baseline);
528        assert_eq!(class, GitChangeClass::BranchSwitch);
529        assert!(class.requires_full_rebuild());
530    }
531
532    #[test]
533    fn classify_commit_that_changes_tree_is_tree_diverged() {
534        let tmp = TempDir::new().unwrap();
535        init_repo(tmp.path());
536        let watcher = GitStateWatcher::new(tmp.path()).unwrap();
537        let baseline = watcher.current_state();
538
539        fs::write(tmp.path().join("a.txt"), b"alpha-modified\n").unwrap();
540        run_git(tmp.path(), &["commit", "-q", "-am", "edit alpha"]);
541
542        let class = watcher.classify(&baseline);
543        assert_eq!(class, GitChangeClass::TreeDiverged);
544        assert!(class.requires_full_rebuild());
545    }
546
547    #[test]
548    fn classify_checkout_to_other_branch_is_branch_switch() {
549        let tmp = TempDir::new().unwrap();
550        init_repo(tmp.path());
551        let watcher = GitStateWatcher::new(tmp.path()).unwrap();
552        let baseline = watcher.current_state();
553
554        run_git(tmp.path(), &["checkout", "-q", "-b", "other"]);
555
556        let class = watcher.classify(&baseline);
557        assert_eq!(class, GitChangeClass::BranchSwitch);
558        assert!(class.requires_full_rebuild());
559    }
560
561    #[test]
562    fn classify_gc_is_noise() {
563        let tmp = TempDir::new().unwrap();
564        init_repo(tmp.path());
565        // Generate a few dangling loose objects so `gc --prune=now` has
566        // something to collect.
567        fs::write(tmp.path().join("b.txt"), b"bravo\n").unwrap();
568        run_git(tmp.path(), &["add", "b.txt"]);
569        run_git(tmp.path(), &["reset", "--hard", "HEAD"]);
570        let watcher = GitStateWatcher::new(tmp.path()).unwrap();
571        let baseline = watcher.current_state();
572        run_git(tmp.path(), &["gc", "--quiet", "--prune=now"]);
573
574        let class = watcher.classify(&baseline);
575        assert_eq!(class, GitChangeClass::Noise);
576        assert!(!class.requires_full_rebuild());
577    }
578
579    #[test]
580    fn classify_staging_operations_are_noise() {
581        let tmp = TempDir::new().unwrap();
582        init_repo(tmp.path());
583        let watcher = GitStateWatcher::new(tmp.path()).unwrap();
584        let baseline = watcher.current_state();
585
586        fs::write(tmp.path().join("c.txt"), b"charlie\n").unwrap();
587        run_git(tmp.path(), &["add", "c.txt"]);
588        run_git(tmp.path(), &["reset", "HEAD", "c.txt"]);
589
590        let class = watcher.classify(&baseline);
591        assert_eq!(class, GitChangeClass::Noise);
592        assert!(!class.requires_full_rebuild());
593    }
594
595    #[test]
596    fn classify_missing_baseline_fields_falls_back_to_branch_switch() {
597        let tmp = TempDir::new().unwrap();
598        init_repo(tmp.path());
599        let watcher = GitStateWatcher::new(tmp.path()).unwrap();
600        let empty = LastIndexedGitState::default();
601        assert_eq!(watcher.classify(&empty), GitChangeClass::BranchSwitch);
602    }
603
604    #[test]
605    fn poll_changed_reports_events_after_git_command() {
606        let tmp = TempDir::new().unwrap();
607        init_repo(tmp.path());
608        let watcher = GitStateWatcher::new(tmp.path()).unwrap();
609        // Drain any events left from setup.
610        let _ = watcher.poll_changed();
611        fs::write(tmp.path().join("a.txt"), b"alpha-modified\n").unwrap();
612        run_git(tmp.path(), &["commit", "-q", "-am", "edit alpha"]);
613        // notify is eventual-consistency on Linux inotify; wait briefly.
614        std::thread::sleep(std::time::Duration::from_millis(200));
615        assert!(
616            watcher.poll_changed(),
617            "watcher should report events after a commit touches .git/"
618        );
619    }
620}