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