git_policy_cli/replay_shared_paths.rs
1//! What:
2//! The paths both the prepared commit and the landed history changed since the preparation base,
3//! with the three tree entries subsumption compares.
4//! Why:
5//! Subsumption decides per shared path; the listing is two `git diff-tree` runs whatever the history length,
6//! and paths stay Latin-1 decoded so every byte survives and encodes back exactly.
7//!
8//! In TS you'd write (pseudocode):
9//! ```ts
10//! await listSharedPaths({ gitPath, shadowPath, mergeBase, current, prepared });
11//! ```
12
13/// Shadow runs for the two listings.
14use super::replay_shadow::run_shadow_checked;
15/// The shadow runner's context and failure.
16use super::transaction_git::{GitContext, GitFailure};
17/// `Path` names the shadow and the working directory.
18use std::path::Path;
19
20/// Git's absent-entry mode in raw diff output.
21const ABSENT_MODE: &str = "000000";
22
23/// What:
24/// One tree entry: its mode and object ID.
25/// Why:
26/// Subsumption compares modes as well as objects, and a regular file's mode decides the synthetic base.
27///
28/// In TS you'd write (pseudocode):
29/// ```ts
30/// type TreeEntry = { mode: string; oid: string };
31/// ```
32#[derive(Clone, Debug, Eq, PartialEq)]
33pub struct TreeEntry {
34 /// Git mode, such as `100644`.
35 pub mode: String,
36 /// Object ID.
37 pub oid: String,
38}
39
40/// What:
41/// One change of a raw diff record: the path and its entries before and after.
42/// Why:
43/// Both listings produce these; a path missing from one side has an absent entry.
44///
45/// In TS you'd write (pseudocode):
46/// ```ts
47/// type RawChange = { path; fromMode; fromOid; toMode; toOid };
48/// ```
49#[derive(Clone, Debug, Eq, PartialEq)]
50pub struct RawChange {
51 /// Path, Latin-1 decoded.
52 pub path: String,
53 /// Entry before the change, absent for an addition.
54 pub from: Option<TreeEntry>,
55 /// Entry after the change, absent for a deletion.
56 pub to: Option<TreeEntry>,
57}
58
59/// What:
60/// One path both sides changed, with its three entries.
61/// Why:
62/// The subsumption rules read the base, landed, and prepared entries together.
63///
64/// In TS you'd write (pseudocode):
65/// ```ts
66/// type SharedPath = { path; base: SideEntry; landed: SideEntry; prepared: SideEntry };
67/// ```
68#[derive(Clone, Debug, Eq, PartialEq)]
69pub struct SharedPath {
70 /// Repository path, Latin-1 decoded.
71 pub path: String,
72 /// Preparation base entry, absent when the path was added after it.
73 pub base: Option<TreeEntry>,
74 /// Landed entry, absent when the landed history deleted the path.
75 pub landed: Option<TreeEntry>,
76 /// Prepared entry, absent when the prepared commit deleted the path.
77 pub prepared: Option<TreeEntry>,
78}
79
80/// What:
81/// Raw diff output that does not have the expected shape.
82/// Why:
83/// A malformed record cannot be attributed, so the replay fails rather than guess.
84///
85/// In TS you'd write (pseudocode):
86/// ```ts
87/// throw new TypeError('malformed raw diff record');
88/// ```
89#[derive(Clone, Debug, Eq, PartialEq)]
90pub struct RawFormatError(pub String);
91
92/// What:
93/// Converts one raw mode and object ID into an entry, absent for the all-zero mode.
94/// Why:
95/// Git writes `000000` for an absent side of a raw record.
96///
97/// In TS you'd write (pseudocode):
98/// ```ts
99/// entryOf({ mode, oid }); // ABSENT_ENTRY for mode 000000
100/// ```
101fn entry_of(mode: &str, oid: &str) -> Option<TreeEntry> {
102 if mode == ABSENT_MODE {
103 return None;
104 }
105 return Some(TreeEntry {
106 mode: String::from(mode),
107 oid: String::from(oid),
108 });
109}
110
111/// What:
112/// Parses `git diff-tree -r -z --raw --no-renames --no-abbrev` output:
113/// a `:` header of five fields followed by the path, both NUL-terminated.
114/// Why:
115/// Each path appears once because renames are off, so the parser keeps records in output order.
116///
117/// In TS you'd write (pseudocode):
118/// ```ts
119/// parseRawChanges(text); // RawChange[]
120/// ```
121pub fn parse_raw_changes(output: &str) -> Result<Vec<RawChange>, RawFormatError> {
122 // `mut` lets the records be collected as the output is walked.
123 let mut changes: Vec<RawChange> = Vec::new();
124 let tokens: Vec<&str> = output.split('\0').collect();
125 let mut cursor: usize = 0;
126 while cursor < tokens.len() {
127 let header: &str = tokens[cursor];
128 cursor += 1;
129 if header.is_empty() {
130 continue;
131 }
132 let fields: Vec<&str> = header.trim_start_matches(':').split(' ').collect();
133 let path: &str = match tokens.get(cursor) {
134 Some(found) => found,
135 None => return Err(RawFormatError(format!("raw record {header} has no path"))),
136 };
137 cursor += 1;
138 if fields.len() != 5 || !header.starts_with(':') {
139 return Err(RawFormatError(format!("malformed raw record {header}")));
140 }
141 changes.push(RawChange {
142 path: String::from(path),
143 from: entry_of(fields[0], fields[2]),
144 to: entry_of(fields[1], fields[3]),
145 });
146 }
147 return Ok(changes);
148}
149
150/// What:
151/// Lists the raw changes between two commits in the shadow.
152/// Why:
153/// Two runs, one per side of the replay, keep the process count fixed.
154///
155/// In TS you'd write (pseudocode):
156/// ```ts
157/// rawChanges({ gitPath, shadowPath, from, to }); // ReadonlyMap<path, RawChange>
158/// ```
159fn raw_changes(
160 context: &GitContext,
161 shadow: &Path,
162 cwd: &Path,
163 from: &str,
164 to: &str,
165) -> Result<Vec<RawChange>, SharedPathError> {
166 let output = run_shadow_checked(
167 context,
168 shadow,
169 cwd,
170 &[
171 "diff-tree",
172 "-r",
173 "-z",
174 "--raw",
175 "--no-renames",
176 "--no-abbrev",
177 from,
178 to,
179 ],
180 None,
181 None,
182 &[],
183 )
184 .map_err(SharedPathError::Git)?;
185 let text: String = output.stdout.iter().map(|byte| { return char::from(*byte); }).collect();
186 return parse_raw_changes(text.as_str()).map_err(SharedPathError::Format);
187}
188
189/// What:
190/// A shared-path listing failure:
191/// a Git command failed, or its output had an unexpected shape.
192/// Why:
193/// Replay reports either as `transaction-failed`.
194///
195/// In TS you'd write (pseudocode):
196/// ```ts
197/// throw new CommitTransactionGitError(...);
198/// ```
199#[derive(Clone, Debug, Eq, PartialEq)]
200pub enum SharedPathError {
201 /// A Git command failed.
202 Git(GitFailure),
203 /// The raw output had an unexpected shape.
204 Format(RawFormatError),
205}
206
207/// What:
208/// Lists the paths both the landed history and the prepared commit changed since the preparation base,
209/// in the landed listing's order, with the three entries.
210/// Why:
211/// The landed listing decides the order subsumption and the synthetic base follow.
212///
213/// In TS you'd write (pseudocode):
214/// ```ts
215/// await listSharedPaths({ gitPath, shadowPath, mergeBase, current, prepared });
216/// ```
217pub fn list_shared_paths(
218 context: &GitContext,
219 shadow: &Path,
220 cwd: &Path,
221 merge_base: &str,
222 current: &str,
223 prepared: &str,
224) -> Result<Vec<SharedPath>, SharedPathError> {
225 let landed: Vec<RawChange> = raw_changes(context, shadow, cwd, merge_base, current)?;
226 let mine: Vec<RawChange> = raw_changes(context, shadow, cwd, merge_base, prepared)?;
227 let shared: Vec<SharedPath> = landed
228 .into_iter()
229 .filter_map(|change| {
230 let own: &RawChange = mine.iter().find(|candidate| { return candidate.path == change.path; })?;
231 return Some(SharedPath {
232 path: change.path,
233 base: change.from,
234 landed: change.to,
235 prepared: own.to.clone(),
236 });
237 })
238 .collect();
239 return Ok(shared);
240}
241
242/// What:
243/// Whether two entries name the same mode and object, or are both absent.
244/// Why:
245/// An identical prepared and landed entry is subsumed without any text check.
246///
247/// In TS you'd write (pseudocode):
248/// ```ts
249/// sameEntry({ left, right });
250/// ```
251pub fn same_entry(left: &Option<TreeEntry>, right: &Option<TreeEntry>) -> bool {
252 return left == right;
253}
254
255/// What:
256/// Whether all three entries are regular files, so only their bytes can decide the path.
257/// Why:
258/// Symbolic links and submodules never take the text check.
259///
260/// In TS you'd write (pseudocode):
261/// ```ts
262/// isRegularTriple(shared);
263/// ```
264pub fn is_regular_triple(shared: &SharedPath) -> bool {
265 return [&shared.base, &shared.landed, &shared.prepared]
266 .iter()
267 .all(|side| { return side.as_ref().is_some_and(|entry| { return is_regular_mode(entry.mode.as_str()); }); });
268}
269
270/// What:
271/// Whether the text check can decide a path:
272/// landed and prepared are regular files, and the base is one too or absent.
273/// Why:
274/// An addition on both sides is read against an empty base, so it is a candidate.
275///
276/// In TS you'd write (pseudocode):
277/// ```ts
278/// isTextCandidate(shared);
279/// ```
280pub fn is_text_candidate(shared: &SharedPath) -> bool {
281 let changed_regular: bool = [&shared.landed, &shared.prepared]
282 .iter()
283 .all(|side| { return side.as_ref().is_some_and(|entry| { return is_regular_mode(entry.mode.as_str()); }); });
284 let base_regular: bool = match &shared.base {
285 None => true,
286 Some(entry) => is_regular_mode(entry.mode.as_str()),
287 };
288 return changed_regular && base_regular;
289}
290
291/// What:
292/// Whether a mode is a regular file mode, `100644` or `100755`.
293/// Why:
294/// Only these modes are text candidates.
295///
296/// In TS you'd write (pseudocode):
297/// ```ts
298/// REGULAR_MODES.has(mode);
299/// ```
300fn is_regular_mode(mode: &str) -> bool {
301 return mode == "100644" || mode == "100755";
302}
303
304#[cfg(test)]
305#[path = "replay_shared_paths_tests.rs"]
306mod tests;