git_policy_cli/capture_order.rs
1//! What:
2//! The capture under capture order:
3//! the per-worktree capture lock,
4//! the store identity,
5//! the sequence allocation and its durable write,
6//! the listing of the paths the capture read from the worktree,
7//! and `captured.json`.
8//! Why:
9//! Every commit transaction captures its content while holding the short per-worktree capture lock
10//! and takes the next sequence number under it,
11//! so sequence numbers give one total order of the captured disk states of a worktree.
12//! The incumbent and this wrapper share the store,
13//! so the lock, the identity and the write order must match exactly
14//! (`src/policy-engine/commit-capture-order-store.ts`,
15//! `commit-capture-order-capture.ts`).
16//!
17//! In TS you'd write (pseudocode):
18//! ```ts
19//! const { stamp, value } = await captureInOrder({ gitDir, capture });
20//! ```
21
22/// Store layout names, the sequence reader and the store read.
23use super::capture_store::{
24 CAPTURE_LOCK_NAME, CaptureError, SEQUENCE_FILENAME, WORKTREE_ID_FILENAME,
25 ensure_capture_store, read_next_capture_sequence, read_store_file,
26};
27/// Record encoding and the `captured.json` name.
28use super::capture_records::{CapturedRecord, encode_captured_record};
29/// Exclusive writes, durable and not, and directory sync.
30use super::private_storage::{
31 sync_directory, write_exclusive_unsynced, write_private_file,
32};
33/// Owner lock acquisition, its poll delay, its silent wait notice and its failure.
34use super::live_lock::acquire_live_owner_lock;
35use super::owner_lock::OwnerLockError;
36/// Random identity bytes formatted as a UUID.
37use super::random_id::random_uuid;
38/// One Git start and its failure text.
39use super::transaction_phase::{Phase, PhaseMarkerError, reach_transaction_phase};
40use super::transaction_git::{GitContext, GitFailure, GitOutput, GitRequest, run_git_checked};
41/// Journal enums: the commit selection mode and the schema constant.
42use super::transaction_journal::TransactionMode;
43/// Trim with JavaScript's whitespace rules.
44use super::js_text::trim_javascript;
45/// `Path`/`PathBuf` are borrowed/owned filesystem paths.
46use std::path::{Path, PathBuf};
47/// Sorted byte-keyed map of the staged entries.
48use std::collections::{BTreeMap, BTreeSet};
49/// `OsStr` is the operating-system text type used by process arguments.
50use std::ffi::OsStr;
51
52/// Name of the captured record inside a transaction directory.
53pub const CAPTURED_FILENAME: &str = "captured.json";
54
55/// Failure of a capture under capture order.
56/// Each variant keeps the incumbent's wording,
57/// so a diagnostic reads the same from either wrapper.
58#[derive(Debug)]
59pub enum CaptureOrderError {
60 /// The store, its identity or its sequence file is unusable.
61 Store(CaptureError),
62 /// The capture lock could not be taken.
63 Lock(OwnerLockError),
64 /// A Git read of the capture failed.
65 Git(GitFailure),
66 /// The transaction directory could not receive `captured.json`.
67 Write(String),
68 /// The test-only phase marker variable is malformed or its marker could not be written.
69 Phase(PhaseMarkerError),
70}
71
72impl From<CaptureError> for CaptureOrderError {
73 /// Wraps a store failure.
74 fn from(error: CaptureError) -> Self {
75 return Self::Store(error);
76 }
77}
78
79impl From<GitFailure> for CaptureOrderError {
80 /// Wraps a Git failure.
81 fn from(error: GitFailure) -> Self {
82 return Self::Git(error);
83 }
84}
85
86impl std::fmt::Display for CaptureOrderError {
87 /// Writes the wording of the wrapped failure.
88 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89 return match self {
90 Self::Store(error) => formatter.write_str(error.0.as_str()),
91 Self::Lock(error) => write!(formatter, "{error}"),
92 Self::Git(error) => write!(formatter, "{error}"),
93 Self::Write(text) => formatter.write_str(text.as_str()),
94 Self::Phase(error) => write!(formatter, "{error}"),
95 };
96 }
97}
98
99/// What:
100/// Position of one capture in its worktree's capture order.
101/// Why:
102/// Sequence numbers are only comparable within one store generation,
103/// so the identity travels with the number.
104///
105/// In TS you'd write (pseudocode):
106/// ```ts
107/// type CaptureStamp = { worktreeId: string; sequence: number };
108/// ```
109#[derive(Clone, Debug, Eq, PartialEq)]
110pub struct CaptureStamp {
111 /// Identity of the store that allocated the number.
112 pub worktree_id: String,
113 /// Capture sequence number, starting at 1.
114 pub sequence: i64,
115}
116
117/// What:
118/// Reads the store identity, creating it on the first capture.
119/// Why:
120/// A deleted store starts again with a new identity,
121/// so numbers of different generations are never compared.
122///
123/// In TS you'd write (pseudocode):
124/// ```ts
125/// await ensureWorktreeId(store);
126/// ```
127fn ensure_worktree_id(store: &Path) -> Result<String, CaptureError> {
128 let path: PathBuf = store.join(WORKTREE_ID_FILENAME);
129 if let Some(text) = read_store_file(path.as_path())? {
130 let recorded: &str = trim_javascript(text.as_str());
131 if recorded.is_empty() {
132 return Err(CaptureError(format!(
133 "Capture store worktree identity is empty: {}",
134 path.display()
135 )));
136 }
137 return Ok(String::from(recorded));
138 }
139 let identity: String = random_uuid().map_err(|error| { return CaptureError(error.0); })?;
140 write_private_file(path.as_path(), format!("{identity}\n").as_bytes())
141 .map_err(|error| { return CaptureError(format!("writing {} failed: {error}", path.display())); })?;
142 sync_directory(store)
143 .map_err(|error| { return CaptureError(format!("syncing {} failed: {error}", store.display())); })?;
144 return Ok(identity);
145}
146
147/// What:
148/// Writes the sequence file through a temporary file and a rename,
149/// so a reader sees the old number or the new one.
150/// Why:
151/// A crash after the rename leaves only a gap in the numbers.
152///
153/// In TS you'd write (pseudocode):
154/// ```ts
155/// await writePrivateFile({ path: tmp, bytes }); await rename(tmp, join(store, 'sequence'));
156/// ```
157pub fn write_sequence(store: &Path, sequence: i64) -> Result<(), CaptureError> {
158 let temporary: PathBuf = store.join(format!(
159 "{SEQUENCE_FILENAME}.{}.tmp",
160 random_uuid().map_err(|error| { return CaptureError(error.0); })?
161 ));
162 let written: Result<(), CaptureError> = write_private_file(
163 temporary.as_path(),
164 format!("{sequence}\n").as_bytes(),
165 )
166 .map_err(|error| { return CaptureError(format!("writing {} failed: {error}", temporary.display())); })
167 .and_then(|()| {
168 return std::fs::rename(&temporary, store.join(SEQUENCE_FILENAME)).map_err(|error| {
169 return CaptureError(format!("renaming {} failed: {error}", temporary.display()));
170 });
171 });
172 if let Err(error) = written {
173 // Removing the temporary file is best effort; the original failure is the answer.
174 let _ = std::fs::remove_file(&temporary);
175 return Err(error);
176 }
177 return sync_directory(store)
178 .map_err(|error| { return CaptureError(format!("syncing {} failed: {error}", store.display())); });
179}
180
181/// What:
182/// Allocates the next sequence number durably.
183/// Why:
184/// The caller holds the capture lock,
185/// so the read and the write cannot race another capture of this worktree.
186///
187/// In TS you'd write (pseudocode):
188/// ```ts
189/// const sequence = await readNextCaptureSequence(gitDir); await writeSequence(store, sequence);
190/// ```
191fn allocate_sequence(git_dir: &Path, store: &Path) -> Result<i64, CaptureError> {
192 let sequence: i64 = read_next_capture_sequence(git_dir)?;
193 write_sequence(store, sequence)?;
194 return Ok(sequence);
195}
196
197/// What:
198/// Runs one capture under the per-worktree capture lock and stamps it with the next sequence number.
199/// Why:
200/// The lock is held only for the capture itself,
201/// never across hooks, the editor or landing.
202///
203/// In TS you'd write (pseudocode):
204/// ```ts
205/// const { stamp, value } = await captureInOrder({ gitDir, capture });
206/// ```
207pub fn capture_in_order<Value, Failure: From<CaptureOrderError>>(
208 git_dir: &Path,
209 capture: impl FnOnce() -> Result<Value, Failure>,
210) -> Result<(CaptureStamp, Value), Failure> {
211 let store: PathBuf = ensure_capture_store(git_dir).map_err(CaptureOrderError::from)?;
212 // Released when this function returns, through the owner lock's drop.
213 let lock = acquire_live_owner_lock(store.join(CAPTURE_LOCK_NAME).as_path(), "the capture lock")
214 .map_err(CaptureOrderError::Lock)?;
215 // The test-only marker of the incumbent's `captureInOrder`: reached once the capture lock is held.
216 reach_transaction_phase(Phase::CaptureLocked, None).map_err(CaptureOrderError::Phase)?;
217 let worktree_id: String = ensure_worktree_id(store.as_path()).map_err(CaptureOrderError::from)?;
218 let sequence: i64 = allocate_sequence(git_dir, store.as_path()).map_err(CaptureOrderError::from)?;
219 let value: Value = capture()?;
220 drop(lock);
221 return Ok((
222 CaptureStamp {
223 worktree_id,
224 sequence,
225 },
226 value,
227 ));
228}
229
230/// What:
231/// Writes `captured.json` exclusively and whole, without syncing it.
232/// Why:
233/// A transaction directory holds one captured record,
234/// and a second write means a bug, so the exclusive open refuses it.
235/// A torn or lost record only costs a warning and subsumption in later replays (the tearing table in
236/// `doc/planning/cli-git-spawn-and-sync-reduction.md`), so no flush is worth waiting for.
237///
238/// In TS you'd write (pseudocode):
239/// ```ts
240/// await writeCapturedRecord({ directory, record });
241/// ```
242pub fn write_captured_record(directory: &Path, record: &CapturedRecord) -> Result<(), CaptureOrderError> {
243 // Unsynced: a torn or lost record costs a warning and subsumption in later replays, not the landing.
244 return write_exclusive_unsynced(
245 directory.join(CAPTURED_FILENAME).as_path(),
246 encode_captured_record(record).as_bytes(),
247 )
248 .map_err(|error| {
249 return CaptureOrderError::Write(format!(
250 "writing {} failed: {error}",
251 directory.join(CAPTURED_FILENAME).display()
252 ));
253 });
254}
255
256/// What:
257/// Decodes a Git path byte-for-byte as Latin-1,
258/// the incumbent's text for paths in JSON records.
259/// Why:
260/// Latin-1 maps each byte to one code point,
261/// so sorting the text sorts the raw bytes.
262///
263/// In TS you'd write (pseudocode):
264/// ```ts
265/// decodeLatin1(bytes)
266/// ```
267fn latin1(bytes: &[u8]) -> String {
268 return bytes.iter().map(|byte| { return char::from(*byte); }).collect();
269}
270
271/// What:
272/// Splits NUL-terminated Git output into its nonempty records.
273/// Why:
274/// `-z` output ends every record with NUL and never contains an empty record.
275///
276/// In TS you'd write (pseudocode):
277/// ```ts
278/// output.split('\0').filter((record) => record !== '')
279/// ```
280fn nul_records(output: &[u8]) -> Vec<&[u8]> {
281 return output
282 .split(|byte| { return *byte == 0; })
283 .filter(|record| { return !record.is_empty(); })
284 .collect();
285}
286
287/// What:
288/// The index and object-store inputs of one listing.
289/// Why:
290/// Staging and listing read the private index,
291/// and only staging writes objects into the shadow store.
292///
293/// In TS you'd write (pseudocode):
294/// ```ts
295/// type Workspace = Pick<CommitTransactionWorkspace, 'capturedIndexPath' | 'commitIndexPath' | 'objectDirectory'>;
296/// ```
297#[derive(Clone, Debug, Eq, PartialEq)]
298pub struct CaptureIndexes {
299 /// Real index copied at invocation.
300 pub captured_index: PathBuf,
301 /// Private commit index.
302 pub commit_index: PathBuf,
303 /// Shadow object store.
304 pub object_directory: PathBuf,
305}
306
307/// What:
308/// A Git request that reads or writes one private index,
309/// ignoring the invocation's global options as the incumbent does.
310/// Why:
311/// `GIT_INDEX_FILE` and `GIT_OBJECT_DIRECTORY` select the private state.
312///
313/// In TS you'd write (pseudocode):
314/// ```ts
315/// runTransactionGit({ gitPath, cwd, indexPath, objectDirectory, args })
316/// ```
317pub fn indexed_request<S: AsRef<OsStr>>(
318 cwd: &Path,
319 arguments: &[S],
320 index: Option<&Path>,
321 object_directory: Option<&Path>,
322) -> GitRequest {
323 let mut request: GitRequest = GitRequest::new(cwd, arguments);
324 request.without_prefix = true;
325 request.index = index.map(Path::to_path_buf);
326 request.object_directory = object_directory.map(Path::to_path_buf);
327 return request;
328}
329
330/// What:
331/// Reads an index as `<mode> <object> <stage>` per path, stages joined by `;`.
332/// Why:
333/// Comparing two indexes needs each path's staged state,
334/// including every conflict stage.
335///
336/// In TS you'd write (pseudocode):
337/// ```ts
338/// await stagedEntries({ gitPath, cwd, indexPath });
339/// ```
340fn staged_entries(
341 context: &GitContext,
342 cwd: &Path,
343 index: &Path,
344) -> Result<BTreeMap<Vec<u8>, String>, GitFailure> {
345 let output: GitOutput = run_git_checked(
346 context,
347 &indexed_request(cwd, &["ls-files", "--stage", "-z"], Some(index), None),
348 )?;
349 let mut entries: BTreeMap<Vec<u8>, String> = BTreeMap::new();
350 for record in nul_records(output.stdout.as_slice()) {
351 // Git always separates the entry from its path with a tab, so a missing tab cannot come from Git.
352 let Some(tab) = record.iter().position(|byte| { return *byte == b'\t'; }) else {
353 return Err(GitFailure(String::from("git ls-files --stage returned an unparsable record.")));
354 };
355 let staged: &mut String = entries.entry(record[tab + 1..].to_vec()).or_default();
356 staged.push_str(latin1(&record[..tab]).as_str());
357 staged.push(';');
358 }
359 return Ok(entries);
360}
361
362/// What:
363/// Lists the paths the capture read from the worktree,
364/// in the incumbent's order.
365/// Why:
366/// Explicit-path commits read every path the private index changes against the base;
367/// index commits that stage worktree content read only the paths their staging changed;
368/// any other index commit read no worktree bytes at capture.
369///
370/// In TS you'd write (pseudocode):
371/// ```ts
372/// await listWorktreeCapturedPaths({ gitPath, cwd, workspace, mode, stagesWorktree, baseRevision });
373/// ```
374pub fn list_worktree_captured_paths(
375 context: &GitContext,
376 cwd: &Path,
377 indexes: &CaptureIndexes,
378 mode: TransactionMode,
379 stages_worktree: bool,
380 base_revision: &str,
381) -> Result<Vec<String>, GitFailure> {
382 if mode == TransactionMode::ExplicitPath {
383 let output: GitOutput = run_git_checked(
384 context,
385 &indexed_request(
386 cwd,
387 &[
388 "diff-index",
389 "--cached",
390 "--name-only",
391 "-z",
392 "--no-renames",
393 base_revision,
394 ],
395 Some(indexes.commit_index.as_path()),
396 Some(indexes.object_directory.as_path()),
397 ),
398 )?;
399 return Ok(nul_records(output.stdout.as_slice())
400 .into_iter()
401 .map(latin1)
402 .collect());
403 }
404 if !stages_worktree {
405 return Ok(Vec::new());
406 }
407 let before: BTreeMap<Vec<u8>, String> =
408 staged_entries(context, cwd, indexes.captured_index.as_path())?;
409 let after: BTreeMap<Vec<u8>, String> =
410 staged_entries(context, cwd, indexes.commit_index.as_path())?;
411 // Union of both key sets, in byte order, keeping paths whose staged state changed.
412 let mut changed: BTreeSet<Vec<u8>> = BTreeSet::new();
413 for path in before.keys().chain(after.keys()) {
414 if before.get(path) != after.get(path) {
415 changed.insert(path.clone());
416 }
417 }
418 return Ok(changed.iter().map(|path| { return latin1(path.as_slice()); }).collect());
419}
420
421/// What:
422/// Runs the capture under capture order and journals it.
423/// Why:
424/// The sequence is taken before the capture reads any bytes,
425/// and `captured.json` is written only after the worktree listing is known.
426///
427/// In TS you'd write (pseudocode):
428/// ```ts
429/// await captureCommitContent({ gitPath, cwd, gitDir, workspace, mode, stagesWorktree, baseRevision, nextSequenceBeforeBase, capture });
430/// ```
431pub fn capture_commit_content(
432 context: &GitContext,
433 cwd: &Path,
434 git_dir: &Path,
435 directory: &Path,
436 indexes: &CaptureIndexes,
437 mode: TransactionMode,
438 stages_worktree: bool,
439 base_revision: &str,
440 next_sequence_before_base: i64,
441 capture: impl FnOnce() -> Result<(), CaptureOrderError>,
442) -> Result<CapturedRecord, CaptureOrderError> {
443 let (stamp, ()) = capture_in_order(git_dir, capture)?;
444 let worktree_paths: Vec<String> = list_worktree_captured_paths(
445 context,
446 cwd,
447 indexes,
448 mode,
449 stages_worktree,
450 base_revision,
451 )?;
452 let record: CapturedRecord = CapturedRecord {
453 worktree_id: stamp.worktree_id,
454 sequence: stamp.sequence,
455 next_sequence_before_base,
456 worktree_paths,
457 };
458 write_captured_record(directory, &record)?;
459 return Ok(record);
460}
461
462/// Capture-order controls stay out of the release executable.
463#[cfg(test)]
464#[path = "capture_order_tests.rs"]
465mod tests;