Skip to main content

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 durable writes and directory sync.
30use super::private_storage::{
31    sync_directory, write_exclusive_synced, write_private_file,
32};
33/// Owner lock acquisition, its poll delay, its silent wait notice and its failure.
34use super::owner_lock::{DEFAULT_POLL_DELAY, OwnerLockError, SilentWait, acquire_owner_lock};
35/// Random identity bytes formatted as a UUID.
36use super::random_id::random_uuid;
37/// One Git start and its failure text.
38use super::transaction_phase::{Phase, PhaseMarkerError, reach_transaction_phase};
39use super::transaction_git::{GitContext, GitFailure, GitOutput, GitRequest, run_git_checked};
40/// Journal enums: the commit selection mode and the schema constant.
41use super::transaction_journal::TransactionMode;
42/// Trim with JavaScript's whitespace rules.
43use super::js_text::trim_javascript;
44/// `Path`/`PathBuf` are borrowed/owned filesystem paths.
45use std::path::{Path, PathBuf};
46/// Sorted byte-keyed map of the staged entries.
47use std::collections::{BTreeMap, BTreeSet};
48/// `OsStr` is the operating-system text type used by process arguments.
49use std::ffi::OsStr;
50
51/// Name of the captured record inside a transaction directory.
52pub const CAPTURED_FILENAME: &str = "captured.json";
53
54/// Failure of a capture under capture order.
55///  Each variant keeps the incumbent's wording,
56///  so a diagnostic reads the same from either wrapper.
57#[derive(Debug)]
58pub enum CaptureOrderError {
59    /// The store, its identity or its sequence file is unusable.
60    Store(CaptureError),
61    /// The capture lock could not be taken.
62    Lock(OwnerLockError),
63    /// A Git read of the capture failed.
64    Git(GitFailure),
65    /// The transaction directory could not receive `captured.json`.
66    Write(String),
67    /// The test-only phase marker variable is malformed or its marker could not be written.
68    Phase(PhaseMarkerError),
69}
70
71impl From<CaptureError> for CaptureOrderError {
72    /// Wraps a store failure.
73    fn from(error: CaptureError) -> Self {
74        return Self::Store(error);
75    }
76}
77
78impl From<GitFailure> for CaptureOrderError {
79    /// Wraps a Git failure.
80    fn from(error: GitFailure) -> Self {
81        return Self::Git(error);
82    }
83}
84
85impl std::fmt::Display for CaptureOrderError {
86    /// Writes the wording of the wrapped failure.
87    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88        return match self {
89            Self::Store(error) => formatter.write_str(error.0.as_str()),
90            Self::Lock(error) => write!(formatter, "{error}"),
91            Self::Git(error) => write!(formatter, "{error}"),
92            Self::Write(text) => formatter.write_str(text.as_str()),
93            Self::Phase(error) => write!(formatter, "{error}"),
94        };
95    }
96}
97
98/// What:
99///  Position of one capture in its worktree's capture order.
100/// Why:
101///   Sequence numbers are only comparable within one store generation,
102///  so the identity travels with the number.
103///
104/// In TS you'd write (pseudocode):
105/// ```ts
106/// type CaptureStamp = { worktreeId: string; sequence: number };
107/// ```
108#[derive(Clone, Debug, Eq, PartialEq)]
109pub struct CaptureStamp {
110    /// Identity of the store that allocated the number.
111    pub worktree_id: String,
112    /// Capture sequence number, starting at 1.
113    pub sequence: i64,
114}
115
116/// What:
117///  Reads the store identity, creating it on the first capture.
118/// Why:
119///   A deleted store starts again with a new identity,
120///  so numbers of different generations are never compared.
121///
122/// In TS you'd write (pseudocode):
123/// ```ts
124/// await ensureWorktreeId(store);
125/// ```
126fn ensure_worktree_id(store: &Path) -> Result<String, CaptureError> {
127    let path: PathBuf = store.join(WORKTREE_ID_FILENAME);
128    if let Some(text) = read_store_file(path.as_path())? {
129        let recorded: &str = trim_javascript(text.as_str());
130        if recorded.is_empty() {
131            return Err(CaptureError(format!(
132                "Capture store worktree identity is empty: {}",
133                path.display()
134            )));
135        }
136        return Ok(String::from(recorded));
137    }
138    let identity: String = random_uuid().map_err(|error| { return CaptureError(error.0); })?;
139    write_private_file(path.as_path(), format!("{identity}\n").as_bytes())
140        .map_err(|error| { return CaptureError(format!("writing {} failed: {error}", path.display())); })?;
141    sync_directory(store)
142        .map_err(|error| { return CaptureError(format!("syncing {} failed: {error}", store.display())); })?;
143    return Ok(identity);
144}
145
146/// What:
147///  Writes the sequence file through a temporary file and a rename,
148///  so a reader sees the old number or the new one.
149/// Why:
150///   A crash after the rename leaves only a gap in the numbers.
151///
152/// In TS you'd write (pseudocode):
153/// ```ts
154/// await writePrivateFile({ path: tmp, bytes }); await rename(tmp, join(store, 'sequence'));
155/// ```
156pub fn write_sequence(store: &Path, sequence: i64) -> Result<(), CaptureError> {
157    let temporary: PathBuf = store.join(format!(
158        "{SEQUENCE_FILENAME}.{}.tmp",
159        random_uuid().map_err(|error| { return CaptureError(error.0); })?
160    ));
161    let written: Result<(), CaptureError> = write_private_file(
162        temporary.as_path(),
163        format!("{sequence}\n").as_bytes(),
164    )
165    .map_err(|error| { return CaptureError(format!("writing {} failed: {error}", temporary.display())); })
166    .and_then(|()| {
167        return std::fs::rename(&temporary, store.join(SEQUENCE_FILENAME)).map_err(|error| {
168            return CaptureError(format!("renaming {} failed: {error}", temporary.display()));
169        });
170    });
171    if let Err(error) = written {
172        // Removing the temporary file is best effort; the original failure is the answer.
173        let _ = std::fs::remove_file(&temporary);
174        return Err(error);
175    }
176    return sync_directory(store)
177        .map_err(|error| { return CaptureError(format!("syncing {} failed: {error}", store.display())); });
178}
179
180/// What:
181///  Allocates the next sequence number durably.
182/// Why:
183///   The caller holds the capture lock,
184///  so the read and the write cannot race another capture of this worktree.
185///
186/// In TS you'd write (pseudocode):
187/// ```ts
188/// const sequence = await readNextCaptureSequence(gitDir); await writeSequence(store, sequence);
189/// ```
190fn allocate_sequence(git_dir: &Path, store: &Path) -> Result<i64, CaptureError> {
191    let sequence: i64 = read_next_capture_sequence(git_dir)?;
192    write_sequence(store, sequence)?;
193    return Ok(sequence);
194}
195
196/// What:
197///  Runs one capture under the per-worktree capture lock and stamps it with the next sequence number.
198/// Why:
199///   The lock is held only for the capture itself,
200///  never across hooks, the editor or landing.
201///
202/// In TS you'd write (pseudocode):
203/// ```ts
204/// const { stamp, value } = await captureInOrder({ gitDir, capture });
205/// ```
206pub fn capture_in_order<Value, Failure: From<CaptureOrderError>>(
207    git_dir: &Path,
208    capture: impl FnOnce() -> Result<Value, Failure>,
209) -> Result<(CaptureStamp, Value), Failure> {
210    let store: PathBuf = ensure_capture_store(git_dir).map_err(CaptureOrderError::from)?;
211    // Released when this function returns, through the owner lock's drop.
212    let lock = acquire_owner_lock(
213        store.join(CAPTURE_LOCK_NAME).as_path(),
214        DEFAULT_POLL_DELAY,
215        &mut SilentWait,
216    )
217    .map_err(CaptureOrderError::Lock)?;
218    // The test-only marker of the incumbent's `captureInOrder`: reached once the capture lock is held.
219    reach_transaction_phase(Phase::CaptureLocked, None).map_err(CaptureOrderError::Phase)?;
220    let worktree_id: String = ensure_worktree_id(store.as_path()).map_err(CaptureOrderError::from)?;
221    let sequence: i64 = allocate_sequence(git_dir, store.as_path()).map_err(CaptureOrderError::from)?;
222    let value: Value = capture()?;
223    drop(lock);
224    return Ok((
225        CaptureStamp {
226            worktree_id,
227            sequence,
228        },
229        value,
230    ));
231}
232
233/// What:
234///  Writes `captured.json` exclusively and makes it durable.
235/// Why:
236///   A transaction directory holds one captured record,
237///  and a second write means a bug, so the exclusive open refuses it.
238///
239/// In TS you'd write (pseudocode):
240/// ```ts
241/// await writeCapturedRecord({ directory, record });
242/// ```
243pub fn write_captured_record(directory: &Path, record: &CapturedRecord) -> Result<(), CaptureOrderError> {
244    return write_exclusive_synced(
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;