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