use super::credential_print::{COMPOUND_OPENERS, KEYWORDS, is_identifier};
use super::heredoc::HeredocBodies;
use super::shell_lex::{self, QuoteScan, Scope};
use super::{MAX_WRAPPER_DEPTH, split_shell_segments, split_shell_segments_raw};
const WORD_BREAKS: &[u8] = b"();&|<>";
#[derive(Debug, PartialEq, Eq)]
pub(super) enum Step {
Command(String),
Enter,
Leave,
}
pub(super) struct Groups {
pub(super) steps: Vec<Step>,
pub(super) parsed: bool,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Group {
Subshell,
Brace {
scoped: bool,
},
}
struct Unbalanced;
struct Peeled {
command: Option<String>,
leaves: usize,
coproc: bool,
}
pub(super) fn grouped_steps(command: &str) -> Groups {
let mut steps = Vec::new();
let mut opaque = false;
match steps_into(command, 0, &mut steps, &mut opaque) {
Ok(()) => Groups {
steps,
parsed: !opaque,
},
Err(Unbalanced) => Groups {
steps: split_shell_segments(command)
.into_iter()
.map(Step::Command)
.collect(),
parsed: false,
},
}
}
fn steps_into(
command: &str,
depth: usize,
out: &mut Vec<Step>,
opaque: &mut bool,
) -> Result<(), Unbalanced> {
let mut open = Vec::new();
for raw in split_shell_segments_raw(command) {
let peeled = peel_segment(raw, &mut open, out, opaque)?;
if let Some(text) = peeled.command {
let (wrapped, scope) = shell_lex::wrapped_command_scoped(&text);
*opaque |= scope == Scope::Unplaceable;
let inner = match wrapped {
shell_lex::WrappedCommand::Inner(inner) if depth < MAX_WRAPPER_DEPTH => Some(inner),
_ => None,
};
if peeled.coproc {
out.push(Step::Enter);
}
out.push(Step::Command(text));
if let Some(inner) = inner {
let child = scope == Scope::Child;
if child {
out.push(Step::Enter);
}
steps_into(&inner, depth + 1, out, opaque)?;
if child {
out.push(Step::Leave);
}
}
if peeled.coproc {
out.push(Step::Leave);
}
}
out.extend(std::iter::repeat_with(|| Step::Leave).take(peeled.leaves));
}
if open.is_empty() {
Ok(())
} else {
Err(Unbalanced)
}
}
fn peel_segment(
raw: &str,
open: &mut Vec<Group>,
out: &mut Vec<Step>,
opaque: &mut bool,
) -> Result<Peeled, Unbalanced> {
let mut rest = raw;
let mut closed = false;
let mut coproc = false;
loop {
let t = rest.trim_start();
let word = first_word(t);
rest = if let Some(after) = t.strip_prefix('(') {
open.push(Group::Subshell);
out.push(Step::Enter);
coproc = false;
after
} else if let Some(after) = t.strip_prefix(')') {
pop(open, Group::Subshell)?;
out.push(Step::Leave);
closed = true;
after
} else if word == "{" {
open.push(Group::Brace { scoped: coproc });
if coproc {
out.push(Step::Enter);
}
coproc = false;
&t[1..]
} else if word == "}" {
match open.pop() {
Some(Group::Brace { scoped }) => {
if scoped {
out.push(Step::Leave);
}
}
_ => return Err(Unbalanced),
}
closed = true;
&t[1..]
} else if coproc && COMPOUND_OPENERS.contains(&word) {
return Err(Unbalanced);
} else if KEYWORDS.contains(&word) {
let after = t[word.len()..].trim_start();
if word == "coproc" {
coproc = true;
*opaque = true;
coproc_body(after)
} else {
match after.strip_prefix("-p") {
Some(p) if word == "time" && p.starts_with(char::is_whitespace) => p,
_ => after,
}
}
} else {
break;
};
}
if closed {
return Ok(Peeled {
command: None,
leaves: 0,
coproc: false,
});
}
let body = rest.trim();
let (end, leaves) = trailing_closers(body, open)?;
let command = body[..end].trim();
*opaque |= cannot_place(command);
Ok(Peeled {
command: (!command.is_empty()).then(|| command.to_string()),
leaves,
coproc,
})
}
fn first_word(t: &str) -> &str {
let end = t
.bytes()
.position(|b| b.is_ascii_whitespace() || WORD_BREAKS.contains(&b))
.unwrap_or(t.len());
&t[..end]
}
fn coproc_body(after: &str) -> &str {
let name = first_word(after);
let next = after[name.len()..].trim_start();
if is_identifier(name)
&& (next.starts_with('(') || COMPOUND_OPENERS.contains(&first_word(next)))
{
next
} else {
after
}
}
fn cannot_place(command: &str) -> bool {
let word = first_word(command);
let after = command[word.len()..].trim_start();
matches!(word, "case" | "function")
|| (!word.is_empty()
&& !word.contains('=')
&& after
.strip_prefix('(')
.is_some_and(|p| p.trim_start().starts_with(')')))
}
fn trailing_closers(body: &str, open: &mut Vec<Group>) -> Result<(usize, usize), Unbalanced> {
let quotes = QuoteScan::new(body);
let heredocs = HeredocBodies::scan(body);
let live = |i: usize| {
(!quotes.balanced || quotes.is_unquoted(i))
&& !heredocs.contains(i)
&& !heredocs.suppresses_separator(i)
};
let bytes = body.as_bytes();
let (mut depth, mut leaves) = (0usize, 0usize);
let mut end = None;
let mut i = 0;
while i < bytes.len() {
if !live(i) {
i += 1;
continue;
}
match bytes[i] {
b'\\' => i += 1,
b'(' if end.is_some() => return Err(Unbalanced),
b'(' => depth += 1,
b')' if depth > 0 => depth -= 1,
b')' => {
pop(open, Group::Subshell)?;
leaves += 1;
end.get_or_insert(i);
}
_ => {}
}
i += 1;
}
if depth > 0 {
return Err(Unbalanced);
}
Ok((end.unwrap_or(bytes.len()), leaves))
}
fn pop(open: &mut Vec<Group>, kind: Group) -> Result<(), Unbalanced> {
if open.pop() == Some(kind) {
Ok(())
} else {
Err(Unbalanced)
}
}
pub(super) fn mentions_git_commit(command: &str) -> bool {
mentions_git_verb(command, |verb, _| verb == "commit").is_some()
}
pub(super) fn mentions_git_verb(
command: &str,
matches: impl Fn(&str, &[String]) -> bool,
) -> Option<String> {
let words: Vec<String> = command
.split(|c: char| c.is_whitespace() || ";&|()<>`,{}".contains(c))
.map(|w| {
let bare: String = w
.chars()
.filter(|c| !matches!(c, '\'' | '"' | '\\'))
.collect();
bare.trim_matches(|c: char| "!$".contains(c)).to_string()
})
.filter(|w| !w.is_empty())
.collect();
let git = words
.iter()
.any(|w| w.rsplit('/').next().is_some_and(|base| base == "git"));
if !git {
return None;
}
(0..words.len())
.find(|&i| matches(&words[i], &words[i + 1..]))
.map(|i| words[i].clone())
}
pub(super) fn has_brace_expanded_program(segment: &str) -> bool {
let Some(argv) = shlex::split(segment) else {
return false;
};
let words: Vec<&str> = argv
.iter()
.map(|w| w.trim_start_matches('('))
.skip_while(|w| w.is_empty() || KEYWORDS.contains(w) || *w == "-p")
.collect();
let resolved = crate::commands::program_word::resolve_program_word(&words)
.ok()
.and_then(|word| words.get(word.index).copied());
words
.first()
.into_iter()
.copied()
.chain(resolved)
.any(is_brace_expansion)
}
fn is_brace_expansion(word: &str) -> bool {
word.match_indices('{').any(|(at, _)| {
!word[..at].ends_with('$')
&& word[at + 1..].find('}').is_some_and(|end| {
let inner = &word[at + 1..at + 1 + end];
inner.contains(',') || inner.contains("..")
})
})
}
pub(super) const BRACE_EXPANSION_REASON: &str = "this command's program word is a brace \
expansion (`{git,-C,<dir>,commit}`), which bash turns into a different command than the \
word the guard reads, so it cannot establish what would actually run. Spell the command \
out word by word.";
pub(super) const UNPARSED_GROUP_COMMIT_REASON: &str = "Commit denied because the guard cannot \
place it (ADR-0061, #9127): this command carries `git commit` inside a `( … )` subshell, \
a `{ …; }` brace group, a function definition, a `case`, a `coproc` or an `eval` behind \
`command`/`noglob` whose openers and closers the guard cannot pair up or whose commands \
it cannot follow, so it cannot tell \
which checkout the commit lands in, and a commit never lands on a local main checkout. A \
stray `)` or `}` counts too — in a `#` comment or an unquoted regex — so quote it or drop \
the comment. Run the commit as a plain command — `git -C \
/abs/path/.claude/worktrees/<name> commit …`, or `cd` into the worktree first — with no \
grouping around it. Nothing is lost; the changes are still in the tree.";
pub(super) fn unplaced_git_verb_reason(
command: &str,
matches: impl Fn(&str, &[String]) -> bool,
) -> Option<String> {
if grouped_steps(command).parsed {
return None;
}
mentions_git_verb(command, matches).map(|verb| unparsed_group_git_reason(&verb))
}
fn unparsed_group_git_reason(verb: &str) -> String {
format!(
"Git command denied because the guard cannot place it (#9127): this command carries \
`git {verb}` in a form that discards work, moves a HEAD or removes a worktree, inside a \
`( … )` subshell, a `{{ …; }}` brace group, a function definition, a `case`, a \
`coproc` or an `eval` behind `command`/`noglob` whose openers and closers the guard \
cannot pair up or whose commands it cannot follow, so it cannot tell which checkout or \
worktree it lands in. Run it as a plain command — `git -C \
/abs/path/.claude/worktrees/<name> {verb} …`, or `cd` into the worktree first — with \
no grouping around it."
)
}
#[cfg(test)]
mod tests {
use super::*;
fn commands(groups: &Groups) -> Vec<&str> {
groups
.steps
.iter()
.filter_map(|step| match step {
Step::Command(text) => Some(text.as_str()),
_ => None,
})
.collect()
}
#[test]
fn grouped_steps_peels_subshells_and_brace_groups() {
for (command, expected) in [
("(git commit -m x)", vec!["git commit -m x"]),
("( ( git commit ) )", vec!["git commit"]),
("((git commit))", vec!["git commit"]),
("{ git commit; }", vec!["git commit"]),
("{ git commit; } > /dev/null 2>&1", vec!["git commit"]),
(
"(git commit) 2>&1 && echo ok",
vec!["git commit", "echo ok"],
),
("( { git commit; } )", vec!["git commit"]),
("(cd x && git commit)", vec!["cd x", "git commit"]),
(
"if true; then git commit; fi",
vec!["true", "git commit", "fi"],
),
("time -p git commit", vec!["git commit"]),
("! git commit", vec!["git commit"]),
("arr=(a b); git commit", vec!["arr=(a b)", "git commit"]),
("echo $(date) x", vec!["echo $(date) x"]),
] {
let groups = grouped_steps(command);
assert!(groups.parsed, "`{command}` must parse");
assert_eq!(commands(&groups), expected, "{command}");
}
let groups = grouped_steps("(cd x && git commit); git log");
assert_eq!(
groups.steps,
[
Step::Enter,
Step::Command("cd x".into()),
Step::Command("git commit".into()),
Step::Leave,
Step::Command("git log".into()),
]
);
}
#[test]
fn grouped_steps_brackets_a_wrapper_string() {
let groups = grouped_steps("sh -c '(git commit)' && git log");
assert!(groups.parsed);
assert_eq!(
groups.steps,
[
Step::Command("sh -c '(git commit)'".into()),
Step::Enter,
Step::Enter,
Step::Command("git commit".into()),
Step::Leave,
Step::Leave,
Step::Command("git log".into()),
]
);
}
#[test]
fn grouped_steps_reports_an_unbalanced_group() {
for command in [
"(git commit",
"{ git commit",
"git commit; }",
"git commit )",
"{ git commit )",
"( git commit; }",
"(a) (b)",
"git commit (",
"f() { git commit; }; f",
"sh -c '(git commit'",
] {
let groups = grouped_steps(command);
assert!(!groups.parsed, "`{command}` must not parse");
assert_eq!(
commands(&groups),
split_shell_segments(command)
.iter()
.map(String::as_str)
.collect::<Vec<_>>()
);
}
}
#[test]
fn grouped_steps_ignores_quoted_and_heredoc_parens() {
for command in [
"git commit -m 'case a) and b)'",
"git commit -m \"fix(x: y\"",
"git commit -m a\\)",
"git commit -F- <<'EOF'\nfix: x\n\n1) one\nEOF",
] {
let groups = grouped_steps(command);
assert!(groups.parsed, "`{command}` must parse");
assert_eq!(commands(&groups).len(), 1, "{command}");
}
}
#[test]
fn grouped_steps_scopes_a_child_shell_but_not_eval() {
let eval = grouped_steps("eval \"cd /m\"; git commit");
assert!(eval.parsed);
assert_eq!(
eval.steps,
[
Step::Command("eval \"cd /m\"".into()),
Step::Command("cd /m".into()),
Step::Command("git commit".into()),
]
);
for carrier in [
"sh -c 'cd /m'",
"env -S 'cd /m'",
"flock /tmp/l -c 'cd /m'",
"xargs cd /m",
] {
let groups = grouped_steps(&format!("{carrier}; git commit"));
assert!(groups.parsed, "{carrier}");
assert_eq!(groups.steps[1], Step::Enter, "{carrier}");
assert_eq!(groups.steps[3], Step::Leave, "{carrier}");
}
for (prefix, scope) in [
("A=1 ", Scope::Current),
("builtin ", Scope::Current),
("A=1 builtin ", Scope::Current),
("env ", Scope::Child),
("nohup ", Scope::Child),
("timeout 5 ", Scope::Child),
("command ", Scope::Unplaceable),
("command -p ", Scope::Unplaceable),
("command -- ", Scope::Unplaceable),
("builtin -- ", Scope::Unplaceable),
("/bin/builtin ", Scope::Unplaceable),
("noglob ", Scope::Unplaceable),
("nocorrect ", Scope::Unplaceable),
] {
let command = format!("{prefix}eval 'cd /m'");
assert_eq!(
shell_lex::wrapped_command_scoped(&command).1,
scope,
"{command}"
);
let parsed = grouped_steps(&format!("{command}; git commit")).parsed;
assert_eq!(parsed, scope != Scope::Unplaceable, "{command}");
}
}
#[test]
fn grouped_steps_peels_and_scopes_a_coproc() {
for (command, expected) in [
("coproc git commit", vec!["git commit"]),
("coproc NAME { git commit; }", vec!["git commit"]),
("coproc NAME ( git commit )", vec!["git commit"]),
("coproc { git commit; }", vec!["git commit"]),
("(coproc git commit)", vec!["git commit"]),
] {
let groups = grouped_steps(command);
assert!(!groups.parsed, "`{command}` is a coproc, never placed");
assert_eq!(commands(&groups), expected, "{command}");
assert_eq!(groups.steps.first(), Some(&Step::Enter), "{command}");
assert_eq!(groups.steps.last(), Some(&Step::Leave), "{command}");
}
let groups = grouped_steps("coproc NAME while true; do git commit; done");
assert!(!groups.parsed);
}
#[test]
fn grouped_steps_marks_shapes_it_cannot_place() {
for command in [
"case x in (x) git commit;; esac",
"f() ( git commit ); f",
"f () ( git commit )",
"function f ( git commit )",
"sh -c 'case x in (x) git commit;; esac'",
] {
assert!(!grouped_steps(command).parsed, "`{command}` must not parse");
}
for command in ["arr=(); git commit", "git commit -m 'case (x) f() coproc'"] {
assert!(grouped_steps(command).parsed, "`{command}` must parse");
}
}
#[test]
fn mentions_git_commit_reads_words_not_substrings() {
for command in [
"(git commit",
"{ /usr/bin/git -C x commit",
"(\"git\" commit",
"(g''it c''ommit",
"(git co\\mmit",
"(g\"i\"t commit",
"(cd x;git commit",
"case x in (x) {git,-C,/m,commit,-a};; esac",
"f() { {git,commit}; }",
] {
assert!(mentions_git_commit(command), "{command}");
}
for command in [
"(git status",
"git log --grep=commit",
"(commit-msg",
"(git add src/{a,b}.rs",
] {
assert!(!mentions_git_commit(command), "{command}");
}
}
#[test]
fn mentions_git_verb_hands_the_matcher_the_words_after_it() {
let hard = |verb: &str, tail: &[String]| {
verb == "reset" && tail.first().is_some_and(|t| t == "--hard")
};
assert_eq!(
mentions_git_verb("coproc x; (git reset --hard", hard).as_deref(),
Some("reset")
);
assert_eq!(mentions_git_verb("(git reset HEAD", hard), None);
assert_eq!(mentions_git_verb("(reset --hard", hard), None);
}
}