use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::boundary::TermScope;
use crate::error::{Error, Result};
use crate::event::EventKind;
use crate::git::{self, RemoteTip};
use crate::registry::Registry;
use crate::store::{self, Resolution};
use crate::stream::Stream;
use crate::workspace::{self, object};
use crate::{branch, guidance, policy};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PreserveRequest {
pub repo: String,
pub branch: String,
#[serde(default, skip_serializing_if = "TermScope::is_registry")]
pub term_scope: TermScope,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Preservation {
Pushed,
AlreadyOnOrigin,
NoRemote,
}
pub(crate) const PUSHED: &str = "pushed";
pub(crate) const ALREADY_ON_ORIGIN: &str = "already-on-origin";
pub(crate) const NO_REMOTE: &str = "no-remote";
impl Preservation {
fn wire(self) -> &'static str {
match self {
Preservation::Pushed => PUSHED,
Preservation::AlreadyOnOrigin => ALREADY_ON_ORIGIN,
Preservation::NoRemote => NO_REMOTE,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Preserved {
pub identity: String,
pub branch: String,
pub from: PathBuf,
pub remote: Option<String>,
pub commit: Option<String>,
pub outcome: Preservation,
}
pub fn run(registry: &Registry, request: &PreserveRequest) -> Result<Preserved> {
let resolution = store::resolve(registry, &request.repo)?;
let branch = &request.branch;
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 base = git::default_branch(&resolution.publication, "origin")?;
branch::refuse_unfinished_merges(
registry,
&resolution,
branch,
&format!("preserve it with `{}`", command(&resolution, branch)),
)?;
let from = branch::locate(
registry,
&resolution,
branch,
&base,
&format!("preserve it with `{}`", command(&resolution, branch)),
)?;
let mut stream = open_stream(&resolution, branch)?;
let reference = format!("refs/heads/{branch}");
let commit = git::tip(&from, &reference).ok_or_else(|| Error::Invalid {
reason: format!(
"branch {branch:?} resolves to no commit in {}, so there is nothing to \
preserve; `onevcs recoverable` lists every preserved branch and the checkout \
it is in",
from.display()
),
})?;
if !git::has_remote(&from, ORIGIN) {
return Ok(report(
&mut stream,
Preserved {
identity: resolution.key.clone(),
branch: branch.clone(),
from,
remote: None,
commit: Some(commit),
outcome: Preservation::NoRemote,
},
));
}
let remote = git::remote_url(&from, ORIGIN)?;
if let RemoteTip::At(advertised) = git::remote_tip(&from, ORIGIN, branch, &[])? {
if advertised.as_str() == commit {
return Ok(report(
&mut stream,
Preserved {
identity: resolution.key.clone(),
branch: branch.clone(),
from,
remote: Some(remote),
commit: Some(commit),
outcome: Preservation::AlreadyOnOrigin,
},
));
}
}
let compared = format!("{ORIGIN}/{base}");
crate::boundary::evidence::guard_publication(
crate::Providers::real().hosting,
&resolution.key,
&crate::boundary::screen::Outgoing {
repo: &from,
base: git::tip(&from, &compared).map(|_| compared.as_str()),
tip: &reference,
branch: Some(branch),
title: None,
body: None,
},
&request.term_scope,
)?;
let pushed = git::push_preserving(&from, branch, ORIGIN)?;
if !pushed.accepted() {
return Err(refused(&resolution, branch, &remote, &pushed));
}
Ok(report(
&mut stream,
Preserved {
identity: resolution.key.clone(),
branch: branch.clone(),
from,
remote: Some(remote),
commit: Some(commit),
outcome: Preservation::Pushed,
},
))
}
const ORIGIN: &str = "origin";
fn report(stream: &mut Stream, preserved: Preserved) -> Preserved {
let payload = object(json!({
"branch": preserved.branch,
"identity": preserved.identity,
"remote": preserved.remote,
"commit": preserved.commit,
"outcome": preserved.outcome.wire(),
}));
stream.emit(EventKind::BranchPreserved, payload);
preserved
}
fn open_stream(resolution: &Resolution, branch: &str) -> Result<Stream> {
let token = workspace::all()?
.into_iter()
.find(|record| record.identity == resolution.key && *record.branch == *branch)
.map(|record| record.token.to_string())
.unwrap_or_else(|| preserve_token(branch));
let mut stream = Stream::open(&token)?;
stream.label("identity", &resolution.key);
Ok(stream)
}
pub(crate) fn preserve_token(branch: &str) -> String {
format!("preserve-{}", policy::branch_slug(branch))
}
fn command(resolution: &Resolution, branch: &str) -> String {
let repo = resolution.publication.to_string_lossy();
guidance::command(["onevcs", "preserve", branch, "--repo", &repo])
}
fn refused(resolution: &Resolution, branch: &str, remote: &str, pushed: &git::Pushed) -> Error {
let summary = pushed
.refusal()
.unwrap_or_else(|| pushed.output().lines().next_back().unwrap_or("").trim());
Error::PushRejected {
reason: format!(
"{remote} declined branch {branch:?} of identity {key:?}: {summary}. Nothing was \
pushed and nothing was replaced — a preserving push is never forced, so a \
non-fast-forward is reported rather than resolved by discarding what the origin \
has. Reconcile the two in {from}, then run `{again}`",
key = resolution.key,
from = resolution.publication.display(),
again = command(resolution, branch),
),
}
}