use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
pub const SLOT_PREFIX: &str = "qed-worktree-slot-";
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
struct SlotRecord {
#[serde(default, skip_serializing_if = "Option::is_none")]
retained_by: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct Registry {
#[serde(default)]
slots: BTreeMap<String, SlotRecord>,
}
fn registry_dir() -> PathBuf {
std::env::temp_dir().join("qed-worktree-slots")
}
fn camp_slug(camp_root: &Path) -> String {
let canon = std::fs::canonicalize(camp_root).unwrap_or_else(|_| camp_root.to_path_buf());
let name = canon
.file_name()
.map(|n| sanitize(&n.to_string_lossy()))
.unwrap_or_else(|| "camp".to_string());
let hash = blake3::hash(canon.to_string_lossy().as_bytes()).to_hex();
format!("{name}-{}", &hash[..8])
}
fn sanitize(s: &str) -> String {
s.chars()
.map(|c| if c.is_ascii_alphanumeric() || c == '_' { c } else { '-' })
.collect()
}
pub fn slot_path(camp_root: &Path, pipeline: &str, n: u32) -> PathBuf {
std::env::temp_dir().join(format!(
"{SLOT_PREFIX}{}-{}-{n}",
camp_slug(camp_root),
sanitize(pipeline)
))
}
pub fn is_slot_path(path: &Path) -> bool {
path.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with(SLOT_PREFIX))
}
struct RegistryLock(std::fs::File);
impl RegistryLock {
fn acquire(camp_root: &Path) -> Result<Self, String> {
let dir = registry_dir();
std::fs::create_dir_all(&dir).map_err(|e| format!("create {}: {e}", dir.display()))?;
let path = dir.join(format!("{}.lock", camp_slug(camp_root)));
let file = std::fs::OpenOptions::new()
.create(true)
.read(true)
.write(true)
.truncate(false)
.open(&path)
.map_err(|e| format!("open {}: {e}", path.display()))?;
file.lock().map_err(|e| format!("lock {}: {e}", path.display()))?;
Ok(Self(file))
}
}
impl Drop for RegistryLock {
fn drop(&mut self) {
let _ = self.0.unlock();
}
}
fn registry_path(camp_root: &Path) -> PathBuf {
registry_dir().join(format!("{}.json", camp_slug(camp_root)))
}
fn load(camp_root: &Path) -> Registry {
std::fs::read_to_string(registry_path(camp_root))
.ok()
.and_then(|raw| serde_json::from_str(&raw).ok())
.unwrap_or_default()
}
fn save(camp_root: &Path, reg: &Registry) -> Result<(), String> {
let path = registry_path(camp_root);
let body = serde_json::to_string_pretty(reg).map_err(|e| e.to_string())? + "\n";
std::fs::write(&path, body).map_err(|e| format!("write {}: {e}", path.display()))
}
fn key(path: &Path) -> String {
path.to_string_lossy().into_owned()
}
fn registered(camp_root: &Path) -> BTreeMap<PathBuf, Option<String>> {
let mut out = BTreeMap::new();
let Ok(o) = std::process::Command::new("git")
.current_dir(camp_root)
.args(["worktree", "list", "--porcelain"])
.output()
else {
return out;
};
let text = String::from_utf8_lossy(&o.stdout);
let mut current: Option<PathBuf> = None;
for line in text.lines() {
if let Some(p) = line.strip_prefix("worktree ") {
let p = PathBuf::from(p);
let canon = std::fs::canonicalize(&p).unwrap_or(p);
out.insert(canon.clone(), None);
current = Some(canon);
} else if line == "locked" || line.starts_with("locked ") {
if let Some(c) = ¤t {
let reason = line.strip_prefix("locked").unwrap_or("").trim().to_string();
out.insert(c.clone(), Some(reason));
}
}
}
out
}
fn canonical(path: &Path) -> PathBuf {
std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}
fn lock_is_live(reason: &str) -> bool {
match crate::runner::parse_worktree_lock_reason(reason) {
Some((_, pid)) => pid_alive(pid),
None => true,
}
}
fn pid_alive(pid: u32) -> bool {
if pid == std::process::id() {
return true;
}
#[cfg(unix)]
{
std::process::Command::new("kill")
.args(["-0", &pid.to_string()])
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(true)
}
#[cfg(not(unix))]
{
true
}
}
fn git(dir: &Path, args: &[&str]) -> Result<(), String> {
let out = std::process::Command::new("git")
.current_dir(dir)
.args(args)
.output()
.map_err(|e| format!("spawn git: {e}"))?;
if out.status.success() {
Ok(())
} else {
Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
}
}
#[derive(Debug)]
pub struct SlotLease {
pub path: PathBuf,
pub reused: bool,
}
pub fn acquire(
camp_root: &Path,
pipeline: &str,
slots: u32,
git_ref: &str,
run_id: &str,
) -> Result<Option<SlotLease>, String> {
if slots == 0 {
return Ok(None);
}
let _guard = RegistryLock::acquire(camp_root)?;
let mut reg = load(camp_root);
let _ = git(camp_root, &["worktree", "prune"]);
let worktrees = registered(camp_root);
let lock_reason = crate::runner::worktree_lock_reason(run_id, std::process::id());
for n in 0..slots {
let path = slot_path(camp_root, pipeline, n);
let k = key(&path);
if !path.exists() {
reg.slots.remove(&k);
}
if reg.slots.get(&k).is_some_and(|r| r.retained_by.is_some()) {
continue;
}
match worktrees.get(&canonical(&path)) {
Some(Some(reason)) if lock_is_live(reason) => continue,
Some(lock) => {
if lock.is_some() {
let _ = git(camp_root, &["worktree", "unlock", &path.to_string_lossy()]);
}
git(&path, &["checkout", "--force", "--detach", git_ref])
.map_err(|e| format!("reposition slot {} at {git_ref}: {e}", path.display()))?;
git(&path, &["clean", "-fd"])
.map_err(|e| format!("clean slot {}: {e}", path.display()))?;
git(camp_root, &["worktree", "lock", "--reason", &lock_reason, &path.to_string_lossy()])
.map_err(|e| format!("lock slot {}: {e}", path.display()))?;
save(camp_root, ®)?;
return Ok(Some(SlotLease { path, reused: true }));
}
None if path.exists() => {
tracing::warn!(
path = %path.display(),
"qed: worktree slot path exists but is not a registered worktree; skipping it"
);
continue;
}
None => {
git(
camp_root,
&["worktree", "add", "--force", "--detach", &path.to_string_lossy(), git_ref],
)
.map_err(|e| format!("git worktree add slot {} at {git_ref}: {e}", path.display()))?;
git(camp_root, &["worktree", "lock", "--reason", &lock_reason, &path.to_string_lossy()])
.map_err(|e| format!("lock slot {}: {e}", path.display()))?;
save(camp_root, ®)?;
return Ok(Some(SlotLease { path, reused: false }));
}
}
}
save(camp_root, ®)?;
Ok(None)
}
pub fn set_retained(camp_root: &Path, path: &Path, run_id: Option<&str>) -> Result<(), String> {
let _guard = RegistryLock::acquire(camp_root)?;
let mut reg = load(camp_root);
match run_id {
Some(id) => {
reg.slots.insert(key(path), SlotRecord { retained_by: Some(id.to_string()) });
}
None => {
reg.slots.remove(&key(path));
}
}
save(camp_root, ®)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn slot_paths_are_stable_and_distinct_per_pipeline_and_index() {
let camp = tempfile::TempDir::new().unwrap();
let a0 = slot_path(camp.path(), "yah-cli-release", 0);
assert_eq!(a0, slot_path(camp.path(), "yah-cli-release", 0));
assert_ne!(a0, slot_path(camp.path(), "yah-cli-release", 1));
assert_ne!(a0, slot_path(camp.path(), "other", 0));
assert!(is_slot_path(&a0));
assert!(!is_slot_path(Path::new("/tmp/qed-worktree-abc")));
let other = tempfile::TempDir::new().unwrap();
assert_ne!(a0, slot_path(other.path(), "yah-cli-release", 0), "camps never share");
}
}