Skip to main content

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;