Skip to main content

git_policy_cli/
capture_store.rs

1//! What:
2//!  The per-worktree capture store:
3//!  its location,
4//!  its sequence file and its identity file,
5//!       read as the incumbent reads them.
6//! Why:
7//!  Every commit transaction captures under a short per-worktree lock and takes the next
8//!      sequence number,
9//!  so sequence numbers order the captured disk states of one worktree.
10//!      Recovery and pruning read the store;
11//!  both wrappers share it
12//!      (`src/policy-engine/commit-capture-order-store.ts`):
13//!      `<git-dir>/cli-git-captures/{capture.lock/, worktree-id, sequence, landed/<oid>.json}`.
14//!
15//! In TS you'd write (pseudocode):
16//! ```ts
17//! const next = await readNextCaptureSequence(gitDir);
18//! ```
19
20/// Debug diagnostics.
21use super::diagnostic_log::debug;
22/// Trimming with the incumbent's `.trim()` semantics.
23use super::js_text::trim_javascript;
24/// The largest safe integer.
25use super::json_record::MAX_SAFE_INTEGER;
26/// The private-directory creation the registry uses.
27use super::transaction_registry::ensure_transaction_root;
28/// `Path`/`PathBuf` are borrowed/owned filesystem paths.
29use std::path::{Path, PathBuf};
30
31/// Capture store directory name inside the worktree's Git directory.
32pub const CAPTURE_STORE_NAME: &str = "cli-git-captures";
33
34/// Capture lock directory name inside the store.
35pub const CAPTURE_LOCK_NAME: &str = "capture.lock";
36
37/// Worktree identity filename inside the store.
38pub const WORKTREE_ID_FILENAME: &str = "worktree-id";
39
40/// Sequence filename inside the store.
41pub const SEQUENCE_FILENAME: &str = "sequence";
42
43/// What:
44///  A malformed or unreadable capture-order state,
45///  with its message.
46/// Why:
47///   The incumbent's `CaptureOrderRecordError` and `TypeError`s of the store;
48///  recovery and
49///       pruning report them and continue.
50///
51/// In TS you'd write (pseudocode):
52/// ```ts
53/// class CaptureOrderRecordError extends Error {}
54/// ```
55#[derive(Clone, Debug, Eq, PartialEq)]
56pub struct CaptureError(pub String);
57
58/// What:
59///  The message as display text.
60/// Why:
61///   Warnings quote it.
62///
63/// In TS you'd write (pseudocode):
64/// ```ts
65/// error.message
66/// ```
67impl std::fmt::Display for CaptureError {
68    /// Writes the message.
69    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70        return formatter.write_str(self.0.as_str());
71    }
72}
73
74/// What:
75///  The capture store directory of a worktree.
76/// Why:
77///   `captureStorePath`.
78///
79/// In TS you'd write (pseudocode):
80/// ```ts
81/// captureStorePath('/repo/.git') // '/repo/.git/cli-git-captures'
82/// ```
83pub fn capture_store_path(git_dir: &Path) -> PathBuf {
84    return git_dir.join(CAPTURE_STORE_NAME);
85}
86
87/// What:
88///  The value of a sequence file:
89///  canonical decimal digits of a safe integer,
90///  then one
91///       newline.
92/// Why:
93///   `parseSequence`:
94///  `String(Number.parseInt(digits, 10)) === digits` admits no sign,
95///  no
96///       leading zero and no whitespace;
97///  anything else is a malformed store.
98///
99/// In TS you'd write (pseudocode):
100/// ```ts
101/// function parseSequence({ text, path }): number; // throws TypeError
102/// ```
103pub fn parse_sequence(bytes: &[u8], path: &Path) -> Result<i64, CaptureError> {
104    let Some(digits) = bytes.strip_suffix(b"\n") else {
105        return Err(malformed_sequence(path));
106    };
107    if digits.is_empty() || (digits.len() > 1 && digits[0] == b'0') {
108        return Err(malformed_sequence(path));
109    }
110    // `mut` allows accumulating the value digit by digit.
111    let mut value: i64 = 0;
112    for digit in digits {
113        if !digit.is_ascii_digit() {
114            return Err(malformed_sequence(path));
115        }
116        // Sixteen digits already exceed the safe range, so the value never overflows `i64`.
117        value = value * 10 + i64::from(digit - b'0');
118        if value > MAX_SAFE_INTEGER {
119            return Err(malformed_sequence(path));
120        }
121    }
122    return Ok(value);
123}
124
125/// What:
126///  The failure for a malformed sequence file.
127/// Why:
128///   Every malformed spelling reports the same message.
129///
130/// In TS you'd write (pseudocode):
131/// ```ts
132/// new TypeError(`Capture sequence file is malformed: ${path}`)
133/// ```
134fn malformed_sequence(path: &Path) -> CaptureError {
135    return CaptureError(format!(
136        "Capture sequence file is malformed: {}",
137        path.display()
138    ));
139}
140
141/// What:
142///  The next capture sequence number the store would allocate,
143///  read without the lock;
144///  an
145///       absent store or sequence file reads as 1.
146/// Why:
147///   `readNextCaptureSequence`:
148///  landings record it after their compare-and-swap.
149///
150/// In TS you'd write (pseudocode):
151/// ```ts
152/// async function readNextCaptureSequence(gitDir: string): Promise<number>;
153/// ```
154pub fn read_next_capture_sequence(git_dir: &Path) -> Result<i64, CaptureError> {
155    let path: PathBuf = capture_store_path(git_dir).join(SEQUENCE_FILENAME);
156    match std::fs::read(&path) {
157        Ok(bytes) => return Ok(parse_sequence(bytes.as_slice(), path.as_path())? + 1),
158        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
159            debug(
160                "readNextCaptureSequence",
161                format!("no capture sequence yet at {}", path.display()).as_str(),
162            );
163            return Ok(1);
164        }
165        Err(error) => {
166            return Err(CaptureError(format!(
167                "reading {} failed: {error}",
168                path.display()
169            )));
170        }
171    }
172}
173
174/// What:
175///  Create the store directory as a private,
176///  non-linked directory when it is missing.
177/// Why:
178///   `ensureCaptureStore`,
179///  through the registry's root creation.
180///
181/// In TS you'd write (pseudocode):
182/// ```ts
183/// await ensureCaptureStore(gitDir);
184/// ```
185pub fn ensure_capture_store(git_dir: &Path) -> Result<PathBuf, CaptureError> {
186    let store: PathBuf = capture_store_path(git_dir);
187    if let Err(error) = ensure_transaction_root(store.as_path()) {
188        return Err(CaptureError(error.0));
189    }
190    return Ok(store);
191}
192
193/// What:
194///  The text of a small regular store file read without following a link,
195///  decoded as
196///       UTF-8 with replacement characters.
197/// Why:
198///   `readStoreFile` reads with `'utf8'`,
199///  which never fails on bytes.
200///  A missing file is
201///       `Ok(None)`.
202///
203/// In TS you'd write (pseudocode):
204/// ```ts
205/// async function readStoreFile(path: string): Promise<string>; // ENOENT propagates
206/// ```
207pub fn read_store_file(path: &Path) -> Result<Option<String>, CaptureError> {
208    match super::private_storage::read_regular_file(path, STORE_FILE_LIMIT) {
209        Ok(bytes) => return Ok(Some(String::from_utf8_lossy(bytes.as_slice()).into_owned())),
210        Err(super::private_storage::ReadRefusal::Missing) => return Ok(None),
211        Err(super::private_storage::ReadRefusal::NotRegular) => {
212            return Err(CaptureError(format!(
213                "Capture store entry is not a regular file: {}",
214                path.display()
215            )));
216        }
217        Err(super::private_storage::ReadRefusal::TooLarge) => {
218            return Err(CaptureError(format!(
219                "Capture store entry is larger than {STORE_FILE_LIMIT} bytes: {}",
220                path.display()
221            )));
222        }
223        Err(super::private_storage::ReadRefusal::Io(error)) => {
224            return Err(CaptureError(format!(
225                "reading {} failed: {error}",
226                path.display()
227            )));
228        }
229    }
230}
231
232/// Largest store file read:
233///  records list captured paths,
234///  so they can be large.
235const STORE_FILE_LIMIT: u64 = 256 * 1024 * 1024;
236
237/// What:
238///  The store's worktree identity without creating one,
239///  or nothing when absent.
240/// Why:
241///   `readWorktreeId`:
242///  a record of another store generation is unusable.
243///
244/// In TS you'd write (pseudocode):
245/// ```ts
246/// async function readWorktreeId(gitDir: string): Promise<string | typeof WORKTREE_ID_ABSENT>;
247/// ```
248pub fn read_worktree_id(git_dir: &Path) -> Result<Option<String>, CaptureError> {
249    let path: PathBuf = capture_store_path(git_dir).join(WORKTREE_ID_FILENAME);
250    match read_store_file(path.as_path())? {
251        Some(text) => return Ok(Some(String::from(trim_javascript(text.as_str())))),
252        None => {
253            debug(
254                "readWorktreeId",
255                format!("no capture store identity in {}", git_dir.display()).as_str(),
256            );
257            return Ok(None);
258        }
259    }
260}
261
262/// Store controls stay out of the release executable.
263#[cfg(test)]
264#[path = "capture_store_tests.rs"]
265mod tests;