Skip to main content

git_policy_cli/
replay_capture_order.rs

1//! What:
2//!  Capture order for one replay:
3//!       the landed first-parent changes, the per-path decision between the prepared and the landed captures,
4//!       and the parser of the `git diff-tree --stdin` output that lists the changes.
5//! Why:
6//!  When the prepared commit and every landed commit that changed a path captured it from the same worktree,
7//!       the later capture's bytes land, which is what sequential native commits record from the shared disk.
8//!       Every other path keeps subsumption and then the three-way merge.
9//!
10//! In TS you'd write (pseudocode):
11//! ```ts
12//! decideCaptureOrder({ path, captured, history, landed }); // 'prepared' | 'landed' | 'unordered'
13//! ```
14
15/// Ordered and keyed collections of paths and captures.
16use std::collections::{BTreeMap, BTreeSet};
17/// Error type of the parsers.
18use std::fmt;
19
20/// What:
21///  One capture with the paths its worktree read, Latin-1 decoded.
22/// Why:
23///   Captures are compared by worktree identity and sequence number only.
24///
25/// In TS you'd write (pseudocode):
26/// ```ts
27/// type CaptureView = { worktreeId: string; sequence: number; paths: ReadonlySet<string> };
28/// ```
29#[derive(Clone, Debug, Eq, PartialEq)]
30pub struct CaptureView {
31    /// Identity of the worktree that captured.
32    pub worktree_id: String,
33    /// Capture sequence number.
34    pub sequence: i64,
35    /// Paths whose bytes the capture read from the worktree.
36    pub paths: BTreeSet<String>,
37}
38
39/// What:
40///  One landed commit and the paths it changed against its first parent.
41/// Why:
42///   The decision needs each landed commit's changed paths to know which captures to consult.
43///
44/// In TS you'd write (pseudocode):
45/// ```ts
46/// type LandedChange = { commit: string; paths: ReadonlySet<string> };
47/// ```
48#[derive(Clone, Debug, Eq, PartialEq)]
49pub struct LandedChange {
50    /// Commit ID.
51    pub commit: String,
52    /// Changed paths, Latin-1 decoded.
53    pub paths: BTreeSet<String>,
54}
55
56/// What:
57///  The capture-order decision for one shared path.
58/// Why:
59///   Replay acts on the three answers differently: the prepared entry lands, the landed entry stays,
60///       or subsumption and the merge decide.
61///
62/// In TS you'd write (pseudocode):
63/// ```ts
64/// type CaptureOrderDecision = 'prepared' | 'landed' | 'unordered';
65/// ```
66#[derive(Clone, Copy, Debug, Eq, PartialEq)]
67pub enum CaptureOrderDecision {
68    /// The prepared commit captured later: its entry lands.
69    Prepared,
70    /// A landed commit captured later: the landed entry stays.
71    Landed,
72    /// Capture order does not apply: subsumption, then the three-way merge.
73    Unordered,
74}
75
76/// What:
77///  Diff-tree output that does not have the shape `git diff-tree --stdin -r -z --raw` writes.
78/// Why:
79///   A raw record without a commit before it cannot be attributed, so the replay fails.
80///
81/// In TS you'd write (pseudocode):
82/// ```ts
83/// throw new TypeError('git diff-tree printed a raw record without a commit or a path.');
84/// ```
85#[derive(Clone, Debug, Eq, PartialEq)]
86pub struct CaptureOrderFormatError(pub String);
87
88impl fmt::Display for CaptureOrderFormatError {
89    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
90        return write!(formatter, "{}", self.0);
91    }
92}
93
94/// What:
95///  Builds a capture view from a capture's stamp and its recorded worktree paths.
96/// Why:
97///   Records and the transaction's own capture share one comparison shape.
98///
99/// In TS you'd write (pseudocode):
100/// ```ts
101/// captureView({ stamp, worktreePaths });
102/// ```
103pub fn capture_view(worktree_id: &str, sequence: i64, worktree_paths: &[String]) -> CaptureView {
104    return CaptureView {
105        worktree_id: String::from(worktree_id),
106        sequence,
107        paths: worktree_paths.iter().cloned().collect(),
108    };
109}
110
111/// What:
112///  Parses `git diff-tree --stdin -r -z --raw` output:
113///       each commit ID NUL-terminated, then its raw records, each a `:` header and a path, both NUL-terminated.
114/// Why:
115///   One process lists every landed commit's changed paths, so the parser keeps commits in output order.
116///
117/// In TS you'd write (pseudocode):
118/// ```ts
119/// parseCommitChanges('abc\0:100644 100644 1 2 M\0a.txt\0'); // [{ commit: 'abc', paths: Set(['a.txt']) }]
120/// ```
121pub fn parse_commit_changes(output: &str) -> Result<Vec<LandedChange>, CaptureOrderFormatError> {
122    // `mut` lets records be attached to the most recent commit as they are read.
123    let mut commits: Vec<LandedChange> = Vec::new();
124    let tokens: Vec<&str> = output.split('\0').collect();
125    let mut cursor: usize = 0;
126    while cursor < tokens.len() {
127        let token: &str = tokens[cursor];
128        cursor += 1;
129        if token.starts_with(':') {
130            let path: &str = match tokens.get(cursor) {
131                Some(found) => found,
132                None => {
133                    return Err(CaptureOrderFormatError(String::from(
134                        "git diff-tree printed a raw record without a commit or a path.",
135                    )));
136                }
137            };
138            cursor += 1;
139            let owner: &mut LandedChange = match commits.last_mut() {
140                Some(found) => found,
141                None => {
142                    return Err(CaptureOrderFormatError(String::from(
143                        "git diff-tree printed a raw record without a commit or a path.",
144                    )));
145                }
146            };
147            owner.paths.insert(String::from(path));
148        } else if !token.is_empty() {
149            commits.push(LandedChange {
150                commit: String::from(token),
151                paths: BTreeSet::new(),
152            });
153        }
154    }
155    return Ok(commits);
156}
157
158/// What:
159///  Decides one shared path from the prepared capture, the landed history, and the landed captures.
160/// Why:
161///   The prepared capture must have read the path from the worktree, and every landed commit that changed
162///       the path must have a capture from the same worktree that also read it;
163///       the later sequence number then decides.
164///
165/// In TS you'd write (pseudocode):
166/// ```ts
167/// decideCaptureOrder({ path, captured, history, landed });
168/// ```
169pub fn decide_capture_order(
170    path: &str,
171    captured: &CaptureView,
172    history: &[LandedChange],
173    landed: &BTreeMap<String, CaptureView>,
174) -> CaptureOrderDecision {
175    if !captured.paths.contains(path) {
176        return CaptureOrderDecision::Unordered;
177    }
178    // Landed commits that changed the path.
179    let touching: Vec<&LandedChange> = history
180        .iter()
181        .filter(|change| { return change.paths.contains(path); })
182        .collect();
183    // Their captures of the path from this worktree; a commit without one leaves the list shorter.
184    let ordered: Vec<&CaptureView> = touching
185        .iter()
186        .filter_map(|change| { return landed.get(&change.commit); })
187        .filter(|record| { return record.worktree_id == captured.worktree_id && record.paths.contains(path); })
188        .collect();
189    if touching.is_empty() || ordered.len() != touching.len() {
190        return CaptureOrderDecision::Unordered;
191    }
192    let latest: i64 = ordered.iter().map(|record| { return record.sequence; }).max().unwrap_or(i64::MIN);
193    if captured.sequence > latest {
194        return CaptureOrderDecision::Prepared;
195    }
196    return CaptureOrderDecision::Landed;
197}
198
199#[cfg(test)]
200#[path = "replay_capture_order_tests.rs"]
201mod tests;