git_policy_cli/worktree_copy_lock.rs
1//! What:
2//! The settlement lock that serializes worktree-copy installation and recovery per common Git directory,
3//! its owner record, liveness-based retirement of dead owners, and the inherited-lease check nested Git runs use.
4//! Why:
5//! Two copies into one repository must not interleave, but a commit in a linked worktree must never wait for them;
6//! only an owner proven alive may be waited for, and only an owner proven dead may be replaced.
7//!
8//! In TS you'd write (pseudocode):
9//! ```ts
10//! await using lease = await acquireWorktreeCopyLock(commonDir);
11//! ```
12
13use super::index_lock_wait::{BackoffState, INITIAL_BACKOFF, jittered_wait, next_backoff};
14use super::json_record::quote;
15use super::live_lock::{LIVE_WAIT_VARIABLE, live_wait_limit};
16use super::owner_lock_record::{OwnerLockRecord, owner_lock_holder_is_alive};
17use super::process_identity::current_birth_identity;
18use super::random_id::random_uuid;
19use super::worktree_copy_error::{WorktreeCopyError, failure_with_cause, raw_failure};
20use crate::worktree_copy_journal::ensure_journal_root;
21use std::io::Write;
22use std::path::{Path, PathBuf};
23use std::time::{Duration, Instant};
24
25/// What:
26/// The published name of the settlement lock inside the journal root.
27/// Why:
28/// Every process in one repository contends on this exact name.
29///
30/// In TS you'd write (pseudocode):
31/// ```ts
32/// join(journalRoot, 'settlement.lock')
33/// ```
34pub const SETTLEMENT_LOCK_NAME: &str = "settlement.lock";
35
36/// What:
37/// The owner record's file name inside a published lock directory.
38/// Why:
39/// Readers find the owner by this name.
40///
41/// In TS you'd write (pseudocode):
42/// ```ts
43/// const SETTLEMENT_OWNER_FILENAME = 'owner.json';
44/// ```
45pub const OWNER_RECORD_NAME: &str = "owner.json";
46
47/// What:
48/// How long an owner without evidence may hold the lock before acquisition gives up.
49/// Why:
50/// A malformed record must not block the repository forever, but a live owner is waited for without limit.
51///
52/// In TS you'd write (pseudocode):
53/// ```ts
54/// export const UNPROVEN_OWNER_TIMEOUT_MS = 1_000;
55/// ```
56pub const UNPROVEN_OWNER_TIMEOUT_MS: u64 = 1_000;
57
58/// What:
59/// The delay between read-only checks while a live owner holds the lock.
60/// Why:
61/// Short enough to notice a release promptly, long enough not to spin.
62///
63/// In TS you'd write (pseudocode):
64/// ```ts
65/// const LIVE_OWNER_POLL_MS = 20;
66/// ```
67const LIVE_OWNER_POLL: Duration = Duration::from_millis(20);
68
69/// What:
70/// The environment variable through which nested Git runs inherit a held lease.
71/// Why:
72/// A hook that runs the wrapper inside `git worktree add` must forward without waiting for its own parent.
73///
74/// In TS you'd write (pseudocode):
75/// ```ts
76/// export const WORKTREE_COPY_LEASE_ENV = 'CLI_GIT_WORKTREE_COPY_LEASE';
77/// ```
78pub const WORKTREE_COPY_LEASE_ENV: &str = "CLI_GIT_WORKTREE_COPY_LEASE";
79
80/// What:
81/// The identity an owner record names: a unguessable token, a process, and that process's birth identity.
82/// Why:
83/// Liveness is judged on the process and its birth, so a reused PID is never taken for the owner.
84///
85/// In TS you'd write (pseudocode):
86/// ```ts
87/// type LockOwner = { leaseToken: string; ownerPid: number; ownerBirthIdentity: string; schemaVersion: 1 };
88/// ```
89#[derive(Clone, Debug, Eq, PartialEq)]
90pub struct LockOwner {
91 /// Capability passed to real-Git descendants.
92 pub lease_token: String,
93 /// The owner's process identifier.
94 pub owner_pid: i64,
95 /// The owner's process birth identity.
96 pub owner_birth_identity: String,
97}
98
99/// What:
100/// What a published lock directory currently says.
101/// Why:
102/// Acquisition acts differently on a live owner, a dead one, a vanished lock, and an unproven record.
103///
104/// In TS you'd write (pseudocode):
105/// ```ts
106/// readPublishedOwner(lockDirectory): Promise<LockOwner | UnprovenOwner | typeof LOCK_RETRY>;
107/// ```
108#[derive(Clone, Debug, Eq, PartialEq)]
109enum Published {
110 /// A parsed owner record.
111 Owner(LockOwner),
112 /// The lock vanished or changed while it was read.
113 Retry,
114 /// A record or file that proves nothing, with the evidence.
115 Unproven(String),
116}
117
118/// What:
119/// The text of one owner record, as the incumbent writes it.
120/// Why:
121/// Other wrappers read this record, so its key order and spelling are fixed.
122///
123/// In TS you'd write (pseudocode):
124/// ```ts
125/// `${JSON.stringify({ leaseToken, ownerBirthIdentity, ownerPid, schemaVersion })}\n`
126/// ```
127pub fn encode_owner(owner: &LockOwner) -> String {
128 return format!(
129 "{{\"leaseToken\":{},\"ownerBirthIdentity\":{},\"ownerPid\":{},\"schemaVersion\":1}}\n",
130 quote(owner.lease_token.as_str()),
131 quote(owner.owner_birth_identity.as_str()),
132 owner.owner_pid
133 );
134}
135
136/// What:
137/// Parses one owner record, or names why it proves nothing.
138/// Why:
139/// Every field is checked before the owner's process is ever consulted.
140///
141/// In TS you'd write (pseudocode):
142/// ```ts
143/// function parseLockOwner(text: string): LockOwner | string;
144/// ```
145pub fn parse_owner(text: &str) -> Result<LockOwner, String> {
146 let value: serde_json::Value =
147 serde_json::from_str(text).map_err(|error| format!("not JSON ({error})"))?;
148 let missing: &str = "missing or mistyped owner fields";
149 let object = value
150 .as_object()
151 .ok_or_else(|| return String::from(missing))?;
152 let lease_token: &str = object
153 .get("leaseToken")
154 .and_then(serde_json::Value::as_str)
155 .filter(|token| return !token.is_empty())
156 .ok_or_else(|| return String::from(missing))?;
157 let owner_pid: i64 = object
158 .get("ownerPid")
159 .and_then(serde_json::Value::as_i64)
160 .filter(|pid| return *pid >= 1)
161 .ok_or_else(|| return String::from(missing))?;
162 let birth: &str = object
163 .get("ownerBirthIdentity")
164 .and_then(serde_json::Value::as_str)
165 .filter(|identity| return !identity.is_empty())
166 .ok_or_else(|| return String::from(missing))?;
167 if object
168 .get("schemaVersion")
169 .and_then(serde_json::Value::as_f64)
170 != Some(1.0)
171 {
172 return Err(String::from(missing));
173 }
174 return Ok(LockOwner {
175 lease_token: lease_token.to_string(),
176 owner_pid,
177 owner_birth_identity: birth.to_string(),
178 });
179}
180
181/// What:
182/// Reads the owner a published lock names.
183/// Why:
184/// A vanished lock asks for another attempt; an unreadable or invalid one is unproven evidence.
185///
186/// In TS you'd write (pseudocode):
187/// ```ts
188/// await readPublishedOwner(lockDirectory);
189/// ```
190fn read_published(lock: &Path) -> Result<Published, WorktreeCopyError> {
191 let owner_path: PathBuf = lock.join(OWNER_RECORD_NAME);
192 let text: String = match std::fs::read_to_string(owner_path.as_path()) {
193 Ok(text) => text,
194 Err(error) => {
195 // `match` on the operating-system code: vanished or non-directory means retry; unreadable means unproven.
196 return match error.raw_os_error() {
197 Some(code) if code == libc::ENOENT || code == libc::ENOTDIR => Ok(Published::Retry),
198 Some(code) if code == libc::EACCES || code == libc::EISDIR => {
199 let name: &str = if code == libc::EACCES {
200 "EACCES"
201 } else {
202 "EISDIR"
203 };
204 Ok(Published::Unproven(format!(
205 "{} is unreadable ({name})",
206 quote(owner_path.to_string_lossy().as_ref())
207 )))
208 }
209 _ => Err(raw_failure(&error)),
210 };
211 }
212 };
213 return Ok(match parse_owner(text.as_str()) {
214 Ok(owner) => Published::Owner(owner),
215 Err(reason) => Published::Unproven(format!(
216 "{} holds an invalid owner record: {reason}",
217 quote(owner_path.to_string_lossy().as_ref())
218 )),
219 });
220}
221
222/// What:
223/// Whether a recorded owner is alive with the same process birth.
224/// Why:
225/// A reused PID or an exited but unreaped process is dead.
226///
227/// In TS you'd write (pseudocode):
228/// ```ts
229/// await lockOwnerIsAlive(owner);
230/// ```
231fn owner_alive(owner: &LockOwner) -> Result<bool, WorktreeCopyError> {
232 let record: OwnerLockRecord = OwnerLockRecord {
233 token: owner.lease_token.clone(),
234 owner_pid: owner.owner_pid,
235 owner_birth_identity: owner.owner_birth_identity.clone(),
236 transaction_id: None,
237 };
238 return owner_lock_holder_is_alive(&record).map_err(|error| return raw_failure(&error));
239}
240
241/// What:
242/// The disposable settlement lock: released explicitly, or removed when dropped if still held.
243/// Why:
244/// Release reports an ownership change; the drop fallback cannot report, so it only removes what it still owns.
245///
246/// In TS you'd write (pseudocode):
247/// ```ts
248/// type WorktreeCopyLease = AsyncDisposable & { leaseToken: string };
249/// ```
250#[derive(Debug)]
251pub struct WorktreeCopyLease {
252 /// The published lock directory.
253 lock_directory: PathBuf,
254 /// The owner identity this process published.
255 owner: LockOwner,
256 /// Whether release has already run.
257 released: bool,
258}
259
260impl WorktreeCopyLease {
261 /// The token a nested Git run inherits.
262 pub fn lease_token(&self) -> &str {
263 return self.owner.lease_token.as_str();
264 }
265
266 /// Removes the lock when this process still owns it, and fails with the ownership wording otherwise.
267 pub fn release(mut self) -> Result<(), WorktreeCopyError> {
268 self.released = true;
269 if !remove_lock_of(
270 self.lock_directory.as_path(),
271 self.owner.lease_token.as_str(),
272 )? {
273 return Err(failure_with_cause(
274 format!(
275 "cli-git: worktree-copy lock ownership changed: {}.",
276 quote(self.lock_directory.to_string_lossy().as_ref())
277 )
278 .as_str(),
279 None,
280 ));
281 }
282 return Ok(());
283 }
284}
285
286impl Drop for WorktreeCopyLease {
287 /// Removes a lease that was never released explicitly, ignoring a failure that nothing can report.
288 fn drop(&mut self) {
289 if !self.released {
290 let _ignored: Result<bool, WorktreeCopyError> = remove_lock_of(
291 self.lock_directory.as_path(),
292 self.owner.lease_token.as_str(),
293 );
294 }
295 }
296}
297
298/// What:
299/// Removes a published lock by renaming it away first, when it still names the expected owner.
300/// Why:
301/// A rename frees the published name atomically, so a concurrent publication is never emptied in place;
302/// a replacement moved by a race is renamed back.
303///
304/// In TS you'd write (pseudocode):
305/// ```ts
306/// await removeLockOf({ lockDirectory, leaseToken });
307/// ```
308fn remove_lock_of(lock: &Path, lease_token: &str) -> Result<bool, WorktreeCopyError> {
309 return remove_lock_of_with(lock, lease_token, rename_lock);
310}
311
312/// The rename that frees a published lock name,
313/// as a plain function so a test can supply another.
314///
315/// In TS you'd write (pseudocode):
316/// ```ts
317/// await rename(from, to);
318/// ```
319fn rename_lock(from: &Path, to: &Path) -> std::io::Result<()> {
320 return std::fs::rename(from, to);
321}
322
323/// What:
324/// `remove_lock_of` with the rename that frees the published name supplied by the caller.
325/// Why:
326/// A lock that vanishes between the read and the rename, and a rename that is refused, are races and faults a test
327/// cannot otherwise provoke on demand.
328///
329/// In TS you'd write (pseudocode):
330/// ```ts
331/// await removeLockOf({ lockDirectory, leaseToken, rename });
332/// ```
333fn remove_lock_of_with(
334 lock: &Path,
335 lease_token: &str,
336 rename: fn(&Path, &Path) -> std::io::Result<()>,
337) -> Result<bool, WorktreeCopyError> {
338 match read_published(lock)? {
339 Published::Owner(owner) if owner.lease_token == lease_token => {}
340 _ => return Ok(false),
341 }
342 let retired: PathBuf = PathBuf::from(format!(
343 "{}.{}.stale",
344 lock.to_string_lossy(),
345 random_uuid().map_err(|error| return raw_failure(&error.0))?
346 ));
347 // `match` on the rename: a vanished lock is simply not ours to remove; any other failure is fatal.
348 match rename(lock, retired.as_path()) {
349 Ok(()) => {}
350 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
351 Err(error) => return Err(raw_failure(&error)),
352 }
353 let moved: Published = read_published(retired.as_path())?;
354 if !matches!(&moved, Published::Owner(owner) if owner.lease_token == lease_token) {
355 // A replacement moved by the race goes back; a rename-back collision leaves both in place for inspection.
356 let _ignored: std::io::Result<()> = std::fs::rename(retired.as_path(), lock);
357 return Ok(false);
358 }
359 std::fs::remove_dir_all(retired.as_path()).map_err(|error| return raw_failure(&error))?;
360 return Ok(true);
361}
362
363/// What:
364/// Publishes this process's owner as a complete candidate directory, then renames it to the lock name.
365/// Why:
366/// The rename is the single atomic step that makes the lock visible, so a reader never sees an owner-less lock.
367///
368/// In TS you'd write (pseudocode):
369/// ```ts
370/// await writeCandidateOwner({ candidateDirectory, owner }); await rename(candidateDirectory, lockDirectory);
371/// ```
372fn publish_candidate(
373 lock: &Path,
374 owner: &LockOwner,
375) -> Result<Option<WorktreeCopyLease>, WorktreeCopyError> {
376 let candidate: PathBuf = PathBuf::from(format!(
377 "{}.{}.pending",
378 lock.to_string_lossy(),
379 random_uuid().map_err(|error| return raw_failure(&error.0))?
380 ));
381 write_candidate(candidate.as_path(), owner)?;
382 match std::fs::rename(candidate.as_path(), lock) {
383 Ok(()) => {
384 return Ok(Some(WorktreeCopyLease {
385 lock_directory: lock.to_path_buf(),
386 owner: owner.clone(),
387 released: false,
388 }));
389 }
390 Err(error) => {
391 let _ignored: std::io::Result<()> = std::fs::remove_dir_all(candidate.as_path());
392 // `match` on the error: an occupied name means another owner holds the lock; anything else is fatal.
393 if matches!(error.kind(), std::io::ErrorKind::AlreadyExists)
394 || error.raw_os_error() == Some(libc::ENOTEMPTY)
395 {
396 return Ok(None);
397 }
398 return Err(raw_failure(&error));
399 }
400 }
401}
402
403/// What:
404/// Writes one candidate lock directory with its owner record, synced, and removes it on failure.
405/// Why:
406/// A candidate is never visible under the lock name until its owner record is complete.
407///
408/// In TS you'd write (pseudocode):
409/// ```ts
410/// async function writeCandidateOwner({ candidateDirectory, owner }): Promise<void>;
411/// ```
412fn write_candidate(candidate: &Path, owner: &LockOwner) -> Result<(), WorktreeCopyError> {
413 let written: std::io::Result<()> = (|| {
414 use std::os::unix::fs::DirBuilderExt;
415 std::fs::DirBuilder::new().mode(0o700).create(candidate)?;
416 crate::private_storage::write_exclusive_synced(
417 candidate.join(OWNER_RECORD_NAME).as_path(),
418 encode_owner(owner).as_bytes(),
419 )?;
420 return Ok(());
421 })();
422 if let Err(error) = written {
423 let _ignored: std::io::Result<()> = std::fs::remove_dir_all(candidate);
424 return Err(raw_failure(&error));
425 }
426 return Ok(());
427}
428
429/// What:
430/// The owner identity of this process with a fresh token.
431/// Why:
432/// Every acquisition publishes a token no other process can guess.
433///
434/// In TS you'd write (pseudocode):
435/// ```ts
436/// await currentLockOwner();
437/// ```
438fn current_owner() -> Result<LockOwner, WorktreeCopyError> {
439 let birth: String = current_birth_identity().map_err(|error| {
440 return failure_with_cause(
441 "cli-git: current worktree-copy lock owner identity is unavailable.",
442 Some(&error),
443 );
444 })?;
445 let token: String = random_uuid().map_err(|error| return raw_failure(&error.0))?;
446 return Ok(LockOwner {
447 lease_token: token,
448 owner_pid: i64::from(std::process::id()),
449 owner_birth_identity: birth,
450 });
451}
452
453/// What:
454/// One unit-interval random number from the operating system.
455/// Why:
456/// Git's backoff is jittered, so waiters do not move in lockstep.
457///
458/// In TS you'd write (pseudocode):
459/// ```ts
460/// Math.random()
461/// ```
462fn unit_random() -> Result<f64, WorktreeCopyError> {
463 let mut bytes: [u8; 8] = [0; 8];
464 getrandom::fill(&mut bytes).map_err(|error| return raw_failure(&error))?;
465 return Ok((u64::from_le_bytes(bytes) >> 11) as f64 / (1u64 << 53) as f64);
466}
467
468/// What:
469/// The lock as a waiter sees it at one attempt.
470/// Why:
471/// Each result tells the acquisition loop whether to take the lock, wait for a live owner, or back off.
472///
473/// In TS you'd write (pseudocode):
474/// ```ts
475/// type AttemptResult = { kind: 'acquired' } | { kind: 'live' } | { kind: 'retry' } | { kind: 'unproven' };
476/// ```
477enum Attempt {
478 /// The lock is ours.
479 Acquired(WorktreeCopyLease),
480 /// A live owner holds it.
481 Live(LockOwner),
482 /// The lock changed or a dead owner was retired; try again.
483 Retry,
484 /// An owner without evidence holds it.
485 Unproven(String),
486}
487
488/// What:
489/// One publication attempt and the classification of whatever blocks it.
490/// Why:
491/// A dead owner is retired on the spot, so one attempt can end the wait.
492///
493/// In TS you'd write (pseudocode):
494/// ```ts
495/// await attemptAcquire({ lockDirectory, owner });
496/// ```
497fn attempt(lock: &Path, owner: &LockOwner) -> Result<Attempt, WorktreeCopyError> {
498 if let Some(lease) = publish_candidate(lock, owner)? {
499 return Ok(Attempt::Acquired(lease));
500 }
501 return match read_published(lock)? {
502 Published::Retry => Ok(Attempt::Retry),
503 Published::Unproven(evidence) => Ok(Attempt::Unproven(evidence)),
504 Published::Owner(holder) => {
505 if owner_alive(&holder)? {
506 return Ok(Attempt::Live(holder));
507 }
508 let _retired: bool = remove_lock_of(lock, holder.lease_token.as_str())?;
509 Ok(Attempt::Retry)
510 }
511 };
512}
513
514/// What:
515/// Waits, reading only, while a live owner keeps the lock.
516/// Why:
517/// The wait ends when the owner releases, replaces the lock, or stops running.
518///
519/// In TS you'd write (pseudocode):
520/// ```ts
521/// await waitWhileOwnerLives({ lockDirectory, owner });
522/// ```
523fn wait_while_owner_lives(lock: &Path, owner: &LockOwner, limit: Duration) -> Result<(), WorktreeCopyError> {
524 let started: Instant = Instant::now();
525 loop {
526 std::thread::sleep(LIVE_OWNER_POLL);
527 if started.elapsed() >= limit {
528 let message: String = format!(
529 "cli-git: gave up after waiting {} seconds for PID {}, which still holds worktree-copy settlement {}; {} sets how long cli-git waits. cli-git left the lock in place.",
530 started.elapsed().as_secs(),
531 owner.owner_pid,
532 quote(lock.to_string_lossy().as_ref()),
533 LIVE_WAIT_VARIABLE
534 );
535 return Err(failure_with_cause(message.as_str(), None));
536 }
537 match read_published(lock)? {
538 Published::Owner(current) if current.lease_token == owner.lease_token => {
539 if !owner_alive(¤t)? {
540 return Ok(());
541 }
542 }
543 _ => return Ok(()),
544 }
545 }
546}
547
548/// What:
549/// The stderr line that names a live holder.
550/// Why:
551/// A waiting user learns which process to look at and that cli-git is still waiting on purpose.
552///
553/// In TS you'd write (pseudocode):
554/// ```ts
555/// liveHolderLine({ lockDirectory, owner });
556/// ```
557fn live_holder_line(lock: &Path, owner: &LockOwner) -> String {
558 return format!(
559 "cli-git: waiting for PID {}, which holds worktree-copy settlement {} (proven by its live process identity); cli-git waits until it releases the lock.\n",
560 owner.owner_pid,
561 quote(lock.to_string_lossy().as_ref())
562 );
563}
564
565/// What:
566/// Acquires the settlement lock: waits for a proven-live owner up to this process's wait limit, retires a dead one,
567/// and gives an owner without evidence the bounded wait before failing.
568/// Why:
569/// Installation and recovery of one repository must never interleave, and no lock is taken from an owner that might still run.
570///
571/// In TS you'd write (pseudocode):
572/// ```ts
573/// await using lease = await acquireWorktreeCopyLock(commonDir);
574/// ```
575pub fn acquire_worktree_copy_lock(
576 common_dir: &Path,
577 stderr: &mut dyn Write,
578) -> Result<WorktreeCopyLease, WorktreeCopyError> {
579 return acquire_worktree_copy_lock_for(common_dir, stderr, live_wait_limit());
580}
581
582/// What:
583/// Acquires the settlement lock, waiting for each proven-live owner for at most `live_limit`.
584/// Why:
585/// A live owner that never releases would otherwise hold every later command for good;
586/// the refusal names it and leaves its lock in place.
587///
588/// In TS you'd write (pseudocode):
589/// ```ts
590/// await using lease = await acquireWorktreeCopyLock(commonDir, { liveLimitMs });
591/// ```
592pub fn acquire_worktree_copy_lock_for(
593 common_dir: &Path,
594 stderr: &mut dyn Write,
595 live_limit: Duration,
596) -> Result<WorktreeCopyLease, WorktreeCopyError> {
597 let lock: PathBuf = ensure_journal_root(common_dir)?.join(SETTLEMENT_LOCK_NAME);
598 let owner: LockOwner = current_owner()?;
599 let mut named: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
600 let mut unproven_since: Option<Instant> = None;
601 let mut backoff: BackoffState = INITIAL_BACKOFF;
602 loop {
603 // `match` on the attempt: a held lock is waited for, a retry backs off, and an acquisition ends the loop.
604 let unproven: Option<String> = match attempt(lock.as_path(), &owner)? {
605 Attempt::Acquired(lease) => return Ok(lease),
606 Attempt::Live(holder) => {
607 unproven_since = None;
608 if named.insert(holder.lease_token.clone()) {
609 let line: String = live_holder_line(lock.as_path(), &holder);
610 stderr
611 .write_all(line.as_bytes())
612 .map_err(|error| return raw_failure(&error))?;
613 }
614 wait_while_owner_lives(lock.as_path(), &holder, live_limit)?;
615 continue;
616 }
617 Attempt::Retry => None,
618 Attempt::Unproven(evidence) => Some(evidence),
619 };
620 let started: Instant = *unproven_since.get_or_insert_with(Instant::now);
621 if let Some(evidence) = unproven.filter(|_| {
622 return started.elapsed() >= Duration::from_millis(UNPROVEN_OWNER_TIMEOUT_MS);
623 }) {
624 {
625 let message: String = format!(
626 "cli-git: worktree-copy settlement lock {} stayed in place for {} ms and cli-git could not prove whether its owner is alive. Evidence: {}. cli-git left the lock in place; remove it only after confirming no cli-git process is using this repository.",
627 quote(lock.to_string_lossy().as_ref()),
628 UNPROVEN_OWNER_TIMEOUT_MS,
629 evidence
630 );
631 return Err(failure_with_cause(message.as_str(), None));
632 }
633 }
634 let delay_ms: u64 = jittered_wait(backoff, unit_random()?);
635 backoff = next_backoff(backoff);
636 std::thread::sleep(Duration::from_millis(delay_ms));
637 }
638}
639
640/// What:
641/// Takes the settlement lock if it is free or its dead owner is retired; reports a held lock instead of waiting.
642/// Why:
643/// A command that neither creates nor moves worktrees must not wait behind a copy.
644///
645/// In TS you'd write (pseudocode):
646/// ```ts
647/// const lease = await tryAcquireWorktreeCopyLock(commonDir); // WORKTREE_COPY_LOCK_HELD when held
648/// ```
649pub fn try_acquire_worktree_copy_lock(
650 common_dir: &Path,
651) -> Result<Option<WorktreeCopyLease>, WorktreeCopyError> {
652 let lock: PathBuf = ensure_journal_root(common_dir)?.join(SETTLEMENT_LOCK_NAME);
653 let owner: LockOwner = current_owner()?;
654 // Ten attempts absorb a lock that keeps changing; a live or unproven holder ends the attempts at once.
655 for _attempt in 0..10 {
656 // `match` on the attempt: an acquisition returns the lease, a retry tries again, and any holder means held.
657 match attempt(lock.as_path(), &owner)? {
658 Attempt::Acquired(lease) => return Ok(Some(lease)),
659 Attempt::Retry => continue,
660 Attempt::Live(_) | Attempt::Unproven(_) => return Ok(None),
661 }
662 }
663 return Err(failure_with_cause(
664 format!(
665 "cli-git: worktree-copy settlement lock under {} kept changing while it was acquired.",
666 quote(common_dir.to_string_lossy().as_ref())
667 )
668 .as_str(),
669 None,
670 ));
671}
672
673/// What:
674/// Whether the environment's lease token names the live owner of the published lock.
675/// Why:
676/// A nested Git run that carries the outer run's token forwards without waiting for the lock it is already inside.
677///
678/// In TS you'd write (pseudocode):
679/// ```ts
680/// await validatesInheritedWorktreeCopyLease({ commonDir, leaseToken });
681/// ```
682pub fn validate_inherited_lease(
683 common_dir: &Path,
684 lease_token: &str,
685) -> Result<bool, WorktreeCopyError> {
686 if lease_token.is_empty() {
687 return Ok(false);
688 }
689 let lock: PathBuf = ensure_journal_root(common_dir)?.join(SETTLEMENT_LOCK_NAME);
690 return match read_published(lock.as_path())? {
691 Published::Owner(owner) if owner.lease_token == lease_token => owner_alive(&owner),
692 _ => Ok(false),
693 };
694}
695
696/// Lock controls stay out of the release executable.
697#[cfg(test)]
698#[path = "worktree_copy_lock_tests.rs"]
699mod tests;