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::private_storage::{sync_directory, write_private_file};
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 durably:
274///  exclusively,
275///  then the directory entry.
276/// Why:
277///   `writeJournalRecord`:
278///  a state file is never rewritten,
279///  so a crash leaves the newest complete state readable.
280///
281/// In TS you'd write (pseudocode):
282/// ```ts
283/// await writeJournalRecord({ directory, filename, record });
284/// ```
285fn write_journal(directory: &Path, filename: &str, bytes: &[u8]) -> Result<(), LandingError> {
286    let path: PathBuf = directory.join(filename);
287    if let Err(error) = write_private_file(path.as_path(), bytes) {
288        return Err(LandingError::Journal(format!("writing {} failed: {error}", path.display())));
289    }
290    if let Err(error) = sync_directory(directory) {
291        return Err(LandingError::Journal(format!("syncing {} failed: {error}", directory.display())));
292    }
293    return Ok(());
294}
295
296/// What:
297///  Reach one test-only phase marker.
298/// Why:
299///   A malformed marker variable fails the landing at every phase it reaches.
300///
301/// In TS you'd write (pseudocode):
302/// ```ts
303/// await reachTransactionPhase({ phase });
304/// ```
305fn reach(phase: Phase) -> Result<(), LandingError> {
306    return reach_transaction_phase(phase, None).map_err(LandingError::Phase);
307}
308
309/// What:
310///  The device and inode of a landing artifact.
311/// Why:
312///   `fileIdentity`:
313///  recovery proves an artifact is the exact file written.
314///
315/// In TS you'd write (pseudocode):
316/// ```ts
317/// await fileIdentity(path)
318/// ```
319fn artifact_identity(path: &Path) -> Result<FileIdentity, LandingError> {
320    return match file_identity_of(path) {
321        Ok((device, inode, _)) => Ok(FileIdentity { device, inode }),
322        Err(error) => Err(LandingError::Journal(format!("reading the identity of {} failed: {error}", path.display()))),
323    };
324}
325
326/// What:
327///  The length and hash of a landing artifact as it is on disk now.
328/// Why:
329///   The index snapshots are not synced (a sync of each costs about 2 ms), so the landing record carries their
330///  digests and recovery refuses to install a snapshot that differs from them.
331///
332/// In TS you'd write (pseudocode):
333/// ```ts
334/// await digestFile(path)
335/// ```
336fn artifact_digest(path: &Path) -> Result<ContentDigest, LandingError> {
337    return digest_file(path)
338        .map_err(|error| { return LandingError::Journal(format!("reading {} to digest it failed: {error}", path.display())); });
339}
340
341/// What:
342///  Whether a target value names a commit.
343/// Why:
344///   Only a target that still names a commit can be replayed onto.
345///
346/// In TS you'd write (pseudocode):
347/// ```ts
348/// current.kind === 'commit'
349/// ```
350fn names_commit(target: &Base) -> bool {
351    return matches!(target, Base::Commit(_));
352}
353
354/// What:
355///  The real index lock while a landing holds it.
356/// Why:
357///   The lock moves out when the install consumes it,
358///  and a lock that recovery needs for the install stays in place when the attempt fails.
359///
360/// In TS you'd write (pseudocode):
361/// ```ts
362/// await using indexLock = await acquireRealIndexLock({ realIndexPath, transactionDirectory, attempt, timeoutMs });
363/// ```
364struct Held {
365    /// The lock,
366    ///  until the install consumes it.
367    lock: Option<RealIndexLock>,
368    /// Whether the landing reached the point after which only the install is left,
369    ///  so recovery installs through this lock if the attempt dies or fails.
370    for_recovery: bool,
371}
372
373/// What:
374///  Runs one landing attempt.
375/// Why:
376///   `landTransaction`:
377///  the caller already holds the landing lock (`_landing_lock` is the witness) and has dealt with the starvation reservation.
378///  The real `index.lock` is released on every exit except an install,
379///  and except a failure after the target advanced (or, for a normalization, just before the install),
380///  because recovery completes the install through the same lock and fails closed without it.
381///  A target that moved ends the attempt with `HeadMoved`;
382///  when `replayable`,
383///  the caller replays and revalidates outside both locks and lands again.
384///
385/// In TS you'd write (pseudocode):
386/// ```ts
387/// await landTransaction({ gitPath, cwd, capture, workspace, mode, payload, committedPaths, addedPaths, selectedWorktreePaths, indexLockTimeoutMs, attempt });
388/// ```
389pub fn land_transaction(
390    context: &GitContext,
391    cwd: &Path,
392    _landing_lock: &OwnerLock,
393    workspace: &mut LandingWorkspace,
394    request: &LandingRequest<'_>,
395) -> Result<LandingOutcome, LandingError> {
396    debug(TAG, format!("landing attempt {} of transaction {}", request.attempt, workspace.transaction_id).as_str());
397    let acquired: RealIndexLock = acquire_landing_index_lock(
398        request.capture.layout.real_index_path.as_path(),
399        workspace.directory.as_path(),
400        request.attempt,
401        request.index_lock_timeout_ms,
402        "landed nothing",
403    )
404    .map_err(LandingError::Lock)?;
405    let mut held: Held = Held { lock: Some(acquired), for_recovery: false };
406    let result: Result<LandingOutcome, LandingError> = land_locked(context, cwd, workspace, request, &mut held);
407    let Some(lock) = held.lock.take() else {
408        return result;
409    };
410    if held.for_recovery {
411        debug(TAG, "the target advanced before the install failed; the index lock is left for recovery");
412        return result;
413    }
414    match (lock.release_after_landing(), result) {
415        (Ok(()), outcome) => return outcome,
416        (Err(release), Ok(_)) => return Err(LandingError::Release(release)),
417        (Err(release), Err(error)) => {
418            warn(TAG, format!("the index lock could not be released after a failed landing: {release}").as_str());
419            return Err(error);
420        }
421    }
422}
423
424/// What:
425///  The landing steps that run while this process holds the real index lock.
426/// Why:
427///   The lock moves out of `held` only when the install consumes it,
428///  so the caller releases whatever is still held.
429///
430/// In TS you'd write (pseudocode):
431/// ```ts
432/// // the body of landTransaction after `await using indexLock`
433/// ```
434fn land_locked(
435    context: &GitContext,
436    cwd: &Path,
437    workspace: &mut LandingWorkspace,
438    request: &LandingRequest<'_>,
439    held: &mut Held,
440) -> Result<LandingOutcome, LandingError> {
441    reach(Phase::LandingLocked)?;
442    let capture: &InvocationCapture = request.capture;
443    // A fresh read of the worktree's `HEAD` under the landing lock, never the recorded one.
444    let ref_files: RefFilesLayout<'_> = RefFilesLayout {
445        git_dir: capture.layout.git_dir.as_path(),
446        common_dir: capture.layout.common_dir.as_path(),
447        ref_format: capture.layout.ref_format,
448        object_format: capture.layout.object_format.as_str(),
449    };
450    let symbolic_head: SymbolicHead = resolve_symbolic_head(context, cwd, &ref_files).map_err(LandingError::Git)?;
451    if symbolic_head != capture.layout.symbolic_head {
452        debug(TAG, "HEAD names another target than at invocation");
453        return Ok(LandingOutcome::BranchSwitched { current: symbolic_head });
454    }
455    let expected_old: Base = match request.payload {
456        LandingPayload::Commit { expected_old, .. } => expected_old.clone(),
457        LandingPayload::NormalizeOnly { .. } => capture.base.clone(),
458    };
459    let current: Base = resolve_ref_commit(context, cwd, capture.layout.target_ref.as_str()).map_err(LandingError::Git)?;
460    if !same_target(&current, &expected_old) {
461        debug(TAG, format!("{} moved since preparation", capture.layout.target_ref).as_str());
462        let replayable: bool = matches!(request.payload, LandingPayload::Commit { .. })
463            && capture.layout.conclusion == Conclusion::None
464            && names_commit(&current);
465        return Ok(LandingOutcome::HeadMoved { current, replayable });
466    }
467    let pack_name: Option<String> = match request.payload {
468        LandingPayload::Commit { new_oid, .. } => Some(
469            migrate_shadow_objects(
470                context,
471                cwd,
472                workspace.shadow.as_path(),
473                new_oid.as_str(),
474                &expected_old,
475                Some(transaction_keep_message(workspace.transaction_id.as_str()).as_str()),
476            )
477            .map_err(LandingError::Git)?,
478        ),
479        LandingPayload::NormalizeOnly { .. } => None,
480    };
481    let post: PathBuf = match record_landing(context, cwd, workspace, request, held, &expected_old, pack_name.as_ref()) {
482        Ok(post) => post,
483        Err(error) => {
484            discard_unrecorded_keep(capture.layout.object_directory.as_path(), workspace, pack_name.as_deref());
485            return Err(error);
486        }
487    };
488    if let LandingPayload::Commit { new_oid, .. } = request.payload {
489        let pack: &str = pack_name.as_deref().unwrap_or("");
490        if let Some(lost) = advance_target(context, cwd, workspace, request, held, new_oid.as_str(), &expected_old, pack)? {
491            return Ok(lost);
492        }
493    }
494    if let Err(error) = complete_landing(context, cwd, workspace, request, held, post.as_path()) {
495        // Once the branch advanced, a later failure must say so, or a consumer would commit the same work again.
496        return Err(match request.payload {
497            LandingPayload::Commit { new_oid, .. } => LandingError::LandedThenFailed {
498                oid: new_oid.clone(),
499                target: capture.layout.target_ref.clone(),
500                cause: Box::new(error),
501            },
502            LandingPayload::NormalizeOnly { .. } => error,
503        });
504    }
505    debug(TAG, format!("landed attempt {}", request.attempt).as_str());
506    let oid: String = match request.payload {
507        LandingPayload::Commit { new_oid, .. } => new_oid.clone(),
508        LandingPayload::NormalizeOnly { .. } => match &capture.base {
509            Base::Commit(oid) => oid.clone(),
510            Base::Unborn => String::new(),
511        },
512    };
513    return Ok(LandingOutcome::Landed { oid });
514}
515
516/// What:
517///  Install the post-index and write the records that complete a landing whose record is durable.
518/// Why:
519///   Everything here follows the branch advance, so the caller words a failure of any step as one that happened
520///  after the commit landed.
521///
522/// In TS you'd write (pseudocode):
523/// ```ts
524/// // the tail of landTransaction after the ref update
525/// ```
526fn complete_landing(
527    context: &GitContext,
528    cwd: &Path,
529    workspace: &LandingWorkspace,
530    request: &LandingRequest<'_>,
531    held: &mut Held,
532    post: &Path,
533) -> Result<(), LandingError> {
534    // A normalization has only the install left once its record is durable.
535    held.for_recovery = true;
536    let Some(installing) = held.lock.take() else {
537        return Err(LandingError::Journal(String::from("the real index lock is not held")));
538    };
539    installing.install(post).map_err(LandingError::Recovery)?;
540    write_journal(workspace.directory.as_path(), INDEX_INSTALLED_FILENAME, b"")?;
541    reach(Phase::IndexInstalled)?;
542    if matches!(request.payload, LandingPayload::Commit { .. }) {
543        reproduce_conclusion_cleanup(
544            context,
545            cwd,
546            request.capture.layout.git_dir.as_path(),
547            workspace.shadow.as_path(),
548            workspace.directory.as_path(),
549            request.capture.layout.ref_format,
550        )
551        .map_err(LandingError::Recovery)?;
552    }
553    return Ok(());
554}
555
556/// What:
557///  Computes the post-index against the real index as it is now.
558/// Why:
559///   A normalization lands the commit index as an exact private index with no guarded paths,
560///  as the incumbent's defaults do.
561///
562/// In TS you'd write (pseudocode):
563/// ```ts
564/// await computeLandingPostIndex({ gitPath, cwd, mode, preLandingIndexPath, capturedIndexPath, postIndexPath, landedTreeOid, baseRevision, committedPaths, landedIndexPath });
565/// ```
566fn compute_post_index(
567    context: &GitContext,
568    cwd: &Path,
569    workspace: &LandingWorkspace,
570    request: &LandingRequest<'_>,
571    landed_tree: &str,
572    pre_landing: &Path,
573    post: &Path,
574) -> Result<(), LandingError> {
575    let base: String = base_revision(request.capture);
576    let (landed_index, exact_private_index, guarded_paths): (&Path, bool, &[Vec<u8>]) = match request.payload {
577        LandingPayload::Commit { landed_index, exact_private_index, guarded_paths, .. } => {
578            (landed_index.as_path(), *exact_private_index, guarded_paths.as_slice())
579        }
580        LandingPayload::NormalizeOnly { .. } => (workspace.commit_index.as_path(), true, &[]),
581    };
582    let input: PostIndexInput<'_> = PostIndexInput {
583        mode: request.mode,
584        pre_landing_index: pre_landing,
585        captured_index: workspace.captured_index.as_path(),
586        landed_index,
587        post_index: post,
588        landed_tree,
589        base_revision: base.as_str(),
590        committed_paths: request.committed_paths,
591        guarded_paths,
592        exact_private_index,
593    };
594    return compute_landing_post_index(context, cwd, &input).map_err(LandingError::Git);
595}
596
597/// What:
598///  The steps between the objects' migration and the landing record: the pre-landing index copy,
599///  the post-index, and the record itself.
600///  Returns the post-index path.
601/// Why:
602///   Until the record is written nothing but this process knows the pack's keep,
603///  so the caller removes the keep when any of these steps fails;
604///  after `preserved` is set, recovery owns the directory and removes the keep by its message.
605///
606/// In TS you'd write (pseudocode):
607/// ```ts
608/// const post = await recordLanding({ gitPath, cwd, workspace, request, indexLock, expectedOld, packName });
609/// ```
610fn record_landing(
611    context: &GitContext,
612    cwd: &Path,
613    workspace: &mut LandingWorkspace,
614    request: &LandingRequest<'_>,
615    held: &Held,
616    expected_old: &Base,
617    pack_name: Option<&String>,
618) -> Result<PathBuf, LandingError> {
619    reach(Phase::ObjectsMigrated)?;
620    let capture: &InvocationCapture = request.capture;
621    let landed_tree: &str = match request.payload {
622        LandingPayload::Commit { landed_tree_oid, .. } | LandingPayload::NormalizeOnly { landed_tree_oid } => landed_tree_oid.as_str(),
623    };
624    let pre_landing: PathBuf = workspace.directory.join(pre_landing_index_filename(request.attempt));
625    let post: PathBuf = workspace.directory.join(post_index_filename(request.attempt));
626    if let Err(error) = copy_index_with_timestamps(capture.layout.real_index_path.as_path(), pre_landing.as_path()) {
627        return Err(LandingError::Journal(format!("copying the real index to {} failed: {error}", pre_landing.display())));
628    }
629    compute_post_index(context, cwd, workspace, request, landed_tree, pre_landing.as_path(), post.as_path())?;
630    // The record below hands the directory to recovery, so the mark comes first, as the incumbent's does.
631    workspace.preserved = true;
632    let lock_identity = match held.lock.as_ref() {
633        Some(lock) => lock.identity().clone(),
634        None => return Err(LandingError::Journal(String::from("the real index lock is not held"))),
635    };
636    let record: LandingRecord = LandingRecord {
637        attempt: request.attempt,
638        operation: match request.payload {
639            LandingPayload::Commit { .. } => LandingOperation::Commit,
640            LandingPayload::NormalizeOnly { .. } => LandingOperation::NormalizeOnly,
641        },
642        expected_old: expected_old.clone(),
643        new_oid: match request.payload {
644            LandingPayload::Commit { new_oid, .. } => Some(new_oid.clone()),
645            LandingPayload::NormalizeOnly { .. } => None,
646        },
647        landed_tree_oid: landed_tree.to_string(),
648        pre_landing_index: artifact_identity(pre_landing.as_path())?,
649        post_index: artifact_identity(post.as_path())?,
650        lock: lock_identity,
651        pack_name: pack_name.cloned(),
652        added_paths: request.added_paths.to_vec(),
653        selected_worktree_paths: request.selected_worktree_paths.to_vec(),
654        pre_landing_digest: Some(artifact_digest(pre_landing.as_path())?),
655        post_digest: Some(artifact_digest(post.as_path())?),
656    };
657    write_journal(
658        workspace.directory.as_path(),
659        landing_record_filename(request.attempt).as_str(),
660        encode_landing(&record).as_bytes(),
661    )?;
662    return Ok(post);
663}
664
665/// What:
666///  Removes the pack keep of a landing that failed before its record handed the directory to recovery.
667/// Why:
668///   The original failure is what the caller reports,
669///  so a keep that cannot be removed is a warning that names it; recovery's keep sweep by message still finds it.
670///
671/// In TS you'd write (pseudocode):
672/// ```ts
673/// if (!workspace.preserved && packName) await removePackKeep({ objectDirectory, packName }).catch(warn);
674/// ```
675fn discard_unrecorded_keep(objects: &Path, workspace: &LandingWorkspace, pack_name: Option<&str>) {
676    if workspace.preserved {
677        return;
678    }
679    let Some(pack) = pack_name else {
680        return;
681    };
682    if let Err(error) = remove_pack_keep(objects, pack) {
683        warn(TAG, format!("the pack keep of a landing that failed before its record could not be removed: {error}").as_str());
684    }
685}
686
687/// What:
688///  Advances the target by compare-and-swap and journals the result.
689///  Returns the outcome when the swap was lost.
690/// Why:
691///   A lost swap removes the pack keep and releases the directory from recovery,
692///  then reports who won;
693///  a won swap writes `ref-updated.json`,
694///  removes the keep,
695///  and records the landed capture.
696///
697/// In TS you'd write (pseudocode):
698/// ```ts
699/// const swap = await compareAndSwapTarget({ gitPath, cwd, capture, transactionId, newOid, expectedOld });
700/// ```
701fn advance_target(
702    context: &GitContext,
703    cwd: &Path,
704    workspace: &mut LandingWorkspace,
705    request: &LandingRequest<'_>,
706    held: &mut Held,
707    new_oid: &str,
708    expected_old: &Base,
709    pack_name: &str,
710) -> Result<Option<LandingOutcome>, LandingError> {
711    let capture: &InvocationCapture = request.capture;
712    let swap: CompareAndSwap<'_> = CompareAndSwap {
713        target_ref: capture.layout.target_ref.as_str(),
714        detached: capture.layout.symbolic_head == SymbolicHead::Detached,
715        nonce: workspace.transaction_id.as_str(),
716        new_oid,
717        expected_old,
718        hash_length: capture.layout.empty_tree_oid.len(),
719    };
720    let output = compare_and_swap_target(context, cwd, &swap).map_err(LandingError::Git)?;
721    let objects: &Path = capture.layout.object_directory.as_path();
722    if !output.succeeded() {
723        debug(TAG, format!("compare-and-swap lost: {}", String::from_utf8_lossy(&output.stderr).trim()).as_str());
724        remove_pack_keep(objects, pack_name).map_err(LandingError::Recovery)?;
725        workspace.preserved = false;
726        let winner: Base = resolve_ref_commit(context, cwd, capture.layout.target_ref.as_str()).map_err(LandingError::Git)?;
727        let replayable: bool = capture.layout.conclusion == Conclusion::None && names_commit(&winner);
728        return Ok(Some(LandingOutcome::HeadMoved { current: winner, replayable }));
729    }
730    // From here recovery finds the landed commit by its nonce and installs the index through this lock.
731    held.for_recovery = true;
732    write_journal(workspace.directory.as_path(), REF_UPDATED_FILENAME, encode_ref_updated(new_oid).as_bytes())?;
733    reach(Phase::RefUpdated)?;
734    remove_pack_keep(objects, pack_name).map_err(LandingError::Recovery)?;
735    record_landed_capture_or_warn(
736        capture.layout.git_dir.as_path(),
737        workspace.directory.as_path(),
738        workspace.transaction_id.as_str(),
739        new_oid,
740    );
741    return Ok(None);
742}
743
744/// Landing attempt controls stay out of the release executable.
745#[cfg(test)]
746#[path = "landing_attempt_tests.rs"]
747mod tests;