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::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(&current)? {
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;