use super::tables::GIT_HOST_SUBCOMMANDS;
use super::{ClassifyError, ShellContext, Sink};
use crate::action::AtomicAction;
const FLAGS: &[&str] = &[
"-p",
"--paginate",
"-P",
"--no-pager",
"--bare",
"--no-replace-objects",
"--no-lazy-fetch",
"--no-optional-locks",
"--no-advice",
"--literal-pathspecs",
"--glob-pathspecs",
"--noglob-pathspecs",
"--icase-pathspecs",
];
const GIT_DIR: &str = "--git-dir";
const DIRECTORIES: &[&str] = &["-C", GIT_DIR, "--work-tree"];
const CONFIG: &[&str] = &["-c", "--config-env"];
const OTHER_VALUES: &[&str] = &["--namespace", "--super-prefix", "--attr-source"];
const INERT_CONFIG: &[&str] = &[
"color.",
"column.",
"advice.",
"log.",
"user.name",
"user.email",
"core.quotepath",
"core.autocrlf",
"core.abbrev",
"init.defaultbranch",
"pull.rebase",
];
const CONFIG_VALUED: &[&str] = &[
"-f",
"--file",
"--blob",
"-t",
"--type",
"--default",
"--comment",
"--value",
"--url",
];
const CONFIG_SUBCOMMANDS: &[&str] = &[
"get",
"set",
"unset",
"list",
"edit",
"rename-section",
"remove-section",
];
pub(crate) struct GitCommand<'a> {
pub subcommand: Vec<String>,
pub directories: Vec<&'a str>,
pub chdirs: Vec<&'a str>,
pub keep_original: bool,
}
pub(crate) fn parse(argv: &[String]) -> GitCommand<'_> {
let mut directories = Vec::new();
let mut chdirs = Vec::new();
let mut keep_original = false;
let mut i = 1;
while let Some(arg) = argv.get(i).filter(|a| a.starts_with('-')) {
i += 1;
let (name, inline) = match arg.split_once('=') {
Some((name, value)) if name.starts_with("--") => (name, Some(value)),
_ => (arg.as_str(), None),
};
if inline.is_none() && FLAGS.contains(&name) {
continue;
}
if ![DIRECTORIES, CONFIG, OTHER_VALUES]
.iter()
.any(|options| options.contains(&name))
{
keep_original = true;
continue;
}
let Some(value) = inline.or_else(|| argv.get(i).map(String::as_str)) else {
keep_original = true;
break;
};
i += usize::from(inline.is_none());
if DIRECTORIES.contains(&name) {
directories.push(value);
if name == "-C" {
chdirs.push(value);
}
keep_original |= name == GIT_DIR;
} else if CONFIG.contains(&name) {
let key = value
.split_once('=')
.map_or(value, |(k, _)| k)
.to_ascii_lowercase();
keep_original |= !INERT_CONFIG
.iter()
.any(|k| key == *k || (k.ends_with('.') && key.starts_with(k)));
}
}
GitCommand {
subcommand: argv[..1].iter().chain(&argv[i..]).cloned().collect(),
directories,
chdirs,
keep_original,
}
}
pub(super) fn names_hosts(argv: &[String]) -> bool {
parse(argv)
.subcommand
.get(1)
.is_some_and(|s| GIT_HOST_SUBCOMMANDS.contains(&s.as_str()))
}
pub(super) fn config_name(argv: &[String]) -> Option<usize> {
let start = argv.len() + 1 - parse(argv).subcommand.len();
if argv.get(start).map(String::as_str) != Some("config") {
return None;
}
let mut i = start + 1;
let mut subcommand_seen = false;
while let Some(arg) = argv.get(i) {
i += 1;
if arg.starts_with('-') {
i += usize::from(CONFIG_VALUED.contains(&arg.as_str()));
} else if !subcommand_seen && CONFIG_SUBCOMMANDS.contains(&arg.as_str()) {
subcommand_seen = true;
} else {
return Some(i - 1);
}
}
None
}
pub(super) fn push_shell(
argv: &[String],
ctx: &ShellContext<'_>,
sink: &mut Sink,
) -> Result<(), ClassifyError> {
let git = parse(argv);
for dir in git.directories {
sink.read(ctx, dir)?;
}
if git.subcommand.get(1).is_some_and(|s| s == "restore") {
let mut options = true;
for word in &git.subcommand[2..] {
if options && word == "--" {
options = false;
} else if !options || !word.starts_with('-') {
sink.write(ctx, word)?;
}
}
}
for (path, writes) in remote_paths(&git.subcommand) {
if writes {
sink.write(ctx, path)?;
} else {
sink.read(ctx, path)?;
}
}
sink.push(AtomicAction::Shell {
argv: git.subcommand,
})?;
if git.keep_original {
sink.push(AtomicAction::Shell {
argv: argv.to_vec(),
})?;
}
Ok(())
}
const REMOTE_VALUED: &[&str] = &[
"-o",
"--origin",
"-b",
"--branch",
"-u",
"--upload-pack",
"--receive-pack",
"--exec",
"--reference",
"--reference-if-able",
"--separate-git-dir",
"--depth",
"--deepen",
"--shallow-since",
"--shallow-exclude",
"-c",
"--config",
"--template",
"-j",
"--jobs",
"--filter",
"--server-option",
"--bundle-uri",
"--revision",
"--ref-format",
"--negotiation-tip",
"--refmap",
"--repo",
"--push-option",
"--sort",
"--format",
"--prefix",
"--output",
"--remote",
];
fn remote_paths(sub: &[String]) -> Vec<(&str, bool)> {
let Some(name) = sub.get(1).map(String::as_str) else {
return Vec::new();
};
let mut operands = Vec::new();
let mut remote = None;
let mut words = sub[2..].iter();
let mut options = true;
while let Some(word) = words.next() {
if options && word == "--" {
options = false;
} else if options && word.starts_with('-') {
let flag = word == "-u" && matches!(name, "fetch" | "pull");
let (option, value) = match word.split_once('=') {
Some((option, value)) => (option, Some(value)),
None if !flag && REMOTE_VALUED.contains(&word.as_str()) => {
(word.as_str(), words.next().map(String::as_str))
}
None => (word.as_str(), None),
};
if option == "--remote" {
remote = value;
}
} else {
operands.push(word.as_str());
}
}
let mut paths = Vec::new();
match name {
"clone" => {
paths.extend(operands.first().map(|r| (*r, false)));
paths.extend(operands.get(1).map(|d| (*d, true)));
}
"fetch" | "pull" | "ls-remote" => paths.extend(operands.first().map(|r| (*r, false))),
"push" => paths.extend(operands.first().map(|r| (*r, true))),
"archive" => paths.extend(remote.map(|r| (r, false))),
_ => {}
}
let is_dest = |path: &str, writes: bool| name == "clone" && writes && !path.is_empty();
paths.retain(|&(path, writes)| is_dest(path, writes) || is_local_repository(path));
paths
}
fn is_local_repository(remote: &str) -> bool {
let head = remote.split('/').next().unwrap_or(remote);
!remote.is_empty()
&& !remote.contains("://")
&& !head.contains(':')
&& (remote.contains('/') || remote.starts_with(['.', '~']))
}
#[cfg(test)]
mod tests {
use super::{config_name, parse, remote_paths};
#[test]
fn local_remotes_are_read_and_clone_and_push_targets_written() {
for (cmd, paths) in [
("git clone ../r", &[("../r", false)][..]),
(
"git clone --depth 1 /srv/r ~/.claude",
&[("/srv/r", false), ("~/.claude", true)],
),
("git clone https://x.example/r d", &[("d", true)]),
("git clone host:r", &[]),
("git fetch origin main", &[]),
("git fetch ~/.ssh/r", &[("~/.ssh/r", false)]),
("git fetch -u ../r", &[("../r", false)]),
("git ls-remote -u up .", &[(".", false)]),
("git push ../mirror main", &[("../mirror", true)]),
("git push origin main", &[]),
("git archive --remote=../r HEAD", &[("../r", false)]),
("git archive --remote ~/r HEAD", &[("~/r", false)]),
("git log ../r", &[]),
] {
let argv = argv(cmd);
assert_eq!(remote_paths(&argv), paths, "{cmd}");
}
}
fn argv(cmd: &str) -> Vec<String> {
cmd.split(' ').map(str::to_owned).collect()
}
fn keeps_original(cmd: &str) -> bool {
parse(&argv(cmd)).keep_original
}
#[test]
fn a_named_git_directory_keeps_the_command_as_written() {
assert!(keeps_original("git --git-dir=evil status"));
assert!(keeps_original("git --git-dir evil log"));
assert!(!keeps_original("git -C sub status"));
assert!(!keeps_original("git --work-tree=sub status"));
}
#[test]
fn the_config_name_is_found_past_options_and_subcommands() {
for (cmd, name) in [
("git config --get user.name", Some(3)),
("git -C sub config --global user.email", Some(5)),
("git config -f .gitmodules --get-regexp path", Some(5)),
("git config --file=x --get user.name", Some(4)),
("git config get --show-origin user.name", Some(4)),
("git config --list", None),
("git log user.name", None),
("git", None),
] {
assert_eq!(config_name(&argv(cmd)), name, "{cmd}");
}
}
}