mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
Use root repo hooks in linked worktrees (#21969)
# Why Linked worktrees currently load their own project hook declarations, so the same repo can present different hook definitions depending on which checkout is active. https://github.com/openai/codex/pull/21762 tried to share trust by giving matching worktree hooks a shared synthetic key, but review pointed out that divergent worktree hook definitions would then fight over one `trusted_hash`. Instead of introducing a second trust model, this makes linked worktrees use the root checkout as the single source of truth for project hook declarations. Worktree-local project config can still diverge for unrelated settings, but project hooks now keep one real source path and one trust state per repo. # What - Teach project config loading to remember the matching root-checkout `.codex/` folder for actual linked-worktree project layers. - Keep ordinary project config sourced from the worktree, but replace project hook declarations with the root checkout's matching layer before hook discovery runs, including linked-worktree layers with `.codex/` but no local `config.toml`. - Make hook discovery use that authoritative hook folder for both `hooks.json` and TOML hook source paths, so linked worktrees produce the same hook key and trust state as the root checkout. - Cover the linked-worktree path plus regressions for missing worktree `config.toml` and nested non-worktree project roots.
This commit is contained in:
committed by
GitHub
Unverified
parent
2304ec45ca
commit
934a40c7d9
@@ -1546,6 +1546,8 @@ To enable or disable a skill by name:
|
||||
|
||||
Use `hooks/list` to fetch discovered hooks for one or more `cwds`. Each result is evaluated with that `cwd`'s effective config, so feature gates and discovered config layers can differ within a single response.
|
||||
|
||||
For linked Git worktrees, project hook declarations come from the matching `.codex/` folders in the root checkout rather than from divergent hook declarations stored only in the linked worktree. This keeps each repo on one authoritative project-hook definition and one trust state.
|
||||
|
||||
Hooks are returned even when disabled so clients can render and re-enable them. User-controlled state lives under `hooks.state`. Managed hooks are non-configurable, and user entries for managed hook keys are ignored during loading.
|
||||
|
||||
For unmanaged hooks, `currentHash` and `trustStatus` describe whether the current definition is first-seen, approved, or changed since approval. Only trusted unmanaged hooks become runnable. Hook keys combine the source identity with a trailing event/group/handler selector that is currently positional.
|
||||
|
||||
@@ -107,6 +107,29 @@ enabled = true
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_project_hook_config(dot_codex_folder: &std::path::Path, command: &str) -> Result<()> {
|
||||
std::fs::create_dir_all(dot_codex_folder)?;
|
||||
std::fs::write(
|
||||
dot_codex_folder.join("config.toml"),
|
||||
format!(
|
||||
r#"[features]
|
||||
hooks = true
|
||||
|
||||
[hooks]
|
||||
|
||||
[[hooks.PreToolUse]]
|
||||
matcher = "Bash"
|
||||
|
||||
[[hooks.PreToolUse.hooks]]
|
||||
type = "command"
|
||||
command = "{command}"
|
||||
timeout = 5
|
||||
"#
|
||||
),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hooks_list_shows_discovered_hook() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
@@ -368,6 +391,88 @@ timeout = 5
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hooks_list_uses_root_repo_hooks_for_linked_worktrees() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let workspace = TempDir::new()?;
|
||||
let repo_root = workspace.path().join("repo");
|
||||
let worktree_root = workspace.path().join("worktree");
|
||||
let worktree_git_dir = repo_root.join(".git/worktrees/feature-x");
|
||||
|
||||
std::fs::create_dir_all(&worktree_git_dir)?;
|
||||
std::fs::create_dir_all(&worktree_root)?;
|
||||
std::fs::write(
|
||||
worktree_root.join(".git"),
|
||||
format!("gitdir: {}\n", worktree_git_dir.display()),
|
||||
)?;
|
||||
write_project_hook_config(&repo_root.join(".codex"), "echo root hook")?;
|
||||
write_project_hook_config(&worktree_root.join(".codex"), "echo worktree hook")?;
|
||||
set_project_trust_level(codex_home.path(), &repo_root, TrustLevel::Trusted)?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let list_id = mcp
|
||||
.send_hooks_list_request(HooksListParams {
|
||||
cwds: vec![repo_root.clone(), worktree_root.clone()],
|
||||
})
|
||||
.await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(list_id)),
|
||||
)
|
||||
.await??;
|
||||
let HooksListResponse { data } = to_response(response)?;
|
||||
let repo_hook = data[0].hooks[0].clone();
|
||||
let worktree_hook = data[1].hooks[0].clone();
|
||||
let repo_config_path =
|
||||
AbsolutePathBuf::from_absolute_path(repo_root.join(".codex/config.toml"))?;
|
||||
|
||||
assert_eq!(repo_hook.command.as_deref(), Some("echo root hook"));
|
||||
assert_eq!(worktree_hook.command.as_deref(), Some("echo root hook"));
|
||||
assert_eq!(repo_hook.key, worktree_hook.key);
|
||||
assert_eq!(repo_hook.source_path, repo_config_path);
|
||||
assert_eq!(worktree_hook.source_path, repo_config_path);
|
||||
|
||||
let write_id = mcp
|
||||
.send_config_batch_write_request(ConfigBatchWriteParams {
|
||||
edits: vec![ConfigEdit {
|
||||
key_path: "hooks.state".to_string(),
|
||||
value: serde_json::json!({
|
||||
repo_hook.key.clone(): {
|
||||
"trusted_hash": repo_hook.current_hash.clone()
|
||||
}
|
||||
}),
|
||||
merge_strategy: MergeStrategy::Upsert,
|
||||
}],
|
||||
file_path: None,
|
||||
expected_version: None,
|
||||
reload_user_config: true,
|
||||
})
|
||||
.await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(write_id)),
|
||||
)
|
||||
.await??;
|
||||
let _: codex_app_server_protocol::ConfigWriteResponse = to_response(response)?;
|
||||
|
||||
let list_id = mcp
|
||||
.send_hooks_list_request(HooksListParams {
|
||||
cwds: vec![worktree_root],
|
||||
})
|
||||
.await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(list_id)),
|
||||
)
|
||||
.await??;
|
||||
let HooksListResponse { data } = to_response(response)?;
|
||||
assert_eq!(data[0].hooks[0].trust_status, HookTrustStatus::Trusted);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn config_batch_write_toggles_user_hook() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
|
||||
@@ -627,6 +627,8 @@ struct ProjectTrustContext {
|
||||
project_root: AbsolutePathBuf,
|
||||
project_root_key: String,
|
||||
project_root_lookup_keys: Vec<String>,
|
||||
checkout_root: Option<AbsolutePathBuf>,
|
||||
repo_root: Option<AbsolutePathBuf>,
|
||||
repo_root_key: Option<String>,
|
||||
repo_root_lookup_keys: Option<Vec<String>>,
|
||||
projects_trust: std::collections::HashMap<String, TrustLevel>,
|
||||
@@ -712,22 +714,36 @@ impl ProjectTrustContext {
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn root_checkout_hooks_folder_for_dir(&self, dir: &AbsolutePathBuf) -> Option<AbsolutePathBuf> {
|
||||
let checkout_root = self.checkout_root.as_ref()?;
|
||||
let repo_root = self.repo_root.as_ref()?;
|
||||
// Regular checkouts resolve both paths to the same root; linked worktrees do not.
|
||||
if checkout_root == repo_root {
|
||||
return None;
|
||||
}
|
||||
|
||||
let relative_dir = dir.as_path().strip_prefix(checkout_root.as_path()).ok()?;
|
||||
Some(repo_root.join(relative_dir).join(".codex"))
|
||||
}
|
||||
}
|
||||
|
||||
fn project_layer_entry(
|
||||
dot_codex_folder: &AbsolutePathBuf,
|
||||
config: TomlValue,
|
||||
disabled_reason: Option<String>,
|
||||
hooks_config_folder_override: Option<AbsolutePathBuf>,
|
||||
) -> ConfigLayerEntry {
|
||||
let source = ConfigLayerSource::Project {
|
||||
dot_codex_folder: dot_codex_folder.clone(),
|
||||
};
|
||||
|
||||
if let Some(reason) = disabled_reason {
|
||||
let entry = if let Some(reason) = disabled_reason {
|
||||
ConfigLayerEntry::new_disabled(source, config, reason)
|
||||
} else {
|
||||
ConfigLayerEntry::new(source, config)
|
||||
}
|
||||
};
|
||||
entry.with_hooks_config_folder_override(hooks_config_folder_override)
|
||||
}
|
||||
|
||||
fn sanitize_project_config(config: &mut TomlValue) -> Vec<String> {
|
||||
@@ -786,6 +802,7 @@ async fn project_trust_context(
|
||||
.first()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| project_trust_key(project_root.as_path()));
|
||||
let checkout_root = find_git_checkout_root(fs, cwd).await;
|
||||
let repo_root = resolve_root_git_project_for_trust(fs, cwd).await;
|
||||
let repo_root_lookup_keys = repo_root
|
||||
.as_ref()
|
||||
@@ -803,6 +820,8 @@ async fn project_trust_context(
|
||||
project_root,
|
||||
project_root_key,
|
||||
project_root_lookup_keys,
|
||||
checkout_root,
|
||||
repo_root,
|
||||
repo_root_key,
|
||||
repo_root_lookup_keys,
|
||||
projects_trust,
|
||||
@@ -944,6 +963,24 @@ async fn find_project_root(
|
||||
Ok(cwd.clone())
|
||||
}
|
||||
|
||||
async fn find_git_checkout_root(
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
cwd: &AbsolutePathBuf,
|
||||
) -> Option<AbsolutePathBuf> {
|
||||
let base = match fs.get_metadata(cwd, /*sandbox*/ None).await {
|
||||
Ok(metadata) if metadata.is_directory => cwd.clone(),
|
||||
_ => cwd.parent()?,
|
||||
};
|
||||
|
||||
for dir in base.ancestors() {
|
||||
let dot_git = dir.join(".git");
|
||||
if fs.get_metadata(&dot_git, /*sandbox*/ None).await.is_ok() {
|
||||
return Some(dir);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
struct LoadedProjectLayers {
|
||||
layers: Vec<ConfigLayerEntry>,
|
||||
startup_warnings: Vec<String>,
|
||||
@@ -995,6 +1032,7 @@ async fn load_project_layers(
|
||||
|
||||
let decision = trust_context.decision_for_dir(&dir);
|
||||
let disabled_reason = trust_context.disabled_reason_for_decision(&decision);
|
||||
let hooks_config_folder_override = trust_context.root_checkout_hooks_folder_for_dir(&dir);
|
||||
let dot_codex_normalized =
|
||||
normalize_path(dot_codex_abs.as_path()).unwrap_or_else(|_| dot_codex_abs.to_path_buf());
|
||||
if dot_codex_abs == codex_home_abs || dot_codex_normalized == codex_home_normalized {
|
||||
@@ -1019,6 +1057,7 @@ async fn load_project_layers(
|
||||
&dot_codex_abs,
|
||||
TomlValue::Table(toml::map::Map::new()),
|
||||
disabled_reason.clone(),
|
||||
hooks_config_folder_override.clone(),
|
||||
));
|
||||
continue;
|
||||
}
|
||||
@@ -1027,13 +1066,25 @@ async fn load_project_layers(
|
||||
let ignored_project_config_keys = sanitize_project_config(&mut config);
|
||||
let config =
|
||||
resolve_relative_paths_in_config_toml(config, dot_codex_abs.as_path())?;
|
||||
let config = merge_root_checkout_project_hooks(
|
||||
fs,
|
||||
config,
|
||||
hooks_config_folder_override.as_ref(),
|
||||
decision.is_trusted(),
|
||||
)
|
||||
.await?;
|
||||
if disabled_reason.is_none() && !ignored_project_config_keys.is_empty() {
|
||||
startup_warnings.push(project_ignored_config_keys_warning(
|
||||
&dot_codex_abs,
|
||||
&ignored_project_config_keys,
|
||||
));
|
||||
}
|
||||
let entry = project_layer_entry(&dot_codex_abs, config, disabled_reason.clone());
|
||||
let entry = project_layer_entry(
|
||||
&dot_codex_abs,
|
||||
config,
|
||||
disabled_reason.clone(),
|
||||
hooks_config_folder_override.clone(),
|
||||
);
|
||||
layers.push(entry);
|
||||
}
|
||||
Err(err) => {
|
||||
@@ -1041,10 +1092,18 @@ async fn load_project_layers(
|
||||
// If there is no config.toml file, record an empty entry
|
||||
// for this project layer, as this may still have subfolders
|
||||
// that are significant in the overall ConfigLayerStack.
|
||||
let config = merge_root_checkout_project_hooks(
|
||||
fs,
|
||||
TomlValue::Table(toml::map::Map::new()),
|
||||
hooks_config_folder_override.as_ref(),
|
||||
decision.is_trusted(),
|
||||
)
|
||||
.await?;
|
||||
layers.push(project_layer_entry(
|
||||
&dot_codex_abs,
|
||||
TomlValue::Table(toml::map::Map::new()),
|
||||
config,
|
||||
disabled_reason,
|
||||
hooks_config_folder_override,
|
||||
));
|
||||
} else {
|
||||
let config_file_display = config_file.as_path().display();
|
||||
@@ -1062,6 +1121,64 @@ async fn load_project_layers(
|
||||
startup_warnings,
|
||||
})
|
||||
}
|
||||
|
||||
/// For linked worktrees, preserve ordinary worktree-local project config while
|
||||
/// replacing only hook declarations with the matching root-checkout layer.
|
||||
async fn merge_root_checkout_project_hooks(
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
mut config: TomlValue,
|
||||
hooks_config_folder_override: Option<&AbsolutePathBuf>,
|
||||
is_trusted: bool,
|
||||
) -> io::Result<TomlValue> {
|
||||
let Some(hooks_config_folder) = hooks_config_folder_override else {
|
||||
return Ok(config);
|
||||
};
|
||||
let hooks_config_file = hooks_config_folder.join(CONFIG_TOML_FILE);
|
||||
let root_config = match fs
|
||||
.read_file_text(&hooks_config_file, /*sandbox*/ None)
|
||||
.await
|
||||
{
|
||||
Ok(contents) => {
|
||||
let parsed: TomlValue = match toml::from_str(&contents) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(err) => {
|
||||
if is_trusted {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!(
|
||||
"Error parsing project hooks config file {}: {err}",
|
||||
hooks_config_file.as_path().display()
|
||||
),
|
||||
));
|
||||
}
|
||||
TomlValue::Table(toml::map::Map::new())
|
||||
}
|
||||
};
|
||||
resolve_relative_paths_in_config_toml(parsed, hooks_config_folder.as_path())?
|
||||
}
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => {
|
||||
TomlValue::Table(toml::map::Map::new())
|
||||
}
|
||||
Err(err) => {
|
||||
return Err(io::Error::new(
|
||||
err.kind(),
|
||||
format!(
|
||||
"Failed to read project hooks config file {}: {err}",
|
||||
hooks_config_file.as_path().display()
|
||||
),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let Some(config_table) = config.as_table_mut() else {
|
||||
return Ok(config);
|
||||
};
|
||||
config_table.remove("hooks");
|
||||
if let Some(hooks) = root_config.get("hooks") {
|
||||
config_table.insert("hooks".to_string(), hooks.clone());
|
||||
}
|
||||
Ok(config)
|
||||
}
|
||||
/// The legacy mechanism for specifying admin-enforced configuration is to read
|
||||
/// from a file like `/etc/codex/managed_config.toml` that has the same
|
||||
/// structure as `config.toml` where fields like `approval_policy` can specify
|
||||
|
||||
@@ -66,6 +66,7 @@ pub struct ConfigLayerEntry {
|
||||
pub raw_toml: Option<String>,
|
||||
pub version: String,
|
||||
pub disabled_reason: Option<String>,
|
||||
hooks_config_folder_override: Option<AbsolutePathBuf>,
|
||||
}
|
||||
|
||||
impl ConfigLayerEntry {
|
||||
@@ -77,6 +78,7 @@ impl ConfigLayerEntry {
|
||||
raw_toml: None,
|
||||
version,
|
||||
disabled_reason: None,
|
||||
hooks_config_folder_override: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +90,7 @@ impl ConfigLayerEntry {
|
||||
raw_toml: Some(raw_toml),
|
||||
version,
|
||||
disabled_reason: None,
|
||||
hooks_config_folder_override: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +106,7 @@ impl ConfigLayerEntry {
|
||||
raw_toml: None,
|
||||
version,
|
||||
disabled_reason: Some(disabled_reason.into()),
|
||||
hooks_config_folder_override: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +118,14 @@ impl ConfigLayerEntry {
|
||||
self.raw_toml.as_deref()
|
||||
}
|
||||
|
||||
pub(crate) fn with_hooks_config_folder_override(
|
||||
mut self,
|
||||
hooks_config_folder_override: Option<AbsolutePathBuf>,
|
||||
) -> Self {
|
||||
self.hooks_config_folder_override = hooks_config_folder_override;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn metadata(&self) -> ConfigLayerMetadata {
|
||||
ConfigLayerMetadata {
|
||||
name: self.name.clone(),
|
||||
@@ -142,6 +154,18 @@ impl ConfigLayerEntry {
|
||||
ConfigLayerSource::LegacyManagedConfigTomlFromMdm => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the `.codex/` folder that should be used for hook declarations.
|
||||
///
|
||||
/// Project layers normally use their own config folder. Linked Git worktrees
|
||||
/// can instead point hook discovery at the matching folder from the root
|
||||
/// checkout while the rest of the project config still comes from the
|
||||
/// worktree.
|
||||
pub fn hooks_config_folder(&self) -> Option<AbsolutePathBuf> {
|
||||
self.hooks_config_folder_override
|
||||
.clone()
|
||||
.or_else(|| self.config_folder())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
|
||||
@@ -23,7 +23,6 @@ use codex_config::config_toml::ConfigToml;
|
||||
use codex_config::config_toml::ProjectConfig;
|
||||
use codex_config::loader::load_config_layers_state;
|
||||
use codex_config::loader::load_requirements_toml;
|
||||
use codex_config::version_for_toml;
|
||||
use codex_exec_server::LOCAL_FS;
|
||||
use codex_protocol::config_types::TrustLevel;
|
||||
use codex_protocol::config_types::WebSearchMode;
|
||||
@@ -68,6 +67,45 @@ async fn make_config_for_test(
|
||||
.await
|
||||
}
|
||||
|
||||
async fn write_linked_worktree_pointer(
|
||||
repo_root: &Path,
|
||||
worktree_root: &Path,
|
||||
) -> std::io::Result<()> {
|
||||
let worktree_git_dir = repo_root.join(".git/worktrees/feature-x");
|
||||
tokio::fs::create_dir_all(&worktree_git_dir).await?;
|
||||
tokio::fs::write(
|
||||
worktree_root.join(".git"),
|
||||
format!("gitdir: {}\n", worktree_git_dir.display()),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn write_project_hook_config(
|
||||
dot_codex_folder: &Path,
|
||||
foo: Option<&str>,
|
||||
command: &str,
|
||||
) -> std::io::Result<()> {
|
||||
tokio::fs::create_dir_all(dot_codex_folder).await?;
|
||||
let foo = foo
|
||||
.map(|value| format!("foo = \"{value}\"\n\n"))
|
||||
.unwrap_or_default();
|
||||
tokio::fs::write(
|
||||
dot_codex_folder.join(CONFIG_TOML_FILE),
|
||||
format!(
|
||||
r#"{foo}[hooks]
|
||||
|
||||
[[hooks.PreToolUse]]
|
||||
matcher = "Bash"
|
||||
|
||||
[[hooks.PreToolUse.hooks]]
|
||||
type = "command"
|
||||
command = "{command}"
|
||||
"#
|
||||
),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cli_overrides_resolve_relative_paths_against_cwd() -> std::io::Result<()> {
|
||||
let codex_home = tempdir().expect("tempdir");
|
||||
@@ -391,18 +429,13 @@ async fn returns_empty_when_all_layers_missing() {
|
||||
let user_layer = layers
|
||||
.get_user_layer()
|
||||
.expect("expected a user layer even when CODEX_HOME/config.toml does not exist");
|
||||
assert_eq!(
|
||||
&ConfigLayerEntry {
|
||||
name: ConfigLayerSource::User {
|
||||
file: AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, tmp.path())
|
||||
},
|
||||
config: TomlValue::Table(toml::map::Map::new()),
|
||||
raw_toml: None,
|
||||
version: version_for_toml(&TomlValue::Table(toml::map::Map::new())),
|
||||
disabled_reason: None,
|
||||
let expected_user_layer = ConfigLayerEntry::new(
|
||||
ConfigLayerSource::User {
|
||||
file: AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, tmp.path()),
|
||||
},
|
||||
user_layer,
|
||||
TomlValue::Table(toml::map::Map::new()),
|
||||
);
|
||||
assert_eq!(&expected_user_layer, user_layer);
|
||||
assert_eq!(
|
||||
user_layer.config,
|
||||
TomlValue::Table(toml::map::Map::new()),
|
||||
@@ -1412,6 +1445,260 @@ async fn project_layers_prefer_closest_cwd() -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn linked_worktree_project_layers_keep_worktree_config_but_use_root_repo_hooks()
|
||||
-> std::io::Result<()> {
|
||||
let tmp = tempdir()?;
|
||||
let repo_root = tmp.path().join("repo");
|
||||
let repo_child = repo_root.join("child");
|
||||
let worktree_root = tmp.path().join("worktree");
|
||||
let worktree_child = worktree_root.join("child");
|
||||
|
||||
tokio::fs::create_dir_all(worktree_root.join(".codex")).await?;
|
||||
tokio::fs::create_dir_all(worktree_child.join(".codex")).await?;
|
||||
write_linked_worktree_pointer(&repo_root, &worktree_root).await?;
|
||||
write_project_hook_config(
|
||||
&repo_root.join(".codex"),
|
||||
Some("repo-root"),
|
||||
"echo repo root hook",
|
||||
)
|
||||
.await?;
|
||||
write_project_hook_config(
|
||||
&repo_child.join(".codex"),
|
||||
Some("repo-child"),
|
||||
"echo repo child hook",
|
||||
)
|
||||
.await?;
|
||||
write_project_hook_config(
|
||||
&worktree_root.join(".codex"),
|
||||
Some("worktree-root"),
|
||||
"echo worktree root hook",
|
||||
)
|
||||
.await?;
|
||||
write_project_hook_config(
|
||||
&worktree_child.join(".codex"),
|
||||
Some("worktree-child"),
|
||||
"echo worktree child hook",
|
||||
)
|
||||
.await?;
|
||||
|
||||
let codex_home = tmp.path().join("home");
|
||||
tokio::fs::create_dir_all(&codex_home).await?;
|
||||
make_config_for_test(
|
||||
&codex_home,
|
||||
&repo_root,
|
||||
TrustLevel::Trusted,
|
||||
/*project_root_markers*/ None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let cwd = AbsolutePathBuf::from_absolute_path(&worktree_child)?;
|
||||
let layers = load_config_layers_state(
|
||||
LOCAL_FS.as_ref(),
|
||||
&codex_home,
|
||||
Some(cwd),
|
||||
&[] as &[(String, TomlValue)],
|
||||
LoaderOverrides::default(),
|
||||
CloudRequirementsLoader::default(),
|
||||
&codex_config::NoopThreadConfigLoader,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let project_layers: Vec<_> = layers
|
||||
.layers_high_to_low()
|
||||
.into_iter()
|
||||
.filter(|layer| matches!(layer.name, ConfigLayerSource::Project { .. }))
|
||||
.collect();
|
||||
assert_eq!(project_layers.len(), 2);
|
||||
assert_eq!(
|
||||
project_layers[0].hooks_config_folder(),
|
||||
Some(AbsolutePathBuf::from_absolute_path(
|
||||
repo_child.join(".codex")
|
||||
)?)
|
||||
);
|
||||
assert_eq!(
|
||||
project_layers[1].hooks_config_folder(),
|
||||
Some(AbsolutePathBuf::from_absolute_path(
|
||||
repo_root.join(".codex")
|
||||
)?)
|
||||
);
|
||||
assert_eq!(
|
||||
project_layers[0]
|
||||
.config
|
||||
.get("foo")
|
||||
.and_then(TomlValue::as_str),
|
||||
Some("worktree-child")
|
||||
);
|
||||
assert_eq!(
|
||||
project_hook_command(project_layers[0]),
|
||||
Some("echo repo child hook")
|
||||
);
|
||||
assert_eq!(
|
||||
project_layers[1]
|
||||
.config
|
||||
.get("foo")
|
||||
.and_then(TomlValue::as_str),
|
||||
Some("worktree-root")
|
||||
);
|
||||
assert_eq!(
|
||||
project_hook_command(project_layers[1]),
|
||||
Some("echo repo root hook")
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn linked_worktree_project_layers_use_root_repo_hooks_without_worktree_config_toml()
|
||||
-> std::io::Result<()> {
|
||||
let tmp = tempdir()?;
|
||||
let repo_root = tmp.path().join("repo");
|
||||
let worktree_root = tmp.path().join("worktree");
|
||||
|
||||
tokio::fs::create_dir_all(worktree_root.join(".codex")).await?;
|
||||
write_linked_worktree_pointer(&repo_root, &worktree_root).await?;
|
||||
write_project_hook_config(
|
||||
&repo_root.join(".codex"),
|
||||
/*foo*/ None,
|
||||
"echo repo root hook",
|
||||
)
|
||||
.await?;
|
||||
|
||||
let codex_home = tmp.path().join("home");
|
||||
tokio::fs::create_dir_all(&codex_home).await?;
|
||||
make_config_for_test(
|
||||
&codex_home,
|
||||
&repo_root,
|
||||
TrustLevel::Trusted,
|
||||
/*project_root_markers*/ None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let cwd = AbsolutePathBuf::from_absolute_path(&worktree_root)?;
|
||||
let layers = load_config_layers_state(
|
||||
LOCAL_FS.as_ref(),
|
||||
&codex_home,
|
||||
Some(cwd),
|
||||
&[] as &[(String, TomlValue)],
|
||||
LoaderOverrides::default(),
|
||||
CloudRequirementsLoader::default(),
|
||||
&codex_config::NoopThreadConfigLoader,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let project_layers: Vec<_> = layers
|
||||
.layers_high_to_low()
|
||||
.into_iter()
|
||||
.filter(|layer| matches!(layer.name, ConfigLayerSource::Project { .. }))
|
||||
.collect();
|
||||
assert_eq!(project_layers.len(), 1);
|
||||
assert_eq!(
|
||||
project_layers[0].hooks_config_folder(),
|
||||
Some(AbsolutePathBuf::from_absolute_path(
|
||||
repo_root.join(".codex")
|
||||
)?)
|
||||
);
|
||||
assert_eq!(
|
||||
project_hook_command(project_layers[0]),
|
||||
Some("echo repo root hook")
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn nested_project_root_markers_do_not_redirect_regular_repo_hooks() -> std::io::Result<()> {
|
||||
let tmp = tempdir()?;
|
||||
let repo_root = tmp.path().join("repo");
|
||||
let project_root = repo_root.join("project");
|
||||
let nested = project_root.join("child");
|
||||
|
||||
tokio::fs::create_dir_all(repo_root.join(".git")).await?;
|
||||
tokio::fs::create_dir_all(&project_root).await?;
|
||||
tokio::fs::write(project_root.join(".hg"), "hg").await?;
|
||||
write_project_hook_config(
|
||||
&repo_root.join(".codex"),
|
||||
/*foo*/ None,
|
||||
"echo repo root hook",
|
||||
)
|
||||
.await?;
|
||||
write_project_hook_config(
|
||||
&project_root.join(".codex"),
|
||||
/*foo*/ None,
|
||||
"echo project root hook",
|
||||
)
|
||||
.await?;
|
||||
write_project_hook_config(
|
||||
&nested.join(".codex"),
|
||||
/*foo*/ None,
|
||||
"echo nested hook",
|
||||
)
|
||||
.await?;
|
||||
|
||||
let codex_home = tmp.path().join("home");
|
||||
tokio::fs::create_dir_all(&codex_home).await?;
|
||||
make_config_for_test(
|
||||
&codex_home,
|
||||
&project_root,
|
||||
TrustLevel::Trusted,
|
||||
Some(vec![".hg".to_string()]),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let cwd = AbsolutePathBuf::from_absolute_path(&nested)?;
|
||||
let layers = load_config_layers_state(
|
||||
LOCAL_FS.as_ref(),
|
||||
&codex_home,
|
||||
Some(cwd),
|
||||
&[] as &[(String, TomlValue)],
|
||||
LoaderOverrides::default(),
|
||||
CloudRequirementsLoader::default(),
|
||||
&codex_config::NoopThreadConfigLoader,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let project_layers: Vec<_> = layers
|
||||
.layers_high_to_low()
|
||||
.into_iter()
|
||||
.filter(|layer| matches!(layer.name, ConfigLayerSource::Project { .. }))
|
||||
.collect();
|
||||
assert_eq!(project_layers.len(), 2);
|
||||
assert_eq!(
|
||||
project_layers[0].hooks_config_folder(),
|
||||
Some(AbsolutePathBuf::from_absolute_path(nested.join(".codex"))?)
|
||||
);
|
||||
assert_eq!(
|
||||
project_layers[1].hooks_config_folder(),
|
||||
Some(AbsolutePathBuf::from_absolute_path(
|
||||
project_root.join(".codex")
|
||||
)?)
|
||||
);
|
||||
assert_eq!(
|
||||
project_hook_command(project_layers[0]),
|
||||
Some("echo nested hook")
|
||||
);
|
||||
assert_eq!(
|
||||
project_hook_command(project_layers[1]),
|
||||
Some("echo project root hook")
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn project_hook_command(layer: &ConfigLayerEntry) -> Option<&str> {
|
||||
layer
|
||||
.config
|
||||
.get("hooks")?
|
||||
.get("PreToolUse")?
|
||||
.as_array()?
|
||||
.first()?
|
||||
.get("hooks")?
|
||||
.as_array()?
|
||||
.first()?
|
||||
.get("command")?
|
||||
.as_str()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn project_paths_resolve_relative_to_dot_codex_and_override_in_order() -> std::io::Result<()>
|
||||
{
|
||||
@@ -1563,18 +1850,13 @@ async fn project_layer_is_added_when_dot_codex_exists_without_config_toml() -> s
|
||||
.into_iter()
|
||||
.filter(|layer| matches!(layer.name, ConfigLayerSource::Project { .. }))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
vec![&ConfigLayerEntry {
|
||||
name: ConfigLayerSource::Project {
|
||||
dot_codex_folder: AbsolutePathBuf::from_absolute_path(project_root.join(".codex"))?,
|
||||
},
|
||||
config: TomlValue::Table(toml::map::Map::new()),
|
||||
raw_toml: None,
|
||||
version: version_for_toml(&TomlValue::Table(toml::map::Map::new())),
|
||||
disabled_reason: None,
|
||||
}],
|
||||
project_layers
|
||||
let expected_project_layer = ConfigLayerEntry::new(
|
||||
ConfigLayerSource::Project {
|
||||
dot_codex_folder: AbsolutePathBuf::from_absolute_path(project_root.join(".codex"))?,
|
||||
},
|
||||
TomlValue::Table(toml::map::Map::new()),
|
||||
);
|
||||
assert_eq!(vec![&expected_project_layer], project_layers);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1667,18 +1949,13 @@ async fn codex_home_within_project_tree_is_not_double_loaded() -> std::io::Resul
|
||||
.collect();
|
||||
|
||||
let child_config: TomlValue = toml::from_str("foo = \"child\"\n").expect("parse child config");
|
||||
assert_eq!(
|
||||
vec![&ConfigLayerEntry {
|
||||
name: ConfigLayerSource::Project {
|
||||
dot_codex_folder: AbsolutePathBuf::from_absolute_path(&nested_dot_codex)?,
|
||||
},
|
||||
config: child_config.clone(),
|
||||
raw_toml: None,
|
||||
version: version_for_toml(&child_config),
|
||||
disabled_reason: None,
|
||||
}],
|
||||
project_layers
|
||||
let expected_project_layer = ConfigLayerEntry::new(
|
||||
ConfigLayerSource::Project {
|
||||
dot_codex_folder: AbsolutePathBuf::from_absolute_path(&nested_dot_codex)?,
|
||||
},
|
||||
child_config,
|
||||
);
|
||||
assert_eq!(vec![&expected_project_layer], project_layers);
|
||||
assert_eq!(
|
||||
layers.effective_config().get("foo"),
|
||||
Some(&TomlValue::String("child".to_string()))
|
||||
|
||||
@@ -106,7 +106,7 @@ pub(crate) fn discover_handlers(
|
||||
if !policy.allows(&policy_source) {
|
||||
continue;
|
||||
}
|
||||
let json_hooks = load_hooks_json(layer.config_folder().as_deref(), &mut warnings);
|
||||
let json_hooks = load_hooks_json(layer.hooks_config_folder().as_deref(), &mut warnings);
|
||||
let toml_hooks = load_toml_hooks_from_layer(layer, &mut warnings);
|
||||
|
||||
if let (Some((json_source_path, json_events)), Some((toml_source_path, toml_events))) =
|
||||
@@ -346,7 +346,10 @@ fn config_toml_source_path(layer: &ConfigLayerEntry) -> AbsolutePathBuf {
|
||||
ConfigLayerSource::System { file }
|
||||
| ConfigLayerSource::User { file }
|
||||
| ConfigLayerSource::LegacyManagedConfigTomlFromFile { file } => file.clone(),
|
||||
ConfigLayerSource::Project { dot_codex_folder } => dot_codex_folder.join(CONFIG_TOML_FILE),
|
||||
ConfigLayerSource::Project { dot_codex_folder } => layer
|
||||
.hooks_config_folder()
|
||||
.unwrap_or_else(|| dot_codex_folder.clone())
|
||||
.join(CONFIG_TOML_FILE),
|
||||
ConfigLayerSource::Mdm { domain, key } => {
|
||||
synthetic_layer_path(&format!("<mdm:{domain}:{key}>/{CONFIG_TOML_FILE}"))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user