git_policy_cli/candidate/candidate_store.rs
1//! What:
2//! The per-invocation owner of candidate versions and candidate bytes.
3//! Why:
4//! Policies read committed or staged content,
5//! never whatever is in the live
6//! worktree.
7//! A version costs one listing process,
8//! its bytes come from one
9//! long-lived object reader,
10//! and neither count grows with the number of files.
11//! After a fix or replay changed the index,
12//! `invalidate` retires every version.
13//!
14//! In TS you'd write (pseudocode):
15//! ```ts
16//! // const store = new CandidateStore(git, prefix, overlay);
17//! // const version = await store.version({ kind: 'staged-against-head' });
18//! // const bytes = await store.bytes(version.candidates[0]);
19//! ```
20
21/// Import the layer's failure type and its closed list of causes.
22use crate::candidate_error::{CandidateError, CandidateFailure};
23/// Import the candidate modes,
24/// the validated object name and its parser.
25use crate::candidate_object::{CandidateMode, ObjectId, parse_object_id};
26/// Import the long-lived object reader.
27use crate::candidate_reader::{ObjectReader, start_object_reader};
28/// Import the listing parser and its records.
29use crate::candidate_record::{CandidateRecord, parse_raw_records};
30/// Import the stage-record parser of `git ls-files --stage`.
31use crate::candidate_stage::{StageRecord, parse_stage_records};
32/// Import the tree-record parser of `git ls-tree -r`.
33use crate::candidate_tree::{TreeRecord, parse_tree_records};
34/// Import the version types and builder.
35use crate::candidate_version::{Candidate, CandidateSource, CandidateVersion, build_version};
36/// Import the captured-query runner and its line helper.
37use crate::git_metadata::{MetadataOutput, run_metadata_git, strip_git_line};
38/// `HashSet<T>` is an unordered collection of distinct values with constant-time membership tests.
39use std::collections::HashSet;
40/// `OsString` is owned operating-system text of raw OS bytes (sibling `String` must be UTF-8).
41use std::ffi::OsString;
42/// `Path`/`PathBuf` are borrowed/owned filesystem paths of raw OS bytes.
43use std::path::{Path, PathBuf};
44/// What:
45/// `Rc<T>` is a shared,
46/// read-only handle to one heap value,
47/// counted so the value
48/// lives until its last handle is gone (siblings:
49/// `Box<T>`,
50/// a single owner,
51/// and
52/// `Arc<T>`,
53/// the thread-safe form).
54/// Why:
55/// Several policies hold the same version and the same bytes at once.
56/// Nothing
57/// here crosses threads,
58/// so `Arc` would add cost for no benefit.
59///
60/// In TS you'd write (pseudocode):
61/// ```ts
62/// // Every object reference in TS already behaves like this.
63/// ```
64use std::rc::Rc;
65
66/// What:
67/// Everything one invocation needs to list and read candidates.
68/// `Option<ObjectReader>` is "a running reader or nothing";
69/// `u64` is an unsigned
70/// 64-bit counter (siblings `u32`,
71/// `usize`).
72/// Why:
73/// The reader is started only when something needs it,
74/// so an invocation that
75/// reads no object starts no reader.
76/// `u64` cannot run out within a process.
77///
78/// In TS you'd write (pseudocode):
79/// ```ts
80/// class CandidateStore { #reader?: ObjectReader; #generation = 0n; #versions: [CandidateSource, CandidateVersion][] = [] }
81/// ```
82pub struct CandidateStore {
83 /// The real Git executable every listing and the reader start.
84 real_git: PathBuf,
85 /// The caller's arguments before the subcommand,
86 /// replayed so Git selects the same repository.
87 global_prefix: Vec<OsString>,
88 /// Added environment pairs,
89 /// such as a private index file or object directory.
90 overlay: Vec<(OsString, OsString)>,
91 /// The long-lived object reader,
92 /// once started.
93 reader: Option<ObjectReader>,
94 /// Counts invalidations;
95 /// candidates of an earlier generation are refused.
96 generation: u64,
97 /// Versions already listed in this generation,
98 /// by source.
99 versions: Vec<(CandidateSource, Rc<CandidateVersion>)>,
100 /// The commit every "against `HEAD`" listing compares with instead of the live `HEAD`:
101 /// `Some(Some(commit))` for a recorded base, `Some(None)` for a recorded unborn branch, `None` for live `HEAD`.
102 baseline: Option<Option<ObjectId>>,
103}
104
105/// What:
106/// The last commit any store listed with its files, kept for the rest of the process.
107/// Why:
108/// See `CandidateStore::commit_tree_records`. One entry bounds the memory to one commit's listing, and a commit's listing is
109/// the same whichever store asks, so no store has to be told about another.
110///
111/// In TS you'd write (pseudocode):
112/// ```ts
113/// let lastListed: { commit: ObjectId; records: TreeRecord[] } | undefined;
114/// ```
115static LISTED: std::sync::Mutex<Option<(ObjectId, std::sync::Arc<Vec<TreeRecord>>)>> = std::sync::Mutex::new(None);
116
117/// The most commits one `git rev-list` receives.
118/// `usize` is the type of counts; 2,048 object names stay far below any argument limit.
119/// Exempt from a citation under `LQM` (class: chunk size): it splits one command into several starts of the same
120/// command, so the result is the same and only the number of starts changes. Classed once in
121/// `doc/planning/cli-git-shipped-limits-audit.md`, "Exempt limits"; the coordinator's pick under the owner's delegation,
122/// open to the owner's veto.
123const COMMIT_CHUNK_SIZE: usize = crate::candidate_prediction::PATHSPEC_CHUNK_SIZE;
124
125/// What:
126/// Build the failure of a listing command that could not be started or exited unsuccessfully.
127/// Why:
128/// The message names the Git operation,
129/// so the person can rerun it.
130///
131/// In TS you'd write (pseudocode):
132/// ```ts
133/// function listingFailure(failure: CandidateFailure, operation: string, detail: string): CandidateError;
134/// ```
135fn listing_failure(failure: CandidateFailure, operation: &str, detail: &str) -> CandidateError {
136 return CandidateError::new(
137 failure,
138 format!("cli-git could not list candidate files: git {operation} {detail}").as_str(),
139 );
140}
141
142/// What:
143/// `impl CandidateStore { ... }` attaches the store's operations,
144/// like class methods.
145/// Why:
146/// The store is the only place that starts Git for candidates,
147/// which is what
148/// keeps the process count bounded.
149///
150/// In TS you'd write (pseudocode):
151/// ```ts
152/// class CandidateStore { version(source) {} bytes(candidate) {} invalidate() {} }
153/// ```
154impl CandidateStore {
155 /// What:
156 /// Create a store without starting any process.
157 /// `&[OsString]` borrows the global arguments;
158 /// `&[(OsString, OsString)]` borrows
159 /// environment pairs;
160 /// `.to_vec()` and `.to_path_buf()` copy them into owned storage.
161 /// Why:
162 /// The store outlives the caller's borrowed arguments,
163 /// and commands that
164 /// never ask for candidates pay nothing.
165 ///
166 /// In TS you'd write (pseudocode):
167 /// ```ts
168 /// constructor(realGit: string, globalPrefix: string[], overlay: [string, string][])
169 /// ```
170 pub fn new(
171 real_git: &Path,
172 global_prefix: &[OsString],
173 overlay: &[(OsString, OsString)],
174 ) -> CandidateStore {
175 return CandidateStore {
176 real_git: real_git.to_path_buf(),
177 global_prefix: global_prefix.to_vec(),
178 overlay: overlay.to_vec(),
179 // `None` is the "absent" variant: no reader yet.
180 reader: None,
181 generation: 0,
182 versions: Vec::new(),
183 baseline: None,
184 };
185 }
186
187 /// What:
188 /// Compare every "against `HEAD`" listing with a recorded base instead of the live `HEAD`.
189 /// Why:
190 /// A commit transaction reads its candidates against the base it recorded at invocation,
191 /// because another landing may have moved `HEAD` since.
192 ///
193 /// In TS you'd write (pseudocode):
194 /// ```ts
195 /// listChangedIndexPaths({ baseRevision }) // never live HEAD
196 /// ```
197 #[must_use]
198 pub fn with_baseline(mut self, baseline: Option<ObjectId>) -> CandidateStore {
199 self.baseline = Some(baseline);
200 return self;
201 }
202
203 /// What:
204 /// The commit the "against `HEAD`" listings compare with:
205 /// the recorded baseline when one was set, otherwise the commit `HEAD` names now.
206 /// Why:
207 /// One place decides the baseline, so the listings and the `HEAD` tree always agree.
208 ///
209 /// In TS you'd write (pseudocode):
210 /// ```ts
211 /// const baseline = recorded ?? await headCommit();
212 /// ```
213 fn baseline_commit(&mut self) -> Result<Option<ObjectId>, CandidateError> {
214 if let Some(recorded) = &self.baseline {
215 return Ok(recorded.clone());
216 }
217 return self.reader()?.head_commit();
218 }
219
220 /// What:
221 /// The running reader,
222 /// started on first use.
223 /// `&mut ObjectReader` lends it for changing.
224 /// Why:
225 /// One reader serves every object read of the invocation.
226 ///
227 /// In TS you'd write (pseudocode):
228 /// ```ts
229 /// #reader(): ObjectReader { return (this.#reader ??= startObjectReader(...)); }
230 /// ```
231 fn reader(&mut self) -> Result<&mut ObjectReader, CandidateError> {
232 if self.reader.is_none() {
233 // A trailing `?` returns a start failure to our caller, or unwraps the reader.
234 let started: ObjectReader = start_object_reader(
235 self.real_git.as_path(),
236 self.global_prefix.as_slice(),
237 self.overlay.as_slice(),
238 )?;
239 // `Some(...)` is the "present" variant.
240 self.reader = Some(started);
241 }
242 // `.as_mut()` lends the stored reader; `let Some(..) = .. else` unwraps it.
243 let Some(reader) = self.reader.as_mut() else {
244 // `Err(...)` is the failure variant. The reader was stored just above, so this is not expected.
245 return Err(CandidateError::new(
246 CandidateFailure::ReaderEnded,
247 "cli-git could not read a Git object: the object reader was not started.",
248 ));
249 };
250 // `Ok(...)` is the success variant.
251 return Ok(reader);
252 }
253
254 /// What:
255 /// Run one listing command and return its standard output.
256 /// `Vec<u8>` is an owned byte list (sibling `String` would require UTF-8).
257 /// Why:
258 /// Listings print raw pathname bytes.
259 /// A command that fails lists nothing
260 /// trustworthy,
261 /// so its output is discarded and Git's own message is reported.
262 ///
263 /// In TS you'd write (pseudocode):
264 /// ```ts
265 /// async #runListing(operation: string, args: string[]): Promise<Buffer>
266 /// ```
267 fn run_listing(
268 &self,
269 operation: &str,
270 command: &[OsString],
271 ) -> Result<Vec<u8>, CandidateError> {
272 // `.clone()` copies the stored prefix; `.extend_from_slice(..)` appends the command after it.
273 let mut arguments: Vec<OsString> = self.global_prefix.clone();
274 arguments.extend_from_slice(command);
275 // `match` on the run's `Result`: `Err(error)` means Git could not be started.
276 let output: MetadataOutput = match run_metadata_git(
277 self.real_git.as_path(),
278 arguments.as_slice(),
279 self.overlay.as_slice(),
280 ) {
281 Ok(captured) => captured,
282 Err(error) => {
283 return Err(listing_failure(
284 CandidateFailure::GitNotStarted,
285 operation,
286 format!("could not be started: {error}.").as_str(),
287 ));
288 }
289 };
290 if !output.success {
291 // `String::from_utf8_lossy` decodes Git's message for display, replacing invalid bytes.
292 return Err(listing_failure(
293 CandidateFailure::GitFailed,
294 operation,
295 format!(
296 "failed: {}",
297 String::from_utf8_lossy(output.stderr.as_slice()).trim()
298 )
299 .as_str(),
300 ));
301 }
302 return Ok(output.stdout);
303 }
304
305 /// What:
306 /// The name of the empty tree in this repository's hash format.
307 /// Why:
308 /// A repository without commits has no `HEAD` to compare the index with;
309 /// the
310 /// empty tree makes every staged entry an addition.
311 /// Git computes the name
312 /// from empty input,
313 /// so SHA-1 and SHA-256 repositories both work.
314 ///
315 /// In TS you'd write (pseudocode):
316 /// ```ts
317 /// async #emptyTree(): Promise<ObjectId>
318 /// ```
319 fn empty_tree(&self) -> Result<ObjectId, CandidateError> {
320 // The query runner gives Git no standard input, which is the empty tree's content.
321 let output: Vec<u8> = self.run_listing(
322 "hash-object",
323 &[
324 OsString::from("hash-object"),
325 OsString::from("-t"),
326 OsString::from("tree"),
327 OsString::from("--stdin"),
328 ],
329 )?;
330 let Some(tree) = parse_object_id(strip_git_line(output.as_slice())) else {
331 return Err(listing_failure(
332 CandidateFailure::ListingMalformed,
333 "hash-object",
334 "did not print the empty tree's object name.",
335 ));
336 };
337 return Ok(tree);
338 }
339
340 /// What:
341 /// List the index against a commit,
342 /// or against the empty tree when there is none,
343 /// limited to `pathspecs`.
344 /// `Option<&ObjectId>` is "a borrowed commit name or
345 /// nothing";
346 /// an empty `pathspecs` lists the whole index.
347 /// Why:
348 /// `diff-index --cached` reads only the index and the tree,
349 /// never the
350 /// worktree.
351 /// Rename detection is off,
352 /// so a renamed file is one deleted path
353 /// and one added path,
354 /// each judged on its own.
355 ///
356 /// In TS you'd write (pseudocode):
357 /// ```ts
358 /// async #listStaged(baseline: ObjectId | undefined, pathspecs: string[]): Promise<Buffer>
359 /// ```
360 fn list_staged(
361 &self,
362 baseline: Option<&ObjectId>,
363 pathspecs: &[OsString],
364 ) -> Result<Vec<u8>, CandidateError> {
365 let tree: ObjectId = match baseline {
366 Some(commit) => commit.clone(),
367 None => self.empty_tree()?,
368 };
369 // `vec![...]` builds the fixed part of the command; the pathspecs follow `--`.
370 let mut command: Vec<OsString> = vec![
371 OsString::from("diff-index"),
372 OsString::from("--cached"),
373 OsString::from("--raw"),
374 OsString::from("-z"),
375 OsString::from("--no-renames"),
376 OsString::from("--no-abbrev"),
377 OsString::from(tree.as_str()),
378 OsString::from("--"),
379 ];
380 command.extend_from_slice(pathspecs);
381 return self.run_listing("diff-index", command.as_slice());
382 }
383
384 /// What:
385 /// List what one commit changed against each of its parents.
386 /// Why:
387 /// `-m` lists a merge once per parent and `--root` lists a first commit
388 /// against nothing,
389 /// so every commit shape yields its changed paths.
390 ///
391 /// In TS you'd write (pseudocode):
392 /// ```ts
393 /// async #listCommitted(commit: ObjectId): Promise<Buffer>
394 /// ```
395 fn list_committed(&self, commit: &ObjectId) -> Result<Vec<u8>, CandidateError> {
396 return self.run_listing(
397 "diff-tree",
398 &[
399 OsString::from("diff-tree"),
400 OsString::from("--root"),
401 OsString::from("--no-commit-id"),
402 OsString::from("-r"),
403 OsString::from("-z"),
404 OsString::from("-m"),
405 OsString::from("--no-renames"),
406 OsString::from("--no-abbrev"),
407 OsString::from(commit.as_str()),
408 ],
409 );
410 }
411
412 /// What:
413 /// The version of one source,
414 /// listed once per generation and shared afterwards.
415 /// `Rc::clone(..)` makes another handle to the same version without copying it.
416 /// Why:
417 /// Every policy of a pass sees the same paths,
418 /// and asking again costs no process.
419 ///
420 /// In TS you'd write (pseudocode):
421 /// ```ts
422 /// async version(source: CandidateSource): Promise<CandidateVersion>
423 /// ```
424 pub fn version(
425 &mut self,
426 source: &CandidateSource,
427 ) -> Result<Rc<CandidateVersion>, CandidateError> {
428 // `for (known, version) in &self.versions` destructures each borrowed pair.
429 for (known, version) in &self.versions {
430 if known == source {
431 return Ok(Rc::clone(version));
432 }
433 }
434 let output: Vec<u8> = match source {
435 CandidateSource::StagedAgainstHead => {
436 let head: Option<ObjectId> = self.baseline_commit()?;
437 // `.as_ref()` turns `&Option<ObjectId>` into `Option<&ObjectId>`; `&[]` is no pathspec.
438 self.list_staged(head.as_ref(), &[])?
439 }
440 CandidateSource::StagedAgainstCommit(commit) => self.list_staged(Some(commit), &[])?,
441 CandidateSource::Committed(commit) => self.list_committed(commit)?,
442 };
443 let records: Vec<CandidateRecord> = parse_raw_records(output.as_slice())?;
444 // `Rc::new(..)` moves the built version into shared storage.
445 let version: Rc<CandidateVersion> = Rc::new(build_version(self.generation, records));
446 self.versions.push((source.clone(), Rc::clone(&version)));
447 return Ok(version);
448 }
449
450 /// What:
451 /// The index entries `pathspecs` select,
452 /// every conflict stage included,
453 /// with
454 /// repository-relative pathnames.
455 /// Why:
456 /// `ls-files --stage` reads only the index,
457 /// so comparing two of these listings
458 /// shows exactly which entries an operation on that index changed.
459 /// `--full-name`
460 /// keeps pathnames repository-relative from any directory.
461 ///
462 /// In TS you'd write (pseudocode):
463 /// ```ts
464 /// async stageRecords(pathspecs: string[]): Promise<StageRecord[]>
465 /// ```
466 pub fn stage_records(
467 &mut self,
468 pathspecs: &[OsString],
469 ) -> Result<Vec<StageRecord>, CandidateError> {
470 let mut command: Vec<OsString> = vec![
471 OsString::from("ls-files"),
472 OsString::from("--stage"),
473 OsString::from("-z"),
474 OsString::from("--full-name"),
475 OsString::from("--"),
476 ];
477 command.extend_from_slice(pathspecs);
478 let output: Vec<u8> = self.run_listing("ls-files", command.as_slice())?;
479 return parse_stage_records(output.as_slice());
480 }
481
482 /// What:
483 /// The changed-path records of the index against `HEAD`,
484 /// limited to `pathspecs`;
485 /// against the empty tree when no commit exists yet.
486 /// Why:
487 /// Which selected paths `HEAD` lacks makes them additions,
488 /// and which removed
489 /// paths it has makes them deletions.
490 /// The limit keeps a conflict elsewhere in the
491 /// index out of the listing.
492 ///
493 /// In TS you'd write (pseudocode):
494 /// ```ts
495 /// async headRecords(pathspecs: string[]): Promise<CandidateRecord[]>
496 /// ```
497 pub fn head_records(
498 &mut self,
499 pathspecs: &[OsString],
500 ) -> Result<Vec<CandidateRecord>, CandidateError> {
501 let head: Option<ObjectId> = self.baseline_commit()?;
502 let output: Vec<u8> = self.list_staged(head.as_ref(), pathspecs)?;
503 return parse_raw_records(output.as_slice());
504 }
505
506 /// What: Every file of the `HEAD` commit with its mode and object; nothing when the
507 /// branch has no commit yet.
508 /// Why: A policy that compares the candidate state with `HEAD` reads `HEAD`'s copy of
509 /// a file without naming the path in an object request, and learns its mode.
510 /// `--full-tree` lists from the top level whatever directory Git runs in.
511 ///
512 /// In TS you'd write (pseudocode):
513 /// ```ts
514 /// async headTreeRecords(): Promise<TreeRecord[]>
515 /// ```
516 pub fn head_tree_records(&mut self) -> Result<Vec<TreeRecord>, CandidateError> {
517 let Some(head) = self.baseline_commit()? else {
518 return Ok(Vec::new());
519 };
520 return self.commit_tree_records(&head);
521 }
522
523 /// What: Every file of one commit with its mode and object.
524 /// Why: Policies that compare candidates with the parents of a commit read each parent's files
525 /// the way they read `HEAD`'s. `--full-tree` lists from the top level whatever directory
526 /// Git runs in.
527 ///
528 /// In TS you'd write (pseudocode):
529 /// ```ts
530 /// async commitTreeRecords(commit: ObjectId): Promise<TreeRecord[]>
531 /// ```
532 pub fn commit_tree_records(
533 &mut self,
534 commit: &ObjectId,
535 ) -> Result<Vec<TreeRecord>, CandidateError> {
536 // What: `LISTED` holds the last listing of any store in this process, keyed by the commit it listed.
537 // Why: A commit's name fixes its tree, so the listing of one commit never changes; the policies before a commit and
538 // the gate after it list the same base commit with two stores, and each listing starts Git over every tree of a
539 // repository with tens of thousands of files.
540 //
541 // In TS you'd write (pseudocode):
542 // ```ts
543 // if (lastListed?.commit === commit) return lastListed.records;
544 // ```
545 if let Some((listed, records)) = LISTED.lock().ok().and_then(|held| { return held.clone(); })
546 && listed == *commit
547 {
548 return Ok(records.as_ref().clone());
549 }
550 let records: Vec<TreeRecord> = self.list_commit_tree(commit)?;
551 if let Ok(mut held) = LISTED.lock() {
552 *held = Some((commit.clone(), std::sync::Arc::new(records.clone())));
553 }
554 return Ok(records);
555 }
556
557 /// The listing of one commit's files by Git, as `commit_tree_records` asks for it when it has no answer kept.
558 fn list_commit_tree(&mut self, commit: &ObjectId) -> Result<Vec<TreeRecord>, CandidateError> {
559 let output: Vec<u8> = self.run_listing(
560 "ls-tree",
561 &[
562 OsString::from("ls-tree"),
563 OsString::from("-r"),
564 OsString::from("-z"),
565 OsString::from("--full-tree"),
566 OsString::from(commit.as_str()),
567 ],
568 )?;
569 return parse_tree_records(output.as_slice());
570 }
571
572 /// What: The commit the "against `HEAD`" listings compare with: the recorded base or the commit
573 /// `HEAD` names now; nothing when the branch has no commit yet.
574 /// Why: A policy that skips content the baseline already holds names the commit in its log
575 /// record and lists its files through `commit_tree_records`.
576 ///
577 /// In TS you'd write (pseudocode):
578 /// ```ts
579 /// async baselineReference(): Promise<ObjectId | undefined>
580 /// ```
581 pub fn baseline_reference(&mut self) -> Result<Option<ObjectId>, CandidateError> {
582 return self.baseline_commit();
583 }
584
585 /// What: The parents of these commits that are not among the commits themselves, each once, in
586 /// the order of the commits and then of their parents.
587 /// Why: A pushed commit's parent that is also pushed is new to the remote, so only the parents
588 /// outside the given commits count as already published. One `rev-list` answers 2,048
589 /// commits, so the process count does not grow with the commits.
590 ///
591 /// In TS you'd write (pseudocode):
592 /// ```ts
593 /// async parentsOutside(commits: ObjectId[]): Promise<ObjectId[]>
594 /// ```
595 pub fn parents_outside(
596 &mut self,
597 commits: &[ObjectId],
598 ) -> Result<Vec<ObjectId>, CandidateError> {
599 // Membership tests stay constant-time however many commits a push carries.
600 let given: HashSet<&ObjectId> = commits.iter().collect();
601 let mut seen: HashSet<ObjectId> = HashSet::new();
602 let mut outside: Vec<ObjectId> = Vec::new();
603 for chunk in commits.chunks(COMMIT_CHUNK_SIZE) {
604 let mut command: Vec<OsString> = vec![
605 OsString::from("rev-list"),
606 OsString::from("--no-walk=unsorted"),
607 OsString::from("--parents"),
608 ];
609 for commit in chunk {
610 command.push(OsString::from(commit.as_str()));
611 }
612 let output: Vec<u8> = self.run_listing("rev-list", command.as_slice())?;
613 for line in output.split(|byte| { return *byte == b'\n'; }) {
614 // The first name of a line is the commit itself; the rest are its parents.
615 for name in line.split(|byte| { return *byte == b' '; }).skip(1) {
616 let Some(parent) = parse_object_id(name) else {
617 return Err(listing_failure(
618 CandidateFailure::ListingMalformed,
619 "rev-list",
620 "printed a parent that is not an object name.",
621 ));
622 };
623 if !given.contains(&parent) && seen.insert(parent.clone()) {
624 outside.push(parent);
625 }
626 }
627 }
628 }
629 return Ok(outside);
630 }
631
632 /// What: A version of the current generation built from records the caller derived.
633 /// `Vec<CandidateRecord>` is taken by value: the records move into the version.
634 /// Why: A predicted staging operation has no single listing command; its candidates
635 /// come from comparing index states. The version is not remembered by source,
636 /// because no source names it.
637 ///
638 /// In TS you'd write (pseudocode):
639 /// ```ts
640 /// derivedVersion(records: CandidateRecord[]): CandidateVersion
641 /// ```
642 pub fn derived_version(&mut self, records: Vec<CandidateRecord>) -> Rc<CandidateVersion> {
643 return Rc::new(build_version(self.generation, records));
644 }
645
646 /// What:
647 /// The records of one commit's own delta, each against every parent (or the empty tree for a root commit).
648 /// Why:
649 /// A pushed commit's changes are the content a manual-push scan reads; the listing is the one the
650 /// landed-delta source already uses.
651 ///
652 /// In TS you'd write (pseudocode):
653 /// ```ts
654 /// committedRecords(commit: ObjectId): CandidateRecord[]
655 /// ```
656 pub fn committed_records(
657 &mut self,
658 commit: &ObjectId,
659 ) -> Result<Vec<CandidateRecord>, CandidateError> {
660 let output: Vec<u8> = self.list_committed(commit)?;
661 return parse_raw_records(output.as_slice());
662 }
663
664 /// What:
665 /// Every blob entry of one tree, as records that add each path.
666 /// Why:
667 /// A push with no known published base scans the pushed tip's whole tree.
668 /// Submodule entries are not listed: their content is a commit name, which no content policy reads.
669 ///
670 /// In TS you'd write (pseudocode):
671 /// ```ts
672 /// treeRecords(tree: ObjectId): CandidateRecord[] // ls-tree -r, every blob as added
673 /// ```
674 pub fn tree_records(
675 &mut self,
676 tree: &ObjectId,
677 ) -> Result<Vec<CandidateRecord>, CandidateError> {
678 let listing: Vec<u8> = self.run_listing(
679 "ls-tree",
680 &[
681 OsString::from("ls-tree"),
682 OsString::from("--full-tree"),
683 OsString::from("-r"),
684 OsString::from("-z"),
685 OsString::from(tree.as_str()),
686 ],
687 )?;
688 let raw: Vec<u8> = added_raw_from_ls_tree(listing.as_slice())?;
689 return parse_raw_records(raw.as_slice());
690 }
691
692 /// What:
693 /// The exact bytes a candidate holds in its version.
694 /// `Rc<[u8]>` is a shared read-only byte list.
695 /// Why:
696 /// Bytes come from the object the listing named,
697 /// so an edit to the worktree
698 /// file after staging is invisible here.
699 /// A deleted path has no bytes.
700 /// A
701 /// gitlink has no blob in this repository,
702 /// so its bytes are the submodule
703 /// commit's name,
704 /// as in the TypeScript wrapper.
705 /// A candidate of a retired
706 /// version is refused instead of answered with bytes that may be outdated.
707 ///
708 /// In TS you'd write (pseudocode):
709 /// ```ts
710 /// async bytes(candidate: Candidate): Promise<Uint8Array>
711 /// ```
712 pub fn bytes(&mut self, candidate: &Candidate) -> Result<Rc<[u8]>, CandidateError> {
713 if candidate.identity.generation != self.generation {
714 return Err(CandidateError::new(
715 CandidateFailure::StaleCandidate,
716 "cli-git refused to read a candidate from a version that was invalidated by a fix or replay; \
717 list the version again and use its candidates.",
718 ));
719 }
720 let Some(object) = candidate.object.as_ref() else {
721 // `Rc::from(Vec::new())` is a shared empty byte list.
722 return Ok(Rc::from(Vec::new()));
723 };
724 if candidate.mode == CandidateMode::Gitlink {
725 // `.as_bytes()` views the name's text as bytes; `Rc::from(..)` copies them into shared storage.
726 return Ok(Rc::from(object.as_str().as_bytes()));
727 }
728 return self.reader()?.blob(object);
729 }
730
731 /// What:
732 /// Retire every version listed so far.
733 /// `+= 1` advances the generation.
734 /// Why:
735 /// A fix or replay rewrites index entries,
736 /// so earlier listings no longer
737 /// describe what would be committed.
738 /// Blob bytes stay remembered in the
739 /// reader:
740 /// an object name always denotes the same bytes.
741 ///
742 /// In TS you'd write (pseudocode):
743 /// ```ts
744 /// invalidate(): void { this.#versions = []; this.#generation += 1n; }
745 /// ```
746 pub fn invalidate(&mut self) {
747 self.versions.clear();
748 self.generation += 1;
749 }
750}
751
752/// Repository controls stay out of the release executable.
753#[cfg(test)]
754#[path = "candidate_store_tests.rs"]
755mod tests;
756
757/// Committed versions,
758/// invalidation and listing failures.
759#[cfg(test)]
760#[path = "candidate_store_lifecycle_tests.rs"]
761mod lifecycle_tests;
762
763/// The process-count bound and its per-file positive control.
764#[cfg(test)]
765#[path = "candidate_process_count_tests.rs"]
766mod process_count_tests;
767
768/// Unborn,
769/// SHA-256 and private-index listings.
770#[cfg(test)]
771#[path = "candidate_store_baseline_tests.rs"]
772mod baseline_tests;
773
774/// Listings that must be refused.
775#[cfg(test)]
776#[path = "candidate_store_failure_tests.rs"]
777mod failure_tests;
778
779/// What:
780/// Turn NUL-separated `ls-tree -r -z` records into raw diff records that add each blob.
781/// Why:
782/// The record parser is the one the diff listings use, so tree entries follow the same path and mode rules.
783///
784/// In TS you'd write (pseudocode):
785/// ```ts
786/// function addedRawFromLsTree(output: Uint8Array): Uint8Array;
787/// ```
788fn added_raw_from_ls_tree(output: &[u8]) -> Result<Vec<u8>, CandidateError> {
789 let mut raw: Vec<u8> = Vec::new();
790 for record in output
791 .split(|byte| {
792 return *byte == 0;
793 })
794 .filter(|record| {
795 return !record.is_empty();
796 })
797 {
798 let Some(tab) = record.iter().position(|byte| { return *byte == b'\t'; }) else {
799 return Err(CandidateError::new(
800 crate::candidate_error::CandidateFailure::ListingMalformed,
801 "cli-git could not list the pushed tree: an entry lacks its path separator.",
802 ));
803 };
804 // The mode, type and object name are ASCII; the path stays the exact bytes Git printed.
805 let metadata = String::from_utf8_lossy(&record[..tab]);
806 let path: &[u8] = &record[tab + 1..];
807 let fields: Vec<&str> = metadata.split(' ').collect();
808 if fields.len() != 3 {
809 return Err(CandidateError::new(
810 crate::candidate_error::CandidateFailure::ListingMalformed,
811 "cli-git could not list the pushed tree: an entry lacks its mode, type or object name.",
812 ));
813 }
814 if fields[1] != "blob" {
815 continue;
816 }
817 let zeros = "0".repeat(fields[2].len());
818 raw.extend_from_slice(format!(":000000 {} {zeros} {} A", fields[0], fields[2]).as_bytes());
819 raw.push(0);
820 raw.extend_from_slice(path);
821 raw.push(0);
822 }
823 return Ok(raw);
824}
825/// The `HEAD` tree listing.
826#[cfg(test)]
827#[path = "candidate_store_tree_tests.rs"]
828mod tree_tests;