Skip to main content

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;