Skip to main content

git_policy_cli/
commit_transaction.rs

1//! What:
2//!  The commit transaction in the incumbent's order, up to the settled policies:
3//!  layout capture, the published workspace, the preparation base, `preparing.json`, the shadow repository, the
4//!  private index under capture order, interactive selection, and the bounded policy convergence.
5//! Why:
6//!  The incumbent (`src/policy-engine/commit-transaction.ts`) captures before it mutates, publishes the directory
7//!  before the base is read, and takes the landing lock only inside the landing attempt, never through preparation:
8//!  holding it here would serialize concurrent commits.
9//!  The conclusion (`commit_conclusion.rs`) takes the settled state to a landed commit.
10//!
11//! In TS you'd write (pseudocode):
12//! ```ts
13//! const result = await runCommitTransaction({ args, gitPath, policyOptions, concurrency });
14//! ```
15
16use super::candidate_object::{ObjectId, parse_object_id};
17use super::candidate_prediction::CandidateRequest;
18use super::capture_invocation::{
19    InvocationCapture, InvocationLayout, base_revision, capture_invocation_base, capture_invocation_layout,
20};
21use super::capture_order::{CaptureIndexes, CaptureOrderError, capture_commit_content};
22use super::capture_store::read_next_capture_sequence;
23use super::command_commit::CommitRegion;
24use super::command_options::OptionValue;
25use super::command_options_query::value_bytes;
26use super::commit_conclusion::{ConclusionInput, conclude_commit_transaction};
27use super::commit_convergence::{CommitConvergence, CommitConvergenceInput, converge_commit_policies};
28use super::commit_facts::PrivateIndexFacts;
29use super::commit_index::{
30    IndexSelection, initialize_commit_index, list_changed_index_paths, list_unmerged_index_paths,
31    materialize_pathspec_file, prepare_interactive_selection,
32};
33use super::commit_staged_deletions::{
34    STAGED_DELETION_CODE, STAGED_DELETION_CORE_ID, staged_deletion_message, staged_deletions_present_on_disk,
35};
36use super::commit_workspace::{CommitWorkspace, WorkspaceError, create_commit_workspace};
37use super::config_schema::CliGitConfig;
38use super::diagnostic_log::debug;
39use super::diagnostics::EngineFailureCode;
40use super::landing_attempt::LandingError;
41use super::path_bytes::{os_from_path_bytes, path_message_text};
42use super::markdown_linter::embedded_linter;
43use super::policy_checks::{ShippedChecks, shipped_checks};
44use super::policy_forbidden_strings::default_scanner_settings;
45use super::policy_markdown::markdown_state;
46use super::policy_content::LifecycleContent;
47use super::policy_convergence::Convergence;
48use super::policy_engine::{StageRequest, pass_exit_code};
49use super::policy_events::PolicyEvent;
50use super::policy_pass::{PassResult, run_policy_pass};
51use super::policy_trigger::Trigger;
52use super::journal_record::{RecordSync, write_record};
53use super::repository_facts::RepositoryFacts;
54use super::transaction_git::{GitContext, GitFailure};
55use super::transaction_journal::{Base, Conclusion, PREPARING_FILENAME, PreparingRecord, TransactionMode};
56use super::external_hooks::warn_external_hooks;
57use super::traditional_hook_warning::warn_traditional_hooks;
58use super::transaction_journal_encode::encode_preparing;
59use super::transaction_phase::PhaseMarkerError;
60use super::wrapper_controls::Controls;
61use std::ffi::OsString;
62use std::path::{Path, PathBuf};
63
64/// Module tag for diagnostics.
65const TAG: &str = "runCommitTransaction";
66
67/// What:
68///  Everything the invocation fixes before the transaction starts.
69/// Why:
70///   The steps share one identity, one parsed commit region and one configuration; passing them together keeps a
71///   step from reading another invocation's state.
72///
73/// In TS you'd write (pseudocode):
74/// ```ts
75/// type CommitInvocation = { args; gitPath; cwd; region; policyOptions; concurrency };
76/// ```
77pub struct CommitInvocation<'a> {
78    /// Real Git, the child environment overlay, and the invocation's global options.
79    pub context: &'a GitContext,
80    /// The invocation's environment, read for leases and inherited configuration.
81    pub environment: &'a [(OsString, OsString)],
82    /// The effective invocation directory (the process directory moved by every `-C`).
83    pub cwd: &'a Path,
84    /// The arguments with the wrapper's controls removed.
85    pub arguments: &'a [OsString],
86    /// The index of `commit` within `arguments`.
87    pub subcommand_index: usize,
88    /// The commit options Git's table read.
89    pub region: &'a CommitRegion,
90    /// The configuration of the worktree.
91    pub config: &'a CliGitConfig,
92    /// The wrapper controls given on the command line.
93    pub controls: &'a Controls,
94}
95
96/// What:
97///  Why the transaction stopped without a policy result.
98/// Why:
99///   The incumbent's boundary maps each to a different ending: Git's own exit code with no wrapper text, a message
100///   with exit status 1, a finding with exit status 2, or a verbatim stop text.
101///
102/// In TS you'd write (pseudocode):
103/// ```ts
104/// NativeCommitFailedError | CommitTransactionGitError | IndexLockUnprovenOwnerError | Error
105/// ```
106#[derive(Debug)]
107pub enum TransactionStop {
108    /// Native `git commit` exited nonzero during preparation: its status is the invocation's, and Git printed why.
109    NativeExit(i32),
110    /// A Git step failed: the message reaches standard error and the status is 1.
111    Message(String),
112    /// A transaction failure reported as an engine failure event with status 2.
113    Failed {
114        /// The failure's stable code.
115        code: EngineFailureCode,
116        /// What went wrong.
117        message: String,
118    },
119}
120
121impl From<GitFailure> for TransactionStop {
122    /// A Git step failure keeps its message and ends the command with status 1.
123    fn from(failure: GitFailure) -> Self {
124        return TransactionStop::Message(failure.0);
125    }
126}
127
128impl From<WorkspaceError> for TransactionStop {
129    /// An unpublishable workspace fails the transaction.
130    fn from(failure: WorkspaceError) -> Self {
131        return failed(failure.to_string());
132    }
133}
134
135impl From<CaptureOrderError> for TransactionStop {
136    /// A capture failure fails the transaction.
137    fn from(failure: CaptureOrderError) -> Self {
138        return match failure {
139            CaptureOrderError::Git(git) => TransactionStop::Message(git.0),
140            other => failed(other.to_string()),
141        };
142    }
143}
144
145impl From<PhaseMarkerError> for TransactionStop {
146    /// A malformed test marker fails the transaction.
147    fn from(failure: PhaseMarkerError) -> Self {
148        return failed(failure.to_string());
149    }
150}
151
152impl From<LandingError> for TransactionStop {
153    /// A failed landing attempt: an unproven lock owner has its own code, the rest are transaction failures.
154    fn from(failure: LandingError) -> Self {
155        return match failure {
156            LandingError::Lock(super::index_writer_coordination::CoordinationFailure::UnprovenOwner(text)) => {
157                TransactionStop::Failed {
158                    code: EngineFailureCode::IndexLockUnprovenOwner,
159                    message: text,
160                }
161            }
162            LandingError::Git(git) => TransactionStop::Message(git.0),
163            other => failed(other.to_string()),
164        };
165    }
166}
167
168/// What:
169///  A transaction failure with the generic code.
170/// Why:
171///   Most unexpected failures are reported the same way.
172///
173/// In TS you'd write (pseudocode):
174/// ```ts
175/// initialTransactionFailure({ args, message })
176/// ```
177pub fn failed(message: String) -> TransactionStop {
178    return TransactionStop::Failed {
179        code: EngineFailureCode::TransactionFailed,
180        message,
181    };
182}
183
184/// What:
185///  What the transaction decided.
186/// Why:
187///   The command prints the events, runs the post-commit lifecycle for a landed commit, and exits with the code.
188///
189/// In TS you'd write (pseudocode):
190/// ```ts
191/// type CommitTransactionResult = { policyResult: PolicyEngineResult; committed: boolean; landedOid?: string };
192/// ```
193#[derive(Clone, Debug, Eq, PartialEq)]
194pub struct TransactionResult {
195    /// The events the invocation reports, in order.
196    pub events: Vec<PolicyEvent>,
197    /// The exit status when nothing landed; 0 after a landed commit.
198    pub exit_code: i32,
199    /// The landed commit, when the transaction landed one.
200    pub landed_oid: Option<String>,
201    /// Whether the Git configuration read when the commit began holds LFS keys, so the commit's auto-push must upload
202    ///  LFS objects first; `false` when nothing landed.
203    pub uses_lfs: bool,
204}
205
206/// What:
207///  The transaction directory's names, derived from the registry root and the transaction ID.
208/// Why:
209///   Callers that build facts before the transaction creates anything use the same names.
210///
211/// In TS you'd write (pseudocode):
212/// ```ts
213/// transactionPaths({ registryRoot, commonDir, transactionId });
214/// ```
215pub fn transaction_directory(registry_root: &Path, transaction_id: &str) -> PathBuf {
216    return registry_root.join(transaction_id);
217}
218
219/// What:
220///  Write one journal record exclusively and whole, then sync as `sync` says.
221/// Why:
222///   A state file is never rewritten, so a crash leaves the newest complete state readable; the level says what a
223///  power loss may leave of this record.
224///
225/// In TS you'd write (pseudocode):
226/// ```ts
227/// await writeJournalRecord({ directory, filename, record, sync });
228/// ```
229pub fn write_journal_record(directory: &Path, filename: &str, text: &str, sync: RecordSync) -> Result<(), TransactionStop> {
230    return write_record(directory, filename, text.as_bytes(), sync).map_err(failed);
231}
232
233/// What:
234///  The commit-region facts the transaction decides from, derived once.
235/// Why:
236///   The mode and the selection flags are read in several steps.
237///
238/// In TS you'd write (pseudocode):
239/// ```ts
240/// const { concludesSequencer, readOnlySelection, selectsPaths, stagedAll, mode } = ...;
241/// ```
242#[derive(Clone, Debug, Eq, PartialEq)]
243pub struct Selection {
244    /// Whether a pathless commit concludes a merge, cherry-pick or revert.
245    pub concludes_sequencer: bool,
246    /// Whether the selection UI (include, interactive, patch) leaves the policies read-only.
247    pub read_only: bool,
248    /// Whether any path was selected.
249    pub selects_paths: bool,
250    /// Whether `-a` stages privately.
251    pub staged_all: bool,
252    /// The private index mode.
253    pub mode: TransactionMode,
254}
255
256/// What:
257///  Decide the selection facts of one commit invocation.
258/// Why:
259///   Invalid combinations reach native Git unchanged, so it rejects them.
260///
261/// In TS you'd write (pseudocode):
262/// ```ts
263/// const mode = region.hasNoOnlyFlag || concludesSequencer || readOnlySelection || region.hasAllFlag || (!selectsPaths) ? 'index' : 'explicit-path';
264/// ```
265pub fn decide_selection(region: &CommitRegion, conclusion: Conclusion) -> Selection {
266    let concludes_sequencer: bool = conclusion != Conclusion::None && conclusion != Conclusion::Amend;
267    let read_only: bool = region.include || region.interactive || region.patch;
268    let selects_paths: bool = !region.pathspecs.is_empty() || region.pathspec_from_file.is_some();
269    let staged_all: bool = region.all && !selects_paths && !read_only && region.only != Some(true);
270    let index_mode: bool = region.only == Some(false)
271        || concludes_sequencer
272        || read_only
273        || region.all
274        || !selects_paths;
275    return Selection {
276        concludes_sequencer,
277        read_only,
278        selects_paths,
279        staged_all,
280        mode: if index_mode { TransactionMode::Index } else { TransactionMode::ExplicitPath },
281    };
282}
283
284/// What:
285///  The positional pathspec tokens of the commit region, exactly as the command line gave them.
286/// Why:
287///   The private index selection and the journal must hand Git the same bytes the person typed,
288///  including a name that is not UTF-8.
289///
290/// In TS you'd write (pseudocode):
291/// ```ts
292/// region.pathspecs
293/// ```
294fn pathspec_arguments(invocation: &CommitInvocation<'_>) -> Vec<OsString> {
295    let region_arguments: &[OsString] = &invocation.arguments[invocation.subcommand_index + 1..];
296    return invocation
297        .region
298        .pathspecs
299        .iter()
300        .map(|index| { return region_arguments[*index].clone(); })
301        .collect();
302}
303
304/// What:
305///  The positional pathspec tokens of the commit region as text.
306/// Why:
307///   The private commit arguments are text, and a pathspec is removed from them by equal text;
308///  a name that is not UTF-8 is compared by its readable form, the form the arguments themselves have.
309///
310/// In TS you'd write (pseudocode):
311/// ```ts
312/// region.pathspecs
313/// ```
314fn pathspec_texts(invocation: &CommitInvocation<'_>) -> Vec<String> {
315    let region_arguments: &[OsString] = &invocation.arguments[invocation.subcommand_index + 1..];
316    return invocation
317        .region
318        .pathspecs
319        .iter()
320        .map(|index| { return region_arguments[*index].to_string_lossy().into_owned(); })
321        .collect();
322}
323
324/// What:
325///  The pathspec-file source the commit names, as text.
326/// Why:
327///   Standard input is copied once into the transaction so no second read sees different bytes.
328///
329/// In TS you'd write (pseudocode):
330/// ```ts
331/// region.pathspecFile
332/// ```
333fn pathspec_file_source(invocation: &CommitInvocation<'_>) -> Option<String> {
334    let region_arguments: &[OsString] = &invocation.arguments[invocation.subcommand_index + 1..];
335    let value: OptionValue = invocation.region.pathspec_from_file?;
336    return Some(String::from_utf8_lossy(value_bytes(region_arguments, value)).into_owned());
337}
338
339/// What:
340///  The prepared state a converged commit hands to its conclusion.
341/// Why:
342///   The conclusion needs the pass that settled and the paths the commit carries.
343///
344/// In TS you'd write (pseudocode):
345/// ```ts
346/// type SettledCommitTransaction = { workspace; capture; mode; pass; changedPasses; changedPaths; ... };
347/// ```
348struct Converged {
349    /// The settled pass, whose events the invocation reports.
350    pass: PassResult,
351    /// How many passes changed content before it settled.
352    changed_passes: u64,
353    /// The paths the corrections wrote.
354    changed_paths: Vec<Vec<u8>>,
355}
356
357/// What:
358///  Rebuild the shipped checks over the private commit index: rule facts stay real, the candidates come from the
359///  private index against the recorded base.
360/// Why:
361///   The incumbent's rules ask real Git about the real index, while the content policies read what the transaction
362///   corrects.
363///
364/// In TS you'd write (pseudocode):
365/// ```ts
366/// const facts = createPrivateIndexFacts({ gitPath, cwd, indexPath, paths, baseRevision, objectDirectory });
367/// ```
368pub fn private_checks<'a, F: RepositoryFacts>(
369    checks: &'a mut ShippedChecks<F>,
370    index: &Path,
371    object_directory: &Path,
372    candidates: CandidateRequest,
373) -> ShippedChecks<PrivateIndexFacts<&'a mut F>> {
374    // The scanner settings and the Markdown linter move to the rebuilt checks; the originals are placeholders that
375    // nothing reads again, because the rebuilt checks run every policy of this transaction.
376    let scanner_settings = std::mem::replace(&mut checks.scanner_settings, default_scanner_settings());
377    let markdown = std::mem::replace(&mut checks.markdown, markdown_state(Box::new(embedded_linter())));
378    let arguments: Vec<OsString> = checks.arguments.clone();
379    let allowed_worktree_dirs: Vec<PathBuf> = checks.allowed_worktree_dirs.clone();
380    let mut rebuilt: ShippedChecks<PrivateIndexFacts<&'a mut F>> = shipped_checks(
381        PrivateIndexFacts::new(&mut checks.facts, index, object_directory),
382        arguments,
383        LifecycleContent::Requested(candidates),
384        allowed_worktree_dirs,
385    );
386    rebuilt.scanner_settings = scanner_settings;
387    rebuilt.markdown = markdown;
388    return rebuilt;
389}
390
391/// What:
392///  Whether the capture lists worktree changes that the commit stages: `-a` stages every tracked change and
393///  `--include` stages the named paths.
394/// Why:
395///   The capture record names those paths, so recovery copies exactly the worktree files the commit staged.
396///
397/// In TS you'd write (pseudocode):
398/// ```ts
399/// stagedAll || include
400/// ```
401pub fn stages_worktree(staged_all: bool, include: bool) -> bool {
402    return staged_all || include;
403}
404
405/// What:
406///  How many passes changed the state when the convergence settled; any other ending counts none.
407/// Why:
408///   The fix summary names the passes of a settled convergence only.
409///
410/// In TS you'd write (pseudocode):
411/// ```ts
412/// convergence.kind === 'settled' ? convergence.changedPasses : 0
413/// ```
414pub fn settled_passes(convergence: &Convergence) -> u64 {
415    return match convergence {
416        Convergence::Settled { changed_passes } => *changed_passes as u64,
417        _ => 0,
418    };
419}
420
421/// What:
422///  The candidate request for the paths a private index changes against the recorded base.
423/// Why:
424///   The policies check exactly the paths the commit changes; each path is literal and rooted at the top level, so a
425///   name with glob characters or a directory below the current one still names one file.
426///
427/// In TS you'd write (pseudocode):
428/// ```ts
429/// createPrivateIndexFacts({ paths: candidatePaths, baseRevision })
430/// ```
431pub fn staged_at_base(base: Option<ObjectId>, paths: &[Vec<u8>]) -> CandidateRequest {
432    return CandidateRequest::StagedAtBase {
433        base,
434        pathspecs: paths
435            .iter()
436            .map(|path| {
437                let mut pathspec: Vec<u8> = b":(top,literal)".to_vec();
438                pathspec.extend_from_slice(path.as_slice());
439                return os_from_path_bytes(pathspec.as_slice());
440            })
441            .collect(),
442    };
443}
444
445/// What:
446///  The recorded base as an object ID, or nothing for an unborn branch.
447/// Why:
448///   The candidate listing compares the private index with this commit.
449///
450/// In TS you'd write (pseudocode):
451/// ```ts
452/// capture.base.kind === 'commit' ? capture.base.oid : undefined
453/// ```
454pub fn base_object(capture: &InvocationCapture) -> Result<Option<ObjectId>, TransactionStop> {
455    return match &capture.base {
456        Base::Unborn => Ok(None),
457        Base::Commit(oid) => match parse_object_id(oid.as_bytes()) {
458            Some(found) => Ok(Some(found)),
459            None => Err(failed(format!("the preparation base {oid} is not an object name"))),
460        },
461    };
462}
463
464/// What:
465///  Run the pre-forward policies over the private index until they settle.
466/// Why:
467///   A read-only selection commits exactly what the person chose, so its single pass applies no patch.
468///
469/// In TS you'd write (pseudocode):
470/// ```ts
471/// readOnlySelection ? { kind: 'settled', pass: firstPass } : await convergeCommitPolicies({ ... });
472/// ```
473fn converge<G: RepositoryFacts>(
474    request: &StageRequest,
475    checks: &mut ShippedChecks<PrivateIndexFacts<G>>,
476    context: &GitContext,
477    cwd: &Path,
478    workspace: &CommitWorkspace,
479    read_only: bool,
480) -> Converged {
481    if read_only {
482        checks.can_apply_patches = false;
483        let pass: PassResult = run_policy_pass(request, checks);
484        return Converged {
485            pass,
486            changed_passes: 0,
487            changed_paths: Vec::new(),
488        };
489    }
490    let outcome: CommitConvergence = converge_commit_policies(CommitConvergenceInput {
491        request,
492        checks,
493        stage: super::private_index_stage::PrivateStage {
494            context,
495            cwd,
496            index: workspace.landing.commit_index.as_path(),
497            object_directory: Some(workspace.object_directory.as_path()),
498        },
499    });
500    let changed_passes: u64 = settled_passes(&outcome.convergence);
501    return Converged {
502        pass: outcome.pass,
503        changed_passes,
504        changed_paths: outcome.changed_paths,
505    };
506}
507
508/// What:
509///  Run one commit through the transaction: capture, private preparation, serial landing and post-landing completion.
510/// Why:
511///   Every non-dry-run commit runs through it, including clean commits and `commit -a`, because native `git commit`
512///   keeps `index.lock` on disk through hooks and the editor.
513///
514/// In TS you'd write (pseudocode):
515/// ```ts
516/// return await runCommitTransaction({ args, gitPath, policyOptions, concurrency });
517/// ```
518pub fn run_commit_transaction<F: RepositoryFacts>(
519    invocation: &CommitInvocation<'_>,
520    checks: &mut ShippedChecks<F>,
521    request: &StageRequest,
522) -> Result<TransactionResult, TransactionStop> {
523    let context: &GitContext = invocation.context;
524    let cwd: &Path = invocation.cwd;
525    let layout: InvocationLayout = capture_invocation_layout(context, cwd, invocation.region.amend)?;
526    // Decided from the snapshot the layout already holds: no process and no file system call, and it never blocks.
527    warn_traditional_hooks(layout.config_snapshot.as_ref(), &mut std::io::stderr());
528    warn_external_hooks(layout.config_snapshot.as_ref(), &mut std::io::stderr());
529    let selection: Selection = decide_selection(invocation.region, layout.conclusion);
530    let mut workspace: CommitWorkspace = create_commit_workspace(&layout)?;
531    // Read after publication and before the base, so landed-capture pruning keeps every record this transaction
532    // may replay over.
533    let next_sequence_before_base: i64 = read_next_capture_sequence(layout.git_dir.as_path())
534        .map_err(|error| { return failed(error.0); })?;
535    let capture: InvocationCapture = capture_invocation_base(context, cwd, layout)?;
536    let base: String = base_revision(&capture);
537    let pathspecs: Vec<String> = pathspec_texts(invocation);
538    let pathspec_arguments: Vec<OsString> = pathspec_arguments(invocation);
539    if selection.mode == TransactionMode::ExplicitPath {
540        // Before any private staging, so Git's "nothing to commit" or its force-add hint never stands in for the reason.
541        let present: Vec<Vec<u8>> = staged_deletions_present_on_disk(
542            context,
543            cwd,
544            capture.layout.repository_root.as_path(),
545            capture.layout.real_index_path.as_path(),
546            base.as_str(),
547            pathspec_arguments.as_slice(),
548        )?;
549        if !present.is_empty() {
550            debug(TAG, "an explicit-path commit selects a staged deletion whose path is on disk");
551            return Ok(TransactionResult {
552                events: vec![PolicyEvent::CoreFinding {
553                    core_id: STAGED_DELETION_CORE_ID,
554                    code: STAGED_DELETION_CODE,
555                    message: staged_deletion_message(present.as_slice()),
556                }],
557                exit_code: 1,
558                landed_oid: None,
559                uses_lfs: false,
560            });
561        }
562    }
563    write_journal_record(
564        workspace.landing.directory.as_path(),
565        PREPARING_FILENAME,
566        encode_preparing(&PreparingRecord {
567            transaction_id: workspace.landing.transaction_id.clone(),
568            mode: selection.mode,
569            base: capture.base.clone(),
570            symbolic_head: capture.layout.symbolic_head.clone(),
571            target_ref: capture.layout.target_ref.clone(),
572            conclusion: capture.layout.conclusion,
573            repository_root: capture.layout.repository_root.display().to_string(),
574            git_dir: capture.layout.git_dir.display().to_string(),
575            common_dir: capture.layout.common_dir.display().to_string(),
576            real_index_path: capture.layout.real_index_path.display().to_string(),
577            object_directory: capture.layout.object_directory.display().to_string(),
578            ref_format: capture.layout.ref_format,
579            empty_tree_oid: capture.layout.empty_tree_oid.clone(),
580            shadow_path: workspace.landing.shadow.display().to_string(),
581            selected_pathspecs: pathspec_arguments
582                .iter()
583                .map(|pathspec| { return pathspec.as_encoded_bytes().to_vec(); })
584                .collect(),
585            invoked_at: capture.layout.invoked_at.clone(),
586        })
587        .as_str(),
588        // The file is synced (a torn record sticks every guarded command); its name is made durable by the directory
589        // sync of `landing-<n>.json`, before the branch moves.
590        RecordSync::FileOnly,
591    )?;
592    // Before the private index exists, so every object preparation writes lands in the shadow store, which a
593    // concurrent real `gc --prune=now` never sees.
594    super::shadow_create::create_shadow_repository(
595        context,
596        cwd,
597        &capture,
598        workspace.landing.transaction_id.as_str(),
599        workspace.landing.directory.as_path(),
600    )?;
601    let pathspec_file: Option<PathBuf> = materialize_pathspec_file(
602        workspace.landing.directory.as_path(),
603        cwd,
604        pathspec_file_source(invocation).as_deref(),
605        &mut std::io::stdin(),
606    )
607    .map_err(|error| { return failed(format!("reading the pathspec file failed: {error}")); })?;
608    let indexes: CaptureIndexes = CaptureIndexes {
609        captured_index: workspace.landing.captured_index.clone(),
610        commit_index: workspace.landing.commit_index.clone(),
611        object_directory: workspace.object_directory.clone(),
612    };
613    let index_selection: IndexSelection<'_> = IndexSelection {
614        mode: selection.mode,
615        pathspecs: pathspec_arguments.as_slice(),
616        pathspec_file: pathspec_file.as_deref(),
617        pathspec_file_nul: invocation.region.pathspec_file_nul,
618        stage_into_index: invocation.region.include,
619        stage_tracked_changes: selection.staged_all,
620        base_revision: base.as_str(),
621    };
622    capture_commit_content(
623        context,
624        cwd,
625        capture.layout.git_dir.as_path(),
626        workspace.landing.directory.as_path(),
627        &indexes,
628        selection.mode,
629        stages_worktree(selection.staged_all, invocation.region.include),
630        base.as_str(),
631        next_sequence_before_base,
632        || {
633            return initialize_commit_index(
634                context,
635                cwd,
636                capture.layout.real_index_path.as_path(),
637                &indexes,
638                &index_selection,
639            )
640            .map_err(CaptureOrderError::from);
641        },
642    )?;
643    if invocation.region.interactive || invocation.region.patch {
644        prepare_interactive_selection(
645            context,
646            cwd,
647            workspace.landing.commit_index.as_path(),
648            workspace.object_directory.as_path(),
649            invocation.region.patch,
650            &index_selection,
651        )?;
652    }
653    let unmerged: Vec<Vec<u8>> =
654        list_unmerged_index_paths(context, cwd, workspace.landing.commit_index.as_path())?;
655    if !unmerged.is_empty() {
656        return Ok(TransactionResult {
657            events: vec![PolicyEvent::EngineFailure {
658                code: EngineFailureCode::ContentUnavailable,
659                message: format!(
660                    "Automatic commit fixes do not support unmerged index paths: {}",
661                    unmerged
662                        .iter()
663                        .map(|path| { return path_message_text(path.as_slice()); })
664                        .collect::<Vec<String>>()
665                        .join(", ")
666                ),
667                trigger: Some(Trigger::PreForward),
668                policy: None,
669                path: None,
670            }],
671            exit_code: 2,
672            landed_oid: None,
673            uses_lfs: false,
674        });
675    }
676    let candidate_paths: Vec<Vec<u8>> = list_changed_index_paths(
677        context,
678        cwd,
679        workspace.landing.commit_index.as_path(),
680        base.as_str(),
681        workspace.object_directory.as_path(),
682    )?;
683    let mut private: ShippedChecks<PrivateIndexFacts<&mut F>> = private_checks(
684        checks,
685        workspace.landing.commit_index.as_path(),
686        workspace.object_directory.as_path(),
687        staged_at_base(base_object(&capture)?, candidate_paths.as_slice()),
688    );
689    let converged: Converged = converge(request, &mut private, context, cwd, &workspace, selection.read_only);
690    let code: i32 = pass_exit_code(converged.pass.events.as_slice(), converged.pass.end);
691    if code != 0 {
692        debug(TAG, "policies blocked the commit before preparation");
693        return Ok(TransactionResult {
694            events: converged.pass.events,
695            exit_code: code,
696            landed_oid: None,
697            uses_lfs: false,
698        });
699    }
700    return conclude_commit_transaction(ConclusionInput {
701        invocation,
702        workspace: &mut workspace,
703        capture: &capture,
704        selection: &selection,
705        request,
706        checks: &mut private,
707        pass: converged.pass,
708        changed_passes: converged.changed_passes,
709        changed_paths: converged.changed_paths,
710        committed_paths: candidate_paths,
711        pathspecs: pathspecs.as_slice(),
712    });
713}
714
715/// Controls for this module stay out of the release executable.
716#[cfg(test)]
717#[path = "commit_transaction_tests.rs"]
718mod tests;