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
33#[must_use]
38pub fn sanitize_git_url(url: &str) -> String {
39 if let Some(at_pos) = url.find('@') {
40 if let Some(proto_end) = url.find("://") {
41 if at_pos > proto_end {
42 let (proto, rest) = url.split_at(proto_end + 3);
43 if let Some(host_start) = rest.find('@') {
44 return format!("{}***@{}", proto, &rest[host_start + 1..]);
45 }
46 }
47 } else {
48 return format!("***{}", &url[at_pos..]);
49 }
50 }
51 url.to_string()
52}
53
54#[must_use]
63pub fn git_failure_detail(
64 stderr: &[u8],
65 stdout: &[u8],
66 status: std::process::ExitStatus,
67) -> String {
68 let raw = if stderr.is_empty() { stdout } else { stderr };
69 let cleaned: String = String::from_utf8_lossy(raw)
70 .trim()
71 .chars()
72 .filter(|c| !c.is_control() || *c == '\n')
73 .collect();
74 if cleaned.is_empty() {
75 format!("exit code {status}")
76 } else {
77 sanitize_git_url(&cleaned)
78 }
79}
80
81pub fn working_tree_dirty(repo: &Path) -> bool {
83 secure_git()
84 .args(["status", "--porcelain"])
85 .current_dir(repo)
86 .stderr(Stdio::null())
87 .output()
88 .is_ok_and(|o| o.status.success() && !o.stdout.is_empty())
89}
90
91pub fn has_unpushed_commits(repo: &Path) -> bool {
95 let output = match secure_git()
96 .args(["rev-list", "--count", "@{u}..HEAD"])
97 .current_dir(repo)
98 .output()
99 {
100 Ok(out) => out,
101 Err(e) => {
102 tracing::warn!("git rev-list count failed at {}: {}", repo.display(), e);
103 return false;
104 }
105 };
106
107 if !output.status.success() {
108 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
109 tracing::warn!(
110 "git rev-list count failed at {} with exit {}: {}",
111 repo.display(),
112 output.status,
113 stderr
114 );
115 return false;
116 }
117
118 match parse_commit_count(&output.stdout) {
119 Some(count) => count > 0,
120 None => {
121 tracing::warn!("git rev-list count output invalid at {}", repo.display());
122 false
123 }
124 }
125}
126
127fn parse_commit_count(output: &[u8]) -> Option<u32> {
128 std::str::from_utf8(output)
129 .ok()
130 .and_then(|s| s.trim().parse::<u32>().ok())
131}
132
133#[cfg(test)]
134mod tests {
135 use super::*;
136
137 #[test]
138 fn secure_git_includes_config_flags() {
139 let cmd = secure_git();
140 assert_eq!(cmd.get_program(), "git");
142 }
143
144 #[test]
145 fn sanitize_git_url_http_with_credentials() {
146 let url = "https://user:password@github.com/owner/repo.git";
147 assert_eq!(
148 sanitize_git_url(url),
149 "https://***@github.com/owner/repo.git"
150 );
151 }
152
153 #[test]
154 fn sanitize_git_url_ssh() {
155 let url = "git@github.com:owner/repo.git";
156 assert_eq!(sanitize_git_url(url), "***@github.com:owner/repo.git");
157 }
158
159 #[test]
160 fn sanitize_git_url_no_credentials() {
161 let url = "https://github.com/owner/repo.git";
162 assert_eq!(sanitize_git_url(url), url);
163 }
164
165 #[test]
166 fn git_failure_detail_prefers_stderr() {
167 use std::os::unix::process::ExitStatusExt;
168 let status = std::process::ExitStatus::from_raw(1 << 8);
169 let detail = git_failure_detail(b"fatal: repository not found\n", b"ignored", status);
170 assert_eq!(detail, "fatal: repository not found");
171 }
172
173 #[test]
174 fn git_failure_detail_falls_back_to_stdout_when_stderr_empty() {
175 use std::os::unix::process::ExitStatusExt;
176 let status = std::process::ExitStatus::from_raw(1 << 8);
177 let detail = git_failure_detail(b"", b"index locked\n", status);
178 assert_eq!(detail, "index locked");
179 }
180
181 #[test]
182 fn git_failure_detail_scrubs_credentials_in_stderr() {
183 use std::os::unix::process::ExitStatusExt;
184 let status = std::process::ExitStatus::from_raw(1 << 8);
185 let detail = git_failure_detail(
186 b"fatal: could not read from https://user:tok@github.com/x.git\n",
187 b"",
188 status,
189 );
190 assert!(!detail.contains("tok"), "credential leaked: {detail}");
191 }
192
193 #[test]
194 fn git_failure_detail_strips_control_and_ansi_sequences() {
195 use std::os::unix::process::ExitStatusExt;
196 let status = std::process::ExitStatus::from_raw(1 << 8);
197 let hostile = b"\x1b[2Jcleared\x1b]0;title";
198 let detail = git_failure_detail(hostile, b"", status);
199 assert_eq!(detail, "[2Jcleared]0;title");
200 }
201
202 #[test]
203 fn git_failure_detail_falls_back_to_exit_code() {
204 use std::os::unix::process::ExitStatusExt;
205 let status = std::process::ExitStatus::from_raw(128 << 8);
206 let detail = git_failure_detail(b" \n", b"", status);
207 assert!(detail.contains("exit code"), "got: {detail}");
208 }
209
210 #[cfg(test)]
211 mod prop_tests {
212 use super::*;
213 use proptest::prelude::*;
214
215 proptest! {
216 #[test]
217 fn parse_commit_count_valid_numeric(count_val in 0u32..100_000) {
218 let bytes = format!("{count_val}").into_bytes();
219 prop_assert_eq!(parse_commit_count(&bytes), Some(count_val));
220 }
221
222 #[test]
223 fn parse_commit_count_whitespace_trimmed(count_val in 0u32..100_000) {
224 let bytes = format!(" {count_val} \n").into_bytes();
225 prop_assert_eq!(parse_commit_count(&bytes), Some(count_val));
226 }
227
228 #[test]
229 fn parse_commit_count_malformed_returns_none(junk in ".*") {
230 let _ = parse_commit_count(junk.as_bytes());
232 }
233
234 #[test]
235 fn sanitize_git_url_no_panic(s in ".*") {
236 let _ = sanitize_git_url(&s);
237 }
238
239 #[test]
240 fn sanitize_git_url_idempotent(s in ".*") {
241 let once = sanitize_git_url(&s);
242 let twice = sanitize_git_url(&once);
243 prop_assert_eq!(once, twice);
244 }
245
246 #[test]
247 fn sanitize_git_url_scrubs_http_credentials(
248 user in "[a-zA-Z0-9_]+",
249 pass in "[a-zA-Z0-9_]+",
250 host in "[a-zA-Z0-9.-]+",
251 path in "[a-zA-Z0-9/_.-]*"
252 ) {
253 let url = format!("https://{user}:{pass}@{host}/{path}");
254 let sanitized = sanitize_git_url(&url);
255 prop_assert!(!sanitized.contains(&format!("{user}:{pass}@")),
259 "credentials leaked in: {sanitized}");
260 prop_assert!(sanitized.contains("***@"), "expected *** in: {sanitized}");
261 }
262 }
263 }
264}