git_policy_cli/commit/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 crate::candidate_object::{ObjectId, parse_object_id};
15use crate::candidate_prediction::CandidateRequest;
16use crate::capture_invocation::InvocationCapture;
17use crate::command_commit::CommitRegion;
18use crate::commit_facts::PrivateIndexFacts;
19use crate::config_schema::CliGitConfig;
20use crate::diagnostics::EngineFailureCode;
21use crate::path_bytes::os_from_path_bytes;
22use crate::markdown_linter::embedded_linter;
23use crate::policy_checks::{ShippedChecks, shipped_checks};
24use crate::policy_forbidden_strings::default_scanner_settings;
25use crate::policy_markdown::markdown_state;
26use crate::policy_content::LifecycleContent;
27use crate::policy_convergence::Convergence;
28use crate::policy_events::PolicyEvent;
29use crate::repository_facts::RepositoryFacts;
30use crate::transaction_git::{GitContext, GitFailure};
31use crate::transaction_journal::{Base, Conclusion, TransactionMode};
32use crate::transaction_phase::PhaseMarkerError;
33use crate::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 read_only: bool = region.include || region.interactive || region.patch;
180 let selects_paths: bool = !region.pathspecs.is_empty() || region.pathspec_from_file.is_some();
181 // Named paths never conclude another session's unfinished merge or stopped cherry-pick: real Git refuses them there as a
182 // partial commit, and the commit lands as an ordinary commit of those paths while the state is carried on (`SPEC.md`,
183 // "Merges and commits never fail"). Real Git takes named paths during a revert and concludes it with them, which stays.
184 let paths_only: bool = selects_paths && !read_only && !region.all && region.only != Some(false);
185 let refused_by_git: bool = matches!(conclusion, Conclusion::Merge | Conclusion::CherryPick);
186 let concludes_sequencer: bool = conclusion != Conclusion::None && conclusion != Conclusion::Amend && !(paths_only && refused_by_git);
187 let staged_all: bool = region.all && !selects_paths && !read_only && region.only != Some(true);
188 let index_mode: bool = region.only == Some(false)
189 || concludes_sequencer
190 || read_only
191 || region.all
192 || !selects_paths;
193 return Selection {
194 concludes_sequencer,
195 read_only,
196 selects_paths,
197 staged_all,
198 mode: if index_mode { TransactionMode::Index } else { TransactionMode::ExplicitPath },
199 };
200}
201
202/// What:
203/// Rebuild the shipped checks over the private commit index: rule facts stay real, the candidates come from the
204/// private index against the recorded base.
205/// Why:
206/// The incumbent's rules ask real Git about the real index, while the content policies read what the transaction
207/// corrects.
208///
209/// In TS you'd write (pseudocode):
210/// ```ts
211/// const facts = createPrivateIndexFacts({ gitPath, cwd, indexPath, paths, baseRevision, objectDirectory });
212/// ```
213pub fn private_checks<'a, F: RepositoryFacts>(
214 checks: &'a mut ShippedChecks<F>,
215 index: &Path,
216 object_directory: &Path,
217 candidates: CandidateRequest,
218) -> ShippedChecks<PrivateIndexFacts<&'a mut F>> {
219 // The scanner settings and the Markdown linter move to the rebuilt checks; the originals are placeholders that
220 // nothing reads again, because the rebuilt checks run every policy of this transaction.
221 let scanner_settings = std::mem::replace(&mut checks.scanner_settings, default_scanner_settings());
222 let markdown = std::mem::replace(&mut checks.markdown, markdown_state(Box::new(embedded_linter())));
223 let arguments: Vec<OsString> = checks.arguments.clone();
224 let allowed_worktree_dirs: Vec<PathBuf> = checks.allowed_worktree_dirs.clone();
225 let mut rebuilt: ShippedChecks<PrivateIndexFacts<&'a mut F>> = shipped_checks(
226 PrivateIndexFacts::new(&mut checks.facts, index, object_directory),
227 arguments,
228 LifecycleContent::Requested(candidates),
229 allowed_worktree_dirs,
230 );
231 rebuilt.scanner_settings = scanner_settings;
232 rebuilt.markdown = markdown;
233 return rebuilt;
234}
235
236/// What:
237/// How many passes changed the state when the convergence settled; any other ending counts none.
238/// Why:
239/// The fix summary names the passes of a settled convergence only.
240///
241/// In TS you'd write (pseudocode):
242/// ```ts
243/// convergence.kind === 'settled' ? convergence.changedPasses : 0
244/// ```
245pub fn settled_passes(convergence: &Convergence) -> u64 {
246 return match convergence {
247 Convergence::Settled { changed_passes } => *changed_passes as u64,
248 _ => 0,
249 };
250}
251
252/// What:
253/// The candidate request for the paths a private index changes against the recorded base.
254/// Why:
255/// The policies check exactly the paths the commit changes; each path is literal and rooted at the top level, so a
256/// name with glob characters or a directory below the current one still names one file.
257///
258/// In TS you'd write (pseudocode):
259/// ```ts
260/// createPrivateIndexFacts({ paths: candidatePaths, baseRevision })
261/// ```
262pub fn staged_at_base(base: Option<ObjectId>, paths: &[Vec<u8>]) -> CandidateRequest {
263 return CandidateRequest::StagedAtBase {
264 base,
265 pathspecs: paths
266 .iter()
267 .map(|path| {
268 let mut pathspec: Vec<u8> = b":(top,literal)".to_vec();
269 pathspec.extend_from_slice(path.as_slice());
270 return os_from_path_bytes(pathspec.as_slice());
271 })
272 .collect(),
273 };
274}
275
276/// What:
277/// The recorded base as an object ID, or nothing for an unborn branch.
278/// Why:
279/// The candidate listing compares the private index with this commit.
280///
281/// In TS you'd write (pseudocode):
282/// ```ts
283/// capture.base.kind === 'commit' ? capture.base.oid : undefined
284/// ```
285pub fn base_object(capture: &InvocationCapture) -> Result<Option<ObjectId>, TransactionStop> {
286 return match &capture.base {
287 Base::Unborn => Ok(None),
288 Base::Commit(oid) => match parse_object_id(oid.as_bytes()) {
289 Some(found) => Ok(Some(found)),
290 None => Err(failed(format!("the preparation base {oid} is not an object name"))),
291 },
292 };
293}
294
295/// Controls for this module stay out of the release executable.
296#[cfg(test)]
297#[path = "commit_transaction_tests.rs"]
298mod tests;