forge_agent/env/
git_state.rs1use std::path::Path;
9use std::time::Duration;
10
11use forge_primitives::HandoffGitState;
12
13use super::git_command;
14
15const PROBE_TIMEOUT: Duration = Duration::from_secs(5);
18
19const DIRTY_CAP: usize = 20;
23const DIFF_CAP: usize = 12;
24
25pub async fn probe(cwd: &Path) -> HandoffGitState {
28 let branch = run(cwd, &["rev-parse", "--abbrev-ref", "HEAD"]).await;
29 let head = run(cwd, &["rev-parse", "--short", "HEAD"]).await;
30 let Some(branch) = branch else {
33 return HandoffGitState::default();
34 };
35 let (dirty, dirty_total) = run(cwd, &["status", "--porcelain"])
36 .await
37 .map(|out| capped_lines(&out, DIRTY_CAP))
38 .unwrap_or_default();
39 let (diff, _) = run(cwd, &["diff", "--stat"])
40 .await
41 .map(|out| capped_lines(&out, DIFF_CAP))
42 .unwrap_or_default();
43 HandoffGitState {
44 branch: Some(branch.trim().to_owned()),
45 head: head.map(|out| out.trim().to_owned()),
46 dirty,
47 dirty_total: u32::try_from(dirty_total).unwrap_or(u32::MAX),
48 diff,
49 }
50}
51
52async fn run(cwd: &Path, args: &[&str]) -> Option<String> {
54 let mut command = git_command::tokio_git_command(cwd);
55 command.arg("-C").arg(cwd).args(args).kill_on_drop(true);
56 let output = tokio::time::timeout(PROBE_TIMEOUT, command.output()).await.ok()?.ok()?;
57 if !output.status.success() {
58 return None;
59 }
60 let text = String::from_utf8_lossy(&output.stdout).into_owned();
61 (!text.trim().is_empty()).then_some(text)
62}
63
64fn capped_lines(output: &str, cap: usize) -> (Vec<String>, usize) {
68 let lines: Vec<String> = output
69 .lines()
70 .map(str::trim_end)
71 .filter(|line| !line.trim().is_empty())
72 .map(str::to_owned)
73 .collect();
74 let total = lines.len();
75 let keep = total.saturating_sub(cap);
76 (lines.into_iter().skip(keep).collect(), total)
77}
78
79#[cfg(test)]
80mod tests {
81 use super::capped_lines;
82
83 #[test]
87 fn a_long_list_keeps_its_newest_lines_and_its_total() {
88 let output = (0..30).map(|n| format!("line {n}")).collect::<Vec<_>>().join("\n");
89 let (kept, total) = capped_lines(&output, 12);
90 assert_eq!(kept.len(), 12, "the cap is the cap");
91 assert_eq!(total, 30, "and the total is the uncapped count");
92 assert_eq!(kept.first().map(String::as_str), Some("line 18"), "the newest end");
93 assert_eq!(kept.last().map(String::as_str), Some("line 29"), "through the last line");
94 }
95
96 #[test]
99 fn blank_lines_are_dropped_and_a_short_list_is_whole() {
100 let (kept, total) = capped_lines("a\n\n \nb\n", 5);
101 assert_eq!(kept, vec!["a".to_owned(), "b".to_owned()]);
102 assert_eq!(total, 2, "the total counts rows, not blank lines");
103 }
104}