Skip to main content

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;