git_policy_cli/candidate/candidate_known_content.rs
1//! What:
2//! Which candidates hold content that a parent commit's tree already holds,
3//! found by exact blob object name.
4//! Why:
5//! A pure rename or a copy re-lists a file as an addition,
6//! and a merge lists every file one parent lacks as an addition,
7//! although the bytes are already in history.
8//! Scanning them again refuses moves of
9//! files that were accepted when they entered history,
10//! while an object name is the hash of the bytes,
11//! so a
12//! candidate whose object a parent holds carries no byte that is new.
13//! The comparison is by object
14//! name only:
15//! never by pathname,
16//! never by similarity,
17//! so one changed byte makes the content new.
18//!
19//! In TS you'd write (pseudocode):
20//! ```ts
21//! // const known = await findKnownContent(store, version, ContentBasis.of(request), rulesPath, corrected);
22//! // known.entry(candidate.identity.index)?.parent
23//! ```
24
25/// Import the layer's failure type.
26use crate::candidate_error::CandidateError;
27/// Import the candidate modes and the validated object name.
28use crate::candidate_object::{CandidateMode, ObjectId};
29/// Import what a lifecycle asks for.
30use crate::candidate_prediction::CandidateRequest;
31/// Import the store that lists commits and trees.
32use crate::candidate_store::CandidateStore;
33/// Import the candidate and the immutable version type.
34use crate::candidate_version::{Candidate, CandidateVersion};
35/// Import the change a converged fix installs,
36/// which marks a candidate whose bytes are no longer its object's.
37use crate::direct_fix_install::InstallChange;
38/// Import which candidates a scan reads at all.
39use crate::scanner_selection::is_scannable;
40/// What:
41/// `BTreeMap<K, V>` is a map kept sorted by key (sibling `HashMap` is unordered);
42/// `HashSet<T>` is an
43/// unordered set with constant-time membership tests.
44/// Why:
45/// Known candidates are listed in candidate order,
46/// and each parent's blobs are looked up by name.
47///
48/// In TS you'd write (pseudocode):
49/// ```ts
50/// // Map<number, KnownEntry> iterated in key order; Set<ObjectId>.
51/// ```
52use std::collections::{BTreeMap, HashSet};
53
54/// What:
55/// The commits whose trees count as already holding content for one lifecycle.
56/// `#[derive(...)]` generates cloning,
57/// debug printing and `==`.
58/// Why:
59/// Each lifecycle compares with a different history:
60/// the commit being made with its base,
61/// a landed
62/// commit with each of its parents,
63/// a push with the parents that were published before it.
64///
65/// In TS you'd write (pseudocode):
66/// ```ts
67/// type ContentBasis = { kind: 'nothing' } | { kind: 'baseline' } | { kind: 'parents-outside'; commits: ObjectId[] };
68/// ```
69#[derive(Clone, Debug, Eq, PartialEq)]
70pub enum ContentBasis {
71 /// No tree counts:
72 /// a direct check reads the selected files for what they hold now,
73 /// and a push with a tip whose published history is unknown has no commit to vouch for anything.
74 Nothing,
75 /// The commit the candidates are compared with:
76 /// the recorded base of a commit,
77 /// else the commit `HEAD` names;
78 /// an unborn branch has none.
79 Baseline,
80 /// Every parent of these commits that is not one of them:
81 /// a landed commit's parents,
82 /// or a push's already published parents.
83 ParentsOutside(
84 /// The commits whose parents are listed.
85 Vec<ObjectId>,
86 ),
87}
88
89/// What:
90/// `impl ContentBasis { ... }` attaches the choice for a request.
91/// Why:
92/// The choice follows the request's kind alone,
93/// so no policy decides it separately.
94///
95/// In TS you'd write (pseudocode):
96/// ```ts
97/// class ContentBasis { static of(request: CandidateRequest): ContentBasis }
98/// ```
99impl ContentBasis {
100 /// What:
101 /// The basis of one request.
102 /// `&CandidateRequest` borrows what the lifecycle asked for.
103 /// Why:
104 /// A parent commit of a pushed commit that is itself pushed is new to the remote,
105 /// so a push counts only the parents outside the pushed commits.
106 /// A pushed tip tree has no published
107 /// base to compare with,
108 /// and its candidates share one version with the commits' candidates,
109 /// so a push with such a tip skips nothing.
110 ///
111 /// In TS you'd write (pseudocode):
112 /// ```ts
113 /// static of(request: CandidateRequest): ContentBasis
114 /// ```
115 pub fn of(request: &CandidateRequest) -> ContentBasis {
116 match request {
117 CandidateRequest::Direct(_) => return ContentBasis::Nothing,
118 CandidateRequest::Add(_)
119 | CandidateRequest::Staged(_)
120 | CandidateRequest::StagedAtBase { .. } => return ContentBasis::Baseline,
121 CandidateRequest::Landed(commit) => {
122 return ContentBasis::ParentsOutside(vec![commit.clone()]);
123 }
124 CandidateRequest::Pushed(scope) => {
125 if !scope.trees.is_empty() {
126 return ContentBasis::Nothing;
127 }
128 return ContentBasis::ParentsOutside(scope.commits.clone());
129 }
130 }
131 }
132}
133
134/// What:
135/// One candidate whose content a parent already holds:
136/// the object and the first parent that holds it.
137/// `#[derive(...)]` generates cloning,
138/// debug printing and `==`.
139/// Why:
140/// The skip is a decision,
141/// and a decision is only checkable when its inputs are kept.
142///
143/// In TS you'd write (pseudocode):
144/// ```ts
145/// type KnownEntry = { object: ObjectId; parent: ObjectId };
146/// ```
147#[derive(Clone, Debug, Eq, PartialEq)]
148pub struct KnownEntry {
149 /// The candidate's blob.
150 pub object: ObjectId,
151 /// The commit whose tree holds the blob.
152 pub parent: ObjectId,
153}
154
155/// What:
156/// `impl KnownEntry { ... }` attaches the record of the decision.
157/// Why:
158/// One wording for the decision,
159/// so a log line always carries the same inputs.
160///
161/// In TS you'd write (pseudocode):
162/// ```ts
163/// class KnownEntry { describe(displayPath: string): string }
164/// ```
165impl KnownEntry {
166 /// What:
167 /// The decision as one line of text.
168 /// `&str` is the pathname as the scanner masks it.
169 /// Why:
170 /// The pathname is shown masked because a pathname may hold a secret;
171 /// the blob and the parent are
172 /// object names.
173 ///
174 /// In TS you'd write (pseudocode):
175 /// ```ts
176 /// describe(displayPath: string): string
177 /// ```
178 pub fn describe(&self, display_path: &str) -> String {
179 return format!(
180 "skipped the content of {display_path}: blob {} is already in the tree of commit {}; only the pathname was checked.",
181 self.object.as_str(),
182 self.parent.as_str()
183 );
184 }
185}
186
187/// What:
188/// The known candidates of one version,
189/// by the candidate's position in the version.
190/// `#[derive(...)]` generates cloning,
191/// debug printing and `==`;
192/// `Default` is the empty value.
193/// Why:
194/// The scan pass asks by position,
195/// and the log lists them in candidate order.
196///
197/// In TS you'd write (pseudocode):
198/// ```ts
199/// class KnownContent { entry(index: number): KnownEntry | undefined }
200/// ```
201#[derive(Clone, Debug, Default, Eq, PartialEq)]
202pub struct KnownContent {
203 /// The known candidates,
204 /// sorted by position.
205 entries: BTreeMap<usize, KnownEntry>,
206}
207
208/// What:
209/// `impl KnownContent { ... }` attaches the read-only accessors.
210/// Why:
211/// A scan can read the decision and cannot change it.
212///
213/// In TS you'd write (pseudocode):
214/// ```ts
215/// class KnownContent { entry(index) {} len() {} }
216/// ```
217impl KnownContent {
218 /// What:
219 /// The entry of the candidate at `index`,
220 /// if its content is known.
221 /// Why:
222 /// The scan pass asks once per candidate.
223 ///
224 /// In TS you'd write (pseudocode):
225 /// ```ts
226 /// entry(index: number): KnownEntry | undefined
227 /// ```
228 pub fn entry(&self, index: usize) -> Option<&KnownEntry> {
229 return self.entries.get(&index);
230 }
231
232 /// What:
233 /// How many candidates are known.
234 /// Why:
235 /// A pass with none reads exactly the bytes it read before the rule existed.
236 ///
237 /// In TS you'd write (pseudocode):
238 /// ```ts
239 /// get length(): number
240 /// ```
241 pub fn len(&self) -> usize {
242 return self.entries.len();
243 }
244
245 /// What:
246 /// Whether no candidate is known.
247 /// Why:
248 /// Clippy asks a type with a `len()` to provide `is_empty()` too.
249 ///
250 /// In TS you'd write (pseudocode):
251 /// ```ts
252 /// get isEmpty(): boolean
253 /// ```
254 pub fn is_empty(&self) -> bool {
255 return self.entries.is_empty();
256 }
257}
258
259/// What:
260/// Whether a candidate's content can be known from a parent's tree at all.
261/// `&[Vec<u8>]` is the rules files' own pathnames.
262/// Why:
263/// Only a scanned blob has bytes that a parent could already hold.
264/// A submodule entry is a commit name,
265/// and a file a fix corrected no longer holds its object's bytes.
266///
267/// In TS you'd write (pseudocode):
268/// ```ts
269/// const comparable = (candidate, rulesPath, corrected) => isScannable(candidate, rulesPath) && candidate.object !== undefined && candidate.mode !== 'gitlink' && !corrected.has(candidate.path);
270/// ```
271fn is_comparable(
272 candidate: &Candidate,
273 rules_paths: &[Vec<u8>],
274 corrected: &BTreeMap<Vec<u8>, InstallChange>,
275) -> bool {
276 return is_scannable(candidate, rules_paths)
277 && candidate.object.is_some()
278 && candidate.mode != CandidateMode::Gitlink
279 && !corrected.contains_key(&candidate.path);
280}
281
282/// What:
283/// Find every comparable candidate whose blob a parent's tree holds.
284/// `&mut CandidateStore` lends the listing;
285/// `&[Vec<u8>]` is the rules files' pathnames;
286/// `&BTreeMap<..>` are the files a fix corrected.
287/// Why:
288/// Parents are listed one after another and listing stops when every candidate is placed,
289/// so a
290/// version that is all known costs one listing.
291/// No candidate to compare and no parent to compare with
292/// each cost no Git process beyond the basis question.
293/// The first parent that holds a blob is the one reported.
294///
295/// In TS you'd write (pseudocode):
296/// ```ts
297/// async function findKnownContent(store, version, basis, rulesPath, corrected): Promise<KnownContent>;
298/// ```
299pub fn find_known_content(
300 store: &mut CandidateStore,
301 version: &CandidateVersion,
302 basis: &ContentBasis,
303 rules_paths: &[Vec<u8>],
304 corrected: &BTreeMap<Vec<u8>, InstallChange>,
305) -> Result<KnownContent, CandidateError> {
306 let mut pending: Vec<&Candidate> = Vec::new();
307 for candidate in version.candidates() {
308 if is_comparable(candidate, rules_paths, corrected) {
309 pending.push(candidate);
310 }
311 }
312 let mut entries: BTreeMap<usize, KnownEntry> = BTreeMap::new();
313 if pending.is_empty() {
314 return Ok(KnownContent { entries });
315 }
316 let parents: Vec<ObjectId> = match basis {
317 ContentBasis::Nothing => Vec::new(),
318 // `.into_iter().collect()` turns "a commit or nothing" into a list of zero or one.
319 ContentBasis::Baseline => store.baseline_reference()?.into_iter().collect(),
320 ContentBasis::ParentsOutside(commits) => store.parents_outside(commits.as_slice())?,
321 };
322 for parent in parents {
323 if pending.is_empty() {
324 break;
325 }
326 let mut blobs: HashSet<ObjectId> = HashSet::new();
327 for record in store.commit_tree_records(&parent)? {
328 if record.mode != CandidateMode::Gitlink {
329 blobs.insert(record.object);
330 }
331 }
332 let mut unplaced: Vec<&Candidate> = Vec::new();
333 for candidate in pending {
334 match candidate.object.as_ref() {
335 Some(object) if blobs.contains(object) => {
336 entries.insert(
337 candidate.identity.index,
338 KnownEntry {
339 object: object.clone(),
340 parent: parent.clone(),
341 },
342 );
343 }
344 Some(_) | None => unplaced.push(candidate),
345 }
346 }
347 pending = unplaced;
348 }
349 return Ok(KnownContent { entries });
350}
351
352/// Basis choice,
353/// lookup by object name and the stop once every candidate is placed stay out of the release executable.
354#[cfg(test)]
355#[path = "candidate_known_content_tests.rs"]
356mod tests;