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::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 makes it durable.
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///
236/// In TS you'd write (pseudocode):
237/// ```ts
238/// await writeCapturedRecord({ directory, record });
239/// ```
240pub fn write_captured_record(directory: &Path, record: &CapturedRecord) -> Result<(), CaptureOrderError> {
241    return write_exclusive_synced(
242        directory.join(CAPTURED_FILENAME).as_path(),
243        encode_captured_record(record).as_bytes(),
244    )
245    .map_err(|error| {
246        return CaptureOrderError::Write(format!(
247            "writing {} failed: {error}",
248            directory.join(CAPTURED_FILENAME).display()
249        ));
250    });
251}
252
253/// What:
254///  Decodes a Git path byte-for-byte as Latin-1,
255///  the incumbent's text for paths in JSON records.
256/// Why:
257///   Latin-1 maps each byte to one code point,
258///  so sorting the text sorts the raw bytes.
259///
260/// In TS you'd write (pseudocode):
261/// ```ts
262/// decodeLatin1(bytes)
263/// ```
264fn latin1(bytes: &[u8]) -> String {
265    return bytes.iter().map(|byte| { return char::from(*byte); }).collect();
266}
267
268/// What:
269///  Splits NUL-terminated Git output into its nonempty records.
270/// Why:
271///   `-z` output ends every record with NUL and never contains an empty record.
272///
273/// In TS you'd write (pseudocode):
274/// ```ts
275/// output.split('\0').filter((record) => record !== '')
276/// ```
277fn nul_records(output: &[u8]) -> Vec<&[u8]> {
278    return output
279        .split(|byte| { return *byte == 0; })
280        .filter(|record| { return !record.is_empty(); })
281        .collect();
282}
283
284/// What:
285///  The index and object-store inputs of one listing.
286/// Why:
287///   Staging and listing read the private index,
288///  and only staging writes objects into the shadow store.
289///
290/// In TS you'd write (pseudocode):
291/// ```ts
292/// type Workspace = Pick<CommitTransactionWorkspace, 'capturedIndexPath' | 'commitIndexPath' | 'objectDirectory'>;
293/// ```
294#[derive(Clone, Debug, Eq, PartialEq)]
295pub struct CaptureIndexes {
296    /// Real index copied at invocation.
297    pub captured_index: PathBuf,
298    /// Private commit index.
299    pub commit_index: PathBuf,
300    /// Shadow object store.
301    pub object_directory: PathBuf,
302}
303
304/// What:
305///  A Git request that reads or writes one private index,
306///  ignoring the invocation's global options as the incumbent does.
307/// Why:
308///   `GIT_INDEX_FILE` and `GIT_OBJECT_DIRECTORY` select the private state.
309///
310/// In TS you'd write (pseudocode):
311/// ```ts
312/// runTransactionGit({ gitPath, cwd, indexPath, objectDirectory, args })
313/// ```
314pub fn indexed_request<S: AsRef<OsStr>>(
315    cwd: &Path,
316    arguments: &[S],
317    index: Option<&Path>,
318    object_directory: Option<&Path>,
319) -> GitRequest {
320    let mut request: GitRequest = GitRequest::new(cwd, arguments);
321    request.without_prefix = true;
322    request.index = index.map(Path::to_path_buf);
323    request.object_directory = object_directory.map(Path::to_path_buf);
324    return request;
325}
326
327/// What:
328///  Reads an index as `<mode> <object> <stage>` per path, stages joined by `;`.
329/// Why:
330///   Comparing two indexes needs each path's staged state,
331///  including every conflict stage.
332///
333/// In TS you'd write (pseudocode):
334/// ```ts
335/// await stagedEntries({ gitPath, cwd, indexPath });
336/// ```
337fn staged_entries(
338    context: &GitContext,
339    cwd: &Path,
340    index: &Path,
341) -> Result<BTreeMap<Vec<u8>, String>, GitFailure> {
342    let output: GitOutput = run_git_checked(
343        context,
344        &indexed_request(cwd, &["ls-files", "--stage", "-z"], Some(index), None),
345    )?;
346    let mut entries: BTreeMap<Vec<u8>, String> = BTreeMap::new();
347    for record in nul_records(output.stdout.as_slice()) {
348        // Git always separates the entry from its path with a tab, so a missing tab cannot come from Git.
349        let Some(tab) = record.iter().position(|byte| { return *byte == b'\t'; }) else {
350            return Err(GitFailure(String::from("git ls-files --stage returned an unparsable record.")));
351        };
352        let staged: &mut String = entries.entry(record[tab + 1..].to_vec()).or_default();
353        staged.push_str(latin1(&record[..tab]).as_str());
354        staged.push(';');
355    }
356    return Ok(entries);
357}
358
359/// What:
360///  Lists the paths the capture read from the worktree,
361///  in the incumbent's order.
362/// Why:
363///   Explicit-path commits read every path the private index changes against the base;
364///  index commits that stage worktree content read only the paths their staging changed;
365///  any other index commit read no worktree bytes at capture.
366///
367/// In TS you'd write (pseudocode):
368/// ```ts
369/// await listWorktreeCapturedPaths({ gitPath, cwd, workspace, mode, stagesWorktree, baseRevision });
370/// ```
371pub fn list_worktree_captured_paths(
372    context: &GitContext,
373    cwd: &Path,
374    indexes: &CaptureIndexes,
375    mode: TransactionMode,
376    stages_worktree: bool,
377    base_revision: &str,
378) -> Result<Vec<String>, GitFailure> {
379    if mode == TransactionMode::ExplicitPath {
380        let output: GitOutput = run_git_checked(
381            context,
382            &indexed_request(
383                cwd,
384                &[
385                    "diff-index",
386                    "--cached",
387                    "--name-only",
388                    "-z",
389                    "--no-renames",
390                    base_revision,
391                ],
392                Some(indexes.commit_index.as_path()),
393                Some(indexes.object_directory.as_path()),
394            ),
395        )?;
396        return Ok(nul_records(output.stdout.as_slice())
397            .into_iter()
398            .map(latin1)
399            .collect());
400    }
401    if !stages_worktree {
402        return Ok(Vec::new());
403    }
404    let before: BTreeMap<Vec<u8>, String> =
405        staged_entries(context, cwd, indexes.captured_index.as_path())?;
406    let after: BTreeMap<Vec<u8>, String> =
407        staged_entries(context, cwd, indexes.commit_index.as_path())?;
408    // Union of both key sets, in byte order, keeping paths whose staged state changed.
409    let mut changed: BTreeSet<Vec<u8>> = BTreeSet::new();
410    for path in before.keys().chain(after.keys()) {
411        if before.get(path) != after.get(path) {
412            changed.insert(path.clone());
413        }
414    }
415    return Ok(changed.iter().map(|path| { return latin1(path.as_slice()); }).collect());
416}
417
418/// What:
419///  Runs the capture under capture order and journals it.
420/// Why:
421///   The sequence is taken before the capture reads any bytes,
422///  and `captured.json` is written only after the worktree listing is known.
423///
424/// In TS you'd write (pseudocode):
425/// ```ts
426/// await captureCommitContent({ gitPath, cwd, gitDir, workspace, mode, stagesWorktree, baseRevision, nextSequenceBeforeBase, capture });
427/// ```
428pub fn capture_commit_content(
429    context: &GitContext,
430    cwd: &Path,
431    git_dir: &Path,
432    directory: &Path,
433    indexes: &CaptureIndexes,
434    mode: TransactionMode,
435    stages_worktree: bool,
436    base_revision: &str,
437    next_sequence_before_base: i64,
438    capture: impl FnOnce() -> Result<(), CaptureOrderError>,
439) -> Result<CapturedRecord, CaptureOrderError> {
440    let (stamp, ()) = capture_in_order(git_dir, capture)?;
441    let worktree_paths: Vec<String> = list_worktree_captured_paths(
442        context,
443        cwd,
444        indexes,
445        mode,
446        stages_worktree,
447        base_revision,
448    )?;
449    let record: CapturedRecord = CapturedRecord {
450        worktree_id: stamp.worktree_id,
451        sequence: stamp.sequence,
452        next_sequence_before_base,
453        worktree_paths,
454    };
455    write_captured_record(directory, &record)?;
456    return Ok(record);
457}
458
459/// Capture-order controls stay out of the release executable.
460#[cfg(test)]
461#[path = "capture_order_tests.rs"]
462mod tests;