codex_git_utils/fsmonitor.rs
1//! Policy for preserving Git's built-in filesystem monitor.
2//!
3//! Codex overrides `core.fsmonitor` so repository configuration cannot select
4//! an executable helper. Preserve the built-in daemon only when the effective
5//! value is boolean true and Git advertises daemon support.
6//!
7//! The daemon avoids scanning every tracked file and untracked directory:
8//! https://github.com/git/git/blob/94f057755b7941b321fd11fec1b2e3ca5313a4e0/Documentation/git-fsmonitor--daemon.adoc#L49-L57
9//! https://github.com/git/git/blob/94f057755b7941b321fd11fec1b2e3ca5313a4e0/Documentation/git-update-index.adoc#L545-L550
10
11use std::future::Future;
12
13/// The safe `core.fsmonitor` override for an internal Git command.
14#[derive(Clone, Copy, Debug, Eq, PartialEq)]
15pub enum FsmonitorOverride {
16 /// Disable repository-selected filesystem monitor helpers.
17 Disabled,
18 /// Preserve Git's built-in filesystem monitor daemon.
19 BuiltIn,
20}
21
22impl FsmonitorOverride {
23 /// Returns the complete Git configuration override.
24 pub const fn git_config_arg(self) -> &'static str {
25 match self {
26 Self::Disabled => "core.fsmonitor=false",
27 Self::BuiltIn => "core.fsmonitor=true",
28 }
29 }
30}
31
32/// Executes the Git commands required by [`detect_fsmonitor_override`].
33///
34/// Implementations must return stdout only when Git exits successfully.
35/// Timeouts, spawn or transport failures, signal termination, and nonzero exit
36/// statuses must return `None`.
37pub trait FsmonitorProbeRunner: Send {
38 /// Runs one bounded probe in the target repository.
39 fn run_probe(&mut self, args: &[&str]) -> impl Future<Output = Option<Vec<u8>>> + Send;
40}
41
42/// Returns the safe filesystem monitor override for the target repository.
43///
44/// This intentionally probes every time. Effective Git configuration is
45/// layered, may use conditional includes, and can change while Codex is
46/// running:
47/// https://git-scm.com/docs/git-config#SCOPES
48/// https://git-scm.com/docs/git-config#_conditional_includes
49pub async fn detect_fsmonitor_override(
50 runner: &mut impl FsmonitorProbeRunner,
51) -> FsmonitorOverride {
52 // A typed query converts every matching value before `--get` selects the
53 // effective one. A shadowed helper path can therefore make a repository-
54 // local true fail conversion. Query the raw effective value first.
55 // https://github.com/git/git/blob/94f057755b7941b321fd11fec1b2e3ca5313a4e0/builtin/config.c#L482-L514
56 // https://github.com/git/git/blob/94f057755b7941b321fd11fec1b2e3ca5313a4e0/builtin/config.c#L611-L614
57 let Some(config) = runner
58 .run_probe(&["config", "--null", "--get", "core.fsmonitor"])
59 .await
60 else {
61 return FsmonitorOverride::Disabled;
62 };
63 let Some(config) = config.strip_suffix(b"\0") else {
64 return FsmonitorOverride::Disabled;
65 };
66 if config.contains(&0) {
67 return FsmonitorOverride::Disabled;
68 }
69 let Ok(config) = str::from_utf8(config) else {
70 return FsmonitorOverride::Disabled;
71 };
72
73 // Git accepts these case-insensitive spellings directly, as well as
74 // valueless keys and nonzero integers. Ask Git to normalize uncommon
75 // spellings, filtering by the raw effective value before conversion so a
76 // shadowed helper pathname cannot make the query fail.
77 // https://github.com/git/git/blob/94f057755b7941b321fd11fec1b2e3ca5313a4e0/parse.c#L158-L181
78 // https://github.com/git/git/blob/94f057755b7941b321fd11fec1b2e3ca5313a4e0/builtin/config.c#L264-L279
79 // https://github.com/git/git/blob/94f057755b7941b321fd11fec1b2e3ca5313a4e0/builtin/config.c#L496-L507
80 let configured = if ["true", "yes", "on"]
81 .iter()
82 .any(|value| config.eq_ignore_ascii_case(value))
83 {
84 true
85 } else if ["false", "no", "off"]
86 .iter()
87 .any(|value| config.eq_ignore_ascii_case(value))
88 {
89 false
90 } else {
91 let typed_args = [
92 "config",
93 "--null",
94 "--type=bool",
95 "--fixed-value",
96 "--get",
97 "core.fsmonitor",
98 config,
99 ];
100 matches!(
101 runner.run_probe(&typed_args).await.as_deref(),
102 Some(b"true\0")
103 )
104 };
105 if !configured {
106 return FsmonitorOverride::Disabled;
107 }
108
109 // Git 2.35.1 and older interpret "true" as a hook pathname. Before Git
110 // 2.26, a successful empty hook response can hide tracked changes. Require
111 // the feature line Git added specifically for capability checks.
112 // https://github.com/git/git/blob/94f057755b7941b321fd11fec1b2e3ca5313a4e0/Documentation/config/core.adoc#L90-L99
113 // https://github.com/git/git/commit/dd77cf61a1a2fbf52c94d0cd986d555ad2ba8a4b
114 let Some(build_options) = runner.run_probe(&["version", "--build-options"]).await else {
115 return FsmonitorOverride::Disabled;
116 };
117 if build_options
118 .split(|byte| *byte == b'\n')
119 .any(|line| line.trim_ascii() == b"feature: fsmonitor--daemon")
120 {
121 FsmonitorOverride::BuiltIn
122 } else {
123 FsmonitorOverride::Disabled
124 }
125}
126
127#[cfg(test)]
128#[path = "fsmonitor_tests.rs"]
129mod tests;