mirror of
https://github.com/openai/codex.git
synced 2026-11-01 16:17:58 +00: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:
@@ -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()?;
|
||||
|
||||
Reference in New Issue
Block a user