Skip to main content

git_worktree_manager/operations/
lockfile.rs

1//! Session lockfile — explicit "this worktree is in use" marker.
2//!
3//! Written when a user enters a worktree via `gw shell` or `gw start`.
4//! Removed on Drop. Readers verify PID liveness and delete stale files.
5
6use 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/// Non-unix stale-lock TTL: a lockfile whose mtime is older than this is
15/// treated as belonging to a crashed process and removed on next read.
16#[cfg(not(unix))]
17const STALE_TTL: std::time::Duration = std::time::Duration::from_secs(7 * 24 * 60 * 60);
18
19/// Current on-disk lockfile schema version. A mismatching version makes
20/// the lockfile "foreign" for the reader — `read_and_clean_stale` returns
21/// it as-is without removing the file. Writers still check PID ownership,
22/// so a foreign-version entry held by another PID is treated as a live
23/// foreign lock by `acquire` and refused.
24pub const LOCK_VERSION: u32 = 1;
25
26fn default_version() -> u32 {
27    0
28}
29
30/// Serialized lockfile contents describing the session that owns a worktree.
31#[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
40/// RAII guard that removes the lockfile when dropped, provided the lockfile
41/// still belongs to this process. A `Drop` that blindly removed the file
42/// would delete another session's lock if two processes raced the acquire.
43///
44/// Note: the RAII guard only guarantees correct cleanup on drop. It does
45/// **not** guarantee ongoing exclusive access — if another process bypasses
46/// `acquire` and clobbers the file mid-session, this guard will not notice,
47/// though Drop will correctly refuse to remove a file whose PID no longer
48/// matches.
49pub struct SessionLock {
50    path: PathBuf,
51    owner_pid: u32,
52}
53
54impl Drop for SessionLock {
55    fn drop(&mut self) {
56        // There is a microsecond-scale race here: between reading and
57        // removing, another process could in theory write its own lockfile.
58        // In practice, a foreigner would have had to pass acquire's
59        // ownership check — which it could not while our (still-present)
60        // lockfile named this PID. So this is best-effort and safe.
61        // If the file is unreadable or malformed we cannot prove ownership —
62        // leave it alone and let a subsequent `read_and_clean_stale` or
63        // `acquire` handle it.
64        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/// Check whether a process with the given PID is currently alive.
76#[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
98/// Resolve the directory that should hold the lockfile.
99///
100/// In a git worktree created by `git worktree add`, `<worktree>/.git` is a
101/// *file* containing `gitdir: <absolute-path>` pointing to the per-worktree
102/// directory under `<main>/.git/worktrees/<name>`. Writing a lockfile inside
103/// a regular file would fail silently, so we dereference the `gitdir:`
104/// indicator. In the main worktree, `.git` is a directory and is used as-is.
105fn 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/// Outcome of an `acquire` call. Callers may want to distinguish "an active
136/// session already owns this worktree" (should block entry) from "we could
137/// not write the lockfile at all" (degrade to a warning and proceed).
138#[derive(Debug, thiserror::Error)]
139pub enum AcquireError {
140    /// Another live session holds the lock.
141    #[error("worktree already in use by PID {} ({})", .0.pid, .0.cmd)]
142    ForeignLock(LockEntry),
143    /// Lockfile could not be written/read.
144    #[error("lockfile I/O error: {0}")]
145    Io(#[from] std::io::Error),
146    /// Serializing/deserializing the lockfile failed.
147    #[error("lockfile serialization error: {0}")]
148    Serde(#[from] serde_json::Error),
149}
150
151/// Sweep the lock directory for stale tmp files left by dead processes.
152/// Filenames have the form `gw-session.lock.tmp.<pid>`. Removal errors are
153/// ignored — this is best-effort housekeeping.
154fn 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
179/// Acquire an exclusive session lock for the given worktree. Cleans up stale
180/// locks; returns `AcquireError::ForeignLock` if a live foreign PID holds the
181/// lock, or `AcquireError::Io`/`AcquireError::Serde` for I/O / serialization
182/// failures.
183pub 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        // Remove any stale tmp files from dead PIDs before writing our own.
203        cleanup_stale_tmp_files(parent);
204    }
205
206    // Atomic write: write to tmp, then rename. The tmp name includes our
207    // PID so racing processes do not clobber each other's tmp files.
208    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    // Post-rename ownership verification: if two processes both passed the
222    // pre-check and raced the rename, only one file survives on disk. Re-read
223    // and confirm it still contains our PID; otherwise the other process won
224    // the race and we must not return a SessionLock (whose Drop would then
225    // remove the foreigner's file).
226    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
240/// Read the current lock entry, cleaning up if the owner is gone.
241///
242/// Behavior by schema version:
243/// - Foreign-version entries (e.g. written by a future `gw`) are returned
244///   unmodified and never cleaned — we do not own them.
245/// - Same-version entries are cleaned when the owner is known-dead:
246///   on unix, `pid_alive(pid)` is the authority; on non-unix (where we
247///   cannot cheaply verify PID liveness), the lockfile's mtime is the
248///   fallback and a file older than 7 days is treated as stale.
249pub 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    // Version gate: unknown versions are treated as foreign locks — do not
255    // touch them beyond returning whatever we can parse. A future gw version
256    // bumping LOCK_VERSION must still interoperate safely here.
257    if entry.version != LOCK_VERSION {
258        return Some(entry);
259    }
260
261    // Prefer OS-level liveness when we have it.
262    #[cfg(unix)]
263    let alive = pid_alive(entry.pid);
264    // On non-unix we cannot cheaply verify PID liveness, so fall back to
265    // mtime: if the lockfile has not been touched in STALE_TTL, assume the
266    // owner crashed and clean it up. Metadata read failures bias toward
267    // keeping the lockfile (report as alive) to avoid accidentally nuking a
268    // real session over a transient filesystem glitch.
269    #[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    // unix-only: relies on pid_alive returning false for a fake PID. The
318    // non-unix implementation falls back to mtime, so a freshly written
319    // lockfile is never considered stale in that path.
320    #[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        // Simulate a git worktree: <worktree>/.git is a file containing
357        // `gitdir: <path>` pointing to the real per-worktree directory.
358        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        // Overwrite the file as if another process had taken over.
384        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        // Cleanup for the TempDir drop
399        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        // Write raw JSON without a version field → parses as version 0.
427        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        // read_and_clean_stale should return Some(entry) and NOT remove it.
434        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    // unix-only: relies on pid_alive returning false for a fake PID.
443    #[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}