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}