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;