const GIT_READONLY_SUBCMDS: &[&str] = &[
"status",
"log",
"diff",
"show",
"blame",
"rev-parse",
"ls-files",
"ls-tree",
"cat-file",
"describe",
"shortlog",
"whatchanged",
"grep",
];
const GIT_BRANCH_LIST_FLAGS: &[&str] = &[
"--show-current",
"-a",
"--all",
"-r",
"--remotes",
"-v",
"-vv",
"--verbose",
"--list",
"--no-color",
];
const GIT_REMOTE_LIST_FLAGS: &[&str] = &["-v", "--verbose"];
const GIT_OUTPUT_FLAG: &str = "--output";
const GIT_GREP_EXEC_LONG: &str = "--open-files-in-pager";
const GIT_GREP_EXEC_MIN_PREFIX: usize = 4;
const GIT_GREP_EXEC_SHORT: char = 'O';
const DIFF_FAMILY: &[&str] = &["log", "whatchanged", "show", "diff"];
const PATCH_BY_DEFAULT: &[&str] = &["show", "diff"];
const NO_TEXTCONV: &str = "--no-textconv";
const NO_EXT_DIFF: &str = "--no-ext-diff";
const TEXTCONV: &str = "--textconv";
const EXT_DIFF: &str = "--ext-diff";
const SHOW_SIGNATURE: &str = "--show-signature";
const FORMAT_SIGNATURE_PLACEHOLDER: &str = "%G";
const PATCH_SUPPRESSORS: &[&str] = &[
"-s",
"--no-patch",
"--quiet",
"--stat",
"--numstat",
"--shortstat",
"--summary",
"--dirstat",
"--name-only",
"--name-status",
"--raw",
];
const NO_PATCH_FLAGS: &[&str] = &[
"--oneline",
"-n",
"--max-count",
"--skip",
"--since",
"--until",
"--after",
"--before",
"--author",
"--committer",
"--grep",
"-i",
"--regexp-ignore-case",
"--all",
"--branches",
"--tags",
"--remotes",
"--decorate",
"--no-decorate",
"--graph",
"--format",
"--pretty",
"--abbrev-commit",
"--no-abbrev-commit",
"--date",
"--relative-date",
"--reverse",
"--first-parent",
"--no-merges",
"--merges",
"--follow",
"--topo-order",
"--date-order",
"--left-right",
"--parents",
"--no-walk",
"--no-color",
"--color",
"--cached",
"--staged",
"-M",
"--find-renames",
NO_TEXTCONV,
NO_EXT_DIFF,
];
const STATUS_VERBOSE_LONG: &str = "--verbose";
const STATUS_VERBOSE_SHORT: char = 'v';
const CAT_FILE_EXEC_LONG: &[&str] = &[TEXTCONV, "--filters"];
const GREP_EXEC_LONG: &[&str] = &[TEXTCONV];
const LONG_PREFIX_MIN: usize = 3;
pub(super) fn is_readonly<'a>(mut toks: impl Iterator<Item = &'a str>) -> bool {
match toks.next() {
Some("branch") => only_flags(toks, GIT_BRANCH_LIST_FLAGS),
Some("remote") => only_flags(toks, GIT_REMOTE_LIST_FLAGS),
Some(sub) if GIT_READONLY_SUBCMDS.contains(&sub) => {
let args: Vec<&str> = toks.collect();
!args.iter().any(|t| flag_writes(sub, t))
&& runs_no_driver(sub, &args)
&& worktree::stays_off_worktree(sub, &args)
}
_ => false,
}
}
fn only_flags<'a>(mut rest: impl Iterator<Item = &'a str>, allowed: &[&str]) -> bool {
rest.all(|t| allowed.contains(&t))
}
fn flag_name(arg: &str) -> &str {
arg.split_once('=').map_or(arg, |(n, _)| n)
}
fn long_prefix_of(arg: &str, targets: &[&str]) -> bool {
let name = flag_name(arg);
name.starts_with("--")
&& name.len() >= LONG_PREFIX_MIN
&& targets.iter().any(|t| t.starts_with(name))
}
fn flag_writes(sub: &str, arg: &str) -> bool {
let name = flag_name(arg);
if name == GIT_OUTPUT_FLAG {
return true;
}
if sub != "grep" {
return false;
}
if name.starts_with("--") {
return name.len() >= GIT_GREP_EXEC_MIN_PREFIX && GIT_GREP_EXEC_LONG.starts_with(name);
}
name.starts_with('-') && name.contains(GIT_GREP_EXEC_SHORT)
}
fn runs_no_driver(sub: &str, args: &[&str]) -> bool {
match sub {
"status" => !args.iter().any(|a| status_verbose(a)),
"cat-file" => !args.iter().any(|a| long_prefix_of(a, CAT_FILE_EXEC_LONG)),
"grep" => !args.iter().any(|a| long_prefix_of(a, GREP_EXEC_LONG)),
"blame" => {
args.contains(&NO_TEXTCONV) && !args.iter().any(|a| long_prefix_of(a, &[TEXTCONV]))
}
s if DIFF_FAMILY.contains(&s) => diff_family_is_safe(s, args),
_ => true,
}
}
fn status_verbose(arg: &str) -> bool {
if arg.starts_with("--") {
return long_prefix_of(arg, &[STATUS_VERBOSE_LONG]);
}
arg.starts_with('-') && arg.contains(STATUS_VERBOSE_SHORT)
}
fn diff_family_is_safe(sub: &str, args: &[&str]) -> bool {
let opts: Vec<&str> = args.iter().copied().take_while(|a| *a != "--").collect();
if opts.iter().any(|a| verifies_signature(a)) {
return false;
}
let drivers_off = opts.contains(&NO_TEXTCONV)
&& opts.contains(&NO_EXT_DIFF)
&& !opts.iter().any(|a| *a == TEXTCONV || *a == EXT_DIFF);
drivers_off || renders_no_patch(sub, &opts)
}
fn verifies_signature(arg: &str) -> bool {
arg == SHOW_SIGNATURE || arg.contains(FORMAT_SIGNATURE_PLACEHOLDER)
}
fn renders_no_patch(sub: &str, opts: &[&str]) -> bool {
let (flags, positionals): (Vec<&str>, Vec<&str>) =
opts.iter().partition(|a| a.starts_with('-'));
let suppressor = |f: &&str| PATCH_SUPPRESSORS.contains(&flag_name(f));
if !flags.iter().all(|f| suppressor(f) || is_no_patch_flag(f)) {
return false;
}
if !PATCH_BY_DEFAULT.contains(&sub) {
return true;
}
let objects_only =
sub == "show" && !positionals.is_empty() && positionals.iter().all(|p| p.contains(':'));
flags.iter().any(suppressor) || objects_only
}
fn is_no_patch_flag(arg: &str) -> bool {
let digits = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit());
if let Some(rest) = arg.strip_prefix('-')
&& (digits(rest) || rest.strip_prefix('n').is_some_and(digits))
{
return true;
}
NO_PATCH_FLAGS.contains(&flag_name(arg))
}
mod worktree;
#[cfg(test)]
mod tests;