Skip to main content

git_policy_cli/
commit_conclusion.rs

1//! What:
2//!  The conclusion of a settled commit transaction:
3//!  the normalization-only landing when the corrections removed every change, otherwise the hook dispatcher,
4//!  native preparation in the shadow, the prepared record, the landing loop and the post-landing completion.
5//! Why:
6//!  The incumbent's `commit-transaction-conclusion.ts` runs these steps in this order: the tree is written from the
7//!  stable private index, the dispatcher shim is shared by preparation and the post-landing `post-commit`, the
8//!  prepared record is durable before the preparation marker, and the transaction directory is removed only after
9//!  the worktree copies are installed.
10//!
11//! In TS you'd write (pseudocode):
12//! ```ts
13//! return await concludeCommitTransaction(settled);
14//! ```
15
16use super::capture_invocation::InvocationCapture;
17use super::commit_args::{resolve_private_commit_args, split_commit_invocation, without_all_flag};
18use super::commit_landing_loop::{LoopInput, LoopOutcome, land_with_replay};
19use super::commit_facts::PrivateIndexFacts;
20use super::commit_index::write_private_tree;
21use super::commit_no_change::{NoChangeInput, complete_no_change_transaction};
22use super::commit_preparation::{PREPARED_FILENAME, PreparationHooks, PreparationRequest, read_prepared_commit, run_native_commit};
23use super::commit_replay_options::{ReplayOptions, replay_options};
24use super::commit_transaction::{CommitInvocation, Selection, TransactionResult, TransactionStop, failed, write_journal_record};
25use super::commit_workspace::CommitWorkspace;
26use super::commit_worktree_records::{HookChanges, describe_hook_changes, selected_worktree_records};
27use super::diagnostic_log::debug;
28use super::global_arguments::global_layout;
29use super::hook_dispatch_entries::write_hook_dispatch;
30use super::lfs_pre_push::uses_lfs;
31use super::hook_dispatch_plan::{HookDispatchPlan, HookPlanInputs, SILENCED_HOOK_EVENTS, compute_hook_dispatch_plan, new_preparation_lease};
32use super::policy_checks::ShippedChecks;
33use super::policy_engine::StageRequest;
34use super::policy_events::PolicyEvent;
35use super::policy_pass::PassResult;
36use super::post_landing::{LandedTransaction, PostLandingRequest, complete_landed_transaction};
37use super::repository_facts::RepositoryFacts;
38use super::transaction_git::GitContext;
39use super::transaction_journal::{AddedPath, PreparedRecord};
40use super::transaction_journal_encode::encode_prepared;
41use super::transaction_phase::{Phase, reach_transaction_phase};
42use std::ffi::OsString;
43use std::path::Path;
44
45/// Module tag for diagnostics.
46const TAG: &str = "concludeCommitTransaction";
47
48/// What:
49///  Everything the conclusion reads from the settled transaction.
50/// Why:
51///   The steps share the workspace, the capture, the policies' settled pass and the selection facts.
52///
53/// In TS you'd write (pseudocode):
54/// ```ts
55/// type SettledCommitTransaction = { workspace; capture; mode; pass; changedPasses; changedPaths; ... };
56/// ```
57pub struct ConclusionInput<'a, F: RepositoryFacts> {
58    /// The invocation's fixed facts.
59    pub invocation: &'a CommitInvocation<'a>,
60    /// The published workspace.
61    pub workspace: &'a mut CommitWorkspace,
62    /// The invocation capture.
63    pub capture: &'a InvocationCapture,
64    /// The selection facts.
65    pub selection: &'a Selection,
66    /// The request the policies run under.
67    pub request: &'a StageRequest,
68    /// The shipped checks over the private index.
69    pub checks: &'a mut ShippedChecks<PrivateIndexFacts<F>>,
70    /// The settled pass.
71    pub pass: PassResult,
72    /// How many passes changed content.
73    pub changed_passes: u64,
74    /// The paths the corrections wrote.
75    pub changed_paths: Vec<Vec<u8>>,
76    /// The paths the private index changes against the base.
77    pub committed_paths: Vec<Vec<u8>>,
78    /// The positional pathspec tokens.
79    pub pathspecs: &'a [String],
80}
81
82/// What:
83///  The settled pass's events with the fix summary the corrections earned.
84/// Why:
85///   The summary appears only after some pass changed candidate bytes, naming each changed path once in byte order.
86///
87/// In TS you'd write (pseudocode):
88/// ```ts
89/// withChangedFixSummary({ result: pass, trigger: 'pre-forward', passes, changedPaths });
90/// ```
91pub fn events_with_changed_summary(events: Vec<PolicyEvent>, passes: u64, changed_paths: &[Vec<u8>]) -> Vec<PolicyEvent> {
92    if passes == 0 {
93        return events;
94    }
95    return events_with_summary(events, passes, changed_paths);
96}
97
98/// What:
99///  The events with a fix summary appended whatever the pass count.
100/// Why:
101///   A normalization that removed every change always reports its summary.
102///
103/// In TS you'd write (pseudocode):
104/// ```ts
105/// withFixSummary({ result, trigger: 'pre-forward', passes, changedPaths });
106/// ```
107pub fn events_with_summary(mut events: Vec<PolicyEvent>, passes: u64, changed_paths: &[Vec<u8>]) -> Vec<PolicyEvent> {
108    let mut sorted: Vec<Vec<u8>> = changed_paths.to_vec();
109    sorted.sort();
110    events.push(PolicyEvent::FixSummary {
111        trigger: super::policy_trigger::Trigger::PreForward,
112        passes,
113        changed_paths: sorted
114            .iter()
115            .map(|path| { return super::event_path::EventPath::from_git_bytes(path.as_slice()); })
116            .collect(),
117    });
118    return events;
119}
120
121/// What:
122///  The kept global options and the commit region of the private arguments.
123/// Why:
124///   Native `git commit` runs in the shadow, so the options that would redirect it away are dropped.
125///
126/// In TS you'd write (pseudocode):
127/// ```ts
128/// splitCommitInvocation({ args: privateArgs, subcommandIndex });
129/// ```
130fn private_invocation(pass: &PassResult, pathspecs: &[String], selection: &Selection) -> (Vec<String>, Vec<String>) {
131    let text: Vec<String> = pass.arguments.iter().map(|token| { return token.to_string_lossy().into_owned(); }).collect();
132    let private_args: Vec<String> =
133        resolve_private_commit_args(text.as_slice(), pathspecs, selection.mode, selection.read_only);
134    let owned: Vec<OsString> = private_args.iter().map(OsString::from).collect();
135    let index: usize = global_layout(owned.as_slice()).prefix_len;
136    return split_commit_invocation(private_args.as_slice(), index);
137}
138
139/// What:
140///  Complete a settled policy pass through a landed commit or a normalization-only reconciliation.
141/// Why:
142///   This is the incumbent's `concludeCommitTransaction`.
143///
144/// In TS you'd write (pseudocode):
145/// ```ts
146/// return await concludeCommitTransaction(settled);
147/// ```
148pub fn conclude_commit_transaction<F: RepositoryFacts>(
149    input: ConclusionInput<'_, F>,
150) -> Result<TransactionResult, TransactionStop> {
151    let ConclusionInput {
152        invocation,
153        workspace,
154        capture,
155        selection,
156        request,
157        checks,
158        pass,
159        changed_passes,
160        changed_paths,
161        committed_paths,
162        pathspecs,
163    } = input;
164    let context: &GitContext = invocation.context;
165    let cwd: &Path = invocation.cwd;
166    let repository_root: &Path = capture.layout.repository_root.as_path();
167    let timeout_ms: u64 = invocation.config.concurrency.index_lock.unproven_owner_timeout_ms;
168    let intended_tree: String = write_private_tree(
169        context,
170        cwd,
171        workspace.landing.commit_index.as_path(),
172        workspace.object_directory.as_path(),
173    )?;
174    let changes = checks.content.install_changes();
175    let selected: Vec<AddedPath> = selected_worktree_records(
176        context,
177        cwd,
178        repository_root,
179        workspace.landing.commit_index.as_path(),
180        changes.as_slice(),
181    )?;
182    let eligible: bool = !changes.is_empty()
183        && !invocation.region.allow_empty
184        && !invocation.region.amend
185        && !selection.concludes_sequencer;
186    if let Some(result) = complete_no_change_transaction(NoChangeInput {
187        invocation,
188        workspace,
189        capture,
190        mode: selection.mode,
191        eligible,
192        intended_tree: intended_tree.as_str(),
193        committed_paths: committed_paths.as_slice(),
194        selected_worktree_paths: selected.as_slice(),
195        events: &pass.events,
196        changed_passes,
197        changed_paths: changed_paths.as_slice(),
198        index_lock_timeout_ms: timeout_ms,
199    })? {
200        return Ok(result);
201    }
202    let (global_args, commit_args): (Vec<String>, Vec<String>) = private_invocation(&pass, pathspecs, selection);
203    let global_os: Vec<OsString> = global_args.iter().map(OsString::from).collect();
204    let lease: String = new_preparation_lease(workspace.landing.directory.as_path())
205        .map_err(|error| { return failed(format!("{error:?}")); })?;
206    let plan: HookDispatchPlan = compute_hook_dispatch_plan(&HookPlanInputs {
207        context,
208        cwd,
209        global_arguments: global_os.as_slice(),
210        common_directory: capture.layout.common_dir.as_path(),
211        worktree_root: repository_root,
212        lease: lease.as_str(),
213        concurrent_commits: invocation.config.concurrency.hooks.concurrent_commits,
214        inherited_lease_valid: invocation.inherited_lease_valid,
215        environment: invocation.environment,
216        snapshot: capture.layout.config_snapshot.as_ref(),
217    })
218    .map_err(|error| { return failed(format!("{error:?}")); })?;
219    write_hook_dispatch(workspace.hooks_directory.as_path(), &plan, invocation.executable)
220        .map_err(|error| { return failed(error.to_string()); })?;
221    let hooks: PreparationHooks = PreparationHooks {
222        directory: workspace.hooks_directory.clone(),
223        silenced_events: SILENCED_HOOK_EVENTS.iter().map(|event| { return String::from(*event); }).collect(),
224    };
225    let native_args: Vec<String> = if selection.staged_all {
226        without_all_flag(commit_args.as_slice())
227    } else {
228        commit_args.clone()
229    };
230    let preparation: PreparationRequest<'_> = PreparationRequest {
231        capture,
232        shadow: workspace.landing.shadow.as_path(),
233        hooks: &hooks,
234        commit_index: workspace.landing.commit_index.as_path(),
235        global_args: global_args.as_slice(),
236        commit_args: native_args.as_slice(),
237    };
238    let status: i32 = run_native_commit(context, cwd, &preparation).map_err(|error| { return TransactionStop::Message(error.to_string()); })?;
239    if status != 0 {
240        return Err(TransactionStop::NativeExit(status));
241    }
242    let prepared = read_prepared_commit(context, workspace.landing.shadow.as_path())?;
243    let hook_changes: HookChanges = describe_hook_changes(
244        context,
245        cwd,
246        workspace.object_directory.as_path(),
247        intended_tree.as_str(),
248        prepared.tree_oid.as_str(),
249    )?;
250    debug(
251        TAG,
252        format!("prepared {}; hooks changed {} paths", prepared.oid, hook_changes.paths.len()).as_str(),
253    );
254    let mut prepared_records: Vec<AddedPath> = selected.clone();
255    prepared_records.extend(hook_changes.worktree_records.iter().cloned());
256    write_journal_record(
257        workspace.landing.directory.as_path(),
258        PREPARED_FILENAME,
259        encode_prepared(&PreparedRecord {
260            shadow_path: workspace.landing.shadow.display().to_string(),
261            prepared_oid: prepared.oid.clone(),
262            signed: prepared.signed,
263            intended_tree_oid: intended_tree.clone(),
264            committed_paths: committed_paths.clone(),
265            added_paths: Vec::new(),
266            selected_worktree_paths: prepared_records.clone(),
267        })
268        .as_str(),
269    )?;
270    reach_transaction_phase(Phase::PreparationDone, None)?;
271    let settled_events: Vec<PolicyEvent> = events_with_changed_summary(pass.events.clone(), changed_passes, changed_paths.as_slice());
272    let options: ReplayOptions = replay_options(commit_args.as_slice());
273    let outcome: LoopOutcome = land_with_replay(LoopInput {
274        invocation,
275        workspace,
276        capture,
277        mode: selection.mode,
278        request,
279        checks,
280        prepared: &prepared,
281        settled_events: settled_events.clone(),
282        options: &options,
283        global_args: global_args.as_slice(),
284        read_only_selection: selection.read_only,
285        named_hooks_possible: plan.named_hooks_possible,
286        committed_paths: committed_paths.clone(),
287        added_paths: Vec::new(),
288        worktree_records: prepared_records.clone(),
289        hook_changes,
290        index_lock_timeout_ms: timeout_ms,
291        reserve_after_lost_races: invocation.config.concurrency.landing.reserve_after_lost_races,
292    })?;
293    let (oid, loop_events, final_worktree, final_added): (String, Vec<PolicyEvent>, Vec<AddedPath>, Vec<AddedPath>) =
294        match outcome {
295            LoopOutcome::Failed { events, exit_code } => {
296                return Ok(TransactionResult {
297                    events,
298                    exit_code,
299                    landed_oid: None,
300                    uses_lfs: false,
301                });
302            }
303            LoopOutcome::Landed {
304                oid,
305                events,
306                worktree_records,
307                added_paths,
308            } => (oid, events, worktree_records, added_paths),
309        };
310    // Before cleanup, so an interruption leaves the journal for startup recovery to finish the worktree copies.
311    let landed: LandedTransaction = LandedTransaction {
312        landed_oid: oid.clone(),
313        branch: capture.layout.target_ref.clone(),
314        cwd: cwd.to_path_buf(),
315        repository_root: repository_root.to_path_buf(),
316        directory: workspace.landing.directory.clone(),
317        shadow: workspace.landing.shadow.clone(),
318        git_dir: capture.layout.git_dir.clone(),
319        registry_root: capture.layout.registry_root.clone(),
320        real_index: capture.layout.real_index_path.clone(),
321        added_paths: final_added,
322        worktree_records: final_worktree,
323        config_snapshot: capture.layout.config_snapshot.clone(),
324    };
325    let completed = complete_landed_transaction(PostLandingRequest {
326        context,
327        plan: &plan,
328        landed: &landed,
329    });
330    if let Err(error) = completed {
331        return Err(TransactionStop::Message(error.to_string()));
332    }
333    // The completion removed the shadow and the directory, so the workspace has nothing left to dispose of; on a
334    // failure the landing's mark stays, and recovery finishes what the completion did not.
335    workspace.finish_transaction();
336    workspace.mark_disposed();
337    let mut events: Vec<PolicyEvent> = settled_events;
338    events.extend(loop_events);
339    return Ok(TransactionResult {
340        events,
341        exit_code: 0,
342        landed_oid: Some(oid),
343        uses_lfs: capture.layout.config_snapshot.as_ref().is_none_or(uses_lfs),
344    });
345}
346
347/// Controls for this module stay out of the release executable.
348#[cfg(test)]
349#[path = "commit_conclusion_tests.rs"]
350mod tests;