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}
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 durably: exclusively, then the directory entry.
221/// Why:
222///   A state file is never rewritten, so a crash leaves the newest complete state readable.
223///
224/// In TS you'd write (pseudocode):
225/// ```ts
226/// await writeJournalRecord({ directory, filename, record });
227/// ```
228pub fn write_journal_record(directory: &Path, filename: &str, text: &str) -> Result<(), TransactionStop> {
229    let path: PathBuf = directory.join(filename);
230    write_private_file(path.as_path(), text.as_bytes())
231        .map_err(|error| { return failed(format!("writing {} failed: {error}", path.display())); })?;
232    sync_directory(directory)
233        .map_err(|error| { return failed(format!("syncing {} failed: {error}", directory.display())); })?;
234    return Ok(());
235}
236
237/// What:
238///  The commit-region facts the transaction decides from, derived once.
239/// Why:
240///   The mode and the selection flags are read in several steps.
241///
242/// In TS you'd write (pseudocode):
243/// ```ts
244/// const { concludesSequencer, readOnlySelection, selectsPaths, stagedAll, mode } = ...;
245/// ```
246#[derive(Clone, Debug, Eq, PartialEq)]
247pub struct Selection {
248    /// Whether a pathless commit concludes a merge, cherry-pick or revert.
249    pub concludes_sequencer: bool,
250    /// Whether the selection UI (include, interactive, patch) leaves the policies read-only.
251    pub read_only: bool,
252    /// Whether any path was selected.
253    pub selects_paths: bool,
254    /// Whether `-a` stages privately.
255    pub staged_all: bool,
256    /// The private index mode.
257    pub mode: TransactionMode,
258}
259
260/// What:
261///  Decide the selection facts of one commit invocation.
262/// Why:
263///   Invalid combinations reach native Git unchanged, so it rejects them.
264///
265/// In TS you'd write (pseudocode):
266/// ```ts
267/// const mode = region.hasNoOnlyFlag || concludesSequencer || readOnlySelection || region.hasAllFlag || (!selectsPaths) ? 'index' : 'explicit-path';
268/// ```
269pub fn decide_selection(region: &CommitRegion, conclusion: Conclusion) -> Selection {
270    let concludes_sequencer: bool = conclusion != Conclusion::None && conclusion != Conclusion::Amend;
271    let read_only: bool = region.include || region.interactive || region.patch;
272    let selects_paths: bool = !region.pathspecs.is_empty() || region.pathspec_from_file.is_some();
273    let staged_all: bool = region.all && !selects_paths && !read_only && region.only != Some(true);
274    let index_mode: bool = region.only == Some(false)
275        || concludes_sequencer
276        || read_only
277        || region.all
278        || !selects_paths;
279    return Selection {
280        concludes_sequencer,
281        read_only,
282        selects_paths,
283        staged_all,
284        mode: if index_mode { TransactionMode::Index } else { TransactionMode::ExplicitPath },
285    };
286}
287
288/// What:
289///  The positional pathspec tokens of the commit region, exactly as the command line gave them.
290/// Why:
291///   The private index selection and the journal must hand Git the same bytes the person typed,
292///  including a name that is not UTF-8.
293///
294/// In TS you'd write (pseudocode):
295/// ```ts
296/// region.pathspecs
297/// ```
298fn pathspec_arguments(invocation: &CommitInvocation<'_>) -> Vec<OsString> {
299    let region_arguments: &[OsString] = &invocation.arguments[invocation.subcommand_index + 1..];
300    return invocation
301        .region
302        .pathspecs
303        .iter()
304        .map(|index| { return region_arguments[*index].clone(); })
305        .collect();
306}
307
308/// What:
309///  The positional pathspec tokens of the commit region as text.
310/// Why:
311///   The private commit arguments are text, and a pathspec is removed from them by equal text;
312///  a name that is not UTF-8 is compared by its readable form, the form the arguments themselves have.
313///
314/// In TS you'd write (pseudocode):
315/// ```ts
316/// region.pathspecs
317/// ```
318fn pathspec_texts(invocation: &CommitInvocation<'_>) -> Vec<String> {
319    let region_arguments: &[OsString] = &invocation.arguments[invocation.subcommand_index + 1..];
320    return invocation
321        .region
322        .pathspecs
323        .iter()
324        .map(|index| { return region_arguments[*index].to_string_lossy().into_owned(); })
325        .collect();
326}
327
328/// What:
329///  The pathspec-file source the commit names, as text.
330/// Why:
331///   Standard input is copied once into the transaction so no second read sees different bytes.
332///
333/// In TS you'd write (pseudocode):
334/// ```ts
335/// region.pathspecFile
336/// ```
337fn pathspec_file_source(invocation: &CommitInvocation<'_>) -> Option<String> {
338    let region_arguments: &[OsString] = &invocation.arguments[invocation.subcommand_index + 1..];
339    let value: OptionValue = invocation.region.pathspec_from_file?;
340    return Some(String::from_utf8_lossy(value_bytes(region_arguments, value)).into_owned());
341}
342
343/// What:
344///  The prepared state a converged commit hands to its conclusion.
345/// Why:
346///   The conclusion needs the pass that settled and the paths the commit carries.
347///
348/// In TS you'd write (pseudocode):
349/// ```ts
350/// type SettledCommitTransaction = { workspace; capture; mode; pass; changedPasses; changedPaths; ... };
351/// ```
352struct Converged {
353    /// The settled pass, whose events the invocation reports.
354    pass: PassResult,
355    /// How many passes changed content before it settled.
356    changed_passes: u64,
357    /// The paths the corrections wrote.
358    changed_paths: Vec<Vec<u8>>,
359}
360
361/// What:
362///  Rebuild the shipped checks over the private commit index: rule facts stay real, the candidates come from the
363///  private index against the recorded base.
364/// Why:
365///   The incumbent's rules ask real Git about the real index, while the content policies read what the transaction
366///   corrects.
367///
368/// In TS you'd write (pseudocode):
369/// ```ts
370/// const facts = createPrivateIndexFacts({ gitPath, cwd, indexPath, paths, baseRevision, objectDirectory });
371/// ```
372pub fn private_checks<'a, F: RepositoryFacts>(
373    checks: &'a mut ShippedChecks<F>,
374    index: &Path,
375    object_directory: &Path,
376    candidates: CandidateRequest,
377) -> ShippedChecks<PrivateIndexFacts<&'a mut F>> {
378    // The scanner settings and the Markdown linter move to the rebuilt checks; the originals are placeholders that
379    // nothing reads again, because the rebuilt checks run every policy of this transaction.
380    let scanner_settings = std::mem::replace(&mut checks.scanner_settings, default_scanner_settings());
381    let markdown = std::mem::replace(&mut checks.markdown, markdown_state(Box::new(executable_linter(&[]))));
382    let arguments: Vec<OsString> = checks.arguments.clone();
383    let allowed_worktree_dirs: Vec<PathBuf> = checks.allowed_worktree_dirs.clone();
384    let mut rebuilt: ShippedChecks<PrivateIndexFacts<&'a mut F>> = shipped_checks(
385        PrivateIndexFacts::new(&mut checks.facts, index, object_directory),
386        arguments,
387        LifecycleContent::Requested(candidates),
388        allowed_worktree_dirs,
389    );
390    rebuilt.scanner_settings = scanner_settings;
391    rebuilt.markdown = markdown;
392    return rebuilt;
393}
394
395/// What:
396///  Whether the capture lists worktree changes that the commit stages: `-a` stages every tracked change and
397///  `--include` stages the named paths.
398/// Why:
399///   The capture record names those paths, so recovery copies exactly the worktree files the commit staged.
400///
401/// In TS you'd write (pseudocode):
402/// ```ts
403/// stagedAll || include
404/// ```
405pub fn stages_worktree(staged_all: bool, include: bool) -> bool {
406    return staged_all || include;
407}
408
409/// What:
410///  How many passes changed the state when the convergence settled; any other ending counts none.
411/// Why:
412///   The fix summary names the passes of a settled convergence only.
413///
414/// In TS you'd write (pseudocode):
415/// ```ts
416/// convergence.kind === 'settled' ? convergence.changedPasses : 0
417/// ```
418pub fn settled_passes(convergence: &Convergence) -> u64 {
419    return match convergence {
420        Convergence::Settled { changed_passes } => *changed_passes as u64,
421        _ => 0,
422    };
423}
424
425/// What:
426///  The candidate request for the paths a private index changes against the recorded base.
427/// Why:
428///   The policies check exactly the paths the commit changes; each path is literal and rooted at the top level, so a
429///   name with glob characters or a directory below the current one still names one file.
430///
431/// In TS you'd write (pseudocode):
432/// ```ts
433/// createPrivateIndexFacts({ paths: candidatePaths, baseRevision })
434/// ```
435pub fn staged_at_base(base: Option<ObjectId>, paths: &[Vec<u8>]) -> CandidateRequest {
436    return CandidateRequest::StagedAtBase {
437        base,
438        pathspecs: paths
439            .iter()
440            .map(|path| {
441                let mut pathspec: Vec<u8> = b":(top,literal)".to_vec();
442                pathspec.extend_from_slice(path.as_slice());
443                return os_from_path_bytes(pathspec.as_slice());
444            })
445            .collect(),
446    };
447}
448
449/// What:
450///  The recorded base as an object ID, or nothing for an unborn branch.
451/// Why:
452///   The candidate listing compares the private index with this commit.
453///
454/// In TS you'd write (pseudocode):
455/// ```ts
456/// capture.base.kind === 'commit' ? capture.base.oid : undefined
457/// ```
458pub fn base_object(capture: &InvocationCapture) -> Result<Option<ObjectId>, TransactionStop> {
459    return match &capture.base {
460        Base::Unborn => Ok(None),
461        Base::Commit(oid) => match parse_object_id(oid.as_bytes()) {
462            Some(found) => Ok(Some(found)),
463            None => Err(failed(format!("the preparation base {oid} is not an object name"))),
464        },
465    };
466}
467
468/// What:
469///  Run the pre-forward policies over the private index until they settle.
470/// Why:
471///   A read-only selection commits exactly what the person chose, so its single pass applies no patch.
472///
473/// In TS you'd write (pseudocode):
474/// ```ts
475/// readOnlySelection ? { kind: 'settled', pass: firstPass } : await convergeCommitPolicies({ ... });
476/// ```
477fn converge<G: RepositoryFacts>(
478    request: &StageRequest,
479    checks: &mut ShippedChecks<PrivateIndexFacts<G>>,
480    context: &GitContext,
481    cwd: &Path,
482    workspace: &CommitWorkspace,
483    read_only: bool,
484) -> Converged {
485    if read_only {
486        checks.can_apply_patches = false;
487        let pass: PassResult = run_policy_pass(request, checks);
488        return Converged {
489            pass,
490            changed_passes: 0,
491            changed_paths: Vec::new(),
492        };
493    }
494    let outcome: CommitConvergence = converge_commit_policies(CommitConvergenceInput {
495        request,
496        checks,
497        stage: super::private_index_stage::PrivateStage {
498            context,
499            cwd,
500            index: workspace.landing.commit_index.as_path(),
501            object_directory: Some(workspace.object_directory.as_path()),
502        },
503    });
504    let changed_passes: u64 = settled_passes(&outcome.convergence);
505    return Converged {
506        pass: outcome.pass,
507        changed_passes,
508        changed_paths: outcome.changed_paths,
509    };
510}
511
512/// What:
513///  Run one commit through the transaction: capture, private preparation, serial landing and post-landing completion.
514/// Why:
515///   Every non-dry-run commit runs through it, including clean commits and `commit -a`, because native `git commit`
516///   keeps `index.lock` on disk through hooks and the editor.
517///
518/// In TS you'd write (pseudocode):
519/// ```ts
520/// return await runCommitTransaction({ args, gitPath, policyOptions, concurrency });
521/// ```
522pub fn run_commit_transaction<F: RepositoryFacts>(
523    invocation: &CommitInvocation<'_>,
524    checks: &mut ShippedChecks<F>,
525    request: &StageRequest,
526) -> Result<TransactionResult, TransactionStop> {
527    let context: &GitContext = invocation.context;
528    let cwd: &Path = invocation.cwd;
529    let layout: InvocationLayout = capture_invocation_layout(context, cwd, invocation.region.amend)?;
530    // Decided from the snapshot the layout already holds: no process and no file system call, and it never blocks.
531    warn_traditional_hooks(layout.config_snapshot.as_ref(), &mut std::io::stderr());
532    let selection: Selection = decide_selection(invocation.region, layout.conclusion);
533    let mut workspace: CommitWorkspace = create_commit_workspace(&layout)?;
534    // Read after publication and before the base, so landed-capture pruning keeps every record this transaction
535    // may replay over.
536    let next_sequence_before_base: i64 = read_next_capture_sequence(layout.git_dir.as_path())
537        .map_err(|error| { return failed(error.0); })?;
538    let capture: InvocationCapture = capture_invocation_base(context, cwd, layout)?;
539    let base: String = base_revision(&capture);
540    let pathspecs: Vec<String> = pathspec_texts(invocation);
541    let pathspec_arguments: Vec<OsString> = pathspec_arguments(invocation);
542    if selection.mode == TransactionMode::ExplicitPath {
543        // Before any private staging, so Git's "nothing to commit" or its force-add hint never stands in for the reason.
544        let present: Vec<Vec<u8>> = staged_deletions_present_on_disk(
545            context,
546            cwd,
547            capture.layout.repository_root.as_path(),
548            capture.layout.real_index_path.as_path(),
549            base.as_str(),
550            pathspec_arguments.as_slice(),
551        )?;
552        if !present.is_empty() {
553            debug(TAG, "an explicit-path commit selects a staged deletion whose path is on disk");
554            return Ok(TransactionResult {
555                events: vec![PolicyEvent::CoreFinding {
556                    core_id: STAGED_DELETION_CORE_ID,
557                    code: STAGED_DELETION_CODE,
558                    message: staged_deletion_message(present.as_slice()),
559                }],
560                exit_code: 1,
561                landed_oid: None,
562            });
563        }
564    }
565    write_journal_record(
566        workspace.landing.directory.as_path(),
567        PREPARING_FILENAME,
568        encode_preparing(&PreparingRecord {
569            transaction_id: workspace.landing.transaction_id.clone(),
570            mode: selection.mode,
571            base: capture.base.clone(),
572            symbolic_head: capture.layout.symbolic_head.clone(),
573            target_ref: capture.layout.target_ref.clone(),
574            conclusion: capture.layout.conclusion,
575            repository_root: capture.layout.repository_root.display().to_string(),
576            git_dir: capture.layout.git_dir.display().to_string(),
577            common_dir: capture.layout.common_dir.display().to_string(),
578            real_index_path: capture.layout.real_index_path.display().to_string(),
579            object_directory: capture.layout.object_directory.display().to_string(),
580            ref_format: capture.layout.ref_format,
581            empty_tree_oid: capture.layout.empty_tree_oid.clone(),
582            shadow_path: workspace.landing.shadow.display().to_string(),
583            selected_pathspecs: pathspec_arguments
584                .iter()
585                .map(|pathspec| { return pathspec.as_encoded_bytes().to_vec(); })
586                .collect(),
587            invoked_at: capture.layout.invoked_at.clone(),
588        })
589        .as_str(),
590    )?;
591    // Before the private index exists, so every object preparation writes lands in the shadow store, which a
592    // concurrent real `gc --prune=now` never sees.
593    super::shadow_create::create_shadow_repository(
594        context,
595        cwd,
596        &capture,
597        workspace.landing.transaction_id.as_str(),
598        workspace.landing.directory.as_path(),
599    )?;
600    let pathspec_file: Option<PathBuf> = materialize_pathspec_file(
601        workspace.landing.directory.as_path(),
602        cwd,
603        pathspec_file_source(invocation).as_deref(),
604        &mut std::io::stdin(),
605    )
606    .map_err(|error| { return failed(format!("reading the pathspec file failed: {error}")); })?;
607    let indexes: CaptureIndexes = CaptureIndexes {
608        captured_index: workspace.landing.captured_index.clone(),
609        commit_index: workspace.landing.commit_index.clone(),
610        object_directory: workspace.object_directory.clone(),
611    };
612    let index_selection: IndexSelection<'_> = IndexSelection {
613        mode: selection.mode,
614        pathspecs: pathspec_arguments.as_slice(),
615        pathspec_file: pathspec_file.as_deref(),
616        pathspec_file_nul: invocation.region.pathspec_file_nul,
617        stage_into_index: invocation.region.include,
618        stage_tracked_changes: selection.staged_all,
619        base_revision: base.as_str(),
620    };
621    capture_commit_content(
622        context,
623        cwd,
624        capture.layout.git_dir.as_path(),
625        workspace.landing.directory.as_path(),
626        &indexes,
627        selection.mode,
628        stages_worktree(selection.staged_all, invocation.region.include),
629        base.as_str(),
630        next_sequence_before_base,
631        || {
632            return initialize_commit_index(
633                context,
634                cwd,
635                capture.layout.real_index_path.as_path(),
636                &indexes,
637                &index_selection,
638            )
639            .map_err(CaptureOrderError::from);
640        },
641    )?;
642    if invocation.region.interactive || invocation.region.patch {
643        prepare_interactive_selection(
644            context,
645            cwd,
646            workspace.landing.commit_index.as_path(),
647            workspace.object_directory.as_path(),
648            invocation.region.patch,
649            &index_selection,
650        )?;
651    }
652    let unmerged: Vec<Vec<u8>> =
653        list_unmerged_index_paths(context, cwd, workspace.landing.commit_index.as_path())?;
654    if !unmerged.is_empty() {
655        return Ok(TransactionResult {
656            events: vec![PolicyEvent::EngineFailure {
657                code: EngineFailureCode::ContentUnavailable,
658                message: format!(
659                    "Automatic commit fixes do not support unmerged index paths: {}",
660                    unmerged
661                        .iter()
662                        .map(|path| { return path_message_text(path.as_slice()); })
663                        .collect::<Vec<String>>()
664                        .join(", ")
665                ),
666                trigger: Some(Trigger::PreForward),
667                policy: None,
668                path: None,
669            }],
670            exit_code: 2,
671            landed_oid: None,
672        });
673    }
674    let candidate_paths: Vec<Vec<u8>> = list_changed_index_paths(
675        context,
676        cwd,
677        workspace.landing.commit_index.as_path(),
678        base.as_str(),
679        workspace.object_directory.as_path(),
680    )?;
681    let mut private: ShippedChecks<PrivateIndexFacts<&mut F>> = private_checks(
682        checks,
683        workspace.landing.commit_index.as_path(),
684        workspace.object_directory.as_path(),
685        staged_at_base(base_object(&capture)?, candidate_paths.as_slice()),
686    );
687    let converged: Converged = converge(request, &mut private, context, cwd, &workspace, selection.read_only);
688    let code: i32 = pass_exit_code(converged.pass.events.as_slice(), converged.pass.end);
689    if code != 0 {
690        debug(TAG, "policies blocked the commit before preparation");
691        return Ok(TransactionResult {
692            events: converged.pass.events,
693            exit_code: code,
694            landed_oid: None,
695        });
696    }
697    return conclude_commit_transaction(ConclusionInput {
698        invocation,
699        workspace: &mut workspace,
700        capture: &capture,
701        selection: &selection,
702        request,
703        checks: &mut private,
704        pass: converged.pass,
705        changed_passes: converged.changed_passes,
706        changed_paths: converged.changed_paths,
707        committed_paths: candidate_paths,
708        pathspecs: pathspecs.as_slice(),
709    });
710}
711
712/// Controls for this module stay out of the release executable.
713#[cfg(test)]
714#[path = "commit_transaction_tests.rs"]
715mod tests;