Skip to main content

forge_primitives/
git_diff.rs

1//! Git diff snapshot types.
2//!
3//! Type-only - the scanner that produces these lives in
4//! `forge_agent::env::git_diff::scan`. The four shapes cross every
5//! forge-* crate boundary (agent produces, workspace mediates, a view
6//! renders), so they live in primitives per the crate placement guide.
7//! Sibling git types (`GitBranch`, `GitPrInfo`, `GitIssueRef`) live in
8//! [`crate::git`].
9
10use crate::git::{GitBranch, GitIssueRef, GitPrInfo};
11use serde::{Deserialize, Serialize};
12
13/// Per-layer scan state. Replaces the earlier parallel-bool encoding
14/// (`Option<T>` paired with a `*_scan_ok: bool`) so the three legal
15/// states are unrepresentable as illegal combinations.
16///
17/// - `Clean` - the layer ran cleanly with nothing to report (clean
18///   worktree, branch at merge-base, on the default branch, detached
19///   HEAD with no base, or not in a repo at all).
20/// - `Populated(T)` - the layer ran and produced data the renderer
21///   should show.
22/// - `ScanFailed` - the underlying subprocess crashed, timed out, or
23///   exceeded the stdout cap. The renderer surfaces a "(scan failed)"
24///   stub so the user sees the failure rather than a silent
25///   clean-tree render.
26///
27/// Serialised as the socket's record shape for the bounded content read,
28/// whose layers answer in the same three states the scanner's do.
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31pub enum LayerState<T> {
32    Clean,
33    Populated(T),
34    ScanFailed,
35}
36
37impl<T> LayerState<T> {
38    /// True when the layer has data the renderer should show.
39    pub fn is_populated(&self) -> bool {
40        matches!(self, LayerState::Populated(_))
41    }
42}
43
44/// Whether a scan may reach the network for its PR block.
45///
46/// The lookup is one `gh api` call: a full TLS + API round trip measured at
47/// 0.9 s on this machine, and it sat on the record a client waits for. A read
48/// somebody is waiting on serves the PR it already holds and leaves the
49/// refresh to the seat's own loop, which nobody waits on.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum PrRefresh {
52    /// A cache past its window is looked up again.
53    Fetch,
54    /// A cache past its window is served as it stands for the branch and sha
55    /// it belongs to - anything else answers nothing - and is never looked up.
56    ServeCached,
57}
58
59/// Three-state classification of the scan's repo gate. Replaces the
60/// earlier `in_repo` + `scanner_ok` bool pair so the illegal
61/// "in a repo but the scanner failed" combination is unrepresentable.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum RepoGate {
64    /// `git rev-parse` confirmed the cwd is inside a work tree.
65    InRepo,
66    /// The cwd is not inside a work tree, and the scanner's
67    /// repo-existence probe found no `.git` to contradict that.
68    NotARepo,
69    /// The rev-parse gate yielded no branch and the repo-existence probe
70    /// did not rule a repo out: a timeout or oversize read, a checkout
71    /// git refuses, or a git that would not run. The renderer surfaces a
72    /// "scanner unhealthy" banner distinct from a legitimate non-repo
73    /// cwd.
74    ScannerFailed,
75}
76
77/// Snapshot of one project's git state, suitable for rendering in
78/// the Inspector pane's GIT section. Branch info is folded in here
79/// so a single polled scan covers everything the renderer needs.
80///
81/// `worktree` and `branch_ahead` are independent: a worker on a
82/// topic branch with uncommitted edits surfaces both layers, while
83/// the lead on `main` with a clean tree surfaces neither.
84///
85/// `repo_gate` makes the repo-vs-scanner-failure states exclusive at
86/// the type level. The remaining cross-field invariant the scanner
87/// upholds: `branch_ahead` is only `Populated` when `default_branch`
88/// resolved (the renderer's tuple-match relies on it), and the layer
89/// states / `pr` / `closes` / `default_branch` stay empty unless
90/// `repo_gate` is [`RepoGate::InRepo`].
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct GitDiffSnapshot {
93    /// Current branch (Named / Detached / NoRepo / Unknown).
94    pub branch: GitBranch,
95    /// HEAD's newest pushed ancestor at scan time: the first sha of
96    /// HEAD's first-parent history the remote already tracks. `None`
97    /// when nothing is pushed, the walk failed, or the PR lookup was
98    /// skipped (default branch / detached). The PR cache key rides on
99    /// this rather than the branch name, so a worktree whose local
100    /// branch differs from the PR's head ref still resolves its PR.
101    pub pushed_sha: Option<String>,
102    /// Wall-clock stamp of the last PR-lookup attempt, `Some` even
103    /// when the attempt found nothing or failed - the refetch timer
104    /// must rate-limit failed lookups too or a persistent `gh`
105    /// failure hammers every 10 s scan. `None` when the PR side never
106    /// ran (non-repo, default branch, detached).
107    pub pr_fetched_at: Option<std::time::SystemTime>,
108    /// Resolved default branch (e.g. `main`, `master`), if known.
109    /// `None` when `origin/HEAD` is missing AND neither `main` nor
110    /// `master` exists as a local ref.
111    pub default_branch: Option<String>,
112    /// Repo gate: in-repo, not-a-repo, or scanner-failed. Replaces the
113    /// old `in_repo` + `scanner_ok` bool pair so the illegal
114    /// "in a repo but the scanner failed" combination can't be built.
115    pub repo_gate: RepoGate,
116    /// Layer 1: uncommitted edits vs HEAD.
117    pub worktree: LayerState<GitDiffStats>,
118    /// Layer 2: commits the current branch has ahead of
119    /// `default_branch`. The commit count is exposed separately
120    /// from the file stats because `--numstat` collapses every
121    /// commit into a single stat block; the count tells the
122    /// renderer "this many commits produced these stats".
123    pub branch_ahead: LayerState<GitBranchAhead>,
124    /// Open pull request containing the current branch's newest
125    /// pushed ancestor, if one exists. Only populated for `Named`
126    /// non-default branches; `None` otherwise. Cached across scans
127    /// while the branch name and pushed sha both hold and the fetch
128    /// is less than 5 minutes old (see the scanner's `prev`
129    /// parameter).
130    pub pr: Option<GitPrInfo>,
131    /// Issues the open PR closes (from GitHub's
132    /// `closingIssuesReferences`). Empty when there's no PR or the
133    /// PR doesn't reference any issues. Cached alongside `pr`.
134    pub closes: Vec<GitIssueRef>,
135}
136
137/// Per-file numstat plus aggregate totals for one diff layer.
138/// Shared between [`GitDiffSnapshot::worktree`] (layer 1, HEAD vs
139/// workdir) and [`GitBranchAhead::stats`] (layer 2, default vs
140/// branch tip).
141#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
142pub struct GitDiffStats {
143    pub files: Vec<GitDiffFile>,
144    pub total_files: usize,
145    pub total_added: u32,
146    pub total_removed: u32,
147}
148
149/// How current the base a drawn branch diff was measured against is.
150///
151/// The base is a remote-tracking ref (`origin/main`), and how far the remote
152/// has moved past it is only knowable over the network. The read that draws
153/// the diff refreshes it first, so a diff is never shown from a stale base
154/// without saying so.
155#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
156#[serde(rename_all = "snake_case")]
157pub enum BaseState {
158    /// A local base with nothing it could lag behind: no `origin` remote, and
159    /// no upstream of its own. A local base beside an `origin`, or one whose
160    /// branch tracks some remote (`main@{upstream}`), is `Unverified` instead
161    /// - it can be arbitrarily behind one nobody fetched.
162    Local,
163    /// The base was fetched and matches the remote.
164    Current,
165    /// The base could not be fetched, and the remote has `behind` commits it
166    /// lacks.
167    Behind { behind: u32 },
168    /// The base could not be fetched and could not be checked against the
169    /// remote either.
170    Unverified,
171}
172
173/// Layer 2 payload: how far the branch is ahead of the default
174/// branch, alongside the corresponding numstat.
175#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
176pub struct GitBranchAhead {
177    /// Number of commits between the merge-base with `default_branch`
178    /// and the branch tip. The renderer surfaces this so the user
179    /// can see "N commits ahead" without inferring it from the file
180    /// list.
181    pub commit_count: u32,
182    /// File-level numstat for the same commit range.
183    pub stats: GitDiffStats,
184    /// The chain itself, newest first, capped at the scan's own bound:
185    /// the count above says how many there are, these say what they are.
186    #[serde(default)]
187    pub commits: Vec<crate::git::GitCommit>,
188}
189
190/// One file's diff stats. `added` / `removed` are git's `--numstat`
191/// line counts; binary files (which numstat reports as `-`) are
192/// dropped by the parser rather than appearing here.
193#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
194pub struct GitDiffFile {
195    pub path: String,
196    pub added: u32,
197    pub removed: u32,
198    /// How git classified the change. `--numstat` carries no code, so
199    /// the scan merges it in from `--name-status`; a file the merge did
200    /// not reach keeps the default.
201    #[serde(default)]
202    pub status: crate::git::FileStatus,
203}
204
205/// The tree behind a git row's depth, bundled: what a hover over the
206/// row draws beyond the branch and the count.
207///
208/// `None` layers read as nothing to state - a clean tree, an unscanned
209/// one - the same way [`LayerState`] means it; the row above still
210/// carries the gate that says which.
211#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
212pub struct GitWorkView {
213    /// The branch this tree is measured against, when it resolved; the
214    /// row names it (`3 commits vs main`) and hides itself when this
215    /// tree IS it.
216    pub default_branch: Option<String>,
217    /// The uncommitted files, with marks and counts.
218    pub worktree: Option<GitDiffStats>,
219    /// The branch's chain ahead of its default.
220    pub ahead: Option<GitBranchAhead>,
221    /// What the seat's last watch refresh found for the base the chain is
222    /// measured against, `None` until one has run. **A base can move without
223    /// the count or the chain moving**: the range is `base..HEAD`, so an
224    /// upstream advance the branch does not contain leaves the branch's own
225    /// commits exactly as they were, and this is the only field that says so.
226    #[serde(default)]
227    pub base: Option<BaseState>,
228    /// Git's own words for a base refresh that could not run, beside the
229    /// state it left. **The reason rides the row** so a page that hears a
230    /// failed check learns why from the frame rather than only from its next
231    /// read, and so a failure with new words is news even when the state is
232    /// the one already drawn. Absent on records from an older server.
233    #[serde(default)]
234    pub base_error: Option<String>,
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    #[test]
242    fn layer_state_is_populated_returns_true_only_for_populated() {
243        let clean: LayerState<u32> = LayerState::Clean;
244        let populated: LayerState<u32> = LayerState::Populated(42);
245        let failed: LayerState<u32> = LayerState::ScanFailed;
246        assert!(!clean.is_populated());
247        assert!(populated.is_populated());
248        assert!(!failed.is_populated());
249    }
250
251    /// **The four states' wire spellings, as the client parser reads them.**
252    /// serde's external tagging nests a struct variant's fields under the
253    /// variant name (`{"behind": {"behind": 3}}`), which a parser written
254    /// against a flat object silently reads as unknown - the stale-base label
255    /// then never draws. The client's `diffFrom` test feeds these same shapes.
256    #[test]
257    fn the_base_states_wire_spellings() {
258        assert_eq!(
259            serde_json::to_value(BaseState::Local).expect("encodes"),
260            serde_json::json!("local")
261        );
262        assert_eq!(
263            serde_json::to_value(BaseState::Current).expect("encodes"),
264            serde_json::json!("current"),
265        );
266        assert_eq!(
267            serde_json::to_value(BaseState::Unverified).expect("encodes"),
268            serde_json::json!("unverified"),
269        );
270        assert_eq!(
271            serde_json::to_value(BaseState::Behind { behind: 3 }).expect("encodes"),
272            serde_json::json!({"behind": {"behind": 3}}),
273            "a struct variant nests its fields under the variant name",
274        );
275    }
276}