Skip to main content

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