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