Skip to main content

git_policy_cli/
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 super::commit_timing::CommitTiming;
16use super::service_commit::{ServiceCommit, service_commit};
17use super::service_handoff::{ServiceHandoff, hand_over};
18use super::service_messages::render;
19use super::commit_transaction::{CommitInvocation, TransactionStop};
20use super::config_schema::CliGitConfig;
21use super::command_commit::CommitRegion;
22use super::diagnostics::{EngineFailureCode, render_engine_failure};
23use super::git_version::check_git_version;
24use super::policy_checks::ShippedChecks;
25use super::policy_engine::StageRequest;
26use super::policy_events::{PolicyEvent, render_policy_events};
27use super::policy_trigger::Trigger;
28use super::post_commit::{POST_COMMIT_BLOCKED_EXIT_CODE, PostCommitRequest, run_post_commit_gate};
29use super::repository_facts::RepositoryFacts;
30use super::transaction_git::GitContext;
31use super::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                message,
113                trigger: Some(Trigger::PreForward),
114                policy: None,
115                path: None,
116            };
117            write_stderr(render_policy_events(0, std::slice::from_ref(&event)).as_str());
118            return CommitEnding { code: 2 };
119        }
120    }
121}
122
123/// What:
124///  Everything the commit command reads from the wrapped invocation.
125/// Why:
126///   The command needs the identity and the loaded configuration the lifecycle already resolved.
127///
128/// In TS you'd write (pseudocode):
129/// ```ts
130/// { rawArgs, gitPath, runtimeResolution, ... }
131/// ```
132pub struct CommitCommand<'a> {
133    /// Real Git, its overlay and the global options.
134    pub context: &'a GitContext,
135    /// The invocation's environment.
136    pub environment: &'a [(OsString, OsString)],
137    /// The arguments with the wrapper's controls removed.
138    pub arguments: &'a [OsString],
139    /// The index of `commit` within the arguments.
140    pub subcommand_index: usize,
141    /// The commit options Git's table read.
142    pub region: &'a CommitRegion,
143    /// The loaded configuration.
144    pub config: &'a CliGitConfig,
145    /// The wrapper controls given on the command line.
146    pub controls: &'a Controls,
147}
148
149/// What:
150///  Run one commit through the service and its lifecycle.
151/// Why:
152///   The service is the only writer of a commit, so the command takes no lock and recovers nothing: it prepares the commit,
153///  sends it, and reports what the service landed.
154///
155/// In TS you'd write (pseudocode):
156/// ```ts
157/// await runCommitCommand({ ... });
158/// ```
159pub fn run_commit_command<F: RepositoryFacts>(command: &CommitCommand<'_>, checks: &mut ShippedChecks<F>) -> CommitEnding {
160    let mut timing: CommitTiming = CommitTiming::start();
161    let ending: CommitEnding = run_timed(command, checks, &mut timing);
162    // One append to the repository's timing file once everything the person sees is written; the terminal never shows it.
163    timing.finish(ending.code);
164    return ending;
165}
166
167/// `run_commit_command`,
168/// recording in `timing` how the command's time was spent and how it ended.
169fn run_timed<F: RepositoryFacts>(command: &CommitCommand<'_>, checks: &mut ShippedChecks<F>, timing: &mut CommitTiming) -> CommitEnding {
170    if let Err(reason) = check_git_version(command.context) {
171        write_stderr(render_engine_failure(0, EngineFailureCode::ContentUnavailable, reason.as_str()).as_str());
172        return CommitEnding { code: 2 };
173    }
174    let start: PathBuf = std::env::current_dir().unwrap_or_else(|_| { return PathBuf::from("."); });
175    let cwd: PathBuf = effective_directory(start.as_path(), command.context.global_prefix.as_slice());
176    let invocation: CommitInvocation<'_> = CommitInvocation {
177        context: command.context,
178        environment: command.environment,
179        cwd: cwd.as_path(),
180        arguments: command.arguments,
181        subcommand_index: command.subcommand_index,
182        region: command.region,
183        config: command.config,
184        controls: command.controls,
185    };
186    let request: StageRequest = StageRequest {
187        trigger: Trigger::PreForward,
188        config: command.config.policies.clone(),
189        controls: command.controls.clone(),
190        selected: Vec::new(),
191    };
192    // Every command line lands through the resident service; a machine with none to reach or start ends here with the plain
193    // message of its platform.
194    let served: ServiceCommit = match service_commit(&invocation, checks, &request, timing) {
195        Ok(found) => found,
196        Err(stop) => return report_stop(stop),
197    };
198    write_stderr(render_policy_events(0, served.result.events.as_slice()).as_str());
199    let (Some(landed), Some(handoff)) = (served.result.landed_oid, served.handoff) else {
200        timing.outcome = "ended";
201        return CommitEnding { code: served.result.exit_code };
202    };
203    timing.outcome = "landed";
204    return post_commit(command, &handoff, landed.as_str(), timing);
205}
206
207/// What:
208///  The post-commit lifecycle of a commit the service landed: the gate, then the handoff of the push and the upkeep.
209/// Why:
210///   The push runs after the command returns, so the commit never waits for the network; a blocked gate keeps the commit
211///  local and exits 2, and a handoff that fails is said plainly without changing the exit status.
212///
213/// In TS you'd write (pseudocode):
214/// ```ts
215/// const gate = await runGate(); if (gate.blocked) return 2; await handOver(); return 0;
216/// ```
217fn post_commit(command: &CommitCommand<'_>, handoff: &ServiceHandoff, landed: &str, timing: &mut CommitTiming) -> CommitEnding {
218    let request: PostCommitRequest<'_> = PostCommitRequest {
219        real_git: command.context.real_git.as_path(),
220        landed_oid: landed,
221        arguments: command.arguments,
222        global_prefix: command.context.global_prefix.as_slice(),
223        overlay: command.context.overlay.as_slice(),
224        environment: command.environment,
225        config: command.config.policies.clone(),
226        controls: command.controls.clone(),
227    };
228    let mut stderr = std::io::stderr();
229    let gating: Instant = Instant::now();
230    let gate = run_post_commit_gate(&request);
231    timing.gate += gating.elapsed();
232    write_stderr(render_policy_events(0, gate.events.as_slice()).as_str());
233    if gate.blocked {
234        return CommitEnding { code: POST_COMMIT_BLOCKED_EXIT_CODE };
235    }
236    let handing_over: Instant = Instant::now();
237    let handed = hand_over(handoff, landed, &mut stderr);
238    timing.handoff += handing_over.elapsed();
239    if let Err(failure) = handed {
240        write_stderr(format!("{}\n", render(&failure)).as_str());
241    }
242    return CommitEnding { code: 0 };
243}
244
245/// Controls for this module stay out of the release executable.
246#[cfg(test)]
247#[path = "commit_command_tests.rs"]
248mod tests;