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