Skip to main content

majordomus_cli/worktree/
lock.rs

1//! One lock per repository, around every operation that changes the worktree topology.
2//!
3//! Two agents working the same repository is the normal case here, not the exotic one, and
4//! `git worktree add` twice at once on the same destination is how a half-registered
5//! worktree happens. The lock lives under the *common* git directory —
6//! `<git-common-dir>/majordomus/locks/worktrees.lock` — which is the one directory every
7//! linked worktree of a repository shares and which no other repository can see. So the
8//! lock is visible from every worktree, there is exactly one of it, and two different
9//! repositories never wait on each other.
10//!
11//! It is not under `.ai/local/`: that is per-checkout, so a lock there would be one lock per
12//! worktree, which is no lock at all. It is not the MCP server's lease either: that elects
13//! a process to serve, which is a different question with a different lifetime. Reads take
14//! no lock; only a mutation serialises.
15
16use std::fs;
17use std::io::Write;
18use std::path::{Path, PathBuf};
19use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
20
21use super::error::{Result, WorktreeError};
22
23/// Where the lock lives under the common git directory.
24pub const LOCK_RELATIVE: &str = "majordomus/locks/worktrees.lock";
25
26/// How long a caller waits for the lock before saying who has it.
27pub const WAIT: Duration = Duration::from_secs(20);
28
29/// A lock file older than this is treated as abandoned: a process that died between taking
30/// the lock and releasing it must not stop the repository for ever. Generous, because a
31/// migration of a large worktree across devices can take a while.
32pub const STALE_AFTER: Duration = Duration::from_secs(900);
33
34/// The held lock. Dropping it releases it; a panic or an early return therefore cannot leave
35/// the repository locked.
36#[derive(Debug)]
37pub struct WorktreeLock {
38    path: PathBuf,
39    token: String,
40}
41
42impl WorktreeLock {
43    /// The lock file for a repository, given its common git directory.
44    pub fn path_for(git_common_dir: &Path) -> PathBuf {
45        git_common_dir.join(LOCK_RELATIVE)
46    }
47
48    /// Take the lock, waiting up to `WAIT` for another holder to finish.
49    ///
50    /// The primitive is `O_CREAT|O_EXCL`, which is atomic on every filesystem this runs on:
51    /// exactly one caller creates the file. A file older than `STALE_AFTER` is removed and
52    /// the attempt repeats, so a killed process is recovered from without a person being
53    /// asked to delete anything.
54    pub fn acquire(git_common_dir: &Path) -> Result<Self> {
55        Self::acquire_with(git_common_dir, WAIT)
56    }
57
58    /// The same, with the waiting time as an argument, so a test does not wait twenty
59    /// seconds to prove that a held lock is held.
60    pub fn acquire_with(git_common_dir: &Path, wait: Duration) -> Result<Self> {
61        let path = Self::path_for(git_common_dir);
62        if let Some(dir) = path.parent() {
63            fs::create_dir_all(dir).map_err(|e| WorktreeError::io(dir, &e))?;
64        }
65        let token = format!(
66            "pid {} at {}",
67            std::process::id(),
68            SystemTime::now()
69                .duration_since(UNIX_EPOCH)
70                .map(|d| d.as_secs())
71                .unwrap_or(0)
72        );
73        let deadline = Instant::now() + wait;
74        loop {
75            match fs::OpenOptions::new()
76                .write(true)
77                .create_new(true)
78                .open(&path)
79            {
80                Ok(mut f) => {
81                    // Best effort: the content is for the person who reads the message, and
82                    // the lock is the file's existence, not its content.
83                    let _ = f.write_all(token.as_bytes());
84                    return Ok(WorktreeLock { path, token });
85                }
86                Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
87                    if Self::is_stale(&path) {
88                        // Whoever wrote it is gone. Removing it races with another recoverer
89                        // taking the same decision; both then contend on create_new, which
90                        // only one of them wins, so the race has no bad outcome.
91                        let _ = fs::remove_file(&path);
92                        continue;
93                    }
94                    if Instant::now() >= deadline {
95                        return Err(WorktreeError::LockUnavailable {
96                            holder: fs::read_to_string(&path).ok().map(|s| s.trim().to_string()),
97                            path,
98                        });
99                    }
100                    std::thread::sleep(Duration::from_millis(50));
101                }
102                Err(e) => return Err(WorktreeError::io(&path, &e)),
103            }
104        }
105    }
106
107    fn is_stale(path: &Path) -> bool {
108        let Ok(meta) = fs::metadata(path) else {
109            return false;
110        };
111        let Ok(modified) = meta.modified() else {
112            return false;
113        };
114        SystemTime::now()
115            .duration_since(modified)
116            .map(|age| age > STALE_AFTER)
117            .unwrap_or(false)
118    }
119
120    /// The lock file, for a message.
121    pub fn path(&self) -> &Path {
122        &self.path
123    }
124}
125
126impl Drop for WorktreeLock {
127    fn drop(&mut self) {
128        // Only remove the file if it is still this process's: a lock reclaimed as stale by
129        // someone else must not then be deleted by the process that lost it.
130        if let Ok(content) = fs::read_to_string(&self.path) {
131            if content.trim() != self.token {
132                return;
133            }
134        }
135        let _ = fs::remove_file(&self.path);
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn the_lock_is_under_the_common_directory_so_every_worktree_sees_one_lock() {
145        let p = WorktreeLock::path_for(Path::new("/a/foo/.git"));
146        assert_eq!(
147            p,
148            PathBuf::from("/a/foo/.git/majordomus/locks/worktrees.lock")
149        );
150    }
151
152    #[test]
153    fn a_second_holder_waits_and_then_names_the_first() {
154        let dir = tempfile::tempdir().unwrap();
155        let held = WorktreeLock::acquire(dir.path()).unwrap();
156        let e = WorktreeLock::acquire_with(dir.path(), Duration::from_millis(120)).unwrap_err();
157        assert_eq!(e.code(), "LockUnavailable");
158        assert!(
159            format!("{e}").contains("pid "),
160            "the message says who holds it: {e}"
161        );
162        drop(held);
163        let _again = WorktreeLock::acquire_with(dir.path(), Duration::from_millis(10)).unwrap();
164    }
165
166    #[test]
167    fn releasing_removes_the_file() {
168        let dir = tempfile::tempdir().unwrap();
169        let path = WorktreeLock::path_for(dir.path());
170        {
171            let _l = WorktreeLock::acquire(dir.path()).unwrap();
172            assert!(path.exists());
173        }
174        assert!(!path.exists());
175    }
176
177    #[test]
178    fn a_lock_reclaimed_by_someone_else_is_not_removed_by_the_loser() {
179        let dir = tempfile::tempdir().unwrap();
180        let path = WorktreeLock::path_for(dir.path());
181        let mine = WorktreeLock::acquire(dir.path()).unwrap();
182        fs::write(&path, "pid 999999 at 0").unwrap();
183        drop(mine);
184        assert!(path.exists(), "the other holder's lock file survives");
185        fs::remove_file(&path).unwrap();
186    }
187}