git_policy_cli/commit_transaction.rs
1//! What:
2//! The values every commit of the service route shares: the invocation, how it can stop, what it came to, the selection
3//! the command line makes, and the policy checks over the private index.
4//! Why:
5//! The preparation (`service_prepare.rs`) and the commit command (`commit_command.rs`) pass these between them. The
6//! transaction that once landed a commit itself, with its registry, recovery and replay, is gone: the resident service is the
7//! only writer of a commit (`doc/planning/cli-git-resident-service.md`, "Landing every command line").
8//!
9//! In TS you'd write (pseudocode):
10//! ```ts
11//! const selection = decideSelection(region, conclusion);
12//! ```
13
14use super::candidate_object::{ObjectId, parse_object_id};
15use super::candidate_prediction::CandidateRequest;
16use super::capture_invocation::InvocationCapture;
17use super::command_commit::CommitRegion;
18use super::commit_facts::PrivateIndexFacts;
19use super::config_schema::CliGitConfig;
20use super::diagnostics::EngineFailureCode;
21use super::path_bytes::os_from_path_bytes;
22use super::markdown_linter::embedded_linter;
23use super::policy_checks::{ShippedChecks, shipped_checks};
24use super::policy_forbidden_strings::default_scanner_settings;
25use super::policy_markdown::markdown_state;
26use super::policy_content::LifecycleContent;
27use super::policy_convergence::Convergence;
28use super::policy_events::PolicyEvent;
29use super::repository_facts::RepositoryFacts;
30use super::transaction_git::{GitContext, GitFailure};
31use super::transaction_journal::{Base, Conclusion, TransactionMode};
32use super::transaction_phase::PhaseMarkerError;
33use super::wrapper_controls::Controls;
34use std::ffi::OsString;
35use std::path::{Path, PathBuf};
36
37
38/// What:
39/// Everything the invocation fixes before the transaction starts.
40/// Why:
41/// The steps share one identity, one parsed commit region and one configuration; passing them together keeps a
42/// step from reading another invocation's state.
43///
44/// In TS you'd write (pseudocode):
45/// ```ts
46/// type CommitInvocation = { args; gitPath; cwd; region; policyOptions; concurrency };
47/// ```
48pub struct CommitInvocation<'a> {
49 /// Real Git, the child environment overlay, and the invocation's global options.
50 pub context: &'a GitContext,
51 /// The invocation's environment, read for leases and inherited configuration.
52 pub environment: &'a [(OsString, OsString)],
53 /// The effective invocation directory (the process directory moved by every `-C`).
54 pub cwd: &'a Path,
55 /// The arguments with the wrapper's controls removed.
56 pub arguments: &'a [OsString],
57 /// The index of `commit` within `arguments`.
58 pub subcommand_index: usize,
59 /// The commit options Git's table read.
60 pub region: &'a CommitRegion,
61 /// The configuration of the worktree.
62 pub config: &'a CliGitConfig,
63 /// The wrapper controls given on the command line.
64 pub controls: &'a Controls,
65}
66
67/// What:
68/// Why the transaction stopped without a policy result.
69/// Why:
70/// The incumbent's boundary maps each to a different ending: Git's own exit code with no wrapper text, a message
71/// with exit status 1, a finding with exit status 2, or a verbatim stop text.
72///
73/// In TS you'd write (pseudocode):
74/// ```ts
75/// NativeCommitFailedError | CommitTransactionGitError | IndexLockUnprovenOwnerError | Error
76/// ```
77#[derive(Debug)]
78pub enum TransactionStop {
79 /// Native `git commit` exited nonzero during preparation: its status is the invocation's, and Git printed why.
80 NativeExit(i32),
81 /// A Git step failed: the message reaches standard error and the status is 1.
82 Message(String),
83 /// A transaction failure reported as an engine failure event with status 2.
84 Failed {
85 /// The failure's stable code.
86 code: EngineFailureCode,
87 /// What went wrong.
88 message: String,
89 },
90}
91
92impl From<GitFailure> for TransactionStop {
93 /// A Git step failure keeps its message and ends the command with status 1.
94 fn from(failure: GitFailure) -> Self {
95 return TransactionStop::Message(failure.0);
96 }
97}
98
99
100impl From<PhaseMarkerError> for TransactionStop {
101 /// A malformed test marker fails the transaction.
102 fn from(failure: PhaseMarkerError) -> Self {
103 return failed(failure.to_string());
104 }
105}
106
107
108/// What:
109/// A transaction failure with the generic code.
110/// Why:
111/// Most unexpected failures are reported the same way.
112///
113/// In TS you'd write (pseudocode):
114/// ```ts
115/// initialTransactionFailure({ args, message })
116/// ```
117pub fn failed(message: String) -> TransactionStop {
118 return TransactionStop::Failed {
119 code: EngineFailureCode::TransactionFailed,
120 message,
121 };
122}
123
124/// What:
125/// What the transaction decided.
126/// Why:
127/// The command prints the events, runs the post-commit lifecycle for a landed commit, and exits with the code.
128///
129/// In TS you'd write (pseudocode):
130/// ```ts
131/// type CommitTransactionResult = { policyResult: PolicyEngineResult; committed: boolean; landedOid?: string };
132/// ```
133#[derive(Clone, Debug, Eq, PartialEq)]
134pub struct TransactionResult {
135 /// The events the invocation reports, in order.
136 pub events: Vec<PolicyEvent>,
137 /// The exit status when nothing landed; 0 after a landed commit.
138 pub exit_code: i32,
139 /// The landed commit, when the transaction landed one.
140 pub landed_oid: Option<String>,
141 /// Whether the Git configuration read when the commit began holds LFS keys, so the commit's auto-push must upload
142 /// LFS objects first; `false` when nothing landed.
143 pub uses_lfs: bool,
144}
145
146/// What:
147/// The commit-region facts the transaction decides from, derived once.
148/// Why:
149/// The mode and the selection flags are read in several steps.
150///
151/// In TS you'd write (pseudocode):
152/// ```ts
153/// const { concludesSequencer, readOnlySelection, selectsPaths, stagedAll, mode } = ...;
154/// ```
155#[derive(Clone, Debug, Eq, PartialEq)]
156pub struct Selection {
157 /// Whether a pathless commit concludes a merge, cherry-pick or revert.
158 pub concludes_sequencer: bool,
159 /// Whether the selection UI (include, interactive, patch) leaves the policies read-only.
160 pub read_only: bool,
161 /// Whether any path was selected.
162 pub selects_paths: bool,
163 /// Whether `-a` stages privately.
164 pub staged_all: bool,
165 /// The private index mode.
166 pub mode: TransactionMode,
167}
168
169/// What:
170/// Decide the selection facts of one commit invocation.
171/// Why:
172/// Invalid combinations reach native Git unchanged, so it rejects them.
173///
174/// In TS you'd write (pseudocode):
175/// ```ts
176/// const mode = region.hasNoOnlyFlag || concludesSequencer || readOnlySelection || region.hasAllFlag || (!selectsPaths) ? 'index' : 'explicit-path';
177/// ```
178pub fn decide_selection(region: &CommitRegion, conclusion: Conclusion) -> Selection {
179 let concludes_sequencer: bool = conclusion != Conclusion::None && conclusion != Conclusion::Amend;
180 let read_only: bool = region.include || region.interactive || region.patch;
181 let selects_paths: bool = !region.pathspecs.is_empty() || region.pathspec_from_file.is_some();
182 let staged_all: bool = region.all && !selects_paths && !read_only && region.only != Some(true);
183 let index_mode: bool = region.only == Some(false)
184 || concludes_sequencer
185 || read_only
186 || region.all
187 || !selects_paths;
188 return Selection {
189 concludes_sequencer,
190 read_only,
191 selects_paths,
192 staged_all,
193 mode: if index_mode { TransactionMode::Index } else { TransactionMode::ExplicitPath },
194 };
195}
196
197/// What:
198/// Rebuild the shipped checks over the private commit index: rule facts stay real, the candidates come from the
199/// private index against the recorded base.
200/// Why:
201/// The incumbent's rules ask real Git about the real index, while the content policies read what the transaction
202/// corrects.
203///
204/// In TS you'd write (pseudocode):
205/// ```ts
206/// const facts = createPrivateIndexFacts({ gitPath, cwd, indexPath, paths, baseRevision, objectDirectory });
207/// ```
208pub fn private_checks<'a, F: RepositoryFacts>(
209 checks: &'a mut ShippedChecks<F>,
210 index: &Path,
211 object_directory: &Path,
212 candidates: CandidateRequest,
213) -> ShippedChecks<PrivateIndexFacts<&'a mut F>> {
214 // The scanner settings and the Markdown linter move to the rebuilt checks; the originals are placeholders that
215 // nothing reads again, because the rebuilt checks run every policy of this transaction.
216 let scanner_settings = std::mem::replace(&mut checks.scanner_settings, default_scanner_settings());
217 let markdown = std::mem::replace(&mut checks.markdown, markdown_state(Box::new(embedded_linter())));
218 let arguments: Vec<OsString> = checks.arguments.clone();
219 let allowed_worktree_dirs: Vec<PathBuf> = checks.allowed_worktree_dirs.clone();
220 let mut rebuilt: ShippedChecks<PrivateIndexFacts<&'a mut F>> = shipped_checks(
221 PrivateIndexFacts::new(&mut checks.facts, index, object_directory),
222 arguments,
223 LifecycleContent::Requested(candidates),
224 allowed_worktree_dirs,
225 );
226 rebuilt.scanner_settings = scanner_settings;
227 rebuilt.markdown = markdown;
228 return rebuilt;
229}
230
231/// What:
232/// How many passes changed the state when the convergence settled; any other ending counts none.
233/// Why:
234/// The fix summary names the passes of a settled convergence only.
235///
236/// In TS you'd write (pseudocode):
237/// ```ts
238/// convergence.kind === 'settled' ? convergence.changedPasses : 0
239/// ```
240pub fn settled_passes(convergence: &Convergence) -> u64 {
241 return match convergence {
242 Convergence::Settled { changed_passes } => *changed_passes as u64,
243 _ => 0,
244 };
245}
246
247/// What:
248/// The candidate request for the paths a private index changes against the recorded base.
249/// Why:
250/// The policies check exactly the paths the commit changes; each path is literal and rooted at the top level, so a
251/// name with glob characters or a directory below the current one still names one file.
252///
253/// In TS you'd write (pseudocode):
254/// ```ts
255/// createPrivateIndexFacts({ paths: candidatePaths, baseRevision })
256/// ```
257pub fn staged_at_base(base: Option<ObjectId>, paths: &[Vec<u8>]) -> CandidateRequest {
258 return CandidateRequest::StagedAtBase {
259 base,
260 pathspecs: paths
261 .iter()
262 .map(|path| {
263 let mut pathspec: Vec<u8> = b":(top,literal)".to_vec();
264 pathspec.extend_from_slice(path.as_slice());
265 return os_from_path_bytes(pathspec.as_slice());
266 })
267 .collect(),
268 };
269}
270
271/// What:
272/// The recorded base as an object ID, or nothing for an unborn branch.
273/// Why:
274/// The candidate listing compares the private index with this commit.
275///
276/// In TS you'd write (pseudocode):
277/// ```ts
278/// capture.base.kind === 'commit' ? capture.base.oid : undefined
279/// ```
280pub fn base_object(capture: &InvocationCapture) -> Result<Option<ObjectId>, TransactionStop> {
281 return match &capture.base {
282 Base::Unborn => Ok(None),
283 Base::Commit(oid) => match parse_object_id(oid.as_bytes()) {
284 Some(found) => Ok(Some(found)),
285 None => Err(failed(format!("the preparation base {oid} is not an object name"))),
286 },
287 };
288}
289
290/// Controls for this module stay out of the release executable.
291#[cfg(test)]
292#[path = "commit_transaction_tests.rs"]
293mod tests;