use std::path::{Path, PathBuf};
use crate::boundary::TermScope;
use crate::error::{self, Error, Result};
use crate::host::Hosting;
use crate::publish::{self, PublishOutcome, Subject};
use crate::registry::Registry;
use crate::rules::MergePolicy;
use crate::store::{self, Resolution};
use crate::stream::Stream;
use crate::workspace::{self, Ref};
use crate::{git, guidance, home, ids, lock, policy, provenance, publishing, sweep};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Verb {
Recover,
PublishBranch,
}
impl Verb {
pub fn name(self) -> &'static str {
match self {
Verb::Recover => "recover",
Verb::PublishBranch => "publish-branch",
}
}
pub fn command(self, branch: &str, repo: &Path) -> String {
let repo = repo.to_string_lossy();
guidance::command(["onevcs", self.name(), branch, "--repo", &repo])
}
pub fn runs(self) -> &'static str {
match self {
Verb::Recover => "recoveries",
Verb::PublishBranch => "publications",
}
}
pub const ALL: [Verb; 2] = [Verb::Recover, Verb::PublishBranch];
}
pub struct Landing {
pub verb: Verb,
pub resolution: Resolution,
pub repo_argument: PathBuf,
pub resolved: policy::Resolved,
pub rules_file: PathBuf,
pub effective: MergePolicy,
pub trailers: provenance::Trailers,
pub source: PathBuf,
pub clone: PathBuf,
pub worktree: PathBuf,
pub run_root: PathBuf,
pub branch: Ref,
pub change_base: Ref,
pub compared_change_base: String,
pub observed: Option<String>,
pub stack_replay: Option<String>,
_tenancy: Tenancy,
}
struct Tenancy {
verb: Verb,
run_root: PathBuf,
source: PathBuf,
lease: Option<lock::Guard>,
_publication: publishing::Lease,
}
impl Drop for Tenancy {
fn drop(&mut self) {
let Some(lease) = self.lease.take() else {
return;
};
let verb = self.verb.name();
let run_root = self.run_root.display();
match sweep::release(&self.run_root, &self.source, lease) {
Ok(sweep::Release::Released { stopped }) if !stopped.is_empty() => eprintln!(
"onevcs: stopped {} still working in the workspace this {verb} built in, {run_root}, \
and released it",
stopped
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(", "),
),
Ok(sweep::Release::Released { .. }) => {}
Ok(sweep::Release::Kept(why)) => eprintln!(
"onevcs: kept the workspace this {verb} built in, {run_root}: {why}. `onevcs \
sweep` reclaims it once that no longer holds",
why = why.describe(),
),
Err(error) => eprintln!(
"onevcs: warning: the workspace this {verb} built in, {run_root}, could not be \
released: {error}. `onevcs sweep` reports what it kept and why"
),
}
}
}
fn stack_tip(repo: &Path, base: &str) -> Option<String> {
git::tip(repo, &format!("origin/{base}")).or_else(|| git::tip(repo, base))
}
pub fn prepare(
registry: &Registry,
verb: Verb,
repo: &Path,
branch: &str,
requested: Option<MergePolicy>,
) -> Result<Landing> {
let resolution = store::resolve_path(registry, repo)?;
let (file, rules_source) = policy::load(registry)?;
let trailers = provenance::from_rules(&file);
if !git::is_valid_branch_name(branch) {
return Err(Error::Invalid {
reason: format!(
"{branch:?} is not a valid branch name; `onevcs recoverable` lists every \
preserved branch by name and the checkout it is in"
),
});
}
let normalized = store::normalize(&resolution.identity.origin);
let resolved = policy::resolve(&file, &rules_source, &normalized, &resolution.publication);
let rules_file = rules_source.file()?;
let effective = publish::effective_policy(&resolved.policy, requested)?;
let base = Ref::from_git(git::default_branch(&resolution.publication, "origin")?);
refuse_unfinished_merges(
registry,
&resolution,
branch,
&format!("land it with `{}`", verb.command(branch, repo)),
)?;
let source = locate(
registry,
&resolution,
branch,
&base,
&format!("land it with `{}`", verb.command(branch, repo)),
)?;
if let Err(error) = sweep::enforce(verb) {
eprintln!(
"onevcs: warning: the {family} workspaces could not be reclaimed before this \
landing: {error}. `onevcs sweep` reports what it kept and why",
family = verb.runs(),
);
}
let run_root = home::workspaces_dir()?.join(verb.runs()).join(format!(
"{}-{}",
policy::branch_slug(branch),
ids::unique()
));
home::ensure_dir(&run_root)?;
let lease = lock::try_shared(&workspace::occupancy_identity(&run_root))?.ok_or_else(|| {
Error::Invalid {
reason: format!(
"the run root {} is already occupied; nothing else should hold a run root this command just cut, so the state root under {} is being written to by something other than onevcs",
run_root.display(),
home::workspaces_dir()
.map(|dir| dir.display().to_string())
.unwrap_or_else(|_| "the workspaces directory".to_owned()),
),
}
})?;
let clone = run_root.join("clone");
let worktree = run_root.join("worktree");
let publication = publishing::Lease::take(&resolution.key, branch, &run_root, &worktree)?;
let tenancy = Tenancy {
verb,
run_root: run_root.clone(),
source: source.clone(),
lease: Some(lease),
_publication: publication,
};
let observed = git::tip(&source, &format!("origin/{branch}"));
let origin = git::remote_url(&source, "origin")
.unwrap_or_else(|_| source.to_string_lossy().into_owned());
git::retain_objects_for_borrowers(&source)?;
git::clone_sharing(&source, &clone, &origin, &base)?;
if !git::import_branch(&clone, &source, branch)? {
return Err(error::at("read branch out of", &source)(branch));
}
git::worktree_add_existing(&clone, &worktree, branch)?;
git::fetch(&clone, "origin")?;
let hosted = git::tip(&clone, &format!("origin/{branch}"));
let compared = crate::vcs::base_ref(&clone, &base);
let recorded = match provenance::recorded_change_base(&clone, &compared, branch, &trailers)? {
Some(recorded) => Some(Ref::try_from(recorded).map_err(|reason| Error::Invalid {
reason: format!(
"branch {branch:?} records the base it was stacked on as {reason}: the {} trailer \
on its preserved commit is not a branch, so nothing here can tell which base it \
belongs on. `onevcs recoverable --json` reports the value as it stands; correct \
that trailer on the branch in {}, then land it with `{}`",
trailers.change_base(),
source.display(),
verb.command(branch, repo),
),
})?),
None => None,
};
let (change_base, stack_replay) = match recorded {
Some(recorded) if recorded != base => {
let tip = stack_tip(&clone, &recorded).ok_or_else(|| Error::Invalid {
reason: format!(
"branch {branch:?} records the base it was stacked on as {recorded:?}, and \
neither {source} nor its origin has that branch, so nothing can tell which \
of the branch's commits belong to the change below it. Restore or push \
{recorded:?}, or correct the {trailer} trailer on the branch in {source}, \
then land it with `{command}`",
source = source.display(),
trailer = trailers.change_base(),
command = verb.command(branch, repo),
),
})?;
if publish::root_is_known_to_carry_the_stack(&clone, branch, &base, &tip)? {
(base.clone(), Some(tip))
} else {
(recorded, None)
}
}
Some(recorded) => (recorded, None),
None => (base.clone(), None),
};
let compared_change_base = crate::vcs::base_ref(&clone, &change_base);
if stack_replay.is_some() && observed.is_none() {
if let Some(hosted) = &hosted {
return Err(Error::SyncConflict {
reason: format!(
"{branch:?} is on the host at {hosted}, and nothing in {source} has ever seen \
it there — so this run has no commit it can safely replace. This publication \
replays the branch onto {change_base:?}, and pushing it without knowing what \
it replaces could overwrite work nobody here has seen. Nothing was pushed \
and the branch is retained. Fetch the host's copy with `{fetch}`, reconcile \
it with the branch, and then land it with `{command}`",
source = source.display(),
fetch = guidance::command([
"git",
"-C",
&source.to_string_lossy(),
"fetch",
"origin",
branch
]),
command = verb.command(branch, repo),
),
});
}
}
Ok(Landing {
verb,
resolution,
repo_argument: repo.to_path_buf(),
resolved,
rules_file,
effective,
trailers,
source,
clone,
worktree,
run_root,
branch: Ref::from_git(branch),
change_base,
compared_change_base,
observed,
stack_replay,
_tenancy: tenancy,
})
}
impl Landing {
pub fn unattested(&self) -> Result<Vec<String>> {
provenance::unattested(
&self.clone,
&self.compared_change_base,
&self.branch,
&self.trailers,
)
}
pub fn unrecognized(&self) -> Result<Vec<crate::rules::TrailerPrefix>> {
provenance::unrecognized(
&self.clone,
&self.compared_change_base,
&self.branch,
&self.trailers,
)
}
pub fn ahead(&self) -> Result<Vec<git::CommitMessage>> {
git::log_messages(&self.clone, &self.compared_change_base, &self.branch)
}
pub fn command(&self) -> String {
self.verb.command(&self.branch, &self.repo_argument)
}
pub fn rules_check(&self) -> String {
let repo = self.repo_argument.to_string_lossy();
guidance::command(["onevcs", "rules", "check", &repo])
}
pub fn command_for(&self, verb: Verb) -> String {
verb.command(&self.branch, &self.repo_argument)
}
pub fn unreadable_prefix(&self, prefix: &crate::rules::TrailerPrefix) -> String {
format!(
"branch {:?} carries provenance under the trailer prefix {prefix:?}, which this host \
is not configured to read. Set trailer_prefix to {prefix:?} in the rules file at {}, \
which must declare version {} to carry that key; confirm it with `{}`, then land \
it with `{}`. Until then nothing may publish it as though it were complete",
self.branch,
self.rules_file.display(),
policy::VERSION,
self.rules_check(),
self.command_for(Verb::Recover),
)
}
pub fn check_subject(&self, title: Option<&Subject>) -> Result<()> {
publish::subject_for(
&self.clone,
&self.compared_change_base,
&self.branch,
title.map(|title| &**title),
&self.trailers,
)
.map(|_| ())
}
pub fn sync_change_base(&self, stream: &mut Stream) -> Result<()> {
let reconciled = publish::reconcile(
&self.worktree,
&self.compared_change_base,
&self.branch,
self.stack_replay.as_deref(),
)?;
let publish::Reconciled::Conflicted(attempted, conflict) = reconciled else {
return Ok(());
};
publish::report_conflict(stream, &self.branch, &self.change_base, &conflict, None);
let conflicting = guidance::listed(conflict.paths());
Err(Error::SyncConflict {
reason: match attempted {
publish::Reconciliation::Replay { from } => format!(
"{compared} conflicts with {branch:?} in {conflicting}, and re-running will \
conflict again: {compared} already carries what {branch:?} was stacked on, so this verb \
replays only its own commits onto {compared} and nothing about either has \
changed. The branch is retained in {source} — resolve the conflict on it \
there, by replaying it with `{replay}` and committing the resolution, and \
then land it with `{command}`, which is what publishes it",
compared = self.compared_change_base,
branch = self.branch,
source = self.source.display(),
replay = guidance::command([
"git",
"rebase",
"--onto",
&self.compared_change_base,
&from,
&self.branch,
]),
command = self.command(),
),
publish::Reconciliation::Merge => format!(
"{compared} conflicts with {branch:?} in {conflicting}, and re-running will \
conflict again: this verb merges {compared} into the branch and nothing about either has \
changed. The branch is retained in {source} — resolve the conflict on it \
there, by merging {compared} into it and committing the resolution, and then \
land it with `{command}`, which is what publishes it",
compared = self.compared_change_base,
branch = self.branch,
source = self.source.display(),
command = self.command(),
),
},
})
}
pub fn publish(
&self,
title: Option<Subject>,
body: Option<String>,
term_scope: &TermScope,
hosting: &dyn Hosting,
stream: &mut Stream,
) -> Result<PublishOutcome> {
let context = publish::Context {
resolution: self.resolution.clone(),
policy: self.resolved.policy.clone(),
effective: self.effective,
repo: self.clone.clone(),
worktree: self.worktree.clone(),
branch: self.branch.clone(),
target: publish::Target::Base(self.change_base.clone()),
push: match (&self.stack_replay, &self.observed) {
(Some(_), Some(replaced)) => publish::Push::Replacing {
replaced: replaced.clone(),
},
_ => publish::Push::Forward,
},
run_root: self.run_root.clone(),
preserved_into: self.source.clone(),
title,
body,
draft: None,
drafts: self.resolved.drafts.clone(),
trailers: Vec::new(),
provenance: self.trailers.clone(),
hosting,
cancellation: &publish::NeverCancelled,
built: publish::Built::InWorkspace,
term_scope: term_scope.clone(),
};
let outcome = publish::run(&context, stream);
if outcome.is_err() {
let _ = git::copy_branch(&context.repo, &self.source, &self.branch);
}
outcome
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Holds {
Work,
WhatTheBaseHas,
}
struct Held {
checkout: PathBuf,
tip: String,
holds: Holds,
}
impl Held {
fn describe(&self) -> String {
format!(
"{} at {}{}",
self.checkout.display(),
self.tip,
match self.holds {
Holds::Work => "",
Holds::WhatTheBaseHas => " (already in the base)",
}
)
}
}
pub(crate) fn refuse_unfinished_merges(
registry: &Registry,
resolution: &Resolution,
branch: &str,
then: &str,
) -> Result<()> {
for checkout in crate::workspace::checkouts_of(registry, resolution)? {
if !git::is_repo(&checkout) {
continue;
}
for (worktree, on) in git::worktree_heads(&checkout)? {
if on.as_deref() == Some(branch) {
crate::vcs::refuse_unfinished_merge(&worktree, branch, then)?;
}
}
}
Ok(())
}
pub(crate) fn locate(
registry: &Registry,
resolution: &Resolution,
branch: &str,
base: &str,
next: &str,
) -> Result<PathBuf> {
let searched = crate::workspace::checkouts_of(registry, resolution)?;
let current = crate::vcs::base_commit(&resolution.publication, base);
let mut held: Vec<Held> = Vec::new();
for candidate in &searched {
let Some(tip) = git::is_repo(candidate)
.then(|| git::tip(candidate, &format!("refs/heads/{branch}")))
.flatten()
else {
continue;
};
let compared = crate::vcs::judged_against(candidate, base, current.as_ref());
let holds = match git::trees_differ(candidate, &compared, branch)? {
true => Holds::Work,
false => Holds::WhatTheBaseHas,
};
held.push(Held {
checkout: candidate.clone(),
tip,
holds,
});
}
let [first, second, ..] = held.as_slice() else {
let Some(only) = held.first() else {
return Err(nowhere(&resolution.key, branch, &searched));
};
announce(branch, only, &held);
return Ok(only.checkout.clone());
};
if held.iter().all(|copy| copy.holds == Holds::WhatTheBaseHas) {
announce(branch, first, &held);
return Ok(first.checkout.clone());
}
for copy in &held {
if !carries_the_rest(copy, &held)? {
continue;
}
announce(branch, copy, &held);
return Ok(copy.checkout.clone());
}
let differing = held
.iter()
.find(|copy| copy.tip != first.tip)
.unwrap_or(second);
let differ = differ(
first,
&git::shape_of(&first.checkout, &first.tip)?,
differing,
&git::shape_of(&differing.checkout, &differing.tip)?,
);
Err(diverged(
branch,
&resolution.key,
next,
&held,
first,
differing,
&differ,
))
}
fn carries_the_rest(copy: &Held, held: &[Held]) -> Result<bool> {
for other in held {
if !git::known_to_reach(©.checkout, &other.tip, ©.tip)? {
return Ok(false);
}
}
Ok(true)
}
fn announce(branch: &str, chosen: &Held, held: &[Held]) {
if held.iter().all(|copy| copy.tip == chosen.tip) {
return;
}
let passed_over: Vec<String> = held
.iter()
.filter(|copy| copy.checkout != chosen.checkout)
.map(Held::describe)
.collect();
eprintln!(
"onevcs: branch {branch:?} is in {count} checkouts of this identity, and the copy in \
{chosen} is the one being published; passed over: {passed_over}",
count = held.len(),
chosen = chosen.describe(),
passed_over = passed_over.join(", "),
);
}
fn diverged(
branch: &str,
identity: &str,
next: &str,
held: &[Held],
into: &Held,
from: &Held,
differ: &str,
) -> Error {
Error::Invalid {
reason: format!(
"branch {branch:?} is in {count} checkouts of identity {identity:?}, and no copy of it \
carries the rest, so nothing here can tell which copy is the work and taking one \
would discard the other: {listed}. {differ}. Reconcile them in one checkout — \
`{fetch}` brings {from}'s copy into {into} as FETCH_HEAD, `{diff}` then shows what \
the two differ by, and merging or rebasing onto the one that is there keeps both — \
or delete the copy that is not the work, and then {next}",
count = held.len(),
listed = held
.iter()
.map(Held::describe)
.collect::<Vec<_>>()
.join(", "),
fetch = guidance::command([
"git",
"-C",
&into.checkout.to_string_lossy(),
"fetch",
&from.checkout.to_string_lossy(),
branch,
]),
diff = guidance::command([
"git",
"-C",
&into.checkout.to_string_lossy(),
"diff",
"--stat",
&into.tip,
&from.tip,
]),
from = from.checkout.display(),
into = into.checkout.display(),
),
}
}
fn differ(into: &Held, into_shape: &git::Shape, from: &Held, from_shape: &git::Shape) -> String {
let amended = into_shape.parents == from_shape.parents
&& into_shape.subject == from_shape.subject
&& into_shape.tree != from_shape.tree;
let how = match amended {
true => {
"the way an amend does — the same parent and the same subject over a different \
tree, so one of them was re-committed after the other was taken"
}
false => "as two separate commits do",
};
format!(
"They differ {how}: {into}, and {from}",
into = facts_of(into, into_shape),
from = facts_of(from, from_shape),
)
}
fn facts_of(copy: &Held, shape: &git::Shape) -> String {
format!(
"the copy in {path} stands at {tip}, on parent(s) {parents:?}, with the subject \
{subject:?}, the tree {tree}, and the commit date {committed}",
path = copy.checkout.display(),
tip = copy.tip,
parents = shape.parents,
subject = shape.subject,
tree = shape.tree,
committed = shape.committed,
)
}
fn nowhere(identity: &str, branch: &str, searched: &[PathBuf]) -> Error {
Error::Invalid {
reason: format!(
"branch {branch:?} is in none of the checkouts of identity {identity:?}: {}. `onevcs \
recoverable` lists every preserved branch and the checkout it is in",
searched
.iter()
.map(|path| path.display().to_string())
.collect::<Vec<_>>()
.join(", ")
),
}
}