use std::path::{Path, PathBuf};
use serde_json::json;
use crate::error::{self, Error, Result};
use crate::event::EventKind;
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::{object, Ref};
use crate::{git, guidance, home, ids, policy, provenance};
#[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])
}
fn runs(self) -> &'static str {
match self {
Verb::Recover => "recoveries",
Verb::PublishBranch => "publications",
}
}
}
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 base: Ref,
pub change_base: Ref,
pub compared_change_base: String,
}
pub fn prepare(
registry: &Registry,
verb: Verb,
repo: &Path,
branch: &str,
requested: Option<MergePolicy>,
) -> Result<Landing> {
let named = repo.to_str().ok_or_else(|| Error::Invalid {
reason: format!(
"the repository path {} is not valid UTF-8, so it can name no registered checkout; \
`onevcs repos` lists them as they are recorded",
repo.display()
),
})?;
let resolution = store::resolve(registry, named)?;
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 source = locate(registry, &resolution, branch)?;
let run_root = home::workspaces_dir()?.join(verb.runs()).join(format!(
"{}-{}",
policy::branch_slug(branch),
ids::unique()
));
home::ensure_dir(&run_root)?;
let clone = run_root.join("clone");
let worktree = run_root.join("worktree");
let base = Ref::from_git(git::default_branch(&resolution.publication, "origin")?);
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 compared = crate::vcs::base_ref(&clone, &base);
let change_base = match provenance::recorded_change_base(&clone, &compared, branch, &trailers)?
{
Some(recorded) => 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 => base.clone(),
};
let compared_change_base = crate::vcs::base_ref(&clone, &change_base);
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),
base,
change_base,
compared_change_base,
})
}
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 merge_change_base(&self, stream: &mut Stream) -> Result<()> {
let merged = git::merge_into_branch(
&self.worktree,
&self.compared_change_base,
&format!("Merge {} into {}", self.compared_change_base, self.branch),
)?;
if merged {
return Ok(());
}
stream.emit(
EventKind::SyncConflict,
object(json!({"branch": self.branch, "base": self.change_base})),
);
Err(Error::SyncConflict {
reason: format!(
"{compared} conflicts with {branch:?}, 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>,
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(),
base: self.base.clone(),
change_base: self.change_base.clone(),
run_root: self.run_root.clone(),
title,
trailers: Vec::new(),
provenance: self.trailers.clone(),
hosting,
};
let outcome = publish::run(&context, stream);
if outcome.is_err() {
let _ = git::copy_branch(&context.repo, &self.source, &self.branch);
}
outcome
}
}
fn locate(registry: &Registry, resolution: &Resolution, branch: &str) -> Result<PathBuf> {
let mut searched: Vec<PathBuf> = vec![resolution.publication.clone()];
for checkout in registry.checkouts.values() {
if checkout.identity == resolution.key && !searched.contains(&checkout.path) {
searched.push(checkout.path.clone());
}
}
for record in crate::workspace::all()? {
if record.identity == resolution.key && !searched.contains(&record.clone) {
searched.push(record.clone.clone());
}
}
for candidate in &searched {
if git::is_repo(candidate) && git::branch_exists(candidate, branch) {
return Ok(candidate.clone());
}
}
Err(Error::Invalid {
reason: format!(
"branch {branch:?} is in none of the checkouts of identity {:?}: {}. `onevcs \
recoverable` lists every preserved branch and the checkout it is in",
resolution.key,
searched
.iter()
.map(|path| path.display().to_string())
.collect::<Vec<_>>()
.join(", ")
),
})
}