Skip to main content

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;