use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
const LOCK_FILENAME: &str = "gw-session.lock";
#[cfg(not(unix))]
const STALE_TTL: std::time::Duration = std::time::Duration::from_secs(7 * 24 * 60 * 60);
pub const LOCK_VERSION: u32 = 1;
fn default_version() -> u32 {
0
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LockEntry {
#[serde(default = "default_version")]
pub version: u32,
pub pid: u32,
pub started_at: i64,
pub cmd: String,
}
pub struct SessionLock {
path: PathBuf,
owner_pid: u32,
}
impl Drop for SessionLock {
fn drop(&mut self) {
if let Ok(raw) = fs::read_to_string(&self.path) {
if let Ok(entry) = serde_json::from_str::<LockEntry>(&raw) {
if entry.pid != self.owner_pid {
return;
}
let _ = fs::remove_file(&self.path);
}
}
}
}
#[cfg(unix)]
pub fn pid_alive(pid: u32) -> bool {
unsafe {
let ret = libc::kill(pid as libc::pid_t, 0);
if ret == 0 {
return true;
}
#[cfg(target_os = "macos")]
let err = *libc::__error();
#[cfg(target_os = "linux")]
let err = *libc::__errno_location();
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
let err = 0;
err == libc::EPERM
}
}
#[cfg(not(unix))]
pub fn pid_alive(_pid: u32) -> bool {
true
}
fn lock_dir(worktree: &Path) -> PathBuf {
let dot_git = worktree.join(".git");
if let Ok(meta) = fs::metadata(&dot_git) {
if meta.is_file() {
if let Ok(raw) = fs::read_to_string(&dot_git) {
for line in raw.lines() {
if let Some(rest) = line.strip_prefix("gitdir:") {
let trimmed = rest.trim();
if !trimmed.is_empty() {
return PathBuf::from(trimmed);
}
}
}
}
}
}
dot_git
}
fn lock_path(worktree: &Path) -> PathBuf {
lock_dir(worktree).join(LOCK_FILENAME)
}
fn now_epoch_seconds() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
#[derive(Debug, thiserror::Error)]
pub enum AcquireError {
#[error("worktree already in use by PID {} ({})", .0.pid, .0.cmd)]
ForeignLock(LockEntry),
#[error("lockfile I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("lockfile serialization error: {0}")]
Serde(#[from] serde_json::Error),
}
fn cleanup_stale_tmp_files(dir: &Path) {
let entries = match fs::read_dir(dir) {
Ok(d) => d,
Err(_) => return,
};
let prefix = format!("{}.tmp.", LOCK_FILENAME);
let me = std::process::id();
for entry in entries.flatten() {
let name = entry.file_name();
let name_s = name.to_string_lossy();
let Some(pid_str) = name_s.strip_prefix(&prefix) else {
continue;
};
let Ok(pid) = pid_str.parse::<u32>() else {
continue;
};
if pid == me {
continue;
}
if !pid_alive(pid) {
let _ = fs::remove_file(entry.path());
}
}
}
pub fn acquire(worktree: &Path, cmd: &str) -> std::result::Result<SessionLock, AcquireError> {
let path = lock_path(worktree);
if let Some(existing) = read_and_clean_stale(worktree) {
if existing.pid != std::process::id() {
return Err(AcquireError::ForeignLock(existing));
}
}
let entry = LockEntry {
version: LOCK_VERSION,
pid: std::process::id(),
started_at: now_epoch_seconds(),
cmd: cmd.to_string(),
};
let json = serde_json::to_string(&entry)?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
cleanup_stale_tmp_files(parent);
}
let tmp = path.with_file_name(format!("{}.tmp.{}", LOCK_FILENAME, std::process::id()));
{
use std::io::Write;
let mut f = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(&tmp)?;
f.write_all(json.as_bytes())?;
f.sync_all().ok();
}
fs::rename(&tmp, &path)?;
if let Ok(raw) = fs::read_to_string(&path) {
if let Ok(final_entry) = serde_json::from_str::<LockEntry>(&raw) {
if final_entry.pid != std::process::id() {
return Err(AcquireError::ForeignLock(final_entry));
}
}
}
Ok(SessionLock {
path,
owner_pid: std::process::id(),
})
}
pub fn read_and_clean_stale(worktree: &Path) -> Option<LockEntry> {
let path = lock_path(worktree);
let raw = fs::read_to_string(&path).ok()?;
let entry: LockEntry = serde_json::from_str(&raw).ok()?;
if entry.version != LOCK_VERSION {
return Some(entry);
}
#[cfg(unix)]
let alive = pid_alive(entry.pid);
#[cfg(not(unix))]
let alive = match fs::metadata(&path).and_then(|m| m.modified()) {
Ok(mtime) => std::time::SystemTime::now()
.duration_since(mtime)
.map(|age| age < STALE_TTL)
.unwrap_or(true),
Err(_) => true,
};
if alive {
Some(entry)
} else {
let _ = fs::remove_file(&path);
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn make_worktree() -> TempDir {
let dir = TempDir::new().unwrap();
fs::create_dir_all(dir.path().join(".git")).unwrap();
dir
}
#[test]
fn acquire_writes_file_and_drop_removes_it() {
let wt = make_worktree();
let path = wt.path().join(".git").join(LOCK_FILENAME);
{
let _lock = acquire(wt.path(), "test").unwrap();
assert!(path.exists());
}
assert!(!path.exists());
}
#[test]
fn read_returns_entry_for_live_pid() {
let wt = make_worktree();
let _lock = acquire(wt.path(), "shell").unwrap();
let entry = read_and_clean_stale(wt.path()).unwrap();
assert_eq!(entry.pid, std::process::id());
assert_eq!(entry.cmd, "shell");
}
#[cfg(unix)]
#[test]
fn read_removes_stale_lockfile() {
let wt = make_worktree();
let path = wt.path().join(".git").join(LOCK_FILENAME);
let entry = LockEntry {
version: LOCK_VERSION,
pid: 999_999_999,
started_at: 0,
cmd: "ghost".to_string(),
};
fs::write(&path, serde_json::to_string(&entry).unwrap()).unwrap();
assert!(read_and_clean_stale(wt.path()).is_none());
assert!(!path.exists());
}
#[test]
fn acquire_does_not_leave_tmp_file_behind() {
let wt = make_worktree();
let _lock = acquire(wt.path(), "shell").unwrap();
let git_dir = wt.path().join(".git");
let entries: Vec<_> = fs::read_dir(&git_dir)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
let tmp_files: Vec<_> = entries
.iter()
.filter(|n| n.starts_with("gw-session.lock.tmp."))
.collect();
assert!(tmp_files.is_empty(), "tmp files leaked: {:?}", tmp_files);
assert!(entries.iter().any(|n| n == "gw-session.lock"));
}
#[test]
fn lock_dir_follows_gitdir_indicator_when_dot_git_is_file() {
let root = TempDir::new().unwrap();
let real_gitdir = root.path().join("main.git/worktrees/feature");
fs::create_dir_all(&real_gitdir).unwrap();
let wt = root.path().join("feature");
fs::create_dir_all(&wt).unwrap();
fs::write(
wt.join(".git"),
format!("gitdir: {}\n", real_gitdir.display()),
)
.unwrap();
let dir = lock_dir(&wt);
assert_eq!(dir, real_gitdir);
let _lock = acquire(&wt, "shell").unwrap();
assert!(real_gitdir.join(LOCK_FILENAME).exists());
let entry = read_and_clean_stale(&wt).unwrap();
assert_eq!(entry.pid, std::process::id());
}
#[cfg(unix)]
#[test]
fn drop_does_not_remove_lockfile_owned_by_another_process() {
let wt = make_worktree();
let lock = acquire(wt.path(), "shell").unwrap();
let entry = LockEntry {
version: LOCK_VERSION,
pid: unsafe { libc::getppid() } as u32,
started_at: 0,
cmd: "other".to_string(),
};
let path = wt.path().join(".git").join(LOCK_FILENAME);
fs::write(&path, serde_json::to_string(&entry).unwrap()).unwrap();
drop(lock);
assert!(
path.exists(),
"foreign-owned lockfile was incorrectly removed"
);
let _ = fs::remove_file(&path);
}
#[cfg(unix)]
#[test]
fn acquire_fails_when_live_lock_from_other_pid() {
let wt = make_worktree();
let path = wt.path().join(".git").join(LOCK_FILENAME);
let other_pid = unsafe { libc::getppid() } as u32;
let entry = LockEntry {
version: LOCK_VERSION,
pid: other_pid,
started_at: 0,
cmd: "other".to_string(),
};
fs::write(&path, serde_json::to_string(&entry).unwrap()).unwrap();
match acquire(wt.path(), "shell") {
Err(AcquireError::ForeignLock(e)) => assert_eq!(e.pid, other_pid),
Err(e) => panic!("expected ForeignLock, got {:?}", e),
Ok(_) => panic!("expected ForeignLock, got Ok"),
}
}
#[test]
fn foreign_version_lockfile_is_not_cleaned() {
let wt = make_worktree();
let path = wt.path().join(".git").join(LOCK_FILENAME);
let raw = serde_json::json!({
"pid": 999_999_999u32,
"started_at": 0,
"cmd": "future-gw"
});
fs::write(&path, raw.to_string()).unwrap();
let entry = read_and_clean_stale(wt.path()).expect("foreign-version entry preserved");
assert_eq!(entry.version, 0);
assert!(
path.exists(),
"foreign-version lockfile must not be cleaned"
);
}
#[cfg(unix)]
#[test]
fn cleanup_stale_tmp_files_removes_dead_pids() {
let wt = make_worktree();
let git = wt.path().join(".git");
let dead = git.join(format!("{}.tmp.{}", LOCK_FILENAME, 999_999_999u32));
fs::write(&dead, "stale").unwrap();
cleanup_stale_tmp_files(&git);
assert!(!dead.exists(), "dead-pid tmp file should be removed");
}
}