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;