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;