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 VALUED: [&str; 6] = [
"-C",
"-c",
"--git-dir",
"--work-tree",
"--namespace",
"--config-env",
];
const ALIAS_DEPTH: usize = 16;
const ANCESTORS: usize = 3;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct GitCommand {
argv: Vec<String>,
subcommand: usize,
prefix: String,
}
impl GitCommand {
pub(crate) fn of(argv: Vec<String>) -> Option<Self> {
let subcommand = subcommand_at(&argv)?;
SUBCOMMANDS
.contains(&argv[subcommand].as_str())
.then_some(Self {
argv,
subcommand,
prefix: String::new(),
})
}
pub(crate) fn run_in(self, prefix: String) -> Self {
Self { prefix, ..self }
}
pub(crate) fn prefix(&self) -> &str {
&self.prefix
}
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 is_stat(&self) -> bool {
let args: Vec<&str> = self.arguments().take_while(|arg| *arg != "--").collect();
self.subcommand() == "diff"
&& args
.iter()
.any(|arg| *arg == "--stat" || arg.starts_with("--stat="))
&& !args
.iter()
.any(|arg| matches!(*arg, "-p" | "-u" | "--patch" | "--patch-with-stat"))
}
pub(crate) fn as_patch(&self) -> Self {
let end = self
.argv
.iter()
.position(|arg| arg == "--")
.unwrap_or(self.argv.len());
let argv = self
.argv
.iter()
.enumerate()
.filter(|&(i, arg)| i >= end || !(arg == "--stat" || arg.starts_with("--stat=")))
.map(|(_, arg)| arg.clone());
let mut argv: Vec<String> = argv.collect();
argv.insert(self.subcommand + 1, "--patch".to_owned());
Self {
argv,
subcommand: self.subcommand,
prefix: self.prefix.clone(),
}
}
pub(crate) fn command(&self, context: Option<&str>) -> Command {
let words = self.words(context);
let mut command = Command::new(words[0]);
command.args(["-c", "color.ui=always"]);
command.args(&words[1..=self.subcommand]);
command.arg("--no-ext-diff");
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,
prefix: self.prefix.clone(),
})
}
pub(crate) fn file_patterns(&self) -> Vec<String> {
self.pathspecs()
.map(|pathspec| pattern_of(pathspec, &self.prefix))
.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,
prefix: self.prefix.clone(),
}
}
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, prefix: &str) -> String {
if let Some(glob) = pathspec.strip_prefix(":(top,exclude)") {
return format!("!{glob}");
}
if let Some(glob) = pathspec.strip_prefix(":(top)") {
return glob.to_owned();
}
if pathspec.starts_with(':') {
return pathspec.to_owned();
}
format!("{prefix}{pathspec}")
}
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(expanded(argv)).map(|git| git.argv)
}
fn expanded(argv: Vec<String>) -> Vec<String> {
let mut expanding = argv.clone();
for _ in 0..ALIAS_DEPTH {
let Some(at) = subcommand_at(&expanding) else {
return argv;
};
if SUBCOMMANDS.contains(&expanding[at].as_str()) {
return expanding;
}
let Some(words) = alias(&expanding[..at], &expanding[at]) else {
return expanding;
};
expanding.splice(at..=at, words);
}
argv
}
fn subcommand_at(argv: &[String]) -> Option<usize> {
let program = Path::new(argv.first()?).file_stem()?.to_str()?;
if !program.eq_ignore_ascii_case("git") {
return None;
}
let mut value_follows = false;
argv.iter().enumerate().skip(1).find_map(|(i, arg)| {
if std::mem::take(&mut value_follows) {
return None;
}
value_follows = VALUED.contains(&arg.as_str());
(!arg.starts_with('-')).then_some(i)
})
}
fn alias(own: &[String], name: &str) -> Option<Vec<String>> {
let output = Command::new(&own[0])
.args(&own[1..])
.args(["config", "--get", &format!("alias.{name}")])
.output()
.ok()?;
let value = String::from_utf8(output.stdout).ok()?;
let value = value.trim_end_matches('\n');
if !output.status.success() || value.starts_with('!') {
return None;
}
shell_words::split(value)
.ok()
.filter(|words| !words.is_empty())
}
#[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",
"-c",
"color.ui=always",
"diff",
"--no-ext-diff",
"main"
]
);
assert_eq!(
rerun(&["git", "diff", "-U10", "main", "--", "src"], Some("-U2")),
[
"git",
"-c",
"color.ui=always",
"diff",
"--no-ext-diff",
"-U2",
"main",
"--",
"src"
]
);
assert_eq!(
rerun(&["git", "diff", "--unified", "10"], Some("-W")),
[
"git",
"-c",
"color.ui=always",
"diff",
"--no-ext-diff",
"-W"
]
);
assert_eq!(
rerun(&["git", "diff", "--function-context"], Some("-U3")),
[
"git",
"-c",
"color.ui=always",
"diff",
"--no-ext-diff",
"-U3"
]
);
}
fn repo_with_aliases(aliases: &[(&str, &str)]) -> temp_dir::TempDir {
let dir = temp_dir::TempDir::new().unwrap();
let git = |args: &[&str]| {
let status = Command::new("git")
.arg("-C")
.arg(dir.path())
.args(args)
.status()
.unwrap();
assert!(status.success());
};
git(&["init", "-q"]);
for (name, value) in aliases {
git(&["config", &format!("alias.{name}"), value]);
}
dir
}
fn expanded_in(dir: &temp_dir::TempDir, args: &[&str]) -> Vec<String> {
let dir = dir.path().to_string_lossy();
let argv = ["git", "-C", &dir]
.iter()
.chain(args)
.map(|arg| (*arg).to_owned())
.collect();
expanded(argv)[3..].to_vec()
}
#[test]
fn an_alias_is_expanded_to_the_command_it_stands_for() {
let dir = repo_with_aliases(&[("lg", "log --oneline --format='%h %s'")]);
assert_eq!(
expanded_in(&dir, &["lg", "-p"]),
["log", "--oneline", "--format=%h %s", "-p"]
);
}
#[test]
fn an_alias_of_an_alias_is_expanded_to_the_command() {
let dir = repo_with_aliases(&[("l", "lg -p"), ("lg", "log --oneline")]);
assert_eq!(expanded_in(&dir, &["l"]), ["log", "--oneline", "-p"]);
}
#[test]
fn an_alias_given_on_the_command_line_is_expanded() {
let dir = repo_with_aliases(&[]);
assert_eq!(
expanded_in(&dir, &["-c", "alias.d=diff --cached", "d"]),
["-c", "alias.d=diff --cached", "diff", "--cached"]
);
}
#[test]
fn a_shell_alias_is_left_as_it_is() {
let dir = repo_with_aliases(&[("sh", "!git log")]);
assert_eq!(expanded_in(&dir, &["sh"]), ["sh"]);
}
#[test]
fn a_subcommand_is_not_looked_up_as_an_alias() {
let dir = repo_with_aliases(&[("log", "diff")]);
assert_eq!(expanded_in(&dir, &["log"]), ["log"]);
}
#[test]
fn aliases_that_expand_to_each_other_are_left_as_they_are() {
let dir = repo_with_aliases(&[("a", "b"), ("b", "a")]);
assert_eq!(expanded_in(&dir, &["a"]), ["a"]);
}
}