Skip to main content

remem/
git_util.rs

1//! Minimal Git CLI helpers used by capture identity. Pure parsers remain
2//! separate from the shared bounded subprocess executor; recursive test
3//! helpers exercise large-output, timeout, descendant, and cleanup paths.
4
5use std::collections::HashSet;
6use std::io::{self, Read};
7#[cfg(unix)]
8use std::os::unix::process::CommandExt;
9use std::path::{Path, PathBuf};
10use std::process::{Child, Command, Output, Stdio};
11use std::sync::mpsc::{self, Receiver, RecvTimeoutError};
12use std::thread::{self, JoinHandle};
13use std::time::{Duration, Instant};
14
15use anyhow::{anyhow, bail, Context, Result};
16
17const COMMIT_METADATA_FORMAT: &str = "--format=%H%x00%h%x00%at%x00%s";
18pub(crate) const GIT_PROBE_TIMEOUT: Duration = Duration::from_secs(2);
19const COMMAND_POLL_INTERVAL: Duration = Duration::from_millis(10);
20const COMMAND_TERMINATE_GRACE: Duration = Duration::from_millis(100);
21const COMMAND_CLEANUP_TIMEOUT: Duration = Duration::from_millis(500);
22
23struct DrainWorker {
24    label: &'static str,
25    receiver: Receiver<io::Result<Vec<u8>>>,
26    handle: Option<JoinHandle<()>>,
27    bytes: Option<Vec<u8>>,
28    error: Option<String>,
29}
30
31impl DrainWorker {
32    fn spawn<R>(label: &'static str, mut reader: R) -> Self
33    where
34        R: Read + Send + 'static,
35    {
36        let (sender, receiver) = mpsc::channel();
37        let handle = thread::spawn(move || {
38            let mut bytes = Vec::new();
39            let result = reader.read_to_end(&mut bytes).map(|_| bytes);
40            let send_result = sender.send(result);
41            drop(send_result);
42        });
43        Self {
44            label,
45            receiver,
46            handle: Some(handle),
47            bytes: None,
48            error: None,
49        }
50    }
51
52    fn await_until(&mut self, deadline: Instant) -> Result<()> {
53        if self.bytes.is_some() {
54            return Ok(());
55        }
56        if let Some(error) = &self.error {
57            bail!("{error}");
58        }
59        let remaining = deadline.saturating_duration_since(Instant::now());
60        let received = if remaining.is_zero() {
61            self.receiver.try_recv().map_err(|error| match error {
62                mpsc::TryRecvError::Empty => anyhow!("{} reader exceeded deadline", self.label),
63                mpsc::TryRecvError::Disconnected => {
64                    anyhow!("{} reader disconnected", self.label)
65                }
66            })?
67        } else {
68            self.receiver
69                .recv_timeout(remaining)
70                .map_err(|error| match error {
71                    RecvTimeoutError::Timeout => anyhow!("{} reader exceeded deadline", self.label),
72                    RecvTimeoutError::Disconnected => {
73                        anyhow!("{} reader disconnected", self.label)
74                    }
75                })?
76        };
77        match received {
78            Ok(bytes) => self.bytes = Some(bytes),
79            Err(error) => {
80                let message = format!("{} pipe read failed: {error}", self.label);
81                self.error = Some(message.clone());
82                bail!("{message}");
83            }
84        }
85        let Some(handle) = self.handle.take() else {
86            let message = format!("{} reader lost its join handle", self.label);
87            self.error = Some(message.clone());
88            bail!("{message}");
89        };
90        if handle.join().is_err() {
91            let message = format!("{} reader thread panicked", self.label);
92            self.error = Some(message.clone());
93            bail!("{message}");
94        }
95        Ok(())
96    }
97
98    fn take_bytes(&mut self) -> Vec<u8> {
99        self.bytes.take().unwrap_or_default()
100    }
101}
102
103fn await_drains(
104    stdout: &mut DrainWorker,
105    stderr: &mut DrainWorker,
106    deadline: Instant,
107) -> Result<()> {
108    stdout.await_until(deadline)?;
109    stderr.await_until(deadline)?;
110    Ok(())
111}
112
113fn signal_process_group(process_group_id: u32, signal: libc::c_int) -> Result<()> {
114    #[cfg(unix)]
115    {
116        let result = unsafe { libc::kill(-(process_group_id as libc::pid_t), signal) };
117        if result == 0 {
118            return Ok(());
119        }
120        let error = io::Error::last_os_error();
121        if error.raw_os_error() == Some(libc::ESRCH) {
122            return Ok(());
123        }
124        Err(error).context("signal Git process group")
125    }
126    #[cfg(not(unix))]
127    {
128        let _ = process_group_id;
129        let _ = signal;
130        Ok(())
131    }
132}
133
134fn reap_child_until(child: &mut Child, deadline: Instant) -> Result<()> {
135    loop {
136        match child.try_wait().context("poll Git child during cleanup")? {
137            Some(_) => return Ok(()),
138            None if Instant::now() < deadline => thread::sleep(COMMAND_POLL_INTERVAL),
139            None => bail!("Git child reap exceeded cleanup deadline"),
140        }
141    }
142}
143
144fn cleanup_child(child: &mut Child, direct_reaped: bool, deadline: Instant) -> Vec<String> {
145    let mut failures = Vec::new();
146    #[cfg(unix)]
147    {
148        if let Err(error) = signal_process_group(child.id(), libc::SIGTERM) {
149            failures.push(error.to_string());
150        }
151        let grace_deadline = (Instant::now() + COMMAND_TERMINATE_GRACE).min(deadline);
152        if !direct_reaped {
153            loop {
154                match child.try_wait() {
155                    Ok(Some(_)) => break,
156                    Ok(None) if Instant::now() < grace_deadline => {
157                        thread::sleep(COMMAND_POLL_INTERVAL)
158                    }
159                    Ok(None) => break,
160                    Err(error) => {
161                        failures.push(format!("poll Git child after TERM failed: {error}"));
162                        break;
163                    }
164                }
165            }
166        } else if Instant::now() < grace_deadline {
167            thread::sleep(grace_deadline.saturating_duration_since(Instant::now()));
168        }
169        if let Err(error) = signal_process_group(child.id(), libc::SIGKILL) {
170            failures.push(error.to_string());
171        }
172    }
173    #[cfg(not(unix))]
174    if !direct_reaped {
175        if let Err(error) = child.kill() {
176            failures.push(format!("kill Git child failed: {error}"));
177        }
178    }
179    if !direct_reaped {
180        if let Err(error) = reap_child_until(child, deadline) {
181            failures.push(error.to_string());
182        }
183    }
184    failures
185}
186
187fn lifecycle_error(
188    primary: impl Into<String>,
189    child: &mut Child,
190    direct_reaped: bool,
191    stdout: &mut DrainWorker,
192    stderr: &mut DrainWorker,
193) -> anyhow::Error {
194    let cleanup_deadline = Instant::now() + COMMAND_CLEANUP_TIMEOUT;
195    let mut failures = cleanup_child(child, direct_reaped, cleanup_deadline);
196    if let Err(error) = stdout.await_until(cleanup_deadline) {
197        failures.push(error.to_string());
198    }
199    if let Err(error) = stderr.await_until(cleanup_deadline) {
200        failures.push(error.to_string());
201    }
202    let primary = primary.into();
203    if failures.is_empty() {
204        anyhow!(primary)
205    } else {
206        anyhow!("{primary}; cleanup: {}", failures.join("; "))
207    }
208}
209
210pub(crate) fn command_output_with_timeout(command: Command, timeout: Duration) -> Result<Output> {
211    command_output_with_timeout_inner(command, timeout, false)
212}
213
214fn command_output_with_timeout_inner(
215    mut command: Command,
216    timeout: Duration,
217    inject_poll_error: bool,
218) -> Result<Output> {
219    command.stdout(Stdio::piped()).stderr(Stdio::piped());
220    #[cfg(unix)]
221    command.process_group(0);
222    let mut child = command.spawn().context("spawn bounded command")?;
223    let stdout = child
224        .stdout
225        .take()
226        .context("bounded command omitted stdout pipe");
227    let stderr = child
228        .stderr
229        .take()
230        .context("bounded command omitted stderr pipe");
231    let (stdout, stderr) = match (stdout, stderr) {
232        (Ok(stdout), Ok(stderr)) => (stdout, stderr),
233        (stdout, stderr) => {
234            let cleanup_deadline = Instant::now() + COMMAND_CLEANUP_TIMEOUT;
235            let failures = cleanup_child(&mut child, false, cleanup_deadline);
236            let detail = stdout
237                .err()
238                .or_else(|| stderr.err())
239                .map(|error| error.to_string())
240                .unwrap_or_else(|| "missing output pipe".to_string());
241            if failures.is_empty() {
242                bail!("{detail}");
243            }
244            bail!("{detail}; cleanup: {}", failures.join("; "));
245        }
246    };
247    let mut stdout_worker = DrainWorker::spawn("stdout", stdout);
248    let mut stderr_worker = DrainWorker::spawn("stderr", stderr);
249    let deadline = Instant::now() + timeout;
250    let mut inject_poll_error = inject_poll_error;
251
252    loop {
253        let polled = if inject_poll_error {
254            inject_poll_error = false;
255            Err(io::Error::other("injected poll error"))
256        } else {
257            child.try_wait()
258        };
259        match polled {
260            Ok(Some(status)) => {
261                if let Err(error) = await_drains(&mut stdout_worker, &mut stderr_worker, deadline) {
262                    return Err(lifecycle_error(
263                        format!("collect bounded command output: {error}"),
264                        &mut child,
265                        true,
266                        &mut stdout_worker,
267                        &mut stderr_worker,
268                    ));
269                }
270                return Ok(Output {
271                    status,
272                    stdout: stdout_worker.take_bytes(),
273                    stderr: stderr_worker.take_bytes(),
274                });
275            }
276            Ok(None) if Instant::now() < deadline => thread::sleep(COMMAND_POLL_INTERVAL),
277            Ok(None) => {
278                return Err(lifecycle_error(
279                    format!("bounded command timed out after {} ms", timeout.as_millis()),
280                    &mut child,
281                    false,
282                    &mut stdout_worker,
283                    &mut stderr_worker,
284                ));
285            }
286            Err(error) => {
287                return Err(lifecycle_error(
288                    format!("poll bounded command failed: {error}"),
289                    &mut child,
290                    false,
291                    &mut stdout_worker,
292                    &mut stderr_worker,
293                ));
294            }
295        }
296    }
297}
298
299#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
300#[serde(rename_all = "snake_case")]
301pub enum GitEvidenceKind {
302    ObservedCommit,
303    TerminalSnapshot,
304}
305
306impl GitEvidenceKind {
307    pub fn as_str(self) -> &'static str {
308        match self {
309            Self::ObservedCommit => "observed_commit",
310            Self::TerminalSnapshot => "terminal_snapshot",
311        }
312    }
313}
314
315#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
316pub struct GitCommitMetadata {
317    pub repo_path: String,
318    pub sha: String,
319    pub short_sha: String,
320    pub branch: Option<String>,
321    pub message: Option<String>,
322    pub authored_at_epoch: Option<i64>,
323    pub changed_files: Vec<String>,
324}
325
326#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
327pub struct GitCommitEvidence {
328    pub kind: GitEvidenceKind,
329    pub metadata: GitCommitMetadata,
330    pub locator: Option<String>,
331}
332
333impl GitCommitMetadata {
334    pub fn matches_sha(&self, sha: &str) -> bool {
335        let needle = sha.trim();
336        !needle.is_empty()
337            && (self.sha == needle
338                || self.short_sha == needle
339                || self.sha.starts_with(needle)
340                || self.short_sha.starts_with(needle))
341    }
342}
343
344/// Parse the output of `git rev-parse --show-toplevel`. Returns `None` for
345/// empty or whitespace-only input so callers can fall back cleanly.
346pub fn parse_toplevel_output(stdout: &str) -> Option<PathBuf> {
347    let trimmed = stdout.trim();
348    if trimmed.is_empty() {
349        None
350    } else {
351        Some(PathBuf::from(trimmed))
352    }
353}
354
355/// Spawn `git rev-parse --show-toplevel` in `cwd`. Returns `None` when git
356/// is unavailable, the directory is outside any git worktree, or the output
357/// is empty. Single subprocess, no network.
358pub fn resolve_toplevel(cwd: &Path) -> Option<PathBuf> {
359    let output = git_output_soft(cwd, &["rev-parse", "--show-toplevel"])?;
360    if !output.status.success() {
361        return None;
362    }
363    parse_toplevel_output(&String::from_utf8_lossy(&output.stdout))
364}
365
366fn resolve_toplevel_required(cwd: &Path) -> Result<PathBuf> {
367    let stdout = git_stdout_required_path(cwd, &["rev-parse", "--show-toplevel"])?;
368    parse_toplevel_output(&stdout)
369        .with_context(|| format!("git returned an empty repository root in {}", cwd.display()))
370}
371
372pub(crate) fn git_output_soft(cwd: &Path, args: &[&str]) -> Option<Output> {
373    let mut command = Command::new("git");
374    command.args(args).current_dir(cwd);
375    match command_output_with_timeout(command, GIT_PROBE_TIMEOUT) {
376        Ok(output) => Some(output),
377        Err(error) => {
378            crate::log::error(
379                "git",
380                &format!(
381                    "git {} lifecycle failure in {}: {error:#}",
382                    args.join(" "),
383                    cwd.display()
384                ),
385            );
386            None
387        }
388    }
389}
390
391pub fn parse_changed_files_output(stdout: &str) -> Vec<String> {
392    let mut seen = HashSet::new();
393    let mut files = Vec::new();
394    for file in stdout
395        .lines()
396        .map(str::trim)
397        .filter(|line| !line.is_empty())
398    {
399        if seen.insert(file.to_string()) {
400            files.push(file.to_string());
401        }
402    }
403    files
404}
405
406pub fn parse_authored_epoch_output(stdout: &str) -> Option<i64> {
407    stdout.trim().parse::<i64>().ok()
408}
409
410pub fn parse_branch_output(stdout: &str) -> Option<String> {
411    let branch = stdout.trim();
412    if branch.is_empty() || branch == "HEAD" {
413        None
414    } else {
415        Some(branch.to_string())
416    }
417}
418
419pub fn short_sha_for(sha: &str) -> String {
420    sha.chars().take(7).collect()
421}
422
423pub fn detect_commit_metadata(cwd: &str) -> Result<Option<GitCommitMetadata>> {
424    let Some(_) = resolve_toplevel(Path::new(cwd)) else {
425        return Ok(None);
426    };
427    let sha = git_stdout_required(cwd, &["rev-parse", "--verify", "HEAD^{commit}"])?;
428    let sha = sha.trim().to_ascii_lowercase();
429    if sha.is_empty() {
430        bail!("git returned an empty HEAD commit in {cwd}");
431    }
432
433    let metadata = resolve_commit_metadata(cwd, &sha)?;
434    let final_sha = git_stdout_required(cwd, &["rev-parse", "--verify", "HEAD^{commit}"])?;
435    if !final_sha.trim().eq_ignore_ascii_case(&sha) {
436        bail!("git HEAD changed while capturing commit metadata in {cwd}");
437    }
438    Ok(Some(metadata))
439}
440
441pub fn resolve_commit_metadata(cwd: &str, commitish: &str) -> Result<GitCommitMetadata> {
442    let repo_path = resolve_toplevel_required(Path::new(cwd))
443        .with_context(|| format!("resolve required Git repository in {cwd}"))?;
444    let commit_ref = format!("{}^{{commit}}", commitish.trim());
445    let sha = git_stdout_required(cwd, &["rev-parse", "--verify", &commit_ref])?;
446    let sha = sha.trim().to_ascii_lowercase();
447    if sha.is_empty() {
448        bail!("git returned an empty commit for evidence {commitish}");
449    }
450    let raw_metadata = git_stdout_required(cwd, &["show", "-s", COMMIT_METADATA_FORMAT, &sha])?;
451    let mut fields = raw_metadata.trim_end_matches(['\r', '\n']).splitn(4, '\0');
452    let resolved_sha = fields
453        .next()
454        .unwrap_or_default()
455        .trim()
456        .to_ascii_lowercase();
457    let short_sha = fields
458        .next()
459        .unwrap_or_default()
460        .trim()
461        .to_ascii_lowercase();
462    let authored_at_epoch = fields
463        .next()
464        .and_then(parse_authored_epoch_output)
465        .context("git commit metadata omitted authored epoch")?;
466    let message = fields
467        .next()
468        .map(str::trim)
469        .filter(|value| !value.is_empty())
470        .map(str::to_string);
471    if resolved_sha != sha || short_sha.is_empty() || !sha.starts_with(&short_sha) {
472        bail!("git returned inconsistent metadata for captured commit {sha}");
473    }
474
475    let current_head = git_stdout_required(cwd, &["rev-parse", "--verify", "HEAD^{commit}"])?;
476    let branch = if current_head.trim().eq_ignore_ascii_case(&sha) {
477        parse_branch_output(&git_stdout_required(cwd, &["branch", "--show-current"])?)
478    } else {
479        None
480    };
481    let changed_files = parse_changed_files_output(&git_stdout_required(
482        cwd,
483        &[
484            "diff-tree",
485            "--root",
486            "-m",
487            "--no-commit-id",
488            "--name-only",
489            "-r",
490            &sha,
491        ],
492    )?);
493    Ok(sanitize_commit_metadata(GitCommitMetadata {
494        repo_path: repo_path.to_string_lossy().to_string(),
495        sha,
496        short_sha,
497        branch,
498        message,
499        authored_at_epoch: Some(authored_at_epoch),
500        changed_files,
501    }))
502}
503
504pub fn sanitize_commit_metadata(mut metadata: GitCommitMetadata) -> GitCommitMetadata {
505    metadata.branch = metadata
506        .branch
507        .map(|value| crate::adapter::common::redact_sensitive_text(&value));
508    metadata.message = metadata
509        .message
510        .map(|value| crate::adapter::common::redact_sensitive_text(&value));
511    metadata.changed_files = metadata
512        .changed_files
513        .into_iter()
514        .map(|value| crate::adapter::common::redact_sensitive_text(&value))
515        .collect();
516    metadata
517}
518
519fn git_stdout_required(cwd: &str, args: &[&str]) -> Result<String> {
520    git_stdout_required_path(Path::new(cwd), args)
521}
522
523fn git_stdout_required_path(cwd: &Path, args: &[&str]) -> Result<String> {
524    let mut command = Command::new("git");
525    command.args(args).current_dir(cwd);
526    let output = command_output_with_timeout(command, GIT_PROBE_TIMEOUT)
527        .with_context(|| format!("run required git {} in {}", args.join(" "), cwd.display()))?;
528    if !output.status.success() {
529        let stderr_text = String::from_utf8_lossy(&output.stderr);
530        let stderr = crate::db::truncate_str(&stderr_text, 400);
531        bail!(
532            "git {} failed in {} with status {}: {stderr}",
533            args.join(" "),
534            cwd.display(),
535            output.status
536        );
537    }
538    String::from_utf8(output.stdout).with_context(|| {
539        format!(
540            "git {} returned non-UTF-8 output in {}",
541            args.join(" "),
542            cwd.display()
543        )
544    })
545}
546
547#[cfg(test)]
548mod tests {
549    use super::*;
550    use std::io::Write;
551    use std::sync::atomic::{AtomicU64, Ordering};
552
553    static NEXT_PID_FILE: AtomicU64 = AtomicU64::new(1);
554
555    #[test]
556    fn parsers_preserve_git_identity_contracts() {
557        assert_eq!(
558            parse_toplevel_output("/Users/foo/project\n"),
559            Some(PathBuf::from("/Users/foo/project"))
560        );
561        assert_eq!(parse_toplevel_output(""), None);
562        assert_eq!(parse_toplevel_output("   \t\n  "), None);
563        assert_eq!(
564            parse_changed_files_output("src/lib.rs\n\nREADME.md\nsrc/lib.rs\n"),
565            vec!["src/lib.rs".to_string(), "README.md".to_string()]
566        );
567        assert_eq!(
568            parse_authored_epoch_output("1700000000\n"),
569            Some(1700000000)
570        );
571        assert_eq!(parse_authored_epoch_output("not-an-epoch"), None);
572        assert_eq!(
573            parse_branch_output("feature/x\n"),
574            Some("feature/x".to_string())
575        );
576        assert_eq!(parse_branch_output("HEAD\n"), None);
577        assert_eq!(parse_branch_output("\n"), None);
578    }
579
580    #[test]
581    fn commit_metadata_matches_only_non_empty_sha_prefixes() {
582        let metadata = GitCommitMetadata {
583            repo_path: "/repo".to_string(),
584            sha: "abcdef1234567890".to_string(),
585            short_sha: "abcdef1".to_string(),
586            branch: Some("main".to_string()),
587            message: None,
588            authored_at_epoch: None,
589            changed_files: Vec::new(),
590        };
591
592        assert!(metadata.matches_sha("abcdef1234567890"));
593        assert!(metadata.matches_sha("abcdef1"));
594        assert!(metadata.matches_sha("abcdef"));
595        assert!(!metadata.matches_sha(""));
596        assert!(!metadata.matches_sha("fedcba"));
597    }
598
599    fn helper_command(mode: &str) -> Command {
600        let mut command = Command::new(std::env::current_exe().expect("test executable"));
601        command
602            .args([
603                "--exact",
604                "git_util::tests::bounded_command_test_helper",
605                "--nocapture",
606            ])
607            .env("REMEM_BOUNDED_COMMAND_HELPER", mode);
608        command
609    }
610
611    #[test]
612    fn bounded_command_test_helper() {
613        let Ok(mode) = std::env::var("REMEM_BOUNDED_COMMAND_HELPER") else {
614            return;
615        };
616        match mode.as_str() {
617            "sleep" => thread::sleep(Duration::from_secs(5)),
618            "large" => {
619                let bytes = vec![b'x'; 256 * 1024];
620                std::io::stdout().write_all(&bytes).expect("write stdout");
621                std::io::stderr().write_all(&bytes).expect("write stderr");
622            }
623            "descendant" => {
624                let child = helper_command("sleep")
625                    .stdout(Stdio::inherit())
626                    .stderr(Stdio::inherit())
627                    .spawn()
628                    .expect("spawn descendant");
629                if let Ok(path) = std::env::var("REMEM_DESCENDANT_PID_FILE") {
630                    std::fs::write(path, child.id().to_string()).expect("write descendant pid");
631                }
632                std::mem::forget(child);
633            }
634            other => panic!("unknown helper mode {other}"),
635        }
636    }
637
638    fn assert_descendant_cleanup() -> anyhow::Error {
639        let pid_file = std::env::temp_dir().join(format!(
640            "remem-git-util-descendant-{}-{}",
641            std::process::id(),
642            NEXT_PID_FILE.fetch_add(1, Ordering::Relaxed)
643        ));
644        if pid_file.exists() {
645            std::fs::remove_file(&pid_file).expect("remove stale pid file");
646        }
647        let mut command = helper_command("descendant");
648        command.env("REMEM_DESCENDANT_PID_FILE", &pid_file);
649        let started = Instant::now();
650        let error = command_output_with_timeout(command, Duration::from_millis(150))
651            .expect_err("inherited pipes must exceed the command deadline");
652        assert!(started.elapsed() < Duration::from_secs(2));
653        let pid: u32 = std::fs::read_to_string(&pid_file)
654            .expect("read descendant pid")
655            .parse()
656            .expect("parse descendant pid");
657        std::fs::remove_file(pid_file).expect("remove pid file");
658        #[cfg(unix)]
659        {
660            let pid = pid as libc::pid_t;
661            for _ in 0..50 {
662                if unsafe { libc::kill(pid, 0) } != 0 {
663                    return error;
664                }
665                thread::sleep(Duration::from_millis(10));
666            }
667            panic!("descendant process {pid} survived process-group cleanup");
668        }
669        #[cfg(not(unix))]
670        {
671            let _ = pid;
672            error
673        }
674    }
675
676    #[cfg(unix)]
677    #[test]
678    fn command_output_with_timeout_kills_process_group() {
679        let error = assert_descendant_cleanup();
680        assert!(error.to_string().contains("reader exceeded deadline"));
681    }
682
683    #[test]
684    fn command_output_with_timeout_bounds_reader_completion() {
685        let error = assert_descendant_cleanup();
686        assert!(!error.to_string().contains("cleanup:"));
687    }
688
689    #[test]
690    fn command_output_with_timeout_cleans_up_after_poll_error() {
691        let started = Instant::now();
692        let error = command_output_with_timeout_inner(
693            helper_command("sleep"),
694            Duration::from_secs(2),
695            true,
696        )
697        .expect_err("injected poll error must fail");
698        assert!(error.to_string().contains("injected poll error"));
699        assert!(started.elapsed() < Duration::from_secs(2));
700    }
701
702    #[test]
703    fn command_output_with_timeout_drains_large_output() {
704        let output = command_output_with_timeout(helper_command("large"), Duration::from_secs(2))
705            .expect("large output must drain concurrently");
706        assert!(output.status.success());
707        assert!(output.stdout.len() >= 256 * 1024);
708        assert!(output.stderr.len() >= 256 * 1024);
709    }
710
711    #[test]
712    fn required_toplevel_preserves_timeout_context() {
713        let missing = Path::new("/remem-gh864-definitely-missing");
714        let error = resolve_toplevel_required(missing).expect_err("missing cwd must fail");
715        let message = format!("{error:#}");
716        assert!(message.contains("rev-parse --show-toplevel"));
717        assert!(message.contains(missing.to_string_lossy().as_ref()));
718    }
719
720    #[test]
721    fn git_metadata_commands_use_bounded_executor() {
722        let repo = Path::new(env!("CARGO_MANIFEST_DIR"));
723        let metadata = resolve_commit_metadata(repo.to_str().expect("UTF-8 repo"), "HEAD")
724            .expect("repository HEAD metadata");
725        assert!(!metadata.sha.is_empty());
726        assert_eq!(GIT_PROBE_TIMEOUT, Duration::from_secs(2));
727    }
728}