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}