1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub struct FsmonitorQueryBoundary(pub u64);
26
27#[derive(Debug, Clone, Copy)]
29pub struct FsmonitorRefreshOptions {
30 pub query_boundary: FsmonitorQueryBoundary,
32}
33
34impl FsmonitorRefreshOptions {
35 #[must_use]
37 pub fn with_query_boundary(boundary: FsmonitorQueryBoundary) -> Self {
38 Self {
39 query_boundary: boundary,
40 }
41 }
42}
43
44#[must_use]
46pub fn v1_hook_token(boundary: FsmonitorQueryBoundary) -> String {
47 format!("{}", boundary.0)
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
52enum FsmonitorMode {
53 Disabled,
54 Hook(PathBuf),
55 Ipc,
56}
57
58#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub struct FsmonitorRefreshResult {
95 pub index_changed: bool,
97 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
108pub 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 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
261fn 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#[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#[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}