Skip to main content

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;