Skip to main content

git_policy_cli/
commit_landing_loop.rs

1//! What:
2//!  The landing loop: land, and after each lost race replay and revalidate outside both locks, until the commit
3//!  lands, its replay conflicts, revalidation rejects it, or the target cannot take it.
4//! Why:
5//!  Each replay rebuilds the prepared commit from the preparation base onto the target that won, so every replay
6//!  starts from the same prepared change. After `landing.reserveAfterLostRaces` lost races the transaction takes the
7//!  landing reservation before it replays, and every landing attempt yields to another transaction's live
8//!  reservation; the reservation is released when the loop ends (`src/policy-engine/commit-landing-loop.ts`).
9//!
10//! In TS you'd write (pseudocode):
11//! ```ts
12//! const outcome = await landWithReplay({ context, mode, prepared, settled, options, committedPaths, ... });
13//! ```
14
15use super::capture_invocation::InvocationCapture;
16use super::commit_facts::PrivateIndexFacts;
17use super::commit_landing_lock::{acquire_reserved_lock, landing_is_nested};
18use super::commit_preparation::PreparedCommit;
19use super::commit_replay_options::ReplayOptions;
20use super::commit_revalidation::{Revalidation, RevalidationContext, revalidate_replay};
21use super::commit_transaction::{CommitInvocation, TransactionStop, failed};
22use super::commit_workspace::CommitWorkspace;
23use super::commit_worktree_records::{HookChanges, settled_entry};
24use super::diagnostic_log::debug;
25use super::landing_attempt::{LandingOutcome, LandingPayload, LandingRequest, land_transaction};
26use super::landing_findings::{LANDING_FINDING_EXIT_CODE, landing_finding};
27use super::landing_objects::migrate_shadow_objects;
28use super::owner_lock::OwnerLock;
29use super::policy_checks::ShippedChecks;
30use super::policy_engine::StageRequest;
31use super::policy_events::PolicyEvent;
32use super::replay_commit::{MergeStep, ReplayError, ReplayRequest, merge_replay, rebuild_replay};
33use super::replay_merge::ReplayedCommit;
34use super::replay_reservation::{LandingReservation, ReservationError};
35use super::repository_facts::RepositoryFacts;
36use super::transaction_journal::{AddedPath, Base, TransactionMode};
37use super::transaction_phase::{Phase, reach_transaction_phase};
38use std::path::{Path, PathBuf};
39
40/// Module tag for diagnostics.
41const TAG: &str = "landWithReplay";
42
43/// What:
44///  What the next landing attempt lands.
45/// Why:
46///   The candidate is replaced after each replay and lives only inside the loop.
47///
48/// In TS you'd write (pseudocode):
49/// ```ts
50/// type LandingCandidate = { newOid; treeOid; expectedOld; indexPath; exactPrivateIndex; approvedTree; committedPaths; ... };
51/// ```
52struct Candidate {
53    /// The commit to land.
54    new_oid: String,
55    /// Its tree.
56    tree: String,
57    /// The target value its parent names.
58    expected_old: Base,
59    /// The private index holding its tree.
60    index: PathBuf,
61    /// Whether that index is the one native preparation committed.
62    exact_private_index: bool,
63    /// The tree `pre-commit` last approved.
64    approved_tree: String,
65    /// The paths an explicit-path landing resets.
66    committed_paths: Vec<String>,
67    /// The policy-added paths.
68    added_paths: Vec<AddedPath>,
69    /// The selected, corrected and hook-changed worktree completions.
70    worktree_records: Vec<AddedPath>,
71    /// Everything commit hooks staged so far.
72    hook_changes: HookChanges,
73    /// The events of lost races, replays and revalidations so far.
74    events: Vec<PolicyEvent>,
75    /// The landing attempt number.
76    attempt: i64,
77    /// The lost races so far.
78    lost_races: u64,
79}
80
81/// What:
82///  The inputs of the loop.
83/// Why:
84///   Replay and revalidation reuse the invocation's facts.
85///
86/// In TS you'd write (pseudocode):
87/// ```ts
88/// type LandingLoopInput = { context; mode; prepared; settled; options; committedPaths; addedPaths; worktreeRecords; hookChanges; indexLockTimeoutMs; reserveAfterLostRaces };
89/// ```
90pub struct LoopInput<'a, F: RepositoryFacts> {
91    /// The invocation's fixed facts.
92    pub invocation: &'a CommitInvocation<'a>,
93    /// The published workspace.
94    pub workspace: &'a mut CommitWorkspace,
95    /// The invocation capture.
96    pub capture: &'a InvocationCapture,
97    /// The commit selection mode.
98    pub mode: TransactionMode,
99    /// The request the policies run under.
100    pub request: &'a StageRequest,
101    /// The shipped checks over the private index.
102    pub checks: &'a mut ShippedChecks<PrivateIndexFacts<F>>,
103    /// The verified prepared commit.
104    pub prepared: &'a PreparedCommit,
105    /// The settled preparation pass's events with its fix summary.
106    pub settled_events: Vec<PolicyEvent>,
107    /// The replay-relevant commit options.
108    pub options: &'a ReplayOptions,
109    /// The caller's kept global options.
110    pub global_args: &'a [String],
111    /// Whether interactive or include selection forbids automatic patches.
112    pub read_only_selection: bool,
113    /// The paths an explicit-path commit carries.
114    pub committed_paths: Vec<String>,
115    /// The policy-added paths.
116    pub added_paths: Vec<AddedPath>,
117    /// The worktree completions.
118    pub worktree_records: Vec<AddedPath>,
119    /// What preparation hooks staged.
120    pub hook_changes: HookChanges,
121    /// The backoff budget for a foreign `index.lock`.
122    pub index_lock_timeout_ms: u64,
123    /// The lost landing races after which the transaction takes the landing reservation.
124    pub reserve_after_lost_races: u64,
125}
126
127/// What:
128///  How the loop ended.
129/// Why:
130///   A landed commit continues to post-landing completion; a failure carries the complete events and the status.
131///
132/// In TS you'd write (pseudocode):
133/// ```ts
134/// type LandingLoopOutcome = { kind: 'landed'; oid; events; worktreeRecords; addedPaths } | { kind: 'failed'; result };
135/// ```
136pub enum LoopOutcome {
137    /// The commit landed.
138    Landed {
139        /// The landed commit.
140        oid: String,
141        /// The events to append after the settled preparation pass.
142        events: Vec<PolicyEvent>,
143        /// The worktree completions for the landed commit.
144        worktree_records: Vec<AddedPath>,
145        /// The policy-added paths of the landed commit.
146        added_paths: Vec<AddedPath>,
147    },
148    /// Nothing landed.
149    Failed {
150        /// The complete blocking events.
151        events: Vec<PolicyEvent>,
152        /// The exit status.
153        exit_code: i32,
154    },
155}
156
157/// What:
158///  Points worktree completions at the blobs a replayed tree holds, dropping paths it no longer holds as ordinary files.
159/// Why:
160///   A completion names the blob the commit now carries for its path.
161///
162/// In TS you'd write (pseudocode):
163/// ```ts
164/// await retargetRecords({ context, indexPath, records });
165/// ```
166fn retarget_records(
167    input: &CommitInvocation<'_>,
168    index: &Path,
169    records: &[AddedPath],
170) -> Result<Vec<AddedPath>, TransactionStop> {
171    let mut retargeted: Vec<AddedPath> = Vec::new();
172    for record in records {
173        let Some((mode, oid)) = settled_entry(input.context, input.cwd, index, record.path.as_bytes())? else {
174            continue;
175        };
176        if mode != record.git_mode {
177            continue;
178        }
179        retargeted.push(AddedPath {
180            intended_oid: oid,
181            ..record.clone()
182        });
183    }
184    return Ok(retargeted);
185}
186
187/// What:
188///  The replay request of one lost race.
189/// Why:
190///   Every replay step reads the same facts, so they are built once.
191///
192/// In TS you'd write (pseudocode):
193/// ```ts
194/// { gitPath, shadowPath, objectDirectory, cwd, directory, replay, mergeBase, current, prepared }
195/// ```
196fn replay_request<'a, F: RepositoryFacts>(
197    input: &'a LoopInput<'a, F>,
198    replay: u64,
199    onto: &'a str,
200) -> ReplayRequest<'a> {
201    return ReplayRequest {
202        context: input.invocation.context,
203        shadow: input.workspace.landing.shadow.as_path(),
204        object_directory: input.workspace.object_directory.as_path(),
205        worktree_root: input.capture.layout.repository_root.as_path(),
206        git_dir: input.capture.layout.git_dir.as_path(),
207        transaction_directory: input.workspace.landing.directory.as_path(),
208        replay,
209        base: match &input.capture.base {
210            Base::Commit(oid) => Some(oid.as_str()),
211            Base::Unborn => None,
212        },
213        empty_tree: input.capture.layout.empty_tree_oid.as_str(),
214        current: onto,
215        prepared: input.prepared.oid.as_str(),
216        signing_key: input.options.signing_key.as_deref(),
217        global_arguments: input.global_args,
218    };
219}
220
221/// What:
222///  The failure a replay that broke maps to.
223/// Why:
224///   A rejected re-run `pre-commit` is Git's own status; any other replay failure becomes a transaction failure
225///   naming the target and the cause, with nothing landed.
226///
227/// In TS you'd write (pseudocode):
228/// ```ts
229/// transactionFailure({ previous: input.settled, message: `${targetRef} moved while this commit was prepared, and replaying it onto ${onto} failed; nothing landed: ${error}` })
230/// ```
231fn replay_failure(capture: &InvocationCapture, onto: &str, cause: String) -> LoopOutcome {
232    return LoopOutcome::Failed {
233        events: vec![PolicyEvent::EngineFailure {
234            code: super::diagnostics::EngineFailureCode::TransactionFailed,
235            message: format!(
236                "{} moved while this commit was prepared, and replaying it onto {onto} failed; nothing landed: {cause}",
237                capture.layout.target_ref
238            ),
239            trigger: Some(super::policy_trigger::Trigger::PreForward),
240            policy: None,
241            path: None,
242        }],
243        exit_code: 2,
244    };
245}
246
247/// What:
248///  The events of a failure after some attempts: the settled events, then what the loop gathered, then the last ones.
249/// Why:
250///   Every failure ends the same list of events.
251///
252/// In TS you'd write (pseudocode):
253/// ```ts
254/// appendEvents({ events: settled.events, appended: [...candidate.events, finding] })
255/// ```
256fn failed_events(settled: &[PolicyEvent], raced: &[PolicyEvent], last: Vec<PolicyEvent>) -> Vec<PolicyEvent> {
257    let mut events: Vec<PolicyEvent> = settled.to_vec();
258    events.extend(raced.iter().cloned());
259    events.extend(last);
260    return events;
261}
262
263/// What:
264///  Replay after a lost race: merge, conflict handling, revalidation, and the rebuilt commit.
265/// Why:
266///   The next candidate is built from the prepared commit again, with the lost race's events kept.
267///
268/// In TS you'd write (pseudocode):
269/// ```ts
270/// const next = await replayCandidate({ input, candidate, onto });
271/// ```
272fn replay_candidate<F: RepositoryFacts>(
273    input: &mut LoopInput<'_, F>,
274    candidate: &Candidate,
275    raced: Vec<PolicyEvent>,
276    onto: &str,
277) -> Result<Result<Candidate, LoopOutcome>, TransactionStop> {
278    let replay: u64 = lost_races_after(candidate.lost_races);
279    let merge: Result<MergeStep, ReplayError> = merge_replay(&replay_request(input, replay, onto));
280    let merged_tree: String = match merge {
281        Err(error) => return Ok(Err(replay_failure(input.capture, onto, replay_error_text(&error)))),
282        Ok(MergeStep::Conflict(conflict)) => {
283            // Unkept: the prepared commit survives the shadow's removal until gc expires unreachable objects.
284            migrate_shadow_objects(
285                input.invocation.context,
286                input.invocation.cwd,
287                input.workspace.landing.shadow.as_path(),
288                input.prepared.oid.as_str(),
289                &input.capture.base,
290                None,
291            )?;
292            let last: Vec<PolicyEvent> = vec![PolicyEvent::ReplayConflict {
293                target_ref: input.capture.layout.target_ref.clone(),
294                paths: conflict.paths,
295                winning_oid: conflict.winning_oid,
296                prepared_oid: conflict.prepared_oid,
297            }];
298            return Ok(Err(LoopOutcome::Failed {
299                events: failed_events(input.settled_events.as_slice(), raced.as_slice(), last),
300                exit_code: 1,
301            }));
302        }
303        Ok(MergeStep::Merged { tree, subsumed_paths, kept_landed_paths }) => {
304            debug(
305                TAG,
306                format!("replay onto {onto} keeps the prepared entries of {subsumed_paths:?} and the landed entries of {kept_landed_paths:?}").as_str(),
307            );
308            tree
309        }
310    };
311    let revalidation: Revalidation = {
312        let context: RevalidationContext<'_> = RevalidationContext {
313            invocation: input.invocation,
314            capture: input.capture,
315            read_only_selection: input.read_only_selection,
316            global_args: input.global_args,
317            no_verify: input.options.no_verify,
318        };
319        revalidate_replay(
320            &context,
321            input.workspace,
322            input.request,
323            input.checks,
324            replay,
325            merged_tree.as_str(),
326            onto,
327            candidate.approved_tree.as_str(),
328        )?
329    };
330    let (tree, index, revalidation_events, revalidated_paths, revalidated_hooks): (String, PathBuf, Vec<PolicyEvent>, Vec<String>, HookChanges) =
331        match revalidation {
332            Revalidation::Blocked { events, exit_code } => {
333                return Ok(Err(LoopOutcome::Failed {
334                    events: failed_events(input.settled_events.as_slice(), raced.as_slice(), events),
335                    exit_code,
336                }));
337            }
338            Revalidation::Revalidated {
339                tree,
340                index,
341                events,
342                committed_paths,
343                hook_changes,
344            } => (tree, index, events, committed_paths, hook_changes),
345        };
346    let rebuilt = match rebuild_replay(&replay_request(input, replay, onto), tree.as_str()) {
347        Ok(found) => found,
348        Err(error) => return Ok(Err(replay_failure(input.capture, onto, replay_error_text(&error)))),
349    };
350    let committed_paths: Vec<String> = union_paths(input.committed_paths.as_slice(), revalidated_paths);
351    let mut worktree_records: Vec<AddedPath> = retarget_records(input.invocation, index.as_path(), input.worktree_records.as_slice())?;
352    worktree_records.extend(revalidated_hooks.worktree_records.iter().cloned());
353    let mut events: Vec<PolicyEvent> = replayed_events(
354        raced,
355        input.prepared.oid.as_str(),
356        &input.capture.base,
357        onto,
358        &rebuilt,
359    );
360    events.extend(revalidation_events);
361    return Ok(Ok(Candidate {
362        new_oid: rebuilt.oid,
363        tree: tree.clone(),
364        expected_old: Base::Commit(String::from(onto)),
365        index: index.clone(),
366        exact_private_index: false,
367        approved_tree: tree,
368        committed_paths,
369        added_paths: retarget_records(input.invocation, index.as_path(), input.added_paths.as_slice())?,
370        worktree_records,
371        hook_changes: HookChanges::combine(&input.hook_changes, &revalidated_hooks),
372        events,
373        attempt: next_attempt_number(candidate.attempt),
374        lost_races: lost_races_after(candidate.lost_races),
375    }));
376}
377
378/// What:
379///  The paths of a replay: those already committed, then the ones revalidation added that are new.
380/// Why:
381///   A path both lists name is one path of the commit.
382///
383/// In TS you'd write (pseudocode):
384/// ```ts
385/// [...new Set([...committedPaths, ...revalidatedPaths])]
386/// ```
387pub fn union_paths(committed: &[String], revalidated: Vec<String>) -> Vec<String> {
388    let mut paths: Vec<String> = committed.to_vec();
389    for path in revalidated {
390        if !paths.contains(&path) {
391            paths.push(path);
392        }
393    }
394    return paths;
395}
396
397/// What:
398///  The events of one replay: the lost race, the replayed commit, and the dropped signature headers when there are some.
399/// Why:
400///   The incumbent prints them in this order, and the headers event only when the rebuild dropped a header.
401///
402/// In TS you'd write (pseudocode):
403/// ```ts
404/// [raceLost, commitReplayed, ...(dropped.length > 0 ? [replayHeadersDropped] : [])]
405/// ```
406pub fn replayed_events(
407    raced: Vec<PolicyEvent>,
408    prepared_oid: &str,
409    base: &Base,
410    onto: &str,
411    rebuilt: &ReplayedCommit,
412) -> Vec<PolicyEvent> {
413    let mut events: Vec<PolicyEvent> = raced;
414    events.push(PolicyEvent::CommitReplayed {
415        prepared_oid: String::from(prepared_oid),
416        from_base: match base {
417            Base::Commit(oid) => Some(oid.clone()),
418            Base::Unborn => None,
419        },
420        onto: String::from(onto),
421        oid: rebuilt.oid.clone(),
422    });
423    if !rebuilt.dropped_headers.is_empty() {
424        events.push(PolicyEvent::ReplayHeadersDropped {
425            prepared_oid: String::from(prepared_oid),
426            oid: rebuilt.oid.clone(),
427            headers: rebuilt.dropped_headers.clone(),
428        });
429    }
430    return events;
431}
432
433/// What:
434///  The landing attempt number that follows a lost race.
435/// Why:
436///   The attempt number names the attempt's landing files, so two attempts of one commit never share a name.
437///
438/// In TS you'd write (pseudocode):
439/// ```ts
440/// attempt + 1
441/// ```
442pub fn next_attempt_number(attempt: i64) -> i64 {
443    return attempt + 1;
444}
445
446/// What:
447///  The count of lost races once one more race is lost.
448/// Why:
449///   The reservation threshold and the `race-lost` marker occurrence both read this count.
450///
451/// In TS you'd write (pseudocode):
452/// ```ts
453/// lostRaces + 1
454/// ```
455pub fn lost_races_after(lost_races: u64) -> u64 {
456    return lost_races + 1;
457}
458
459/// What:
460///  The wording of a replay step failure.
461/// Why:
462///   The transaction failure names the cause after the target and the parent.
463///
464/// In TS you'd write (pseudocode):
465/// ```ts
466/// caughtValueText(error)
467/// ```
468fn replay_error_text(error: &ReplayError) -> String {
469    return match error {
470        ReplayError::Shared(inner) => format!("{inner:?}"),
471        ReplayError::Capture(inner) => format!("{inner:?}"),
472        ReplayError::Subsumption(inner) => format!("{inner:?}"),
473        ReplayError::Merge(inner) => format!("{inner:?}"),
474        ReplayError::Git(inner) => inner.0.clone(),
475        ReplayError::Object(text) => text.clone(),
476    };
477}
478
479/// What:
480///  Map a reservation failure to the transaction failure it is.
481/// Why:
482///   A reservation failure stops the landing loop with nothing landed.
483///
484/// In TS you'd write (pseudocode):
485/// ```ts
486/// throw error
487/// ```
488fn reservation_stop(error: ReservationError) -> TransactionStop {
489    return failed(format!("the landing reservation failed: {error:?}"));
490}
491
492/// What:
493///  Land a prepared commit, replaying and revalidating after every lost race.
494/// Why:
495///   The landing lock is taken for one attempt only and released before any replay or revalidation work, so a loser
496///   never blocks the others while it rebuilds its commit.
497///
498/// In TS you'd write (pseudocode):
499/// ```ts
500/// await landWithReplay({ ... });
501/// ```
502pub fn land_with_replay<F: RepositoryFacts>(mut input: LoopInput<'_, F>) -> Result<LoopOutcome, TransactionStop> {
503    let layout = &input.capture.layout;
504    let nested: bool = landing_is_nested(layout, input.invocation.environment);
505    let mut reservation: LandingReservation = LandingReservation::open(
506        layout.registry_root.as_path(),
507        input.workspace.landing.directory.as_path(),
508        input.workspace.landing.transaction_id.as_str(),
509        nested,
510    );
511    let result: Result<LoopOutcome, TransactionStop> = run_loop(&mut input, &mut reservation);
512    // Released when the loop ends, landed or failed.
513    if let Err(error) = reservation.release() {
514        debug(TAG, format!("releasing the landing reservation failed: {error:?}").as_str());
515    }
516    return result;
517}
518
519/// What:
520///  The loop proper, with the reservation owned by the caller.
521/// Why:
522///   The caller releases the reservation whatever the loop returns.
523///
524/// In TS you'd write (pseudocode):
525/// ```ts
526/// for (let candidate = ...;;) { ... }
527/// ```
528fn run_loop<F: RepositoryFacts>(
529    input: &mut LoopInput<'_, F>,
530    reservation: &mut LandingReservation,
531) -> Result<LoopOutcome, TransactionStop> {
532    let mut candidate: Candidate = Candidate {
533        new_oid: input.prepared.oid.clone(),
534        tree: input.prepared.tree_oid.clone(),
535        expected_old: input.capture.base.clone(),
536        index: input.workspace.landing.commit_index.clone(),
537        exact_private_index: true,
538        approved_tree: input.prepared.tree_oid.clone(),
539        committed_paths: input.committed_paths.clone(),
540        added_paths: input.added_paths.clone(),
541        worktree_records: input.worktree_records.clone(),
542        hook_changes: input.hook_changes.clone(),
543        events: Vec::new(),
544        attempt: 1,
545        lost_races: 0,
546    };
547    loop {
548        let payload: LandingPayload = LandingPayload::Commit {
549            new_oid: candidate.new_oid.clone(),
550            landed_tree_oid: candidate.tree.clone(),
551            expected_old: candidate.expected_old.clone(),
552            landed_index: candidate.index.clone(),
553            exact_private_index: candidate.exact_private_index,
554            guarded_paths: candidate.hook_changes.paths.clone(),
555        };
556        let request: LandingRequest<'_> = LandingRequest {
557            capture: input.capture,
558            mode: input.mode,
559            payload: &payload,
560            committed_paths: candidate.committed_paths.as_slice(),
561            added_paths: candidate.added_paths.as_slice(),
562            selected_worktree_paths: candidate.worktree_records.as_slice(),
563            index_lock_timeout_ms: input.index_lock_timeout_ms,
564            attempt: candidate.attempt,
565        };
566        let outcome: LandingOutcome = {
567            // The lock is released when this block ends, before any replay work.
568            let lock: OwnerLock = acquire_reserved_lock(
569                input.invocation.context,
570                input.invocation.cwd,
571                &input.capture.layout,
572                input.invocation.environment,
573                reservation,
574            )
575            .map_err(reservation_stop)?;
576            land_transaction(
577                input.invocation.context,
578                input.invocation.cwd,
579                &lock,
580                &mut input.workspace.landing,
581                &request,
582            )?
583        };
584        let onto: String = match &outcome {
585            LandingOutcome::Landed { oid } => {
586                return Ok(LoopOutcome::Landed {
587                    oid: oid.clone(),
588                    events: candidate.events,
589                    worktree_records: candidate.worktree_records,
590                    added_paths: candidate.added_paths,
591                });
592            }
593            LandingOutcome::HeadMoved {
594                current: Base::Commit(oid),
595                replayable: true,
596            } => oid.clone(),
597            _ => {
598                let mut last: Vec<PolicyEvent> = Vec::new();
599                if let Some(event) = landing_finding(&outcome, input.capture, Some(input.prepared.oid.as_str()), false) {
600                    last.push(event);
601                }
602                return Ok(LoopOutcome::Failed {
603                    events: failed_events(input.settled_events.as_slice(), candidate.events.as_slice(), last),
604                    exit_code: LANDING_FINDING_EXIT_CODE,
605                });
606            }
607        };
608        let lost_races: u64 = lost_races_after(candidate.lost_races);
609        debug(TAG, format!("lost landing race {lost_races} to {onto}").as_str());
610        let reserved: bool = lost_races >= input.reserve_after_lost_races
611            && reservation.reserve(lost_races).map_err(reservation_stop)?;
612        reach_transaction_phase(Phase::RaceLost, Some(lost_races))?;
613        let mut raced: Vec<PolicyEvent> = candidate.events.clone();
614        raced.push(PolicyEvent::LandingRaceLost {
615            attempt: lost_races,
616            winning_oid: onto.clone(),
617        });
618        if reserved {
619            raced.push(PolicyEvent::LandingReserved { lost_races });
620        }
621        match replay_candidate(input, &candidate, raced, onto.as_str())? {
622            Ok(next) => candidate = next,
623            Err(ended) => return Ok(ended),
624        }
625    }
626}
627
628/// Controls for this module stay out of the release executable.
629#[cfg(test)]
630#[path = "commit_landing_loop_tests.rs"]
631mod tests;