git_policy_cli/manual_push_porcelain.rs
1//! What:
2//! The parser of `git push --dry-run --porcelain` output, which tells the manual-push probe what a push would send.
3//! Why:
4//! The probe used to learn the update lines from a recording hook written as shell text. Git's own dry-run report
5//! carries the same facts without any program of ours: for each destination a `To <url>` line, then one tab-separated
6//! line per ref (`<flag>\t<from>:<to>\t<summary>`), then `Done`. With `core.abbrev=no` the summary of an update holds
7//! the full old and new object names (`old..new`, or `old...new` when forced); a new ref, a deletion and a refusal
8//! hold a bracketed word instead.
9//!
10//! In TS you'd write (pseudocode):
11//! ```ts
12//! const blocks = parsePorcelain(stdout); // [{ shownLocation, updates: [{ from, to, before, after }] }]
13//! ```
14
15/// What the remote held for a ref before the push, as far as the report says.
16///
17/// In TS you'd write (pseudocode):
18/// ```ts
19/// type RemoteBefore = { kind: 'absent' } | { kind: 'known'; oid: string } | { kind: 'unknown' };
20/// ```
21#[derive(Clone, Debug, Eq, PartialEq)]
22pub enum RemoteBefore {
23 /// A new branch or tag: the remote has no such ref.
24 Absent,
25 /// An update: the remote held this object name.
26 Known(String),
27 /// A deletion or a refusal: the report does not say, so the confirmation asks the remote.
28 Unknown,
29}
30
31/// One reported ref update.
32///
33/// In TS you'd write (pseudocode):
34/// ```ts
35/// type PorcelainUpdate = { from: string; to: string; before: RemoteBefore; after?: string };
36/// ```
37#[derive(Clone, Debug, Eq, PartialEq)]
38pub struct PorcelainUpdate {
39 /// The local ref or expression pushed from; empty for a deletion.
40 pub from: String,
41 /// The remote ref updated.
42 pub to: String,
43 /// What the remote held before.
44 pub before: RemoteBefore,
45 /// The object the update sends, when the report names it.
46 pub after: Option<String>,
47}
48
49/// The updates reported for one destination.
50///
51/// In TS you'd write (pseudocode):
52/// ```ts
53/// type PorcelainBlock = { updates: PorcelainUpdate[] };
54/// ```
55#[derive(Clone, Debug, Eq, PartialEq)]
56pub struct PorcelainBlock {
57 /// The location Git printed after `To`, with any credentials removed.
58 pub shown: String,
59 /// The reported updates of this destination, up-to-date refs left out.
60 pub updates: Vec<PorcelainUpdate>,
61}
62
63/// The start of the line Git adds to the report when the push would set an upstream.
64const SET_UPSTREAM_PREFIX: &str = "Would set upstream of ";
65
66/// What:
67/// Split an update's summary into what the remote held and what is sent.
68/// Why:
69/// `old..new` and `old...new` name both; `[new branch]` and `[new tag]` mean the remote has nothing; any other
70/// bracketed word (a deletion, a refusal) leaves the remote's value to the confirmation.
71///
72/// In TS you'd write (pseudocode):
73/// ```ts
74/// function parseSummary(summary: string): { before: RemoteBefore; after?: string };
75/// ```
76fn parse_summary(summary: &str) -> (RemoteBefore, Option<String>) {
77 let token: &str = summary.split(' ').next().unwrap_or_default();
78 if token == "[new" {
79 return (RemoteBefore::Absent, None);
80 }
81 let range: Option<(&str, &str)> = token.split_once("...").or_else(|| {
82 return token.split_once("..");
83 });
84 return match range {
85 Some((old, new)) if !old.is_empty() && !new.is_empty() => {
86 (RemoteBefore::Known(old.to_owned()), Some(new.to_owned()))
87 }
88 Some(_) | None => (RemoteBefore::Unknown, None),
89 };
90}
91
92/// What:
93/// Parse the standard output of a porcelain dry-run push into one block per destination.
94/// An error names the line that is not part of the format.
95/// Why:
96/// An unrecognised line is a probe failure, never a guess about what would be pushed.
97///
98/// In TS you'd write (pseudocode):
99/// ```ts
100/// function parsePorcelain(output: string): PorcelainBlock[]; // throws
101/// ```
102pub fn parse_porcelain(output: &str) -> Result<Vec<PorcelainBlock>, String> {
103 let mut blocks: Vec<PorcelainBlock> = Vec::new();
104 for line in output.lines() {
105 if let Some(shown) = line.strip_prefix("To ") {
106 blocks.push(PorcelainBlock { shown: shown.to_owned(), updates: Vec::new() });
107 continue;
108 }
109 // `-u`, `--set-upstream` and `push.autoSetupRemote` make Git add this line to the report (measured on 2.56.0).
110 if line == "Done" || line.is_empty() || line.starts_with(SET_UPSTREAM_PREFIX) {
111 continue;
112 }
113 let mut fields = line.splitn(3, '\t');
114 let (Some(flag), Some(spec), summary) = (fields.next(), fields.next(), fields.next().unwrap_or_default()) else {
115 return Err(format!("Malformed porcelain line: {line}"));
116 };
117 // Git splits a refspec at its last colon, so a source such as `:/message` keeps its own.
118 let Some((from, to)) = spec.rsplit_once(':') else {
119 return Err(format!("Malformed porcelain ref pair: {line}"));
120 };
121 let Some(block) = blocks.last_mut() else {
122 return Err(format!("Porcelain line before any destination: {line}"));
123 };
124 if flag == "=" {
125 continue;
126 }
127 let (before, after) = parse_summary(summary);
128 block.updates.push(PorcelainUpdate { from: from.to_owned(), to: to.to_owned(), before, after });
129 }
130 return Ok(blocks);
131}
132
133/// The parser is controlled in the test module.
134#[cfg(test)]
135#[path = "manual_push_porcelain_tests.rs"]
136mod tests;