use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
pub const IGNORE_ENV: &str = "WSB_IGNORE_WORKTREE_LOCK";
pub const WRITING_ENV: &str = "WSB_WORKTREE_WRITER";
pub const WRITERS_WAIT_CAP: Duration = Duration::from_secs(30 * 60);
pub const WAIT_CAP: Duration = Duration::from_secs(5 * 60);
const POLL: Duration = Duration::from_secs(2);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Holder {
pub command: String,
pub argv: Vec<String>,
pub pid: u32,
pub host: String,
pub taken_at: String,
pub reason: String,
pub protects: String,
}
pub fn path(root: &Path) -> Result<PathBuf, String> {
Ok(crate::local_state::LocalState::of(root).map_err(|e| format!("{e:#}"))?.lock())
}
fn writers(root: &Path) -> Result<PathBuf, String> {
Ok(crate::local_state::LocalState::of(root).map_err(|e| format!("{e:#}"))?.writers())
}
#[derive(Debug)]
pub struct Held {
path: PathBuf,
pid: u32,
}
impl Held {
pub fn path(&self) -> &Path {
&self.path
}
}
impl Drop for Held {
fn drop(&mut self) {
if read(&self.path).ok().flatten().is_some_and(|holder| holder.pid == self.pid) {
let _ = std::fs::remove_file(&self.path);
}
}
}
pub fn take(root: &Path, command: &str, reason: &str) -> Result<Held, String> {
take_within(root, command, reason, WAIT_CAP)
}
fn take_within(root: &Path, command: &str, reason: &str, cap: Duration) -> Result<Held, String> {
let lock = path(root)?;
if let Some(parent) = lock.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("cannot create {}: {e}", parent.display()))?;
}
let holder = Holder {
command: command.to_string(),
argv: std::env::args().collect(),
pid: std::process::id(),
host: host_name(),
taken_at: chrono::Utc::now().to_rfc3339(),
reason: reason.to_string(),
protects: format!("the worktree at {}, while it is snapshotted", root.display()),
};
let text = serde_json::to_string_pretty(&holder).expect("a holder serializes");
let started = Instant::now();
loop {
wait_until_free_since(root, command, started, cap)?;
if create_exclusively(&lock, &text)? {
let held = Held { path: lock, pid: holder.pid };
wait_for_writers(root, command, Instant::now(), WRITERS_WAIT_CAP)?;
return Ok(held);
}
}
}
#[derive(Debug)]
pub struct Writing {
file: PathBuf,
}
impl Drop for Writing {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.file);
}
}
pub fn begin_writing(root: &Path, operation: &str) -> Result<Option<Writing>, String> {
if std::env::var_os(WRITING_ENV).is_some() {
return Ok(None);
}
let ignore = std::env::var_os(IGNORE_ENV).is_some();
let dir = writers(root)?;
std::fs::create_dir_all(&dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?;
let pid = std::process::id();
let record = Holder {
command: operation.to_string(),
argv: std::env::args().collect(),
pid,
host: host_name(),
taken_at: chrono::Utc::now().to_rfc3339(),
reason: format!("{operation} is writing into the tree"),
protects: format!("the worktree at {}, while it is written", root.display()),
};
let text = serde_json::to_string_pretty(&record).expect("a holder serializes");
let file = dir.join(format!("{}-{pid}.json", host_name()));
loop {
wait_until_free_with(root, operation, ignore, WAIT_CAP)?;
std::fs::write(&file, &text).map_err(|e| format!("cannot write {}: {e}", file.display()))?;
let writing = Writing { file: file.clone() };
if ignore || read(&path(root)?)?.is_none() {
std::env::set_var(WRITING_ENV, pid.to_string());
return Ok(Some(writing));
}
drop(writing);
}
}
fn wait_for_writers(root: &Path, command: &str, started: Instant, cap: Duration) -> Result<(), String> {
let dir = writers(root)?;
let mut said = false;
loop {
let writers = live_writers(&dir)?;
let Some(first) = writers.first() else {
return Ok(());
};
if started.elapsed() >= cap {
return Err(format!(
"{command}: {} (pid {}) is still writing into the worktree after {}s, so it cannot be \
snapshotted. Its registration is {}.",
first.command,
first.pid,
started.elapsed().as_secs(),
dir.display()
));
}
if !said {
crate::outln!(
"{command}: waiting for {} (pid {}) to finish writing into the worktree",
first.command,
first.pid
);
said = true;
}
std::thread::sleep(POLL);
}
}
fn live_writers(dir: &Path) -> Result<Vec<Holder>, String> {
let entries = match std::fs::read_dir(dir) {
Ok(entries) => entries,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(format!("cannot read {}: {e}", dir.display())),
};
let mut live = Vec::new();
for entry in entries {
let entry = entry.map_err(|e| format!("cannot read {}: {e}", dir.display()))?;
let Some(writer) = read(&entry.path())? else {
continue;
};
if writer.host == host_name() && !crate::ops::process::is_alive(writer.pid) {
let _ = std::fs::remove_file(entry.path());
continue;
}
live.push(writer);
}
Ok(live)
}
fn create_exclusively(lock: &Path, text: &str) -> Result<bool, String> {
match std::fs::OpenOptions::new().write(true).create_new(true).open(lock) {
Ok(mut file) => {
use std::io::Write;
file.write_all(text.as_bytes())
.map_err(|e| format!("cannot write {}: {e}", lock.display()))?;
Ok(true)
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(false),
Err(e) => Err(format!("cannot take the worktree lock {}: {e}", lock.display())),
}
}
pub fn wait_until_free(root: &Path, operation: &str) -> Result<(), String> {
wait_until_free_with(root, operation, std::env::var_os(IGNORE_ENV).is_some(), WAIT_CAP)
}
fn wait_until_free_with(root: &Path, operation: &str, ignore: bool, cap: Duration) -> Result<(), String> {
if ignore {
if let Some(holder) = read(&path(root)?)? {
crate::outln!(
"{operation}: ignoring the worktree lock held by {} (pid {}, {}) — --ignore-worktree-lock",
holder.command,
holder.pid,
holder.reason
);
}
return Ok(());
}
wait_until_free_since(root, operation, Instant::now(), cap)
}
fn wait_until_free_since(root: &Path, operation: &str, started: Instant, cap: Duration) -> Result<(), String> {
let lock = path(root)?;
let mut said = false;
loop {
let Some(holder) = read(&lock)? else {
return Ok(());
};
if holder.host == host_name() && !crate::ops::process::is_alive(holder.pid) {
return Err(format!(
"{operation}: the worktree lock is stale — {} (pid {}) took it at {} to {}, \
and that process is gone. Delete {} to clear it.",
holder.command,
holder.pid,
holder.taken_at,
holder.reason,
lock.display()
));
}
if started.elapsed() >= cap {
return Err(format!(
"{operation}: the worktree is still locked after {}s — {} (pid {} on {}) took it at {} to {}. \
Delete {} to clear it, or pass --ignore-worktree-lock.",
started.elapsed().as_secs(),
holder.command,
holder.pid,
holder.host,
holder.taken_at,
holder.reason,
lock.display()
));
}
if !said {
crate::outln!(
"{operation}: waiting on the worktree lock — {} (pid {}) is {}. \
Deleting {} clears it.",
holder.command,
holder.pid,
holder.reason,
lock.display()
);
said = true;
}
std::thread::sleep(POLL);
}
}
fn read(lock: &Path) -> Result<Option<Holder>, String> {
match std::fs::read_to_string(lock) {
Ok(text) => serde_json::from_str(&text).map(Some).map_err(|e| {
format!(
"the workspace lock {} is not one wsb wrote ({e}); delete it to clear it",
lock.display()
)
}),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(format!("cannot read the workspace lock {}: {e}", lock.display())),
}
}
#[cfg(unix)]
fn host_name() -> String {
let mut buffer = [0u8; 256];
let ok = unsafe { libc::gethostname(buffer.as_mut_ptr().cast(), buffer.len()) } == 0;
if !ok {
return String::from("unknown");
}
let end = buffer.iter().position(|b| *b == 0).unwrap_or(buffer.len());
String::from_utf8_lossy(&buffer[..end]).into_owned()
}
#[cfg(windows)]
fn host_name() -> String {
std::env::var("COMPUTERNAME").unwrap_or_else(|_| String::from("unknown"))
}
#[cfg(test)]
#[path = "worktree_lock_tests.rs"]
mod tests;