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 // A negative position has no `usize`, and `matches_at` rejects a position whose preimage runs past the image.
135 let matches = |at: i64| -> Option<usize> {
136 let index: usize = usize::try_from(at).ok()?;
137 return matches_at(image, preimage, index, match_beginning, match_end).then_some(index);
138 };
139 if let Some(found) = matches(first) {
140 return Some(found);
141 }
142 // `mut` lets the search widen by one line per side until it passes both ends of the image.
143 let mut distance: i64 = 1;
144 while first + distance <= length || first - distance >= 0 {
145 if let Some(found) = matches(first + distance) {
146 return Some(found);
147 }
148 if let Some(found) = matches(first - distance) {
149 return Some(found);
150 }
151 distance += 1;
152 }
153 return None;
154}
155
156/// What:
157/// Apply one hunk in reverse to the image:
158/// its new side (context and additions) must be found,
159/// and its old side (context and removals) takes its place.
160/// Why:
161/// `reverse_patches` swaps the sides before Git searches,
162/// so the search starts from the old side's line and anchors on the new side's start.
163///
164/// In TS you'd write (pseudocode):
165/// ```ts
166/// reverseHunk({ image, hunk }); // image or HUNK_REJECTED
167/// ```
168fn reverse_hunk(image: &[ImageLine], hunk: &PatchHunk) -> Option<Vec<ImageLine>> {
169 let preimage: Vec<Vec<u8>> = hunk
170 .lines
171 .iter()
172 .filter(|line| { return line.op != b'-'; })
173 .map(|line| { return line.text.clone(); })
174 .collect();
175 let postimage: Vec<ImageLine> = hunk
176 .lines
177 .iter()
178 .filter(|line| { return line.op != b'+'; })
179 .map(|line| { return ImageLine {
180 text: line.text.clone(),
181 patched: true,
182 }; })
183 .collect();
184 let trailing: usize = match hunk.lines.iter().rposition(|line| { return line.op != b' '; }) {
185 Some(last_change) => hunk.lines.len() - 1 - last_change,
186 None => hunk.lines.len(),
187 };
188 let line: i64 = if hunk.old_start == 0 { 0 } else { hunk.old_start - 1 };
189 let at: usize = find_position(
190 image,
191 &preimage,
192 line,
193 hunk.new_start <= 1,
194 trailing == 0,
195 )?;
196 // `mut` lets the reversed image be built in order.
197 let mut rebuilt: Vec<ImageLine> = image[..at].to_vec();
198 rebuilt.extend(postimage);
199 rebuilt.extend_from_slice(&image[at + preimage.len()..]);
200 return Some(rebuilt);
201}
202
203/// What:
204/// Whether every hunk of a landed change applies in reverse to the prepared bytes,
205/// in order, so the prepared bytes already contain the landed change.
206/// Why:
207/// Subsumption's strict check; the one-sided extension in `replay_containment` covers what this rejects.
208///
209/// In TS you'd write (pseudocode):
210/// ```ts
211/// reverseApplies({ hunks, prepared: 'a\nB\nc\nd\n' });
212/// ```
213pub fn reverse_applies(hunks: &[PatchHunk], prepared: &[u8]) -> bool {
214 // `mut` lets the image be replaced by each hunk's result.
215 let mut image: Vec<ImageLine> = split_keeping_newlines(prepared)
216 .into_iter()
217 .map(|text| { return ImageLine {
218 text,
219 patched: false,
220 }; })
221 .collect();
222 for hunk in hunks {
223 match reverse_hunk(&image, hunk) {
224 Some(next) => image = next,
225 None => return false,
226 }
227 }
228 return true;
229}
230
231#[cfg(test)]
232#[path = "replay_reverse_apply_tests.rs"]
233mod tests;