Skip to main content

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::executable_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        // An empty environment has no PATH: until a lifecycle gives the invocation's
183        // environment, a Markdown candidate fails as unchecked rather than passing.
184        markdown: markdown_state(Box::new(executable_linter(&[]))),
185    };
186}
187
188/// What:
189///  The outcome of a check whose repository fact could not be read.
190///       `String` is the owned reason the facts provider gave.
191/// Why:
192///   Every fact these checks ask for is read from the repository,
193///  so its absence is
194///       `content-unavailable`,
195///  never a failure of the policy itself.
196///
197/// In TS you'd write (pseudocode):
198/// ```ts
199/// const factUnavailable = (message: string): PolicyOutcome => ({ kind: 'failed', code: 'content-unavailable', message });
200/// ```
201fn fact_unavailable(message: String) -> PolicyOutcome {
202    return PolicyOutcome::Failed {
203        code: EngineFailureCode::ContentUnavailable,
204        message,
205        // `None`: a repository fact is about no single file.
206        path: None,
207    };
208}
209
210/// What:
211///  The outcome "nothing found".
212///  `Vec::<PolicyFinding>::new()` is an empty owned list.
213/// Why:
214///   Most checks end this way;
215///  one helper keeps them identical.
216///
217/// In TS you'd write (pseudocode):
218/// ```ts
219/// const clean = (): PolicyOutcome => ({ kind: 'findings', findings: [] });
220/// ```
221fn clean() -> PolicyOutcome {
222    return PolicyOutcome::Findings(Vec::<PolicyFinding>::new());
223}
224
225/// What:
226///  The outcome "exactly one finding about the command".
227///  `&'static str` is text
228///       baked into the program;
229///  `String` is owned text.
230/// Why:
231///   The four command policies each report at most one finding,
232///  without a path.
233///
234/// In TS you'd write (pseudocode):
235/// ```ts
236/// const rejected = (code: string, message: string): PolicyOutcome => ({ kind: 'findings', findings: [{ code, message }] });
237/// ```
238fn rejected(code: &'static str, message: String) -> PolicyOutcome {
239    // `vec![...]` is a macro building an owned list from literal items.
240    return PolicyOutcome::Findings(vec![PolicyFinding {
241        code,
242        message,
243        // `None` is the "absent" case of `Option`.
244        path: None,
245        location: None,
246        fix_available: false,
247    }]);
248}
249
250/// What:
251///  The require-root check.
252///  `&mut dyn RepositoryFacts` lends "any facts provider" for
253///       writing (`dyn` means the concrete type is chosen at run time,
254///  like a TS interface
255///       value);
256///  `&[OsString]` borrows the arguments.
257/// Why:
258///   A forwarded command is first judged by its arguments,
259///  because many commands are
260///       exempt.
261///  A direct check has no Git command whose exemption could apply.
262///  The root
263///       and the directory both come from Git's own answer.
264///
265/// In TS you'd write (pseudocode):
266/// ```ts
267/// async function checkRequireRoot(facts: RepositoryFacts, args: string[], trigger: PolicyTrigger): Promise<PolicyOutcome>;
268/// ```
269fn check_require_root(
270    facts: &mut dyn RepositoryFacts,
271    arguments: &[OsString],
272    trigger: Trigger,
273) -> PolicyOutcome {
274    // `&[]` is an empty flag list: wrapper controls were removed before this point.
275    if trigger == Trigger::PreForward
276        && decide_require_root(arguments, &[]) != RequireRootDecision::NeedsRepositoryRoot
277    {
278        return clean();
279    }
280    // `match` unpacks the answer; `Ok`/`Err` are the success/failure variants.
281    let location: RepositoryLocation = match facts.location() {
282        Ok(found) => found,
283        Err(message) => return fact_unavailable(message),
284    };
285    // `let Some(x) = ... else { ... };` unwraps the top level or returns: no worktree, no root.
286    let Some(root) = worktree_root(&location.identity) else {
287        return clean();
288    };
289    let measured: RequireRootFacts = RequireRootFacts {
290        effective_directory: effective_directory(root, location.prefix.as_slice()),
291        // `Some(x)` is the "present" case; `.to_path_buf()` copies the borrowed path.
292        repository_root: Some(root.to_path_buf()),
293    };
294    // `&measured` lends the measurements read-only.
295    match resolve_require_root(&measured) {
296        RequireRootVerdict::Pass => return clean(),
297        RequireRootVerdict::NotAtRoot(violation) => {
298            return rejected(NOT_AT_ROOT_CODE, violation.message);
299        }
300    }
301}
302
303/// What:
304///  The linked-worktree-only check.
305///  `&[PathBuf]` borrows the exempt tool caches.
306/// Why:
307///   A guarded form that overwrites is rejected at once in the main worktree and outside any worktree, and in a
308///  linked worktree only while uncommitted state exists; a form that only moves `HEAD` is rejected in the main or
309///  a linked worktree only while uncommitted state exists. Only these need the worktree kind or the status.
310///
311/// In TS you'd write (pseudocode):
312/// ```ts
313/// async function checkLinkedWorktree(facts, args, allowedDirs): Promise<PolicyOutcome>;
314/// ```
315fn check_linked_worktree(
316    facts: &mut dyn RepositoryFacts,
317    arguments: &[OsString],
318    allowed_worktree_dirs: &[PathBuf],
319) -> PolicyOutcome {
320    // `let Variant(x) = ... else { ... };` unwraps the guarded command or returns.
321    let (command, moves_head_only): (GuardedCommand, bool) = match decide_linked_worktree(arguments) {
322        LinkedWorktreeDecision::Pass => return clean(),
323        LinkedWorktreeDecision::NeedsEffectiveTarget(command) => (command, false),
324        LinkedWorktreeDecision::WhenDirty(command) => (command, true),
325    };
326    let location: RepositoryLocation = match facts.location() {
327        Ok(found) => found,
328        Err(message) => return fact_unavailable(message),
329    };
330    let target: EffectiveTarget =
331        classify_effective_target(&location.identity, allowed_worktree_dirs);
332    if moves_head_only {
333        // A `HEAD` move outside a worktree or in an exempt cache is not this policy's concern.
334        if target != EffectiveTarget::MainWorktree && target != EffectiveTarget::LinkedWorktree {
335            return clean();
336        }
337    } else if let Some(message) = resolve_linked_worktree(command, target) {
338        record_audit(&location.identity, command, AuditOutcome::Rejected, arguments, None);
339        return rejected(LINKED_WORKTREE_REQUIRED_CODE, message);
340    }
341    if target == EffectiveTarget::Allowlisted {
342        record_audit(&location.identity, command, AuditOutcome::Exempt, arguments, None);
343        return clean();
344    }
345    match facts.worktree_dirty(dirty_scope(command)) {
346        Ok(true) => {
347            record_audit(&location.identity, command, AuditOutcome::Rejected, arguments, Some(true));
348            return rejected(LINKED_WORKTREE_REQUIRED_CODE, dirty_worktree_message(command));
349        }
350        Ok(false) => {
351            record_audit(&location.identity, command, AuditOutcome::Allowed, arguments, Some(false));
352            return clean();
353        }
354        Err(message) => return fact_unavailable(message),
355    }
356}
357
358/// What:
359///  The branch-worktree-only check.
360/// Why:
361///   Explicit creation is rejected from the arguments;
362///  a bare name is rejected only
363///       when Git would create a branch for it from the one remote that has it.
364///
365/// In TS you'd write (pseudocode):
366/// ```ts
367/// async function checkBranchWorktree(facts, args): Promise<PolicyOutcome>;
368/// ```
369fn check_branch_worktree(facts: &mut dyn RepositoryFacts, arguments: &[OsString]) -> PolicyOutcome {
370    match decide_branch_worktree(arguments) {
371        BranchWorktreeDecision::Pass => return clean(),
372        BranchWorktreeDecision::Creates(command) => {
373            return rejected(BRANCH_CREATION_CODE, branch_creation_message(command, None));
374        }
375        BranchWorktreeDecision::NeedsRemoteGuess { command, target } => {
376            // `.as_os_str()` lends the owned name as a borrowed one.
377            match facts.remote_guess_creates_branch(target.as_os_str()) {
378                Err(message) => return fact_unavailable(message),
379                Ok(false) => return clean(),
380                Ok(true) => {
381                    return rejected(
382                        BRANCH_CREATION_CODE,
383                        branch_creation_message(command, Some(target.as_os_str())),
384                    );
385                }
386            }
387        }
388    }
389}
390
391/// What:
392///  The add-explicit check.
393/// Why:
394///   The decision needs the arguments only.
395///
396/// In TS you'd write (pseudocode):
397/// ```ts
398/// function checkAddExplicit(args: string[]): PolicyOutcome;
399/// ```
400fn check_add_explicit(arguments: &[OsString]) -> PolicyOutcome {
401    match decide_add_explicit(arguments) {
402        Some(message) => return rejected(BULK_ADD_CODE, message),
403        None => return clean(),
404    }
405}
406
407/// What:
408///  `impl<F: RepositoryFacts> PolicyChecks for ShippedChecks<F>` provides the engine's
409///       interface for the shipped policies,
410///  for any facts provider `F`.
411/// Why:
412///   One `match` over the typed policy identity is the whole registry of behavior:
413///       the compiler refuses a policy without a check.
414///
415/// In TS you'd write (pseudocode):
416/// ```ts
417/// check(policy: PolicyId, trigger: PolicyTrigger): Promise<PolicyOutcome> { switch (policy) { /* ... */ } }
418/// ```
419impl<F: RepositoryFacts> PolicyChecks for ShippedChecks<F> {
420    /// What:
421    ///  The outcome of one policy at one lifecycle point.
422    /// Why:
423    ///   Each arm calls the one check that owns the policy.
424    ///
425    /// In TS you'd write (pseudocode):
426    /// ```ts
427    /// check(policy: PolicyId, trigger: PolicyTrigger): PolicyOutcome;
428    /// ```
429    fn check(&mut self, policy: PolicyId, trigger: Trigger) -> PolicyOutcome {
430        // `&mut self.facts` lends the provider for writing; `.as_slice()` lends a list.
431        match policy {
432            PolicyId::RequireRoot => {
433                return check_require_root(&mut self.facts, self.arguments.as_slice(), trigger);
434            }
435            PolicyId::LinkedWorktreeOnly => {
436                return check_linked_worktree(
437                    &mut self.facts,
438                    self.arguments.as_slice(),
439                    self.allowed_worktree_dirs.as_slice(),
440                );
441            }
442            PolicyId::BranchWorktreeOnly => {
443                return check_branch_worktree(&mut self.facts, self.arguments.as_slice());
444            }
445            PolicyId::AddExplicit => return check_add_explicit(self.arguments.as_slice()),
446            PolicyId::FinalNewline => {
447                return check_final_newline(
448                    &mut self.content,
449                    &self.candidates,
450                    &mut self.facts,
451                    trigger,
452                    self.can_apply_patches,
453                );
454            }
455            PolicyId::ForbiddenRootContext => {
456                return check_root_context(&mut self.content, &self.candidates, &mut self.facts);
457            }
458            PolicyId::MarkdownAutofix => {
459                return check_markdown(
460                    &mut self.content,
461                    &self.candidates,
462                    &mut self.facts,
463                    &mut self.markdown,
464                    trigger,
465                );
466            }
467            PolicyId::DependentVersionBump => {
468                return check_dependent_version(
469                    &mut self.content,
470                    &self.candidates,
471                    &mut self.facts,
472                    trigger,
473                );
474            }
475            PolicyId::CatalogSpecifiers => {
476                return check_catalog_specifiers(
477                    &mut self.content,
478                    &self.candidates,
479                    &mut self.facts,
480                );
481            }
482            PolicyId::ForbiddenStrings => {
483                return check_forbidden_strings(
484                    &mut self.content,
485                    &self.candidates,
486                    &mut self.facts,
487                    &self.scanner_settings,
488                    &mut self.scanner,
489                );
490            }
491        }
492    }
493}
494
495/// Per-policy outcomes and lazy fact use stay out of the release executable.
496#[cfg(test)]
497#[path = "policy_checks_tests.rs"]
498mod tests;