use crate::error::{GitError, RailError, RailResult, ResultExt};
use crate::utils;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
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)
}
fn parse_nul_paths(bytes: &[u8]) -> Vec<PathBuf> {
bytes
.split(|byte| *byte == 0)
.filter(|raw| !raw.is_empty())
.map(|raw| PathBuf::from(String::from_utf8_lossy(raw).into_owned()))
.collect()
}
fn is_generated_target_path(path: &Path) -> bool {
path
.components()
.any(|component| component.as_os_str() == std::ffi::OsStr::new("target"))
}
#[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 repo_path = utils::canonicalize_existing(path).unwrap_or_else(|_| path.to_path_buf());
let output = Command::new("git")
.arg("-C")
.arg(&repo_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: repo_path }));
}
return Err(RailError::message(format!("Failed to open git repository: {}", stderr)));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let reported_root = normalize_git_path(stdout.trim());
let worktree_root = utils::canonicalize_existing(&reported_root).unwrap_or(reported_root);
Ok(Self {
repo_path,
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 fn changed_paths(&self) -> RailResult<Vec<PathBuf>> {
let tracked = self.run_git(&["diff", "--name-only", "-z", "HEAD"])?;
let mut paths = parse_nul_paths(&tracked.stdout);
let untracked = self.run_git(&["ls-files", "--others", "--exclude-standard", "-z"])?;
paths.extend(
parse_nul_paths(&untracked.stdout)
.into_iter()
.filter(|path| !is_generated_target_path(path)),
);
paths.sort();
paths.dedup();
Ok(paths)
}
pub(crate) fn hash_bytes(&self, bytes: &[u8]) -> RailResult<String> {
use std::io::Write as _;
let mut cmd = self.git_cmd();
cmd
.args(["hash-object", "--stdin"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd
.spawn()
.map_err(|error| RailError::message(format!("failed to start git hash-object: {}", error)))?;
child
.stdin
.take()
.ok_or_else(|| RailError::message("git hash-object stdin was unavailable"))?
.write_all(bytes)
.map_err(|error| RailError::message(format!("failed to write git hash input: {}", error)))?;
let output = child
.wait_with_output()
.map_err(|error| RailError::message(format!("failed to read git hash-object output: {}", error)))?;
if !output.status.success() {
return Err(RailError::Git(GitError::CommandFailed {
command: "git hash-object --stdin".to_string(),
stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
}));
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
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 stage_paths(&self, paths: &[PathBuf]) -> RailResult<()> {
if paths.is_empty() {
return Ok(());
}
let mut cmd = self.git_cmd();
cmd.args(["add", "-A", "--"]);
for path in paths {
cmd.arg(self.normalize_repo_path(path)?);
}
let output = cmd
.output()
.map_err(|e| RailError::message(format!("failed to stage planned paths: {}", e)))?;
if !output.status.success() {
return Err(RailError::Git(GitError::CommandFailed {
command: "git add -A -- <planned-paths>".to_string(),
stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
}));
}
Ok(())
}
fn normalize_repo_path(&self, path: &Path) -> RailResult<PathBuf> {
if !path.is_absolute() {
return Ok(path.to_path_buf());
}
utils::path_relative_to(&self.worktree_root, path).map_err(|error| {
RailError::message(format!(
"path '{}' is outside git worktree '{}': {}",
path.display(),
self.worktree_root.display(),
error
))
})
}
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::fs;
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);
},
}
}
#[test]
fn test_stage_paths_never_stages_unplanned_files() {
let temp = tempfile::TempDir::new().unwrap();
init_repo(temp.path(), "main").unwrap();
let git = SystemGit::open(temp.path()).unwrap();
git.set_config("user.name", "Test User").unwrap();
git.set_config("user.email", "test@example.com").unwrap();
fs::write(temp.path().join("planned.txt"), "before\n").unwrap();
fs::write(temp.path().join("unplanned.txt"), "before\n").unwrap();
git.stage_all().unwrap();
git.commit("initial").unwrap();
fs::write(temp.path().join("planned.txt"), "after\n").unwrap();
fs::write(temp.path().join("unplanned.txt"), "after\n").unwrap();
git.stage_paths(&[PathBuf::from("planned.txt")]).unwrap();
let staged = git.run_git_stdout(&["diff", "--cached", "--name-only"]).unwrap();
assert_eq!(staged, "planned.txt");
assert_eq!(
git.changed_paths().unwrap(),
vec![PathBuf::from("planned.txt"), PathBuf::from("unplanned.txt")]
);
}
#[test]
fn test_hash_bytes_uses_stable_git_object_ids() {
let git = SystemGit::open(Path::new(".")).unwrap();
let first = git.hash_bytes(b"approved inputs").unwrap();
let repeated = git.hash_bytes(b"approved inputs").unwrap();
let changed = git.hash_bytes(b"different inputs").unwrap();
assert_eq!(first, repeated);
assert_ne!(first, changed);
assert!(first.len() >= 40);
assert!(first.bytes().all(|byte| byte.is_ascii_hexdigit()));
}
#[cfg(windows)]
#[test]
fn test_git_accepts_windows_canonical_paths_at_process_boundaries() {
let temp = tempfile::TempDir::new().unwrap();
init_repo(temp.path(), "main").unwrap();
let git = SystemGit::open(temp.path()).unwrap();
git.set_config("user.name", "Test User").unwrap();
git.set_config("user.email", "test@example.com").unwrap();
let file = temp.path().join("tracked.txt");
fs::write(&file, "before\n").unwrap();
git.stage_all().unwrap();
git.commit("initial").unwrap();
let verbatim_root = fs::canonicalize(temp.path()).unwrap();
let verbatim_file = fs::canonicalize(&file).unwrap();
let git = SystemGit::open(&verbatim_root).unwrap();
assert_eq!(
git
.get_commits_touching_path(&verbatim_file, None, "HEAD")
.unwrap()
.len(),
1
);
fs::write(&file, "after\n").unwrap();
git.stage_paths(&[verbatim_file]).unwrap();
assert_eq!(
git.run_git_stdout(&["diff", "--cached", "--name-only"]).unwrap(),
"tracked.txt"
);
}
}