Skip to main content

git_policy_cli/commit/
commit_command.rs

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