Skip to main content

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;