1use super::{ChangeSet, GitBackend, GitCapabilities, GitError, Result, parser};
7use std::io::Read;
8use std::path::{Path, PathBuf};
9use std::process::{Command, Stdio};
10use std::time::Duration;
11
12const DEFAULT_MAX_OUTPUT_SIZE: usize = 10 * 1024 * 1024; const MIN_MAX_OUTPUT_SIZE: usize = 1024 * 1024; const MAX_MAX_OUTPUT_SIZE: usize = 100 * 1024 * 1024; const DEFAULT_TIMEOUT_MS: u64 = 3000;
22
23#[must_use]
71pub fn max_git_output_size() -> usize {
72 let size = std::env::var("SQRY_GIT_MAX_OUTPUT_SIZE")
73 .ok()
74 .and_then(|s| s.parse().ok())
75 .unwrap_or(DEFAULT_MAX_OUTPUT_SIZE);
76 size.clamp(MIN_MAX_OUTPUT_SIZE, MAX_MAX_OUTPUT_SIZE)
77}
78
79#[derive(Debug, Clone, Copy, Default)]
91pub struct SubprocessGit;
92
93impl SubprocessGit {
94 #[must_use]
96 pub fn new() -> Self {
97 Self
98 }
99
100 pub(crate) fn execute_git(args: &[&str], timeout_ms: Option<u64>) -> Result<String> {
114 let timeout = Duration::from_millis(timeout_ms.unwrap_or(DEFAULT_TIMEOUT_MS));
115 let max_output_size = max_git_output_size(); let mut cmd = Command::new("git");
119 cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
120
121 let mut child = cmd.spawn().map_err(|e| {
123 if e.kind() == std::io::ErrorKind::NotFound {
124 GitError::NotFound
125 } else {
126 GitError::CommandFailed {
127 message: format!("Failed to spawn git: {e}"),
128 stdout: String::new(),
129 stderr: String::new(),
130 }
131 }
132 })?;
133
134 let start = std::time::Instant::now();
136 let result = loop {
137 match child.try_wait() {
138 Ok(Some(status)) => {
139 break status;
141 }
142 Ok(None) => {
143 if start.elapsed() >= timeout {
145 let _ = child.kill();
147 let timeout_ms = timeout.as_millis().try_into().unwrap_or(u64::MAX);
149 return Err(GitError::Timeout(timeout_ms));
150 }
151 std::thread::sleep(Duration::from_millis(10));
153 }
154 Err(e) => {
155 return Err(GitError::CommandFailed {
156 message: format!("Failed to wait for git: {e}"),
157 stdout: String::new(),
158 stderr: String::new(),
159 });
160 }
161 }
162 };
163
164 let status = result;
165
166 let mut stdout = Vec::new();
169 if let Some(out) = child.stdout.take() {
170 let mut limited = out.take((max_output_size + 1) as u64);
171 limited
172 .read_to_end(&mut stdout)
173 .map_err(|e| GitError::CommandFailed {
174 message: format!("Failed to read stdout: {e}"),
175 stdout: String::new(),
176 stderr: String::new(),
177 })?;
178
179 if stdout.len() > max_output_size {
181 return Err(GitError::OutputExceededLimit {
182 limit_bytes: max_output_size,
183 actual_bytes: stdout.len(), });
185 }
186 }
187
188 let mut stderr = Vec::new();
190 if let Some(err) = child.stderr.take() {
191 let mut limited = err.take((max_output_size + 1) as u64);
192 limited
193 .read_to_end(&mut stderr)
194 .map_err(|e| GitError::CommandFailed {
195 message: format!("Failed to read stderr: {e}"),
196 stdout: String::new(),
197 stderr: String::new(),
198 })?;
199 }
200
201 if !status.success() {
203 let stdout_str = String::from_utf8_lossy(&stdout);
204 let stderr_str = String::from_utf8_lossy(&stderr);
205 return Err(GitError::CommandFailed {
206 message: format!("Exit code {}", status.code().unwrap_or(-1)),
207 stdout: stdout_str.to_string(),
208 stderr: stderr_str.to_string(),
209 });
210 }
211
212 String::from_utf8(stdout)
214 .map_err(|e| GitError::InvalidOutput(format!("Git output is not valid UTF-8: {e}")))
215 }
216
217 fn get_timeout_ms() -> Option<u64> {
219 std::env::var("SQRY_GIT_TIMEOUT_MS")
220 .ok()
221 .and_then(|s| s.parse::<u64>().ok())
222 .map(|t| t.clamp(100, 60000))
223 }
224
225 #[allow(dead_code)]
227 fn get_rename_similarity() -> u8 {
228 std::env::var("SQRY_GIT_RENAME_SIMILARITY")
229 .ok()
230 .and_then(|s| s.parse::<u8>().ok())
231 .map_or(50, |s| s.min(100))
232 }
233
234 #[allow(dead_code)]
236 fn should_include_untracked() -> bool {
237 std::env::var("SQRY_GIT_INCLUDE_UNTRACKED")
238 .ok()
239 .and_then(|s| s.parse::<u8>().ok())
240 != Some(0)
241 }
242}
243
244impl GitBackend for SubprocessGit {
245 fn is_repo(&self, root: &Path) -> Result<bool> {
246 let result = Self::execute_git(
247 &["-C", &root.display().to_string(), "rev-parse", "--git-dir"],
248 Self::get_timeout_ms(),
249 );
250
251 match result {
252 Ok(_) => Ok(true),
253 Err(GitError::CommandFailed { stderr, .. })
254 if stderr.contains("not a git repository") =>
255 {
256 Ok(false)
257 }
258 Err(GitError::NotFound) => Ok(false),
259 Err(e) => Err(e),
260 }
261 }
262
263 fn repo_root(&self, root: &Path) -> Result<PathBuf> {
264 let output = Self::execute_git(
265 &[
266 "-C",
267 &root.display().to_string(),
268 "rev-parse",
269 "--show-toplevel",
270 ],
271 Self::get_timeout_ms(),
272 )?;
273
274 Ok(PathBuf::from(output.trim()))
275 }
276
277 fn head(&self, root: &Path) -> Result<Option<String>> {
278 let result = Self::execute_git(
279 &["-C", &root.display().to_string(), "rev-parse", "HEAD"],
280 Self::get_timeout_ms(),
281 );
282
283 match result {
284 Ok(output) => Ok(Some(output.trim().to_string())),
285 Err(GitError::CommandFailed { message, .. }) if message.contains("128") => {
286 Ok(None)
288 }
289 Err(e) => Err(e),
290 }
291 }
292
293 fn uncommitted(
294 &self,
295 root: &Path,
296 include_untracked: bool,
297 ) -> Result<(ChangeSet, Option<String>)> {
298 let head = self.head(root)?;
300
301 let root_str = root.display().to_string();
303
304 let args = if include_untracked {
306 vec![
307 "-C",
308 &root_str,
309 "status",
310 "--porcelain=v1",
311 "-z",
312 "--ignore-submodules=all",
313 ]
314 } else {
315 vec![
316 "-C",
317 &root_str,
318 "status",
319 "--porcelain=v1",
320 "-z",
321 "--ignore-submodules=all",
322 "--untracked-files=no",
323 ]
324 };
325
326 let output = Self::execute_git(&args, Self::get_timeout_ms())?;
327
328 let changeset = parser::parse_porcelain(&output)?;
330
331 Ok((changeset, head))
332 }
333
334 fn since(
335 &self,
336 root: &Path,
337 baseline: &str,
338 rename_similarity: u8,
339 ) -> Result<(ChangeSet, Option<String>)> {
340 let head = self.head(root)?;
342
343 let range = format!("{baseline}..HEAD");
345
346 let similarity = format!("-M{rename_similarity}");
348 let output = Self::execute_git(
349 &[
350 "-C",
351 &root.display().to_string(),
352 "diff",
353 "--name-status",
354 "-z",
355 &similarity,
356 "--ignore-submodules=all",
357 &range, "--",
359 ],
360 Self::get_timeout_ms(),
361 )?;
362
363 let changeset = parser::parse_diff_name_status(&output)?;
365
366 Ok((changeset, head))
367 }
368
369 fn capabilities(&self) -> GitCapabilities {
370 GitCapabilities {
371 supports_blame: false, supports_time_travel: false, supports_history_index: false, }
375 }
376}
377
378#[cfg(test)]
379mod tests {
380 use super::*;
381 use std::fs;
382
383 fn tempdir_outside_git_repo() -> tempfile::TempDir {
384 #[cfg(unix)]
385 fn is_in_git_repo(path: &std::path::Path) -> bool {
386 path.ancestors()
387 .any(|ancestor| ancestor.join(".git").is_dir())
388 }
389
390 #[cfg(unix)]
391 {
392 for base in [
393 std::path::Path::new("/var/tmp"),
394 std::path::Path::new("/dev/shm"),
395 ] {
396 if base.is_dir()
397 && !is_in_git_repo(base)
398 && let Ok(tmpdir) = tempfile::TempDir::new_in(base)
399 {
400 return tmpdir;
401 }
402 }
403 }
404
405 tempfile::tempdir().expect("create temp dir")
406 }
407
408 fn create_test_repo() -> tempfile::TempDir {
410 let tmpdir = tempfile::tempdir().unwrap();
411 let path = tmpdir.path();
412
413 let init = Command::new("git")
415 .args(["init"])
416 .current_dir(path)
417 .output()
418 .expect("Failed to init git repo");
419 assert!(init.status.success(), "git init failed: {init:?}");
420
421 let cfg1 = Command::new("git")
423 .args(["config", "user.name", "Test"])
424 .current_dir(path)
425 .output()
426 .expect("Failed to config user.name");
427 assert!(
428 cfg1.status.success(),
429 "git config user.name failed: {cfg1:?}"
430 );
431
432 let cfg2 = Command::new("git")
433 .args(["config", "user.email", "test@example.com"])
434 .current_dir(path)
435 .output()
436 .expect("Failed to config user.email");
437 assert!(
438 cfg2.status.success(),
439 "git config user.email failed: {cfg2:?}"
440 );
441
442 let cfg3 = Command::new("git")
444 .args(["config", "commit.gpgSign", "false"])
445 .current_dir(path)
446 .output()
447 .expect("Failed to config commit.gpgSign");
448 assert!(
449 cfg3.status.success(),
450 "git config commit.gpgSign failed: {cfg3:?}"
451 );
452
453 let cfg4 = Command::new("git")
455 .args(["config", "core.autocrlf", "false"])
456 .current_dir(path)
457 .output()
458 .expect("Failed to config core.autocrlf");
459 assert!(
460 cfg4.status.success(),
461 "git config core.autocrlf failed: {cfg4:?}"
462 );
463
464 tmpdir
465 }
466
467 #[test]
468 fn test_is_repo_true() {
469 let tmpdir = create_test_repo();
470 let backend = SubprocessGit::new();
471
472 let result = backend.is_repo(tmpdir.path());
473 assert!(result.is_ok());
474 assert!(result.unwrap());
475 }
476
477 #[test]
478 fn test_is_repo_false() {
479 let tmpdir = tempdir_outside_git_repo();
480 let backend = SubprocessGit::new();
481
482 let result = backend.is_repo(tmpdir.path());
483 assert!(result.is_ok());
484 assert!(!result.unwrap());
485 }
486
487 #[test]
488 fn test_repo_root() {
489 let tmpdir = create_test_repo();
490 let backend = SubprocessGit::new();
491
492 let result = backend.repo_root(tmpdir.path());
493 assert!(result.is_ok());
494
495 let root = result.unwrap();
496 assert!(root.ends_with(tmpdir.path().file_name().unwrap()));
497 }
498
499 #[test]
500 fn test_head_no_commits() {
501 let tmpdir = create_test_repo();
502 let backend = SubprocessGit::new();
503
504 let result = backend.head(tmpdir.path());
505 assert!(result.is_ok());
506 assert_eq!(result.unwrap(), None); }
508
509 #[test]
510 fn test_head_with_commit() {
511 let tmpdir = create_test_repo();
512 let path = tmpdir.path();
513
514 fs::write(path.join("test.txt"), "hello").unwrap();
516 let add = Command::new("git")
517 .args(["add", "test.txt"])
518 .current_dir(path)
519 .output()
520 .unwrap();
521 assert!(add.status.success(), "git add failed: {add:?}");
522 let commit = Command::new("git")
523 .args(["commit", "-m", "Initial commit"])
524 .current_dir(path)
525 .output()
526 .unwrap();
527 assert!(commit.status.success(), "git commit failed: {commit:?}");
528
529 let backend = SubprocessGit::new();
530 let result = backend.head(path);
531 assert!(result.is_ok());
532
533 let head = result.unwrap();
534 assert!(head.is_some());
535 assert_eq!(head.unwrap().len(), 40); }
537
538 #[test]
539 fn test_uncommitted_empty() {
540 let tmpdir = create_test_repo();
541 let path = tmpdir.path();
542
543 fs::write(path.join("test.txt"), "hello").unwrap();
545 let add = Command::new("git")
546 .args(["add", "test.txt"])
547 .current_dir(path)
548 .output()
549 .unwrap();
550 assert!(add.status.success());
551 let commit = Command::new("git")
552 .args(["commit", "-m", "Initial"])
553 .current_dir(path)
554 .output()
555 .unwrap();
556 assert!(commit.status.success());
557
558 let backend = SubprocessGit::new();
559 let result = backend.uncommitted(path, true);
560 assert!(result.is_ok());
561
562 let (changeset, head) = result.unwrap();
563 assert!(changeset.is_empty());
564 assert!(head.is_some());
565 }
566
567 #[test]
568 fn test_uncommitted_modified() {
569 let tmpdir = create_test_repo();
570 let path = tmpdir.path();
571
572 fs::write(path.join("test.txt"), "hello").unwrap();
574 let add = Command::new("git")
575 .args(["add", "test.txt"])
576 .current_dir(path)
577 .output()
578 .unwrap();
579 assert!(add.status.success());
580 let commit = Command::new("git")
581 .args(["commit", "-m", "Initial"])
582 .current_dir(path)
583 .output()
584 .unwrap();
585 assert!(commit.status.success());
586
587 fs::write(path.join("test.txt"), "modified").unwrap();
589
590 let backend = SubprocessGit::new();
591 let result = backend.uncommitted(path, true);
592 assert!(result.is_ok());
593
594 let (changeset, _) = result.unwrap();
595 assert_eq!(changeset.modified.len(), 1);
596 assert_eq!(changeset.modified[0], PathBuf::from("test.txt"));
597 }
598
599 #[test]
600 fn test_uncommitted_untracked() {
601 let tmpdir = create_test_repo();
602 let path = tmpdir.path();
603
604 fs::write(path.join("test.txt"), "hello").unwrap();
606 Command::new("git")
607 .args(["add", "test.txt"])
608 .current_dir(path)
609 .output()
610 .unwrap();
611 Command::new("git")
612 .args(["commit", "-m", "Initial"])
613 .current_dir(path)
614 .output()
615 .unwrap();
616
617 fs::write(path.join("new.txt"), "new").unwrap();
619
620 let backend = SubprocessGit::new();
621
622 let result = backend.uncommitted(path, true);
624 assert!(result.is_ok());
625 let (changeset, _) = result.unwrap();
626 assert_eq!(changeset.added.len(), 1);
627
628 let result = backend.uncommitted(path, false);
630 assert!(result.is_ok());
631 let (changeset, _) = result.unwrap();
632 assert!(changeset.is_empty());
633 }
634
635 #[test]
636 fn test_since() {
637 let tmpdir = create_test_repo();
638 let path = tmpdir.path();
639
640 fs::write(path.join("file1.txt"), "hello").unwrap();
642 let add = Command::new("git")
643 .args(["add", "file1.txt"])
644 .current_dir(path)
645 .output()
646 .unwrap();
647 assert!(add.status.success());
648 let commit = Command::new("git")
649 .args(["commit", "-m", "First"])
650 .current_dir(path)
651 .output()
652 .unwrap();
653 assert!(commit.status.success());
654
655 let backend = SubprocessGit::new();
657 let baseline = backend.head(path).unwrap().unwrap();
658
659 fs::write(path.join("file2.txt"), "world").unwrap();
661 let add2 = Command::new("git")
662 .args(["add", "file2.txt"])
663 .current_dir(path)
664 .output()
665 .unwrap();
666 assert!(add2.status.success());
667 let commit2 = Command::new("git")
668 .args(["commit", "-m", "Second"])
669 .current_dir(path)
670 .output()
671 .unwrap();
672 assert!(commit2.status.success());
673
674 let result = backend.since(path, &baseline, 50);
676 assert!(result.is_ok());
677
678 let (changeset, head) = result.unwrap();
679 assert_eq!(changeset.added.len(), 1);
680 assert_eq!(changeset.added[0], PathBuf::from("file2.txt"));
681 assert!(head.is_some());
682 assert_ne!(head.unwrap(), baseline); }
684
685 #[test]
686 fn test_capabilities() {
687 let backend = SubprocessGit::new();
688 let caps = backend.capabilities();
689
690 assert!(!caps.supports_blame);
691 assert!(!caps.supports_time_travel);
692 assert!(!caps.supports_history_index);
693 }
694
695 mod p1_17_git_output_limit {
697 use super::*;
698 use serial_test::serial;
699
700 #[test]
701 #[serial]
702 fn test_max_git_output_size_default() {
703 unsafe {
704 std::env::remove_var("SQRY_GIT_MAX_OUTPUT_SIZE");
705 }
706 assert_eq!(max_git_output_size(), 10 * 1024 * 1024); }
708
709 #[test]
710 #[serial]
711 fn test_max_git_output_size_env_override() {
712 unsafe {
713 std::env::set_var("SQRY_GIT_MAX_OUTPUT_SIZE", "52428800"); }
715 assert_eq!(max_git_output_size(), 52_428_800);
716 unsafe {
717 std::env::remove_var("SQRY_GIT_MAX_OUTPUT_SIZE");
718 }
719 }
720
721 #[test]
722 #[serial]
723 fn test_max_git_output_size_clamping_min() {
724 unsafe {
725 std::env::set_var("SQRY_GIT_MAX_OUTPUT_SIZE", "500"); }
727 assert_eq!(max_git_output_size(), 1024 * 1024); unsafe {
729 std::env::remove_var("SQRY_GIT_MAX_OUTPUT_SIZE");
730 }
731 }
732
733 #[test]
734 #[serial]
735 fn test_max_git_output_size_clamping_max() {
736 unsafe {
737 std::env::set_var("SQRY_GIT_MAX_OUTPUT_SIZE", "999000000000"); }
739 assert_eq!(max_git_output_size(), 100 * 1024 * 1024); unsafe {
741 std::env::remove_var("SQRY_GIT_MAX_OUTPUT_SIZE");
742 }
743 }
744
745 #[test]
746 #[serial]
747 fn test_max_git_output_size_malformed() {
748 unsafe {
749 std::env::set_var("SQRY_GIT_MAX_OUTPUT_SIZE", "invalid");
750 }
751 assert_eq!(max_git_output_size(), 10 * 1024 * 1024); unsafe {
753 std::env::remove_var("SQRY_GIT_MAX_OUTPUT_SIZE");
754 }
755 }
756
757 #[test]
758 fn test_output_exceeded_error_formatting() {
759 let err = GitError::OutputExceededLimit {
760 limit_bytes: 10 * 1024 * 1024, actual_bytes: 15 * 1024 * 1024, };
763
764 let msg = err.detailed_message();
765 assert!(
766 msg.contains("10.0 MB"),
767 "Error message should show limit in MB"
768 );
769 assert!(
770 msg.contains(">15.0 MB"),
771 "Error message should show actual size in MB"
772 );
773 assert!(
774 msg.contains("SQRY_GIT_MAX_OUTPUT_SIZE"),
775 "Error message should mention env var"
776 );
777 assert!(
778 msg.contains("git diff --stat"),
779 "Error message should suggest investigation command"
780 );
781 }
782
783 #[test]
784 fn test_output_exceeded_error_suggested_limit() {
785 let err = GitError::OutputExceededLimit {
786 limit_bytes: 10 * 1024 * 1024, actual_bytes: 15 * 1024 * 1024, };
789
790 let suggested = err.suggested_limit();
791 assert_eq!(suggested, 32_505_856); let msg = err.detailed_message();
797 assert!(
798 msg.contains("32505856"),
799 "Error message should show suggested limit in bytes (31 MB = 32505856)"
800 );
801 assert!(
802 msg.contains("31 MB"),
803 "Error message should show suggested limit in MB"
804 );
805 }
806 }
807}