git_policy_cli/repo_read/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 crate::candidate_error::CandidateError;
35/// Candidate preparation on a private index.
36use crate::candidate_prediction::{CandidateRequest, PreparedCandidates, prepare_candidates};
37/// The captured-query runner and its byte helpers.
38use crate::git_metadata::{MetadataOutput, path_from_git_bytes, run_metadata_git};
39use crate::repo_read_adapter::{location_of, sequencer_of};
40use crate::git_system_config::with_real_git;
41use crate::repo_read_layout::{RepoLayout, read_layout};
42use crate::repository_location::{
43 RepositoryLocation, location_query_arguments, parse_location_output,
44};
45use crate::rule_branch_worktree::{remote_guess_creates_branch, remote_guess_query_arguments};
46use crate::rule_commit_index::{IndexVsHead, index_query_arguments, index_state_from_exit};
47use crate::rule_worktree_dirty::{DirtyScope, dirty_from_output, dirty_query_arguments};
48use crate::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 crate::transaction_git::GitContext;
54/// The common directory of a location's identity.
55use crate::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;