use std::path::Path;
use crate::error::{Error, Result};
use crate::event::EventKind;
use crate::host::{Hosting, Sha};
use crate::landed::{self, Landed};
use crate::publish::{Publication, PublishRequest};
use crate::registry::Identity;
use crate::session::{
HeldBy, Holding, Lifecycle, LineChange, Liveness, NetNegative, PreservedBranch, Provenance,
Recoverable, Scope, Session, SessionRecord, SessionRequest, SessionToken,
};
use crate::stream::Stream;
use crate::workspace::{self, object};
use crate::{git, lock, provenance, publish, store};
use serde_json::json;
use url::Url;
pub trait Vcs {
fn resolve_identity(&self, origin_or_path: &str) -> Result<Identity>;
fn open_session(&self, req: SessionRequest) -> Result<Session>;
fn adopt_session(&self, token: SessionToken) -> Result<Session>;
fn session(&self, token: &SessionToken) -> Result<SessionRecord>;
fn close_session(&self, token: &SessionToken) -> Result<Session>;
fn preserve(&self, s: &Session, provenance: Provenance) -> Result<PreservedBranch>;
fn publish(
&self,
token: &SessionToken,
request: &PublishRequest,
hosting: &dyn Hosting,
) -> Result<Publication>;
fn recoverable(&self, scope: Scope) -> Result<Vec<Recoverable>>;
fn preserved(&self, scope: Scope) -> Result<Vec<Recoverable>>;
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Git;
impl Vcs for Git {
fn resolve_identity(&self, origin_or_path: &str) -> Result<Identity> {
let registry = store::load()?;
Ok(store::resolve(®istry, origin_or_path)?.identity)
}
fn open_session(&self, req: SessionRequest) -> Result<Session> {
let registry = store::load()?;
let (record, _stream) = workspace::open(®istry, &req)?;
Ok(record.session())
}
fn adopt_session(&self, token: SessionToken) -> Result<Session> {
let (record, _stream, _preserved) = workspace::adopt(&token.0)?;
Ok(record.session())
}
fn session(&self, token: &SessionToken) -> Result<SessionRecord> {
let record = workspace::load(&token.0)?;
let base = judging_base(&record)?;
let trailers = provenance::configured()?;
Ok(SessionRecord {
session: record.session(),
identity: record.identity.clone(),
lifecycle: record.state,
provenance: provenance::provenance_of(&record.clone, &base, &record.branch, &trailers)?,
retried_by: record
.retried_by
.as_ref()
.map(|token| SessionToken(token.to_string())),
})
}
fn close_session(&self, token: &SessionToken) -> Result<Session> {
let record = workspace::close(&token.0)?;
Ok(record.session())
}
fn preserve(&self, s: &Session, provenance: Provenance) -> Result<PreservedBranch> {
let record = workspace::load(&s.token.0)?;
let mut stream = Stream::open(&s.token.0)?;
preserve_into(&record, &mut stream, provenance)
}
fn publish(
&self,
token: &SessionToken,
request: &PublishRequest,
hosting: &dyn Hosting,
) -> Result<Publication> {
publish::run_for_session(token, request, hosting)
}
fn recoverable(&self, scope: Scope) -> Result<Vec<Recoverable>> {
collect(&scope, Reporting::UnpublishedOnly)
}
fn preserved(&self, scope: Scope) -> Result<Vec<Recoverable>> {
collect(&scope, Reporting::Everything)
}
}
pub fn preserve_into(
record: &workspace::Record,
stream: &mut Stream,
kind: Provenance,
) -> Result<PreservedBranch> {
let trailers = provenance::configured()?;
if git::is_dirty(&record.worktree)? {
git::add_all(&record.worktree)?;
let message = match kind {
Provenance::Complete => format!("chore: preserve work on {}", record.branch),
Provenance::IncompleteStep => provenance::incomplete_message(
&format!("work on {}", record.branch),
record.change_base.as_deref(),
&trailers,
),
};
let sha = git::commit(&record.worktree, &message)?;
stream.emit(
EventKind::CommitPreserved,
object(json!({
"branch": record.branch,
"sha": sha,
"provenance": spell_provenance(kind),
})),
);
}
let copied = git::copy_branch(&record.clone, &record.execution_checkout, &record.branch)?;
if !copied {
return Err(Error::Invalid {
reason: format!(
"the execution checkout {} refused branch {:?}; it holds work this session's \
clone does not, so nothing outside the session carries this branch",
record.execution_checkout.display(),
record.branch
),
});
}
let base = judging_base(record)?;
Ok(PreservedBranch {
branch: record.branch.to_string(),
base: record.base.to_string(),
provenance: provenance::provenance_of(&record.clone, &base, &record.branch, &trailers)?,
change_url: None,
change_base: record.change_base.as_ref().map(ToString::to_string),
})
}
pub fn spell_provenance(kind: Provenance) -> &'static str {
match kind {
Provenance::Complete => "complete",
Provenance::IncompleteStep => "incomplete-step",
}
}
fn judging_base(record: &workspace::Record) -> Result<String> {
let target = publish::standing_target(record)?;
Ok(base_ref(&record.clone, target.base()))
}
pub fn base_ref(repo: &Path, base: &str) -> String {
let remote = format!("origin/{base}");
if git::ref_exists(repo, &format!("refs/remotes/{remote}")) {
remote
} else {
base.to_owned()
}
}
pub fn judged_against<'a>(
repo: impl Into<git::Asked<'a>>,
base: &str,
current: Option<&Sha>,
) -> String {
let repo = repo.into();
match current {
Some(sha) if git::has_commit(repo, sha) => sha.0.clone(),
_ => base_ref(repo.path(), base),
}
}
pub fn base_commit(checkout: &Path, base: &str) -> Option<Sha> {
git::tip(checkout, &base_ref(checkout, base)).map(Sha)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Reporting {
UnpublishedOnly,
Everything,
}
pub fn collect(scope: &Scope, reporting: Reporting) -> Result<Vec<Recoverable>> {
let registry = store::load()?;
let (rules, _source) = crate::policy::load(®istry)?;
let trailers = provenance::from_rules(&rules);
let sessions = workspace::all()?;
let streams = crate::status::recorded_streams(&mut Vec::new())?;
let wanted = match scope {
Scope::All => None,
Scope::Repo(repo) => Some(store::resolve(®istry, repo)?.key),
};
let mut rows: Vec<(Option<u64>, Recoverable)> = Vec::new();
let mut withheld_rows: Vec<(Option<u64>, Recoverable)> = Vec::new();
let mut seen: Vec<(String, String)> = Vec::new();
let mut identities: Vec<&str> = registry
.checkouts
.values()
.map(|checkout| checkout.identity.as_str())
.collect();
identities.sort_unstable();
identities.dedup();
for identity in identities {
if wanted.as_ref().is_some_and(|key| key != identity) {
continue;
}
let resolution = store::resolve(®istry, identity)?;
let publication = resolution.publication.clone();
let current = git::default_branch(&publication, "origin")
.ok()
.and_then(|base| base_commit(&publication, &base));
let lent = git::objects_dir(&publication).ok();
for repo in workspace::checkouts_of(®istry, &resolution)? {
if !git::is_repo(&repo) {
continue;
}
let base = match git::default_branch(&repo, "origin") {
Ok(base) => base,
Err(_) => continue,
};
let asked = git::Asked::borrowing(&repo, lent.as_deref());
let compared = judged_against(asked, &base, current.as_ref());
for branch in git::unpublished_branches(&repo)? {
let key = (identity.to_owned(), branch.clone());
if seen.contains(&key) {
continue;
}
if superseded_copy(&sessions, &repo, identity, &branch) {
continue;
}
let recorded = crate::status::recorded_for(
&streams,
identity,
&branch,
session_holding(&sessions, identity, &branch),
);
let recorded = landed::Recorded {
change: recorded
.change
.or_else(|| change_url_of(asked, &compared, &branch, &trailers)),
..recorded
};
let change_url = recorded.change.clone();
let mut verdict = landed::decide(
asked,
&compared,
current.as_ref(),
&branch,
&recorded,
&trailers,
)?;
if unfollowable_chain(&sessions, identity, &branch) {
verdict = Landed::Unknown;
}
let withheld = verdict.is_landed();
if withheld && reporting == Reporting::UnpublishedOnly {
continue;
}
if !withheld {
seen.push(key);
}
let row = preserved_row(
&Preserved {
identity,
repo: asked,
publication: &publication,
base: &base,
compared: &compared,
branch: &branch,
change_url,
verdict,
},
&sessions,
&trailers,
)?;
if withheld {
withheld_rows.push(row);
} else {
rows.push(row);
}
}
}
}
let mut kept: Vec<(String, String)> = Vec::new();
for (at, row) in withheld_rows {
let key = (row.identity.clone(), row.branch.branch.clone());
if seen.contains(&key) || kept.contains(&key) {
continue;
}
kept.push(key);
rows.push((at, row));
}
rows.sort_by_key(|(at, row)| {
(
std::cmp::Reverse(at.unwrap_or(0)),
row.branch.branch.clone(),
)
});
Ok(rows.into_iter().map(|(_, row)| row).collect())
}
struct Preserved<'a> {
identity: &'a str,
repo: git::Asked<'a>,
publication: &'a Path,
base: &'a str,
compared: &'a str,
branch: &'a str,
change_url: Option<Url>,
verdict: Landed,
}
fn preserved_row(
preserved: &Preserved<'_>,
sessions: &[workspace::Record],
trailers: &provenance::Trailers,
) -> Result<(Option<u64>, Recoverable)> {
let Preserved {
identity,
repo,
publication,
base,
compared,
branch,
ref change_url,
ref verdict,
} = *preserved;
let unrecognized = provenance::unrecognized(repo, compared, branch, trailers)?;
let kind = match unrecognized.first() {
Some(_) => Provenance::IncompleteStep,
None => provenance::provenance_of(repo, compared, branch, trailers)?,
};
let incomplete = kind == Provenance::IncompleteStep;
let change_base = provenance::recorded_change_base(repo, compared, branch, trailers)?;
let held_by = held_by(sessions, identity, branch)?;
let mut stopped = match verdict {
Landed::Yes { evidence } => format!(
"nothing stopped: this branch's work reached {base}, and {tier} ({commit}) says so",
tier = verdict.tier(),
commit = evidence.commit(),
),
_ => match &held_by {
Some(held) => format!(
"nothing has stopped: session {token} still holds this branch and \
{because}. Its work is being made in {worktree}",
token = held.token.0,
because = held.holding.because(),
worktree = held.worktree.display(),
),
None => sessions
.iter()
.find(|record| *record.branch == *branch)
.map(|record| {
if record.state == Lifecycle::Open {
format!("session {} was left open", record.token)
} else {
format!("session {} closed without publishing", record.token)
}
})
.unwrap_or_else(|| {
"no session record names this branch; it stopped before recording one"
.to_owned()
}),
},
};
if let Landed::InPart { evidence, unlanded } = verdict {
stopped.push_str(&format!(
". Part of this branch's work reached {base}, and {tier} ({commit}) says so — the \
{unlanded} commit(s) it has gained since are what is left to publish",
tier = verdict.tier(),
commit = evidence.commit(),
));
}
if *verdict == Landed::Unknown {
stopped.push_str(&format!(
". Whether it landed cannot be decided from history: nothing records that it \
reached {base} — no landing, no change request's number in the base's history, \
and no landing trailer — and comparing content settles nothing here, so {base} \
may already carry this work"
));
}
if let Some(prefix) = unrecognized.first() {
stopped.push_str(&format!(
". Its provenance is written under the trailer prefix {prefix:?}, which \
this host is not configured to read: set trailer_prefix in the rules \
file to {prefix:?} before publishing it"
));
}
let verb = if incomplete {
"recover"
} else {
"publish-branch"
};
let recover_command = match verdict.is_landed() {
true => Vec::new(),
false => vec![
"onevcs".to_owned(),
verb.to_owned(),
branch.to_owned(),
"--repo".to_owned(),
publication.display().to_string(),
],
};
Ok((
git::committed_at(repo.path(), branch),
Recoverable {
identity: identity.to_owned(),
branch: PreservedBranch {
branch: branch.to_owned(),
base: base.to_owned(),
provenance: kind,
change_url: change_url
.clone()
.or_else(|| change_url_of(repo, compared, branch, trailers)),
change_base,
},
checkout: repo.path().to_path_buf(),
landed: verdict.clone(),
stopped_because: stopped,
recover_command,
held_by,
net_negative: net_negative(repo, compared, branch)?,
},
))
}
fn superseded_copy(
sessions: &[workspace::Record],
repo: &Path,
identity: &str,
branch: &str,
) -> bool {
sessions.iter().any(|record| {
record.retried_by.is_some()
&& record.clone == repo
&& record.identity == identity
&& *record.branch == *branch
})
}
fn unfollowable_chain(sessions: &[workspace::Record], identity: &str, branch: &str) -> bool {
sessions
.iter()
.filter(|record| record.identity == identity && *record.branch == *branch)
.any(|record| workspace::newest(record).is_err())
}
fn session_holding<'a>(
sessions: &'a [workspace::Record],
identity: &str,
branch: &str,
) -> Option<&'a str> {
sessions
.iter()
.find(|record| record.identity == identity && *record.branch == *branch)
.map(|record| record.token.as_ref())
}
fn held_by(sessions: &[workspace::Record], identity: &str, branch: &str) -> Result<Option<HeldBy>> {
for record in sessions {
if record.identity != identity
|| *record.branch != *branch
|| record.state != Lifecycle::Open
{
continue;
}
let holding = match record.liveness() {
Liveness::Live => Some(Holding::OwnerRunning),
Liveness::Stale => {
lock::is_occupied(&record.lease())?.then_some(Holding::RunRootOccupied)
}
};
if let Some(holding) = holding {
return Ok(Some(HeldBy {
token: SessionToken(record.token.to_string()),
worktree: record.worktree.clone(),
holding,
}));
}
}
Ok(None)
}
fn net_negative<'a>(
repo: impl Into<git::Asked<'a>>,
compared: &str,
branch: &str,
) -> Result<Option<NetNegative>> {
let repo = repo.into();
let Some(fork) = git::merge_base(repo, compared, branch)? else {
return Ok(None);
};
let counted = git::line_change(repo, &fork, branch)?;
Ok(NetNegative::new(LineChange {
added: counted.added,
removed: counted.removed,
}))
}
pub fn change_url_of<'a>(
repo: impl Into<git::Asked<'a>>,
base: &str,
branch: &str,
trailers: &provenance::Trailers,
) -> Option<Url> {
let commits = git::log_messages(repo, base, branch).ok()?;
commits
.iter()
.rev()
.flat_map(|commit| commit.message.lines())
.filter_map(|line| line.trim().strip_prefix(trailers.change_url()))
.find_map(|value| Url::parse(value.trim()).ok())
}