use getset::Getters;
use std::collections::HashMap;
use configparser::ini::Ini;
use git_wrapper::ConfigSetError;
use git_wrapper::{
RefSearchError, RepoError, Repository, StagingError, SubtreeAddError, SubtreePullError,
SubtreePushError, SubtreeSplitError,
};
use std::path::{Path, PathBuf};
use posix_errors::{PosixError, EAGAIN, EINVAL, ENOENT, ENOTRECOVERABLE, ENOTSUP};
#[derive(Getters, Clone, Debug, Eq, PartialEq)]
pub struct SubtreeConfig {
#[getset(get = "pub")]
id: String,
#[getset(get = "pub")]
follow: Option<String>,
#[getset(get = "pub")]
origin: Option<String>,
#[getset(get = "pub")]
upstream: Option<String>,
#[getset(get = "pub")]
pull_pre_releases: bool,
}
impl Ord for SubtreeConfig {
#[inline]
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.id.cmp(&other.id)
}
}
impl PartialOrd for SubtreeConfig {
#[inline]
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl SubtreeConfig {
#[must_use]
#[inline]
pub const fn new(
id: String,
follow: Option<String>,
origin: Option<String>,
upstream: Option<String>,
pull_pre_releases: bool,
) -> Self {
Self {
id,
follow,
origin,
upstream,
pull_pre_releases,
}
}
#[must_use]
#[inline]
pub const fn is_pullable(&self) -> bool {
self.upstream.is_some() || self.origin.is_some()
}
#[must_use]
#[inline]
pub const fn is_pushable(&self) -> bool {
self.origin.is_some()
}
#[must_use]
#[inline]
pub fn config_file(&self) -> String {
let mut result = self
.id
.rsplit_once('/')
.map_or_else(|| "".to_owned(), |x| x.1.to_owned());
result.push_str(".gitsubtrees");
result
}
#[must_use]
#[inline]
pub fn name(&self) -> String {
self.id()
.rsplit_once('/')
.map_or_else(|| self.id.clone(), |x| x.1.to_owned())
}
fn parse_remote_version_req(input: &str) -> Result<semver::VersionReq, PosixError> {
let tmp = input
.strip_suffix('}')
.ok_or_else(|| PosixError::new(EINVAL, format!("Illegal upstream value {}", input)))?;
let tmp2 = tmp
.strip_prefix("@{")
.ok_or_else(|| PosixError::new(EINVAL, format!("Illegal upstream value {}", input)))?;
semver::VersionReq::parse(tmp2).map_err(|e| PosixError::new(EINVAL, format!("{}", e)))
}
#[inline]
pub fn ref_to_pull(&self) -> Result<String, PosixError> {
if !self.is_pullable() {
return Err(PosixError::new(
ENOENT,
"Subtree does not have upstream remote defined".to_owned(),
));
}
let candidate = self.follow.clone().unwrap_or_else(|| "HEAD".to_owned());
let remote = &self
.upstream
.clone()
.or_else(|| self.origin.clone())
.ok_or_else(|| PosixError::new(ENOENT, "No origin or upstream set".to_owned()))?;
let follow = if candidate == *"@{tags}" {
find_latest_version(remote)?
} else if candidate.starts_with("@{") {
let range = Self::parse_remote_version_req(&candidate)?;
return find_latest_version_matching(remote, &range, *self.pull_pre_releases());
} else if candidate == *"HEAD" {
git_wrapper::resolve_head(remote)?
} else {
candidate
};
Ok(follow)
}
}
#[must_use]
#[inline]
pub fn alias_url(url: &str) -> String {
let github = regex::Regex::new(r"^(git@github.com:|.+://github.com/)").expect("Valid RegEx");
let gitlab = regex::Regex::new(r"^(git@gitlab.com:|.+://gitlab.com/)").expect("Valid RegEx");
let bitbucket =
regex::Regex::new(r"^(git@bitbucket.com:|.+://bitbucket.com/)").expect("Valid RegEx");
if github.is_match(url) {
return github.replace(url, "GH:").to_string();
}
if gitlab.is_match(url) {
return gitlab.replace(url, "GL:").to_string();
}
if bitbucket.is_match(url) {
return bitbucket.replace(url, "BB:").to_string();
}
url.to_owned()
}
fn versions_from_remote(url: &str) -> Result<HashMap<semver::Version, String>, PosixError> {
let mut result = HashMap::new();
let tmp = git_wrapper::tags_from_remote(url)?;
for s in tmp {
let version_result = lenient_semver::parse(&s);
if let Ok(version) = version_result {
result.insert(version, s);
}
}
Ok(result)
}
#[inline]
pub fn find_latest_version(remote: &str) -> Result<String, PosixError> {
let versions = versions_from_remote(remote)?;
if versions.is_empty() {
let message = "Failed to find any valid tags".to_owned();
return Err(PosixError::new(ENOENT, message));
}
let mut keys: Vec<&semver::Version> = Vec::new();
for v in versions.keys() {
keys.push(v);
}
keys.sort();
let key = keys.pop().expect("Keys should not be empty");
Ok(versions.get(key).expect("Keys should exist").clone())
}
#[inline]
pub fn find_latest_version_matching(
remote: &str,
range: &semver::VersionReq,
pre_releases: bool,
) -> Result<String, PosixError> {
let versions_map = versions_from_remote(remote)?;
let mut keys: Vec<&semver::Version> = Vec::new();
for v in versions_map.keys() {
keys.push(v);
}
keys.sort();
let mut latest: Option<&semver::Version> = None;
let mut versions: Vec<&semver::Version> = versions_map.keys().collect();
versions.sort();
for v in versions {
if range.matches(v) {
latest.replace(v);
} else if pre_releases {
let tmp = semver::Version::new(v.major, v.minor, v.patch);
if range.matches(&tmp) {
latest.replace(v);
}
} else {
}
}
latest.map_or_else(
|| {
let msg = format!("Failed to find a tag matching {}", range);
Err(PosixError::new(ENOENT, msg))
},
|v| {
let result = versions_map.get(v);
Ok(result.expect("Version is in version map").clone())
},
)
}
#[derive(Debug)]
pub struct Subtrees {
repo: Repository,
configs: Vec<SubtreeConfig>,
}
#[allow(missing_docs)]
#[derive(thiserror::Error, Debug)]
pub enum SubtreesError {
#[error("{0}")]
RepoError(#[from] RepoError),
#[error("{0}")]
InvalidConfig(#[from] ConfigError),
}
impl From<SubtreesError> for PosixError {
#[inline]
fn from(err: SubtreesError) -> Self {
match err {
SubtreesError::InvalidConfig(e) => Self::new(EINVAL, format!("{}", e)),
SubtreesError::RepoError(e) => e.into(),
}
}
}
#[allow(missing_docs)]
#[derive(thiserror::Error, Debug)]
pub enum ConfigError {
#[error("{0}")]
ReadFailed(#[from] std::io::Error),
#[error("Failed to parse config {0:?}")]
ParseFailed(PathBuf),
}
impl From<ConfigError> for PosixError {
#[inline]
fn from(err: ConfigError) -> Self {
match err {
ConfigError::ReadFailed(e) => e.into(),
ConfigError::ParseFailed(p) => Self::new(1, format!("Failed to parse config {:?}", p)),
}
}
}
#[allow(missing_docs)]
#[derive(thiserror::Error, Debug, PartialEq, Eq)]
pub enum AdditionError {
#[error("{0}")]
AddError(#[from] SubtreeAddError),
#[error("Work tree is dirty")]
WorkTreeDirty,
#[error("Failed to write config {0:?}")]
WriteConfig(String),
#[error("No upstream remote defined")]
NoUpstream,
#[error("{0}")]
StagingError(#[from] StagingError),
#[error("Invalid version {0}")]
InvalidVersion(String),
#[error("{0}")]
Failure(String, i32),
}
impl From<AdditionError> for PosixError {
#[inline]
fn from(err: AdditionError) -> Self {
match err {
AdditionError::AddError(e) => e.into(),
AdditionError::StagingError(e) => e.into(),
AdditionError::WorkTreeDirty => {
let msg = "Working tree is dirty".to_owned();
Self::new(ENOTSUP, msg)
}
AdditionError::NoUpstream => Self::new(1, format!("{}", err)),
AdditionError::InvalidVersion(version) => {
let msg = format!("Invalid version {}", version);
Self::new(EINVAL, msg)
}
AdditionError::Failure(msg, _) | AdditionError::WriteConfig(msg) => Self::new(1, msg),
}
}
}
impl From<ConfigSetError> for AdditionError {
#[inline]
fn from(err: ConfigSetError) -> Self {
match err {
ConfigSetError::InvalidConfigFile(f) => {
let msg = format!("Invalid config file: {}", f);
Self::WriteConfig(msg)
}
ConfigSetError::WriteFailed(f) => {
let msg = format!("Failed to write config file: {}", f);
Self::WriteConfig(msg)
}
ConfigSetError::InvalidSectionOrKey(msg) => Self::WriteConfig(msg),
ConfigSetError::Failure(msg, code) => Self::Failure(msg, code),
}
}
}
#[allow(missing_docs)]
#[derive(thiserror::Error, Debug)]
pub enum FindError {
#[error("Bare repository")]
BareRepository,
#[error("{0}")]
ConfigError(#[from] ConfigError),
#[error("Not found subtree {0}")]
NotFound(String),
}
impl From<FindError> for PosixError {
#[inline]
fn from(err: FindError) -> Self {
Self::new(EINVAL, format!("{}", err))
}
}
#[allow(missing_docs)]
#[derive(thiserror::Error, Debug)]
pub enum PullError {
#[error("{0}")]
Failure(String),
#[error("{0}")]
IOError(#[from] std::io::Error),
#[error("No changes to pull")]
NoChanges,
#[error("No upstream remote defined")]
NoUpstream,
#[error("{0}")]
ReferenceNotFound(#[from] RefSearchError),
#[error("Work tree is dirty")]
WorkTreeDirty,
}
impl From<PullError> for PosixError {
#[inline]
fn from(err: PullError) -> Self {
match err {
PullError::WorkTreeDirty => {
let msg = "Can not execute pull operation in a dirty repository".to_owned();
Self::new(ENOENT, msg)
}
PullError::ReferenceNotFound(e) => e.into(),
PullError::NoChanges => {
let msg = "Upstream does not have any new changes".to_owned();
Self::new(EAGAIN, msg)
}
PullError::NoUpstream => {
let msg = "Subtree does not have a upstream defined".to_owned();
Self::new(ENOTRECOVERABLE, msg)
}
PullError::Failure(msg) => Self::new(1, msg),
PullError::IOError(e) => Self::from(e),
}
}
}
impl From<SubtreePullError> for PullError {
#[inline]
fn from(prev: SubtreePullError) -> Self {
match prev {
SubtreePullError::Failure(msg, _) => Self::Failure(msg),
SubtreePullError::WorkTreeDirty => Self::WorkTreeDirty,
}
}
}
#[allow(missing_docs)]
#[derive(thiserror::Error, Debug)]
pub enum PushError {
#[error("No upstream remote defined")]
NoUpstream,
#[error("{0}")]
Failure(String),
}
impl From<PushError> for PosixError {
#[inline]
fn from(err: PushError) -> Self {
match err {
PushError::NoUpstream => {
let msg = "Subtree does not have a upstream defined".to_owned();
Self::new(ENOTRECOVERABLE, msg)
}
PushError::Failure(msg) => Self::new(1, msg),
}
}
}
impl From<SubtreePushError> for PushError {
#[inline]
fn from(prev: SubtreePushError) -> Self {
match prev {
SubtreePushError::Failure(msg, _) => Self::Failure(msg),
}
}
}
#[allow(missing_docs)]
#[derive(thiserror::Error, Debug)]
pub enum SplitError {
#[error("Work tree is dirty")]
WorkTreeDirty,
#[error("{0}")]
Failure(String),
}
impl From<SplitError> for PosixError {
#[inline]
fn from(err: SplitError) -> Self {
match err {
SplitError::WorkTreeDirty => {
let msg = "Can not execute push operation in a dirty repository".to_owned();
Self::new(ENOENT, msg)
}
SplitError::Failure(msg) => Self::new(1, msg),
}
}
}
impl From<SubtreeSplitError> for SplitError {
#[inline]
fn from(prev: SubtreeSplitError) -> Self {
match prev {
SubtreeSplitError::Failure(msg, _) => Self::Failure(msg),
SubtreeSplitError::WorkTreeDirty => Self::WorkTreeDirty,
}
}
}
#[allow(clippy::missing_errors_doc)]
impl Subtrees {
#[inline]
pub fn new() -> Result<Self, SubtreesError> {
let repo = Repository::default()?;
let configs = all(&repo)?;
Ok(Self { repo, configs })
}
#[inline]
pub fn from_repo(repo: Repository) -> Result<Self, SubtreesError> {
let configs = all(&repo)?;
Ok(Self { repo, configs })
}
#[inline]
pub fn from_dir(path: &Path) -> Result<Self, SubtreesError> {
let repo = Repository::discover(path)?;
let configs = all(&repo)?;
Ok(Self { repo, configs })
}
#[inline]
pub fn add(
&self,
subtree: &SubtreeConfig,
revision: Option<&str>,
subject: Option<&str>,
) -> Result<(), AdditionError> {
if let Some(rev) = revision {
let remote = subtree.upstream.as_ref().ok_or(AdditionError::NoUpstream)?;
let target = subtree.id();
let title = subject.map_or_else(
|| format!(":{} Import {}", target, alias_url(remote)),
|v| format!(":{} {}", target, v),
);
let msg = format!(
"{}
git-subtree-origin: {}
git-subtree-remote-ref: {}",
title, remote, rev
);
self.repo.subtree_add(remote, target, rev, &msg)?;
}
self.persist(subtree)?;
self.repo.stage(Path::new(&subtree.config_file()))?;
let mut cmd = self.repo.git();
cmd.args(&["commit", "--amend", "--no-edit"]);
let out = cmd.output().expect("Failed to execute git-commit(1)");
if !out.status.success() {
let msg = String::from_utf8_lossy(&out.stderr).to_string();
return Err(AdditionError::WriteConfig(msg));
}
Ok(())
}
#[inline]
pub fn all(&self) -> Result<Vec<SubtreeConfig>, ConfigError> {
Ok(self.configs.clone())
}
#[must_use]
#[inline]
pub fn head(&self) -> Option<String> {
Some(self.repo.head())
}
fn persist(&self, subtree: &SubtreeConfig) -> Result<(), ConfigSetError> {
let root = self.repo.work_tree().expect("Repo without work_tree");
let file = root.join(subtree.config_file());
let section = subtree.name();
let mut has_written = false;
if let Some(value) = subtree.follow() {
let key = format!("{}.follow", section);
git_wrapper::config_file_set(&file, &key, value)?;
has_written = true;
}
if let Some(value) = subtree.origin() {
let key = format!("{}.origin", section);
git_wrapper::config_file_set(&file, &key, value)?;
has_written = true;
}
if let Some(value) = subtree.upstream() {
let key = format!("{}.upstream", section);
git_wrapper::config_file_set(&file, &key, value)?;
has_written = true;
}
if *subtree.pull_pre_releases() {
let key = format!("{}.pull_pre_releases", section);
git_wrapper::config_file_set(&file, &key, "true")?;
has_written = true;
}
if !has_written {
let key = format!("{}.version", section);
git_wrapper::config_file_set(&file, &key, "1")?;
}
Ok(())
}
#[inline]
pub fn pull(&self, subtree: &SubtreeConfig, git_ref: &str) -> Result<String, PullError> {
let prefix = subtree.id();
let remote = subtree
.upstream()
.as_ref()
.or_else(|| subtree.origin().as_ref())
.ok_or(PullError::NoUpstream)?;
let message = format!("Update :{} to {}", prefix, &git_ref);
let head_before = self.repo.head();
self.repo.subtree_pull(remote, prefix, git_ref, &message)?;
let head_after = self.repo.head();
if head_before == head_after {
return Err(PullError::NoChanges);
}
let mut cmd = self.repo.git();
let out = cmd
.arg("rev-parse")
.arg("--short")
.arg("HEAD^2")
.output()
.expect("Got second parent");
if out.status.success() {
Ok(String::from_utf8(out.stdout)
.expect("UTF-8 encoding")
.trim()
.to_owned())
} else {
Err(PullError::Failure(
"Failed to execute git rev-parse".to_owned(),
))
}
}
#[inline]
pub fn split(&self, subtree: &SubtreeConfig) -> Result<(), SplitError> {
let prefix = subtree.id();
Ok(self.repo.subtree_split(prefix)?)
}
#[inline]
pub fn push(&self, subtree: &SubtreeConfig, git_ref: &str) -> Result<(), PushError> {
let prefix = subtree.id();
let remote = subtree.origin().as_ref().ok_or(PushError::NoUpstream)?;
if git_ref == "HEAD" {
let head = git_wrapper::resolve_head(remote).expect("asd");
Ok(self.repo.subtree_push(remote, prefix, &head)?)
} else {
Ok(self.repo.subtree_push(remote, prefix, git_ref)?)
}
}
#[inline]
pub fn changed_modules(&self, id: &str) -> Result<Vec<SubtreeConfig>, ConfigError> {
let subtree_modules = self.all()?;
if subtree_modules.is_empty() {
return Ok(vec![]);
}
let revision = format!("{}~1..{}", id, id);
let mut args = vec![
"diff",
&revision,
"--name-only",
"--no-renames",
"--no-color",
"--",
];
for s in &subtree_modules {
args.push(&s.id);
}
let proc = self
.repo
.git()
.args(args)
.output()
.expect("Failed running git diff");
if !proc.status.success() {
return Ok(vec![]);
}
let mut result = Vec::new();
let text = String::from_utf8_lossy(&proc.stdout);
let changed: Vec<&str> = text.lines().collect();
for f in &changed {
for d in subtree_modules.iter().rev() {
if f.starts_with(d.id.as_str()) {
result.push(d.clone());
break;
}
}
}
result.dedup();
Ok(result)
}
#[allow(clippy::missing_panics_doc)]
#[inline]
pub fn find_subtree(&self, needle: &str) -> Result<SubtreeConfig, FindError> {
let configs = self.all()?;
for c in configs {
if c.id() == needle {
return Ok(c);
}
}
Err(FindError::NotFound(needle.to_owned()))
}
}
fn configs_from_path(
repo: &Repository,
parser: &mut Ini,
path: &Path,
) -> Result<Vec<SubtreeConfig>, ConfigError> {
let content = repo
.hack_read_file(path)
.map(|vec| String::from_utf8_lossy(&vec).to_string())?;
let msg = &format!("Failed to parse {:?}", path);
let config_map = parser.read(content).expect(msg);
let parent_dir = path.parent();
let mut result = Vec::with_capacity(config_map.keys().len());
for name in config_map.keys() {
let id: String = parent_dir.map_or_else(
|| name.clone(),
|parent| {
parent
.join(name)
.to_str()
.expect("Convertable to str")
.to_owned()
},
);
result.push(SubtreeConfig {
id,
follow: parser.get(name, "follow"),
origin: parser.get(name, "origin"),
upstream: parser.get(name, "upstream"),
pull_pre_releases: parser
.getbool(name, "pull-pre-releases")
.unwrap_or_default()
.unwrap_or(false),
});
}
Ok(result)
}
fn config_files(repo: &Repository) -> Vec<PathBuf> {
let mut cmd = repo.git();
cmd.arg("ls-files").args(&[
"-z",
"--cached",
"--deleted",
"--",
".gitsubtrees",
"**/.gitsubtrees",
]);
let out = cmd.output().expect("Successful git-ls-files(1) invocation");
let tmp = String::from_utf8(out.stdout).expect("UTF-8 encoding");
let files: Vec<&str> = tmp.split('\0').filter(|e| !e.is_empty()).collect();
let mut result: Vec<PathBuf> = Vec::with_capacity(files.len());
for line in files {
result.push(PathBuf::from(line));
}
result
}
fn all(repo: &Repository) -> Result<Vec<SubtreeConfig>, ConfigError> {
let config_paths = config_files(repo);
let mut result = vec![];
let mut config_parser = Ini::new_cs();
for path in config_paths {
let mut tmp = configs_from_path(repo, &mut config_parser, &path)?;
result.append(&mut tmp);
}
Ok(result)
}
#[cfg(test)]
mod test {
use crate::SubtreeConfig;
use crate::Subtrees;
use git_wrapper::Repository;
use tempfile::TempDir;
#[test]
fn bkg_monorepo() {
let subtrees = Subtrees::new().unwrap();
{
let result = subtrees.all();
assert!(result.is_ok(), "Found subtree configs");
let all_configs = result.unwrap();
assert!(
all_configs.len() > 50,
"Sould find at least 50 subtrees, found: {}",
all_configs.len()
);
}
{
let expected = "rust/git-wrapper";
let result = subtrees.find_subtree(expected);
assert!(result.is_ok(), "Found subtree rust/git-wrapper subtree");
let gsi_subtree = result.unwrap();
let actual = gsi_subtree.id();
assert!(
actual == expected,
"Expected subtree id {}, got {}",
expected,
actual
);
}
}
#[test]
fn subtree_add() {
let tmp_dir = TempDir::new().unwrap();
let repo_path = tmp_dir.path();
{
git_wrapper::setup_test_author();
let repo = Repository::create(repo_path).expect("Created repository");
let readme = repo_path.join("README.md");
std::fs::File::create(&readme).unwrap();
std::fs::write(&readme, "# README").unwrap();
repo.stage(&readme).unwrap();
repo.commit("Test").unwrap();
}
let mgr = Subtrees::from_dir(repo_path).unwrap();
let config = SubtreeConfig {
id: "bar".to_owned(),
follow: Some("master".to_owned()),
origin: None,
upstream: Some("https://github.com/kalkin/file-expert".to_owned()),
pull_pre_releases: false,
};
let actual = mgr.add(&config, Some("master"), None);
assert!(actual.is_ok(), "Expected a subtrees instance");
}
#[test]
fn subtree_pull() {
let tmp_dir = TempDir::new().unwrap();
let repo_path = tmp_dir.path();
{
git_wrapper::setup_test_author();
let repo = Repository::create(repo_path).expect("Created repository");
let readme = repo_path.join("README.md");
std::fs::File::create(&readme).unwrap();
std::fs::write(&readme, "# README").unwrap();
repo.stage(&readme).unwrap();
repo.commit("Test").unwrap();
}
let mgr = Subtrees::from_dir(repo_path).unwrap();
let config = SubtreeConfig {
id: "bar".to_owned(),
follow: Some("v0.10.1".to_owned()),
origin: None,
upstream: Some("https://github.com/kalkin/file-expert".to_owned()),
pull_pre_releases: false,
};
mgr.add(&config, Some("v0.10.1"), None).unwrap();
let actual = mgr.pull(&config, "v0.13.1");
assert!(
actual.is_ok(),
"Expected successful pull execution, got: {:?}",
actual
);
}
}