use std::path::{Path, PathBuf};
use trusty_mpm::core::project_aliases::is_main_checkout;
use super::{PathEnv, git_dash_c_override, resolve_target_path, split_shell_segments};
use crate::commands::hook_rewrite::first_command_token;
use crate::commands::pm_guard_bash::shell_lex;
pub(crate) fn evaluate_main_checkout_destructive_command(
command: &str,
cwd: &Path,
) -> Option<String> {
let (verb, target) = destructive_target_dir(command, cwd, &PathEnv::from_process())?;
is_main_checkout(&target).then(|| deny_reason(&verb, &target))
}
fn deny_reason(verb: &str, target: &Path) -> String {
format!(
"Destructive git command denied in a main checkout (ADR-0037): `git {verb}` would \
overwrite or delete uncommitted work in {}, which is a project's main checkout rather \
than a worktree. Another session may be standing in that directory, and what this \
removes is not in git — it cannot be recovered. ADR-0037 makes the main checkout \
read-only apart from documents and configuration. Do this work in a worktree instead: \
`git worktree add .claude/worktrees/<name>`, or ask the PM to re-dispatch you with \
`isolation: \"worktree\"`. Read-only git (`status`, `log`, `diff`), branch creation \
(`checkout -b`), and a plain `git reset` are never blocked by this rule.",
target.display()
)
}
fn destructive_target_dir(command: &str, cwd: &Path, env: &PathEnv) -> Option<(String, PathBuf)> {
let mut effective_cwd = cwd.to_path_buf();
for segment in split_shell_segments(command) {
let trimmed = segment.trim();
if trimmed.is_empty() {
continue;
}
if first_command_token(trimmed) == Some("cd") {
if let Some(argv) = shlex::split(trimmed)
&& let Some(dest) = argv.get(1)
{
effective_cwd = resolve_target_path(dest, &effective_cwd, env);
}
continue;
}
let Some(subcommand) = shell_lex::git_subcommand(trimmed) else {
continue;
};
let Some(argv) = shlex::split(trimmed) else {
continue;
};
let Some(idx) = argv.iter().position(|t| *t == subcommand) else {
continue;
};
if !is_whole_tree_destructive(&subcommand, &argv[idx + 1..]) {
continue;
}
let base = match git_dash_c_override(&argv, idx) {
Some(dash_c) => resolve_target_path(dash_c, &effective_cwd, env),
None => effective_cwd.clone(),
};
return Some((subcommand, base));
}
None
}
fn is_whole_tree_destructive(subcommand: &str, tail: &[String]) -> bool {
let has = |names: &[&str]| tail.iter().any(|t| names.contains(&t.as_str()));
let cluster_has = |chars: &[char]| {
tail.iter().any(|t| {
t.starts_with('-')
&& !t.starts_with("--")
&& t.chars().skip(1).any(|c| chars.contains(&c))
})
};
match subcommand {
"reset" => has(&["--hard", "--merge", "--keep"]),
"clean" => {
let dry_run = has(&["--dry-run"]) || cluster_has(&['n']);
let forced = has(&["--force"]) || cluster_has(&['f', 'x', 'X']);
forced && !dry_run
}
"checkout" => {
has(&["-f", "--force"])
|| tail.iter().any(|t| t == "." || t == "./")
|| pathspec_separator_with_paths(tail)
}
"restore" => {
let staged = has(&["--staged", "-S"]);
let worktree = has(&["--worktree", "-W"]);
worktree || !staged
}
"switch" => has(&["--discard-changes", "-f", "--force"]),
_ => false,
}
}
fn pathspec_separator_with_paths(tail: &[String]) -> bool {
tail.iter()
.position(|t| t == "--")
.is_some_and(|i| i + 1 < tail.len())
}
#[cfg(test)]
mod tests {
use super::*;
fn tail(args: &[&str]) -> Vec<String> {
args.iter().map(|s| (*s).to_string()).collect()
}
#[test]
fn is_whole_tree_destructive_denies_the_incident_shapes() {
assert!(is_whole_tree_destructive(
"checkout",
&tail(&["a1b2c3d", "--", "."])
));
assert!(is_whole_tree_destructive(
"clean",
&tail(&["-fdx", "--", "crates/", "docs/"])
));
}
#[test]
fn is_whole_tree_destructive_denies_every_listed_form() {
for (verb, args) in [
("checkout", vec!["--", "src/lib.rs"]),
("checkout", vec!["HEAD~1", "--", "crates/"]),
("checkout", vec!["."]),
("checkout", vec!["./"]),
("checkout", vec!["-f", "main"]),
("checkout", vec!["--force", "main"]),
("restore", vec!["src/lib.rs"]),
("restore", vec!["--source=HEAD~1", "--", "src/"]),
("restore", vec!["--staged", "--worktree", "src/"]),
("restore", vec!["-S", "-W", "src/"]),
("reset", vec!["--hard"]),
("reset", vec!["--hard", "origin/main"]),
("reset", vec!["--merge", "HEAD"]),
("reset", vec!["--keep", "HEAD~2"]),
("clean", vec!["-f"]),
("clean", vec!["-fd"]),
("clean", vec!["-fdx"]),
("clean", vec!["-x"]),
("clean", vec!["--force", "-d"]),
("switch", vec!["--discard-changes", "main"]),
("switch", vec!["-f", "main"]),
] {
assert!(
is_whole_tree_destructive(verb, &tail(&args)),
"expected `git {verb} {}` to be destructive",
args.join(" ")
);
}
}
#[test]
fn is_whole_tree_destructive_allows_the_near_misses() {
for (verb, args) in [
("reset", vec!["HEAD"]),
("reset", vec![]),
("reset", vec!["--soft", "HEAD~1"]),
("reset", vec!["--mixed", "HEAD"]),
("checkout", vec!["-b", "feature/x"]),
("checkout", vec!["-B", "feature/x", "origin/main"]),
("checkout", vec!["main"]),
("checkout", vec!["a1b2c3d"]),
("restore", vec!["--staged", "src/lib.rs"]),
("restore", vec!["-S", "src/lib.rs"]),
("clean", vec!["-n"]),
("clean", vec!["-nfd"]),
("clean", vec!["--dry-run", "-fdx"]),
("clean", vec!["-d"]),
("switch", vec!["main"]),
("switch", vec!["-c", "feature/x"]),
("status", vec!["--short"]),
("log", vec!["--oneline", "-20"]),
("diff", vec!["--stat", "HEAD~1"]),
("stash", vec!["list"]),
("add", vec!["-A"]),
("commit", vec!["-m", "wip"]),
] {
assert!(
!is_whole_tree_destructive(verb, &tail(&args)),
"`git {verb} {}` must NOT be treated as destructive",
args.join(" ")
);
}
}
#[test]
fn destructive_target_dir_defaults_to_the_hook_cwd() {
let env = PathEnv::from_process();
let (verb, dir) =
destructive_target_dir("git reset --hard origin/main", Path::new("/repo"), &env)
.expect("a destructive verb must resolve a target");
assert_eq!(verb, "reset");
assert_eq!(dir, PathBuf::from("/repo"));
}
#[test]
fn destructive_target_dir_follows_cd_and_dash_c() {
let env = PathEnv::from_process();
let (_, via_cd) = destructive_target_dir(
"cd /elsewhere/main && git clean -fdx",
Path::new("/repo"),
&env,
)
.expect("cd must move the target");
assert_eq!(via_cd, PathBuf::from("/elsewhere/main"));
let (_, via_dash_c) = destructive_target_dir(
"git -C /elsewhere/main reset --hard",
Path::new("/repo"),
&env,
)
.expect("-C must move the target");
assert_eq!(via_dash_c, PathBuf::from("/elsewhere/main"));
let (_, relative) =
destructive_target_dir("git -C ../other reset --hard", Path::new("/a/b"), &env)
.expect("relative -C must resolve");
assert_eq!(relative, PathBuf::from("/a/other"));
}
#[test]
fn destructive_target_dir_finds_a_verb_hidden_in_a_later_segment() {
let env = PathEnv::from_process();
let (verb, _) = destructive_target_dir(
"git status --short; git checkout -- crates/",
Path::new("/repo"),
&env,
)
.expect("the second segment must be classified");
assert_eq!(verb, "checkout");
}
#[test]
fn destructive_target_dir_is_none_for_ordinary_work() {
let env = PathEnv::from_process();
for command in [
"git status",
"git log --oneline -20",
"git diff HEAD~1",
"ls -la",
"cargo test -p trusty-mpm",
"git checkout -b feature/x",
"git reset HEAD",
"git commit -m 'x'",
"",
"git reset --hard 'unterminated",
] {
assert!(
destructive_target_dir(command, Path::new("/repo"), &env).is_none(),
"`{command}` must not resolve a destructive target"
);
}
}
#[test]
fn deny_reason_names_the_verb_the_path_and_the_remedy() {
let reason = deny_reason("clean", Path::new("/repo/main"));
assert!(reason.contains("ADR-0037"), "{reason}");
assert!(reason.contains("git clean"), "{reason}");
assert!(reason.contains("/repo/main"), "{reason}");
assert!(reason.contains(".claude/worktrees"), "{reason}");
assert!(reason.contains(r#"isolation: "worktree""#), "{reason}");
}
}