use std::fmt;
use std::fs::File;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use filetime::FileTime;
use crate::branch::Verb;
use crate::error::{self, Result};
use crate::{gate, git, home, ids, lock, workspace};
pub const DEFAULT_MIN_AGE_HOURS: &str = "24";
pub fn hours(raw: &str) -> std::result::Result<Duration, String> {
let value: f64 = raw
.trim()
.parse()
.map_err(|_| format!("{raw:?} is not a number of hours"))?;
Duration::try_from_secs_f64(value * 3600.0)
.map_err(|_| format!("{raw:?} is not a number of hours at or above zero"))
}
pub fn run(dry_run: bool, min_age: Duration) -> Result<Report> {
let root = home::workspaces_dir()?;
let mut report = Report {
root: root.clone(),
dry_run,
min_age,
examined: Vec::new(),
skipped: Vec::new(),
reclaimed: Vec::new(),
retained: Vec::new(),
};
match std::fs::read_dir(&root) {
Ok(entries) => {
for entry in entries {
let entry = match entry {
Ok(entry) => entry,
Err(e) => {
report.skipped.push(Skipped {
path: root.clone(),
reason: format!("an entry under it could not be read: {e}"),
});
continue;
}
};
let name = entry.file_name().to_string_lossy().into_owned();
if Verb::ALL.iter().any(|verb| verb.runs() == name) {
continue;
}
report.skipped.push(Skipped {
path: entry.path(),
reason: outside_this_verb(&entry.path()),
});
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(error::at("read the workspaces under", &root)(e)),
}
report
.skipped
.sort_by(|a, b| a.path.cmp(&b.path).then(a.reason.cmp(&b.reason)));
let mut families: Vec<Verb> = Verb::ALL.to_vec();
families.sort_by_key(|verb| verb.runs());
for verb in families {
let family = root.join(verb.runs());
let entries = match std::fs::read_dir(&family) {
Ok(entries) => entries,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
report.examined.push(Examined {
name: verb.runs(),
path: family,
roots: None,
unreadable: Vec::new(),
});
continue;
}
Err(e) => {
report.skipped.push(Skipped {
path: family.clone(),
reason: format!("cannot read this family of run roots: {e}"),
});
continue;
}
};
let mut run_roots: Vec<PathBuf> = Vec::new();
let mut unreadable: Vec<String> = Vec::new();
for entry in entries {
match entry {
Ok(entry) => run_roots.push(entry.path()),
Err(e) => unreadable.push(e.to_string()),
}
}
run_roots.sort();
unreadable.sort();
report.examined.push(Examined {
name: verb.runs(),
path: family,
roots: Some(run_roots.len()),
unreadable,
});
for run_root in run_roots {
match judge(&run_root, min_age)? {
Verdict::Retain(why) => report.retained.push(Retained {
path: run_root,
why,
}),
Verdict::Reclaim(lease) => reclaim(&mut report, run_root, lease)?,
}
}
}
Ok(report)
}
fn not_this_crates(run_root: &Path) -> Option<&'static str> {
if !names_a_run(run_root) {
return Some("its name is not one this crate composes for a run root");
}
let clone = run_root.join("clone");
if !git::is_repo(&clone) {
return Some("it holds no run clone this crate would have cut");
}
if !clone.join(".git/objects/info/alternates").is_file() {
return Some(
"the repository under it borrows no lender's objects, and every run clone \
this crate cuts is a shared clone that does",
);
}
None
}
fn names_a_run(run_root: &Path) -> bool {
let Some(name) = run_root.file_name().and_then(|name| name.to_str()) else {
return false;
};
let mut parts = name.rsplitn(4, '-');
let hex = |part: Option<&str>| {
part.is_some_and(|part| !part.is_empty() && part.chars().all(|c| c.is_ascii_hexdigit()))
};
let (counter, nanos, pid) = (parts.next(), parts.next(), parts.next());
hex(counter) && hex(nanos) && hex(pid) && parts.next().is_some_and(|slug| !slug.is_empty())
}
fn shows_it_may_empty(run_root: &Path) -> bool {
run_root.parent().is_some_and(shows_it_may_unlink_from) && shows_it_may_empty_within(run_root)
}
fn shows_it_may_empty_within(path: &Path) -> bool {
let Ok(meta) = std::fs::symlink_metadata(path) else {
return false;
};
if !meta.is_dir() {
return true;
}
if !shows_it_may_unlink_from(path) {
return false;
}
let Ok(entries) = std::fs::read_dir(path) else {
return false;
};
entries
.map(|entry| entry.map(|entry| shows_it_may_empty_within(&entry.path())))
.all(|answer| answer.unwrap_or(false))
}
fn shows_it_may_unlink_from(directory: &Path) -> bool {
!hands_unlinks_to_owners(directory) && probe(directory).is_ok()
}
#[cfg(unix)]
fn hands_unlinks_to_owners(directory: &Path) -> bool {
use std::os::unix::fs::PermissionsExt;
std::fs::symlink_metadata(directory).is_ok_and(|meta| meta.permissions().mode() & 0o1000 != 0)
}
#[cfg(not(unix))]
fn hands_unlinks_to_owners(_directory: &Path) -> bool {
false
}
fn probe(directory: &Path) -> std::io::Result<()> {
let before = std::fs::metadata(directory)?;
let (accessed, modified) = (
FileTime::from_last_access_time(&before),
FileTime::from_last_modification_time(&before),
);
filetime::set_file_times(directory, accessed, modified)?;
let entry = directory.join(format!(".sweep-probe-{}", ids::unique()));
std::fs::create_dir(&entry)?;
std::fs::remove_dir(&entry)?;
let file = directory.join(format!(".sweep-probe-{}", ids::unique()));
File::create(&file)?;
std::fs::remove_file(&file)?;
filetime::set_file_times(directory, accessed, modified)
}
fn outside_this_verb(path: &Path) -> String {
if path.join("runs").is_dir() {
return "the per-run lifecycle clone root, which `onevcs session open` keeps as a \
bounded recovery history so a dead run's branch stays reachable; this verb \
does not reach into it"
.to_owned();
}
"not a family this verb cuts run roots under".to_owned()
}
enum Verdict {
Reclaim(lock::Guard),
Retain(Kept),
}
fn judge(run_root: &Path, min_age: Duration) -> Result<Verdict> {
if !run_root.is_dir() {
return Ok(Verdict::Retain(Kept::OwnerUnproven(
"it is not a directory, and every run root is one",
)));
}
if let Some(missing) = not_this_crates(run_root) {
return Ok(Verdict::Retain(Kept::OwnerUnproven(missing)));
}
let Some(lease) = lock::try_exclusive(&workspace::occupancy_identity(run_root))? else {
return Ok(Verdict::Retain(Kept::Occupied));
};
if !gate::has_recorded_verdict(run_root) {
return Ok(Verdict::Retain(Kept::NoVerdict));
}
let age = SystemTime::now()
.duration_since(last_written(run_root))
.unwrap_or(Duration::ZERO);
if age < min_age {
return Ok(Verdict::Retain(Kept::Fresh {
age,
floor: min_age,
}));
}
if !shows_it_may_empty(run_root) {
return Ok(Verdict::Retain(Kept::Unproven));
}
Ok(Verdict::Reclaim(lease))
}
fn reclaim(report: &mut Report, run_root: PathBuf, lease: lock::Guard) -> Result<()> {
let bytes = size_of(&run_root);
if report.dry_run {
report.reclaimed.push(Reclaimed {
path: run_root,
bytes,
});
return Ok(());
}
if let Err(e) = std::fs::remove_dir_all(&run_root) {
return Err(error::at("remove the reclaimable workspace at", &run_root)(
e,
));
}
report.reclaimed.push(Reclaimed {
path: run_root,
bytes,
});
drop(lease);
Ok(())
}
fn last_written(path: &Path) -> SystemTime {
let mut newest = modified(path);
if std::fs::symlink_metadata(path).is_ok_and(|meta| meta.is_dir()) {
match std::fs::read_dir(path) {
Ok(entries) => {
for entry in entries.flatten() {
newest = newest.max(last_written(&entry.path()));
}
}
Err(_) => return SystemTime::now(),
}
}
newest
}
fn modified(path: &Path) -> SystemTime {
std::fs::symlink_metadata(path)
.and_then(|meta| meta.modified())
.unwrap_or_else(|_| SystemTime::now())
}
fn size_of(path: &Path) -> u64 {
let Ok(meta) = std::fs::symlink_metadata(path) else {
return 0;
};
if !meta.is_dir() {
return meta.len();
}
let Ok(entries) = std::fs::read_dir(path) else {
return 0;
};
entries.flatten().map(|entry| size_of(&entry.path())).sum()
}
struct Examined {
name: &'static str,
path: PathBuf,
roots: Option<usize>,
unreadable: Vec<String>,
}
struct Skipped {
path: PathBuf,
reason: String,
}
struct Reclaimed {
path: PathBuf,
bytes: u64,
}
struct Retained {
path: PathBuf,
why: Kept,
}
const OCCUPIED: &str =
"a live session holds its occupancy lease; nothing was removed and nothing was terminated";
const NO_VERDICT: &str =
"its gate has recorded no verdict under {}, so nothing here can say the publication finished";
const UNPROVEN: &str = "something it holds, or the directory it sits in, did not answer that this user may write into it — so removing it belongs to whoever can, and nothing under it was touched";
enum Kept {
OwnerUnproven(&'static str),
Occupied,
NoVerdict,
Fresh { age: Duration, floor: Duration },
Unproven,
}
impl Kept {
fn describe(&self) -> String {
match self {
Kept::OwnerUnproven(what) => format!("its owner cannot be proven: {what}"),
Kept::Occupied => OCCUPIED.to_owned(),
Kept::NoVerdict => NO_VERDICT.replace("{}", gate::PRESERVED_LOG_DIRNAME),
Kept::Fresh { age, floor } => format!(
"it was written {} ago, inside the {} the age floor leaves alone",
describe_duration(*age),
describe_duration(*floor),
),
Kept::Unproven => format!("this host cannot show it may remove it: {UNPROVEN}"),
}
}
}
pub struct Report {
root: PathBuf,
dry_run: bool,
min_age: Duration,
examined: Vec<Examined>,
skipped: Vec<Skipped>,
reclaimed: Vec<Reclaimed>,
retained: Vec<Retained>,
}
impl Report {
fn bytes(&self) -> u64 {
self.reclaimed.iter().map(|entry| entry.bytes).sum()
}
}
impl fmt::Display for Report {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let verb = match self.dry_run {
true => "would reclaim",
false => "reclaimed",
};
writeln!(
f,
"onevcs sweep: {verb} {} workspace(s), {}, keeping anything written inside the last {}.",
self.reclaimed.len(),
describe_bytes(self.bytes()),
describe_duration(self.min_age),
)?;
if self.dry_run {
writeln!(f, "Nothing was removed: this was a rehearsal.")?;
}
writeln!(f, "Families examined:")?;
for family in &self.examined {
match family.roots {
Some(roots) => writeln!(
f,
" {} — {roots} run root(s) in {}",
family.name,
family.path.display()
)?,
None => writeln!(
f,
" {} — nothing has cut a run root at {} yet",
family.name,
family.path.display()
)?,
}
for unreadable in &family.unreadable {
writeln!(
f,
" …and one entry of it this sweep could not examine: {unreadable}"
)?;
}
}
writeln!(f, "Families not examined:")?;
if self.skipped.is_empty() {
writeln!(f, " none")?;
}
for skipped in &self.skipped {
writeln!(f, " {} — {}", skipped.path.display(), skipped.reason)?;
}
writeln!(f, "Reclaimed:")?;
if self.reclaimed.is_empty() {
writeln!(f, " none")?;
}
for reclaimed in &self.reclaimed {
writeln!(
f,
" {} — {}",
reclaimed.path.display(),
describe_bytes(reclaimed.bytes)
)?;
}
writeln!(f, "Retained:")?;
if self.retained.is_empty() {
writeln!(f, " none")?;
}
for retained in &self.retained {
writeln!(
f,
" {} — {}",
retained.path.display(),
retained.why.describe()
)?;
}
write!(
f,
"This answers for the publication and recovery workspaces onevcs owns under {}, \
and for nothing else on this host.",
self.root.display()
)
}
}
fn describe_bytes(bytes: u64) -> String {
const UNITS: [(u64, &str); 3] = [(1_000_000_000, "GB"), (1_000_000, "MB"), (1_000, "kB")];
for (scale, unit) in UNITS {
if bytes >= scale {
#[expect(
clippy::cast_precision_loss,
reason = "a byte count rendered to one decimal place is prose, not a measurement \
anything decides on"
)]
let value = bytes as f64 / scale as f64;
return format!("{value:.1} {unit}");
}
}
format!("{bytes} bytes")
}
fn describe_duration(window: Duration) -> String {
let seconds = window.as_secs();
match (seconds / 3600, (seconds % 3600) / 60) {
(0, 0) => format!("{seconds} second(s)"),
(0, minutes) => format!("{minutes} minute(s)"),
(hours, 0) => format!("{hours} hour(s)"),
(hours, minutes) => format!("{hours} hour(s) {minutes} minute(s)"),
}
}