git_policy_cli/post_commit.rs
1//! What:
2//! The post-commit policy gate over a landed commit's own changes, and the backup that follows it.
3//! Why:
4//! The incumbent (`src/policy-engine/post-commit-lifecycle.ts`) checks the committed bytes of
5//! every path a landed commit changed, and only a gate that passes lets the commit reach auto-push.
6//! A blocked gate keeps the commit local and names it in a `commit-landed` event; the command exits `2`.
7//! The landed commit is an input, so the integrator can call this after any landing.
8//!
9//! In TS you'd write (pseudocode):
10//! ```ts
11//! const result = await runPostCommitLifecycle({ rawArgs, transformedArgs, gitPath, cwd, landedOid, ... });
12//! if (!result.blocked) await autoPush({ gitPath, cwd, landedOid: result.oid });
13//! ```
14
15/// The auto-push a clean gate hands the landed commit to, and the Git request it runs with.
16use super::auto_push::{AutoPushResult, auto_push};
17use super::auto_push_single_flight::{PushGit, SingleFlightError};
18/// The landed-commit object name and the candidate layer's landed-delta source.
19use super::candidate_object::parse_object_id;
20use super::candidate_prediction::CandidateRequest;
21/// The policy configuration the gate runs with.
22use super::config_schema::PolicyConfig;
23/// The reading of the invocation's environment.
24use super::child_environment::environment_value;
25/// The failure code and the event kinds the gate emits.
26use super::diagnostics::EngineFailureCode;
27/// The linter the Markdown policy finds on, and runs with, the invocation's environment.
28use super::markdown_linter::executable_linter;
29use super::policy_checks::shipped_checks;
30use super::policy_content::LifecycleContent;
31use super::policy_engine::{StageEnd, StageRequest, pass_exit_code};
32use super::policy_events::{PolicyEvent, render_policy_events};
33use super::policy_pass::run_policy_pass;
34use super::policy_trigger::Trigger;
35use super::repository_facts::git_facts;
36/// The variable naming the forbidden-strings rules file.
37use super::scanner_selection::RULES_VARIABLE;
38use super::wrapper_controls::Controls;
39/// Owned OS strings for the replayed global options and the child environment.
40use std::ffi::OsString;
41/// `Write` is the stderr sink the gate's JSONL lines go to.
42use std::io::Write;
43/// `Path` is a borrowed filesystem path.
44use std::path::Path;
45
46/// Exit status the command returns when the gate blocks the backup, as the incumbent's `PolicyDecisionError(2)`.
47pub const POST_COMMIT_BLOCKED_EXIT_CODE: i32 = 2;
48
49/// What:
50/// The inputs of one post-commit gate.
51/// Why:
52/// The landed commit names the delta; the global prefix and the environment make every query read
53/// the repository the commit landed in.
54///
55/// In TS you'd write (pseudocode):
56/// ```ts
57/// type PostCommitInput = { gitPath; cwd; landedOid; arguments; environment; policySeverities; policyOptions };
58/// ```
59#[derive(Clone, Debug)]
60pub struct PostCommitRequest<'request> {
61 /// Absolute real Git executable.
62 pub real_git: &'request Path,
63 /// The landed commit object name, as Git printed it.
64 pub landed_oid: &'request str,
65 /// The arguments the command forwarded to Git.
66 pub arguments: &'request [OsString],
67 /// The global options of the invocation, kept before every query (`-C`, `--git-dir`, `-c`).
68 pub global_prefix: &'request [OsString],
69 /// Variables added to every query.
70 pub overlay: &'request [(OsString, OsString)],
71 /// The invocation's whole environment:
72 /// the Markdown linter is found on its `PATH` and runs with it,
73 /// and the forbidden-strings rules variable is read from it.
74 pub environment: &'request [(OsString, OsString)],
75 /// The policy severities and options the gate runs with.
76 pub config: PolicyConfig,
77 /// Escapes and flags the invocation carries.
78 pub controls: Controls,
79}
80
81/// What:
82/// The settled gate:
83/// the landed commit, every event it produced, and whether the backup must not run.
84/// Why:
85/// `events` already carries the `commit-landed` event when `blocked` is true,
86/// so the caller renders one list.
87///
88/// In TS you'd write (pseudocode):
89/// ```ts
90/// type PostCommitLifecycleResult = { oid: string; events: PolicyEvent[]; blocked: boolean };
91/// ```
92#[derive(Clone, Debug, Eq, PartialEq)]
93pub struct PostCommitGate {
94 /// The landed commit object name, as given.
95 pub oid: String,
96 /// Every event the gate produced, in order.
97 pub events: Vec<PolicyEvent>,
98 /// Whether the backup must not run.
99 pub blocked: bool,
100}
101
102/// What:
103/// Run the post-commit policies over the landed commit's delta.
104/// Why:
105/// Only a gate with no blocking event, no failure and no pending correction lets the backup run;
106/// a warning-only result is allowed, as in the incumbent.
107/// A landed name that is not an object name is an engine failure, so the commit is kept and reported.
108///
109/// In TS you'd write (pseudocode):
110/// ```ts
111/// async function runPostCommitLifecycle(input): Promise<PostCommitLifecycleResult>;
112/// ```
113pub fn run_post_commit_gate(request: &PostCommitRequest) -> PostCommitGate {
114 let oid = request.landed_oid.to_owned();
115 let (mut events, end): (Vec<PolicyEvent>, StageEnd) =
116 match parse_object_id(request.landed_oid.as_bytes()) {
117 Some(commit) => run_staged_gate(request, commit),
118 None => (
119 vec![PolicyEvent::EngineFailure {
120 code: EngineFailureCode::ContentUnavailable,
121 message: format!("landed commit {} is not an object name", request.landed_oid),
122 trigger: Some(Trigger::PostCommit),
123 policy: None,
124 path: None,
125 }],
126 StageEnd::Failed,
127 ),
128 };
129 let blocked = pass_exit_code(events.as_slice(), end) != 0;
130 if blocked {
131 events.push(PolicyEvent::CommitLanded { oid: oid.clone() });
132 }
133 return PostCommitGate {
134 oid,
135 events,
136 blocked,
137 };
138}
139
140/// Run the post-commit stage over the landed delta of a parsed commit name.
141///
142/// In TS you'd write (pseudocode):
143/// ```ts
144/// await dependencies.runPolicyEngine({ trigger: 'post-commit', gitFacts: createPostCommitGitFacts(...) })
145/// ```
146fn run_staged_gate(
147 request: &PostCommitRequest,
148 commit: super::candidate_object::ObjectId,
149) -> (Vec<PolicyEvent>, StageEnd) {
150 let facts = git_facts(request.real_git, request.global_prefix, request.overlay);
151 let mut checks = shipped_checks(
152 facts,
153 request.arguments.to_vec(),
154 LifecycleContent::Requested(CandidateRequest::Landed(commit)),
155 Vec::new(),
156 );
157 // The scanner and Markdown options are the configuration's, as the pre-commit stage's checks hold them.
158 checks.scanner_settings.options = request.config.forbidden_strings.clone();
159 checks.markdown.options = request.config.markdown_autofix.clone();
160 // The forbidden-strings rules variable is read from this invocation's environment.
161 checks.scanner_settings.rules_variable = environment_value(request.environment, RULES_VARIABLE);
162 // The Markdown linter is found on, and runs with, this invocation's environment.
163 checks.markdown.linter = Box::new(executable_linter(request.environment));
164 let stage: StageRequest = StageRequest {
165 trigger: Trigger::PostCommit,
166 config: request.config.clone(),
167 controls: request.controls.clone(),
168 selected: Vec::new(),
169 };
170 let pass = run_policy_pass(&stage, &mut checks);
171 return (pass.events, pass.end);
172}
173
174/// What:
175/// The result of the gate and the backup it allowed or prevented.
176/// Why:
177/// The exit status and the auto-push result come from one place, so the caller only reports them.
178///
179/// In TS you'd write (pseudocode):
180/// ```ts
181/// { gate: PostCommitLifecycleResult; autoPush?: AutoPushResult }
182/// ```
183#[derive(Clone, Debug, Eq, PartialEq)]
184pub struct PostCommitOutcome {
185 /// The gate result, including the `commit-landed` event when blocked.
186 pub gate: PostCommitGate,
187 /// `0` after a backup or a warning-only gate; `2` when the gate blocked the backup.
188 pub exit_code: i32,
189 /// The auto-push result, absent when the gate blocked the backup.
190 pub auto_push: Option<AutoPushResult>,
191}
192
193/// What:
194/// Run the gate, print its events, and back up the landed commit only when the gate allows it.
195/// Why:
196/// The incumbent prints the events before the backup and never backs up a blocked commit;
197/// a failed backup is reported by `auto_push` and never changes the outcome's exit code.
198///
199/// In TS you'd write (pseudocode):
200/// ```ts
201/// const result = await runPostCommitLifecycle(input);
202/// process.stderr.write(renderPolicyEvents(result.events));
203/// if (!result.blocked) await autoPush({ gitPath, cwd, landedOid: result.oid });
204/// ```
205pub fn run_post_commit(
206 request: &PostCommitRequest,
207 git: &PushGit,
208 err: &mut dyn Write,
209) -> Result<PostCommitOutcome, SingleFlightError> {
210 let gate = run_post_commit_gate(request);
211 let rendered = render_policy_events(0, gate.events.as_slice());
212 if !rendered.is_empty() {
213 err.write_all(rendered.as_bytes())
214 .map_err(SingleFlightError::Io)?;
215 }
216 if gate.blocked {
217 return Ok(PostCommitOutcome {
218 gate,
219 exit_code: POST_COMMIT_BLOCKED_EXIT_CODE,
220 auto_push: None,
221 });
222 }
223 let landed = gate.oid.clone();
224 let pushed = auto_push(git, landed.as_str(), err)?;
225 return Ok(PostCommitOutcome {
226 gate,
227 exit_code: 0,
228 auto_push: Some(pushed),
229 });
230}
231
232/// Post-commit gate behavior is controlled in the test module.
233#[cfg(test)]
234#[path = "post_commit_tests.rs"]
235mod tests;