Skip to main content

git_policy_cli/
repository_facts.rs

1//! What:
2//!  The repository facts policies and fixed transforms need,
3//!  behind one small
4//!       interface,
5//!  with the provider that asks real Git.
6//! Why:
7//!  The rule cores decide from arguments alone and name the fact they still need.
8//!      This module fetches those facts lazily and at most once:
9//!  an invocation whose rules
10//!      need nothing starts no Git process,
11//!  and several rules that need the repository's
12//!      location share one query.
13//!  Tests drive the rules with a scripted provider.
14//!
15//! In TS you'd write (pseudocode):
16//! ```ts
17//! // const facts = createGitFacts({ gitPath, globalPrefix }); await facts.location();
18//! ```
19
20/// What:
21///  `use` brings names from sibling files into this file;
22///  `super::` means "the parent
23///       module",
24///  where every sibling file of this crate is declared.
25/// Why:
26///   Each fact has a pure core that builds its query and reads its answer;
27///  this module
28///       only runs the query.
29///
30/// In TS you'd write (pseudocode):
31/// ```ts
32/// import { indexQueryArguments, indexStateFromExit } from './rule_commit_index.ts';
33/// ```
34use super::candidate_error::CandidateError;
35/// Candidate preparation on a private index.
36use super::candidate_prediction::{CandidateRequest, PreparedCandidates, prepare_candidates};
37/// The captured-query runner and its byte helpers.
38use super::git_metadata::{MetadataOutput, path_from_git_bytes, run_metadata_git};
39use super::repo_read_adapter::{location_of, sequencer_of};
40use super::git_system_config::with_real_git;
41use super::repo_read_layout::{RepoLayout, read_layout};
42use super::repository_location::{
43    RepositoryLocation, location_query_arguments, parse_location_output,
44};
45use super::rule_branch_worktree::{remote_guess_creates_branch, remote_guess_query_arguments};
46use super::rule_commit_index::{IndexVsHead, index_query_arguments, index_state_from_exit};
47use super::rule_worktree_dirty::{DirtyScope, dirty_from_output, dirty_query_arguments};
48use super::rule_commit_sequencer::{
49    SequencerFacts, SequencerState, sequencer_head_paths, sequencer_query_arguments,
50    sequencer_state, sequencer_state_when_query_fails,
51};
52/// The transaction Git context.
53use super::transaction_git::GitContext;
54/// The common directory of a location's identity.
55use super::worktree_identity::{common_directory, git_directory};
56/// What:
57///  `OsStr`/`OsString` are borrowed/owned operating-system text of raw bytes.
58///  Sibling
59///       the reader might expect:
60///  `&str`/`String`,
61///  which must be valid UTF-8.
62/// Why:
63///   Global options and branch names are passed to Git unchanged.
64///
65/// In TS you'd write (pseudocode):
66/// ```ts
67/// // string, but byte-preserving.
68/// ```
69use std::ffi::{OsStr, OsString};
70/// `Path`/`PathBuf` are borrowed/owned filesystem paths of raw bytes.
71use std::path::{Path, PathBuf};
72
73/// What:
74///  The facts a rule may ask for.
75///  A `trait` is a named set of methods a type promises
76///       to provide,
77///  like a TS `interface`;
78///  `&mut self` lends the provider for writing,
79///       because it remembers answers.
80///  `Result<T, String>` is "a value,
81///  or a message
82///       saying why Git could not be asked or understood".
83/// Why:
84///   Two providers exist:
85///  real Git for the executable,
86///  a script for tests.
87///
88/// In TS you'd write (pseudocode):
89/// ```ts
90/// interface RepositoryFacts { location(): Promise<RepositoryLocation>; indexVsHead(): Promise<IndexVsHead>; /* ... */ }
91/// ```
92pub trait RepositoryFacts {
93    /// Repository identity,
94    ///  worktree top level and the path below it;
95    ///  asked once.
96    fn location(&mut self) -> Result<RepositoryLocation, String>;
97    /// Whether the index differs from `HEAD`.
98    fn index_vs_head(&mut self) -> Result<IndexVsHead, String>;
99    /// Whether the worktree holds uncommitted state of the given scope.
100    fn worktree_dirty(&mut self, scope: DirtyScope) -> Result<bool, String>;
101    /// Whether a merge,
102    ///  cherry-pick or revert awaits its concluding commit.
103    fn sequencer_state(&mut self) -> Result<SequencerState, String>;
104    /// Whether `git switch <target>` or `git checkout <target>` would create a local
105    /// branch from the one remote branch of that name.
106    fn remote_guess_creates_branch(&mut self, target: &OsStr) -> Result<bool, String>;
107    /// The candidates a content policy reads for this request,
108    ///  prepared on a private
109    /// index;
110    ///  a failure carries the candidate layer's cause.
111    fn candidates(
112        &mut self,
113        request: &CandidateRequest,
114    ) -> Result<PreparedCandidates, CandidateError>;
115    /// The location when it was already asked for,
116    ///  without asking.
117    fn asked_location(&self) -> Option<Result<RepositoryLocation, String>>;
118    /// The repository's common Git directory when it is known without starting Git: from the location already asked,
119    ///  or from the file system.
120    /// A provider that has neither answers nothing.
121    fn known_common_dir(&mut self) -> Option<PathBuf> {
122        let asked: Option<Result<RepositoryLocation, String>> = self.asked_location();
123        return asked.and_then(Result::ok).and_then(|found| { return common_directory(&found.identity).map(Path::to_path_buf); });
124    }
125    /// The invocation's own Git directory (the worktree's, which holds its work records and notices) when it is known without
126    ///  starting Git: from the location already asked, or from the file system. A provider that has neither answers nothing.
127    fn known_git_dir(&mut self) -> Option<PathBuf> {
128        let asked: Option<Result<RepositoryLocation, String>> = self.asked_location();
129        return asked.and_then(Result::ok).and_then(|found| { return git_directory(&found.identity).map(Path::to_path_buf); });
130    }
131    /// Real Git,
132    ///  the child environment and the invocation's global options,
133    ///  for transaction
134    /// and recovery work.
135    fn transaction_context(&self) -> GitContext;
136}
137
138/// What:
139///  The provider that asks real Git.
140///  A `struct` is a record with named fields;
141///       `Option<T>` is "a value or nothing";
142///  `usize` is the unsigned integer of counts.
143/// Why:
144///   It owns copies of the executable path,
145///  the caller's global options and the child
146///       environment additions,
147///  so it can be handed around without borrowing rules.
148///  The
149///       query count lets a test prove how many Git processes an invocation started.
150///
151/// In TS you'd write (pseudocode):
152/// ```ts
153/// type GitFacts = { realGit: string; globalPrefix: string[]; overlay: [string, string][]; location?: Result; queries: number };
154/// ```
155#[derive(Clone, Debug)]
156pub struct GitFacts {
157    /// The selected real Git executable.
158    real_git: PathBuf,
159    /// The arguments before the subcommand,
160    ///  without wrapper controls.
161    global_prefix: Vec<OsString>,
162    /// Variables added to every Git child.
163    overlay: Vec<(OsString, OsString)>,
164    /// The remembered location answer,
165    ///  once asked.
166    location: Option<Result<RepositoryLocation, String>>,
167    /// The in-process layout, read once: `Some(None)` means the reader declined and Git answers.
168    layout: Option<Option<RepoLayout>>,
169    /// How many Git processes this provider started.
170    pub queries: usize,
171}
172
173/// What:
174///  Build a provider that has asked nothing yet.
175///  `&Path` and `&[...]` borrow the
176///       caller's values;
177///  the provider keeps its own copies.
178/// Why:
179///   Creating a provider costs no process;
180///  only a rule that needs a fact pays for it.
181///
182/// In TS you'd write (pseudocode):
183/// ```ts
184/// function gitFacts(realGit: string, globalPrefix: string[], overlay: [string, string][]): GitFacts;
185/// ```
186pub fn git_facts(
187    real_git: &Path,
188    global_prefix: &[OsString],
189    overlay: &[(OsString, OsString)],
190) -> GitFacts {
191    return GitFacts {
192        // `.to_path_buf()` and `.to_vec()` copy the borrowed values into owned ones.
193        real_git: real_git.to_path_buf(),
194        global_prefix: global_prefix.to_vec(),
195        overlay: overlay.to_vec(),
196        // `None` is the "absent" case of `Option`: nothing has been asked.
197        location: None,
198        layout: None,
199        queries: 0,
200    };
201}
202
203/// What:
204///  Say that real Git could not be started for a fact.
205///  `&std::io::Error` borrows the
206///       operating system's error.
207/// Why:
208///   The message names the executable and the fact,
209///  so the reader knows what failed.
210///
211/// In TS you'd write (pseudocode):
212/// ```ts
213/// const message = `cli-git could not start ${realGit} to read ${fact}: ${error}`;
214/// ```
215fn not_started(real_git: &Path, fact: &str, error: &std::io::Error) -> String {
216    // `format!` builds owned text; `.display()` shows a path, replacing undecodable bytes.
217    return format!(
218        "cli-git could not start {} to read {fact}: {error}",
219        real_git.display()
220    );
221}
222
223/// Private helpers of the real-Git provider.
224impl GitFacts {
225    /// What:
226    ///  Run one captured Git query after the caller's global options and count it.
227    ///       `&mut self` lends the provider for writing;
228    ///  `Vec<OsString>` is the owned
229    ///       argument list,
230    ///  already starting with the global options.
231    /// Why:
232    ///   Every fact goes through here,
233    ///  so the count cannot miss a process.
234    ///
235    /// In TS you'd write (pseudocode):
236    /// ```ts
237    /// async query(args: string[], fact: string): Promise<MetadataOutput>;
238    /// ```
239    fn query(&mut self, arguments: Vec<OsString>, fact: &str) -> Result<MetadataOutput, String> {
240        self.queries += 1;
241        // `match` unpacks the start result; `Ok`/`Err` are the success/failure variants.
242        match run_metadata_git(
243            self.real_git.as_path(),
244            arguments.as_slice(),
245            self.overlay.as_slice(),
246        ) {
247            Ok(output) => return Ok(output),
248            // `&error` borrows the error for the message.
249            Err(error) => return Err(not_started(self.real_git.as_path(), fact, &error)),
250        }
251    }
252
253    /// What:
254    ///  The repository layout read from the file system, once per provider;
255    ///  `None` when the reader declined and Git must answer.
256    /// Why:
257    ///   Location and sequencer questions share one read and start no Git process when the layout is plain.
258    ///
259    /// In TS you'd write (pseudocode):
260    /// ```ts
261    /// layout(): RepoLayout | undefined; // memoized
262    /// ```
263    fn layout(&mut self) -> Option<&RepoLayout> {
264        if self.layout.is_none() {
265            self.layout = Some(read_layout(self.global_prefix.as_slice(), with_real_git(self.overlay.as_slice(), self.real_git.as_path()).as_slice()));
266        }
267        return self.layout.as_ref().and_then(Option::as_ref);
268    }
269}
270
271/// What:
272///  `impl RepositoryFacts for GitFacts` provides the interface's methods for the
273///       real-Git provider.
274/// Why:
275///   Each method is one query whose arguments and reading belong to a pure core.
276///
277/// In TS you'd write (pseudocode):
278/// ```ts
279/// class GitFacts implements RepositoryFacts { /* ... */ }
280/// ```
281impl RepositoryFacts for GitFacts {
282    fn location(&mut self) -> Result<RepositoryLocation, String> {
283        // `if let Some(known) = &self.location` borrows the remembered answer when present.
284        if let Some(known) = &self.location {
285            // `.clone()` copies the remembered answer for the caller.
286            return known.clone();
287        }
288        // A plain layout is read from the file system; only a layout the reader declines costs a Git process.
289        let read: Option<RepositoryLocation> = self.layout().map(location_of);
290        if let Some(found) = read {
291            self.location = Some(Ok(found.clone()));
292            return Ok(found);
293        }
294        let arguments: Vec<OsString> = location_query_arguments(self.global_prefix.as_slice());
295        let answer: Result<RepositoryLocation, String> =
296            match self.query(arguments, "the repository location") {
297                Ok(output) => read_location(&output),
298                Err(message) => Err(message),
299            };
300        // `Some(x)` is the "present" case of `Option`.
301        self.location = Some(answer.clone());
302        return answer;
303    }
304
305    fn index_vs_head(&mut self) -> Result<IndexVsHead, String> {
306        let arguments: Vec<OsString> = index_query_arguments(self.global_prefix.as_slice());
307        // A trailing `?` returns the failure to our caller, or unwraps the output.
308        let output: MetadataOutput = self.query(arguments, "the index state")?;
309        return Ok(index_state_from_exit(output.code));
310    }
311
312    fn worktree_dirty(&mut self, scope: DirtyScope) -> Result<bool, String> {
313        let arguments: Vec<OsString> = dirty_query_arguments(self.global_prefix.as_slice(), scope);
314        let output: MetadataOutput = self.query(arguments, "the worktree status")?;
315        return dirty_from_output(&output);
316    }
317
318    fn sequencer_state(&mut self) -> Result<SequencerState, String> {
319        if let Some(state) = self.layout().and_then(sequencer_of) {
320            return Ok(state);
321        }
322        let arguments: Vec<OsString> = sequencer_query_arguments(self.global_prefix.as_slice());
323        let output: MetadataOutput = self.query(arguments, "the Git directory")?;
324        if !output.success {
325            return Ok(sequencer_state_when_query_fails());
326        }
327        let paths: Vec<Vec<u8>> = match sequencer_head_paths(output.stdout.as_slice()) {
328            Ok(found) => found,
329            Err(error) => return Err(error.to_string()),
330        };
331        return Ok(sequencer_state(SequencerFacts {
332            merge_head: head_file_exists(paths[0].as_slice()),
333            cherry_pick_head: head_file_exists(paths[1].as_slice()),
334            revert_head: head_file_exists(paths[2].as_slice()),
335        }));
336    }
337
338    fn remote_guess_creates_branch(&mut self, target: &OsStr) -> Result<bool, String> {
339        let arguments: Vec<OsString> =
340            remote_guess_query_arguments(self.global_prefix.as_slice(), target);
341        let output: MetadataOutput = self.query(arguments, "branch names")?;
342        if !output.success {
343            return Ok(false);
344        }
345        // `.as_encoded_bytes()` lends the raw bytes of the branch name.
346        return Ok(remote_guess_creates_branch(
347            output.stdout.as_slice(),
348            target.as_encoded_bytes(),
349        ));
350    }
351
352    /// Prepare the candidates with this provider's Git,
353    ///  prefix and environment.
354    fn candidates(
355        &mut self,
356        request: &CandidateRequest,
357    ) -> Result<PreparedCandidates, CandidateError> {
358        // The same Git, prefix and environment as every other question, so the prediction
359        // looks at the repository the command will change.
360        return prepare_candidates(
361            self.real_git.as_path(),
362            self.global_prefix.as_slice(),
363            self.overlay.as_slice(),
364            request,
365        );
366    }
367
368    fn asked_location(&self) -> Option<Result<RepositoryLocation, String>> {
369        return self.location.clone();
370    }
371
372    fn known_common_dir(&mut self) -> Option<PathBuf> {
373        if let Some(Ok(found)) = &self.location {
374            return common_directory(&found.identity).map(Path::to_path_buf);
375        }
376        // A plain layout is read from the file system; a layout the reader declines is not worth a Git process here.
377        return self.layout().map(|found| { return found.common_dir.clone(); });
378    }
379
380    fn known_git_dir(&mut self) -> Option<PathBuf> {
381        if let Some(Ok(found)) = &self.location {
382            return git_directory(&found.identity).map(Path::to_path_buf);
383        }
384        return self.layout().map(|found| { return found.git_dir.clone(); });
385    }
386
387    fn transaction_context(&self) -> GitContext {
388        return GitContext {
389            real_git: self.real_git.clone(),
390            overlay: self.overlay.clone(),
391            global_prefix: self.global_prefix.clone(),
392            // The repository is known once its location was asked; a context made before that has none.
393            common_dir: match &self.location {
394                Some(Ok(found)) => common_directory(&found.identity).map(Path::to_path_buf),
395                Some(Err(_)) | None => None,
396            },
397        };
398    }
399}
400
401/// What:
402///  Read the location answer,
403///  turning an unreadable one into a message.
404///       `&MetadataOutput` borrows the captured query result.
405/// Why:
406///   Callers of the facts interface report failures as text.
407///
408/// In TS you'd write (pseudocode):
409/// ```ts
410/// try { return parseLocationOutput(output.success, output.stdout); } catch (e) { throw String(e); }
411/// ```
412fn read_location(output: &MetadataOutput) -> Result<RepositoryLocation, String> {
413    match parse_location_output(output.success, output.stdout.as_slice()) {
414        Ok(location) => return Ok(location),
415        // `.to_string()` renders the input/output error as owned text.
416        Err(error) => return Err(error.to_string()),
417    }
418}
419
420/// What:
421///  Whether the file at the path Git printed exists,
422///  without following a final link.
423///       `&[u8]` borrows the path bytes.
424/// Why:
425///   Git itself tests these head files with `lstat`,
426///  so a dangling link counts as
427///       present here too.
428///
429/// In TS you'd write (pseudocode):
430/// ```ts
431/// const exists = await lstat(path).then(() => true, () => false);
432/// ```
433fn head_file_exists(path: &[u8]) -> bool {
434    // `match` unpacks "a path or nothing": empty bytes are not a path.
435    match path_from_git_bytes(path) {
436        // `.is_ok()` is true when the file's metadata could be read.
437        Some(found) => return std::fs::symlink_metadata(found).is_ok(),
438        None => return false,
439    }
440}
441
442/// Query-count and disposable-repository controls stay out of the release executable.
443#[cfg(test)]
444#[path = "repository_facts_tests.rs"]
445mod tests;