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