use crate::Res;
use crate::error::Error;
use crate::git::diff::DiffType;
use std::iter;
use std::path::Path;
use std::process::Command;
use sysinfo::{Pid, Process, ProcessRefreshKind, ProcessesToUpdate, System, UpdateKind};
const SUBCOMMANDS: [&str; 3] = ["diff", "show", "log"];
const READ_ONLY: [&str; 10] = [
"diff", "show", "log", "blame", "grep", "shortlog", "reflog", "ls-files", "describe", "status",
];
const ANCESTORS: usize = 3;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct GitCommand {
argv: Vec<String>,
subcommand: usize,
}
impl GitCommand {
pub(crate) fn of(argv: Vec<String>) -> Option<Self> {
let program = Path::new(argv.first()?).file_stem()?.to_str()?;
if !program.eq_ignore_ascii_case("git") {
return None;
}
let subcommand = argv
.iter()
.position(|arg| SUBCOMMANDS.contains(&arg.as_str()))?;
Some(Self { argv, subcommand })
}
pub(crate) fn diff_type(&self) -> DiffType {
if self.subcommand() != "diff" {
return DiffType::TreeToTree;
}
let args: Vec<&str> = self.arguments().take_while(|arg| *arg != "--").collect();
if args.contains(&"--cached") || args.contains(&"--staged") {
DiffType::IndexToTree
} else if args.iter().any(|arg| !arg.starts_with('-')) {
DiffType::TreeToTree
} else {
DiffType::WorkdirToIndex
}
}
pub(crate) fn command(&self, context: Option<&str>) -> Command {
let words = self.words(context);
let mut command = Command::new(words[0]);
command.args(&words[1..=self.subcommand]);
command.args(["--no-ext-diff", "--no-color"]);
command.args(&words[self.subcommand + 1..]);
command
}
pub(crate) fn line(&self, context: Option<&str>) -> String {
let words = self.words(context);
iter::once("git")
.chain(words[1..].iter().copied())
.map(quoted)
.collect::<Vec<_>>()
.join(" ")
}
pub(crate) fn edited(&self, line: &str) -> Res<Self> {
let words = shell_words::split(line).map_err(|_| Error::EditedCommandQuotes)?;
let own = &self.argv[1..self.subcommand];
let [program, rest @ ..] = words.as_slice() else {
return Err(Error::EditedCommandFixed);
};
if program != "git" || !rest.starts_with(own) {
return Err(Error::EditedCommandFixed);
}
match rest.get(own.len()) {
None => return Err(Error::EditedCommandFixed),
Some(subcommand) if !READ_ONLY.contains(&subcommand.as_str()) => {
return Err(Error::EditedCommandWrites(subcommand.clone()));
}
Some(_) => (),
}
Ok(Self {
argv: iter::once(self.argv[0].clone())
.chain(rest.iter().cloned())
.collect(),
subcommand: self.subcommand,
})
}
pub(crate) fn file_patterns(&self) -> Vec<String> {
self.pathspecs().map(pattern_of).collect()
}
pub(crate) fn asking_for(&self, patterns: &[String]) -> Self {
let mut argv: Vec<String> = self.argv[..=self.subcommand].to_vec();
argv.extend(
self.arguments()
.take_while(|arg| *arg != "--")
.map(str::to_owned),
);
if !patterns.is_empty() {
argv.push("--".to_owned());
argv.extend(patterns.iter().map(|pattern| pathspec_of(pattern)));
}
Self {
argv,
subcommand: self.subcommand,
}
}
fn pathspecs(&self) -> impl Iterator<Item = &str> {
self.arguments().skip_while(|arg| *arg != "--").skip(1)
}
fn words<'a>(&'a self, context: Option<&'a str>) -> Vec<&'a str> {
let mut words: Vec<&str> = self.argv[..=self.subcommand]
.iter()
.map(String::as_str)
.collect();
words.extend(context);
match context {
Some(_) => words.extend(without_context(self.arguments())),
None => words.extend(self.arguments()),
}
words
}
fn subcommand(&self) -> &str {
&self.argv[self.subcommand]
}
fn arguments(&self) -> impl Iterator<Item = &str> + Clone {
self.argv[self.subcommand + 1..].iter().map(String::as_str)
}
}
fn pathspec_of(pattern: &str) -> String {
match pattern {
_ if pattern.starts_with(':') => pattern.to_owned(),
_ if let Some(glob) = pattern.strip_prefix('!') => format!(":(top,exclude){glob}"),
_ => format!(":(top){pattern}"),
}
}
fn pattern_of(pathspec: &str) -> String {
if let Some(glob) = pathspec.strip_prefix(":(top,exclude)") {
return format!("!{glob}");
}
pathspec
.strip_prefix(":(top)")
.unwrap_or(pathspec)
.to_owned()
}
pub(crate) fn excluding(path: &str) -> String {
if path.contains(['*', '?', '[', '\\']) {
return format!(":(top,exclude,literal){path}");
}
format!("!{path}")
}
fn quoted(word: &str) -> String {
const BARE: &str = "_@%+=:,./-~^";
if !word.is_empty()
&& word
.chars()
.all(|c| c.is_alphanumeric() || BARE.contains(c))
{
return word.to_owned();
}
format!("'{}'", word.replace('\'', r"'\''"))
}
fn without_context<'a>(args: impl Iterator<Item = &'a str>) -> Vec<&'a str> {
let mut kept = Vec::new();
let mut value_follows = false;
for arg in args {
if std::mem::take(&mut value_follows) {
continue;
}
match arg {
"-U" | "--unified" => value_follows = true,
"-W" | "--function-context" | "--no-function-context" => {}
_ if arg.starts_with("-U") || arg.starts_with("--unified=") => {}
_ => kept.push(arg),
}
}
kept
}
pub(crate) fn paging_for() -> Option<Vec<String>> {
let mut system = System::new();
let me = Pid::from_u32(std::process::id());
let mut descendant = me;
refresh(&mut system, &[me]);
for _ in 0..ANCESTORS {
let Some(ancestor) = system.process(descendant).and_then(Process::parent) else {
break;
};
refresh(&mut system, &[ancestor]);
if let Some(argv) = git_argv(&system, ancestor) {
return Some(argv);
}
descendant = ancestor;
}
let shell = system.process(me)?.parent()?;
let is_sibling = |system: &System, pid: Pid| {
pid != me && system.process(pid).and_then(Process::parent) == Some(shell)
};
let previous = Pid::from_u32(std::process::id().checked_sub(1)?);
refresh(&mut system, &[previous]);
if is_sibling(&system, previous)
&& let Some(argv) = git_argv(&system, previous)
{
return Some(argv);
}
system.refresh_processes_specifics(ProcessesToUpdate::All, true, command_lines());
let mut siblings = system
.processes()
.keys()
.filter(|&&pid| is_sibling(&system, pid))
.filter_map(|&pid| git_argv(&system, pid));
siblings.next().filter(|_| siblings.next().is_none())
}
fn refresh(system: &mut System, pids: &[Pid]) {
system.refresh_processes_specifics(ProcessesToUpdate::Some(pids), true, command_lines());
}
fn command_lines() -> ProcessRefreshKind {
ProcessRefreshKind::nothing().with_cmd(UpdateKind::Always)
}
fn git_argv(system: &System, pid: Pid) -> Option<Vec<String>> {
let argv: Vec<String> = system
.process(pid)?
.cmd()
.iter()
.map(|arg| arg.to_string_lossy().into_owned())
.collect();
GitCommand::of(argv).map(|git| git.argv)
}
#[cfg(test)]
mod tests {
use super::*;
fn git(args: &[&str]) -> GitCommand {
GitCommand::of(args.iter().map(|arg| (*arg).to_owned()).collect()).unwrap()
}
fn rerun(args: &[&str], context: Option<&str>) -> Vec<String> {
let command = git(args).command(context);
std::iter::once(command.get_program())
.chain(command.get_args())
.map(|arg| arg.to_string_lossy().into_owned())
.collect()
}
#[test]
fn a_git_command_is_recognised_behind_its_own_options() {
assert_eq!(
git(&["/usr/bin/git", "-c", "color.diff=always", "diff"]).subcommand(),
"diff"
);
assert_eq!(git(&["git.exe", "show", "HEAD"]).subcommand(), "show");
assert_eq!(GitCommand::of(vec!["ls".to_owned()]), None);
assert_eq!(GitCommand::of(vec!["git".to_owned()]), None);
assert_eq!(
GitCommand::of(["git", "status"].map(String::from).to_vec()),
None
);
}
#[test]
fn the_command_says_what_the_patch_is_a_diff_of() {
assert_eq!(git(&["git", "diff"]).diff_type(), DiffType::WorkdirToIndex);
assert_eq!(
git(&["git", "diff", "-U8", "--", "src/lib.rs"]).diff_type(),
DiffType::WorkdirToIndex
);
assert_eq!(
git(&["git", "diff", "--cached"]).diff_type(),
DiffType::IndexToTree
);
assert_eq!(
git(&["git", "diff", "main"]).diff_type(),
DiffType::TreeToTree
);
assert_eq!(
git(&["git", "show", "HEAD"]).diff_type(),
DiffType::TreeToTree
);
}
#[test]
fn the_line_offered_for_editing_is_the_effective_command() {
assert_eq!(git(&["git", "diff", "main"]).line(None), "git diff main");
assert_eq!(
git(&["/usr/bin/git", "-c", "color.ui=always", "diff"]).line(None),
"git -c color.ui=always diff"
);
assert_eq!(
git(&["git", "diff", "-U10", "main"]).line(Some("-U2")),
"git diff -U2 main"
);
assert_eq!(
git(&["git", "log", "-p", "--", "a file"]).line(None),
"git log -p -- 'a file'"
);
}
#[test]
fn an_edit_may_ask_git_anything_that_only_reports() {
let edited = |line: &str| {
git(&["/usr/bin/git", "diff", "main"])
.edited(line)
.map(|git| git.line(None))
};
assert_eq!(edited("git show HEAD~2").unwrap(), "git show HEAD~2");
assert_eq!(edited("git log -p -- src").unwrap(), "git log -p -- src");
assert_eq!(
edited("git diff main -- ':(top,exclude)*_test.go'").unwrap(),
"git diff main -- ':(top,exclude)*_test.go'"
);
}
#[test]
fn an_edit_leaves_the_program_and_gits_own_options_alone() {
let git = git(&["/usr/bin/git", "-c", "color.ui=always", "diff"]);
let program = |line: &str| Ok::<_, Error>(git.edited(line)?.argv[0].clone());
assert_eq!(
program("git -c color.ui=always show").unwrap(),
"/usr/bin/git"
);
assert!(matches!(
program("/usr/bin/git -c color.ui=always show"),
Err(Error::EditedCommandFixed)
));
assert!(matches!(
program("git show"),
Err(Error::EditedCommandFixed)
));
assert!(matches!(
program("git -c color.ui=always --git-dir=/elsewhere show"),
Err(Error::EditedCommandWrites(_))
));
}
#[test]
fn an_edit_that_would_change_the_repository_is_refused() {
let git = git(&["git", "diff"]);
assert!(matches!(
git.edited("git commit --amend"),
Err(Error::EditedCommandWrites(subcommand)) if subcommand == "commit"
));
assert!(matches!(
git.edited("git reset --hard"),
Err(Error::EditedCommandWrites(_))
));
assert!(matches!(git.edited("git"), Err(Error::EditedCommandFixed)));
assert!(matches!(
git.edited("git diff 'unbalanced"),
Err(Error::EditedCommandQuotes)
));
}
#[test]
fn the_paths_asked_for_round_trip_through_their_patterns() {
let patterns = ["src", "!*_test.go", ":(top,exclude,literal)a[1].go"].map(String::from);
let asked = git(&["git", "diff", "main"]).asking_for(&patterns);
assert_eq!(
asked.line(None),
"git diff main -- ':(top)src' ':(top,exclude)*_test.go' ':(top,exclude,literal)a[1].go'"
);
assert_eq!(asked.file_patterns(), patterns);
assert_eq!(asked.asking_for(&asked.file_patterns()), asked);
}
#[test]
fn the_paths_asked_for_are_replaced_wholesale() {
let git = git(&["git", "diff", "-U8", "main", "--", "src"]);
assert_eq!(
git.asking_for(&["lib".to_owned()]).line(None),
"git diff -U8 main -- ':(top)lib'"
);
assert_eq!(git.asking_for(&[]).line(None), "git diff -U8 main");
}
#[test]
fn one_file_is_excluded_by_its_own_name() {
assert_eq!(excluding("src/gen/a.pb.go"), "!src/gen/a.pb.go");
assert_eq!(
excluding("src/a[1].go"),
":(top,exclude,literal)src/a[1].go"
);
}
#[test]
fn asking_again_keeps_the_question_and_replaces_the_context() {
assert_eq!(
rerun(&["git", "-c", "color.ui=always", "diff", "main"], None),
[
"git",
"-c",
"color.ui=always",
"diff",
"--no-ext-diff",
"--no-color",
"main"
]
);
assert_eq!(
rerun(&["git", "diff", "-U10", "main", "--", "src"], Some("-U2")),
[
"git",
"diff",
"--no-ext-diff",
"--no-color",
"-U2",
"main",
"--",
"src"
]
);
assert_eq!(
rerun(&["git", "diff", "--unified", "10"], Some("-W")),
["git", "diff", "--no-ext-diff", "--no-color", "-W"]
);
assert_eq!(
rerun(&["git", "diff", "--function-context"], Some("-U3")),
["git", "diff", "--no-ext-diff", "--no-color", "-U3"]
);
}
}