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 };
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}
91
92/// What:
93/// What reading a branch's record found.
94/// Why:
95/// Absence and an unreadable record both mean "no attempt"; they differ only in the diagnostic.
96///
97/// In TS you'd write (pseudocode):
98/// ```ts
99/// LastPushedRecord | typeof LAST_PUSHED_ABSENT
100/// ```
101#[derive(Clone, Debug, Eq, PartialEq)]
102pub enum LastPushedRead {
103 /// A valid record.
104 Record(LastPushedRecord),
105 /// No record, or a record that does not validate.
106 Absent,
107}
108
109/// What:
110/// Parse and validate record text.
111/// `Option<A>` is "a value or nothing".
112/// Why:
113/// Every field the incumbent checks is checked, so a malformed record never drives a join.
114///
115/// In TS you'd write (pseudocode):
116/// ```ts
117/// function parseLastPushedRecord(text: string): LastPushedRecord; // throws TypeError
118/// ```
119pub fn parse_last_pushed_record(text: &str) -> Option<LastPushedRecord> {
120 let object = parse_object(text)?;
121 if safe_integer_field(&object, "schemaVersion") != Some(LAST_PUSHED_SCHEMA_VERSION) {
122 return None;
123 }
124 let tip = non_empty_string(&object, "tip")?;
125 let outcome = match string_field(&object, "outcome")? {
126 "pushed" => LastPushedOutcome::Pushed,
127 "failed" => LastPushedOutcome::Failed,
128 _ => return None,
129 };
130 let owner_token = non_empty_string(&object, "ownerToken")?;
131 let owner_pid = safe_integer_field(&object, "ownerPid")?;
132 let exit_code = safe_integer_field(&object, "exitCode")?;
133 let output = string_field(&object, "output")?;
134 return Some(LastPushedRecord {
135 tip,
136 outcome,
137 owner_token,
138 owner_pid,
139 exit_code,
140 output: output.to_owned(),
141 });
142}
143
144/// A string field that must not be empty.
145///
146/// In TS you'd write (pseudocode):
147/// ```ts
148/// typeof value.tip === 'string' && value.tip !== ''
149/// ```
150fn non_empty_string(
151 object: &serde_json::Map<String, serde_json::Value>,
152 key: &str,
153) -> Option<String> {
154 let text = string_field(object, key)?;
155 if text.is_empty() {
156 return None;
157 }
158 return Some(text.to_owned());
159}
160
161/// What:
162/// Read a branch's record, treating an absent or unreadable one as "no attempt".
163/// Why:
164/// A missing file is the normal first push;
165/// any other read failure (permission denied, a directory in its place) must stop the caller,
166/// and a record that fails validation is replaced by the next push.
167///
168/// In TS you'd write (pseudocode):
169/// ```ts
170/// async function readLastPushedRecord(recordPath): Promise<LastPushedRecord | typeof LAST_PUSHED_ABSENT>;
171/// ```
172pub fn read_last_pushed_record(record_path: &Path) -> std::io::Result<LastPushedRead> {
173 let bytes = match std::fs::read(record_path) {
174 Ok(bytes) => bytes,
175 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
176 return Ok(LastPushedRead::Absent);
177 }
178 Err(error) => return Err(error),
179 };
180 let text = String::from_utf8_lossy(&bytes);
181 return match parse_last_pushed_record(&text) {
182 Some(record) => Ok(LastPushedRead::Record(record)),
183 None => {
184 warn(
185 TAG,
186 &format!(
187 "ignoring unreadable last-pushed record {}; the next push replaces it",
188 record_path.display()
189 ),
190 );
191 Ok(LastPushedRead::Absent)
192 }
193 };
194}
195
196/// What:
197/// Serialize a record in the incumbent's `JSON.stringify` key order, with its trailing newline.
198/// Why:
199/// The incumbent and this wrapper write the same bytes for the same record.
200///
201/// In TS you'd write (pseudocode):
202/// ```ts
203/// `${JSON.stringify(record)}\n`
204/// ```
205pub fn encode_last_pushed_record(record: &LastPushedRecord) -> String {
206 return format!(
207 "{{\"schemaVersion\":{LAST_PUSHED_SCHEMA_VERSION},\"tip\":{},\"outcome\":{},\"ownerToken\":{},\"ownerPid\":{},\"exitCode\":{},\"output\":{}}}\n",
208 quote(&record.tip),
209 quote(record.outcome.as_str()),
210 quote(&record.owner_token),
211 record.owner_pid,
212 record.exit_code,
213 quote(&record.output),
214 );
215}
216
217/// What:
218/// Publish a branch's record by rename, so readers never see a partial record.
219/// Why:
220/// The candidate is written exclusively with mode `0600` beside the record and renamed over it;
221/// a failed publication removes the candidate and returns the error.
222///
223/// In TS you'd write (pseudocode):
224/// ```ts
225/// async function writeLastPushedRecord({ recordPath, record }): Promise<void>;
226/// ```
227pub fn write_last_pushed_record(
228 record_path: &Path,
229 record: &LastPushedRecord,
230) -> std::io::Result<()> {
231 let candidate_name = format!("{}.{}.pending", record_path.display(), random_uuid_or_io()?);
232 let candidate = Path::new(&candidate_name);
233 let bytes = encode_last_pushed_record(record);
234 let published = write_exclusive_synced(candidate, bytes.as_bytes()).and_then(|()| {
235 return std::fs::rename(candidate, record_path);
236 });
237 if let Err(error) = published {
238 let _removed = std::fs::remove_file(candidate);
239 return Err(error);
240 }
241 return Ok(());
242}
243
244/// A fresh unique suffix; a failed random source becomes an I/O error for the caller.
245///
246/// In TS you'd write (pseudocode):
247/// ```ts
248/// randomUUID()
249/// ```
250fn random_uuid_or_io() -> std::io::Result<String> {
251 return random_uuid().map_err(|error| {
252 return std::io::Error::other(error.0);
253 });
254}
255
256/// Parsing, validation, publication and the candidate removal are controlled in the test module.
257#[cfg(test)]
258#[path = "auto_push_record_tests.rs"]
259mod tests;