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 and directory syncs.
32use super::private_storage::{sync_directory, write_private_file};
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    match write_private_file(
391        path.as_path(),
392        encode_landed_capture_record(&record).as_bytes(),
393    ) {
394        Ok(()) => {
395            if let Err(error) = sync_directory(directory.as_path()) {
396                return Err(CaptureError(format!(
397                    "syncing {} failed: {error}",
398                    directory.display()
399                )));
400            }
401            debug(
402                "recordLandedCapture",
403                format!(
404                    "recorded capture {} for landed {landed_oid}",
405                    record.sequence
406                )
407                .as_str(),
408            );
409        }
410        Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
411            debug(
412                "recordLandedCapture",
413                format!("landed record of {landed_oid} already exists: {error}").as_str(),
414            );
415        }
416        Err(error) => {
417            return Err(CaptureError(format!(
418                "writing {} failed: {error}",
419                path.display()
420            )));
421        }
422    }
423    return Ok(true);
424}
425
426/// What:
427///  Record a landed capture as `record_landed_capture` does,
428///  but report a failure as a
429///       warning instead of returning it.
430/// Why:
431///   `recordLandedCaptureOrWarn`:
432///  the commit already landed;
433///  a missing record only sends its
434///       paths through subsumption in later replays.
435///
436/// In TS you'd write (pseudocode):
437/// ```ts
438/// async function recordLandedCaptureOrWarn(options): Promise<boolean>;
439/// ```
440pub fn record_landed_capture_or_warn(
441    git_dir: &Path,
442    transaction_directory: &Path,
443    transaction_id: &str,
444    landed_oid: &str,
445) -> bool {
446    match record_landed_capture(git_dir, transaction_directory, transaction_id, landed_oid) {
447        Ok(recorded) => return recorded,
448        Err(error) => {
449            warn(
450                "recordLandedCaptureOrWarn",
451                format!(
452                    "could not record the capture of landed {landed_oid}; later replays over it use subsumption: {error}"
453                )
454                .as_str(),
455            );
456            return false;
457        }
458    }
459}
460
461/// What:
462///  The landed-record filenames,
463///  or none when the directory is absent.
464/// Why:
465///   `listLandedRecordNames`.
466///
467/// In TS you'd write (pseudocode):
468/// ```ts
469/// async function listLandedRecordNames(gitDir: string): Promise<readonly string[]>;
470/// ```
471pub fn list_landed_record_names(git_dir: &Path) -> Result<Vec<String>, CaptureError> {
472    let directory: PathBuf = landed_record_directory(git_dir);
473    let listing: std::fs::ReadDir = match std::fs::read_dir(&directory) {
474        Ok(found) => found,
475        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
476            debug(
477                "listLandedRecordNames",
478                format!("no landed-capture records in {}", git_dir.display()).as_str(),
479            );
480            return Ok(Vec::new());
481        }
482        Err(error) => {
483            return Err(CaptureError(format!(
484                "listing {} failed: {error}",
485                directory.display()
486            )));
487        }
488    };
489    // `mut` allows collecting the names one at a time.
490    let mut names: Vec<String> = Vec::new();
491    for item in listing {
492        match item {
493            Ok(entry) => names.push(entry.file_name().to_string_lossy().into_owned()),
494            Err(error) => {
495                return Err(CaptureError(format!(
496                    "listing {} failed: {error}",
497                    directory.display()
498                )));
499            }
500        }
501    }
502    return Ok(names);
503}
504
505/// What:
506///  The smallest next sequence number a published transaction of this store generation
507///       read before its base;
508///  `None` keeps every record (a published transaction has not
509///       captured yet);
510///  `Some(i64::MAX)` stands for the incumbent's `Infinity`.
511/// Why:
512///   `pinningBound`.
513///
514/// In TS you'd write (pseudocode):
515/// ```ts
516/// async function pinningBound({ registryRoot, worktreeId }): Promise<number | typeof KEEP_ALL>;
517/// ```
518fn pinning_bound(
519    registry_root: &Path,
520    worktree_id: Option<&str>,
521) -> Result<Option<i64>, CaptureError> {
522    let entries: Vec<RegistryEntry> = match list_transaction_entries(registry_root) {
523        Ok(found) => found,
524        Err(error) => return Err(CaptureError(error.0)),
525    };
526    // `mut` allows narrowing the bound transaction by transaction.
527    let mut bound: i64 = i64::MAX;
528    for entry in entries {
529        if entry.kind != EntryKind::Transaction {
530            continue;
531        }
532        let Some(captured) = read_captured_record(entry.path.as_path())? else {
533            return Ok(None);
534        };
535        if Some(captured.worktree_id.as_str()) == worktree_id {
536            bound = bound.min(captured.next_sequence_before_base);
537        }
538    }
539    return Ok(Some(bound));
540}
541
542/// What:
543///  Whether one landed-record file can go:
544///  not a record,
545///  unreadable,
546///  of another store
547///       generation,
548///  or recorded a next number below the bound.
549/// Why:
550///   `prunable`:
551///  a malformed record is reported and removed.
552///
553/// In TS you'd write (pseudocode):
554/// ```ts
555/// async function prunable({ directory, name, worktreeId, bound }): Promise<boolean>;
556/// ```
557fn prunable(directory: &Path, name: &str, worktree_id: Option<&str>, bound: i64) -> bool {
558    if !name.ends_with(LANDED_RECORD_SUFFIX) {
559        return true;
560    }
561    let path: PathBuf = directory.join(name);
562    let name_text: String = path.display().to_string();
563    let parsed: Result<LandedCaptureRecord, CaptureError> = match read_store_file(path.as_path()) {
564        Ok(Some(text)) => parse_landed_capture_record(text.as_str(), name_text.as_str()),
565        Ok(None) => Err(CaptureError(format!("{name_text} vanished"))),
566        Err(error) => Err(error),
567    };
568    match parsed {
569        Ok(record) => {
570            return Some(record.worktree_id.as_str()) != worktree_id
571                || record.next_sequence_after_landing < bound;
572        }
573        Err(error) => {
574            warn(
575                "pruneLandedCaptures",
576                format!("removing unreadable landed-capture record {name_text}: {error}").as_str(),
577            );
578            return true;
579        }
580    }
581}
582
583/// What:
584///  Remove every landed record no published transaction can replay over;
585///  returns how many.
586///       Never fails:
587///  a failure is reported and the records stay for the next pruning.
588/// Why:
589///   `pruneLandedCaptures` runs after every transaction removes its directory and after
590///       startup recovery,
591///  so the last transaction leaves no record behind.
592///
593/// In TS you'd write (pseudocode):
594/// ```ts
595/// async function pruneLandedCaptures({ gitDir, registryRoot }): Promise<number>;
596/// ```
597pub fn prune_landed_captures(git_dir: &Path, registry_root: &Path) -> usize {
598    match prune_or_fail(git_dir, registry_root) {
599        Ok(count) => return count,
600        Err(error) => {
601            warn(
602                "pruneLandedCaptures",
603                format!(
604                    "could not prune landed-capture records in {}; they stay for the next pruning: {error}",
605                    git_dir.display()
606                )
607                .as_str(),
608            );
609            return 0;
610        }
611    }
612}
613
614/// What:
615///  The pruning steps,
616///  with failures returned.
617/// Why:
618///   `prune_landed_captures` turns any failure into a warning.
619///
620/// In TS you'd write (pseudocode):
621/// ```ts
622/// // body of pruneLandedCaptures inside its try
623/// ```
624fn prune_or_fail(git_dir: &Path, registry_root: &Path) -> Result<usize, CaptureError> {
625    let names: Vec<String> = list_landed_record_names(git_dir)?;
626    if names.is_empty() {
627        return Ok(0);
628    }
629    let worktree_id: Option<String> = read_worktree_id(git_dir)?;
630    let Some(bound) = pinning_bound(registry_root, worktree_id.as_deref())? else {
631        debug(
632            "pruneLandedCaptures",
633            "a published transaction has not captured yet; keeping every landed-capture record",
634        );
635        return Ok(0);
636    };
637    let directory: PathBuf = landed_record_directory(git_dir);
638    // `mut` allows counting removals.
639    let mut removed: usize = 0;
640    for name in &names {
641        if !prunable(
642            directory.as_path(),
643            name.as_str(),
644            worktree_id.as_deref(),
645            bound,
646        ) {
647            continue;
648        }
649        let path: PathBuf = directory.join(name);
650        match std::fs::remove_file(&path) {
651            Ok(()) => removed += 1,
652            Err(error) if error.kind() == std::io::ErrorKind::NotFound => removed += 1,
653            Err(error) => {
654                return Err(CaptureError(format!(
655                    "removing {} failed: {error}",
656                    path.display()
657                )));
658            }
659        }
660    }
661    debug(
662        "pruneLandedCaptures",
663        format!("pruned {removed} of {} landed-capture records", names.len()).as_str(),
664    );
665    return Ok(removed);
666}
667
668/// The incumbent's `Infinity` bound cannot be exceeded by a safe integer.
669const _: () = assert!(MAX_SAFE_INTEGER < i64::MAX);
670
671/// Record controls stay out of the release executable.
672#[cfg(test)]
673#[path = "capture_records_tests.rs"]
674mod tests;