use std::fs;
use std::future::Future;
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::process::{Command, Output, Stdio};
use std::time::Duration;
use tokio::process::Command as AsyncCommand;
use tokio::task::spawn_blocking;
use tokio::time;
use super::error::GitError;
const GIT_COMMAND_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Clone, Debug, Eq, PartialEq)]
pub(super) struct AsyncGitCommand {
pub(super) arguments: Vec<String>,
pub(super) environment: Vec<(String, String)>,
pub(super) repo_path: PathBuf,
}
impl AsyncGitCommand {
pub(super) fn new(repo_path: PathBuf, arguments: Vec<String>) -> Self {
Self {
arguments,
environment: Vec::new(),
repo_path,
}
}
pub(super) fn with_environment(mut self, environment: Vec<(String, String)>) -> Self {
self.environment = environment;
self
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(super) struct AsyncGitCommandOutput {
pub(super) exit_code: Option<i32>,
pub(super) stderr: Vec<u8>,
pub(super) stdout: Vec<u8>,
}
impl AsyncGitCommandOutput {
pub(super) fn success(&self) -> bool {
self.exit_code == Some(0)
}
}
#[cfg_attr(test, mockall::automock)]
pub(super) trait AsyncGitCommandRunner: Send + Sync {
fn run(
&self,
command: AsyncGitCommand,
) -> Pin<Box<dyn Future<Output = Result<AsyncGitCommandOutput, GitError>> + Send>>;
}
pub(super) struct ProcessAsyncGitCommandRunner;
impl AsyncGitCommandRunner for ProcessAsyncGitCommandRunner {
fn run(
&self,
command: AsyncGitCommand,
) -> Pin<Box<dyn Future<Output = Result<AsyncGitCommandOutput, GitError>> + Send>> {
Box::pin(async move { run_git_command_with_timeout(command, GIT_COMMAND_TIMEOUT).await })
}
}
pub(crate) async fn repo_url(repo_path: PathBuf) -> Result<String, GitError> {
let remote = run_git_command(
repo_path,
vec![
"remote".to_string(),
"get-url".to_string(),
"origin".to_string(),
],
"Git remote get-url failed".to_string(),
)
.await?;
Ok(normalize_repo_url(remote.trim()))
}
pub(crate) async fn main_repo_root(repo_path: PathBuf) -> Result<PathBuf, GitError> {
match resolve_shared_repo(&repo_path).await? {
SharedRepo::Working(path) | SharedRepo::Bare(path) => Ok(path),
}
}
pub(crate) async fn main_checkout_working_tree(
repo_path: PathBuf,
) -> Result<Option<PathBuf>, GitError> {
spawn_blocking(move || main_checkout_working_tree_sync(&repo_path)).await?
}
pub(super) fn main_checkout_working_tree_sync(
repo_path: &Path,
) -> Result<Option<PathBuf>, GitError> {
match resolve_shared_repo_sync(repo_path)? {
SharedRepo::Working(path) => Ok(Some(path)),
SharedRepo::Bare(_) => Ok(None),
}
}
pub(super) enum SharedRepo {
Working(PathBuf),
Bare(PathBuf),
}
pub(super) fn resolve_shared_repo_sync(repo_path: &Path) -> Result<SharedRepo, GitError> {
let (git_dir, git_common_dir) = git_directory_paths(repo_path)?;
let is_bare = run_git_command_sync(
&git_common_dir,
&["rev-parse", "--is-bare-repository"],
"Git rev-parse --is-bare-repository failed",
)?;
if is_bare.trim() == "true" {
return Ok(SharedRepo::Bare(git_common_dir));
}
if git_dir == git_common_dir {
return Ok(SharedRepo::Working(repo_root_from_git_dir(
repo_path, &git_dir,
)?));
}
Ok(SharedRepo::Working(repo_root_from_git_dir(
repo_path,
&git_common_dir,
)?))
}
pub(super) fn resolve_git_dir(repo_dir: &Path) -> Option<PathBuf> {
let dot_git = repo_dir.join(".git");
if dot_git.is_dir() {
return Some(dot_git);
}
if dot_git.is_file() {
let content = fs::read_to_string(&dot_git).ok()?;
let git_dir_line = content.lines().find(|line| line.starts_with("gitdir:"))?;
let git_dir_path = git_dir_line.trim_start_matches("gitdir:").trim();
let git_dir = PathBuf::from(git_dir_path);
if git_dir.is_absolute() {
return Some(git_dir);
}
return Some(repo_dir.join(git_dir));
}
None
}
pub(super) async fn run_git_command(
repo_path: PathBuf,
args: Vec<String>,
error_context: String,
) -> Result<String, GitError> {
let command_runner = ProcessAsyncGitCommandRunner;
run_git_command_with_runner(
AsyncGitCommand::new(repo_path, args),
&error_context,
&command_runner,
)
.await
}
pub(super) async fn run_git_command_with_runner(
command: AsyncGitCommand,
error_context: &str,
command_runner: &dyn AsyncGitCommandRunner,
) -> Result<String, GitError> {
let git_invocation = format_git_invocation_from_strings(&command.arguments);
let output = command_runner.run(command).await?;
if !output.success() {
let detail = command_output_detail(&output.stdout, &output.stderr);
return Err(GitError::CommandFailed {
command: git_invocation,
stderr: format!("{error_context}: {detail}"),
});
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
async fn run_git_command_with_timeout(
command: AsyncGitCommand,
timeout: Duration,
) -> Result<AsyncGitCommandOutput, GitError> {
let git_invocation = format_git_invocation_from_strings(&command.arguments);
let mut process = AsyncCommand::new("git");
process
.args(&command.arguments)
.current_dir(&command.repo_path)
.stdin(Stdio::null())
.kill_on_drop(true);
apply_non_interactive_environment_async(&mut process);
for (key, value) in &command.environment {
process.env(key, value);
}
let output = time::timeout(timeout, process.output())
.await
.map_err(|_| GitError::CommandTimedOut {
command: git_invocation.clone(),
timeout,
})?
.map_err(|error| GitError::CommandFailed {
command: git_invocation.clone(),
stderr: error.to_string(),
})?;
Ok(AsyncGitCommandOutput {
exit_code: output.status.code(),
stderr: output.stderr,
stdout: output.stdout,
})
}
pub(super) fn run_git_command_sync(
repo_path: &Path,
args: &[&str],
error_context: &str,
) -> Result<String, GitError> {
let output = run_git_command_output_sync(repo_path, args)?;
if !output.status.success() {
let detail = command_output_detail(&output.stdout, &output.stderr);
let git_invocation = format_git_invocation(args);
return Err(GitError::CommandFailed {
command: git_invocation,
stderr: format!("{error_context}: {detail}"),
});
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
fn format_git_invocation(args: &[&str]) -> String {
if args.is_empty() {
return "git".to_string();
}
format!("git {}", args.join(" "))
}
pub(super) fn format_git_invocation_from_strings(args: &[String]) -> String {
let argument_refs = args.iter().map(String::as_str).collect::<Vec<_>>();
format_git_invocation(&argument_refs)
}
pub(super) fn run_git_command_output_sync(
repo_path: &Path,
args: &[&str],
) -> Result<Output, GitError> {
run_git_command_output_with_env_sync(repo_path, args, &[] as &[(&str, &str)])
}
pub(super) fn run_git_command_output_with_env_sync<Key, Value>(
repo_path: &Path,
args: &[&str],
environment: &[(Key, Value)],
) -> Result<Output, GitError>
where
Key: AsRef<std::ffi::OsStr>,
Value: AsRef<std::ffi::OsStr>,
{
let mut command = Command::new("git");
command
.args(args)
.current_dir(repo_path)
.stdin(Stdio::null());
apply_non_interactive_environment(&mut command);
for (key, value) in environment {
command.env(key, value);
}
command.output().map_err(|error| GitError::CommandFailed {
command: format_git_invocation(args),
stderr: error.to_string(),
})
}
fn apply_non_interactive_environment(command: &mut Command) {
let git_ssh_command = std::env::var("GIT_SSH_COMMAND").map_or_else(
|_| "ssh -o BatchMode=yes".to_string(),
|configured_command| format!("{configured_command} -o BatchMode=yes"),
);
command
.env("GIT_OPTIONAL_LOCKS", "0")
.env("GIT_TERMINAL_PROMPT", "0")
.env("GCM_INTERACTIVE", "never")
.env("GIT_SSH_COMMAND", git_ssh_command);
}
fn apply_non_interactive_environment_async(command: &mut AsyncCommand) {
let git_ssh_command = std::env::var("GIT_SSH_COMMAND").map_or_else(
|_| "ssh -o BatchMode=yes".to_string(),
|configured_command| format!("{configured_command} -o BatchMode=yes"),
);
command
.env("GIT_OPTIONAL_LOCKS", "0")
.env("GIT_TERMINAL_PROMPT", "0")
.env("GCM_INTERACTIVE", "never")
.env("GIT_SSH_COMMAND", git_ssh_command);
}
pub(super) fn command_output_detail(stdout: &[u8], stderr: &[u8]) -> String {
let stderr_text = String::from_utf8_lossy(stderr).trim().to_string();
if !stderr_text.is_empty() {
return stderr_text;
}
let stdout_text = String::from_utf8_lossy(stdout).trim().to_string();
if !stdout_text.is_empty() {
return stdout_text;
}
"Unknown git error".to_string()
}
async fn resolve_shared_repo(repo_path: &Path) -> Result<SharedRepo, GitError> {
let (git_dir, git_common_dir) = git_directory_paths_async(repo_path).await?;
let is_bare = run_git_command(
git_common_dir.clone(),
vec!["rev-parse".to_string(), "--is-bare-repository".to_string()],
"Git rev-parse --is-bare-repository failed".to_string(),
)
.await?;
if is_bare.trim() == "true" {
return Ok(SharedRepo::Bare(git_common_dir));
}
let shared_git_dir = if git_dir == git_common_dir {
git_dir
} else {
git_common_dir
};
let repo_root = repo_root_from_git_dir_async(repo_path, &shared_git_dir).await?;
Ok(SharedRepo::Working(repo_root))
}
fn normalize_repo_url(remote: &str) -> String {
let trimmed = remote.trim_end_matches(".git");
if let Some(path) = trimmed.strip_prefix("git@github.com:") {
return format!("https://github.com/{path}");
}
if let Some(path) = trimmed.strip_prefix("ssh://git@github.com/") {
return format!("https://github.com/{path}");
}
trimmed.to_string()
}
async fn git_directory_paths_async(repo_path: &Path) -> Result<(PathBuf, PathBuf), GitError> {
let stdout = run_git_command(
repo_path.to_path_buf(),
vec![
"rev-parse".to_string(),
"--git-dir".to_string(),
"--git-common-dir".to_string(),
],
"Git rev-parse failed".to_string(),
)
.await?;
parse_git_directory_paths(repo_path, &stdout)
}
fn git_directory_paths(repo_path: &Path) -> Result<(PathBuf, PathBuf), GitError> {
let stdout = run_git_command_sync(
repo_path,
&["rev-parse", "--git-dir", "--git-common-dir"],
"Git rev-parse failed",
)?;
parse_git_directory_paths(repo_path, &stdout)
}
fn parse_git_directory_paths(
repo_path: &Path,
stdout: &str,
) -> Result<(PathBuf, PathBuf), GitError> {
let mut lines = stdout
.lines()
.map(str::trim)
.filter(|line| !line.is_empty());
let git_dir = lines
.next()
.ok_or_else(|| GitError::OutputParse("Git rev-parse output missing git-dir".to_string()))?;
let git_common_dir = lines.next().ok_or_else(|| {
GitError::OutputParse("Git rev-parse output missing git-common-dir".to_string())
})?;
Ok((
normalize_git_dir_path(repo_path, git_dir),
normalize_git_dir_path(repo_path, git_common_dir),
))
}
async fn repo_root_from_git_dir_async(
repo_path: &Path,
git_dir: &Path,
) -> Result<PathBuf, GitError> {
if let Some(repo_root) = repo_root_from_dot_git_dir(git_dir)? {
return Ok(repo_root);
}
let root = run_git_command(
repo_path.to_path_buf(),
vec!["rev-parse".to_string(), "--show-toplevel".to_string()],
"Git rev-parse --show-toplevel failed".to_string(),
)
.await?;
parse_repo_root(&root)
}
fn repo_root_from_git_dir(repo_path: &Path, git_dir: &Path) -> Result<PathBuf, GitError> {
if let Some(repo_root) = repo_root_from_dot_git_dir(git_dir)? {
return Ok(repo_root);
}
let root = run_git_command_sync(
repo_path,
&["rev-parse", "--show-toplevel"],
"Git rev-parse --show-toplevel failed",
)?;
parse_repo_root(&root)
}
fn repo_root_from_dot_git_dir(git_dir: &Path) -> Result<Option<PathBuf>, GitError> {
if git_dir
.file_name()
.and_then(std::ffi::OsStr::to_str)
.is_none_or(|name| name != ".git")
{
return Ok(None);
}
git_dir
.parent()
.map(Path::to_path_buf)
.map(Some)
.ok_or_else(|| {
GitError::OutputParse(format!(
"Git directory has no parent: {}",
git_dir.display()
))
})
}
fn parse_repo_root(root: &str) -> Result<PathBuf, GitError> {
let root = root.trim().to_string();
if root.is_empty() {
return Err(GitError::OutputParse(
"Git rev-parse --show-toplevel returned empty output".to_string(),
));
}
Ok(PathBuf::from(root))
}
fn normalize_git_dir_path(repo_path: &Path, git_path: &str) -> PathBuf {
let git_path = PathBuf::from(git_path);
let git_path = if git_path.is_absolute() {
git_path
} else {
repo_path.join(git_path)
};
std::fs::canonicalize(&git_path).unwrap_or(git_path)
}
#[cfg(test)]
#[path = "repo_test.rs"]
mod tests;