Skip to main content

git_policy_cli/
candidate_reader.rs

1//! What:
2//!  One long-lived `git cat-file --batch` process that answers every object read of an invocation.
3//! Why:
4//!  Starting Git costs far more than reading one object,
5//!  so candidate bytes are read
6//!      through a single process whose count does not grow with the number of files.
7//!      Blob bytes are remembered by object name:
8//!  a name always denotes the same bytes,
9//!      so a remembered blob can never become stale.
10//!
11//! In TS you'd write (pseudocode):
12//! ```ts
13//! // const reader = startObjectReader(git); const bytes = await reader.blob(oid);
14//! ```
15
16/// Import the pure reply reader and the values it produces.
17use super::candidate_batch::{BatchReply, ObjectKind, read_batch_reply};
18/// Import the layer's failure type and its closed list of causes.
19use super::candidate_error::{CandidateError, CandidateFailure};
20/// Starts that ride out a short shortage of processes.
21use super::start_retry::RetryStart;
22/// Import the validated object name.
23use super::candidate_object::ObjectId;
24/// Import the shared child-command builder.
25use super::forwarding::git_command;
26/// What:
27///  `HashMap<K, V>` is a key-to-value table (siblings:
28///  `BTreeMap`,
29///  which keeps keys
30///       sorted,
31///  and `Vec` of pairs).
32/// Why:
33///   Remembered blobs are looked up by object name and never listed in order.
34///
35/// In TS you'd write (pseudocode):
36/// ```ts
37/// const blobs = new Map<ObjectId, Uint8Array>();
38/// ```
39use std::collections::HashMap;
40/// `OsString` is owned operating-system text of raw OS bytes (sibling `String` must be UTF-8).
41use std::ffi::OsString;
42/// `BufReader` adds a read buffer to a pipe;
43///  `Write` is the trait that gives pipes `.write_all(..)`.
44use std::io::{BufReader, Write};
45/// `Path` is a borrowed filesystem path of raw OS bytes.
46use std::path::Path;
47/// The child process record,
48///  its two pipes,
49///  and the stream selector.
50use std::process::{Child, ChildStdin, ChildStdout, Stdio};
51/// What:
52///  `Rc<T>` is a shared,
53///  read-only handle to one heap value,
54///  counted so the value
55///       lives until its last handle is gone (siblings:
56///  `Box<T>`,
57///  a single owner,
58///  and
59///       `Arc<T>`,
60///  the thread-safe form).
61/// Why:
62///   The reader keeps a blob while callers also hold it;
63///  copying large files for
64///       each holder would double their memory.
65///  Nothing here crosses threads,
66///  so `Arc`
67///       would add cost for no benefit.
68///
69/// In TS you'd write (pseudocode):
70/// ```ts
71/// // Every object reference in TS already behaves like this.
72/// ```
73use std::rc::Rc;
74
75/// What:
76///  The request and reply pipes of a running reader.
77/// Why:
78///   Both are closed together:
79///  after any reply failure the stream can no longer be
80///       trusted,
81///  and closing both ends lets the process stop whatever it was doing.
82///
83/// In TS you'd write (pseudocode):
84/// ```ts
85/// type ReaderPipes = { input: Writable; output: Readable };
86/// ```
87struct ReaderPipes {
88    /// Where request lines are written.
89    input: ChildStdin,
90    /// Where replies are read,
91    ///  through a buffer so header lines do not cost one read per byte.
92    output: BufReader<ChildStdout>,
93}
94
95/// What:
96///  A running object reader and the blobs it has already read.
97///       `Option<ReaderPipes>` is "open pipes or nothing";
98///  nothing means closed.
99///       `Rc<[u8]>` is a shared read-only byte list.
100/// Why:
101///   One value owns the process for its whole life,
102///  so the process is always
103///       stopped and collected when the value is dropped.
104///
105/// In TS you'd write (pseudocode):
106/// ```ts
107/// class ObjectReader { #child: ChildProcess; #pipes?: ReaderPipes; #blobs: Map<ObjectId, Uint8Array> }
108/// ```
109pub struct ObjectReader {
110    /// The `git cat-file --batch` process.
111    child: Child,
112    /// Open pipes,
113    ///  or nothing once the reader was closed after a failure.
114    pipes: Option<ReaderPipes>,
115    /// Blob bytes already read,
116    ///  by object name.
117    blobs: HashMap<ObjectId, Rc<[u8]>>,
118}
119
120/// What:
121///  Build the failure for a reader that can no longer answer.
122/// Why:
123///   The message tells the person how to see Git's own explanation,
124///  because the
125///       reader's error stream is discarded (an unread pipe could stall Git).
126///
127/// In TS you'd write (pseudocode):
128/// ```ts
129/// function readerEnded(detail: string): CandidateError;
130/// ```
131fn reader_ended(detail: &str) -> CandidateError {
132    return CandidateError::new(
133        CandidateFailure::ReaderEnded,
134        format!(
135            "cli-git could not read a Git object: the git cat-file --batch process {detail}. \
136             Run `git cat-file --batch-check` in this repository to see Git's own diagnostic."
137        )
138        .as_str(),
139    );
140}
141
142/// What:
143///  Start the reader for the repository the caller's global options select.
144///       `&[OsString]` borrows the arguments before the subcommand,
145///  unchanged;
146///       `&[(OsString, OsString)]` borrows added environment pairs.
147///       `Result<T, E>` is "a value or a failure".
148/// Why:
149///   Replaying the global prefix makes the reader open the same repository as the
150///       forwarded command.
151///  The overlay may point it at a private object directory,
152///  so
153///       one reader is bound to one overlay for its whole life.
154///
155/// In TS you'd write (pseudocode):
156/// ```ts
157/// function startObjectReader(realGit, globalPrefix, overlay): ObjectReader;
158/// ```
159pub fn start_object_reader(
160    real_git: &Path,
161    global_prefix: &[OsString],
162    overlay: &[(OsString, OsString)],
163) -> Result<ObjectReader, CandidateError> {
164    // `.to_vec()` copies the borrowed prefix into an owned, growable argument list.
165    let mut arguments: Vec<OsString> = global_prefix.to_vec();
166    arguments.push(OsString::from("cat-file"));
167    arguments.push(OsString::from("--batch"));
168    // What: `Stdio::piped()` connects a stream to this process; `Stdio::null()` discards it.
169    //       `.spawn()` starts the child without waiting; `match` reads its `Result`.
170    // Why:  Requests and replies travel over the two pipes. Standard error is discarded
171    //       because nothing reads it while replies are awaited, and a full unread pipe
172    //       would block Git forever.
173    //
174    // In TS you'd write (pseudocode):
175    // ```ts
176    // const child = spawn(realGit, args, { stdio: ['pipe', 'pipe', 'ignore'] });
177    // ```
178    let started: std::io::Result<Child> = git_command(real_git, arguments.as_slice(), overlay)
179        .stdin(Stdio::piped())
180        .stdout(Stdio::piped())
181        .stderr(Stdio::null())
182        .spawn_retrying();
183    let mut child: Child = match started {
184        Ok(spawned) => spawned,
185        Err(error) => {
186            // `Err(...)` is the failure variant.
187            return Err(CandidateError::new(
188                CandidateFailure::GitNotStarted,
189                format!(
190                    "cli-git could not start git cat-file --batch to read Git objects: {error}."
191                )
192                .as_str(),
193            ));
194        }
195    };
196    // What: `.take()` moves each pipe out of the child record, leaving nothing behind.
197    //       `let (Some(a), Some(b)) = (x, y) else { ... };` unwraps both or exits.
198    // Why:  The reader owns the pipes so it can close them independently of the process record.
199    //
200    // In TS you'd write (pseudocode):
201    // ```ts
202    // const { stdin, stdout } = child;
203    // ```
204    let (Some(input), Some(output)) = (child.stdin.take(), child.stdout.take()) else {
205        // Both streams were requested as pipes, so this is not expected; the child is still collected.
206        let _ = child.kill();
207        let _ = child.wait();
208        return Err(reader_ended("started without its request and reply pipes"));
209    };
210    // `Ok(...)` is the success variant; `Some(...)` is the "present" variant.
211    return Ok(ObjectReader {
212        child,
213        pipes: Some(ReaderPipes {
214            input,
215            output: BufReader::new(output),
216        }),
217        blobs: HashMap::new(),
218    });
219}
220
221/// What:
222///  `impl ObjectReader { ... }` attaches the read operations,
223///  like class methods.
224/// Why:
225///   Only complete object names and the fixed word `HEAD` are ever written as
226///       requests,
227///  so no caller text can inject a second request line.
228///
229/// In TS you'd write (pseudocode):
230/// ```ts
231/// class ObjectReader { blob(oid) {} headCommit() {} }
232/// ```
233impl ObjectReader {
234    /// What:
235    ///  Send one request line and read its reply.
236    ///  `&mut self` borrows the reader
237    ///       for changing:
238    ///  its pipes advance and may be closed.
239    /// Why:
240    ///   Strict alternation (one request,
241    ///  then its whole reply) can never fill a
242    ///       pipe in both directions at once.
243    ///  Any failure closes the pipes,
244    ///  because the
245    ///       rest of the stream could be object content that looks like a reply.
246    ///
247    /// In TS you'd write (pseudocode):
248    /// ```ts
249    /// async #request(name: Buffer): Promise<BatchReply>
250    /// ```
251    fn request(&mut self, name: &[u8]) -> Result<BatchReply, CandidateError> {
252        // `.as_mut()` lends the open pipes for changing without taking them out.
253        let Some(pipes) = self.pipes.as_mut() else {
254            return Err(reader_ended("was closed after an earlier failure"));
255        };
256        let mut line: Vec<u8> = name.to_vec();
257        line.push(b'\n');
258        // `if let Err(error) = ...` runs the block only when the write failed.
259        if let Err(error) = pipes.input.write_all(line.as_slice()) {
260            // `None` is the "absent" variant: assigning it drops, and so closes, both pipes.
261            self.pipes = None;
262            return Err(reader_ended(
263                format!("stopped accepting requests ({error})").as_str(),
264            ));
265        }
266        match read_batch_reply(&mut pipes.output, name) {
267            Ok(reply) => return Ok(reply),
268            Err(error) => {
269                self.pipes = None;
270                return Err(error);
271            }
272        }
273    }
274
275    /// What:
276    ///  The exact bytes of one blob,
277    ///  read once and shared afterwards.
278    ///       `Rc::clone(..)` makes another handle to the same bytes without copying them.
279    /// Why:
280    ///   The same blob often backs several paths and several policies;
281    ///  each further
282    ///       use costs neither a request nor a copy.
283    ///
284    /// In TS you'd write (pseudocode):
285    /// ```ts
286    /// async blob(oid: ObjectId): Promise<Uint8Array>
287    /// ```
288    pub fn blob(&mut self, object: &ObjectId) -> Result<Rc<[u8]>, CandidateError> {
289        // `.get(object)` is `Option<&Rc<[u8]>>`: the remembered bytes, if any.
290        if let Some(bytes) = self.blobs.get(object) {
291            return Ok(Rc::clone(bytes));
292        }
293        // A trailing `?` returns the failure to our caller, or unwraps the reply.
294        let reply: BatchReply = self.request(object.as_str().as_bytes())?;
295        // What: `match` on the reply; `{ kind, bytes, .. }` binds two fields and ignores the rest.
296        // Why:  Only a blob is file content; a missing or differently typed object is a
297        //       failure the caller must not read as empty content.
298        //
299        // In TS you'd write (pseudocode):
300        // ```ts
301        // if (reply.kind === 'missing') throw ...; if (reply.type !== 'blob') throw ...;
302        // ```
303        match reply {
304            BatchReply::Missing => {
305                return Err(CandidateError::new(
306                    CandidateFailure::ObjectMissing,
307                    format!(
308                        "cli-git could not read candidate content: Git has no object {}. \
309                         Run `git fsck` to check the object store.",
310                        object.as_str()
311                    )
312                    .as_str(),
313                ));
314            }
315            BatchReply::Found { kind, bytes, .. } => {
316                if kind != ObjectKind::Blob {
317                    return Err(CandidateError::new(
318                        CandidateFailure::ObjectKindUnexpected,
319                        format!(
320                            "cli-git could not read candidate content: object {} is not a blob.",
321                            object.as_str()
322                        )
323                        .as_str(),
324                    ));
325                }
326                // `Rc::from(bytes)` moves the owned bytes into shared storage.
327                let shared: Rc<[u8]> = Rc::from(bytes);
328                // `.clone()` copies the name so the table owns its key.
329                self.blobs.insert(object.clone(), Rc::clone(&shared));
330                return Ok(shared);
331            }
332        }
333    }
334
335    /// What:
336    ///  The commit `HEAD` names,
337    ///  or nothing when the branch has no commit yet.
338    ///       `Option<ObjectId>` is "a commit name or nothing".
339    /// Why:
340    ///   The staged listing compares against this commit.
341    ///  Asking the running
342    ///       reader costs no further process.
343    ///
344    /// In TS you'd write (pseudocode):
345    /// ```ts
346    /// async headCommit(): Promise<ObjectId | undefined>
347    /// ```
348    pub fn head_commit(&mut self) -> Result<Option<ObjectId>, CandidateError> {
349        match self.request(b"HEAD")? {
350            BatchReply::Missing => return Ok(None),
351            BatchReply::Found { object, kind, .. } => {
352                if kind != ObjectKind::Commit {
353                    return Err(CandidateError::new(
354                        CandidateFailure::ObjectKindUnexpected,
355                        "cli-git could not list candidate files: HEAD does not name a commit.",
356                    ));
357                }
358                return Ok(Some(object));
359            }
360        }
361    }
362}
363
364/// What:
365///  `impl Drop` runs when the value goes out of scope,
366///  like a `finally` block
367///       attached to the value itself.
368/// Why:
369///   A child that is never waited for stays in the process table as a zombie,
370///       which counts against the process limit of long sessions and containers.
371///
372/// In TS you'd write (pseudocode):
373/// ```ts
374/// [Symbol.dispose]() { this.#pipes = undefined; this.#child.kill(); }
375/// ```
376impl Drop for ObjectReader {
377    /// Close both pipes,
378    ///  which ends the process,
379    ///  then collect it.
380    fn drop(&mut self) {
381        self.pipes = None;
382        // `let _ =` discards the exit status: every reply was already checked on its own.
383        let _ = self.child.wait();
384    }
385}
386
387/// Process controls stay out of the release executable.
388#[cfg(test)]
389#[path = "candidate_reader_tests.rs"]
390mod tests;
391
392/// Stand-in programs that cut replies short,
393///  mismatch them,
394///  or never answer.
395#[cfg(test)]
396#[path = "candidate_reader_failure_tests.rs"]
397mod failure_tests;