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