git_policy_cli/replay_reverse_apply.rs
1//! What:
2//! The exact emulation of `git apply --reverse --check` for one text file:
3//! the subsumption test that asks whether a landed change already sits in the prepared bytes.
4//! Why:
5//! Git's `apply.c` matching (`apply_one_fragment`, `find_pos`, `match_fragment`) with its defaults:
6//! no context reduction,
7//! no whitespace fixing,
8//! no overlapping hunks,
9//! and anchoring at the file's start and end.
10//! Replay must give the same answer as Git for the same bytes.
11//!
12//! In TS you'd write (pseudocode):
13//! ```ts
14//! reverseApplies({ hunks, prepared }): boolean
15//! ```
16
17/// Hunks and lines from `replay_patch`.
18use super::replay_patch::{PatchHunk, split_keeping_newlines};
19
20/// One image line:
21/// its bytes and whether an earlier hunk wrote it,
22/// so no later hunk may match it.
23/// Why:
24/// Git marks patched lines in the image and never matches them again.
25///
26/// In TS you'd write (pseudocode):
27/// ```ts
28/// type ImageLine = { text: string; patched: boolean };
29/// ```
30#[derive(Clone, Debug, Eq, PartialEq)]
31struct ImageLine {
32 /// Line bytes.
33 text: Vec<u8>,
34 /// Written by an earlier hunk.
35 patched: bool,
36}
37
38/// What:
39/// The bytes Git's quick line hash ignores:
40/// spaces, tabs, newlines, vertical tabs, form feeds and carriage returns.
41/// Why:
42/// An incomplete last preimage line matches an image line with the same non-whitespace content.
43///
44/// In TS you'd write (pseudocode):
45/// ```ts
46/// hashKey(text) // text without HASH_WHITESPACE
47/// ```
48fn hash_key(text: &[u8]) -> Vec<u8> {
49 return text
50 .iter()
51 .copied()
52 .filter(|byte| { return !matches!(*byte, b' ' | b'\t' | b'\n' | 0x0b | 0x0c | b'\r'); })
53 .collect();
54}
55
56/// What:
57/// Whether one image line matches one preimage line,
58/// as Git's quick hash check and `memcmp` together decide it.
59/// Why:
60/// A preimage's incomplete last line only has to be a prefix of its image line
61/// whose non-whitespace content is equal,
62/// unless the hunk is anchored at the end, where the line must be equal.
63///
64/// In TS you'd write (pseudocode):
65/// ```ts
66/// lineMatches({ line, wanted, incompleteLast, matchEnd });
67/// ```
68fn line_matches(line: &ImageLine, wanted: &[u8], incomplete_last: bool, match_end: bool) -> bool {
69 if line.patched {
70 return false;
71 }
72 if !incomplete_last || match_end {
73 return line.text == wanted;
74 }
75 return line.text.starts_with(wanted) && hash_key(&line.text) == hash_key(wanted);
76}
77
78/// What:
79/// Git's `match_fragment` without whitespace fixing:
80/// whether the preimage matches the image starting at `at`.
81/// Why:
82/// The beginning and end anchors are checked before any line.
83///
84/// In TS you'd write (pseudocode):
85/// ```ts
86/// matchesAt({ image, preimage, at, matchBeginning, matchEnd });
87/// ```
88fn matches_at(
89 image: &[ImageLine],
90 preimage: &[Vec<u8>],
91 at: usize,
92 match_beginning: bool,
93 match_end: bool,
94) -> bool {
95 let end: usize = preimage.len() + at;
96 if end > image.len() || (match_end && end != image.len()) || (match_beginning && at != 0) {
97 return false;
98 }
99 return preimage.iter().enumerate().all(|(index, wanted)| {
100 let incomplete_last: bool = index + 1 == preimage.len() && !wanted.ends_with(b"\n");
101 return line_matches(&image[at + index], wanted, incomplete_last, match_end);
102 });
103}
104
105/// What:
106/// Git's `find_pos`:
107/// the expected line first,
108/// then one line further forwards and backwards in turn.
109/// Why:
110/// A hunk whose context moved a few lines still applies where Git finds it,
111/// so the subsumption answer matches Git's.
112///
113/// In TS you'd write (pseudocode):
114/// ```ts
115/// findPosition({ image, preimage, line, matchBeginning, matchEnd }); // first match or -1
116/// ```
117fn find_position(
118 image: &[ImageLine],
119 preimage: &[Vec<u8>],
120 line: i64,
121 match_beginning: bool,
122 match_end: bool,
123) -> Option<usize> {
124 let length: i64 = i64::try_from(image.len()).unwrap_or(i64::MAX);
125 let preimage_length: i64 = i64::try_from(preimage.len()).unwrap_or(i64::MAX);
126 let anchored: i64 = if match_beginning {
127 0
128 } else if match_end {
129 length - preimage_length
130 } else {
131 line
132 };
133 let first: i64 = anchored.max(0).min(length);
134 let matches = |at: i64| -> Option<usize> {
135 if at < 0 || at > length {
136 return None;
137 }
138 let index: usize = usize::try_from(at).ok()?;
139 return matches_at(image, preimage, index, match_beginning, match_end).then_some(index);
140 };
141 if let Some(found) = matches(first) {
142 return Some(found);
143 }
144 // `mut` lets the search widen by one line per side until it passes both ends of the image.
145 let mut distance: i64 = 1;
146 while first + distance <= length || first - distance >= 0 {
147 if let Some(found) = matches(first + distance) {
148 return Some(found);
149 }
150 if let Some(found) = matches(first - distance) {
151 return Some(found);
152 }
153 distance += 1;
154 }
155 return None;
156}
157
158/// What:
159/// Apply one hunk in reverse to the image:
160/// its new side (context and additions) must be found,
161/// and its old side (context and removals) takes its place.
162/// Why:
163/// `reverse_patches` swaps the sides before Git searches,
164/// so the search starts from the old side's line and anchors on the new side's start.
165///
166/// In TS you'd write (pseudocode):
167/// ```ts
168/// reverseHunk({ image, hunk }); // image or HUNK_REJECTED
169/// ```
170fn reverse_hunk(image: &[ImageLine], hunk: &PatchHunk) -> Option<Vec<ImageLine>> {
171 let preimage: Vec<Vec<u8>> = hunk
172 .lines
173 .iter()
174 .filter(|line| { return line.op != b'-'; })
175 .map(|line| { return line.text.clone(); })
176 .collect();
177 let postimage: Vec<ImageLine> = hunk
178 .lines
179 .iter()
180 .filter(|line| { return line.op != b'+'; })
181 .map(|line| { return ImageLine {
182 text: line.text.clone(),
183 patched: true,
184 }; })
185 .collect();
186 let trailing: usize = match hunk.lines.iter().rposition(|line| { return line.op != b' '; }) {
187 Some(last_change) => hunk.lines.len() - 1 - last_change,
188 None => hunk.lines.len(),
189 };
190 let line: i64 = if hunk.old_start == 0 { 0 } else { hunk.old_start - 1 };
191 let at: usize = find_position(
192 image,
193 &preimage,
194 line,
195 hunk.new_start <= 1,
196 trailing == 0,
197 )?;
198 // `mut` lets the reversed image be built in order.
199 let mut rebuilt: Vec<ImageLine> = image[..at].to_vec();
200 rebuilt.extend(postimage);
201 rebuilt.extend_from_slice(&image[at + preimage.len()..]);
202 return Some(rebuilt);
203}
204
205/// What:
206/// Whether every hunk of a landed change applies in reverse to the prepared bytes,
207/// in order, so the prepared bytes already contain the landed change.
208/// Why:
209/// Subsumption's strict check; the one-sided extension in `replay_containment` covers what this rejects.
210///
211/// In TS you'd write (pseudocode):
212/// ```ts
213/// reverseApplies({ hunks, prepared: 'a\nB\nc\nd\n' });
214/// ```
215pub fn reverse_applies(hunks: &[PatchHunk], prepared: &[u8]) -> bool {
216 // `mut` lets the image be replaced by each hunk's result.
217 let mut image: Vec<ImageLine> = split_keeping_newlines(prepared)
218 .into_iter()
219 .map(|text| { return ImageLine {
220 text,
221 patched: false,
222 }; })
223 .collect();
224 for hunk in hunks {
225 match reverse_hunk(&image, hunk) {
226 Some(next) => image = next,
227 None => return false,
228 }
229 }
230 return true;
231}
232
233#[cfg(test)]
234#[path = "replay_reverse_apply_tests.rs"]
235mod tests;