git_policy_cli/auto_push_record.rs
1//! What:
2//! The per-branch `last-pushed` record that single-flight auto-push coordinates through.
3//! Why:
4//! The incumbent (`src/auto-push-record.ts`) and this wrapper must read each other's records,
5//! because a commit waiting on a push made by the other wrapper decides from the same file.
6//! The record is a cache: an absent or unreadable record only costs an extra push,
7//! so only a read failure other than "absent" stops the caller.
8//!
9//! In TS you'd write (pseudocode):
10//! ```ts
11//! readLastPushedRecord(recordPath): Promise<LastPushedRecord | typeof LAST_PUSHED_ABSENT>;
12//! writeLastPushedRecord({ recordPath, record }): Promise<void>;
13//! ```
14
15/// The safe-integer and string field readers, the object parser and the JSON string quoting.
16use super::diagnostic_log::warn;
17use super::json_record::{parse_object, quote, safe_integer_field, string_field};
18/// The exclusive private file creation the incumbent does with mode `0600`.
19use super::private_storage::write_exclusive_synced;
20/// Unique candidate names, as the incumbent's `randomUUID`.
21use super::random_id::random_uuid;
22/// `Path` is a borrowed filesystem path.
23use std::path::Path;
24
25/// Record file name suffix after the encoded branch key.
26pub const LAST_PUSHED_SUFFIX: &str = ".last-pushed.json";
27
28/// Record schema version the incumbent writes and reads.
29pub const LAST_PUSHED_SCHEMA_VERSION: i64 = 1;
30
31/// Tag used for diagnostics from this module.
32const TAG: &str = "cli-git";
33
34/// What:
35/// Outcome of one recorded push attempt.
36/// Why:
37/// Only `pushed` and `failed` exist in the incumbent's record.
38///
39/// In TS you'd write (pseudocode):
40/// ```ts
41/// type LastPushedOutcome = 'pushed' | 'failed';
42/// ```
43#[derive(Clone, Copy, Debug, Eq, PartialEq)]
44pub enum LastPushedOutcome {
45 /// The push succeeded.
46 Pushed,
47 /// The push failed; the record keeps its output.
48 Failed,
49}
50
51impl LastPushedOutcome {
52 /// The spelling the record stores.
53 ///
54 /// In TS you'd write (pseudocode):
55 /// ```ts
56 /// outcome
57 /// ```
58 pub fn as_str(self) -> &'static str {
59 return match self {
60 Self::Pushed => "pushed",
61 Self::Failed => "failed",
62 };
63 }
64}
65
66/// What:
67/// The most recent push attempt for one branch.
68/// Why:
69/// `owner_token` tells a waiting commit whether a push finished after it first read the record;
70/// `output` is the complete text of a failed push, which every joined commit surfaces.
71///
72/// In TS you'd write (pseudocode):
73/// ```ts
74/// type LastPushedRecord = { schemaVersion: 1; tip; outcome; ownerToken; ownerPid; exitCode; output; publishedTip?: string };
75/// ```
76#[derive(Clone, Debug, Eq, PartialEq)]
77pub struct LastPushedRecord {
78 /// Branch tip the attempt resolved before pushing; never empty.
79 pub tip: String,
80 /// Whether the push succeeded.
81 pub outcome: LastPushedOutcome,
82 /// Owner-lock token of the attempt; never empty.
83 pub owner_token: String,
84 /// Process that ran the attempt.
85 pub owner_pid: i64,
86 /// Push exit code, `0` on success.
87 pub exit_code: i64,
88 /// Complete push output of a failed attempt; empty on success.
89 pub output: String,
90 /// The newest tip of the branch known to be on the remote, kept by a failed attempt so that a failure does not
91 /// erase what the branch is known to have published; absent from a record of a successful push, whose `tip` is it.
92 pub published_tip: Option<String>,
93}
94
95impl LastPushedRecord {
96 /// What:
97 /// The newest tip of the branch this record shows on the remote, if it shows one.
98 /// Why:
99 /// A successful push published its `tip`; a failed one published nothing, so it can only repeat what the record
100 /// before it showed.
101 ///
102 /// In TS you'd write (pseudocode):
103 /// ```ts
104 /// const published = (record) => (record.outcome === 'pushed' ? record.tip : record.publishedTip);
105 /// ```
106 pub fn published(&self) -> Option<&str> {
107 return match self.outcome {
108 LastPushedOutcome::Pushed => Some(self.tip.as_str()),
109 LastPushedOutcome::Failed => self.published_tip.as_deref(),
110 };
111 }
112}
113
114/// What:
115/// What reading a branch's record found.
116/// Why:
117/// Absence and an unreadable record both mean "no attempt"; they differ only in the diagnostic.
118///
119/// In TS you'd write (pseudocode):
120/// ```ts
121/// LastPushedRecord | typeof LAST_PUSHED_ABSENT
122/// ```
123#[derive(Clone, Debug, Eq, PartialEq)]
124pub enum LastPushedRead {
125 /// A valid record.
126 Record(LastPushedRecord),
127 /// No record, or a record that does not validate.
128 Absent,
129}
130
131/// What:
132/// Parse and validate record text.
133/// `Option<A>` is "a value or nothing".
134/// Why:
135/// Every field the incumbent checks is checked, so a malformed record never drives a join.
136///
137/// In TS you'd write (pseudocode):
138/// ```ts
139/// function parseLastPushedRecord(text: string): LastPushedRecord; // throws TypeError
140/// ```
141pub fn parse_last_pushed_record(text: &str) -> Option<LastPushedRecord> {
142 let object = parse_object(text)?;
143 if safe_integer_field(&object, "schemaVersion") != Some(LAST_PUSHED_SCHEMA_VERSION) {
144 return None;
145 }
146 let tip = non_empty_string(&object, "tip")?;
147 let outcome = match string_field(&object, "outcome")? {
148 "pushed" => LastPushedOutcome::Pushed,
149 "failed" => LastPushedOutcome::Failed,
150 _ => return None,
151 };
152 let owner_token = non_empty_string(&object, "ownerToken")?;
153 let owner_pid = safe_integer_field(&object, "ownerPid")?;
154 let exit_code = safe_integer_field(&object, "exitCode")?;
155 let output = string_field(&object, "output")?;
156 // Written only by this wrapper; a record without it is valid, one with an empty or non-string value is not.
157 let published_tip = match object.get("publishedTip") {
158 None => None,
159 Some(_) => Some(non_empty_string(&object, "publishedTip")?),
160 };
161 return Some(LastPushedRecord {
162 tip,
163 outcome,
164 owner_token,
165 owner_pid,
166 exit_code,
167 output: output.to_owned(),
168 published_tip,
169 });
170}
171
172/// A string field that must not be empty.
173///
174/// In TS you'd write (pseudocode):
175/// ```ts
176/// typeof value.tip === 'string' && value.tip !== ''
177/// ```
178fn non_empty_string(
179 object: &serde_json::Map<String, serde_json::Value>,
180 key: &str,
181) -> Option<String> {
182 let text = string_field(object, key)?;
183 if text.is_empty() {
184 return None;
185 }
186 return Some(text.to_owned());
187}
188
189/// What:
190/// Read a branch's record, treating an absent or unreadable one as "no attempt".
191/// Why:
192/// A missing file is the normal first push;
193/// any other read failure (permission denied, a directory in its place) must stop the caller,
194/// and a record that fails validation is replaced by the next push.
195///
196/// In TS you'd write (pseudocode):
197/// ```ts
198/// async function readLastPushedRecord(recordPath): Promise<LastPushedRecord | typeof LAST_PUSHED_ABSENT>;
199/// ```
200pub fn read_last_pushed_record(record_path: &Path) -> std::io::Result<LastPushedRead> {
201 let bytes = match std::fs::read(record_path) {
202 Ok(bytes) => bytes,
203 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
204 return Ok(LastPushedRead::Absent);
205 }
206 Err(error) => return Err(error),
207 };
208 let text = String::from_utf8_lossy(&bytes);
209 return match parse_last_pushed_record(&text) {
210 Some(record) => Ok(LastPushedRead::Record(record)),
211 None => {
212 warn(
213 TAG,
214 &format!(
215 "ignoring unreadable last-pushed record {}; the next push replaces it",
216 record_path.display()
217 ),
218 );
219 Ok(LastPushedRead::Absent)
220 }
221 };
222}
223
224/// What:
225/// Serialize a record in the incumbent's `JSON.stringify` key order, with its trailing newline.
226/// Why:
227/// The incumbent and this wrapper write the same bytes for the same record.
228///
229/// In TS you'd write (pseudocode):
230/// ```ts
231/// `${JSON.stringify(record)}\n`
232/// ```
233pub fn encode_last_pushed_record(record: &LastPushedRecord) -> String {
234 let published_tip = match &record.published_tip {
235 Some(tip) => format!(",\"publishedTip\":{}", quote(tip)),
236 None => String::new(),
237 };
238 return format!(
239 "{{\"schemaVersion\":{LAST_PUSHED_SCHEMA_VERSION},\"tip\":{},\"outcome\":{},\"ownerToken\":{},\"ownerPid\":{},\"exitCode\":{},\"output\":{}{published_tip}}}\n",
240 quote(&record.tip),
241 quote(record.outcome.as_str()),
242 quote(&record.owner_token),
243 record.owner_pid,
244 record.exit_code,
245 quote(&record.output),
246 );
247}
248
249/// What:
250/// Publish a branch's record by rename, so readers never see a partial record.
251/// Why:
252/// The candidate is written exclusively with mode `0600` beside the record and renamed over it;
253/// a failed publication removes the candidate and returns the error.
254///
255/// In TS you'd write (pseudocode):
256/// ```ts
257/// async function writeLastPushedRecord({ recordPath, record }): Promise<void>;
258/// ```
259pub fn write_last_pushed_record(
260 record_path: &Path,
261 record: &LastPushedRecord,
262) -> std::io::Result<()> {
263 let candidate_name = format!("{}.{}.pending", record_path.display(), random_uuid_or_io()?);
264 let candidate = Path::new(&candidate_name);
265 let bytes = encode_last_pushed_record(record);
266 let published = write_exclusive_synced(candidate, bytes.as_bytes()).and_then(|()| {
267 return std::fs::rename(candidate, record_path);
268 });
269 if let Err(error) = published {
270 let _removed = std::fs::remove_file(candidate);
271 return Err(error);
272 }
273 return Ok(());
274}
275
276/// A fresh unique suffix; a failed random source becomes an I/O error for the caller.
277///
278/// In TS you'd write (pseudocode):
279/// ```ts
280/// randomUUID()
281/// ```
282fn random_uuid_or_io() -> std::io::Result<String> {
283 return random_uuid().map_err(|error| {
284 return std::io::Error::other(error.0);
285 });
286}
287
288/// Parsing, validation, publication and the candidate removal are controlled in the test module.
289#[cfg(test)]
290#[path = "auto_push_record_tests.rs"]
291mod tests;