Skip to main content

git_policy_cli/
capture_records.rs

1//! What:
2//!  `captured.json` (a transaction's position in its worktree's capture order) and the
3//!       landed-capture records,
4//!  with recording after a landing and pruning.
5//! Why:
6//!  Replay decides a path both a prepared commit and an earlier-landed commit captured from
7//!      this worktree by capture order;
8//!  a landed record is kept while any published transaction
9//!      may replay over it and pruned after (`src/policy-engine/commit-capture-order-journal.ts`,
10//!      `commit-capture-order-records.ts`,
11//!  `commit-capture-order-prune.ts`).
12//!
13//! In TS you'd write (pseudocode):
14//! ```ts
15//! await recordLandedCaptureOrWarn({ gitDir, transactionDirectory, transactionId, landedOid });
16//! await pruneLandedCaptures({ gitDir, registryRoot });
17//! ```
18
19/// The store's location,
20///  files and failure.
21use super::capture_store::{
22    CaptureError, capture_store_path, ensure_capture_store, read_next_capture_sequence,
23    read_store_file, read_worktree_id,
24};
25/// Diagnostics.
26use super::diagnostic_log::{debug, warn};
27/// Record readers and the ordered writer.
28use super::json_record::{
29    MAX_SAFE_INTEGER, ObjectWriter, parse_object, safe_integer, string_array,
30};
31/// Private exclusive files, written without a sync.
32use super::private_storage::write_private_file_unsynced;
33/// The journal schema version `captured.json` shares.
34use super::transaction_journal::JOURNAL_SCHEMA_VERSION;
35/// Registry listing and root creation.
36use super::transaction_registry::{
37    EntryKind, RegistryEntry, ensure_transaction_root, list_transaction_entries,
38};
39/// `Map`/`Value` are JSON objects and values.
40use serde_json::{Map, Value};
41/// `Path`/`PathBuf` are borrowed/owned filesystem paths.
42use std::path::{Path, PathBuf};
43
44/// Capture-order record filename inside a transaction directory.
45pub const CAPTURED_FILENAME: &str = "captured.json";
46
47/// Landed-record directory name inside the store.
48pub const LANDED_DIRECTORY_NAME: &str = "landed";
49
50/// Landed-record filename suffix.
51pub const LANDED_RECORD_SUFFIX: &str = ".json";
52
53/// Landed-record schema version.
54pub const LANDED_SCHEMA_VERSION: i64 = 1;
55
56/// What:
57///  `captured.json`:
58///  the capture stamp,
59///  the next sequence read before the preparation base,
60///       and the Latin-1 decoded paths the capture read from the worktree.
61/// Why:
62///   The record pins landed records against pruning and names the captured paths.
63///
64/// In TS you'd write (pseudocode):
65/// ```ts
66/// type CapturedRecord = { schemaVersion: 2; state: 'captured'; worktreeId: string; sequence: number; nextSequenceBeforeBase: number; worktreePaths: string[] };
67/// ```
68#[derive(Clone, Debug, Eq, PartialEq)]
69pub struct CapturedRecord {
70    /// Identity of the store that allocated the sequence number.
71    pub worktree_id: String,
72    /// Capture sequence number,
73    ///  from 1.
74    pub sequence: i64,
75    /// Next sequence number read before the preparation base.
76    pub next_sequence_before_base: i64,
77    /// Paths whose bytes the capture read from the worktree,
78    ///  Latin-1 decoded,
79    ///  in Git order.
80    pub worktree_paths: Vec<String>,
81}
82
83/// What:
84///  One landed-capture record.
85/// Why:
86///   `LandedCaptureRecord`,
87///  written right after a compare-and-swap.
88///
89/// In TS you'd write (pseudocode):
90/// ```ts
91/// type LandedCaptureRecord = { schemaVersion: 1; commit; transactionId; worktreeId; sequence; nextSequenceAfterLanding; worktreePaths };
92/// ```
93#[derive(Clone, Debug, Eq, PartialEq)]
94pub struct LandedCaptureRecord {
95    /// Landed commit.
96    pub commit: String,
97    /// Transaction that landed it.
98    pub transaction_id: String,
99    /// Store identity.
100    pub worktree_id: String,
101    /// Capture sequence number.
102    pub sequence: i64,
103    /// Next capture sequence number read after the compare-and-swap.
104    pub next_sequence_after_landing: i64,
105    /// Paths the landing transaction captured from the worktree,
106    ///  Latin-1 decoded.
107    pub worktree_paths: Vec<String>,
108}
109
110/// What:
111///  Parse a JSON object record,
112///  or fail naming it.
113/// Why:
114///   `parseObject`:
115///  arrays and scalars are not records.
116///
117/// In TS you'd write (pseudocode):
118/// ```ts
119/// parseObject({ text, name })
120/// ```
121fn record_object(text: &str, name: &str) -> Result<Map<String, Value>, CaptureError> {
122    match parse_object(text) {
123        Some(map) => return Ok(map),
124        None => return Err(CaptureError(format!("{name} is not a JSON object."))),
125    }
126}
127
128/// What:
129///  The failure for a malformed field.
130/// Why:
131///   Every field reader reports the same shape.
132///
133/// In TS you'd write (pseudocode):
134/// ```ts
135/// new CaptureOrderRecordError(`${name} has a malformed ${key} field.`)
136/// ```
137fn malformed(name: &str, key: &str) -> CaptureError {
138    return CaptureError(format!("{name} has a malformed {key} field."));
139}
140
141/// What:
142///  A required non-empty string field.
143/// Why:
144///   `stringField`.
145///
146/// In TS you'd write (pseudocode):
147/// ```ts
148/// stringField({ value, key, name })
149/// ```
150fn text_field(map: &Map<String, Value>, key: &str, name: &str) -> Result<String, CaptureError> {
151    match map.get(key) {
152        Some(Value::String(text)) if !text.is_empty() => return Ok(text.clone()),
153        _ => return Err(malformed(name, key)),
154    }
155}
156
157/// What:
158///  A required positive safe integer field.
159/// Why:
160///   `positiveField`.
161///
162/// In TS you'd write (pseudocode):
163/// ```ts
164/// positiveField({ value, key, name })
165/// ```
166fn positive_field(map: &Map<String, Value>, key: &str, name: &str) -> Result<i64, CaptureError> {
167    match map.get(key).and_then(safe_integer) {
168        Some(number) if number >= 1 => return Ok(number),
169        _ => return Err(malformed(name, key)),
170    }
171}
172
173/// What:
174///  A required array of non-empty strings.
175/// Why:
176///   `pathsField`.
177///
178/// In TS you'd write (pseudocode):
179/// ```ts
180/// pathsField({ value, key, name })
181/// ```
182fn paths_field(
183    map: &Map<String, Value>,
184    key: &str,
185    name: &str,
186) -> Result<Vec<String>, CaptureError> {
187    let Some(Value::Array(items)) = map.get(key) else {
188        return Err(malformed(name, key));
189    };
190    // `mut` allows collecting the paths one at a time.
191    let mut paths: Vec<String> = Vec::with_capacity(items.len());
192    for item in items {
193        match item {
194            Value::String(path) if !path.is_empty() => paths.push(path.clone()),
195            _ => return Err(malformed(name, key)),
196        }
197    }
198    return Ok(paths);
199}
200
201/// What:
202///  Parse `captured.json`.
203/// Why:
204///   `parseCapturedRecord`.
205///
206/// In TS you'd write (pseudocode):
207/// ```ts
208/// function parseCapturedRecord(text: string): CapturedRecord;
209/// ```
210pub fn parse_captured_record(text: &str) -> Result<CapturedRecord, CaptureError> {
211    let map: Map<String, Value> = record_object(text, CAPTURED_FILENAME)?;
212    if map.get("schemaVersion").and_then(safe_integer) != Some(JOURNAL_SCHEMA_VERSION)
213        || map.get("state").and_then(Value::as_str) != Some("captured")
214    {
215        return Err(CaptureError(format!(
216            "{CAPTURED_FILENAME} is not a schema-version-2 captured record."
217        )));
218    }
219    return Ok(CapturedRecord {
220        worktree_id: text_field(&map, "worktreeId", CAPTURED_FILENAME)?,
221        sequence: positive_field(&map, "sequence", CAPTURED_FILENAME)?,
222        next_sequence_before_base: positive_field(
223            &map,
224            "nextSequenceBeforeBase",
225            CAPTURED_FILENAME,
226        )?,
227        worktree_paths: paths_field(&map, "worktreePaths", CAPTURED_FILENAME)?,
228    });
229}
230
231/// What:
232///  Encode `captured.json` in the incumbent's field order,
233///  with a newline.
234/// Why:
235///   The capture writes exactly these bytes.
236///
237/// In TS you'd write (pseudocode):
238/// ```ts
239/// `${JSON.stringify({ schemaVersion: 2, state: 'captured', worktreeId, sequence, nextSequenceBeforeBase, worktreePaths })}\n`
240/// ```
241pub fn encode_captured_record(record: &CapturedRecord) -> String {
242    // `mut` allows appending one field at a time.
243    let mut writer: ObjectWriter = ObjectWriter::new();
244    writer
245        .integer("schemaVersion", JOURNAL_SCHEMA_VERSION)
246        .string("state", "captured")
247        .string("worktreeId", record.worktree_id.as_str())
248        .integer("sequence", record.sequence)
249        .integer("nextSequenceBeforeBase", record.next_sequence_before_base)
250        .raw(
251            "worktreePaths",
252            string_array(record.worktree_paths.as_slice()).as_str(),
253        );
254    return format!("{}\n", writer.finish());
255}
256
257/// What:
258///  A transaction's `captured.json`,
259///  or nothing when it has not captured yet.
260/// Why:
261///   `readCapturedRecord`.
262///
263/// In TS you'd write (pseudocode):
264/// ```ts
265/// async function readCapturedRecord(directory: string): Promise<CapturedRecord | typeof CAPTURED_ABSENT>;
266/// ```
267pub fn read_captured_record(directory: &Path) -> Result<Option<CapturedRecord>, CaptureError> {
268    match read_store_file(directory.join(CAPTURED_FILENAME).as_path())? {
269        Some(text) => return Ok(Some(parse_captured_record(text.as_str())?)),
270        None => return Ok(None),
271    }
272}
273
274/// What:
275///  The landed-record directory of a worktree.
276/// Why:
277///   `landedRecordDirectory`.
278///
279/// In TS you'd write (pseudocode):
280/// ```ts
281/// landedRecordDirectory('/repo/.git') // '/repo/.git/cli-git-captures/landed'
282/// ```
283pub fn landed_record_directory(git_dir: &Path) -> PathBuf {
284    return capture_store_path(git_dir).join(LANDED_DIRECTORY_NAME);
285}
286
287/// What:
288///  Parse one landed record.
289/// Why:
290///   `parseLandedCaptureRecord`.
291///
292/// In TS you'd write (pseudocode):
293/// ```ts
294/// parseLandedCaptureRecord({ text, name })
295/// ```
296pub fn parse_landed_capture_record(
297    text: &str,
298    name: &str,
299) -> Result<LandedCaptureRecord, CaptureError> {
300    let map: Map<String, Value> = record_object(text, name)?;
301    if map.get("schemaVersion").and_then(safe_integer) != Some(LANDED_SCHEMA_VERSION) {
302        return Err(CaptureError(format!(
303            "{name} is not a schema-version-1 landed-capture record."
304        )));
305    }
306    return Ok(LandedCaptureRecord {
307        commit: text_field(&map, "commit", name)?,
308        transaction_id: text_field(&map, "transactionId", name)?,
309        worktree_id: text_field(&map, "worktreeId", name)?,
310        sequence: positive_field(&map, "sequence", name)?,
311        next_sequence_after_landing: positive_field(&map, "nextSequenceAfterLanding", name)?,
312        worktree_paths: paths_field(&map, "worktreePaths", name)?,
313    });
314}
315
316/// What:
317///  Encode a landed record in the incumbent's field order,
318///  with a newline.
319/// Why:
320///   `landedRecordOf` builds the object in this order before `JSON.stringify`.
321///
322/// In TS you'd write (pseudocode):
323/// ```ts
324/// `${JSON.stringify(landedRecordOf(...))}\n`
325/// ```
326pub fn encode_landed_capture_record(record: &LandedCaptureRecord) -> String {
327    // `mut` allows appending one field at a time.
328    let mut writer: ObjectWriter = ObjectWriter::new();
329    writer
330        .integer("schemaVersion", LANDED_SCHEMA_VERSION)
331        .string("commit", record.commit.as_str())
332        .string("transactionId", record.transaction_id.as_str())
333        .string("worktreeId", record.worktree_id.as_str())
334        .integer("sequence", record.sequence)
335        .integer(
336            "nextSequenceAfterLanding",
337            record.next_sequence_after_landing,
338        )
339        .raw(
340            "worktreePaths",
341            string_array(record.worktree_paths.as_slice()).as_str(),
342        );
343    return format!("{}\n", writer.finish());
344}
345
346/// What:
347///  Record the capture of a commit that just landed,
348///  or that recovery found landed;
349///  returns
350///       whether the transaction had a capture to record.
351///  An existing record is kept.
352/// Why:
353///   `recordLandedCapture`:
354///  only after the compare-and-swap succeeded.
355///
356/// In TS you'd write (pseudocode):
357/// ```ts
358/// async function recordLandedCapture({ gitDir, transactionDirectory, transactionId, landedOid }): Promise<boolean>;
359/// ```
360pub fn record_landed_capture(
361    git_dir: &Path,
362    transaction_directory: &Path,
363    transaction_id: &str,
364    landed_oid: &str,
365) -> Result<bool, CaptureError> {
366    let Some(captured) = read_captured_record(transaction_directory)? else {
367        debug(
368            "recordLandedCapture",
369            format!(
370                "transaction {transaction_id} captured without capture order; {landed_oid} gets no landed record"
371            )
372            .as_str(),
373        );
374        return Ok(false);
375    };
376    ensure_capture_store(git_dir)?;
377    let directory: PathBuf = landed_record_directory(git_dir);
378    if let Err(error) = ensure_transaction_root(directory.as_path()) {
379        return Err(CaptureError(error.0));
380    }
381    let record: LandedCaptureRecord = LandedCaptureRecord {
382        commit: String::from(landed_oid),
383        transaction_id: String::from(transaction_id),
384        worktree_id: captured.worktree_id,
385        sequence: captured.sequence,
386        next_sequence_after_landing: read_next_capture_sequence(git_dir)?,
387        worktree_paths: captured.worktree_paths,
388    };
389    let path: PathBuf = directory.join(format!("{landed_oid}{LANDED_RECORD_SUFFIX}"));
390    // Unsynced, file and directory: a torn or lost landed record completes in the tearing table at every phase.
391    match write_private_file_unsynced(
392        path.as_path(),
393        encode_landed_capture_record(&record).as_bytes(),
394    ) {
395        Ok(()) => {
396            debug(
397                "recordLandedCapture",
398                format!(
399                    "recorded capture {} for landed {landed_oid}",
400                    record.sequence
401                )
402                .as_str(),
403            );
404        }
405        Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
406            debug(
407                "recordLandedCapture",
408                format!("landed record of {landed_oid} already exists: {error}").as_str(),
409            );
410        }
411        Err(error) => {
412            return Err(CaptureError(format!(
413                "writing {} failed: {error}",
414                path.display()
415            )));
416        }
417    }
418    return Ok(true);
419}
420
421/// What:
422///  Record a landed capture as `record_landed_capture` does,
423///  but report a failure as a
424///       warning instead of returning it.
425/// Why:
426///   `recordLandedCaptureOrWarn`:
427///  the commit already landed;
428///  a missing record only sends its
429///       paths through subsumption in later replays.
430///
431/// In TS you'd write (pseudocode):
432/// ```ts
433/// async function recordLandedCaptureOrWarn(options): Promise<boolean>;
434/// ```
435pub fn record_landed_capture_or_warn(
436    git_dir: &Path,
437    transaction_directory: &Path,
438    transaction_id: &str,
439    landed_oid: &str,
440) -> bool {
441    match record_landed_capture(git_dir, transaction_directory, transaction_id, landed_oid) {
442        Ok(recorded) => return recorded,
443        Err(error) => {
444            warn(
445                "recordLandedCaptureOrWarn",
446                format!(
447                    "could not record the capture of landed {landed_oid}; later replays over it use subsumption: {error}"
448                )
449                .as_str(),
450            );
451            return false;
452        }
453    }
454}
455
456/// What:
457///  The landed-record filenames,
458///  or none when the directory is absent.
459/// Why:
460///   `listLandedRecordNames`.
461///
462/// In TS you'd write (pseudocode):
463/// ```ts
464/// async function listLandedRecordNames(gitDir: string): Promise<readonly string[]>;
465/// ```
466pub fn list_landed_record_names(git_dir: &Path) -> Result<Vec<String>, CaptureError> {
467    let directory: PathBuf = landed_record_directory(git_dir);
468    let listing: std::fs::ReadDir = match std::fs::read_dir(&directory) {
469        Ok(found) => found,
470        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
471            debug(
472                "listLandedRecordNames",
473                format!("no landed-capture records in {}", git_dir.display()).as_str(),
474            );
475            return Ok(Vec::new());
476        }
477        Err(error) => {
478            return Err(CaptureError(format!(
479                "listing {} failed: {error}",
480                directory.display()
481            )));
482        }
483    };
484    // `mut` allows collecting the names one at a time.
485    let mut names: Vec<String> = Vec::new();
486    for item in listing {
487        match item {
488            Ok(entry) => names.push(entry.file_name().to_string_lossy().into_owned()),
489            Err(error) => {
490                return Err(CaptureError(format!(
491                    "listing {} failed: {error}",
492                    directory.display()
493                )));
494            }
495        }
496    }
497    return Ok(names);
498}
499
500/// What:
501///  The smallest next sequence number a published transaction of this store generation
502///       read before its base;
503///  `None` keeps every record (a published transaction has not
504///       captured yet);
505///  `Some(i64::MAX)` stands for the incumbent's `Infinity`.
506/// Why:
507///   `pinningBound`.
508///
509/// In TS you'd write (pseudocode):
510/// ```ts
511/// async function pinningBound({ registryRoot, worktreeId }): Promise<number | typeof KEEP_ALL>;
512/// ```
513fn pinning_bound(
514    registry_root: &Path,
515    worktree_id: Option<&str>,
516) -> Result<Option<i64>, CaptureError> {
517    let entries: Vec<RegistryEntry> = match list_transaction_entries(registry_root) {
518        Ok(found) => found,
519        Err(error) => return Err(CaptureError(error.0)),
520    };
521    // `mut` allows narrowing the bound transaction by transaction.
522    let mut bound: i64 = i64::MAX;
523    for entry in entries {
524        if entry.kind != EntryKind::Transaction {
525            continue;
526        }
527        let Some(captured) = read_captured_record(entry.path.as_path())? else {
528            return Ok(None);
529        };
530        if Some(captured.worktree_id.as_str()) == worktree_id {
531            bound = bound.min(captured.next_sequence_before_base);
532        }
533    }
534    return Ok(Some(bound));
535}
536
537/// What:
538///  Whether one landed-record file can go:
539///  not a record,
540///  unreadable,
541///  of another store
542///       generation,
543///  or recorded a next number below the bound.
544/// Why:
545///   `prunable`:
546///  a malformed record is reported and removed.
547///
548/// In TS you'd write (pseudocode):
549/// ```ts
550/// async function prunable({ directory, name, worktreeId, bound }): Promise<boolean>;
551/// ```
552fn prunable(directory: &Path, name: &str, worktree_id: Option<&str>, bound: i64) -> bool {
553    if !name.ends_with(LANDED_RECORD_SUFFIX) {
554        return true;
555    }
556    let path: PathBuf = directory.join(name);
557    let name_text: String = path.display().to_string();
558    let parsed: Result<LandedCaptureRecord, CaptureError> = match read_store_file(path.as_path()) {
559        Ok(Some(text)) => parse_landed_capture_record(text.as_str(), name_text.as_str()),
560        Ok(None) => Err(CaptureError(format!("{name_text} vanished"))),
561        Err(error) => Err(error),
562    };
563    match parsed {
564        Ok(record) => {
565            return Some(record.worktree_id.as_str()) != worktree_id
566                || record.next_sequence_after_landing < bound;
567        }
568        Err(error) => {
569            warn(
570                "pruneLandedCaptures",
571                format!("removing unreadable landed-capture record {name_text}: {error}").as_str(),
572            );
573            return true;
574        }
575    }
576}
577
578/// What:
579///  Remove every landed record no published transaction can replay over;
580///  returns how many.
581///       Never fails:
582///  a failure is reported and the records stay for the next pruning.
583/// Why:
584///   `pruneLandedCaptures` runs after every transaction removes its directory and after
585///       startup recovery,
586///  so the last transaction leaves no record behind.
587///
588/// In TS you'd write (pseudocode):
589/// ```ts
590/// async function pruneLandedCaptures({ gitDir, registryRoot }): Promise<number>;
591/// ```
592pub fn prune_landed_captures(git_dir: &Path, registry_root: &Path) -> usize {
593    match prune_or_fail(git_dir, registry_root) {
594        Ok(count) => return count,
595        Err(error) => {
596            warn(
597                "pruneLandedCaptures",
598                format!(
599                    "could not prune landed-capture records in {}; they stay for the next pruning: {error}",
600                    git_dir.display()
601                )
602                .as_str(),
603            );
604            return 0;
605        }
606    }
607}
608
609/// What:
610///  The pruning steps,
611///  with failures returned.
612/// Why:
613///   `prune_landed_captures` turns any failure into a warning.
614///
615/// In TS you'd write (pseudocode):
616/// ```ts
617/// // body of pruneLandedCaptures inside its try
618/// ```
619fn prune_or_fail(git_dir: &Path, registry_root: &Path) -> Result<usize, CaptureError> {
620    let names: Vec<String> = list_landed_record_names(git_dir)?;
621    if names.is_empty() {
622        return Ok(0);
623    }
624    let worktree_id: Option<String> = read_worktree_id(git_dir)?;
625    let Some(bound) = pinning_bound(registry_root, worktree_id.as_deref())? else {
626        debug(
627            "pruneLandedCaptures",
628            "a published transaction has not captured yet; keeping every landed-capture record",
629        );
630        return Ok(0);
631    };
632    let directory: PathBuf = landed_record_directory(git_dir);
633    // `mut` allows counting removals.
634    let mut removed: usize = 0;
635    for name in &names {
636        if !prunable(
637            directory.as_path(),
638            name.as_str(),
639            worktree_id.as_deref(),
640            bound,
641        ) {
642            continue;
643        }
644        let path: PathBuf = directory.join(name);
645        match std::fs::remove_file(&path) {
646            Ok(()) => removed += 1,
647            Err(error) if error.kind() == std::io::ErrorKind::NotFound => removed += 1,
648            Err(error) => {
649                return Err(CaptureError(format!(
650                    "removing {} failed: {error}",
651                    path.display()
652                )));
653            }
654        }
655    }
656    debug(
657        "pruneLandedCaptures",
658        format!("pruned {removed} of {} landed-capture records", names.len()).as_str(),
659    );
660    return Ok(removed);
661}
662
663/// The incumbent's `Infinity` bound cannot be exceeded by a safe integer.
664const _: () = assert!(MAX_SAFE_INTEGER < i64::MAX);
665
666/// Record controls stay out of the release executable.
667#[cfg(test)]
668#[path = "capture_records_tests.rs"]
669mod tests;