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::commit_transaction::{CommitInvocation, TransactionResult, TransactionStop, run_commit_transaction};
18use super::config_schema::CliGitConfig;
19use super::command_commit::CommitRegion;
20use super::diagnostic_log::warn;
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::{PostCommitOutcome, PostCommitRequest, run_post_commit};
28use super::repository_facts::RepositoryFacts;
29use super::transaction_git::GitContext;
30use super::transaction_lease::has_valid_preparation_lease;
31use super::wrapper_controls::Controls;
32use std::ffi::OsString;
33use std::io::Write;
34use std::path::{Path, PathBuf};
35
36/// Module tag for diagnostics.
37const TAG: &str = "runCommitCommand";
38
39/// What:
40///  The effective invocation directory: the process directory moved by every `-C` of the global options.
41/// Why:
42///   Relative pathspecs and the transaction's Git queries resolve from where Git itself would run.
43///
44/// In TS you'd write (pseudocode):
45/// ```ts
46/// parseGlobalOptions(args).effectiveCwd
47/// ```
48pub fn effective_directory(start: &Path, global_prefix: &[OsString]) -> PathBuf {
49    let mut directory: PathBuf = start.to_path_buf();
50    let mut index: usize = 0;
51    while index < global_prefix.len() {
52        if global_prefix[index] == "-C"
53            && let Some(value) = global_prefix.get(index + 1)
54        {
55            directory = directory.join(value);
56            index += 2;
57            continue;
58        }
59        index += 1;
60    }
61    return directory;
62}
63
64/// What:
65///  The ending of one commit command.
66/// Why:
67///   Everything the command wrote it wrote itself and in order, so only the exit status is left to the caller.
68///
69/// In TS you'd write (pseudocode):
70/// ```ts
71/// process.exitCode = code;
72/// ```
73#[derive(Clone, Copy, Debug, Eq, PartialEq)]
74pub struct CommitEnding {
75    /// The process exit status.
76    pub code: i32,
77}
78
79/// What:
80///  Write text to standard error, ignoring a closed stream.
81/// Why:
82///   The exit status already carries the result.
83///
84/// In TS you'd write (pseudocode):
85/// ```ts
86/// process.stderr.write(text);
87/// ```
88fn write_stderr(text: &str) {
89    let mut stream = std::io::stderr();
90    let _ = stream.write_all(text.as_bytes());
91    let _ = stream.flush();
92}
93
94/// What:
95///  Print what the transaction stopped with and name the exit status.
96/// Why:
97///   Each stop ends differently: Git's own status with nothing printed, a message with status 1, or a finding with
98///   status 2.
99///
100/// In TS you'd write (pseudocode):
101/// ```ts
102/// catch (error) { ... process.exitCode = ... }
103/// ```
104fn report_stop(stop: TransactionStop) -> CommitEnding {
105    match stop {
106        TransactionStop::NativeExit(code) => return CommitEnding { code },
107        TransactionStop::Message(message) => {
108            write_stderr(format!("{message}\n").as_str());
109            return CommitEnding { code: 1 };
110        }
111        TransactionStop::Failed { code, message } => {
112            let event: PolicyEvent = PolicyEvent::EngineFailure {
113                code,
114                message,
115                trigger: Some(Trigger::PreForward),
116                policy: None,
117                path: None,
118            };
119            write_stderr(render_policy_events(0, std::slice::from_ref(&event)).as_str());
120            return CommitEnding { code: 2 };
121        }
122    }
123}
124
125/// What:
126///  Everything the commit command reads from the wrapped invocation.
127/// Why:
128///   The command needs the identity and the loaded configuration the lifecycle already resolved.
129///
130/// In TS you'd write (pseudocode):
131/// ```ts
132/// { rawArgs, gitPath, runtimeResolution, ... }
133/// ```
134pub struct CommitCommand<'a> {
135    /// Real Git, its overlay and the global options.
136    pub context: &'a GitContext,
137    /// The invocation's environment.
138    pub environment: &'a [(OsString, OsString)],
139    /// The arguments with the wrapper's controls removed.
140    pub arguments: &'a [OsString],
141    /// The index of `commit` within the arguments.
142    pub subcommand_index: usize,
143    /// The commit options Git's table read.
144    pub region: &'a CommitRegion,
145    /// The loaded configuration.
146    pub config: &'a CliGitConfig,
147    /// The wrapper controls given on the command line.
148    pub controls: &'a Controls,
149}
150
151/// What:
152///  Run one `git commit` through the transaction and its lifecycle.
153/// Why:
154///   Startup recovery ran before configuration loaded, so the transaction starts from a clean registry unless a live
155///   transaction belongs to another invocation, which does not stop it.
156///
157/// In TS you'd write (pseudocode):
158/// ```ts
159/// await runCommitCommand({ ... });
160/// ```
161pub fn run_commit_command<F: RepositoryFacts>(command: &CommitCommand<'_>, checks: &mut ShippedChecks<F>) -> CommitEnding {
162    if let Err(reason) = check_git_version(command.context) {
163        write_stderr(render_engine_failure(0, EngineFailureCode::ContentUnavailable, reason.as_str()).as_str());
164        return CommitEnding { code: 2 };
165    }
166    let start: PathBuf = std::env::current_dir().unwrap_or_else(|_| { return PathBuf::from("."); });
167    let cwd: PathBuf = effective_directory(start.as_path(), command.context.global_prefix.as_slice());
168    let executable: PathBuf = match std::env::current_exe() {
169        Ok(found) => found,
170        Err(error) => {
171            write_stderr(format!("cli-git: cannot determine this executable: {error}\n").as_str());
172            return CommitEnding { code: 2 };
173        }
174    };
175    let invocation: CommitInvocation<'_> = CommitInvocation {
176        context: command.context,
177        environment: command.environment,
178        cwd: cwd.as_path(),
179        arguments: command.arguments,
180        subcommand_index: command.subcommand_index,
181        region: command.region,
182        config: command.config,
183        controls: command.controls,
184        inherited_lease_valid: has_valid_preparation_lease(command.environment),
185        executable: executable.as_path(),
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    let result: TransactionResult = match run_commit_transaction(&invocation, checks, &request) {
194        Ok(found) => found,
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());
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(command: &CommitCommand<'_>, invocation: &CommitInvocation<'_>, landed: &str) -> CommitEnding {
214    let request: PostCommitRequest<'_> = PostCommitRequest {
215        real_git: command.context.real_git.as_path(),
216        landed_oid: landed,
217        arguments: command.arguments,
218        global_prefix: command.context.global_prefix.as_slice(),
219        overlay: command.context.overlay.as_slice(),
220        environment: command.environment,
221        config: command.config.policies.clone(),
222        controls: command.controls.clone(),
223    };
224    let git: PushGit = PushGit {
225        real_git: command.context.real_git.clone(),
226        cwd: invocation.cwd.to_path_buf(),
227        environment: environment_with_overlay(command.environment, command.context.overlay.as_slice()),
228    };
229    let mut stderr = std::io::stderr();
230    match run_post_commit(&request, &git, &mut stderr) {
231        Ok(PostCommitOutcome { exit_code, .. }) => return CommitEnding { code: exit_code },
232        Err(error) => {
233            warn(TAG, format!("the post-commit lifecycle failed: {error:?}").as_str());
234            write_stderr(format!("{error:?}\n").as_str());
235            return CommitEnding { code: 1 };
236        }
237    }
238}
239
240/// Controls for this module stay out of the release executable.
241#[cfg(test)]
242#[path = "commit_command_tests.rs"]
243mod tests;