Skip to main content

git_policy_cli/
landing_attempt.rs

1//! What:
2//!  One landing attempt inside the landing critical section:
3//!  the real `index.lock`,
4//!  the branch-switch and head-moved checks,
5//!  object migration,
6//!  the post-index,
7//!  `landing-<n>.json`,
8//!  the compare-and-swap with the nonce reflog message,
9//!  `ref-updated.json`,
10//!  the index install,
11//!  the `index-installed` marker and conclusion cleanup.
12//! Why:
13//!  A landing is serial under the landing lock and the real `index.lock`, and it journals every state
14//!  so recovery can finish or discard a landing that died anywhere inside
15//!  (`src/policy-engine/commit-landing.ts`).
16//!  The caller takes the landing lock, checks the starvation reservation and replays a moved target;
17//!  the caller's post-landing step disposes of the transaction directory.
18//!  This module never runs hooks, the editor, signing, network operations or the hook lock.
19//!
20//! In TS you'd write (pseudocode):
21//! ```ts
22//! const outcome = await landTransaction({ gitPath, cwd, capture, workspace, mode, payload, committedPaths, addedPaths, selectedWorktreePaths, indexLockTimeoutMs, attempt });
23//! ```
24
25/// Recording the capture of a landed commit.
26use super::capture_records::record_landed_capture_or_warn;
27/// The invocation facts, and the baseline revision the post-index reads.
28use super::capture_invocation::{InvocationCapture, base_revision, resolve_ref_commit, resolve_symbolic_head};
29/// Where the worktree's `HEAD` file and branch files live, for the fresh read under the landing lock.
30use super::ref_files::RefFilesLayout;
31/// Native conclusion cleanup after the index is installed.
32use super::conclusion_cleanup::reproduce_conclusion_cleanup;
33/// The index copy that keeps the timestamps Git reads.
34use super::commit_index::copy_index_with_timestamps;
35/// The digest of an index snapshot.
36use super::content_digest::{ContentDigest, digest_file};
37/// Debug and warning text to the diagnostic log.
38use super::diagnostic_log::{debug, warn};
39/// Failure of waiting for or creating the real index lock.
40use super::index_writer_coordination::CoordinationFailure;
41/// The post-index computation and its inputs.
42use super::landing_index::{PostIndexInput, compute_landing_post_index};
43/// Object migration into a kept pack.
44use super::landing_objects::migrate_shadow_objects;
45/// Target comparison and the compare-and-swap.
46use super::landing_support::{CompareAndSwap, compare_and_swap_target, same_target};
47/// The keep message and its removal.
48use super::pack_keep::{remove_pack_keep, transaction_keep_message};
49/// The held landing lock, which the caller must prove it holds.
50use super::owner_lock::OwnerLock;
51/// Journal files are written durably.
52use super::journal_record::{RecordSync, write_record};
53/// Recovery failures that the shared steps report.
54use super::recovery_error::RecoveryError;
55/// File identities of the landing artifacts.
56use super::recovery_files::file_identity_of;
57/// The real index lock, taken for the attempt.
58use super::real_index_lock::{RealIndexLock, acquire_landing_index_lock};
59/// Running real Git.
60use super::transaction_git::{GitContext, GitFailure};
61/// Journal records, names and filenames.
62use super::transaction_journal::{
63    AddedPath, Base, Conclusion, FileIdentity, INDEX_INSTALLED_FILENAME, LandingOperation, LandingRecord,
64    REF_UPDATED_FILENAME, SymbolicHead, TransactionMode, landing_record_filename, post_index_filename,
65    pre_landing_index_filename,
66};
67/// Record encoders.
68use super::transaction_journal_encode::{encode_landing, encode_ref_updated};
69/// The test-only phase markers.
70use super::transaction_phase::{Phase, PhaseMarkerError, reach_transaction_phase};
71/// `Path` and `PathBuf` are borrowed and owned filesystem paths.
72use std::path::{Path, PathBuf};
73
74/// Module tag for diagnostics.
75const TAG: &str = "landTransaction";
76
77/// What:
78///  What one landing installs.
79/// Why:
80///   A prepared commit advances the target;
81///  a normalization installs only the settled index.
82///
83/// In TS you'd write (pseudocode):
84/// ```ts
85/// type LandingPayload = { operation: 'commit'; newOid; landedTreeOid; expectedOld; landedIndexPath; exactPrivateIndex; guardedPaths } | { operation: 'normalize-only'; landedTreeOid };
86/// ```
87#[derive(Clone, Debug, Eq, PartialEq)]
88pub enum LandingPayload {
89    /// A prepared commit.
90    Commit {
91        /// Prepared commit.
92        new_oid: String,
93        /// Prepared or replayed tree.
94        landed_tree_oid: String,
95        /// Target value the commit's parent names:
96        ///  the preparation base,
97        ///  or the replay parent.
98        expected_old: Base,
99        /// Private index the commit's tree came from.
100        landed_index: PathBuf,
101        /// Whether `landed_index` is the exact index native preparation committed,
102        ///  false after a replay.
103        exact_private_index: bool,
104        /// Paths a commit hook changed,
105        ///  reconciled only while their real index entry is unchanged since invocation.
106        guarded_paths: Vec<Vec<u8>>,
107    },
108    /// A normalization whose settled tree equals the base.
109    NormalizeOnly {
110        /// Base tree the index settles to.
111        landed_tree_oid: String,
112    },
113}
114
115/// What:
116///  The result of one landing attempt that ran to a decision.
117/// Why:
118///   The caller replays a moved target, reports a switched branch, and completes a landing.
119///
120/// In TS you'd write (pseudocode):
121/// ```ts
122/// type LandingOutcome = { kind: 'landed'; oid } | { kind: 'head-moved'; current; replayable } | { kind: 'branch-switched'; current };
123/// ```
124#[derive(Clone, Debug, Eq, PartialEq)]
125pub enum LandingOutcome {
126    /// The target advanced,
127    ///  or a normalization installed its index.
128    Landed {
129        /// Landed commit;
130        ///  the base for a normalization,
131        ///  empty when that base is unborn.
132        oid: String,
133    },
134    /// The target moved since preparation;
135    ///  nothing landed.
136    HeadMoved {
137        /// Target value that won.
138        current: Base,
139        /// Whether replay can land this commit on the new target;
140        ///  amends,
141        ///  conclusions,
142        ///  normalizations and a deleted target never replay.
143        replayable: bool,
144    },
145    /// `HEAD` names another branch,
146    ///  or is detached,
147    ///  unlike at invocation;
148    ///  nothing landed.
149    BranchSwitched {
150        /// Current symbolic `HEAD` target.
151        current: SymbolicHead,
152    },
153}
154
155/// What:
156///  Why a landing attempt stopped without a decision.
157/// Why:
158///   Each variant names the step that failed,
159///  and the workspace's `preserved` flag says whether recovery now owns the transaction directory.
160///
161/// In TS you'd write (pseudocode):
162/// ```ts
163/// type LandingFailure = IndexLockUnprovenOwnerError | SubprocessError | Error;
164/// ```
165#[derive(Debug)]
166pub enum LandingError {
167    /// The real index lock could not be taken.
168    Lock(CoordinationFailure),
169    /// A Git step failed.
170    Git(GitFailure),
171    /// A shared recovery step failed:
172    ///  pack keep removal,
173    ///  index install or conclusion cleanup.
174    Recovery(RecoveryError),
175    /// A journal record or marker could not be written.
176    Journal(String),
177    /// The test-only phase marker variable is malformed or its marker could not be written.
178    Phase(PhaseMarkerError),
179    /// The landing decided but could not release the real index lock.
180    Release(String),
181    /// The branch already advanced, and a step that completes the landing failed afterwards:
182    ///  the commit exists and must not be committed again.
183    LandedThenFailed {
184        /// The commit that landed.
185        oid: String,
186        /// The reference it landed on.
187        target: String,
188        /// The step that failed afterwards.
189        cause: Box<LandingError>,
190    },
191}
192
193/// The wording of the failure.
194impl std::fmt::Display for LandingError {
195    /// Writes the wording of the failing step.
196    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
197        return match self {
198            Self::Lock(failure) => write!(formatter, "{failure:?}"),
199            Self::Git(failure) => write!(formatter, "{failure}"),
200            Self::Recovery(failure) => write!(formatter, "{}", failure.0),
201            Self::Journal(text) | Self::Release(text) => formatter.write_str(text.as_str()),
202            Self::Phase(failure) => write!(formatter, "{failure}"),
203            Self::LandedThenFailed { oid, target, cause } => write!(
204                formatter,
205                "the commit {oid} landed on {target}, but completing its records failed: {cause}. \
206The commit is done, so do not retry it; check it with git log and git status."
207            ),
208        };
209    }
210}
211
212/// What:
213///  The transaction state a landing reads and marks.
214/// Why:
215///   `CommitTransactionWorkspace`:
216///  the landing needs the directory and its private indexes,
217///  and tells the caller whether recovery now owns the directory.
218///
219/// In TS you'd write (pseudocode):
220/// ```ts
221/// type CommitTransactionWorkspace = { transactionId; directory; commitIndexPath; capturedIndexPath; shadowPath; preserveForRecovery(); finishTransaction() };
222/// ```
223#[derive(Clone, Debug, Eq, PartialEq)]
224pub struct LandingWorkspace {
225    /// Transaction ID,
226    ///  also the reflog nonce.
227    pub transaction_id: String,
228    /// Durable transaction directory.
229    pub directory: PathBuf,
230    /// Shadow repository holding the prepared commit.
231    pub shadow: PathBuf,
232    /// Exact real index snapshot taken at invocation.
233    pub captured_index: PathBuf,
234    /// Private commit index.
235    pub commit_index: PathBuf,
236    /// Whether a landing record hands the directory to recovery,
237    ///  so the caller's disposal must leave it in place.
238    ///  Set before `landing-<n>.json` is written and cleared again when the compare-and-swap is lost.
239    pub preserved: bool,
240}
241
242/// What:
243///  The facts of one attempt beside the workspace.
244/// Why:
245///   They stay fixed across attempts of one commit, except the payload and the attempt number.
246///
247/// In TS you'd write (pseudocode):
248/// ```ts
249/// { capture, mode, payload, committedPaths, addedPaths, selectedWorktreePaths, indexLockTimeoutMs, attempt }
250/// ```
251#[derive(Clone, Copy, Debug)]
252pub struct LandingRequest<'request> {
253    /// Invocation capture naming the target and the real index.
254    pub capture: &'request InvocationCapture,
255    /// Commit selection mode.
256    pub mode: TransactionMode,
257    /// The commit or normalization to land.
258    pub payload: &'request LandingPayload,
259    /// Paths an explicit-path commit carries.
260    pub committed_paths: &'request [Vec<u8>],
261    /// Policy-added path records.
262    pub added_paths: &'request [AddedPath],
263    /// Selected newline correction records.
264    pub selected_worktree_paths: &'request [AddedPath],
265    /// Backoff budget for a foreign `index.lock`.
266    pub index_lock_timeout_ms: u64,
267    /// Landing attempt number,
268    ///  from 1.
269    pub attempt: i64,
270}
271
272/// What:
273///  Write one journal record exclusively and whole,
274///  then sync as `sync` says.
275/// Why:
276///   `writeJournalRecord`:
277///  a state file is never rewritten,
278///  so a crash leaves the newest complete state readable;
279///  the level says what a power loss may leave of this record.
280///
281/// In TS you'd write (pseudocode):
282/// ```ts
283/// await writeJournalRecord({ directory, filename, record, sync });
284/// ```
285fn write_journal(directory: &Path, filename: &str, bytes: &[u8], sync: RecordSync) -> Result<(), LandingError> {
286    return write_record(directory, filename, bytes, sync).map_err(LandingError::Journal);
287}
288
289/// What:
290///  Reach one test-only phase marker.
291/// Why:
292///   A malformed marker variable fails the landing at every phase it reaches.
293///
294/// In TS you'd write (pseudocode):
295/// ```ts
296/// await reachTransactionPhase({ phase });
297/// ```
298fn reach(phase: Phase) -> Result<(), LandingError> {
299    return reach_transaction_phase(phase, None).map_err(LandingError::Phase);
300}
301
302/// What:
303///  The device and inode of a landing artifact.
304/// Why:
305///   `fileIdentity`:
306///  recovery proves an artifact is the exact file written.
307///
308/// In TS you'd write (pseudocode):
309/// ```ts
310/// await fileIdentity(path)
311/// ```
312fn artifact_identity(path: &Path) -> Result<FileIdentity, LandingError> {
313    return match file_identity_of(path) {
314        Ok((device, inode, _)) => Ok(FileIdentity { device, inode }),
315        Err(error) => Err(LandingError::Journal(format!("reading the identity of {} failed: {error}", path.display()))),
316    };
317}
318
319/// What:
320///  The length and hash of a landing artifact as it is on disk now.
321/// Why:
322///   The index snapshots are not synced (a sync of each costs about 2 ms), so the landing record carries their
323///  digests and recovery refuses to install a snapshot that differs from them.
324///
325/// In TS you'd write (pseudocode):
326/// ```ts
327/// await digestFile(path)
328/// ```
329fn artifact_digest(path: &Path) -> Result<ContentDigest, LandingError> {
330    return digest_file(path)
331        .map_err(|error| { return LandingError::Journal(format!("reading {} to digest it failed: {error}", path.display())); });
332}
333
334/// What:
335///  Whether a target value names a commit.
336/// Why:
337///   Only a target that still names a commit can be replayed onto.
338///
339/// In TS you'd write (pseudocode):
340/// ```ts
341/// current.kind === 'commit'
342/// ```
343fn names_commit(target: &Base) -> bool {
344    return matches!(target, Base::Commit(_));
345}
346
347/// What:
348///  The real index lock while a landing holds it.
349/// Why:
350///   The lock moves out when the install consumes it,
351///  and a lock that recovery needs for the install stays in place when the attempt fails.
352///
353/// In TS you'd write (pseudocode):
354/// ```ts
355/// await using indexLock = await acquireRealIndexLock({ realIndexPath, transactionDirectory, attempt, timeoutMs });
356/// ```
357struct Held {
358    /// The lock,
359    ///  until the install consumes it.
360    lock: Option<RealIndexLock>,
361    /// Whether the landing reached the point after which only the install is left,
362    ///  so recovery installs through this lock if the attempt dies or fails.
363    for_recovery: bool,
364}
365
366/// What:
367///  Runs one landing attempt.
368/// Why:
369///   `landTransaction`:
370///  the caller already holds the landing lock (`_landing_lock` is the witness) and has dealt with the starvation reservation.
371///  The real `index.lock` is released on every exit except an install,
372///  and except a failure after the target advanced (or, for a normalization, just before the install),
373///  because recovery completes the install through the same lock and fails closed without it.
374///  A target that moved ends the attempt with `HeadMoved`;
375///  when `replayable`,
376///  the caller replays and revalidates outside both locks and lands again.
377///
378/// In TS you'd write (pseudocode):
379/// ```ts
380/// await landTransaction({ gitPath, cwd, capture, workspace, mode, payload, committedPaths, addedPaths, selectedWorktreePaths, indexLockTimeoutMs, attempt });
381/// ```
382pub fn land_transaction(
383    context: &GitContext,
384    cwd: &Path,
385    _landing_lock: &OwnerLock,
386    workspace: &mut LandingWorkspace,
387    request: &LandingRequest<'_>,
388) -> Result<LandingOutcome, LandingError> {
389    debug(TAG, format!("landing attempt {} of transaction {}", request.attempt, workspace.transaction_id).as_str());
390    let acquired: RealIndexLock = acquire_landing_index_lock(
391        request.capture.layout.real_index_path.as_path(),
392        workspace.directory.as_path(),
393        request.attempt,
394        request.index_lock_timeout_ms,
395        "landed nothing",
396    )
397    .map_err(LandingError::Lock)?;
398    let mut held: Held = Held { lock: Some(acquired), for_recovery: false };
399    let result: Result<LandingOutcome, LandingError> = land_locked(context, cwd, workspace, request, &mut held);
400    let Some(lock) = held.lock.take() else {
401        return result;
402    };
403    if held.for_recovery {
404        debug(TAG, "the target advanced before the install failed; the index lock is left for recovery");
405        return result;
406    }
407    match (lock.release_after_landing(), result) {
408        (Ok(()), outcome) => return outcome,
409        (Err(release), Ok(_)) => return Err(LandingError::Release(release)),
410        (Err(release), Err(error)) => {
411            warn(TAG, format!("the index lock could not be released after a failed landing: {release}").as_str());
412            return Err(error);
413        }
414    }
415}
416
417/// What:
418///  The landing steps that run while this process holds the real index lock.
419/// Why:
420///   The lock moves out of `held` only when the install consumes it,
421///  so the caller releases whatever is still held.
422///
423/// In TS you'd write (pseudocode):
424/// ```ts
425/// // the body of landTransaction after `await using indexLock`
426/// ```
427fn land_locked(
428    context: &GitContext,
429    cwd: &Path,
430    workspace: &mut LandingWorkspace,
431    request: &LandingRequest<'_>,
432    held: &mut Held,
433) -> Result<LandingOutcome, LandingError> {
434    reach(Phase::LandingLocked)?;
435    let capture: &InvocationCapture = request.capture;
436    // A fresh read of the worktree's `HEAD` under the landing lock, never the recorded one.
437    let ref_files: RefFilesLayout<'_> = RefFilesLayout {
438        git_dir: capture.layout.git_dir.as_path(),
439        common_dir: capture.layout.common_dir.as_path(),
440        ref_format: capture.layout.ref_format,
441        object_format: capture.layout.object_format.as_str(),
442    };
443    let symbolic_head: SymbolicHead = resolve_symbolic_head(context, cwd, &ref_files).map_err(LandingError::Git)?;
444    if symbolic_head != capture.layout.symbolic_head {
445        debug(TAG, "HEAD names another target than at invocation");
446        return Ok(LandingOutcome::BranchSwitched { current: symbolic_head });
447    }
448    let expected_old: Base = match request.payload {
449        LandingPayload::Commit { expected_old, .. } => expected_old.clone(),
450        LandingPayload::NormalizeOnly { .. } => capture.base.clone(),
451    };
452    let current: Base = resolve_ref_commit(context, cwd, capture.layout.target_ref.as_str()).map_err(LandingError::Git)?;
453    if !same_target(&current, &expected_old) {
454        debug(TAG, format!("{} moved since preparation", capture.layout.target_ref).as_str());
455        let replayable: bool = matches!(request.payload, LandingPayload::Commit { .. })
456            && capture.layout.conclusion == Conclusion::None
457            && names_commit(&current);
458        return Ok(LandingOutcome::HeadMoved { current, replayable });
459    }
460    let pack_name: Option<String> = match request.payload {
461        LandingPayload::Commit { new_oid, .. } => Some(
462            migrate_shadow_objects(
463                context,
464                cwd,
465                workspace.shadow.as_path(),
466                new_oid.as_str(),
467                &expected_old,
468                Some(transaction_keep_message(workspace.transaction_id.as_str()).as_str()),
469            )
470            .map_err(LandingError::Git)?,
471        ),
472        LandingPayload::NormalizeOnly { .. } => None,
473    };
474    let post: PathBuf = match record_landing(context, cwd, workspace, request, held, &expected_old, pack_name.as_ref()) {
475        Ok(post) => post,
476        Err(error) => {
477            discard_unrecorded_keep(capture.layout.object_directory.as_path(), workspace, pack_name.as_deref());
478            return Err(error);
479        }
480    };
481    if let LandingPayload::Commit { new_oid, .. } = request.payload {
482        let pack: &str = pack_name.as_deref().unwrap_or("");
483        if let Some(lost) = advance_target(context, cwd, workspace, request, held, new_oid.as_str(), &expected_old, pack)? {
484            return Ok(lost);
485        }
486    }
487    if let Err(error) = complete_landing(context, cwd, workspace, request, held, post.as_path()) {
488        // Once the branch advanced, a later failure must say so, or a consumer would commit the same work again.
489        return Err(match request.payload {
490            LandingPayload::Commit { new_oid, .. } => LandingError::LandedThenFailed {
491                oid: new_oid.clone(),
492                target: capture.layout.target_ref.clone(),
493                cause: Box::new(error),
494            },
495            LandingPayload::NormalizeOnly { .. } => error,
496        });
497    }
498    debug(TAG, format!("landed attempt {}", request.attempt).as_str());
499    let oid: String = match request.payload {
500        LandingPayload::Commit { new_oid, .. } => new_oid.clone(),
501        LandingPayload::NormalizeOnly { .. } => match &capture.base {
502            Base::Commit(oid) => oid.clone(),
503            Base::Unborn => String::new(),
504        },
505    };
506    return Ok(LandingOutcome::Landed { oid });
507}
508
509/// What:
510///  Install the post-index and write the records that complete a landing whose record is durable.
511/// Why:
512///   Everything here follows the branch advance, so the caller words a failure of any step as one that happened
513///  after the commit landed.
514///
515/// In TS you'd write (pseudocode):
516/// ```ts
517/// // the tail of landTransaction after the ref update
518/// ```
519fn complete_landing(
520    context: &GitContext,
521    cwd: &Path,
522    workspace: &LandingWorkspace,
523    request: &LandingRequest<'_>,
524    held: &mut Held,
525    post: &Path,
526) -> Result<(), LandingError> {
527    // A normalization has only the install left once its record is durable.
528    held.for_recovery = true;
529    let Some(installing) = held.lock.take() else {
530        return Err(LandingError::Journal(String::from("the real index lock is not held")));
531    };
532    installing.install(post).map_err(LandingError::Recovery)?;
533    // An empty, halved or lost marker completes in the tearing table (`doc/planning/cli-git-spawn-and-sync-reduction.md`).
534    write_journal(workspace.directory.as_path(), INDEX_INSTALLED_FILENAME, b"", RecordSync::Nothing)?;
535    reach(Phase::IndexInstalled)?;
536    if matches!(request.payload, LandingPayload::Commit { .. }) {
537        reproduce_conclusion_cleanup(
538            context,
539            cwd,
540            request.capture.layout.git_dir.as_path(),
541            workspace.shadow.as_path(),
542            workspace.directory.as_path(),
543            request.capture.layout.ref_format,
544        )
545        .map_err(LandingError::Recovery)?;
546    }
547    return Ok(());
548}
549
550/// What:
551///  Computes the post-index against the real index as it is now.
552/// Why:
553///   A normalization lands the commit index as an exact private index with no guarded paths,
554///  as the incumbent's defaults do.
555///
556/// In TS you'd write (pseudocode):
557/// ```ts
558/// await computeLandingPostIndex({ gitPath, cwd, mode, preLandingIndexPath, capturedIndexPath, postIndexPath, landedTreeOid, baseRevision, committedPaths, landedIndexPath });
559/// ```
560fn compute_post_index(
561    context: &GitContext,
562    cwd: &Path,
563    workspace: &LandingWorkspace,
564    request: &LandingRequest<'_>,
565    landed_tree: &str,
566    pre_landing: &Path,
567    post: &Path,
568) -> Result<(), LandingError> {
569    let base: String = base_revision(request.capture);
570    let (landed_index, exact_private_index, guarded_paths): (&Path, bool, &[Vec<u8>]) = match request.payload {
571        LandingPayload::Commit { landed_index, exact_private_index, guarded_paths, .. } => {
572            (landed_index.as_path(), *exact_private_index, guarded_paths.as_slice())
573        }
574        LandingPayload::NormalizeOnly { .. } => (workspace.commit_index.as_path(), true, &[]),
575    };
576    let input: PostIndexInput<'_> = PostIndexInput {
577        mode: request.mode,
578        pre_landing_index: pre_landing,
579        captured_index: workspace.captured_index.as_path(),
580        landed_index,
581        post_index: post,
582        landed_tree,
583        base_revision: base.as_str(),
584        committed_paths: request.committed_paths,
585        guarded_paths,
586        exact_private_index,
587    };
588    return compute_landing_post_index(context, cwd, &input).map_err(LandingError::Git);
589}
590
591/// What:
592///  The steps between the objects' migration and the landing record: the pre-landing index copy,
593///  the post-index, and the record itself.
594///  Returns the post-index path.
595/// Why:
596///   Until the record is written nothing but this process knows the pack's keep,
597///  so the caller removes the keep when any of these steps fails;
598///  after `preserved` is set, recovery owns the directory and removes the keep by its message.
599///
600/// In TS you'd write (pseudocode):
601/// ```ts
602/// const post = await recordLanding({ gitPath, cwd, workspace, request, indexLock, expectedOld, packName });
603/// ```
604fn record_landing(
605    context: &GitContext,
606    cwd: &Path,
607    workspace: &mut LandingWorkspace,
608    request: &LandingRequest<'_>,
609    held: &Held,
610    expected_old: &Base,
611    pack_name: Option<&String>,
612) -> Result<PathBuf, LandingError> {
613    reach(Phase::ObjectsMigrated)?;
614    let capture: &InvocationCapture = request.capture;
615    let landed_tree: &str = match request.payload {
616        LandingPayload::Commit { landed_tree_oid, .. } | LandingPayload::NormalizeOnly { landed_tree_oid } => landed_tree_oid.as_str(),
617    };
618    let pre_landing: PathBuf = workspace.directory.join(pre_landing_index_filename(request.attempt));
619    let post: PathBuf = workspace.directory.join(post_index_filename(request.attempt));
620    if let Err(error) = copy_index_with_timestamps(capture.layout.real_index_path.as_path(), pre_landing.as_path()) {
621        return Err(LandingError::Journal(format!("copying the real index to {} failed: {error}", pre_landing.display())));
622    }
623    compute_post_index(context, cwd, workspace, request, landed_tree, pre_landing.as_path(), post.as_path())?;
624    // The record below hands the directory to recovery, so the mark comes first, as the incumbent's does.
625    workspace.preserved = true;
626    let lock_identity = match held.lock.as_ref() {
627        Some(lock) => lock.identity().clone(),
628        None => return Err(LandingError::Journal(String::from("the real index lock is not held"))),
629    };
630    let record: LandingRecord = LandingRecord {
631        attempt: request.attempt,
632        operation: match request.payload {
633            LandingPayload::Commit { .. } => LandingOperation::Commit,
634            LandingPayload::NormalizeOnly { .. } => LandingOperation::NormalizeOnly,
635        },
636        expected_old: expected_old.clone(),
637        new_oid: match request.payload {
638            LandingPayload::Commit { new_oid, .. } => Some(new_oid.clone()),
639            LandingPayload::NormalizeOnly { .. } => None,
640        },
641        landed_tree_oid: landed_tree.to_string(),
642        pre_landing_index: artifact_identity(pre_landing.as_path())?,
643        post_index: artifact_identity(post.as_path())?,
644        lock: lock_identity,
645        pack_name: pack_name.cloned(),
646        added_paths: request.added_paths.to_vec(),
647        selected_worktree_paths: request.selected_worktree_paths.to_vec(),
648        pre_landing_digest: Some(artifact_digest(pre_landing.as_path())?),
649        post_digest: Some(artifact_digest(post.as_path())?),
650    };
651    write_journal(
652        workspace.directory.as_path(),
653        landing_record_filename(request.attempt).as_str(),
654        encode_landing(&record).as_bytes(),
655        // The barrier before the compare-and-swap: the record and its directory entry reach the disk first.
656        RecordSync::FileAndDirectory,
657    )?;
658    return Ok(post);
659}
660
661/// What:
662///  Removes the pack keep of a landing that failed before its record handed the directory to recovery.
663/// Why:
664///   The original failure is what the caller reports,
665///  so a keep that cannot be removed is a warning that names it; recovery's keep sweep by message still finds it.
666///
667/// In TS you'd write (pseudocode):
668/// ```ts
669/// if (!workspace.preserved && packName) await removePackKeep({ objectDirectory, packName }).catch(warn);
670/// ```
671fn discard_unrecorded_keep(objects: &Path, workspace: &LandingWorkspace, pack_name: Option<&str>) {
672    if workspace.preserved {
673        return;
674    }
675    let Some(pack) = pack_name else {
676        return;
677    };
678    if let Err(error) = remove_pack_keep(objects, pack) {
679        warn(TAG, format!("the pack keep of a landing that failed before its record could not be removed: {error}").as_str());
680    }
681}
682
683/// What:
684///  Advances the target by compare-and-swap and journals the result.
685///  Returns the outcome when the swap was lost.
686/// Why:
687///   A lost swap removes the pack keep and releases the directory from recovery,
688///  then reports who won;
689///  a won swap writes `ref-updated.json`,
690///  removes the keep,
691///  and records the landed capture.
692///
693/// In TS you'd write (pseudocode):
694/// ```ts
695/// const swap = await compareAndSwapTarget({ gitPath, cwd, capture, transactionId, newOid, expectedOld });
696/// ```
697fn advance_target(
698    context: &GitContext,
699    cwd: &Path,
700    workspace: &mut LandingWorkspace,
701    request: &LandingRequest<'_>,
702    held: &mut Held,
703    new_oid: &str,
704    expected_old: &Base,
705    pack_name: &str,
706) -> Result<Option<LandingOutcome>, LandingError> {
707    let capture: &InvocationCapture = request.capture;
708    let swap: CompareAndSwap<'_> = CompareAndSwap {
709        target_ref: capture.layout.target_ref.as_str(),
710        detached: capture.layout.symbolic_head == SymbolicHead::Detached,
711        nonce: workspace.transaction_id.as_str(),
712        new_oid,
713        expected_old,
714        hash_length: capture.layout.empty_tree_oid.len(),
715    };
716    let output = compare_and_swap_target(context, cwd, &swap).map_err(LandingError::Git)?;
717    let objects: &Path = capture.layout.object_directory.as_path();
718    if !output.succeeded() {
719        debug(TAG, format!("compare-and-swap lost: {}", String::from_utf8_lossy(&output.stderr).trim()).as_str());
720        remove_pack_keep(objects, pack_name).map_err(LandingError::Recovery)?;
721        workspace.preserved = false;
722        let winner: Base = resolve_ref_commit(context, cwd, capture.layout.target_ref.as_str()).map_err(LandingError::Git)?;
723        let replayable: bool = capture.layout.conclusion == Conclusion::None && names_commit(&winner);
724        return Ok(Some(LandingOutcome::HeadMoved { current: winner, replayable }));
725    }
726    // From here recovery finds the landed commit by its nonce and installs the index through this lock.
727    held.for_recovery = true;
728    write_journal(
729        workspace.directory.as_path(),
730        REF_UPDATED_FILENAME,
731        encode_ref_updated(new_oid).as_bytes(),
732        // A record whose name a power loss took completes in the tearing table, so only the file is synced.
733        RecordSync::FileOnly,
734    )?;
735    reach(Phase::RefUpdated)?;
736    remove_pack_keep(objects, pack_name).map_err(LandingError::Recovery)?;
737    record_landed_capture_or_warn(
738        capture.layout.git_dir.as_path(),
739        workspace.directory.as_path(),
740        workspace.transaction_id.as_str(),
741        new_oid,
742    );
743    return Ok(None);
744}
745
746/// Landing attempt controls stay out of the release executable.
747#[cfg(test)]
748#[path = "landing_attempt_tests.rs"]
749mod tests;