use crate::error::{GitError, RailError, RailResult, ResultExt};
use std::path::{Path, PathBuf};
use std::process::Command;
fn normalize_git_path(path: &str) -> PathBuf {
#[cfg(windows)]
{
let bytes = path.as_bytes();
if bytes.len() >= 3 && bytes[0] == b'/' && bytes[2] == b'/' && bytes[1].is_ascii_alphabetic() {
let drive = (bytes[1] as char).to_ascii_uppercase();
let rest = &path[2..]; let windows_path = format!("{}:{}", drive, rest.replace('/', "\\"));
return PathBuf::from(windows_path);
}
}
PathBuf::from(path)
}
#[derive(Debug, Clone)]
pub struct CommitInfo {
pub sha: String,
pub author: String,
pub author_email: String,
pub committer: String,
pub committer_email: String,
pub message: String,
pub timestamp: i64,
pub parent_shas: Vec<String>,
}
#[derive(Clone)]
pub struct SystemGit {
pub(crate) repo_path: PathBuf,
pub(crate) worktree_root: PathBuf,
}
impl SystemGit {
pub fn open(path: &Path) -> RailResult<Self> {
let output = Command::new("git")
.arg("-C")
.arg(path)
.args(["rev-parse", "--show-toplevel"])
.output()
.context("Failed to execute git rev-parse")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("not a git repository") {
return Err(RailError::Git(GitError::RepoNotFound {
path: path.to_path_buf(),
}));
}
return Err(RailError::message(format!("Failed to open git repository: {}", stderr)));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let worktree_root = normalize_git_path(stdout.trim());
Ok(Self {
repo_path: path.to_path_buf(),
worktree_root,
})
}
pub fn head_commit(&self) -> RailResult<String> {
self.run_git_stdout(&["rev-parse", "HEAD"])
}
pub fn current_branch(&self) -> RailResult<String> {
self
.run_git_stdout(&["rev-parse", "--abbrev-ref", "HEAD"])
.or(Ok("HEAD".to_string()))
}
pub fn is_detached_head(&self) -> RailResult<bool> {
let branch = self.run_git_stdout(&["rev-parse", "--abbrev-ref", "HEAD"])?;
Ok(branch == "HEAD")
}
pub fn default_branch(&self) -> RailResult<Option<String>> {
if let Ok(output) = self.run_git_stdout(&["symbolic-ref", "refs/remotes/origin/HEAD"]) {
if let Some(branch) = output.strip_prefix("refs/remotes/origin/") {
return Ok(Some(branch.to_string()));
}
}
for name in &["main", "master"] {
if self.run_git_check(&["rev-parse", "--verify", &format!("refs/heads/{}", name)]) {
return Ok(Some((*name).to_string()));
}
}
Ok(None)
}
pub fn is_dirty(&self) -> RailResult<bool> {
let output = self.run_git_stdout(&["status", "--porcelain"])?;
Ok(!output.is_empty())
}
pub fn dirty_files(&self) -> RailResult<Vec<String>> {
let output = self.run_git_stdout(&["status", "--porcelain"])?;
Ok(output.lines().map(|s| s.to_string()).collect())
}
pub(crate) fn git_cmd(&self) -> Command {
let mut cmd = Command::new("git");
cmd.arg("-C").arg(&self.repo_path);
cmd.env_clear();
if let Some(path) = std::env::var_os("PATH") {
cmd.env("PATH", path);
}
for key in ["HOME", "USERPROFILE", "HOMEDRIVE", "HOMEPATH"] {
if let Some(val) = std::env::var_os(key) {
cmd.env(key, val);
}
}
for key in [
"SSH_AUTH_SOCK",
"SSH_ASKPASS",
"DISPLAY",
"GIT_ASKPASS",
"GIT_SSH",
"GIT_SSH_COMMAND",
"GIT_TERMINAL_PROMPT",
] {
if let Some(val) = std::env::var_os(key) {
cmd.env(key, val);
}
}
cmd.arg("-c").arg("protocol.version=2");
cmd.arg("-c").arg("advice.detachedHead=false");
cmd.arg("-c").arg("core.quotePath=false");
cmd
}
pub(crate) fn run_git(&self, args: &[&str]) -> RailResult<std::process::Output> {
let mut cmd = self.git_cmd();
cmd.args(args);
let output = cmd
.output()
.with_context(|| format!("Failed to execute git {}", args.join(" ")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(RailError::Git(GitError::CommandFailed {
command: format!("git {}", args.join(" ")),
stderr: stderr.to_string(),
}));
}
Ok(output)
}
pub(crate) fn run_git_stdout(&self, args: &[&str]) -> RailResult<String> {
let output = self.run_git(args)?;
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub(crate) fn run_git_check(&self, args: &[&str]) -> bool {
let mut cmd = self.git_cmd();
cmd.args(args);
if let Ok(output) = cmd.output() {
output.status.success()
} else {
false
}
}
pub(crate) fn run_git_with_error<F>(&self, args: &[&str], error_fn: F) -> RailResult<std::process::Output>
where
F: FnOnce(&str) -> RailError,
{
let mut cmd = self.git_cmd();
cmd.args(args);
let output = cmd
.output()
.with_context(|| format!("Failed to execute git {}", args.join(" ")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(error_fn(&stderr));
}
Ok(output)
}
pub fn tag_exists(&self, tag_name: &str) -> RailResult<bool> {
let ref_name = format!("refs/tags/{}", tag_name);
Ok(self.run_git_check(&["rev-parse", "-q", "--verify", &ref_name]))
}
pub fn get_config(&self, key: &str) -> RailResult<Option<String>> {
let mut cmd = self.git_cmd();
cmd.args(["config", "--get", key]);
match cmd.output() {
Ok(output) if output.status.success() => Ok(Some(String::from_utf8_lossy(&output.stdout).trim().to_string())),
Ok(output) if output.status.code() == Some(1) => {
Ok(None)
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(RailError::Git(GitError::CommandFailed {
command: format!("git config --get {}", key),
stderr: stderr.to_string(),
}))
}
Err(e) => Err(RailError::message(format!("Failed to get git config {}: {}", key, e))),
}
}
pub fn set_config(&self, key: &str, value: &str) -> RailResult<()> {
self.run_git(&["config", key, value])?;
Ok(())
}
pub fn stage_all(&self) -> RailResult<()> {
self.run_git(&["add", "-A"])?;
Ok(())
}
pub fn has_staged_changes(&self) -> RailResult<bool> {
Ok(!self.run_git_check(&["diff", "--cached", "--quiet"]))
}
pub fn commit(&self, message: &str) -> RailResult<String> {
self.run_git(&["commit", "-m", message])?;
self.run_git_stdout(&["rev-parse", "HEAD"])
}
pub fn create_tag(&self, name: &str, message: Option<&str>, sign: bool) -> RailResult<()> {
let mut cmd = self.git_cmd();
if sign {
cmd.args(["tag", "-s"]);
} else {
cmd.args(["-c", "tag.gpgsign=false", "tag", "-a"]);
}
if let Some(msg) = message {
cmd.args(["-m", msg]);
}
cmd.arg(name);
let output = cmd.output().context("Failed to run git tag")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(RailError::Git(GitError::CommandFailed {
command: format!("git tag {}", name),
stderr: stderr.to_string(),
}));
}
Ok(())
}
pub fn find_latest_tag(&self, pattern: &str) -> RailResult<Option<String>> {
let mut cmd = self.git_cmd();
cmd.args(["tag", "--list", pattern, "--sort=-version:refname"]);
match cmd.output() {
Ok(output) if output.status.success() => {
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(stdout.lines().next().map(|s| s.to_string()))
}
Ok(_) => Ok(None), Err(e) => Err(RailError::message(format!("Failed to find tag: {}", e))),
}
}
pub fn ls_remote_has_content(&self, url: &str) -> RailResult<bool> {
let mut cmd = self.git_cmd();
cmd.args(["ls-remote", "--heads", url]);
match cmd.output() {
Ok(output) => Ok(output.status.success() && !output.stdout.is_empty()),
Err(e) => Err(RailError::message(format!("Failed to check remote: {}", e))),
}
}
pub fn log_formatted(&self, format: &str, from: Option<&str>, to: &str) -> RailResult<String> {
let format_arg = format!("--format={}", format);
let range = if let Some(from_ref) = from {
format!("{}..{}", from_ref, to)
} else {
to.to_string()
};
self.run_git_stdout(&["log", &format_arg, &range])
}
pub fn has_signing_configured(&self) -> bool {
self.get_config("user.signingkey").ok().flatten().is_some()
|| self.get_config("gpg.format").ok().flatten().is_some()
}
}
pub fn init_repo(path: &std::path::Path, initial_branch: &str) -> RailResult<()> {
let output = Command::new("git")
.arg("init")
.arg("--initial-branch")
.arg(initial_branch)
.arg(path)
.output()
.context("Failed to run git init")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(RailError::Git(GitError::CommandFailed {
command: "git init".to_string(),
stderr: stderr.to_string(),
}));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::ffi::OsStr;
use std::sync::{Mutex, OnceLock};
static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
fn lock_env() -> std::sync::MutexGuard<'static, ()> {
ENV_LOCK.get_or_init(|| Mutex::new(())).lock().unwrap()
}
fn command_has_env_value(cmd: &Command, key: &str, value: &str) -> bool {
cmd
.get_envs()
.any(|(k, v)| k == OsStr::new(key) && v == Some(OsStr::new(value)))
}
#[test]
fn test_git_cmd_preserves_ssh_auth_sock_when_set() {
let _guard = lock_env();
let key = "SSH_AUTH_SOCK";
let prev = std::env::var_os(key);
unsafe {
std::env::set_var(key, "cargo-rail-test-sock");
}
let git = SystemGit::open(Path::new(".")).unwrap();
let cmd = git.git_cmd();
assert!(command_has_env_value(&cmd, key, "cargo-rail-test-sock"));
match prev {
Some(v) => unsafe {
std::env::set_var(key, v);
},
None => unsafe {
std::env::remove_var(key);
},
}
}
#[test]
fn test_git_cmd_preserves_git_ssh_command_when_set() {
let _guard = lock_env();
let key = "GIT_SSH_COMMAND";
let prev = std::env::var_os(key);
unsafe {
std::env::set_var(key, "ssh -o BatchMode=yes");
}
let git = SystemGit::open(Path::new(".")).unwrap();
let cmd = git.git_cmd();
assert!(command_has_env_value(&cmd, key, "ssh -o BatchMode=yes"));
match prev {
Some(v) => unsafe {
std::env::set_var(key, v);
},
None => unsafe {
std::env::remove_var(key);
},
}
}
}