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