//! Thin wrapper around the `git` executable used for every mutating operation.
use ide_core::{CancellationToken, IdeError, IdeResult};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::OnceLock;
use std::sync::mpsc;
use std::time::Duration;
/// Explicit git executable from Settings › Version Control › Git (`None` = auto-detect).
static CONFIGURED: std::sync::RwLock> = std::sync::RwLock::new(None);
/// Sets (or clears, with `None` / an empty path) the git executable to use.
pub fn set_git_executable(path: Option<&Path>) {
let path = path.filter(|p| !p.as_os_str().is_empty()).map(Path::to_path_buf);
if *CONFIGURED.read().unwrap_or_else(|e| e.into_inner()) != path {
*CONFIGURED.write().unwrap_or_else(|e| e.into_inner()) = path;
}
}
/// Locates the auto-detected git executable once per process.
///
/// GUI apps on macOS start with a minimal `PATH`, so a few well-known install
/// locations are probed after the `PATH` lookup.
fn detected_git() -> Option<&'static Path> {
static GIT: OnceLock > = OnceLock::new();
GIT.get_or_init(|| {
if let Some(explicit) = std::env::var_os("OPENIDEA_GIT").map(PathBuf::from).filter(|p| p.is_file()) {
return Some(explicit);
}
if let Ok(found) = which::which("git") {
return Some(found);
}
let candidates: &[&str] = if cfg!(windows) {
&[r"C:\Program Files\Git\cmd\git.exe", r"C:\Program Files (x86)\Git\cmd\git.exe"]
} else {
&["/opt/homebrew/bin/git", "/usr/local/bin/git", "/usr/bin/git", "/bin/git"]
};
candidates.iter().map(PathBuf::from).find(|p| p.is_file())
})
.as_deref()
}
/// The auto-detected git executable, if any.
pub fn detected_git_executable() -> Option {
detected_git().map(Path::to_path_buf)
}
pub(crate) fn git_binary() -> IdeResult {
if let Some(configured) = CONFIGURED.read().unwrap_or_else(|e| e.into_inner()).clone() {
return Ok(configured);
}
detected_git()
.map(Path::to_path_buf)
.ok_or_else(|| IdeError::unavailable("git executable not found; install git and make sure it is on PATH"))
}
/// `git --version` of `executable` (or the configured / detected one), e.g. `git version 2.44.0`.
pub fn git_version(executable: Option<&Path>) -> IdeResult {
let exe = match executable.filter(|p| !p.as_os_str().is_empty()) {
Some(p) => p.to_path_buf(),
None => git_binary()?,
};
let mut cmd = Command::new(&exe);
cmd.arg("--version").stdin(Stdio::null()).env("LC_ALL", "C");
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(0x0800_0000);
}
let out = cmd.output().map_err(|e| IdeError::unavailable(format!("cannot run {}: {e}", exe.display())))?;
let text = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !out.status.success() || !text.starts_with("git version") {
let err = String::from_utf8_lossy(&out.stderr).trim().to_string();
return Err(IdeError::invalid(format!(
"{} is not a git executable{}",
exe.display(),
if err.is_empty() { String::new() } else { format!(": {err}") }
)));
}
Ok(text)
}
/// Builds a `git` command with the environment every invocation needs.
pub(crate) fn command(root: &Path) -> IdeResult {
let mut cmd = Command::new(git_binary()?);
cmd.current_dir(root)
.arg("-c")
.arg("core.quotepath=off")
.arg("-c")
.arg("color.ui=false")
.env("GIT_TERMINAL_PROMPT", "0")
.env("LC_ALL", "C")
.env("LANG", "C")
.env("GIT_PAGER", "cat")
.env("GIT_EDITOR", "true")
.env("GIT_SEQUENCE_EDITOR", "true")
.env_remove("GIT_DIR")
.env_remove("GIT_WORK_TREE")
.env_remove("GIT_INDEX_FILE");
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
cmd.creation_flags(CREATE_NO_WINDOW);
}
Ok(cmd)
}
pub(crate) struct Output {
pub stdout: Vec,
pub stderr: Vec,
pub code: Option,
}
impl Output {
pub fn stdout_str(&self) -> String {
String::from_utf8_lossy(&self.stdout).into_owned()
}
}
/// Runs git to completion, optionally feeding `stdin`. Never fails on a non-zero exit.
pub(crate) fn run_raw(root: &Path, args: &[&str], stdin: Option<&[u8]>) -> IdeResult {
run_raw_env(root, args, stdin, &[])
}
/// [`run_raw`] with extra environment variables (e.g. a temporary `GIT_INDEX_FILE`).
pub(crate) fn run_raw_env(
root: &Path,
args: &[&str],
stdin: Option<&[u8]>,
env: &[(&str, &std::ffi::OsStr)],
) -> IdeResult {
let mut cmd = command(root)?;
for (k, v) in env {
cmd.env(k, v);
}
cmd.args(args)
.stdin(if stdin.is_some() { Stdio::piped() } else { Stdio::null() })
.stdout(Stdio::piped())
.stderr(Stdio::piped());
tracing::debug!(?args, "git");
let mut child = cmd.spawn().map_err(spawn_error)?;
let writer = match (stdin, child.stdin.take()) {
(Some(data), Some(mut pipe)) => {
let data = data.to_vec();
Some(std::thread::spawn(move || {
// A broken pipe just means git stopped reading early; its exit status tells the story.
let _ = pipe.write_all(&data);
}))
}
_ => None,
};
let out = child.wait_with_output()?;
if let Some(w) = writer {
let _ = w.join();
}
Ok(Output { stdout: out.stdout, stderr: out.stderr, code: out.status.code() })
}
/// Runs git and maps a non-zero exit status to an error carrying git's message.
pub(crate) fn run(root: &Path, args: &[&str], stdin: Option<&[u8]>) -> IdeResult {
let out = run_raw(root, args, stdin)?;
if out.code == Some(0) { Ok(out) } else { Err(failure(args, &out.stderr, &out.stdout, out.code)) }
}
/// [`run`] with extra environment variables.
pub(crate) fn run_env(
root: &Path,
args: &[&str],
stdin: Option<&[u8]>,
env: &[(&str, &std::ffi::OsStr)],
) -> IdeResult {
let out = run_raw_env(root, args, stdin, env)?;
if out.code == Some(0) { Ok(out) } else { Err(failure(args, &out.stderr, &out.stdout, out.code)) }
}
fn spawn_error(e: std::io::Error) -> IdeError {
if e.kind() == std::io::ErrorKind::NotFound {
IdeError::unavailable(format!("could not run git: {e}"))
} else {
e.into()
}
}
pub(crate) fn failure(args: &[&str], stderr: &[u8], stdout: &[u8], code: Option) -> IdeError {
let stderr = String::from_utf8_lossy(stderr).trim().to_string();
let stdout = String::from_utf8_lossy(stdout).trim().to_string();
let mut msg = if stderr.is_empty() {
stdout.clone()
} else {
// Merge/rebase/stash report conflicts on stdout; keep them next to the error.
let conflicts: Vec<&str> = stdout.lines().filter(|l| l.starts_with("CONFLICT")).collect();
if conflicts.is_empty() { stderr } else { format!("{}\n{stderr}", conflicts.join("\n")) }
};
if msg.is_empty() {
let sub = subcommand(args);
msg = match code {
Some(c) => format!("git {sub} failed with exit code {c}"),
None => format!("git {sub} was terminated"),
};
}
IdeError::new(classify(&msg), msg)
}
fn subcommand<'a>(args: &[&'a str]) -> &'a str {
let mut skip_next = false;
for a in args {
if skip_next {
skip_next = false;
} else if *a == "-c" {
skip_next = true;
} else if !a.starts_with('-') {
return a;
}
}
"git"
}
fn classify(msg: &str) -> ide_core::error::ErrorKind {
use ide_core::error::ErrorKind;
let lower = msg.to_ascii_lowercase();
if lower.contains("did not match any file")
|| lower.contains("unknown revision")
|| lower.contains("not found")
|| lower.contains("does not exist")
{
ErrorKind::NotFound
} else if lower.contains("already exists") {
ErrorKind::AlreadyExists
} else if lower.contains("permission denied") {
ErrorKind::PermissionDenied
} else {
ErrorKind::Internal
}
}
/// Runs a (network) command, streaming every progress line to `on_output`.
///
/// Lines are split on both `\r` and `\n` so git's in-place progress counters arrive
/// as separate updates. The child (and, on Unix, its whole process group — ssh,
/// remote helpers) is killed promptly once `cancel` fires.
pub(crate) fn run_streaming(
root: &Path,
args: &[&str],
cancel: &CancellationToken,
on_output: &dyn Fn(String),
) -> IdeResult<()> {
run_streaming_env(root, args, &[], cancel, on_output)
}
/// [`run_streaming`] with extra environment variables (e.g. a sequence editor).
pub(crate) fn run_streaming_env(
root: &Path,
args: &[&str],
env: &[(&str, &std::ffi::OsStr)],
cancel: &CancellationToken,
on_output: &dyn Fn(String),
) -> IdeResult<()> {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let mut cmd = command(root)?;
for (k, v) in env {
cmd.env(k, v);
}
cmd.args(args).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
cmd.process_group(0);
}
tracing::debug!(?args, "git (streaming)");
let mut child = cmd.spawn().map_err(spawn_error)?;
let (tx, rx) = mpsc::channel::();
let mut readers = Vec::new();
if let Some(out) = child.stdout.take() {
readers.push(spawn_reader(out, tx.clone(), Stream::Stdout));
}
if let Some(err) = child.stderr.take() {
readers.push(spawn_reader(err, tx.clone(), Stream::Stderr));
}
drop(tx);
// Keep the tail of stderr (and stdout conflict reports) for the error message.
let mut stderr_tail: Vec = Vec::new();
let mut conflicts: Vec = Vec::new();
let mut open = readers.len();
while open > 0 {
if cancel.is_cancelled() {
kill(&mut child);
let _ = child.wait();
for r in readers {
let _ = r.join();
}
return Err(IdeError::cancelled());
}
match rx.recv_timeout(Duration::from_millis(40)) {
Ok(Event::Line(stream, line)) => {
if stream == Stream::Stderr {
stderr_tail.push(line.clone());
if stderr_tail.len() > 40 {
stderr_tail.remove(0);
}
} else if line.starts_with("CONFLICT") && conflicts.len() < 40 {
conflicts.push(line.clone());
}
on_output(line);
}
Ok(Event::Closed) => open -= 1,
Err(mpsc::RecvTimeoutError::Timeout) => {}
Err(mpsc::RecvTimeoutError::Disconnected) => break,
}
}
// Pipes are closed; the process is exiting. Still honour cancellation while waiting.
let status = loop {
if let Some(status) = child.try_wait()? {
break status;
}
if cancel.is_cancelled() {
kill(&mut child);
let _ = child.wait();
return Err(IdeError::cancelled());
}
std::thread::sleep(Duration::from_millis(10));
};
for r in readers {
let _ = r.join();
}
if status.success() {
return Ok(());
}
let relevant: Vec<&str> =
conflicts.iter().chain(stderr_tail.iter()).map(String::as_str).filter(|l| !is_progress_line(l)).collect();
let msg = relevant.join("\n");
Err(failure(args, msg.as_bytes(), b"", status.code()))
}
fn is_progress_line(line: &str) -> bool {
let progress_prefixes = [
"remote: Counting",
"remote: Compressing",
"remote: Enumerating",
"remote: Total",
"Counting objects",
"Compressing objects",
"Enumerating objects",
"Receiving objects",
"Resolving deltas",
"Writing objects",
"Delta compression",
"Unpacking objects",
];
progress_prefixes.iter().any(|p| line.starts_with(p))
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Stream {
Stdout,
Stderr,
}
enum Event {
Line(Stream, String),
Closed,
}
fn spawn_reader(
mut pipe: impl Read + Send + 'static,
tx: mpsc::Sender,
stream: Stream,
) -> std::thread::JoinHandle<()> {
std::thread::spawn(move || {
let mut buf = [0u8; 8192];
let mut pending: Vec = Vec::new();
let flush = |pending: &mut Vec| {
if !pending.is_empty() {
let line = String::from_utf8_lossy(pending).trim_end().to_string();
pending.clear();
if !line.is_empty() {
let _ = tx.send(Event::Line(stream, line));
}
}
};
loop {
match pipe.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => {
for &b in &buf[..n] {
if b == b'\n' || b == b'\r' {
flush(&mut pending);
} else {
pending.push(b);
}
}
}
}
}
flush(&mut pending);
let _ = tx.send(Event::Closed);
})
}
fn kill(child: &mut Child) {
#[cfg(unix)]
{
let pid = child.id() as libc::pid_t;
// SAFETY: plain syscall; the child was spawned as leader of its own process group.
unsafe {
libc::kill(-pid, libc::SIGTERM);
}
std::thread::sleep(Duration::from_millis(20));
unsafe {
libc::kill(-pid, libc::SIGKILL);
}
}
let _ = child.kill();
}