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;