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