git_worktree_manager/operations/
lockfile.rs1use std::fs;
7use std::path::{Path, PathBuf};
8use std::time::{SystemTime, UNIX_EPOCH};
9
10use serde::{Deserialize, Serialize};
11
12const LOCK_FILENAME: &str = "gw-session.lock";
13
14#[cfg(not(unix))]
17const STALE_TTL: std::time::Duration = std::time::Duration::from_secs(7 * 24 * 60 * 60);
18
19pub const LOCK_VERSION: u32 = 1;
25
26fn default_version() -> u32 {
27 0
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct LockEntry {
33 #[serde(default = "default_version")]
34 pub version: u32,
35 pub pid: u32,
36 pub started_at: i64,
37 pub cmd: String,
38}
39
40pub struct SessionLock {
50 path: PathBuf,
51 owner_pid: u32,
52}
53
54impl Drop for SessionLock {
55 fn drop(&mut self) {
56 if let Ok(raw) = fs::read_to_string(&self.path) {
65 if let Ok(entry) = serde_json::from_str::<LockEntry>(&raw) {
66 if entry.pid != self.owner_pid {
67 return;
68 }
69 let _ = fs::remove_file(&self.path);
70 }
71 }
72 }
73}
74
75#[cfg(unix)]
77pub fn pid_alive(pid: u32) -> bool {
78 unsafe {
79 let ret = libc::kill(pid as libc::pid_t, 0);
80 if ret == 0 {
81 return true;
82 }
83 #[cfg(target_os = "macos")]
84 let err = *libc::__error();
85 #[cfg(target_os = "linux")]
86 let err = *libc::__errno_location();
87 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
88 let err = 0;
89 err == libc::EPERM
90 }
91}
92
93#[cfg(not(unix))]
94pub fn pid_alive(_pid: u32) -> bool {
95 true
96}
97
98fn lock_dir(worktree: &Path) -> PathBuf {
106 let dot_git = worktree.join(".git");
107 if let Ok(meta) = fs::metadata(&dot_git) {
108 if meta.is_file() {
109 if let Ok(raw) = fs::read_to_string(&dot_git) {
110 for line in raw.lines() {
111 if let Some(rest) = line.strip_prefix("gitdir:") {
112 let trimmed = rest.trim();
113 if !trimmed.is_empty() {
114 return PathBuf::from(trimmed);
115 }
116 }
117 }
118 }
119 }
120 }
121 dot_git
122}
123
124fn lock_path(worktree: &Path) -> PathBuf {
125 lock_dir(worktree).join(LOCK_FILENAME)
126}
127
128fn now_epoch_seconds() -> i64 {
129 SystemTime::now()
130 .duration_since(UNIX_EPOCH)
131 .map(|d| d.as_secs() as i64)
132 .unwrap_or(0)
133}
134
135#[derive(Debug, thiserror::Error)]
139pub enum AcquireError {
140 #[error("worktree already in use by PID {} ({})", .0.pid, .0.cmd)]
142 ForeignLock(LockEntry),
143 #[error("lockfile I/O error: {0}")]
145 Io(#[from] std::io::Error),
146 #[error("lockfile serialization error: {0}")]
148 Serde(#[from] serde_json::Error),
149}
150
151fn cleanup_stale_tmp_files(dir: &Path) {
155 let entries = match fs::read_dir(dir) {
156 Ok(d) => d,
157 Err(_) => return,
158 };
159 let prefix = format!("{}.tmp.", LOCK_FILENAME);
160 let me = std::process::id();
161 for entry in entries.flatten() {
162 let name = entry.file_name();
163 let name_s = name.to_string_lossy();
164 let Some(pid_str) = name_s.strip_prefix(&prefix) else {
165 continue;
166 };
167 let Ok(pid) = pid_str.parse::<u32>() else {
168 continue;
169 };
170 if pid == me {
171 continue;
172 }
173 if !pid_alive(pid) {
174 let _ = fs::remove_file(entry.path());
175 }
176 }
177}
178
179pub fn acquire(worktree: &Path, cmd: &str) -> std::result::Result<SessionLock, AcquireError> {
184 let path = lock_path(worktree);
185
186 if let Some(existing) = read_and_clean_stale(worktree) {
187 if existing.pid != std::process::id() {
188 return Err(AcquireError::ForeignLock(existing));
189 }
190 }
191
192 let entry = LockEntry {
193 version: LOCK_VERSION,
194 pid: std::process::id(),
195 started_at: now_epoch_seconds(),
196 cmd: cmd.to_string(),
197 };
198 let json = serde_json::to_string(&entry)?;
199
200 if let Some(parent) = path.parent() {
201 fs::create_dir_all(parent)?;
202 cleanup_stale_tmp_files(parent);
204 }
205
206 let tmp = path.with_file_name(format!("{}.tmp.{}", LOCK_FILENAME, std::process::id()));
209 {
210 use std::io::Write;
211 let mut f = std::fs::OpenOptions::new()
212 .write(true)
213 .create(true)
214 .truncate(true)
215 .open(&tmp)?;
216 f.write_all(json.as_bytes())?;
217 f.sync_all().ok();
218 }
219 fs::rename(&tmp, &path)?;
220
221 if let Ok(raw) = fs::read_to_string(&path) {
227 if let Ok(final_entry) = serde_json::from_str::<LockEntry>(&raw) {
228 if final_entry.pid != std::process::id() {
229 return Err(AcquireError::ForeignLock(final_entry));
230 }
231 }
232 }
233
234 Ok(SessionLock {
235 path,
236 owner_pid: std::process::id(),
237 })
238}
239
240pub fn read_and_clean_stale(worktree: &Path) -> Option<LockEntry> {
250 let path = lock_path(worktree);
251 let raw = fs::read_to_string(&path).ok()?;
252 let entry: LockEntry = serde_json::from_str(&raw).ok()?;
253
254 if entry.version != LOCK_VERSION {
258 return Some(entry);
259 }
260
261 #[cfg(unix)]
263 let alive = pid_alive(entry.pid);
264 #[cfg(not(unix))]
270 let alive = match fs::metadata(&path).and_then(|m| m.modified()) {
271 Ok(mtime) => std::time::SystemTime::now()
272 .duration_since(mtime)
273 .map(|age| age < STALE_TTL)
274 .unwrap_or(true),
275 Err(_) => true,
276 };
277
278 if alive {
279 Some(entry)
280 } else {
281 let _ = fs::remove_file(&path);
282 None
283 }
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289 use tempfile::TempDir;
290
291 fn make_worktree() -> TempDir {
292 let dir = TempDir::new().unwrap();
293 fs::create_dir_all(dir.path().join(".git")).unwrap();
294 dir
295 }
296
297 #[test]
298 fn acquire_writes_file_and_drop_removes_it() {
299 let wt = make_worktree();
300 let path = wt.path().join(".git").join(LOCK_FILENAME);
301 {
302 let _lock = acquire(wt.path(), "test").unwrap();
303 assert!(path.exists());
304 }
305 assert!(!path.exists());
306 }
307
308 #[test]
309 fn read_returns_entry_for_live_pid() {
310 let wt = make_worktree();
311 let _lock = acquire(wt.path(), "shell").unwrap();
312 let entry = read_and_clean_stale(wt.path()).unwrap();
313 assert_eq!(entry.pid, std::process::id());
314 assert_eq!(entry.cmd, "shell");
315 }
316
317 #[cfg(unix)]
321 #[test]
322 fn read_removes_stale_lockfile() {
323 let wt = make_worktree();
324 let path = wt.path().join(".git").join(LOCK_FILENAME);
325 let entry = LockEntry {
326 version: LOCK_VERSION,
327 pid: 999_999_999,
328 started_at: 0,
329 cmd: "ghost".to_string(),
330 };
331 fs::write(&path, serde_json::to_string(&entry).unwrap()).unwrap();
332 assert!(read_and_clean_stale(wt.path()).is_none());
333 assert!(!path.exists());
334 }
335
336 #[test]
337 fn acquire_does_not_leave_tmp_file_behind() {
338 let wt = make_worktree();
339 let _lock = acquire(wt.path(), "shell").unwrap();
340 let git_dir = wt.path().join(".git");
341 let entries: Vec<_> = fs::read_dir(&git_dir)
342 .unwrap()
343 .filter_map(|e| e.ok())
344 .map(|e| e.file_name().to_string_lossy().into_owned())
345 .collect();
346 let tmp_files: Vec<_> = entries
347 .iter()
348 .filter(|n| n.starts_with("gw-session.lock.tmp."))
349 .collect();
350 assert!(tmp_files.is_empty(), "tmp files leaked: {:?}", tmp_files);
351 assert!(entries.iter().any(|n| n == "gw-session.lock"));
352 }
353
354 #[test]
355 fn lock_dir_follows_gitdir_indicator_when_dot_git_is_file() {
356 let root = TempDir::new().unwrap();
359 let real_gitdir = root.path().join("main.git/worktrees/feature");
360 fs::create_dir_all(&real_gitdir).unwrap();
361 let wt = root.path().join("feature");
362 fs::create_dir_all(&wt).unwrap();
363 fs::write(
364 wt.join(".git"),
365 format!("gitdir: {}\n", real_gitdir.display()),
366 )
367 .unwrap();
368
369 let dir = lock_dir(&wt);
370 assert_eq!(dir, real_gitdir);
371
372 let _lock = acquire(&wt, "shell").unwrap();
373 assert!(real_gitdir.join(LOCK_FILENAME).exists());
374 let entry = read_and_clean_stale(&wt).unwrap();
375 assert_eq!(entry.pid, std::process::id());
376 }
377
378 #[cfg(unix)]
379 #[test]
380 fn drop_does_not_remove_lockfile_owned_by_another_process() {
381 let wt = make_worktree();
382 let lock = acquire(wt.path(), "shell").unwrap();
383 let entry = LockEntry {
385 version: LOCK_VERSION,
386 pid: unsafe { libc::getppid() } as u32,
387 started_at: 0,
388 cmd: "other".to_string(),
389 };
390 let path = wt.path().join(".git").join(LOCK_FILENAME);
391 fs::write(&path, serde_json::to_string(&entry).unwrap()).unwrap();
392
393 drop(lock);
394 assert!(
395 path.exists(),
396 "foreign-owned lockfile was incorrectly removed"
397 );
398 let _ = fs::remove_file(&path);
400 }
401
402 #[cfg(unix)]
403 #[test]
404 fn acquire_fails_when_live_lock_from_other_pid() {
405 let wt = make_worktree();
406 let path = wt.path().join(".git").join(LOCK_FILENAME);
407 let other_pid = unsafe { libc::getppid() } as u32;
408 let entry = LockEntry {
409 version: LOCK_VERSION,
410 pid: other_pid,
411 started_at: 0,
412 cmd: "other".to_string(),
413 };
414 fs::write(&path, serde_json::to_string(&entry).unwrap()).unwrap();
415 match acquire(wt.path(), "shell") {
416 Err(AcquireError::ForeignLock(e)) => assert_eq!(e.pid, other_pid),
417 Err(e) => panic!("expected ForeignLock, got {:?}", e),
418 Ok(_) => panic!("expected ForeignLock, got Ok"),
419 }
420 }
421
422 #[test]
423 fn foreign_version_lockfile_is_not_cleaned() {
424 let wt = make_worktree();
425 let path = wt.path().join(".git").join(LOCK_FILENAME);
426 let raw = serde_json::json!({
428 "pid": 999_999_999u32,
429 "started_at": 0,
430 "cmd": "future-gw"
431 });
432 fs::write(&path, raw.to_string()).unwrap();
433 let entry = read_and_clean_stale(wt.path()).expect("foreign-version entry preserved");
435 assert_eq!(entry.version, 0);
436 assert!(
437 path.exists(),
438 "foreign-version lockfile must not be cleaned"
439 );
440 }
441
442 #[cfg(unix)]
444 #[test]
445 fn cleanup_stale_tmp_files_removes_dead_pids() {
446 let wt = make_worktree();
447 let git = wt.path().join(".git");
448 let dead = git.join(format!("{}.tmp.{}", LOCK_FILENAME, 999_999_999u32));
449 fs::write(&dead, "stale").unwrap();
450 cleanup_stale_tmp_files(&git);
451 assert!(!dead.exists(), "dead-pid tmp file should be removed");
452 }
453}