use std::path::{Path, PathBuf};
use trusty_mpm::core::project_aliases::worktree_root;
use crate::commands::pm_guard_bash::{PathEnv, resolve_target_path};
const ENTER_WORKTREE_TOOL: &str = "EnterWorktree";
const ENTER_WORKTREE_DENY_TAIL: &str = "A pinned agent that switches trees is left wedged: the harness reports the switch as a \
success and moves the working directory, but its isolation guard can stay pinned to the \
dispatch-time path, after which every command — a bare `pwd` included — is refused with \
\"a worktree-isolated agent's commands must run inside its worktree\", and `ExitWorktree` \
is refused too. Stay in the worktree you were given: ask the PM for the other tree's base \
SHA and branch from that SHA here, which is the workaround that worked in the incident. \
If you have already switched and every command is being refused, call `EnterWorktree` \
again with `path` set to your own worktree — re-entry into the pinned tree is never \
blocked. SendMessage is never blocked either — use it to report back.";
pub(crate) fn evaluate_enter_worktree(
tool_name: &str,
tool_input: Option<&serde_json::Value>,
caller_is_subagent: bool,
hook_cwd: &Path,
) -> Option<String> {
if tool_name != ENTER_WORKTREE_TOOL || !caller_is_subagent {
return None;
}
let pinned = worktree_root(hook_cwd)?;
let Some(target) = target_path(tool_input) else {
return Some(deny_reason(&pinned, None));
};
let resolved = resolve_target_path(&target, hook_cwd, &PathEnv::from_process());
match worktree_root(&resolved) {
Some(root) if root == pinned => None,
Some(root) => Some(deny_reason(&pinned, Some(&root))),
None => Some(deny_reason(&pinned, Some(&resolved))),
}
}
fn target_path(tool_input: Option<&serde_json::Value>) -> Option<String> {
let raw = tool_input?.get("path")?.as_str()?.trim();
(!raw.is_empty()).then(|| raw.to_string())
}
fn deny_reason(pinned: &Path, target: Option<&PathBuf>) -> String {
let head = match target {
Some(target) => format!(
"EnterWorktree denied while worktree-pinned (#7172): this agent is pinned to the \
worktree {} and this call would move it to {}.",
pinned.display(),
target.display()
),
None => format!(
"EnterWorktree denied while worktree-pinned (#7172, #5649): this agent is pinned to \
the worktree {} and this call would create a worktree of its own, which is the PM's \
to declare with `isolation: \"worktree\"`.",
pinned.display()
),
};
format!("{head} {ENTER_WORKTREE_DENY_TAIL}")
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
const PINNED: &str = "/repo/.claude/worktrees/agent-a";
const PINNED_SUBDIR: &str = "/repo/.claude/worktrees/agent-a/crates/trusty-mpm";
fn evaluate(input: serde_json::Value, cwd: &str) -> Option<String> {
evaluate_enter_worktree(ENTER_WORKTREE_TOOL, Some(&input), true, Path::new(cwd))
}
#[test]
fn denies_a_switch_to_another_worktree() {
let reason = evaluate(
json!({"path": "/repo/.claude/worktrees/agent-b"}),
PINNED_SUBDIR,
)
.expect("a switch to another worktree must deny");
assert!(
reason.contains(PINNED),
"the deny must name the pin: {reason}"
);
assert!(
reason.contains("/repo/.claude/worktrees/agent-b"),
"the deny must name the target: {reason}"
);
assert!(
reason.contains("base SHA") && reason.contains("call `EnterWorktree` again"),
"the deny must carry both the workaround and the recovery: {reason}"
);
}
#[test]
fn denies_a_target_outside_the_worktree_family() {
let reason = evaluate(json!({"path": "/repo"}), PINNED)
.expect("a target outside .claude/worktrees must deny");
assert!(reason.contains("/repo"), "{reason}");
}
#[test]
fn denies_creating_a_new_worktree_while_pinned() {
let reason = evaluate(json!({"name": "scratch"}), PINNED)
.expect("creating a worktree while pinned must deny");
assert!(reason.contains("#5649"), "{reason}");
assert!(evaluate(json!({"path": " "}), PINNED).is_some());
assert!(
evaluate_enter_worktree(ENTER_WORKTREE_TOOL, None, true, Path::new(PINNED)).is_some()
);
}
#[test]
fn resolves_a_relative_target_before_comparing() {
assert!(
evaluate(json!({"path": "../agent-b"}), PINNED).is_some(),
"a relative escape into a sibling tree must deny"
);
assert_eq!(evaluate(json!({"path": "../.."}), PINNED_SUBDIR), None);
}
#[test]
fn allows_re_entering_the_pinned_worktree() {
assert_eq!(evaluate(json!({"path": PINNED}), PINNED), None);
assert_eq!(evaluate(json!({"path": PINNED}), PINNED_SUBDIR), None);
assert_eq!(
evaluate(json!({"path": PINNED_SUBDIR}), PINNED),
None,
"a subdirectory of the pinned tree is still the pinned tree"
);
}
#[test]
fn allows_the_pm() {
assert_eq!(
evaluate_enter_worktree(
ENTER_WORKTREE_TOOL,
Some(&json!({"path": "/repo/.claude/worktrees/agent-b"})),
false,
Path::new(PINNED),
),
None
);
}
#[test]
fn allows_an_agent_outside_a_worktree() {
assert_eq!(
evaluate(json!({"path": "/repo/.claude/worktrees/agent-b"}), "/repo"),
None
);
}
#[test]
fn allows_every_other_tool() {
for tool in ["Bash", "Read", "Edit", "ExitWorktree", "enterworktree"] {
assert_eq!(
evaluate_enter_worktree(
tool,
Some(&json!({"path": "/repo/.claude/worktrees/agent-b"})),
true,
Path::new(PINNED),
),
None,
"{tool} must not be touched by this rule"
);
}
}
}