Skip to main content

git_stk/
lock.rs

1//! A coarse advisory lock so two git-stk processes never run state-mutating
2//! commands at once. Git locks its own index and refs, but not git-stk's
3//! multi-step orchestration (snapshot, rebases, metadata, provider calls), so
4//! a concurrent run could clobber the undo snapshot or half-rewrite the stack.
5
6use std::fs;
7use std::io::{ErrorKind, Write};
8use std::path::PathBuf;
9
10use anyhow::{Context, Result, bail};
11
12use crate::git;
13
14const LOCK_FILE: &str = "stk-lock";
15
16/// Held for the duration of a mutating command; removes the lock file on drop.
17/// Outside a git repo it is a no-op, so the command surfaces its own error.
18pub struct Lock {
19    path: Option<PathBuf>,
20}
21
22impl Lock {
23    /// Take the lock for `command`, or fail if another git-stk process holds
24    /// it. Naming the command makes the contention message actionable.
25    pub fn acquire(command: &str) -> Result<Self> {
26        let Ok(path) = git::git_common_path(LOCK_FILE) else {
27            // Not a git repo: nothing to guard, and the command will report
28            // the real problem itself.
29            return Ok(Self { path: None });
30        };
31        let path = PathBuf::from(path);
32
33        // Two passes: if the first finds a lock whose holder process has died
34        // (a `kill -9`, a crash, a power loss mid-command), reclaim it and try
35        // once more. A second failure means it is genuinely held, or another
36        // run reclaimed it first.
37        for reclaim in [true, false] {
38            match fs::OpenOptions::new()
39                .write(true)
40                .create_new(true)
41                .open(&path)
42            {
43                Ok(mut file) => {
44                    // Best effort: the holder line feeds the error message and
45                    // the staleness check.
46                    let _ = writeln!(file, "{} {command}", std::process::id());
47                    return Ok(Self { path: Some(path) });
48                }
49                Err(error) if error.kind() == ErrorKind::AlreadyExists => {
50                    let holder = fs::read_to_string(&path).unwrap_or_default();
51                    let holder = holder.trim().to_owned();
52                    if reclaim && holder_is_stale(&holder) {
53                        anstream::eprintln!(
54                            "{}",
55                            crate::style::dim(&format!(
56                                "reclaiming a stale git-stk lock; its holder ({holder}) is gone"
57                            ))
58                        );
59                        let _ = fs::remove_file(&path);
60                        continue;
61                    }
62                    let by = if holder.is_empty() {
63                        String::new()
64                    } else {
65                        format!(" ({holder})")
66                    };
67                    bail!(
68                        "another git stk operation is in progress{by}; wait for it to \
69                         finish, or remove {} if it is stale",
70                        path.display()
71                    );
72                }
73                Err(error) => {
74                    return Err(error)
75                        .with_context(|| format!("failed to take the lock at {}", path.display()));
76                }
77            }
78        }
79        unreachable!("the second pass always returns or bails");
80    }
81}
82
83/// Whether the lock's recorded holder process is gone, so the lock is stale
84/// and safe to reclaim. Conservative: an unparseable holder, a live process,
85/// a process owned by another user, or a platform we cannot probe all read as
86/// "not stale" - keep the lock and let the contention message stand.
87fn holder_is_stale(holder: &str) -> bool {
88    holder
89        .split_whitespace()
90        .next()
91        .and_then(|token| token.parse::<i32>().ok())
92        .is_some_and(process_is_dead)
93}
94
95#[cfg(unix)]
96fn process_is_dead(pid: i32) -> bool {
97    if pid <= 0 {
98        return false;
99    }
100    // kill(pid, 0) sends no signal; it just probes existence. 0 means alive,
101    // ESRCH means no such process (dead), and EPERM means it exists but is
102    // owned by another user (alive, and not ours to reclaim).
103    if unsafe { libc::kill(pid, 0) } == 0 {
104        return false;
105    }
106    std::io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH)
107}
108
109#[cfg(not(unix))]
110fn process_is_dead(_pid: i32) -> bool {
111    // No portable existence probe without a platform crate; never auto-reclaim
112    // here - the contention message tells the user how to clear a stale lock.
113    false
114}
115
116impl Drop for Lock {
117    fn drop(&mut self) {
118        if let Some(path) = &self.path {
119            let _ = fs::remove_file(path);
120        }
121    }
122}