Skip to main content

sqry_core/git/
subprocess.rs

1//! Subprocess-backed git implementation
2//!
3//! This backend executes git commands via subprocess for change detection.
4//! It provides robust error handling, timeout protection, and output size limits.
5
6use 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
12/// Maximum allowed git output size (10 MB by default)
13///
14/// Can be overridden via `SQRY_GIT_MAX_OUTPUT_SIZE` environment variable.
15/// Values are clamped to 1 MB - 100 MB range for security (P1-17).
16const DEFAULT_MAX_OUTPUT_SIZE: usize = 10 * 1024 * 1024; // 10 MB
17const MIN_MAX_OUTPUT_SIZE: usize = 1024 * 1024; // 1 MB minimum
18const MAX_MAX_OUTPUT_SIZE: usize = 100 * 1024 * 1024; // 100 MB maximum
19
20/// Default timeout for git commands (3 seconds)
21const DEFAULT_TIMEOUT_MS: u64 = 3000;
22
23/// Get maximum git output size, respecting environment variable override
24///
25/// Reads from `SQRY_GIT_MAX_OUTPUT_SIZE` environment variable.
26/// If not set or invalid, returns 10 MB default.
27/// Values are clamped between 1 MB and 100 MB for safety (P1-17).
28///
29/// # Security
30///
31/// This limit prevents `DoS` attacks from malicious repositories that could
32/// produce arbitrarily large git diffs or status output, leading to memory
33/// exhaustion and potential OOM kills.
34///
35/// # Examples
36///
37/// ```
38/// # use sqry_core::git::max_git_output_size;
39/// // Default behavior
40/// let size = max_git_output_size(); // 10 MB (10485760 bytes)
41/// assert_eq!(size, 10 * 1024 * 1024);
42/// ```
43///
44/// ```
45/// # use sqry_core::git::max_git_output_size;
46/// // With environment override
47/// unsafe { std::env::set_var("SQRY_GIT_MAX_OUTPUT_SIZE", "52428800"); } // 50 MB
48/// let size = max_git_output_size(); // 50 MB
49/// assert_eq!(size, 52428800);
50/// # unsafe { std::env::remove_var("SQRY_GIT_MAX_OUTPUT_SIZE"); }
51/// ```
52///
53/// ```
54/// # use sqry_core::git::max_git_output_size;
55/// // Clamping: value too low
56/// unsafe { std::env::set_var("SQRY_GIT_MAX_OUTPUT_SIZE", "500"); } // 500 bytes (too small)
57/// let size = max_git_output_size(); // Clamped to 1 MB minimum
58/// assert_eq!(size, 1024 * 1024);
59/// # unsafe { std::env::remove_var("SQRY_GIT_MAX_OUTPUT_SIZE"); }
60/// ```
61///
62/// ```
63/// # use sqry_core::git::max_git_output_size;
64/// // Malformed value
65/// unsafe { std::env::set_var("SQRY_GIT_MAX_OUTPUT_SIZE", "invalid"); }
66/// let size = max_git_output_size(); // Falls back to 10 MB default
67/// assert_eq!(size, 10 * 1024 * 1024);
68/// # unsafe { std::env::remove_var("SQRY_GIT_MAX_OUTPUT_SIZE"); }
69/// ```
70#[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/// Subprocess-backed git integration
80///
81/// Executes git commands via `Command::new("git")` with array arguments
82/// (no shell invocation) to prevent command injection.
83///
84/// # Security
85///
86/// - Command injection: Uses `Command::new("git")` with array args, never shell
87/// - Path traversal: All paths are canonicalized and validated
88/// - Resource exhaustion: Output limited to 10MB, commands timeout after 3s
89/// - Process cleanup: Hung processes killed with SIGTERM then SIGKILL
90#[derive(Debug, Clone, Copy, Default)]
91pub struct SubprocessGit;
92
93impl SubprocessGit {
94    /// Create a new subprocess git backend
95    #[must_use]
96    pub fn new() -> Self {
97        Self
98    }
99
100    /// Execute a git command with timeout and output size limit
101    ///
102    /// # Security
103    ///
104    /// - Uses `Command::new("git")` with array args (no shell)
105    /// - Limits output to configurable size (default 10MB, range 1MB-100MB)
106    /// - Kills process after timeout (default 3s)
107    /// - Captures stderr for error reporting
108    ///
109    /// # Visibility
110    ///
111    /// Public within crate to allow `RecencyIndex` and other git modules to use
112    /// the same safety mechanisms (output limits, timeouts).
113    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(); // P1-17: Configurable limit
116
117        // Build command (no shell invocation)
118        let mut cmd = Command::new("git");
119        cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
120
121        // Spawn process
122        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        // Wait with timeout
135        let start = std::time::Instant::now();
136        let result = loop {
137            match child.try_wait() {
138                Ok(Some(status)) => {
139                    // Process exited
140                    break status;
141                }
142                Ok(None) => {
143                    // Still running - check timeout
144                    if start.elapsed() >= timeout {
145                        // Timeout - kill process
146                        let _ = child.kill();
147                        // Duration beyond u64::MAX ms (~584 million years) is impossible; clamp to max
148                        let timeout_ms = timeout.as_millis().try_into().unwrap_or(u64::MAX);
149                        return Err(GitError::Timeout(timeout_ms));
150                    }
151                    // Sleep briefly to avoid busy-wait
152                    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        // Read stdout (with size limit)
167        // P1-17: Read +1 byte to detect if limit was exceeded
168        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            // P1-17: Hard error if limit exceeded (not silent truncation)
180            if stdout.len() > max_output_size {
181                return Err(GitError::OutputExceededLimit {
182                    limit_bytes: max_output_size,
183                    actual_bytes: stdout.len(), // Exact size (we read it all)
184                });
185            }
186        }
187
188        // Read stderr (for error messages)
189        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        // Check exit status
202        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        // Convert stdout to string
213        String::from_utf8(stdout)
214            .map_err(|e| GitError::InvalidOutput(format!("Git output is not valid UTF-8: {e}")))
215    }
216
217    /// Get timeout from environment variable (clamped to 100-60000ms)
218    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    /// Get rename similarity threshold from environment (clamped to 0-100)
226    #[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    /// Check if untracked files should be included (default: true)
235    #[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                // HEAD-less repo (no commits yet)
287                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        // Get current HEAD before querying status (to avoid race)
299        let head = self.head(root)?;
300
301        // Convert path to string (needed for lifetime)
302        let root_str = root.display().to_string();
303
304        // Execute git status with null-terminated output
305        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        // Parse porcelain output
329        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        // Get current HEAD before querying diff (to avoid race)
341        let head = self.head(root)?;
342
343        // Build diff range (baseline..HEAD)
344        let range = format!("{baseline}..HEAD");
345
346        // Execute git diff with null-terminated output
347        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, // range before path separator per git syntax
358                "--",
359            ],
360            Self::get_timeout_ms(),
361        )?;
362
363        // Parse diff output
364        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,         // Blame not yet implemented
372            supports_time_travel: false,   // Time-travel not yet implemented
373            supports_history_index: false, // Historical indexing not yet implemented
374        }
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    /// Helper to create a temporary git repo for testing
409    fn create_test_repo() -> tempfile::TempDir {
410        let tmpdir = tempfile::tempdir().unwrap();
411        let path = tmpdir.path();
412
413        // Initialize git repo
414        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        // Configure git user for commits
422        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        // Disable GPG signing to avoid global config interference
443        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        // Disable CRLF conversion for consistent line endings across platforms
454        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); // No commits yet
507    }
508
509    #[test]
510    fn test_head_with_commit() {
511        let tmpdir = create_test_repo();
512        let path = tmpdir.path();
513
514        // Create and commit a file
515        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); // SHA-1 hash length
536    }
537
538    #[test]
539    fn test_uncommitted_empty() {
540        let tmpdir = create_test_repo();
541        let path = tmpdir.path();
542
543        // Create initial commit
544        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        // Create initial commit
573        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        // Modify file
588        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        // Create initial commit
605        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        // Add untracked file
618        fs::write(path.join("new.txt"), "new").unwrap();
619
620        let backend = SubprocessGit::new();
621
622        // With untracked
623        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        // Without untracked
629        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        // Create first commit
641        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        // Get baseline commit
656        let backend = SubprocessGit::new();
657        let baseline = backend.head(path).unwrap().unwrap();
658
659        // Create second commit
660        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        // Check changes since baseline
675        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); // HEAD moved
683    }
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    // P1-17: Tests for max_git_output_size() configuration function
696    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); // 10 MB
707        }
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"); // 50 MB
714            }
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"); // Below min (1 MB)
726            }
727            assert_eq!(max_git_output_size(), 1024 * 1024); // Clamped to 1 MB
728            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"); // Above max (100 MB)
738            }
739            assert_eq!(max_git_output_size(), 100 * 1024 * 1024); // Clamped to 100 MB
740            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); // Falls back to default
752            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,  // 10 MB
761                actual_bytes: 15 * 1024 * 1024, // 15 MB
762            };
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,  // 10 MB
787                actual_bytes: 15 * 1024 * 1024, // 15 MB
788            };
789
790            let suggested = err.suggested_limit();
791            // Should suggest 2× actual, rounded up to nearest MB
792            // 15MB × 2 = 30MB = 31457280 bytes
793            // Rounded up to nearest MB: (30/1)+1 = 31MB = 32505856 bytes
794            assert_eq!(suggested, 32_505_856); // 31 MB in bytes
795
796            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}