git_policy_cli/policy_checks.rs
1//! What:
2//! The shipped policies as the engine sees them:
3//! one check per policy,
4//! built from
5//! the pure rule cores and the repository facts they ask for.
6//! Why:
7//! The engine owns order,
8//! severity and stopping;
9//! the rule cores own decisions from
10//! arguments;
11//! this module joins them and fetches a repository fact only when a core
12//! asks.
13//! The content policies read the lifecycle's candidates:
14//! they report nothing
15//! where a lifecycle has none.
16//! The Markdown policy checks the candidates it is given
17//! with the native Markdown linter.
18//!
19//! In TS you'd write (pseudocode):
20//! ```ts
21//! // const findings = await policy.check({ context });
22//! ```
23
24/// What:
25/// `use` brings names from sibling files into this file;
26/// `super::` means "the parent
27/// module",
28/// where every sibling file of this crate is declared.
29/// Why:
30/// Each check combines one rule core with the facts interface.
31///
32/// In TS you'd write (pseudocode):
33/// ```ts
34/// import { decideRequireRoot, resolveRequireRoot } from './rule_require_root.ts';
35/// ```
36use super::diagnostics::EngineFailureCode;
37use super::discard_audit::{AuditOutcome, record_audit};
38/// Which worktree a command targets,
39/// for the linked-worktree rule.
40use super::effective_target::{EffectiveTarget, classify_effective_target};
41/// The Markdown autofix check,
42/// its state and the linter the executable gives it.
43use super::markdown_linter::embedded_linter;
44/// What the lifecycle offers content policies,
45/// and its prepared candidates.
46use super::policy_content::{ContentState, LifecycleContent};
47/// The dependent-version check over the candidate state.
48use super::policy_dependent_version::check_dependent_version;
49/// The engine's check interface,
50/// findings and outcomes.
51use super::policy_engine::{PolicyChecks, PolicyFinding, PolicyOutcome};
52/// The built-in final-newline check over candidates.
53use super::policy_final_newline::check_final_newline;
54/// The forbidden-strings check,
55/// its settings and its scanner.
56use super::policy_forbidden_strings::{
57 ScannerSettings, ScannerState, check_forbidden_strings, default_scanner_settings,
58 unloaded_scanner,
59};
60use super::policy_markdown::{MarkdownState, check_markdown, markdown_state};
61/// The typed policy identities.
62use super::policy_registry::PolicyId;
63/// The optional root-context check over candidates.
64use super::policy_root_context::check_root_context;
65/// The optional catalog-specifier check over candidates.
66use super::policy_catalog_specifiers::check_catalog_specifiers;
67/// The lifecycle points.
68use super::policy_trigger::Trigger;
69/// The facts interface every check asks.
70use super::repository_facts::RepositoryFacts;
71/// Where a command runs,
72/// and the directory Git works in.
73use super::repository_location::{RepositoryLocation, effective_directory};
74/// The add-explicit rule core.
75use super::rule_add_explicit::{BULK_ADD_CODE, decide_add_explicit};
76/// The branch-worktree rule core.
77use super::rule_branch_worktree::{
78 BRANCH_CREATION_CODE, BranchWorktreeDecision, branch_creation_message, decide_branch_worktree,
79};
80/// The linked-worktree rule core.
81use super::rule_linked_worktree::{
82 GuardedCommand, LINKED_WORKTREE_REQUIRED_CODE, LinkedWorktreeDecision, decide_linked_worktree,
83 dirty_scope, dirty_worktree_message, resolve_linked_worktree,
84};
85/// The require-root rule core.
86use super::rule_require_root::{
87 NOT_AT_ROOT_CODE, RequireRootDecision, RequireRootFacts, RequireRootVerdict,
88 decide_require_root, resolve_require_root,
89};
90/// The worktree's top level.
91use super::worktree_identity::worktree_root;
92/// What:
93/// `OsString` is owned operating-system text of raw bytes.
94/// Sibling the reader might
95/// expect:
96/// `String`,
97/// which must be valid UTF-8.
98/// Why:
99/// The command's arguments are kept exactly as Git will receive them.
100///
101/// In TS you'd write (pseudocode):
102/// ```ts
103/// // string, but byte-preserving.
104/// ```
105use std::ffi::OsString;
106/// `PathBuf` is an owned filesystem path of raw bytes.
107use std::path::PathBuf;
108
109/// What:
110/// The shipped policies over one invocation.
111/// `<F: RepositoryFacts>` says the record
112/// works with any one type `F` that provides the facts interface,
113/// chosen where the
114/// record is built:
115/// real Git in the executable,
116/// a script in tests.
117/// Why:
118/// Holding the provider by value keeps its remembered answers and its query count
119/// for the whole invocation,
120/// and lets a test read them back afterwards.
121///
122/// In TS you'd write (pseudocode):
123/// ```ts
124/// type ShippedChecks<F extends RepositoryFacts> = { facts: F; arguments: string[]; candidates: LifecycleContent; allowedWorktreeDirs: string[]; content: ContentState; markdown: MarkdownState };
125/// ```
126pub struct ShippedChecks<F: RepositoryFacts> {
127 /// Whether a pass may propose corrections that its caller applies; the commit path's convergence sets it.
128 pub can_apply_patches: bool,
129 /// Where repository facts come from.
130 pub facts: F,
131 /// The command's arguments without wrapper controls;
132 /// for a direct command,
133 /// the Git
134 /// global options written before `cli-git`.
135 pub arguments: Vec<OsString>,
136 /// What the lifecycle offers content policies.
137 pub candidates: LifecycleContent,
138 /// Tool-cache directories exempt from linked-worktree enforcement.
139 pub allowed_worktree_dirs: Vec<PathBuf>,
140 /// The candidates once a content policy prepared them,
141 /// shared by every later one.
142 pub content: ContentState,
143 /// Where the forbidden-strings rules come from:
144 /// options and environment.
145 pub scanner_settings: ScannerSettings,
146 /// The forbidden-strings scanner once loaded,
147 /// shared by every later scan.
148 pub scanner: ScannerState,
149 /// The Markdown autofix policy's options,
150 /// linter and exclude patterns.
151 pub markdown: MarkdownState,
152}
153
154/// What:
155/// The shipped checks for one invocation,
156/// with no candidates yet prepared.
157/// `<F: RepositoryFacts>` accepts any facts provider.
158/// Why:
159/// Every lifecycle builds its checks the same way;
160/// it only chooses what the
161/// content policies may read.
162///
163/// In TS you'd write (pseudocode):
164/// ```ts
165/// function shippedChecks(facts, args, candidates, allowedWorktreeDirs): ShippedChecks;
166/// ```
167pub fn shipped_checks<F: RepositoryFacts>(
168 facts: F,
169 arguments: Vec<OsString>,
170 candidates: LifecycleContent,
171 allowed_worktree_dirs: Vec<PathBuf>,
172) -> ShippedChecks<F> {
173 return ShippedChecks {
174 can_apply_patches: false,
175 facts,
176 arguments,
177 candidates,
178 allowed_worktree_dirs,
179 content: ContentState::new(),
180 scanner_settings: default_scanner_settings(),
181 scanner: unloaded_scanner(),
182 // The linter is linked in, so it needs nothing from the invocation's environment and is ready at once.
183 markdown: markdown_state(Box::new(embedded_linter())),
184 };
185}
186
187/// What:
188/// The outcome of a check whose repository fact could not be read.
189/// `String` is the owned reason the facts provider gave.
190/// Why:
191/// Every fact these checks ask for is read from the repository,
192/// so its absence is
193/// `content-unavailable`,
194/// never a failure of the policy itself.
195///
196/// In TS you'd write (pseudocode):
197/// ```ts
198/// const factUnavailable = (message: string): PolicyOutcome => ({ kind: 'failed', code: 'content-unavailable', message });
199/// ```
200fn fact_unavailable(message: String) -> PolicyOutcome {
201 return PolicyOutcome::Failed {
202 code: EngineFailureCode::ContentUnavailable,
203 message,
204 // `None`: a repository fact is about no single file.
205 path: None,
206 };
207}
208
209/// What:
210/// The outcome "nothing found".
211/// `Vec::<PolicyFinding>::new()` is an empty owned list.
212/// Why:
213/// Most checks end this way;
214/// one helper keeps them identical.
215///
216/// In TS you'd write (pseudocode):
217/// ```ts
218/// const clean = (): PolicyOutcome => ({ kind: 'findings', findings: [] });
219/// ```
220fn clean() -> PolicyOutcome {
221 return PolicyOutcome::Findings(Vec::<PolicyFinding>::new());
222}
223
224/// What:
225/// The outcome "exactly one finding about the command".
226/// `&'static str` is text
227/// baked into the program;
228/// `String` is owned text.
229/// Why:
230/// The four command policies each report at most one finding,
231/// without a path.
232///
233/// In TS you'd write (pseudocode):
234/// ```ts
235/// const rejected = (code: string, message: string): PolicyOutcome => ({ kind: 'findings', findings: [{ code, message }] });
236/// ```
237fn rejected(code: &'static str, message: String) -> PolicyOutcome {
238 // `vec![...]` is a macro building an owned list from literal items.
239 return PolicyOutcome::Findings(vec![PolicyFinding {
240 code,
241 message,
242 // `None` is the "absent" case of `Option`.
243 path: None,
244 location: None,
245 fix_available: false,
246 }]);
247}
248
249/// What:
250/// The require-root check.
251/// `&mut dyn RepositoryFacts` lends "any facts provider" for
252/// writing (`dyn` means the concrete type is chosen at run time,
253/// like a TS interface
254/// value);
255/// `&[OsString]` borrows the arguments.
256/// Why:
257/// A forwarded command is first judged by its arguments,
258/// because many commands are
259/// exempt.
260/// A direct check has no Git command whose exemption could apply.
261/// The root
262/// and the directory both come from Git's own answer.
263///
264/// In TS you'd write (pseudocode):
265/// ```ts
266/// async function checkRequireRoot(facts: RepositoryFacts, args: string[], trigger: PolicyTrigger): Promise<PolicyOutcome>;
267/// ```
268fn check_require_root(
269 facts: &mut dyn RepositoryFacts,
270 arguments: &[OsString],
271 trigger: Trigger,
272) -> PolicyOutcome {
273 // `&[]` is an empty flag list: wrapper controls were removed before this point.
274 if trigger == Trigger::PreForward
275 && decide_require_root(arguments, &[]) != RequireRootDecision::NeedsRepositoryRoot
276 {
277 return clean();
278 }
279 // `match` unpacks the answer; `Ok`/`Err` are the success/failure variants.
280 let location: RepositoryLocation = match facts.location() {
281 Ok(found) => found,
282 Err(message) => return fact_unavailable(message),
283 };
284 // `let Some(x) = ... else { ... };` unwraps the top level or returns: no worktree, no root.
285 let Some(root) = worktree_root(&location.identity) else {
286 return clean();
287 };
288 let measured: RequireRootFacts = RequireRootFacts {
289 effective_directory: effective_directory(root, location.prefix.as_slice()),
290 // `Some(x)` is the "present" case; `.to_path_buf()` copies the borrowed path.
291 repository_root: Some(root.to_path_buf()),
292 };
293 // `&measured` lends the measurements read-only.
294 match resolve_require_root(&measured) {
295 RequireRootVerdict::Pass => return clean(),
296 RequireRootVerdict::NotAtRoot(violation) => {
297 return rejected(NOT_AT_ROOT_CODE, violation.message);
298 }
299 }
300}
301
302/// What:
303/// The linked-worktree-only check.
304/// `&[PathBuf]` borrows the exempt tool caches.
305/// Why:
306/// A guarded form that overwrites is rejected at once in the main worktree and outside any worktree, and in a
307/// linked worktree only while uncommitted state exists; a form that only moves `HEAD` is rejected in the main or
308/// a linked worktree only while uncommitted state exists. Only these need the worktree kind or the status.
309///
310/// In TS you'd write (pseudocode):
311/// ```ts
312/// async function checkLinkedWorktree(facts, args, allowedDirs): Promise<PolicyOutcome>;
313/// ```
314fn check_linked_worktree(
315 facts: &mut dyn RepositoryFacts,
316 arguments: &[OsString],
317 allowed_worktree_dirs: &[PathBuf],
318) -> PolicyOutcome {
319 // `let Variant(x) = ... else { ... };` unwraps the guarded command or returns.
320 let (command, moves_head_only): (GuardedCommand, bool) = match decide_linked_worktree(arguments) {
321 LinkedWorktreeDecision::Pass => return clean(),
322 LinkedWorktreeDecision::NeedsEffectiveTarget(command) => (command, false),
323 LinkedWorktreeDecision::WhenDirty(command) => (command, true),
324 };
325 let location: RepositoryLocation = match facts.location() {
326 Ok(found) => found,
327 Err(message) => return fact_unavailable(message),
328 };
329 let target: EffectiveTarget =
330 classify_effective_target(&location.identity, allowed_worktree_dirs);
331 if moves_head_only {
332 // A `HEAD` move outside a worktree or in an exempt cache is not this policy's concern.
333 if target != EffectiveTarget::MainWorktree && target != EffectiveTarget::LinkedWorktree {
334 return clean();
335 }
336 } else if let Some(message) = resolve_linked_worktree(command, target) {
337 record_audit(&location.identity, command, AuditOutcome::Rejected, arguments, None);
338 return rejected(LINKED_WORKTREE_REQUIRED_CODE, message);
339 }
340 if target == EffectiveTarget::Allowlisted {
341 record_audit(&location.identity, command, AuditOutcome::Exempt, arguments, None);
342 return clean();
343 }
344 match facts.worktree_dirty(dirty_scope(command)) {
345 Ok(true) => {
346 record_audit(&location.identity, command, AuditOutcome::Rejected, arguments, Some(true));
347 return rejected(LINKED_WORKTREE_REQUIRED_CODE, dirty_worktree_message(command));
348 }
349 Ok(false) => {
350 record_audit(&location.identity, command, AuditOutcome::Allowed, arguments, Some(false));
351 return clean();
352 }
353 Err(message) => return fact_unavailable(message),
354 }
355}
356
357/// What:
358/// The branch-worktree-only check.
359/// Why:
360/// Explicit creation is rejected from the arguments;
361/// a bare name is rejected only
362/// when Git would create a branch for it from the one remote that has it.
363///
364/// In TS you'd write (pseudocode):
365/// ```ts
366/// async function checkBranchWorktree(facts, args): Promise<PolicyOutcome>;
367/// ```
368fn check_branch_worktree(facts: &mut dyn RepositoryFacts, arguments: &[OsString]) -> PolicyOutcome {
369 match decide_branch_worktree(arguments) {
370 BranchWorktreeDecision::Pass => return clean(),
371 BranchWorktreeDecision::Creates(command) => {
372 return rejected(BRANCH_CREATION_CODE, branch_creation_message(command, None));
373 }
374 BranchWorktreeDecision::NeedsRemoteGuess { command, target } => {
375 // `.as_os_str()` lends the owned name as a borrowed one.
376 match facts.remote_guess_creates_branch(target.as_os_str()) {
377 Err(message) => return fact_unavailable(message),
378 Ok(false) => return clean(),
379 Ok(true) => {
380 return rejected(
381 BRANCH_CREATION_CODE,
382 branch_creation_message(command, Some(target.as_os_str())),
383 );
384 }
385 }
386 }
387 }
388}
389
390/// What:
391/// The add-explicit check.
392/// Why:
393/// The decision needs the arguments only.
394///
395/// In TS you'd write (pseudocode):
396/// ```ts
397/// function checkAddExplicit(args: string[]): PolicyOutcome;
398/// ```
399fn check_add_explicit(arguments: &[OsString]) -> PolicyOutcome {
400 match decide_add_explicit(arguments) {
401 Some(message) => return rejected(BULK_ADD_CODE, message),
402 None => return clean(),
403 }
404}
405
406/// What:
407/// `impl<F: RepositoryFacts> PolicyChecks for ShippedChecks<F>` provides the engine's
408/// interface for the shipped policies,
409/// for any facts provider `F`.
410/// Why:
411/// One `match` over the typed policy identity is the whole registry of behavior:
412/// the compiler refuses a policy without a check.
413///
414/// In TS you'd write (pseudocode):
415/// ```ts
416/// check(policy: PolicyId, trigger: PolicyTrigger): Promise<PolicyOutcome> { switch (policy) { /* ... */ } }
417/// ```
418impl<F: RepositoryFacts> PolicyChecks for ShippedChecks<F> {
419 /// What:
420 /// The outcome of one policy at one lifecycle point.
421 /// Why:
422 /// Each arm calls the one check that owns the policy.
423 ///
424 /// In TS you'd write (pseudocode):
425 /// ```ts
426 /// check(policy: PolicyId, trigger: PolicyTrigger): PolicyOutcome;
427 /// ```
428 fn check(&mut self, policy: PolicyId, trigger: Trigger) -> PolicyOutcome {
429 // `&mut self.facts` lends the provider for writing; `.as_slice()` lends a list.
430 match policy {
431 PolicyId::RequireRoot => {
432 return check_require_root(&mut self.facts, self.arguments.as_slice(), trigger);
433 }
434 PolicyId::LinkedWorktreeOnly => {
435 return check_linked_worktree(
436 &mut self.facts,
437 self.arguments.as_slice(),
438 self.allowed_worktree_dirs.as_slice(),
439 );
440 }
441 PolicyId::BranchWorktreeOnly => {
442 return check_branch_worktree(&mut self.facts, self.arguments.as_slice());
443 }
444 PolicyId::AddExplicit => return check_add_explicit(self.arguments.as_slice()),
445 PolicyId::FinalNewline => {
446 return check_final_newline(
447 &mut self.content,
448 &self.candidates,
449 &mut self.facts,
450 trigger,
451 self.can_apply_patches,
452 );
453 }
454 PolicyId::ForbiddenRootContext => {
455 return check_root_context(&mut self.content, &self.candidates, &mut self.facts);
456 }
457 PolicyId::MarkdownAutofix => {
458 return check_markdown(
459 &mut self.content,
460 &self.candidates,
461 &mut self.facts,
462 &mut self.markdown,
463 trigger,
464 );
465 }
466 PolicyId::DependentVersionBump => {
467 return check_dependent_version(
468 &mut self.content,
469 &self.candidates,
470 &mut self.facts,
471 trigger,
472 );
473 }
474 PolicyId::CatalogSpecifiers => {
475 return check_catalog_specifiers(
476 &mut self.content,
477 &self.candidates,
478 &mut self.facts,
479 );
480 }
481 PolicyId::ForbiddenStrings => {
482 return check_forbidden_strings(
483 &mut self.content,
484 &self.candidates,
485 &mut self.facts,
486 &self.scanner_settings,
487 &mut self.scanner,
488 );
489 }
490 }
491 }
492}
493
494/// Per-policy outcomes and lazy fact use stay out of the release executable.
495#[cfg(test)]
496#[path = "policy_checks_tests.rs"]
497mod tests;