Skip to main content

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;