1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub struct FsmonitorQueryBoundary(pub u64);
27
28#[derive(Debug, Clone, Copy)]
30pub struct FsmonitorRefreshOptions {
31 pub query_boundary: FsmonitorQueryBoundary,
33}
34
35impl FsmonitorRefreshOptions {
36 #[must_use]
38 pub fn with_query_boundary(boundary: FsmonitorQueryBoundary) -> Self {
39 Self {
40 query_boundary: boundary,
41 }
42 }
43}
44
45#[must_use]
47pub fn v1_hook_token(boundary: FsmonitorQueryBoundary) -> String {
48 format!("{}", boundary.0)
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
53enum FsmonitorMode {
54 Disabled,
55 Hook(PathBuf),
56 Ipc,
57}
58
59#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub struct FsmonitorRefreshResult {
96 pub index_changed: bool,
98 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
109pub 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 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
262fn 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#[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#[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}