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 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")?);
let source = locate(
registry,
&resolution,
branch,
&base,
&format!("land it with `{}`", verb.command(branch, repo)),
)?;
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
}
}
#[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 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);
Err(diverged(
branch,
&resolution.key,
next,
&held,
first,
differing,
))
}
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,
) -> 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}. Reconcile them in one checkout — \
`{fetch}` brings {from}'s copy into {into} as FETCH_HEAD, to merge or rebase onto the \
one that is there — 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,
]),
from = from.checkout.display(),
into = into.checkout.display(),
),
}
}
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(", ")
),
}
}