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