use std::path::{Path, PathBuf};
use trusty_mpm::core::project_aliases::{is_main_checkout, main_checkout_root};
use trusty_mpm::core::staged_paths::staged_paths;
use super::{
PathEnv, git_dash_c_override, resolve_target_path, split_shell_segments, unresolved_target,
};
use crate::commands::hook_rewrite::first_command_token;
use crate::commands::pm_guard::is_source_code_path;
use crate::commands::pm_guard_bash::shell_lex;
use crate::commands::pm_guard_write_boundary::write_lands_in_a_scratchpad_clone;
pub(crate) fn evaluate_main_checkout_destructive_command(
command: &str,
cwd: &Path,
) -> Option<String> {
evaluate_main_checkout_destructive_command_in(command, cwd, &PathEnv::from_process())
}
fn evaluate_main_checkout_destructive_command_in(
command: &str,
cwd: &Path,
env: &PathEnv,
) -> Option<String> {
let (verb, target) = git_verb_target_dir(command, cwd, env, is_whole_tree_destructive)?;
if !is_main_checkout(&target) {
return None;
}
let unresolved = unresolved_target(&target);
if unresolved.is_none()
&& main_checkout_root(&target)
.is_some_and(|root| write_lands_in_a_scratchpad_clone(&target, &root))
{
return None;
}
match unresolved {
Some(unresolved) => Some(unresolved_directory_deny_reason(
DESTRUCTIVE_UNRESOLVED_HEADLINE,
&verb,
&unresolved.shown,
&unresolved.token,
)),
None => Some(deny_reason(&verb, &target)),
}
}
pub(crate) enum CommitVerdict {
Deny(String),
DocsOnly { root: PathBuf, dirs: [PathBuf; 2] },
}
pub(crate) fn evaluate_main_checkout_commit_command(
command: &str,
cwd: &Path,
) -> Option<CommitVerdict> {
evaluate_main_checkout_commit_command_in(command, cwd, &PathEnv::from_process())
}
fn evaluate_main_checkout_commit_command_in(
command: &str,
cwd: &Path,
env: &PathEnv,
) -> Option<CommitVerdict> {
let (verb, target, tail) =
git_verb_target_dir_with_tail(command, cwd, env, |verb, _| verb == "commit")?;
let root = main_checkout_root(&target)?;
if let Some(unresolved) = unresolved_target(&target) {
return Some(CommitVerdict::Deny(unresolved_directory_deny_reason(
COMMIT_UNRESOLVED_HEADLINE,
&verb,
&unresolved.shown,
&unresolved.token,
)));
}
if !command_is_a_lone_commit(command) {
return Some(CommitVerdict::Deny(composed_commit_deny_reason(&root)));
}
Some(classify_staged_commit(
&tail,
staged_paths(&target),
&target,
root,
))
}
fn command_is_a_lone_commit(command: &str) -> bool {
let mut commits = 0;
for segment in split_shell_segments(command) {
let trimmed = segment.trim();
if trimmed.is_empty() {
continue;
}
if first_command_token(trimmed).as_deref() == Some("cd") {
continue;
}
if shell_lex::git_subcommand(trimmed).as_deref() != Some("commit") {
return false;
}
commits += 1;
}
commits == 1
}
fn classify_staged_commit(
tail: &[String],
staged: Option<Vec<String>>,
target: &Path,
root: PathBuf,
) -> CommitVerdict {
if !commit_flags_leave_the_index_authoritative(tail) {
return CommitVerdict::Deny(commit_deny_reason(&root));
}
let Some(staged) = staged.filter(|s| !s.is_empty()) else {
return CommitVerdict::Deny(commit_deny_reason(&root));
};
let source: Vec<&str> = staged
.iter()
.map(String::as_str)
.filter(|p| is_source_code_path(p))
.collect();
if source.is_empty() {
CommitVerdict::DocsOnly {
dirs: [root.clone(), target.to_path_buf()],
root,
}
} else {
CommitVerdict::Deny(staged_source_deny_reason(&root, &source))
}
}
fn commit_flags_leave_the_index_authoritative(tail: &[String]) -> bool {
const NO_VALUE: &[&str] = &[
"-s",
"--signoff",
"--no-signoff",
"-n",
"--no-verify",
"--verify",
"-q",
"--quiet",
"-v",
"--verbose",
"-e",
"--edit",
"--no-edit",
"--allow-empty",
"--allow-empty-message",
"--status",
"--no-status",
"--no-post-rewrite",
"-S",
"--gpg-sign",
"--no-gpg-sign",
];
const WITH_VALUE: &[&str] = &[
"-m",
"--message",
"-F",
"--file",
"--author",
"--date",
"--cleanup",
"--trailer",
];
let mut expecting_value = false;
for token in tail {
if expecting_value {
expecting_value = false;
continue;
}
let name = match token.split_once('=') {
Some((head, _)) if head.starts_with("--") => head,
_ if token.starts_with("-S") => "-S",
_ => token.as_str(),
};
if NO_VALUE.contains(&name) {
continue;
}
if WITH_VALUE.contains(&name) {
expecting_value = name == token.as_str();
continue;
}
return false;
}
true
}
pub(crate) fn main_checkout_head_move(
command: &str,
cwd: &Path,
) -> Option<(String, PathBuf, PathBuf)> {
let (verb, target) =
git_verb_target_dir(command, cwd, &PathEnv::from_process(), starts_a_head_move)?;
let root = main_checkout_root(&target)?;
Some((verb, target, root))
}
const IN_PROGRESS_CONTROL_FLAGS: &[&str] = &[
"--abort",
"--quit",
"--continue",
"--skip",
"--edit-todo",
"--show-current-patch",
];
fn starts_a_head_move(subcommand: &str, tail: &[String]) -> bool {
matches!(subcommand, "merge" | "rebase")
&& !tail
.first()
.is_some_and(|t| IN_PROGRESS_CONTROL_FLAGS.contains(&t.as_str()))
}
pub(crate) fn head_move_deny_reason(verb: &str, target: &Path, live: &[String]) -> String {
let mut names: Vec<&str> = live.iter().map(String::as_str).collect();
names.sort_unstable();
names.dedup();
format!(
"HEAD-moving git command denied in a shared main checkout (ADR-0048): `git {verb}` moves \
HEAD and writes the working tree of {}, and the daemon's delegation records name {} as \
running there with no worktree of its own, dispatched by a DIFFERENT session standing in \
the same directory (#6797: your own session's agents are excluded from this answer, so \
this is not you). Moving HEAD under a live writer changes the branch its \
uncommitted work sits on, and git reports no error when it happens. If you \
believe that record is stale — the agent finished without its stop signal reaching the \
daemon — report it to the PM rather than retrying this command. If you only need this \
checkout updated, run `git fetch` or `git pull` instead: both are permitted here \
whoever else is in the tree (ADR-0053), and `git fetch` writes only \
`refs/remotes/origin/*` without touching HEAD or the working tree — then branch and \
diff against `origin/main` rather than local `main`, which only a pull moves. If you \
need the merge \
or rebase itself, do it in a worktree: ask the PM to re-dispatch you with \
`isolation: \"worktree\"`, or `git worktree add .claude/worktrees/<name>`. Read-only git \
(`status`, `log`, `diff`), `git fetch`, `git pull`, `git rebase --abort`/`--continue`, \
`git merge --abort`, and everything under `.claude/worktrees/**` are never blocked by \
this rule.",
target.display(),
names.join(", ")
)
}
fn commit_deny_reason(target: &Path) -> String {
format!(
"Commit denied in a main checkout (ADR-0044): {} is a project's main checkout, which is \
read-only apart from documents and configuration, and other sessions are standing on \
this same git HEAD. A commit here lands on whichever branch HEAD currently points at — \
the reported failure is a commit landing on another workstream's branch and the branch \
it belonged to left empty, with no error at any step. A DOCUMENTS-ONLY commit is \
permitted here (ADR-0049), but only when the staged set says so: stage the documents \
explicitly (`git add -- <paths>`) and commit with a plain `git commit -m …`. This call \
was refused because the staged set does not describe what it would commit — nothing is \
staged, the index could not be read, or the command carries `-a`, `--amend`, \
`--include`, `--only`, or a pathspec, each of which commits content the index does not \
hold. For source changes, commit from a worktree instead: ask the PM to re-dispatch you \
with `isolation: \"worktree\"`, or move the work with \
`git worktree add .claude/worktrees/<name>` and commit there. Nothing is lost — the \
changes are still in the tree. Read-only git (`status`, `log`, `diff`), `git add`, and \
everything under `.claude/worktrees/**` are never blocked by this rule.",
target.display()
)
}
fn composed_commit_deny_reason(target: &Path) -> String {
format!(
"Composed commit denied in a main checkout (ADR-0049): {} is a project's main checkout, \
where a documents-only commit is permitted — but only when the guard can see what the \
commit will contain. It reads the index once, before this command runs, and that reading \
describes exactly one commit with nothing in between: a second `git commit`, a \
`git add`, or any other command in the same call can change the index after the reading \
and before the commit, so the reading would be describing a commit that never happens. \
Split the call: stage in one Bash call (`git add -- <paths>`), then run \
`git commit -m …` as its own call with nothing chained to it. `cd` is the one exception \
and may be chained, since it touches no index. Nothing is lost — the changes are still \
in the tree, and `git add` is never blocked by this rule.",
target.display()
)
}
fn staged_source_deny_reason(target: &Path, source: &[&str]) -> String {
let mut names: Vec<&str> = source.to_vec();
names.sort_unstable();
names.dedup();
format!(
"Source commit denied in a main checkout (ADR-0044, amended by ADR-0049): {} is a \
project's main checkout, which is read-only apart from documents and configuration, and \
other sessions are standing on this same git HEAD. Documents may be committed here; \
these staged paths are source and may not be: {}. A mixed staged set is refused as a \
whole rather than split, because a commit is one object and half of it cannot be sent \
elsewhere. Two ways forward. To land the documents from here, unstage the source \
(`git restore --staged -- <paths above>`) and commit again. To land the source, do it \
in a worktree: ask the PM to re-dispatch you with `isolation: \"worktree\"`, or \
`git worktree add .claude/worktrees/<name>` and commit there. Nothing is lost — the \
changes are still in the tree, and `git add` is never blocked by this rule.",
target.display(),
names.join(", ")
)
}
pub(crate) fn docs_commit_deny_reason(target: &Path, live: &[String]) -> String {
let mut names: Vec<&str> = live.iter().map(String::as_str).collect();
names.sort_unstable();
names.dedup();
format!(
"Documents-only commit denied in a SHARED main checkout (ADR-0049): the staged set is \
documents and configuration, which may be committed in {} — but a commit moves HEAD, \
and the daemon's delegation records name {} as running there with no worktree of its \
own, dispatched by a DIFFERENT session standing in the same directory (#6797: your own \
session's agents are excluded from this answer, so this is not one of yours). Moving \
HEAD under a live writer changes the branch its uncommitted work sits on, and git \
reports no error when it happens. The content is not the problem here and unstaging it \
will not help. Wait for that agent to finish and commit then, or commit from a worktree: \
ask the PM to re-dispatch you with `isolation: \"worktree\"`, or \
`git worktree add .claude/worktrees/<name>`. If you believe that record is stale — the \
agent finished without its stop signal reaching the daemon — report it to the PM rather \
than retrying this command. Nothing is lost; the staged changes are still in the tree.",
target.display(),
names.join(", ")
)
}
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's rule is that a main \
checkout's files are not EDITED or destroyed; UPDATING it is expected, and `git fetch` \
is never blocked. 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()
)
}
const DESTRUCTIVE_UNRESOLVED_HEADLINE: &str =
"Destructive git command denied because its target directory is unresolvable (ADR-0037)";
const COMMIT_UNRESOLVED_HEADLINE: &str =
"Commit denied because its target directory is unresolvable (ADR-0044, amended by ADR-0049)";
fn unresolved_directory_deny_reason(
headline: &str,
verb: &str,
target: &Path,
token: &str,
) -> String {
format!(
"{headline}: `git {verb}` names {}, which still carries the unresolved shell expansion \
`{token}`. The guard expands `$TMPDIR`, `$TMP`, `$HOME` and `$PWD`, and a leading `~` \
only when `$HOME` is set, so it cannot tell whether this lands in a sibling worktree or \
in the main checkout it walked up to — and it will not clear a directory it cannot name. \
Spell the directory out (`git -C /abs/path/.claude/worktrees/<name> {verb} …`) or `cd` \
into the worktree first. A `-C` path that names a harness worktree \
(`.claude/worktrees/`, `.worktrees/`) is not restricted by this rule, so the same command \
with the path written out is allowed.",
target.display()
)
}
fn git_verb_target_dir(
command: &str,
cwd: &Path,
env: &PathEnv,
matches: impl Fn(&str, &[String]) -> bool,
) -> Option<(String, PathBuf)> {
git_verb_target_dir_with_tail(command, cwd, env, matches).map(|(verb, dir, _)| (verb, dir))
}
pub(super) fn git_verb_target_dir_with_tail(
command: &str,
cwd: &Path,
env: &PathEnv,
matches: impl Fn(&str, &[String]) -> bool,
) -> Option<(String, PathBuf, Vec<String>)> {
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).as_deref() == 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((argv, candidates)) = shell_lex::git_subcommand_candidates(trimmed) else {
continue;
};
for idx in candidates {
let subcommand = &argv[idx];
let tail = &argv[idx + 1..];
if !matches(subcommand, tail) {
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.clone(), base, tail.to_vec()));
}
}
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::*;
use tempfile::TempDir;
fn tail(args: &[&str]) -> Vec<String> {
args.iter().map(|s| (*s).to_string()).collect()
}
fn destructive_target_dir(
command: &str,
cwd: &Path,
env: &PathEnv,
) -> Option<(String, PathBuf)> {
git_verb_target_dir(command, cwd, env, is_whole_tree_destructive)
}
fn main_checkout_dir() -> TempDir {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::create_dir(dir.path().join(".git")).expect("mkdir .git");
dir
}
#[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 commit_target_dir_finds_the_commit_and_follows_cd_and_dash_c() {
let env = PathEnv::from_process();
let is_commit = |verb: &str, _: &[String]| verb == "commit";
let (verb, dir) =
git_verb_target_dir("git commit -m 'wip'", Path::new("/repo"), &env, is_commit)
.expect("a commit must resolve a target");
assert_eq!(verb, "commit");
assert_eq!(dir, PathBuf::from("/repo"));
let (_, chained) = git_verb_target_dir(
"git add -A && git commit -m 'wip'",
Path::new("/repo"),
&env,
is_commit,
)
.expect("the second segment must be classified");
assert_eq!(chained, PathBuf::from("/repo"));
let (_, via_cd) = git_verb_target_dir(
"cd /elsewhere/main && git commit -m x",
Path::new("/repo"),
&env,
is_commit,
)
.expect("cd must move the target");
assert_eq!(via_cd, PathBuf::from("/elsewhere/main"));
let (_, via_dash_c) = git_verb_target_dir(
"git -C /elsewhere/main commit -m x",
Path::new("/repo"),
&env,
is_commit,
)
.expect("-C must move the target");
assert_eq!(via_dash_c, PathBuf::from("/elsewhere/main"));
}
#[test]
fn commit_target_dir_is_none_for_everything_else() {
let env = PathEnv::from_process();
let is_commit = |verb: &str, _: &[String]| verb == "commit";
for command in [
"git status",
"git log --oneline -5",
"git add -A",
"git push origin HEAD",
"cargo test -p trusty-mpm",
"",
] {
assert!(
git_verb_target_dir(command, Path::new("/repo"), &env, is_commit).is_none(),
"`{command}` is not a commit"
);
}
}
#[test]
fn evaluate_main_checkout_commit_denies_in_a_checkout_and_allows_in_a_worktree() {
let checkout = main_checkout_dir();
let CommitVerdict::Deny(reason) =
evaluate_main_checkout_commit_command("git commit -m 'wip'", checkout.path())
.expect("a commit in a main checkout must be classified")
else {
panic!("an unreadable staged set must deny");
};
assert!(reason.contains("ADR-0044"), "{reason}");
let worktree = tempfile::tempdir().expect("tempdir");
std::fs::write(worktree.path().join(".git"), "gitdir: /elsewhere").expect("write .git");
assert!(
evaluate_main_checkout_commit_command("git commit -m 'wip'", worktree.path()).is_none()
);
let plain = tempfile::tempdir().expect("tempdir");
assert!(
evaluate_main_checkout_commit_command("git commit -m 'wip'", plain.path()).is_none()
);
}
#[test]
fn destructive_deny_names_an_unresolved_variable_rather_than_the_checkout() {
let checkout = main_checkout_dir();
let reason = evaluate_main_checkout_destructive_command(
"git -C $WT checkout -- src/lib.rs",
checkout.path(),
)
.expect("an unresolvable target directory must still deny");
assert!(reason.contains("$WT"), "{reason}");
assert!(reason.contains("unresolvable"), "{reason}");
assert!(
!reason.contains("which is a project's main checkout"),
"the guard must not assert what it could not establish: {reason}"
);
}
#[test]
fn destructive_sees_past_an_unknown_git_global_option() {
let checkout = main_checkout_dir();
for command in [
"git --shallow-file status checkout -- Cargo.toml",
"git --attr-source status checkout -- Cargo.toml",
"git --shallow-file log reset --hard",
] {
assert!(
evaluate_main_checkout_destructive_command(command, checkout.path()).is_some(),
"must deny: {command}"
);
}
assert!(!command_is_a_lone_commit(
"git --shallow-file x commit -m wip"
));
}
#[test]
fn destructive_allows_a_path_restoring_checkout_in_a_sibling_worktree() {
let checkout = main_checkout_dir();
let sibling = checkout.path().join(".claude/worktrees/agent-b");
for command in [
format!("git -C {} checkout -- src/lib.rs", sibling.display()),
"git -C .claude/worktrees/agent-b checkout -- src/lib.rs".to_string(),
"cd .claude/worktrees/agent-b && git restore src/lib.rs".to_string(),
] {
assert!(
evaluate_main_checkout_destructive_command(&command, checkout.path()).is_none(),
"a sibling worktree is not the main checkout: {command}"
);
}
assert!(
evaluate_main_checkout_destructive_command(
"git checkout -- src/lib.rs",
checkout.path()
)
.is_some(),
"the main checkout itself stays denied"
);
}
#[test]
fn destructive_allows_a_checkout_in_a_scratchpad_clone_only() {
let dir = tempfile::tempdir().expect("tempdir");
let clone = dir.path().join("scratchpad").join("red-proof");
std::fs::create_dir_all(clone.join(".git")).expect("mkdir clone .git");
assert!(
evaluate_main_checkout_destructive_command(
"git checkout abc1234 -- src/lib.rs",
&clone
)
.is_none(),
"a scratchpad clone is nobody's shared tree"
);
let real = dir.path().join("realrepo");
std::fs::create_dir_all(real.join(".git")).expect("mkdir real .git");
let link = dir.path().join("scratchpad").join("linkrepo");
std::os::unix::fs::symlink(&real, &link).expect("symlink");
assert!(
evaluate_main_checkout_destructive_command("git checkout abc1234 -- src/lib.rs", &link)
.is_some(),
"a symlink into a real checkout must not buy the exemption"
);
assert!(
evaluate_main_checkout_destructive_command("git -C $WT checkout -- .", &clone)
.is_some(),
"an unresolved directory proves nothing about where the checkout runs"
);
}
#[test]
fn unresolved_directory_deny_reason_names_the_variable_and_the_remedy() {
let reason = unresolved_directory_deny_reason(
DESTRUCTIVE_UNRESOLVED_HEADLINE,
"checkout",
Path::new("/repo/$WT"),
"$WT",
);
assert!(reason.contains("/repo/$WT"), "{reason}");
assert!(reason.contains("$WT"), "{reason}");
assert!(reason.contains(".claude/worktrees/"), "{reason}");
}
fn no_home() -> PathEnv {
PathEnv {
tmpdir: None,
tmp: None,
home: None,
}
}
#[test]
fn destructive_deny_names_a_surviving_tilde_rather_than_the_checkout() {
let checkout = main_checkout_dir();
let reason = evaluate_main_checkout_destructive_command_in(
"git -C ~/audit-live-check-7137/repo-acme-test checkout -- .",
checkout.path(),
&no_home(),
)
.expect("an unresolvable target directory must still deny");
assert!(reason.contains("unresolvable"), "{reason}");
assert!(reason.contains('~'), "{reason}");
assert!(
!reason.contains("which is a project's main checkout"),
"the guard must not assert what it could not establish: {reason}"
);
assert!(
!reason.contains(&checkout.path().display().to_string()),
"the launch directory is not where this command lands: {reason}"
);
}
#[test]
fn commit_deny_names_a_surviving_tilde_rather_than_the_checkout() {
let checkout = main_checkout_dir();
let verdict = evaluate_main_checkout_commit_command_in(
"git -C ~/audit-live-check-7137/repo-acme-test commit --allow-empty -m test",
checkout.path(),
&no_home(),
)
.expect("an unresolvable target directory must still deny");
let CommitVerdict::Deny(reason) = verdict else {
panic!("a target the guard cannot place must never reach the docs-only carve-out");
};
assert!(reason.contains("unresolvable"), "{reason}");
assert!(
reason.contains("~/audit-live-check-7137/repo-acme-test"),
"the refusal must quote the token as written: {reason}"
);
assert!(
!reason.contains("is a project's main checkout"),
"the guard must not assert what it could not establish: {reason}"
);
assert!(
!reason.contains(&checkout.path().display().to_string()),
"the launch directory is not where this command lands: {reason}"
);
}
#[test]
fn commit_with_home_set_resolves_the_tilde_and_leaves_the_checkout_alone() {
let checkout = main_checkout_dir();
let home = tempfile::tempdir().expect("tempdir");
let env = PathEnv {
tmpdir: None,
tmp: None,
home: Some(home.path().display().to_string()),
};
assert!(
evaluate_main_checkout_commit_command_in(
"git -C ~/audit-live-check-7137/repo-acme-test commit --allow-empty -m test",
checkout.path(),
&env,
)
.is_none(),
"a resolved target outside any checkout is not this rule's business"
);
}
#[test]
fn commit_deny_reason_names_the_path_and_the_remedy() {
let reason = commit_deny_reason(Path::new("/repo/main"));
assert!(reason.contains("/repo/main"), "{reason}");
assert!(reason.contains(r#"isolation: "worktree""#), "{reason}");
assert!(reason.contains("Nothing is lost"), "{reason}");
assert!(reason.contains("ADR-0049"), "{reason}");
assert!(reason.contains("git add"), "{reason}");
}
fn classify(tail: &[&str], staged: Option<&[&str]>) -> CommitVerdict {
let tail: Vec<String> = tail.iter().map(|s| (*s).to_string()).collect();
let staged = staged.map(|s| s.iter().map(|p| (*p).to_string()).collect());
classify_staged_commit(
&tail,
staged,
Path::new("/repo/crates"),
PathBuf::from("/repo"),
)
}
fn is_docs_only(verdict: &CommitVerdict) -> bool {
matches!(verdict, CommitVerdict::DocsOnly { .. })
}
fn deny_text(verdict: &CommitVerdict) -> &str {
match verdict {
CommitVerdict::Deny(reason) => reason,
CommitVerdict::DocsOnly { .. } => panic!("expected a deny"),
}
}
#[test]
fn classify_staged_commit_permits_documents_and_configuration() {
for staged in [
&["docs/adr/0049-x.md"][..],
&["CLAUDE.md", "Cargo.toml"][..],
&[".claude/settings.json", "TASK.md", "Makefile"][..],
&["crates/trusty-mpm/changelog.d/5782-x.md"][..],
] {
let verdict = classify(&["-m", "docs: x"], Some(staged));
assert!(is_docs_only(&verdict), "{staged:?} is documents-only");
}
let CommitVerdict::DocsOnly { root, dirs } = classify(&[], Some(&["README.md"])) else {
panic!("expected DocsOnly");
};
assert_eq!(root, PathBuf::from("/repo"));
assert_eq!(
dirs,
[PathBuf::from("/repo"), PathBuf::from("/repo/crates")]
);
}
#[test]
fn classify_staged_commit_denies_source_and_names_it() {
let verdict = classify(&["-m", "feat: x"], Some(&["crates/a/src/lib.rs"]));
let reason = deny_text(&verdict);
assert!(reason.contains("crates/a/src/lib.rs"), "{reason}");
assert!(reason.contains("ADR-0049"), "{reason}");
assert!(reason.contains("git restore --staged"), "{reason}");
}
#[test]
fn classify_staged_commit_denies_a_mixed_set_and_names_only_the_source() {
let verdict = classify(
&["-m", "wip"],
Some(&["docs/x.md", "src/a.rs", "Cargo.toml", "src/b.py"]),
);
let reason = deny_text(&verdict);
assert!(
reason.contains("src/a.rs") && reason.contains("src/b.py"),
"{reason}"
);
assert!(!reason.contains("docs/x.md"), "{reason}");
assert!(!reason.contains("Cargo.toml"), "{reason}");
}
#[test]
fn classify_staged_commit_denies_an_unreadable_or_empty_index() {
for staged in [None, Some(&[][..])] {
let verdict = classify(&["-m", "wip"], staged);
assert!(deny_text(&verdict).contains("ADR-0044"), "{staged:?}");
}
}
#[test]
fn classify_staged_commit_denies_the_forms_the_index_does_not_describe() {
for tail in [
&["-a", "-m", "wip"][..],
&["-am", "wip"][..],
&["--all"][..],
&["--amend", "--no-edit"][..],
&["-i", "--", "src/lib.rs"][..],
&["--include", "src/lib.rs"][..],
&["-o", "docs/x.md"][..],
&["--only", "docs/x.md"][..],
&["docs/x.md"][..],
&["-m", "wip", "--", "docs/x.md"][..],
] {
let verdict = classify(tail, Some(&["docs/x.md"]));
assert!(
!is_docs_only(&verdict),
"`git commit {}` must not be licensed by the index",
tail.join(" ")
);
}
}
#[test]
fn commit_flags_leave_the_index_authoritative_accepts_message_forms() {
for tail in [
&[][..],
&["-m", "docs: x"][..],
&["--message", "docs: x"][..],
&["--message=docs: x"][..],
&["-m", "docs: x", "-s"][..],
&["-m", "x", "--no-verify"][..],
&["-q", "-m", "x"][..],
&["--author", "A <a@b>", "-m", "x"][..],
&["--date=2026-08-16", "-m", "x"][..],
&["-S", "-m", "x"][..],
&["-Sdeadbeef", "-m", "x"][..],
&["--gpg-sign=deadbeef", "-m", "x"][..],
&["-F", "/tmp/msg", "--cleanup=strip"][..],
&["--trailer", "Closes: #1", "-m", "x"][..],
&["--allow-empty", "-m", "x"][..],
&["-m", "--amend"][..],
] {
let tail: Vec<String> = tail.iter().map(|s| (*s).to_string()).collect();
assert!(
commit_flags_leave_the_index_authoritative(&tail),
"`git commit {}` leaves the index authoritative",
tail.join(" ")
);
}
}
#[test]
fn commit_flags_leave_the_index_authoritative_rejects_content_flags() {
for tail in [
&["-a"][..],
&["--all"][..],
&["--amend"][..],
&["-i"][..],
&["--include"][..],
&["-o"][..],
&["--only"][..],
&["-sn"][..],
&["--some-future-flag"][..],
] {
let tail: Vec<String> = tail.iter().map(|s| (*s).to_string()).collect();
assert!(
!commit_flags_leave_the_index_authoritative(&tail),
"`git commit {}` must not be licensed by the index",
tail.join(" ")
);
}
}
#[test]
fn commit_flags_leave_the_index_authoritative_rejects_a_pathspec() {
for tail in [
&["docs/x.md"][..],
&["-m", "x", "docs/x.md"][..],
&["--"][..],
] {
let tail: Vec<String> = tail.iter().map(|s| (*s).to_string()).collect();
assert!(
!commit_flags_leave_the_index_authoritative(&tail),
"a pathspec is `--only` by implication: {}",
tail.join(" ")
);
}
}
#[test]
fn command_is_a_lone_commit_accepts_a_commit_and_cd() {
for command in [
"git commit -m 'docs: x'",
"cd /repo && git commit -m 'docs: x'",
"cd /repo && cd docs && git commit -m x",
"git -C /repo commit -m x",
] {
assert!(
command_is_a_lone_commit(command),
"`{command}` is a lone commit"
);
}
}
#[test]
fn command_is_a_lone_commit_rejects_a_second_commit() {
for command in [
"git commit -m docs && git add -A && git commit -a -m src",
"git commit -m docs ; git commit --amend --no-edit",
"git commit -m a || git commit -m b",
] {
assert!(
!command_is_a_lone_commit(command),
"`{command}` carries more than one commit"
);
}
}
#[test]
fn command_is_a_lone_commit_rejects_anything_that_can_restage() {
for command in [
"git add -A && git commit -m docs",
"echo x > src/lib.rs && git commit -m docs",
"git status && git commit -m docs",
"git commit -m docs && cargo test",
"git status",
] {
assert!(
!command_is_a_lone_commit(command),
"`{command}` is not a lone commit"
);
}
}
#[test]
fn composed_commit_deny_reason_names_the_composition_and_the_remedy() {
let reason = composed_commit_deny_reason(Path::new("/repo"));
assert!(reason.contains("/repo"), "{reason}");
assert!(reason.contains("ADR-0049"), "{reason}");
assert!(reason.contains("Split the call"), "{reason}");
assert!(reason.contains("git add -- <paths>"), "{reason}");
}
#[test]
fn commit_target_dir_hands_back_the_argv_tail() {
let env = PathEnv::from_process();
let (verb, dir, tail) = git_verb_target_dir_with_tail(
"cd /repo && git -C sub commit --amend -m wip",
Path::new("/start"),
&env,
|verb, _| verb == "commit",
)
.expect("a commit must resolve");
assert_eq!(verb, "commit");
assert_eq!(dir, PathBuf::from("/repo/sub"));
assert_eq!(tail, vec!["--amend", "-m", "wip"]);
}
#[test]
fn staged_source_deny_reason_names_every_source_file() {
let reason = staged_source_deny_reason(Path::new("/repo"), &["src/b.rs", "src/a.rs"]);
assert!(
reason.contains("src/a.rs") && reason.contains("src/b.rs"),
"{reason}"
);
assert!(
reason.find("src/a.rs") < reason.find("src/b.rs"),
"{reason}"
);
assert!(reason.contains(r#"isolation: "worktree""#), "{reason}");
}
#[test]
fn docs_commit_deny_reason_names_the_writer_and_attributes_the_claim() {
let reason = docs_commit_deny_reason(Path::new("/repo"), &["rust-engineer".to_string()]);
assert!(reason.contains("/repo"), "{reason}");
assert!(reason.contains("rust-engineer"), "{reason}");
assert!(reason.contains("ADR-0049"), "{reason}");
assert!(reason.contains("delegation records"), "{reason}");
assert!(reason.contains("unstaging it will not help"), "{reason}");
assert!(reason.contains(r#"isolation: "worktree""#), "{reason}");
}
#[test]
fn starts_a_head_move_permits_pull_and_fetch() {
for (verb, args) in [
("pull", vec![]),
("pull", vec!["--rebase"]),
("pull", vec!["origin", "main"]),
("pull", vec!["--ff-only"]),
("fetch", vec!["origin"]),
("fetch", vec!["--all", "--prune"]),
] {
assert!(
!starts_a_head_move(verb, &tail(&args)),
"`git {verb} {}` is permitted in a main checkout (ADR-0053)",
args.join(" ")
);
}
}
#[test]
fn starts_a_head_move_still_covers_merge_and_rebase() {
for (verb, args) in [
("merge", vec!["origin/main"]),
("merge", vec!["--no-ff", "feature/x"]),
("merge", vec!["--squash", "feature/x"]),
("rebase", vec!["origin/main"]),
("rebase", vec!["-i", "HEAD~3"]),
("rebase", vec!["--onto", "main", "feature/x"]),
] {
assert!(
starts_a_head_move(verb, &tail(&args)),
"expected `git {verb} {}` to start a HEAD move",
args.join(" ")
);
}
}
#[test]
fn starts_a_head_move_allows_in_progress_control() {
for (verb, args) in [
("rebase", vec!["--abort"]),
("rebase", vec!["--continue"]),
("rebase", vec!["--skip"]),
("rebase", vec!["--quit"]),
("rebase", vec!["--edit-todo"]),
("rebase", vec!["--show-current-patch"]),
("merge", vec!["--abort"]),
("merge", vec!["--quit"]),
("merge", vec!["--continue"]),
] {
assert!(
!starts_a_head_move(verb, &tail(&args)),
"`git {verb} {}` resolves an in-progress operation and must be allowed",
args.join(" ")
);
}
}
#[test]
fn starts_a_head_move_ignores_everything_else() {
for (verb, args) in [
("fetch", vec!["origin"]),
("fetch", vec!["--all", "--prune"]),
("status", vec!["--short"]),
("log", vec!["--oneline", "-20"]),
("diff", vec!["--stat"]),
("merge-base", vec!["main", "HEAD"]),
("merge-tree", vec!["main", "HEAD"]),
("checkout", vec!["main"]),
("switch", vec!["main"]),
("pull-request", vec![]),
("commit", vec!["-m", "wip"]),
] {
assert!(
!starts_a_head_move(verb, &tail(&args)),
"`git {verb} {}` must not be treated as a HEAD move",
args.join(" ")
);
}
}
#[test]
fn starts_a_head_move_matches_in_progress_control_positionally() {
for args in [
vec!["-m", "--continue", "origin/main"],
vec!["origin/main", "-m", "--abort"],
] {
assert!(
starts_a_head_move("merge", &tail(&args)),
"`git merge {}` is a real merge and must still classify",
args.join(" ")
);
}
assert!(!starts_a_head_move("rebase", &tail(&["--continue"])));
}
#[test]
fn main_checkout_head_move_resolves_a_subdirectory_to_the_checkout_root() {
let checkout = main_checkout_dir();
let sub = checkout.path().join("crates/foo");
std::fs::create_dir_all(&sub).expect("mkdir sub");
let (verb, target, root) = main_checkout_head_move(
&format!("cd {} && git merge origin/main", sub.display()),
checkout.path(),
)
.expect("a merge from a subdirectory must resolve");
assert_eq!(verb, "merge");
assert_eq!(target, sub);
assert_eq!(root, checkout.path());
}
#[test]
fn main_checkout_head_move_finds_the_checkout_and_skips_a_worktree() {
let checkout = main_checkout_dir();
let (verb, target, root) =
main_checkout_head_move("git rebase origin/main", checkout.path())
.expect("a rebase in a main checkout must resolve");
assert_eq!(verb, "rebase");
assert_eq!(target, checkout.path());
assert_eq!(root, checkout.path());
let worktree = tempfile::tempdir().expect("tempdir");
std::fs::write(worktree.path().join(".git"), "gitdir: /elsewhere").expect("write .git");
assert_eq!(
main_checkout_head_move("git rebase origin/main", worktree.path()),
None
);
let plain = tempfile::tempdir().expect("tempdir");
assert_eq!(
main_checkout_head_move("git merge main", plain.path()),
None
);
}
#[test]
fn main_checkout_head_move_follows_cd_and_dash_c_and_later_segments() {
let checkout = main_checkout_dir();
let path = checkout.path().display();
let outside = tempfile::tempdir().expect("tempdir");
let (_, via_dash_c, _) =
main_checkout_head_move(&format!("git -C {path} rebase origin/main"), outside.path())
.expect("-C must move the target");
assert_eq!(via_dash_c, checkout.path());
let (_, via_cd, _) = main_checkout_head_move(
&format!("cd {path} && git merge origin/main"),
outside.path(),
)
.expect("cd must move the target");
assert_eq!(via_cd, checkout.path());
for command in [
"git fetch origin && git merge origin/main",
"git pull && git merge origin/main",
] {
let (verb, _, _) = main_checkout_head_move(command, checkout.path())
.expect("the second segment must be classified");
assert_eq!(verb, "merge", "`{command}` must classify its merge");
}
}
#[test]
fn main_checkout_head_move_is_none_for_ordinary_work_in_a_checkout() {
let checkout = main_checkout_dir();
for command in [
"git fetch origin",
"git pull",
"git pull --ff-only",
"git pull --rebase",
"cd crates/foo && git pull",
"git status --porcelain",
"git log --oneline -5",
"git worktree list",
"cargo test -p trusty-mpm",
"git rebase --abort",
"",
] {
assert_eq!(
main_checkout_head_move(command, checkout.path()),
None,
"`{command}` must not resolve a HEAD move"
);
}
}
#[test]
fn head_move_deny_reason_names_the_verb_the_path_and_both_remedies() {
let reason = head_move_deny_reason(
"merge",
Path::new("/repo/main"),
&["rust-engineer".to_string(), "rust-engineer".to_string()],
);
assert!(reason.contains("ADR-0048"), "{reason}");
assert!(reason.contains("git merge"), "{reason}");
assert!(reason.contains("/repo/main"), "{reason}");
assert_eq!(reason.matches("rust-engineer").count(), 1, "{reason}");
assert!(reason.contains("git fetch"), "{reason}");
assert!(reason.contains("git pull"), "{reason}");
assert!(reason.contains("ADR-0053"), "{reason}");
assert!(reason.contains(r#"isolation: "worktree""#), "{reason}");
}
#[test]
fn head_move_deny_reason_attributes_the_claim_to_the_daemon() {
let reason = head_move_deny_reason("merge", Path::new("/repo/main"), &["qa".to_string()]);
assert!(
reason.contains("the daemon's delegation records name"),
"the claim must be attributed to its source: {reason}"
);
assert!(
!reason.contains("is already writing there"),
"the deny must not assert another agent's behaviour as fact: {reason}"
);
}
#[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}");
}
}