Skip to main content

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;