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