git_policy_cli/replay_subsumption.rs
1//! What:
2//! Subsumption before replay's three-way merge:
3//! the per-path rules, and the synthetic merge base that makes `git merge-tree` take the prepared bytes
4//! for every subsumed path and the landed bytes for every path capture order drops.
5//! Why:
6//! A path whose prepared bytes already contain the landed change keeps the prepared entry,
7//! matching what native sequential commits produce in a shared worktree.
8//! Setting the synthetic base to the landed content means the merge sees no landed change there.
9//!
10//! In TS you'd write (pseudocode):
11//! ```ts
12//! await subsumeLandedChanges({ gitPath, shadowPath, objectDirectory, cwd, directory, replay, mergeBase, current, prepared, orderPaths });
13//! ```
14
15/// Shared paths and their entries.
16use super::replay_shared_paths::{SharedPath, SharedPathError, TreeEntry, is_regular_triple, is_text_candidate, same_entry};
17/// Capture order's per-path decision.
18use super::replay_capture_order::CaptureOrderDecision;
19/// The text half of the check.
20use super::replay_subsumption_text::{TextSubsumptionError, subsumed_text_paths};
21use super::replay_shadow::{replay_base_index, run_shadow_checked};
22use super::transaction_git::{GitContext, GitFailure};
23/// Decisions per path and the path-keyed maps.
24use std::collections::BTreeMap;
25/// `Path` names the shadow and the directories.
26use std::path::Path;
27
28/// What:
29/// Why a subsumption step failed:
30/// a listing, the text check, or a Git command that writes the synthetic base.
31/// Why:
32/// The replay reports each as `transaction-failed`.
33///
34/// In TS you'd write (pseudocode):
35/// ```ts
36/// throw new CommitTransactionGitError(message);
37/// ```
38#[derive(Clone, Debug, Eq, PartialEq)]
39pub enum SubsumptionError {
40 /// The shared-path listing failed.
41 Shared(SharedPathError),
42 /// The text check failed.
43 Text(TextSubsumptionError),
44 /// A Git command that writes the synthetic base failed.
45 Git(GitFailure),
46}
47
48/// What:
49/// The effective merge base of one replay, and the paths each side keeps.
50/// Why:
51/// The replay merges against `merge_base`, and reports the prepared and landed paths it settled without a merge.
52///
53/// In TS you'd write (pseudocode):
54/// ```ts
55/// type SubsumptionBase = { mergeBase; subsumedPaths; keptLandedPaths };
56/// ```
57#[derive(Clone, Debug, Eq, PartialEq)]
58pub struct SubsumptionBase {
59 /// Commit to pass as `--merge-base`.
60 pub merge_base: String,
61 /// Paths whose prepared entries replay as they are: subsumed ones and those the prepared commit captured later, in Git order.
62 pub subsumed_paths: Vec<String>,
63 /// Paths whose landed entries stay because a landed commit captured them later, in Git order.
64 pub kept_landed_paths: Vec<String>,
65}
66
67/// What:
68/// How the synthetic base settles one path.
69/// Why:
70/// A subsumed path takes the landed content, a prepared-ordered path takes the landed entry,
71/// and a landed-ordered path takes the prepared entry, so the merge keeps the wanted side.
72///
73/// In TS you'd write (pseudocode):
74/// ```ts
75/// type BaseEdit = { shared: SharedPath; rule: 'subsumed' | 'prepared' | 'landed' };
76/// ```
77#[derive(Clone, Copy, Debug, Eq, PartialEq)]
78pub enum BaseRule {
79 /// Subsumed by the text check, identity, or deletion on both sides.
80 Subsumed,
81 /// Captured later by the prepared commit: the synthetic base takes the landed entry.
82 Prepared,
83 /// Captured later by a landed commit: the synthetic base takes the prepared entry.
84 Landed,
85}
86
87/// What:
88/// One synthetic-base edit: the path and its rule.
89/// Why:
90/// The edits are written as one `update-index --index-info` batch.
91///
92/// In TS you'd write (pseudocode):
93/// ```ts
94/// type BaseEdit = { shared; rule };
95/// ```
96#[derive(Clone, Debug, Eq, PartialEq)]
97pub struct BaseEdit {
98 /// The shared path.
99 pub shared: SharedPath,
100 /// The settlement rule.
101 pub rule: BaseRule,
102}
103
104/// What:
105/// The object ID width of a shared path's present entries; zero when all are absent.
106/// Why:
107/// Absent-entry records write zeros of the repository's width.
108///
109/// In TS you'd write (pseudocode):
110/// ```ts
111/// oidWidth(shared);
112/// ```
113fn oid_width(shared: &SharedPath) -> usize {
114 return [&shared.base, &shared.landed, &shared.prepared]
115 .iter()
116 .find_map(|entry| { return entry.as_ref().map(|present| { return present.oid.len(); }); })
117 .unwrap_or(0);
118}
119
120/// What:
121/// The `update-index --index-info` record that sets a subsumed path to its landed content.
122/// Why:
123/// A regular file keeps the base's mode so a mode change still merges three-way;
124/// a path the landed side deleted is removed with mode `0`.
125///
126/// In TS you'd write (pseudocode):
127/// ```ts
128/// subsumedRecord(shared); // NUL-terminated
129/// ```
130pub fn subsumed_record(shared: &SharedPath) -> Vec<u8> {
131 let Some(landed) = &shared.landed else {
132 let width: usize = shared.base.as_ref().map(|entry| { return entry.oid.len(); }).unwrap_or(0);
133 return format!("0 {}\t{}\0", "0".repeat(width), shared.path).into_bytes();
134 };
135 let mode: &str = match &shared.base {
136 Some(base) if is_regular_triple(shared) => base.mode.as_str(),
137 _ => landed.mode.as_str(),
138 };
139 return format!("{mode} {}\t{}\0", landed.oid, shared.path).into_bytes();
140}
141
142/// What:
143/// The `update-index --index-info` record that sets a path exactly to one side's entry,
144/// so `git merge-tree` sees no change on that side and takes the other side's entry.
145/// Why:
146/// Capture order replaces the side it drops with the one it keeps.
147///
148/// In TS you'd write (pseudocode):
149/// ```ts
150/// exactRecord({ path, entry, width });
151/// ```
152pub fn exact_record(path: &str, entry: &Option<TreeEntry>, width: usize) -> Vec<u8> {
153 return match entry {
154 None => format!("0 {}\t{path}\0", "0".repeat(width)).into_bytes(),
155 Some(present) => format!("{} {}\t{path}\0", present.mode, present.oid).into_bytes(),
156 };
157}
158
159/// What:
160/// The record one edit contributes to the synthetic base.
161/// Why:
162/// The rule picks the subsumed record or the exact entry of the side the merge should keep.
163///
164/// In TS you'd write (pseudocode):
165/// ```ts
166/// baseRecord(edit);
167/// ```
168pub fn base_record(edit: &BaseEdit) -> Vec<u8> {
169 return match edit.rule {
170 BaseRule::Subsumed => subsumed_record(&edit.shared),
171 BaseRule::Prepared => exact_record(&edit.shared.path, &edit.shared.landed, oid_width(&edit.shared)),
172 BaseRule::Landed => exact_record(&edit.shared.path, &edit.shared.prepared, oid_width(&edit.shared)),
173 };
174}
175
176/// What:
177/// Writes the synthetic merge base:
178/// the preparation base with every edit's record applied, then a commit over that tree.
179/// Why:
180/// The merge base holds the landed content at each subsumed path, so a three-way merge takes the prepared bytes there.
181///
182/// In TS you'd write (pseudocode):
183/// ```ts
184/// await writeSubsumedBase({ gitPath, shadowPath, directory, replay, mergeBase, subsumed });
185/// ```
186fn write_subsumed_base(
187 context: &GitContext,
188 shadow: &Path,
189 cwd: &Path,
190 directory: &Path,
191 replay: u64,
192 merge_base: &str,
193 edits: &[BaseEdit],
194) -> Result<String, SubsumptionError> {
195 let index = replay_base_index(directory, replay);
196 run_shadow_checked(context, shadow, cwd, &["read-tree", merge_base], None, Some(index.as_path()), &[])
197 .map_err(SubsumptionError::Git)?;
198 // `mut` lets the records be appended in edit order.
199 let mut input: Vec<u8> = Vec::new();
200 for edit in edits {
201 input.extend_from_slice(&base_record(edit));
202 }
203 run_shadow_checked(
204 context,
205 shadow,
206 cwd,
207 &["update-index", "-z", "--index-info"],
208 Some(&input),
209 Some(index.as_path()),
210 &[],
211 )
212 .map_err(SubsumptionError::Git)?;
213 let tree_output = run_shadow_checked(context, shadow, cwd, &["write-tree"], None, Some(index.as_path()), &[])
214 .map_err(SubsumptionError::Git)?;
215 let tree: String = String::from_utf8_lossy(&tree_output.stdout).trim().to_string();
216 let message: String = format!(
217 "tree {tree}\nauthor cli-git <cli-git@localhost> 0 +0000\ncommitter cli-git <cli-git@localhost> 0 +0000\n\ncli-git replay base with subsumed landed changes\n"
218 );
219 let commit = run_shadow_checked(
220 context,
221 shadow,
222 cwd,
223 &["hash-object", "-t", "commit", "-w", "--stdin"],
224 Some(message.as_bytes()),
225 None,
226 &[],
227 )
228 .map_err(SubsumptionError::Git)?;
229 return Ok(String::from_utf8_lossy(&commit.stdout).trim().to_string());
230}
231
232/// What:
233/// Whether a shared path is decided by identity or the text check, before capture order applies:
234/// identical entries, or a text candidate the text check subsumes.
235/// Why:
236/// Capture order decides first; only the remaining paths go through these rules.
237///
238/// In TS you'd write (pseudocode):
239/// ```ts
240/// subsumeLandedChanges(...) // identical ∪ text
241/// ```
242pub fn is_identical(shared: &SharedPath) -> bool {
243 return same_entry(&shared.prepared, &shared.landed);
244}
245
246/// What:
247/// Computes the effective merge base of one replay from the shared paths the caller listed once.
248/// Why:
249/// The result carries the synthetic base when any path settles before the merge, otherwise the preparation base.
250///
251/// In TS you'd write (pseudocode):
252/// ```ts
253/// await subsumeLandedChanges({ ... });
254/// ```
255pub fn subsume_landed_changes(
256 context: &GitContext,
257 shadow: &Path,
258 object_directory: &Path,
259 cwd: &Path,
260 directory: &Path,
261 replay: u64,
262 merge_base: &str,
263 shared: &[SharedPath],
264 order: &BTreeMap<String, CaptureOrderDecision>,
265) -> Result<SubsumptionBase, SubsumptionError> {
266 let decision_of = |shared_path: &SharedPath| -> CaptureOrderDecision {
267 return order.get(&shared_path.path).copied().unwrap_or(CaptureOrderDecision::Unordered);
268 };
269 let unordered: Vec<&SharedPath> = shared
270 .iter()
271 .filter(|entry| { return decision_of(entry) == CaptureOrderDecision::Unordered; })
272 .collect();
273 let text_candidates: Vec<SharedPath> = unordered
274 .iter()
275 .filter(|entry| { return is_text_candidate(entry) && !is_identical(entry); })
276 .map(|entry| { return (*entry).clone(); })
277 .collect();
278 let text: Vec<String> = subsumed_text_paths(context, shadow, cwd, object_directory, &text_candidates)
279 .map_err(SubsumptionError::Text)?;
280 // Every synthetic base edit in Git order.
281 let edits: Vec<BaseEdit> = shared
282 .iter()
283 .filter_map(|entry| { return match decision_of(entry) {
284 CaptureOrderDecision::Prepared => Some(BaseEdit { shared: entry.clone(), rule: BaseRule::Prepared }),
285 CaptureOrderDecision::Landed => Some(BaseEdit { shared: entry.clone(), rule: BaseRule::Landed }),
286 CaptureOrderDecision::Unordered => {
287 let subsumed: bool = is_identical(entry) || text.contains(&entry.path);
288 if subsumed {
289 return Some(BaseEdit { shared: entry.clone(), rule: BaseRule::Subsumed });
290 }
291 return None;
292 }
293 }; })
294 .collect();
295 let subsumed_paths: Vec<String> = edits
296 .iter()
297 .filter(|edit| { return edit.rule != BaseRule::Landed; })
298 .map(|edit| { return edit.shared.path.clone(); })
299 .collect();
300 let kept_landed_paths: Vec<String> = edits
301 .iter()
302 .filter(|edit| { return edit.rule == BaseRule::Landed; })
303 .map(|edit| { return edit.shared.path.clone(); })
304 .collect();
305 if edits.is_empty() {
306 return Ok(SubsumptionBase {
307 merge_base: String::from(merge_base),
308 subsumed_paths: Vec::new(),
309 kept_landed_paths: Vec::new(),
310 });
311 }
312 let synthetic: String = write_subsumed_base(context, shadow, cwd, directory, replay, merge_base, &edits)?;
313 return Ok(SubsumptionBase {
314 merge_base: synthetic,
315 subsumed_paths,
316 kept_landed_paths,
317 });
318}
319
320#[cfg(test)]
321#[path = "replay_subsumption_tests.rs"]
322mod tests;