diff --git a/README.md b/README.md index da2e128..883d11b 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,36 @@ Combined with `WSLGIT_USE_INTERACTIVE_SHELL=smart` (default) this can make every This feature is only available in Windows 10 builds 17063 and later. +### WSLGIT_ENABLE_LOGGING + +Internal diagnostic logging is disabled by default. To enable it, set the +Windows environment variable `WSLGIT_ENABLE_LOGGING` to `true` or `1`. An +unset variable and every other value, including `false` and `0`, leave logging +disabled. + +This setting is separate from Fork's **Enable verbose Git output** preference. +Enabling either setting does not enable the other: Fork's preference controls +the Git output shown by Fork, while `WSLGIT_ENABLE_LOGGING` records wslgit's +internal routing and timing metadata. + +When enabled, wslgit appends metadata-only records to `wslgit.log` next to the +running executable. Every record includes a millisecond timestamp and process +ID so events from concurrent invocations can be correlated. Records also +include the wslgit version, routing events, argument counts, path-translation +events, process-launch error kinds, completion status, and elapsed time. They +do not include command arguments, repository paths, environment values, OS +error messages, or credential material. The active log is limited to 1 MiB; +when the next record would exceed that limit, it is rotated to `wslgit.log.1`. +Only one backup is retained, for at most approximately 2 MiB total. Logging +errors are ignored so they cannot interrupt Git operations. If another wslgit +process is already updating the log, a diagnostic record can be dropped rather +than delaying the Git command. + +Enable logging only while troubleshooting, reproduce the problem, inspect the +two log files, and then unset `WSLGIT_ENABLE_LOGGING` or set it to `false` or +`0`. Fork's verbose Git output can be enabled and disabled independently for +the same troubleshooting session. + ## Building from source First, install Rust from https://www.rust-lang.org. Rust on Windows also diff --git a/src/main.rs b/src/main.rs index 042e57b..467521f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,14 @@ use std::env; +use std::ffi::c_void; +use std::fmt; use std::fs::OpenOptions; use std::io::{self, IsTerminal, Write}; use std::os::windows::process::CommandExt; use std::path::{Path, PathBuf}; -use std::process::{Command, Stdio}; +use std::process::{Command, ExitStatus, Stdio}; use std::sync::LazyLock; +use std::time::{Instant, SystemTime, UNIX_EPOCH}; #[macro_use] extern crate lazy_static; @@ -19,12 +22,135 @@ mod wsl; const VERSION: &'static str = env!("CARGO_PKG_VERSION"); const BASH_EXECUTABLE: &str = "/bin/bash"; +const MAX_LOG_BYTES: u64 = 1024 * 1024; +const LOG_MUTEX_NAME: &str = r"Local\wslgit-diagnostic-log-v1"; +const WAIT_OBJECT_0: u32 = 0; +const WAIT_ABANDONED: u32 = 0x80; + +#[link(name = "kernel32")] +extern "system" { + #[link_name = "CreateMutexW"] + fn create_mutex_w( + mutex_attributes: *mut c_void, + initial_owner: i32, + name: *const u16, + ) -> *mut c_void; + #[link_name = "WaitForSingleObject"] + fn wait_for_single_object(handle: *mut c_void, milliseconds: u32) -> u32; + #[link_name = "ReleaseMutex"] + fn release_mutex(handle: *mut c_void) -> i32; + #[link_name = "CloseHandle"] + fn close_handle(handle: *mut c_void) -> i32; +} // https://learn.microsoft.com/en-us/windows/win32/procthread/process-creation-flags const CREATE_NO_WINDOW: u32 = 0x08000000; static mut DOUBLE_DASH_FOUND: bool = false; +#[derive(Clone, Copy)] +enum LogTarget { + WindowsGit, + Wsl, +} + +#[derive(Clone, Copy)] +enum LogOperation { + Run, + Start, + Wait, +} + +impl fmt::Display for LogOperation { + fn fmt(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + let value = match self { + LogOperation::Run => "run", + LogOperation::Start => "start", + LogOperation::Wait => "wait", + }; + write!(formatter, "{}", value) + } +} + +impl fmt::Display for LogTarget { + fn fmt(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + let value = match self { + LogTarget::WindowsGit => "windows_git", + LogTarget::Wsl => "wsl", + }; + write!(formatter, "{}", value) + } +} + +enum LogEvent { + Arguments { + input_count: usize, + forwarded_count: usize, + }, + Complete { + target: LogTarget, + exit_status: Option, + elapsed_ms: u128, + }, + Dispatch { + target: LogTarget, + }, + NoDistribution, + PathTranslation, + ProcessError { + target: LogTarget, + operation: LogOperation, + error_kind: io::ErrorKind, + elapsed_ms: u128, + }, + Start, +} + +impl fmt::Display for LogEvent { + fn fmt(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + match self { + LogEvent::Arguments { + input_count, + forwarded_count, + } => write!( + formatter, + "event=arguments input_count={} forwarded_count={}", + input_count, forwarded_count + ), + LogEvent::Complete { + target, + exit_status, + elapsed_ms, + } => { + let exit_status = exit_status + .map(|code| code.to_string()) + .unwrap_or_else(|| "unavailable".to_string()); + write!( + formatter, + "event=complete target={} exit_status={} elapsed_ms={}", + target, exit_status, elapsed_ms + ) + } + LogEvent::Dispatch { target } => { + write!(formatter, "event=dispatch target={}", target) + } + LogEvent::NoDistribution => write!(formatter, "event=no_distribution"), + LogEvent::PathTranslation => write!(formatter, "event=path_translation"), + LogEvent::ProcessError { + target, + operation, + error_kind, + elapsed_ms, + } => write!( + formatter, + "event=process_error target={} operation={} error_kind={:?} elapsed_ms={}", + target, operation, error_kind, elapsed_ms + ), + LogEvent::Start => write!(formatter, "event=start version={}", VERSION), + } + } +} + fn translate_path_to_unix(argument: String) -> String { let argument = argument.as_bytes(); @@ -153,12 +279,7 @@ fn translate_path_to_win(line: &[u8]) -> Vec { .output() .expect("failed to execute echo_cmd"); if enable_logging() { - log(format!( - "{:?} -> {} -> {:?}", - line, - echo_cmd, - std::str::from_utf8(&output.stdout).unwrap() - )); + log(LogEvent::PathTranslation); } return output.stdout; } @@ -363,40 +484,165 @@ fn enable_logging() -> bool { false } -fn log_arguments(out_args: &Vec) { - let in_args = env::args().collect::>(); - log(format!("{:?} -> {:?}", in_args, out_args)); +fn log_arguments(out_args: &[String]) { + log(LogEvent::Arguments { + input_count: env::args().skip(1).count(), + forwarded_count: out_args.len(), + }); +} + +fn write_log_record(open_log: F, message: &str) +where + F: FnOnce() -> io::Result>, +{ + if let Ok(mut log_file) = open_log() { + let mut record = Vec::with_capacity(message.len().saturating_add(1)); + record.extend_from_slice(message.as_bytes()); + record.push(b'\n'); + let _ = log_file.write_all(&record); + } +} + +struct NamedMutexGuard { + handle: *mut c_void, } -fn log(message: String) { - let logfile = match env::current_exe() { - Ok(exe_path) => exe_path - .parent() - .unwrap() - .join("wslgit.log") - .to_string_lossy() - .into_owned(), - Err(e) => { - eprintln!("Failed to get current exe path: {}", e); - Path::new("wslgit.log").to_string_lossy().into_owned() +impl Drop for NamedMutexGuard { + fn drop(&mut self) { + unsafe { + release_mutex(self.handle); + close_handle(self.handle); } + } +} + +fn try_acquire_named_mutex(name: &str) -> Option { + let wide_name = name + .encode_utf16() + .chain(std::iter::once(0)) + .collect::>(); + let handle = unsafe { create_mutex_w(std::ptr::null_mut(), 0, wide_name.as_ptr()) }; + if handle.is_null() { + return None; + } + + let wait_status = unsafe { wait_for_single_object(handle, 0) }; + if wait_status == WAIT_OBJECT_0 || wait_status == WAIT_ABANDONED { + Some(NamedMutexGuard { handle }) + } else { + unsafe { + close_handle(handle); + } + None + } +} + +fn write_bounded_log(logfile: &Path, message: &str, max_bytes: u64) { + let record_bytes = message.len().saturating_add(1) as u64; + if record_bytes > max_bytes { + return; + } + + let mut rotated_path = logfile.as_os_str().to_os_string(); + rotated_path.push(".1"); + let rotated_path = PathBuf::from(rotated_path); + + match std::fs::metadata(logfile) { + Ok(metadata) if metadata.len() > max_bytes => { + match std::fs::remove_file(&rotated_path) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(_) => return, + } + if std::fs::remove_file(logfile).is_err() { + return; + } + } + Ok(metadata) if metadata.len().saturating_add(record_bytes) > max_bytes => { + match std::fs::remove_file(&rotated_path) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(_) => return, + } + if std::fs::rename(logfile, rotated_path).is_err() { + return; + } + } + Ok(_) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(_) => return, + } + + write_log_record( + || { + OpenOptions::new() + .append(true) + .create(true) + .open(logfile) + .map(|file| Box::new(file) as Box) + }, + message, + ); +} + +fn log(event: LogEvent) { + let logfile = match env::current_exe() + .ok() + .and_then(|exe_path| exe_path.parent().map(|parent| parent.join("wslgit.log"))) + { + Some(logfile) => logfile, + None => return, }; - let f = OpenOptions::new() - .append(true) - .create(true) - .open(logfile) - .unwrap(); - write!(&f, "{}\n", message).unwrap(); + let timestamp_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + let record = format!( + "timestamp_ms={} pid={} {}", + timestamp_ms, + std::process::id(), + event + ); + + let _log_guard = match try_acquire_named_mutex(LOG_MUTEX_NAME) { + Some(guard) => guard, + None => return, + }; + write_bounded_log(&logfile, &record, MAX_LOG_BYTES); +} + +fn log_completion(target: LogTarget, status: &ExitStatus, started_at: &Instant) { + if enable_logging() { + log(LogEvent::Complete { + target, + exit_status: status.code(), + elapsed_ms: started_at.elapsed().as_millis(), + }); + } +} + +fn log_process_error( + target: LogTarget, + operation: LogOperation, + error: &io::Error, + started_at: &Instant, +) { + if enable_logging() { + log(LogEvent::ProcessError { + target, + operation, + error_kind: error.kind(), + elapsed_ms: started_at.elapsed().as_millis(), + }); + } } fn main() { + let started_at = Instant::now(); + if enable_logging() { - log(format!( - "wslgit version {}, current_dir {}", - VERSION, - env::current_dir().unwrap().to_str().unwrap().to_string() - )); + log(LogEvent::Start); } let have_terminal = { @@ -421,7 +667,7 @@ fn main() { } None => { if enable_logging() { - log("no distribution found".to_owned()); + log(LogEvent::NoDistribution); } if let Ok(windows_git) = env::var("WSLGIT_WINDOWS_GIT") { let status; @@ -433,14 +679,26 @@ fn main() { } if enable_logging() { - log(format!("running Windows git {}", windows_git)); + log(LogEvent::Dispatch { + target: LogTarget::WindowsGit, + }); log_arguments(&args); } - status = windows_git_proc_setup.status().expect(&format!( - "Failed to execute command '{}' {:?}", - &windows_git, args - )); + status = windows_git_proc_setup.status().unwrap_or_else(|error| { + log_process_error( + LogTarget::WindowsGit, + LogOperation::Run, + &error, + &started_at, + ); + panic!( + "Failed to execute command '{}' {:?}: {}", + &windows_git, args, error + ) + }); + + log_completion(LogTarget::WindowsGit, &status, &started_at); // forward any exit code if let Some(exit_code) = status.code() { @@ -461,6 +719,9 @@ fn main() { cmd_args.push(git_cmd.clone()); if enable_logging() { + log(LogEvent::Dispatch { + target: LogTarget::Wsl, + }); log_arguments(&cmd_args); } @@ -493,10 +754,14 @@ fn main() { let git_proc = git_proc_setup .stdout(Stdio::piped()) .spawn() - .expect(&format!("Failed to execute command '{}'", &git_cmd)); - let output = git_proc - .wait_with_output() - .expect(&format!("Failed to wait for git call '{}'", &git_cmd)); + .unwrap_or_else(|error| { + log_process_error(LogTarget::Wsl, LogOperation::Start, &error, &started_at); + panic!("Failed to execute command '{}': {}", &git_cmd, error) + }); + let output = git_proc.wait_with_output().unwrap_or_else(|error| { + log_process_error(LogTarget::Wsl, LogOperation::Wait, &error, &started_at); + panic!("Failed to wait for git call '{}': {}", &git_cmd, error) + }); status = output.status; let output_bytes = output.stdout; let mut stdout = io::stdout(); @@ -507,11 +772,14 @@ fn main() { } else { // run the subprocess without capturing its output // the output of the subprocess is passed through unchanged - status = git_proc_setup - .status() - .expect(&format!("Failed to execute command '{}'", &git_cmd)); + status = git_proc_setup.status().unwrap_or_else(|error| { + log_process_error(LogTarget::Wsl, LogOperation::Run, &error, &started_at); + panic!("Failed to execute command '{}': {}", &git_cmd, error) + }); } + log_completion(LogTarget::Wsl, &status, &started_at); + // forward any exit code if let Some(exit_code) = status.code() { std::process::exit(exit_code); @@ -522,6 +790,151 @@ fn main() { mod tests { use super::*; + #[test] + fn log_open_failures_are_ignored() { + write_log_record( + || Err(io::Error::new(io::ErrorKind::PermissionDenied, "denied")), + "event=test", + ); + } + + #[test] + fn log_write_failures_are_ignored() { + struct FailingWriter; + + impl Write for FailingWriter { + fn write(&mut self, _buffer: &[u8]) -> io::Result { + Err(io::Error::new(io::ErrorKind::WriteZero, "write failed")) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + + write_log_record(|| Ok(Box::new(FailingWriter)), "event=test"); + } + + #[test] + fn log_record_is_written_with_one_write_call() { + use std::sync::{Arc, Mutex}; + + struct RecordingWriter { + writes: Arc>>>, + } + + impl Write for RecordingWriter { + fn write(&mut self, buffer: &[u8]) -> io::Result { + self.writes.lock().unwrap().push(buffer.to_vec()); + Ok(buffer.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + + let writes = Arc::new(Mutex::new(Vec::new())); + let writer_writes = Arc::clone(&writes); + write_log_record( + move || { + Ok(Box::new(RecordingWriter { + writes: writer_writes, + })) + }, + "event=test", + ); + + assert_eq!(*writes.lock().unwrap(), vec![b"event=test\n".to_vec()]); + } + + #[test] + fn log_mutex_is_nonblocking_between_threads() { + let mutex_name = format!( + r"Local\wslgit-log-test-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + ); + let guard = try_acquire_named_mutex(&mutex_name).expect("failed to acquire test mutex"); + + let contended_name = mutex_name.clone(); + let contended = + std::thread::spawn(move || try_acquire_named_mutex(&contended_name).is_none()) + .join() + .unwrap(); + assert!(contended); + + drop(guard); + let acquired = std::thread::spawn(move || try_acquire_named_mutex(&mutex_name).is_some()) + .join() + .unwrap(); + assert!(acquired); + } + + #[test] + fn process_error_event_contains_only_controlled_metadata() { + assert_eq!( + LogEvent::ProcessError { + target: LogTarget::Wsl, + operation: LogOperation::Start, + error_kind: io::ErrorKind::NotFound, + elapsed_ms: 12, + } + .to_string(), + "event=process_error target=wsl operation=start error_kind=NotFound elapsed_ms=12" + ); + } + + #[test] + fn log_rotates_before_exceeding_the_size_limit() { + let test_dir = env::temp_dir().join(format!( + "wslgit-log-rotation-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&test_dir).unwrap(); + let log_path = test_dir.join("wslgit.log"); + std::fs::write(&log_path, b"12345678").unwrap(); + + write_bounded_log(&log_path, "abcd", 10); + + assert_eq!(std::fs::read(&log_path).unwrap(), b"abcd\n"); + assert_eq!( + std::fs::read(test_dir.join("wslgit.log.1")).unwrap(), + b"12345678" + ); + assert!(std::fs::metadata(&log_path).unwrap().len() <= 10); + std::fs::remove_dir_all(test_dir).unwrap(); + } + + #[test] + fn log_discards_an_existing_file_that_already_exceeds_the_limit() { + let test_dir = env::temp_dir().join(format!( + "wslgit-log-oversize-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&test_dir).unwrap(); + let log_path = test_dir.join("wslgit.log"); + std::fs::write(&log_path, b"123456789012").unwrap(); + + write_bounded_log(&log_path, "abcd", 10); + + assert_eq!(std::fs::read(&log_path).unwrap(), b"abcd\n"); + let rotated_log_path = test_dir.join("wslgit.log.1"); + assert!(!rotated_log_path.exists()); + std::fs::remove_dir_all(test_dir).unwrap(); + } + #[test] fn use_interactive_shell_test() { // default diff --git a/tests/integration.rs b/tests/integration.rs index 1c2962c..fde3630 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -7,7 +7,10 @@ mod integration { use assert_cmd::prelude::*; use predicates::prelude::*; use std::env; - use std::process::Command; + use std::process::{Command, Stdio}; + use std::sync::Mutex; + + static LOG_TEST_MUTEX: Mutex<()> = Mutex::new(()); #[test] fn simple_argument() { @@ -19,6 +22,164 @@ mod integration { .stdout(predicate::str::contains("git version")); } + #[test] + fn diagnostic_log_excludes_arguments_and_working_directory() { + let _log_guard = LOG_TEST_MUTEX + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let sentinel = "WSLGIT_SECRET_SENTINEL_7F3A9C"; + let binary = cargo_bin!(env!("CARGO_PKG_NAME")); + let log_path = binary.parent().unwrap().join("wslgit.log"); + let rotated_log_path = binary.parent().unwrap().join("wslgit.log.1"); + let test_dir = env::temp_dir().join(sentinel); + + let _ = std::fs::remove_file(&log_path); + let _ = std::fs::remove_file(&rotated_log_path); + std::fs::create_dir_all(&test_dir).unwrap(); + + Command::new(binary.as_os_str()) + .args(["-c", &format!("wslgit.test={}", sentinel), "--version"]) + .current_dir(&test_dir) + .env("WSLGIT_ENABLE_LOGGING", "1") + .env("WSLGIT_USE_INTERACTIVE_SHELL", "false") + .assert() + .success() + .stdout(predicate::str::contains("git version")); + + let log_contents = std::fs::read_to_string(&log_path).unwrap(); + let _ = std::fs::remove_file(&log_path); + let _ = std::fs::remove_file(&rotated_log_path); + std::fs::remove_dir_all(test_dir).unwrap(); + + assert!(!log_contents.contains(sentinel), "{}", log_contents); + } + + #[test] + fn diagnostic_log_records_completion_metadata() { + let _log_guard = LOG_TEST_MUTEX + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let binary = cargo_bin!(env!("CARGO_PKG_NAME")); + let log_path = binary.parent().unwrap().join("wslgit.log"); + let rotated_log_path = binary.parent().unwrap().join("wslgit.log.1"); + + let _ = std::fs::remove_file(&log_path); + let _ = std::fs::remove_file(&rotated_log_path); + + Command::new(binary.as_os_str()) + .arg("--version") + .env("WSLGIT_ENABLE_LOGGING", "true") + .env("WSLGIT_USE_INTERACTIVE_SHELL", "false") + .assert() + .success(); + + let log_contents = std::fs::read_to_string(&log_path).unwrap(); + let _ = std::fs::remove_file(&log_path); + let _ = std::fs::remove_file(&rotated_log_path); + + let records = log_contents.lines().collect::>(); + assert!(!records.is_empty()); + + let process_ids = records + .iter() + .map(|record| { + let timestamp_ms = record + .split_whitespace() + .find_map(|field| field.strip_prefix("timestamp_ms=")) + .expect("missing record timestamp") + .parse::() + .expect("record timestamp is not numeric"); + assert!(timestamp_ms > 0, "{}", record); + + record + .split_whitespace() + .find_map(|field| field.strip_prefix("pid=")) + .expect("missing process ID") + .parse::() + .expect("process ID is not numeric") + }) + .collect::>(); + assert!( + process_ids.windows(2).all(|pair| pair[0] == pair[1]), + "{:?}", + process_ids + ); + + let completion = records + .iter() + .find(|line| line.contains("event=complete ")) + .expect("missing completion record"); + let dispatch = records + .iter() + .find(|line| line.contains("event=dispatch target=wsl")) + .expect("missing WSL dispatch record"); + assert!(dispatch.contains("pid="), "{}", dispatch); + assert!(completion.contains("target=wsl"), "{}", completion); + assert!(completion.contains("exit_status=0"), "{}", completion); + let elapsed_ms = completion + .split("elapsed_ms=") + .nth(1) + .expect("missing elapsed duration") + .parse::() + .expect("elapsed duration is not numeric"); + assert!(elapsed_ms < 60_000, "{}", completion); + } + + #[test] + fn diagnostic_log_rotation_survives_concurrent_processes() { + const TEST_MAX_LOG_BYTES: usize = 1024 * 1024; + + let _log_guard = LOG_TEST_MUTEX + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let binary = cargo_bin!(env!("CARGO_PKG_NAME")); + let log_path = binary.parent().unwrap().join("wslgit.log"); + let rotated_log_path = binary.parent().unwrap().join("wslgit.log.1"); + + let _ = std::fs::remove_file(&log_path); + let _ = std::fs::remove_file(&rotated_log_path); + + let history_marker = b"pre-rotation-history\n"; + let mut full_log = history_marker.to_vec(); + full_log.resize(TEST_MAX_LOG_BYTES, b'x'); + std::fs::write(&log_path, full_log).unwrap(); + + let mut children = Vec::new(); + for _ in 0..8 { + children.push( + Command::new(binary.as_os_str()) + .arg("--version") + .env("WSLGIT_ENABLE_LOGGING", "1") + .env("WSLGIT_USE_INTERACTIVE_SHELL", "false") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(), + ); + } + for mut child in children { + assert!(child.wait().unwrap().success()); + } + + let active_log = std::fs::read_to_string(&log_path).unwrap(); + let rotated_log = std::fs::read(&rotated_log_path).unwrap(); + let active_size = std::fs::metadata(&log_path).unwrap().len(); + let rotated_size = std::fs::metadata(&rotated_log_path).unwrap().len(); + + let _ = std::fs::remove_file(&log_path); + let _ = std::fs::remove_file(&rotated_log_path); + + assert!(active_size <= TEST_MAX_LOG_BYTES as u64); + assert!(rotated_size <= TEST_MAX_LOG_BYTES as u64); + assert!(rotated_log.starts_with(history_marker)); + assert!(!active_log.is_empty()); + for record in active_log.lines() { + assert!(record.starts_with("timestamp_ms="), "{}", record); + assert!(record.contains(" pid="), "{}", record); + assert!(record.contains(" event="), "{}", record); + } + } + #[test] fn argument_with_invalid_characters() { // https://github.com/andy-5/wslgit/issues/54