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