use std::path::Path;
use trusty_mpm::core::project_aliases::main_checkout_root;
use trusty_mpm::core::uncommitted_changes::has_uncommitted_changes;
use super::main_checkout::git_verb_targets_with_tail;
use super::{PathEnv, shell_groups, unresolved_target};
use crate::commands::pm_guard_write_boundary::write_lands_in_a_scratchpad_clone;
pub(crate) fn evaluate_main_checkout_head_switch(
command: &str,
cwd: &Path,
caller_is_subagent: bool,
) -> Option<String> {
if !caller_is_subagent {
return None;
}
evaluate_head_switch_in(
command,
cwd,
&PathEnv::from_process(),
has_uncommitted_changes,
)
}
fn evaluate_head_switch_in(
command: &str,
cwd: &Path,
env: &PathEnv,
dirty: impl Fn(&Path) -> Option<bool>,
) -> Option<String> {
if let Some(reason) = shell_groups::unplaced_git_verb_reason(command, switches_head) {
return Some(reason);
}
for (verb, target, _) in git_verb_targets_with_tail(command, cwd, env, switches_head) {
if let Some(unresolved) = unresolved_target(&target) {
return Some(unresolved_deny_reason(
&verb,
&unresolved.shown,
&unresolved.token,
));
}
let Some(root) = main_checkout_root(&target) else {
continue;
};
if write_lands_in_a_scratchpad_clone(&target, &root) {
continue;
}
match dirty(&root) {
Some(false) => continue,
Some(true) => return Some(deny_reason(&verb, &root, DIRTY)),
None => return Some(deny_reason(&verb, &root, UNREADABLE)),
}
}
None
}
fn switches_head(subcommand: &str, tail: &[String]) -> bool {
let first = tail.first().map(String::as_str);
match subcommand {
"checkout" => checkout_switches_head(tail),
"switch" => !tail.is_empty(),
"stash" => !matches!(
first,
Some("list" | "show" | "create" | "store" | "drop" | "clear")
),
"bisect" => {
first.is_some_and(|f| !matches!(f, "log" | "view" | "visualize" | "terms" | "help"))
}
_ => false,
}
}
fn checkout_switches_head(tail: &[String]) -> bool {
let args = match tail.iter().position(|t| t == "--") {
Some(i) if i + 1 < tail.len() => return false,
Some(i) => &tail[..i],
None => tail,
};
let path_form = args.iter().any(|t| {
matches!(t.as_str(), "." | "./" | "-p" | "--patch") || t.starts_with("--pathspec-from-file")
});
!path_form
&& args.iter().any(|t| {
matches!(t.as_str(), "-b" | "-B" | "--orphan" | "--detach" | "-") || !t.starts_with('-')
})
}
const DIRTY: &str = "holds uncommitted work (`git status --porcelain` lists changes)";
const UNREADABLE: &str = "could not be checked: `git status --porcelain` failed there, and \
a state the guard cannot read is refused rather than assumed clean";
fn deny_reason(verb: &str, root: &Path, state: &str) -> String {
format!(
"HEAD switch denied in a main checkout (#8572): `git {verb}` would move HEAD, overwrite \
files, or move work out of the working tree of {}, a project's main checkout that \
{state}. That work \
belongs to whoever stands in this checkout, usually the operator. A branch switch carries \
it onto another branch, or overwrites or masks it where the branches differ, and git \
reports no error. Use your own worktree instead: if a worktree already holds the branch, \
run the command there (`git worktree list` names it); otherwise ask the PM to re-dispatch \
you with `isolation: \"worktree\"`. Publishing a branch needs no checkout: \
`git push origin <branch>` works from here, and `tm pr open` runs from the worktree that \
holds the branch. Read-only git, `git fetch`, `git pull`, `git push`, and everything \
under `.claude/worktrees/**` stay allowed, including `git checkout <sha> -- <path>` in \
your own worktree.",
root.display()
)
}
fn unresolved_deny_reason(verb: &str, shown: &Path, token: &str) -> String {
format!(
"HEAD switch denied because its target directory is unresolvable (#8572): `git {verb}` \
names {}, which still carries the unresolved shell expansion `{token}`, so the guard \
cannot tell whether it lands in your worktree or in a main checkout. Spell the directory \
out (`git -C /abs/path/.claude/worktrees/<name> {verb} …`) or `cd` into the worktree \
first.",
shown.display()
)
}
#[cfg(test)]
mod tests {
use super::*;
fn env() -> PathEnv {
PathEnv {
tmpdir: None,
tmp: None,
home: Some("/Users/nobody".to_string()),
}
}
fn main_checkout() -> (tempfile::TempDir, std::path::PathBuf) {
let dir = tempfile::tempdir().expect("tempdir");
let repo = dir.path().join("repo");
std::fs::create_dir_all(repo.join(".git")).expect("mkdir .git");
(dir, repo)
}
fn argv(tail: &[&str]) -> Vec<String> {
tail.iter().map(|s| (*s).to_string()).collect()
}
#[test]
fn switches_head_covers_branch_switches_stash_and_bisect() {
for (verb, tail) in [
("checkout", vec!["main"]),
("checkout", vec!["feat/non-exhaustive-137"]),
("checkout", vec!["-b", "feature/x"]),
("checkout", vec!["-B", "feature/x", "origin/main"]),
("checkout", vec!["--detach"]),
("checkout", vec!["a1b2c3d"]),
("checkout", vec!["-"]),
("checkout", vec!["main", "--"]),
("checkout", vec!["-q", "main"]),
("switch", vec!["main"]),
("switch", vec!["-c", "feature/x"]),
("stash", vec![]),
("stash", vec!["push", "-m", "wip"]),
("stash", vec!["-u"]),
("stash", vec!["pop"]),
("stash", vec!["branch", "x"]),
("bisect", vec!["start"]),
] {
assert!(switches_head(verb, &argv(&tail)), "{verb} {tail:?}");
}
}
#[test]
fn switches_head_leaves_path_restores_and_reads_alone() {
for (verb, tail) in [
("checkout", vec![]),
("checkout", vec!["a1b2c3d", "--", "src/lib.rs"]),
("checkout", vec!["--", "src/lib.rs"]),
("checkout", vec!["."]),
("checkout", vec!["-p", "main"]),
("checkout", vec!["--pathspec-from-file=list.txt"]),
("switch", vec![]),
("stash", vec!["list"]),
("stash", vec!["show", "-p"]),
("bisect", vec!["log"]),
("bisect", vec![]),
("status", vec![]),
("push", vec!["origin", "feat/x"]),
] {
assert!(!switches_head(verb, &argv(&tail)), "{verb} {tail:?}");
}
}
#[test]
fn head_switch_denies_a_dirty_main_checkout() {
let (_dir, repo) = main_checkout();
for command in [
"git checkout feat/non-exhaustive-137",
"git switch main",
"git checkout -b fix/x",
"git stash && git checkout feat/x",
"git checkout src/lib.rs",
] {
let reason = evaluate_head_switch_in(command, &repo, &env(), |_| Some(true))
.unwrap_or_else(|| panic!("`{command}` must be denied"));
assert!(reason.contains("#8572"), "{reason}");
assert!(reason.contains(&repo.display().to_string()), "{reason}");
assert!(reason.contains("uncommitted work"), "{reason}");
assert!(reason.contains("isolation: \"worktree\""), "{reason}");
assert!(reason.contains("move HEAD, overwrite files"), "{reason}");
}
}
#[test]
fn head_switch_refuses_an_eval_it_cannot_place() {
let (_dir, repo) = main_checkout();
let wt = repo.join(".claude/worktrees/agent-1");
std::fs::create_dir_all(&wt).expect("mkdir wt");
std::fs::write(wt.join(".git"), "gitdir: ../../.git/worktrees/agent-1").expect(".git");
let wt = wt.display();
for command in [
format!("command -p eval \"cd {wt}\"; git stash"),
format!("command -p eval \"cd {wt}\"; git checkout x"),
format!("noglob eval \"cd {wt}\"; git stash"),
format!("command eval \"cd {wt}\"; git checkout x"),
] {
let reason = evaluate_head_switch_in(&command, &repo, &env(), |_| Some(true))
.unwrap_or_else(|| panic!("`{command}` must be denied"));
assert!(reason.contains("cannot place it"), "{command}: {reason}");
}
for command in [
format!("eval \"cd {wt}\"; git stash"),
format!("cd {wt} && git checkout x"),
] {
assert!(
evaluate_head_switch_in(&command, &repo, &env(), |_| Some(true)).is_none(),
"`{command}` must be allowed"
);
}
}
#[test]
fn head_switch_allows_a_clean_main_checkout() {
let (_dir, repo) = main_checkout();
for command in ["git checkout main", "git switch -c feature/x", "git stash"] {
assert!(
evaluate_head_switch_in(command, &repo, &env(), |_| Some(false)).is_none(),
"`{command}` in a clean checkout must be allowed"
);
}
}
#[test]
fn head_switch_fails_closed_when_the_dirty_state_is_unreadable() {
let (_dir, repo) = main_checkout();
let reason = evaluate_head_switch_in("git checkout feat/x", &repo, &env(), |_| None)
.expect("an unreadable state must refuse the switch");
assert!(reason.contains("could not be checked"), "{reason}");
}
#[test]
fn head_switch_allows_the_agents_own_worktree() {
let (_dir, repo) = main_checkout();
let wt = repo.join(".claude/worktrees/agent-1");
std::fs::create_dir_all(&wt).expect("mkdir wt");
std::fs::write(wt.join(".git"), "gitdir: ../../.git/worktrees/agent-1").expect(".git");
for command in [
"git checkout a1b2c3d -- src/lib.rs",
"git checkout feat/x",
"git stash push -u -m tag",
] {
assert!(
evaluate_head_switch_in(command, &wt, &env(), |_| Some(true)).is_none(),
"`{command}` in the agent's own worktree must be allowed"
);
}
let cmd = format!("git -C {} checkout feat/x", wt.display());
assert!(evaluate_head_switch_in(&cmd, &repo, &env(), |_| Some(true)).is_none());
}
#[test]
fn head_switch_judges_every_segment() {
let (_dir, repo) = main_checkout();
let command = "git -C .claude/worktrees/a checkout x && git checkout feat/y";
assert!(evaluate_head_switch_in(command, &repo, &env(), |_| Some(true)).is_some());
}
#[test]
fn head_switch_refuses_an_unresolved_directory() {
let (_dir, repo) = main_checkout();
let reason =
evaluate_head_switch_in("git -C $WT checkout feat/x", &repo, &env(), |_| Some(false))
.expect("an unresolved directory must refuse");
assert!(reason.contains("$WT"), "{reason}");
}
#[test]
fn head_switch_refuses_an_unresolved_directory_from_a_worktree() {
let (_dir, repo) = main_checkout();
let wt = repo.join(".claude/worktrees/agent-1");
std::fs::create_dir_all(&wt).expect("mkdir wt");
std::fs::write(wt.join(".git"), "gitdir: ../../.git/worktrees/agent-1").expect(".git");
for (command, token) in [
("git -C $MAIN checkout feat/x", "$MAIN"),
("cd $MAIN && git switch x", "$MAIN"),
("git -C \"${MAIN}\" stash", "${MAIN}"),
("git -C \"$(cat /tmp/main)\" checkout x", "$("),
("git -C $(cat /tmp/main) checkout x", "$("),
("cd \"`cat /tmp/main`\" && git checkout x", "`"),
] {
let reason = evaluate_head_switch_in(command, &wt, &env(), |_| Some(false))
.unwrap_or_else(|| panic!("`{command}` from a worktree must be denied"));
assert!(reason.contains("unresolvable"), "{reason}");
assert!(reason.contains(token), "{command}: {reason}");
}
}
#[test]
fn head_switch_allows_a_scratchpad_clone() {
let dir = tempfile::tempdir().expect("tempdir");
let clone = dir.path().join("scratchpad").join("bisect");
std::fs::create_dir_all(clone.join(".git")).expect("mkdir clone .git");
assert!(
evaluate_head_switch_in("git bisect start", &clone, &env(), |_| Some(true)).is_none()
);
}
#[test]
fn head_switch_is_agent_only() {
let (_dir, repo) = main_checkout();
assert!(evaluate_main_checkout_head_switch("git checkout feat/x", &repo, false).is_none());
}
}