Skip to main content

grit_lib/
fsmonitor.rs

1//! Filesystem monitor integration for index stat refresh (`core.fsmonitor`).
2//!
3//! Queries Git's fsmonitor hook protocol v2 or the built-in daemon IPC socket
4//! before worktree stat scans, updating `FSMN` token and `CE_FSMONITOR_VALID` bits
5//! so unchanged paths can skip disk I/O (matching Git's `refresh_fsmonitor`).
6//!
7//! ```rust
8//! use grit_lib::worktree::fsmonitor::FsmonitorQueryBoundary;
9//! let _ = FsmonitorQueryBoundary(0);
10//! ```
11
12use std::ffi::OsString;
13use std::path::{Path, PathBuf};
14
15use crate::command_runner::{
16    system_command_runner, CommandEnvironment, CommandRunner, CommandSpec, CommandStdin,
17    CommandStdio,
18};
19use crate::config::ConfigSet;
20use crate::error::{Error, Result};
21use crate::index::Index;
22
23/// Nanoseconds since the Unix epoch, sampled immediately before a fsmonitor hook query.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub struct FsmonitorQueryBoundary(pub u64);
26
27/// Inputs for [`refresh_index`]; embedders supply the query boundary explicitly.
28#[derive(Debug, Clone, Copy)]
29pub struct FsmonitorRefreshOptions {
30    /// Token Git stores after a hook protocol v1 query (sampled before spawning the hook).
31    pub query_boundary: FsmonitorQueryBoundary,
32}
33
34impl FsmonitorRefreshOptions {
35    /// Build refresh options from an explicit boundary (for tests and embedders).
36    #[must_use]
37    pub fn with_query_boundary(boundary: FsmonitorQueryBoundary) -> Self {
38        Self {
39            query_boundary: boundary,
40        }
41    }
42}
43
44/// Format the hook protocol v1 token string from a pre-sampled boundary.
45#[must_use]
46pub fn v1_hook_token(boundary: FsmonitorQueryBoundary) -> String {
47    format!("{}", boundary.0)
48}
49
50/// Whether `core.fsmonitor` is configured for hook or built-in IPC mode.
51#[derive(Debug, Clone, PartialEq, Eq)]
52enum FsmonitorMode {
53    Disabled,
54    Hook(PathBuf),
55    Ipc,
56}
57
58/// Returns whether [`ConfigSet`] enables fsmonitor via `core.fsmonitor`.
59///
60/// `true` when the value is a hook executable path, the literal `true` (built-in IPC), or any
61/// other non-empty string; `false` when unset, empty, or `false`.
62#[must_use]
63pub fn fsmonitor_enabled(config: &ConfigSet) -> bool {
64    !matches!(fsmonitor_mode(config), FsmonitorMode::Disabled)
65}
66
67fn fsmonitor_mode(config: &ConfigSet) -> FsmonitorMode {
68    let Some(raw) = config.get("core.fsmonitor") else {
69        return FsmonitorMode::Disabled;
70    };
71    let trimmed = raw.trim();
72    if trimmed.eq_ignore_ascii_case("true") {
73        return FsmonitorMode::Ipc;
74    }
75    if trimmed.eq_ignore_ascii_case("false") {
76        return FsmonitorMode::Disabled;
77    }
78    if trimmed.is_empty() {
79        return FsmonitorMode::Disabled;
80    }
81    FsmonitorMode::Hook(PathBuf::from(trimmed))
82}
83
84fn hook_version(config: &ConfigSet) -> u32 {
85    config
86        .get("core.fsmonitorhookversion")
87        .and_then(|v| v.trim().parse().ok())
88        .filter(|&v| v == 1 || v == 2)
89        .unwrap_or(2)
90}
91
92/// Outcome of [`refresh_index`] for this index load.
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub struct FsmonitorRefreshResult {
95    /// Token or valid bits changed (index should be persisted).
96    pub index_changed: bool,
97    /// When true, [`entry_skips_worktree_stat`] may skip I/O for valid entries.
98    pub honor_valid_bits: bool,
99}
100
101impl FsmonitorRefreshResult {
102    const UNCHANGED: Self = Self {
103        index_changed: false,
104        honor_valid_bits: false,
105    };
106}
107
108/// Refresh fsmonitor validity on `index` when `core.fsmonitor` is enabled.
109///
110/// # Errors
111///
112/// Returns I/O or hook failures only for unexpected spawn errors; a failed query
113/// falls back to clearing all valid bits (Git-compatible) without error.
114pub fn refresh_index(
115    index: &mut Index,
116    git_dir: &Path,
117    work_tree: &Path,
118    config: &ConfigSet,
119    runner: Option<&dyn CommandRunner>,
120    options: &FsmonitorRefreshOptions,
121) -> Result<FsmonitorRefreshResult> {
122    if index.fsmonitor_refresh_ran {
123        return Ok(FsmonitorRefreshResult::UNCHANGED);
124    }
125    index.fsmonitor_refresh_ran = true;
126
127    let mode = fsmonitor_mode(config);
128    if matches!(mode, FsmonitorMode::Disabled) {
129        return Ok(FsmonitorRefreshResult::UNCHANGED);
130    }
131
132    let default_runner = system_command_runner();
133    let runner = runner.unwrap_or(default_runner.as_ref());
134    let token_before = index.fsmonitor_last_update.clone();
135    let valid_before: u32 = index
136        .entries()
137        .iter()
138        .filter(|e| e.fsmonitor_valid())
139        .count() as u32;
140
141    let hook_version = hook_version(config);
142    let v1_query_token = if hook_version == 1 {
143        Some(v1_hook_token(options.query_boundary))
144    } else {
145        None
146    };
147
148    // Git skips the hook until the index already has an FSMN token; seed the token and invalidate.
149    if matches!(mode, FsmonitorMode::Hook(_)) && index.fsmonitor_last_update.is_none() {
150        let token = v1_query_token
151            .clone()
152            .unwrap_or_else(|| bootstrap_token(options.query_boundary));
153        index.set_fsmonitor_last_update(Some(token));
154        invalidate_all_valid(index);
155        index.fsmonitor_validity_changed();
156        let token_after = index.fsmonitor_last_update.clone();
157        let valid_after: u32 = index
158            .entries()
159            .iter()
160            .filter(|e| e.fsmonitor_valid())
161            .count() as u32;
162        let changed = token_before != token_after || valid_before != valid_after;
163        return Ok(FsmonitorRefreshResult {
164            index_changed: changed,
165            honor_valid_bits: false,
166        });
167    }
168
169    let query = match mode {
170        FsmonitorMode::Disabled => unreachable!("fsmonitor disabled above"),
171        FsmonitorMode::Hook(path) => query_hook(
172            runner,
173            &path,
174            work_tree,
175            index,
176            config,
177            options.query_boundary,
178            v1_query_token.clone(),
179        ),
180        FsmonitorMode::Ipc => query_ipc(git_dir, index),
181    };
182
183    let honor_valid_bits = match query {
184        Ok(outcome) => {
185            apply_query_result(index, config, outcome);
186            true
187        }
188        Err(_) => {
189            invalidate_all_valid(index);
190            if index.fsmonitor_last_update.is_none() {
191                index.set_fsmonitor_last_update(Some(
192                    v1_query_token
193                        .clone()
194                        .unwrap_or_else(|| bootstrap_token(options.query_boundary)),
195                ));
196            }
197            false
198        }
199    };
200
201    let token_after = index.fsmonitor_last_update.clone();
202    let valid_after: u32 = index
203        .entries()
204        .iter()
205        .filter(|e| e.fsmonitor_valid())
206        .count() as u32;
207    Ok(FsmonitorRefreshResult {
208        index_changed: token_before != token_after || valid_before != valid_after,
209        honor_valid_bits,
210    })
211}
212
213struct QueryOutcome {
214    token: String,
215    trivial: bool,
216    changed_paths: Vec<String>,
217}
218
219fn bootstrap_token(boundary: FsmonitorQueryBoundary) -> String {
220    format!("builtin:{}", boundary.0)
221}
222
223fn query_hook(
224    runner: &dyn CommandRunner,
225    hook: &Path,
226    work_tree: &Path,
227    index: &Index,
228    config: &ConfigSet,
229    _query_boundary: FsmonitorQueryBoundary,
230    v1_query_token: Option<String>,
231) -> Result<QueryOutcome> {
232    let version = hook_version(config);
233    let last = index.fsmonitor_last_update.as_deref().unwrap_or("");
234    let spec = CommandSpec {
235        program: hook.to_path_buf(),
236        args: vec![OsString::from(version.to_string()), OsString::from(last)],
237        cwd: Some(work_tree.to_path_buf()),
238        env: CommandEnvironment::inherit_process_only(),
239        stdin: CommandStdin::Null,
240        stdout: CommandStdio::Pipe,
241        stderr: CommandStdio::Null,
242        shell: None,
243    };
244    let child = runner.spawn(&spec).map_err(Error::Io)?;
245    let output = child.wait_with_output().map_err(Error::Io)?;
246    if !output.status.success {
247        return Err(Error::Message(format!(
248            "fsmonitor hook exited with {}",
249            output.status.code.unwrap_or(-1)
250        )));
251    }
252    if version == 1 {
253        let token = v1_query_token.ok_or_else(|| {
254            Error::Message("internal error: missing v1 fsmonitor query token".into())
255        })?;
256        return Ok(parse_v1_hook_response(&output.stdout, token));
257    }
258    parse_v2_hook_response(&output.stdout)
259}
260
261/// Parse hook v1 stdout: NUL-delimited changed paths only (no response token).
262fn parse_v1_hook_response(buf: &[u8], token: String) -> QueryOutcome {
263    let mut changed_paths = Vec::new();
264    let mut trivial = false;
265    for part in buf.split(|&b| b == 0).filter(|p| !p.is_empty()) {
266        let path = String::from_utf8_lossy(part);
267        if path == "/" {
268            trivial = true;
269            break;
270        }
271        changed_paths.push(path.into_owned());
272    }
273    QueryOutcome {
274        token,
275        trivial,
276        changed_paths,
277    }
278}
279
280fn parse_v2_hook_response(buf: &[u8]) -> Result<QueryOutcome> {
281    let mut parts = buf.split(|&b| b == 0).filter(|p| !p.is_empty());
282    let token = parts
283        .next()
284        .map(|p| String::from_utf8_lossy(p).into_owned())
285        .filter(|t| !t.is_empty())
286        .ok_or_else(|| Error::Message("fsmonitor hook returned empty token".into()))?;
287    let mut changed_paths = Vec::new();
288    let mut trivial = false;
289    for p in parts {
290        let s = String::from_utf8_lossy(p);
291        if s == "/" {
292            trivial = true;
293            break;
294        }
295        changed_paths.push(s.into_owned());
296    }
297    Ok(QueryOutcome {
298        token,
299        trivial,
300        changed_paths,
301    })
302}
303
304fn query_ipc(git_dir: &Path, index: &Index) -> Result<QueryOutcome> {
305    let ipc_path = git_dir.join("fsmonitor--daemon.ipc");
306    let token = index
307        .fsmonitor_last_update
308        .as_deref()
309        .unwrap_or("builtin:fake");
310    let response = ipc_send_query(&ipc_path, token.as_bytes())?;
311    parse_v2_hook_response(&response)
312}
313
314fn ipc_send_query(ipc_path: &Path, message: &[u8]) -> Result<Vec<u8>> {
315    #[cfg(unix)]
316    {
317        use std::io::{BufReader, Write};
318        use std::os::unix::net::UnixStream;
319        use std::time::Duration;
320
321        let stream = UnixStream::connect(ipc_path).map_err(Error::Io)?;
322        stream
323            .set_read_timeout(Some(Duration::from_secs(30)))
324            .map_err(Error::Io)?;
325        stream
326            .set_write_timeout(Some(Duration::from_secs(30)))
327            .map_err(Error::Io)?;
328        let mut stream = stream;
329        crate::pkt_line::write_packet_raw(&mut stream, message).map_err(Error::Io)?;
330        crate::pkt_line::write_flush(&mut stream).map_err(Error::Io)?;
331        stream.flush().map_err(Error::Io)?;
332
333        let mut reader = BufReader::new(stream);
334        let mut out = Vec::new();
335        while let Some(pkt) = crate::pkt_line::read_packet(&mut reader).map_err(Error::Io)? {
336            match pkt {
337                crate::pkt_line::Packet::Data(s) => out.extend_from_slice(s.as_bytes()),
338                crate::pkt_line::Packet::Flush => break,
339                crate::pkt_line::Packet::Delim | crate::pkt_line::Packet::ResponseEnd => break,
340            }
341        }
342        Ok(out)
343    }
344    #[cfg(windows)]
345    {
346        ipc_send_query_windows(ipc_path, message)
347    }
348    #[cfg(not(any(unix, windows)))]
349    {
350        let _ = (ipc_path, message);
351        Err(Error::Message(
352            "fsmonitor IPC is not supported on this platform".into(),
353        ))
354    }
355}
356
357/// Git `initialize_pipe_name` for fsmonitor IPC (testable on all platforms).
358#[must_use]
359pub fn git_style_windows_pipe_name_from_absolute_ipc(absolute_ipc_path: &str) -> String {
360    let mut body = absolute_ipc_path.replace('\\', "/");
361    if body.starts_with("//") {
362        body = body[2..].to_string();
363    }
364    const PREFIX: &str = r"\\.\pipe\";
365    let mut pipe = String::from(PREFIX);
366    pipe.push_str(&body);
367    let path_start = PREFIX.len();
368    if body.len() >= 2 && body.as_bytes()[1] == b':' {
369        pipe.replace_range(path_start + 1..path_start + 2, "_");
370        let head = &pipe[..path_start + 2];
371        let tail = pipe[path_start + 2..].replace('/', "\\");
372        return format!("{head}{tail}");
373    }
374    pipe.replace('/', "\\")
375}
376
377#[cfg(windows)]
378fn ipc_send_query_windows(ipc_path: &Path, message: &[u8]) -> Result<Vec<u8>> {
379    use std::ffi::OsStr;
380    use std::io::{BufReader, Write};
381    use std::os::windows::ffi::OsStrExt;
382    use std::os::windows::io::FromRawHandle;
383
384    use windows_sys::Win32::Foundation::{GENERIC_READ, GENERIC_WRITE, INVALID_HANDLE_VALUE};
385    use windows_sys::Win32::Storage::FileSystem::{
386        CreateFileW, FlushFileBuffers, FILE_ATTRIBUTE_NORMAL, FILE_SHARE_READ, FILE_SHARE_WRITE,
387        OPEN_EXISTING,
388    };
389    use windows_sys::Win32::System::Pipes::WaitNamedPipeW;
390
391    let abs = std::path::absolute(ipc_path).map_err(Error::Io)?;
392    let abs_git = crate::git_path::user_display_path(&abs);
393    let pipe_name = git_style_windows_pipe_name_from_absolute_ipc(&abs_git);
394    let mut pipe: Vec<u16> = OsStr::new(&pipe_name).encode_wide().collect();
395    pipe.push(0);
396    unsafe {
397        if WaitNamedPipeW(pipe.as_ptr(), 3000) == 0 {
398            return Err(Error::Message("fsmonitor daemon pipe not available".into()));
399        }
400        let handle = CreateFileW(
401            pipe.as_ptr(),
402            GENERIC_READ | GENERIC_WRITE,
403            FILE_SHARE_READ | FILE_SHARE_WRITE,
404            std::ptr::null(),
405            OPEN_EXISTING,
406            FILE_ATTRIBUTE_NORMAL,
407            std::ptr::null_mut(),
408        );
409        if handle == INVALID_HANDLE_VALUE {
410            return Err(Error::Message("fsmonitor IPC connect failed".into()));
411        }
412        let mut file = std::fs::File::from_raw_handle(handle as _);
413        crate::pkt_line::write_packet_raw(&mut file, message).map_err(Error::Io)?;
414        crate::pkt_line::write_flush(&mut file).map_err(Error::Io)?;
415        file.flush().map_err(Error::Io)?;
416        let _ = FlushFileBuffers(handle);
417
418        let mut reader = BufReader::new(file);
419        let mut out = Vec::new();
420        while let Some(pkt) = crate::pkt_line::read_packet(&mut reader).map_err(Error::Io)? {
421            match pkt {
422                crate::pkt_line::Packet::Data(s) => out.extend_from_slice(s.as_bytes()),
423                crate::pkt_line::Packet::Flush => break,
424                crate::pkt_line::Packet::Delim | crate::pkt_line::Packet::ResponseEnd => break,
425            }
426        }
427        Ok(out)
428    }
429}
430
431fn apply_query_result(index: &mut Index, config: &ConfigSet, outcome: QueryOutcome) {
432    index.set_fsmonitor_last_update(Some(outcome.token));
433    if outcome.trivial {
434        invalidate_all_valid(index);
435        return;
436    }
437    if outcome.changed_paths.is_empty() {
438        return;
439    }
440    let ignorecase = config
441        .get_bool("core.ignorecase")
442        .and_then(|r| r.ok())
443        .unwrap_or(false);
444    for path in outcome.changed_paths {
445        index.invalidate_fsmonitor_for_path(&path, ignorecase);
446    }
447}
448
449fn invalidate_all_valid(index: &mut Index) {
450    index.invalidate_all_fsmonitor_valid();
451}
452
453/// When true, this index entry can skip worktree stat I/O during diff/status.
454#[must_use]
455pub fn entry_skips_worktree_stat(entry: &crate::index::IndexEntry, index: &Index) -> bool {
456    index.fsmonitor_last_update.is_some() && entry.fsmonitor_valid()
457}
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462
463    #[test]
464    fn parse_v2_nul_response_token_and_paths() {
465        let raw = b"tok\0foo.txt\0bar/\0";
466        let out = parse_v2_hook_response(raw).expect("parse");
467        assert_eq!(out.token, "tok");
468        assert!(!out.trivial);
469        assert_eq!(out.changed_paths, vec!["foo.txt", "bar/"]);
470    }
471
472    #[test]
473    fn parse_v2_trivial_response() {
474        let raw = b"tok\0/\0";
475        let out = parse_v2_hook_response(raw).expect("parse");
476        assert!(out.trivial);
477    }
478
479    #[test]
480    fn parse_v1_changed_paths() {
481        let raw = b"tracked.txt\0";
482        let out = parse_v1_hook_response(raw, v1_hook_token(FsmonitorQueryBoundary(123)));
483        assert_eq!(out.token, "123");
484        assert!(!out.trivial);
485        assert_eq!(out.changed_paths, vec!["tracked.txt"]);
486    }
487
488    #[test]
489    fn v1_hook_token_uses_injected_boundary() {
490        assert_eq!(v1_hook_token(FsmonitorQueryBoundary(99_001)), "99001");
491    }
492
493    #[test]
494    fn windows_pipe_name_local_drive_matches_git() {
495        assert_eq!(
496            git_style_windows_pipe_name_from_absolute_ipc(
497                "C:/Users/foo/repo/.git/fsmonitor--daemon.ipc"
498            ),
499            r"\\.\pipe\C_\Users\foo\repo\.git\fsmonitor--daemon.ipc"
500        );
501    }
502
503    #[test]
504    fn windows_pipe_name_unc_skips_leading_slash() {
505        assert_eq!(
506            git_style_windows_pipe_name_from_absolute_ipc(
507                "//server/share/repo/.git/fsmonitor--daemon.ipc"
508            ),
509            r"\\.\pipe\server\share\repo\.git\fsmonitor--daemon.ipc"
510        );
511    }
512
513    #[test]
514    fn parse_v1_trivial_response() {
515        let raw = b"/\0";
516        let out = parse_v1_hook_response(raw, v1_hook_token(FsmonitorQueryBoundary(456)));
517        assert!(out.trivial);
518        assert!(out.changed_paths.is_empty());
519    }
520}