git_policy_cli/candidate_stage.rs
1//! What:
2//! Parse the NUL-delimited stage records of `git ls-files --stage -z`,
3//! find the paths
4//! one staging operation changed,
5//! and turn them into candidate records.
6//! Why:
7//! `git add` is checked before Git runs it,
8//! by replaying it on a private copy of the
9//! index.
10//! The paths it would stage are exactly those whose index records differ
11//! between the copy before and after the replay.
12//! Comparing complete records,
13//! conflict
14//! stages included,
15//! finds a resolved conflict like any other change,
16//! and an unrelated
17//! conflict elsewhere in the index never stops the comparison.
18//! Pathnames stay the raw
19//! bytes Git printed.
20//!
21//! Git 2.56.0 (`builtin/ls-files.c`,
22//! `show_ce`) prints,
23//! with `--stage -z`:
24//! `<mode> SP <object> SP <stage> TAB <path> NUL`.
25//!
26//! In TS you'd write (pseudocode):
27//! ```ts
28//! // const before = parseStageRecords(stdoutBefore); const after = parseStageRecords(stdoutAfter);
29//! // const paths = stagedDelta(before, after);
30//! ```
31
32/// Import the layer's failure type and its closed list of causes.
33use super::candidate_error::{CandidateError, CandidateFailure};
34/// Import the candidate modes,
35/// the validated object name and their parsers.
36use super::candidate_object::{CandidateMode, ObjectId, mode_from_git, parse_object_id};
37/// Import the candidate record a version is built from,
38/// and its change kinds.
39use super::candidate_record::{CandidateChange, CandidateRecord};
40/// What:
41/// `HashMap<K, V>` is a key-to-value table and `HashSet<T>` a set of keys
42/// (siblings:
43/// `BTreeMap` and `BTreeSet`,
44/// which keep keys sorted).
45/// Why:
46/// Each path of one state is found without scanning the other state's list.
47///
48/// In TS you'd write (pseudocode):
49/// ```ts
50/// const byPath = new Map<string, StageRecord[]>(); const added = new Set<string>();
51/// ```
52use std::collections::{HashMap, HashSet};
53
54/// What:
55/// One index entry.
56/// `u8` is an unsigned byte;
57/// a stage is 0 for a merged entry and
58/// 1 to 3 for the sides of a conflict.
59/// `#[derive(...)]` generates cloning,
60/// debug
61/// printing and `==`.
62/// Why:
63/// Two states are compared record by record,
64/// so a record holds every field Git
65/// printed for the entry.
66///
67/// In TS you'd write (pseudocode):
68/// ```ts
69/// type StageRecord = { mode: CandidateMode; object: ObjectId; stage: 0 | 1 | 2 | 3; path: Buffer };
70/// ```
71#[derive(Clone, Debug, Eq, PartialEq)]
72pub struct StageRecord {
73 /// The entry's file mode.
74 pub mode: CandidateMode,
75 /// The object the entry names.
76 pub object: ObjectId,
77 /// 0 for a merged entry;
78 /// 1,
79 /// 2 or 3 for a conflict stage.
80 pub stage: u8,
81 /// Repository-relative pathname,
82 /// exactly as Git printed it.
83 pub path: Vec<u8>,
84}
85
86/// What:
87/// One path a staging operation changed,
88/// with what the index holds there afterwards.
89/// `Vec<StageRecord>` is empty when the operation removed the path from the index.
90/// Why:
91/// A candidate is built from the state after the operation;
92/// a removed path becomes
93/// a deletion only when the baseline commit has it.
94///
95/// In TS you'd write (pseudocode):
96/// ```ts
97/// type ChangedPath = { path: Buffer; after: StageRecord[] };
98/// ```
99#[derive(Clone, Debug, Eq, PartialEq)]
100pub struct ChangedPath {
101 /// Repository-relative pathname.
102 pub path: Vec<u8>,
103 /// The path's records after the operation,
104 /// in stage order;
105 /// empty when it was removed.
106 pub after: Vec<StageRecord>,
107}
108
109/// What:
110/// Build the failure for one malformed stage record.
111/// `position` is the record's
112/// zero-based place in the listing;
113/// `usize` is the type of list positions.
114/// Why:
115/// A record is named by its position,
116/// never by its pathname,
117/// which can itself be a
118/// forbidden string.
119///
120/// In TS you'd write (pseudocode):
121/// ```ts
122/// function stageFailure(failure: CandidateFailure, position: number, detail: string): CandidateError;
123/// ```
124fn stage_failure(failure: CandidateFailure, position: usize, detail: &str) -> CandidateError {
125 return CandidateError::new(
126 failure,
127 format!(
128 "cli-git could not read the index: entry record {position} {detail}. \
129 Inspect the entries with `git ls-files --stage`."
130 )
131 .as_str(),
132 );
133}
134
135/// What:
136/// Whether one byte is NUL,
137/// the record terminator.
138/// Why:
139/// A named predicate for `split`,
140/// because the repository bans anonymous functions.
141///
142/// In TS you'd write (pseudocode):
143/// ```ts
144/// const isNul = (byte: number) => byte === 0;
145/// ```
146fn is_nul(byte: &u8) -> bool {
147 return *byte == 0;
148}
149
150/// What:
151/// Whether one byte is a space,
152/// the metadata field separator.
153/// Why:
154/// A named predicate for `split`.
155///
156/// In TS you'd write (pseudocode):
157/// ```ts
158/// const isSpace = (byte: number) => byte === 0x20;
159/// ```
160fn is_space(byte: &u8) -> bool {
161 return *byte == b' ';
162}
163
164/// What:
165/// Whether one byte is a tab,
166/// which ends the metadata.
167/// Why:
168/// A named predicate for `position`.
169///
170/// In TS you'd write (pseudocode):
171/// ```ts
172/// const isTab = (byte: number) => byte === 0x09;
173/// ```
174fn is_tab(byte: &u8) -> bool {
175 return *byte == b'\t';
176}
177
178/// What:
179/// The stage number of a stage field,
180/// or nothing for any other text.
181/// `Option<u8>` is "a number or nothing".
182/// Why:
183/// Git prints exactly one digit from 0 to 3;
184/// anything else means the output is not
185/// a stage listing.
186///
187/// In TS you'd write (pseudocode):
188/// ```ts
189/// function stageNumber(text: Buffer): number | undefined;
190/// ```
191fn stage_number(text: &[u8]) -> Option<u8> {
192 // `match` compares the whole field with each accepted spelling.
193 match text {
194 b"0" => return Some(0),
195 b"1" => return Some(1),
196 b"2" => return Some(2),
197 b"3" => return Some(3),
198 _ => return None,
199 }
200}
201
202/// What:
203/// Parse one record from its zero-based position and its bytes,
204/// terminator removed.
205/// Why:
206/// The first tab ends the metadata;
207/// the pathname is every byte after it,
208/// tabs
209/// included,
210/// and must not be empty.
211///
212/// In TS you'd write (pseudocode):
213/// ```ts
214/// function parseStageRecord(position: number, record: Buffer): StageRecord;
215/// ```
216fn parse_stage_record(position: usize, record: &[u8]) -> Result<StageRecord, CandidateError> {
217 // What: `.iter().position(is_tab)` is the index of the first tab, if any.
218 // Why: Without a tab there is no pathname.
219 //
220 // In TS you'd write (pseudocode):
221 // ```ts
222 // const tab = record.indexOf(0x09); if (tab === -1) throw malformed();
223 // ```
224 let Some(tab) = record.iter().position(is_tab) else {
225 return Err(stage_failure(
226 CandidateFailure::ListingMalformed,
227 position,
228 "has no tab before its pathname",
229 ));
230 };
231 // `&record[..tab]` borrows the bytes before the tab; `&record[tab + 1..]` those after it.
232 let path: &[u8] = &record[tab + 1..];
233 let fields: Vec<&[u8]> = record[..tab].split(is_space).collect();
234 if fields.len() != 3 || path.is_empty() {
235 return Err(stage_failure(
236 CandidateFailure::ListingMalformed,
237 position,
238 "does not have three metadata fields and a pathname",
239 ));
240 }
241 let Some(mode) = mode_from_git(fields[0]) else {
242 return Err(stage_failure(
243 CandidateFailure::UnsupportedMode,
244 position,
245 "has a file mode other than 100644, 100755, 120000 or 160000",
246 ));
247 };
248 let Some(object) = parse_object_id(fields[1]) else {
249 return Err(stage_failure(
250 CandidateFailure::ListingMalformed,
251 position,
252 "does not carry a complete object name",
253 ));
254 };
255 let Some(stage) = stage_number(fields[2]) else {
256 return Err(stage_failure(
257 CandidateFailure::ListingMalformed,
258 position,
259 "does not carry a stage from 0 to 3",
260 ));
261 };
262 // `.to_vec()` copies the borrowed pathname into storage the record owns.
263 return Ok(StageRecord {
264 mode,
265 object,
266 stage,
267 path: path.to_vec(),
268 });
269}
270
271/// What:
272/// Parse a complete `git ls-files --stage -z` listing.
273/// `Result<Vec<StageRecord>, CandidateError>` is "every record,
274/// or the first failure".
275/// Why:
276/// A listing that does not end with NUL was cut short;
277/// a partial list would hide
278/// entries from the comparison,
279/// so the whole listing is refused.
280///
281/// In TS you'd write (pseudocode):
282/// ```ts
283/// function parseStageRecords(output: Buffer): StageRecord[];
284/// ```
285pub fn parse_stage_records(output: &[u8]) -> Result<Vec<StageRecord>, CandidateError> {
286 // `Vec::new()` is an empty growable list; `mut` lets the loop append to it.
287 let mut records: Vec<StageRecord> = Vec::new();
288 if output.is_empty() {
289 return Ok(records);
290 }
291 // `.strip_suffix(&[0_u8])` is `Some(rest)` when the output ends with a NUL byte.
292 let Some(body) = output.strip_suffix(&[0_u8]) else {
293 return Err(stage_failure(
294 CandidateFailure::ListingMalformed,
295 0,
296 "is cut short: the listing does not end with a NUL byte",
297 ));
298 };
299 // `.split(is_nul).enumerate()` yields each record with its zero-based position.
300 for (position, record) in body.split(is_nul).enumerate() {
301 // A trailing `?` returns the failure to our caller, or unwraps the record.
302 records.push(parse_stage_record(position, record)?);
303 }
304 return Ok(records);
305}
306
307/// What:
308/// The records of one index state grouped by path,
309/// in listing order.
310/// `Vec<(&[u8], &[StageRecord])>` is a list of borrowed pathnames,
311/// each with the
312/// consecutive records Git listed for it.
313/// Why:
314/// Git lists every stage of a path together,
315/// sorted,
316/// so one pass over the slice
317/// groups them without copying a record.
318///
319/// In TS you'd write (pseudocode):
320/// ```ts
321/// function groupByPath(records: StageRecord[]): [Buffer, StageRecord[]][];
322/// ```
323fn group_by_path(records: &[StageRecord]) -> Vec<(&[u8], &[StageRecord])> {
324 let mut groups: Vec<(&[u8], &[StageRecord])> = Vec::new();
325 // `start` is where the group being collected begins.
326 let mut start: usize = 0;
327 // `.windows(2)` visits each pair of neighbours; `.enumerate()` adds the left one's index.
328 for (index, pair) in records.windows(2).enumerate() {
329 if pair[0].path != pair[1].path {
330 groups.push((records[start].path.as_slice(), &records[start..=index]));
331 start = index + 1;
332 }
333 }
334 // `.get(start)` is `Some(first)` when a last group remains after the final boundary.
335 if let Some(first) = records.get(start) {
336 groups.push((first.path.as_slice(), &records[start..]));
337 }
338 return groups;
339}
340
341/// What:
342/// The paths whose records differ between two index states,
343/// in the order the
344/// installed wrapper reports them:
345/// paths the first state held,
346/// in its order,
347/// then
348/// paths only the second state holds,
349/// in its order.
350/// Why:
351/// A path is changed when its complete record list differs,
352/// stage by stage,
353/// so a
354/// resolved conflict counts and an untouched conflict does not.
355///
356/// In TS you'd write (pseudocode):
357/// ```ts
358/// function stagedDelta(before: StageRecord[], after: StageRecord[]): ChangedPath[];
359/// ```
360pub fn staged_delta(before: &[StageRecord], after: &[StageRecord]) -> Vec<ChangedPath> {
361 let before_groups: Vec<(&[u8], &[StageRecord])> = group_by_path(before);
362 let after_groups: Vec<(&[u8], &[StageRecord])> = group_by_path(after);
363 let mut before_by_path: HashMap<&[u8], &[StageRecord]> = HashMap::new();
364 for (path, records) in &before_groups {
365 // `*path` and `*records` copy the borrowed slices out of the pair.
366 before_by_path.insert(*path, *records);
367 }
368 let mut after_by_path: HashMap<&[u8], &[StageRecord]> = HashMap::new();
369 for (path, records) in &after_groups {
370 after_by_path.insert(*path, *records);
371 }
372 let mut changed: Vec<ChangedPath> = Vec::new();
373 for (path, records) in &before_groups {
374 // `.get(path)` is the other state's records for this path, if it has the path;
375 // `.copied()` turns the borrowed entry into the slice it holds.
376 let later: Option<&[StageRecord]> = after_by_path.get(path).copied();
377 if later != Some(*records) {
378 changed.push(ChangedPath {
379 path: path.to_vec(),
380 // `.unwrap_or_default()` is the empty slice for a removed path.
381 after: later.unwrap_or_default().to_vec(),
382 });
383 }
384 }
385 for (path, records) in &after_groups {
386 if !before_by_path.contains_key(path) {
387 changed.push(ChangedPath {
388 path: path.to_vec(),
389 after: records.to_vec(),
390 });
391 }
392 }
393 return changed;
394}
395
396/// What:
397/// Turn changed paths into candidate records,
398/// given the records `git diff-index`
399/// printed for those paths against the baseline commit.
400/// Why:
401/// A path the index still holds is a candidate with its merged entry:
402/// an addition
403/// when the baseline lacks it,
404/// otherwise a modification,
405/// even when its content now
406/// equals the baseline's.
407/// A removed path is a deletion only when the baseline has
408/// it;
409/// a path that was never committed carries nothing to check and is dropped.
410/// A path left unmerged has no single staged content and stops the prediction.
411///
412/// In TS you'd write (pseudocode):
413/// ```ts
414/// function deltaCandidates(changed: ChangedPath[], baseline: CandidateRecord[]): CandidateRecord[];
415/// ```
416pub fn delta_candidates(
417 changed: &[ChangedPath],
418 baseline: &[CandidateRecord],
419) -> Result<Vec<CandidateRecord>, CandidateError> {
420 let mut baseline_by_path: HashMap<&[u8], &CandidateRecord> = HashMap::new();
421 for record in baseline {
422 baseline_by_path.insert(record.path.as_slice(), record);
423 }
424 let mut candidates: Vec<CandidateRecord> = Vec::new();
425 for (position, path) in changed.iter().enumerate() {
426 let difference: Option<&CandidateRecord> =
427 baseline_by_path.get(path.path.as_slice()).copied();
428 // `match` on the records after the operation: none, exactly one merged entry, or anything else.
429 match path.after.as_slice() {
430 [] => {
431 // `if let Some(record) = ...` keeps a deletion the baseline listed.
432 if let Some(record) = difference
433 && record.change == CandidateChange::Deleted
434 {
435 candidates.push(record.clone());
436 }
437 }
438 [entry] if entry.stage == 0 => {
439 let change: CandidateChange = match difference {
440 Some(record) if record.change == CandidateChange::Added => {
441 CandidateChange::Added
442 }
443 _ => CandidateChange::Modified,
444 };
445 candidates.push(CandidateRecord {
446 path: path.path.clone(),
447 mode: entry.mode,
448 change,
449 // `Some(...)` is the "present" variant: a staged entry always names an object.
450 object: Some(entry.object.clone()),
451 });
452 }
453 _ => {
454 return Err(stage_failure(
455 CandidateFailure::UnmergedPath,
456 position,
457 "is left unmerged (conflicted) by the staging operation; resolve the conflict and stage the result",
458 ));
459 }
460 }
461 }
462 return Ok(candidates);
463}
464
465/// What:
466/// Turn every entry of a scope into a candidate record,
467/// given the records
468/// `git diff-index` printed for the same scope against the baseline commit.
469/// Why:
470/// A direct check reads every selected file,
471/// changed or not,
472/// so every merged entry
473/// is a candidate:
474/// an addition when the baseline lacks it,
475/// otherwise a modification.
476/// A conflicted entry has no single content and stops the projection.
477///
478/// In TS you'd write (pseudocode):
479/// ```ts
480/// function scopeCandidates(entries: StageRecord[], baseline: CandidateRecord[]): CandidateRecord[];
481/// ```
482pub fn scope_candidates(
483 entries: &[StageRecord],
484 baseline: &[CandidateRecord],
485) -> Result<Vec<CandidateRecord>, CandidateError> {
486 let mut added: HashSet<&[u8]> = HashSet::new();
487 for record in baseline {
488 if record.change == CandidateChange::Added {
489 added.insert(record.path.as_slice());
490 }
491 }
492 let mut candidates: Vec<CandidateRecord> = Vec::new();
493 for (position, entry) in entries.iter().enumerate() {
494 if entry.stage != 0 {
495 return Err(stage_failure(
496 CandidateFailure::UnmergedPath,
497 position,
498 "is an unmerged (conflicted) index entry; resolve the conflict and stage the result",
499 ));
500 }
501 let change: CandidateChange = if added.contains(&entry.path.as_slice()) {
502 CandidateChange::Added
503 } else {
504 CandidateChange::Modified
505 };
506 candidates.push(CandidateRecord {
507 path: entry.path.clone(),
508 mode: entry.mode,
509 change,
510 object: Some(entry.object.clone()),
511 });
512 }
513 return Ok(candidates);
514}
515
516/// Parser,
517/// delta and classification controls stay out of the release executable.
518#[cfg(test)]
519#[path = "candidate_stage_tests.rs"]
520mod tests;