1use super::constants::GIT_NETWORK_TIMEOUT;
2use super::paths::home_dir_var;
3use super::process::{command_output_with_timeout, stderr_lossy_trimmed, stdout_lossy_trimmed};
4use crate::config;
5
6pub fn git_cmd_safe(
16 url: Option<&str>,
17 ssh_policy: Option<config::SshHostKeyPolicy>,
18) -> std::process::Command {
19 let mut cmd = std::process::Command::new("git");
20 #[cfg(unix)]
28 let null_config = "/dev/null";
29 #[cfg(windows)]
30 let null_config = "NUL";
31 cmd.env("GIT_TERMINAL_PROMPT", "0")
32 .env("GIT_ASKPASS", "true")
33 .env("SSH_ASKPASS", "true")
34 .env("GIT_CONFIG_NOSYSTEM", "1")
35 .env("GIT_CONFIG_GLOBAL", null_config)
36 .stdin(std::process::Stdio::null())
37 .stdout(std::process::Stdio::null())
38 .stderr(std::process::Stdio::piped());
39 if url.is_some_and(|u| u.starts_with("git@") || u.starts_with("ssh://")) {
40 let policy = ssh_policy.unwrap_or_default();
41 cmd.env(
42 "GIT_SSH_COMMAND",
43 format!(
44 "ssh -o BatchMode=yes -o StrictHostKeyChecking={}",
45 policy.as_ssh_option()
46 ),
47 );
48 }
49 cmd
50}
51
52pub fn git_cmd_local() -> std::process::Command {
58 let mut cmd = std::process::Command::new("git");
59 cmd.env("GIT_TERMINAL_PROMPT", "0");
60 cmd
61}
62
63pub fn try_git_cmd(
66 url: Option<&str>,
67 args: &[&str],
68 label: &str,
69 ssh_policy: Option<config::SshHostKeyPolicy>,
70) -> bool {
71 let mut cmd = git_cmd_safe(url, ssh_policy);
72 cmd.args(args);
73 match command_output_with_timeout(&mut cmd, GIT_NETWORK_TIMEOUT) {
74 Ok(output) if output.status.success() => true,
75 Ok(output) => {
76 tracing::debug!(
77 "git {} CLI failed (exit {}): {}",
78 label,
79 output.status.code().unwrap_or(-1),
80 stderr_lossy_trimmed(&output),
81 );
82 false
83 }
84 Err(e) => {
85 tracing::debug!("git {} CLI unavailable: {e}", label);
86 false
87 }
88 }
89}
90
91pub const COSIGN_BIN_ENV: &str = "CFGD_COSIGN_BIN";
93
94pub fn cosign_cmd() -> std::process::Command {
108 super::process::tool_cmd(COSIGN_BIN_ENV, "cosign")
109}
110
111pub fn require_cosign() -> std::result::Result<(), String> {
115 super::process::require_tool_with_seam(COSIGN_BIN_ENV, "cosign", None)
116}
117
118pub fn detect_default_branch(repo_dir: &std::path::Path) -> Option<String> {
126 let dir = repo_dir.display().to_string();
127
128 let mut cmd = git_cmd_safe(None, None);
129 cmd.args([
130 "-C",
131 &dir,
132 "symbolic-ref",
133 "--short",
134 "refs/remotes/origin/HEAD",
135 ])
136 .stdout(std::process::Stdio::piped());
137 if let Ok(output) = cmd.output()
138 && output.status.success()
139 {
140 let raw = stdout_lossy_trimmed(&output);
141 let stripped = raw.strip_prefix("origin/").unwrap_or(&raw);
142 if !stripped.is_empty() {
143 return Some(stripped.to_string());
144 }
145 }
146
147 let mut cmd = git_cmd_safe(None, None);
148 cmd.args(["-C", &dir, "symbolic-ref", "--short", "HEAD"])
149 .stdout(std::process::Stdio::piped());
150 if let Ok(output) = cmd.output()
151 && output.status.success()
152 {
153 let branch = stdout_lossy_trimmed(&output);
154 if !branch.is_empty() {
155 return Some(branch);
156 }
157 }
158
159 None
160}
161
162pub fn git_ssh_credentials(
171 _url: &str,
172 username_from_url: Option<&str>,
173 allowed_types: git2::CredentialType,
174) -> std::result::Result<git2::Cred, git2::Error> {
175 let username = username_from_url.unwrap_or("git");
176
177 if allowed_types.contains(git2::CredentialType::SSH_KEY) {
178 if let Ok(cred) = git2::Cred::ssh_key_from_agent(username) {
179 return Ok(cred);
180 }
181 let home = home_dir_var().unwrap_or_default();
182 for key_name in &["id_ed25519", "id_rsa", "id_ecdsa"] {
183 let key_path = std::path::Path::new(&home).join(".ssh").join(key_name);
184 if key_path.exists()
185 && let Ok(cred) = git2::Cred::ssh_key(username, None, &key_path, None)
186 {
187 return Ok(cred);
188 }
189 }
190 }
191
192 if allowed_types.contains(git2::CredentialType::USER_PASS_PLAINTEXT) {
193 return git2::Cred::credential_helper(
194 &git2::Config::open_default()
195 .map_err(|e| git2::Error::from_str(&format!("cannot open git config: {e}")))?,
196 _url,
197 username_from_url,
198 );
199 }
200
201 if allowed_types.contains(git2::CredentialType::DEFAULT) {
202 return git2::Cred::default();
203 }
204
205 Err(git2::Error::from_str("no suitable credentials found"))
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211 use serial_test::serial;
212 use std::fs;
213
214 struct EnvVarGuard {
218 key: &'static str,
219 prior: Option<String>,
220 }
221
222 impl EnvVarGuard {
223 fn capture(key: &'static str) -> Self {
224 Self {
225 key,
226 prior: std::env::var(key).ok(),
227 }
228 }
229 }
230
231 impl Drop for EnvVarGuard {
232 fn drop(&mut self) {
233 unsafe {
235 match self.prior.take() {
236 Some(v) => std::env::set_var(self.key, v),
237 None => std::env::remove_var(self.key),
238 }
239 }
240 }
241 }
242
243 #[test]
244 fn git_cmd_local_sets_terminal_prompt_zero_and_no_ssh_env() {
245 let cmd = git_cmd_local();
246 let prog = std::path::Path::new(cmd.get_program())
247 .file_name()
248 .and_then(|s| s.to_str())
249 .unwrap_or("");
250 assert_eq!(prog, "git", "program must resolve to `git`");
251
252 let envs: std::collections::HashMap<&std::ffi::OsStr, Option<&std::ffi::OsStr>> =
253 cmd.get_envs().collect();
254 let term = envs
255 .get(std::ffi::OsStr::new("GIT_TERMINAL_PROMPT"))
256 .and_then(|v| v.as_deref())
257 .and_then(|s| s.to_str());
258 assert_eq!(
259 term,
260 Some("0"),
261 "GIT_TERMINAL_PROMPT must be set to 0 to prevent prompt-driven hangs"
262 );
263 assert!(
264 !envs.contains_key(std::ffi::OsStr::new("GIT_SSH_COMMAND")),
265 "git_cmd_local is for local-only ops and must not configure GIT_SSH_COMMAND"
266 );
267 }
268
269 #[test]
270 #[serial]
271 fn require_cosign_with_env_var_pointing_to_real_file_succeeds() {
272 let tmp = tempfile::TempDir::new().expect("tempdir");
273 let bin = tmp.path().join("anything");
274 fs::write(&bin, "").expect("write");
275
276 let _guard = EnvVarGuard::capture("CFGD_COSIGN_BIN");
277 unsafe {
279 std::env::set_var("CFGD_COSIGN_BIN", &bin);
280 }
281 require_cosign().expect("env-var pointing to existing file → Ok");
282 }
283
284 #[test]
285 #[serial]
286 fn require_cosign_with_env_var_pointing_to_missing_file_errors_out() {
287 let _guard = EnvVarGuard::capture("CFGD_COSIGN_BIN");
288 unsafe {
290 std::env::set_var("CFGD_COSIGN_BIN", "/no/such/file/at/all");
291 }
292 let err = require_cosign().expect_err("missing file → Err");
293 assert!(
294 err.contains("CFGD_COSIGN_BIN") && err.contains("not a file"),
295 "error must call out env-var + missing-file: {err}"
296 );
297 }
298
299 #[test]
300 fn detect_default_branch_on_current_repo() {
301 let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
302 .parent()
303 .unwrap()
304 .parent()
305 .unwrap();
306 let result = detect_default_branch(repo_root);
307 assert!(
308 result.is_some(),
309 "should detect default branch in the cfgd repo"
310 );
311 let branch = result.unwrap();
312 assert!(!branch.is_empty(), "detected branch name must not be empty");
313 }
314
315 #[test]
316 fn detect_default_branch_returns_none_for_non_repo() {
317 let tmp = tempfile::TempDir::new().unwrap();
318 let result = detect_default_branch(tmp.path());
319 assert!(result.is_none(), "non-git directory must return None");
320 }
321
322 #[test]
323 fn detect_default_branch_on_fresh_init_repo() {
324 let tmp = tempfile::TempDir::new().unwrap();
325 let repo = git2::Repository::init(tmp.path()).unwrap();
326 let sig = git2::Signature::now("test", "test@test.com").unwrap();
327 let tree_id = repo.index().unwrap().write_tree().unwrap();
328 let tree = repo.find_tree(tree_id).unwrap();
329 repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[])
330 .unwrap();
331 let result = detect_default_branch(tmp.path());
332 assert!(result.is_some());
333 }
334
335 #[test]
336 fn try_git_cmd_succeeds_on_version() {
337 let ok = try_git_cmd(None, &["--version"], "version-check", None);
338 assert!(ok, "git --version should succeed");
339 }
340
341 #[test]
342 fn try_git_cmd_fails_on_invalid_subcommand() {
343 let ok = try_git_cmd(None, &["not-a-real-subcommand-xyz"], "invalid-cmd", None);
344 assert!(!ok, "invalid git subcommand should return false");
345 }
346}