use super::constants::GIT_NETWORK_TIMEOUT;
use super::paths::home_dir_var;
use super::process::{command_output_with_timeout, stderr_lossy_trimmed, stdout_lossy_trimmed};
use crate::config;
pub fn git_cmd_safe(
url: Option<&str>,
ssh_policy: Option<config::SshHostKeyPolicy>,
) -> std::process::Command {
let mut cmd = std::process::Command::new("git");
#[cfg(unix)]
let null_config = "/dev/null";
#[cfg(windows)]
let null_config = "NUL";
cmd.env("GIT_TERMINAL_PROMPT", "0")
.env("GIT_ASKPASS", "true")
.env("SSH_ASKPASS", "true")
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_GLOBAL", null_config)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped());
if url.is_some_and(|u| u.starts_with("git@") || u.starts_with("ssh://")) {
let policy = ssh_policy.unwrap_or_default();
cmd.env(
"GIT_SSH_COMMAND",
format!(
"ssh -o BatchMode=yes -o StrictHostKeyChecking={}",
policy.as_ssh_option()
),
);
}
cmd
}
pub fn git_cmd_local() -> std::process::Command {
let mut cmd = std::process::Command::new("git");
cmd.env("GIT_TERMINAL_PROMPT", "0");
cmd
}
pub fn try_git_cmd(
url: Option<&str>,
args: &[&str],
label: &str,
ssh_policy: Option<config::SshHostKeyPolicy>,
) -> bool {
let mut cmd = git_cmd_safe(url, ssh_policy);
cmd.args(args);
match command_output_with_timeout(&mut cmd, GIT_NETWORK_TIMEOUT) {
Ok(output) if output.status.success() => true,
Ok(output) => {
tracing::debug!(
"git {} CLI failed (exit {}): {}",
label,
output.status.code().unwrap_or(-1),
stderr_lossy_trimmed(&output),
);
false
}
Err(e) => {
tracing::debug!("git {} CLI unavailable: {e}", label);
false
}
}
}
pub const COSIGN_BIN_ENV: &str = "CFGD_COSIGN_BIN";
pub fn cosign_cmd() -> std::process::Command {
super::process::tool_cmd(COSIGN_BIN_ENV, "cosign")
}
pub fn require_cosign() -> std::result::Result<(), String> {
super::process::require_tool_with_seam(COSIGN_BIN_ENV, "cosign", None)
}
pub fn detect_default_branch(repo_dir: &std::path::Path) -> Option<String> {
let dir = repo_dir.display().to_string();
let mut cmd = git_cmd_safe(None, None);
cmd.args([
"-C",
&dir,
"symbolic-ref",
"--short",
"refs/remotes/origin/HEAD",
])
.stdout(std::process::Stdio::piped());
if let Ok(output) = cmd.output()
&& output.status.success()
{
let raw = stdout_lossy_trimmed(&output);
let stripped = raw.strip_prefix("origin/").unwrap_or(&raw);
if !stripped.is_empty() {
return Some(stripped.to_string());
}
}
let mut cmd = git_cmd_safe(None, None);
cmd.args(["-C", &dir, "symbolic-ref", "--short", "HEAD"])
.stdout(std::process::Stdio::piped());
if let Ok(output) = cmd.output()
&& output.status.success()
{
let branch = stdout_lossy_trimmed(&output);
if !branch.is_empty() {
return Some(branch);
}
}
None
}
pub fn git_ssh_credentials(
_url: &str,
username_from_url: Option<&str>,
allowed_types: git2::CredentialType,
) -> std::result::Result<git2::Cred, git2::Error> {
let username = username_from_url.unwrap_or("git");
if allowed_types.contains(git2::CredentialType::SSH_KEY) {
if let Ok(cred) = git2::Cred::ssh_key_from_agent(username) {
return Ok(cred);
}
let home = home_dir_var().unwrap_or_default();
for key_name in &["id_ed25519", "id_rsa", "id_ecdsa"] {
let key_path = std::path::Path::new(&home).join(".ssh").join(key_name);
if key_path.exists()
&& let Ok(cred) = git2::Cred::ssh_key(username, None, &key_path, None)
{
return Ok(cred);
}
}
}
if allowed_types.contains(git2::CredentialType::USER_PASS_PLAINTEXT) {
return git2::Cred::credential_helper(
&git2::Config::open_default()
.map_err(|e| git2::Error::from_str(&format!("cannot open git config: {e}")))?,
_url,
username_from_url,
);
}
if allowed_types.contains(git2::CredentialType::DEFAULT) {
return git2::Cred::default();
}
Err(git2::Error::from_str("no suitable credentials found"))
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
use std::fs;
struct EnvVarGuard {
key: &'static str,
prior: Option<String>,
}
impl EnvVarGuard {
fn capture(key: &'static str) -> Self {
Self {
key,
prior: std::env::var(key).ok(),
}
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
unsafe {
match self.prior.take() {
Some(v) => std::env::set_var(self.key, v),
None => std::env::remove_var(self.key),
}
}
}
}
#[test]
fn git_cmd_local_sets_terminal_prompt_zero_and_no_ssh_env() {
let cmd = git_cmd_local();
let prog = std::path::Path::new(cmd.get_program())
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("");
assert_eq!(prog, "git", "program must resolve to `git`");
let envs: std::collections::HashMap<&std::ffi::OsStr, Option<&std::ffi::OsStr>> =
cmd.get_envs().collect();
let term = envs
.get(std::ffi::OsStr::new("GIT_TERMINAL_PROMPT"))
.and_then(|v| v.as_deref())
.and_then(|s| s.to_str());
assert_eq!(
term,
Some("0"),
"GIT_TERMINAL_PROMPT must be set to 0 to prevent prompt-driven hangs"
);
assert!(
!envs.contains_key(std::ffi::OsStr::new("GIT_SSH_COMMAND")),
"git_cmd_local is for local-only ops and must not configure GIT_SSH_COMMAND"
);
}
#[test]
#[serial]
fn require_cosign_with_env_var_pointing_to_real_file_succeeds() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let bin = tmp.path().join("anything");
fs::write(&bin, "").expect("write");
let _guard = EnvVarGuard::capture("CFGD_COSIGN_BIN");
unsafe {
std::env::set_var("CFGD_COSIGN_BIN", &bin);
}
require_cosign().expect("env-var pointing to existing file → Ok");
}
#[test]
#[serial]
fn require_cosign_with_env_var_pointing_to_missing_file_errors_out() {
let _guard = EnvVarGuard::capture("CFGD_COSIGN_BIN");
unsafe {
std::env::set_var("CFGD_COSIGN_BIN", "/no/such/file/at/all");
}
let err = require_cosign().expect_err("missing file → Err");
assert!(
err.contains("CFGD_COSIGN_BIN") && err.contains("not a file"),
"error must call out env-var + missing-file: {err}"
);
}
#[test]
fn detect_default_branch_on_current_repo() {
let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap();
let result = detect_default_branch(repo_root);
assert!(
result.is_some(),
"should detect default branch in the cfgd repo"
);
let branch = result.unwrap();
assert!(!branch.is_empty(), "detected branch name must not be empty");
}
#[test]
fn detect_default_branch_returns_none_for_non_repo() {
let tmp = tempfile::TempDir::new().unwrap();
let result = detect_default_branch(tmp.path());
assert!(result.is_none(), "non-git directory must return None");
}
#[test]
fn detect_default_branch_on_fresh_init_repo() {
let tmp = tempfile::TempDir::new().unwrap();
let repo = git2::Repository::init(tmp.path()).unwrap();
let sig = git2::Signature::now("test", "test@test.com").unwrap();
let tree_id = repo.index().unwrap().write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[])
.unwrap();
let result = detect_default_branch(tmp.path());
assert!(result.is_some());
}
#[test]
fn try_git_cmd_succeeds_on_version() {
let ok = try_git_cmd(None, &["--version"], "version-check", None);
assert!(ok, "git --version should succeed");
}
#[test]
fn try_git_cmd_fails_on_invalid_subcommand() {
let ok = try_git_cmd(None, &["not-a-real-subcommand-xyz"], "invalid-cmd", None);
assert!(!ok, "invalid git subcommand should return false");
}
}