Skip to main content

git_policy_cli/
index_writer_coordination.rs

1//! What:
2//!  Coordination of a forwarded index writer with concurrent landings:
3//!  the landing lock is taken
4//!       after recovery,
5//!  a foreign `index.lock` is waited for under the classification rules,
6//!  and the
7//!       lock is handed to real Git through the landing lease until Git returns.
8//! Why:
9//!  An index writer must not interleave with a landing commit,
10//!  and a nested wrapper invoked by the
11//!      coordinated Git (for example `git rebase --exec 'git add'`) must not wait on the lock its
12//!      ancestor holds (`src/index-lock/index-writer-coordination.ts`).
13//!
14//! In TS you'd write (pseudocode):
15//! ```ts
16//! await using coordination = await coordinateIndexWriter({ command, gitPath, timeoutMs });
17//! ```
18
19/// Foreign-lock evidence readers used to explain the failure.
20use super::index_lock_wait::{
21    WaitFailure, live_holder_timeout_message, unproven_owner_message, wait_for_index_lock,
22};
23/// The host side effects of the wait.
24use super::index_lock_wait_host::HostWaitEffects;
25/// Index-writer classification of a resolved command.
26use super::index_writer_commands::is_index_writer;
27use super::diagnostic_log::debug;
28/// The landing-location query.
29use super::landing_location::{LandingLocation, resolve_landing_location};
30/// The engine failure event,
31///  rendered for standard error.
32use super::diagnostics::{EngineFailureCode, render_engine_failure};
33/// Startup recovery run before the landing lock is taken.
34use super::transaction_gate::{Gate, recover_for_command};
35/// Running real Git for the location query and recovery.
36use super::transaction_git::GitContext;
37/// The owner lock and its silent wait.
38use super::live_lock::acquire_live_owner_lock;
39use super::owner_lock::OwnerLock;
40/// The landing lease and its variable.
41use super::transaction_lease::{LANDING_LEASE_VARIABLE, format_landing_lease, has_valid_landing_lease};
42/// Registry names and creation.
43use super::transaction_registry::{LANDING_LOCK_NAME, TRANSACTION_ROOT_NAME, ensure_transaction_root};
44use super::commit_recovery::recovery_directories;
45/// The resolved forwarded command.
46use super::forwarded_command::ResolvedCommand;
47/// The repository shape the invocation selects.
48use super::worktree_identity::WorktreeIdentity;
49/// The once-per-process Git version check.
50use super::git_version::{GitRelease, check_git_version};
51/// `OsString` is owned operating-system text of raw bytes.
52use std::ffi::OsString;
53/// `Instant` starts the wait's clock.
54use std::time::Instant;
55use std::path::Path;
56
57/// What:
58///  The landing lock held by an index writer,
59///  and the lease real Git inherits.
60/// Why:
61///   The lock is released after Git returns;
62///  the lease tells nested wrappers the ancestor holds it.
63///
64/// In TS you'd write (pseudocode):
65/// ```ts
66/// type IndexWriterCoordination = AsyncDisposable & { environment: Record<string, string> };
67/// ```
68pub struct Coordination {
69    /// The landing lock,
70    ///  released when the coordination ends.
71    lock: OwnerLock,
72    /// The lease variable and its value.
73    pub lease: (OsString, OsString),
74}
75
76/// What:
77///  Why a coordinated command must not run.
78/// Why:
79///   Each case has its own stderr event and exit status.
80///
81/// In TS you'd write (pseudocode):
82/// ```ts
83/// type CoordinationFailure = IndexLockUnprovenOwnerError | Error;
84/// ```
85#[derive(Clone, Debug, Eq, PartialEq)]
86pub enum CoordinationFailure {
87    /// A dead or unproven foreign owner outlasted the budget.
88    UnprovenOwner(String),
89    /// Something the coordination had to read could not be read or recovered.
90    Stop(String),
91}
92
93impl CoordinationFailure {
94    /// What:
95    ///  The engine failure event for this failure,
96    ///  as standard error.
97    /// Why:
98    ///   The incumbent reports both as engine failures with exit status 2.
99    ///
100    /// In TS you'd write (pseudocode):
101    /// ```ts
102    /// renderPolicyEvents([createEngineFailureEvent({ code, message })])
103    /// ```
104    pub fn stderr(&self) -> String {
105        match self {
106            CoordinationFailure::UnprovenOwner(message) => {
107                return render_engine_failure(0, EngineFailureCode::IndexLockUnprovenOwner, message.as_str());
108            }
109            CoordinationFailure::Stop(message) => {
110                return render_engine_failure(0, EngineFailureCode::ContentUnavailable, message.as_str());
111            }
112        }
113    }
114}
115
116/// What:
117///  The inputs of one coordination.
118/// Why:
119///   Named fields keep the call readable and satisfy the parameter-object rule.
120///
121/// In TS you'd write (pseudocode):
122/// ```ts
123/// coordinateIndexWriter({ command, gitPath, timeoutMs, environment })
124/// ```
125pub struct CoordinationInput<'a> {
126    /// Git context of the invocation.
127    pub context: &'a GitContext,
128    /// The repository shape the invocation selects.
129    pub identity: &'a WorktreeIdentity,
130    /// The resolved forwarded command.
131    pub command: &'a ResolvedCommand,
132    /// The environment the invocation inherited,
133    ///  carrying any lease.
134    pub environment: &'a [(OsString, OsString)],
135    /// `indexLock.unprovenOwnerTimeoutMs`.
136    pub timeout_ms: u64,
137}
138
139/// What:
140///  Whether the command's index is the real index,
141///  and where its landing lock lives.
142/// Why:
143///   `coordinateIndexWriter` coordinates only with the real index,
144///  and only outside an ancestor's lease.
145///
146/// In TS you'd write (pseudocode):
147/// ```ts
148/// if (location.indexPath !== location.realIndexPath) return NO_COORDINATION;
149/// ```
150fn coordinated_location(
151    context: &GitContext,
152    command: &ResolvedCommand,
153    environment: &[(OsString, OsString)],
154) -> Result<Option<LandingLocation>, CoordinationFailure> {
155    let global: &[OsString] = &command.arguments[..command.subcommand_index];
156    let location: LandingLocation = match resolve_landing_location(context, global) {
157        Ok(Some(found)) => found,
158        Ok(None) => return Ok(None),
159        Err(reason) => return Err(CoordinationFailure::Stop(reason)),
160    };
161    if location.index_path != location.real_index_path {
162        return Ok(None);
163    }
164    let lock_directory: std::path::PathBuf = location.registry_root.join(LANDING_LOCK_NAME);
165    if has_valid_landing_lease(environment, lock_directory.as_path()) {
166        return Ok(None);
167    }
168    return Ok(Some(location));
169}
170
171/// What:
172///  Take the landing lock,
173///  waiting while a live landing holds it.
174/// Why:
175///   Recovery runs before the acquisition because the native recovery takes the landing lock itself.
176///
177/// In TS you'd write (pseudocode):
178/// ```ts
179/// await acquireLocationLandingLock({ gitPath, location });
180/// ```
181fn acquire_landing(context: &GitContext, identity: &WorktreeIdentity, location: &LandingLocation) -> Result<OwnerLock, CoordinationFailure> {
182    match recover_for_command(context, identity) {
183        Gate::Clear | Gate::Live(_) => {}
184        Gate::Stop(stderr) => return Err(CoordinationFailure::Stop(stderr)),
185    }
186    if let Err(error) = ensure_transaction_root(location.registry_root.as_path()) {
187        return Err(CoordinationFailure::Stop(error.0));
188    }
189    let lock_directory: std::path::PathBuf = location.registry_root.join(LANDING_LOCK_NAME);
190    match acquire_live_owner_lock(lock_directory.as_path(), "the landing lock") {
191        Ok(lock) => return Ok(lock),
192        Err(error) => return Err(CoordinationFailure::Stop(error.to_string())),
193    }
194}
195
196/// What:
197///  Wait for any foreign `index.lock` to clear,
198///  under the classification rules.
199/// Why:
200///   The lock is held meanwhile,
201///  so no landing starts beside the index writer.
202///
203/// In TS you'd write (pseudocode):
204/// ```ts
205/// await waitForIndexLock({ realIndexPath, timeoutMs, consequence, attempt });
206/// ```
207pub fn wait_for_lock(
208    real_index: &Path,
209    timeout_ms: u64,
210    consequence: &str,
211) -> Result<(), CoordinationFailure> {
212    // `mut` lets the wait advance its clock and record its reports.
213    let mut effects: HostWaitEffects = HostWaitEffects {
214        real_index: real_index.to_path_buf(),
215        started: Instant::now(),
216    };
217    match wait_for_index_lock(&mut effects, timeout_ms, consequence) {
218        Ok(()) => return Ok(()),
219        Err(WaitFailure::UnprovenOwner {
220            evidence,
221            verdict,
222            timeout_ms: budget,
223            consequence: reason,
224        }) => {
225            return Err(CoordinationFailure::UnprovenOwner(unproven_owner_message(
226                &evidence,
227                &verdict,
228                budget,
229                reason.as_str(),
230            )));
231        }
232        Err(WaitFailure::LiveHolderTimedOut {
233            lock_path,
234            pid,
235            command,
236            waited_ms,
237            consequence: reason,
238            ..
239        }) => {
240            return Err(CoordinationFailure::Stop(live_holder_timeout_message(
241                lock_path.as_path(),
242                pid,
243                command.as_deref(),
244                waited_ms,
245                reason.as_str(),
246            )));
247        }
248        Err(WaitFailure::Read(reason)) => return Err(CoordinationFailure::Stop(reason)),
249    }
250}
251
252/// What:
253///  Coordinate one forwarded command:
254///  nothing for a non-writer,
255///  otherwise hold the landing lock
256///       and return the lease real Git inherits.
257/// Why:
258///   `coordinateIndexWriter`;
259///  the lock is released by the caller after Git returns.
260///
261/// In TS you'd write (pseudocode):
262/// ```ts
263/// async function coordinateIndexWriter({ command, gitPath, timeoutMs, environment }): Promise<IndexWriterCoordination>;
264/// ```
265pub fn coordinate_index_writer(input: &CoordinationInput<'_>) -> Result<Option<Coordination>, CoordinationFailure> {
266    return coordinate_index_writer_checked(input, check_git_version);
267}
268
269/// What:
270///  The coordination with the Git version answer supplied,
271///  so the controls can refuse an old Git.
272/// Why:
273///   The process-wide check answers once per process and cannot be made to refuse inside a test.
274///
275/// In TS you'd write (pseudocode):
276/// ```ts
277/// coordinateIndexWriter({ ..., requireGit: checkGitVersion })
278/// ```
279pub fn coordinate_index_writer_checked(
280    input: &CoordinationInput<'_>,
281    version: fn(&GitContext) -> Result<GitRelease, String>,
282) -> Result<Option<Coordination>, CoordinationFailure> {
283    if !is_index_writer(input.command) {
284        debug("coordinateIndexWriter", "not an index writer; no coordination");
285        return Ok(None);
286    }
287    let location: LandingLocation = match coordinated_location(input.context, input.command, input.environment)? {
288        Some(found) => found,
289        None => {
290            debug("coordinateIndexWriter", "no real-index landing location, or an inherited lease; no coordination");
291            return Ok(None);
292        }
293    };
294    debug("coordinateIndexWriter", format!("coordinating git {} with {}", command_name(input.command), location.registry_root.display()).as_str());
295    // The version check runs once per process, before the first work that depends on Git's behavior.
296    if let Err(reason) = version(input.context) {
297        return Err(CoordinationFailure::Stop(reason));
298    }
299    let lock: OwnerLock = acquire_landing(input.context, input.identity, &location)?;
300    let consequence: String = format!("did not run git {}", command_name(input.command));
301    match wait_for_lock(location.real_index_path.as_path(), input.timeout_ms, consequence.as_str()) {
302        Ok(()) => {}
303        Err(failure) => {
304            release_quietly(lock);
305            return Err(failure);
306        }
307    }
308    let lease_value: String = format_landing_lease(
309        lock.directory().to_string_lossy().as_ref(),
310        lock.token(),
311    );
312    return Ok(Some(Coordination {
313        lock,
314        lease: (OsString::from(LANDING_LEASE_VARIABLE), OsString::from(lease_value)),
315    }));
316}
317
318/// What:
319///  The subcommand's name as text,
320///  for the diagnostics.
321/// Why:
322///   A message names the command the wrapper did not run.
323///
324/// In TS you'd write (pseudocode):
325/// ```ts
326/// String(command.subcommand)
327/// ```
328fn command_name(command: &ResolvedCommand) -> String {
329    return String::from_utf8_lossy(command.word()).into_owned();
330}
331
332/// What:
333///  Take the landing lock of a worktree's repository for a direct fix,
334///  after recovery.
335/// Why:
336///   A direct fix changes the repository,
337///  so no landing may run beside it;
338///  outside a repository
339///       there is no landing to exclude.
340///
341/// In TS you'd write (pseudocode):
342/// ```ts
343/// await acquireSelectedLandingLock({ gitPath, globalArgs });
344/// ```
345pub fn hold_landing_lock(
346    context: &GitContext,
347    identity: &WorktreeIdentity,
348) -> Result<Option<OwnerLock>, CoordinationFailure> {
349    let Some((git_dir, _)) = recovery_directories(identity) else {
350        return Ok(None);
351    };
352    let location: LandingLocation = LandingLocation {
353        git_dir: git_dir.to_path_buf(),
354        index_path: git_dir.join("index"),
355        real_index_path: git_dir.join("index"),
356        registry_root: git_dir.join(TRANSACTION_ROOT_NAME),
357    };
358    let lock: OwnerLock = acquire_landing(context, identity, &location)?;
359    return Ok(Some(lock));
360}
361
362/// What:
363///  Release a lock whose failure cannot be reported further.
364/// Why:
365///   A release error after a failed wait is dropped;
366///  the wait's failure is the one that matters.
367///
368/// In TS you'd write (pseudocode):
369/// ```ts
370/// await lock[Symbol.asyncDispose]();
371/// ```
372fn release_quietly(lock: OwnerLock) {
373    let _ = lock.release();
374}
375
376impl Coordination {
377    /// What:
378    ///  Release the landing lock after real Git returned.
379    /// Why:
380    ///   Releasing reports a failure the caller may still print.
381    ///
382    /// In TS you'd write (pseudocode):
383    /// ```ts
384    /// await coordination[Symbol.asyncDispose]();
385    /// ```
386    pub fn release(self) -> Result<(), String> {
387        match self.lock.release() {
388            Ok(()) => return Ok(()),
389            Err(error) => return Err(error.to_string()),
390        }
391    }
392}
393
394/// Coordination controls stay out of the release executable.
395#[cfg(test)]
396#[path = "index_writer_coordination_tests.rs"]
397mod tests;