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 change_base: Ref,
pub compared_change_base: String,
pub observed: Option<String>,
pub stack_replay: Option<String>,
}
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 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 base = Ref::from_git(git::default_branch(&resolution.publication, "origin")?);
let source = locate(registry, &resolution, branch, &base)?;
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 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,
})
}
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) = reconciled else {
return Ok(());
};
stream.emit(
EventKind::SyncConflict,
object(json!({"branch": self.branch, "base": self.change_base})),
);
Err(Error::SyncConflict {
reason: match attempted {
publish::Reconciliation::Replay { from } => format!(
"{compared} conflicts with {branch:?}, 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:?}, 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(),
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(),
title,
body: None,
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,
base: &str,
) -> Result<PathBuf> {
let searched = crate::workspace::checkouts_of(registry, resolution)?;
let current = crate::vcs::base_commit(&resolution.publication, base);
let mut spent: Option<PathBuf> = None;
for candidate in &searched {
if !git::is_repo(candidate) || !git::branch_exists(candidate, branch) {
continue;
}
let compared = crate::vcs::judged_against(candidate, base, current.as_ref());
if git::trees_differ(candidate, &compared, branch)? {
return Ok(candidate.clone());
}
spent.get_or_insert_with(|| candidate.clone());
}
if let Some(candidate) = spent {
return Ok(candidate);
}
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(", ")
),
})
}