use std::path::Path;
use std::process::{Command, Stdio};
pub const GIT_CONFIG_FLAGS: &[&str] = &[
"-c",
"core.fsmonitor=false",
"-c",
"core.hooksPath=/dev/null",
];
pub fn secure_git() -> Command {
let mut cmd = Command::new("git");
cmd.args(GIT_CONFIG_FLAGS);
cmd.stdin(Stdio::null());
cmd
}
#[must_use]
pub fn sanitize_git_url(url: &str) -> String {
if let Some(at_pos) = url.find('@') {
if let Some(proto_end) = url.find("://") {
if at_pos > proto_end {
let (proto, rest) = url.split_at(proto_end + 3);
if let Some(host_start) = rest.find('@') {
return format!("{}***@{}", proto, &rest[host_start + 1..]);
}
}
} else {
return format!("***{}", &url[at_pos..]);
}
}
url.to_string()
}
#[must_use]
pub fn git_failure_detail(
stderr: &[u8],
stdout: &[u8],
status: std::process::ExitStatus,
) -> String {
let raw = if stderr.is_empty() { stdout } else { stderr };
let cleaned: String = String::from_utf8_lossy(raw)
.trim()
.chars()
.filter(|c| !c.is_control() || *c == '\n')
.collect();
if cleaned.is_empty() {
format!("exit code {status}")
} else {
sanitize_git_url(&cleaned)
}
}
pub fn working_tree_dirty(repo: &Path) -> bool {
secure_git()
.args(["status", "--porcelain"])
.current_dir(repo)
.stderr(Stdio::null())
.output()
.is_ok_and(|o| o.status.success() && !o.stdout.is_empty())
}
pub fn has_unpushed_commits(repo: &Path) -> bool {
let output = match secure_git()
.args(["rev-list", "--count", "@{u}..HEAD"])
.current_dir(repo)
.output()
{
Ok(out) => out,
Err(e) => {
tracing::debug!("git rev-list count failed at {}: {}", repo.display(), e);
return false;
}
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
tracing::debug!(
"git rev-list count failed at {} with exit {}: {}",
repo.display(),
output.status,
stderr
);
return false;
}
let count_str = match std::str::from_utf8(&output.stdout) {
Ok(s) => s,
Err(e) => {
tracing::debug!(
"git rev-list count output invalid UTF-8 at {}: {}",
repo.display(),
e
);
return false;
}
};
match count_str.trim().parse::<u32>() {
Ok(count) => count > 0,
Err(e) => {
tracing::debug!(
"git rev-list count parse failed at {} for '{}': {}",
repo.display(),
count_str.trim(),
e
);
false
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn secure_git_includes_config_flags() {
let cmd = secure_git();
assert_eq!(cmd.get_program(), "git");
}
#[test]
fn sanitize_git_url_http_with_credentials() {
let url = "https://user:password@github.com/owner/repo.git";
assert_eq!(
sanitize_git_url(url),
"https://***@github.com/owner/repo.git"
);
}
#[test]
fn sanitize_git_url_ssh() {
let url = "git@github.com:owner/repo.git";
assert_eq!(sanitize_git_url(url), "***@github.com:owner/repo.git");
}
#[test]
fn sanitize_git_url_no_credentials() {
let url = "https://github.com/owner/repo.git";
assert_eq!(sanitize_git_url(url), url);
}
#[test]
fn git_failure_detail_prefers_stderr() {
use std::os::unix::process::ExitStatusExt;
let status = std::process::ExitStatus::from_raw(1 << 8);
let detail = git_failure_detail(b"fatal: repository not found\n", b"ignored", status);
assert_eq!(detail, "fatal: repository not found");
}
#[test]
fn git_failure_detail_falls_back_to_stdout_when_stderr_empty() {
use std::os::unix::process::ExitStatusExt;
let status = std::process::ExitStatus::from_raw(1 << 8);
let detail = git_failure_detail(b"", b"index locked\n", status);
assert_eq!(detail, "index locked");
}
#[test]
fn git_failure_detail_scrubs_credentials_in_stderr() {
use std::os::unix::process::ExitStatusExt;
let status = std::process::ExitStatus::from_raw(1 << 8);
let detail = git_failure_detail(
b"fatal: could not read from https://user:tok@github.com/x.git\n",
b"",
status,
);
assert!(!detail.contains("tok"), "credential leaked: {detail}");
}
#[test]
fn git_failure_detail_strips_control_and_ansi_sequences() {
use std::os::unix::process::ExitStatusExt;
let status = std::process::ExitStatus::from_raw(1 << 8);
let hostile = b"\x1b[2Jcleared\x1b]0;title";
let detail = git_failure_detail(hostile, b"", status);
assert_eq!(detail, "[2Jcleared]0;title");
}
#[test]
fn git_failure_detail_falls_back_to_exit_code() {
use std::os::unix::process::ExitStatusExt;
let status = std::process::ExitStatus::from_raw(128 << 8);
let detail = git_failure_detail(b" \n", b"", status);
assert!(detail.contains("exit code"), "got: {detail}");
}
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
#[cfg(test)]
mod prop_tests {
use proptest::prelude::*;
proptest! {
#[test]
fn parse_rev_list_count_handles_valid_numeric_output(count_val in 0u32..1000) {
let output_bytes = format!("{}", count_val).into_bytes();
let parsed = std::str::from_utf8(&output_bytes)
.unwrap_or("0")
.trim()
.parse::<u32>()
.unwrap_or(0);
prop_assert_eq!(parsed, count_val);
}
#[test]
fn parse_rev_list_count_handles_malformed_output(junk in ".*") {
let _parsed = std::str::from_utf8(junk.as_bytes())
.unwrap_or("0")
.trim()
.parse::<u32>()
.unwrap_or(0);
}
#[test]
fn parse_rev_list_count_with_whitespace(count_val in 0u32..1000) {
let output_bytes = format!(" {} \n", count_val).into_bytes();
let parsed = std::str::from_utf8(&output_bytes)
.unwrap_or("0")
.trim()
.parse::<u32>()
.unwrap_or(0);
prop_assert_eq!(parsed, count_val);
}
}
}
}