Skip to main content

git_policy_cli/
candidate_prediction.rs

1//! What:
2//!  The two candidate sources that need a private index:
3//!  what `git add` would stage,
4//!       and the worktree files a direct `git cli-git check` or `fix` selects.
5//! Why:
6//!  Both are answered before Git touches the real index.
7//!  `git add` is replayed on a
8//!      private copy of the index,
9//!  and the candidates are the entries the replay changed,
10//!      as the installed wrapper's `add-policy-facts.ts` and `add-staged-delta.ts` define
11//!      them.
12//!  A direct command stages its scope with `git add --all` on a private copy,
13//!      as `direct-check-facts.ts` does,
14//!  and every selected entry is a candidate.
15//!  Either
16//!      way the bytes are blobs Git hashed from the worktree at that moment,
17//!  read through
18//!      the one object reader,
19//!  so the number of Git processes does not grow with the
20//!      number of files.
21//!
22//! In TS you'd write (pseudocode):
23//! ```ts
24//! // await using prepared = await prepareCandidates(git, { kind: 'add', region: ['--', 'a.txt'] });
25//! // for (const candidate of prepared.version.candidates) await prepared.store.bytes(candidate);
26//! ```
27
28/// Import the layer's failure type and its closed list of causes.
29use super::candidate_error::{CandidateError, CandidateFailure};
30/// The landed commit object name a landed-delta request reads.
31use super::candidate_object::ObjectId;
32/// Import the private index copy every prediction stages into.
33use super::candidate_private_index::PrivateIndex;
34/// Import the candidate record a version is built from.
35use super::candidate_record::CandidateRecord;
36/// Import the stage-record comparison and the two ways of turning entries into candidates.
37use super::candidate_stage::{
38    ChangedPath, StageRecord, delta_candidates, scope_candidates, staged_delta,
39};
40/// Import the per-invocation owner of versions and bytes.
41use super::candidate_store::CandidateStore;
42/// Import the immutable version type.
43use super::candidate_version::{CandidateSource, CandidateVersion};
44/// Import the captured-query runner and its byte helpers.
45use super::git_metadata::{MetadataOutput, path_from_git_bytes, run_metadata_git, strip_git_line};
46/// What:
47///  `OsString` is owned operating-system text of raw OS bytes (sibling `String`
48///       must be UTF-8).
49/// Why:
50///   Arguments,
51///  pathspecs and environment values reach Git byte for byte.
52///
53/// In TS you'd write (pseudocode):
54/// ```ts
55/// // string, but byte-preserving.
56/// ```
57use std::ffi::OsString;
58/// `Path`/`PathBuf` are borrowed/owned filesystem paths of raw OS bytes.
59use std::path::{Path, PathBuf};
60/// `Rc<T>` is a shared,
61///  read-only handle;
62///  versions are shared by every policy of a pass.
63use std::rc::Rc;
64
65/// The environment variable that points Git at a different index file.
66pub const INDEX_FILE_VARIABLE: &str = "GIT_INDEX_FILE";
67
68/// What:
69///  The most pathspecs one `git diff-index` receives.
70///  `usize` is the type of counts.
71/// Why:
72///   The installed wrapper passes candidate paths 2,048 at a time
73///       (`commit-transaction-candidate-batch.ts`),
74///  far below any platform's argument
75///       limit;
76///  one process per 2,048 changed paths keeps the count bounded.
77///
78/// In TS you'd write (pseudocode):
79/// ```ts
80/// const PATHSPEC_CHUNK_SIZE = 2_048;
81/// ```
82pub const PATHSPEC_CHUNK_SIZE: usize = 2_048;
83
84/// What:
85///  What a lifecycle asks for.
86///  An `enum` is a closed set of named alternatives;
87///  each
88///       carries the arguments it needs.
89///  `#[derive(...)]` generates cloning,
90///  debug
91///       printing and `==`.
92/// Why:
93///   The two sources differ only in what is staged on the private copy and in which
94///       entries become candidates.
95///
96/// In TS you'd write (pseudocode):
97/// ```ts
98/// type CandidateRequest = { kind: 'add'; region: string[] } | { kind: 'direct'; pathspecs: string[] };
99/// ```
100#[derive(Clone, Debug, Eq, PartialEq)]
101pub enum CandidateRequest {
102    /// What `git add` with these arguments would stage.
103    Add(
104        /// The arguments after `add`,
105        ///  wrapper controls removed.
106        Vec<OsString>,
107    ),
108    /// The worktree files a direct command selects.
109    Direct(
110        /// The pathspecs of the scope;
111        ///  `--all` is `:/`.
112        Vec<OsString>,
113    ),
114    /// The entries the index already holds for the pathspecs, read without staging the worktree first.
115    ///  The commit path uses it, so corrections already written to the private index are the candidates.
116    Staged(
117        /// The pathspecs of the scope.
118        Vec<OsString>,
119    ),
120    /// The entries the private index holds for the pathspecs, with a recorded base commit (the empty tree when the
121    ///  branch was unborn) as the baseline that marks additions, read without staging the worktree. The commit
122    ///  transaction uses it: its baseline is the base it recorded at invocation, never the `HEAD` another landing may
123    ///  have moved since. No pathspec means no candidate.
124    StagedAtBase {
125        /// The recorded base commit, or nothing for an unborn branch.
126        base: Option<ObjectId>,
127        /// The pathspecs of the scope, each literal and rooted at the top level.
128        pathspecs: Vec<OsString>,
129    },
130    /// The changes a manual push publishes: each newly published commit's own delta and the whole tree
131    ///  of each tip with no known published base.
132    Pushed(
133        /// The commits and trees the scan reads.
134        PushedScope,
135    ),
136    /// The paths one landed commit changed, each against every parent,
137    ///  as the post-commit gate reads them: the committed bytes and no worktree or index state.
138    Landed(
139        /// The landed commit whose tree changes are listed.
140        ObjectId,
141    ),
142}
143
144/// What:
145///  The commits and trees a manual push scans.
146/// Why:
147///   A pushed commit contributes its own delta, and a tip with no known base contributes its tree; both are
148///  listed without staging anything.
149///
150/// In TS you'd write (pseudocode):
151/// ```ts
152/// type PushedScope = { commits: ObjectId[]; trees: ObjectId[] };
153/// ```
154#[derive(Clone, Debug, Eq, PartialEq)]
155pub struct PushedScope {
156    /// Newly published commits, each contributing its own delta.
157    pub commits: Vec<ObjectId>,
158    /// Pushed tips whose whole tree is new.
159    pub trees: Vec<ObjectId>,
160}
161
162/// What:
163///  Prepared candidates:
164///  the store that reads their bytes,
165///  the version that lists
166///       them,
167///  and the private index the store is bound to.
168///  Fields are dropped in the
169///       order written,
170///  so the store's object reader ends before the private directory
171///       is removed.
172/// Why:
173///   The store keeps reading blobs after preparation;
174///  the private index must outlive it.
175///
176/// In TS you'd write (pseudocode):
177/// ```ts
178/// type PreparedCandidates = { store: CandidateStore; version: CandidateVersion; [Symbol.dispose](): void };
179/// ```
180pub struct PreparedCandidates {
181    /// Reads candidate bytes through one long-lived object reader.
182    pub store: CandidateStore,
183    /// The candidates,
184    ///  in the order the installed wrapper reports them.
185    pub version: Rc<CandidateVersion>,
186    /// The real index the private copy was made from,
187    ///  which a direct fix proves unchanged.
188    pub real_index: PathBuf,
189    /// Kept only so the private directory lives as long as the store;
190    ///  never read.
191    _private: PrivateIndex,
192}
193
194/// What:
195///  Build the failure of a Git command a prediction ran.
196///       `&MetadataOutput` borrows the captured run;
197///  `None` means Git could not be started.
198/// Why:
199///   A failed replay is reported with Git's own message,
200///  so the person sees why the
201///       command they typed could not be staged.
202///
203/// In TS you'd write (pseudocode):
204/// ```ts
205/// function predictionFailure(purpose: string, operation: string, output?: GitOutput, error?: Error): CandidateError;
206/// ```
207fn command_failure(purpose: &str, operation: &str, output: &MetadataOutput) -> CandidateError {
208    // `String::from_utf8_lossy` decodes Git's message for display, replacing invalid bytes.
209    return CandidateError::new(
210        CandidateFailure::GitFailed,
211        format!(
212            "cli-git could not {purpose}: git {operation} failed: {}",
213            String::from_utf8_lossy(output.stderr.as_slice()).trim()
214        )
215        .as_str(),
216    );
217}
218
219/// What:
220///  Build the failure of a Git command that could not be started.
221/// Why:
222///   Without Git there is nothing to predict,
223///  and the person needs the reason.
224///
225/// In TS you'd write (pseudocode):
226/// ```ts
227/// function notStarted(purpose: string, operation: string, error: Error): CandidateError;
228/// ```
229fn not_started(purpose: &str, operation: &str, error: &std::io::Error) -> CandidateError {
230    return CandidateError::new(
231        CandidateFailure::GitNotStarted,
232        format!("cli-git could not {purpose}: git {operation} could not be started: {error}.")
233            .as_str(),
234    );
235}
236
237/// What:
238///  Run one Git command of a prediction and return its output when it succeeded.
239///       `&[OsString]` borrows the global prefix and the command separately.
240/// Why:
241///   The prefix selects the same repository the caller's command selects;
242///  the
243///       command follows it.
244///
245/// In TS you'd write (pseudocode):
246/// ```ts
247/// async function runGit(git, prefix, command, overlay, purpose): Promise<GitOutput>;
248/// ```
249fn run_git(
250    real_git: &Path,
251    prefix: &[OsString],
252    command: &[OsString],
253    overlay: &[(OsString, OsString)],
254    purpose: &str,
255) -> Result<MetadataOutput, CandidateError> {
256    // `.to_vec()` copies the borrowed prefix; `.extend_from_slice(..)` appends the command.
257    let mut arguments: Vec<OsString> = prefix.to_vec();
258    arguments.extend_from_slice(command);
259    // The operation named in messages is the command's first word, `add` or `rev-parse`.
260    let operation: String = match command.first() {
261        Some(word) => word.to_string_lossy().into_owned(),
262        None => String::from("(no command)"),
263    };
264    // `match` on the start result: `Err(error)` means Git could not be started at all.
265    let output: MetadataOutput = match run_metadata_git(real_git, arguments.as_slice(), overlay) {
266        Ok(captured) => captured,
267        Err(error) => return Err(not_started(purpose, operation.as_str(), &error)),
268    };
269    if !output.success {
270        return Err(command_failure(purpose, operation.as_str(), &output));
271    }
272    return Ok(output);
273}
274
275/// What:
276///  The absolute path of the index the caller's command would use.
277/// Why:
278///   Git answers for `GIT_INDEX_FILE`,
279///  a linked worktree's own index and
280///       `--git-dir`,
281///  so the copy is of exactly the index `git add` would change.
282///
283/// In TS you'd write (pseudocode):
284/// ```ts
285/// async function realIndexPath(git, prefix, overlay): Promise<string>;
286/// ```
287fn real_index_path(
288    real_git: &Path,
289    prefix: &[OsString],
290    overlay: &[(OsString, OsString)],
291    purpose: &str,
292) -> Result<PathBuf, CandidateError> {
293    let output: MetadataOutput = run_git(
294        real_git,
295        prefix,
296        &[
297            OsString::from("rev-parse"),
298            OsString::from("--path-format=absolute"),
299            OsString::from("--git-path"),
300            OsString::from("index"),
301        ],
302        overlay,
303        purpose,
304    )?;
305    // `match` unpacks "a path or nothing": empty output names no index.
306    match path_from_git_bytes(strip_git_line(output.stdout.as_slice())) {
307        Some(path) => return Ok(path),
308        None => {
309            return Err(CandidateError::new(
310                CandidateFailure::ListingMalformed,
311                format!(
312                    "cli-git could not {purpose}: git rev-parse --git-path index printed no path."
313                )
314                .as_str(),
315            ));
316        }
317    }
318}
319
320/// What:
321///  One literal,
322///  top-level pathspec naming exactly `path`,
323///  or nothing when the bytes
324///       cannot be a path on this platform.
325/// Why:
326///   `:(top,literal)` makes Git match the repository-relative name byte for byte from
327///       any directory,
328///  whatever wildcard or magic characters it contains.
329///
330/// In TS you'd write (pseudocode):
331/// ```ts
332/// const literalPathspec = (path: Buffer) => Buffer.concat([Buffer.from(':(top,literal)'), path]);
333/// ```
334fn literal_pathspec(path: &[u8]) -> Option<OsString> {
335    // `?` returns `None` when the bytes are not a path here.
336    let native: PathBuf = path_from_git_bytes(path)?;
337    let mut pathspec: OsString = OsString::from(":(top,literal)");
338    pathspec.push(native.as_os_str());
339    return Some(pathspec);
340}
341
342/// What:
343///  The records of `HEAD` for the changed paths,
344///  asked 2,048 paths at a time.
345/// Why:
346///   Which changed paths `HEAD` lacks or still has decides additions and deletions;
347///       nothing outside the changed paths is listed,
348///  so a conflict elsewhere in the
349///       index cannot stop the prediction.
350///  No change asks nothing.
351///
352/// In TS you'd write (pseudocode):
353/// ```ts
354/// async function baselineRecords(store: CandidateStore, changed: ChangedPath[]): Promise<CandidateRecord[]>;
355/// ```
356fn baseline_records(
357    store: &mut CandidateStore,
358    changed: &[ChangedPath],
359) -> Result<Vec<CandidateRecord>, CandidateError> {
360    let mut records: Vec<CandidateRecord> = Vec::new();
361    // `.chunks(n)` yields consecutive slices of at most `n` items; an empty list yields none.
362    for chunk in changed.chunks(PATHSPEC_CHUNK_SIZE) {
363        let mut pathspecs: Vec<OsString> = Vec::with_capacity(chunk.len());
364        for path in chunk {
365            let Some(pathspec) = literal_pathspec(path.path.as_slice()) else {
366                return Err(CandidateError::new(
367                    CandidateFailure::ListingMalformed,
368                    "cli-git could not predict what git add stages: Git listed an entry whose \
369                     name is not a path on this platform.",
370                ));
371            };
372            pathspecs.push(pathspec);
373        }
374        records.extend(store.head_records(pathspecs.as_slice())?);
375    }
376    return Ok(records);
377}
378
379/// What:
380///  The candidates `git add` with `region` would stage,
381///  against `store`'s private index.
382/// Why:
383///   The whole index is listed before and after the replay,
384///  so a path the replay
385///       touched outside the caller's directory is still found.
386///  The replay uses the
387///       caller's own prefix and environment,
388///  so it reads its pathspecs exactly as the
389///       real command will.
390///
391/// In TS you'd write (pseudocode):
392/// ```ts
393/// async function predictAdd(store, git, prefix, overlay, region): Promise<CandidateRecord[]>;
394/// ```
395fn predicted_add(
396    store: &mut CandidateStore,
397    real_git: &Path,
398    prefix: &[OsString],
399    overlay: &[(OsString, OsString)],
400    region: &[OsString],
401) -> Result<Vec<CandidateRecord>, CandidateError> {
402    let whole_index: [OsString; 1] = [OsString::from(":/")];
403    let before: Vec<StageRecord> = store.stage_records(&whole_index)?;
404    let mut command: Vec<OsString> = vec![OsString::from("add")];
405    command.extend_from_slice(region);
406    run_git(
407        real_git,
408        prefix,
409        command.as_slice(),
410        overlay,
411        "predict what git add stages",
412    )?;
413    let after: Vec<StageRecord> = store.stage_records(&whole_index)?;
414    let changed: Vec<ChangedPath> = staged_delta(before.as_slice(), after.as_slice());
415    let baseline: Vec<CandidateRecord> = baseline_records(store, changed.as_slice())?;
416    return delta_candidates(changed.as_slice(), baseline.as_slice());
417}
418
419/// What:
420///  Every entry `pathspecs` select after staging them with `git add --all` on the
421///       private index.
422/// Why:
423///   The private stage hashes the selected worktree files,
424///  so their bytes are what
425///       the worktree holds,
426///  tracked and new files alike,
427///  and the real index is untouched.
428///
429/// In TS you'd write (pseudocode):
430/// ```ts
431/// async function projectScope(store, git, prefix, overlay, pathspecs): Promise<CandidateRecord[]>;
432/// ```
433fn projected_scope(
434    store: &mut CandidateStore,
435    real_git: &Path,
436    prefix: &[OsString],
437    overlay: &[(OsString, OsString)],
438    pathspecs: &[OsString],
439) -> Result<Vec<CandidateRecord>, CandidateError> {
440    let mut command: Vec<OsString> = vec![
441        OsString::from("add"),
442        OsString::from("--all"),
443        OsString::from("--"),
444    ];
445    command.extend_from_slice(pathspecs);
446    run_git(
447        real_git,
448        prefix,
449        command.as_slice(),
450        overlay,
451        "read the selected worktree files",
452    )?;
453    return staged_scope(store, pathspecs);
454}
455
456/// What:
457///  The candidates of the entries the index already holds for these pathspecs, against `HEAD`.
458/// Why:
459///   The direct and the staged requests both end here; only the direct one stages the worktree first.
460///
461/// In TS you'd write (pseudocode):
462/// ```ts
463/// scopeCandidates(await stageRecords(pathspecs), await headRecords(pathspecs));
464/// ```
465fn staged_scope(
466    store: &mut CandidateStore,
467    pathspecs: &[OsString],
468) -> Result<Vec<CandidateRecord>, CandidateError> {
469    let entries: Vec<StageRecord> = store.stage_records(pathspecs)?;
470    if entries.is_empty() {
471        // Nothing selected: no baseline is needed and no object reader starts.
472        return Ok(Vec::new());
473    }
474    let baseline: Vec<CandidateRecord> = store.head_records(pathspecs)?;
475    return scope_candidates(entries.as_slice(), baseline.as_slice());
476}
477
478/// What:
479///  Prepare the candidates of one request.
480///  `real_git`,
481///  `global_prefix` and `overlay`
482///       are the Git executable,
483///  the caller's arguments before the subcommand and the
484///       child environment additions,
485///  exactly as every other Git query receives them.
486/// Why:
487///   The real index is copied first,
488///  so nothing below can change it.
489///  For `git add`
490///       the store's own listings add `--no-literal-pathspecs` after the caller's prefix:
491///       they rely on pathspec magic (`:/`,
492///  `:(top,literal)`),
493///  which literal pathspec
494///       mode,
495///  from `GIT_LITERAL_PATHSPECS` or `--literal-pathspecs`,
496///  would turn into
497///       names that match nothing,
498///  leaving every change unchecked.
499///  The replay keeps the
500///       caller's mode.
501///
502/// In TS you'd write (pseudocode):
503/// ```ts
504/// async function prepareCandidates(git, prefix, overlay, request): Promise<PreparedCandidates>;
505/// ```
506pub fn prepare_candidates(
507    real_git: &Path,
508    global_prefix: &[OsString],
509    overlay: &[(OsString, OsString)],
510    request: &CandidateRequest,
511) -> Result<PreparedCandidates, CandidateError> {
512    let purpose: &str = match request {
513        CandidateRequest::Add(_) => "predict what git add stages",
514        CandidateRequest::Direct(_) => "read the selected worktree files",
515        CandidateRequest::Staged(_) | CandidateRequest::StagedAtBase { .. } => "read the staged index entries",
516        CandidateRequest::Landed(_) => "read the landed commit changes",
517        CandidateRequest::Pushed(_) => "read the pushed commit changes",
518    };
519    let real_index: PathBuf = real_index_path(real_git, global_prefix, overlay, purpose)?;
520    let private: PrivateIndex = PrivateIndex::create(real_index.as_path())?;
521    // The caller's environment, with every command of the prediction on the private index.
522    let mut private_overlay: Vec<(OsString, OsString)> = overlay.to_vec();
523    private_overlay.push((
524        OsString::from(INDEX_FILE_VARIABLE),
525        private.path().as_os_str().to_os_string(),
526    ));
527    // A pushed scope lists committed records directly; nothing is staged or predicted for it.
528    if let CandidateRequest::Pushed(scope) = request {
529        let mut pushed_store: CandidateStore =
530            CandidateStore::new(real_git, global_prefix, private_overlay.as_slice());
531        let mut records: Vec<CandidateRecord> = Vec::new();
532        for commit in &scope.commits {
533            records.extend(pushed_store.committed_records(commit)?);
534        }
535        for tree in &scope.trees {
536            records.extend(pushed_store.tree_records(tree)?);
537        }
538        let version: Rc<CandidateVersion> = pushed_store.derived_version(records);
539        return Ok(PreparedCandidates {
540            store: pushed_store,
541            version,
542            real_index,
543            _private: private,
544        });
545    }
546    // A landed commit lists its committed records directly; nothing is staged or predicted for it.
547    if let CandidateRequest::Landed(commit) = request {
548        let mut landed_store: CandidateStore =
549            CandidateStore::new(real_git, global_prefix, private_overlay.as_slice());
550        let version: Rc<CandidateVersion> =
551            landed_store.version(&CandidateSource::Committed(commit.clone()))?;
552        return Ok(PreparedCandidates {
553            store: landed_store,
554            version,
555            real_index,
556            _private: private,
557        });
558    }
559    // `match` picks the source; each arm builds its store and its candidate records.
560    // `mut store` because building the version below reads the store's generation.
561    let (mut store, records): (CandidateStore, Vec<CandidateRecord>) = match request {
562        CandidateRequest::Add(region) => {
563            let mut listing_prefix: Vec<OsString> = global_prefix.to_vec();
564            listing_prefix.push(OsString::from("--no-literal-pathspecs"));
565            let mut add_store: CandidateStore = CandidateStore::new(
566                real_git,
567                listing_prefix.as_slice(),
568                private_overlay.as_slice(),
569            );
570            let staged: Vec<CandidateRecord> = predicted_add(
571                &mut add_store,
572                real_git,
573                global_prefix,
574                private_overlay.as_slice(),
575                region.as_slice(),
576            )?;
577            (add_store, staged)
578        }
579        CandidateRequest::Landed(_) | CandidateRequest::Pushed(_) => {
580            unreachable!("landed and pushed requests return before the staged match");
581        }
582        CandidateRequest::Direct(pathspecs) => {
583            // The same magic-enabled prefix as the add arm: the staging and the listings must read
584            // `:/` and the selected pathspecs alike, so literal mode cannot split them.
585            let mut scope_prefix: Vec<OsString> = global_prefix.to_vec();
586            scope_prefix.push(OsString::from("--no-literal-pathspecs"));
587            let mut scope_store: CandidateStore =
588                CandidateStore::new(real_git, scope_prefix.as_slice(), private_overlay.as_slice());
589            let selected: Vec<CandidateRecord> = projected_scope(
590                &mut scope_store,
591                real_git,
592                scope_prefix.as_slice(),
593                private_overlay.as_slice(),
594                pathspecs.as_slice(),
595            )?;
596            (scope_store, selected)
597        }
598        CandidateRequest::Staged(pathspecs) => {
599            let mut scope_store: CandidateStore =
600                CandidateStore::new(real_git, global_prefix, private_overlay.as_slice());
601            let selected: Vec<CandidateRecord> = staged_scope(&mut scope_store, pathspecs.as_slice())?;
602            (scope_store, selected)
603        }
604        CandidateRequest::StagedAtBase { base, pathspecs } => {
605            let mut scope_store: CandidateStore =
606                CandidateStore::new(real_git, global_prefix, private_overlay.as_slice()).with_baseline(base.clone());
607            // An empty scope would list the whole index, so it is answered without asking Git.
608            let selected: Vec<CandidateRecord> = if pathspecs.is_empty() {
609                Vec::new()
610            } else {
611                staged_scope(&mut scope_store, pathspecs.as_slice())?
612            };
613            (scope_store, selected)
614        }
615    };
616    let version: Rc<CandidateVersion> = store.derived_version(records);
617    return Ok(PreparedCandidates {
618        store,
619        version,
620        real_index,
621        _private: private,
622    });
623}
624
625/// Prediction and projection controls against real Git stay out of the release executable.
626#[cfg(test)]
627#[path = "candidate_prediction_tests.rs"]
628mod tests;
629
630/// The process-count bound of both sources and its per-file positive control.
631#[cfg(test)]
632#[path = "candidate_prediction_count_tests.rs"]
633mod count_tests;