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