1use std::ffi::OsStr;
8use std::io::{Read, Seek, SeekFrom};
9use std::path::Path;
10use std::process::{Command, Output, Stdio};
11use std::time::{Duration, Instant};
12
13pub const GIT_COMMAND_TIMEOUT: Duration = Duration::from_secs(300);
15
16const GIT_POLL_INTERVAL: Duration = Duration::from_millis(50);
17const BLOBLESS_FILTER: &str = "blob:none";
18pub const CMN_PROMISOR_REMOTE: &str = "cmn-promisor";
19
20#[derive(Debug, thiserror::Error)]
22pub enum GitError {
23 #[error("failed to run git: {0}")]
25 Exec(#[from] std::io::Error),
26 #[error("{0}")]
28 Command(String),
29 #[error("git command timed out after {timeout_secs}s: {command}")]
31 Timeout { command: String, timeout_secs: u64 },
32 #[error("git size budget exceeded: {0}")]
34 SizeLimit(String),
35 #[error("rejected git URL: {0}")]
37 InvalidUrl(String),
38 #[error("rejected git argument: {0}")]
40 InvalidArg(String),
41}
42
43#[derive(Debug, Clone, Copy)]
45pub struct GitSizeLimits {
46 pub max_bytes: u64,
47 pub max_files: u64,
48}
49
50impl GitSizeLimits {
51 pub fn new(max_bytes: u64, max_files: u64) -> Self {
52 Self {
53 max_bytes,
54 max_files,
55 }
56 }
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub struct GitSizeStats {
61 pub bytes: u64,
62 pub files: u64,
63}
64
65fn reject_option_like(value: &str, what: &str) -> Result<(), GitError> {
70 if value.starts_with('-') {
71 return Err(GitError::InvalidArg(format!(
72 "{} must not start with '-': {}",
73 what, value
74 )));
75 }
76 Ok(())
77}
78
79fn validate_remote_url(url: &str) -> Result<(), GitError> {
85 let normalized = substrate::normalize_and_validate_url(url).map_err(|e| {
89 let safe_url = agent_first_data::redact_url_secrets(url);
90 GitError::InvalidUrl(e.to_string().replace(url, &safe_url))
91 })?;
92
93 let parsed = reqwest::Url::parse(&normalized)
95 .map_err(|e| GitError::InvalidUrl(format!("invalid URL syntax ({})", e)))?;
96 if parsed.scheme() != "https" {
97 let safe_url = agent_first_data::redact_url_secrets(url);
98 return Err(GitError::InvalidUrl(format!(
99 "only https:// URLs are allowed (got: {})",
100 safe_url
101 )));
102 }
103 Ok(())
104}
105
106fn display_command(program: &str, args: &[impl AsRef<OsStr>]) -> String {
109 let mut parts = vec![program.to_string()];
110 parts.extend(
111 args.iter()
112 .map(|arg| arg.as_ref().to_string_lossy().into_owned()),
113 );
114 parts.join(" ")
115}
116
117fn run_program_raw<S: AsRef<OsStr>>(
118 program: &str,
119 dir: Option<&Path>,
120 args: &[S],
121 timeout: Duration,
122) -> Result<Output, GitError> {
123 let command_display = display_command(program, args);
124 let mut stdout_file = tempfile::tempfile()?;
125 let mut stderr_file = tempfile::tempfile()?;
126
127 let mut cmd = Command::new(program);
128 cmd.args(args.iter().map(|arg| arg.as_ref()));
129 if let Some(d) = dir {
130 cmd.current_dir(d);
131 }
132 cmd.stdout(Stdio::from(stdout_file.try_clone()?));
133 cmd.stderr(Stdio::from(stderr_file.try_clone()?));
134
135 let mut child = cmd.spawn()?;
136 let started = Instant::now();
137 let status = loop {
138 if let Some(status) = child.try_wait()? {
139 break status;
140 }
141 if started.elapsed() >= timeout {
142 let _ = child.kill();
143 let _ = child.wait();
144 return Err(GitError::Timeout {
145 command: command_display,
146 timeout_secs: timeout.as_secs(),
147 });
148 }
149 std::thread::sleep(GIT_POLL_INTERVAL.min(timeout.saturating_sub(started.elapsed())));
150 };
151
152 let mut stdout = Vec::new();
153 stdout_file.seek(SeekFrom::Start(0))?;
154 stdout_file.read_to_end(&mut stdout)?;
155 let mut stderr = Vec::new();
156 stderr_file.seek(SeekFrom::Start(0))?;
157 stderr_file.read_to_end(&mut stderr)?;
158
159 Ok(Output {
160 status,
161 stdout,
162 stderr,
163 })
164}
165
166fn run_git_raw_unchecked<S: AsRef<OsStr>>(
169 dir: Option<&Path>,
170 args: &[S],
171) -> Result<Output, GitError> {
172 run_program_raw("git", dir, args, GIT_COMMAND_TIMEOUT)
173}
174
175fn run_git_raw<S: AsRef<OsStr>>(dir: Option<&Path>, args: &[S]) -> Result<Output, GitError> {
178 let output = run_git_raw_unchecked(dir, args)?;
179 if !output.status.success() {
180 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
181 let message = if stderr.is_empty() {
182 format!(
183 "{} exited with {}",
184 display_command("git", args),
185 output.status
186 )
187 } else {
188 stderr
189 };
190 return Err(GitError::Command(message));
191 }
192 Ok(output)
193}
194
195fn run_git<S: AsRef<OsStr>>(args: &[S]) -> Result<(), GitError> {
197 run_git_raw(None, args).map(|_| ())
198}
199
200fn run_git_in<S: AsRef<OsStr>>(dir: &Path, args: &[S]) -> Result<(), GitError> {
202 run_git_raw(Some(dir), args).map(|_| ())
203}
204
205fn run_git_output<S: AsRef<OsStr>>(dir: &Path, args: &[S]) -> Result<String, GitError> {
207 let output = run_git_raw(Some(dir), args)?;
208 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
209}
210
211pub fn is_available() -> bool {
213 run_git_raw_unchecked(None, &["--version"])
214 .map(|output| output.status.success())
215 .unwrap_or(false)
216}
217
218fn clone_repo_args(url: &str, dest: &str, shallow: bool) -> Vec<String> {
219 let mut args = vec![
220 "clone".to_string(),
221 "--filter".to_string(),
222 BLOBLESS_FILTER.to_string(),
223 ];
224 if shallow {
225 args.extend(["--depth".to_string(), "1".to_string()]);
226 }
227 args.extend(["--".to_string(), url.to_string(), dest.to_string()]);
228 args
229}
230
231fn clone_bare_repo_args(url: &str, dest: &str) -> Vec<String> {
232 vec![
233 "clone".to_string(),
234 "--bare".to_string(),
235 "--filter".to_string(),
236 BLOBLESS_FILTER.to_string(),
237 "--".to_string(),
238 url.to_string(),
239 dest.to_string(),
240 ]
241}
242
243fn clone_from_local_args(local_bare_path: &Path, dest: &Path, no_checkout: bool) -> Vec<String> {
244 let src = format!("file://{}", local_bare_path.display());
245 let dest_str = dest.display().to_string();
246 let mut args = vec![
247 "clone".to_string(),
248 "--filter".to_string(),
249 BLOBLESS_FILTER.to_string(),
250 ];
251 if no_checkout {
252 args.push("--no-checkout".to_string());
253 }
254 args.extend(["--".to_string(), src, dest_str]);
255 args
256}
257
258fn fetch_to_bare_args(remote_url: &str) -> Vec<String> {
259 vec![
260 "fetch".to_string(),
261 "--filter".to_string(),
262 BLOBLESS_FILTER.to_string(),
263 "--force".to_string(),
264 remote_url.to_string(),
265 "+refs/heads/*:refs/heads/*".to_string(),
266 ]
267}
268
269pub fn clone_repo(url: &str, dest: &Path, shallow: bool) -> Result<(), GitError> {
275 validate_remote_url(url)?;
276 let dest_str = dest.display().to_string();
277 run_git(&clone_repo_args(url, &dest_str, shallow))
278}
279
280pub fn clone_bare_repo(url: &str, dest: &Path) -> Result<(), GitError> {
282 validate_remote_url(url)?;
283 let dest_str = dest.display().to_string();
284 run_git(&clone_bare_repo_args(url, &dest_str))
285}
286
287pub fn clone_from_local(local_bare_path: &Path, dest: &Path) -> Result<(), GitError> {
289 run_git(&clone_from_local_args(local_bare_path, dest, false))
290}
291
292pub fn clone_from_local_no_checkout(local_bare_path: &Path, dest: &Path) -> Result<(), GitError> {
294 run_git(&clone_from_local_args(local_bare_path, dest, true))
295}
296
297pub fn checkout_ref(repo_path: &Path, ref_spec: &str) -> Result<(), GitError> {
299 reject_option_like(ref_spec, "git ref")?;
303 run_git_in(repo_path, &["checkout", ref_spec, "--"])
304}
305
306pub fn init_repo(path: &Path) -> Result<(), GitError> {
308 run_git_in(path, &["init"])
309}
310
311pub fn configure_blobless_promisor_remote(
313 repo_path: &Path,
314 remote_name: &str,
315 remote_url: &str,
316) -> Result<(), GitError> {
317 reject_option_like(remote_name, "remote name")?;
318 validate_remote_url(remote_url)?;
319 if get_remote_url(repo_path, remote_name)?.is_some() {
320 run_git_in(repo_path, &["remote", "set-url", remote_name, remote_url])?;
321 } else {
322 run_git_in(repo_path, &["remote", "add", remote_name, remote_url])?;
323 }
324 let promisor_key = format!("remote.{remote_name}.promisor");
325 run_git_in(repo_path, &["config", promisor_key.as_str(), "true"])?;
326 let filter_key = format!("remote.{remote_name}.partialclonefilter");
327 run_git_in(repo_path, &["config", filter_key.as_str(), BLOBLESS_FILTER])?;
328 run_git_in(
329 repo_path,
330 &["config", "extensions.partialClone", remote_name],
331 )
332}
333
334pub fn configure_blobless_origin(repo_path: &Path, remote_url: &str) -> Result<(), GitError> {
336 configure_blobless_promisor_remote(repo_path, "origin", remote_url)
337}
338
339pub fn add_all_and_commit(repo_path: &Path, message: &str) -> Result<String, GitError> {
341 run_git_in(repo_path, &["add", "."])?;
342 run_git_in(
343 repo_path,
344 &[
345 "-c",
346 "user.name=CMN Hypha",
347 "-c",
348 "user.email=hypha@cmn.dev",
349 "commit",
350 "-m",
351 message,
352 ],
353 )?;
354 run_git_output(repo_path, &["rev-parse", "HEAD"])
355}
356
357pub fn get_head_commit(repo_path: &Path) -> Result<String, GitError> {
359 run_git_output(repo_path, &["rev-parse", "HEAD"])
360}
361
362pub fn commit_exists(repo_path: &Path, commit_sha: &str) -> Result<bool, GitError> {
364 reject_option_like(commit_sha, "commit sha")?;
365 let output = run_git_raw_unchecked(Some(repo_path), &["cat-file", "-t", commit_sha])?;
366 Ok(output.status.success())
367}
368
369pub fn fetch_to_bare(bare_repo_path: &Path, remote_url: &str) -> Result<(), GitError> {
371 validate_remote_url(remote_url)?;
372 run_git_in(bare_repo_path, &fetch_to_bare_args(remote_url))
373}
374
375pub fn fetch_from_remote(repo_path: &Path, remote_name: &str) -> Result<(), GitError> {
377 reject_option_like(remote_name, "remote name")?;
378 run_git_in(
379 repo_path,
380 &["fetch", "--filter", BLOBLESS_FILTER, remote_name],
381 )
382}
383
384pub fn add_remote(repo_path: &Path, remote_name: &str, remote_url: &str) -> Result<(), GitError> {
386 reject_option_like(remote_name, "remote name")?;
387 reject_option_like(remote_url, "remote url")?;
388 run_git_in(repo_path, &["remote", "add", remote_name, remote_url])
389}
390
391pub fn set_remote_url(repo_path: &Path, remote_name: &str, new_url: &str) -> Result<(), GitError> {
393 reject_option_like(remote_name, "remote name")?;
394 reject_option_like(new_url, "remote url")?;
395 run_git_in(repo_path, &["remote", "set-url", remote_name, new_url])
396}
397
398pub fn is_working_dir_clean(repo_path: &Path) -> Result<bool, GitError> {
402 let output = run_git_output(repo_path, &["status", "--porcelain"])?;
403 Ok(output.is_empty())
404}
405
406pub fn get_root_commit_bare(bare_repo_path: &Path) -> Result<String, GitError> {
408 run_git_output(bare_repo_path, &["rev-list", "--max-parents=0", "HEAD"])
409}
410
411pub fn get_root_commit(repo_path: &Path) -> Result<String, GitError> {
413 run_git_output(repo_path, &["rev-list", "--max-parents=0", "HEAD"])
414}
415
416pub fn get_remote_url(repo_path: &Path, remote: &str) -> Result<Option<String>, GitError> {
418 match run_git_output(repo_path, &["remote", "get-url", remote]) {
419 Ok(url) if url.is_empty() => Ok(None),
420 Ok(url) => Ok(Some(url)),
421 Err(_) => Ok(None),
422 }
423}
424
425pub fn last_commit_epoch_ms(repo_path: &Path) -> Option<u64> {
427 let output = run_git_output(repo_path, &["log", "-1", "--format=%ct", "--", "."]).ok()?;
428 let epoch_s: u64 = output.parse().ok()?;
429 Some(epoch_s * 1000)
430}
431
432pub fn enforce_size_budget(path: &Path, limits: GitSizeLimits) -> Result<GitSizeStats, GitError> {
434 let mut stats = GitSizeStats { bytes: 0, files: 0 };
435 let mut stack = vec![path.to_path_buf()];
436
437 while let Some(dir) = stack.pop() {
438 for entry in std::fs::read_dir(&dir)? {
439 let entry = entry?;
440 let path = entry.path();
441 let meta = std::fs::symlink_metadata(&path)?;
442
443 stats.files = stats.files.saturating_add(1);
444 stats.bytes = stats.bytes.saturating_add(meta.len());
445 if stats.files > limits.max_files {
446 return Err(GitError::SizeLimit(format!(
447 "{} contains more than {} entries",
448 path.display(),
449 limits.max_files
450 )));
451 }
452 if stats.bytes > limits.max_bytes {
453 return Err(GitError::SizeLimit(format!(
454 "{} exceeds {} bytes",
455 path.display(),
456 limits.max_bytes
457 )));
458 }
459
460 if meta.is_dir() {
461 stack.push(path);
462 }
463 }
464 }
465
466 Ok(stats)
467}
468
469#[cfg(test)]
470#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
471mod tests {
472 use super::*;
473 use std::fs;
474
475 #[test]
476 fn clone_args_use_blobless_filter_for_shallow_and_full() {
477 assert_eq!(
478 clone_repo_args("https://example.com/repo.git", "/tmp/repo", true),
479 [
480 "clone",
481 "--filter",
482 "blob:none",
483 "--depth",
484 "1",
485 "--",
486 "https://example.com/repo.git",
487 "/tmp/repo",
488 ]
489 .map(String::from)
490 .to_vec()
491 );
492 assert_eq!(
493 clone_repo_args("https://example.com/repo.git", "/tmp/repo", false),
494 [
495 "clone",
496 "--filter",
497 "blob:none",
498 "--",
499 "https://example.com/repo.git",
500 "/tmp/repo",
501 ]
502 .map(String::from)
503 .to_vec()
504 );
505 }
506
507 #[test]
508 fn bare_clone_and_fetch_args_use_blobless_filter() {
509 assert_eq!(
510 clone_bare_repo_args("https://example.com/repo.git", "/tmp/repo.git"),
511 [
512 "clone",
513 "--bare",
514 "--filter",
515 "blob:none",
516 "--",
517 "https://example.com/repo.git",
518 "/tmp/repo.git",
519 ]
520 .map(String::from)
521 .to_vec()
522 );
523 assert_eq!(
524 fetch_to_bare_args("https://example.com/repo.git"),
525 [
526 "fetch",
527 "--filter",
528 "blob:none",
529 "--force",
530 "https://example.com/repo.git",
531 "+refs/heads/*:refs/heads/*",
532 ]
533 .map(String::from)
534 .to_vec()
535 );
536 }
537
538 #[test]
539 fn enforce_size_budget_rejects_too_many_bytes() {
540 let dir = tempfile::tempdir().expect("tempdir");
541 fs::write(dir.path().join("large.bin"), [0u8; 16]).expect("write");
542
543 let err = enforce_size_budget(dir.path(), GitSizeLimits::new(8, 10)).unwrap_err();
544 assert!(matches!(err, GitError::SizeLimit(_)));
545 }
546
547 #[test]
548 fn enforce_size_budget_rejects_too_many_entries() {
549 let dir = tempfile::tempdir().expect("tempdir");
550 fs::write(dir.path().join("one.txt"), b"1").expect("write one");
551 fs::write(dir.path().join("two.txt"), b"2").expect("write two");
552
553 let err = enforce_size_budget(dir.path(), GitSizeLimits::new(1024, 1)).unwrap_err();
554 assert!(matches!(err, GitError::SizeLimit(_)));
555 }
556
557 #[test]
558 fn remote_url_validation_redacts_userinfo_password() {
559 let password = "git-password-canary";
560 let err = validate_remote_url(&format!("https://agent:{password}@example.com/repo.git"))
561 .unwrap_err()
562 .to_string();
563 assert!(!err.contains(password));
564 assert!(err.contains("***"));
565 }
566
567 #[cfg(unix)]
568 #[test]
569 fn run_program_raw_times_out() {
570 let err =
571 run_program_raw("sh", None, &["-c", "sleep 2"], Duration::from_millis(20)).unwrap_err();
572 assert!(matches!(err, GitError::Timeout { .. }));
573 }
574}