use std::collections::BTreeMap;
use std::fmt;
use std::fs::File;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use filetime::FileTime;
use serde::ser::{SerializeStruct, Serializer};
use serde::Serialize;
use crate::branch::Verb;
use crate::error::{self, Result};
use crate::landed;
use crate::processes::{self, Holder};
use crate::provenance;
use crate::store::{self, Resolution};
use crate::{git, guidance, home, ids, lock, merge_path, workspace};
pub const SCHEMA_VERSION: u32 = 1;
const VERB: &str = "onevcs sweep";
pub const RETAINED_UNPUBLISHED: usize = workspace::RETAINED_DEAD_RUNS;
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(),
not_examined: Vec::new(),
reclaimed: Vec::new(),
retained: Vec::new(),
records: Vec::new(),
};
let landings = landings()?;
let identities = identities(&landings.registry)?;
let mut outside: Vec<PathBuf> = Vec::new();
match std::fs::read_dir(&root) {
Ok(entries) => {
for entry in entries {
let entry = match entry {
Ok(entry) => entry,
Err(e) => {
report.not_examined.push(NotExamined {
family: ROOT,
path: root.clone(),
reason: format!("an entry under it could not be read: {e}"),
owner: format!(
"an operator who can list {}: this verb names every family \
under it once the listing answers",
root.display()
),
});
continue;
}
};
let name = entry.file_name().to_string_lossy().into_owned();
if Verb::ALL.iter().any(|verb| verb.runs() == name) {
continue;
}
outside.push(entry.path());
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(error::at("read the workspaces under", &root)(e)),
}
outside.sort();
for path in outside {
outside_this_verb(&mut report, path, &identities);
}
let mut families: Vec<Verb> = Verb::ALL.to_vec();
families.sort_by_key(|verb| verb.runs());
for verb in families {
family(&mut report, verb, min_age, &landings)?;
}
records(&mut report, dry_run, min_age)?;
Ok(report)
}
fn records(report: &mut Report, dry_run: bool, min_age: Duration) -> Result<()> {
for record in workspace::spent_records()? {
let path = workspace::record_path(&record.token)?;
let age = SystemTime::now()
.duration_since(last_written(&path))
.unwrap_or(Duration::ZERO);
let outcome = if age < min_age {
Forgetting::Fresh {
age,
floor: min_age,
}
} else if dry_run {
Forgetting::Forgotten
} else {
match workspace::forget(&path) {
Ok(()) => Forgetting::Forgotten,
Err(kept) => Forgetting::Kept(kept),
}
};
report.records.push(SessionRecord {
path,
token: record.token,
branch: record.branch,
outcome,
});
}
report.records.sort_by(|a, b| (*a.token).cmp(&b.token));
Ok(())
}
pub fn enforce(verb: Verb) -> Result<()> {
let min_age = hours(DEFAULT_MIN_AGE_HOURS).map_err(error::invalid)?;
let root = home::workspaces_dir()?;
let mut report = Report {
root,
dry_run: false,
min_age,
examined: Vec::new(),
not_examined: Vec::new(),
reclaimed: Vec::new(),
retained: Vec::new(),
records: Vec::new(),
};
family(&mut report, verb, min_age, &landings()?)?;
match report.not_examined.first() {
Some(unread) => Err(error::invalid(format!(
"{}: {}",
unread.path.display(),
unread.reason
))),
None => Ok(()),
}
}
fn family(report: &mut Report, verb: Verb, min_age: Duration, landings: &Landings) -> Result<()> {
let directory = report.root.join(verb.runs());
let entries = match std::fs::read_dir(&directory) {
Ok(entries) => entries,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
report.examined.push(Examined {
name: verb.runs(),
path: directory,
roots: None,
unreadable: Vec::new(),
});
return Ok(());
}
Err(e) => {
report.not_examined.push(NotExamined {
family: verb.runs(),
owner: format!(
"an operator who can list {}: this verb examines the family once its \
listing answers",
directory.display()
),
path: directory,
reason: format!("cannot read this family of run roots: {e}"),
});
return Ok(());
}
};
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: directory,
roots: Some(run_roots.len()),
unreadable,
});
let mut holding: Vec<Holding> = Vec::new();
for run_root in run_roots {
match judge(&run_root, min_age, landings)? {
Verdict::Retain(why) => report.retained.push(Retained {
path: run_root,
why,
}),
Verdict::Reclaim(lease) => reclaim(report, run_root, lease)?,
Verdict::Holds(mut holds) => {
holds.path = run_root;
holding.push(holds);
}
}
}
holding.sort_by_key(|holds| std::cmp::Reverse(holds.written));
for (older, holds) in holding.into_iter().enumerate() {
let Holding {
path,
branches,
lease,
..
} = holds;
if older < RETAINED_UNPUBLISHED {
report.retained.push(Retained {
path,
why: Kept::HoldsUnpublishedWork { branches },
});
} else {
reclaim(report, path, lease)?;
}
}
Ok(())
}
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)
}
const LIFECYCLE_RUNS: &str = "lifecycle-runs";
const POOL: &str = "pool";
const PRESERVED_BRANCHES: &str = "preserved-branches";
const STRAY: &str = "stray";
const ROOT: &str = "root";
fn identities(registry: &crate::registry::Registry) -> Result<BTreeMap<PathBuf, Resolution>> {
let mut known = BTreeMap::new();
for key in registry.identities.keys() {
if let Ok(resolution) = store::resolve(registry, key) {
known.insert(workspace::identity_dir(key)?, resolution);
}
}
Ok(known)
}
fn outside_this_verb(
report: &mut Report,
path: PathBuf,
identities: &BTreeMap<PathBuf, Resolution>,
) {
let known = identities.get(&path);
let repo = known.map_or("REPO".to_owned(), |known| known.alias.clone());
let runs = path.join("runs");
let pool = path.join("pool");
if !runs.is_dir() && !pool.is_dir() && known.is_none() {
report.not_examined.push(NotExamined {
family: STRAY,
path,
reason: "not a family this verb cuts run roots under".to_owned(),
owner: "whoever put it there: it is not onevcs state, no onevcs verb touches it, \
and removing it by hand is theirs"
.to_owned(),
});
return;
}
if runs.is_dir() {
report.not_examined.push(NotExamined {
family: LIFECYCLE_RUNS,
path: runs,
reason: "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(),
owner: format!(
"`onevcs recoverable --repo {repo}` names the branch each retained run left \
and the verb that lands or discards it; the next `onevcs session open` of \
this repository reclaims a run root once nothing holds it"
),
});
}
if pool.is_dir() {
report.not_examined.push(NotExamined {
family: POOL,
path: pool,
reason: "the pool of warm slots a session of this repository may be placed on \
next; this verb does not reach into it"
.to_owned(),
owner: format!(
"`onevcs pool status {repo}` reads the slots; `onevcs pool prune {repo}` \
empties the idle ones"
),
});
}
if let Some(known) = known {
report.not_examined.push(NotExamined {
family: PRESERVED_BRANCHES,
path: known.publication.clone(),
reason: "the unpublished branches sessions of this repository left behind — \
handed back into this checkout, or still only in a run clone under \
runs/ — each of which holds its run root from reclamation; this verb \
lands and discards none of them"
.to_owned(),
owner: format!(
"`onevcs recoverable --repo {repo}` names each one and the verb that lands \
or discards it"
),
});
}
}
enum Verdict {
Reclaim(lock::Guard),
Holds(Holding),
Retain(Kept),
}
struct Holding {
path: PathBuf,
branches: Vec<String>,
written: SystemTime,
lease: lock::Guard,
}
fn judge(run_root: &Path, min_age: Duration, landings: &Landings) -> 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 !merge_path::has_recorded_verdict(run_root) {
return Ok(Verdict::Retain(Kept::NoVerdict));
}
let written = last_written(run_root);
let age = SystemTime::now()
.duration_since(written)
.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));
}
match unpublished_work(&run_root.join("clone"), landings) {
Ok(branches) if branches.is_empty() => Ok(Verdict::Reclaim(lease)),
Ok(branches) => Ok(Verdict::Holds(Holding {
path: run_root.to_path_buf(),
branches,
written,
lease,
})),
Err(_) => Ok(Verdict::Retain(Kept::WorkUnknown)),
}
}
fn unpublished_work(clone: &Path, landings: &Landings) -> Result<Vec<String>> {
let base = git::default_branch(clone, "origin")?;
let known = landings.known(clone, &base);
let asked = git::Asked::borrowing(clone, known.lent.as_deref());
let compared = crate::vcs::judged_against(asked, &base, known.base.as_ref());
let mut holding = Vec::new();
for branch in git::unpublished_branches(clone)? {
let recorded = crate::status::recorded_for(
&landings.streams,
known.identity.as_deref().unwrap_or_default(),
&branch,
None,
);
let recorded = landed::Recorded {
change: recorded.change.or_else(|| {
crate::vcs::change_url_of(asked, &compared, &branch, &landings.trailers)
}),
..recorded
};
let verdict = landed::decide(
asked,
&compared,
known.base.as_ref(),
&branch,
&recorded,
&landings.trailers,
)?;
if !verdict.is_landed() {
holding.push(branch);
}
}
Ok(holding)
}
struct Landings {
registry: crate::registry::Registry,
streams: Vec<crate::status::Recorded>,
trailers: provenance::Trailers,
}
struct Known {
identity: Option<String>,
base: Option<crate::host::Sha>,
lent: Option<PathBuf>,
}
impl Landings {
fn known(&self, clone: &Path, base: &str) -> Known {
let Some(resolution) = git::remote_url(clone, "origin")
.ok()
.and_then(|origin| store::resolve(&self.registry, &origin).ok())
else {
return Known {
identity: None,
base: None,
lent: None,
};
};
Known {
identity: Some(resolution.key),
base: crate::vcs::base_commit(&resolution.publication, base),
lent: git::objects_dir(&resolution.publication).ok(),
}
}
}
fn landings() -> Result<Landings> {
let registry = store::load()?;
let (rules, _source) = crate::policy::load(®istry)?;
Ok(Landings {
trailers: provenance::from_rules(&rules),
streams: crate::status::recorded_streams(&mut Vec::new())?,
registry,
})
}
fn reclaim(report: &mut Report, run_root: PathBuf, lease: lock::Guard) -> Result<()> {
let bytes = size_of(&run_root);
let holding = processes::holding(&run_root);
if report.dry_run {
report.reclaimed.push(Reclaimed {
path: run_root,
bytes,
processes: Signalled::WouldReach(holding.iter().map(Holder::pid).collect()),
});
return Ok(());
}
let mut released: Vec<processes::Pid> = Vec::new();
let mut left: Vec<processes::Pid> = Vec::new();
for outcome in processes::stop(&holding, &run_root) {
match outcome {
processes::Outcome::Released(pid) => released.push(pid),
processes::Outcome::Holding(holder) => left.push(holder.pid()),
}
}
if !left.is_empty() {
report.retained.push(Retained {
path: run_root,
why: Kept::StillRunning { pids: left },
});
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,
processes: Signalled::Released(released),
});
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()
}
#[derive(Serialize)]
struct Examined {
#[serde(rename = "family")]
name: &'static str,
path: PathBuf,
roots: Option<usize>,
unreadable: Vec<String>,
}
#[derive(Serialize)]
struct NotExamined {
family: &'static str,
path: PathBuf,
reason: String,
owner: String,
}
struct SessionRecord {
path: PathBuf,
token: workspace::Token,
branch: workspace::Ref,
outcome: Forgetting,
}
enum Forgetting {
Forgotten,
Fresh { age: Duration, floor: Duration },
Kept(String),
}
impl Forgetting {
fn describe(&self, dry_run: bool) -> String {
match self {
Forgetting::Forgotten if dry_run => "would be forgotten".to_owned(),
Forgetting::Forgotten => "forgotten".to_owned(),
Forgetting::Fresh { age, floor } => format!(
"kept: it was written {} ago, inside the {} the age floor leaves alone",
guidance::describe_duration(*age),
guidance::describe_duration(*floor),
),
Forgetting::Kept(why) => {
format!("kept: this host would not remove it: {why}")
}
}
}
}
#[derive(Serialize)]
struct Reclaimed {
path: PathBuf,
bytes: u64,
processes: Signalled,
}
enum Signalled {
WouldReach(Vec<processes::Pid>),
Released(Vec<processes::Pid>),
}
impl Signalled {
fn describe(&self) -> String {
let (pids, said) = match self {
Signalled::WouldReach(pids) => (pids, WOULD_SIGNAL),
Signalled::Released(pids) => (pids, SIGNALLED),
};
match pids.is_empty() {
true => String::new(),
false => said.replace("{}", &describe_processes(pids)),
}
}
fn pids(&self) -> &[processes::Pid] {
match self {
Signalled::WouldReach(pids) | Signalled::Released(pids) => pids,
}
}
}
impl Serialize for Signalled {
fn serialize<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
self.pids().serialize(serializer)
}
}
const WOULD_SIGNAL: &str = ", and would signal {} working inside it";
const SIGNALLED: &str = ", after signalling {} that then let it go";
#[derive(Serialize)]
struct Retained {
path: PathBuf,
#[serde(serialize_with = "describe_kept")]
why: Kept,
}
fn describe_kept<S: Serializer>(
kept: &Kept,
serializer: S,
) -> std::result::Result<S::Ok, S::Error> {
kept.describe().serialize(serializer)
}
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";
const WORK_UNKNOWN: &str =
"nothing here could ask its clone which of its branches the origin already has, and a \
workspace that may hold the only copy of somebody's work is kept";
enum Kept {
OwnerUnproven(&'static str),
Occupied,
NoVerdict,
Fresh { age: Duration, floor: Duration },
Unproven,
WorkUnknown,
HoldsUnpublishedWork { branches: Vec<String> },
StillRunning { pids: Vec<processes::Pid> },
}
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("{}", merge_path::PRESERVED_LOG_DIRNAME),
Kept::Fresh { age, floor } => format!(
"it was written {} ago, inside the {} the age floor leaves alone",
guidance::describe_duration(*age),
guidance::describe_duration(*floor),
),
Kept::Unproven => format!("this host cannot show it may remove it: {UNPROVEN}"),
Kept::WorkUnknown => WORK_UNKNOWN.to_owned(),
Kept::HoldsUnpublishedWork { branches } => format!(
"its clone holds work no origin has on {branches}, and it is one of the \
{RETAINED_UNPUBLISHED} most recently written workspaces of this family that do",
branches = describe_branches(branches),
),
Kept::StillRunning { pids } => format!(
"{processes} it left running are working inside it still, after being asked to \
stop and then ended, and unlinking files a process holds open frees none of \
them — so nothing under it was removed",
processes = describe_processes(pids),
),
}
}
}
fn describe_branches(branches: &[String]) -> String {
branches
.iter()
.map(|branch| format!("{branch:?}"))
.collect::<Vec<_>>()
.join(", ")
}
fn describe_processes(pids: &[processes::Pid]) -> String {
let listed = pids
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(", ");
format!("{} process(es) ({listed})", pids.len())
}
pub struct Report {
root: PathBuf,
dry_run: bool,
min_age: Duration,
examined: Vec<Examined>,
not_examined: Vec<NotExamined>,
reclaimed: Vec<Reclaimed>,
retained: Vec<Retained>,
records: Vec<SessionRecord>,
}
impl Report {
fn bytes(&self) -> u64 {
self.reclaimed.iter().map(|entry| entry.bytes).sum()
}
fn examined_roots(&self) -> usize {
self.examined
.iter()
.map(|family| family.roots.unwrap_or(0))
.sum()
}
}
#[derive(Serialize)]
struct SessionRecordLine<'a> {
path: &'a Path,
session: &'a workspace::Token,
branch: &'a workspace::Ref,
why: String,
}
#[derive(Serialize)]
struct Totals {
examined_roots: usize,
reclaimed: usize,
reclaimed_bytes: u64,
retained: usize,
}
impl Serialize for Report {
fn serialize<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
let mut report = serializer.serialize_struct("Report", 11)?;
report.serialize_field("schema_version", &SCHEMA_VERSION)?;
report.serialize_field("verb", VERB)?;
report.serialize_field("dry_run", &self.dry_run)?;
report.serialize_field("min_age_hours", &(self.min_age.as_secs_f64() / 3600.0))?;
report.serialize_field("root", &self.root)?;
report.serialize_field("examined", &self.examined)?;
report.serialize_field("not_examined", &self.not_examined)?;
report.serialize_field("reclaimed", &self.reclaimed)?;
report.serialize_field("retained", &self.retained)?;
let records: Vec<SessionRecordLine<'_>> = self
.records
.iter()
.map(|record| SessionRecordLine {
path: &record.path,
session: &record.token,
branch: &record.branch,
why: record.outcome.describe(self.dry_run),
})
.collect();
report.serialize_field("session_records", &records)?;
report.serialize_field(
"totals",
&Totals {
examined_roots: self.examined_roots(),
reclaimed: self.reclaimed.len(),
reclaimed_bytes: self.bytes(),
retained: self.retained.len(),
},
)?;
report.end()
}
}
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()),
guidance::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.not_examined.is_empty() {
writeln!(f, " none")?;
}
for family in &self.not_examined {
writeln!(f, " {} — {}", family.path.display(), family.reason)?;
writeln!(f, " {}, reached by {}", family.family, family.owner)?;
}
writeln!(f, "Reclaimed:")?;
if self.reclaimed.is_empty() {
writeln!(f, " none")?;
}
for reclaimed in &self.reclaimed {
let said = reclaimed.processes.describe();
writeln!(
f,
" {} — {}{said}",
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()
)?;
}
writeln!(f, "Session records with nothing left behind them:")?;
if self.records.is_empty() {
writeln!(f, " none")?;
}
for record in &self.records {
writeln!(
f,
" {} — the session {} on {:?}, {}",
record.path.display(),
record.token,
record.branch,
record.outcome.describe(self.dry_run),
)?;
}
write!(
f,
"This answers for the publication and recovery workspaces onevcs owns under {}, \
for the session records beside them, 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")
}