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 };
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;