majordomus_cli/worktree/
lock.rs1use 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
23pub const LOCK_RELATIVE: &str = "majordomus/locks/worktrees.lock";
25
26pub const WAIT: Duration = Duration::from_secs(20);
28
29pub const STALE_AFTER: Duration = Duration::from_secs(900);
33
34#[derive(Debug)]
37pub struct WorktreeLock {
38 path: PathBuf,
39 token: String,
40}
41
42impl WorktreeLock {
43 pub fn path_for(git_common_dir: &Path) -> PathBuf {
45 git_common_dir.join(LOCK_RELATIVE)
46 }
47
48 pub fn acquire(git_common_dir: &Path) -> Result<Self> {
55 Self::acquire_with(git_common_dir, WAIT)
56 }
57
58 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 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 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 pub fn path(&self) -> &Path {
122 &self.path
123 }
124}
125
126impl Drop for WorktreeLock {
127 fn drop(&mut self) {
128 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}