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