Skip to main content

forge_agent/env/
git_state.rs

1//! The working tree as a handoff brief reads it: branch, `HEAD`, the dirty
2//! paths and the diff's stat.
3//!
4//! Three probes rather than the diff scanner, which walks every changed file
5//! and may ask GitHub about a pull request - a brief is written in the moment
6//! a session swaps and cannot wait on either.
7
8use std::path::Path;
9use std::time::Duration;
10
11use forge_primitives::HandoffGitState;
12
13use super::git_command;
14
15/// Each probe's own budget. A wedged git costs the brief that probe and no
16/// more; the swap is already under way when this runs.
17const PROBE_TIMEOUT: Duration = Duration::from_secs(5);
18
19/// How many dirty paths and diff lines the brief keeps. The brief is capped
20/// by the data it names, so the read is capped here rather than by a
21/// consumer.
22const DIRTY_CAP: usize = 20;
23const DIFF_CAP: usize = 12;
24
25/// Read the three facts. A directory that is not a repository answers with
26/// everything absent, which is a fact the brief states rather than an error.
27pub 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    // One probe says whether this is a repository at all: a directory that
31    // is not one answers nothing to all four, and the brief draws that.
32    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
52/// One git line of output, or `None` when it failed, timed out or was empty.
53async 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
64/// The last `cap` non-blank lines, and how many there were in all: the part
65/// of a long list that says what the successor will meet, and the count the
66/// brief states so the cap is not read as the whole.
67fn 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    /// A long list keeps its newest end, which is where the work in flight
84    /// is; the cap is a property of the brief rather than of git. The total
85    /// rides along so the brief can say what it is not showing.
86    #[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    /// Blank lines are not rows a brief draws, and a list shorter than the
97    /// cap is carried whole.
98    #[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}