Skip to main content

git_policy_cli/
replay_subsumption_text.rs

1//! What:
2//!  The text half of replay's subsumption:
3//!       which regular-file paths hold prepared bytes that already contain the landed change.
4//! Why:
5//!  A path is subsumed when the landed change applies in reverse to the prepared bytes with full context,
6//!       or when the prepared change of every landed region is the landed change extended on one side.
7//!       Hunks come from index-named single-level trees, so no patch header carries a user path,
8//!       and binary detection is Git's content rule (a NUL byte in the first 8000 bytes of any of the three blobs).
9//!
10//! In TS you'd write (pseudocode):
11//! ```ts
12//! await subsumedTextPaths({ gitPath, shadowPath, cwd, objectDirectory, candidates });
13//! ```
14
15/// Blob batch reader and the index-named tree writer's shadow runs.
16use super::blob_batch::load_blob_batch;
17/// The one-sided extension and the strict reverse check.
18use super::replay_containment::contains_landed_change;
19use super::replay_patch::{PatchHunk, parse_indexed_patch, split_keeping_newlines};
20use super::replay_reverse_apply::reverse_applies;
21use super::replay_shadow::run_shadow_checked;
22use super::replay_shared_paths::{SharedPath, TreeEntry};
23use super::transaction_git::{GitContext, GitFailure};
24/// Hunk maps keyed by entry name.
25use std::collections::{BTreeMap, HashMap};
26/// `Path` names the shadow and the object directory.
27use std::path::Path;
28
29/// Bytes Git inspects for a NUL when deciding that a blob is binary (`FIRST_FEW_BYTES`).
30const BINARY_PROBE_BYTES: usize = 8_000;
31
32/// Context lines of the strict reverse check, Git's default.
33const STRICT_CONTEXT: u32 = 3;
34
35/// What:
36///  The text check's failure:
37///       a Git command failed, a blob could not be read, or a patch had an unexpected shape.
38/// Why:
39///   Replay reports each as `transaction-failed`, never as a silent "not subsumed".
40///
41/// In TS you'd write (pseudocode):
42/// ```ts
43/// throw new CommitTransactionGitError(message);
44/// ```
45#[derive(Clone, Debug, Eq, PartialEq)]
46pub enum TextSubsumptionError {
47    /// A Git command failed.
48    Git(GitFailure),
49    /// A blob batch could not be read.
50    Blob(String),
51    /// A patch did not parse.
52    Format(String),
53}
54
55/// What:
56///  Whether a blob is binary by Git's rule:
57///       a NUL byte among its first 8000 bytes.
58/// Why:
59///   Attributes never decide binary-ness here, so a `text` attribute cannot make a binary blob a text candidate.
60///
61/// In TS you'd write (pseudocode):
62/// ```ts
63/// isBinary(bytes); // bytes.subarray(0, 8000).includes(0)
64/// ```
65fn is_binary(bytes: &[u8]) -> bool {
66    return bytes[..bytes.len().min(BINARY_PROBE_BYTES)].contains(&0);
67}
68
69/// What:
70///  Writes the empty blob when an addition on both sides needs an empty base; returns its ID,
71///       or an empty string when no candidate needs it.
72/// Why:
73///   An addition on both sides is read against an empty base, so the empty blob is the base entry.
74///
75/// In TS you'd write (pseudocode):
76/// ```ts
77/// await emptyBlobFor({ gitPath, shadowPath, candidates });
78/// ```
79fn empty_blob_for(
80    context: &GitContext,
81    shadow: &Path,
82    cwd: &Path,
83    candidates: &[SharedPath],
84) -> Result<String, TextSubsumptionError> {
85    if !candidates.iter().any(|shared| { return shared.base.is_none(); }) {
86        return Ok(String::new());
87    }
88    let output = run_shadow_checked(
89        context,
90        shadow,
91        cwd,
92        &["hash-object", "-w", "-t", "blob", "--stdin"],
93        Some(&[]),
94        None,
95        &[],
96    )
97    .map_err(TextSubsumptionError::Git)?;
98    return Ok(String::from_utf8_lossy(&output.stdout).trim().to_string());
99}
100
101/// What:
102///  The object ID a side contributes to the index-named trees:
103///       its blob, or the empty blob for an absent base.
104/// Why:
105///   Index-named trees need one blob per candidate per side.
106///
107/// In TS you'd write (pseudocode):
108/// ```ts
109/// sideOid({ entry, emptyBlob });
110/// ```
111fn side_oid(entry: &Option<TreeEntry>, empty_blob: &str) -> String {
112    return match entry {
113        Some(present) => present.oid.clone(),
114        None => String::from(empty_blob),
115    };
116}
117
118/// What:
119///  Writes a single-level tree of regular-file entries named by their position.
120/// Why:
121///   Named by index, the tree's paths never carry a user path into the patch headers.
122///
123/// In TS you'd write (pseudocode):
124/// ```ts
125/// await indexedTree({ gitPath, shadowPath, oids });
126/// ```
127fn indexed_tree(
128    context: &GitContext,
129    shadow: &Path,
130    cwd: &Path,
131    oids: &[String],
132) -> Result<String, TextSubsumptionError> {
133    // `mut` lets the records be appended in index order.
134    let mut input: Vec<u8> = Vec::new();
135    for (index, oid) in oids.iter().enumerate() {
136        input.extend_from_slice(format!("100644 blob {oid}\t{index}\0").as_bytes());
137    }
138    let output = run_shadow_checked(context, shadow, cwd, &["mktree", "-z"], Some(&input), None, &[])
139        .map_err(TextSubsumptionError::Git)?;
140    return Ok(String::from_utf8_lossy(&output.stdout).trim().to_string());
141}
142
143/// What:
144///  Hunks per entry name between two index-named trees, with the given context lines.
145/// Why:
146///   Full-context hunks feed the strict check; zero-context hunks feed the containment check.
147///
148/// In TS you'd write (pseudocode):
149/// ```ts
150/// await indexedHunks({ gitPath, shadowPath, from, to, context });
151/// ```
152fn indexed_hunks(
153    context: &GitContext,
154    shadow: &Path,
155    cwd: &Path,
156    from: &str,
157    to: &str,
158    lines_of_context: u32,
159) -> Result<BTreeMap<Vec<u8>, Vec<PatchHunk>>, TextSubsumptionError> {
160    let unified: String = format!("--unified={lines_of_context}");
161    let output = run_shadow_checked(
162        context,
163        shadow,
164        cwd,
165        &[
166            "diff-tree",
167            "-p",
168            "--text",
169            unified.as_str(),
170            "--no-color",
171            "--no-ext-diff",
172            "--no-textconv",
173            "--no-renames",
174            "--src-prefix=a/",
175            "--dst-prefix=b/",
176            from,
177            to,
178        ],
179        None,
180        None,
181        &[],
182    )
183    .map_err(TextSubsumptionError::Git)?;
184    return parse_indexed_patch(&output.stdout)
185        .map_err(|error| { return TextSubsumptionError::Format(error.0); });
186}
187
188/// What:
189///  Decides the text candidates:
190///       the paths whose prepared bytes already contain the landed change.
191/// Why:
192///   Only candidates whose three blobs are all text reach this check; the caller has already removed identical entries.
193///
194/// In TS you'd write (pseudocode):
195/// ```ts
196/// await subsumedTextPaths({ gitPath, shadowPath, cwd, objectDirectory, candidates });
197/// ```
198pub fn subsumed_text_paths(
199    context: &GitContext,
200    shadow: &Path,
201    cwd: &Path,
202    object_directory: &Path,
203    candidates: &[SharedPath],
204) -> Result<Vec<String>, TextSubsumptionError> {
205    if candidates.is_empty() {
206        return Ok(Vec::new());
207    }
208    let empty_blob: String = empty_blob_for(context, shadow, cwd, candidates)?;
209    // Every present blob of every candidate, in candidate order.
210    let oids: Vec<String> = candidates
211        .iter()
212        .flat_map(|shared| { return [&shared.base, &shared.landed, &shared.prepared]; })
213        .filter_map(|entry| { return entry.as_ref().map(|present| { return present.oid.clone(); }); })
214        .collect();
215    let blobs: HashMap<String, Vec<u8>> = load_blob_batch(context, cwd, &oids, Some(object_directory))
216        .map_err(TextSubsumptionError::Blob)?;
217    let blob_of = |entry: &Option<TreeEntry>| -> Option<Vec<u8>> {
218        return entry.as_ref().map(|present| { return blobs.get(&present.oid).cloned().unwrap_or_default(); });
219    };
220    // Candidates whose present blobs are all text; a blob the batch did not return counts as binary.
221    let text: Vec<&SharedPath> = candidates
222        .iter()
223        .filter(|shared| {
224            return [&shared.base, &shared.landed, &shared.prepared].iter().all(|entry| {
225                let blob = entry.as_ref().map(|present| { return blobs.get(&present.oid); });
226                return match blob {
227                    None => true,
228                    Some(Some(bytes)) => !is_binary(bytes),
229                    Some(None) => false,
230                };
231            });
232        })
233        .collect();
234    if text.is_empty() {
235        return Ok(Vec::new());
236    }
237    let owned: Vec<SharedPath> = text.iter().map(|shared| { return (*shared).clone(); }).collect();
238    let base_tree: String = indexed_tree(
239        context,
240        shadow,
241        cwd,
242        &owned.iter().map(|shared| { return side_oid(&shared.base, &empty_blob); }).collect::<Vec<String>>(),
243    )?;
244    let landed_tree: String = indexed_tree(
245        context,
246        shadow,
247        cwd,
248        &owned.iter().map(|shared| { return side_oid(&shared.landed, &empty_blob); }).collect::<Vec<String>>(),
249    )?;
250    let prepared_tree: String = indexed_tree(
251        context,
252        shadow,
253        cwd,
254        &owned.iter().map(|shared| { return side_oid(&shared.prepared, &empty_blob); }).collect::<Vec<String>>(),
255    )?;
256    let strict: BTreeMap<Vec<u8>, Vec<PatchHunk>> =
257        indexed_hunks(context, shadow, cwd, &base_tree, &landed_tree, STRICT_CONTEXT)?;
258    let landed_zero: BTreeMap<Vec<u8>, Vec<PatchHunk>> =
259        indexed_hunks(context, shadow, cwd, &base_tree, &landed_tree, 0)?;
260    let prepared_zero: BTreeMap<Vec<u8>, Vec<PatchHunk>> =
261        indexed_hunks(context, shadow, cwd, &base_tree, &prepared_tree, 0)?;
262    // `mut` lets the subsumed paths be collected in candidate order.
263    let mut subsumed: Vec<String> = Vec::new();
264    for (index, shared) in owned.iter().enumerate() {
265        let name: Vec<u8> = index.to_string().into_bytes();
266        let prepared_bytes: Vec<u8> = blob_of(&shared.prepared).unwrap_or_default();
267        let base_bytes: Vec<u8> = blob_of(&shared.base).unwrap_or_default();
268        let strict_hunks: &[PatchHunk] = strict.get(&name).map(Vec::as_slice).unwrap_or_default();
269        let landed_hunks: &[PatchHunk] = landed_zero.get(&name).map(Vec::as_slice).unwrap_or_default();
270        let prepared_hunks: &[PatchHunk] = prepared_zero.get(&name).map(Vec::as_slice).unwrap_or_default();
271        let base_lines: Vec<Vec<u8>> = split_keeping_newlines(&base_bytes);
272        if reverse_applies(strict_hunks, &prepared_bytes)
273            || contains_landed_change(landed_hunks, prepared_hunks, &base_lines)
274        {
275            subsumed.push(shared.path.clone());
276        }
277    }
278    return Ok(subsumed);
279}
280
281#[cfg(test)]
282#[path = "replay_subsumption_text_tests.rs"]
283mod tests;