use std::collections::{HashMap, HashSet}; use std::path::Path; use std::sync::atomic::AtomicUsize; use std::sync::{Arc, Condvar, Mutex, MutexGuard}; use lazy_static::lazy_static; use sysinfo::{Pid, PidExt, Process, ProcessExt, ProcessRefreshKind, SystemExt}; use crate::utils::DELTA_ATOMIC_ORDERING; pub type DeltaPid = u32; #[derive(Clone, Debug, PartialEq, Eq)] pub enum CallingProcess { GitDiff(CommandLine), GitShow(CommandLine, Option), // element 2 is filename GitLog(CommandLine), GitReflog(CommandLine), GitBlame(CommandLine), GitGrep(CommandLine), OtherGrep, // rg, grep, ag, ack, etc None, // no matching process could be found Pending, // calling process is currently being determined } // The information where the calling process info comes from *should* be inside // `CallingProcess`, but that is handed out (within a MutexGuard) to callers. // To keep the interface simple, store it here: static CALLER_INFO_SOURCE: AtomicUsize = AtomicUsize::new(CALLER_GUESSED); const CALLER_GUESSED: usize = 1; const CALLER_KNOWN: usize = 2; impl CallingProcess { pub fn paths_in_input_are_relative_to_cwd(&self) -> bool { match self { CallingProcess::GitDiff(cmd) if cmd.long_options.contains("--relative") => true, CallingProcess::GitShow(cmd, _) if cmd.long_options.contains("--relative") => true, CallingProcess::GitLog(cmd) if cmd.long_options.contains("--relative") => true, CallingProcess::GitBlame(_) | CallingProcess::GitGrep(_) | CallingProcess::OtherGrep => true, _ => false, } } } #[derive(Clone, Debug, PartialEq, Eq)] pub struct CommandLine { pub long_options: HashSet, pub short_options: HashSet, pub last_arg: Option, } lazy_static! { static ref CALLER: Arc<(Mutex, Condvar)> = Arc::new((Mutex::new(CallingProcess::Pending), Condvar::new())); } // delta was called by this process (or called by something which called delta and it), // try looking up this information in the process tree. pub fn start_determining_calling_process_in_thread() { // The handle is neither kept nor returned nor joined but dropped, so the main // thread can exit early if it does not need to know its parent process. std::thread::Builder::new() .name("find_calling_process".into()) .spawn(move || { let calling_process = determine_calling_process(); let (caller_mutex, determine_done) = &**CALLER; let mut caller = caller_mutex.lock().unwrap(); if CALLER_INFO_SOURCE.load(DELTA_ATOMIC_ORDERING) <= CALLER_GUESSED { *caller = calling_process; } determine_done.notify_all(); }) .unwrap(); } // delta starts the process, so it is known. pub fn set_calling_process(args: &[String]) { if let ProcessArgs::Args(result) = describe_calling_process(args) { let (caller_mutex, determine_done) = &**CALLER; let mut caller = caller_mutex.lock().unwrap(); *caller = result; CALLER_INFO_SOURCE.store(CALLER_KNOWN, DELTA_ATOMIC_ORDERING); determine_done.notify_all(); } } #[cfg(not(test))] pub fn calling_process() -> MutexGuard<'static, CallingProcess> { let (caller_mutex, determine_done) = &**CALLER; determine_done .wait_while(caller_mutex.lock().unwrap(), |caller| { *caller == CallingProcess::Pending }) .unwrap() } // The return value is duck-typed to work in place of a MutexGuard when testing. #[cfg(test)] pub fn calling_process() -> Box { type _UnusedImport = MutexGuard<'static, i8>; if crate::utils::process::tests::FakeParentArgs::are_set() { // If the (thread-local) FakeParentArgs are set, then the following command returns // these, so the cached global real ones can not be used. Box::new(determine_calling_process()) } else { let (caller_mutex, _) = &**CALLER; let mut caller = caller_mutex.lock().unwrap(); if *caller == CallingProcess::Pending { *caller = determine_calling_process(); } Box::new(caller.clone()) } } fn determine_calling_process() -> CallingProcess { calling_process_cmdline(ProcInfo::new(), describe_calling_process) .unwrap_or(CallingProcess::None) } // Return value of `extract_args(args: &[String]) -> ProcessArgs` function which is // passed to `calling_process_cmdline()`. #[derive(Debug, PartialEq, Eq)] pub enum ProcessArgs { // A result has been successfully extracted from args. Args(T), // The extraction has failed. ArgError, // The process does not match, others may be inspected. OtherProcess, } pub fn describe_calling_process(args: &[String]) -> ProcessArgs { let mut args = args.iter().map(|s| s.as_str()); fn is_any_of<'a, I>(cmd: Option<&str>, others: I) -> bool where I: IntoIterator, { cmd.map(|cmd| others.into_iter().any(|o| o.eq_ignore_ascii_case(cmd))) .unwrap_or(false) } match args.next() { Some(command) => match Path::new(command).file_stem() { Some(s) if s.to_str().map(is_git_binary).unwrap_or(false) => { let mut args = args.skip_while(|s| { *s != "diff" && *s != "show" && *s != "log" && *s != "reflog" && *s != "grep" && *s != "blame" }); match args.next() { Some("diff") => { ProcessArgs::Args(CallingProcess::GitDiff(parse_command_line(args))) } Some("show") => { let command_line = parse_command_line(args); let filename = if let Some(last_arg) = &command_line.last_arg { match last_arg.split_once(':') { Some((_, filename)) => Path::new(filename) .file_name() .map(|f| f.to_string_lossy().to_string()), None => None, } } else { None }; ProcessArgs::Args(CallingProcess::GitShow(command_line, filename)) } Some("log") => { ProcessArgs::Args(CallingProcess::GitLog(parse_command_line(args))) } Some("reflog") => { ProcessArgs::Args(CallingProcess::GitReflog(parse_command_line(args))) } Some("grep") => { ProcessArgs::Args(CallingProcess::GitGrep(parse_command_line(args))) } Some("blame") => { ProcessArgs::Args(CallingProcess::GitBlame(parse_command_line(args))) } _ => { // It's git, but not a subcommand that we parse. Don't // look at any more processes. ProcessArgs::ArgError } } } // TODO: parse_style_sections is failing to parse ANSI escape sequences emitted by // grep (BSD and GNU), ag, pt. See #794 Some(s) if is_any_of(s.to_str(), ["rg", "ack", "sift"]) => { ProcessArgs::Args(CallingProcess::OtherGrep) } Some(_) => { // It's not git, and it's not another grep tool. Keep // looking at other processes. ProcessArgs::OtherProcess } _ => { // Could not parse file stem (not expected); keep looking at // other processes. ProcessArgs::OtherProcess } }, _ => { // Empty arguments (not expected); keep looking. ProcessArgs::OtherProcess } } } fn is_git_binary(git: &str) -> bool { // Ignore case, for e.g. NTFS or APFS file systems Path::new(git) .file_stem() .and_then(|os_str| os_str.to_str()) .map(|s| s.eq_ignore_ascii_case("git")) .unwrap_or(false) } // Given `--aa val -bc -d val e f -- ...` return // ({"--aa"}, {"-b", "-c", "-d"}) fn parse_command_line<'a>(args: impl Iterator) -> CommandLine { let mut long_options = HashSet::new(); let mut short_options = HashSet::new(); let mut last_arg = None; let mut after_double_dash = false; for s in args { if after_double_dash { last_arg = Some(s); } else if s == "--" { after_double_dash = true; } else if s.starts_with("--") { long_options.insert(s.split('=').next().unwrap().to_owned()); } else if let Some(suffix) = s.strip_prefix('-') { short_options.extend(suffix.chars().map(|c| format!("-{c}"))); } else { last_arg = Some(s); } } CommandLine { long_options, short_options, last_arg: last_arg.map(|s| s.to_string()), } } struct ProcInfo { info: sysinfo::System, } impl ProcInfo { fn new() -> Self { // On Linux sysinfo optimizes for repeated process queries and keeps per-process // /proc file descriptors open. This caching is not needed here, so // set this to zero (this does nothing on other platforms). // Also, there is currently a kernel bug which slows down syscalls when threads are // involved (here: the ctrlc handler) and a lot of files are kept open. sysinfo::set_open_files_limit(0); ProcInfo { info: sysinfo::System::new(), } } } trait ProcActions { fn cmd(&self) -> &[String]; fn parent(&self) -> Option; fn pid(&self) -> DeltaPid; fn start_time(&self) -> u64; } impl ProcActions for T where T: ProcessExt, { fn cmd(&self) -> &[String] { ProcessExt::cmd(self) } fn parent(&self) -> Option { ProcessExt::parent(self).map(|p| p.as_u32()) } fn pid(&self) -> DeltaPid { ProcessExt::pid(self).as_u32() } fn start_time(&self) -> u64 { ProcessExt::start_time(self) } } trait ProcessInterface { type Out: ProcActions; fn my_pid(&self) -> DeltaPid; fn process(&self, pid: DeltaPid) -> Option<&Self::Out>; fn processes(&self) -> &HashMap; fn refresh_process(&mut self, pid: DeltaPid) -> bool; fn refresh_processes(&mut self); fn parent_process(&mut self, pid: DeltaPid) -> Option<&Self::Out> { self.refresh_process(pid).then_some(())?; let parent_pid = self.process(pid)?.parent()?; self.refresh_process(parent_pid).then_some(())?; self.process(parent_pid) } fn naive_sibling_process(&mut self, pid: DeltaPid) -> Option<&Self::Out> { let sibling_pid = pid - 1; self.refresh_process(sibling_pid).then_some(())?; self.process(sibling_pid) } fn find_sibling_in_refreshed_processes( &mut self, pid: DeltaPid, extract_args: &F, ) -> Option where F: Fn(&[String]) -> ProcessArgs, Self: Sized, { /* $ start_blame_of.sh src/main.rs | delta \_ /usr/bin/some-terminal-emulator | \_ common_git_and_delta_ancestor | \_ /bin/sh /opt/git/start_blame_of.sh src/main.rs | | \_ /bin/sh /opt/some/wrapper git blame src/main.rs | | \_ /usr/bin/git blame src/main.rs | \_ /bin/sh /opt/some/wrapper delta | \_ delta Walk up the process tree of delta and of every matching other process, counting the steps along the way. Find the common ancestor processes, calculate the distance, and select the one with the shortest. */ let this_start_time = self.process(pid)?.start_time(); let mut pid_distances = HashMap::::new(); let mut collect_parent_pids = |pid, distance| { pid_distances.insert(pid, distance); }; iter_parents(self, pid, &mut collect_parent_pids); let process_start_time_difference_less_than_3s = |a, b| (a as i64 - b as i64).abs() < 3; let cmdline_of_closest_matching_process = self .processes() .iter() .filter(|(_, proc)| { process_start_time_difference_less_than_3s(this_start_time, proc.start_time()) }) .filter_map(|(&pid, proc)| match extract_args(proc.cmd()) { ProcessArgs::Args(args) => { let mut length_of_process_chain = usize::MAX; let mut sum_distance = |pid, distance| { if length_of_process_chain == usize::MAX { if let Some(distance_to_first_common_parent) = pid_distances.get(&pid) { length_of_process_chain = distance_to_first_common_parent + distance; } } }; iter_parents(self, pid.as_u32(), &mut sum_distance); if length_of_process_chain == usize::MAX { None } else { Some((length_of_process_chain, args)) } } _ => None, }) .min_by_key(|(distance, _)| *distance) .map(|(_, result)| result); cmdline_of_closest_matching_process } } impl ProcessInterface for ProcInfo { type Out = Process; fn my_pid(&self) -> DeltaPid { std::process::id() } fn refresh_process(&mut self, pid: DeltaPid) -> bool { self.info .refresh_process_specifics(Pid::from_u32(pid), ProcessRefreshKind::new()) } fn process(&self, pid: DeltaPid) -> Option<&Self::Out> { self.info.process(Pid::from_u32(pid)) } fn processes(&self) -> &HashMap { self.info.processes() } fn refresh_processes(&mut self) { self.info .refresh_processes_specifics(ProcessRefreshKind::new()) } } fn calling_process_cmdline(mut info: P, extract_args: F) -> Option where P: ProcessInterface, F: Fn(&[String]) -> ProcessArgs, { #[cfg(test)] { if let Some(args) = tests::FakeParentArgs::get() { match extract_args(&args) { ProcessArgs::Args(result) => return Some(result), _ => return None, } } } let my_pid = info.my_pid(); // 1) Try the parent process(es). If delta is set as the pager in git, then git is the parent process. // If delta is started by a script check the parent's parent as well. let mut current_pid = my_pid; 'parent_iter: for depth in [1, 2, 3] { let parent = match info.parent_process(current_pid) { None => { break 'parent_iter; } Some(parent) => parent, }; let parent_pid = parent.pid(); match extract_args(parent.cmd()) { ProcessArgs::Args(result) => return Some(result), ProcessArgs::ArgError => return None, // 2) The 1st parent process was something else, this can happen if git output is piped into delta, e.g. // `git blame foo.txt | delta`. When the shell sets up the pipe it creates the two processes, the pids // are usually consecutive, so naively check if the process with `my_pid - 1` matches. ProcessArgs::OtherProcess if depth == 1 => { let sibling = info.naive_sibling_process(current_pid); if let Some(proc) = sibling { if let ProcessArgs::Args(result) = extract_args(proc.cmd()) { return Some(result); } } } // This check is not done for the parent's parent etc. ProcessArgs::OtherProcess => {} } current_pid = parent_pid; } /* 3) Neither parent(s) nor the direct sibling were a match. The most likely case is that the input program of the pipe wrote all its data and exited before delta started, so no command line can be parsed. Same if the data was piped from an input file. There might also be intermediary scripts in between or piped input with a gap in pids or (rarely) randomized pids, so check processes for the closest match in the process tree. The size of this process tree can be reduced by only refreshing selected processes. 100 /usr/bin/some-terminal-emulator 124 \_ -shell 301 | \_ /usr/bin/git blame src/main.rs 302 | \_ wraps_delta.sh 303 | \_ delta 304 | \_ less --RAW-CONTROL-CHARS --quit-if-one-screen 125 \_ -shell 800 | \_ /usr/bin/git blame src/main.rs 200 | \_ delta 400 | \_ less --RAW-CONTROL-CHARS --quit-if-one-screen 126 \_ -shell 501 | \_ /bin/sh /wrapper/for/git blame src/main.rs 555 | | \_ /usr/bin/git blame src/main.rs 502 | \_ delta 567 | \_ less --RAW-CONTROL-CHARS --quit-if-one-screen */ // Also `add` because `A_has_pid101 | delta_has_pid102`, but if A is a wrapper which then calls // git (no `exec`), then the final pid of the git process might be 103 or greater. let pid_range = my_pid.saturating_sub(10)..my_pid.saturating_add(10); for p in pid_range { // Processes which were not refreshed do not exist for sysinfo, so by selectively // letting it know about processes the `find_sibling..` function will only // consider these. if info.process(p).is_none() { info.refresh_process(p); } } match info.find_sibling_in_refreshed_processes(my_pid, &extract_args) { None => { #[cfg(not(target_os = "linux"))] let full_scan = true; // The full scan is expensive on Linux and rarely successful, so disable it by default. #[cfg(target_os = "linux")] let full_scan = std::env::var("DELTA_CALLING_PROCESS_QUERY_ALL") .is_ok_and(|v| !["0", "false", "no"].iter().any(|&n| n == v)); if full_scan { info.refresh_processes(); info.find_sibling_in_refreshed_processes(my_pid, &extract_args) } else { None } } some => some, } } // Walk up the process tree, calling `f` with the pid and the distance to `starting_pid`. // Prerequisite: `info.refresh_processes()` has been called. fn iter_parents(info: &P, starting_pid: DeltaPid, f: F) where P: ProcessInterface, F: FnMut(DeltaPid, usize), { fn inner_iter_parents(info: &P, pid: DeltaPid, mut f: F, distance: usize) where P: ProcessInterface, F: FnMut(u32, usize), { // Probably bad input, not a tree: if distance > 2000 { return; } if let Some(proc) = info.process(pid) { if let Some(pid) = proc.parent() { f(pid, distance); inner_iter_parents(info, pid, f, distance + 1) } } } inner_iter_parents(info, starting_pid, f, 1) } #[cfg(test)] pub mod tests { use super::*; use itertools::Itertools; use std::cell::RefCell; use std::rc::Rc; thread_local! { static FAKE_ARGS: RefCell>> = const { RefCell::new(TlsState::None) }; } #[derive(Debug, PartialEq)] enum TlsState { Once(T), Scope(T), With(usize, Rc>), None, Invalid, ErrorAlreadyHandled, } // When calling `FakeParentArgs::get()`, it can return `Some(values)` which were set earlier // during in the #[test]. Otherwise returns None. // This value can be valid once: `FakeParentArgs::once(val)`, for the entire scope: // `FakeParentArgs::for_scope(val)`, or can be different values every time `get()` is called: // `FakeParentArgs::with([val1, val2, val3])`. // It is an error if `once` or `with` values remain unused, or are overused. // Note: The values are stored per-thread, so the expectation is that no thread boundaries are // crossed. pub struct FakeParentArgs {} impl FakeParentArgs { pub fn once(args: &str) -> Self { Self::new(args, TlsState::Once, "once") } pub fn for_scope(args: &str) -> Self { Self::new(args, TlsState::Scope, "for_scope") } fn new(args: &str, initial: F, from_: &str) -> Self where F: Fn(Vec) -> TlsState>, { let string_vec = args.split(' ').map(str::to_owned).collect(); if FAKE_ARGS.with(|a| a.replace(initial(string_vec))) != TlsState::None { Self::error(from_); } FakeParentArgs {} } pub fn with(args: &[&str]) -> Self { let with = TlsState::With( 0, Rc::new( args.iter() .map(|a| a.split(' ').map(str::to_owned).collect()) .collect(), ), ); if FAKE_ARGS.with(|a| a.replace(with)) != TlsState::None || args.is_empty() { Self::error("with creation"); } FakeParentArgs {} } pub fn get() -> Option> { FAKE_ARGS.with(|a| { let old_value = a.replace_with(|old_value| match old_value { TlsState::Once(_) => TlsState::Invalid, TlsState::Scope(args) => TlsState::Scope(args.clone()), TlsState::With(n, args) => TlsState::With(*n + 1, Rc::clone(args)), TlsState::None => TlsState::None, TlsState::Invalid => TlsState::Invalid, TlsState::ErrorAlreadyHandled => TlsState::ErrorAlreadyHandled, }); match old_value { TlsState::Once(args) | TlsState::Scope(args) => Some(args), TlsState::With(n, args) if n < args.len() => Some(args[n].clone()), TlsState::None => None, TlsState::Invalid | TlsState::With(_, _) | TlsState::ErrorAlreadyHandled => { Self::error("get"); None } } }) } pub fn are_set() -> bool { FAKE_ARGS.with(|a| { *a.borrow() != TlsState::None && *a.borrow() != TlsState::ErrorAlreadyHandled }) } fn error(where_: &str) { FAKE_ARGS.with(|a| { let old_value = a.replace(TlsState::ErrorAlreadyHandled); match old_value { TlsState::ErrorAlreadyHandled => (), _ => { panic!( "test logic error (in {}): wrong FakeParentArgs scope?", where_ ); } } }); } } impl Drop for FakeParentArgs { fn drop(&mut self) { // Clears an Invalid state and tests if a Once or With value has been used. FAKE_ARGS.with(|a| { let old_value = a.replace(TlsState::None); match old_value { TlsState::With(n, args) => { if n != args.len() { Self::error("drop with") } } TlsState::Once(_) | TlsState::None => Self::error("drop"), TlsState::Scope(_) | TlsState::Invalid | TlsState::ErrorAlreadyHandled => {} } }); } } #[derive(Debug, Default)] struct FakeProc { #[allow(dead_code)] pid: DeltaPid, start_time: u64, cmd: Vec, ppid: Option, } impl FakeProc { fn new(pid: DeltaPid, start_time: u64, cmd: Vec, ppid: Option) -> Self { FakeProc { pid, start_time, cmd, ppid, } } } impl ProcActions for FakeProc { fn cmd(&self) -> &[String] { &self.cmd } fn parent(&self) -> Option { self.ppid } fn pid(&self) -> DeltaPid { self.pid } fn start_time(&self) -> u64 { self.start_time } } #[derive(Debug, Default)] struct MockProcInfo { delta_pid: DeltaPid, info: HashMap, } impl MockProcInfo { fn with(processes: &[(DeltaPid, u64, &str, Option)]) -> Self { MockProcInfo { delta_pid: processes.last().map(|p| p.0).unwrap_or(1), info: processes .iter() .map(|(pid, start_time, cmd, ppid)| { let cmd_vec = cmd.split(' ').map(str::to_owned).collect(); ( Pid::from_u32(*pid), FakeProc::new(*pid, *start_time, cmd_vec, *ppid), ) }) .collect(), } } } impl ProcessInterface for MockProcInfo { type Out = FakeProc; fn my_pid(&self) -> DeltaPid { self.delta_pid } fn process(&self, pid: DeltaPid) -> Option<&Self::Out> { self.info.get(&Pid::from_u32(pid)) } fn processes(&self) -> &HashMap { &self.info } fn refresh_processes(&mut self) {} fn refresh_process(&mut self, _pid: DeltaPid) -> bool { true } } fn set(arg1: &[&str]) -> HashSet { arg1.iter().map(|&s| s.to_owned()).collect() } #[test] fn test_process_testing() { { let _args = FakeParentArgs::once("git blame hello"); assert_eq!( calling_process_cmdline(ProcInfo::new(), describe_calling_process), Some(CallingProcess::GitBlame(CommandLine { long_options: [].into(), short_options: [].into(), last_arg: Some("hello".into()) })) ); } { let _args = FakeParentArgs::once("git blame world.txt"); assert_eq!( calling_process_cmdline(ProcInfo::new(), describe_calling_process), Some(CallingProcess::GitBlame(CommandLine { long_options: [].into(), short_options: [].into(), last_arg: Some("world.txt".into()) })) ); } { let _args = FakeParentArgs::for_scope("git blame hello world.txt"); assert_eq!( calling_process_cmdline(ProcInfo::new(), describe_calling_process), Some(CallingProcess::GitBlame(CommandLine { long_options: [].into(), short_options: [].into(), last_arg: Some("world.txt".into()) })) ); } } #[test] #[should_panic(expected = "test logic error (in get): wrong FakeParentArgs scope?")] fn test_process_testing_assert() { let _args = FakeParentArgs::once("git blame do.not.panic"); assert_eq!( calling_process_cmdline(ProcInfo::new(), describe_calling_process), Some(CallingProcess::GitBlame(CommandLine { long_options: [].into(), short_options: [].into(), last_arg: Some("do.not.panic".into()) })) ); calling_process_cmdline(ProcInfo::new(), describe_calling_process); } #[test] #[should_panic(expected = "test logic error (in drop): wrong FakeParentArgs scope?")] fn test_process_testing_assert_once_never_used() { let _args = FakeParentArgs::once("never used"); } #[test] #[should_panic(expected = "test logic error (in once): wrong FakeParentArgs scope?")] fn test_process_testing_assert_for_scope_never_used() { let _args = FakeParentArgs::for_scope(&"never used"); let _args = FakeParentArgs::once(&"never used"); } #[test] #[should_panic(expected = "test logic error (in for_scope): wrong FakeParentArgs scope?")] fn test_process_testing_assert_once_never_used2() { let _args = FakeParentArgs::once(&"never used"); let _args = FakeParentArgs::for_scope(&"never used"); } #[test] fn test_process_testing_scope_can_remain_unused() { let _args = FakeParentArgs::for_scope("never used"); } #[test] fn test_process_testing_n_times() { let _args = FakeParentArgs::with(&["git blame once", "git blame twice"]); assert_eq!( calling_process_cmdline(ProcInfo::new(), describe_calling_process), Some(CallingProcess::GitBlame(CommandLine { long_options: [].into(), short_options: [].into(), last_arg: Some("once".into()) })) ); assert_eq!( calling_process_cmdline(ProcInfo::new(), describe_calling_process), Some(CallingProcess::GitBlame(CommandLine { long_options: [].into(), short_options: [].into(), last_arg: Some("twice".into()) })) ); } #[test] #[should_panic(expected = "test logic error (in drop with): wrong FakeParentArgs scope?")] fn test_process_testing_n_times_unused() { let _args = FakeParentArgs::with(&["git blame once", "git blame twice"]); } #[test] #[should_panic(expected = "test logic error (in drop with): wrong FakeParentArgs scope?")] fn test_process_testing_n_times_underused() { let _args = FakeParentArgs::with(&["git blame once", "git blame twice"]); assert_eq!( calling_process_cmdline(ProcInfo::new(), describe_calling_process), Some(CallingProcess::GitBlame(CommandLine { long_options: [].into(), short_options: [].into(), last_arg: Some("once".into()) })) ); } #[test] #[should_panic(expected = "test logic error (in get): wrong FakeParentArgs scope?")] fn test_process_testing_n_times_overused() { let _args = FakeParentArgs::with(&["git blame once"]); assert_eq!( calling_process_cmdline(ProcInfo::new(), describe_calling_process), Some(CallingProcess::GitBlame(CommandLine { long_options: [].into(), short_options: [].into(), last_arg: Some("once".into()) })) ); calling_process_cmdline(ProcInfo::new(), describe_calling_process); } #[test] fn test_describe_calling_process_blame() { let no_processes = MockProcInfo::with(&[]); assert_eq!( calling_process_cmdline(no_processes, describe_calling_process), None ); let two_trees = MockProcInfo::with(&[ (2, 100, "-shell", None), (3, 100, "git blame src/main.rs", Some(2)), (4, 100, "call_delta.sh", None), (5, 100, "delta", Some(4)), ]); assert_eq!( calling_process_cmdline(two_trees, describe_calling_process), None ); let no_options_command_line = CommandLine { long_options: [].into(), short_options: [].into(), last_arg: Some("hello.txt".to_string()), }; let parent = MockProcInfo::with(&[ (2, 100, "-shell", None), (3, 100, "git blame hello.txt", Some(2)), (4, 100, "delta", Some(3)), ]); assert_eq!( calling_process_cmdline(parent, describe_calling_process), Some(CallingProcess::GitBlame(no_options_command_line.clone())) ); let parent = MockProcInfo::with(&[ (2, 100, "-shell", None), (3, 100, "git blame -- hello.txt", Some(2)), (4, 100, "delta", Some(3)), ]); assert_eq!( calling_process_cmdline(parent, describe_calling_process), Some(CallingProcess::GitBlame(no_options_command_line.clone())) ); let parent = MockProcInfo::with(&[ (2, 100, "-shell", None), (3, 100, "git blame -- --not.an.argument", Some(2)), (4, 100, "delta", Some(3)), ]); assert_eq!( calling_process_cmdline(parent, describe_calling_process), Some(CallingProcess::GitBlame(CommandLine { long_options: [].into(), short_options: [].into(), last_arg: Some("--not.an.argument".to_string()), })) ); let parent = MockProcInfo::with(&[ (2, 100, "-shell", None), (3, 100, "git blame --help.txt", Some(2)), (4, 100, "delta", Some(3)), ]); assert_eq!( calling_process_cmdline(parent, describe_calling_process), Some(CallingProcess::GitBlame(CommandLine { long_options: ["--help.txt".into()].into(), short_options: [].into(), last_arg: None, })) ); let parent = MockProcInfo::with(&[ (2, 100, "-shell", None), (3, 100, "git blame --", Some(2)), (4, 100, "delta", Some(3)), ]); assert_eq!( calling_process_cmdline(parent, describe_calling_process), Some(CallingProcess::GitBlame(CommandLine { long_options: [].into(), short_options: [].into(), last_arg: None, })) ); let parent = MockProcInfo::with(&[ (2, 100, "-shell", None), (3, 100, "Git.exe blame hello.txt", Some(2)), (4, 100, "delta", Some(3)), ]); assert_eq!( calling_process_cmdline(parent, describe_calling_process), Some(CallingProcess::GitBlame(no_options_command_line.clone())) ); let git_blame_command = "git -c a=b blame -fnb --incremental -t --color-by-age -M --since=3.weeks --contents annotation.txt -C -C2 hello.txt"; // here -C2 is parsed as -C and -2. It doesn't really matters because we only use last_arg from options // to determine the file type. let expected_result = Some(CallingProcess::GitBlame(CommandLine { long_options: set(&["--incremental", "--color-by-age", "--since", "--contents"]), short_options: set(&["-f", "-n", "-b", "-t", "-M", "-C", "-2"]), last_arg: Some("hello.txt".to_string()), })); let parent = MockProcInfo::with(&[ (2, 100, "-shell", None), (3, 100, git_blame_command, Some(2)), (4, 100, "delta", Some(3)), ]); assert_eq!( calling_process_cmdline(parent, describe_calling_process), expected_result ); let grandparent = MockProcInfo::with(&[ (2, 100, "-shell", None), (3, 100, git_blame_command, Some(2)), (4, 100, "call_delta.sh", Some(3)), (5, 100, "delta", Some(4)), ]); assert_eq!( calling_process_cmdline(grandparent, describe_calling_process), expected_result ); let sibling = MockProcInfo::with(&[ (2, 100, "-xterm", None), (3, 100, "-shell", Some(2)), (4, 100, "git blame src/main.rs", Some(3)), (5, 100, "delta", Some(3)), ]); assert_eq!( calling_process_cmdline(sibling, describe_calling_process), Some(CallingProcess::GitBlame(CommandLine { long_options: [].into(), short_options: [].into(), last_arg: Some("src/main.rs".into()) })) ); let indirect_sibling = MockProcInfo::with(&[ (2, 100, "-xterm", None), (3, 100, "-shell", Some(2)), (4, 100, "Git.exe blame --correct src/main.abc", Some(3)), ( 10, 100, "Git.exe blame --ignored-child src/main.def", Some(4), ), (5, 100, "delta.sh", Some(3)), (20, 100, "delta", Some(5)), ]); assert_eq!( calling_process_cmdline(indirect_sibling, describe_calling_process), Some(CallingProcess::GitBlame(CommandLine { long_options: set(&["--correct"]), short_options: [].into(), last_arg: Some("src/main.abc".into()) })) ); let indirect_sibling2 = MockProcInfo::with(&[ (2, 100, "-xterm", None), (3, 100, "-shell", Some(2)), (4, 100, "git wrap src/main.abc", Some(3)), (10, 100, "git blame src/main.def", Some(4)), (5, 100, "delta.sh", Some(3)), (20, 100, "delta", Some(5)), ]); assert_eq!( calling_process_cmdline(indirect_sibling2, describe_calling_process), Some(CallingProcess::GitBlame(CommandLine { long_options: [].into(), short_options: [].into(), last_arg: Some("src/main.def".into()) })) ); // 3 blame processes, 2 with matching start times, pick the one with lower // distance but larger start time difference. let indirect_sibling_start_times = MockProcInfo::with(&[ (2, 100, "-xterm", None), (3, 100, "-shell", Some(2)), (4, 109, "git wrap src/main.abc", Some(3)), (10, 109, "git blame src/main.def", Some(4)), (20, 100, "git wrap1 src/main.abc", Some(3)), (21, 100, "git wrap2 src/main.def", Some(20)), (22, 101, "git blame src/main.not", Some(21)), (23, 102, "git blame src/main.this", Some(20)), (5, 100, "delta.sh", Some(3)), (20, 100, "delta", Some(5)), ]); assert_eq!( calling_process_cmdline(indirect_sibling_start_times, describe_calling_process), Some(CallingProcess::GitBlame(CommandLine { long_options: [].into(), short_options: [].into(), last_arg: Some("src/main.this".into()) })) ); } #[test] fn test_describe_calling_process_grep() { let no_processes = MockProcInfo::with(&[]); assert_eq!( calling_process_cmdline(no_processes, describe_calling_process), None ); let empty_command_line = CommandLine { long_options: [].into(), short_options: [].into(), last_arg: Some("hello.txt".to_string()), }; let parent = MockProcInfo::with(&[ (2, 100, "-shell", None), (3, 100, "git grep pattern hello.txt", Some(2)), (4, 100, "delta", Some(3)), ]); assert_eq!( calling_process_cmdline(parent, describe_calling_process), Some(CallingProcess::GitGrep(empty_command_line.clone())) ); let parent = MockProcInfo::with(&[ (2, 100, "-shell", None), (3, 100, "Git.exe grep pattern hello.txt", Some(2)), (4, 100, "delta", Some(3)), ]); assert_eq!( calling_process_cmdline(parent, describe_calling_process), Some(CallingProcess::GitGrep(empty_command_line)) ); for grep_command in &[ "/usr/local/bin/rg pattern hello.txt", "RG.exe pattern hello.txt", "/usr/local/bin/ack pattern hello.txt", "ack.exe pattern hello.txt", ] { let parent = MockProcInfo::with(&[ (2, 100, "-shell", None), (3, 100, grep_command, Some(2)), (4, 100, "delta", Some(3)), ]); assert_eq!( calling_process_cmdline(parent, describe_calling_process), Some(CallingProcess::OtherGrep) ); } let git_grep_command = "git grep -ab --function-context -n --show-function -W --foo=val pattern hello.txt"; let expected_result = Some(CallingProcess::GitGrep(CommandLine { long_options: set(&["--function-context", "--show-function", "--foo"]), short_options: set(&["-a", "-b", "-n", "-W"]), last_arg: Some("hello.txt".to_string()), })); let parent = MockProcInfo::with(&[ (2, 100, "-shell", None), (3, 100, git_grep_command, Some(2)), (4, 100, "delta", Some(3)), ]); assert_eq!( calling_process_cmdline(parent, describe_calling_process), expected_result ); let grandparent = MockProcInfo::with(&[ (2, 100, "-shell", None), (3, 100, git_grep_command, Some(2)), (4, 100, "call_delta.sh", Some(3)), (5, 100, "delta", Some(4)), ]); assert_eq!( calling_process_cmdline(grandparent, describe_calling_process), expected_result ); } #[test] fn test_describe_calling_process_git_show() { for (command, expected_extension) in [ ( "/usr/local/bin/git show --abbrev-commit -w 775c3b84:./src/hello.rs", "hello.rs", ), ( "/usr/local/bin/git show --abbrev-commit -w HEAD~1:Makefile", "Makefile", ), ( "git -c x.y=z show --abbrev-commit -w 775c3b84:./src/hello.bye.R", "hello.bye.R", ), ] { let parent = MockProcInfo::with(&[ (2, 100, "-shell", None), (3, 100, command, Some(2)), (4, 100, "delta", Some(3)), ]); if let Some(CallingProcess::GitShow(cmd_line, filename)) = calling_process_cmdline(parent, describe_calling_process) { assert_eq!(cmd_line.long_options, set(&["--abbrev-commit"])); assert_eq!(cmd_line.short_options, set(&["-w"])); assert_eq!(filename, Some(expected_extension.to_string())); } else { unreachable!(); } } } #[test] fn test_process_calling_cmdline() { // GitHub runs CI tests for arm under qemu where sysinfo can not find the parent process. if std::env::vars().any(|(key, _)| key == "CROSS_RUNNER" || key == "QEMU_LD_PREFIX") { return; } let mut info = ProcInfo::new(); info.refresh_processes(); let mut ppid_distance = Vec::new(); iter_parents(&info, std::process::id(), |pid, distance| { ppid_distance.push(pid as i32); ppid_distance.push(distance as i32) }); assert!(ppid_distance[1] == 1); fn find_calling_process(args: &[String], want: &[&str]) -> ProcessArgs<()> { if args.iter().any(|have| want.iter().any(|want| want == have)) { ProcessArgs::Args(()) } else { ProcessArgs::ArgError } } // Tests that caller is something like "cargo test" or "cargo tarpaulin" let find_test = |args: &[String]| find_calling_process(args, &["t", "test", "tarpaulin"]); assert_eq!(calling_process_cmdline(info, find_test), Some(())); let nonsense = ppid_distance .iter() .map(|i| i.to_string()) .join("Y40ii4RihK6lHiK4BDsGSx"); let find_nothing = |args: &[String]| find_calling_process(args, &[&nonsense]); assert_eq!(calling_process_cmdline(ProcInfo::new(), find_nothing), None); } }