use std::path::{Path, PathBuf};
use trusty_mpm::core::project_aliases::is_main_checkout;
use super::pm_guard::{EDIT_TOOLS, edit_tool_target_path, is_source_code_path};
pub(crate) fn evaluate_main_checkout_write(
tool_name: &str,
tool_input: Option<&serde_json::Value>,
cwd: &Path,
) -> Option<String> {
if !EDIT_TOOLS.contains(&tool_name) {
return None;
}
let target = edit_tool_target_path(tool_input)?;
if !is_source_code_path(target) {
return None;
}
let resolved = resolve_write_target(target, cwd);
is_main_checkout(&resolved).then(|| deny_reason(target))
}
fn resolve_write_target(target: &str, cwd: &Path) -> PathBuf {
let path = Path::new(target);
let absolute = if path.is_absolute() {
path.to_path_buf()
} else {
cwd.join(path)
};
absolute
.parent()
.map_or_else(|| absolute.clone(), Path::to_path_buf)
}
fn deny_reason(target: &str) -> String {
format!(
"Source write denied in a main checkout (ADR-0044): `{target}` is a source file in a \
project's main checkout, which is read-only apart from documents and configuration. \
Other sessions stand in this same directory — the reported failure is branches \
switching under each other and a commit landing on a workstream it did not belong to, \
with no error at any step. Do this work in a worktree instead: if you are a dispatched \
agent, ask the PM to re-dispatch you with `isolation: \"worktree\"`, which gets you \
your own tree; if you are the PM, dispatch the change to an agent rather than writing \
it here. Documents (`.md`), configuration (`.toml`, `.json`, `.yaml`), and everything \
under `.claude/worktrees/**` stay writable and are not affected by this rule."
)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn main_checkout() -> TempDir {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::create_dir(dir.path().join(".git")).expect("mkdir .git");
dir
}
fn write_input(path: &Path) -> serde_json::Value {
serde_json::json!({"file_path": path.to_string_lossy(), "content": "fn main() {}"})
}
#[test]
fn denies_a_source_write_in_a_main_checkout() {
let dir = main_checkout();
for tool in EDIT_TOOLS {
let reason = evaluate_main_checkout_write(
tool,
Some(&write_input(&dir.path().join("src/lib.rs"))),
dir.path(),
)
.unwrap_or_else(|| panic!("{tool} on a source file in a main checkout must be denied"));
assert!(reason.contains("ADR-0044"), "{reason}");
}
}
#[test]
fn allows_documents_and_configuration() {
let dir = main_checkout();
for name in [
"README.md",
"CLAUDE.md",
"Cargo.toml",
".claude/settings.json",
"TASK.md",
"docs/adr/0044-x.md",
"Makefile",
] {
assert_eq!(
evaluate_main_checkout_write(
"Write",
Some(&write_input(&dir.path().join(name))),
dir.path()
),
None,
"{name} is a document or configuration and must stay writable"
);
}
}
#[test]
fn allows_a_write_inside_a_worktree() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(dir.path().join(".git"), "gitdir: /elsewhere").expect("write .git");
assert_eq!(
evaluate_main_checkout_write(
"Write",
Some(&write_input(&dir.path().join("src/lib.rs"))),
dir.path()
),
None
);
}
#[test]
fn allows_a_write_outside_any_repository() {
let dir = tempfile::tempdir().expect("tempdir");
assert_eq!(
evaluate_main_checkout_write(
"Write",
Some(&write_input(&dir.path().join("scratch.rs"))),
dir.path()
),
None
);
}
#[test]
fn allows_every_non_edit_tool() {
let dir = main_checkout();
for tool in ["Read", "Bash", "Grep", "Agent", "SendMessage"] {
assert_eq!(
evaluate_main_checkout_write(
tool,
Some(&write_input(&dir.path().join("src/lib.rs"))),
dir.path()
),
None,
"{tool} is not an edit tool"
);
}
}
#[test]
fn allows_a_call_with_no_readable_target() {
let dir = main_checkout();
for input in [
None,
Some(serde_json::json!({})),
Some(serde_json::json!({"file_path": ""})),
Some(serde_json::json!({"file_path": 7})),
] {
assert_eq!(
evaluate_main_checkout_write("Write", input.as_ref(), dir.path()),
None,
"{input:?} names no target"
);
}
}
#[test]
fn resolves_a_relative_target_against_the_hook_cwd() {
let dir = main_checkout();
assert!(
evaluate_main_checkout_write(
"Edit",
Some(&serde_json::json!({"file_path": "src/lib.rs"})),
dir.path()
)
.is_some(),
"a relative source path must resolve against the hook cwd"
);
}
#[test]
fn denies_an_absolute_write_from_outside_the_checkout() {
let checkout = main_checkout();
let elsewhere = tempfile::tempdir().expect("tempdir");
assert!(
evaluate_main_checkout_write(
"Write",
Some(&write_input(&checkout.path().join("src/lib.rs"))),
elsewhere.path()
)
.is_some(),
"the target decides, not the caller's directory"
);
}
#[test]
fn deny_reason_names_the_file_and_both_remedies() {
let reason = deny_reason("src/lib.rs");
assert!(reason.contains("src/lib.rs"), "{reason}");
assert!(reason.contains(r#"isolation: "worktree""#), "{reason}");
assert!(reason.contains("Documents"), "{reason}");
}
}