1use std::path::Path;
7use std::process::{Command, Stdio};
8
9pub const GIT_CONFIG_FLAGS: &[&str] = &[
12 "-c",
13 "core.fsmonitor=false",
14 "-c",
15 "core.hooksPath=/dev/null",
16];
17
18pub fn secure_git() -> Command {
27 let mut cmd = Command::new("git");
28 cmd.args(GIT_CONFIG_FLAGS);
29 cmd.stdin(Stdio::null());
30 cmd
31}
32
33pub const DEFAULT_GIT_TIMEOUT_SECS: u64 = 10;
37
38pub const DEFAULT_GIT_PLUGIN_TIMEOUT_SECS: u64 = 30;
42
43pub fn apply_git_timeout(cmd: &mut Command, secs: u64) -> &mut Command {
52 cmd.env("GIT_CONNECT_TIMEOUT", secs.to_string());
53
54 if std::env::var_os("GIT_SSH_COMMAND").is_none() {
56 cmd.env(
57 "GIT_SSH_COMMAND",
58 format!("ssh -o ConnectTimeout={secs} -o BatchMode=yes"),
59 );
60 }
61
62 cmd.args([
64 "-c",
65 &format!("http.lowSpeedTime={secs}"),
66 "-c",
67 "http.lowSpeedLimit=1",
68 ]);
69
70 cmd
71}
72
73#[must_use]
78pub fn sanitize_git_url(url: &str) -> String {
79 if let Some(at_pos) = url.find('@') {
80 if let Some(proto_end) = url.find("://") {
81 if at_pos > proto_end {
82 let (proto, rest) = url.split_at(proto_end + 3);
83 if let Some(host_start) = rest.find('@') {
84 return format!("{}***@{}", proto, &rest[host_start + 1..]);
85 }
86 }
87 } else {
88 return format!("***{}", &url[at_pos..]);
89 }
90 }
91 url.to_string()
92}
93
94#[must_use]
103pub fn git_failure_detail(
104 stderr: &[u8],
105 stdout: &[u8],
106 status: std::process::ExitStatus,
107) -> String {
108 let raw = if stderr.is_empty() { stdout } else { stderr };
109 let cleaned: String = String::from_utf8_lossy(raw)
110 .trim()
111 .chars()
112 .filter(|c| !c.is_control() || *c == '\n')
113 .collect();
114 if cleaned.is_empty() {
115 format!("exit code {status}")
116 } else {
117 sanitize_git_url(&cleaned)
118 }
119}
120
121pub fn working_tree_dirty(repo: &Path) -> bool {
123 secure_git()
124 .args(["status", "--porcelain"])
125 .current_dir(repo)
126 .stderr(Stdio::null())
127 .output()
128 .is_ok_and(|o| o.status.success() && !o.stdout.is_empty())
129}
130
131pub fn has_unpushed_commits(repo: &Path) -> bool {
135 let output = match secure_git()
136 .args(["rev-list", "--count", "@{u}..HEAD"])
137 .current_dir(repo)
138 .output()
139 {
140 Ok(out) => out,
141 Err(e) => {
142 tracing::warn!("git rev-list count failed at {}: {}", repo.display(), e);
143 return false;
144 }
145 };
146
147 if !output.status.success() {
148 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
149 tracing::warn!(
150 "git rev-list count failed at {} with exit {}: {}",
151 repo.display(),
152 output.status,
153 stderr
154 );
155 return false;
156 }
157
158 match parse_commit_count(&output.stdout) {
159 Some(count) => count > 0,
160 None => {
161 tracing::warn!("git rev-list count output invalid at {}", repo.display());
162 false
163 }
164 }
165}
166
167fn parse_commit_count(output: &[u8]) -> Option<u32> {
168 std::str::from_utf8(output)
169 .ok()
170 .and_then(|s| s.trim().parse::<u32>().ok())
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176
177 #[test]
178 fn secure_git_includes_config_flags() {
179 let cmd = secure_git();
180 assert_eq!(cmd.get_program(), "git");
182 }
183
184 #[test]
185 fn apply_git_timeout_sets_tcp_timeout() {
186 use std::ffi::OsStr;
187 let mut cmd = secure_git();
188 apply_git_timeout(&mut cmd, 10);
189 assert!(
190 cmd.get_envs()
191 .any(|(k, v)| k == OsStr::new("GIT_CONNECT_TIMEOUT") && v == Some(OsStr::new("10"))),
192 "GIT_CONNECT_TIMEOUT env var should be set to 10"
193 );
194 }
195
196 #[test]
197 fn apply_git_timeout_sets_ssh_command() {
198 use std::ffi::OsStr;
199 let mut cmd = secure_git();
200 apply_git_timeout(&mut cmd, 10);
201 assert!(
204 cmd.get_envs()
205 .any(|(k, v)| k == OsStr::new("GIT_SSH_COMMAND")
206 && v == Some(OsStr::new("ssh -o ConnectTimeout=10 -o BatchMode=yes"))),
207 "GIT_SSH_COMMAND should be set correctly"
208 );
209 }
210
211 #[test]
212 fn apply_git_timeout_sets_http_config() {
213 let mut cmd = secure_git();
214 apply_git_timeout(&mut cmd, 10);
215 let args: Vec<String> = cmd
216 .get_args()
217 .map(|s| s.to_string_lossy().into_owned())
218 .collect();
219 assert!(
220 args.windows(2).any(|w| w == ["-c", "http.lowSpeedTime=10"]),
221 "should set http.lowSpeedTime=10"
222 );
223 assert!(
224 args.windows(2).any(|w| w == ["-c", "http.lowSpeedLimit=1"]),
225 "should set http.lowSpeedLimit=1"
226 );
227 }
228
229 #[test]
230 fn sanitize_git_url_http_with_credentials() {
231 let url = "https://user:password@github.com/owner/repo.git";
232 assert_eq!(
233 sanitize_git_url(url),
234 "https://***@github.com/owner/repo.git"
235 );
236 }
237
238 #[test]
239 fn sanitize_git_url_ssh() {
240 let url = "git@github.com:owner/repo.git";
241 assert_eq!(sanitize_git_url(url), "***@github.com:owner/repo.git");
242 }
243
244 #[test]
245 fn sanitize_git_url_no_credentials() {
246 let url = "https://github.com/owner/repo.git";
247 assert_eq!(sanitize_git_url(url), url);
248 }
249
250 #[test]
251 fn git_failure_detail_prefers_stderr() {
252 use std::os::unix::process::ExitStatusExt;
253 let status = std::process::ExitStatus::from_raw(1 << 8);
254 let detail = git_failure_detail(b"fatal: repository not found\n", b"ignored", status);
255 assert_eq!(detail, "fatal: repository not found");
256 }
257
258 #[test]
259 fn git_failure_detail_falls_back_to_stdout_when_stderr_empty() {
260 use std::os::unix::process::ExitStatusExt;
261 let status = std::process::ExitStatus::from_raw(1 << 8);
262 let detail = git_failure_detail(b"", b"index locked\n", status);
263 assert_eq!(detail, "index locked");
264 }
265
266 #[test]
267 fn git_failure_detail_scrubs_credentials_in_stderr() {
268 use std::os::unix::process::ExitStatusExt;
269 let status = std::process::ExitStatus::from_raw(1 << 8);
270 let detail = git_failure_detail(
271 b"fatal: could not read from https://user:tok@github.com/x.git\n",
272 b"",
273 status,
274 );
275 assert!(!detail.contains("tok"), "credential leaked: {detail}");
276 }
277
278 #[test]
279 fn git_failure_detail_strips_control_and_ansi_sequences() {
280 use std::os::unix::process::ExitStatusExt;
281 let status = std::process::ExitStatus::from_raw(1 << 8);
282 let hostile = b"\x1b[2Jcleared\x1b]0;title";
283 let detail = git_failure_detail(hostile, b"", status);
284 assert_eq!(detail, "[2Jcleared]0;title");
285 }
286
287 #[test]
288 fn git_failure_detail_falls_back_to_exit_code() {
289 use std::os::unix::process::ExitStatusExt;
290 let status = std::process::ExitStatus::from_raw(128 << 8);
291 let detail = git_failure_detail(b" \n", b"", status);
292 assert!(detail.contains("exit code"), "got: {detail}");
293 }
294
295 #[cfg(test)]
296 mod prop_tests {
297 use super::*;
298 use proptest::prelude::*;
299
300 proptest! {
301 #[test]
302 fn parse_commit_count_valid_numeric(count_val in 0u32..100_000) {
303 let bytes = format!("{count_val}").into_bytes();
304 prop_assert_eq!(parse_commit_count(&bytes), Some(count_val));
305 }
306
307 #[test]
308 fn parse_commit_count_whitespace_trimmed(count_val in 0u32..100_000) {
309 let bytes = format!(" {count_val} \n").into_bytes();
310 prop_assert_eq!(parse_commit_count(&bytes), Some(count_val));
311 }
312
313 #[test]
314 fn parse_commit_count_malformed_returns_none(junk in ".*") {
315 let _ = parse_commit_count(junk.as_bytes());
317 }
318
319 #[test]
320 fn sanitize_git_url_no_panic(s in ".*") {
321 let _ = sanitize_git_url(&s);
322 }
323
324 #[test]
325 fn sanitize_git_url_idempotent(s in ".*") {
326 let once = sanitize_git_url(&s);
327 let twice = sanitize_git_url(&once);
328 prop_assert_eq!(once, twice);
329 }
330
331 #[test]
332 fn sanitize_git_url_scrubs_http_credentials(
333 user in "[a-zA-Z0-9_]+",
334 pass in "[a-zA-Z0-9_]+",
335 host in "[a-zA-Z0-9.-]+",
336 path in "[a-zA-Z0-9/_.-]*"
337 ) {
338 let url = format!("https://{user}:{pass}@{host}/{path}");
339 let sanitized = sanitize_git_url(&url);
340 prop_assert!(!sanitized.contains(&format!("{user}:{pass}@")),
344 "credentials leaked in: {sanitized}");
345 prop_assert!(sanitized.contains("***@"), "expected *** in: {sanitized}");
346 }
347 }
348 }
349}