1use 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
344pub 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
355pub 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}