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