Skip to main content

git_policy_cli/
commit_command.rs

1//! What:
2//!  The `git commit` command of the native wrapper: the transaction, the settled events, the post-commit gate and
3//!  the automatic backup, in the incumbent's order.
4//! Why:
5//!  The incumbent (`src/bin.ts`) runs the commit transaction instead of forwarding the commit, prints the
6//!  pre-forward events only after the transaction returned, then runs the post-commit lifecycle for a landed commit
7//!  and, when the gate passes, the automatic push. A commit that landed nothing exits with the transaction's status.
8//!
9//! In TS you'd write (pseudocode):
10//! ```ts
11//! const transaction = await runCommitTransactionBoundary({ args, gitPath, policyOptions, concurrency });
12//! renderPolicyEvents(transaction.policyResult.events);
13//! const post = await runPostCommitLifecycle({ ... }); await autoPush({ ... });
14//! ```
15use super::auto_push_single_flight::PushGit;
16use super::child_environment::environment_with_overlay;
17use super::service_commit::{ServiceCommit, try_service_commit};
18use super::service_handoff::{ServiceHandoff, hand_over, show_notices};
19use super::service_messages::render;
20use super::commit_transaction::{CommitInvocation, TransactionResult, TransactionStop, run_commit_transaction};
21use super::config_schema::CliGitConfig;
22use super::command_commit::CommitRegion;
23use super::diagnostic_log::warn;
24use super::diagnostics::{EngineFailureCode, render_engine_failure};
25use super::git_version::check_git_version;
26use super::policy_checks::ShippedChecks;
27use super::policy_engine::StageRequest;
28use super::policy_events::{PolicyEvent, render_policy_events};
29use super::policy_trigger::Trigger;
30use super::post_commit::{POST_COMMIT_BLOCKED_EXIT_CODE, PostCommitOutcome, PostCommitRequest, run_post_commit, run_post_commit_gate};
31use super::repository_facts::RepositoryFacts;
32use super::transaction_git::GitContext;
33use super::wrapper_controls::Controls;
34use std::ffi::OsString;
35use std::io::Write;
36use std::path::{Path, PathBuf};
37
38/// Module tag for diagnostics.
39const TAG: &str = "runCommitCommand";
40
41/// What:
42///  The effective invocation directory: the process directory moved by every `-C` of the global options.
43/// Why:
44///   Relative pathspecs and the transaction's Git queries resolve from where Git itself would run.
45///
46/// In TS you'd write (pseudocode):
47/// ```ts
48/// parseGlobalOptions(args).effectiveCwd
49/// ```
50pub fn effective_directory(start: &Path, global_prefix: &[OsString]) -> PathBuf {
51    let mut directory: PathBuf = start.to_path_buf();
52    let mut index: usize = 0;
53    while index < global_prefix.len() {
54        if global_prefix[index] == "-C"
55            && let Some(value) = global_prefix.get(index + 1)
56        {
57            directory = directory.join(value);
58            index += 2;
59            continue;
60        }
61        index += 1;
62    }
63    return directory;
64}
65
66/// What:
67///  The ending of one commit command.
68/// Why:
69///   Everything the command wrote it wrote itself and in order, so only the exit status is left to the caller.
70///
71/// In TS you'd write (pseudocode):
72/// ```ts
73/// process.exitCode = code;
74/// ```
75#[derive(Clone, Copy, Debug, Eq, PartialEq)]
76pub struct CommitEnding {
77    /// The process exit status.
78    pub code: i32,
79}
80
81/// What:
82///  Write text to standard error, ignoring a closed stream.
83/// Why:
84///   The exit status already carries the result.
85///
86/// In TS you'd write (pseudocode):
87/// ```ts
88/// process.stderr.write(text);
89/// ```
90fn write_stderr(text: &str) {
91    let mut stream = std::io::stderr();
92    let _ = stream.write_all(text.as_bytes());
93    let _ = stream.flush();
94}
95
96/// What:
97///  Print what the transaction stopped with and name the exit status.
98/// Why:
99///   Each stop ends differently: Git's own status with nothing printed, a message with status 1, or a finding with
100///   status 2.
101///
102/// In TS you'd write (pseudocode):
103/// ```ts
104/// catch (error) { ... process.exitCode = ... }
105/// ```
106fn report_stop(stop: TransactionStop) -> CommitEnding {
107    match stop {
108        TransactionStop::NativeExit(code) => return CommitEnding { code },
109        TransactionStop::Message(message) => {
110            write_stderr(format!("{message}\n").as_str());
111            return CommitEnding { code: 1 };
112        }
113        TransactionStop::Failed { code, message } => {
114            let event: PolicyEvent = PolicyEvent::EngineFailure {
115                code,
116                message,
117                trigger: Some(Trigger::PreForward),
118                policy: None,
119                path: None,
120            };
121            write_stderr(render_policy_events(0, std::slice::from_ref(&event)).as_str());
122            return CommitEnding { code: 2 };
123        }
124    }
125}
126
127/// What:
128///  Everything the commit command reads from the wrapped invocation.
129/// Why:
130///   The command needs the identity and the loaded configuration the lifecycle already resolved.
131///
132/// In TS you'd write (pseudocode):
133/// ```ts
134/// { rawArgs, gitPath, runtimeResolution, ... }
135/// ```
136pub struct CommitCommand<'a> {
137    /// Real Git, its overlay and the global options.
138    pub context: &'a GitContext,
139    /// The invocation's environment.
140    pub environment: &'a [(OsString, OsString)],
141    /// The arguments with the wrapper's controls removed.
142    pub arguments: &'a [OsString],
143    /// The index of `commit` within the arguments.
144    pub subcommand_index: usize,
145    /// The commit options Git's table read.
146    pub region: &'a CommitRegion,
147    /// The loaded configuration.
148    pub config: &'a CliGitConfig,
149    /// The wrapper controls given on the command line.
150    pub controls: &'a Controls,
151}
152
153/// What:
154///  Run one `git commit` through the transaction and its lifecycle.
155/// Why:
156///   Startup recovery ran before configuration loaded, so the transaction starts from a clean registry unless a live
157///   transaction belongs to another invocation, which does not stop it.
158///
159/// In TS you'd write (pseudocode):
160/// ```ts
161/// await runCommitCommand({ ... });
162/// ```
163pub fn run_commit_command<F: RepositoryFacts>(command: &CommitCommand<'_>, checks: &mut ShippedChecks<F>) -> CommitEnding {
164    if let Err(reason) = check_git_version(command.context) {
165        write_stderr(render_engine_failure(0, EngineFailureCode::ContentUnavailable, reason.as_str()).as_str());
166        return CommitEnding { code: 2 };
167    }
168    let start: PathBuf = std::env::current_dir().unwrap_or_else(|_| { return PathBuf::from("."); });
169    let cwd: PathBuf = effective_directory(start.as_path(), command.context.global_prefix.as_slice());
170    let invocation: CommitInvocation<'_> = CommitInvocation {
171        context: command.context,
172        environment: command.environment,
173        cwd: cwd.as_path(),
174        arguments: command.arguments,
175        subcommand_index: command.subcommand_index,
176        region: command.region,
177        config: command.config,
178        controls: command.controls,
179    };
180    let request: StageRequest = StageRequest {
181        trigger: Trigger::PreForward,
182        config: command.config.policies.clone(),
183        controls: command.controls.clone(),
184        selected: Vec::new(),
185    };
186    // A plain scoped commit goes through the resident service when the person opted in; everything else, and every
187    // command line the service does not cover, runs through the transaction as before.
188    let served: Result<Option<ServiceCommit>, TransactionStop> = try_service_commit(&invocation, checks, &request);
189    let (result, handoff): (TransactionResult, Option<ServiceHandoff>) = match served {
190        Ok(Some(found)) => (found.result, found.handoff),
191        Ok(None) => match run_commit_transaction(&invocation, checks, &request) {
192            Ok(found) => (found, None),
193            Err(stop) => return report_stop(stop),
194        },
195        Err(stop) => return report_stop(stop),
196    };
197    write_stderr(render_policy_events(0, result.events.as_slice()).as_str());
198    let Some(landed) = result.landed_oid else {
199        return CommitEnding { code: result.exit_code };
200    };
201    return post_commit(command, &invocation, landed.as_str(), result.uses_lfs, handoff.as_ref());
202}
203
204/// What:
205///  The post-commit lifecycle of a landed commit: the gate over its changes, then the automatic backup.
206/// Why:
207///   A blocked gate keeps the commit local and exits 2; a passing gate lets the backup run.
208///
209/// In TS you'd write (pseudocode):
210/// ```ts
211/// const post = await runPostCommitLifecycle({ ... }); if (post.blocked) throw new PolicyDecisionError(2); await autoPush({ ... });
212/// ```
213fn post_commit(
214    command: &CommitCommand<'_>,
215    invocation: &CommitInvocation<'_>,
216    landed: &str,
217    uses_lfs: bool,
218    handoff: Option<&ServiceHandoff>,
219) -> CommitEnding {
220    let request: PostCommitRequest<'_> = PostCommitRequest {
221        real_git: command.context.real_git.as_path(),
222        landed_oid: landed,
223        arguments: command.arguments,
224        global_prefix: command.context.global_prefix.as_slice(),
225        overlay: command.context.overlay.as_slice(),
226        environment: command.environment,
227        config: command.config.policies.clone(),
228        controls: command.controls.clone(),
229    };
230    // A commit the service landed leaves its push and upkeep to the service; the gate still runs here.
231    if let Some(handed) = handoff {
232        return post_commit_handed(&request, handed, landed);
233    }
234    let git: PushGit = PushGit {
235        real_git: command.context.real_git.clone(),
236        cwd: invocation.cwd.to_path_buf(),
237        environment: environment_with_overlay(command.environment, command.context.overlay.as_slice()),
238        lfs: uses_lfs,
239    };
240    let mut stderr = std::io::stderr();
241    match run_post_commit(&request, &git, &mut stderr) {
242        Ok(PostCommitOutcome { exit_code, .. }) => return CommitEnding { code: exit_code },
243        Err(error) => {
244            warn(TAG, format!("the post-commit lifecycle failed: {error:?}").as_str());
245            write_stderr(format!("{error:?}\n").as_str());
246            return CommitEnding { code: 1 };
247        }
248    }
249}
250
251/// What:
252///  The post-commit lifecycle of a commit the service landed: earlier notices, the gate, then the handoff of the push and
253///  the upkeep.
254/// Why:
255///   The push runs after the command returns, so the commit never waits for the network; a blocked gate keeps the commit
256///  local and exits 2 exactly as before, and a handoff that fails is said plainly without changing the exit status.
257///
258/// In TS you'd write (pseudocode):
259/// ```ts
260/// showNotices(); const gate = await runGate(); if (gate.blocked) return 2; await handOver(); return 0;
261/// ```
262fn post_commit_handed(request: &PostCommitRequest<'_>, handoff: &ServiceHandoff, landed: &str) -> CommitEnding {
263    let mut stderr = std::io::stderr();
264    show_notices(handoff, &mut stderr);
265    let gate = run_post_commit_gate(request);
266    write_stderr(render_policy_events(0, gate.events.as_slice()).as_str());
267    if gate.blocked {
268        return CommitEnding { code: POST_COMMIT_BLOCKED_EXIT_CODE };
269    }
270    if let Err(failure) = hand_over(handoff, landed, &mut stderr) {
271        write_stderr(format!("{}\n", render(&failure)).as_str());
272    }
273    return CommitEnding { code: 0 };
274}
275
276/// Controls for this module stay out of the release executable.
277#[cfg(test)]
278#[path = "commit_command_tests.rs"]
279mod tests;