From 314937fb1194c0cbc7191d4f2b8a331139d15d72 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Mon, 22 Dec 2025 11:45:45 -0800 Subject: [PATCH] feat: add support for project_root_markers in config.toml (#8359) - allow configuring `project_root_markers` in `config.toml` (user/system/MDM) to control project discovery beyond `.git` - honor the markers after merging pre-project layers; default to `[".git"]` when unset and skip ancestor walk when set to an empty array - document the option and add coverage for alternate markers in config loader tests --- codex-rs/core/src/config/mod.rs | 5 ++ codex-rs/core/src/config_loader/mod.rs | 74 ++++++++++++++++++++++-- codex-rs/core/src/config_loader/tests.rs | 63 ++++++++++++++++++++ docs/config.md | 12 ++++ 4 files changed, 149 insertions(+), 5 deletions(-) diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index f580c66a1d..aaecd5cc20 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -800,6 +800,11 @@ pub struct ConfigToml { #[serde(default)] pub ghost_snapshot: Option, + /// Markers used to detect the project root when searching parent + /// directories for `.codex` folders. Defaults to [".git"] when unset. + #[serde(default)] + pub project_root_markers: Option>, + /// When `true`, checks for Codex updates on startup and surfaces update prompts. /// Set to `false` only if your Codex updates are centrally managed. /// Defaults to `true`. diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs index 6eb818955c..4bc9fb9c56 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/core/src/config_loader/mod.rs @@ -32,6 +32,7 @@ pub use state::LoaderOverrides; /// On Unix systems, load requirements from this file path, if present. const DEFAULT_REQUIREMENTS_TOML_FILE_UNIX: &str = "/etc/codex/requirements.toml"; +const DEFAULT_PROJECT_ROOT_MARKERS: &[&str] = &[".git"]; /// To build up the set of admin-enforced constraints, we build up from multiple /// configuration layers in the following order, but a constraint defined in an @@ -141,7 +142,14 @@ pub async fn load_config_layers_state( } if let Some(cwd) = cwd { - let project_root = find_project_root(&cwd).await?; + let mut merged_so_far = TomlValue::Table(toml::map::Map::new()); + for layer in &layers { + merge_toml_values(&mut merged_so_far, &layer.config); + } + let project_root_markers = project_root_markers_from_config(&merged_so_far)? + .unwrap_or_else(default_project_root_markers); + + let project_root = find_project_root(&cwd, &project_root_markers).await?; let project_layers = load_project_layers(&cwd, &project_root).await?; layers.extend(project_layers); } @@ -260,6 +268,53 @@ async fn load_requirements_from_legacy_scheme( Ok(()) } +/// Reads `project_root_markers` from the [toml::Value] produced by merging +/// `config.toml` from the config layers in the stack preceding +/// [ConfigLayerSource::Project]. +/// +/// Invariants: +/// - If `project_root_markers` is not specified, returns `Ok(None)`. +/// - If `project_root_markers` is specified, returns `Ok(Some(markers))` where +/// `markers` is a `Vec` (including `Ok(Some(Vec::new()))` for an +/// empty array, which indicates that root detection should be disabled). +/// - Returns an error if `project_root_markers` is specified but is not an +/// array of strings. +fn project_root_markers_from_config(config: &TomlValue) -> io::Result>> { + let Some(table) = config.as_table() else { + return Ok(None); + }; + let Some(markers_value) = table.get("project_root_markers") else { + return Ok(None); + }; + let TomlValue::Array(entries) = markers_value else { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "project_root_markers must be an array of strings", + )); + }; + if entries.is_empty() { + return Ok(Some(Vec::new())); + } + let mut markers = Vec::new(); + for entry in entries { + let Some(marker) = entry.as_str() else { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "project_root_markers must be an array of strings", + )); + }; + markers.push(marker.to_string()); + } + Ok(Some(markers)) +} + +fn default_project_root_markers() -> Vec { + DEFAULT_PROJECT_ROOT_MARKERS + .iter() + .map(ToString::to_string) + .collect() +} + /// Takes a `toml::Value` parsed from a config.toml file and walks through it, /// resolving any `AbsolutePathBuf` fields against `base_dir`, returning a new /// `toml::Value` with the same shape but with paths resolved. @@ -320,11 +375,20 @@ fn copy_shape_from_original(original: &TomlValue, resolved: &TomlValue) -> TomlV } } -async fn find_project_root(cwd: &AbsolutePathBuf) -> io::Result { +async fn find_project_root( + cwd: &AbsolutePathBuf, + project_root_markers: &[String], +) -> io::Result { + if project_root_markers.is_empty() { + return Ok(cwd.clone()); + } + for ancestor in cwd.as_path().ancestors() { - let git_dir = ancestor.join(".git"); - if tokio::fs::metadata(&git_dir).await.is_ok() { - return AbsolutePathBuf::from_absolute_path(ancestor); + for marker in project_root_markers { + let marker_path = ancestor.join(marker); + if tokio::fs::metadata(&marker_path).await.is_ok() { + return AbsolutePathBuf::from_absolute_path(ancestor); + } } } Ok(cwd.clone()) diff --git a/codex-rs/core/src/config_loader/tests.rs b/codex-rs/core/src/config_loader/tests.rs index dcfb1c07f3..7160f8c10a 100644 --- a/codex-rs/core/src/config_loader/tests.rs +++ b/codex-rs/core/src/config_loader/tests.rs @@ -355,3 +355,66 @@ async fn project_layer_is_added_when_dot_codex_exists_without_config_toml() -> s Ok(()) } + +#[tokio::test] +async fn project_root_markers_supports_alternate_markers() -> std::io::Result<()> { + let tmp = tempdir()?; + let project_root = tmp.path().join("project"); + let nested = project_root.join("child"); + tokio::fs::create_dir_all(project_root.join(".codex")).await?; + tokio::fs::create_dir_all(nested.join(".codex")).await?; + tokio::fs::write(project_root.join(".hg"), "hg").await?; + tokio::fs::write( + project_root.join(".codex").join(CONFIG_TOML_FILE), + "foo = \"root\"\n", + ) + .await?; + tokio::fs::write( + nested.join(".codex").join(CONFIG_TOML_FILE), + "foo = \"child\"\n", + ) + .await?; + + let codex_home = tmp.path().join("home"); + tokio::fs::create_dir_all(&codex_home).await?; + tokio::fs::write( + codex_home.join(CONFIG_TOML_FILE), + r#" +project_root_markers = [".hg"] +"#, + ) + .await?; + + let cwd = AbsolutePathBuf::from_absolute_path(&nested)?; + let layers = load_config_layers_state( + &codex_home, + Some(cwd), + &[] as &[(String, TomlValue)], + LoaderOverrides::default(), + ) + .await?; + + let project_layers: Vec<_> = layers + .layers_high_to_low() + .into_iter() + .filter_map(|layer| match &layer.name { + super::ConfigLayerSource::Project { dot_codex_folder } => Some(dot_codex_folder), + _ => None, + }) + .collect(); + assert_eq!(project_layers.len(), 2); + assert_eq!(project_layers[0].as_path(), nested.join(".codex").as_path()); + assert_eq!( + project_layers[1].as_path(), + project_root.join(".codex").as_path() + ); + + let merged = layers.effective_config(); + let foo = merged + .get("foo") + .and_then(TomlValue::as_str) + .expect("foo entry"); + assert_eq!(foo, "child"); + + Ok(()) +} diff --git a/docs/config.md b/docs/config.md index f05b3edbe9..5198b22266 100644 --- a/docs/config.md +++ b/docs/config.md @@ -7,6 +7,7 @@ Codex configuration gives you fine-grained control over the model, execution env - [Feature flags](#feature-flags) - [Model selection](#model-selection) - [Execution environment](#execution-environment) +- [Project root detection](#project-root-detection) - [MCP integration](#mcp-integration) - [Observability and telemetry](#observability-and-telemetry) - [Profiles and overrides](#profiles-and-overrides) @@ -415,6 +416,17 @@ set = { PATH = "/usr/bin", MY_FLAG = "1" } Currently, `CODEX_SANDBOX_NETWORK_DISABLED=1` is also added to the environment, assuming network is disabled. This is not configurable. +## Project root detection + +Codex discovers `.codex/` project layers by walking up from the working directory until it hits a project marker. By default it looks for `.git`. You can override the marker list in user/system/MDM config: + +```toml +# $CODEX_HOME/config.toml +project_root_markers = [".git", ".hg", ".sl"] +``` + +Set `project_root_markers = []` to skip searching parent directories and treat the current working directory as the project root. + ## MCP integration ### mcp_servers