use std::path::{Path, PathBuf};
use crate::error::{Error, Result};
use crate::registry::Registry;
use crate::store::{self, Resolution};
use crate::workspace::Ref;
use crate::{branch, git, guidance, ids, policy};
fn preserved(name: &str) -> String {
format!("preserved/{}", policy::branch_slug(name))
}
fn under_another_name(repo: &Path, branch: &str, from: Option<&str>, name: &str) -> String {
let mut argv = vec!["onevcs", "import", branch, "--repo"];
let repo = repo.to_string_lossy();
argv.push(&repo);
if let Some(from) = from {
argv.extend_from_slice(&["--from", from]);
}
let second = preserved(name);
argv.extend_from_slice(&["--as", &second]);
guidance::command(argv)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Source {
Repository(PathBuf),
Remote {
remote: String,
branch: Ref,
},
}
impl Source {
pub fn describe(&self) -> String {
match self {
Source::Repository(path) => path.display().to_string(),
Source::Remote { remote, branch } => format!("{remote}/{branch}"),
}
}
fn location(&self) -> String {
match self {
Source::Repository(path) => path.to_string_lossy().into_owned(),
Source::Remote { remote, .. } => remote.clone(),
}
}
fn branch<'a>(&'a self, asked: &'a Ref) -> &'a Ref {
match self {
Source::Repository(_) => asked,
Source::Remote { branch, .. } => branch,
}
}
}
#[derive(Debug, Clone)]
pub struct Imported {
pub destination: PathBuf,
pub name: Ref,
pub source: Source,
pub tip: String,
pub wrote: Wrote,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Wrote {
Created,
FastForwarded,
Unchanged,
}
pub fn run(
registry: &Registry,
repo: &Path,
branch: &str,
from: Option<&str>,
under: Option<&str>,
) -> Result<Imported> {
let resolution = store::resolve_path(registry, repo)?;
let destination = resolution.publication.clone();
let asked = Ref::try_from(branch.to_owned()).map_err(|reason| Error::Invalid {
reason: format!(
"{reason}; `onevcs recoverable` lists every preserved branch by name and the \
checkout it is in"
),
})?;
let name = match under {
Some(under) => Ref::try_from(under.to_owned()).map_err(|reason| Error::Invalid {
reason: format!("--as {reason}: it is not a valid branch name"),
})?,
None => asked.clone(),
};
let source = source_of(registry, &resolution, repo, &destination, &asked, from)?;
let current = git::current_branch(&destination)?;
if current == *name {
return Err(Error::Invalid {
reason: format!(
"{destination} has {name:?} checked out, and import only ever writes refs — \
moving that one would leave its working tree describing a commit the branch no \
longer names. Import it under another name with `{command}`",
destination = destination.display(),
command = under_another_name(repo, branch, from, &name),
),
});
}
let scratch = format!("refs/onevcs/import/{}", ids::unique());
let fetched = git::fetch_into_ref(
&destination,
&source.location(),
source.branch(&asked),
&scratch,
)?;
let imported = (|| -> Result<Imported> {
if !fetched {
return Err(Error::Invalid {
reason: format!(
"{source} has no branch {branch:?} to import; `onevcs recoverable` lists every \
preserved branch and the checkout it is in",
source = source.describe(),
branch = source.branch(&asked),
),
});
}
let tip = git::tip(&destination, &scratch).ok_or_else(|| Error::Invalid {
reason: format!(
"the fetch of {branch:?} from {} left no commit this build can read",
source.describe(),
branch = source.branch(&asked),
),
})?;
let held = git::tip(&destination, &format!("refs/heads/{name}"));
let wrote = match &held {
Some(held) if *held == tip => Wrote::Unchanged,
Some(_) => {
refuse_a_rewrite(&destination, &name, &scratch, repo, &asked, from)?;
Wrote::FastForwarded
}
None => Wrote::Created,
};
git::update_ref(&destination, &format!("refs/heads/{name}"), &tip)?;
Ok(Imported {
destination: destination.clone(),
name: name.clone(),
source: source.clone(),
tip,
wrote,
})
})();
git::delete_ref(&destination, &scratch);
imported
}
fn refuse_a_rewrite(
destination: &Path,
name: &Ref,
scratch: &str,
repo: &Path,
branch: &Ref,
from: Option<&str>,
) -> Result<()> {
if git::is_ancestor(destination, name, scratch)? {
return Ok(());
}
let losing = git::log_messages(destination, scratch, name)?;
let named: Vec<String> = losing
.iter()
.map(|commit| {
let subject = commit.message.lines().next().unwrap_or("").trim();
format!("{} {subject}", &commit.sha[..commit.sha.len().min(12)])
})
.collect();
Err(Error::Invalid {
reason: format!(
"{destination} already has {name:?}, and importing over it is not a fast-forward: \
{count} commit(s) it holds are on no branch this import brings. They would be lost: \
{lost}. Import under another name with `{command}`, or delete {name:?} there first if \
what it holds is spent",
destination = destination.display(),
count = named.len(),
lost = named.join("; "),
command = under_another_name(repo, branch, from, name),
),
})
}
fn source_of(
registry: &Registry,
resolution: &Resolution,
repo: &Path,
destination: &Path,
branch: &Ref,
from: Option<&str>,
) -> Result<Source> {
let Some(from) = from else {
let base = git::default_branch(destination, "origin")?;
let named: &str = branch;
let again =
guidance::command(["onevcs", "import", named, "--repo", &repo.to_string_lossy()]);
return Ok(Source::Repository(branch::locate(
registry,
resolution,
branch,
&base,
&format!("run `{again}` again"),
)?));
};
if git::is_repo(Path::new(from)) {
return Ok(Source::Repository(PathBuf::from(from)));
}
let remotes = git::remotes(destination)?;
if remotes.iter().any(|remote| remote == from) {
return Ok(Source::Remote {
remote: from.to_owned(),
branch: branch.clone(),
});
}
if let Some((remote, named)) = from.split_once('/') {
if remotes.iter().any(|candidate| candidate == remote) {
let named = Ref::try_from(named.to_owned()).map_err(|reason| Error::Invalid {
reason: format!(
"--from {from:?} names remote {remote:?} and {reason}; spell it \
`{remote}/<branch>`"
),
})?;
return Ok(Source::Remote {
remote: remote.to_owned(),
branch: named,
});
}
}
Err(Error::Invalid {
reason: format!(
"--from {from:?} is neither a git repository on this host nor a remote of {}: its \
remotes are {remotes}. Pass the path of a checkout or a run clone — `onevcs \
recoverable` reports where a preserved branch is — or a remote ref such as \
`origin/{branch}`, or omit --from to search everywhere this identity keeps work",
destination.display(),
remotes = if remotes.is_empty() {
"none".to_owned()
} else {
remotes.join(", ")
},
),
})
}