use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use serde_json::{json, Map, Value};
use url::Url;
use crate::error::{self, Error, Result};
use crate::event::EventKind;
use crate::git::{self, LocalTip, ObjectId, RemoteTip};
use crate::host::{ChangeId, ChangeRequest, Hosting, Sha};
use crate::provenance::{self, Trailers};
use crate::registry::Registry;
use crate::session::{Lifecycle, Scope, SessionToken};
use crate::store::{self, Resolution};
use crate::stream::Stream;
use crate::workspace::{self, Record, Ref};
use crate::{ids, label, landed, lock, policy, pool, processes, status, workspaces};
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct BranchRef {
pub identity: String,
pub branch: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RetirementQuery {
pub repo: Option<String>,
pub branch: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum RetireMode {
Lossless,
Reclaim,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RetireRequest {
pub repo: Option<String>,
pub branch: String,
pub mode: RetireMode,
pub dry_run: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RetirePass {
pub scope: Scope,
pub exclude: Vec<BranchRef>,
pub dry_run: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Supersession {
pub repo: String,
pub branch: String,
pub superseded_by: String,
pub landing: String,
pub labels: BTreeMap<String, String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum RetirementClass {
Retirable,
SupersededWithChanges,
Keep,
}
impl RetirementClass {
pub fn as_str(self) -> &'static str {
match self {
RetirementClass::Retirable => "retirable",
RetirementClass::SupersededWithChanges => "superseded-with-changes",
RetirementClass::Keep => "keep",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum KeepReason {
HeldByLiveSession,
Excluded,
OpenChangeRequest,
CheckedOut,
DirtyWorktree,
UnmergedUniqueCommits,
Unknown,
IsBase,
}
impl KeepReason {
pub fn as_str(self) -> &'static str {
match self {
KeepReason::HeldByLiveSession => "held-by-live-session",
KeepReason::Excluded => "excluded",
KeepReason::OpenChangeRequest => "open-change-request",
KeepReason::CheckedOut => "checked-out",
KeepReason::DirtyWorktree => "dirty-worktree",
KeepReason::UnmergedUniqueCommits => "unmerged-unique-commits",
KeepReason::Unknown => "unknown",
KeepReason::IsBase => "is-base",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum RetirementProof {
MergedChangeRequest {
change_url: Url,
head: Sha,
},
RecordedLanding {
commit: Sha,
},
ContentIdentical {
base_commit: Sha,
},
}
impl RetirementProof {
pub fn kind(&self) -> &'static str {
match self {
RetirementProof::MergedChangeRequest { .. } => "merged-change-request",
RetirementProof::RecordedLanding { .. } => "recorded-landing",
RetirementProof::ContentIdentical { .. } => "content-identical",
}
}
pub fn commit(&self) -> &str {
match self {
RetirementProof::MergedChangeRequest { head, .. } => &head.0,
RetirementProof::RecordedLanding { commit } => &commit.0,
RetirementProof::ContentIdentical { base_commit } => &base_commit.0,
}
}
pub fn describe(&self) -> String {
match self {
RetirementProof::MergedChangeRequest { change_url, head } => {
format!(
"merged-change-request — {change_url} merged at head {}",
head.0
)
}
RetirementProof::RecordedLanding { commit } => {
format!(
"recorded-landing — {} landed, and nothing after it changes content",
commit.0
)
}
RetirementProof::ContentIdentical { base_commit } => {
format!(
"content-identical — every path it changed reads the same on {}",
base_commit.0
)
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SupersededBy {
pub branch: String,
pub landing: String,
pub labels: BTreeMap<String, String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum BranchHolderKind {
Checkout,
Slot,
RunClone,
Origin,
}
impl BranchHolderKind {
pub fn as_str(self) -> &'static str {
match self {
BranchHolderKind::Checkout => "checkout",
BranchHolderKind::Slot => "slot",
BranchHolderKind::RunClone => "run-clone",
BranchHolderKind::Origin => "origin",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct BranchHolder {
pub kind: BranchHolderKind,
pub location: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FailedHolder {
pub kind: BranchHolderKind,
pub location: String,
pub error: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "AnyRetirement")]
pub struct Retirement {
pub class: RetirementClass,
pub reason: Option<KeepReason>,
pub identity: String,
pub branch: String,
pub tip: Sha,
pub base: String,
pub proof: Option<RetirementProof>,
pub content_free_commits: Vec<Sha>,
pub superseded_by: Option<SupersededBy>,
pub differing_paths: Vec<String>,
pub holders: Vec<BranchHolder>,
}
#[derive(Deserialize)]
struct AnyRetirement {
class: RetirementClass,
reason: Option<KeepReason>,
identity: String,
branch: String,
tip: Sha,
base: String,
proof: Option<RetirementProof>,
content_free_commits: Vec<Sha>,
superseded_by: Option<SupersededBy>,
differing_paths: Vec<String>,
holders: Vec<BranchHolder>,
}
impl TryFrom<AnyRetirement> for Retirement {
type Error = String;
fn try_from(any: AnyRetirement) -> std::result::Result<Self, String> {
if (any.class == RetirementClass::Keep) != any.reason.is_some() {
return Err("a retirement carries a reason exactly when its class is keep".to_owned());
}
if (any.class == RetirementClass::Retirable) != any.proof.is_some() {
return Err(
"a retirement carries a proof exactly when its class is retirable".to_owned(),
);
}
if any.class == RetirementClass::SupersededWithChanges && any.superseded_by.is_none() {
return Err("a superseded-with-changes retirement names what superseded it".to_owned());
}
Ok(Retirement {
class: any.class,
reason: any.reason,
identity: any.identity,
branch: any.branch,
tip: any.tip,
base: any.base,
proof: any.proof,
content_free_commits: any.content_free_commits,
superseded_by: any.superseded_by,
differing_paths: any.differing_paths,
holders: any.holders,
})
}
}
impl Retirement {
fn kept(census: &Census, branch: &str, reason: KeepReason, copies: &Copies) -> Self {
Retirement {
class: RetirementClass::Keep,
reason: Some(reason),
identity: census.resolution.key.clone(),
branch: branch.to_owned(),
tip: Sha(copies.first_tip()),
base: census.base.clone().unwrap_or_default(),
proof: None,
content_free_commits: Vec::new(),
superseded_by: None,
differing_paths: Vec::new(),
holders: census.holders_of(copies),
}
}
pub fn evidence(&self) -> Vec<String> {
let mut lines = Vec::new();
if let Some(proof) = &self.proof {
lines.push(format!("proof: {}", proof.describe()));
}
if let Some(by) = &self.superseded_by {
lines.push(format!(
"superseded by {} (landed at {})",
by.branch, by.landing
));
if !by.labels.is_empty() {
lines.push(format!("labels: {}", spelled_labels(&by.labels)));
}
}
if !self.differing_paths.is_empty() {
lines.push(format!(
"differs from {} in: {}",
self.base,
self.differing_paths.join(", ")
));
}
if !self.content_free_commits.is_empty() {
lines.push(format!(
"content-free commits at its tip: {}",
self.content_free_commits
.iter()
.map(|commit| commit.0.as_str())
.collect::<Vec<_>>()
.join(", ")
));
}
for holder in &self.holders {
lines.push(format!(
"held in {} {}",
holder.kind.as_str(),
holder.location
));
}
lines
}
pub fn verdict(&self) -> String {
match self.reason {
Some(reason) => format!("{} / {}", self.class.as_str(), reason.as_str()),
None => self.class.as_str().to_owned(),
}
}
}
pub(crate) fn spelled_labels(labels: &BTreeMap<String, String>) -> String {
labels
.iter()
.map(|(key, value)| format!("{key}={value}"))
.collect::<Vec<_>>()
.join(", ")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum RetireOutcome {
Retired,
WouldRetire,
AlreadyRetired,
Kept,
Incomplete,
}
impl RetireOutcome {
pub fn as_str(self) -> &'static str {
match self {
RetireOutcome::Retired => "retired",
RetireOutcome::WouldRetire => "would-retire",
RetireOutcome::AlreadyRetired => "already-retired",
RetireOutcome::Kept => "kept",
RetireOutcome::Incomplete => "incomplete",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Retired {
#[serde(flatten)]
pub retirement: Retirement,
pub outcome: RetireOutcome,
pub deleted: Vec<BranchHolder>,
pub failed: Vec<FailedHolder>,
pub slots_returned: Vec<PathBuf>,
pub run_roots_removed: Vec<PathBuf>,
pub sessions_closed: Vec<SessionToken>,
}
impl Retired {
fn nothing(retirement: Retirement, outcome: RetireOutcome) -> Self {
Retired {
retirement,
outcome,
deleted: Vec::new(),
failed: Vec::new(),
slots_returned: Vec::new(),
run_roots_removed: Vec::new(),
sessions_closed: Vec::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RetirementPassReport {
pub dry_run: bool,
pub examined: Vec<Retired>,
}
pub(crate) fn describe_retired(retired: &Retired, verb: &str) -> Vec<String> {
let retirement = &retired.retirement;
let named = format!("{} of {}", retirement.branch, retirement.identity);
let places = |holders: &[BranchHolder]| {
holders
.iter()
.map(|holder| format!("{} {}", holder.kind.as_str(), holder.location))
.collect::<Vec<_>>()
.join("; ")
};
let mut lines = vec![match retired.outcome {
RetireOutcome::Retired => format!(
"retired: {named} ({}) was deleted from {}",
retirement.verdict(),
places(&retired.deleted)
),
RetireOutcome::WouldRetire => format!(
"would retire: {named} ({}) would be deleted from {}. Nothing was changed: this was \
a rehearsal",
retirement.verdict(),
places(&retired.deleted)
),
RetireOutcome::AlreadyRetired => format!(
"already retired: nothing on this host holds {named} any more, and its retirement \
({}) is recorded",
retirement.verdict()
),
RetireOutcome::Kept => format!(
"refused: {named} is {}, which `onevcs {verb}` does not delete; nothing was deleted",
retirement.verdict()
),
RetireOutcome::Incomplete => format!(
"retired in part: {named} ({}) was deleted from {} and not from {}. Re-run `onevcs \
{verb} {} --repo {}` once that is fixed",
retirement.verdict(),
match retired.deleted.is_empty() {
true => "nowhere".to_owned(),
false => places(&retired.deleted),
},
retired
.failed
.iter()
.map(|failed| format!(
"{} {} ({})",
failed.kind.as_str(),
failed.location,
failed.error
))
.collect::<Vec<_>>()
.join("; "),
retirement.branch,
retirement.identity,
),
}];
lines.extend(
retirement
.evidence()
.into_iter()
.map(|line| format!(" {line}")),
);
if retired.outcome == RetireOutcome::Kept
&& retirement.class == RetirementClass::SupersededWithChanges
{
lines.push(format!(
" it differs from {} only in what the retry that superseded it replaced: `onevcs \
reclaim {} --repo {}` discards that and deletes it",
retirement.base, retirement.branch, retirement.identity
));
}
for slot in &retired.slots_returned {
lines.push(format!(" returned slot {}", slot.display()));
}
for run_root in &retired.run_roots_removed {
lines.push(format!(" removed run root {}", run_root.display()));
}
for token in &retired.sessions_closed {
lines.push(format!(" closed session {}", token.0));
}
lines
}
pub(crate) fn describe_line(retired: &Retired) -> String {
let retirement = &retired.retirement;
let decided = match &retirement.proof {
Some(proof) => proof.describe(),
None => retirement.verdict(),
};
let failed = match retired.failed.is_empty() {
true => String::new(),
false => format!(
"; not deleted from {}",
retired
.failed
.iter()
.map(|failed| format!("{} ({})", failed.location, failed.error))
.collect::<Vec<_>>()
.join("; ")
),
};
format!(
"{branch} [{identity}] — {outcome}: {decided}{failed}",
branch = retirement.branch,
identity = retirement.identity,
outcome = retired.outcome.as_str(),
)
}
pub(crate) fn describe_pass(report: &RetirementPassReport) -> Vec<String> {
let counted = |outcome: RetireOutcome| {
report
.examined
.iter()
.filter(|entry| entry.outcome == outcome)
.count()
};
let retired = match report.dry_run {
true => counted(RetireOutcome::WouldRetire),
false => counted(RetireOutcome::Retired),
};
let mut lines = vec![format!(
"{} {retired} finished branch(es), kept {}{}.",
match report.dry_run {
true => "would retire",
false => "retired",
},
counted(RetireOutcome::Kept),
match counted(RetireOutcome::Incomplete) {
0 => String::new(),
partial => format!(", and retired {partial} in part"),
}
)];
if report.dry_run {
lines.push("Nothing was changed: this was a rehearsal.".to_owned());
}
for entry in &report.examined {
lines.extend(describe_retired(entry, "retire"));
}
lines
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum Trigger {
Verb,
SessionClose,
Sweep,
Pass,
}
impl Trigger {
pub(crate) fn as_str(self) -> &'static str {
match self {
Trigger::Verb => "verb",
Trigger::SessionClose => "session-close",
Trigger::Sweep => "sweep",
Trigger::Pass => "pass",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum Acting {
Retire,
Reclaim,
Automatic,
}
impl Acting {
pub(crate) fn as_str(self) -> &'static str {
match self {
Acting::Retire => "retire",
Acting::Reclaim => "reclaim",
Acting::Automatic => "automatic",
}
}
pub(crate) fn permits(self, class: RetirementClass) -> bool {
match class {
RetirementClass::Retirable => true,
RetirementClass::SupersededWithChanges => self == Acting::Reclaim,
RetirementClass::Keep => false,
}
}
}
#[derive(Clone, Copy)]
pub(crate) struct Ask<'a> {
host: Option<&'a dyn Hosting>,
fetch_objects: bool,
reconcile: bool,
remote: bool,
exclude: &'a [BranchRef],
}
impl<'a> Ask<'a> {
pub(crate) fn offline() -> Ask<'static> {
Ask {
host: None,
fetch_objects: false,
reconcile: false,
remote: false,
exclude: &[],
}
}
fn query(host: &'a dyn Hosting) -> Self {
Ask {
host: Some(host),
fetch_objects: true,
reconcile: false,
remote: true,
exclude: &[],
}
}
fn acting(host: Option<&'a dyn Hosting>, dry_run: bool, exclude: &'a [BranchRef]) -> Self {
Ask {
host,
fetch_objects: true,
reconcile: !dry_run,
remote: true,
exclude,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Reach {
Offline,
Remote,
Fetch,
}
#[derive(Debug, Clone)]
struct Place {
kind: BranchHolderKind,
repo: PathBuf,
slot: Option<PathBuf>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Holding {
Local(usize),
Origin,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Copy {
at: Holding,
tip: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct Copies {
copies: Vec<Copy>,
unreadable: Vec<String>,
}
impl Copies {
fn first_tip(&self) -> String {
self.copies
.first()
.map(|copy| copy.tip.clone())
.unwrap_or_default()
}
}
pub(crate) struct Census<'a> {
registry: &'a Registry,
resolution: Resolution,
base: Option<String>,
base_tip: Option<String>,
lent: Option<PathBuf>,
origin: Option<String>,
places: Vec<Place>,
unlisted: Vec<String>,
heads: Vec<std::cell::OnceCell<std::result::Result<BTreeMap<String, String>, String>>>,
tracked: std::cell::OnceCell<std::result::Result<BTreeMap<String, String>, String>>,
sessions: &'a [Record],
streams: &'a [status::Recorded],
trailers: &'a Trailers,
}
impl<'a> Census<'a> {
pub(crate) fn read(
registry: &'a Registry,
identity: &str,
sessions: &'a [Record],
streams: &'a [status::Recorded],
trailers: &'a Trailers,
reach: Reach,
only: Option<&BTreeSet<PathBuf>>,
) -> Result<Self> {
let resolution = store::resolve(registry, identity)?;
let publication = resolution.publication.clone();
let fetched = reach != Reach::Fetch || git::fetch(&publication, "origin").is_ok();
let base = git::default_branch(&publication, "origin").ok();
let base_tip = match (reach, base.as_deref()) {
(_, None) => None,
(Reach::Remote, Some(base)) => remote_base(&publication, base),
(Reach::Offline | Reach::Fetch, Some(base)) => fetched
.then(|| git::tip(&publication, &format!("refs/remotes/origin/{base}")))
.flatten(),
};
let mut places: Vec<Place> = vec![Place {
kind: BranchHolderKind::Checkout,
repo: publication.clone(),
slot: None,
}];
for checkout in registry.checkouts.values() {
if checkout.identity == resolution.key
&& !places.iter().any(|place| place.repo == checkout.path)
{
places.push(Place {
kind: BranchHolderKind::Checkout,
repo: checkout.path.clone(),
slot: None,
});
}
}
let root = workspace::identity_dir(&resolution.key)?;
let mut unlisted = Vec::new();
for (slot, _) in numbered(&pool::pool_dir(&root), &mut unlisted) {
places.push(Place {
kind: BranchHolderKind::Slot,
repo: slot.join("clone"),
slot: Some(slot),
});
}
for (run_root, _) in listed(&root.join("runs"), &mut unlisted) {
let clone = run_root.join("clone");
if clone.exists() && !emptied(&clone) {
places.push(Place {
kind: BranchHolderKind::RunClone,
repo: clone,
slot: None,
});
}
}
for record in sessions {
if record.identity != resolution.key
|| places.iter().any(|place| place.repo == record.clone)
|| !record.clone.exists()
|| (record.slot.is_none() && emptied(&record.clone))
{
continue;
}
places.push(Place {
kind: match record.slot {
Some(_) => BranchHolderKind::Slot,
None => BranchHolderKind::RunClone,
},
repo: record.clone.clone(),
slot: record.slot.map(|_| record.run_root.clone()),
});
}
if let Some(only) = only {
places.retain(|place| only.contains(&place.repo));
}
Ok(Census {
registry,
heads: places.iter().map(|_| std::cell::OnceCell::new()).collect(),
tracked: std::cell::OnceCell::new(),
lent: git::objects_dir(&publication).ok(),
origin: git::remote_url(&publication, "origin").ok(),
resolution,
base,
base_tip,
places,
unlisted,
sessions,
streams,
trailers,
})
}
fn publication(&self) -> &Path {
&self.resolution.publication
}
fn local_tip(&self, index: usize, branch: &str, fresh: bool) -> LocalTip {
let place = &self.places[index];
if fresh {
return git::local_tip(&place.repo, branch);
}
if !place.repo.exists() {
return LocalTip::Absent;
}
let listed = self.heads[index].get_or_init(|| {
git::heads(&place.repo)
.map(|heads| heads.into_iter().collect())
.map_err(|failure| failure.to_string())
});
match listed {
Ok(heads) => match heads.get(branch).and_then(|tip| ObjectId::parse(tip)) {
Some(tip) => LocalTip::At(tip),
None => LocalTip::Absent,
},
Err(said) => LocalTip::Unreadable(said.clone()),
}
}
fn copies(&self, branch: &str, ask: &Ask<'_>) -> Copies {
let mut read = Copies {
unreadable: self.unlisted.clone(),
..Copies::default()
};
for (index, place) in self.places.iter().enumerate() {
match self.local_tip(index, branch, ask.remote) {
LocalTip::At(tip) => read.copies.push(Copy {
at: Holding::Local(index),
tip: tip.as_str().to_owned(),
}),
LocalTip::Absent => {}
LocalTip::Unreadable(said) => read
.unreadable
.push(format!("{}: {said}", place.repo.display())),
}
}
if self.origin.is_none() {
return read;
}
match ask.remote {
true => match git::remote_tip(self.publication(), "origin", branch, &[]) {
Ok(RemoteTip::At(tip)) => read.copies.push(Copy {
at: Holding::Origin,
tip: tip.as_str().to_owned(),
}),
Ok(RemoteTip::Absent) => {}
Ok(RemoteTip::Unknown) => read
.unreadable
.push("the origin could not be asked where the branch is".to_owned()),
Err(failure) => read.unreadable.push(format!("the origin: {failure}")),
},
false => {
let tracked = self.tracked.get_or_init(|| {
git::remote_heads(self.publication(), "origin")
.map_err(|failure| failure.to_string())
});
match tracked {
Ok(tracked) => {
if let Some(tip) = tracked.get(branch) {
read.copies.push(Copy {
at: Holding::Origin,
tip: tip.clone(),
});
}
}
Err(said) => read
.unreadable
.push(format!("the origin's remote-tracking branches: {said}")),
}
}
}
read
}
fn holder(&self, at: Holding) -> BranchHolder {
match at {
Holding::Local(index) => BranchHolder {
kind: self.places[index].kind,
location: self.places[index].repo.display().to_string(),
},
Holding::Origin => BranchHolder {
kind: BranchHolderKind::Origin,
location: self.origin.clone().unwrap_or_default(),
},
}
}
fn holders_of(&self, copies: &Copies) -> Vec<BranchHolder> {
copies
.copies
.iter()
.map(|copy| self.holder(copy.at))
.collect()
}
fn session_of(&self, branch: &str) -> Option<&Record> {
crate::vcs::latest_session(self.sessions, &self.resolution.key, branch)
}
fn live_holder(&self, branch: &str) -> Result<Option<String>> {
for record in self.sessions {
if record.identity != self.resolution.key
|| record.state != Lifecycle::Open
|| !self.places.iter().any(|place| place.repo == record.clone)
{
continue;
}
let over = *record.branch == *branch
|| git::current_branch(&record.worktree).is_ok_and(|current| current == branch);
if !over {
continue;
}
if record.owner_is_running()
|| lock::is_occupied(&record.lease())?
|| !processes::holding(&record.run_root).is_empty()
{
return Ok(Some(record.token.to_string()));
}
}
Ok(None)
}
fn holding<'c>(&'c self, copies: &'c Copies) -> impl Iterator<Item = &'c Place> + 'c {
copies.copies.iter().filter_map(|copy| match copy.at {
Holding::Local(index) => Some(&self.places[index]),
Holding::Origin => None,
})
}
fn checked_out(&self, branch: &str, copies: &Copies) -> Result<Option<PathBuf>> {
for place in self.holding(copies) {
if place.kind != BranchHolderKind::Checkout {
continue;
}
for (worktree, on) in git::worktree_heads(&place.repo)? {
if on.as_deref() == Some(branch) {
return Ok(Some(worktree));
}
}
}
Ok(None)
}
fn dirty(&self, branch: &str, copies: &Copies) -> Result<Option<PathBuf>> {
for place in self.holding(copies) {
if place.kind == BranchHolderKind::Checkout {
continue;
}
for (worktree, on) in git::worktree_heads(&place.repo)? {
if on.as_deref() != Some(branch) || worktree == place.repo {
continue;
}
if worktree.is_dir() && git::is_dirty(&worktree)? {
return Ok(Some(worktree));
}
}
}
Ok(None)
}
fn classify(&self, branch: &str, ask: &Ask<'_>) -> Result<Classified> {
let copies = self.copies(branch, ask);
let classified = |retirement: Retirement| Classified {
retirement,
copies: copies.clone(),
};
let keep = |reason| classified(Retirement::kept(self, branch, reason, &copies));
let (Some(base), Some(base_tip)) = (self.base.as_deref(), self.base_tip.as_deref()) else {
return Ok(keep(KeepReason::Unknown));
};
if branch == base {
return Ok(keep(KeepReason::IsBase));
}
match self.live_holder(branch) {
Ok(Some(_)) => return Ok(keep(KeepReason::HeldByLiveSession)),
Ok(None) => {}
Err(_) => return Ok(keep(KeepReason::Unknown)),
}
if ask
.exclude
.iter()
.any(|excluded| excluded.identity == self.resolution.key && excluded.branch == branch)
{
return Ok(keep(KeepReason::Excluded));
}
if !copies.unreadable.is_empty() {
return Ok(keep(KeepReason::Unknown));
}
let judged = match self.judge(branch, base_tip, &copies, ask) {
Ok(judged) => judged,
Err(_) => return Ok(keep(KeepReason::Unknown)),
};
Ok(classified(judged))
}
fn judge(
&self,
branch: &str,
base_tip: &str,
copies: &Copies,
ask: &Ask<'_>,
) -> Result<Retirement> {
let kept = |reason| Retirement::kept(self, branch, reason, copies);
let mut judged: Vec<(Copy, Judged)> = Vec::new();
let session = self
.session_of(branch)
.map(|record| record.token.to_string());
let mut evidence = Evidence::of(self, branch, session.as_deref());
for copy in &copies.copies {
let Some(repo) = self.readable(copy, branch, ask) else {
return Ok(kept(KeepReason::Unknown));
};
let asked = git::Asked::borrowing(&repo, self.lent.as_deref());
judged.push((
copy.clone(),
self.judge_copy(asked, ©.tip, base_tip, &evidence)?,
));
}
if judged.iter().all(|(_, one)| one.at_base) {
return Ok(kept(KeepReason::IsBase));
}
if let Some(open) = self.open_change(branch, &mut evidence, &judged, ask)? {
return Ok(kept(open));
}
if self.checked_out(branch, copies)?.is_some() {
return Ok(kept(KeepReason::CheckedOut));
}
if self.dirty(branch, copies)?.is_some() {
return Ok(kept(KeepReason::DirtyWorktree));
}
if evidence.changed {
judged.clear();
for copy in &copies.copies {
let Some(repo) = self.readable(copy, branch, ask) else {
return Ok(kept(KeepReason::Unknown));
};
let asked = git::Asked::borrowing(&repo, self.lent.as_deref());
judged.push((
copy.clone(),
self.judge_copy(asked, ©.tip, base_tip, &evidence)?,
));
}
}
let mut free: Vec<String> = Vec::new();
let mut differing: BTreeSet<String> = BTreeSet::new();
let mut proof: Option<RetirementProof> = None;
let mut uncovered: Option<(PathBuf, String, Option<String>)> = None;
for (copy, one) in &judged {
for commit in &one.content_free {
if !free.contains(commit) {
free.push(commit.clone());
}
}
if one.at_base {
continue;
}
match &one.proof {
Some(found) => {
proof.get_or_insert_with(|| found.clone());
}
None => {
differing.extend(one.differing.iter().cloned());
if uncovered.is_none() {
let repo = self.readable(copy, branch, ask).unwrap_or_default();
uncovered = Some((repo, copy.tip.clone(), one.fork.clone()));
}
}
}
}
let mut retirement = kept(KeepReason::UnmergedUniqueCommits);
retirement.content_free_commits = free.into_iter().map(Sha).collect();
let Some((repo, tip, fork)) = uncovered else {
retirement.class = RetirementClass::Retirable;
retirement.reason = None;
retirement.proof = proof;
if retirement.proof.is_none() {
retirement.proof = Some(RetirementProof::ContentIdentical {
base_commit: Sha(base_tip.to_owned()),
});
}
return Ok(retirement);
};
retirement.differing_paths = differing.into_iter().collect();
let asked = git::Asked::borrowing(&repo, self.lent.as_deref());
if let Some(by) = self.superseded(asked, branch, base_tip, &tip, fork.as_deref())? {
retirement.class = RetirementClass::SupersededWithChanges;
retirement.reason = None;
retirement.superseded_by = Some(by);
}
Ok(retirement)
}
fn readable(&self, copy: &Copy, branch: &str, ask: &Ask<'_>) -> Option<PathBuf> {
if let Holding::Local(index) = copy.at {
return Some(self.places[index].repo.clone());
}
let wanted = Sha(copy.tip.clone());
let local_twin = self.places.iter().enumerate().find(|(index, _)| {
matches!(self.local_tip(*index, branch, false), LocalTip::At(tip) if tip.as_str() == copy.tip)
});
if let Some((_, place)) = local_twin {
return Some(place.repo.clone());
}
let found = self
.places
.iter()
.find(|place| {
place.repo.exists()
&& git::has_commit(
git::Asked::borrowing(&place.repo, self.lent.as_deref()),
&wanted,
)
})
.map(|place| place.repo.clone());
if found.is_some() || !ask.fetch_objects {
return found;
}
let publication = self.publication().to_path_buf();
match git::fetch_objects_of(&publication, "origin", branch) {
Ok(true) if git::has_commit(publication.as_path(), &wanted) => Some(publication),
_ => None,
}
}
fn judge_copy(
&self,
asked: git::Asked<'_>,
tip: &str,
base_tip: &str,
evidence: &Evidence,
) -> Result<Judged> {
let fork = git::merge_base(asked, base_tip, tip)?;
if fork.as_deref() == Some(tip) {
return Ok(Judged::at_base());
}
let (content_free, content_tip) =
git::content_free_tail(asked, tip, fork.as_deref().unwrap_or(""))?;
let history = match &fork {
Some(fork) => git::log_messages(asked, fork, base_tip)?,
None => Vec::new(),
};
let mut proof = None;
if let Some(change) = evidence.merged(&history) {
for head in &evidence.heads {
if git::known_to_reach(asked, &content_tip, head.as_str())? {
proof = Some(RetirementProof::MergedChangeRequest {
change_url: change.clone(),
head: Sha(head.as_str().to_owned()),
});
break;
}
}
}
if proof.is_none() {
proof = self.recorded_landing(asked, tip, base_tip, &history, evidence)?;
}
let mut differing = Vec::new();
if proof.is_none() {
differing = match &fork {
Some(fork) => {
let paths = git::changed_paths(asked, fork, tip)?;
git::differing_among(asked, tip, base_tip, &paths)?
}
None => git::changed_paths(asked, base_tip, tip)?,
};
if differing.is_empty() {
proof = Some(RetirementProof::ContentIdentical {
base_commit: Sha(base_tip.to_owned()),
});
}
}
Ok(Judged {
at_base: false,
fork,
content_free,
differing,
proof,
})
}
fn recorded_landing(
&self,
asked: git::Asked<'_>,
tip: &str,
base_tip: &str,
history: &[git::CommitMessage],
evidence: &Evidence,
) -> Result<Option<RetirementProof>> {
let key = self.trailers.landed();
let mut landed_points: Vec<ObjectId> = Vec::new();
for commit in history {
landed_points.extend(landed::trailer_values(&commit.message, key));
}
if let Some(landing) = &evidence.landing {
if git::known_to_reach(asked, landing.as_str(), base_tip)? {
if let Some(message) = git::commit_message(asked, landing.as_str())? {
landed_points.extend(landed::trailer_values(&message, key));
}
}
}
for point in landed_points {
if !git::known_to_reach(asked, point.as_str(), tip)? {
continue;
}
let mut after = git::commits_in(asked, &format!("{}..{tip}", point.as_str()))?;
after.retain(|commit| commit != point.as_str());
let mut all_free = true;
for commit in &after {
if !git::changes_no_content(asked, commit)? {
all_free = false;
break;
}
}
if all_free {
return Ok(Some(RetirementProof::RecordedLanding {
commit: Sha(point.as_str().to_owned()),
}));
}
}
Ok(None)
}
fn open_change(
&self,
branch: &str,
evidence: &mut Evidence,
judged: &[(Copy, Judged)],
ask: &Ask<'_>,
) -> Result<Option<KeepReason>> {
let Some(change) = evidence.change.clone() else {
return Ok(None);
};
if evidence.landing.is_some() {
return Ok(None);
}
if let (true, Some(hosting)) = (ask.reconcile, ask.host) {
let session = self
.session_of(branch)
.map(|record| record.token.to_string());
if let Some(opened) = status::opened_change(
self.streams,
&self.resolution.key,
branch,
session.as_deref(),
) {
if let (Some(id), Some(target)) = (&opened.id, &opened.base) {
let head = judged
.first()
.map(|(copy, _)| copy.tip.clone())
.unwrap_or_default();
let merged = crate::publish::reconcile_late_merge(
self.registry,
&crate::publish::Watched {
identity: &self.resolution.key,
branch,
url: &opened.url,
id: &id.0,
base: target,
head: &head,
stream: opened.stream.as_deref(),
},
hosting,
);
if let Some(landing) = merged.as_deref().and_then(ObjectId::parse) {
evidence.landing = Some(landing);
evidence.changed = true;
return Ok(None);
}
}
}
}
for (copy, one) in judged {
let Some(fork) = &one.fork else { continue };
let Some(repo) = self.readable(copy, branch, ask) else {
continue;
};
let asked = git::Asked::borrowing(&repo, self.lent.as_deref());
let history = git::log_messages(asked, fork, self.base_tip.as_deref().unwrap_or(""))?;
if landed::names_the_change(&history, change.as_str()).is_some() {
return Ok(None);
}
}
let Some(hosting) = ask.host else {
return Ok(Some(KeepReason::OpenChangeRequest));
};
let session = self
.session_of(branch)
.map(|record| record.token.to_string());
let Some(opened) = status::opened_change(
self.streams,
&self.resolution.key,
branch,
session.as_deref(),
) else {
return Ok(Some(KeepReason::OpenChangeRequest));
};
let (Some(id), Some(target)) = (&opened.id, &opened.base) else {
return Ok(Some(KeepReason::Unknown));
};
let head = judged
.first()
.map(|(copy, _)| copy.tip.clone())
.unwrap_or_default();
let Ok(host) = crate::publish::change_host(&self.resolution.key)
.and_then(|slug| hosting.for_repo(&slug))
else {
return Ok(Some(KeepReason::Unknown));
};
if !ask.reconcile {
match host.merged_at(&change_request(&opened, id, target, &head)) {
Ok(Some(sha)) => {
if let Some(landing) = ObjectId::parse(&sha.0) {
evidence.landing = Some(landing);
evidence.changed = true;
return Ok(None);
}
}
Ok(None) => {}
Err(_) => return Ok(Some(KeepReason::Unknown)),
}
}
match host.find_changes(branch, target) {
Ok(open) if open.iter().any(|found| found.url == opened.url) => {
Ok(Some(KeepReason::OpenChangeRequest))
}
Ok(_) => Ok(None),
Err(_) => Ok(Some(KeepReason::Unknown)),
}
}
fn superseded(
&self,
asked: git::Asked<'_>,
branch: &str,
base_tip: &str,
tip: &str,
fork: Option<&str>,
) -> Result<Option<SupersededBy>> {
let recorded = status::supersessions(self.streams, &self.resolution.key, branch);
for record in recorded.iter().rev() {
let reached = match &record.landing {
SupersedingLanding::Commit(commit) => {
git::known_to_reach(asked, commit.as_str(), base_tip)?
}
SupersedingLanding::Change(url) => {
let from = match fork {
Some(fork) => fork.to_owned(),
None => git::merge_base(asked, base_tip, tip)?.unwrap_or_default(),
};
let history = match from.is_empty() {
true => Vec::new(),
false => git::log_messages(asked, &from, base_tip)?,
};
landed::names_the_change(&history, url.as_str()).is_some()
}
};
if reached {
return Ok(Some(SupersededBy {
branch: record.superseded_by.to_string(),
landing: record.landing.to_string(),
labels: record.labels.clone(),
}));
}
}
Ok(None)
}
}
fn remote_base(publication: &Path, base: &str) -> Option<String> {
let RemoteTip::At(tip) = git::remote_tip(publication, "origin", base, &[]).ok()? else {
return None;
};
let wanted = Sha(tip.as_str().to_owned());
if !git::has_commit(publication, &wanted) {
git::fetch_objects_of(publication, "origin", base).ok()?;
}
git::has_commit(publication, &wanted).then(|| tip.as_str().to_owned())
}
struct Classified {
retirement: Retirement,
copies: Copies,
}
struct Judged {
at_base: bool,
fork: Option<String>,
content_free: Vec<String>,
differing: Vec<String>,
proof: Option<RetirementProof>,
}
impl Judged {
fn at_base() -> Self {
Judged {
at_base: true,
fork: None,
content_free: Vec::new(),
differing: Vec::new(),
proof: None,
}
}
}
struct Evidence {
change: Option<Url>,
landing: Option<ObjectId>,
heads: Vec<ObjectId>,
changed: bool,
}
impl Evidence {
fn of(census: &Census<'_>, branch: &str, session: Option<&str>) -> Self {
let recorded =
status::recorded_for(census.streams, &census.resolution.key, branch, session);
Evidence {
change: recorded.change,
landing: recorded.landing,
heads: status::pushed_heads(census.streams, &census.resolution.key, branch, session),
changed: false,
}
}
fn merged(&self, history: &[git::CommitMessage]) -> Option<&Url> {
let change = self.change.as_ref()?;
(self.landing.is_some() || landed::names_the_change(history, change.as_str()).is_some())
.then_some(change)
}
}
fn change_request(
opened: &status::OpenedChange,
id: &ChangeId,
base: &Ref,
head: &str,
) -> ChangeRequest {
ChangeRequest {
id: id.clone(),
url: opened.url.clone(),
head_sha: Sha(head.to_owned()),
base: base.to_string(),
}
}
fn numbered(directory: &Path, unlisted: &mut Vec<String>) -> Vec<(PathBuf, u32)> {
let mut found: Vec<(PathBuf, u32)> = listed(directory, unlisted)
.into_iter()
.filter_map(|(path, name)| {
name.parse::<u32>()
.ok()
.filter(|number| *number > 0)
.map(|number| (path, number))
})
.collect();
found.sort_by_key(|(_, number)| *number);
found
}
fn listed(directory: &Path, unlisted: &mut Vec<String>) -> Vec<(PathBuf, String)> {
let entries = match std::fs::read_dir(directory) {
Ok(entries) => entries,
Err(failure) if failure.kind() == std::io::ErrorKind::NotFound => return Vec::new(),
Err(failure) => {
unlisted.push(format!("{}: {failure}", directory.display()));
return Vec::new();
}
};
let mut found = Vec::new();
for entry in entries {
match entry {
Ok(entry) if entry.path().is_dir() => found.push((
entry.path(),
entry.file_name().to_string_lossy().into_owned(),
)),
Ok(_) => {}
Err(failure) => unlisted.push(format!("{}: {failure}", directory.display())),
}
}
found.sort();
found
}
fn is_identity_key(identity: &str) -> bool {
if identity.trim() != identity || identity.chars().any(char::is_control) {
return false;
}
let normalized = store::normalize(identity);
match normalized.hosted {
Some(_) => normalized.key == identity,
None => Path::new(identity).is_absolute(),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum SupersedingLanding {
Commit(ObjectId),
Change(Url),
}
impl SupersedingLanding {
fn parse(value: &str) -> Option<Self> {
if let Some(commit) = ObjectId::parse(value) {
return Some(SupersedingLanding::Commit(commit));
}
Url::parse(value)
.ok()
.filter(|url| matches!(url.scheme(), "http" | "https"))
.map(SupersedingLanding::Change)
}
}
impl std::fmt::Display for SupersedingLanding {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SupersedingLanding::Commit(commit) => f.write_str(commit.as_str()),
SupersedingLanding::Change(url) => f.write_str(url.as_str()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SupersessionRecord {
identity: String,
branch: Ref,
superseded_by: Ref,
landing: SupersedingLanding,
labels: BTreeMap<String, String>,
}
impl SupersessionRecord {
pub(crate) fn read(payload: &Map<String, Value>) -> Option<Self> {
let text = |name: &str| payload.get(name).and_then(Value::as_str).map(str::to_owned);
let labels: BTreeMap<String, String> = match payload.get("labels") {
Some(Value::Object(map)) => map
.iter()
.map(|(key, value)| Some((key.clone(), value.as_str()?.to_owned())))
.collect::<Option<_>>()?,
None | Some(Value::Null) => BTreeMap::new(),
Some(_) => return None,
};
label::validate(&labels).ok()?;
Some(SupersessionRecord {
identity: text("identity").filter(|identity| is_identity_key(identity))?,
branch: Ref::try_from(text("branch")?).ok()?,
superseded_by: Ref::try_from(text("superseded_by")?).ok()?,
landing: SupersedingLanding::parse(&text("landing")?)?,
labels,
})
}
pub(crate) fn names(&self, identity: &str, branch: &str) -> bool {
self.identity == identity && *self.branch == *branch
}
pub(crate) fn branch(&self) -> &str {
&self.branch
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct RetiredPayload {
identity: String,
branch: String,
tip: Sha,
class: RetirementClass,
reason: Option<KeepReason>,
proof: Option<RetirementProof>,
superseded_by: Option<SupersededBy>,
differing_paths: Vec<String>,
mode: Acting,
trigger: Trigger,
deleted: Vec<BranchHolder>,
failed: Vec<FailedHolder>,
slots_returned: Vec<PathBuf>,
run_roots_removed: Vec<PathBuf>,
sessions_closed: Vec<String>,
}
impl RetiredPayload {
fn accounts_plainly(&self) -> bool {
let one_line = |text: &str| !text.is_empty() && !text.chars().any(char::is_control);
let place = |kind: BranchHolderKind, location: &str| {
one_line(location)
&& (kind == BranchHolderKind::Origin || Path::new(location).is_absolute())
};
let path = |path: &PathBuf| path.to_str().is_some_and(one_line) && path.is_absolute();
self.deleted
.iter()
.all(|holder| place(holder.kind, &holder.location))
&& self.failed.iter().all(|holder| {
place(holder.kind, &holder.location) && !holder.error.trim().is_empty()
})
&& self.slots_returned.iter().all(path)
&& self.run_roots_removed.iter().all(path)
&& self
.sessions_closed
.iter()
.all(|token| ids::is_safe_name(token))
&& self.differing_paths.iter().all(|path| !path.is_empty())
&& self.evidence_is_plain()
}
fn evidence_is_plain(&self) -> bool {
let commit = |sha: &Sha| ObjectId::parse(&sha.0).is_some();
let proof = match &self.proof {
None => true,
Some(RetirementProof::MergedChangeRequest { head, .. }) => commit(head),
Some(RetirementProof::RecordedLanding { commit: at }) => commit(at),
Some(RetirementProof::ContentIdentical { base_commit }) => commit(base_commit),
};
let superseded = self.superseded_by.as_ref().is_none_or(|by| {
Ref::try_from(by.branch.clone()).is_ok()
&& SupersedingLanding::parse(&by.landing).is_some()
&& label::validate(&by.labels).is_ok()
});
proof && superseded
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct RetiredRecord {
payload: RetiredPayload,
at: String,
}
impl RetiredRecord {
pub(crate) fn read(payload: &Map<String, Value>, at: &status::Stamp) -> Option<Self> {
let payload: RetiredPayload =
serde_json::from_value(Value::Object(payload.clone())).ok()?;
Ref::try_from(payload.branch.clone()).ok()?;
ObjectId::parse(&payload.tip.0)?;
if !is_identity_key(&payload.identity) || !payload.accounts_plainly() {
return None;
}
let consistent = payload.mode.permits(payload.class)
&& payload.reason.is_none()
&& (payload.class == RetirementClass::Retirable) == payload.proof.is_some()
&& (payload.class == RetirementClass::SupersededWithChanges)
== payload.superseded_by.is_some();
if !consistent {
return None;
}
Some(RetiredRecord {
payload,
at: String::from(at.clone()),
})
}
pub(crate) fn names(&self, identity: &str, branch: &str) -> bool {
self.payload.identity == identity && self.payload.branch == branch
}
pub(crate) fn identity(&self) -> &str {
&self.payload.identity
}
pub(crate) fn branch(&self) -> &str {
&self.payload.branch
}
pub(crate) fn class(&self) -> RetirementClass {
self.payload.class
}
pub(crate) fn proof(&self) -> Option<&RetirementProof> {
self.payload.proof.as_ref()
}
pub(crate) fn tip(&self) -> &Sha {
&self.payload.tip
}
pub(crate) fn mode(&self) -> Acting {
self.payload.mode
}
pub(crate) fn trigger(&self) -> Trigger {
self.payload.trigger
}
pub(crate) fn at(&self) -> &str {
&self.at
}
fn retirement(&self, base: &str) -> Retirement {
Retirement {
class: self.payload.class,
reason: self.payload.reason,
identity: self.payload.identity.clone(),
branch: self.payload.branch.clone(),
tip: self.payload.tip.clone(),
base: base.to_owned(),
proof: self.payload.proof.clone(),
content_free_commits: Vec::new(),
superseded_by: self.payload.superseded_by.clone(),
differing_paths: self.payload.differing_paths.clone(),
holders: Vec::new(),
}
}
}
fn stream_token(identity: &str, branch: &str) -> String {
format!(
"branch-{}-{}",
policy::branch_slug(branch),
ids::short_digest(identity)
)
}
fn open_stream(identity: &str, branch: &str) -> Result<Stream> {
let mut stream = Stream::open(&stream_token(identity, branch))?;
stream.label("identity", identity);
Ok(stream)
}
struct Host {
registry: Registry,
sessions: Vec<Record>,
streams: Vec<status::Recorded>,
trailers: Trailers,
}
impl Host {
fn read() -> Result<Self> {
Self::with(None)
}
fn with(sessions: Option<Vec<Record>>) -> Result<Self> {
let registry = store::load()?;
let (rules, _) = crate::policy::load(®istry)?;
let sessions = match sessions {
Some(listed) => listed,
None => workspace::all()?,
};
Ok(Host {
sessions,
streams: status::recorded_streams(&mut Vec::new())?,
trailers: provenance::from_rules(&rules),
registry,
})
}
fn census(&self, identity: &str, reach: Reach) -> Result<Census<'_>> {
Census::read(
&self.registry,
identity,
&self.sessions,
&self.streams,
&self.trailers,
reach,
None,
)
}
fn identities(&self) -> Vec<String> {
let mut keys: Vec<String> = self
.registry
.checkouts
.values()
.map(|checkout| checkout.identity.clone())
.collect();
keys.sort_unstable();
keys.dedup();
keys
}
fn identity_of(&self, repo: Option<&str>, branch: &str) -> Result<String> {
if !git::is_valid_branch_name(branch) {
return Err(error::invalid(format!(
"{branch:?} is not a valid branch name; `onevcs recoverable --all` lists every \
preserved branch by name"
)));
}
if let Some(repo) = repo {
return Ok(store::resolve(&self.registry, repo)?.key);
}
let mut holding = Vec::new();
for identity in self.identities() {
let census = self.census(&identity, Reach::Offline)?;
let held = !census.copies(branch, &Ask::offline()).copies.is_empty()
|| status::retirement_of(&self.streams, &identity, branch).is_some();
if held {
holding.push(identity);
}
}
match holding.len() {
1 => Ok(holding.remove(0)),
0 => Err(Error::UnresolvableReference {
reference: branch.to_owned(),
reason: format!(
"no checkout, pool slot or run clone of a registered identity holds \
{branch:?}, and no retirement of it is recorded; `onevcs recoverable --all` \
lists every preserved branch by name"
),
}),
_ => Err(error::invalid(format!(
"{branch:?} is held by {count} identities — {named} — so which one is meant \
cannot be decided; name it with `--repo`",
count = holding.len(),
named = holding.join(", "),
))),
}
}
}
pub(crate) fn classify(hosting: &dyn Hosting, query: &RetirementQuery) -> Result<Retirement> {
let host = Host::read()?;
let identity = host.identity_of(query.repo.as_deref(), &query.branch)?;
let census = host.census(&identity, Reach::Remote)?;
let ask = Ask::query(hosting);
let classified = census.classify(&query.branch, &ask)?;
if !classified.copies.copies.is_empty() || !classified.copies.unreadable.is_empty() {
return Ok(classified.retirement);
}
match status::retirement_of(&host.streams, &identity, &query.branch) {
Some(record) => Ok(record.retirement(census.base.as_deref().unwrap_or_default())),
None => Err(nowhere(&identity, &query.branch)),
}
}
fn nowhere(identity: &str, branch: &str) -> Error {
Error::UnresolvableReference {
reference: branch.to_owned(),
reason: format!(
"nothing this host keeps for {identity} holds {branch:?} — no registered checkout, \
pool slot, run clone or the origin — and no retirement of it is recorded"
),
}
}
pub(crate) fn classify_offline(census: &Census<'_>, branch: &str) -> Option<Retirement> {
census
.classify(branch, &Ask::offline())
.ok()
.map(|classified| classified.retirement)
}
pub(crate) fn retire_named(hosting: &dyn Hosting, request: &RetireRequest) -> Result<Retired> {
let host = Host::read()?;
let identity = host.identity_of(request.repo.as_deref(), &request.branch)?;
let census = host.census(&identity, reach_for(request.dry_run))?;
let ask = Ask::acting(Some(hosting), request.dry_run, &[]);
let acting = match request.mode {
RetireMode::Lossless => Acting::Retire,
RetireMode::Reclaim => Acting::Reclaim,
};
let copies = census.copies(&request.branch, &ask);
if copies.copies.is_empty() && copies.unreadable.is_empty() {
let Some(record) = status::retirement_of(&host.streams, &identity, &request.branch) else {
return Err(nowhere(&identity, &request.branch));
};
let classified = Classified {
retirement: record.retirement(census.base.as_deref().unwrap_or_default()),
copies,
};
let plan = census.plan(&request.branch, &classified);
let unfinished = !plan.run_roots.is_empty()
|| plan
.sessions
.iter()
.any(|record| record.state == Lifecycle::Open);
return Ok(match (unfinished, request.dry_run) {
(false, _) => Retired::nothing(classified.retirement, RetireOutcome::AlreadyRetired),
(true, true) => Retired {
retirement: classified.retirement,
outcome: RetireOutcome::WouldRetire,
deleted: Vec::new(),
failed: Vec::new(),
slots_returned: Vec::new(),
run_roots_removed: plan.run_roots,
sessions_closed: plan
.sessions
.iter()
.filter(|record| record.state == Lifecycle::Open)
.map(session_token)
.collect(),
},
(true, false) => census.finish(
&request.branch,
classified,
plan,
Done {
deleted: Vec::new(),
failed: Vec::new(),
},
(acting, Trigger::Verb),
),
});
}
act(
&census,
&request.branch,
acting,
Trigger::Verb,
request.dry_run,
&ask,
)
}
fn reach_for(dry_run: bool) -> Reach {
match dry_run {
true => Reach::Remote,
false => Reach::Fetch,
}
}
pub(crate) fn pass(
hosting: Option<&dyn Hosting>,
request: &RetirePass,
trigger: Trigger,
) -> Result<RetirementPassReport> {
pass_over(hosting, request, trigger, None)
}
pub(crate) fn pass_over(
hosting: Option<&dyn Hosting>,
request: &RetirePass,
trigger: Trigger,
sessions: Option<Vec<Record>>,
) -> Result<RetirementPassReport> {
for excluded in &request.exclude {
if excluded.identity.trim().is_empty() || !git::is_valid_branch_name(&excluded.branch) {
return Err(error::invalid(format!(
"{branch:?} of {identity:?} is not a branch a pass can leave alone: an \
exclusion names an identity and a valid branch name",
branch = excluded.branch,
identity = excluded.identity,
)));
}
}
let host = Host::with(sessions)?;
let exclude = request
.exclude
.iter()
.map(|excluded| {
let resolved = store::resolve(&host.registry, &excluded.identity).map_err(|why| {
error::invalid(format!(
"{branch:?} of {identity:?} is not a branch a pass can leave alone: the \
exclusion names no registered identity ({why}); `onevcs repos` lists them",
branch = excluded.branch,
identity = excluded.identity,
))
})?;
Ok(BranchRef {
identity: resolved.key,
branch: excluded.branch.clone(),
})
})
.collect::<Result<Vec<_>>>()?;
let identities = match &request.scope {
Scope::All => host.identities(),
Scope::Repo(repo) => vec![store::resolve(&host.registry, repo)?.key],
};
let ask = Ask::acting(hosting, request.dry_run, &exclude);
let mut report = RetirementPassReport {
dry_run: request.dry_run,
examined: Vec::new(),
};
for identity in identities {
let census = host.census(&identity, reach_for(request.dry_run))?;
for branch in candidates(&census, &host)? {
let copies = census.copies(&branch, &ask);
if copies.copies.is_empty() && copies.unreadable.is_empty() {
continue;
}
report.examined.push(act(
&census,
&branch,
Acting::Automatic,
trigger,
request.dry_run,
&ask,
)?);
}
}
Ok(report)
}
fn candidates(census: &Census<'_>, host: &Host) -> Result<Vec<String>> {
let mut named: BTreeSet<String> = host
.sessions
.iter()
.filter(|record| record.identity == census.resolution.key)
.map(|record| record.branch.to_string())
.collect();
named.extend(status::recorded_branches(
&host.streams,
&census.resolution.key,
));
let mut found: BTreeSet<String> = BTreeSet::new();
for place in &census.places {
if !git::is_repo(&place.repo) {
continue;
}
let Ok(heads) = git::heads(&place.repo) else {
continue;
};
let unpublished: BTreeSet<String> = git::unpublished_branches(&place.repo)
.unwrap_or_default()
.into_iter()
.collect();
for (branch, _) in heads {
if named.contains(&branch) || unpublished.contains(&branch) {
found.insert(branch);
}
}
}
if let Some(base) = &census.base {
found.remove(base);
}
Ok(found.into_iter().collect())
}
pub(crate) fn retired(record: &Record) -> bool {
status::recorded_streams(&mut Vec::new())
.map(|streams| status::retirement_of(&streams, &record.identity, &record.branch).is_some())
.unwrap_or(false)
}
pub(crate) fn after_close(record: &Record) {
let retired = (|| -> Result<Option<Retired>> {
let host = Host::read()?;
let recorded = status::recorded_for(
&host.streams,
&record.identity,
&record.branch,
Some(&record.token),
);
if recorded.landing.is_none() {
return Ok(None);
}
let census = host.census(&record.identity, Reach::Fetch)?;
let ask = Ask::acting(None, false, &[]);
let classified = census.classify(&record.branch, &ask)?;
if classified.retirement.class != RetirementClass::Retirable {
return Ok(None);
}
act(
&census,
&record.branch,
Acting::Automatic,
Trigger::SessionClose,
false,
&ask,
)
.map(Some)
})();
match retired {
Ok(Some(retired)) if retired.outcome == RetireOutcome::Retired => eprintln!(
"onevcs: retired {branch}: {proof}",
branch = record.branch,
proof = retired
.retirement
.proof
.as_ref()
.map(RetirementProof::describe)
.unwrap_or_default(),
),
Ok(Some(retired)) if retired.outcome == RetireOutcome::Incomplete => eprintln!(
"onevcs: warning: {branch} was retired in part; `onevcs retire {branch} --repo {repo}` \
finishes it: {failed}",
branch = record.branch,
repo = record.publication_checkout.display(),
failed = retired
.failed
.iter()
.map(|failed| format!("{} ({})", failed.location, failed.error))
.collect::<Vec<_>>()
.join("; "),
),
Ok(_) => {}
Err(failure) => eprintln!(
"onevcs: warning: whether {branch} could be retired was not decided: {failure}",
branch = record.branch,
),
}
}
const RECLASSIFICATIONS: usize = 3;
fn act(
census: &Census<'_>,
branch: &str,
acting: Acting,
trigger: Trigger,
dry_run: bool,
ask: &Ask<'_>,
) -> Result<Retired> {
let mut attempts = 0;
loop {
let classified = census.classify(branch, ask)?;
if !acting.permits(classified.retirement.class) {
return Ok(Retired::nothing(classified.retirement, RetireOutcome::Kept));
}
let plan = census.plan(branch, &classified);
if dry_run {
return Ok(Retired {
retirement: classified.retirement,
outcome: RetireOutcome::WouldRetire,
deleted: census.holders_of(&classified.copies),
failed: Vec::new(),
slots_returned: plan.slots,
run_roots_removed: plan.run_roots,
sessions_closed: plan.sessions.iter().map(session_token).collect(),
});
}
let now = census.copies(branch, ask);
let moved = now != classified.copies;
if !moved {
match census.delete(branch, &classified.copies) {
Deletion::Moved => {}
Deletion::Done(done) => {
return Ok(census.finish(branch, classified, plan, done, (acting, trigger)));
}
}
}
attempts += 1;
if attempts >= RECLASSIFICATIONS {
let mut retirement = classified.retirement;
retirement.class = RetirementClass::Keep;
retirement.reason = Some(KeepReason::Unknown);
retirement.proof = None;
retirement.superseded_by = None;
retirement.differing_paths = Vec::new();
return Ok(Retired::nothing(retirement, RetireOutcome::Kept));
}
}
}
fn session_token(record: &Record) -> SessionToken {
SessionToken(record.token.to_string())
}
struct Plan {
sessions: Vec<Record>,
run_roots: Vec<PathBuf>,
slots: Vec<PathBuf>,
}
enum Deletion {
Moved,
Done(Done),
}
struct Done {
deleted: Vec<BranchHolder>,
failed: Vec<FailedHolder>,
}
impl Census<'_> {
fn plan(&self, branch: &str, classified: &Classified) -> Plan {
let runs = workspace::identity_dir(&self.resolution.key)
.map(|root| root.join("runs"))
.ok();
let sessions: Vec<Record> = self
.sessions
.iter()
.filter(|record| {
record.identity == self.resolution.key
&& *record.branch == *branch
&& !record.owner_is_running()
})
.cloned()
.collect();
let run_roots = sessions
.iter()
.filter(|record| {
record.slot.is_none()
&& record.run_root.is_dir()
&& runs
.as_ref()
.is_some_and(|runs| record.run_root.starts_with(runs))
})
.map(|record| record.run_root.clone())
.collect();
let mut slots: Vec<PathBuf> = Vec::new();
for copy in &classified.copies.copies {
if let Holding::Local(index) = copy.at {
if let Some(slot) = &self.places[index].slot {
if !slots.contains(slot) {
slots.push(slot.clone());
}
}
}
}
for place in &self.places {
let Some(slot) = &place.slot else { continue };
let on_branch =
git::current_branch(&slot.join("worktree")).is_ok_and(|current| current == branch);
if on_branch && !slots.contains(slot) {
slots.push(slot.clone());
}
}
Plan {
sessions,
run_roots,
slots,
}
}
fn delete(&self, branch: &str, copies: &Copies) -> Deletion {
let mut deleted: Vec<(BranchHolder, Option<(PathBuf, String)>)> = Vec::new();
let mut failed: Vec<FailedHolder> = Vec::new();
let restore = |deleted: &[(BranchHolder, Option<(PathBuf, String)>)]| {
for (_, local) in deleted {
if let Some((repo, tip)) = local {
if let Err(failure) = git::restore_branch(repo, branch, tip) {
eprintln!(
"onevcs: warning: {branch} was deleted from {} and could not be put \
back at {tip} after another copy moved: {failure}",
repo.display()
);
}
}
}
};
for copy in &copies.copies {
let Holding::Local(index) = copy.at else {
continue;
};
let place = &self.places[index];
let holder = self.holder(copy.at);
if let Err(failure) = stand_off(&place.repo, branch) {
failed.push(FailedHolder {
kind: holder.kind,
location: holder.location,
error: failure.to_string(),
});
continue;
}
match git::delete_branch_at(&place.repo, branch, ©.tip) {
Ok(()) => deleted.push((holder, Some((place.repo.clone(), copy.tip.clone())))),
Err(failure) => match git::local_tip(&place.repo, branch) {
LocalTip::At(now) if now.as_str() != copy.tip => {
restore(&deleted);
return Deletion::Moved;
}
LocalTip::Absent => deleted.push((holder, None)),
_ => failed.push(FailedHolder {
kind: holder.kind,
location: holder.location,
error: failure.to_string(),
}),
},
}
}
if let Some(origin) = copies.copies.iter().find(|copy| copy.at == Holding::Origin) {
let holder = self.holder(Holding::Origin);
let answered =
git::delete_remote_branch(self.publication(), "origin", branch, &origin.tip);
let refused = match answered {
Ok(pushed) if pushed.accepted() => None,
Ok(pushed) => Some(
pushed
.refusal()
.map(str::to_owned)
.unwrap_or_else(|| pushed.output().trim().to_owned()),
),
Err(failure) => Some(failure.to_string()),
};
match refused {
None => deleted.push((holder, None)),
Some(why) => match git::remote_tip(self.publication(), "origin", branch, &[]) {
Ok(RemoteTip::Absent) => deleted.push((holder, None)),
Ok(RemoteTip::At(now)) if now.as_str() != origin.tip => {
restore(&deleted);
return Deletion::Moved;
}
_ => failed.push(FailedHolder {
kind: holder.kind,
location: holder.location,
error: why,
}),
},
}
}
Deletion::Done(Done {
deleted: deleted.into_iter().map(|(holder, _)| holder).collect(),
failed,
})
}
fn finish(
&self,
branch: &str,
classified: Classified,
plan: Plan,
done: Done,
(acting, trigger): (Acting, Trigger),
) -> Retired {
let Done {
deleted,
mut failed,
} = done;
let mut sessions_closed = Vec::new();
for record in &plan.sessions {
if record.state != Lifecycle::Open {
continue;
}
let mut closed = record.clone();
closed.state = Lifecycle::Closed;
if let Err(failure) = workspace::save(&closed) {
failed.push(FailedHolder {
kind: match record.slot {
Some(_) => BranchHolderKind::Slot,
None => BranchHolderKind::RunClone,
},
location: record.run_root.display().to_string(),
error: format!(
"its session record {} could not be closed: {failure}",
record.token
),
});
continue;
}
if let Ok(mut stream) = Stream::open(&record.token) {
stream.emit(
EventKind::SessionClosed,
workspace::object(json!({"token": record.token, "branch": record.branch})),
);
}
sessions_closed.push(session_token(record));
}
let mut run_roots_removed = Vec::new();
for run_root in &plan.run_roots {
match remove_run_root(run_root) {
Ok(true) => run_roots_removed.push(run_root.clone()),
Ok(false) => {}
Err(why) => failed.push(FailedHolder {
kind: BranchHolderKind::RunClone,
location: run_root.display().to_string(),
error: why,
}),
}
}
let mut slots_returned = Vec::new();
for slot in &plan.slots {
match self.return_slot(slot) {
Ok(true) => slots_returned.push(slot.clone()),
Ok(false) => {}
Err(why) => failed.push(FailedHolder {
kind: BranchHolderKind::Slot,
location: slot.display().to_string(),
error: why,
}),
}
}
let outcome = match failed.is_empty() {
true => RetireOutcome::Retired,
false => RetireOutcome::Incomplete,
};
let retired = Retired {
retirement: classified.retirement,
outcome,
deleted,
failed,
slots_returned,
run_roots_removed,
sessions_closed,
};
record_retirement(&retired, branch, acting, trigger);
retired
}
fn return_slot(&self, slot: &Path) -> std::result::Result<bool, String> {
let pool = slot
.parent()
.ok_or_else(|| format!("{} has no pool above it", slot.display()))?;
let _serial =
lock::exclusive(&pool::placement_identity(pool)).map_err(|e| e.to_string())?;
if !pool::is_idle(&self.resolution.key, slot).map_err(|e| e.to_string())? {
return Ok(false);
}
let Some(_inside) =
lock::try_exclusive(&workspace::occupancy_identity(slot)).map_err(|e| e.to_string())?
else {
return Err("a command is working in it right now".to_owned());
};
let clone = slot.join("clone");
let worktree = slot.join("worktree");
let _ = git::fetch(&clone, "origin");
let base = Ref::from_git(self.base.clone().unwrap_or_else(|| "HEAD".to_owned()));
let delete = workspaces::resolve_for(&self.resolution, workspaces::Overrides::default())
.map(|resolved| resolved.delete)
.map_err(|e| e.to_string())?;
workspace::reset_onto_base(
&worktree,
&workspace::integrated_base(&clone, &base),
&delete,
)
.map_err(|e| e.to_string())?;
Ok(true)
}
}
fn stand_off(repo: &Path, branch: &str) -> Result<()> {
for (worktree, on) in git::worktree_heads(repo)? {
if on.as_deref() == Some(branch) {
git::detach_head(&worktree)?;
}
}
Ok(())
}
fn emptied(path: &Path) -> bool {
std::fs::read_dir(path).is_ok_and(|mut entries| entries.next().is_none())
}
fn remove_run_root(run_root: &Path) -> std::result::Result<bool, String> {
if !processes::holding(run_root).is_empty() {
return Err("a process is working inside it".to_owned());
}
let Some(_exclusive) =
lock::try_exclusive(&workspace::occupancy_identity(run_root)).map_err(|e| e.to_string())?
else {
return Err("a command is working in it right now".to_owned());
};
let clone = run_root.join("clone");
if git::is_repo(&clone)
&& !git::unpublished_branches(&clone)
.unwrap_or_default()
.is_empty()
{
return Ok(false);
}
std::fs::remove_dir_all(run_root)
.map(|()| true)
.map_err(|e| format!("it could not be removed: {e}"))
}
fn record_retirement(retired: &Retired, branch: &str, acting: Acting, trigger: Trigger) {
let retirement = &retired.retirement;
let payload = RetiredPayload {
identity: retirement.identity.clone(),
branch: branch.to_owned(),
tip: retirement.tip.clone(),
class: retirement.class,
reason: retirement.reason,
proof: retirement.proof.clone(),
superseded_by: retirement.superseded_by.clone(),
differing_paths: retirement.differing_paths.clone(),
mode: acting.to_owned(),
trigger: trigger.to_owned(),
deleted: retired.deleted.clone(),
failed: retired.failed.clone(),
slots_returned: retired.slots_returned.clone(),
run_roots_removed: retired.run_roots_removed.clone(),
sessions_closed: retired
.sessions_closed
.iter()
.map(|token| token.0.clone())
.collect(),
};
let Ok(Value::Object(payload)) = serde_json::to_value(&payload) else {
return;
};
match open_stream(&retirement.identity, branch) {
Ok(mut stream) => stream.emit(EventKind::BranchRetired, payload),
Err(failure) => eprintln!(
"onevcs: warning: {branch} was retired and the record of it was not written: {failure}"
),
}
}
pub(crate) fn supersede(record: &Supersession) -> Result<()> {
let registry = store::load()?;
let identity = store::resolve(®istry, &record.repo)?.key;
for (name, value) in [("branch", &record.branch), ("--by", &record.superseded_by)] {
if !git::is_valid_branch_name(value) {
return Err(error::invalid(format!(
"{value:?} is not a valid branch name, so it cannot be the {name} of a supersession"
)));
}
}
if record.branch == record.superseded_by {
return Err(error::invalid(format!(
"{branch:?} cannot supersede itself",
branch = record.branch
)));
}
let landing = SupersedingLanding::parse(&record.landing).ok_or_else(|| {
error::invalid(format!(
"{landing:?} is neither a full commit id nor a change request's http(s) URL, so it \
names no landing",
landing = record.landing
))
})?;
label::validate(&record.labels)?;
let streams = status::recorded_streams(&mut Vec::new())?;
let already = status::supersessions(&streams, &identity, &record.branch)
.into_iter()
.any(|recorded| {
*recorded.superseded_by == *record.superseded_by && recorded.landing == landing
});
if already {
return Ok(());
}
let mut stream = open_stream(&identity, &record.branch)?;
stream.emit(
EventKind::BranchSuperseded,
workspace::object(json!({
"identity": identity,
"branch": record.branch,
"superseded_by": record.superseded_by,
"landing": landing.to_string(),
"labels": record.labels,
})),
);
Ok(())
}