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