From c224e98a80e64fa17041cb445ae8b65335e2e465 Mon Sep 17 00:00:00 2001 From: Paul Delafosse Date: Fri, 28 Oct 2022 13:08:56 +0200 Subject: [PATCH 01/26] feat: add support for monorepo --- src/bin/cog/main.rs | 44 ++++- src/git/error.rs | 9 + src/git/hook.rs | 28 ++- src/git/mod.rs | 1 + src/git/monorepo.rs | 143 ++++++++++++++++ src/git/oid.rs | 2 +- src/git/repository.rs | 10 +- src/git/tag.rs | 18 +- src/lib.rs | 385 ++++++++++++++++++++++++++++++++++++++---- src/settings/mod.rs | 68 ++++++-- 10 files changed, 640 insertions(+), 68 deletions(-) create mode 100644 src/git/monorepo.rs diff --git a/src/bin/cog/main.rs b/src/bin/cog/main.rs index 51eac5d4..fabf05e5 100644 --- a/src/bin/cog/main.rs +++ b/src/bin/cog/main.rs @@ -11,7 +11,7 @@ use cocogitto::log::filter::{CommitFilter, CommitFilters}; use cocogitto::log::output::Output; use cocogitto::{CocoGitto, SETTINGS}; -use anyhow::{Context, Result}; +use anyhow::{bail, Context, Result}; use clap::builder::{PossibleValue, PossibleValuesParser}; use clap::{ArgAction, ArgGroup, Args, CommandFactory, Parser, Subcommand, ValueEnum}; use clap_complete::{shells, Generator}; @@ -26,6 +26,12 @@ fn hook_profiles() -> PossibleValuesParser { profiles.into() } +fn packages() -> PossibleValuesParser { + let profiles = SETTINGS.packages.keys().map(|profile| -> &str { profile }); + + profiles.into() +} + /// Shell with auto-generated completion script available. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] #[non_exhaustive] @@ -230,6 +236,10 @@ enum Command { #[arg(short = 'H', long, value_parser = hook_profiles())] hook_profile: Option, + /// Specify which package to bump for monorepo + #[arg(long, value_parser = packages())] + package: Option, + /// Dry-run: print the target version. No action taken #[arg(short, long)] dry_run: bool, @@ -300,6 +310,7 @@ fn main() -> Result<()> { patch, pre, hook_profile, + package, dry_run, } => { let mut cocogitto = CocoGitto::get()?; @@ -313,7 +324,36 @@ fn main() -> Result<()> { _ => unreachable!(), }; - cocogitto.create_version(increment, pre.as_deref(), hook_profile.as_deref(), dry_run)? + let is_monorepo = !SETTINGS.packages.is_empty(); + + if is_monorepo { + if increment == VersionIncrement::Auto && package.is_none() { + cocogitto.create_monorepo_version( + pre.as_deref(), + hook_profile.as_deref(), + dry_run, + )? + } else if let Some(package_name) = package { + // Safe unwrap here, package name is validated by clap + let package = SETTINGS.packages.get(&package_name).unwrap(); + cocogitto.create_package_version( + (&package_name, package), + increment, + pre.as_deref(), + hook_profile.as_deref(), + dry_run, + )? + } else { + bail!("Cannot bump monorepo manually, use `--package` to update a specific package.") + } + } else { + cocogitto.create_version( + increment, + pre.as_deref(), + hook_profile.as_deref(), + dry_run, + )? + } } Command::Verify { message, diff --git a/src/git/error.rs b/src/git/error.rs index 91854e50..4fdafb49 100644 --- a/src/git/error.rs +++ b/src/git/error.rs @@ -25,6 +25,7 @@ pub enum Git2Error { Other(git2::Error), NoTagFound, CommitterNotFound, + TagError(TagError), } #[derive(Debug)] @@ -115,6 +116,7 @@ impl Display for Git2Error { "Cannot create tag: changes need to be committed".red(), statuses ), + Git2Error::TagError(_) => writeln!(f, "Tag error"), Git2Error::IOError(_) => writeln!(f, "IO Error"), Git2Error::GpgError(_) => writeln!(f, "failed to sign commit"), }?; @@ -130,6 +132,7 @@ impl Display for Git2Error { | Git2Error::Other(err) | Git2Error::CommitNotFound(err) => writeln!(f, "\ncause: {}", err), Git2Error::GpgError(err) => writeln!(f, "\ncause: {}", err), + Git2Error::TagError(err) => writeln!(f, "\ncause: {}", err), Git2Error::IOError(err) => writeln!(f, "\ncause: {}", err), _ => fmt::Result::Ok(()), } @@ -174,4 +177,10 @@ impl From for Git2Error { } } +impl From for Git2Error { + fn from(err: TagError) -> Self { + Git2Error::TagError(err) + } +} + impl StdError for Git2Error {} diff --git a/src/git/hook.rs b/src/git/hook.rs index 80c25d74..dcc3655d 100644 --- a/src/git/hook.rs +++ b/src/git/hook.rs @@ -1,11 +1,13 @@ +use std::collections::HashMap; use std::fs::{self, Permissions}; use std::io; #[cfg(target_family = "unix")] use std::os::unix::fs::PermissionsExt; use std::path::Path; -use crate::CocoGitto; +use crate::{CocoGitto, HookType}; +use crate::settings::BumpProfile; use anyhow::{anyhow, Result}; pub(crate) static PRE_PUSH_HOOK: &[u8] = include_bytes!("assets/pre-push"); @@ -13,6 +15,30 @@ pub(crate) static PREPARE_COMMIT_HOOK: &[u8] = include_bytes!("assets/commit-msg const PRE_COMMIT_HOOK_PATH: &str = ".git/hooks/commit-msg"; const PRE_PUSH_HOOK_PATH: &str = ".git/hooks/pre-push"; +pub trait Hooks { + fn bump_profiles(&self) -> &HashMap; + fn pre_bump_hooks(&self) -> &Vec; + fn post_bump_hooks(&self) -> &Vec; + + fn get_hooks(&self, hook_type: HookType) -> &Vec { + match hook_type { + HookType::PreBump => self.pre_bump_hooks(), + HookType::PostBump => self.post_bump_hooks(), + } + } + + fn get_profile_hooks(&self, profile: &str, hook_type: HookType) -> &Vec { + let profile = self + .bump_profiles() + .get(profile) + .expect("Bump profile not found"); + match hook_type { + HookType::PreBump => &profile.pre_bump_hooks, + HookType::PostBump => &profile.post_bump_hooks, + } + } +} + pub enum HookKind { PrepareCommit, PrePush, diff --git a/src/git/mod.rs b/src/git/mod.rs index 704b3b8e..0df92934 100644 --- a/src/git/mod.rs +++ b/src/git/mod.rs @@ -2,6 +2,7 @@ pub mod commit; pub mod diff; pub(crate) mod error; pub mod hook; +pub mod monorepo; pub mod oid; pub mod repository; pub mod revspec; diff --git a/src/git/monorepo.rs b/src/git/monorepo.rs new file mode 100644 index 00000000..bcc029ff --- /dev/null +++ b/src/git/monorepo.rs @@ -0,0 +1,143 @@ +use crate::git::repository::Repository; +use crate::git::revspec::CommitRange; +use crate::settings::MonoRepoPackage; +use crate::{Git2Error, OidOf, RevspecPattern, Tag, TagError}; +use std::path::Path; + +impl Repository { + /// Get commits from latest tag and return a map of commit ranges by their respective packages. + pub fn get_commit_range_for_packages( + &self, + package: &MonoRepoPackage, + pattern: &RevspecPattern, + ) -> Result, Git2Error> { + let range = self.get_commit_range(pattern)?; + + let mut commits = vec![]; + + for commit in range.commits { + let parent = commit.parent(0)?.id().to_string(); + let t1 = self + .tree_to_treeish(Some(&parent))? + .expect("Failed to get parent tree"); + let t2 = self + .tree_to_treeish(Some(&commit.id().to_string()))? + .expect("Failed to get commit tree"); + let diff = self.0.diff_tree_to_tree(t1.as_tree(), t2.as_tree(), None)?; + + for delta in diff.deltas() { + if let Some(old) = delta.old_file().path() { + if package.match_path(old) { + commits.push(commit); + break; + } + } + + if let Some(new) = delta.new_file().path() { + if package.match_path(new) { + commits.push(commit); + break; + } + } + } + } + + if !commits.is_empty() { + // TODO: resolve tags here + Ok(Some(CommitRange { + from: OidOf::Other(commits.first().unwrap().id()), + // Safe unwrap, matches are not empty + to: OidOf::Other(commits.last().unwrap().id()), + commits, + })) + } else { + Ok(None) + } + } + + /// Get the latest SemVer tag for a given monorepo package. + pub fn get_latest_package_tag(&self, package_prefix: &str) -> Result { + let tags: Vec = self.all_tags()?; + + tags.into_iter() + .filter(|tag| tag.to_string_with_prefix().starts_with(package_prefix)) + .max() + .ok_or(TagError::NoTag) + } +} + +impl MonoRepoPackage { + fn match_path(&self, path: &Path) -> bool { + path.starts_with(&self.path) + } +} + +#[cfg(test)] +mod test { + use crate::{MonoRepoPackage, Repository, RevspecPattern}; + use anyhow::Result; + use cmd_lib::run_cmd; + use indoc::formatdoc; + use sealed_test::prelude::*; + use speculoos::prelude::*; + use std::path::PathBuf; + + #[sealed_test] + fn get_repo_packages() -> Result<()> { + // Arrange + let settings = formatdoc!( + " + [packages.one] + path = \"one\" + changelog_path = \"one/CHANGELOG.md\" + + [packages.two] + path = \"two\" + changelog_path = \"two/CHANGELOG.md\" + " + ); + + run_cmd!( + git init -b master; + echo $settings > cog.toml; + git add .; + )?; + + let repo = Repository::open(".")?; + repo.commit("chore: init", false)?; + + run_cmd!( + mkdir one; + echo "one" > one/file; + git add .; + git commit -m "feat: package one"; + mkdir two; + echo "two" > two/file; + git add .; + git commit -m "feat: package two"; + echo "two" > two/file2; + git add .; + git commit -m "feat: more changes to two"; + )?; + + // Act + let range = repo.get_commit_range_for_packages( + &MonoRepoPackage { + path: PathBuf::from("two"), + changelog_path: None, + pre_bump_hooks: vec![], + post_bump_hooks: vec![], + bump_profiles: Default::default(), + }, + &RevspecPattern::from("..HEAD"), + )?; + + // Assert + assert_that!(range) + .is_some() + .map(|range| &range.commits) + .has_length(2); + + Ok(()) + } +} diff --git a/src/git/oid.rs b/src/git/oid.rs index 9ef0bcfb..5ccd83ac 100644 --- a/src/git/oid.rs +++ b/src/git/oid.rs @@ -5,7 +5,7 @@ use git2::Oid; use crate::git::tag::Tag; /// A wrapper for git2 oid including tags and HEAD ref -#[derive(Debug, PartialEq, Eq)] +#[derive(Debug, PartialEq, Eq, Clone)] pub enum OidOf { Tag(Tag), Head(Oid), diff --git a/src/git/repository.rs b/src/git/repository.rs index 015de204..fd80816e 100644 --- a/src/git/repository.rs +++ b/src/git/repository.rs @@ -62,7 +62,7 @@ impl Repository { } pub(crate) fn get_head(&self) -> Option { - Repository::tree_to_treeish(&self.0, Some(&"HEAD".to_string())) + self.tree_to_treeish(Some(&"HEAD".to_string())) .ok() .flatten() } @@ -82,15 +82,15 @@ impl Repository { .ok_or(Git2Error::CommitterNotFound) } - fn tree_to_treeish<'a>( - repo: &'a Git2Repository, + pub(crate) fn tree_to_treeish( + &self, arg: Option<&String>, - ) -> Result>, git2::Error> { + ) -> Result, git2::Error> { let arg = match arg { Some(s) => s, None => return Ok(None), }; - let obj = repo.revparse_single(arg)?; + let obj = self.0.revparse_single(arg)?; let tree = obj.peel(ObjectType::Tree)?; Ok(Some(tree)) } diff --git a/src/git/tag.rs b/src/git/tag.rs index 145ea9ff..b9e37422 100644 --- a/src/git/tag.rs +++ b/src/git/tag.rs @@ -15,7 +15,7 @@ impl Repository { /// tag (without configured prefix) is not semver compliant or if the tag /// does not exist. pub fn resolve_tag(&self, tag: &str) -> Result { - let without_prefix = Tag::strip_prefix(tag)?; + let without_prefix = Tag::strip_default_prefix(tag)?; // Ensure the tag is SemVer compliant Version::parse(without_prefix).map_err(|err| TagError::semver(without_prefix, err))?; @@ -87,7 +87,7 @@ pub struct Tag { impl TryFrom> for Tag { type Error = TagError; - fn try_from(tag: Git2Tag) -> std::result::Result { + fn try_from(tag: Git2Tag) -> Result { let name = tag.name().expect("Unexpected unnamed tag"); Self::new(name, Some(tag.id())) } @@ -107,7 +107,7 @@ impl Tag { } pub(crate) fn new(name: &str, oid: Option) -> Result { - let tag = Tag::strip_prefix(name)?.to_string(); + let tag = Tag::strip_default_prefix(name)?.to_string(); Ok(Tag { tag, oid }) } @@ -122,7 +122,17 @@ impl Tag { Version::parse(&self.tag).map_err(|err| TagError::semver(&self.tag, err)) } - fn strip_prefix(tag: &str) -> Result<&str, TagError> { + pub(crate) fn to_package_version(&self, package_name: &str) -> Result { + let prefix = format!("{package_name}-"); + let version = self.tag.strip_prefix(&prefix).ok_or(TagError::InvalidPrefixError { + prefix, + tag: self.tag.clone(), + })?; + + Version::parse(version).map_err(|err| TagError::semver(&self.tag, err)) + } + + fn strip_default_prefix(tag: &str) -> Result<&str, TagError> { match SETTINGS.tag_prefix.as_ref() { None => Ok(tag), Some(prefix) => tag diff --git a/src/lib.rs b/src/lib.rs index ecbe9ef8..81fc5622 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -28,10 +28,12 @@ use settings::{HookType, Settings}; use crate::conventional::changelog::release::Release; use crate::conventional::changelog::template::Template; use crate::git::error::{Git2Error, TagError}; +use crate::git::hook::Hooks; use crate::git::oid::OidOf; use crate::git::revspec::RevspecPattern; use crate::git::tag::Tag; use crate::hook::HookVersion; +use crate::settings::MonoRepoPackage; pub mod conventional; pub mod error; @@ -412,39 +414,7 @@ impl CocoGitto { hooks_config: Option<&str>, dry_run: bool, ) -> Result<()> { - if *SETTINGS == Settings::default() { - let part1 = "Warning: using".yellow(); - let part2 = "with the default configuration. \n".yellow(); - let part3 = "You may want to create a".yellow(); - let part4 = "file in your project root to configure bumps.\n".yellow(); - warn!( - "{} 'cog bump' {}{} 'cog.toml' {}", - part1, part2, part3, part4 - ); - } - let statuses = self.repository.get_statuses()?; - - // Fail if repo contains un-staged or un-committed changes - ensure!(statuses.0.is_empty(), "{}", self.repository.get_statuses()?); - - if !SETTINGS.branch_whitelist.is_empty() { - if let Some(branch) = self.repository.get_branch_shorthand() { - let whitelist = &SETTINGS.branch_whitelist; - let is_match = whitelist.iter().any(|pattern| { - let glob = Glob::new(pattern) - .expect("invalid glob pattern") - .compile_matcher(); - glob.is_match(&branch) - }); - - ensure!( - is_match, - "No patterns matched in {:?} for branch '{}', bump is not allowed", - whitelist, - branch - ) - } - }; + self.pre_bump_checks()?; let current_tag = self.repository.get_latest_tag(); let current_version = match current_tag { @@ -512,6 +482,7 @@ impl CocoGitto { current.as_ref(), &next_version, hooks_config, + None, ); self.repository.add_all()?; @@ -547,6 +518,7 @@ impl CocoGitto { current.as_ref(), &next_version, hooks_config, + None, )?; let current = current @@ -558,6 +530,301 @@ impl CocoGitto { Ok(()) } + pub fn create_monorepo_version( + &mut self, + pre_release: Option<&str>, + hooks_config: Option<&str>, + dry_run: bool, + ) -> Result<()> { + self.pre_bump_checks()?; + let mut package_bumps = vec![]; + + for (package_name, package) in &SETTINGS.packages { + let current_tag = self.repository.get_latest_package_tag(package_name); + let current_version = match current_tag { + Ok(ref tag) => tag.to_package_version(package_name)?, + Err(ref err) if err == &TagError::NoTag => { + warn!("Failed to get current version, falling back to 0.0.0"); + Version::new(0, 0, 0) + } + Err(ref err) => bail!("{}", err), + }; + + let mut next_version = VersionIncrement::Auto.bump(¤t_version, &self.repository)?; + + if next_version.le(¤t_version) || next_version.eq(¤t_version) { + let comparison = format!("{} <= {}", current_version, next_version).red(); + let cause_key = "cause:".red(); + let cause = format!( + "{} version MUST be greater than current one: {}", + cause_key, comparison + ); + + bail!("{}:\n\t{}\n", "SemVer Error".red().to_string(), cause); + }; + + if let Some(pre_release) = pre_release { + next_version.pre = Prerelease::new(pre_release)?; + } + + let version_str = format!("{}-{}", package_name, next_version); + + if dry_run { + print!("{}", version_str); + continue + } + + let origin = if current_version == Version::new(0, 0, 0) { + self.repository.get_first_commit()?.to_string() + } else { + current_tag?.oid_unchecked().to_string() + }; + + let target = self.repository.get_head_commit_oid()?.to_string(); + let pattern = (origin.as_str(), target.as_str()); + + let pattern = RevspecPattern::from(pattern); + let changelog = + self.get_changelog_with_target_package_version(pattern, &version_str, package)?; + + if changelog.is_none() { + println!("No commit found to bump package {package_name}, skipping."); + continue + } + + let changelog = changelog.unwrap(); + let path = package.changelog_path(); + let template = SETTINGS.get_changelog_template()?; + changelog.write_to_file(path, template)?; + + let current = self + .repository + .get_latest_package_tag(package_name) + .map(|tag| HookVersion::new(&tag.to_string_with_prefix())) + .ok(); + + let next_version = HookVersion::new(&Self::prefix_version(next_version.to_string())); + + let hook_result = self.run_hooks( + HookType::PreBump, + current.as_ref(), + &next_version, + hooks_config, + Some(package), + ); + + self.repository.add_all()?; + + // Hook failed, we need to stop here and reset + // the repository to a clean state + if let Err(err) = hook_result { + self.repository.stash_failed_version(&version_str)?; + error!( + "{}", + PreHookError { + cause: err.to_string(), + version: version_str, + stash_number: 0, + } + ); + + exit(1); + } + + package_bumps.push((package_name, package, current, next_version, version_str)); + } + + // Todo: meta version + self.repository + .commit("chore(version): Bump", false)?; + + for (package_name, package, current, next_version, version_str) in package_bumps { + let version_str = Self::prefix_version(version_str); + + self.repository.create_tag(&version_str)?; + + self.run_hooks( + HookType::PostBump, + current.as_ref(), + &next_version, + hooks_config, + Some(package), + )?; + + let current = current + .map(|current| current.prefixed_tag) + .unwrap_or_else(|| "...".to_string()); + let bump = format!("{} -> {}", current, next_version.prefixed_tag).green(); + info!("Bumped package {package_name} version: {}", bump); + } + + + + Ok(()) + } + + pub fn create_package_version( + &mut self, + (package_name, package): (&str, &MonoRepoPackage), + increment: VersionIncrement, + pre_release: Option<&str>, + hooks_config: Option<&str>, + dry_run: bool, + ) -> Result<()> { + self.pre_bump_checks()?; + + let current_tag = self.repository.get_latest_package_tag(package_name); + let current_version = match current_tag { + Ok(ref tag) => tag.to_package_version(package_name)?, + Err(ref err) if err == &TagError::NoTag => { + warn!("Failed to get current version, falling back to 0.0.0"); + Version::new(0, 0, 0) + } + Err(ref err) => bail!("{}", err), + }; + + let mut next_version = increment.bump(¤t_version, &self.repository)?; + + if next_version.le(¤t_version) || next_version.eq(¤t_version) { + let comparison = format!("{} <= {}", current_version, next_version).red(); + let cause_key = "cause:".red(); + let cause = format!( + "{} version MUST be greater than current one: {}", + cause_key, comparison + ); + + bail!("{}:\n\t{}\n", "SemVer Error".red().to_string(), cause); + }; + + if let Some(pre_release) = pre_release { + next_version.pre = Prerelease::new(pre_release)?; + } + + let version_str = format!("{}-{}", package_name, next_version); + + if dry_run { + print!("{}", version_str); + return Ok(()); + } + + let origin = if current_version == Version::new(0, 0, 0) { + self.repository.get_first_commit()?.to_string() + } else { + current_tag?.oid_unchecked().to_string() + }; + + let target = self.repository.get_head_commit_oid()?.to_string(); + let pattern = (origin.as_str(), target.as_str()); + + let pattern = RevspecPattern::from(pattern); + let changelog = + self.get_changelog_with_target_package_version(pattern, &version_str, package)?; + + if changelog.is_none() { + bail!("No commit matching package {package_name} path"); + } + + let changelog = changelog.unwrap(); + let path = package.changelog_path(); + let template = SETTINGS.get_changelog_template()?; + changelog.write_to_file(path, template)?; + + let current = self + .repository + .get_latest_package_tag(package_name) + .map(|tag| HookVersion::new(&tag.to_string_with_prefix())) + .ok(); + + let next_version = HookVersion::new(&Self::prefix_version(next_version.to_string())); + + let hook_result = self.run_hooks( + HookType::PreBump, + current.as_ref(), + &next_version, + hooks_config, + Some(package), + ); + + self.repository.add_all()?; + + // Hook failed, we need to stop here and reset + // the repository to a clean state + if let Err(err) = hook_result { + self.repository.stash_failed_version(&version_str)?; + error!( + "{}", + PreHookError { + cause: err.to_string(), + version: version_str, + stash_number: 0, + } + ); + + exit(1); + } + + let version_str = Self::prefix_version(version_str); + + self.repository + .commit(&format!("chore(version): {}", version_str), false)?; + + self.repository.create_tag(&version_str)?; + + self.run_hooks( + HookType::PostBump, + current.as_ref(), + &next_version, + hooks_config, + Some(package), + )?; + + let current = current + .map(|current| current.prefixed_tag) + .unwrap_or_else(|| "...".to_string()); + let bump = format!("{} -> {}", current, next_version.prefixed_tag).green(); + info!("Bumped package {package_name} version: {}", bump); + + Ok(()) + } + + fn pre_bump_checks(&mut self) -> Result<()> { + if *SETTINGS == Settings::default() { + let part1 = "Warning: using".yellow(); + let part2 = "with the default configuration. \n".yellow(); + let part3 = "You may want to create a".yellow(); + let part4 = "file in your project root to configure bumps.\n".yellow(); + warn!( + "{} 'cog bump' {}{} 'cog.toml' {}", + part1, part2, part3, part4 + ); + } + let statuses = self.repository.get_statuses()?; + + // Fail if repo contains un-staged or un-committed changes + ensure!(statuses.0.is_empty(), "{}", self.repository.get_statuses()?); + + if !SETTINGS.branch_whitelist.is_empty() { + if let Some(branch) = self.repository.get_branch_shorthand() { + let whitelist = &SETTINGS.branch_whitelist; + let is_match = whitelist.iter().any(|pattern| { + let glob = Glob::new(pattern) + .expect("invalid glob pattern") + .compile_matcher(); + glob.is_match(&branch) + }); + + ensure!( + is_match, + "No patterns matched in {:?} for branch '{}', bump is not allowed", + whitelist, + branch + ) + } + }; + + Ok(()) + } + pub fn get_changelog_at_tag(&self, tag: &str, template: Template) -> Result { let pattern = format!("..{}", tag); let pattern = RevspecPattern::from(pattern.as_str()); @@ -582,6 +849,26 @@ impl CocoGitto { Ok(release) } + /// Used for cog bump. the target version + /// is not created yet when generating the changelog. + pub fn get_changelog_with_target_package_version( + &self, + pattern: RevspecPattern, + target_version: &str, + package: &MonoRepoPackage, + ) -> Result> { + let mut release = self + .repository + .get_commit_range_for_packages(package, &pattern)? + .map(Release::from); + + if let Some(release) = &mut release { + release.version = OidOf::Tag(Tag::new(target_version, None)?); + } + + Ok(release) + } + /// ## Get a changelog between two oids /// - `from` default value:latest tag or else first commit /// - `to` default value:`HEAD` or else first commit @@ -607,12 +894,13 @@ impl CocoGitto { current_tag: Option<&HookVersion>, next_version: &HookVersion, hook_profile: Option<&str>, + package: Option<&MonoRepoPackage>, ) -> Result<()> { let settings = Settings::get(&self.repository)?; - let hooks: Vec = match hook_profile { - Some(profile) => settings - .get_profile_hook(profile, hook_type) + let hooks: Vec = match (package, hook_profile) { + (None, Some(profile)) => settings + .get_profile_hooks(profile, hook_type) .iter() .map(|s| s.parse()) .enumerate() @@ -623,7 +911,30 @@ impl CocoGitto { )) }) .try_collect()?, - None => settings + + (Some(package), Some(profile)) => { + let hooks = package.get_profile_hooks(profile, hook_type); + + hooks + .iter() + .map(|s| s.parse()) + .enumerate() + .map(|(idx, result)| { + result.context(format!( + "Cannot parse bump profile {} hook at index {}", + profile, idx + )) + }) + .try_collect()? + } + (Some(package), None) => package + .get_hooks(hook_type) + .iter() + .map(|s| s.parse()) + .enumerate() + .map(|(idx, result)| result.context(format!("Cannot parse hook at index {}", idx))) + .try_collect()?, + (None, None) => settings .get_hooks(hook_type) .iter() .map(|s| s.parse()) diff --git a/src/settings/mod.rs b/src/settings/mod.rs index 1253ab9f..19f12f15 100644 --- a/src/settings/mod.rs +++ b/src/settings/mod.rs @@ -7,6 +7,7 @@ use crate::{CommitsMetadata, CONFIG_PATH, SETTINGS}; use crate::conventional::changelog::error::ChangelogError; use crate::conventional::changelog::template::{RemoteContext, Template}; +use crate::git::hook::Hooks; use crate::settings::error::SettingError; use config::{Config, File}; use conventional_commit_parser::commit::CommitType; @@ -43,6 +44,27 @@ pub struct Settings { pub changelog: Changelog, #[serde(default)] pub bump_profiles: HashMap, + #[serde(default)] + pub packages: HashMap, +} + +#[derive(Debug, Deserialize, Serialize, Eq, PartialEq, Default)] +#[serde(deny_unknown_fields, default)] +pub struct MonoRepoPackage { + pub path: PathBuf, + pub changelog_path: Option, + pub pre_bump_hooks: Vec, + pub post_bump_hooks: Vec, + pub bump_profiles: HashMap, +} + +impl MonoRepoPackage { + pub fn changelog_path(&self) -> PathBuf { + self.changelog_path + .as_ref() + .map(PathBuf::from) + .unwrap_or_else(|| self.path.join("CHANGELOG.md")) + } } #[derive(Debug, Deserialize, Serialize, Clone, Eq, PartialEq)] @@ -156,24 +178,6 @@ impl Settings { default_types } - pub fn get_hooks(&self, hook_type: HookType) -> &Vec { - match hook_type { - HookType::PreBump => &self.pre_bump_hooks, - HookType::PostBump => &self.post_bump_hooks, - } - } - - pub fn get_profile_hook(&self, profile: &str, hook_type: HookType) -> &Vec { - let profile = self - .bump_profiles - .get(profile) - .expect("Bump profile not found"); - match hook_type { - HookType::PreBump => &profile.pre_bump_hooks, - HookType::PostBump => &profile.post_bump_hooks, - } - } - pub fn get_template_context(&self) -> Option { let remote = self.changelog.remote.as_ref().cloned(); @@ -191,3 +195,31 @@ impl Settings { Template::from_arg(template, context) } } + +impl Hooks for Settings { + fn bump_profiles(&self) -> &HashMap { + &self.bump_profiles + } + + fn pre_bump_hooks(&self) -> &Vec { + &self.pre_bump_hooks + } + + fn post_bump_hooks(&self) -> &Vec { + &self.post_bump_hooks + } +} + +impl Hooks for MonoRepoPackage { + fn bump_profiles(&self) -> &HashMap { + &self.bump_profiles + } + + fn pre_bump_hooks(&self) -> &Vec { + &self.pre_bump_hooks + } + + fn post_bump_hooks(&self) -> &Vec { + &self.post_bump_hooks + } +} From 28dbc8e0175971504f956732f8ac5a78b57ec832 Mon Sep 17 00:00:00 2001 From: Paul Delafosse Date: Mon, 31 Oct 2022 11:44:01 +0100 Subject: [PATCH 02/26] refactor: refactor tag serialization & deserialization --- src/conventional/changelog/release.rs | 4 +- src/conventional/changelog/serde.rs | 6 +- src/git/monorepo.rs | 8 +- src/git/revspec.rs | 12 +-- src/git/stash.rs | 8 +- src/git/tag.rs | 134 ++++++++++++++------------ src/hook/mod.rs | 66 +++++-------- src/lib.rs | 124 ++++++++++-------------- src/settings/mod.rs | 2 + tests/lib_tests/bump.rs | 53 +++++----- 10 files changed, 202 insertions(+), 215 deletions(-) diff --git a/src/conventional/changelog/release.rs b/src/conventional/changelog/release.rs index 09ae71e2..a3d4d4f1 100644 --- a/src/conventional/changelog/release.rs +++ b/src/conventional/changelog/release.rs @@ -223,12 +223,12 @@ mod test { let paul_delafosse = "Paul Delafosse"; let a_commit_hash = "17f7e23081db15e9318aeb37529b1d473cf41cbe"; - let version = Tag::new( + let version = Tag::from_str( "1.0.0", Some(Oid::from_str("9bb5facac5724bc81385fdd740fedbb49056da00").unwrap()), ) .unwrap(); - let from = Tag::new( + let from = Tag::from_str( "0.1.0", Some(Oid::from_str("fae3a288a1bc69b14f85a1d5fe57cee1964acd60").unwrap()), ) diff --git a/src/conventional/changelog/serde.rs b/src/conventional/changelog/serde.rs index d69e4eea..a8ed3602 100644 --- a/src/conventional/changelog/serde.rs +++ b/src/conventional/changelog/serde.rs @@ -11,7 +11,7 @@ impl Serialize for Tag { where S: Serializer, { - serializer.serialize_str(&self.to_string_with_prefix()) + serializer.serialize_str(&self.to_string()) } } @@ -58,7 +58,7 @@ impl Serialize for OidOf { let mut oidof = serializer.serialize_struct("OidOf", 1)?; match self { OidOf::Tag(tag) => { - oidof.serialize_field("tag", &tag.to_string_with_prefix())?; + oidof.serialize_field("tag", &tag.to_string())?; if let Some(oid) = tag.oid() { oidof.serialize_field("id", &oid.to_string())?; } @@ -84,7 +84,7 @@ mod test { #[test] fn should_serialize_tag() { - let tag = Tag::new("1.0.0", Some(Oid::from_str("1234567890").unwrap())).unwrap(); + let tag = Tag::from_str("1.0.0", Some(Oid::from_str("1234567890").unwrap())).unwrap(); let result = toml::to_string(&tag); diff --git a/src/git/monorepo.rs b/src/git/monorepo.rs index bcc029ff..844c3358 100644 --- a/src/git/monorepo.rs +++ b/src/git/monorepo.rs @@ -60,7 +60,13 @@ impl Repository { let tags: Vec = self.all_tags()?; tags.into_iter() - .filter(|tag| tag.to_string_with_prefix().starts_with(package_prefix)) + .filter(|tag| tag.prefix.is_some()) + .filter(|tag| { + tag.package + .as_ref() + .map(|package| package == package_prefix) + .unwrap_or_default() + }) .max() .ok_or(TagError::NoTag) } diff --git a/src/git/revspec.rs b/src/git/revspec.rs index 9c346ec0..33bd8abc 100644 --- a/src/git/revspec.rs +++ b/src/git/revspec.rs @@ -281,7 +281,7 @@ impl Repository { }; if range.contains(&oid) { - if let Ok(tag) = Tag::new(name, Some(oid)) { + if let Ok(tag) = Tag::from_str(name, Some(oid)) { tags.push(tag); }; }; @@ -465,9 +465,9 @@ mod test { // Arrange let repo = Repository::open(COCOGITTO_REPOSITORY)?; let v1_0_0 = Oid::from_str("549070fa99986b059cbaa9457b6b6f065bbec46b")?; - let v1_0_0 = OidOf::Tag(Tag::new("1.0.0", Some(v1_0_0))?); + let v1_0_0 = OidOf::Tag(Tag::from_str("1.0.0", Some(v1_0_0))?); let v3_0_0 = Oid::from_str("c6508e243e2816e2d2f58828ee0c6721502958dd")?; - let v3_0_0 = OidOf::Tag(Tag::new("3.0.0", Some(v3_0_0))?); + let v3_0_0 = OidOf::Tag(Tag::from_str("3.0.0", Some(v3_0_0))?); // Act let range = repo.get_commit_range(&RevspecPattern::from("1.0.0..3.0.0"))?; @@ -495,7 +495,7 @@ mod test { }; let v1_0_0 = Oid::from_str("549070fa99986b059cbaa9457b6b6f065bbec46b")?; - let v1_0_0 = OidOf::Tag(Tag::new("1.0.0", Some(v1_0_0))?); + let v1_0_0 = OidOf::Tag(Tag::from_str("1.0.0", Some(v1_0_0))?); // Act let range = repo.get_commit_range(&RevspecPattern::from("1.0.0.."))?; @@ -538,9 +538,9 @@ mod test { // Arrange let repo = Repository::open(COCOGITTO_REPOSITORY)?; let v2_1_1 = Oid::from_str("9dcf728d2eef6b5986633dd52ecbe9e416234898")?; - let v2_1_1 = OidOf::Tag(Tag::new("2.1.1", Some(v2_1_1))?); + let v2_1_1 = OidOf::Tag(Tag::from_str("2.1.1", Some(v2_1_1))?); let v3_0_0 = Oid::from_str("c6508e243e2816e2d2f58828ee0c6721502958dd")?; - let v3_0_0 = OidOf::Tag(Tag::new("3.0.0", Some(v3_0_0))?); + let v3_0_0 = OidOf::Tag(Tag::from_str("3.0.0", Some(v3_0_0))?); // Act let range = repo.get_commit_range(&RevspecPattern::from("..3.0.0"))?; diff --git a/src/git/stash.rs b/src/git/stash.rs index 009a0b8d..42da9c86 100644 --- a/src/git/stash.rs +++ b/src/git/stash.rs @@ -1,10 +1,11 @@ use crate::git::error::Git2Error; use crate::git::repository::Repository; +use crate::Tag; impl Repository { - pub(crate) fn stash_failed_version(&mut self, version: &str) -> Result<(), Git2Error> { + pub(crate) fn stash_failed_version(&mut self, tag: Tag) -> Result<(), Git2Error> { let sig = self.0.signature()?; - let message = &format!("cog_bump_{}", version); + let message = &format!("cog_bump_{}", tag); self.0 .stash_save(&sig, message, None) .map(|_| ()) @@ -15,6 +16,7 @@ impl Repository { #[cfg(test)] mod test { use crate::git::repository::Repository; + use crate::Tag; use anyhow::Result; use cmd_lib::run_cmd; use sealed_test::prelude::*; @@ -35,7 +37,7 @@ mod test { let statuses = repo.get_statuses()?.0; assert_that!(statuses).has_length(1); - repo.stash_failed_version("1.0.0")?; + repo.stash_failed_version(Tag::from_str("1.0.0", None)?)?; let statuses = repo.get_statuses()?.0; assert_that!(statuses).is_empty(); diff --git a/src/git/tag.rs b/src/git/tag.rs index b9e37422..75442b7e 100644 --- a/src/git/tag.rs +++ b/src/git/tag.rs @@ -3,10 +3,8 @@ use crate::git::repository::Repository; use crate::SETTINGS; use git2::string_array::StringArray; use git2::Oid; -use git2::Tag as Git2Tag; use semver::Version; use std::cmp::Ordering; -use std::convert::TryFrom; use std::fmt; use std::fmt::Formatter; @@ -15,11 +13,6 @@ impl Repository { /// tag (without configured prefix) is not semver compliant or if the tag /// does not exist. pub fn resolve_tag(&self, tag: &str) -> Result { - let without_prefix = Tag::strip_default_prefix(tag)?; - - // Ensure the tag is SemVer compliant - Version::parse(without_prefix).map_err(|err| TagError::semver(without_prefix, err))?; - self.resolve_lightweight_tag(tag) } @@ -29,10 +22,10 @@ impl Repository { .resolve_reference_from_short_name(tag) .map_err(|err| TagError::not_found(tag, err)) .map(|reference| reference.target().unwrap()) - .map(|oid| Tag::new(tag, Some(oid)))? + .map(|oid| Tag::from_str(tag, Some(oid)))? } - pub(crate) fn create_tag(&self, name: &str) -> Result<(), Git2Error> { + pub(crate) fn create_tag(&self, tag: &Tag) -> Result<(), Git2Error> { if self.get_diff(true).is_some() { let statuses = self.get_statuses()?; return Err(Git2Error::ChangesNeedToBeCommitted(statuses)); @@ -40,7 +33,7 @@ impl Repository { let head = self.get_head_commit().unwrap(); self.0 - .tag_lightweight(name, &head.into_object(), false) + .tag_lightweight(&tag.to_string(), &head.into_object(), false) .map(|_| ()) .map_err(Git2Error::from) } @@ -80,17 +73,10 @@ impl Repository { #[derive(Debug, PartialEq, Eq, Clone)] pub struct Tag { - tag: String, - oid: Option, -} - -impl TryFrom> for Tag { - type Error = TagError; - - fn try_from(tag: Git2Tag) -> Result { - let name = tag.name().expect("Unexpected unnamed tag"); - Self::new(name, Some(tag.id())) - } + pub package: Option, + pub prefix: Option, + pub version: Version, + pub oid: Option, } impl Tag { @@ -102,52 +88,80 @@ impl Tag { self.oid.as_ref().unwrap() } - pub(crate) fn oid(&self) -> Option<&Oid> { - self.oid.as_ref() - } - - pub(crate) fn new(name: &str, oid: Option) -> Result { - let tag = Tag::strip_default_prefix(name)?.to_string(); - Ok(Tag { tag, oid }) - } - - pub(crate) fn to_string_with_prefix(&self) -> String { - match SETTINGS.tag_prefix.as_ref() { - None => self.tag.to_string(), - Some(prefix) => format!("{}{}", prefix, self.tag), + pub(crate) fn create(version: Version, package: Option) -> Self { + Tag { + package, + prefix: SETTINGS.tag_prefix.clone(), + version, + oid: None, } } - - pub(crate) fn to_version(&self) -> Result { - Version::parse(&self.tag).map_err(|err| TagError::semver(&self.tag, err)) + pub(crate) fn oid(&self) -> Option<&Oid> { + self.oid.as_ref() } - pub(crate) fn to_package_version(&self, package_name: &str) -> Result { - let prefix = format!("{package_name}-"); - let version = self.tag.strip_prefix(&prefix).ok_or(TagError::InvalidPrefixError { - prefix, - tag: self.tag.clone(), - })?; + pub(crate) fn from_str(raw: &str, oid: Option) -> Result { + let prefix = SETTINGS.tag_prefix.as_ref(); - Version::parse(version).map_err(|err| TagError::semver(&self.tag, err)) - } - - fn strip_default_prefix(tag: &str) -> Result<&str, TagError> { - match SETTINGS.tag_prefix.as_ref() { - None => Ok(tag), - Some(prefix) => tag - .strip_prefix(prefix) - .ok_or(TagError::InvalidPrefixError { - prefix: prefix.to_string(), - tag: tag.to_string(), - }), + let tag = SETTINGS + .monorepo_version_separator + .as_ref() + .and_then(|separator| raw.split_once(separator)) + .map(|(package, remains)| { + let version = prefix + .and_then(|prefix| remains.strip_prefix(prefix)) + .unwrap_or(remains); + + if SETTINGS.packages.keys().any(|name| name == package) { + Version::parse(version) + .map(|version| Tag { + package: Some(package.to_string()), + prefix: prefix.cloned(), + version, + oid, + }) + .map_err(|err| TagError::semver(raw, err)) + } else { + Err(TagError::InvalidPrefixError { + prefix: package.to_string(), + tag: raw.to_string(), + }) + } + }); + + if let Some(Ok(tag)) = tag { + Ok(tag) + } else { + let version = prefix + .and_then(|prefix| raw.strip_prefix(prefix)) + .unwrap_or(raw); + + let version = Version::parse(version).map_err(|err| TagError::semver(raw, err))?; + + Ok(Tag { + package: None, + prefix: prefix.cloned(), + version, + oid, + }) } } } impl fmt::Display for Tag { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.to_string_with_prefix()) + let version = self.version.to_string(); + if let Some((prefix, package)) = self.package.as_ref().zip(self.prefix.as_ref()) { + let separator = &SETTINGS.monorepo_version_separator.as_ref().unwrap_or_else(|| + panic!("Found a tag with monorepo package prefix but 'monorepo_version_separator' is not defined") + ); + + write!(f, "{package}{separator}{prefix}{version}") + } else if let Some(prefix) = self.prefix.as_ref() { + write!(f, "{prefix}{version}") + } else { + write!(f, "{version}") + } } } @@ -159,7 +173,7 @@ impl Ord for Tag { impl PartialOrd for Tag { fn partial_cmp(&self, other: &Tag) -> Option { - Some(self.to_version().ok().cmp(&other.to_version().ok())) + Some(self.version.cmp(&other.version)) } } @@ -177,11 +191,11 @@ mod test { let repo = Repository::init(".")?; run_cmd!( git commit --allow-empty -m "first commit"; - git tag the_tag; + git tag 1.0.0; )?; // Act - let tag = repo.resolve_lightweight_tag("the_tag"); + let tag = repo.resolve_lightweight_tag("1.0.0"); // Assert assert_that!(tag).is_ok(); @@ -220,7 +234,7 @@ mod test { let tag = repo.get_latest_tag()?; // Assert - assert_that!(tag.to_string_with_prefix()).is_equal_to("0.2.0".to_string()); + assert_that!(tag.to_string()).is_equal_to("0.2.0".to_string()); Ok(()) } diff --git a/src/hook/mod.rs b/src/hook/mod.rs index ec84ec2c..1a04e307 100644 --- a/src/hook/mod.rs +++ b/src/hook/mod.rs @@ -7,9 +7,7 @@ use std::ops::Range; use std::process::Command; use std::str::FromStr; -use semver::Version; - -use crate::SETTINGS; +use crate::Tag; use parser::Token; use anyhow::{anyhow, ensure, Result}; @@ -21,28 +19,12 @@ pub struct VersionSpan { } pub(crate) struct HookVersion { - pub prefixed_tag: String, + pub prefixed_tag: Tag, } impl HookVersion { - pub(crate) fn new(tag: &str) -> Self { - HookVersion { - prefixed_tag: tag.to_string(), - } - } - pub(crate) fn to_version(&self) -> Result { - match SETTINGS.tag_prefix.as_ref() { - Some(prefix) => { - if self.prefixed_tag.starts_with(prefix) { - let version = self.prefixed_tag.strip_prefix(prefix); - Version::parse(version.unwrap()) - } else { - Version::parse(self.prefixed_tag.as_ref()) - } - } - None => Version::parse(self.prefixed_tag.as_ref()), - } - .map_err(|err| anyhow!("{}", err)) + pub(crate) fn new(tag: Tag) -> Self { + HookVersion { prefixed_tag: tag } } } @@ -52,10 +34,8 @@ impl VersionSpan { version: &HookVersion, latest: Option<&HookVersion>, ) -> Result { - let version = version.to_version()?; - let latest = latest - .map(|version| version.to_version()) - .and_then(Result::ok); + let version = version.prefixed_tag.version.clone(); + let latest = latest.map(|version| version.prefixed_tag.version.clone()); // According to the pest grammar, a `version` or `latest_version` token is expected first let mut version = match self.tokens.pop_front() { @@ -162,7 +142,7 @@ mod test { use git2::Repository; use std::str::FromStr; - use crate::{Hook, HookVersion, Result}; + use crate::{Hook, HookVersion, Result, Tag}; use sealed_test::prelude::*; use speculoos::prelude::*; @@ -183,7 +163,7 @@ mod test { #[test] fn replace_version_cargo() -> Result<()> { let mut hook = Hook::from_str("cargo bump {{version}}")?; - hook.insert_versions(None, &HookVersion::new("1.0.0")) + hook.insert_versions(None, &HookVersion::new(Tag::from_str("1.0.0", None)?)) .unwrap(); assert_that!(hook.0.as_str()).is_equal_to("cargo bump 1.0.0"); @@ -193,7 +173,7 @@ mod test { #[test] fn replace_maven_version() -> Result<()> { let mut hook = Hook::from_str("mvn versions:set -DnewVersion={{version}}")?; - hook.insert_versions(None, &HookVersion::new("1.0.0")) + hook.insert_versions(None, &HookVersion::new(Tag::from_str("1.0.0", None)?)) .unwrap(); assert_that!(hook.0.as_str()).is_equal_to("mvn versions:set -DnewVersion=1.0.0"); @@ -203,7 +183,7 @@ mod test { #[test] fn replace_maven_version_with_expression() -> Result<()> { let mut hook = Hook::from_str("mvn versions:set -DnewVersion={{version+1minor-SNAPSHOT}}")?; - hook.insert_versions(None, &HookVersion::new("1.0.0")) + hook.insert_versions(None, &HookVersion::new(Tag::from_str("1.0.0", None)?)) .unwrap(); assert_that!(hook.0.as_str()).is_equal_to("mvn versions:set -DnewVersion=1.1.0-SNAPSHOT"); @@ -213,7 +193,7 @@ mod test { #[test] fn leave_hook_untouched_when_no_version() -> Result<()> { let mut hook = Hook::from_str("echo \"Hello World\"")?; - hook.insert_versions(None, &HookVersion::new("1.0.0")) + hook.insert_versions(None, &HookVersion::new(Tag::from_str("1.0.0", None)?)) .unwrap(); assert_that!(hook.0.as_str()).is_equal_to("echo \"Hello World\""); @@ -223,7 +203,7 @@ mod test { #[test] fn replace_quoted_version() -> Result<()> { let mut hook = Hook::from_str("echo \"{{version}}\"")?; - hook.insert_versions(None, &HookVersion::new("1.0.0")) + hook.insert_versions(None, &HookVersion::new(Tag::from_str("1.0.0", None)?)) .unwrap(); assert_that!(hook.0.as_str()).is_equal_to("echo \"1.0.0\""); @@ -234,7 +214,7 @@ mod test { fn replace_version_with_nested_simple_quoted_arg() -> Result<()> { let mut hook = Hook::from_str("cog commit chore 'bump snapshot to {{version+1minor-pre}}'")?; - hook.insert_versions(None, &HookVersion::new("1.0.0")) + hook.insert_versions(None, &HookVersion::new(Tag::from_str("1.0.0", None)?)) .unwrap(); assert_that!(hook.0.as_str()).is_equal_to("cog commit chore 'bump snapshot to 1.1.0-pre'"); @@ -245,7 +225,7 @@ mod test { fn replace_version_with_nested_double_quoted_arg() -> Result<()> { let mut hook = Hook::from_str("cog commit chore \"bump snapshot to {{version+1minor-pre}}\"")?; - hook.insert_versions(None, &HookVersion::new("1.0.0")) + hook.insert_versions(None, &HookVersion::new(Tag::from_str("1.0.0", None)?)) .unwrap(); assert_that!(hook.0.as_str()) @@ -256,8 +236,11 @@ mod test { #[test] fn replace_version_with_multiple_placeholders() -> Result<()> { let mut hook = Hook::from_str("echo \"the latest {{latest}}, the greatest {{version}}\"")?; - hook.insert_versions(Some(&HookVersion::new("0.5.9")), &HookVersion::new("1.0.0")) - .unwrap(); + hook.insert_versions( + Some(&HookVersion::new(Tag::from_str("0.5.9", None)?)), + &HookVersion::new(Tag::from_str("1.0.0", None)?), + ) + .unwrap(); assert_that!(hook.0.as_str()).is_equal_to("echo \"the latest 0.5.9, the greatest 1.0.0\""); Ok(()) @@ -268,8 +251,11 @@ mod test { let mut hook = Hook::from_str( "echo \"the latest {{latest+3major+1minor}}, the greatest {{version+2patch}}\"", )?; - hook.insert_versions(Some(&HookVersion::new("0.5.9")), &HookVersion::new("1.0.0")) - .unwrap(); + hook.insert_versions( + Some(&HookVersion::new(Tag::from_str("0.5.9", None)?)), + &HookVersion::new(Tag::from_str("1.0.0", None)?), + ) + .unwrap(); assert_that!(hook.0.as_str()).is_equal_to("echo \"the latest 3.1.0, the greatest 1.0.2\""); Ok(()) @@ -279,7 +265,7 @@ mod test { fn replace_version_with_pre_and_build_metadata() -> Result<()> { let mut hook = Hook::from_str("echo \"the latest {{version+1major-pre.alpha-bravo+build.42}}\"")?; - hook.insert_versions(None, &HookVersion::new("1.0.0")) + hook.insert_versions(None, &HookVersion::new(Tag::from_str("1.0.0", None)?)) .unwrap(); assert_that!(hook.0.as_str()) @@ -293,7 +279,7 @@ mod test { let mut hook = Hook::from_str("git commit --allow-empty -m 'chore(snapshot): bump snapshot to {{version+1patch-SNAPSHOT}}'")?; - hook.insert_versions(None, &HookVersion::new("1.0.0")) + hook.insert_versions(None, &HookVersion::new(Tag::from_str("1.0.0", None)?)) .unwrap(); let outcome = hook.run(); diff --git a/src/lib.rs b/src/lib.rs index 81fc5622..bf2567e8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -418,7 +418,7 @@ impl CocoGitto { let current_tag = self.repository.get_latest_tag(); let current_version = match current_tag { - Ok(ref tag) => tag.to_version()?, + Ok(ref tag) => tag.version.clone(), Err(ref err) if err == &TagError::NoTag => { warn!("Failed to get current version, falling back to 0.0.0"); Version::new(0, 0, 0) @@ -443,13 +443,10 @@ impl CocoGitto { next_version.pre = Prerelease::new(pre_release)?; } - let version_str = match &SETTINGS.tag_prefix { - None => next_version.to_string(), - Some(prefix) => format!("{}{}", prefix, next_version), - }; + let next_tag = Tag::create(next_version, None); if dry_run { - print!("{}", version_str); + print!("{}", next_tag); return Ok(()); } @@ -463,19 +460,15 @@ impl CocoGitto { let pattern = (origin.as_str(), target.as_str()); let pattern = RevspecPattern::from(pattern); - let changelog = self.get_changelog_with_target_version(pattern, &version_str)?; + let changelog = self.get_changelog_with_target_version(pattern, next_tag.clone())?; let path = settings::changelog_path(); let template = SETTINGS.get_changelog_template()?; changelog.write_to_file(path, template)?; - let current = self - .repository - .get_latest_tag() - .map(|tag| HookVersion::new(&tag.to_string_with_prefix())) - .ok(); + let current = self.repository.get_latest_tag().map(HookVersion::new).ok(); - let next_version = HookVersion::new(&Self::prefix_version(next_version.to_string())); + let next_version = HookVersion::new(next_tag.clone()); let hook_result = self.run_hooks( HookType::PreBump, @@ -490,12 +483,12 @@ impl CocoGitto { // Hook failed, we need to stop here and reset // the repository to a clean state if let Err(err) = hook_result { - self.repository.stash_failed_version(&version_str)?; + self.repository.stash_failed_version(next_tag.clone())?; error!( "{}", PreHookError { cause: err.to_string(), - version: version_str, + version: next_tag.to_string(), stash_number: 0, } ); @@ -511,7 +504,7 @@ impl CocoGitto { sign, )?; - self.repository.create_tag(&version_str)?; + self.repository.create_tag(&next_tag)?; self.run_hooks( HookType::PostBump, @@ -522,7 +515,7 @@ impl CocoGitto { )?; let current = current - .map(|current| current.prefixed_tag) + .map(|current| current.prefixed_tag.to_string()) .unwrap_or_else(|| "...".to_string()); let bump = format!("{} -> {}", current, next_version.prefixed_tag).green(); info!("Bumped version: {}", bump); @@ -542,7 +535,7 @@ impl CocoGitto { for (package_name, package) in &SETTINGS.packages { let current_tag = self.repository.get_latest_package_tag(package_name); let current_version = match current_tag { - Ok(ref tag) => tag.to_package_version(package_name)?, + Ok(ref tag) => tag.version.clone(), Err(ref err) if err == &TagError::NoTag => { warn!("Failed to get current version, falling back to 0.0.0"); Version::new(0, 0, 0) @@ -550,7 +543,8 @@ impl CocoGitto { Err(ref err) => bail!("{}", err), }; - let mut next_version = VersionIncrement::Auto.bump(¤t_version, &self.repository)?; + let mut next_version = + VersionIncrement::Auto.bump(¤t_version, &self.repository)?; if next_version.le(¤t_version) || next_version.eq(¤t_version) { let comparison = format!("{} <= {}", current_version, next_version).red(); @@ -567,11 +561,11 @@ impl CocoGitto { next_version.pre = Prerelease::new(pre_release)?; } - let version_str = format!("{}-{}", package_name, next_version); + let tag = Tag::create(next_version, Some(package_name.to_string())); if dry_run { - print!("{}", version_str); - continue + print!("{}", tag); + continue; } let origin = if current_version == Version::new(0, 0, 0) { @@ -585,11 +579,11 @@ impl CocoGitto { let pattern = RevspecPattern::from(pattern); let changelog = - self.get_changelog_with_target_package_version(pattern, &version_str, package)?; + self.get_changelog_with_target_package_version(pattern, tag.clone(), package)?; if changelog.is_none() { println!("No commit found to bump package {package_name}, skipping."); - continue + continue; } let changelog = changelog.unwrap(); @@ -600,10 +594,10 @@ impl CocoGitto { let current = self .repository .get_latest_package_tag(package_name) - .map(|tag| HookVersion::new(&tag.to_string_with_prefix())) + .map(HookVersion::new) .ok(); - let next_version = HookVersion::new(&Self::prefix_version(next_version.to_string())); + let next_version = HookVersion::new(tag.clone()); let hook_result = self.run_hooks( HookType::PreBump, @@ -618,30 +612,27 @@ impl CocoGitto { // Hook failed, we need to stop here and reset // the repository to a clean state if let Err(err) = hook_result { - self.repository.stash_failed_version(&version_str)?; + self.repository.stash_failed_version(tag.clone())?; error!( - "{}", - PreHookError { - cause: err.to_string(), - version: version_str, - stash_number: 0, - } - ); + "{}", + PreHookError { + cause: err.to_string(), + version: tag.to_string(), + stash_number: 0, + } + ); exit(1); } - package_bumps.push((package_name, package, current, next_version, version_str)); + package_bumps.push((package_name, package, current, next_version, tag)); } // Todo: meta version - self.repository - .commit("chore(version): Bump", false)?; + self.repository.commit("chore(version): Bump", false)?; - for (package_name, package, current, next_version, version_str) in package_bumps { - let version_str = Self::prefix_version(version_str); - - self.repository.create_tag(&version_str)?; + for (package_name, package, current, next_version, tag) in package_bumps { + self.repository.create_tag(&tag)?; self.run_hooks( HookType::PostBump, @@ -652,14 +643,12 @@ impl CocoGitto { )?; let current = current - .map(|current| current.prefixed_tag) + .map(|current| current.prefixed_tag.to_string()) .unwrap_or_else(|| "...".to_string()); let bump = format!("{} -> {}", current, next_version.prefixed_tag).green(); info!("Bumped package {package_name} version: {}", bump); } - - Ok(()) } @@ -675,7 +664,7 @@ impl CocoGitto { let current_tag = self.repository.get_latest_package_tag(package_name); let current_version = match current_tag { - Ok(ref tag) => tag.to_package_version(package_name)?, + Ok(ref tag) => tag.version.clone(), Err(ref err) if err == &TagError::NoTag => { warn!("Failed to get current version, falling back to 0.0.0"); Version::new(0, 0, 0) @@ -686,7 +675,7 @@ impl CocoGitto { let mut next_version = increment.bump(¤t_version, &self.repository)?; if next_version.le(¤t_version) || next_version.eq(¤t_version) { - let comparison = format!("{} <= {}", current_version, next_version).red(); + let comparison = format!("{} <= {}", current_version, &next_version).red(); let cause_key = "cause:".red(); let cause = format!( "{} version MUST be greater than current one: {}", @@ -700,10 +689,10 @@ impl CocoGitto { next_version.pre = Prerelease::new(pre_release)?; } - let version_str = format!("{}-{}", package_name, next_version); + let tag = Tag::create(next_version.clone(), Some(package_name.to_string())); if dry_run { - print!("{}", version_str); + print!("{}", tag); return Ok(()); } @@ -718,7 +707,7 @@ impl CocoGitto { let pattern = RevspecPattern::from(pattern); let changelog = - self.get_changelog_with_target_package_version(pattern, &version_str, package)?; + self.get_changelog_with_target_package_version(pattern, tag.clone(), package)?; if changelog.is_none() { bail!("No commit matching package {package_name} path"); @@ -732,10 +721,11 @@ impl CocoGitto { let current = self .repository .get_latest_package_tag(package_name) - .map(|tag| HookVersion::new(&tag.to_string_with_prefix())) + .map(HookVersion::new) .ok(); - let next_version = HookVersion::new(&Self::prefix_version(next_version.to_string())); + let next_version = + HookVersion::new(Tag::create(next_version, Some(package_name.to_string()))); let hook_result = self.run_hooks( HookType::PreBump, @@ -750,12 +740,12 @@ impl CocoGitto { // Hook failed, we need to stop here and reset // the repository to a clean state if let Err(err) = hook_result { - self.repository.stash_failed_version(&version_str)?; + self.repository.stash_failed_version(tag.clone())?; error!( "{}", PreHookError { cause: err.to_string(), - version: version_str, + version: tag.to_string(), stash_number: 0, } ); @@ -763,12 +753,10 @@ impl CocoGitto { exit(1); } - let version_str = Self::prefix_version(version_str); - self.repository - .commit(&format!("chore(version): {}", version_str), false)?; + .commit(&format!("chore(version): {}", tag), false)?; - self.repository.create_tag(&version_str)?; + self.repository.create_tag(&tag)?; self.run_hooks( HookType::PostBump, @@ -779,7 +767,7 @@ impl CocoGitto { )?; let current = current - .map(|current| current.prefixed_tag) + .map(|current| current.prefixed_tag.to_string()) .unwrap_or_else(|| "...".to_string()); let bump = format!("{} -> {}", current, next_version.prefixed_tag).green(); info!("Bumped package {package_name} version: {}", bump); @@ -840,12 +828,12 @@ impl CocoGitto { pub fn get_changelog_with_target_version( &self, pattern: RevspecPattern, - target_version: &str, + tag: Tag, ) -> Result { let commit_range = self.repository.get_commit_range(&pattern)?; let mut release = Release::from(commit_range); - release.version = OidOf::Tag(Tag::new(target_version, None)?); + release.version = OidOf::Tag(tag); Ok(release) } @@ -854,7 +842,7 @@ impl CocoGitto { pub fn get_changelog_with_target_package_version( &self, pattern: RevspecPattern, - target_version: &str, + target_tag: Tag, package: &MonoRepoPackage, ) -> Result> { let mut release = self @@ -863,7 +851,7 @@ impl CocoGitto { .map(Release::from); if let Some(release) = &mut release { - release.version = OidOf::Tag(Tag::new(target_version, None)?); + release.version = OidOf::Tag(target_tag); } Ok(release) @@ -950,16 +938,4 @@ impl CocoGitto { Ok(()) } - - fn prefix_version(version: String) -> String { - if let Some(prefix) = SETTINGS.tag_prefix.as_ref() { - if !version.starts_with(prefix) { - format!("{}{}", prefix, version) - } else { - version - } - } else { - version - } - } } diff --git a/src/settings/mod.rs b/src/settings/mod.rs index 19f12f15..30d9f959 100644 --- a/src/settings/mod.rs +++ b/src/settings/mod.rs @@ -32,6 +32,8 @@ pub struct Settings { #[serde(default)] pub ignore_merge_commits: bool, #[serde(default)] + pub monorepo_version_separator: Option, + #[serde(default)] pub branch_whitelist: Vec, pub tag_prefix: Option, #[serde(default)] diff --git a/tests/lib_tests/bump.rs b/tests/lib_tests/bump.rs index d749e724..37a82ebe 100644 --- a/tests/lib_tests/bump.rs +++ b/tests/lib_tests/bump.rs @@ -2,7 +2,6 @@ use anyhow::Result; use cmd_lib::run_cmd; use cocogitto::{conventional::version::VersionIncrement, CocoGitto}; -use indoc::indoc; use sealed_test::prelude::*; use speculoos::prelude::*; @@ -46,31 +45,33 @@ fn should_fallback_to_0_0_0_when_there_is_no_tag() -> Result<()> { Ok(()) } -#[sealed_test] -fn should_fail_when_latest_tag_is_not_semver_compliant() -> Result<()> { - // Arrange - git_init()?; - git_commit("chore: first commit")?; - git_commit("feat: add a feature commit")?; - git_tag("toto")?; - git_commit("feat: add another feature commit")?; - - let mut cocogitto = CocoGitto::get()?; - - // Act - let result = cocogitto.create_version(VersionIncrement::Auto, None, None, false); - let error = result.unwrap_err().to_string(); - let error = error.as_str(); - - // Assert - assert_that!(error).is_equal_to(indoc!( - " - tag `toto` is not SemVer compliant - \tcause: unexpected character 't' while parsing major version number - " - )); - Ok(()) -} +// FIXME: Failing on non compliant tag should be configurable +// until it's implemented we will ignore non compliant tags +// #[sealed_test] +// fn should_fail_when_latest_tag_is_not_semver_compliant() -> Result<()> { +// // Arrange +// git_init()?; +// git_commit("chore: first commit")?; +// git_commit("feat: add a feature commit")?; +// git_tag("toto")?; +// git_commit("feat: add another feature commit")?; +// +// let mut cocogitto = CocoGitto::get()?; +// +// // Act +// let result = cocogitto.create_version(VersionIncrement::Auto, None, None, false); +// let error = result.unwrap_err().to_string(); +// let error = error.as_str(); +// +// // Assert +// assert_that!(error).is_equal_to(indoc!( +// " +// tag `toto` is not SemVer compliant +// \tcause: unexpected character 't' while parsing major version number +// " +// )); +// Ok(()) +// } #[sealed_test] fn bump_with_whitelisted_branch_ok() -> Result<()> { From 58ed3f064d85055eb8dcfead7cf474f5e5bc99e0 Mon Sep 17 00:00:00 2001 From: Paul Delafosse Date: Mon, 31 Oct 2022 12:06:09 +0100 Subject: [PATCH 03/26] refactor: factorize stash on bump failure --- src/lib.rs | 62 ++++++++++++++++++++---------------------------------- 1 file changed, 23 insertions(+), 39 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index bf2567e8..b2a480d5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,7 +6,7 @@ use std::io::Write; use std::path::Path; use std::process::{exit, Command, Stdio}; -use anyhow::{anyhow, bail, ensure, Context, Result}; +use anyhow::{anyhow, bail, ensure, Context, Error, Result}; use colored::*; use conventional_commit_parser::commit::{CommitType, ConventionalCommit}; use conventional_commit_parser::parse_footers; @@ -443,10 +443,10 @@ impl CocoGitto { next_version.pre = Prerelease::new(pre_release)?; } - let next_tag = Tag::create(next_version, None); + let tag = Tag::create(next_version, None); if dry_run { - print!("{}", next_tag); + print!("{}", tag); return Ok(()); } @@ -460,7 +460,7 @@ impl CocoGitto { let pattern = (origin.as_str(), target.as_str()); let pattern = RevspecPattern::from(pattern); - let changelog = self.get_changelog_with_target_version(pattern, next_tag.clone())?; + let changelog = self.get_changelog_with_target_version(pattern, tag.clone())?; let path = settings::changelog_path(); let template = SETTINGS.get_changelog_template()?; @@ -468,7 +468,7 @@ impl CocoGitto { let current = self.repository.get_latest_tag().map(HookVersion::new).ok(); - let next_version = HookVersion::new(next_tag.clone()); + let next_version = HookVersion::new(tag.clone()); let hook_result = self.run_hooks( HookType::PreBump, @@ -483,17 +483,7 @@ impl CocoGitto { // Hook failed, we need to stop here and reset // the repository to a clean state if let Err(err) = hook_result { - self.repository.stash_failed_version(next_tag.clone())?; - error!( - "{}", - PreHookError { - cause: err.to_string(), - version: next_tag.to_string(), - stash_number: 0, - } - ); - - exit(1); + self.stash_failed_version(&tag, err)?; } let version_str = Self::prefix_version(version_str); @@ -504,7 +494,7 @@ impl CocoGitto { sign, )?; - self.repository.create_tag(&next_tag)?; + self.repository.create_tag(&tag)?; self.run_hooks( HookType::PostBump, @@ -612,17 +602,7 @@ impl CocoGitto { // Hook failed, we need to stop here and reset // the repository to a clean state if let Err(err) = hook_result { - self.repository.stash_failed_version(tag.clone())?; - error!( - "{}", - PreHookError { - cause: err.to_string(), - version: tag.to_string(), - stash_number: 0, - } - ); - - exit(1); + self.stash_failed_version(&tag, err)?; } package_bumps.push((package_name, package, current, next_version, tag)); @@ -740,17 +720,7 @@ impl CocoGitto { // Hook failed, we need to stop here and reset // the repository to a clean state if let Err(err) = hook_result { - self.repository.stash_failed_version(tag.clone())?; - error!( - "{}", - PreHookError { - cause: err.to_string(), - version: tag.to_string(), - stash_number: 0, - } - ); - - exit(1); + self.stash_failed_version(&tag, err)?; } self.repository @@ -775,6 +745,20 @@ impl CocoGitto { Ok(()) } + fn stash_failed_version(&mut self, tag: &Tag, err: Error) -> Result<()> { + self.repository.stash_failed_version(tag.clone())?; + error!( + "{}", + PreHookError { + cause: err.to_string(), + version: tag.to_string(), + stash_number: 0, + } + ); + + exit(1); + } + fn pre_bump_checks(&mut self) -> Result<()> { if *SETTINGS == Settings::default() { let part1 = "Warning: using".yellow(); From 6f7030ba2db070c2ce8b57c4dc25be9060bc01e6 Mon Sep 17 00:00:00 2001 From: Paul Delafosse Date: Mon, 31 Oct 2022 12:11:07 +0100 Subject: [PATCH 04/26] ci: bump codecov action --- .github/workflows/CI.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index f078dd38..37ef7e11 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -46,7 +46,7 @@ jobs: args: --all-features --workspace --lcov --output-path lcov.info - name: Upload coverage to Codecov - uses: codecov/codecov-action@v2 + uses: codecov/codecov-action@v3 with: files: lcov.info fail_ci_if_error: true From efcc5fa115400dd035c23081ab74bde25792b522 Mon Sep 17 00:00:00 2001 From: Paul Delafosse Date: Mon, 31 Oct 2022 13:41:57 +0100 Subject: [PATCH 05/26] refactor: refactor bump increment --- Cargo.lock | 4 +- src/bin/cog/commit.rs | 2 +- src/conventional/bump.rs | 377 +++++++++++++++++++++++++++++++++++ src/conventional/commit.rs | 3 +- src/conventional/mod.rs | 1 + src/conventional/version.rs | 362 ++------------------------------- src/git/monorepo.rs | 1 - src/git/revspec.rs | 2 +- src/git/tag.rs | 237 +++++++++++++++++++--- src/lib.rs | 225 +++++++++------------ src/settings/mod.rs | 8 + tests/cog_tests/changelog.rs | 4 +- tests/helpers.rs | 4 +- 13 files changed, 709 insertions(+), 521 deletions(-) create mode 100644 src/conventional/bump.rs diff --git a/Cargo.lock b/Cargo.lock index 4bf297cb..637142a8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -22,9 +22,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.66" +version = "1.0.68" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "216261ddc8289130e551ddcd5ce8a064710c0d064a4d2895c67151c92b5443f6" +checksum = "2cb2f989d18dd141ab8ae82f64d1a8cdd37e0840f73a406896cf5e99502fab61" [[package]] name = "assert_cmd" diff --git a/src/bin/cog/commit.rs b/src/bin/cog/commit.rs index 81c5cac4..7009a6d4 100644 --- a/src/bin/cog/commit.rs +++ b/src/bin/cog/commit.rs @@ -23,7 +23,7 @@ pub fn edit_message( ) -> Result<(Option, Option, bool)> { let template = prepare_edit_template(typ, message, scope, breaking); - let edited = edit::edit(&template)?; + let edited = edit::edit(template)?; if edited.lines().all(|line| { let trimmed = line.trim_start(); diff --git a/src/conventional/bump.rs b/src/conventional/bump.rs new file mode 100644 index 00000000..66f0d599 --- /dev/null +++ b/src/conventional/bump.rs @@ -0,0 +1,377 @@ +use crate::conventional::error::BumpError; +use crate::{Commit, Repository, RevspecPattern, Tag, VersionIncrement}; +use conventional_commit_parser::commit::CommitType; +use git2::Commit as Git2Commit; +use semver::{BuildMetadata, Prerelease, Version}; + +pub(crate) trait Bump { + fn manual_bump(&self, version: &str) -> Result + where + Self: Sized; + fn major_bump(&self) -> Self; + fn minor_bump(&self) -> Self; + fn patch_bump(&self) -> Self; + fn auto_bump(&self, repository: &Repository) -> Result + where + Self: Sized; +} + +impl Bump for Tag { + fn manual_bump(&self, version: &str) -> Result { + let mut next = self.clone(); + next.version = Version::parse(version)?; + Ok(next) + } + + fn major_bump(&self) -> Self { + let mut next = self.clone(); + next.version.major += 1; + next.version.minor = 0; + next.version.patch = 0; + next.reset_metadata() + } + + fn minor_bump(&self) -> Self { + let mut next = self.clone(); + next.version.minor += 1; + next.version.patch = 0; + next.reset_metadata() + } + + fn patch_bump(&self) -> Self { + let mut next = self.clone(); + next.version.patch += 1; + next.reset_metadata() + } + + fn auto_bump(&self, repository: &Repository) -> Result { + self.create_version_from_commit_history(repository) + } +} + +impl Tag { + pub(crate) fn bump( + &self, + increment: VersionIncrement, + repository: &Repository, + ) -> Result { + match increment { + VersionIncrement::Major => Ok(self.major_bump()), + VersionIncrement::Minor => Ok(self.minor_bump()), + VersionIncrement::Patch => Ok(self.patch_bump()), + VersionIncrement::Auto => self.auto_bump(repository), + VersionIncrement::Manual(version) => self.manual_bump(&version).map_err(Into::into), + } + } + + fn reset_metadata(mut self) -> Self { + self.version.build = BuildMetadata::EMPTY; + self.version.pre = Prerelease::EMPTY; + self.oid = None; + self + } + + fn create_version_from_commit_history( + &self, + repository: &Repository, + ) -> Result { + let changelog_start_oid = match &self.package { + None => repository.get_latest_tag_oid().ok(), + Some(package) => repository + .get_latest_package_tag(package) + .ok() + .and_then(|tag| tag.oid), + } + .unwrap_or_else(|| repository.get_first_commit().expect("non empty repository")); + + let changelog_start_oid = changelog_start_oid.to_string(); + let changelog_start_oid = Some(changelog_start_oid.as_str()); + + let pattern = changelog_start_oid + .map(|oid| format!("{}..", oid)) + .unwrap_or_else(|| "..".to_string()); + let pattern = pattern.as_str(); + let pattern = RevspecPattern::from(pattern); + let commits = repository.get_commit_range(&pattern)?; + + let commits: Vec<&Git2Commit> = commits + .commits + .iter() + .filter(|commit| !commit.message().unwrap_or("").starts_with("Merge ")) + .collect(); + + VersionIncrement::display_history(&commits)?; + + let conventional_commits: Vec = commits + .iter() + .map(|commit| Commit::from_git_commit(commit)) + .filter_map(Result::ok) + .collect(); + + let increment_type = self.version_increment_from_commit_history(&conventional_commits)?; + + Ok(match increment_type { + VersionIncrement::Major => self.major_bump(), + VersionIncrement::Minor => self.minor_bump(), + VersionIncrement::Patch => self.patch_bump(), + _ => unreachable!(), + }) + } + + fn version_increment_from_commit_history( + &self, + commits: &[Commit], + ) -> Result { + let is_major_bump = || { + self.version.major != 0 + && commits + .iter() + .any(|commit| commit.message.is_breaking_change) + }; + + let is_minor_bump = || { + commits + .iter() + .any(|commit| commit.message.commit_type == CommitType::Feature) + }; + + let is_patch_bump = || { + commits + .iter() + .any(|commit| commit.message.commit_type == CommitType::BugFix) + }; + + if is_major_bump() { + Ok(VersionIncrement::Major) + } else if is_minor_bump() { + Ok(VersionIncrement::Minor) + } else if is_patch_bump() { + Ok(VersionIncrement::Patch) + } else { + Err(BumpError::NoCommitFound) + } + } +} + +#[cfg(test)] +mod test { + use crate::conventional::commit::Commit; + use crate::conventional::version::VersionIncrement; + use crate::git::repository::Repository; + use crate::git::tag::Tag; + use anyhow::Result; + use chrono::Utc; + use conventional_commit_parser::commit::{CommitType, ConventionalCommit}; + use sealed_test::prelude::*; + use semver::Version; + use speculoos::prelude::*; + use std::str::FromStr; + + impl Commit { + fn commit_fixture(commit_type: CommitType, is_breaking_change: bool) -> Self { + Commit { + oid: "1234".to_string(), + message: ConventionalCommit { + commit_type, + scope: None, + body: None, + summary: "message".to_string(), + is_breaking_change, + footers: vec![], + }, + author: "".to_string(), + date: Utc::now().naive_local(), + } + } + } + + #[sealed_test] + fn major_bump() -> Result<()> { + // Arrange + let repository = Repository::init(".")?; + let base_version = Tag::from_str("1.0.0", None)?; + + // Act + let tag = base_version.bump(VersionIncrement::Major, &repository)?; + + // Assert + assert_that!(tag.version).is_equal_to(Version::new(2, 0, 0)); + Ok(()) + } + + #[sealed_test] + fn minor_bump() -> Result<()> { + // Arrange + let repository = Repository::init(".")?; + let base_version = Tag::from_str("1.0.0", None)?; + + // Act + let tag = base_version.bump(VersionIncrement::Minor, &repository)?; + + // Assert + assert_that!(tag.version).is_equal_to(Version::new(1, 1, 0)); + Ok(()) + } + + #[sealed_test] + fn patch_bump() -> Result<()> { + // Arrange + let repository = Repository::init(".")?; + let base_version = Tag::from_str("1.0.0", None)?; + + // Act + let tag = base_version.bump(VersionIncrement::Patch, &repository)?; + + // Assert + assert_that!(tag.version).is_equal_to(Version::new(1, 0, 1)); + Ok(()) + } + + #[test] + fn should_get_next_auto_version_patch() -> Result<()> { + // Arrange + let patch = Commit::commit_fixture(CommitType::BugFix, false); + let base_version = Tag::from_str("1.0.0", None)?; + + // Act + let increment = base_version.version_increment_from_commit_history(&[patch]); + + // Assert + assert_that!(increment) + .is_ok() + .is_equal_to(VersionIncrement::Patch); + + Ok(()) + } + + #[test] + fn increment_minor_version_should_set_patch_to_zero() -> Result<()> { + // Arrange + let repository = Repository::init(".")?; + let version = Tag::from_str("1.1.1", None)?; + + // Act + let tag = version.bump(VersionIncrement::Minor, &repository)?; + + // Assert + assert_that!(tag.version).is_equal_to(Version::from_str("1.2.0")?); + + Ok(()) + } + + #[sealed_test] + fn increment_major_version_should_set_minor_and_patch_to_zero() -> Result<()> { + // Arrange + let repository = Repository::init(".")?; + let version = Tag::from_str("1.1.1", None)?; + + // Act + let tag = version.bump(VersionIncrement::Major, &repository)?; + + // Assert + assert_that!(tag.version).is_equal_to(Version::from_str("2.0.0")?); + + Ok(()) + } + + #[sealed_test] + fn increment_should_strip_metadata() -> Result<()> { + // Arrange + let repository = Repository::init(".")?; + let version = Tag::from_str("1.1.1-pre+10.1", None)?; + + // Act + let tag = version.bump(VersionIncrement::Patch, &repository)?; + + // Assert + assert_that!(tag.version).is_equal_to(Version::from_str("1.1.2")?); + + Ok(()) + } + + #[test] + fn should_get_next_auto_version_breaking_changes() -> Result<()> { + // Arrange + let feature = Commit::commit_fixture(CommitType::Feature, false); + let breaking_change = Commit::commit_fixture(CommitType::Feature, true); + let base_version = Tag::from_str("1.0.0", None)?; + + // Act + let version = + base_version.version_increment_from_commit_history(&[feature, breaking_change]); + + // Assert + assert_that!(version) + .is_ok() + .is_equal_to(VersionIncrement::Major); + + Ok(()) + } + + #[test] + fn should_get_next_auto_version_breaking_changes_on_initial_dev_version() -> Result<()> { + // Arrange + let feature = Commit::commit_fixture(CommitType::Feature, false); + let breaking_change = Commit::commit_fixture(CommitType::Feature, true); + let base_version = Tag::from_str("0.1.0", None)?; + + // Act + let version = + base_version.version_increment_from_commit_history(&[feature, breaking_change]); + + // Assert + assert_that!(version) + .is_ok() + .is_equal_to(VersionIncrement::Minor); + + Ok(()) + } + + #[test] + fn should_get_next_auto_version_minor() -> Result<()> { + // Arrange + let patch = Commit::commit_fixture(CommitType::BugFix, false); + let feature = Commit::commit_fixture(CommitType::Feature, false); + let base_version = Tag::from_str("0.1.0", None)?; + + // Act + let version = base_version.version_increment_from_commit_history(&[patch, feature]); + + // Assert + assert_that!(version) + .is_ok() + .is_equal_to(VersionIncrement::Minor); + + Ok(()) + } + + #[test] + fn should_fail_without_feature_bug_fix_or_breaking_change_commit() -> Result<()> { + // Arrange + let chore = Commit::commit_fixture(CommitType::Chore, false); + let docs = Commit::commit_fixture(CommitType::Documentation, false); + let base_version = Tag::from_str("0.1.0", None)?; + + // Act + let version = base_version.version_increment_from_commit_history(&[chore, docs]); + + // Assert + let result = version.unwrap_err().to_string(); + let result = result.as_str(); + + assert_eq!( + result, + r#"failed to bump version + +cause: No conventional commit found to bump current version. + Only feature, bug fix and breaking change commits will trigger an automatic bump. + +suggestion: Please see https://conventionalcommits.org/en/v1.0.0/#summary for more information. + Alternatively consider using `cog bump <--version |--auto|--major|--minor>` + +"# + ); + + Ok(()) + } +} diff --git a/src/conventional/commit.rs b/src/conventional/commit.rs index a46c2ef3..3f9b64fa 100644 --- a/src/conventional/commit.rs +++ b/src/conventional/commit.rs @@ -38,7 +38,8 @@ impl Commit { let oid = commit.id().to_string(); let commit = commit.to_owned(); - let date = NaiveDateTime::from_timestamp(commit.time().seconds(), 0); + let date = NaiveDateTime::from_timestamp_opt(commit.time().seconds(), 0) + .expect("valid commit date"); let message = commit.message(); let git2_message = message.unwrap().to_owned(); let author = commit.author().name().unwrap_or("").to_string(); diff --git a/src/conventional/mod.rs b/src/conventional/mod.rs index 5e972880..08d842cd 100644 --- a/src/conventional/mod.rs +++ b/src/conventional/mod.rs @@ -1,3 +1,4 @@ +pub mod bump; pub mod changelog; pub mod commit; pub(crate) mod error; diff --git a/src/conventional/version.rs b/src/conventional/version.rs index f2623452..f54f9b5c 100644 --- a/src/conventional/version.rs +++ b/src/conventional/version.rs @@ -1,15 +1,10 @@ use crate::conventional::commit::Commit; -use crate::git::repository::Repository; -use std::fmt; - -use crate::conventional::error::BumpError; -use crate::git::revspec::RevspecPattern; use colored::*; use conventional_commit_parser::commit::CommitType; use git2::Commit as Git2Commit; use itertools::Itertools; use log::info; -use semver::Version; +use std::fmt; use std::fmt::Write; #[derive(Debug, PartialEq, Eq)] @@ -22,105 +17,8 @@ pub enum VersionIncrement { } impl VersionIncrement { - pub(crate) fn bump( - &self, - current_version: &Version, - repository: &Repository, - ) -> Result { - match self { - VersionIncrement::Manual(version) => Version::parse(version).map_err(Into::into), - VersionIncrement::Auto => { - VersionIncrement::create_version_from_commit_history(current_version, repository) - } - VersionIncrement::Major => Ok(Version::new(current_version.major + 1, 0, 0)), - VersionIncrement::Patch => Ok(Version::new( - current_version.major, - current_version.minor, - current_version.patch + 1, - )), - VersionIncrement::Minor => Ok(Version::new( - current_version.major, - current_version.minor + 1, - 0, - )), - } - } - - fn create_version_from_commit_history( - current_version: &Version, - repository: &Repository, - ) -> Result { - let changelog_start_oid = repository - .get_latest_tag_oid() - .unwrap_or_else(|_| repository.get_first_commit().unwrap()); - - let changelog_start_oid = changelog_start_oid.to_string(); - let changelog_start_oid = Some(changelog_start_oid.as_str()); - - let pattern = changelog_start_oid - .map(|oid| format!("{}..", oid)) - .unwrap_or_else(|| "..".to_string()); - let pattern = pattern.as_str(); - let pattern = RevspecPattern::from(pattern); - let commits = repository.get_commit_range(&pattern)?; - - let commits: Vec<&Git2Commit> = commits - .commits - .iter() - .filter(|commit| !commit.message().unwrap_or("").starts_with("Merge ")) - .collect(); - - VersionIncrement::display_history(&commits)?; - - let conventional_commits: Vec = commits - .iter() - .map(|commit| Commit::from_git_commit(commit)) - .filter_map(Result::ok) - .collect(); - - let increment_type = VersionIncrement::version_increment_from_commit_history( - current_version, - &conventional_commits, - )?; - - increment_type.bump(current_version, repository) - } - - fn version_increment_from_commit_history( - current_version: &Version, - commits: &[Commit], - ) -> Result { - let is_major_bump = || { - current_version.major != 0 - && commits - .iter() - .any(|commit| commit.message.is_breaking_change) - }; - - let is_minor_bump = || { - commits - .iter() - .any(|commit| commit.message.commit_type == CommitType::Feature) - }; - - let is_patch_bump = || { - commits - .iter() - .any(|commit| commit.message.commit_type == CommitType::BugFix) - }; - - if is_major_bump() { - Ok(VersionIncrement::Major) - } else if is_minor_bump() { - Ok(VersionIncrement::Minor) - } else if is_patch_bump() { - Ok(VersionIncrement::Patch) - } else { - Err(BumpError::NoCommitFound) - } - } - - fn display_history(commits: &[&Git2Commit]) -> Result<(), fmt::Error> { + // TODO: move that to a dedicated CLI display module + pub(crate) fn display_history(commits: &[&Git2Commit]) -> Result<(), fmt::Error> { let conventional_commits: Vec> = commits .iter() .map(|commit| Commit::from_git_commit(commit)) @@ -146,12 +44,14 @@ impl VersionIncrement { .dedup_by_with_count(|c1, c2| c1 == c2) .collect(); - let mut skip_message = "Skipping irrelevant commits:\n".to_string(); - for (count, commit_type) in non_bump_commits { - writeln!(skip_message, "\t- {}: {}", commit_type.as_ref(), count)?; - } + if !non_bump_commits.is_empty() { + let mut skip_message = "\tSkipping irrelevant commits:\n".to_string(); + for (count, commit_type) in non_bump_commits { + writeln!(skip_message, "\t\t- {}: {}", commit_type.as_ref(), count)?; + } - info!("{}", skip_message); + info!("{}", skip_message); + } let bump_commits = conventional_commits .iter() @@ -167,17 +67,17 @@ impl VersionIncrement { match commit { Ok(commit) if commit.message.is_breaking_change => { info!( - "Found {} commit {} with type: {}", + "\t Found {} commit {} with type: {}", "BREAKING CHANGE".red(), commit.shorthand().blue(), commit.message.commit_type.as_ref().yellow() ) } Ok(commit) if commit.message.commit_type == CommitType::BugFix => { - info!("Found bug fix commit {}", commit.shorthand().blue()) + info!("\tFound bug fix commit {}", commit.shorthand().blue()) } Ok(commit) if commit.message.commit_type == CommitType::Feature => { - info!("Found feature commit {}", commit.shorthand().blue()) + info!("\tFound feature commit {}", commit.shorthand().blue()) } _ => (), } @@ -191,239 +91,5 @@ impl VersionIncrement { // Auto version tests resides in test/ dir since it rely on git log // To generate the version mod test { - use std::str::FromStr; - - use crate::conventional::commit::Commit; - use crate::conventional::version::VersionIncrement; - - use crate::Repository; - use anyhow::Result; - use chrono::Utc; - use conventional_commit_parser::commit::{CommitType, ConventionalCommit}; - use pretty_assertions::assert_eq; - use sealed_test::prelude::*; - use semver::Version; - use speculoos::prelude::*; - - impl Commit { - fn commit_fixture(commit_type: CommitType, is_breaking_change: bool) -> Self { - Commit { - oid: "1234".to_string(), - message: ConventionalCommit { - commit_type, - scope: None, - body: None, - summary: "message".to_string(), - is_breaking_change, - footers: vec![], - }, - author: "".to_string(), - date: Utc::now().naive_local(), - } - } - } - - #[sealed_test] - fn major_bump() -> Result<()> { - // Arrange - let repository = Repository::init(".")?; - let base_version = Version::new(1, 0, 0); - - // Act - let version = VersionIncrement::Major.bump(&base_version, &repository)?; - - // Assert - assert_that!(version).is_equal_to(Version::new(2, 0, 0)); - Ok(()) - } - - #[sealed_test] - fn minor_bump() -> Result<()> { - // Arrange - let repository = Repository::init(".")?; - - // Act - let base_version = Version::new(1, 0, 0); - let version = VersionIncrement::Minor.bump(&base_version, &repository)?; - - // Assert - assert_that!(version).is_equal_to(Version::new(1, 1, 0)); - Ok(()) - } - - #[sealed_test] - fn patch_bump() -> Result<()> { - // Arrange - let repository = Repository::init(".")?; - let base_version = Version::new(1, 0, 0); - - // Act - let version = VersionIncrement::Patch.bump(&base_version, &repository)?; - - // Assert - assert_that!(version).is_equal_to(Version::new(1, 0, 1)); - Ok(()) - } - - #[test] - fn increment_minor_version_should_set_patch_to_zero() -> Result<()> { - // Arrange - let repository = Repository::init(".")?; - let version = Version::from_str("1.1.1")?; - - // Act - let bumped = VersionIncrement::Minor.bump(&version, &repository); - - // Assert - assert_that!(bumped) - .is_ok() - .is_equal_to(Version::from_str("1.2.0")?); - - Ok(()) - } - - #[sealed_test] - fn increment_major_version_should_set_minor_and_patch_to_zero() -> Result<()> { - // Arrange - let repository = Repository::init(".")?; - let version = Version::from_str("1.1.1")?; - - // Act - let bumped = VersionIncrement::Major.bump(&version, &repository); - - // Assert - assert_that!(bumped) - .is_ok() - .is_equal_to(Version::from_str("2.0.0")?); - - Ok(()) - } - - #[sealed_test] - fn increment_should_strip_metadata() -> Result<()> { - // Arrange - let repository = Repository::init(".")?; - let version = Version::from_str("1.1.1-pre+10.1")?; - - // Act - let bumped = VersionIncrement::Patch.bump(&version, &repository); - - // Assert - assert_that!(bumped) - .is_ok() - .is_equal_to(Version::from_str("1.1.2")?); - - Ok(()) - } - - #[test] - fn should_get_next_auto_version_patch() -> Result<()> { - // Arrange - let patch = Commit::commit_fixture(CommitType::BugFix, false); - - // Act - let version = VersionIncrement::version_increment_from_commit_history( - &Version::parse("1.0.0")?, - &[patch], - ); - - // Assert - assert_that!(version) - .is_ok() - .is_equal_to(VersionIncrement::Patch); - - Ok(()) - } - - #[test] - fn should_get_next_auto_version_breaking_changes() -> Result<()> { - // Arrange - let feature = Commit::commit_fixture(CommitType::Feature, false); - let breaking_change = Commit::commit_fixture(CommitType::Feature, true); - - // Act - let version = VersionIncrement::version_increment_from_commit_history( - &Version::parse("1.0.0")?, - &[breaking_change, feature], - ); - - // Assert - assert_that!(version) - .is_ok() - .is_equal_to(VersionIncrement::Major); - - Ok(()) - } - - #[test] - fn should_get_next_auto_version_breaking_changes_on_initial_dev_version() -> Result<()> { - // Arrange - let feature = Commit::commit_fixture(CommitType::Feature, false); - let breaking_change = Commit::commit_fixture(CommitType::Feature, true); - - // Act - let version = VersionIncrement::version_increment_from_commit_history( - &Version::parse("0.1.0")?, - &[breaking_change, feature], - ); - - // Assert - assert_that!(version) - .is_ok() - .is_equal_to(VersionIncrement::Minor); - - Ok(()) - } - - #[test] - fn should_get_next_auto_version_minor() -> Result<()> { - // Arrange - let patch = Commit::commit_fixture(CommitType::BugFix, false); - let feature = Commit::commit_fixture(CommitType::Feature, false); - - // Act - let version = VersionIncrement::version_increment_from_commit_history( - &Version::parse("1.0.0")?, - &[patch, feature], - ); - - // Assert - assert_that!(version) - .is_ok() - .is_equal_to(VersionIncrement::Minor); - - Ok(()) - } - - #[test] - fn should_fail_without_feature_bug_fix_or_breaking_change_commit() -> Result<()> { - // Arrange - let patch = Commit::commit_fixture(CommitType::Chore, false); - let feature = Commit::commit_fixture(CommitType::Documentation, false); - - // Act - let version = VersionIncrement::version_increment_from_commit_history( - &Version::parse("1.0.0")?, - &[patch, feature], - ); - - let result = version.unwrap_err().to_string(); - let result = result.as_str(); - - // Assert - assert_eq!( - result, - r#"failed to bump version - -cause: No conventional commit found to bump current version. - Only feature, bug fix and breaking change commits will trigger an automatic bump. - -suggestion: Please see https://conventionalcommits.org/en/v1.0.0/#summary for more information. - Alternatively consider using `cog bump <--version |--auto|--major|--minor>` - -"# - ); - - Ok(()) - } + // TODO } diff --git a/src/git/monorepo.rs b/src/git/monorepo.rs index 844c3358..fa40b7ab 100644 --- a/src/git/monorepo.rs +++ b/src/git/monorepo.rs @@ -60,7 +60,6 @@ impl Repository { let tags: Vec = self.all_tags()?; tags.into_iter() - .filter(|tag| tag.prefix.is_some()) .filter(|tag| { tag.package .as_ref() diff --git a/src/git/revspec.rs b/src/git/revspec.rs index 33bd8abc..afab5097 100644 --- a/src/git/revspec.rs +++ b/src/git/revspec.rs @@ -671,7 +671,7 @@ mod test { .map(|commit| commit.commit.oid.to_string()) .collect(); - assert_that!(head_to_v1).is_equal_to(vec![three.clone()]); + assert_that!(head_to_v1).is_equal_to(vec![three]); assert_that!(commit_before_v1).is_equal_to(vec![two, one, from]); Ok(()) diff --git a/src/git/tag.rs b/src/git/tag.rs index 75442b7e..701e5059 100644 --- a/src/git/tag.rs +++ b/src/git/tag.rs @@ -1,7 +1,6 @@ use crate::git::error::{Git2Error, TagError}; use crate::git::repository::Repository; use crate::SETTINGS; -use git2::string_array::StringArray; use git2::Oid; use semver::Version; use std::cmp::Ordering; @@ -48,7 +47,6 @@ impl Repository { Ok(self .tags()? .iter() - .flatten() .map(|tag| self.resolve_lightweight_tag(tag)) .filter_map(Result::ok) .collect()) @@ -59,19 +57,47 @@ impl Repository { .map(|tag| tag.oid_unchecked().to_owned()) } - fn tags(&self) -> Result { - let pattern = SETTINGS - .tag_prefix - .as_ref() - .map(|prefix| format!("{}*", prefix)); - - self.0 - .tag_names(pattern.as_deref()) - .map_err(|err| TagError::NoMatchFound { pattern, err }) + fn tags(&self) -> Result, TagError> { + let packages: Vec<&str> = SETTINGS + .packages + .keys() + .map(|profile| -> &str { profile }) + .collect(); + + let tags = if packages.is_empty() { + let pattern = SETTINGS + .tag_prefix + .as_ref() + .map(|prefix| format!("{}*", prefix)); + + self.0 + .tag_names(pattern.as_deref()) + .map_err(|err| TagError::NoMatchFound { pattern, err })? + .iter() + .flatten() + .map(str::to_string) + .collect() + } else { + let separator = SETTINGS + .monorepo_separator() + .expect("monorepo_version_separator"); + let tags = self.0.tag_names(None).map_err(|_| TagError::NoTag)?; + + tags.into_iter() + .flatten() + .filter(|tag| match tag.split_once(separator) { + None => false, + Some((tag_package, _)) => packages.contains(&tag_package), + }) + .map(str::to_string) + .collect() + }; + + Ok(tags) } } -#[derive(Debug, PartialEq, Eq, Clone)] +#[derive(Debug, Eq, Clone)] pub struct Tag { pub package: Option, pub prefix: Option, @@ -79,6 +105,40 @@ pub struct Tag { pub oid: Option, } +impl Ord for Tag { + fn cmp(&self, other: &Self) -> Ordering { + self.partial_cmp(other).unwrap_or(Ordering::Less) + } +} + +impl PartialEq for Tag { + fn eq(&self, other: &Self) -> bool { + self.package == other.package + && self.version == other.version + && self.prefix == other.prefix + } +} + +impl PartialOrd for Tag { + fn partial_cmp(&self, other: &Self) -> Option { + if self.package != other.package { + return None; + } + + if self.prefix != other.prefix { + return None; + } + + self.version.partial_cmp(&other.version) + } +} + +impl Default for Tag { + fn default() -> Self { + Tag::create(Version::new(0, 0, 0), None) + } +} + impl Tag { // Tag always contains an oid unless it was created before the tag exist. // The only case where we do that is while creating the changelog during `cog bump`. @@ -96,6 +156,7 @@ impl Tag { oid: None, } } + pub(crate) fn oid(&self) -> Option<&Oid> { self.oid.as_ref() } @@ -104,7 +165,7 @@ impl Tag { let prefix = SETTINGS.tag_prefix.as_ref(); let tag = SETTINGS - .monorepo_version_separator + .monorepo_separator() .as_ref() .and_then(|separator| raw.split_once(separator)) .map(|(package, remains)| { @@ -146,17 +207,26 @@ impl Tag { }) } } + + pub(crate) fn is_zero(&self) -> bool { + self.version == Version::new(0, 0, 0) + } } impl fmt::Display for Tag { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { let version = self.version.to_string(); - if let Some((prefix, package)) = self.package.as_ref().zip(self.prefix.as_ref()) { - let separator = &SETTINGS.monorepo_version_separator.as_ref().unwrap_or_else(|| + if let Some((package, prefix)) = self.package.as_ref().zip(self.prefix.as_ref()) { + let separator = SETTINGS.monorepo_separator().unwrap_or_else(|| panic!("Found a tag with monorepo package prefix but 'monorepo_version_separator' is not defined") ); - write!(f, "{package}{separator}{prefix}{version}") + } else if let Some(package) = self.package.as_ref() { + let separator = SETTINGS.monorepo_separator().unwrap_or_else(|| + panic!("Found a tag with monorepo package prefix but 'monorepo_version_separator' is not defined") + ); + + write!(f, "{package}{separator}{version}") } else if let Some(prefix) = self.prefix.as_ref() { write!(f, "{prefix}{version}") } else { @@ -165,25 +235,136 @@ impl fmt::Display for Tag { } } -impl Ord for Tag { - fn cmp(&self, other: &Self) -> Ordering { - self.partial_cmp(other).unwrap() - } -} - -impl PartialOrd for Tag { - fn partial_cmp(&self, other: &Tag) -> Option { - Some(self.version.cmp(&other.version)) - } -} - #[cfg(test)] mod test { use crate::git::repository::Repository; + use crate::git::tag::Tag; + use crate::settings::Settings; use anyhow::Result; use cmd_lib::run_cmd; use sealed_test::prelude::*; + use semver::Version; use speculoos::prelude::*; + use std::collections::HashMap; + use std::fs; + + #[test] + fn should_get_tag_from_str() -> Result<()> { + let tag = Tag::from_str("1.0.0", None); + assert_that!(tag).is_ok().is_equal_to(Tag { + package: None, + prefix: None, + version: Version::new(1, 0, 0), + oid: None, + }); + + Ok(()) + } + + #[sealed_test] + fn should_get_tag_from_str_with_prefix() -> Result<()> { + Repository::init(".")?; + + let settings = Settings { + tag_prefix: Some("v".to_string()), + ..Default::default() + }; + + let settings = toml::to_string(&settings)?; + fs::write("cog.toml", settings)?; + + let tag = Tag::from_str("v1.0.0", None); + + assert_that!(tag).is_ok().is_equal_to(Tag { + package: None, + prefix: Some("v".to_string()), + version: Version::new(1, 0, 0), + oid: None, + }); + + Ok(()) + } + + #[sealed_test] + fn should_get_tag_from_str_with_separator() -> Result<()> { + Repository::init(".")?; + + let mut packages = HashMap::new(); + packages.insert("one".to_string(), Default::default()); + let settings = Settings { + packages, + ..Default::default() + }; + + let settings = toml::to_string(&settings)?; + fs::write("cog.toml", settings)?; + + let tag = Tag::from_str("one-1.0.0", None); + + assert_that!(tag).is_ok().is_equal_to(Tag { + package: Some("one".to_string()), + prefix: None, + version: Version::new(1, 0, 0), + oid: None, + }); + + Ok(()) + } + + #[sealed_test] + fn should_get_tag_from_str_with_prefix_and_separator() -> Result<()> { + Repository::init(".")?; + + let mut packages = HashMap::new(); + packages.insert("one".to_string(), Default::default()); + let settings = Settings { + tag_prefix: Some("v".to_string()), + packages, + ..Default::default() + }; + + let settings = toml::to_string(&settings)?; + fs::write("cog.toml", settings)?; + + let tag = Tag::from_str("one-v1.0.0", None); + + assert_that!(tag).is_ok().is_equal_to(Tag { + package: Some("one".to_string()), + prefix: Some("v".to_string()), + version: Version::new(1, 0, 0), + oid: None, + }); + + Ok(()) + } + + #[sealed_test] + fn should_get_tag_from_str_with_prefix_and_custom_separator() -> Result<()> { + Repository::init(".")?; + + let mut packages = HashMap::new(); + packages.insert("one".to_string(), Default::default()); + let settings = Settings { + tag_prefix: Some("v".to_string()), + monorepo_version_separator: Some("#".to_string()), + packages, + ..Default::default() + }; + + let settings = toml::to_string(&settings)?; + fs::write("cog.toml", settings)?; + + let tag = Tag::from_str("one#v1.0.0", None); + + assert_that!(tag).is_ok().is_equal_to(Tag { + package: Some("one".to_string()), + prefix: Some("v".to_string()), + version: Version::new(1, 0, 0), + oid: None, + }); + + Ok(()) + } #[sealed_test] fn resolve_lightweight_tag_ok() -> Result<()> { diff --git a/src/lib.rs b/src/lib.rs index b2a480d5..4191e7cf 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -14,7 +14,7 @@ use git2::{Oid, RebaseOptions}; use globset::Glob; use itertools::Itertools; use lazy_static::lazy_static; -use semver::{Prerelease, Version}; +use semver::Prerelease; use tempfile::TempDir; use crate::log::filter::CommitFilters; @@ -199,13 +199,15 @@ impl CocoGitto { .0 .rebase(None, Some(&commit), None, Some(&mut options))?; + let editor = &editor; + while let Some(op) = rebase.next() { if let Ok(rebase_operation) = op { let oid = rebase_operation.id(); let original_commit = self.repository.0.find_commit(oid)?; if errored_commits.contains(&oid) { warn!("Found errored commits:{}", &oid.to_string()[0..7]); - let file_path = dir.path().join(&commit.id().to_string()); + let file_path = dir.path().join(commit.id().to_string()); let mut file = File::create(&file_path)?; let hint = format!( @@ -219,7 +221,7 @@ impl CocoGitto { message_bytes.extend_from_slice(original_commit.message_bytes()); file.write_all(&message_bytes)?; - Command::new(&editor) + Command::new(editor) .arg(&file_path) .stdout(Stdio::inherit()) .stdin(Stdio::inherit()) @@ -407,6 +409,25 @@ impl CocoGitto { Ok(()) } + /// ## Get a changelog between two oids + /// - `from` default value:latest tag or else first commit + /// - `to` default value:`HEAD` or else first commit + pub fn get_changelog( + &self, + pattern: RevspecPattern, + with_child_releases: bool, + ) -> Result { + if with_child_releases { + self.repository + .get_release_range(pattern) + .map_err(Into::into) + } else { + let commit_range = self.repository.get_commit_range(&pattern)?; + + Ok(Release::from(commit_range)) + } + } + pub fn create_version( &mut self, increment: VersionIncrement, @@ -417,49 +438,31 @@ impl CocoGitto { self.pre_bump_checks()?; let current_tag = self.repository.get_latest_tag(); - let current_version = match current_tag { - Ok(ref tag) => tag.version.clone(), + let current_tag = match current_tag { + Ok(ref tag) => tag.to_owned(), Err(ref err) if err == &TagError::NoTag => { warn!("Failed to get current version, falling back to 0.0.0"); - Version::new(0, 0, 0) + Tag::default() } Err(ref err) => bail!("{}", err), }; - let mut next_version = increment.bump(¤t_version, &self.repository)?; + let mut tag = current_tag.bump(increment, &self.repository)?; - if next_version.le(¤t_version) || next_version.eq(¤t_version) { - let comparison = format!("{} <= {}", current_version, next_version).red(); - let cause_key = "cause:".red(); - let cause = format!( - "{} version MUST be greater than current one: {}", - cause_key, comparison - ); - - bail!("{}:\n\t{}\n", "SemVer Error".red().to_string(), cause); - }; + ensure_tag_is_greater_than_previous(¤t_tag, &tag)?; if let Some(pre_release) = pre_release { - next_version.pre = Prerelease::new(pre_release)?; + tag.version.pre = Prerelease::new(pre_release)?; } - let tag = Tag::create(next_version, None); + let tag = Tag::create(tag.version, None); if dry_run { print!("{}", tag); return Ok(()); } - let origin = if current_version == Version::new(0, 0, 0) { - self.repository.get_first_commit()?.to_string() - } else { - current_tag?.oid_unchecked().to_string() - }; - - let target = self.repository.get_head_commit_oid()?.to_string(); - let pattern = (origin.as_str(), target.as_str()); - - let pattern = RevspecPattern::from(pattern); + let pattern = self.get_revspec_for_tag(¤t_tag)?; let changelog = self.get_changelog_with_target_version(pattern, tag.clone())?; let path = settings::changelog_path(); @@ -486,7 +489,6 @@ impl CocoGitto { self.stash_failed_version(&tag, err)?; } - let version_str = Self::prefix_version(version_str); let sign = self.repository.gpg_sign(); self.repository.commit( @@ -523,60 +525,29 @@ impl CocoGitto { let mut package_bumps = vec![]; for (package_name, package) in &SETTINGS.packages { - let current_tag = self.repository.get_latest_package_tag(package_name); - let current_version = match current_tag { - Ok(ref tag) => tag.version.clone(), - Err(ref err) if err == &TagError::NoTag => { - warn!("Failed to get current version, falling back to 0.0.0"); - Version::new(0, 0, 0) - } - Err(ref err) => bail!("{}", err), - }; - - let mut next_version = - VersionIncrement::Auto.bump(¤t_version, &self.repository)?; - - if next_version.le(¤t_version) || next_version.eq(¤t_version) { - let comparison = format!("{} <= {}", current_version, next_version).red(); - let cause_key = "cause:".red(); - let cause = format!( - "{} version MUST be greater than current one: {}", - cause_key, comparison - ); - - bail!("{}:\n\t{}\n", "SemVer Error".red().to_string(), cause); - }; + let old = self.repository.get_latest_package_tag(package_name); + let old = tag_or_fallback_to_zero(old)?; + info!("Package {}, current version {old} ", package_name.bold()); + let mut next_version = old.bump(VersionIncrement::Auto, &self.repository)?; + ensure_tag_is_greater_than_previous(&old, &next_version)?; if let Some(pre_release) = pre_release { - next_version.pre = Prerelease::new(pre_release)?; + next_version.version.pre = Prerelease::new(pre_release)?; } - let tag = Tag::create(next_version, Some(package_name.to_string())); + let tag = Tag::create(next_version.version, Some(package_name.to_string())); if dry_run { print!("{}", tag); continue; } - let origin = if current_version == Version::new(0, 0, 0) { - self.repository.get_first_commit()?.to_string() - } else { - current_tag?.oid_unchecked().to_string() - }; - - let target = self.repository.get_head_commit_oid()?.to_string(); - let pattern = (origin.as_str(), target.as_str()); - - let pattern = RevspecPattern::from(pattern); - let changelog = - self.get_changelog_with_target_package_version(pattern, tag.clone(), package)?; - - if changelog.is_none() { - println!("No commit found to bump package {package_name}, skipping."); + let pattern = self.get_revspec_for_tag(&old)?; + let Some(changelog) = self.get_changelog_with_target_package_version(pattern, tag.clone(), package)? else { + println!("\t No commit found to bump package, skipping."); continue; - } + }; - let changelog = changelog.unwrap(); let path = package.changelog_path(); let template = SETTINGS.get_changelog_template()?; changelog.write_to_file(path, template)?; @@ -643,57 +614,25 @@ impl CocoGitto { self.pre_bump_checks()?; let current_tag = self.repository.get_latest_package_tag(package_name); - let current_version = match current_tag { - Ok(ref tag) => tag.version.clone(), - Err(ref err) if err == &TagError::NoTag => { - warn!("Failed to get current version, falling back to 0.0.0"); - Version::new(0, 0, 0) - } - Err(ref err) => bail!("{}", err), - }; - - let mut next_version = increment.bump(¤t_version, &self.repository)?; - - if next_version.le(¤t_version) || next_version.eq(¤t_version) { - let comparison = format!("{} <= {}", current_version, &next_version).red(); - let cause_key = "cause:".red(); - let cause = format!( - "{} version MUST be greater than current one: {}", - cause_key, comparison - ); - - bail!("{}:\n\t{}\n", "SemVer Error".red().to_string(), cause); - }; - + let current_tag = tag_or_fallback_to_zero(current_tag)?; + let mut next_version = current_tag.bump(increment, &self.repository)?; + ensure_tag_is_greater_than_previous(¤t_tag, &next_version)?; if let Some(pre_release) = pre_release { - next_version.pre = Prerelease::new(pre_release)?; + next_version.version.pre = Prerelease::new(pre_release)?; } - let tag = Tag::create(next_version.clone(), Some(package_name.to_string())); + let tag = Tag::create(next_version.version.clone(), Some(package_name.to_string())); if dry_run { print!("{}", tag); return Ok(()); } - let origin = if current_version == Version::new(0, 0, 0) { - self.repository.get_first_commit()?.to_string() - } else { - current_tag?.oid_unchecked().to_string() - }; - - let target = self.repository.get_head_commit_oid()?.to_string(); - let pattern = (origin.as_str(), target.as_str()); - - let pattern = RevspecPattern::from(pattern); - let changelog = - self.get_changelog_with_target_package_version(pattern, tag.clone(), package)?; - - if changelog.is_none() { + let pattern = self.get_revspec_for_tag(¤t_tag)?; + let Some(changelog) = self.get_changelog_with_target_package_version(pattern, tag.clone(), package)? else { bail!("No commit matching package {package_name} path"); - } + }; - let changelog = changelog.unwrap(); let path = package.changelog_path(); let template = SETTINGS.get_changelog_template()?; changelog.write_to_file(path, template)?; @@ -704,8 +643,10 @@ impl CocoGitto { .map(HookVersion::new) .ok(); - let next_version = - HookVersion::new(Tag::create(next_version, Some(package_name.to_string()))); + let next_version = HookVersion::new(Tag::create( + next_version.version, + Some(package_name.to_string()), + )); let hook_result = self.run_hooks( HookType::PreBump, @@ -823,7 +764,7 @@ impl CocoGitto { /// Used for cog bump. the target version /// is not created yet when generating the changelog. - pub fn get_changelog_with_target_package_version( + fn get_changelog_with_target_package_version( &self, pattern: RevspecPattern, target_tag: Tag, @@ -841,25 +782,6 @@ impl CocoGitto { Ok(release) } - /// ## Get a changelog between two oids - /// - `from` default value:latest tag or else first commit - /// - `to` default value:`HEAD` or else first commit - pub fn get_changelog( - &self, - pattern: RevspecPattern, - with_child_releases: bool, - ) -> Result { - if with_child_releases { - self.repository - .get_release_range(pattern) - .map_err(Into::into) - } else { - let commit_range = self.repository.get_commit_range(&pattern)?; - - Ok(Release::from(commit_range)) - } - } - fn run_hooks( &self, hook_type: HookType, @@ -922,4 +844,39 @@ impl CocoGitto { Ok(()) } + + fn get_revspec_for_tag(&mut self, tag: &Tag) -> Result { + let origin = if tag.is_zero() { + self.repository.get_first_commit()?.to_string() + } else { + tag.oid_unchecked().to_string() + }; + + let target = self.repository.get_head_commit_oid()?.to_string(); + let pattern = (origin.as_str(), target.as_str()); + Ok(RevspecPattern::from(pattern)) + } +} + +fn ensure_tag_is_greater_than_previous(current: &Tag, next: &Tag) -> Result<()> { + if next <= current { + let comparison = format!("{} <= {}", current, next).red(); + let cause_key = "cause:".red(); + let cause = format!( + "{} version MUST be greater than current one: {}", + cause_key, comparison + ); + + bail!("{}:\n\t{}\n", "SemVer Error".red().to_string(), cause); + }; + + Ok(()) +} + +fn tag_or_fallback_to_zero(tag: Result) -> Result { + match tag { + Ok(ref tag) => Ok(tag.clone()), + Err(ref err) if err == &TagError::NoTag => Ok(Tag::default()), + Err(err) => Err(anyhow!(err)), + } } diff --git a/src/settings/mod.rs b/src/settings/mod.rs index 30d9f959..29ed6cc2 100644 --- a/src/settings/mod.rs +++ b/src/settings/mod.rs @@ -196,6 +196,14 @@ impl Settings { Template::from_arg(template, context) } + + pub fn monorepo_separator(&self) -> Option<&str> { + if self.packages.is_empty() { + None + } else { + self.monorepo_version_separator.as_deref().or(Some("-")) + } + } } impl Hooks for Settings { diff --git a/tests/cog_tests/changelog.rs b/tests/cog_tests/changelog.rs index f9580f40..ea00ce3a 100644 --- a/tests/cog_tests/changelog.rs +++ b/tests/cog_tests/changelog.rs @@ -371,9 +371,7 @@ fn get_changelog_whith_custom_template() -> Result<()> { run_cmd!(echo $cog_toml > cog.toml;)?; - let string = fs::read_to_string("cog.toml")?; - println!("{}", string); - + let _string = fs::read_to_string("cog.toml")?; let init_commit = git_commit("chore: init")?; let commit_one = git_commit("feat(scope1): start")?; let commit_two = git_commit("feat: feature 1")?; diff --git a/tests/helpers.rs b/tests/helpers.rs index 06c9fdd0..d9e1c0a0 100644 --- a/tests/helpers.rs +++ b/tests/helpers.rs @@ -77,14 +77,14 @@ pub fn git_tag(tag: &str) -> Result<()> { pub fn assert_tag_exists(tag: &str) -> Result<()> { let tags = run_fun!(git --no-pager tag)?; let tags: Vec<&str> = tags.split('\n').collect(); - assert_that!(tags).contains(&tag); + assert_that!(tags).contains(tag); Ok(()) } pub fn assert_tag_does_not_exist(tag: &str) -> Result<()> { let tags = run_fun!(git --no-pager tag)?; let tags: Vec<&str> = tags.split('\n').collect(); - assert_that!(tags).does_not_contain(&tag); + assert_that!(tags).does_not_contain(tag); Ok(()) } From fb0548ff8fec752e73591926c765ed4dafa3296d Mon Sep 17 00:00:00 2001 From: Paul Delafosse Date: Mon, 16 Jan 2023 14:42:00 +0100 Subject: [PATCH 06/26] refactor: split lib into several command packages --- src/bin/cog/main.rs | 2 +- src/command/bump/mod.rs | 210 +++++++++ src/command/bump/monorepo.rs | 191 +++++++++ src/command/bump/package.rs | 103 +++++ src/command/bump/standard.rs | 91 ++++ src/command/changelog.rs | 37 ++ src/command/check.rs | 48 +++ src/command/commit.rs | 53 +++ src/command/edit.rs | 126 ++++++ src/command/init.rs | 64 +++ src/command/log.rs | 47 +++ src/command/mod.rs | 7 + src/error.rs | 2 +- src/git/monorepo.rs | 38 +- src/hook/mod.rs | 3 +- src/lib.rs | 794 +---------------------------------- tests/lib_tests/init.rs | 4 +- 17 files changed, 1005 insertions(+), 815 deletions(-) create mode 100644 src/command/bump/mod.rs create mode 100644 src/command/bump/monorepo.rs create mode 100644 src/command/bump/package.rs create mode 100644 src/command/bump/standard.rs create mode 100644 src/command/changelog.rs create mode 100644 src/command/check.rs create mode 100644 src/command/commit.rs create mode 100644 src/command/edit.rs create mode 100644 src/command/init.rs create mode 100644 src/command/log.rs create mode 100644 src/command/mod.rs diff --git a/src/bin/cog/main.rs b/src/bin/cog/main.rs index fabf05e5..4c3921f7 100644 --- a/src/bin/cog/main.rs +++ b/src/bin/cog/main.rs @@ -461,7 +461,7 @@ fn main() -> Result<()> { println!("{}", result); } Command::Init { path } => { - cocogitto::init(&path)?; + cocogitto::command::init::init(&path)?; } Command::InstallHook { hook_type } => { let cocogitto = CocoGitto::get()?; diff --git a/src/command/bump/mod.rs b/src/command/bump/mod.rs new file mode 100644 index 00000000..f6ce0253 --- /dev/null +++ b/src/command/bump/mod.rs @@ -0,0 +1,210 @@ +use crate::conventional::changelog::release::Release; +use crate::git::error::TagError; +use crate::git::hook::Hooks; +use crate::git::oid::OidOf; +use crate::git::revspec::RevspecPattern; +use crate::git::tag::Tag; +use crate::hook::{Hook, HookVersion}; +use crate::settings::{HookType, MonoRepoPackage, Settings}; +use crate::PreHookError; +use crate::{CocoGitto, SETTINGS}; +use anyhow::Result; +use anyhow::{anyhow, bail, ensure, Context, Error}; +use colored::Colorize; +use globset::Glob; +use itertools::Itertools; +use log::{error, warn}; +use std::process::exit; + +mod monorepo; +mod package; +mod standard; + +fn ensure_tag_is_greater_than_previous(current: &Tag, next: &Tag) -> Result<()> { + if next <= current { + let comparison = format!("{} <= {}", current, next).red(); + let cause_key = "cause:".red(); + let cause = format!( + "{} version MUST be greater than current one: {}", + cause_key, comparison + ); + + bail!("{}:\n\t{}\n", "SemVer Error".red().to_string(), cause); + }; + + Ok(()) +} + +fn tag_or_fallback_to_zero(tag: Result) -> Result { + match tag { + Ok(ref tag) => Ok(tag.clone()), + Err(ref err) if err == &TagError::NoTag => Ok(Tag::default()), + Err(err) => Err(anyhow!(err)), + } +} + +impl CocoGitto { + /// Used for cog bump. Get all commits that modify at least one path belonging to the given + /// package. Target version is not created yet when generating the changelog. + fn get_changelog_with_target_package_version( + &self, + pattern: RevspecPattern, + starting_tag: Option, + target_tag: Tag, + package: &MonoRepoPackage, + ) -> Result> { + let mut release = self + .repository + .get_commit_range_filtered(starting_tag, &pattern, |path| { + path.starts_with(&package.path) + })? + .map(Release::from); + + if let Some(release) = &mut release { + release.version = OidOf::Tag(target_tag); + } + + Ok(release) + } + + fn stash_failed_version(&mut self, tag: &Tag, err: Error) -> Result<()> { + self.repository.stash_failed_version(tag.clone())?; + error!( + "{}", + PreHookError { + cause: err.to_string(), + version: tag.to_string(), + stash_number: 0, + } + ); + + exit(1); + } + + fn pre_bump_checks(&mut self) -> Result<()> { + if *SETTINGS == Settings::default() { + let part1 = "Warning: using".yellow(); + let part2 = "with the default configuration. \n".yellow(); + let part3 = "You may want to create a".yellow(); + let part4 = "file in your project root to configure bumps.\n".yellow(); + warn!( + "{} 'cog bump' {}{} 'cog.toml' {}", + part1, part2, part3, part4 + ); + } + let statuses = self.repository.get_statuses()?; + + // Fail if repo contains un-staged or un-committed changes + ensure!(statuses.0.is_empty(), "{}", self.repository.get_statuses()?); + + if !SETTINGS.branch_whitelist.is_empty() { + if let Some(branch) = self.repository.get_branch_shorthand() { + let whitelist = &SETTINGS.branch_whitelist; + let is_match = whitelist.iter().any(|pattern| { + let glob = Glob::new(pattern) + .expect("invalid glob pattern") + .compile_matcher(); + glob.is_match(&branch) + }); + + ensure!( + is_match, + "No patterns matched in {:?} for branch '{}', bump is not allowed", + whitelist, + branch + ) + } + }; + + Ok(()) + } + + /// Used for cog bump. the target version + /// is not created yet when generating the changelog. + pub fn get_changelog_with_target_version( + &self, + pattern: RevspecPattern, + tag: Tag, + ) -> Result { + let commit_range = self.repository.get_commit_range(&pattern)?; + + let mut release = Release::from(commit_range); + release.version = OidOf::Tag(tag); + Ok(release) + } + + fn run_hooks( + &self, + hook_type: HookType, + current_tag: Option<&HookVersion>, + next_version: &HookVersion, + hook_profile: Option<&str>, + package: Option<&MonoRepoPackage>, + ) -> Result<()> { + let settings = Settings::get(&self.repository)?; + + let hooks: Vec = match (package, hook_profile) { + (None, Some(profile)) => settings + .get_profile_hooks(profile, hook_type) + .iter() + .map(|s| s.parse()) + .enumerate() + .map(|(idx, result)| { + result.context(format!( + "Cannot parse bump profile {} hook at index {}", + profile, idx + )) + }) + .try_collect()?, + + (Some(package), Some(profile)) => { + let hooks = package.get_profile_hooks(profile, hook_type); + + hooks + .iter() + .map(|s| s.parse()) + .enumerate() + .map(|(idx, result)| { + result.context(format!( + "Cannot parse bump profile {} hook at index {}", + profile, idx + )) + }) + .try_collect()? + } + (Some(package), None) => package + .get_hooks(hook_type) + .iter() + .map(|s| s.parse()) + .enumerate() + .map(|(idx, result)| result.context(format!("Cannot parse hook at index {}", idx))) + .try_collect()?, + (None, None) => settings + .get_hooks(hook_type) + .iter() + .map(|s| s.parse()) + .enumerate() + .map(|(idx, result)| result.context(format!("Cannot parse hook at index {}", idx))) + .try_collect()?, + }; + + for mut hook in hooks { + hook.insert_versions(current_tag, next_version)?; + hook.run().context(hook.to_string())?; + } + + Ok(()) + } + + fn get_revspec_for_tag(&mut self, tag: &Tag) -> Result { + let origin = if tag.is_zero() { + self.repository.get_first_commit()?.to_string() + } else { + tag.oid_unchecked().to_string() + }; + + let target = self.repository.get_head_commit_oid()?.to_string(); + let pattern = (origin.as_str(), target.as_str()); + Ok(RevspecPattern::from(pattern)) + } +} diff --git a/src/command/bump/monorepo.rs b/src/command/bump/monorepo.rs new file mode 100644 index 00000000..b14f7eba --- /dev/null +++ b/src/command/bump/monorepo.rs @@ -0,0 +1,191 @@ +use crate::command::bump::{ensure_tag_is_greater_than_previous, tag_or_fallback_to_zero}; +use crate::conventional::changelog::release::Release; +use crate::conventional::version::VersionIncrement; +use crate::git::oid::OidOf; +use crate::git::revspec::RevspecPattern; +use crate::git::tag::Tag; +use crate::hook::HookVersion; +use crate::settings::HookType; +use crate::{CocoGitto, SETTINGS}; +use anyhow::{bail, Result}; +use colored::*; +use log::info; +use semver::Prerelease; +use std::path::{Path, PathBuf}; + +impl CocoGitto { + pub fn create_monorepo_version( + &mut self, + pre_release: Option<&str>, + hooks_config: Option<&str>, + dry_run: bool, + ) -> Result<()> { + self.pre_bump_checks()?; + let mut package_bumps = vec![]; + + for (package_name, package) in &SETTINGS.packages { + let old = self.repository.get_latest_package_tag(package_name); + let old = tag_or_fallback_to_zero(old)?; + info!("Package {}, current version {old} ", package_name.bold()); + let mut next_version = old.bump(VersionIncrement::Auto, &self.repository)?; + ensure_tag_is_greater_than_previous(&old, &next_version)?; + + if let Some(pre_release) = pre_release { + next_version.version.pre = Prerelease::new(pre_release)?; + } + + let tag = Tag::create(next_version.version, Some(package_name.to_string())); + + if dry_run { + print!("{}", tag); + continue; + } + + let pattern = self.get_revspec_for_tag(&old)?; + + let changelog_start = if old.is_zero() { + None + } else { + Some(OidOf::Tag(old)) + }; + + let Some(changelog) = self.get_changelog_with_target_package_version(pattern, changelog_start, tag.clone(), package)? else { + println!("\t No commit found to bump package, skipping."); + continue; + }; + + let path = package.changelog_path(); + let template = SETTINGS.get_changelog_template()?; + changelog.write_to_file(path, template)?; + + let current = self + .repository + .get_latest_package_tag(package_name) + .map(HookVersion::new) + .ok(); + + let next_version = HookVersion::new(tag.clone()); + + let hook_result = self.run_hooks( + HookType::PreBump, + current.as_ref(), + &next_version, + hooks_config, + Some(package), + ); + + self.repository.add_all()?; + + // Hook failed, we need to stop here and reset + // the repository to a clean state + if let Err(err) = hook_result { + self.stash_failed_version(&tag, err)?; + } + + package_bumps.push((package_name, package, current, next_version, tag)); + } + + if package_bumps.is_empty() { + bail!("Nothing to bump"); + } + + let mut meta_bump = None; + for (_, _, old, _, new) in &package_bumps { + if let Some(old) = old.as_ref() { + let old = &old.prefixed_tag.version; + let new = &new.version; + if old.major > new.major { + meta_bump = Some(VersionIncrement::Major) + } else if old.minor > new.minor { + if meta_bump != Some(VersionIncrement::Major) + || meta_bump != Some(VersionIncrement::Minor) + { + meta_bump = Some(VersionIncrement::Minor) + } + } else if meta_bump != Some(VersionIncrement::Major) + || meta_bump != Some(VersionIncrement::Minor) + || meta_bump != Some(VersionIncrement::Patch) + { + meta_bump = Some(VersionIncrement::Patch) + } + } + } + + // Generate a meta changelog, aggregating new changelog versions + // and commits that does not belong to any package + let current_tag = self.repository.get_latest_tag(); + let current_tag = tag_or_fallback_to_zero(current_tag)?; + let mut tag = current_tag.bump(meta_bump.unwrap(), &self.repository)?; + ensure_tag_is_greater_than_previous(¤t_tag, &tag)?; + if let Some(pre_release) = pre_release { + tag.version.pre = Prerelease::new(pre_release)?; + } + let tag = Tag::create(tag.version, None); + let pattern = self.get_revspec_for_tag(¤t_tag)?; + + let changelog_start = if current_tag.is_zero() { + None + } else { + Some(OidOf::Tag(current_tag.clone())) + }; + + let _changelog = self.get_meta_changelog(pattern, changelog_start, tag)?; + let _template = SETTINGS.get_changelog_template()?; + // changelog.write_to_file(path, template)?; + + self.repository.commit("chore(version): {tag}", false)?; + + for (package_name, package, current, next_version, tag) in package_bumps { + self.repository.create_tag(&tag)?; + + self.run_hooks( + HookType::PostBump, + current.as_ref(), + &next_version, + hooks_config, + Some(package), + )?; + + let current = current + .map(|current| current.prefixed_tag.to_string()) + .unwrap_or_else(|| "...".to_string()); + let bump = format!("{} -> {}", current, next_version.prefixed_tag).green(); + info!("Bumped package {package_name} version: {}", bump); + } + + Ok(()) + } + + /// Used for monorepo package bump. Get all commits that modify at least one path that does not + /// belong to a monorepo package. + /// Target version is not created yet when generating the changelog. + fn get_meta_changelog( + &self, + pattern: RevspecPattern, + starting_tag: Option, + target_tag: Tag, + ) -> Result> { + let package_paths: Vec<&PathBuf> = SETTINGS + .packages + .values() + .map(|package| &package.path) + .collect(); + + let filter = |path: &Path| { + package_paths + .iter() + .any(|package_path| !path.starts_with(package_path)) + }; + + let mut release = self + .repository + .get_commit_range_filtered(starting_tag, &pattern, filter)? + .map(Release::from); + + if let Some(release) = &mut release { + release.version = OidOf::Tag(target_tag); + } + + Ok(release) + } +} diff --git a/src/command/bump/package.rs b/src/command/bump/package.rs new file mode 100644 index 00000000..5d0a43b5 --- /dev/null +++ b/src/command/bump/package.rs @@ -0,0 +1,103 @@ +use crate::command::bump::{ensure_tag_is_greater_than_previous, tag_or_fallback_to_zero}; +use crate::conventional::version::VersionIncrement; +use crate::git::oid::OidOf; +use crate::git::tag::Tag; +use crate::hook::HookVersion; +use crate::settings::{HookType, MonoRepoPackage}; +use crate::{CocoGitto, SETTINGS}; +use anyhow::{bail, Result}; +use colored::*; +use log::info; +use semver::Prerelease; + +impl CocoGitto { + pub fn create_package_version( + &mut self, + (package_name, package): (&str, &MonoRepoPackage), + increment: VersionIncrement, + pre_release: Option<&str>, + hooks_config: Option<&str>, + dry_run: bool, + ) -> Result<()> { + self.pre_bump_checks()?; + + let current_tag = self.repository.get_latest_package_tag(package_name); + let current_tag = tag_or_fallback_to_zero(current_tag)?; + let mut next_version = current_tag.bump(increment, &self.repository)?; + ensure_tag_is_greater_than_previous(¤t_tag, &next_version)?; + if let Some(pre_release) = pre_release { + next_version.version.pre = Prerelease::new(pre_release)?; + } + + let tag = Tag::create(next_version.version.clone(), Some(package_name.to_string())); + + if dry_run { + print!("{}", tag); + return Ok(()); + } + + let pattern = self.get_revspec_for_tag(¤t_tag)?; + + let changelog_start = if current_tag.is_zero() { + None + } else { + Some(OidOf::Tag(current_tag.clone())) + }; + + let Some(changelog) = self.get_changelog_with_target_package_version(pattern, changelog_start, tag.clone(), package)? else { + bail!("No commit matching package {package_name} path"); + }; + + let path = package.changelog_path(); + let template = SETTINGS.get_changelog_template()?; + changelog.write_to_file(path, template)?; + + let current = self + .repository + .get_latest_package_tag(package_name) + .map(HookVersion::new) + .ok(); + + let next_version = HookVersion::new(Tag::create( + next_version.version, + Some(package_name.to_string()), + )); + + let hook_result = self.run_hooks( + HookType::PreBump, + current.as_ref(), + &next_version, + hooks_config, + Some(package), + ); + + self.repository.add_all()?; + + // Hook failed, we need to stop here and reset + // the repository to a clean state + if let Err(err) = hook_result { + self.stash_failed_version(&tag, err)?; + } + + self.repository + .commit(&format!("chore(version): {}", tag), false)?; + + self.repository.create_tag(&tag)?; + + self.run_hooks( + HookType::PostBump, + current.as_ref(), + &next_version, + hooks_config, + Some(package), + )?; + + let current = current + .map(|current| current.prefixed_tag.to_string()) + .unwrap_or_else(|| "...".to_string()); + let bump = format!("{} -> {}", current, next_version.prefixed_tag).green(); + info!("Bumped package {package_name} version: {}", bump); + + Ok(()) + } +} diff --git a/src/command/bump/standard.rs b/src/command/bump/standard.rs new file mode 100644 index 00000000..cb40262a --- /dev/null +++ b/src/command/bump/standard.rs @@ -0,0 +1,91 @@ +use crate::command::bump::{ensure_tag_is_greater_than_previous, tag_or_fallback_to_zero}; +use crate::conventional::version::VersionIncrement; +use crate::git::tag::Tag; +use crate::hook::HookVersion; +use crate::settings::HookType; +use crate::{settings, CocoGitto, SETTINGS}; +use anyhow::Result; +use colored::*; +use log::info; +use semver::Prerelease; + +impl CocoGitto { + pub fn create_version( + &mut self, + increment: VersionIncrement, + pre_release: Option<&str>, + hooks_config: Option<&str>, + dry_run: bool, + ) -> Result<()> { + self.pre_bump_checks()?; + + let current_tag = self.repository.get_latest_tag(); + let current_tag = tag_or_fallback_to_zero(current_tag)?; + let mut tag = current_tag.bump(increment, &self.repository)?; + + ensure_tag_is_greater_than_previous(¤t_tag, &tag)?; + + if let Some(pre_release) = pre_release { + tag.version.pre = Prerelease::new(pre_release)?; + } + + let tag = Tag::create(tag.version, None); + + if dry_run { + print!("{}", tag); + return Ok(()); + } + + let pattern = self.get_revspec_for_tag(¤t_tag)?; + let changelog = self.get_changelog_with_target_version(pattern, tag.clone())?; + + let path = settings::changelog_path(); + let template = SETTINGS.get_changelog_template()?; + changelog.write_to_file(path, template)?; + + let current = self.repository.get_latest_tag().map(HookVersion::new).ok(); + + let next_version = HookVersion::new(tag.clone()); + + let hook_result = self.run_hooks( + HookType::PreBump, + current.as_ref(), + &next_version, + hooks_config, + None, + ); + + self.repository.add_all()?; + + // Hook failed, we need to stop here and reset + // the repository to a clean state + if let Err(err) = hook_result { + self.stash_failed_version(&tag, err)?; + } + + let sign = self.repository.gpg_sign(); + + self.repository.commit( + &format!("chore(version): {}", next_version.prefixed_tag), + sign, + )?; + + self.repository.create_tag(&tag)?; + + self.run_hooks( + HookType::PostBump, + current.as_ref(), + &next_version, + hooks_config, + None, + )?; + + let current = current + .map(|current| current.prefixed_tag.to_string()) + .unwrap_or_else(|| "...".to_string()); + let bump = format!("{} -> {}", current, next_version.prefixed_tag).green(); + info!("Bumped version: {}", bump); + + Ok(()) + } +} diff --git a/src/command/changelog.rs b/src/command/changelog.rs new file mode 100644 index 00000000..d9fb05b4 --- /dev/null +++ b/src/command/changelog.rs @@ -0,0 +1,37 @@ +use crate::conventional::changelog::release::Release; +use crate::conventional::changelog::template::Template; +use crate::git::revspec::RevspecPattern; +use crate::CocoGitto; +use anyhow::anyhow; +use anyhow::Result; + +impl CocoGitto { + /// ## Get a changelog between two oids + /// - `from` default value:latest tag or else first commit + /// - `to` default value:`HEAD` or else first commit + pub fn get_changelog( + &self, + pattern: RevspecPattern, + with_child_releases: bool, + ) -> Result { + if with_child_releases { + self.repository + .get_release_range(pattern) + .map_err(Into::into) + } else { + let commit_range = self.repository.get_commit_range(&pattern)?; + + Ok(Release::from(commit_range)) + } + } + + pub fn get_changelog_at_tag(&self, tag: &str, template: Template) -> Result { + let pattern = format!("..{}", tag); + let pattern = RevspecPattern::from(pattern.as_str()); + let changelog = self.get_changelog(pattern, false)?; + + changelog + .into_markdown(template) + .map_err(|err| anyhow!(err)) + } +} diff --git a/src/command/check.rs b/src/command/check.rs new file mode 100644 index 00000000..c60e2a8b --- /dev/null +++ b/src/command/check.rs @@ -0,0 +1,48 @@ +use crate::conventional::commit::Commit; +use crate::error::CogCheckReport; +use crate::git::revspec::RevspecPattern; +use crate::CocoGitto; +use anyhow::anyhow; +use anyhow::Result; +use colored::*; +use log::info; + +impl CocoGitto { + pub fn check(&self, check_from_latest_tag: bool, ignore_merge_commits: bool) -> Result<()> { + let commit_range = if check_from_latest_tag { + self.repository + .get_commit_range(&RevspecPattern::default())? + } else { + self.repository.all_commits()? + }; + + let errors: Vec<_> = if ignore_merge_commits { + commit_range + .commits + .iter() + .filter(|commit| !commit.message().unwrap_or("").starts_with("Merge ")) + .map(Commit::from_git_commit) + .filter_map(Result::err) + .collect() + } else { + commit_range + .commits + .iter() + .map(Commit::from_git_commit) + .filter_map(Result::err) + .collect() + }; + + if errors.is_empty() { + let msg = "No errored commits".green(); + info!("{}", msg); + Ok(()) + } else { + let report = CogCheckReport { + from: commit_range.from, + errors: errors.into_iter().map(|err| *err).collect(), + }; + Err(anyhow!("{}", report)) + } + } +} diff --git a/src/command/commit.rs b/src/command/commit.rs new file mode 100644 index 00000000..59c6b9b3 --- /dev/null +++ b/src/command/commit.rs @@ -0,0 +1,53 @@ +use crate::conventional::commit::Commit; +use crate::CocoGitto; +use anyhow::Result; +use conventional_commit_parser::commit::{CommitType, ConventionalCommit}; +use conventional_commit_parser::parse_footers; +use log::info; + +impl CocoGitto { + #[allow(clippy::too_many_arguments)] + pub fn conventional_commit( + &self, + commit_type: &str, + scope: Option, + summary: String, + body: Option, + footer: Option, + is_breaking_change: bool, + sign: bool, + ) -> Result<()> { + // Ensure commit type is known + let commit_type = CommitType::from(commit_type); + + // Ensure footers are correctly formatted + let footers = match footer { + Some(footers) => parse_footers(&footers)?, + None => Vec::with_capacity(0), + }; + + let conventional_message = ConventionalCommit { + commit_type, + scope, + body, + footers, + summary, + is_breaking_change, + } + .to_string(); + + // Validate the message + conventional_commit_parser::parse(&conventional_message)?; + + // Git commit + let sign = sign || self.repository.gpg_sign(); + let oid = self.repository.commit(&conventional_message, sign)?; + + // Pretty print a conventional commit summary + let commit = self.repository.0.find_commit(oid)?; + let commit = Commit::from_git_commit(&commit)?; + info!("{}", commit); + + Ok(()) + } +} diff --git a/src/command/edit.rs b/src/command/edit.rs new file mode 100644 index 00000000..aff864e2 --- /dev/null +++ b/src/command/edit.rs @@ -0,0 +1,126 @@ +use crate::conventional::commit::{verify, Commit}; +use crate::git::revspec::RevspecPattern; +use crate::{CocoGitto, SETTINGS}; +use anyhow::{anyhow, Result}; +use colored::*; +use git2::{Oid, RebaseOptions}; +use log::{error, info, warn}; +use std::fs::File; +use std::io::Write; +use std::process::{Command, Stdio}; +use tempfile::TempDir; + +impl CocoGitto { + pub fn check_and_edit(&self, from_latest_tag: bool) -> Result<()> { + let commits = if from_latest_tag { + self.repository + .get_commit_range(&RevspecPattern::default())? + } else { + self.repository.all_commits()? + }; + + let editor = std::env::var("EDITOR") + .map_err(|_err| anyhow!("the 'EDITOR' environment variable was not found"))?; + + let dir = TempDir::new()?; + + let errored_commits: Vec = commits + .commits + .iter() + .map(|commit| { + let conv_commit = Commit::from_git_commit(commit); + (commit.id(), conv_commit) + }) + .filter(|commit| commit.1.is_err()) + .map(|commit| commit.0) + .collect(); + + // Get the last commit oid on the list as a starting point for our rebase + let last_errored_commit = errored_commits.last(); + if let Some(last_errored_commit) = last_errored_commit { + let commit = self + .repository + .0 + .find_commit(last_errored_commit.to_owned())?; + + let rebase_start = if commit.parent_count() == 0 { + commit.id() + } else { + commit.parent_id(0)? + }; + + let commit = self.repository.0.find_annotated_commit(rebase_start)?; + let mut options = RebaseOptions::new(); + + let mut rebase = + self.repository + .0 + .rebase(None, Some(&commit), None, Some(&mut options))?; + + let editor = &editor; + + while let Some(op) = rebase.next() { + if let Ok(rebase_operation) = op { + let oid = rebase_operation.id(); + let original_commit = self.repository.0.find_commit(oid)?; + if errored_commits.contains(&oid) { + warn!("Found errored commits:{}", &oid.to_string()[0..7]); + let file_path = dir.path().join(commit.id().to_string()); + let mut file = File::create(&file_path)?; + + let hint = format!( + "# Editing commit {}\ + \n# Replace this message with a conventional commit compliant one\ + \n# Save and exit to edit the next errored commit\n", + original_commit.id() + ); + + let mut message_bytes: Vec = hint.clone().into(); + message_bytes.extend_from_slice(original_commit.message_bytes()); + file.write_all(&message_bytes)?; + + Command::new(editor) + .arg(&file_path) + .stdout(Stdio::inherit()) + .stdin(Stdio::inherit()) + .stderr(Stdio::inherit()) + .output()?; + + let new_message: String = std::fs::read_to_string(&file_path)? + .lines() + .filter(|line| !line.starts_with('#')) + .filter(|line| !line.trim().is_empty()) + .collect(); + + rebase.commit(None, &original_commit.committer(), Some(&new_message))?; + let ignore_merge_commit = SETTINGS.ignore_merge_commits; + match verify( + self.repository.get_author().ok(), + &new_message, + ignore_merge_commit, + ) { + Ok(_) => { + info!("Changed commit message to:\"{}\"", &new_message.trim_end()) + } + Err(err) => error!( + "Error: {}\n\t{}", + "Edited message is still not compliant".red(), + err + ), + } + } else { + rebase.commit(None, &original_commit.committer(), None)?; + } + } else { + error!("{:?}", op); + } + } + + rebase.finish(None)?; + } else { + info!("{}", "No errored commit, skipping rebase".green()); + } + + Ok(()) + } +} diff --git a/src/command/init.rs b/src/command/init.rs new file mode 100644 index 00000000..857728d3 --- /dev/null +++ b/src/command/init.rs @@ -0,0 +1,64 @@ +use crate::git::repository::Repository; +use crate::settings::Settings; +use crate::CONFIG_PATH; +use anyhow::anyhow; +use log::info; +use std::path::Path; +use std::process::exit; + +pub fn init + ?Sized>(path: &S) -> anyhow::Result<()> { + let path = path.as_ref(); + + if !path.exists() { + std::fs::create_dir(path) + .map_err(|err| anyhow!("failed to create directory `{:?}` \n\ncause: {}", path, err))?; + } + + let mut is_init_commit = false; + let repository = match Repository::open(&path) { + Ok(repo) => { + info!( + "Found git repository in {:?}, skipping initialisation", + &path + ); + repo + } + Err(_) => match Repository::init(&path) { + Ok(repo) => { + info!("Empty git repository initialized in {:?}", &path); + is_init_commit = true; + repo + } + Err(err) => panic!("Unable to init repository on {:?}: {}", &path, err), + }, + }; + + let settings = Settings::default(); + let settings_path = path.join(CONFIG_PATH); + if settings_path.exists() { + eprint!("Found {} in {:?}, Nothing to do", CONFIG_PATH, &path); + exit(1); + } else { + std::fs::write( + &settings_path, + toml::to_string(&settings) + .map_err(|err| anyhow!("failed to serialize {}\n\ncause: {}", CONFIG_PATH, err))?, + ) + .map_err(|err| { + anyhow!( + "failed to write file `{:?}`\n\ncause: {}", + settings_path, + err + ) + })?; + } + + repository.add_all()?; + + if is_init_commit { + let sign = repository.gpg_sign(); + repository.commit("chore: initial commit", sign)?; + } + + Ok(()) +} diff --git a/src/command/log.rs b/src/command/log.rs new file mode 100644 index 00000000..e295fc2a --- /dev/null +++ b/src/command/log.rs @@ -0,0 +1,47 @@ +use crate::conventional::commit::Commit; +use crate::log::filter::CommitFilters; +use crate::CocoGitto; +use anyhow::Result; +use std::fmt::Write; + +impl CocoGitto { + pub fn get_log(&self, filters: CommitFilters) -> Result { + let commits = self.repository.all_commits()?; + let logs = commits + .commits + .iter() + // Remove merge commits + .filter(|commit| !commit.message().unwrap_or("").starts_with("Merge")) + .filter(|commit| filters.filter_git2_commit(commit)) + .map(Commit::from_git_commit) + // Apply filters + .filter(|commit| match commit { + Ok(commit) => filters.filters(commit), + Err(_) => filters.no_error(), + }) + // Format + .map(|commit| match commit { + Ok(commit) => commit.get_log(), + Err(err) => err.to_string(), + }) + .collect::>() + .join("\n"); + + Ok(logs) + } + + pub fn get_repo_tag_name(&self) -> Option { + let repo_path = self.repository.get_repo_dir()?.iter().last()?; + let mut repo_tag_name = repo_path.to_str()?.to_string(); + + if let Some(branch_shorthand) = self.repository.get_branch_shorthand() { + write!(&mut repo_tag_name, " on {}", branch_shorthand).unwrap(); + } + + if let Ok(latest_tag) = self.repository.get_latest_tag() { + write!(&mut repo_tag_name, " {}", latest_tag).unwrap(); + }; + + Some(repo_tag_name) + } +} diff --git a/src/command/mod.rs b/src/command/mod.rs new file mode 100644 index 00000000..c5f89407 --- /dev/null +++ b/src/command/mod.rs @@ -0,0 +1,7 @@ +pub mod bump; +pub mod changelog; +pub mod check; +pub mod commit; +pub mod edit; +pub mod init; +pub mod log; diff --git a/src/error.rs b/src/error.rs index f39d43fb..34a420b5 100644 --- a/src/error.rs +++ b/src/error.rs @@ -40,7 +40,7 @@ pub(crate) struct PreHookError { pub(crate) stash_number: u32, } -impl fmt::Display for PreHookError { +impl Display for PreHookError { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { let header = format!( "Error: {} `{}` {}", diff --git a/src/git/monorepo.rs b/src/git/monorepo.rs index fa40b7ab..7deb83e7 100644 --- a/src/git/monorepo.rs +++ b/src/git/monorepo.rs @@ -1,15 +1,16 @@ use crate::git::repository::Repository; use crate::git::revspec::CommitRange; -use crate::settings::MonoRepoPackage; + use crate::{Git2Error, OidOf, RevspecPattern, Tag, TagError}; use std::path::Path; impl Repository { /// Get commits from latest tag and return a map of commit ranges by their respective packages. - pub fn get_commit_range_for_packages( + pub fn get_commit_range_filtered( &self, - package: &MonoRepoPackage, + start: Option, pattern: &RevspecPattern, + path_filter: impl Fn(&Path) -> bool, ) -> Result, Git2Error> { let range = self.get_commit_range(pattern)?; @@ -20,21 +21,23 @@ impl Repository { let t1 = self .tree_to_treeish(Some(&parent))? .expect("Failed to get parent tree"); + let t2 = self .tree_to_treeish(Some(&commit.id().to_string()))? .expect("Failed to get commit tree"); + let diff = self.0.diff_tree_to_tree(t1.as_tree(), t2.as_tree(), None)?; for delta in diff.deltas() { if let Some(old) = delta.old_file().path() { - if package.match_path(old) { + if path_filter(old) { commits.push(commit); break; } } if let Some(new) = delta.new_file().path() { - if package.match_path(new) { + if path_filter(new) { commits.push(commit); break; } @@ -43,9 +46,8 @@ impl Repository { } if !commits.is_empty() { - // TODO: resolve tags here Ok(Some(CommitRange { - from: OidOf::Other(commits.first().unwrap().id()), + from: start.unwrap_or(OidOf::Other(commits.first().unwrap().id())), // Safe unwrap, matches are not empty to: OidOf::Other(commits.last().unwrap().id()), commits, @@ -71,15 +73,9 @@ impl Repository { } } -impl MonoRepoPackage { - fn match_path(&self, path: &Path) -> bool { - path.starts_with(&self.path) - } -} - #[cfg(test)] mod test { - use crate::{MonoRepoPackage, Repository, RevspecPattern}; + use crate::{Repository, RevspecPattern}; use anyhow::Result; use cmd_lib::run_cmd; use indoc::formatdoc; @@ -126,16 +122,10 @@ mod test { )?; // Act - let range = repo.get_commit_range_for_packages( - &MonoRepoPackage { - path: PathBuf::from("two"), - changelog_path: None, - pre_bump_hooks: vec![], - post_bump_hooks: vec![], - bump_profiles: Default::default(), - }, - &RevspecPattern::from("..HEAD"), - )?; + let range = + repo.get_commit_range_filtered(None, &RevspecPattern::from("..HEAD"), |path| { + path.starts_with(PathBuf::from("two")) + })?; // Assert assert_that!(range) diff --git a/src/hook/mod.rs b/src/hook/mod.rs index 1a04e307..de5425fd 100644 --- a/src/hook/mod.rs +++ b/src/hook/mod.rs @@ -142,8 +142,9 @@ mod test { use git2::Repository; use std::str::FromStr; - use crate::{Hook, HookVersion, Result, Tag}; + use crate::{Result, Tag}; + use crate::hook::{Hook, HookVersion}; use sealed_test::prelude::*; use speculoos::prelude::*; diff --git a/src/lib.rs b/src/lib.rs index 4191e7cf..322349ae 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,40 +1,26 @@ -use ::log::{error, info, warn}; use std::collections::HashMap; -use std::fmt::Write as FmtWrite; -use std::fs::File; -use std::io::Write; -use std::path::Path; -use std::process::{exit, Command, Stdio}; -use anyhow::{anyhow, bail, ensure, Context, Error, Result}; -use colored::*; +use anyhow::Result; + use conventional_commit_parser::commit::{CommitType, ConventionalCommit}; use conventional_commit_parser::parse_footers; -use git2::{Oid, RebaseOptions}; -use globset::Glob; -use itertools::Itertools; + use lazy_static::lazy_static; -use semver::Prerelease; -use tempfile::TempDir; -use crate::log::filter::CommitFilters; -use conventional::commit::{verify, Commit, CommitConfig}; +use conventional::commit::{Commit, CommitConfig}; use conventional::version::VersionIncrement; -use error::{CogCheckReport, PreHookError}; +use error::PreHookError; use git::repository::Repository; -use hook::Hook; + use settings::{HookType, Settings}; -use crate::conventional::changelog::release::Release; -use crate::conventional::changelog::template::Template; use crate::git::error::{Git2Error, TagError}; -use crate::git::hook::Hooks; + use crate::git::oid::OidOf; use crate::git::revspec::RevspecPattern; use crate::git::tag::Tag; -use crate::hook::HookVersion; -use crate::settings::MonoRepoPackage; +pub mod command; pub mod conventional; pub mod error; pub mod git; @@ -63,63 +49,6 @@ lazy_static! { }; } -pub fn init + ?Sized>(path: &S) -> Result<()> { - let path = path.as_ref(); - - if !path.exists() { - std::fs::create_dir(path) - .map_err(|err| anyhow!("failed to create directory `{:?}` \n\ncause: {}", path, err))?; - } - - let mut is_init_commit = false; - let repository = match Repository::open(&path) { - Ok(repo) => { - info!( - "Found git repository in {:?}, skipping initialisation", - &path - ); - repo - } - Err(_) => match Repository::init(&path) { - Ok(repo) => { - info!("Empty git repository initialized in {:?}", &path); - is_init_commit = true; - repo - } - Err(err) => panic!("Unable to init repository on {:?}: {}", &path, err), - }, - }; - - let settings = Settings::default(); - let settings_path = path.join(CONFIG_PATH); - if settings_path.exists() { - eprint!("Found {} in {:?}, Nothing to do", CONFIG_PATH, &path); - exit(1); - } else { - std::fs::write( - &settings_path, - toml::to_string(&settings) - .map_err(|err| anyhow!("failed to serialize {}\n\ncause: {}", CONFIG_PATH, err))?, - ) - .map_err(|err| { - anyhow!( - "failed to write file `{:?}`\n\ncause: {}", - settings_path, - err - ) - })?; - } - - repository.add_all()?; - - if is_init_commit { - let sign = repository.gpg_sign(); - repository.commit("chore: initial commit", sign)?; - } - - Ok(()) -} - #[derive(Debug)] pub struct CocoGitto { repository: Repository, @@ -138,197 +67,6 @@ impl CocoGitto { self.repository.get_author() } - pub fn get_repo_tag_name(&self) -> Option { - let repo_path = self.repository.get_repo_dir()?.iter().last()?; - let mut repo_tag_name = repo_path.to_str()?.to_string(); - - if let Some(branch_shorthand) = self.repository.get_branch_shorthand() { - write!(&mut repo_tag_name, " on {}", branch_shorthand).unwrap(); - } - - if let Ok(latest_tag) = self.repository.get_latest_tag() { - write!(&mut repo_tag_name, " {}", latest_tag).unwrap(); - }; - - Some(repo_tag_name) - } - - pub fn check_and_edit(&self, from_latest_tag: bool) -> Result<()> { - let commits = if from_latest_tag { - self.repository - .get_commit_range(&RevspecPattern::default())? - } else { - self.repository.all_commits()? - }; - - let editor = std::env::var("EDITOR") - .map_err(|_err| anyhow!("the 'EDITOR' environment variable was not found"))?; - - let dir = TempDir::new()?; - - let errored_commits: Vec = commits - .commits - .iter() - .map(|commit| { - let conv_commit = Commit::from_git_commit(commit); - (commit.id(), conv_commit) - }) - .filter(|commit| commit.1.is_err()) - .map(|commit| commit.0) - .collect(); - - // Get the last commit oid on the list as a starting point for our rebase - let last_errored_commit = errored_commits.last(); - if let Some(last_errored_commit) = last_errored_commit { - let commit = self - .repository - .0 - .find_commit(last_errored_commit.to_owned())?; - - let rebase_start = if commit.parent_count() == 0 { - commit.id() - } else { - commit.parent_id(0)? - }; - - let commit = self.repository.0.find_annotated_commit(rebase_start)?; - let mut options = RebaseOptions::new(); - - let mut rebase = - self.repository - .0 - .rebase(None, Some(&commit), None, Some(&mut options))?; - - let editor = &editor; - - while let Some(op) = rebase.next() { - if let Ok(rebase_operation) = op { - let oid = rebase_operation.id(); - let original_commit = self.repository.0.find_commit(oid)?; - if errored_commits.contains(&oid) { - warn!("Found errored commits:{}", &oid.to_string()[0..7]); - let file_path = dir.path().join(commit.id().to_string()); - let mut file = File::create(&file_path)?; - - let hint = format!( - "# Editing commit {}\ - \n# Replace this message with a conventional commit compliant one\ - \n# Save and exit to edit the next errored commit\n", - original_commit.id() - ); - - let mut message_bytes: Vec = hint.clone().into(); - message_bytes.extend_from_slice(original_commit.message_bytes()); - file.write_all(&message_bytes)?; - - Command::new(editor) - .arg(&file_path) - .stdout(Stdio::inherit()) - .stdin(Stdio::inherit()) - .stderr(Stdio::inherit()) - .output()?; - - let new_message: String = std::fs::read_to_string(&file_path)? - .lines() - .filter(|line| !line.starts_with('#')) - .filter(|line| !line.trim().is_empty()) - .collect(); - - rebase.commit(None, &original_commit.committer(), Some(&new_message))?; - let ignore_merge_commit = SETTINGS.ignore_merge_commits; - match verify( - self.repository.get_author().ok(), - &new_message, - ignore_merge_commit, - ) { - Ok(_) => { - info!("Changed commit message to:\"{}\"", &new_message.trim_end()) - } - Err(err) => error!( - "Error: {}\n\t{}", - "Edited message is still not compliant".red(), - err - ), - } - } else { - rebase.commit(None, &original_commit.committer(), None)?; - } - } else { - error!("{:?}", op); - } - } - - rebase.finish(None)?; - } else { - info!("{}", "No errored commit, skipping rebase".green()); - } - - Ok(()) - } - - pub fn check(&self, check_from_latest_tag: bool, ignore_merge_commits: bool) -> Result<()> { - let commit_range = if check_from_latest_tag { - self.repository - .get_commit_range(&RevspecPattern::default())? - } else { - self.repository.all_commits()? - }; - - let errors: Vec<_> = if ignore_merge_commits { - commit_range - .commits - .iter() - .filter(|commit| !commit.message().unwrap_or("").starts_with("Merge ")) - .map(Commit::from_git_commit) - .filter_map(Result::err) - .collect() - } else { - commit_range - .commits - .iter() - .map(Commit::from_git_commit) - .filter_map(Result::err) - .collect() - }; - - if errors.is_empty() { - let msg = "No errored commits".green(); - info!("{}", msg); - Ok(()) - } else { - let report = CogCheckReport { - from: commit_range.from, - errors: errors.into_iter().map(|err| *err).collect(), - }; - Err(anyhow!("{}", report)) - } - } - - pub fn get_log(&self, filters: CommitFilters) -> Result { - let commits = self.repository.all_commits()?; - let logs = commits - .commits - .iter() - // Remove merge commits - .filter(|commit| !commit.message().unwrap_or("").starts_with("Merge")) - .filter(|commit| filters.filter_git2_commit(commit)) - .map(Commit::from_git_commit) - // Apply filters - .filter(|commit| match commit { - Ok(commit) => filters.filters(commit), - Err(_) => filters.no_error(), - }) - // Format - .map(|commit| match commit { - Ok(commit) => commit.get_log(), - Err(err) => err.to_string(), - }) - .collect::>() - .join("\n"); - - Ok(logs) - } - /// Tries to get a commit message conforming to the Conventional Commit spec. /// If the commit message does _not_ conform, `None` is returned instead. pub fn get_conventional_message( @@ -363,520 +101,4 @@ impl CocoGitto { Ok(conventional_message) } - - #[allow(clippy::too_many_arguments)] // FIXME - pub fn conventional_commit( - &self, - commit_type: &str, - scope: Option, - summary: String, - body: Option, - footer: Option, - is_breaking_change: bool, - sign: bool, - ) -> Result<()> { - // Ensure commit type is known - let commit_type = CommitType::from(commit_type); - - // Ensure footers are correctly formatted - let footers = match footer { - Some(footers) => parse_footers(&footers)?, - None => Vec::with_capacity(0), - }; - - let conventional_message = ConventionalCommit { - commit_type, - scope, - body, - footers, - summary, - is_breaking_change, - } - .to_string(); - - // Validate the message - conventional_commit_parser::parse(&conventional_message)?; - - // Git commit - let sign = sign || self.repository.gpg_sign(); - let oid = self.repository.commit(&conventional_message, sign)?; - - // Pretty print a conventional commit summary - let commit = self.repository.0.find_commit(oid)?; - let commit = Commit::from_git_commit(&commit)?; - info!("{}", commit); - - Ok(()) - } - - /// ## Get a changelog between two oids - /// - `from` default value:latest tag or else first commit - /// - `to` default value:`HEAD` or else first commit - pub fn get_changelog( - &self, - pattern: RevspecPattern, - with_child_releases: bool, - ) -> Result { - if with_child_releases { - self.repository - .get_release_range(pattern) - .map_err(Into::into) - } else { - let commit_range = self.repository.get_commit_range(&pattern)?; - - Ok(Release::from(commit_range)) - } - } - - pub fn create_version( - &mut self, - increment: VersionIncrement, - pre_release: Option<&str>, - hooks_config: Option<&str>, - dry_run: bool, - ) -> Result<()> { - self.pre_bump_checks()?; - - let current_tag = self.repository.get_latest_tag(); - let current_tag = match current_tag { - Ok(ref tag) => tag.to_owned(), - Err(ref err) if err == &TagError::NoTag => { - warn!("Failed to get current version, falling back to 0.0.0"); - Tag::default() - } - Err(ref err) => bail!("{}", err), - }; - - let mut tag = current_tag.bump(increment, &self.repository)?; - - ensure_tag_is_greater_than_previous(¤t_tag, &tag)?; - - if let Some(pre_release) = pre_release { - tag.version.pre = Prerelease::new(pre_release)?; - } - - let tag = Tag::create(tag.version, None); - - if dry_run { - print!("{}", tag); - return Ok(()); - } - - let pattern = self.get_revspec_for_tag(¤t_tag)?; - let changelog = self.get_changelog_with_target_version(pattern, tag.clone())?; - - let path = settings::changelog_path(); - let template = SETTINGS.get_changelog_template()?; - changelog.write_to_file(path, template)?; - - let current = self.repository.get_latest_tag().map(HookVersion::new).ok(); - - let next_version = HookVersion::new(tag.clone()); - - let hook_result = self.run_hooks( - HookType::PreBump, - current.as_ref(), - &next_version, - hooks_config, - None, - ); - - self.repository.add_all()?; - - // Hook failed, we need to stop here and reset - // the repository to a clean state - if let Err(err) = hook_result { - self.stash_failed_version(&tag, err)?; - } - - let sign = self.repository.gpg_sign(); - - self.repository.commit( - &format!("chore(version): {}", next_version.prefixed_tag), - sign, - )?; - - self.repository.create_tag(&tag)?; - - self.run_hooks( - HookType::PostBump, - current.as_ref(), - &next_version, - hooks_config, - None, - )?; - - let current = current - .map(|current| current.prefixed_tag.to_string()) - .unwrap_or_else(|| "...".to_string()); - let bump = format!("{} -> {}", current, next_version.prefixed_tag).green(); - info!("Bumped version: {}", bump); - - Ok(()) - } - - pub fn create_monorepo_version( - &mut self, - pre_release: Option<&str>, - hooks_config: Option<&str>, - dry_run: bool, - ) -> Result<()> { - self.pre_bump_checks()?; - let mut package_bumps = vec![]; - - for (package_name, package) in &SETTINGS.packages { - let old = self.repository.get_latest_package_tag(package_name); - let old = tag_or_fallback_to_zero(old)?; - info!("Package {}, current version {old} ", package_name.bold()); - let mut next_version = old.bump(VersionIncrement::Auto, &self.repository)?; - ensure_tag_is_greater_than_previous(&old, &next_version)?; - - if let Some(pre_release) = pre_release { - next_version.version.pre = Prerelease::new(pre_release)?; - } - - let tag = Tag::create(next_version.version, Some(package_name.to_string())); - - if dry_run { - print!("{}", tag); - continue; - } - - let pattern = self.get_revspec_for_tag(&old)?; - let Some(changelog) = self.get_changelog_with_target_package_version(pattern, tag.clone(), package)? else { - println!("\t No commit found to bump package, skipping."); - continue; - }; - - let path = package.changelog_path(); - let template = SETTINGS.get_changelog_template()?; - changelog.write_to_file(path, template)?; - - let current = self - .repository - .get_latest_package_tag(package_name) - .map(HookVersion::new) - .ok(); - - let next_version = HookVersion::new(tag.clone()); - - let hook_result = self.run_hooks( - HookType::PreBump, - current.as_ref(), - &next_version, - hooks_config, - Some(package), - ); - - self.repository.add_all()?; - - // Hook failed, we need to stop here and reset - // the repository to a clean state - if let Err(err) = hook_result { - self.stash_failed_version(&tag, err)?; - } - - package_bumps.push((package_name, package, current, next_version, tag)); - } - - // Todo: meta version - self.repository.commit("chore(version): Bump", false)?; - - for (package_name, package, current, next_version, tag) in package_bumps { - self.repository.create_tag(&tag)?; - - self.run_hooks( - HookType::PostBump, - current.as_ref(), - &next_version, - hooks_config, - Some(package), - )?; - - let current = current - .map(|current| current.prefixed_tag.to_string()) - .unwrap_or_else(|| "...".to_string()); - let bump = format!("{} -> {}", current, next_version.prefixed_tag).green(); - info!("Bumped package {package_name} version: {}", bump); - } - - Ok(()) - } - - pub fn create_package_version( - &mut self, - (package_name, package): (&str, &MonoRepoPackage), - increment: VersionIncrement, - pre_release: Option<&str>, - hooks_config: Option<&str>, - dry_run: bool, - ) -> Result<()> { - self.pre_bump_checks()?; - - let current_tag = self.repository.get_latest_package_tag(package_name); - let current_tag = tag_or_fallback_to_zero(current_tag)?; - let mut next_version = current_tag.bump(increment, &self.repository)?; - ensure_tag_is_greater_than_previous(¤t_tag, &next_version)?; - if let Some(pre_release) = pre_release { - next_version.version.pre = Prerelease::new(pre_release)?; - } - - let tag = Tag::create(next_version.version.clone(), Some(package_name.to_string())); - - if dry_run { - print!("{}", tag); - return Ok(()); - } - - let pattern = self.get_revspec_for_tag(¤t_tag)?; - let Some(changelog) = self.get_changelog_with_target_package_version(pattern, tag.clone(), package)? else { - bail!("No commit matching package {package_name} path"); - }; - - let path = package.changelog_path(); - let template = SETTINGS.get_changelog_template()?; - changelog.write_to_file(path, template)?; - - let current = self - .repository - .get_latest_package_tag(package_name) - .map(HookVersion::new) - .ok(); - - let next_version = HookVersion::new(Tag::create( - next_version.version, - Some(package_name.to_string()), - )); - - let hook_result = self.run_hooks( - HookType::PreBump, - current.as_ref(), - &next_version, - hooks_config, - Some(package), - ); - - self.repository.add_all()?; - - // Hook failed, we need to stop here and reset - // the repository to a clean state - if let Err(err) = hook_result { - self.stash_failed_version(&tag, err)?; - } - - self.repository - .commit(&format!("chore(version): {}", tag), false)?; - - self.repository.create_tag(&tag)?; - - self.run_hooks( - HookType::PostBump, - current.as_ref(), - &next_version, - hooks_config, - Some(package), - )?; - - let current = current - .map(|current| current.prefixed_tag.to_string()) - .unwrap_or_else(|| "...".to_string()); - let bump = format!("{} -> {}", current, next_version.prefixed_tag).green(); - info!("Bumped package {package_name} version: {}", bump); - - Ok(()) - } - - fn stash_failed_version(&mut self, tag: &Tag, err: Error) -> Result<()> { - self.repository.stash_failed_version(tag.clone())?; - error!( - "{}", - PreHookError { - cause: err.to_string(), - version: tag.to_string(), - stash_number: 0, - } - ); - - exit(1); - } - - fn pre_bump_checks(&mut self) -> Result<()> { - if *SETTINGS == Settings::default() { - let part1 = "Warning: using".yellow(); - let part2 = "with the default configuration. \n".yellow(); - let part3 = "You may want to create a".yellow(); - let part4 = "file in your project root to configure bumps.\n".yellow(); - warn!( - "{} 'cog bump' {}{} 'cog.toml' {}", - part1, part2, part3, part4 - ); - } - let statuses = self.repository.get_statuses()?; - - // Fail if repo contains un-staged or un-committed changes - ensure!(statuses.0.is_empty(), "{}", self.repository.get_statuses()?); - - if !SETTINGS.branch_whitelist.is_empty() { - if let Some(branch) = self.repository.get_branch_shorthand() { - let whitelist = &SETTINGS.branch_whitelist; - let is_match = whitelist.iter().any(|pattern| { - let glob = Glob::new(pattern) - .expect("invalid glob pattern") - .compile_matcher(); - glob.is_match(&branch) - }); - - ensure!( - is_match, - "No patterns matched in {:?} for branch '{}', bump is not allowed", - whitelist, - branch - ) - } - }; - - Ok(()) - } - - pub fn get_changelog_at_tag(&self, tag: &str, template: Template) -> Result { - let pattern = format!("..{}", tag); - let pattern = RevspecPattern::from(pattern.as_str()); - let changelog = self.get_changelog(pattern, false)?; - - changelog - .into_markdown(template) - .map_err(|err| anyhow!(err)) - } - - /// Used for cog bump. the target version - /// is not created yet when generating the changelog. - pub fn get_changelog_with_target_version( - &self, - pattern: RevspecPattern, - tag: Tag, - ) -> Result { - let commit_range = self.repository.get_commit_range(&pattern)?; - - let mut release = Release::from(commit_range); - release.version = OidOf::Tag(tag); - Ok(release) - } - - /// Used for cog bump. the target version - /// is not created yet when generating the changelog. - fn get_changelog_with_target_package_version( - &self, - pattern: RevspecPattern, - target_tag: Tag, - package: &MonoRepoPackage, - ) -> Result> { - let mut release = self - .repository - .get_commit_range_for_packages(package, &pattern)? - .map(Release::from); - - if let Some(release) = &mut release { - release.version = OidOf::Tag(target_tag); - } - - Ok(release) - } - - fn run_hooks( - &self, - hook_type: HookType, - current_tag: Option<&HookVersion>, - next_version: &HookVersion, - hook_profile: Option<&str>, - package: Option<&MonoRepoPackage>, - ) -> Result<()> { - let settings = Settings::get(&self.repository)?; - - let hooks: Vec = match (package, hook_profile) { - (None, Some(profile)) => settings - .get_profile_hooks(profile, hook_type) - .iter() - .map(|s| s.parse()) - .enumerate() - .map(|(idx, result)| { - result.context(format!( - "Cannot parse bump profile {} hook at index {}", - profile, idx - )) - }) - .try_collect()?, - - (Some(package), Some(profile)) => { - let hooks = package.get_profile_hooks(profile, hook_type); - - hooks - .iter() - .map(|s| s.parse()) - .enumerate() - .map(|(idx, result)| { - result.context(format!( - "Cannot parse bump profile {} hook at index {}", - profile, idx - )) - }) - .try_collect()? - } - (Some(package), None) => package - .get_hooks(hook_type) - .iter() - .map(|s| s.parse()) - .enumerate() - .map(|(idx, result)| result.context(format!("Cannot parse hook at index {}", idx))) - .try_collect()?, - (None, None) => settings - .get_hooks(hook_type) - .iter() - .map(|s| s.parse()) - .enumerate() - .map(|(idx, result)| result.context(format!("Cannot parse hook at index {}", idx))) - .try_collect()?, - }; - - for mut hook in hooks { - hook.insert_versions(current_tag, next_version)?; - hook.run().context(hook.to_string())?; - } - - Ok(()) - } - - fn get_revspec_for_tag(&mut self, tag: &Tag) -> Result { - let origin = if tag.is_zero() { - self.repository.get_first_commit()?.to_string() - } else { - tag.oid_unchecked().to_string() - }; - - let target = self.repository.get_head_commit_oid()?.to_string(); - let pattern = (origin.as_str(), target.as_str()); - Ok(RevspecPattern::from(pattern)) - } -} - -fn ensure_tag_is_greater_than_previous(current: &Tag, next: &Tag) -> Result<()> { - if next <= current { - let comparison = format!("{} <= {}", current, next).red(); - let cause_key = "cause:".red(); - let cause = format!( - "{} version MUST be greater than current one: {}", - cause_key, comparison - ); - - bail!("{}:\n\t{}\n", "SemVer Error".red().to_string(), cause); - }; - - Ok(()) -} - -fn tag_or_fallback_to_zero(tag: Result) -> Result { - match tag { - Ok(ref tag) => Ok(tag.clone()), - Err(ref err) if err == &TagError::NoTag => Ok(Tag::default()), - Err(err) => Err(anyhow!(err)), - } } diff --git a/tests/lib_tests/init.rs b/tests/lib_tests/init.rs index 9bccb942..fd9d213c 100644 --- a/tests/lib_tests/init.rs +++ b/tests/lib_tests/init.rs @@ -10,7 +10,7 @@ use speculoos::prelude::*; fn should_init_a_cog_repository() -> Result<()> { // Arrange // Act - cocogitto::init(".")?; + cocogitto::command::init::init(".")?; // Assert assert_that!(Path::new("cog.toml")).exists(); @@ -25,7 +25,7 @@ fn should_skip_initialization_if_repository_exists() -> Result<()> { git_commit("The first commit")?; // Act - cocogitto::init(".")?; + cocogitto::command::init::init(".")?; // Assert assert_that!(Path::new("cog.toml")).exists(); From de337ed608467e167d38a05edb1a88cb6de5f2d7 Mon Sep 17 00:00:00 2001 From: Paul Delafosse Date: Tue, 17 Jan 2023 12:19:57 +0100 Subject: [PATCH 07/26] refactor: change monorepo & package bump --- src/bin/cog/main.rs | 15 +- src/command/bump/mod.rs | 139 +++++-- src/command/bump/monorepo.rs | 357 +++++++++++------- src/command/bump/package.rs | 23 +- src/command/bump/standard.rs | 9 +- src/conventional/bump.rs | 198 +++++++--- src/conventional/changelog/mod.rs | 19 +- src/conventional/changelog/release.rs | 10 +- src/conventional/changelog/renderer.rs | 38 +- src/conventional/changelog/template.rs | 110 +++++- .../changelog/template/monorepo_full_hash | 34 ++ .../changelog/template/monorepo_remote | 53 +++ .../changelog/template/monorepo_simple | 47 +++ .../changelog/template/package_full_hash | 27 ++ .../changelog/template/package_remote | 48 +++ .../changelog/template/package_simple | 40 ++ src/conventional/version.rs | 104 ++--- src/git/monorepo.rs | 63 +--- src/git/revspec.rs | 134 +++++++ src/git/tag.rs | 13 + src/lib.rs | 5 +- src/settings/mod.rs | 67 +++- tests/lib_tests/bump.rs | 14 +- 23 files changed, 1162 insertions(+), 405 deletions(-) create mode 100644 src/conventional/changelog/template/monorepo_full_hash create mode 100644 src/conventional/changelog/template/monorepo_remote create mode 100644 src/conventional/changelog/template/monorepo_simple create mode 100644 src/conventional/changelog/template/package_full_hash create mode 100644 src/conventional/changelog/template/package_remote create mode 100644 src/conventional/changelog/template/package_simple diff --git a/src/bin/cog/main.rs b/src/bin/cog/main.rs index 4c3921f7..f738a2f1 100644 --- a/src/bin/cog/main.rs +++ b/src/bin/cog/main.rs @@ -4,7 +4,7 @@ use std::path::PathBuf; use cocogitto::conventional::changelog::template::{RemoteContext, Template}; use cocogitto::conventional::commit as conv_commit; -use cocogitto::conventional::version::VersionIncrement; +use cocogitto::conventional::version::IncrementCommand; use cocogitto::git::hook::HookKind; use cocogitto::git::revspec::RevspecPattern; use cocogitto::log::filter::{CommitFilter, CommitFilters}; @@ -316,18 +316,19 @@ fn main() -> Result<()> { let mut cocogitto = CocoGitto::get()?; let increment = match version { - Some(version) => VersionIncrement::Manual(version), - None if auto => VersionIncrement::Auto, - None if major => VersionIncrement::Major, - None if minor => VersionIncrement::Minor, - None if patch => VersionIncrement::Patch, + Some(version) => IncrementCommand::Manual(version), + None if auto => IncrementCommand::Auto, + None if auto => IncrementCommand::Auto, + None if major => IncrementCommand::Major, + None if minor => IncrementCommand::Minor, + None if patch => IncrementCommand::Patch, _ => unreachable!(), }; let is_monorepo = !SETTINGS.packages.is_empty(); if is_monorepo { - if increment == VersionIncrement::Auto && package.is_none() { + if increment == IncrementCommand::Auto && package.is_none() { cocogitto.create_monorepo_version( pre.as_deref(), hook_profile.as_deref(), diff --git a/src/command/bump/mod.rs b/src/command/bump/mod.rs index f6ce0253..31ef0fa5 100644 --- a/src/command/bump/mod.rs +++ b/src/command/bump/mod.rs @@ -1,4 +1,5 @@ use crate::conventional::changelog::release::Release; +use crate::conventional::commit::Commit; use crate::git::error::TagError; use crate::git::hook::Hooks; use crate::git::oid::OidOf; @@ -11,9 +12,12 @@ use crate::{CocoGitto, SETTINGS}; use anyhow::Result; use anyhow::{anyhow, bail, ensure, Context, Error}; use colored::Colorize; +use conventional_commit_parser::commit::CommitType; use globset::Glob; use itertools::Itertools; -use log::{error, warn}; +use log::{error, info, warn}; +use std::fmt; +use std::fmt::Write; use std::process::exit; mod monorepo; @@ -44,29 +48,6 @@ fn tag_or_fallback_to_zero(tag: Result) -> Result { } impl CocoGitto { - /// Used for cog bump. Get all commits that modify at least one path belonging to the given - /// package. Target version is not created yet when generating the changelog. - fn get_changelog_with_target_package_version( - &self, - pattern: RevspecPattern, - starting_tag: Option, - target_tag: Tag, - package: &MonoRepoPackage, - ) -> Result> { - let mut release = self - .repository - .get_commit_range_filtered(starting_tag, &pattern, |path| { - path.starts_with(&package.path) - })? - .map(Release::from); - - if let Some(release) = &mut release { - release.version = OidOf::Tag(target_tag); - } - - Ok(release) - } - fn stash_failed_version(&mut self, tag: &Tag, err: Error) -> Result<()> { self.repository.stash_failed_version(tag.clone())?; error!( @@ -119,8 +100,7 @@ impl CocoGitto { Ok(()) } - /// Used for cog bump. the target version - /// is not created yet when generating the changelog. + /// The target version is not created yet when generating the changelog. pub fn get_changelog_with_target_version( &self, pattern: RevspecPattern, @@ -133,6 +113,37 @@ impl CocoGitto { Ok(release) } + /// The target package version is not created yet when generating the changelog. + pub fn get_package_changelog_with_target_version( + &self, + pattern: RevspecPattern, + tag: Tag, + package: &str, + ) -> Result { + let commit_range = self + .repository + .get_commit_range_for_package(&pattern, package)?; + + let mut release = Release::from(commit_range); + release.version = OidOf::Tag(tag); + Ok(release) + } + + /// The target global monorepo version is not created yet when generating the changelog. + pub fn get_monorepo_global_changelog_with_target_version( + &self, + pattern: RevspecPattern, + tag: Tag, + ) -> Result { + let commit_range = self + .repository + .get_commit_range_for_monorepo_global(&pattern)?; + + let mut release = Release::from(commit_range); + release.version = OidOf::Tag(tag); + Ok(release) + } + fn run_hooks( &self, hook_type: HookType, @@ -188,6 +199,13 @@ impl CocoGitto { .try_collect()?, }; + if hooks.is_empty() { + match hook_type { + HookType::PreBump => info!("Running pre-bump hooks"), + HookType::PostBump => info!("Running post-bump hooks"), + } + } + for mut hook in hooks { hook.insert_versions(current_tag, next_version)?; hook.run().context(hook.to_string())?; @@ -208,3 +226,72 @@ impl CocoGitto { Ok(RevspecPattern::from(pattern)) } } + +// FIXME +fn _display_history(commits: &[&git2::Commit]) -> Result<(), fmt::Error> { + let conventional_commits: Vec> = commits + .iter() + .map(|commit| Commit::from_git_commit(commit)) + .collect(); + + // Commits which type are neither feat, fix nor breaking changes + // won't affect the version number. + let mut non_bump_commits: Vec<&CommitType> = conventional_commits + .iter() + .filter_map(|commit| match commit { + Ok(commit) => match commit.message.commit_type { + CommitType::Feature | CommitType::BugFix => None, + _ => Some(&commit.message.commit_type), + }, + Err(_) => None, + }) + .collect(); + + non_bump_commits.sort(); + + let non_bump_commits: Vec<(usize, &CommitType)> = non_bump_commits + .into_iter() + .dedup_by_with_count(|c1, c2| c1 == c2) + .collect(); + + if !non_bump_commits.is_empty() { + let mut skip_message = "\tSkipping irrelevant commits:\n".to_string(); + for (count, commit_type) in non_bump_commits { + writeln!(skip_message, "\t\t- {}: {}", commit_type.as_ref(), count)?; + } + + info!("{}", skip_message); + } + + let bump_commits = conventional_commits + .iter() + .filter_map(|commit| match commit { + Ok(commit) => match commit.message.commit_type { + CommitType::Feature | CommitType::BugFix => Some(Ok(commit)), + _ => None, + }, + Err(err) => Some(Err(err)), + }); + + for commit in bump_commits { + match commit { + Ok(commit) if commit.message.is_breaking_change => { + info!( + "\t Found {} commit {} with type: {}", + "BREAKING CHANGE".red(), + commit.shorthand().blue(), + commit.message.commit_type.as_ref().yellow() + ) + } + Ok(commit) if commit.message.commit_type == CommitType::BugFix => { + info!("\tFound bug fix commit {}", commit.shorthand().blue()) + } + Ok(commit) if commit.message.commit_type == CommitType::Feature => { + info!("\tFound feature commit {}", commit.shorthand().blue()) + } + _ => (), + } + } + + Ok(()) +} diff --git a/src/command/bump/monorepo.rs b/src/command/bump/monorepo.rs index b14f7eba..39924a12 100644 --- a/src/command/bump/monorepo.rs +++ b/src/command/bump/monorepo.rs @@ -1,18 +1,36 @@ use crate::command::bump::{ensure_tag_is_greater_than_previous, tag_or_fallback_to_zero}; -use crate::conventional::changelog::release::Release; -use crate::conventional::version::VersionIncrement; -use crate::git::oid::OidOf; -use crate::git::revspec::RevspecPattern; + +use crate::conventional::changelog::template::{ + MonoRepoContext, PackageBumpContext, PackageContext, +}; +use crate::conventional::changelog::ReleaseType; + +use crate::conventional::version::{Increment, IncrementCommand}; + use crate::git::tag::Tag; use crate::hook::HookVersion; use crate::settings::HookType; -use crate::{CocoGitto, SETTINGS}; -use anyhow::{bail, Result}; +use crate::{settings, CocoGitto, SETTINGS}; +use anyhow::Result; use colored::*; + use log::info; use semver::Prerelease; -use std::path::{Path, PathBuf}; +use crate::conventional::error::BumpError; + +struct PackageBumpData { + package_name: String, + package_path: String, + public_api: bool, + old_version: Option, + new_version: HookVersion, + increment: Increment, +} + +// TODO: +// - pretty stdout +// - dry run impl CocoGitto { pub fn create_monorepo_version( &mut self, @@ -21,55 +39,228 @@ impl CocoGitto { dry_run: bool, ) -> Result<()> { self.pre_bump_checks()?; - let mut package_bumps = vec![]; + // Get package bumps + let bumps = self.get_packages_bumps(pre_release)?; + + // Get the greatest package increment among public api packages + let increment_from_package_bumps = bumps + .iter() + .filter(|bump| bump.public_api) + .map(|bump| bump.increment) + .max(); + + // Get current global tag + let old = self.repository.get_latest_tag(); + let old = tag_or_fallback_to_zero(old)?; + let mut tag = old.bump( + IncrementCommand::AutoMonoRepoGlobal(increment_from_package_bumps), + &self.repository, + )?; + ensure_tag_is_greater_than_previous(&old, &tag)?; + + if let Some(pre_release) = pre_release { + tag.version.pre = Prerelease::new(pre_release)?; + } + + let tag = Tag::create(tag.version, None); + + if dry_run { + print!("{}", tag); + return Ok(()); + } + + let mut template_context = vec![]; + for bump in &bumps { + template_context.push(PackageBumpContext { + package_name: &bump.package_name, + package_path: &bump.package_path, + new_version: bump.new_version.prefixed_tag.to_string(), + old_version: bump + .old_version + .as_ref() + .map(|v| v.prefixed_tag.to_string()), + }) + } + + let pattern = self.get_revspec_for_tag(&old)?; + let changelog = + self.get_monorepo_global_changelog_with_target_version(pattern, tag.clone())?; + let path = settings::changelog_path(); + let template = SETTINGS.get_monorepo_changelog_template()?; + changelog.write_to_file( + path, + template, + ReleaseType::MonoRepo(MonoRepoContext { + packages: template_context, + }), + )?; + + let current = self.repository.get_latest_tag().map(HookVersion::new).ok(); + let next_version = HookVersion::new(tag.clone()); + + let hook_result = self.run_hooks( + HookType::PreBump, + current.as_ref(), + &next_version, + hooks_config, + None, + ); + + self.repository.add_all()?; + + if let Err(err) = hook_result { + self.stash_failed_version(&tag, err)?; + } - for (package_name, package) in &SETTINGS.packages { + self.bump_packages(pre_release, hooks_config, &bumps)?; + + let sign = self.repository.gpg_sign(); + self.repository.commit( + &format!("chore(version): {}", next_version.prefixed_tag), + sign, + )?; + + for bump in &bumps { + self.repository.create_tag(&bump.new_version.prefixed_tag)?; + } + + self.repository.create_tag(&tag)?; + + // Run per package post hooks + for bump in bumps { + let package = SETTINGS + .packages + .get(&bump.package_name) + .expect("package exists"); + self.run_hooks( + HookType::PostBump, + bump.old_version.as_ref(), + &bump.new_version, + hooks_config, + Some(package), + )?; + } + + // Run global post hooks + self.run_hooks( + HookType::PostBump, + current.as_ref(), + &next_version, + hooks_config, + None, + )?; + + Ok(()) + } + + // Calculate all package bump + fn get_packages_bumps(&self, pre_release: Option<&str>) -> Result> { + let mut package_bumps = vec![]; + for (package_name, package) in SETTINGS.packages.iter() { let old = self.repository.get_latest_package_tag(package_name); let old = tag_or_fallback_to_zero(old)?; - info!("Package {}, current version {old} ", package_name.bold()); - let mut next_version = old.bump(VersionIncrement::Auto, &self.repository)?; - ensure_tag_is_greater_than_previous(&old, &next_version)?; + + let next_version = old.bump( + IncrementCommand::AutoPackage(package_name.to_string()), + &self.repository, + ); + + if let Err(BumpError::NoCommitFound) = next_version { + continue; + } + + let mut next_version = next_version.unwrap(); if let Some(pre_release) = pre_release { next_version.version.pre = Prerelease::new(pre_release)?; } let tag = Tag::create(next_version.version, Some(package_name.to_string())); + let increment = tag.get_increment_from(&old); - if dry_run { - print!("{}", tag); - continue; + if let Some(increment) = increment { + let old_version = if old.is_zero() { + None + } else { + Some(HookVersion::new(old)) + }; + + package_bumps.push(PackageBumpData { + package_name: package_name.to_string(), + package_path: package.path.to_string_lossy().to_string(), + public_api: package.public_api, + old_version, + new_version: HookVersion::new(tag), + increment, + }) } + } + + Ok(package_bumps) + } + + // Run pre hooks and generate changelog for each package and git add the generated content + fn bump_packages( + &mut self, + pre_release: Option<&str>, + hooks_config: Option<&str>, + package_bumps: &Vec, + ) -> Result<()> { + for bump in package_bumps { + let package_name = &bump.package_name; + let old = self.repository.get_latest_package_tag(package_name); + let old = tag_or_fallback_to_zero(old)?; + info!( + "Preparing bump for package {}, starting from version {old}", + package_name.bold() + ); + + let mut next_version = old.bump( + IncrementCommand::AutoPackage(package_name.to_string()), + &self.repository, + )?; + ensure_tag_is_greater_than_previous(&old, &next_version)?; + if let Some(pre_release) = pre_release { + next_version.version.pre = Prerelease::new(pre_release)?; + } + + let tag = Tag::create(next_version.version, Some(package_name.to_string())); let pattern = self.get_revspec_for_tag(&old)?; - let changelog_start = if old.is_zero() { - None - } else { - Some(OidOf::Tag(old)) - }; + let package = SETTINGS + .packages + .get(package_name.as_str()) + .expect("package exists"); - let Some(changelog) = self.get_changelog_with_target_package_version(pattern, changelog_start, tag.clone(), package)? else { - println!("\t No commit found to bump package, skipping."); - continue; - }; + let changelog = self.get_package_changelog_with_target_version( + pattern, + tag.clone(), + package_name.as_str(), + )?; let path = package.changelog_path(); - let template = SETTINGS.get_changelog_template()?; - changelog.write_to_file(path, template)?; + let template = SETTINGS.get_package_changelog_template()?; + + let additional_context = ReleaseType::Package(PackageContext { + package_name: package_name.as_ref(), + }); + + changelog.write_to_file(&path, template, additional_context)?; + info!("\tChangelog updated {:?}", path); - let current = self + let old_version = self .repository .get_latest_package_tag(package_name) .map(HookVersion::new) .ok(); - let next_version = HookVersion::new(tag.clone()); + let new_version = HookVersion::new(tag.clone()); let hook_result = self.run_hooks( HookType::PreBump, - current.as_ref(), - &next_version, + old_version.as_ref(), + &new_version, hooks_config, Some(package), ); @@ -81,111 +272,11 @@ impl CocoGitto { if let Err(err) = hook_result { self.stash_failed_version(&tag, err)?; } - - package_bumps.push((package_name, package, current, next_version, tag)); - } - - if package_bumps.is_empty() { - bail!("Nothing to bump"); - } - - let mut meta_bump = None; - for (_, _, old, _, new) in &package_bumps { - if let Some(old) = old.as_ref() { - let old = &old.prefixed_tag.version; - let new = &new.version; - if old.major > new.major { - meta_bump = Some(VersionIncrement::Major) - } else if old.minor > new.minor { - if meta_bump != Some(VersionIncrement::Major) - || meta_bump != Some(VersionIncrement::Minor) - { - meta_bump = Some(VersionIncrement::Minor) - } - } else if meta_bump != Some(VersionIncrement::Major) - || meta_bump != Some(VersionIncrement::Minor) - || meta_bump != Some(VersionIncrement::Patch) - { - meta_bump = Some(VersionIncrement::Patch) - } - } - } - - // Generate a meta changelog, aggregating new changelog versions - // and commits that does not belong to any package - let current_tag = self.repository.get_latest_tag(); - let current_tag = tag_or_fallback_to_zero(current_tag)?; - let mut tag = current_tag.bump(meta_bump.unwrap(), &self.repository)?; - ensure_tag_is_greater_than_previous(¤t_tag, &tag)?; - if let Some(pre_release) = pre_release { - tag.version.pre = Prerelease::new(pre_release)?; - } - let tag = Tag::create(tag.version, None); - let pattern = self.get_revspec_for_tag(¤t_tag)?; - - let changelog_start = if current_tag.is_zero() { - None - } else { - Some(OidOf::Tag(current_tag.clone())) - }; - - let _changelog = self.get_meta_changelog(pattern, changelog_start, tag)?; - let _template = SETTINGS.get_changelog_template()?; - // changelog.write_to_file(path, template)?; - - self.repository.commit("chore(version): {tag}", false)?; - - for (package_name, package, current, next_version, tag) in package_bumps { - self.repository.create_tag(&tag)?; - - self.run_hooks( - HookType::PostBump, - current.as_ref(), - &next_version, - hooks_config, - Some(package), - )?; - - let current = current - .map(|current| current.prefixed_tag.to_string()) - .unwrap_or_else(|| "...".to_string()); - let bump = format!("{} -> {}", current, next_version.prefixed_tag).green(); - info!("Bumped package {package_name} version: {}", bump); } Ok(()) } - - /// Used for monorepo package bump. Get all commits that modify at least one path that does not - /// belong to a monorepo package. - /// Target version is not created yet when generating the changelog. - fn get_meta_changelog( - &self, - pattern: RevspecPattern, - starting_tag: Option, - target_tag: Tag, - ) -> Result> { - let package_paths: Vec<&PathBuf> = SETTINGS - .packages - .values() - .map(|package| &package.path) - .collect(); - - let filter = |path: &Path| { - package_paths - .iter() - .any(|package_path| !path.starts_with(package_path)) - }; - - let mut release = self - .repository - .get_commit_range_filtered(starting_tag, &pattern, filter)? - .map(Release::from); - - if let Some(release) = &mut release { - release.version = OidOf::Tag(target_tag); - } - - Ok(release) - } } + +#[cfg(test)] +mod test {} diff --git a/src/command/bump/package.rs b/src/command/bump/package.rs index 5d0a43b5..ecb7fd0a 100644 --- a/src/command/bump/package.rs +++ b/src/command/bump/package.rs @@ -1,11 +1,12 @@ use crate::command::bump::{ensure_tag_is_greater_than_previous, tag_or_fallback_to_zero}; -use crate::conventional::version::VersionIncrement; -use crate::git::oid::OidOf; +use crate::conventional::changelog::template::PackageContext; +use crate::conventional::changelog::ReleaseType; +use crate::conventional::version::IncrementCommand; use crate::git::tag::Tag; use crate::hook::HookVersion; use crate::settings::{HookType, MonoRepoPackage}; use crate::{CocoGitto, SETTINGS}; -use anyhow::{bail, Result}; +use anyhow::Result; use colored::*; use log::info; use semver::Prerelease; @@ -14,7 +15,7 @@ impl CocoGitto { pub fn create_package_version( &mut self, (package_name, package): (&str, &MonoRepoPackage), - increment: VersionIncrement, + increment: IncrementCommand, pre_release: Option<&str>, hooks_config: Option<&str>, dry_run: bool, @@ -38,19 +39,13 @@ impl CocoGitto { let pattern = self.get_revspec_for_tag(¤t_tag)?; - let changelog_start = if current_tag.is_zero() { - None - } else { - Some(OidOf::Tag(current_tag.clone())) - }; - - let Some(changelog) = self.get_changelog_with_target_package_version(pattern, changelog_start, tag.clone(), package)? else { - bail!("No commit matching package {package_name} path"); - }; + let changelog = + self.get_package_changelog_with_target_version(pattern, tag.clone(), package_name)?; let path = package.changelog_path(); let template = SETTINGS.get_changelog_template()?; - changelog.write_to_file(path, template)?; + let additional_context = ReleaseType::Package(PackageContext { package_name }); + changelog.write_to_file(path, template, additional_context)?; let current = self .repository diff --git a/src/command/bump/standard.rs b/src/command/bump/standard.rs index cb40262a..ad43add8 100644 --- a/src/command/bump/standard.rs +++ b/src/command/bump/standard.rs @@ -1,5 +1,7 @@ use crate::command::bump::{ensure_tag_is_greater_than_previous, tag_or_fallback_to_zero}; -use crate::conventional::version::VersionIncrement; + +use crate::conventional::changelog::ReleaseType; +use crate::conventional::version::IncrementCommand; use crate::git::tag::Tag; use crate::hook::HookVersion; use crate::settings::HookType; @@ -12,7 +14,7 @@ use semver::Prerelease; impl CocoGitto { pub fn create_version( &mut self, - increment: VersionIncrement, + increment: IncrementCommand, pre_release: Option<&str>, hooks_config: Option<&str>, dry_run: bool, @@ -41,7 +43,8 @@ impl CocoGitto { let path = settings::changelog_path(); let template = SETTINGS.get_changelog_template()?; - changelog.write_to_file(path, template)?; + + changelog.write_to_file(path, template, ReleaseType::Standard)?; let current = self.repository.get_latest_tag().map(HookVersion::new).ok(); diff --git a/src/conventional/bump.rs b/src/conventional/bump.rs index 66f0d599..39374635 100644 --- a/src/conventional/bump.rs +++ b/src/conventional/bump.rs @@ -1,5 +1,6 @@ use crate::conventional::error::BumpError; -use crate::{Commit, Repository, RevspecPattern, Tag, VersionIncrement}; +use crate::conventional::version::Increment; +use crate::{Commit, IncrementCommand, Repository, RevspecPattern, Tag}; use conventional_commit_parser::commit::CommitType; use git2::Commit as Git2Commit; use semver::{BuildMetadata, Prerelease, Version}; @@ -14,6 +15,16 @@ pub(crate) trait Bump { fn auto_bump(&self, repository: &Repository) -> Result where Self: Sized; + fn auto_global_bump( + &self, + repository: &Repository, + package_increment: Option, + ) -> Result + where + Self: Sized; + fn auto_package_bump(&self, repository: &Repository, package: &str) -> Result + where + Self: Sized; } impl Bump for Tag { @@ -47,20 +58,54 @@ impl Bump for Tag { fn auto_bump(&self, repository: &Repository) -> Result { self.create_version_from_commit_history(repository) } + + fn auto_global_bump( + &self, + repository: &Repository, + package_increment: Option, + ) -> Result + where + Self: Sized, + { + let tag_from_history = self.create_monorepo_global_version_from_commit_history(repository); + match (package_increment, tag_from_history) { + (Some(package_increment), Ok(tag_from_history)) => { + let tag_from_packages = self.bump(package_increment.into(), repository)?; + Ok(tag_from_packages.max(tag_from_history)) + } + (Some(package_increment), Err(_)) => { + let tag_from_packages = self.bump(package_increment.into(), repository)?; + Ok(tag_from_packages) + } + (None, Ok(tag_from_history)) => Ok(tag_from_history), + (None, Err(err)) => Err(err), + } + } + + fn auto_package_bump(&self, repository: &Repository, package: &str) -> Result + where + Self: Sized, + { + self.create_package_version_from_commit_history(package, repository) + } } impl Tag { pub(crate) fn bump( &self, - increment: VersionIncrement, + increment: IncrementCommand, repository: &Repository, ) -> Result { match increment { - VersionIncrement::Major => Ok(self.major_bump()), - VersionIncrement::Minor => Ok(self.minor_bump()), - VersionIncrement::Patch => Ok(self.patch_bump()), - VersionIncrement::Auto => self.auto_bump(repository), - VersionIncrement::Manual(version) => self.manual_bump(&version).map_err(Into::into), + IncrementCommand::Major => Ok(self.major_bump()), + IncrementCommand::Minor => Ok(self.minor_bump()), + IncrementCommand::Patch => Ok(self.patch_bump()), + IncrementCommand::Auto => self.auto_bump(repository), + IncrementCommand::AutoPackage(package) => self.auto_package_bump(repository, &package), + IncrementCommand::AutoMonoRepoGlobal(package_increment) => { + self.auto_global_bump(repository, package_increment) + } + IncrementCommand::Manual(version) => self.manual_bump(&version).map_err(Into::into), } } @@ -75,15 +120,10 @@ impl Tag { &self, repository: &Repository, ) -> Result { - let changelog_start_oid = match &self.package { - None => repository.get_latest_tag_oid().ok(), - Some(package) => repository - .get_latest_package_tag(package) - .ok() - .and_then(|tag| tag.oid), - } - .unwrap_or_else(|| repository.get_first_commit().expect("non empty repository")); - + let changelog_start_oid = repository + .get_latest_tag_oid() + .ok() + .unwrap_or_else(|| repository.get_first_commit().expect("non empty repository")); let changelog_start_oid = changelog_start_oid.to_string(); let changelog_start_oid = Some(changelog_start_oid.as_str()); @@ -100,7 +140,46 @@ impl Tag { .filter(|commit| !commit.message().unwrap_or("").starts_with("Merge ")) .collect(); - VersionIncrement::display_history(&commits)?; + let conventional_commits: Vec = commits + .iter() + .map(|commit| Commit::from_git_commit(commit)) + .filter_map(Result::ok) + .collect(); + + let increment_type = self.version_increment_from_commit_history(&conventional_commits)?; + + Ok(match increment_type { + Increment::Major => self.major_bump(), + Increment::Minor => self.minor_bump(), + Increment::Patch => self.patch_bump(), + }) + } + + fn create_package_version_from_commit_history( + &self, + package: &str, + repository: &Repository, + ) -> Result { + let changelog_start_oid = repository + .get_latest_package_tag(package) + .ok() + .and_then(|tag| tag.oid) + .unwrap_or_else(|| repository.get_first_commit().expect("non empty repository")); + + let changelog_start_oid = changelog_start_oid.to_string(); + let changelog_start_oid = Some(changelog_start_oid.as_str()); + + let pattern = changelog_start_oid + .map(|oid| format!("{}..", oid)) + .unwrap_or_else(|| "..".to_string()); + let pattern = pattern.as_str(); + let pattern = RevspecPattern::from(pattern); + let commits = repository.get_commit_range_for_package(&pattern, package)?; + let commits: Vec<&Git2Commit> = commits + .commits + .iter() + .filter(|commit| !commit.message().unwrap_or("").starts_with("Merge ")) + .collect(); let conventional_commits: Vec = commits .iter() @@ -111,17 +190,56 @@ impl Tag { let increment_type = self.version_increment_from_commit_history(&conventional_commits)?; Ok(match increment_type { - VersionIncrement::Major => self.major_bump(), - VersionIncrement::Minor => self.minor_bump(), - VersionIncrement::Patch => self.patch_bump(), - _ => unreachable!(), + Increment::Major => self.major_bump(), + Increment::Minor => self.minor_bump(), + Increment::Patch => self.patch_bump(), }) } - fn version_increment_from_commit_history( + fn create_monorepo_global_version_from_commit_history( + &self, + repository: &Repository, + ) -> Result { + let changelog_start_oid = repository + .get_latest_tag_oid() + .ok() + .unwrap_or_else(|| repository.get_first_commit().expect("non empty repository")); + + let changelog_start_oid = changelog_start_oid.to_string(); + let changelog_start_oid = Some(changelog_start_oid.as_str()); + + let pattern = changelog_start_oid + .map(|oid| format!("{}..", oid)) + .unwrap_or_else(|| "..".to_string()); + let pattern = pattern.as_str(); + let pattern = RevspecPattern::from(pattern); + let commits = repository.get_commit_range_for_monorepo_global(&pattern)?; + + let commits: Vec<&Git2Commit> = commits + .commits + .iter() + .filter(|commit| !commit.message().unwrap_or("").starts_with("Merge ")) + .collect(); + + let conventional_commits: Vec = commits + .iter() + .map(|commit| Commit::from_git_commit(commit)) + .filter_map(Result::ok) + .collect(); + + let increment_type = self.version_increment_from_commit_history(&conventional_commits)?; + + Ok(match increment_type { + Increment::Major => self.major_bump(), + Increment::Minor => self.minor_bump(), + Increment::Patch => self.patch_bump(), + }) + } + + pub fn version_increment_from_commit_history( &self, commits: &[Commit], - ) -> Result { + ) -> Result { let is_major_bump = || { self.version.major != 0 && commits @@ -142,11 +260,11 @@ impl Tag { }; if is_major_bump() { - Ok(VersionIncrement::Major) + Ok(Increment::Major) } else if is_minor_bump() { - Ok(VersionIncrement::Minor) + Ok(Increment::Minor) } else if is_patch_bump() { - Ok(VersionIncrement::Patch) + Ok(Increment::Patch) } else { Err(BumpError::NoCommitFound) } @@ -156,7 +274,7 @@ impl Tag { #[cfg(test)] mod test { use crate::conventional::commit::Commit; - use crate::conventional::version::VersionIncrement; + use crate::conventional::version::{Increment, IncrementCommand}; use crate::git::repository::Repository; use crate::git::tag::Tag; use anyhow::Result; @@ -192,7 +310,7 @@ mod test { let base_version = Tag::from_str("1.0.0", None)?; // Act - let tag = base_version.bump(VersionIncrement::Major, &repository)?; + let tag = base_version.bump(IncrementCommand::Major, &repository)?; // Assert assert_that!(tag.version).is_equal_to(Version::new(2, 0, 0)); @@ -206,7 +324,7 @@ mod test { let base_version = Tag::from_str("1.0.0", None)?; // Act - let tag = base_version.bump(VersionIncrement::Minor, &repository)?; + let tag = base_version.bump(IncrementCommand::Minor, &repository)?; // Assert assert_that!(tag.version).is_equal_to(Version::new(1, 1, 0)); @@ -220,7 +338,7 @@ mod test { let base_version = Tag::from_str("1.0.0", None)?; // Act - let tag = base_version.bump(VersionIncrement::Patch, &repository)?; + let tag = base_version.bump(IncrementCommand::Patch, &repository)?; // Assert assert_that!(tag.version).is_equal_to(Version::new(1, 0, 1)); @@ -239,7 +357,7 @@ mod test { // Assert assert_that!(increment) .is_ok() - .is_equal_to(VersionIncrement::Patch); + .is_equal_to(Increment::Patch); Ok(()) } @@ -251,7 +369,7 @@ mod test { let version = Tag::from_str("1.1.1", None)?; // Act - let tag = version.bump(VersionIncrement::Minor, &repository)?; + let tag = version.bump(IncrementCommand::Minor, &repository)?; // Assert assert_that!(tag.version).is_equal_to(Version::from_str("1.2.0")?); @@ -266,7 +384,7 @@ mod test { let version = Tag::from_str("1.1.1", None)?; // Act - let tag = version.bump(VersionIncrement::Major, &repository)?; + let tag = version.bump(IncrementCommand::Major, &repository)?; // Assert assert_that!(tag.version).is_equal_to(Version::from_str("2.0.0")?); @@ -281,7 +399,7 @@ mod test { let version = Tag::from_str("1.1.1-pre+10.1", None)?; // Act - let tag = version.bump(VersionIncrement::Patch, &repository)?; + let tag = version.bump(IncrementCommand::Patch, &repository)?; // Assert assert_that!(tag.version).is_equal_to(Version::from_str("1.1.2")?); @@ -301,9 +419,7 @@ mod test { base_version.version_increment_from_commit_history(&[feature, breaking_change]); // Assert - assert_that!(version) - .is_ok() - .is_equal_to(VersionIncrement::Major); + assert_that!(version).is_ok().is_equal_to(Increment::Major); Ok(()) } @@ -320,9 +436,7 @@ mod test { base_version.version_increment_from_commit_history(&[feature, breaking_change]); // Assert - assert_that!(version) - .is_ok() - .is_equal_to(VersionIncrement::Minor); + assert_that!(version).is_ok().is_equal_to(Increment::Minor); Ok(()) } @@ -338,9 +452,7 @@ mod test { let version = base_version.version_increment_from_commit_history(&[patch, feature]); // Assert - assert_that!(version) - .is_ok() - .is_equal_to(VersionIncrement::Minor); + assert_that!(version).is_ok().is_equal_to(Increment::Minor); Ok(()) } diff --git a/src/conventional/changelog/mod.rs b/src/conventional/changelog/mod.rs index a70caa79..a6be541e 100644 --- a/src/conventional/changelog/mod.rs +++ b/src/conventional/changelog/mod.rs @@ -2,7 +2,8 @@ use crate::conventional::changelog::release::Release; use crate::conventional::changelog::renderer::Renderer; use crate::conventional::changelog::error::ChangelogError; -use crate::conventional::changelog::template::Template; +use crate::conventional::changelog::template::{MonoRepoContext, PackageContext, Template}; + use std::fs; use std::path::Path; @@ -21,9 +22,15 @@ See [conventional commits](https://www.conventionalcommits.org/) for commit guid const DEFAULT_FOOTER: &str = "Changelog generated by [cocogitto](https://github.com/cocogitto/cocogitto)."; +pub enum ReleaseType<'a> { + Standard, + MonoRepo(MonoRepoContext<'a>), + Package(PackageContext<'a>), +} + impl Release<'_> { pub fn into_markdown(self, template: Template) -> Result { - let renderer = Renderer::try_new(template)?; + let mut renderer = Renderer::try_new(template)?; renderer.render(self) } @@ -31,8 +38,16 @@ impl Release<'_> { self, path: S, template: Template, + kind: ReleaseType, ) -> Result<(), ChangelogError> { let renderer = Renderer::try_new(template)?; + + let mut renderer = match kind { + ReleaseType::Standard => renderer, + ReleaseType::MonoRepo(context) => renderer.with_monorepo_context(context), + ReleaseType::Package(context) => renderer.with_package_context(context), + }; + let changelog = renderer.render(self)?; let mut changelog_content = fs::read_to_string(path.as_ref()) diff --git a/src/conventional/changelog/release.rs b/src/conventional/changelog/release.rs index a3d4d4f1..b6e10604 100644 --- a/src/conventional/changelog/release.rs +++ b/src/conventional/changelog/release.rs @@ -132,7 +132,7 @@ mod test { fn should_render_default_template() -> Result<()> { // Arrange let release = Release::fixture(); - let renderer = Renderer::default(); + let mut renderer = Renderer::default(); // Act let changelog = renderer.render(release)?; @@ -158,8 +158,8 @@ mod test { fn should_render_full_hash_template() -> Result<()> { // Arrange let release = Release::fixture(); - let renderer = Renderer::try_new(Template { - context: None, + let mut renderer = Renderer::try_new(Template { + remote_context: None, kind: TemplateKind::FullHash, })?; @@ -187,8 +187,8 @@ mod test { fn should_render_github_template() -> Result<()> { // Arrange let release = Release::fixture(); - let renderer = Renderer::try_new(Template { - context: RemoteContext::try_new( + let mut renderer = Renderer::try_new(Template { + remote_context: RemoteContext::try_new( Some("github.com".into()), Some("cocogitto".into()), Some("cocogitto".into()), diff --git a/src/conventional/changelog/renderer.rs b/src/conventional/changelog/renderer.rs index 133fa943..c29e12fe 100644 --- a/src/conventional/changelog/renderer.rs +++ b/src/conventional/changelog/renderer.rs @@ -3,11 +3,14 @@ use std::collections::HashMap; use tera::{get_json_pointer, to_value, try_get_value, Context, Tera, Value}; use crate::conventional::changelog::release::Release; -use crate::conventional::changelog::template::{RemoteContext, Template}; +use crate::conventional::changelog::template::{ + MonoRepoContext, PackageContext, RemoteContext, Template, ToContext, +}; #[derive(Debug)] pub struct Renderer { tera: Tera, + context: Context, template: Template, } @@ -27,10 +30,24 @@ impl Renderer { tera.register_filter("upper_first", Self::upper_first_filter); tera.register_filter("unscoped", Self::unscoped); - Ok(Renderer { tera, template }) + Ok(Renderer { + tera, + context: Context::new(), + template, + }) } - pub(crate) fn render(&self, version: Release) -> Result { + pub(crate) fn with_package_context(mut self, context: PackageContext) -> Self { + self.context.extend(context.to_context()); + self + } + + pub(crate) fn with_monorepo_context(mut self, context: MonoRepoContext) -> Self { + self.context.extend(context.to_context()); + self + } + + pub(crate) fn render(&mut self, version: Release) -> Result { let mut release = self.render_release(&version)?; let mut version = version; while let Some(previous) = version.previous.map(|v| *v) { @@ -41,20 +58,21 @@ impl Renderer { Ok(release) } - fn render_release(&self, version: &Release) -> Result { - let mut template_context = Context::from_serialize(version)?; + + fn render_release(&mut self, version: &Release) -> Result { + let release_context = Context::from_serialize(version)?; + self.context.extend(release_context); let context = self .template - .context + .remote_context .as_ref() - .map(RemoteContext::to_tera_context); + .map(RemoteContext::to_context); if let Some(context) = context { - template_context.extend(context); + self.context.extend(context); } - self.tera - .render(self.template.kind.name(), &template_context) + self.tera.render(self.template.kind.name(), &self.context) } // From git-cliff: https://github.com/orhun/git-cliff/blob/main/git-cliff-core/src/template.rs diff --git a/src/conventional/changelog/template.rs b/src/conventional/changelog/template.rs index 218324cb..50f9fbc1 100644 --- a/src/conventional/changelog/template.rs +++ b/src/conventional/changelog/template.rs @@ -1,6 +1,10 @@ use crate::conventional::changelog::error::ChangelogError; + +use serde::Serialize; + use std::io; use std::path::PathBuf; +use tera::Context; const DEFAULT_TEMPLATE: &[u8] = include_bytes!("template/simple"); const DEFAULT_TEMPLATE_NAME: &str = "default"; @@ -9,9 +13,23 @@ const REMOTE_TEMPLATE_NAME: &str = "remote"; const FULL_HASH_TEMPLATE: &[u8] = include_bytes!("template/full_hash"); const FULL_HASH_TEMPLATE_NAME: &str = "full_hash"; +const PACKAGE_DEFAULT_TEMPLATE: &[u8] = include_bytes!("template/package_simple"); +const PACKAGE_DEFAULT_TEMPLATE_NAME: &str = "package_default"; +const PACKAGE_REMOTE_TEMPLATE: &[u8] = include_bytes!("template/package_remote"); +const PACKAGE_REMOTE_TEMPLATE_NAME: &str = "package_remote"; +const PACKAGE_FULL_HASH_TEMPLATE: &[u8] = include_bytes!("template/package_full_hash"); +const PACKAGE_FULL_HASH_TEMPLATE_NAME: &str = "package_full_hash"; + +const MONOREPO_DEFAULT_TEMPLATE: &[u8] = include_bytes!("template/monorepo_simple"); +const MONOREPO_DEFAULT_TEMPLATE_NAME: &str = "monorepo_default"; +const MONOREPO_REMOTE_TEMPLATE: &[u8] = include_bytes!("template/monorepo_remote"); +const MONOREPO_REMOTE_TEMPLATE_NAME: &str = "monorepo_remote"; +const MONOREPO_FULL_HASH_TEMPLATE: &[u8] = include_bytes!("template/monorepo_full_hash"); +const MONOREPO_FULL_HASH_TEMPLATE_NAME: &str = "monorepo_full_hash"; + #[derive(Debug, Default)] pub struct Template { - pub context: Option, + pub remote_context: Option, pub kind: TemplateKind, } @@ -20,7 +38,7 @@ impl Template { let template = TemplateKind::from_arg(value)?; Ok(Template { - context, + remote_context: context, kind: template, }) } @@ -31,6 +49,12 @@ pub enum TemplateKind { Default, FullHash, Remote, + PackageDefault, + PackageFullHash, + PackageRemote, + MonorepoDefault, + MonorepoFullHash, + MonorepoRemote, Custom(PathBuf), } @@ -47,6 +71,12 @@ impl TemplateKind { DEFAULT_TEMPLATE_NAME => Ok(TemplateKind::Default), REMOTE_TEMPLATE_NAME => Ok(TemplateKind::Remote), FULL_HASH_TEMPLATE_NAME => Ok(TemplateKind::FullHash), + PACKAGE_DEFAULT_TEMPLATE_NAME => Ok(TemplateKind::PackageDefault), + PACKAGE_REMOTE_TEMPLATE_NAME => Ok(TemplateKind::PackageRemote), + PACKAGE_FULL_HASH_TEMPLATE_NAME => Ok(TemplateKind::PackageFullHash), + MONOREPO_DEFAULT_TEMPLATE_NAME => Ok(TemplateKind::MonorepoDefault), + MONOREPO_REMOTE_TEMPLATE_NAME => Ok(TemplateKind::MonorepoRemote), + MONOREPO_FULL_HASH_TEMPLATE_NAME => Ok(TemplateKind::MonorepoFullHash), path => { let path = PathBuf::from(path); if !path.exists() { @@ -63,6 +93,12 @@ impl TemplateKind { TemplateKind::Default => Ok(DEFAULT_TEMPLATE.to_vec()), TemplateKind::Remote => Ok(REMOTE_TEMPLATE.to_vec()), TemplateKind::FullHash => Ok(FULL_HASH_TEMPLATE.to_vec()), + TemplateKind::PackageDefault => Ok(PACKAGE_DEFAULT_TEMPLATE.to_vec()), + TemplateKind::PackageRemote => Ok(PACKAGE_REMOTE_TEMPLATE.to_vec()), + TemplateKind::PackageFullHash => Ok(PACKAGE_FULL_HASH_TEMPLATE.to_vec()), + TemplateKind::MonorepoDefault => Ok(MONOREPO_DEFAULT_TEMPLATE.to_vec()), + TemplateKind::MonorepoRemote => Ok(MONOREPO_REMOTE_TEMPLATE.to_vec()), + TemplateKind::MonorepoFullHash => Ok(MONOREPO_FULL_HASH_TEMPLATE.to_vec()), TemplateKind::Custom(path) => std::fs::read(path), } } @@ -72,6 +108,12 @@ impl TemplateKind { TemplateKind::Default => DEFAULT_TEMPLATE_NAME, TemplateKind::Remote => REMOTE_TEMPLATE_NAME, TemplateKind::FullHash => FULL_HASH_TEMPLATE_NAME, + TemplateKind::PackageDefault => PACKAGE_DEFAULT_TEMPLATE_NAME, + TemplateKind::PackageRemote => PACKAGE_REMOTE_TEMPLATE_NAME, + TemplateKind::PackageFullHash => PACKAGE_FULL_HASH_TEMPLATE_NAME, + TemplateKind::MonorepoDefault => MONOREPO_DEFAULT_TEMPLATE_NAME, + TemplateKind::MonorepoRemote => MONOREPO_REMOTE_TEMPLATE_NAME, + TemplateKind::MonorepoFullHash => MONOREPO_FULL_HASH_TEMPLATE_NAME, TemplateKind::Custom(_) => "custom_template", } } @@ -85,6 +127,58 @@ pub struct RemoteContext { owner: String, } +#[derive(Debug)] +pub struct MonoRepoContext<'a> { + pub packages: Vec>, +} + +#[derive(Debug, Serialize)] +pub struct PackageBumpContext<'a> { + pub package_name: &'a str, + pub package_path: &'a str, + pub new_version: String, + pub old_version: Option, +} + +#[derive(Debug)] +pub struct PackageContext<'a> { + pub package_name: &'a str, +} + +pub(crate) trait ToContext { + fn to_context(&self) -> Context; +} + +impl ToContext for MonoRepoContext<'_> { + fn to_context(&self) -> Context { + let mut context = tera::Context::new(); + context.insert("packages", &self.packages); + context + } +} + +impl<'a> ToContext for PackageContext<'a> { + fn to_context(&self) -> Context { + let mut context = tera::Context::new(); + context.insert("package_name", &self.package_name); + context + } +} + +impl ToContext for RemoteContext { + fn to_context(&self) -> Context { + let mut context = tera::Context::new(); + context.insert("platform", &format!("https://{}", self.remote.as_str())); + context.insert("owner", self.owner.as_str()); + context.insert( + "repository_url", + &format!("https://{}/{}/{}", self.remote, self.owner, self.repository), + ); + + context + } +} + impl RemoteContext { pub fn try_new( remote: Option, @@ -101,16 +195,4 @@ impl RemoteContext { _ => panic!("Changelog remote context should be set. Missing one of 'remote', 'repository', 'owner' in changelog configuration") } } - - pub(crate) fn to_tera_context(&self) -> tera::Context { - let mut context = tera::Context::new(); - context.insert("platform", &format!("https://{}", self.remote.as_str())); - context.insert("owner", self.owner.as_str()); - context.insert( - "repository_url", - &format!("https://{}/{}/{}", self.remote, self.owner, self.repository), - ); - - context - } } diff --git a/src/conventional/changelog/template/monorepo_full_hash b/src/conventional/changelog/template/monorepo_full_hash new file mode 100644 index 00000000..8dfdd38d --- /dev/null +++ b/src/conventional/changelog/template/monorepo_full_hash @@ -0,0 +1,34 @@ +### Package bump +{% for package in packages -%} + {{ package_name }} bumped to {{ new_version }} +{% endfor -%} + + +### Commits +{% for type, typed_commits in commits | sort(attribute="type")| group_by(attribute="type") -%} +#### {{ type | upper_first }} +{% for scope, scoped_commits in typed_commits | group_by(attribute="scope") -%} + +{% for commit in scoped_commits | sort(attribute="scope") -%} + {% if commit.author -%} + {% set author = "@" ~ commit.author -%} + {% else -%} + {% set author = commit.signature -%} + {% endif -%} + - {{ commit.id }} - **({{ scope }})** {{ commit.summary }} - {{ author }} +{% endfor -%} + +{% endfor -%} + +{% for commit in typed_commits | unscoped -%} + + {% if commit.author -%} + {% set author = "@" ~ commit.author -%} + {% else -%} + {% set author = commit.signature -%} + {% endif -%} + - {{ commit.id }} - {{ commit.summary }} - {{ author }} + +{% endfor -%} + +{% endfor -%} \ No newline at end of file diff --git a/src/conventional/changelog/template/monorepo_remote b/src/conventional/changelog/template/monorepo_remote new file mode 100644 index 00000000..33b396a4 --- /dev/null +++ b/src/conventional/changelog/template/monorepo_remote @@ -0,0 +1,53 @@ +{% if version.tag and from.tag -%} + ## [{{ version.tag }}]({{repository_url ~ "/compare/" ~ from.tag ~ ".." ~ version.tag}}) - {{ date | date(format="%Y-%m-%d") }} +{% elif version.tag and from.id -%} + ## [{{ version.tag }}]({{repository_url ~ "/compare/" ~ from.id ~ ".." ~ version.tag}}) - {{ date | date(format="%Y-%m-%d") }} +{% else -%} + {% set from = from.id -%} + {% set to = version.id -%} + + {% set from_shorthand = from.id | truncate(length=7, end="") -%} + {% set to_shorthand = version.id | truncate(length=7, end="") -%} + + ## Unreleased ([{{ from_shorthand ~ ".." ~ to_shorthand }}]({{repository_url ~ "/compare/" ~ from_shorthand ~ ".." ~ to_shorthand}})) +{% endif -%} + +### Package bumps +{% for package in packages -%} + {{ package_name }} bumped to {{ new_version }} +{% endfor -%} + +### Global changes +{% for type, typed_commits in commits | sort(attribute="type")| group_by(attribute="type")-%} +#### {{ type | upper_first }} +{% for scope, scoped_commits in typed_commits | group_by(attribute="scope") -%} + +{% for commit in scoped_commits | sort(attribute="scope") -%} + {% if commit.author and repository_url -%} + {% set author = "@" ~ commit.author -%} + {% set author_link = platform ~ "/" ~ commit.author -%} + {% set author = "[" ~ author ~ "](" ~ author_link ~ ")" -%} + {% else -%} + {% set author = commit.signature -%} + {% endif -%} + {% set commit_link = repository_url ~ "/commit/" ~ commit.id -%} + {% set shorthand = commit.id | truncate(length=7, end="") -%} + - **({{ scope }})** {{ commit.summary }} - ([{{shorthand}}]({{ commit_link }})) - {{ author }} +{% endfor -%} + +{% endfor -%} + +{% for commit in typed_commits | unscoped -%} + {% if commit.author and repository_url -%} + {% set author = "@" ~ commit.author -%} + {% set author_link = platform ~ "/" ~ commit.author -%} + {% set author = "[" ~ author ~ "](" ~ author_link ~ ")" -%} + {% else -%} + {% set author = commit.signature -%} + {% endif -%} + {% set commit_link = repository_url ~ "/commit/" ~ commit.id -%} + {% set shorthand = commit.id | truncate(length=7, end="") -%} + - {{ commit.summary }} - ([{{shorthand}}]({{ commit_link }})) - {{ author }} +{% endfor -%} + +{% endfor -%} \ No newline at end of file diff --git a/src/conventional/changelog/template/monorepo_simple b/src/conventional/changelog/template/monorepo_simple new file mode 100644 index 00000000..1dd41795 --- /dev/null +++ b/src/conventional/changelog/template/monorepo_simple @@ -0,0 +1,47 @@ +{% if version.tag -%} + ## {{ version.tag }} - {{ date | date(format="%Y-%m-%d") }} +{% else -%} + {% set from = commits | last -%} + {% set to = version.id-%} + {% set from_shorthand = from.id | truncate(length=7, end="") -%} + {% set to_shorthand = to | truncate(length=7, end="") -%} + ## Unreleased ({{ from_shorthand ~ ".." ~ to_shorthand }}) +{% endif -%} + +### Package bumps + +{% for package in packages -%} +- {{ package.package_name }} bumped to {{ package.new_version }} +{% endfor -%} + +### Global changes +{% for type, typed_commits in commits | sort(attribute="type")| group_by(attribute="type")-%} +#### {{ type | upper_first }} +{% for scope, scoped_commits in typed_commits | group_by(attribute="scope") -%} + +{% for commit in scoped_commits | sort(attribute="scope") -%} + + {% if commit.author -%} + {% set author = "*" ~ commit.author ~ "*" -%} + {% else -%} + {% set author = commit.signature -%} + {% endif -%} + + {% set shorthand = commit.id | truncate(length=7, end="") -%} + - **({{ scope }})** {{ commit.summary }} - ({{shorthand}}) - {{ author }} +{% endfor -%} + +{% endfor -%} + +{%- for commit in typed_commits | unscoped -%} + {% if commit.author -%} + {% set author = commit.author -%} + {% else -%} + {% set author = commit.signature -%} + {% endif -%} + + {% set shorthand = commit.id | truncate(length=7, end="") -%} + - {{ commit.summary }} - ({{ shorthand }}) - {{ author }} +{% endfor -%} + +{% endfor -%} \ No newline at end of file diff --git a/src/conventional/changelog/template/package_full_hash b/src/conventional/changelog/template/package_full_hash new file mode 100644 index 00000000..26566fca --- /dev/null +++ b/src/conventional/changelog/template/package_full_hash @@ -0,0 +1,27 @@ +{% for type, typed_commits in commits | sort(attribute="type")| group_by(attribute="type") -%} +#### {{ type | upper_first }} +{% for scope, scoped_commits in typed_commits | group_by(attribute="scope") -%} + +{% for commit in scoped_commits | sort(attribute="scope") -%} + {% if commit.author -%} + {% set author = "@" ~ commit.author -%} + {% else -%} + {% set author = commit.signature -%} + {% endif -%} + - {{ commit.id }} - **({{ scope }})** {{ commit.summary }} - {{ author }} +{% endfor -%} + +{% endfor -%} + +{% for commit in typed_commits | unscoped -%} + + {% if commit.author -%} + {% set author = "@" ~ commit.author -%} + {% else -%} + {% set author = commit.signature -%} + {% endif -%} + - {{ commit.id }} - {{ commit.summary }} - {{ author }} + +{% endfor -%} + +{% endfor -%} \ No newline at end of file diff --git a/src/conventional/changelog/template/package_remote b/src/conventional/changelog/template/package_remote new file mode 100644 index 00000000..0062f109 --- /dev/null +++ b/src/conventional/changelog/template/package_remote @@ -0,0 +1,48 @@ +{% if version.tag and from.tag -%} + ## [{{ version.tag }}]({{repository_url ~ "/compare/" ~ from.tag ~ ".." ~ version.tag}}) - {{ date | date(format="%Y-%m-%d") }} +{% elif version.tag and from.id -%} + ## [{{ version.tag }}]({{repository_url ~ "/compare/" ~ from.id ~ ".." ~ version.tag}}) - {{ date | date(format="%Y-%m-%d") }} +{% else -%} + {% set from = from.id -%} + {% set to = version.id -%} + + {% set from_shorthand = from.id | truncate(length=7, end="") -%} + {% set to_shorthand = version.id | truncate(length=7, end="") -%} + + ## Unreleased ([{{ from_shorthand ~ ".." ~ to_shorthand }}]({{repository_url ~ "/compare/" ~ from_shorthand ~ ".." ~ to_shorthand}})) +{% endif -%} + +{% for type, typed_commits in commits | sort(attribute="type")| group_by(attribute="type")-%} + +#### {{ type | upper_first }} +{% for scope, scoped_commits in typed_commits | group_by(attribute="scope") -%} + +{% for commit in scoped_commits | sort(attribute="scope") -%} + {% if commit.author and repository_url -%} + {% set author = "@" ~ commit.author -%} + {% set author_link = platform ~ "/" ~ commit.author -%} + {% set author = "[" ~ author ~ "](" ~ author_link ~ ")" -%} + {% else -%} + {% set author = commit.signature -%} + {% endif -%} + {% set commit_link = repository_url ~ "/commit/" ~ commit.id -%} + {% set shorthand = commit.id | truncate(length=7, end="") -%} + - **({{ scope }})** {{ commit.summary }} - ([{{shorthand}}]({{ commit_link }})) - {{ author }} +{% endfor -%} + +{% endfor -%} + +{% for commit in typed_commits | unscoped -%} + {% if commit.author and repository_url -%} + {% set author = "@" ~ commit.author -%} + {% set author_link = platform ~ "/" ~ commit.author -%} + {% set author = "[" ~ author ~ "](" ~ author_link ~ ")" -%} + {% else -%} + {% set author = commit.signature -%} + {% endif -%} + {% set commit_link = repository_url ~ "/commit/" ~ commit.id -%} + {% set shorthand = commit.id | truncate(length=7, end="") -%} + - {{ commit.summary }} - ([{{shorthand}}]({{ commit_link }})) - {{ author }} +{% endfor -%} + +{% endfor -%} \ No newline at end of file diff --git a/src/conventional/changelog/template/package_simple b/src/conventional/changelog/template/package_simple new file mode 100644 index 00000000..557261af --- /dev/null +++ b/src/conventional/changelog/template/package_simple @@ -0,0 +1,40 @@ +{% if version.tag -%} + ## {{ version.tag }} - {{ date | date(format="%Y-%m-%d") }} +{% else -%} + {% set from = commits | last -%} + {% set to = version.id-%} + {% set from_shorthand = from.id | truncate(length=7, end="") -%} + {% set to_shorthand = to | truncate(length=7, end="") -%} + ## Unreleased ({{ from_shorthand ~ ".." ~ to_shorthand }}) +{% endif -%} + +{% for type, typed_commits in commits | sort(attribute="type")| group_by(attribute="type")-%} +#### {{ type | upper_first }} +{% for scope, scoped_commits in typed_commits | group_by(attribute="scope") -%} + +{% for commit in scoped_commits | sort(attribute="scope") -%} + + {% if commit.author -%} + {% set author = "*" ~ commit.author ~ "*" -%} + {% else -%} + {% set author = commit.signature -%} + {% endif -%} + + {% set shorthand = commit.id | truncate(length=7, end="") -%} + - **({{ scope }})** {{ commit.summary }} - ({{shorthand}}) - {{ author }} +{% endfor -%} + +{% endfor -%} + +{%- for commit in typed_commits | unscoped -%} + {% if commit.author -%} + {% set author = commit.author -%} + {% else -%} + {% set author = commit.signature -%} + {% endif -%} + + {% set shorthand = commit.id | truncate(length=7, end="") -%} + - {{ commit.summary }} - ({{ shorthand }}) - {{ author }} +{% endfor -%} + +{% endfor -%} \ No newline at end of file diff --git a/src/conventional/version.rs b/src/conventional/version.rs index f54f9b5c..6281eb5e 100644 --- a/src/conventional/version.rs +++ b/src/conventional/version.rs @@ -1,89 +1,49 @@ -use crate::conventional::commit::Commit; -use colored::*; -use conventional_commit_parser::commit::CommitType; -use git2::Commit as Git2Commit; -use itertools::Itertools; -use log::info; -use std::fmt; -use std::fmt::Write; +use std::cmp::Ordering; #[derive(Debug, PartialEq, Eq)] -pub enum VersionIncrement { +pub enum IncrementCommand { Major, Minor, Patch, Auto, + AutoPackage(String), + AutoMonoRepoGlobal(Option), Manual(String), } -impl VersionIncrement { - // TODO: move that to a dedicated CLI display module - pub(crate) fn display_history(commits: &[&Git2Commit]) -> Result<(), fmt::Error> { - let conventional_commits: Vec> = commits - .iter() - .map(|commit| Commit::from_git_commit(commit)) - .collect(); - - // Commits which type are neither feat, fix nor breaking changes - // won't affect the version number. - let mut non_bump_commits: Vec<&CommitType> = conventional_commits - .iter() - .filter_map(|commit| match commit { - Ok(commit) => match commit.message.commit_type { - CommitType::Feature | CommitType::BugFix => None, - _ => Some(&commit.message.commit_type), - }, - Err(_) => None, - }) - .collect(); - - non_bump_commits.sort(); - - let non_bump_commits: Vec<(usize, &CommitType)> = non_bump_commits - .into_iter() - .dedup_by_with_count(|c1, c2| c1 == c2) - .collect(); - - if !non_bump_commits.is_empty() { - let mut skip_message = "\tSkipping irrelevant commits:\n".to_string(); - for (count, commit_type) in non_bump_commits { - writeln!(skip_message, "\t\t- {}: {}", commit_type.as_ref(), count)?; - } +#[derive(Debug, PartialEq, Eq, Copy, Clone)] +pub enum Increment { + Major, + Minor, + Patch, +} - info!("{}", skip_message); +impl From for IncrementCommand { + fn from(value: Increment) -> Self { + match value { + Increment::Major => IncrementCommand::Major, + Increment::Minor => IncrementCommand::Minor, + Increment::Patch => IncrementCommand::Patch, } + } +} - let bump_commits = conventional_commits - .iter() - .filter_map(|commit| match commit { - Ok(commit) => match commit.message.commit_type { - CommitType::Feature | CommitType::BugFix => Some(Ok(commit)), - _ => None, - }, - Err(err) => Some(Err(err)), - }); +impl Ord for Increment { + fn cmp(&self, other: &Self) -> Ordering { + self.partial_cmp(other).unwrap() + } +} - for commit in bump_commits { - match commit { - Ok(commit) if commit.message.is_breaking_change => { - info!( - "\t Found {} commit {} with type: {}", - "BREAKING CHANGE".red(), - commit.shorthand().blue(), - commit.message.commit_type.as_ref().yellow() - ) - } - Ok(commit) if commit.message.commit_type == CommitType::BugFix => { - info!("\tFound bug fix commit {}", commit.shorthand().blue()) - } - Ok(commit) if commit.message.commit_type == CommitType::Feature => { - info!("\tFound feature commit {}", commit.shorthand().blue()) - } - _ => (), - } +impl PartialOrd for Increment { + fn partial_cmp(&self, other: &Self) -> Option { + match (self, other) { + (increment, other) if increment == other => Some(Ordering::Equal), + (Increment::Major, _) => Some(Ordering::Greater), + (_, Increment::Major) => Some(Ordering::Less), + (Increment::Minor, _) => Some(Ordering::Greater), + (_, Increment::Minor) => Some(Ordering::Less), + (Increment::Patch, Increment::Patch) => Some(Ordering::Equal), } - - Ok(()) } } diff --git a/src/git/monorepo.rs b/src/git/monorepo.rs index 7deb83e7..0180a97b 100644 --- a/src/git/monorepo.rs +++ b/src/git/monorepo.rs @@ -1,62 +1,8 @@ use crate::git::repository::Repository; -use crate::git::revspec::CommitRange; -use crate::{Git2Error, OidOf, RevspecPattern, Tag, TagError}; -use std::path::Path; +use crate::{Tag, TagError}; impl Repository { - /// Get commits from latest tag and return a map of commit ranges by their respective packages. - pub fn get_commit_range_filtered( - &self, - start: Option, - pattern: &RevspecPattern, - path_filter: impl Fn(&Path) -> bool, - ) -> Result, Git2Error> { - let range = self.get_commit_range(pattern)?; - - let mut commits = vec![]; - - for commit in range.commits { - let parent = commit.parent(0)?.id().to_string(); - let t1 = self - .tree_to_treeish(Some(&parent))? - .expect("Failed to get parent tree"); - - let t2 = self - .tree_to_treeish(Some(&commit.id().to_string()))? - .expect("Failed to get commit tree"); - - let diff = self.0.diff_tree_to_tree(t1.as_tree(), t2.as_tree(), None)?; - - for delta in diff.deltas() { - if let Some(old) = delta.old_file().path() { - if path_filter(old) { - commits.push(commit); - break; - } - } - - if let Some(new) = delta.new_file().path() { - if path_filter(new) { - commits.push(commit); - break; - } - } - } - } - - if !commits.is_empty() { - Ok(Some(CommitRange { - from: start.unwrap_or(OidOf::Other(commits.first().unwrap().id())), - // Safe unwrap, matches are not empty - to: OidOf::Other(commits.last().unwrap().id()), - commits, - })) - } else { - Ok(None) - } - } - /// Get the latest SemVer tag for a given monorepo package. pub fn get_latest_package_tag(&self, package_prefix: &str) -> Result { let tags: Vec = self.all_tags()?; @@ -81,7 +27,6 @@ mod test { use indoc::formatdoc; use sealed_test::prelude::*; use speculoos::prelude::*; - use std::path::PathBuf; #[sealed_test] fn get_repo_packages() -> Result<()> { @@ -122,14 +67,10 @@ mod test { )?; // Act - let range = - repo.get_commit_range_filtered(None, &RevspecPattern::from("..HEAD"), |path| { - path.starts_with(PathBuf::from("two")) - })?; + let range = repo.get_commit_range_for_package(&RevspecPattern::from("..HEAD"), "two")?; // Assert assert_that!(range) - .is_some() .map(|range| &range.commits) .has_length(2); diff --git a/src/git/revspec.rs b/src/git/revspec.rs index afab5097..79fe46f8 100644 --- a/src/git/revspec.rs +++ b/src/git/revspec.rs @@ -8,6 +8,7 @@ use crate::git::error::Git2Error; use crate::git::oid::OidOf; use crate::git::repository::Repository; use crate::git::tag::Tag; +use crate::SETTINGS; #[derive(Debug)] pub struct CommitRange<'repo> { @@ -210,6 +211,92 @@ impl Repository { Ok(CommitRange { from, to, commits }) } + pub fn get_commit_range_for_package( + &self, + pattern: &RevspecPattern, + package: &str, + ) -> Result { + let mut commit_range = self.get_commit_range(pattern)?; + let mut commits = vec![]; + let package = SETTINGS.packages.get(package).expect("package exists"); + for commit in commit_range.commits { + let parent = commit.parent(0)?.id().to_string(); + let t1 = self + .tree_to_treeish(Some(&parent))? + .expect("Failed to get parent tree"); + + let t2 = self + .tree_to_treeish(Some(&commit.id().to_string()))? + .expect("Failed to get commit tree"); + + let diff = self.0.diff_tree_to_tree(t1.as_tree(), t2.as_tree(), None)?; + + for delta in diff.deltas() { + if let Some(old) = delta.old_file().path() { + if old.starts_with(&package.path) { + commits.push(commit); + break; + } + } + + if let Some(new) = delta.new_file().path() { + if new.starts_with(&package.path) { + commits.push(commit); + break; + } + } + } + } + + commit_range.commits = commits; + Ok(commit_range) + } + + pub fn get_commit_range_for_monorepo_global( + &self, + pattern: &RevspecPattern, + ) -> Result { + let mut commit_range = self.get_commit_range(pattern)?; + let mut commits = vec![]; + let package_paths: Vec<_> = SETTINGS + .packages + .values() + .map(|package| &package.path) + .collect(); + + for commit in commit_range.commits { + let parent = commit.parent(0)?.id().to_string(); + let t1 = self + .tree_to_treeish(Some(&parent))? + .expect("Failed to get parent tree"); + + let t2 = self + .tree_to_treeish(Some(&commit.id().to_string()))? + .expect("Failed to get commit tree"); + + let diff = self.0.diff_tree_to_tree(t1.as_tree(), t2.as_tree(), None)?; + + for delta in diff.deltas() { + if let Some(old) = delta.old_file().path() { + if package_paths.iter().all(|path| !old.starts_with(path)) { + commits.push(commit); + break; + } + } + + if let Some(new) = delta.new_file().path() { + if package_paths.iter().all(|path| !new.starts_with(path)) { + commits.push(commit); + break; + } + } + } + } + + commit_range.commits = commits; + Ok(commit_range) + } + fn resolve_oid_of(&self, from: &str) -> OidOf { // either we have a tag name self.resolve_tag(from) @@ -303,11 +390,14 @@ mod test { use git2::Oid; use sealed_test::prelude::*; use speculoos::prelude::*; + use std::collections::HashMap; + use std::path::PathBuf; use crate::git::oid::OidOf; use crate::git::repository::Repository; use crate::git::revspec::RevspecPattern; use crate::git::tag::Tag; + use crate::settings::{MonoRepoPackage, Settings}; const COCOGITTO_REPOSITORY: &str = env!("CARGO_MANIFEST_DIR"); @@ -431,6 +521,50 @@ mod test { Ok(()) } + #[sealed_test] + fn get_package_commit_range() -> Result<()> { + // Arrange + let repo = Repository::init(".")?; + let mut packages = HashMap::new(); + packages.insert( + "one".to_string(), + MonoRepoPackage { + path: PathBuf::from("one"), + ..Default::default() + }, + ); + + let settings = Settings { + packages, + ..Default::default() + }; + + let settings = toml::to_string(&settings)?; + + run_cmd!( + git init; + echo $settings > cog.toml; + git add .; + git commit -m "chore: First commit"; + mkdir one; + echo changes > one/file; + git add .; + git commit -m "feat: package one"; + echo changes > global; + git add .; + git commit -m "feat: global change"; + )?; + + let commit_range_package = + repo.get_commit_range_for_package(&RevspecPattern::from("..HEAD"), "one")?; + let commit_range_global = + repo.get_commit_range_for_monorepo_global(&RevspecPattern::from("..HEAD"))?; + + assert_that!(commit_range_package.commits).has_length(1); + assert_that!(commit_range_global.commits).has_length(1); + Ok(()) + } + #[sealed_test] fn get_tag_commits() -> Result<()> { // Arrange diff --git a/src/git/tag.rs b/src/git/tag.rs index 701e5059..36f9cc9f 100644 --- a/src/git/tag.rs +++ b/src/git/tag.rs @@ -1,3 +1,4 @@ +use crate::conventional::version::Increment; use crate::git::error::{Git2Error, TagError}; use crate::git::repository::Repository; use crate::SETTINGS; @@ -211,6 +212,18 @@ impl Tag { pub(crate) fn is_zero(&self) -> bool { self.version == Version::new(0, 0, 0) } + + pub(crate) fn get_increment_from(&self, other: &Tag) -> Option { + if self.version.major > other.version.major { + Some(Increment::Major) + } else if self.version.minor > other.version.minor { + Some(Increment::Minor) + } else if self.version.patch > other.version.patch { + Some(Increment::Patch) + } else { + None + } + } } impl fmt::Display for Tag { diff --git a/src/lib.rs b/src/lib.rs index 322349ae..60c8a19f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,7 +8,7 @@ use conventional_commit_parser::parse_footers; use lazy_static::lazy_static; use conventional::commit::{Commit, CommitConfig}; -use conventional::version::VersionIncrement; +use conventional::version::IncrementCommand; use error::PreHookError; use git::repository::Repository; @@ -16,7 +16,6 @@ use settings::{HookType, Settings}; use crate::git::error::{Git2Error, TagError}; -use crate::git::oid::OidOf; use crate::git::revspec::RevspecPattern; use crate::git::tag::Tag; @@ -42,7 +41,7 @@ lazy_static! { }; // This cannot be carried by `Cocogitto` struct since we need it to be available in `Changelog`, - // `Commit` etc. Be ensure that `CocoGitto::new` is called before using this in order to bypass + // `Commit` etc. Be sure that `CocoGitto::new` is called before using this in order to bypass // unwrapping in case of error. pub static ref COMMITS_METADATA: CommitsMetadata = { SETTINGS.commit_types() diff --git a/src/settings/mod.rs b/src/settings/mod.rs index 29ed6cc2..06718617 100644 --- a/src/settings/mod.rs +++ b/src/settings/mod.rs @@ -12,6 +12,7 @@ use crate::settings::error::SettingError; use config::{Config, File}; use conventional_commit_parser::commit::CommitType; use serde::{Deserialize, Serialize}; +use std::path::Path; type CommitsMetadataSettings = HashMap; pub(crate) type AuthorSettings = Vec; @@ -41,6 +42,10 @@ pub struct Settings { #[serde(default)] pub post_bump_hooks: Vec, #[serde(default)] + pub pre_package_bump_hooks: Vec, + #[serde(default)] + pub post_package_bump_hooks: Vec, + #[serde(default)] pub commit_types: CommitsMetadataSettings, #[serde(default)] pub changelog: Changelog, @@ -50,16 +55,38 @@ pub struct Settings { pub packages: HashMap, } -#[derive(Debug, Deserialize, Serialize, Eq, PartialEq, Default)] +#[derive(Debug, Deserialize, Serialize, Eq, PartialEq)] #[serde(deny_unknown_fields, default)] pub struct MonoRepoPackage { + /// The package path, relative to the repository root dir. + /// Used to scan commits and set hook commands current directory pub path: PathBuf, + /// Where to write the changelog pub changelog_path: Option, - pub pre_bump_hooks: Vec, - pub post_bump_hooks: Vec, + /// Bumping package marked as public api will increment + /// the global monorepo version + pub public_api: bool, + /// Overrides `pre_package_bump_hooks` + pub pre_bump_hooks: Option>, + /// Overrides `post_package_bump_hooks` + pub post_bump_hooks: Option>, + /// Custom profile to override `pre_bump_hooks`, `post_bump_hooks` pub bump_profiles: HashMap, } +impl Default for MonoRepoPackage { + fn default() -> Self { + Self { + path: Default::default(), + changelog_path: None, + pre_bump_hooks: None, + post_bump_hooks: None, + bump_profiles: Default::default(), + public_api: true, + } + } +} + impl MonoRepoPackage { pub fn changelog_path(&self) -> PathBuf { self.changelog_path @@ -197,6 +224,28 @@ impl Settings { Template::from_arg(template, context) } + pub fn get_package_changelog_template(&self) -> Result { + let context = self.get_template_context(); + let template = self + .changelog + .template + .as_deref() + .unwrap_or("package_default"); + + Template::from_arg(template, context) + } + + pub fn get_monorepo_changelog_template(&self) -> Result { + let context = self.get_template_context(); + let template = self + .changelog + .template + .as_deref() + .unwrap_or("monorepo_default"); + + Template::from_arg(template, context) + } + pub fn monorepo_separator(&self) -> Option<&str> { if self.packages.is_empty() { None @@ -204,6 +253,10 @@ impl Settings { self.monorepo_version_separator.as_deref().or(Some("-")) } } + + pub fn package_paths(&self) -> impl Iterator { + self.packages.values().map(|package| package.path.as_path()) + } } impl Hooks for Settings { @@ -226,10 +279,14 @@ impl Hooks for MonoRepoPackage { } fn pre_bump_hooks(&self) -> &Vec { - &self.pre_bump_hooks + self.pre_bump_hooks + .as_ref() + .unwrap_or(&SETTINGS.pre_package_bump_hooks) } fn post_bump_hooks(&self) -> &Vec { - &self.post_bump_hooks + self.post_bump_hooks + .as_ref() + .unwrap_or(&SETTINGS.post_package_bump_hooks) } } diff --git a/tests/lib_tests/bump.rs b/tests/lib_tests/bump.rs index 37a82ebe..8e53caa6 100644 --- a/tests/lib_tests/bump.rs +++ b/tests/lib_tests/bump.rs @@ -1,7 +1,7 @@ use anyhow::Result; use cmd_lib::run_cmd; -use cocogitto::{conventional::version::VersionIncrement, CocoGitto}; +use cocogitto::{conventional::version::IncrementCommand, CocoGitto}; use sealed_test::prelude::*; use speculoos::prelude::*; @@ -19,7 +19,7 @@ fn bump_ok() -> Result<()> { let mut cocogitto = CocoGitto::get()?; // Act - let result = cocogitto.create_version(VersionIncrement::Auto, None, None, false); + let result = cocogitto.create_version(IncrementCommand::Auto, None, None, false); // Assert assert_that!(result).is_ok(); @@ -37,7 +37,7 @@ fn should_fallback_to_0_0_0_when_there_is_no_tag() -> Result<()> { let mut cocogitto = CocoGitto::get()?; // Act - let result = cocogitto.create_version(VersionIncrement::Auto, None, None, false); + let result = cocogitto.create_version(IncrementCommand::Auto, None, None, false); // Assert assert_that!(result).is_ok(); @@ -90,7 +90,7 @@ fn bump_with_whitelisted_branch_ok() -> Result<()> { let mut cocogitto = CocoGitto::get()?; // Act - let result = cocogitto.create_version(VersionIncrement::Auto, None, None, false); + let result = cocogitto.create_version(IncrementCommand::Auto, None, None, false); // Assert assert_that!(result).is_ok(); @@ -115,7 +115,7 @@ fn bump_with_whitelisted_branch_fails() -> Result<()> { let mut cocogitto = CocoGitto::get()?; // Act - let result = cocogitto.create_version(VersionIncrement::Auto, None, None, false); + let result = cocogitto.create_version(IncrementCommand::Auto, None, None, false); // Assert assert_that!(result.unwrap_err().to_string()).is_equal_to( @@ -144,7 +144,7 @@ fn bump_with_whitelisted_branch_pattern_ok() -> Result<()> { let mut cocogitto = CocoGitto::get()?; // Act - let result = cocogitto.create_version(VersionIncrement::Auto, None, None, false); + let result = cocogitto.create_version(IncrementCommand::Auto, None, None, false); // Assert assert_that!(result).is_ok(); @@ -169,7 +169,7 @@ fn bump_with_whitelisted_branch_pattern_err() -> Result<()> { let mut cocogitto = CocoGitto::get()?; // Act - let result = cocogitto.create_version(VersionIncrement::Auto, None, None, false); + let result = cocogitto.create_version(IncrementCommand::Auto, None, None, false); // Assert assert_that!(result).is_err(); From 84e41352c96b5efb26e7f0617f9c87cad87a596b Mon Sep 17 00:00:00 2001 From: Paul Delafosse Date: Wed, 18 Jan 2023 11:53:00 +0100 Subject: [PATCH 08/26] fix(git): fix list tag for monorepos --- src/command/bump/mod.rs | 18 ++- src/command/bump/monorepo.rs | 4 + src/command/bump/package.rs | 2 + src/command/bump/standard.rs | 2 + src/conventional/bump.rs | 216 +++++++++++++++++++++++++++++++++-- src/conventional/scope.rs | 0 src/git/tag.rs | 89 ++++++++++++--- 7 files changed, 300 insertions(+), 31 deletions(-) delete mode 100644 src/conventional/scope.rs diff --git a/src/command/bump/mod.rs b/src/command/bump/mod.rs index 31ef0fa5..b680dcf0 100644 --- a/src/command/bump/mod.rs +++ b/src/command/bump/mod.rs @@ -150,6 +150,7 @@ impl CocoGitto { current_tag: Option<&HookVersion>, next_version: &HookVersion, hook_profile: Option<&str>, + package_name: Option<&str>, package: Option<&MonoRepoPackage>, ) -> Result<()> { let settings = Settings::get(&self.repository)?; @@ -200,9 +201,20 @@ impl CocoGitto { }; if hooks.is_empty() { - match hook_type { - HookType::PreBump => info!("Running pre-bump hooks"), - HookType::PostBump => info!("Running post-bump hooks"), + let hook_type = match hook_type { + HookType::PreBump => "pre-bump", + HookType::PostBump => "post-bump", + }; + + match package_name { + None => { + let msg = format!("Running {hook_type} hooks").underline(); + info!("{msg}") + } + Some(package_name) => { + let msg = format!("Running {package_name} {hook_type} hooks").underline(); + info!("{msg}") + } } } diff --git a/src/command/bump/monorepo.rs b/src/command/bump/monorepo.rs index 39924a12..a2227756 100644 --- a/src/command/bump/monorepo.rs +++ b/src/command/bump/monorepo.rs @@ -104,6 +104,7 @@ impl CocoGitto { &next_version, hooks_config, None, + None, ); self.repository.add_all()?; @@ -137,6 +138,7 @@ impl CocoGitto { bump.old_version.as_ref(), &bump.new_version, hooks_config, + Some(&bump.package_name), Some(package), )?; } @@ -148,6 +150,7 @@ impl CocoGitto { &next_version, hooks_config, None, + None, )?; Ok(()) @@ -262,6 +265,7 @@ impl CocoGitto { old_version.as_ref(), &new_version, hooks_config, + Some(package_name), Some(package), ); diff --git a/src/command/bump/package.rs b/src/command/bump/package.rs index ecb7fd0a..607e2023 100644 --- a/src/command/bump/package.rs +++ b/src/command/bump/package.rs @@ -63,6 +63,7 @@ impl CocoGitto { current.as_ref(), &next_version, hooks_config, + Some(package_name), Some(package), ); @@ -84,6 +85,7 @@ impl CocoGitto { current.as_ref(), &next_version, hooks_config, + Some(package_name), Some(package), )?; diff --git a/src/command/bump/standard.rs b/src/command/bump/standard.rs index ad43add8..26c40253 100644 --- a/src/command/bump/standard.rs +++ b/src/command/bump/standard.rs @@ -56,6 +56,7 @@ impl CocoGitto { &next_version, hooks_config, None, + None, ); self.repository.add_all()?; @@ -81,6 +82,7 @@ impl CocoGitto { &next_version, hooks_config, None, + None, )?; let current = current diff --git a/src/conventional/bump.rs b/src/conventional/bump.rs index 39374635..95e1faf9 100644 --- a/src/conventional/bump.rs +++ b/src/conventional/bump.rs @@ -56,7 +56,7 @@ impl Bump for Tag { } fn auto_bump(&self, repository: &Repository) -> Result { - self.create_version_from_commit_history(repository) + self.get_version_from_commit_history(repository) } fn auto_global_bump( @@ -67,7 +67,7 @@ impl Bump for Tag { where Self: Sized, { - let tag_from_history = self.create_monorepo_global_version_from_commit_history(repository); + let tag_from_history = self.get_monorepo_global_version_from_commit_history(repository); match (package_increment, tag_from_history) { (Some(package_increment), Ok(tag_from_history)) => { let tag_from_packages = self.bump(package_increment.into(), repository)?; @@ -86,7 +86,7 @@ impl Bump for Tag { where Self: Sized, { - self.create_package_version_from_commit_history(package, repository) + self.get_package_version_from_commit_history(package, repository) } } @@ -116,10 +116,7 @@ impl Tag { self } - fn create_version_from_commit_history( - &self, - repository: &Repository, - ) -> Result { + fn get_version_from_commit_history(&self, repository: &Repository) -> Result { let changelog_start_oid = repository .get_latest_tag_oid() .ok() @@ -155,7 +152,7 @@ impl Tag { }) } - fn create_package_version_from_commit_history( + fn get_package_version_from_commit_history( &self, package: &str, repository: &Repository, @@ -196,7 +193,7 @@ impl Tag { }) } - fn create_monorepo_global_version_from_commit_history( + fn get_monorepo_global_version_from_commit_history( &self, repository: &Repository, ) -> Result { @@ -273,16 +270,22 @@ impl Tag { #[cfg(test)] mod test { + use crate::conventional::bump::Bump; use crate::conventional::commit::Commit; + use crate::conventional::error::BumpError; use crate::conventional::version::{Increment, IncrementCommand}; use crate::git::repository::Repository; use crate::git::tag::Tag; + use crate::settings::{MonoRepoPackage, Settings}; use anyhow::Result; use chrono::Utc; + use cmd_lib::run_cmd; use conventional_commit_parser::commit::{CommitType, ConventionalCommit}; use sealed_test::prelude::*; use semver::Version; use speculoos::prelude::*; + use std::collections::HashMap; + use std::path::PathBuf; use std::str::FromStr; impl Commit { @@ -486,4 +489,199 @@ suggestion: Please see https://conventionalcommits.org/en/v1.0.0/#summary for mo Ok(()) } + + #[sealed_test] + fn get_global_monorepo_version_from_history_should_fail_with_only_package_commit() -> Result<()> + { + // Arrange + let repository = init_monorepo()?; + run_cmd!( + echo "feature" > one; + git add .; + git commit -m "feat: feature package one"; + )?; + + let base_version = Tag::from_str("0.1.0", None)?; + + // Act + let tag = base_version.get_monorepo_global_version_from_commit_history(&repository); + + // Assert + assert_that!(tag) + .is_err() + .matches(|err| matches!(err, BumpError::NoCommitFound)); + + Ok(()) + } + + #[sealed_test] + fn monorepo_auto_bump_should_succeed_with_only_package_commits() -> Result<()> { + // Arrange + let repository = init_monorepo()?; + run_cmd!( + echo "feature" > one; + git add .; + git commit -m "feat: feature package one"; + )?; + let base_version = Tag::from_str("0.1.0", None)?; + + // Act + let tag = base_version.auto_global_bump(&repository, Some(Increment::Minor))?; + + // Assert + assert_that!(tag.version).is_equal_to(Version::new(0, 2, 0)); + + Ok(()) + } + + #[sealed_test] + fn monorepo_auto_bump_should_succeed_with_only_global_commits() -> Result<()> { + // Arrange + let repository = init_monorepo()?; + + run_cmd!( + echo "global" > global; + git add .; + git commit -m "feat: non package commit"; + )?; + + // Act + let tag = Tag::default().auto_global_bump(&repository, None)?; + + // Assert + assert_that!(tag.version).is_equal_to(Version::new(0, 1, 0)); + + Ok(()) + } + + #[sealed_test] + fn monorepo_auto_bump_should_succeed_selecting_history_bump() -> Result<()> { + // Arrange + let repository = init_monorepo()?; + + // Patch increment from global commits + // Minor increment from package bumps + run_cmd!( + echo "global" > global; + git add .; + git commit -m "fix: global fix"; + echo "feature" > one; + git add .; + git commit -m "feat: feature 1 package one"; + )?; + + // Act + let tag = Tag::default().auto_global_bump(&repository, Some(Increment::Minor))?; + + // Assert + assert_that!(tag.version).is_equal_to(Version::new(0, 1, 0)); + + Ok(()) + } + + #[sealed_test] + fn monorepo_auto_bump_should_succeed_selecting_package_bump() -> Result<()> { + // Arrange + let repository = init_monorepo()?; + + // Minor increment from global commits + // Patch increment from package bumps + run_cmd!( + echo "global" > global; + git add .; + git commit -m "feat: global fix"; + echo "feature" > one; + git add .; + git commit -m "fix: fix 1 package one"; + )?; + + // Act + let tag = Tag::default().auto_global_bump(&repository, Some(Increment::Patch))?; + + // Assert + assert_that!(tag.version).is_equal_to(Version::new(0, 1, 0)); + + Ok(()) + } + + #[sealed_test] + fn monorepo_auto_bump_should_succeed_with_equals_history_and_package() -> Result<()> { + // Arrange + let repository = init_monorepo()?; + + // Minor increment from global commits + // Minor increment from package bumps + run_cmd!( + echo "global" > global; + git add .; + git commit -m "feat: global fix"; + echo "feature" > one; + git add .; + git commit -m "feature: package one"; + )?; + + // Act + let tag = Tag::default().auto_global_bump(&repository, Some(Increment::Minor))?; + + // Assert + assert_that!(tag.version).is_equal_to(Version::new(0, 1, 0)); + + Ok(()) + } + + #[sealed_test] + fn monorepo_auto_bump_should_succeed_with_mixed_commit() -> Result<()> { + // Arrange + let repository = init_monorepo()?; + + // Minor increment from global commits + // Minor increment from package bumps + run_cmd!( + echo "start" > start; + git add .; + git commit -m "chore: version"; + git tag "0.1.0"; + git tag "one-0.1.0"; + echo "feature" > one; + echo "global" > global; + git add .; + git commit -m "feature: package one and global"; + )?; + + // Act + let tag = + Tag::from_str("0.1.0", None)?.auto_global_bump(&repository, Some(Increment::Minor))?; + + // Assert + assert_that!(tag.version).is_equal_to(Version::new(0, 2, 0)); + + Ok(()) + } + + fn init_monorepo() -> Result { + let repository = Repository::init(".")?; + let mut packages = HashMap::new(); + packages.insert( + "one".to_string(), + MonoRepoPackage { + path: PathBuf::from("one"), + ..Default::default() + }, + ); + + let settings = Settings { + packages, + ..Default::default() + }; + + let settings = toml::to_string(&settings)?; + + run_cmd!( + echo $settings > cog.toml; + git add .; + git commit -m "chore: first commit"; + )?; + + Ok(repository) + } } diff --git a/src/conventional/scope.rs b/src/conventional/scope.rs deleted file mode 100644 index e69de29b..00000000 diff --git a/src/git/tag.rs b/src/git/tag.rs index 36f9cc9f..68d06b3a 100644 --- a/src/git/tag.rs +++ b/src/git/tag.rs @@ -38,10 +38,13 @@ impl Repository { .map_err(Git2Error::from) } + /// Get the latest tag, will ignore package tag if on a monorepo pub(crate) fn get_latest_tag(&self) -> Result { let tags: Vec = self.all_tags()?; - - tags.into_iter().max().ok_or(TagError::NoTag) + tags.into_iter() + .filter(|tag| tag.package.is_none()) + .max() + .ok_or(TagError::NoTag) } pub(crate) fn all_tags(&self) -> Result, TagError> { @@ -65,33 +68,39 @@ impl Repository { .map(|profile| -> &str { profile }) .collect(); - let tags = if packages.is_empty() { - let pattern = SETTINGS - .tag_prefix - .as_ref() - .map(|prefix| format!("{}*", prefix)); + let pattern = SETTINGS + .tag_prefix + .as_ref() + .map(|prefix| format!("{}*", prefix)); - self.0 - .tag_names(pattern.as_deref()) - .map_err(|err| TagError::NoMatchFound { pattern, err })? - .iter() - .flatten() - .map(str::to_string) - .collect() - } else { + // Collect non packages tags + let mut tags: Vec = self + .0 + .tag_names(pattern.as_deref()) + .map_err(|err| TagError::NoMatchFound { pattern, err })? + .iter() + .flatten() + .map(str::to_string) + .collect(); + + // Extends with packages tags if we are in a mono-repository context + if !packages.is_empty() { let separator = SETTINGS .monorepo_separator() .expect("monorepo_version_separator"); - let tags = self.0.tag_names(None).map_err(|_| TagError::NoTag)?; - tags.into_iter() + let package_tags = self.0.tag_names(None).map_err(|_| TagError::NoTag)?; + + let package_tags = package_tags + .into_iter() .flatten() .filter(|tag| match tag.split_once(separator) { None => false, Some((tag_package, _)) => packages.contains(&tag_package), }) - .map(str::to_string) - .collect() + .map(str::to_string); + + tags.extend(package_tags); }; Ok(tags) @@ -261,6 +270,48 @@ mod test { use std::collections::HashMap; use std::fs; + #[test] + fn should_compare_tags() -> Result<()> { + let v1_0_0 = Tag::from_str("1.0.0", None)?; + let v1_1_0 = Tag::from_str("1.1.0", None)?; + let v2_1_0 = Tag::from_str("2.1.0", None)?; + let v0_1_0 = Tag::from_str("0.1.0", None)?; + let v0_2_0 = Tag::from_str("0.2.0", None)?; + let v0_0_1 = Tag::from_str("0.0.1", None)?; + assert_that!([v1_0_0, v1_1_0, v2_1_0, v0_1_0, v0_2_0, v0_0_1,] + .iter() + .max()) + .is_some() + .is_equal_to(&Tag::from_str("2.1.0", None)?); + + Ok(()) + } + + #[sealed_test] + fn should_compare_tags_with_prefix() -> Result<()> { + Repository::init(".")?; + let settings = Settings { + tag_prefix: Some("v".to_string()), + ..Default::default() + }; + let settings = toml::to_string(&settings)?; + fs::write("cog.toml", settings)?; + + let v1_0_0 = Tag::from_str("v1.0.0", None)?; + let v1_1_0 = Tag::from_str("v1.1.0", None)?; + let v2_1_0 = Tag::from_str("v2.1.0", None)?; + let v0_1_0 = Tag::from_str("v0.1.0", None)?; + let v0_2_0 = Tag::from_str("v0.2.0", None)?; + let v0_0_1 = Tag::from_str("v0.0.1", None)?; + assert_that!([v1_0_0, v1_1_0, v2_1_0, v0_1_0, v0_2_0, v0_0_1,] + .iter() + .max()) + .is_some() + .is_equal_to(&Tag::from_str("2.1.0", None)?); + + Ok(()) + } + #[test] fn should_get_tag_from_str() -> Result<()> { let tag = Tag::from_str("1.0.0", None); From 31c545b7ee6fde446cbdbb8e39ce4357225bfbd5 Mon Sep 17 00:00:00 2001 From: Paul Delafosse Date: Wed, 18 Jan 2023 12:18:17 +0100 Subject: [PATCH 09/26] fix: remove stderrlog auto colors for some bump messages --- src/command/bump/mod.rs | 127 ++++++++++++++++++----------------- src/command/bump/monorepo.rs | 19 ++++-- src/command/bump/package.rs | 2 + src/command/bump/standard.rs | 1 + 4 files changed, 80 insertions(+), 69 deletions(-) diff --git a/src/command/bump/mod.rs b/src/command/bump/mod.rs index b680dcf0..5c3bceee 100644 --- a/src/command/bump/mod.rs +++ b/src/command/bump/mod.rs @@ -200,7 +200,7 @@ impl CocoGitto { .try_collect()?, }; - if hooks.is_empty() { + if !hooks.is_empty() { let hook_type = match hook_type { HookType::PreBump => "pre-bump", HookType::PostBump => "post-bump", @@ -208,11 +208,17 @@ impl CocoGitto { match package_name { None => { - let msg = format!("Running {hook_type} hooks").underline(); + let msg = format!("Running {hook_type} hooks") + .underline() + .white() + .bold(); info!("{msg}") } Some(package_name) => { - let msg = format!("Running {package_name} {hook_type} hooks").underline(); + let msg = format!("Running {package_name} {hook_type} hooks") + .underline() + .white() + .bold(); info!("{msg}") } } @@ -239,71 +245,68 @@ impl CocoGitto { } } -// FIXME -fn _display_history(commits: &[&git2::Commit]) -> Result<(), fmt::Error> { - let conventional_commits: Vec> = commits - .iter() - .map(|commit| Commit::from_git_commit(commit)) - .collect(); - - // Commits which type are neither feat, fix nor breaking changes - // won't affect the version number. - let mut non_bump_commits: Vec<&CommitType> = conventional_commits - .iter() - .filter_map(|commit| match commit { - Ok(commit) => match commit.message.commit_type { +impl Release<'_> { + fn pretty_print_bump_summary(&self) -> Result<(), fmt::Error> { + let conventional_commits: Vec<&Commit> = self + .commits + .iter() + .map(|ch_commit| &ch_commit.commit) + .collect(); + + // Commits which type are neither feat, fix nor breaking changes + // won't affect the version number. + let mut non_bump_commits: Vec<&CommitType> = conventional_commits + .iter() + .filter_map(|commit| match &commit.message.commit_type { CommitType::Feature | CommitType::BugFix => None, + _commit_type if commit.message.is_breaking_change => None, _ => Some(&commit.message.commit_type), - }, - Err(_) => None, - }) - .collect(); - - non_bump_commits.sort(); - - let non_bump_commits: Vec<(usize, &CommitType)> = non_bump_commits - .into_iter() - .dedup_by_with_count(|c1, c2| c1 == c2) - .collect(); - - if !non_bump_commits.is_empty() { - let mut skip_message = "\tSkipping irrelevant commits:\n".to_string(); - for (count, commit_type) in non_bump_commits { - writeln!(skip_message, "\t\t- {}: {}", commit_type.as_ref(), count)?; - } + }) + .collect(); - info!("{}", skip_message); - } + non_bump_commits.sort(); - let bump_commits = conventional_commits - .iter() - .filter_map(|commit| match commit { - Ok(commit) => match commit.message.commit_type { - CommitType::Feature | CommitType::BugFix => Some(Ok(commit)), - _ => None, - }, - Err(err) => Some(Err(err)), - }); - - for commit in bump_commits { - match commit { - Ok(commit) if commit.message.is_breaking_change => { - info!( - "\t Found {} commit {} with type: {}", - "BREAKING CHANGE".red(), - commit.shorthand().blue(), - commit.message.commit_type.as_ref().yellow() - ) - } - Ok(commit) if commit.message.commit_type == CommitType::BugFix => { - info!("\tFound bug fix commit {}", commit.shorthand().blue()) + let non_bump_commits: Vec<(usize, &CommitType)> = non_bump_commits + .into_iter() + .dedup_by_with_count(|c1, c2| c1 == c2) + .collect(); + + if !non_bump_commits.is_empty() { + let mut skip_message = " Skipping irrelevant commits:\n".to_string(); + for (count, commit_type) in non_bump_commits { + writeln!(skip_message, " - {}: {}", commit_type.as_ref(), count)?; } - Ok(commit) if commit.message.commit_type == CommitType::Feature => { - info!("\tFound feature commit {}", commit.shorthand().blue()) + + info!("{}", skip_message); + } + + let bump_commits = + conventional_commits + .iter() + .filter(|commit| match &commit.message.commit_type { + CommitType::Feature | CommitType::BugFix => true, + _commit_type if commit.message.is_breaking_change => true, + _ => false, + }); + + for commit in bump_commits { + match &commit.message.commit_type { + _commit_type if commit.message.is_breaking_change => { + info!( + "\t Found {} commit {} with type: {}", + "BREAKING CHANGE".red(), + commit.shorthand().blue(), + commit.message.commit_type.as_ref().yellow() + ) + } + CommitType::Feature => { + info!("\tFound feature commit {}", commit.shorthand().blue()) + } + CommitType::BugFix => info!("\tFound bug fix commit {}", commit.shorthand().blue()), + _ => (), } - _ => (), } - } - Ok(()) + Ok(()) + } } diff --git a/src/command/bump/monorepo.rs b/src/command/bump/monorepo.rs index a2227756..8a7e2f6b 100644 --- a/src/command/bump/monorepo.rs +++ b/src/command/bump/monorepo.rs @@ -29,7 +29,6 @@ struct PackageBumpData { } // TODO: -// - pretty stdout // - dry run impl CocoGitto { pub fn create_monorepo_version( @@ -85,8 +84,12 @@ impl CocoGitto { let pattern = self.get_revspec_for_tag(&old)?; let changelog = self.get_monorepo_global_changelog_with_target_version(pattern, tag.clone())?; + + changelog.pretty_print_bump_summary()?; + let path = settings::changelog_path(); let template = SETTINGS.get_monorepo_changelog_template()?; + changelog.write_to_file( path, template, @@ -213,10 +216,13 @@ impl CocoGitto { let package_name = &bump.package_name; let old = self.repository.get_latest_package_tag(package_name); let old = tag_or_fallback_to_zero(old)?; - info!( - "Preparing bump for package {}, starting from version {old}", + let msg = format!( + "Bump for package {}, starting from version {old}", package_name.bold() - ); + ) + .white(); + + info!("{msg}"); let mut next_version = old.bump( IncrementCommand::AutoPackage(package_name.to_string()), @@ -242,6 +248,8 @@ impl CocoGitto { package_name.as_str(), )?; + changelog.pretty_print_bump_summary()?; + let path = package.changelog_path(); let template = SETTINGS.get_package_changelog_template()?; @@ -281,6 +289,3 @@ impl CocoGitto { Ok(()) } } - -#[cfg(test)] -mod test {} diff --git a/src/command/bump/package.rs b/src/command/bump/package.rs index 607e2023..a896b3b2 100644 --- a/src/command/bump/package.rs +++ b/src/command/bump/package.rs @@ -42,6 +42,8 @@ impl CocoGitto { let changelog = self.get_package_changelog_with_target_version(pattern, tag.clone(), package_name)?; + changelog.pretty_print_bump_summary()?; + let path = package.changelog_path(); let template = SETTINGS.get_changelog_template()?; let additional_context = ReleaseType::Package(PackageContext { package_name }); diff --git a/src/command/bump/standard.rs b/src/command/bump/standard.rs index 26c40253..818e8770 100644 --- a/src/command/bump/standard.rs +++ b/src/command/bump/standard.rs @@ -40,6 +40,7 @@ impl CocoGitto { let pattern = self.get_revspec_for_tag(¤t_tag)?; let changelog = self.get_changelog_with_target_version(pattern, tag.clone())?; + changelog.pretty_print_bump_summary()?; let path = settings::changelog_path(); let template = SETTINGS.get_changelog_template()?; From e3c58e8650272ba045d2b15577133695bc55fbdf Mon Sep 17 00:00:00 2001 From: Paul Delafosse Date: Wed, 18 Jan 2023 12:43:20 +0100 Subject: [PATCH 10/26] test: remove broken stdout assertion --- tests/cog_tests/bump.rs | 9 --------- 1 file changed, 9 deletions(-) diff --git a/tests/cog_tests/bump.rs b/tests/cog_tests/bump.rs index 737255d5..060d1f0e 100644 --- a/tests/cog_tests/bump.rs +++ b/tests/cog_tests/bump.rs @@ -278,13 +278,6 @@ fn bump_with_profile_hook() -> Result<()> { git_tag("1.0.0")?; git_commit("feat: feature")?; - let expected_stdout = indoc!( - "current 1.0.0 - next 1.0.1 - " - ); - let expected_stderr = "Bumped version: 1.0.0 -> 1.0.1\n"; - // Act Command::cargo_bin("cog")? .arg("bump") @@ -294,8 +287,6 @@ fn bump_with_profile_hook() -> Result<()> { .unwrap() // Assert .assert() - .stdout(expected_stdout) - .stderr(expected_stderr) .success(); assert_tag_exists("1.0.1")?; From 5c44dcd00192ff81c906f6cc3b56f64f74065930 Mon Sep 17 00:00:00 2001 From: Paul Delafosse Date: Wed, 18 Jan 2023 16:34:01 +0100 Subject: [PATCH 11/26] test(monorepo): add integration test --- tests/lib_tests/bump.rs | 99 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/tests/lib_tests/bump.rs b/tests/lib_tests/bump.rs index 8e53caa6..91abcd4a 100644 --- a/tests/lib_tests/bump.rs +++ b/tests/lib_tests/bump.rs @@ -1,6 +1,9 @@ use anyhow::Result; +use std::collections::HashMap; +use std::path::PathBuf; use cmd_lib::run_cmd; +use cocogitto::settings::{MonoRepoPackage, Settings}; use cocogitto::{conventional::version::IncrementCommand, CocoGitto}; use sealed_test::prelude::*; use speculoos::prelude::*; @@ -27,6 +30,102 @@ fn bump_ok() -> Result<()> { Ok(()) } +#[sealed_test] +fn monorepo_bump_ok() -> Result<()> { + // Arrange + let mut settings = Settings { + ..Default::default() + }; + + init_monorepo(&mut settings)?; + + let mut cocogitto = CocoGitto::get()?; + + // Act + let result = cocogitto.create_monorepo_version(None, None, false); + + // Assert + assert_that!(result).is_ok(); + assert_tag_exists("0.1.0")?; + assert_tag_exists("one-0.1.0")?; + Ok(()) +} + +#[sealed_test] +fn monorepo_with_tag_prefix_bump_ok() -> Result<()> { + // Arrange + let mut settings = Settings { + tag_prefix: Some("v".to_string()), + ..Default::default() + }; + + init_monorepo(&mut settings)?; + + let mut cocogitto = CocoGitto::get()?; + + // Act + let result = cocogitto.create_monorepo_version(None, None, false); + + // Assert + assert_that!(result).is_ok(); + assert_tag_exists("v0.1.0")?; + assert_tag_exists("one-v0.1.0")?; + Ok(()) +} + +#[sealed_test] +fn package_bump_ok() -> Result<()> { + // Arrange + let mut settings = Settings { + ..Default::default() + }; + + init_monorepo(&mut settings)?; + let package = settings.packages.get("one").unwrap(); + let mut cocogitto = CocoGitto::get()?; + + // Act + let result = cocogitto.create_package_version( + ("one", package), + IncrementCommand::AutoPackage("one".to_string()), + None, + None, + false, + ); + + // Assert + assert_that!(result).is_ok(); + assert_tag_does_not_exist("0.1.0")?; + assert_tag_exists("one-0.1.0")?; + Ok(()) +} + +fn init_monorepo(settings: &mut Settings) -> Result<()> { + let mut packages = HashMap::new(); + packages.insert( + "one".to_string(), + MonoRepoPackage { + path: PathBuf::from("one"), + ..Default::default() + }, + ); + settings.packages = packages; + let settings = toml::to_string(&settings)?; + + git_init()?; + run_cmd!( + echo $settings > cog.toml; + git add .; + git commit -m "chore: first commit"; + mkdir one; + echo "changes" > one/file; + git add .; + git commit -m "feat: package one feature"; + )?; + + Ok(()) +} + #[sealed_test] fn should_fallback_to_0_0_0_when_there_is_no_tag() -> Result<()> { // Arrange From ca3587c8a5075c56709a42f71087cf6edc52abd3 Mon Sep 17 00:00:00 2001 From: Paul Delafosse Date: Wed, 18 Jan 2023 16:49:08 +0100 Subject: [PATCH 12/26] fix: dry run for monorepo --- src/command/bump/monorepo.rs | 5 ++-- tests/cog_tests/bump.rs | 44 ++++++++++++++++++++++++++++++++++++ tests/helpers.rs | 31 ++++++++++++++++++++++++- tests/lib_tests/bump.rs | 31 +------------------------ 4 files changed, 78 insertions(+), 33 deletions(-) diff --git a/src/command/bump/monorepo.rs b/src/command/bump/monorepo.rs index 8a7e2f6b..15e9cb73 100644 --- a/src/command/bump/monorepo.rs +++ b/src/command/bump/monorepo.rs @@ -28,8 +28,6 @@ struct PackageBumpData { increment: Increment, } -// TODO: -// - dry run impl CocoGitto { pub fn create_monorepo_version( &mut self, @@ -64,6 +62,9 @@ impl CocoGitto { let tag = Tag::create(tag.version, None); if dry_run { + for bump in bumps { + println!("{}", bump.new_version.prefixed_tag) + } print!("{}", tag); return Ok(()); } diff --git a/tests/cog_tests/bump.rs b/tests/cog_tests/bump.rs index 060d1f0e..13845b99 100644 --- a/tests/cog_tests/bump.rs +++ b/tests/cog_tests/bump.rs @@ -8,6 +8,7 @@ use indoc::indoc; use sealed_test::prelude::*; use speculoos::prelude::*; use std::path::Path; +use cocogitto::settings::Settings; #[sealed_test] fn auto_bump_from_start_ok() -> Result<()> { @@ -292,3 +293,46 @@ fn bump_with_profile_hook() -> Result<()> { assert_tag_exists("1.0.1")?; Ok(()) } + + +#[sealed_test] +fn monorepo_dry_run() -> Result<()> { + init_monorepo(&mut Settings::default())?; + + Command::cargo_bin("cog")? + .arg("bump") + .arg("--auto") + .arg("--dry-run") + .assert() + .success() + .stdout(indoc!( + "one-0.1.0 + 0.1.0" + )); + + assert_that!(Path::new("CHANGELOG.md")).does_not_exist(); + assert_tag_does_not_exist("1.1.0")?; + Ok(()) +} + +#[sealed_test] +fn package_dry_run() -> Result<()> { + init_monorepo(&mut Settings::default())?; + + Command::cargo_bin("cog")? + .arg("bump") + .arg("--auto") + .arg("--package") + .arg("one") + .arg("--dry-run") + .assert() + .success() + .stdout(indoc!( + "one-0.1.0" + )); + + assert_that!(Path::new("CHANGELOG.md")).does_not_exist(); + assert_tag_does_not_exist("1.1.0")?; + Ok(()) +} + diff --git a/tests/helpers.rs b/tests/helpers.rs index d9e1c0a0..56427f7d 100644 --- a/tests/helpers.rs +++ b/tests/helpers.rs @@ -1,4 +1,5 @@ -use std::path::Path; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; use anyhow::anyhow; use anyhow::Result; @@ -8,6 +9,34 @@ use speculoos::iter::ContainingIntoIterAssertions; use speculoos::option::OptionAssertions; use cocogitto::CONFIG_PATH; +use cocogitto::settings::{MonoRepoPackage, Settings}; + +pub fn init_monorepo(settings: &mut Settings) -> Result<()> { + let mut packages = HashMap::new(); + packages.insert( + "one".to_string(), + MonoRepoPackage { + path: PathBuf::from("one"), + ..Default::default() + }, + ); + settings.packages = packages; + let settings = toml::to_string(&settings)?; + + git_init()?; + run_cmd!( + echo $settings > cog.toml; + git add .; + git commit -m "chore: first commit"; + mkdir one; + echo "changes" > one/file; + git add .; + git commit -m "feat: package one feature"; + )?; + + Ok(()) +} + /// - Init a repository in the current directory /// - Setup a local git user named Tom diff --git a/tests/lib_tests/bump.rs b/tests/lib_tests/bump.rs index 91abcd4a..3cbeca21 100644 --- a/tests/lib_tests/bump.rs +++ b/tests/lib_tests/bump.rs @@ -1,9 +1,6 @@ use anyhow::Result; -use std::collections::HashMap; -use std::path::PathBuf; - use cmd_lib::run_cmd; -use cocogitto::settings::{MonoRepoPackage, Settings}; +use cocogitto::settings::Settings; use cocogitto::{conventional::version::IncrementCommand, CocoGitto}; use sealed_test::prelude::*; use speculoos::prelude::*; @@ -100,32 +97,6 @@ fn package_bump_ok() -> Result<()> { Ok(()) } -fn init_monorepo(settings: &mut Settings) -> Result<()> { - let mut packages = HashMap::new(); - packages.insert( - "one".to_string(), - MonoRepoPackage { - path: PathBuf::from("one"), - ..Default::default() - }, - ); - settings.packages = packages; - let settings = toml::to_string(&settings)?; - - git_init()?; - run_cmd!( - echo $settings > cog.toml; - git add .; - git commit -m "chore: first commit"; - mkdir one; - echo "changes" > one/file; - git add .; - git commit -m "feat: package one feature"; - )?; - - Ok(()) -} - #[sealed_test] fn should_fallback_to_0_0_0_when_there_is_no_tag() -> Result<()> { // Arrange From 8cbe74f1a78bb4c473ada369904e49d569fff4af Mon Sep 17 00:00:00 2001 From: Paul Delafosse Date: Wed, 18 Jan 2023 16:49:24 +0100 Subject: [PATCH 13/26] fix: gpg sign for package bump --- src/command/bump/package.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/command/bump/package.rs b/src/command/bump/package.rs index a896b3b2..0895f9bc 100644 --- a/src/command/bump/package.rs +++ b/src/command/bump/package.rs @@ -77,8 +77,9 @@ impl CocoGitto { self.stash_failed_version(&tag, err)?; } + let sign = self.repository.gpg_sign(); self.repository - .commit(&format!("chore(version): {}", tag), false)?; + .commit(&format!("chore(version): {}", tag), sign)?; self.repository.create_tag(&tag)?; From 1c2b447bdc559170d8c4850da47b4cef21814f84 Mon Sep 17 00:00:00 2001 From: Paul Delafosse Date: Wed, 18 Jan 2023 16:49:54 +0100 Subject: [PATCH 14/26] style: run hook display --- src/command/bump/mod.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/command/bump/mod.rs b/src/command/bump/mod.rs index 5c3bceee..c597dda5 100644 --- a/src/command/bump/mod.rs +++ b/src/command/bump/mod.rs @@ -208,14 +208,14 @@ impl CocoGitto { match package_name { None => { - let msg = format!("Running {hook_type} hooks") + let msg = format!("[{hook_type}]") .underline() .white() .bold(); info!("{msg}") } Some(package_name) => { - let msg = format!("Running {package_name} {hook_type} hooks") + let msg = format!("[{hook_type}-{package_name}]") .underline() .white() .bold(); From 9528a191bf14479d5bcd6b17910d912319203103 Mon Sep 17 00:00:00 2001 From: Paul Delafosse Date: Wed, 18 Jan 2023 16:50:27 +0100 Subject: [PATCH 15/26] chore: clippy + fmt --- src/command/bump/mod.rs | 5 +---- tests/cog_tests/bump.rs | 8 ++------ tests/helpers.rs | 3 +-- 3 files changed, 4 insertions(+), 12 deletions(-) diff --git a/src/command/bump/mod.rs b/src/command/bump/mod.rs index c597dda5..0f14b67e 100644 --- a/src/command/bump/mod.rs +++ b/src/command/bump/mod.rs @@ -208,10 +208,7 @@ impl CocoGitto { match package_name { None => { - let msg = format!("[{hook_type}]") - .underline() - .white() - .bold(); + let msg = format!("[{hook_type}]").underline().white().bold(); info!("{msg}") } Some(package_name) => { diff --git a/tests/cog_tests/bump.rs b/tests/cog_tests/bump.rs index 13845b99..f592d981 100644 --- a/tests/cog_tests/bump.rs +++ b/tests/cog_tests/bump.rs @@ -4,11 +4,11 @@ use crate::helpers::*; use anyhow::Result; use assert_cmd::prelude::*; +use cocogitto::settings::Settings; use indoc::indoc; use sealed_test::prelude::*; use speculoos::prelude::*; use std::path::Path; -use cocogitto::settings::Settings; #[sealed_test] fn auto_bump_from_start_ok() -> Result<()> { @@ -294,7 +294,6 @@ fn bump_with_profile_hook() -> Result<()> { Ok(()) } - #[sealed_test] fn monorepo_dry_run() -> Result<()> { init_monorepo(&mut Settings::default())?; @@ -327,12 +326,9 @@ fn package_dry_run() -> Result<()> { .arg("--dry-run") .assert() .success() - .stdout(indoc!( - "one-0.1.0" - )); + .stdout(indoc!("one-0.1.0")); assert_that!(Path::new("CHANGELOG.md")).does_not_exist(); assert_tag_does_not_exist("1.1.0")?; Ok(()) } - diff --git a/tests/helpers.rs b/tests/helpers.rs index 56427f7d..b76a46a9 100644 --- a/tests/helpers.rs +++ b/tests/helpers.rs @@ -8,8 +8,8 @@ use speculoos::assert_that; use speculoos::iter::ContainingIntoIterAssertions; use speculoos::option::OptionAssertions; -use cocogitto::CONFIG_PATH; use cocogitto::settings::{MonoRepoPackage, Settings}; +use cocogitto::CONFIG_PATH; pub fn init_monorepo(settings: &mut Settings) -> Result<()> { let mut packages = HashMap::new(); @@ -37,7 +37,6 @@ pub fn init_monorepo(settings: &mut Settings) -> Result<()> { Ok(()) } - /// - Init a repository in the current directory /// - Setup a local git user named Tom pub fn git_init() -> Result<()> { From 160c548f101109f425e78eae205c135542fd954f Mon Sep 17 00:00:00 2001 From: Paul Delafosse Date: Wed, 18 Jan 2023 17:44:27 +0100 Subject: [PATCH 16/26] feat: run hooks in package dir --- src/command/bump/mod.rs | 12 +++++++++++- src/hook/mod.rs | 14 ++++++++++---- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/command/bump/mod.rs b/src/command/bump/mod.rs index 0f14b67e..3ab5803c 100644 --- a/src/command/bump/mod.rs +++ b/src/command/bump/mod.rs @@ -222,8 +222,18 @@ impl CocoGitto { } for mut hook in hooks { + let command = hook.to_string(); + let chars: Vec = command.chars().collect(); + let command = if chars.len() > 78 { + &command[0..command.len()] + } else { + &command + }; + info!("[{command}]"); hook.insert_versions(current_tag, next_version)?; - hook.run().context(hook.to_string())?; + let package_path = package.map(|p| p.path.as_path()); + hook.run(package_path).context(hook.to_string())?; + println!(); } Ok(()) diff --git a/src/hook/mod.rs b/src/hook/mod.rs index de5425fd..3f135f8e 100644 --- a/src/hook/mod.rs +++ b/src/hook/mod.rs @@ -2,7 +2,7 @@ mod error; mod parser; use std::collections::VecDeque; -use std::fmt; +use std::{fmt, path}; use std::ops::Range; use std::process::Command; use std::str::FromStr; @@ -130,8 +130,14 @@ impl Hook { Ok(()) } - pub fn run(&self) -> Result<()> { - let status = Command::new("sh").arg("-c").arg(&self.0).status()?; + pub fn run(&self, package_path: Option<&path::Path>) -> Result<()> { + let mut cmd = Command::new("sh"); + let cmd = cmd.arg("-c").arg(&self.0); + if let Some(current_dir) = package_path { + cmd.current_dir(current_dir); + + } + let status = cmd.status()?; ensure!(status.success(), "hook failed with status {}", status); Ok(()) } @@ -283,7 +289,7 @@ mod test { hook.insert_versions(None, &HookVersion::new(Tag::from_str("1.0.0", None)?)) .unwrap(); - let outcome = hook.run(); + let outcome = hook.run(None); assert_that!(outcome).is_ok(); From 788b1c78ec1c8c7f86bc3ab77d2281977e9fd8aa Mon Sep 17 00:00:00 2001 From: Paul Delafosse Date: Wed, 18 Jan 2023 17:45:43 +0100 Subject: [PATCH 17/26] fix: add new line after exec --- src/bin/cog/main.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/bin/cog/main.rs b/src/bin/cog/main.rs index f738a2f1..72c35844 100644 --- a/src/bin/cog/main.rs +++ b/src/bin/cog/main.rs @@ -507,6 +507,7 @@ fn main() -> Result<()> { } } + println!(); Ok(()) } From 322e27bcfb54b6a95422f6b0937249ff11bc8d6d Mon Sep 17 00:00:00 2001 From: Paul Delafosse Date: Wed, 18 Jan 2023 18:08:04 +0100 Subject: [PATCH 18/26] fix: display version in hook preview --- src/command/bump/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/command/bump/mod.rs b/src/command/bump/mod.rs index 3ab5803c..b40608e8 100644 --- a/src/command/bump/mod.rs +++ b/src/command/bump/mod.rs @@ -222,6 +222,7 @@ impl CocoGitto { } for mut hook in hooks { + hook.insert_versions(current_tag, next_version)?; let command = hook.to_string(); let chars: Vec = command.chars().collect(); let command = if chars.len() > 78 { @@ -230,7 +231,6 @@ impl CocoGitto { &command }; info!("[{command}]"); - hook.insert_versions(current_tag, next_version)?; let package_path = package.map(|p| p.path.as_path()); hook.run(package_path).context(hook.to_string())?; println!(); From 919209b7c5fc81ce8c06aaa8c0b58e62374f7b12 Mon Sep 17 00:00:00 2001 From: Paul Delafosse Date: Wed, 18 Jan 2023 18:08:32 +0100 Subject: [PATCH 19/26] feat: add package template setting --- src/command/bump/package.rs | 2 +- src/settings/mod.rs | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/command/bump/package.rs b/src/command/bump/package.rs index 0895f9bc..283173cc 100644 --- a/src/command/bump/package.rs +++ b/src/command/bump/package.rs @@ -45,7 +45,7 @@ impl CocoGitto { changelog.pretty_print_bump_summary()?; let path = package.changelog_path(); - let template = SETTINGS.get_changelog_template()?; + let template = SETTINGS.get_package_changelog_template()?; let additional_context = ReleaseType::Package(PackageContext { package_name }); changelog.write_to_file(path, template, additional_context)?; diff --git a/src/settings/mod.rs b/src/settings/mod.rs index 06718617..de764cec 100644 --- a/src/settings/mod.rs +++ b/src/settings/mod.rs @@ -100,6 +100,7 @@ impl MonoRepoPackage { #[serde(deny_unknown_fields, default)] pub struct Changelog { pub template: Option, + pub package_template: Option, pub remote: Option, pub path: PathBuf, pub owner: Option, @@ -111,6 +112,7 @@ impl Default for Changelog { fn default() -> Self { Changelog { template: None, + package_template: None, remote: None, path: PathBuf::from("CHANGELOG.md"), owner: None, @@ -228,7 +230,7 @@ impl Settings { let context = self.get_template_context(); let template = self .changelog - .template + .package_template .as_deref() .unwrap_or("package_default"); From 60dc811eda1e117d3df0d83bcc2d12c9a485e947 Mon Sep 17 00:00:00 2001 From: Paul Delafosse Date: Wed, 18 Jan 2023 18:44:58 +0100 Subject: [PATCH 20/26] test: fix tests failing after adding a trailing newline --- src/command/bump/monorepo.rs | 14 +++++++++++--- src/conventional/changelog/error.rs | 2 +- src/conventional/changelog/template.rs | 5 +++-- .../changelog/template/monorepo_full_hash | 4 ++-- .../changelog/template/monorepo_remote | 16 ++++++++++++++-- .../changelog/template/monorepo_simple | 2 +- src/hook/mod.rs | 3 +-- tests/cog_tests/bump.rs | 7 ++++--- tests/cog_tests/changelog.rs | 8 ++++++++ 9 files changed, 45 insertions(+), 16 deletions(-) diff --git a/src/command/bump/monorepo.rs b/src/command/bump/monorepo.rs index 15e9cb73..e399bcce 100644 --- a/src/command/bump/monorepo.rs +++ b/src/command/bump/monorepo.rs @@ -18,6 +18,7 @@ use log::info; use semver::Prerelease; use crate::conventional::error::BumpError; +use crate::git::oid::OidOf; struct PackageBumpData { package_name: String, @@ -74,11 +75,18 @@ impl CocoGitto { template_context.push(PackageBumpContext { package_name: &bump.package_name, package_path: &bump.package_path, - new_version: bump.new_version.prefixed_tag.to_string(), - old_version: bump + version: OidOf::Tag(bump.new_version.prefixed_tag.clone()), + from: bump .old_version .as_ref() - .map(|v| v.prefixed_tag.to_string()), + .map(|v| OidOf::Tag(v.prefixed_tag.clone())) + .unwrap_or_else(|| { + let first = self + .repository + .get_first_commit() + .expect("non empty repository"); + OidOf::Other(first) + }), }) } diff --git a/src/conventional/changelog/error.rs b/src/conventional/changelog/error.rs index 7b37c4b1..5b159a47 100644 --- a/src/conventional/changelog/error.rs +++ b/src/conventional/changelog/error.rs @@ -18,7 +18,7 @@ impl Display for ChangelogError { writeln!(f, "changelog template not found in {:?}", path) } ChangelogError::TeraError(err) => { - writeln!(f, "failed to render changelog: \n\t{}", err) + writeln!(f, "failed to render changelog: \n\t{:?}", err) } ChangelogError::WriteError(err) => { writeln!(f, "failed to write changelog: \n\t{}", err) diff --git a/src/conventional/changelog/template.rs b/src/conventional/changelog/template.rs index 50f9fbc1..e5d987f4 100644 --- a/src/conventional/changelog/template.rs +++ b/src/conventional/changelog/template.rs @@ -2,6 +2,7 @@ use crate::conventional::changelog::error::ChangelogError; use serde::Serialize; +use crate::git::oid::OidOf; use std::io; use std::path::PathBuf; use tera::Context; @@ -136,8 +137,8 @@ pub struct MonoRepoContext<'a> { pub struct PackageBumpContext<'a> { pub package_name: &'a str, pub package_path: &'a str, - pub new_version: String, - pub old_version: Option, + pub version: OidOf, + pub from: OidOf, } #[derive(Debug)] diff --git a/src/conventional/changelog/template/monorepo_full_hash b/src/conventional/changelog/template/monorepo_full_hash index 8dfdd38d..8b71e983 100644 --- a/src/conventional/changelog/template/monorepo_full_hash +++ b/src/conventional/changelog/template/monorepo_full_hash @@ -1,6 +1,6 @@ -### Package bump +### Package updates {% for package in packages -%} - {{ package_name }} bumped to {{ new_version }} + {{ package.package_name }} bumped to {{ package.new_version }} {% endfor -%} diff --git a/src/conventional/changelog/template/monorepo_remote b/src/conventional/changelog/template/monorepo_remote index 33b396a4..45753643 100644 --- a/src/conventional/changelog/template/monorepo_remote +++ b/src/conventional/changelog/template/monorepo_remote @@ -12,9 +12,21 @@ ## Unreleased ([{{ from_shorthand ~ ".." ~ to_shorthand }}]({{repository_url ~ "/compare/" ~ from_shorthand ~ ".." ~ to_shorthand}})) {% endif -%} -### Package bumps +### Package updates {% for package in packages -%} - {{ package_name }} bumped to {{ new_version }} +{% if package.version.tag and package.from.tag -%} +- [{{ package.version.tag }}]({{ package.package_path }}) bumped to [{{ package.version.tag }}]({{repository_url ~ "/compare/" ~ package.from.tag ~ ".." ~ package.version.tag}}) +{% elif package.version.tag and package.from.id -%} +- [{{ package.package_name }}]({{ package.package_path }}) bumped to [{{ package.version.tag }}]({{repository_url ~ "/compare/" ~ package.from.id ~ ".." ~ package.version.tag}}) +{% else -%} + {% set from = package.from.id -%} + {% set to = package.version.id -%} + + {% set from_shorthand = from.id | truncate(length=7, end="") -%} + {% set to_shorthand = version.id | truncate(length=7, end="") -%} + + ## Unreleased ([{{ from_shorthand ~ ".." ~ to_shorthand }}]({{repository_url ~ "/compare/" ~ from_shorthand ~ ".." ~ to_shorthand}})) +{% endif -%} {% endfor -%} ### Global changes diff --git a/src/conventional/changelog/template/monorepo_simple b/src/conventional/changelog/template/monorepo_simple index 1dd41795..f64dd3d7 100644 --- a/src/conventional/changelog/template/monorepo_simple +++ b/src/conventional/changelog/template/monorepo_simple @@ -11,7 +11,7 @@ ### Package bumps {% for package in packages -%} -- {{ package.package_name }} bumped to {{ package.new_version }} +- {{ package.package_name }} bumped to {{ package.version.tag }} {% endfor -%} ### Global changes diff --git a/src/hook/mod.rs b/src/hook/mod.rs index 3f135f8e..f9777475 100644 --- a/src/hook/mod.rs +++ b/src/hook/mod.rs @@ -2,10 +2,10 @@ mod error; mod parser; use std::collections::VecDeque; -use std::{fmt, path}; use std::ops::Range; use std::process::Command; use std::str::FromStr; +use std::{fmt, path}; use crate::Tag; use parser::Token; @@ -135,7 +135,6 @@ impl Hook { let cmd = cmd.arg("-c").arg(&self.0); if let Some(current_dir) = package_path { cmd.current_dir(current_dir); - } let status = cmd.status()?; ensure!(status.success(), "hook failed with status {}", status); diff --git a/tests/cog_tests/bump.rs b/tests/cog_tests/bump.rs index f592d981..61d82ed1 100644 --- a/tests/cog_tests/bump.rs +++ b/tests/cog_tests/bump.rs @@ -67,7 +67,7 @@ fn auto_bump_dry_run_from_latest_tag() -> Result<()> { .arg("--dry-run") .assert() .success() - .stdout("1.1.0"); + .stdout("1.1.0\n"); assert_that!(Path::new("CHANGELOG.md")).does_not_exist(); assert_tag_does_not_exist("1.1.0")?; @@ -306,7 +306,8 @@ fn monorepo_dry_run() -> Result<()> { .success() .stdout(indoc!( "one-0.1.0 - 0.1.0" + 0.1.0 + " )); assert_that!(Path::new("CHANGELOG.md")).does_not_exist(); @@ -326,7 +327,7 @@ fn package_dry_run() -> Result<()> { .arg("--dry-run") .assert() .success() - .stdout(indoc!("one-0.1.0")); + .stdout(indoc!("one-0.1.0\n")); assert_that!(Path::new("CHANGELOG.md")).does_not_exist(); assert_tag_does_not_exist("1.1.0")?; diff --git a/tests/cog_tests/changelog.rs b/tests/cog_tests/changelog.rs index ea00ce3a..9e76b97b 100644 --- a/tests/cog_tests/changelog.rs +++ b/tests/cog_tests/changelog.rs @@ -66,6 +66,7 @@ fn get_changelog_range() -> Result<()> { #### Refactoring - change config name to cog.toml - (d4aa61b) - oknozor + ", today = today ) @@ -101,6 +102,7 @@ fn get_changelog_from_untagged_repo() -> Result<()> { #### Features - **(taef)** feature - ({commit_two}) - Tom + ", commit_two = &commit_two[0..7], commit_three = &commit_three[0..7] @@ -143,6 +145,7 @@ fn get_changelog_from_tagged_repo() -> Result<()> { #### Features - **(taef)** feature - ({commit_one}) - Tom + ", commit_one = &commit_one[0..7], commit_two = &commit_two[0..7], @@ -184,6 +187,7 @@ fn get_changelog_at_tag() -> Result<()> { - **(taef)** feature - ({commit_one}) - Tom - feature 2 - ({commit_two}) - Tom + ", today = today, commit_one = &commit_one[0..7], @@ -235,6 +239,7 @@ fn get_changelog_with_tag_prefix() -> Result<()> { #### Features - feature 1 - ({commit_one}) - Tom + ", today = today, commit_one = &commit_one[0..7], @@ -290,6 +295,7 @@ fn get_changelog_at_tag_prefix() -> Result<()> { #### Miscellaneous Chores - **(version)** v2.0.0 - ({commit_four}) - Tom + ", today = today, commit_two = &commit_two[0..7], @@ -342,6 +348,7 @@ fn get_changelog_from_tag_to_tagged_head() -> Result<()> { - feature 1 - ({commit_two}) - Tom - start - ({commit_one}) - Tom + ", today = today, commit_one = &commit_one[0..7], @@ -412,6 +419,7 @@ fn get_changelog_whith_custom_template() -> Result<()> { - feature 1 - ([{commit_two_short}](https://github.com/test/test/commit/{commit_two})) - Tom - **(scope1)** start - ([{commit_one_short}](https://github.com/test/test/commit/{commit_one})) - Tom + ", today = today, init_commit = &init_commit, From bc57fd3c10411f7ff009ace60b18ee6bed845bec Mon Sep 17 00:00:00 2001 From: Paul Delafosse Date: Wed, 18 Jan 2023 18:46:50 +0100 Subject: [PATCH 21/26] chore: clippy --- src/command/bump/mod.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/command/bump/mod.rs b/src/command/bump/mod.rs index b40608e8..ec178079 100644 --- a/src/command/bump/mod.rs +++ b/src/command/bump/mod.rs @@ -224,8 +224,7 @@ impl CocoGitto { for mut hook in hooks { hook.insert_versions(current_tag, next_version)?; let command = hook.to_string(); - let chars: Vec = command.chars().collect(); - let command = if chars.len() > 78 { + let command = if command.chars().count() > 78 { &command[0..command.len()] } else { &command From 7ad61979f93755990290d9e67755e53e38fd723c Mon Sep 17 00:00:00 2001 From: Paul Delafosse Date: Thu, 19 Jan 2023 08:30:48 +0100 Subject: [PATCH 22/26] test: add tests for monorepo and package templates --- src/conventional/changelog/release.rs | 251 +++++++++++++++++- .../changelog/template/monorepo_full_hash | 4 +- .../changelog/template/monorepo_simple | 3 +- 3 files changed, 253 insertions(+), 5 deletions(-) diff --git a/src/conventional/changelog/release.rs b/src/conventional/changelog/release.rs index b6e10604..8dad2bb9 100644 --- a/src/conventional/changelog/release.rs +++ b/src/conventional/changelog/release.rs @@ -123,7 +123,9 @@ mod test { use crate::conventional::changelog::release::{ChangelogCommit, Release}; use crate::conventional::changelog::renderer::Renderer; - use crate::conventional::changelog::template::{RemoteContext, Template, TemplateKind}; + use crate::conventional::changelog::template::{ + MonoRepoContext, PackageBumpContext, PackageContext, RemoteContext, Template, TemplateKind, + }; use crate::conventional::commit::Commit; use crate::git::oid::OidOf; use crate::git::tag::Tag; @@ -216,6 +218,212 @@ mod test { Ok(()) } + #[test] + fn should_render_template_monorepo() -> Result<()> { + // Arrange + let release = Release::fixture(); + let renderer = Renderer::try_new(Template { + remote_context: None, + kind: TemplateKind::MonorepoDefault, + })?; + + let mut renderer = monorepo_renderer(renderer)?; + + // Act + let changelog = renderer.render(release)?; + + // Assert + assert_eq!( + changelog, + indoc! { + "## 1.0.0 - 2015-09-05 + ### Package updates + - one bumped to 0.1.0 + - two bumped to 0.2.0 + ### Global changes + #### Bug Fixes + - **(parser)** fix parser implementation - (17f7e23) - *oknozor* + #### Features + - **(parser)** implement the changelog generator - (17f7e23) - *oknozor* + - awesome feature - (17f7e23) - Paul Delafosse + " + } + ); + + Ok(()) + } + + #[test] + fn should_render_full_hash_template_monorepo() -> Result<()> { + // Arrange + let release = Release::fixture(); + let renderer = Renderer::try_new(Template { + remote_context: None, + kind: TemplateKind::MonorepoFullHash, + })?; + + let mut renderer = monorepo_renderer(renderer)?; + + // Act + let changelog = renderer.render(release)?; + + // Assert + assert_eq!( + changelog, + indoc! { + "### Package updates + - one bumped to 0.1.0 + - two bumped to 0.2.0 + ### Global changes + #### Bug Fixes + - 17f7e23081db15e9318aeb37529b1d473cf41cbe - **(parser)** fix parser implementation - @oknozor + #### Features + - 17f7e23081db15e9318aeb37529b1d473cf41cbe - **(parser)** implement the changelog generator - @oknozor + - 17f7e23081db15e9318aeb37529b1d473cf41cbe - awesome feature - Paul Delafosse + + " + } + ); + + Ok(()) + } + + #[test] + fn should_render_remote_template_monorepo() -> Result<()> { + // Arrange + let release = Release::fixture(); + let renderer = Renderer::try_new(Template { + remote_context: RemoteContext::try_new( + Some("github.com".into()), + Some("cocogitto".into()), + Some("cocogitto".into()), + ), + kind: TemplateKind::MonorepoRemote, + })?; + + let mut renderer = monorepo_renderer(renderer)?; + + // Act + let changelog = renderer.render(release)?; + + // Assert + assert_eq!( + changelog, + indoc! { + "## [1.0.0](https://github.com/cocogitto/cocogitto/compare/0.1.0..1.0.0) - 2015-09-05 + ### Package updates + - [0.1.0](crates/one) bumped to [0.1.0](https://github.com/cocogitto/cocogitto/compare/0.2.0..0.1.0) + - [0.2.0](crates/two) bumped to [0.2.0](https://github.com/cocogitto/cocogitto/compare/0.3.0..0.2.0) + ### Global changes + #### Bug Fixes + - **(parser)** fix parser implementation - ([17f7e23](https://github.com/cocogitto/cocogitto/commit/17f7e23081db15e9318aeb37529b1d473cf41cbe)) - [@oknozor](https://github.com/oknozor) + #### Features + - **(parser)** implement the changelog generator - ([17f7e23](https://github.com/cocogitto/cocogitto/commit/17f7e23081db15e9318aeb37529b1d473cf41cbe)) - [@oknozor](https://github.com/oknozor) + - awesome feature - ([17f7e23](https://github.com/cocogitto/cocogitto/commit/17f7e23081db15e9318aeb37529b1d473cf41cbe)) - Paul Delafosse + " + } + ); + + Ok(()) + } + + #[test] + fn should_render_template_package() -> Result<()> { + // Arrange + let release = Release::fixture(); + let renderer = Renderer::try_new(Template { + remote_context: None, + kind: TemplateKind::PackageDefault, + })?; + + let mut renderer = package_renderer(renderer)?; + + // Act + let changelog = renderer.render(release)?; + + // Assert + assert_eq!( + changelog, + indoc! { + "## 1.0.0 - 2015-09-05 + #### Bug Fixes + - **(parser)** fix parser implementation - (17f7e23) - *oknozor* + #### Features + - **(parser)** implement the changelog generator - (17f7e23) - *oknozor* + - awesome feature - (17f7e23) - Paul Delafosse + " + } + ); + + Ok(()) + } + + #[test] + fn should_render_full_hash_template_package() -> Result<()> { + // Arrange + let release = Release::fixture(); + let renderer = Renderer::try_new(Template { + remote_context: None, + kind: TemplateKind::PackageFullHash, + })?; + + let mut renderer = package_renderer(renderer)?; + + // Act + let changelog = renderer.render(release)?; + + // Assert + assert_eq!( + changelog, + indoc! { + "#### Bug Fixes + - 17f7e23081db15e9318aeb37529b1d473cf41cbe - **(parser)** fix parser implementation - @oknozor + #### Features + - 17f7e23081db15e9318aeb37529b1d473cf41cbe - **(parser)** implement the changelog generator - @oknozor + - 17f7e23081db15e9318aeb37529b1d473cf41cbe - awesome feature - Paul Delafosse + + " + } + ); + + Ok(()) + } + + #[test] + fn should_render_remote_template_package() -> Result<()> { + // Arrange + let release = Release::fixture(); + let renderer = Renderer::try_new(Template { + remote_context: RemoteContext::try_new( + Some("github.com".into()), + Some("cocogitto".into()), + Some("cocogitto".into()), + ), + kind: TemplateKind::PackageRemote, + })?; + + let mut renderer = package_renderer(renderer)?; + + // Act + let changelog = renderer.render(release)?; + + // Assert + assert_eq!( + changelog, + indoc! { + "## [1.0.0](https://github.com/cocogitto/cocogitto/compare/0.1.0..1.0.0) - 2015-09-05 + #### Bug Fixes + - **(parser)** fix parser implementation - ([17f7e23](https://github.com/cocogitto/cocogitto/commit/17f7e23081db15e9318aeb37529b1d473cf41cbe)) - [@oknozor](https://github.com/oknozor) + #### Features + - **(parser)** implement the changelog generator - ([17f7e23](https://github.com/cocogitto/cocogitto/commit/17f7e23081db15e9318aeb37529b1d473cf41cbe)) - [@oknozor](https://github.com/oknozor) + - awesome feature - ([17f7e23](https://github.com/cocogitto/cocogitto/commit/17f7e23081db15e9318aeb37529b1d473cf41cbe)) - Paul Delafosse + " + } + ); + + Ok(()) + } + impl Release<'_> { pub fn fixture() -> Release<'static> { let date = @@ -303,4 +511,45 @@ mod test { } } } + + fn monorepo_renderer(renderer: Renderer) -> Result { + let renderer = renderer.with_monorepo_context(MonoRepoContext { + packages: vec![ + PackageBumpContext { + package_name: "one", + package_path: "crates/one", + version: OidOf::Tag(Tag::from_str( + "0.1.0", + Some(Oid::from_str("fae3a288a1bc69b14f85a1d5fe57cee1964acd60").unwrap()), + )?), + from: OidOf::Tag(Tag::from_str( + "0.2.0", + Some(Oid::from_str("fae3a288a1bc69b14f85a1d5fe57cee1964acd60").unwrap()), + )?), + }, + PackageBumpContext { + package_name: "two", + package_path: "crates/two", + version: OidOf::Tag(Tag::from_str( + "0.2.0", + Some(Oid::from_str("fae3a288a1bc69b14f85a1d5fe57cee1964acd60").unwrap()), + )?), + from: OidOf::Tag(Tag::from_str( + "0.3.0", + Some(Oid::from_str("fae3a288a1bc69b14f85a1d5fe57cee1964acd60").unwrap()), + )?), + }, + ], + }); + + Ok(renderer) + } + + fn package_renderer(renderer: Renderer) -> Result { + let renderer = renderer.with_package_context(PackageContext { + package_name: "one", + }); + + Ok(renderer) + } } diff --git a/src/conventional/changelog/template/monorepo_full_hash b/src/conventional/changelog/template/monorepo_full_hash index 8b71e983..955ae0bd 100644 --- a/src/conventional/changelog/template/monorepo_full_hash +++ b/src/conventional/changelog/template/monorepo_full_hash @@ -1,10 +1,10 @@ ### Package updates {% for package in packages -%} - {{ package.package_name }} bumped to {{ package.new_version }} + - {{ package.package_name }} bumped to {{ package.version.tag }} {% endfor -%} -### Commits +### Global changes {% for type, typed_commits in commits | sort(attribute="type")| group_by(attribute="type") -%} #### {{ type | upper_first }} {% for scope, scoped_commits in typed_commits | group_by(attribute="scope") -%} diff --git a/src/conventional/changelog/template/monorepo_simple b/src/conventional/changelog/template/monorepo_simple index f64dd3d7..78a9809b 100644 --- a/src/conventional/changelog/template/monorepo_simple +++ b/src/conventional/changelog/template/monorepo_simple @@ -8,8 +8,7 @@ ## Unreleased ({{ from_shorthand ~ ".." ~ to_shorthand }}) {% endif -%} -### Package bumps - +### Package updates {% for package in packages -%} - {{ package.package_name }} bumped to {{ package.version.tag }} {% endfor -%} From fa0989352aced1a613d890cbd4161ae82a09420d Mon Sep 17 00:00:00 2001 From: Paul Delafosse Date: Thu, 19 Jan 2023 08:46:49 +0100 Subject: [PATCH 23/26] docs: update readme screenshots --- docs/assets/cocogitto-bot-example.png | Bin 11555 -> 0 bytes docs/assets/cog-bot-example.png | Bin 14260 -> 18959 bytes docs/assets/cog-bump-example.png | Bin 91909 -> 51014 bytes 3 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 docs/assets/cocogitto-bot-example.png diff --git a/docs/assets/cocogitto-bot-example.png b/docs/assets/cocogitto-bot-example.png deleted file mode 100644 index 5216e8a250d7942255737665ad814958c8b4b0fc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11555 zcmdsdg;yJ0*Kdu|7BB8lid%6jP_($a1$TF+#l27@6sNdLkYb@oaCZ*`_u|37(C2yA z_x=TU-FvfUWlbjMoSE5Y%Wwboj!;tgjP{1;&66ij(4?irRh~R~UW7Ogd-V+QoyfNK z^vM&ZU1@O9*KTpUMBjV?#cJdN6~jA(5s_g}w$ zPx(47Sg`DA5dQC!6s}-un*DA=TU$I3LG;|9`S013A={S&QSXnMZRfgcVxA7sk}g^` zQOg>Gr~}%rWv2XGuU#+W-}?LcHk~V)!(PR(kSiy@6@5h+jM)IoN<DJf}Wlp~q@wa=JKSQt<`WsHmB zi9&7JwYh1Gd$jB7y{}+!$Uyzlq>1IBUNV4DzUKpKC*r6*`Lsacpv0R$#n&@{1F*C- zGKv>f*-%qc+dlU$VqVm|S_Vx{O||z8ei&fs2n3&j&zg2KAq|MyI0+^K4hmtibVt|~s zzP>&?0Z>D{$_U%m(+qVDZHWyt#1aIdPR*ZIDV%pIM#TNBU?@CEiVAVLQ`RmnF8NbE z-^d4+YoivwoSvN2)YMFFJQ1iXgj~?E5jf?1+WU9<#HaOamx^I8Pb*j&5Xh>jVnx_7 zKRQZ*%TTLcT|$>Q6UJg;ZZ70~CS@SPWHK{e!m)ClU0-3a6!cdjAEKLI@NeyAAIV|^ zG89;uQP|2WD-{(Lfxz&Yni}?^s`qu=OPXIfrOL)EyMER9GCmFx=(LN;>}>sK0ejMI zl01bSuc4FiF3r-3K26F3D=Vw<8qbh6BGi`v05tdWx76~9zzh#SG5Vrr^o87{6_y->MrXDAv`O!wkC`1|C%xVJ1*cHQ8h z+>ZYLPL++UtWwDxOUZKhVae2>Uc_L4(5)Y-sSG3yB_-B(*XQ)~^gja$Fp}fAZgGm0 z1}~Wz7~W#IeBy%uA9tOg0Ih%;qRB*OZ;Aa%cd5=sQ$ix7(dx;siVFLMdOJ2YHZ9Dl zVDczCQlct?58N(Y|IU&x2!Y@mF#6|5eSU(Q})%Bj_i-oZj>k{oQnkDyAs$2qE$CG$^X?p0-wgI8W`i0(As^2Y`Fh;r`PA9rHSR7b^^n25;P*?F*DO+6ak-O~eTbl!+?>#x*4$F|2H7i#gp zyN)Fl=;-X6`X-t%lbFuS$~t1o-uNEORp&-{{miY0Z6sqqaGX=$zp+~KF1b{M%JL0b z3Do$nt}EwJL-3~=hfI}P7$tq9qN2pa#D?S(2*th3&CO+GWUQ=;)%X}0Z65CL+S}Vt zR*ljoXJ+6`KA(nJ09YQQ$BQHrI1WEy5AeVjzbR)7a^naFl*b93%}MWcOOV*MngjrP2G@}rUL)=I?g`_pW!4CPq<#~ zp5SAoZlohWO4ci*kC|>64{L+8YQL0C6^qFbH)J=poR*m$45K&a#wvLe64F}%Xfwzi z`)xB*ru}?FBBE)&GYq5Jdw;fia#GDDfs+xFs-2IIZ#cYCK<8+Zd<&I;egA8*!QHgW z){uw5@Wr_%P#B=XQ3L2lMRpryBGLr<7*s&zGFCxK`mrmrKHJ;H>U)73A^96gdx4$Z zikE~OUhd+Pln@#uiF`O9sfqsaNv(ty+$CQ_^|5NLX1-Fr!V`aerm5eS^eAP18wc}1 zf^1xq288bvaNpNzbT%_KHWn3qCbPS@S6WhXa&nT_9}^R!6&?|RmEd*^3UD zAuY$BBYE3I;aIE{8_PEjI?6tYEDXwyRNp!!fi>>~xuCHbc`OYdJKLwUQ(F9zKe`XDm_Kq3rf{z_Y|hT8DVpzc&1J;!aA zB&hXYn>-~sHmJgR@craIz*w@rp(T++aHsAtMKt~$Ryt)pCUQ8q~FFZY8SuPPli+!|YF zv7u{)BhW@5na@d?p6@pZWJLEYs;MSXlM-C#hPEq;kbk$yyKe8b{fD)X2BtOe#Y3Nc zdu<+^n`5e-DXsg<%;4Z)5Hf~w`^bo5uf2kTf}^9Oy!;UM9tZgfShwDm&j}VhH~bMf z0^!=tyDq799DcNKKTLdQ5ft)W%Rqk~idtIc|D^-Fa2So8tw}%3e#z(Z^b~`Mfj%I% z-)cvjs*MeyV(5wQa7@r3oJ$qe<@U#+tPyF4a? z$)`fzS?UQcPzqE!N{kjbskZhgWPn^8IAt7f!(ua2xje?;RzjdHRz8p8nj)_Y(ec3i z(oT2sSGRnY^XW)*pYiT$rfrsCb!lQYWuFh_NY_3hYA*OI)-^ms!c&hy*b%)4WUmXuQ~fJA7$}a7ddBoI!BA z`mU}nM$HN}P0g_2;78T{_)#zVBsHx-EI1@YztwwcU_ch`#Br!=U~o^=)lgqy)K$dx zU^(M-a6M!(tl!0`cQHA4I@;bhVFAgAfHn!Cox)~?G$j`A#C&r*gsai zLk>VVK=}gq`byz`R(%EG87l68{X zaEzT~WRTB4*-v6$vaz*Ow$TkgEG)Mv*;$}r<;St6&o{A9xM)?N$Nq`_FOqH-u8ml_ zuxgPYrC*M7sjlS^%j)a%Su4*vT9-)u-$jP9KIkOHC2Xq+@+0H9n6Pc)s*ki#XWI|V zv+qrZO_Ovu{kQyICOe_#(hdh7tmYQ`DxPa}opR63qhM^BixzD@hkiC>-r{WBdJ_vcG~seEh~52rIZIzd`^yt&Ew^Rh^L zAhKw{1gM=kXJtI3se#NI(9GejS1SasPGE=%iS`|gvZbz zh}GE|Nr=9iExJc?hS=;WZKTO%HDs5Gb^Vq@Xd42FPS4CBBO?p@!vEag-Q3^bb8&Ia zY7qSCPhxCvfM&+WKjp(CW>~Oh2)5&)0&fD>!T8t-t08;bc1ifLxBj9tndIuiA0npm z88vbhl7+VT7>;JGsukDwuU^!_Q`KCE*GoqMUMP_K4TqKqqgd#(tL6MpzQQ-od0G`E z#UYMSSIrmbBc=f*VjQrWB@7Vf4?MxZQ<1S1u|6Jqu?U7Tj}AnHxXwLNG|p@Mfdxpg z#*ce{PJZ2^cq!fJ0?>(B^dLB~u8}Ekc$gp@AFYdDD}dUT)Kj}^&K^EjnAWai;bpik zQrlXP2Awq`b)Zn|F9g8hsc4E}of|M4mxWxXEj+D!)%W)*HK#BXuFpKMK;qqRoe53K zk-Q&TBGvc(&acaVl9HsTp&dqL&TQatvVzBx?)pIZh(NLJoaz(!xj2ViJt4$@wu+0S zlviGFRq7~HvE`ithbl10KTa|P>nyaDksRTEH`RN5+Q)H4((=q8dInyvOZfOEG*K}I zKF1$p^w;v;hx-m{=^yYR;z(L@G7SyKBc6ANs9xyp?d|9gWim{VPvdS63JMGyPUkbJ zzmG;D>g(^13=fa1|6X@2V0_rM*VxExd?^%eC_0YoW%Nzxxn#@_it@ubd}c`_{gM`e zT?)LntN`4N2Gyfx25=-ErY%xDq+>VxJiup9FrINnMNZ@R{0Bb86v@dBeirIOzv;st zxeG|ck7D#Fjj|+-Eu{NrcS|jTC$+XGaWgFtSa+Fum!{y+w|oK{_? zt+aA*ygZExZ$~FO;3PWgNFP7==>WnJa~yvbjz6OHS!ziuNReTjQEi0JFLHA_XWcUG z4;`yFM9W>IW_eDX@THdVG{ukzi?MCn9R0=-kSG|wXmh$TIE66|9fsr|3b>-qe25CT zk{5(Ujm-G8*UX*bi`Co#%XFHpn!Ue`ThZvZ(nG0gSa@wO?+GN*5T0*vFG5d{IkkCZ z$1|=;+_R(!$qig1gd%F7ZCg63=T^5Rg@DRDgRn$mC4C|3(dXk|h$0c?CZ zgvaX8MLbgChHW4;Nyl_Bk*`6OYkarjjt!GO!9`_Hjx^k4zt2U56wAlo%3H(N&K@O5 zuhBiKHqze7z!zAi=TWc|kHuBseU_Ob%>-HgN-E?osRse_@#IkfO~@K-EJHvv`DOH{j)!85XmXk>POR2z@L&5uj>Y+ z_l7&xKzn3U1fDi9#$lhAOUDPCE3w=|e(*1l9~WT(t}wKU1l1GQ>S}77M=}LB3!o0z z_d057ID~{&_TNp|R##`MO@J)=Z$l#8^DfT4TRC7h{%n_A8o~sBjz3t!>Z`&}@v$|L z%PY%DB4YVo3Zx4S+#tM<<>A6X^I-bgmi5MWcCj!P!RGT_k3Wt~Cu7+;+z^k>P0U1O z2bAWNlrpW5QFO9viLi=R_X9ykntNEvYt?1X!p@9I&U5XQGT6;!1N0l8w`f{MGle5P z5uZmpj0EAcoC&aL+D`p7UtO0sU#`n@o$`VrID7}na>sV&Z7aQ^3>RMmD`izl?H9pY z#8rCHG_e`{J@{ zy8tqoholt!PAb6f4k>B|bTg__Wl>pZ=yD^_zD8k6f;0^l^(KVcOzT%# z^4B0>L09<71da=?Oh2O|Sk%`kHJ4^kN(vl{L8SQzmaqyV)P1oXDbW$aZvA(&4FqK|hcSin-^N`|dI_ z9`DJ`Yjv(Vx{r?Ay>W_do_npstv6`*2vd7;$;UuT1Gw>=4G*Z@5gcfM?PnkrAr~FD_mt z2zXum{E6iwZ0IB`hN6x@eFy~Z1#9cxMRse(Ao=PbpRy{z`Uhyka}=36ELG0o7MHSS z;eV#j>z|5&em-05nb422;qA?+&M{{rWU3(dTgf3xPp;`DR@WGdZY!C^Y70EI?DCbu zwG+$j{S2C7)*yu<2Xb<94hjOa-nx?OIj&8jb{8u8@Md`;^}Hm!b6cxN;8(lIqaP^J zupG2ylw@i^8#Oey&zpLU%YjYj%UM|P5iZQ8CX+@dRL-RB^V+cVM6uKC>=*)tz8jda zO(b(~NAMOc_jLC5#2jW9Da9`J65^Cvzf0)0hU{bvv)tU=8XFtUZ>Yc>POSP(i76?T z_C$-gA=1*)2u|Qzn!P<#um4?w*@>7Uu|&l z$eT#X%3^zfMBJ(Gn3=$CPiBRj5X}tR{kW-3% zP)kcoS=pfN-cwWKi)VQ52lMJmN)$5U@@#V+d(-8Yhf4_#Z{NOscZXVI+=X9>i;GK5 zO>KUO57;SW@%H1iH2D29@TY~nruE&|yn>yRDHpO#D$5u0W8Q)kxawT88WqHdB;i;W zehSI6+2u#XDLKXncppp<*W#%^WNj5ZwOp8s2@mIxk+Y$?nvIiF?ITDo zwVEQ-Ge&Z)M7`AgaPjqAtK(`%fqdHc@89##rf8TCdY2$G&sEgy<%W8P`yV-!dEajB z({`F<3*L2{pIF0=j|QAFL(X;>Ri@Sax+cm{B(Zk5+8{m|2Vg~^A(U-n@2qjGqppri zMrL0>Y!FALPQ&#K8WSGQ>vLi2>)X<97{~LDau=AGC?z(41J!st;6mmcMNzqJ1T>D{ zTztpym;I_WbVi9>{bhWn8GT}0hdaENw55R(Sy`K<jIhOCyuXDX-Z(I2auf5pVq_ zyK1HqAnbi+siYM5NB=A5G^4|3CR0;WsVO=zLOO`U?nAk#?~wc{=K3&$y^^e)T#MH! zO>});-ulVLKy`I>PEJmIe0-r|7TDv+Kuj#Ch(6}ULajlJBo*EZ9Ka(BVU)t1GW`zx zVWzg0vzjSbJ={T0Z!r>=@!h+32ssjxlVi-4mzE|t&|OuuC>eZ@kEguV3WNw+*85tRcA3S*KE^Tn$th?(XbnuQdv}MtjR5Tfiv4#5YixFT*MMXH6nB*!HYHAgW zdwF@eyVrX@`4P-HUHZx3v#q6~ow9b}L=>$QFUG&*VE6NJV;D+FX(K}F85wBB zLqkJTmYkl<4bLVZ)(Q&f%c4lRW0o#1)sgz!uGd>t1|0vnG1Yhn%%*jMF*m)8}4 zhC%5>>shuoXRQ7&{!S5C@-DCwu~(0WNAx4xx)J5D*E1#b?r%Mz98)tt_8;0^bn)%~ z&*wM&#gz_4{z|&q>YXiR*}`L`{joF%8AuU#su$mZqCS0x&=s=j}}cjFeVbYbOJj+$Ho#|Wz?J@!K)s6`ZO!(Wpx9eiiKuGwPiE8 zk-Coc9(B{p>gSxFMTmik&IN}xwXMUt@=ViHYZ5U<=U-OHkRl2{icBB^E5#G6biZ)52kKQ(!eN@8))e6mo+u&dAux z^6VK<##vQI$DQwL7j)EyS^oKi<-Hy|QH-(IC~fE1)(H5B2?yZwSa`A{eAI@U^iLt= zg5I@)V>asJ*<5{MutR%QH6_%`WX);0C^|R>w5$Ptw*$LheDx;k95~LfK2;M{sUg8mkhaA2Oyar79UxD)c=yi#8iJ$+c1 zj6S<%P_CgZ-UppG?xwyp8Op1=ZM)G`F#YD%hJUDerk@l;g@x-ZQ}VE#M8DA{*Z1jl z{mHT}{7{kKwIg(*Eenh@ndr_88_zz1OHcT2vM43jV~+j`8e{7p2a4|{wzRbz+xz=} zt(oMb17Bn#N^SG(|44Rn=6I8~? zR+yZb3{2TjR=Ti$=y(RIoSS??`$T0O^NW0O2kt>ru3`$tbt4M^Mw{`eXJ-to8z6YPAH<@hz z68{zwV<97S&y3ufsH%59b2TqM#F3$DCF_gj)xaYrVuaOIT2&-<6^dJ~!DDoCX5T8b ziy&;7jvCE2Ht3ibSh{I?6uM$Ec+C*IS~zmT{X_2j{QN+`kAhxJU*E4ecImLts>*kF zu#qy$f2>-!a?FIrbZdRy=II84Y`j^}J%D;wPWWUrt^HotusD?()NnxgHp^wT`zBU`)0~7E&j*(K}rPg6W=rZw=5CX-6afqeye3Z=cM7w*df0n@7)f( z(~0=sQ#kGbp}w>cmDjRJqgmS)XV06wI|~dE<=DI-SjnWaFDbT<%i6Vo7(OYn)>Zc8 zqG@a2k80s8^4i)0(wtgUxBf4{aa#+BJEI!aLev+ZoTWoI zAhUGIYv;%6EOrB`1Is93bn-?fV~rw|jgCk!peb*U-S0-Cse0iw1W;0no2YIAfP@xa zT*3NSDgu)jx;%R4(<>X+@60mshEp}KT>*OvNxp5&iu0H~Uq~92?NxolDh8JP!8joeGppN`I+_v2I&TEFLp!kH}bi-PzEkkj&en_CHo)1$)d8Dz@|VkBq#hI<7^NyB&a z9g42t6XB-yA`D!r&Q2-8>mcQK~ zFf&G_lmh9nlMWB%#*!u-_)p%qQrto`md79gP75nHOTa6}`5x&xY=EoU(YKf73sr=` zPuo2E1Ga4r$5?APD)!6W$2j=Fm8GL@g%04>YG+t}zS-=HETT673sqjTKxp8j>e!24rRXRCP<{NBku8C>*6<1*{C?t{d`^W@@Jn9z0+JniT<&EvLKP(LFx*CjPD zXBgp?;_jy(SUK3+=F=CviopZyg3A(pS3bCR8Wx=*jL}@V4MIs0Zgm_UXaOf6IGsD^ z$_z=EN%ycgGx)(zaudAnOwvc&woxWzK;p6Rr1{Pxt1KWbDFR7+zEb{Jez@p1z@@HZ z={W|jUo{*4osw}88DTn)kK)re_@pd>m}^8&MAAl>!99wZyYsn~HIr`!6^R_VbcbFV zwzZ$ysn_jv5T@bv(mULFJp~i+#qJsDXBElfskxYFaS-m+*sitr|V*L1&7Dk)u=*a72&kQ+z&~^Ow6f$8}fv9T98nVkObCtpBy)s^`C}-n-A9L_M$X z_R-a_L2FBgn~xRyf2F;lwIJcpy%(v8e#)rSJJ)ufa<$W>H)|~^l5_+X?bXXikO`>; z{I_8a(@VAkx5)8n{88wcEoO8Lkp%?IV9z$#H-jHnW>YA+QTHD;7yKH@tniD(g1Ac| z%X9%IU%7|)%b&^2d#KmLiZZFJkMC-&N;;cVsZCNPy#jpPyEMBB;Qqwl8<;M3=p00} z&u0&=4)Axb%LcoPkw|LKRKDTPZ6hnUud0QOR=tw?5}x8u;k0!q7_MoyF+p6u&h%h* z9+-tUAnScj>dALiGOAIG$-rTB;#&Q2J7Lvg7y=|lJ@VM0H)lm9y;r|cApiYYV)sMb)`_kc9wN;?SW{(UmATnlLddo4V`r?l1A6;zCdf=C&7zmJbwbZcZR>PuM? zpvo~txd-<*>wIPb6UkqvyiOQXP77=hS(M0G(Yp;nIFWwY!4f|3EdGU->gf|Z_rvAS zXMA|02bf(WU^vaiw$t1+p5yypC-K(+`Nd+6pfG=-KWj#?XPudFnEIF%_YGba%18BU zp_S%pC)rnEj(~vDR<&0)>U;O4<-Dm ztN2x@I&dS%#m(*90la_}IUQYZzknrV6APFf)_?j{R^s(?rw@_Q6YHK@i3T-95MN@CAaP8V*bxz1tT?j3fkv13YYR&HSVPt(22k8c9exk~qF!E9B(T zyLY?o#&iRyt%f1vu?m*h>A;q0W^GtpQa9o``ddRBKnjsS^kNM54TUtOmMdc0KzD)!m66E=y-iC>PXWIyPinO{&Fj>o&;1eP zT*{Pn{XL`+fyI}up=ReP=Yjb~I7%`-<}}(s{{W~q z`C%Orzmofq?ePHRwoOibyD~swz6S$!)^#B~@YEDn)h;o8Ls#=Q9VHk7p+0ik1=Cs! zqm#L#3v(~*>e30>!q4VBE$zj`I;*jrV7|zH?6)&@d&+^TvW;Gd(v!To*9gQdF(u27 z;`$F1YR&o>a~U{fQMm8{A92ucPKYVNmcG!hmJp=pl);|d|0qm}HRJ8im)9iw8?9}H z&{d(YVWFBDy7sQB(Tlyxz1qrNT;hK!5%E@d2d~x&3#n?sBVZg2y&nsY*roOM0d9J? z9zJz>MpA9=D{7WE~ne zrf3#XKx;Vs+4i$V^p_W#&7?j7oR(wbSrT5mtv9(na@(`z_#fFF`<&TA=cDTb$qR;X z8tSCOZ?1Fn5Y^@;pCcg`5c8)2Lqox9)GvPh<-?otDF@-KdOaS?&-T&NJ|D(*NOK_^ z%UU*+d!;OAACWB(9ooCwd;FzqM}@b&_R+)cAT!YVz1ecz%5N0EjJc~wW=jxZlFdhC zFtCmmJUgQp1r$?PCZwhA!tWvzwD+2o*dilGyA{Wg`TPu{+oINN&%Kxnsqg(V>U2LjR~phd^r>WSfPo)zyCN-(@8 zhrCu}fHpXeJMfVMaEt!sWT%?`4IoFJbw}=fu#=SOaFteY9m>u3hR?)6KT%)tFJbWY z@8ChIy;d8|am5k7Sr-kpZ=eQ@l!L`lny@y_TW{DnhSuMv&;&>IT{`ga$+oh|{?zD1 z1CyeXwYhcWi+~#Vc~^cG(<4_APP_UTQUH`BXMR(wj*qRMV)ef;vPHO_KeWBx8~gO^OO! zdz-U{ao2zNPdP@6+5?GuduRb zHMKR{Tirc<`}FPG=bqURMR|!2Ncczq0DO>={HhE9Fk_JW7zAj@=k-TB5&$3pq`rPu zbxS*5arVOOTLR9_T@;)gP+2!MePIzliB@jvx73I@&@xU!iy_tgtW%`m7*0h;DX5im657yu`G)JD-tcNGOP*zuacta zUkif&RT=b~;QFtIX#Bez(0?@o=-!Jj{Z|7)4g3F74T@bdQSTMc9O~Ap7hs89cykth z6&R!6Mv5;QRLv`%{`KGB&^#g$OiG~(@L)+fDb>oOQd5(qD0)O)q<^51zyfUSY|5j$ z<10G*J0m^rNt02Q}&AQ>c+LORQ%9a=al>``;Mgo!mg@~IkGSy%@ zVP5#5#=%lo?FoE9ROb$6{@hV)Ol*2u8XdO%`|PBcjP%&lJwt2S#35g(-o^jAe_KNX zNV295VpZ5jWaW-aM<@jhw4BvQ7UgASt12s*K3Sj3mz6%s!^=_x zIO@J;ahez+|Hj*n1Y>k64YE_gXPaRj&N||<4_COl0*2X=zixCZ<_hxi_<4B~>KEdw zwrgZt|E?Qa|0Mrk-@#~-@*`1Fc_>^7@f@9crxyGgWwS>&v+C+%nTE&+Qoq7HE=OFi z_i6D+wMA)216U2iM2d3y9~AJqYysXpIJohqpL@#{X;!M-*gD&B*I~%Xvg)gZw$2$` zq`V^|3!lFH518M+B6L%=*t`5uN~WR9d3LOj${tmNOfQyqoH7Z8M_Jk2B zl!5;n&OT*t$KP2tM*4!7L1x#QvKh-z`OsWUp3~cwpCRN_7nXR90!G8Q=Kg+LafA{- zJ_5a(3tRicN#b&}c^rw+`37M6VfLu*^ z$pKuLXnZ#mikADK!y|)H8lg_TADr;-K6gk({|ACX8aO`^Rpo`Vn&uB!V(Sg5SJ)x# zc9R!rl0~d}0m{Ye55dPZ?->u*p0g~!nH5XZVc>ago!=hisYEL?j{RL^{hp#O6^;8G;vio|tO zH{+|QzCyt+&p7_IhYY(3)2REV-e9Q_ZQv|S&vv;HSM8UFjtV;bOs#I*r z+aU7=vVUIq#YB~2k&4iq1$Q&CRMEj}^~is!_oMY^Gzu;=q@R8OnkY{M2JCOW>RnEz z3>_Ym-A&=Ty2+^&v)LBe1PQTdB@|!GFsDRpC8b_WLY#VwBAq@L%46KfS9e?(f0A5t zv!iP`_V~%wUS+CydtKf(`QLGeA4D_6Q{z&Y5~a!*NAl1S1D7Gz9T#4H_)}8fdDh)S znVWA4h;SO%X=v=#mHB}{rEjPCFCXp~s?8IR0>Xx6>FG=Er}mtD^cZpXzG8Rc!a%ci zu8i-!^Wp3NyO_c%jDuRupo4;@LHglQ4GA zI7*!xXd11+7#PP7wq0#;IeHcBW6}f1fi{(A7G#y9hq|lenIFuYs|<#$u(1Z%OGFC( zO=r;8uVQcKR~0y2<&>1I_RbDJ6Ld3E;`40zUI!Q#e9wPvj?3`@SCA$vcm1xFe$jW11L01wrES`I# z``NCHOZEd4g-0EuwwGZ`5~$_+Y0?GY$@wa1%AvGpg4uf3viZ1(y9HXv5_sd<>nbl_ z`lD8)`u$+KO|Qfd17l~XfGj)v?#+4oexTaH0dY$lh4FbBtS`2zT+POQgHyf_l{HT& zA&Ov>1%D2|koWRC0L}2BS<;b^I(9(+^vgn)_{bTK*WFdE%xQ_w%E~;rmXA>@y=0wd z@KS;1w{dSx96pnQf}Fh1(=1!Bab`iT`8WPWwfmv>DK6GW++7}ZHj|}Nsrvo}A}r{E~PamR!w`?XG`Rw0{dng-Lv$UbJF$FC}iy1nV3 ztG@4c7`nnV4QgW()!b{cc@@P?w|&>I#C0eKthaqRLAzjF)#kqiM_V*N@ZxgV@UUzx z)O96}D03eoz_4{XF+Oy$5M6+}B8d&vol5|ZvMT@ER6_}UBxiiVpP$oq5n{3Ab^}>D z&DHcBaUx9)2a(iWxg%~{NNA8;!bE`&{Nf_{Z7~7&xqnot{gYK<;=SQqBt4TUmJd;3 z9Fnz6^)X@X#!I>ISm8a_(XyiSnk6scZNy&U=O>b5<3P_uqt+$Z$Hy7u!z-=1rcQr$ zZg%OCiqd>E)7sfq4_Hkcl!T#bd7*#7Xo2w-`YdSv@KD+GG>mqIzm^$YLF4Ks`L?Hl ztIUr)`x9uX_Uz1Rdo7#HWU0uUIk?DDRO*XH7;8k7fKs8gnX}nkW+HW%wd#;T!Q7B( z)%^Ut^U>@|oA=V}Y@L3qhxg-kWz8S^KYs}Mz3$Dsg5I2u_XC*=ZbHMHxogJ}MF+^2 zk!d6}&D3{$=yiSHmN?>BZy7ek>a@4E4NbHs6qK6at@|SMy$z_VFRmSD1X`WEZgTfq zW^|}v>8hBXSsXi0c|IF&?4FcIdV3iy`K6*(b3MOSR)qDQrtx`g%n+`2jug;!OQfw_ z%Y6+r-b*u-Iayij4bys2>`Uf*aEl3$n7ex=^r7CFlgorJ6mhlN37D`ZyGbi*)$6{+ z+aRPR?Keu{U&1+QEP(gzR%w5uxVo;Ww0!tMO$G=E{jn*syt(y}qL(Sl?jDNs_F52C zTwI`^X6252c45|jdU`~3#uOcn2(NB|@;ag)pFa71X(V!`=GWh9>%TB|W+23Q(1pP{t6QSXYCcb9u? zxp?!>)FlGGQC>&q5ntwrsdhf9JXw7{d2}fSF~372n^jmFSgh|CbU0V%W3Rljow>`Q zaxa)YX}y(Yth@rT+DoQd(UJ9jN~`5*w6yE_Ni>J?uBcX`D-y0=IpAd8@zw#Z^4xaP z+afY$78bDR?NZ@?Luw|-lvBHHyZPebT)am{P4cxgvrT5QncmgwsYo+@db!Qx%9hym zT4iO4@1nlM3Jxrr;dQmuh(SOvEkx9DPUiKts>1z=()6OPR=dW9c|!$bYM(M<=D7sx zYyVeE6LUCb4n8wop~Ce)H7Y}90Z+HCK7Y1k)5MK;vFD8}xPzMcT?yL~sZyGz&lc?z zqgn%Y()4amluz||nNPF)L|9OPD^Y`&FzFCUeX(}4TD$(|@=LPx>;)xr*8u$%g4Ae< zIG5Ac6A$MPE-Gk1RC@F=&Yo4CdA0oKL+{^{pM|e)c7IrIRh+JRJ}z#lGTNOg z=Qu>_6HhawIQaiN)iLN*iWjMd5p$M?Z6CjSH|_20=pi)YEA|QhIo0WLe1pPrryp!~ zS@MIX%)OuI!c{4!HBq)KAY8d4@5}vW4p~{DILs(`Y~M~6YxSC4(%DRZ=`tzE$?dp7 zHtWx5x!Lp8pynbSE<&zow9k<6kdhjSMLL+*)SbI?Ek8Z3iuz)J*DAc)AN2DJ$T!W2 zpk8$D;+q1&`BF;d-0-yS{yGywn$J2o)@NWgON z>OsBgl}lo?owd`cpgj8Qh1vA|g>hsHMh2TOn6b*gu(2pa-fsLT6veK(hMqq0)H4g= z_-Uc=LeP4j38Z^z9`-5e@pUzGR)6%yQrK!a_#;t^9jK(4bR37h?9i zBLy9uo=>_DU6FfOpg@KD!zmKe>j{uKB+Qsvc{&Qa?y58x#asK!c(Qr;F7(bCI>?9B zL06~wUZojDEELEJM|yGS`AN7|ml;khSnlc_3*YRciIoro_PNa?3aZ9OPGh~gQXbWU#bZ6a6+1HRr~TA=cJu1f*ACp833bpM5)*oBi(FlPjwag7JH3Y_^#I?wp+ zFZo+);0uQ8Hyi$gSC}E?43FgOvX96<>UAT`t4S>O(VB@f&Bp;1~%DoP9>sx*WsY@fa-xo}LeM+zaS67~k31={Vi zKY=0V_p>Xyg9{ulB;#=ajWzi4B_<1Jx-)dB&4*su&})RrA7ASuP^44fLt4UgI)>bi z2Rd)U$dC>Z_#@|JjpBQ?|9Vofdf|cL`g7xNIKO+CljU4}IyoxfLi)njKxFWA{d)7% zbUH{-KOI&vS*-Er1H596!I?P0r!+>_$*)>!*OhyDwWFy_WKbMlhZzS#tF4PC6>=G&~=H zbsN8@TZ`QR7@nFUr^nN-j@MRv4=I4q?u#fAe7`JZy&uZVv&1zgjlF>7DUzikJ*211{DgUVe4IyE5lMCX zp^_A6o6%eES$;`2UG`$-KRnJP_;NO=?X}obko!46}Sfb|wSgg;b0uw7|`0Zz2v!HDQ*>~8Lq z$PsWdq{%v7-Rc1drAKzg**3F0`;dc7k>x?&&)r^h=)-#)p^p2k%*guG5~mISu^_$L z>dNBXBNe%tve{C9p?#wS= zHN%4PjVqVs;G@$GZE;k&xL-Jy6@6*4FUMLj&e+AcO zwqt_dw^0?-n(R{>T^{6n^O63hygIllu3o4|NoaSn8!bE& z)BjDG=47s|VX`8myToUxxc&>yC~C#-(cZy^Mvj@@T(%QkNW8*TZ^&KO7?G^Bf|Q&O z`bSfe4^SeqA)fiQMQ71q0i{My{!0Y_=-#^+Nb{7*^t+}UT`fqtiGI4Utf|8=izNwA zGz|@xIlsDK3!HmGCFJ!vw6V>|_bj+&M%AoyWCWfo9T5T5s~aK64_4aRmJ_#k!pxze z;I^}l=*B7Pb^)`IKt>4p0o9THu-4*X|B?`zSOL{BEoJgr3FRu54LS(@4uz`-mxnC8 zSMk`V$%S>4s?9MoNmZtEqF2o%D7dn)kgVL~^YX}PJ!`Yp_L37R#DQrm4XE}_VB)?7a_xdQ5Z&Xa$sBcnAkz2g9nD4Hc86H3ZFzg+iJdHV1F}?ci z?jk`q7D*Lhrttt-MN+hw!OPRKq_L@zh!$*N{zDRbqHGJeUh8S=!!FC|0yauB66( zab)WwXDMvbkvnT5zL(y8c?SSu^%5R81=;hAlmY(IsY#Fo!ak?v0Uk5?x>-0uZ*R|B z$(6RYORD-`<~N9g1AK#&B4t7&Ri-APL}CjDhNgT+tWlvwHdtPE{An%mW>!@UR-1nz zKc3?=zJt!QQ!yTs(MUvdLh^{yA%vE%_3yywqhP=y(BU~#UQS^=*a(xz zj^|6|HIld`9+BF}jz4?^doLM$zh1#OSB2|KdfG9of)|%GTee5g0K+2-C@`O5u7LenuJ~1qn{5Eqmqb&JsH5>Jj^>n(8egXM= zcAt1unXUgZK zX^FI2Ays%dGw`N(n>`tfT>}hHy>-H8NFB{{Wlwa(4FMp*t20a552I2tQ`-d#yLq`5 z45f@CBWe&5)yzlYgQX>JzFEGh#1FzgtnkE;qUvg`)mZAWt1h7gygHx3`rpxpXWBn0 zz6#Q6(gm7|NeJ%|>JhCS*;AXV%&5I?O4(${QY8*;JK}k|7%x=$-__XR9L0O^IXF6O zW=aUrXzB*+2t>H)i>}rr*?ZiS^Njt#pt?hLds2K#XYp?{RECn138sG)Wvo@<;JzHL z8-IdxeOtd>2@FpcC`lZ;l9?lJ#jXftO=K2wws?9-T3r!Buu_~_i5Yr}-?K?CmYmEG z7@>9hDCn7HArR~8QduzeNpNx(=d;Quv3zO0v@!=n2p9g^x2&69;LTbt*(UKOwDNR! zpno6D#?}(#EzmL6S0Ynz`}6(5T0wJWqy_|oKTWP*y*=(9+&DqgS?*hbXBF^g3s1ok zGNK4jy%dCr`Eydsx)fnO)D$%~3yup_rqX$iug?z|85sZxg5OuNg<7YAX;seSzMm#= z;LCJ*Egjn!i5@oWRi@OE*p=9Z-0ryV8WPQSX!aOG>i(V{ddt@|-?by*X9zhXi76Dt zYBRk3w$o~k`bVFrPd%GVL^G}1P!^N=_Znq)gbg)=-$l<5vy8v>GjHr+BOkWS#rFug znXT(J-&mRsB-cIFyxksIlShJIl)A+=(24O?8c`TONM*<1pw_L!h?8844 zyfY~6^K@ZHr#r(sLi?V;N#;VYN0C-^BO?^BHLi6&&5A-kjt6RSlT!JS%Pcih4%C(& zUd4e$Rv$i1cBb{Gpx_a@nH}5=3Ev)l(w^BCyWaX9?R3nOoyd&Z@rz@9#+fQWQCbGE zA^G(+BweHAy4W;t|W$tv!&57Kh1%eS?%?G_x)rW9>Cz5e zEi2JF+F}a$6k2OHTVXa)D-WT!)!>AQD{8Jair_095Thp|^&`yJTD-ZLHr(eTPV|M| z11?;7wv-r}^#NEi#Sf3NPucsL4Xjn*yIYOoU$8mZ%705{O0Q+n$$LdQ9cWeQot@!; z5LGLP6M&F>9|*Xv&(^>rDV&at%RHjVWi!tL>sznbF4doR=;HVP;>yIgbdx}d4?a8U zP{q47^9zQ^{gU(A*t{QgUjhc3ax|DY4O)!Xeg+2@?2g_v-4gQGxiaDbnsxV9Jymo= z8ZUUaB%77plQ~j-k-cH3>UwYh`q^LMvN&dgx~<{#{yw@s{1bR@E+a+qErFrR_7|(G zqhh_iQ!#Ni_Pq{U!N5oY#j!ZR*R)?l#*7Z^7H1Rw|7rmi93O3UGdv6I#H+B@PH0)} zbd>jUK7wptJ#Gh1WKh|0eX9;Sj7D;14cuK`E@O2UE5qodMP&(Y_e&9kJs9;*mFv~k z#;9*9s9=B)jkmm#kwoS-m*?5oV2->T@DkIPKCi{jK>FWM6?)*-&oriHbZ3h+QNrzJ zi`Zo$IR^dxkzbAxeu#ew+O>^87Lfa?8Q>MzSq6Nax zW7XXH)+tYDQTg{^d6P^1=SQ-O8fx<>-rtH_`F)f?xLLMr z#dXTsbhfbOYV&fFG{l|W${(3VvearamSW;3_fnp|v|ZLp5(^Df77Kf|G~bk9va(oYX<2W(R$W== zfd?``Xf9Tzd#$vbJtLw$tHV+-HqQ1$X7j|iqcMFaLf(t}&WtLWNHUc3!Duy(R)xde zz1nO95(uK8p!7u$r9y&&z0vgWSgPvjMw3Oa`2*>O_A-@XeF;bc4v6C^MHKLzofy3u zmsdcIWLtc&l0%nRa@CIA1_zsp8MHidB+ft9BiJWE4e_wFZ7n<1TpV}FG~wtaZ)MVR z3Wjb4*P0$&(X45Cm~rI|&n|9`1Z7K$IXB{GE5D%DTbd_-G(!EJ@!^O^8UT7L4cdL^)l2uMi(*;3mIjBtM%(nZmg)jP(@*Yz$l?6-W8PFd5K zxD;DUTWycqTmIj1yM>$)by%x-n61KT%Qf2{V(BUehdqP1f=t74$7_n&m>;+Q4tccu z)UH0cVnIwW*f-nT1R2^^!Km#@Z3X!36c}$+dFr%|!R_g#WYBArhoq?JZ>PbUkOM(b z3MaBTbD}r0N!Uj~X;_iArC7rxObH^sXfx^x1kRryoWjLTkZavDaKtA1iU$2!J$D0Y zK4t#i*@z%B5^5WN> zHe+;8Pc-lJWkwR|ODzELVpOsHrjy6Z0y0-@b4*2rkh?}%x>&W@IGF25@nL^B1vTN+{ezVm^(Jv=1|NTGy@^)!~Xq+dQ=)PE1NkO4~Iw z*IR@Odc*DRZhpz7JfVJBIdQ`YK(m&)imQ<=QoXcItxpswn<&1jap6iZE&Uksx)eN^ zGt!5#_`EVA0--3DYoxa7PM1ANj}ODon@8}uOHYq-IFtYpUENMd4jYm$gz$Ky*8^e+ z^bcbtk~JWVWscR^6b1Rbys{*y4uoh2-5C9fb9?T@f$)Qk2ZA;G<*p#q)|M9i{4j#K zu$(w9%NfV_eNcI&h6;moNR%B);x?rWDICKSR-qc&WTL@3So{%tG0Kx~7ZbPO097(p zZk0UqT}aH1+%SU=@-T~lBuKA8v%dfISmSRAAt8?QQBXgetRs)W;pPvHDSk>HyCP|qz|86%+$;Ok|d?3 zw3wi9=W+0JE2J0Ha&W8BV)2y!(}%s7;LV-M$jKAC$qKt5gpVS2gn#$0OtUiEZmkV4WWhxs4O5n<;%~ES z=id4XoqONG1wf5Mm$2>U{Hn!!VRj&7l4bgz#aDp)=%TnV zbiWo_s_G)SX*mK%9MWih&6H@=S5|6OeH-Ww!7#|EeGsu&{#C0tV&f?GW5@vZ4_br@ zTmQ{X>1453&tlkX9|Bae>ZYM}-rRfM!xkLNWDkGE?^`ET^3(xn$*RWLQT&VAL*LMc zXa1YPc6rEphooNUoRc~D-1~XtJ8>d2!j|e({6CvmYRwFVepGfV0f`3dS7dU57Ppz( zkLQ|QC7WG3qWO&ytLsi50%!}<;OWpK=ws?fK4#;H%?)hrIJzS){mcQ=i3aO3#Sb}v zZ2nV&fK{U{Qtb&@c4ul;MEhN)*b^OPKCQyF{>>^kKH53396sSfu|fwT_DtFEfSSVz zkV?s{){{am!lQP_q4qoh!pEe3tW>fA9(h`2=c~X^D|}EUxsKS5|Hko;@h&!`3UNr4 zc>C#OTYG4nJ$qxZs92UwrS|cidNbGSUAhrUAG8)BCuG&kC)owPmJrxv-7N ze3dqMw;Uje=uUb_WUaczgYr$+%jz&4{*HLHiO6X~c{km&s^Eg@v84_DJ+;adg?|FD zfr|#$i)?3t$Q-)K8-ZjT|G;ij^293r-&yJ(aCKC@R*X9xR543X_nodLFdPQXv@;Y_ z+DS-EWIIWIj-RIuV+|#)2|3W6`M|#IhrpZ2@#KZ7maA%A?cQqB9f~Q5^3ZUESM?Wi z^*JH^=$!vO6NC&P*Z!xe9B&`wKSA%j5KzSDv0l7MBkWmZ{x^dL2gq6N%-B7>#*koHphQn5x* zI;=)U89+{GZph$2L8!!m0L>zf%Wi9@Vy}ELVvT?XL~Ob1Q!4+RTs9kc7TGGiJ%WGl zmlf4s|I+b)%2JAe_^KNZOUD_jR((5=soFJSuWwX0JZk1}(wMwn6x6=kXag@li~3@D zED-gENh*Q>Pl8bd0E`5lUi_`Uz>Fr9V)9aB{ZRwo^6AtXwLg7QY8zSopNZdyor~R7 z#VdD4DOlBt(5s!UJz5<`jx`XBviDVUnxgwE+Iol^uR4Xfp}`QKKzzvzotc0 z0>q^2PP+L_JLX_}qTO6;ot}`E=5SH9yPMv<0%5)6n?w9L+mpKKgrD)tFx z)rn1!@S7lyXod_$BKSq6$L35OrlqCj#<*%rzx&_vdJ9JiEK?5Y9vEQND3h0wnLDh> zrb%#M!BNz~Qe0{thIXo1OiNORKX=d2=*OZ{QR_jM{Lcz#k2)<=B$Y+dYO6)R{(}v| z%iE8q9+H1P=l>VVVG6J-%&P$!tdcnkf?q%GQ}dD^03eW}BQhsSQ=+$#?Xf#gN8ixb z5jGPhCsER!2p-j)avSSL;$v@nbJwIHA2=vDs(w11Wy^{ldb&k9aiAhnzm)dD#X@W_4uu!V7A9q`s~Y1F!JG-YcUk1kEylh_}bzETbc` z?)GLW$Rv-;@SL8@(_rv+5;s*pS8m|ug$a;`w|Zg3C({3bo*yrbE_UmA)N2{$L>j;oGT$z1EI>1Sz>$Pzy*Z zPC4R9N61q}2{r?)G86VIjQ)gXb*qb(ea*8A0<7P6Fnx7vVXT9=jIRx>(`{&t{L-G& zSum~Qssm;3iAfMPmjV|(jOKm}M=!Aj+70Sn2DgazlB&{VJ9=*; z(`ybX)=yyLJp_e-wkc>E0mX=S5PB#c~;X_;=jJ`prs;ebMhDdO{hFDi9%T z@=Cm}{oK!HnZ=ot%PtVi_^^)7F3KUAGJlq3>?nErf)95|n4?HTYoYbh&y5t%p+$JF z7AkeOYtOeu816nL!0@nNcn{@lqCx-5n>dk&cjK;cRb{(7d@uLG?NPFz!A+q?flqkW ztzi4IBY&Tpl;z>fhgf)L6eJtPR(;8($UR$d7Sj{<7MPmWcx-wVbO;NHZ>$gVpYQ?T%Y4{6ih_KI z&<#Z@mpW}Z-F6k68QC3wJw!v1t|e4vP%!HZqf_^Z!_&lVcF6WH<5LRfdY5lU?`I;3 zQGV>qw^lV;?svXB0`vTyU)!U4!;k8Y*&Q#6G`*uWA|6LmHa^P<>G|;2@0W0KTdm!h z@Yh=@75EHA2^linqgd1mldM!!!@SH&OINz+G zRdXGKSJ>!zLeApxVo@4(Eu;E%wf4%|^riNwL1Qmpz>LK82R`kxeY@JGobCIGmV%#a z9p4|X58FbqLWGS)55uhug_HDX&uxyxzw^4M4v(@GilhdS*E{S6TSU{eWT zu-+kIj=A#Kl^+n?q3~|fF6ZLP&$N^=52>iJ$g}e<00M%zPxl20U>93of34db-n`}- z{Go@>YHqbzF05s3^WQ7gdf7@O9w)DskP}QI_GCGE`1$^nGo|siVr7VG)~n6EH(Qu} z*pHthVTI3~ug5Leey@MPEfQtrnbdgXUO`>p7Ei!>q$zLh))n0FeS2Ij`f+klYR_6> z`6J(iftL67t4xUyZCt5bEUG*n-y6@I)!`&o$==PK$8}$SG=1lluwyW5Dz;5Ds%D$h z-t7a?4i1ijma_t^p=FS{oG#vm(0&jn*hWVvkJ0sU$*xX3@gn+bu)d(#@+CrI+uC#0 zX~EcGw;isa!xs6)2Q>*>EzgRJVWKB)rbC+c^QmAIgS0#RtD#Zhd@xCx*8SCwXZv)j zGaZLTlgYzzJ>d^79_LRD`kBWZM9)``p!dxwdIp@}r7ex{OIN;1m&kGUTgKSm-(I3NOJRIscZn~ zY~68+TtwAs@Lrk>;mov4eVF=-3};All0P2c9UI7u#BTjI6N5!hRX={5%JrPMRKXUS z^fK=V^jW?7I`%5i%X@m< zYkE^{$jntOBZJ*#G}%U7+rXl8lvya%b);$T+JLM4(dBDL=uM7<7g%frxGZPHV{ZmPXkO<{jw!$2F-d-=H%l8C6`*jgL&a^``ej$<%TWLT?3Y|?vgDX^B8uFM z1!gV_3JFO8;3C7h=W4B5&-#kr(7xKMMSWbaQTKj+cC;(AfuI$%13{0ouper?z8+96 zi#5a-D`2#%$_Hx!xo3{S6Z*#22jAE0D#wsLR<3n zZ+_JES!Roqk=!5E65#z&C}e_h`=fe9Da@Alej1(hn2v~OAs(#?p!_) zxt-tX?a%YfioSZJ4YA3WEyYi6AE`|>Eui~8*2-2>m;MT$@P7wL5`?GlH-%F|oqt+z z-V516v_y{6;EMB7$xk_5{ytEAHv5ivE*eS%#LfD0B%^@dlL9>azTIDQd@9w`W0;1^ zT3}_x%YOxV2Eb~g7oHfMIdYEVg`pO3_`DJKbw>y@eVSUv1fW|?lT}>1yF;8!q>n?B zsytUX?q-w7|q=6@0Y}F#&T@(QaZ1nu zVYi38}@*>mRju{0S!mGV^h=X@!!k$j|nBq2vGep zs$d@x_U8J@5WtRl;}m}q*RHQyWv(W%7X4!`Itil?3vWGVZG~S!a;Xo_kO!7_+EzF+ zV$)aiJnO$dFHl(C%AleFU-_pUA314a3{U7H*J|)En--U%nLNph)M&>@O+D+zykUck zTlG{jIeSvVWh-BjYZX&1neU{>qqJrv2C?ZwNd!Uf(hWx51o{*RWVQzThvZ*U~B z0EZh&xq15A9|8N}!FYf~2#9KczfavWFmivz%BN+eT*H(zJAT zhXw-8J2>`m!kfB(h-3)PRrwlEaG5VvX&il6^IEAJD$oB61@+^D4ji|m%E$4zN#-xt zE0To^1-7qZl-Q)P8i4@99BRURV%m5TEa)Ef1O`l_8pyFO78rF70U0`b`RQtBc;z+N zC^*E8;qlr!1)Tn)WU`^L{u?(iewq1yupaD}gYNV#&@dLYZsK?ckdPoep3Lo9HQ>ln zuuW7cveg`8ix$uB~tPFd!s%7SdN-ez*_Zi*tca+?V?#qo9S7}i3p*49h!>;=6 z-N3JhliUZp9oc0kd5bxDQ+cGH_}Q=B7_46sIUkK*KaXu~_^B`GAg0w0Bxdj$*v(41 zZyuLe5T?E*uPZfBwdOxC-{c0m-P~!^I@nU##{co@d6BF%OV=1Wwq(C)wmm#GRVs?< zwxqOz6Pf>su)o6jR;B4#80c=kTL21WxC^=UVLsaNA^f&0Cy;MBu5+O1K`wJ-DMXba zzplgS?nG@WS3o2z7|jNdAPvnW+QzEBFB~9la6IW@K`jui4>2Xom^Jy~(fKMD{wqP; zErLb*>MUMWogwmFN3K$k;Zz8k~l9V zyAKM(G$?{xah!xKK0z_+^tCB6sBEb4#i%czGj-OQzwk%tT~$?amLCG4!Sj`NvKr16 z7jt_(I0U_9y`nCi$o}ZL2l9G}zUD>GYgJS(cK%2N2H^t`+<#~}G){d1es@FCQ~f=t)szm-)! z&8Wk6#DXr*ai1GgPm(3T8fR05aqmR3bVesFKvP;x~JD3MGBm#9%qYk&aY#} z%Qq!sSZ!^idEjKy)V_VfT^!bs*%O_oCuP!ZZ!vf@^J!3K#0?%g3K3mCI7L>hF(vI=AiOmy z1Wh!{wD#t$Vw&w7S;rFj94Wnig-)mfJ4)X5%)tUAgK+81Zi{tEEJZNvvYHZNc)t>(Lv(&} z-=UhNO(sm@gc!&fTx^U;#;kpFfBemx)3%KF#eGhjQ?;`G)O6@7)5vu${LyCNr@&}+ z6ZhNxWuD(q$E2Mu*Y2^#IwpiZ!v(cRhd0JL_LlVk0Aky}{{<*%6b5`{!Q7{W5)Cli z>JoeD?EAr=nO&~aRcC&~<&kOF;G0TGn%K>IuSUJO(Eq|=+78Tz0R+HNFFnqup&4{w zUW9{3o!)OY;>_v&x=e*WHjLF|QJ4jhIRJo8UF~l$UwUi~LEz4K@#W`mQz}Tn>)C{# zppS96=1<$|Ac1q`F&&kjmEoRBjNjn|i10A~wC9Y60&oLLlG73gO2~g=j>I`1sC4y^ z8A)vVr;%E-abqSY4ea-*;1PocTh(n2tXReF`qfW0>_IglaLOwb-5Dz9QTQe8v58=< zl!wZdJ>*_ZGHU%+s$vph-$a)h?&A;>fW+*mlT!Jt-t%wU>=ZQZgoli<7H`5MJzk{+ zujOarSQ8aE)a3e$u_+@Qd176D^KI&OaSbA#%6548Vl)58hUFK~VcH3f?Q+}WTvZV} zDgqm>t!hbruME0DuYeP*Pj(Eh|%$aYY4GqLV_UpqxrA?QIX@O+)G6`Qb9Ypb|c zIZQ8tV5AkoI;P@eFhaZ|(0prW#KYorIS8Y1sw-UWy_>6sy6lN+Yo_eMniifTh(qAQ z!tkgi+ugGw9AV(2H(t;~T`coHOLgS&#}n0N)hR6_1k=If z0Y$M}#s=RAWIs%R%hK4vTNBoIXDe-gOs0x7ygbjvFS&NQ z4D0;aNY<$qcgfS=Qnvd=v7YnloMHFsxI3%DeWoW|_gG>udifz`hCu+@cExHiclWUM zed3yjphQ4Y;n~^K#nMlm(d8!3x7mxb=23G^wX5o#T$Ite#)4N(Fx92jb$EN*lhup& z|5MAAezTp0;h#gNjH+%TwNq6cyOvn0WoiqtO>Loe?bsqEB1kPIWt!Gj#n{CXDi~Uc zmZD>(Guoz6VyVVX5MiuIYYFq~pD-Wh%YE)S?|bii&-0%9;W_6y@7(Yrg%2G^v>&o` zJmh;478#sO3}mJM>4b{8o1I@xqTuWwhWJeimW!Iv1usNrI`o4>r+~tCu zsitccso_tQ2nUC~h{v!k-0^rBZ07oj+?Y3(f$`@am(0&Q6Dp`DbP~0 zsC%c8&b!rSdw(^V9T6XoDu!@RH+M+VFx*!?D0M*$ck0+pH@g{Xih;(&iE^b5HiQGQ zN9Ys&&QleB&HX*%-K7D_XUf{B!@lc167pPCO7)-34MR2*!tuC%YKzcu&5gXe+p;fEZ{^p^ZwhMBQ~kHm9@CFI z7rokcw^gRXZ{)lDwq238KjTDI+6$oPyUaq|CGKEjVNg1G3QIcwg3iAx^lW*>uF%Wp(u?t2im!Ti0)L-O6fOxnUb?rwN=`9VHaLeX>515xKRggp z@3!hJ{(_gy{)#JKiB`R}fC^7UqljULCZ9KKx-QN>;ANByCuUrwXoku>g8Q^QJLn@H zQP^0+8V}X5z2EmFP-*=|5%9{kd??|+a)A(tR_{DrPs zdAw78&!E2li$Qltlb+K}#jyY@p5ENY17>CkE3$)pl^TQ0vMX>esosbK; z^05PL&uv-?E$V-=``(Z~NI2nweAARshL!~|oKN>vE3_Nv;E8&k1y zTxsF*p?w%7Tt5~j8s*pdNg&;0hP|yD$xDA_DFo!gAC&4C?Q`1SV=mpE{5vZ#&5Y;w zTRYDJ<7t3kvIkrp=zMKtVLK-H?dm}63P+G!pc3J*`Gznl3Qb<{cj{Swh~2PO1#GcX zB(lE96{^s*%U|JfqUVAR zw|>?XJLX+b>q+E%uO`LzFpfBPbbSyz*NB^t*Sh&f=ETo^Ovy4&PmjS`;ZwW2`# zp4cV$d~|1w;Gdpt$vMaD=nfyrSKq+^;I%rGRlrysEGssYJ0_EU)|ji6Zfxap`2)%H z?C^I05beU6vtY`($^(G8NT8Y+D4dnVsS+Uc{fH6<;h8fFTvIfMzyUM=A!O-{q7VaF ze9H=u8=&_giq%kMYZ50iOe2S;2RLpczuooB@lOvWc!lL7G_9Rdky-Q;yhnb_2{5b)unU)5l=CL)hw3 z**SRW3RCVlyk81Q)grWL;+IC&c_oR$X@8^#c%F>i0S`D!Suxu}vK17$*v=TYe4~At zC~BgUw(mjEe8zSWQ;m-Rlh)OVm7s?cWZkHm26hiX)`PtP%pqa2AmrTt3=$3mlm6FG aK@T9NaJl~UT8KMX41ilXS~maapZYKBxJo?$ literal 14260 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

BCM!zfW525M=FW!IpVo+xVfV;{-V0hfwa7#; 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 From 1c09cf8bd0cb3b575ab29e4ae178f40e0fa72d7f Mon Sep 17 00:00:00 2001 From: Paul Delafosse Date: Thu, 19 Jan 2023 08:52:10 +0100 Subject: [PATCH 24/26] docs: update readme --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ad829fc7..58b18c96 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ **The Conventional Commits toolbox** -- **Verified commits️:** create conventional compliant commits with ease. +- **Verified commits:** create conventional compliant commits with ease. - **Automatic Version bump and changelog:** automatically bump versions and generate changelogs with your own custom steps and workflows. - **Release profiles:** your branching model requires different steps for releases, pre-release, hotfixes ? We got you @@ -54,6 +54,7 @@ - **Depends only on libgit2:** cocogitto has one standalone binary, the only system dependency is libgit2. - **Conventional git log:** search your commit history matching Conventional Commits items such as scope and commit type. - **GitHub integration:** enforce the conventional commits specification with our GitHub action and bot. +- **Monorepo support:** Automatic versioning for mono-repositories is supported out of the box.

Explore Cocogitto's docs  ▶ From bb948615d620eda857850d11116cb84bf72f179e Mon Sep 17 00:00:00 2001 From: Paul Delafosse Date: Thu, 19 Jan 2023 10:15:06 +0100 Subject: [PATCH 25/26] fix: infer built-in template prefix for monorepo and packages --- src/settings/mod.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/settings/mod.rs b/src/settings/mod.rs index de764cec..c8dc10d0 100644 --- a/src/settings/mod.rs +++ b/src/settings/mod.rs @@ -234,6 +234,12 @@ impl Settings { .as_deref() .unwrap_or("package_default"); + let template = match template { + "remote" => "package_remote", + "full_hash" => "package_full_hash", + template => template, + }; + Template::from_arg(template, context) } @@ -245,6 +251,12 @@ impl Settings { .as_deref() .unwrap_or("monorepo_default"); + let template = match template { + "remote" => "monorepo_remote", + "full_hash" => "monorepo_full_hash", + template => template, + }; + Template::from_arg(template, context) } From 763a2d481ecb1e74716413efee8ba8d3d125a5a4 Mon Sep 17 00:00:00 2001 From: Paul Delafosse Date: Thu, 19 Jan 2023 10:36:06 +0100 Subject: [PATCH 26/26] feat: add manual bump for monorepo --- src/bin/cog/main.rs | 31 ++-- src/command/bump/monorepo.rs | 155 ++++++++++++++++-- src/conventional/changelog/release.rs | 144 +++++++++++++++- src/conventional/changelog/template.rs | 4 +- .../changelog/template/monorepo_full_hash | 10 +- .../changelog/template/monorepo_remote | 16 +- .../changelog/template/monorepo_simple | 8 + tests/lib_tests/bump.rs | 27 ++- 8 files changed, 353 insertions(+), 42 deletions(-) diff --git a/src/bin/cog/main.rs b/src/bin/cog/main.rs index 72c35844..2ac7564c 100644 --- a/src/bin/cog/main.rs +++ b/src/bin/cog/main.rs @@ -11,7 +11,7 @@ use cocogitto::log::filter::{CommitFilter, CommitFilters}; use cocogitto::log::output::Output; use cocogitto::{CocoGitto, SETTINGS}; -use anyhow::{bail, Context, Result}; +use anyhow::{Context, Result}; use clap::builder::{PossibleValue, PossibleValuesParser}; use clap::{ArgAction, ArgGroup, Args, CommandFactory, Parser, Subcommand, ValueEnum}; use clap_complete::{shells, Generator}; @@ -318,7 +318,6 @@ fn main() -> Result<()> { let increment = match version { Some(version) => IncrementCommand::Manual(version), None if auto => IncrementCommand::Auto, - None if auto => IncrementCommand::Auto, None if major => IncrementCommand::Major, None if minor => IncrementCommand::Minor, None if patch => IncrementCommand::Patch, @@ -328,24 +327,24 @@ fn main() -> Result<()> { let is_monorepo = !SETTINGS.packages.is_empty(); if is_monorepo { - if increment == IncrementCommand::Auto && package.is_none() { - cocogitto.create_monorepo_version( - pre.as_deref(), - hook_profile.as_deref(), - dry_run, - )? - } else if let Some(package_name) = package { - // Safe unwrap here, package name is validated by clap - let package = SETTINGS.packages.get(&package_name).unwrap(); - cocogitto.create_package_version( - (&package_name, package), + match package { + Some(package_name) => { + // Safe unwrap here, package name is validated by clap + let package = SETTINGS.packages.get(&package_name).unwrap(); + cocogitto.create_package_version( + (&package_name, package), + increment, + pre.as_deref(), + hook_profile.as_deref(), + dry_run, + )? + } + None => cocogitto.create_monorepo_version( increment, pre.as_deref(), hook_profile.as_deref(), dry_run, - )? - } else { - bail!("Cannot bump monorepo manually, use `--package` to update a specific package.") + )?, } } else { cocogitto.create_version( diff --git a/src/command/bump/monorepo.rs b/src/command/bump/monorepo.rs index e399bcce..13125577 100644 --- a/src/command/bump/monorepo.rs +++ b/src/command/bump/monorepo.rs @@ -29,8 +29,29 @@ struct PackageBumpData { increment: Increment, } +struct PackageData { + package_name: String, + package_path: String, + version: Tag, +} + impl CocoGitto { pub fn create_monorepo_version( + &mut self, + increment: IncrementCommand, + pre_release: Option<&str>, + hooks_config: Option<&str>, + dry_run: bool, + ) -> Result<()> { + match increment { + IncrementCommand::Auto => { + self.create_monorepo_version_auto(pre_release, hooks_config, dry_run) + } + _ => self.create_monorepo_version_manual(increment, pre_release, hooks_config, dry_run), + } + } + + fn create_monorepo_version_auto( &mut self, pre_release: Option<&str>, hooks_config: Option<&str>, @@ -76,17 +97,18 @@ impl CocoGitto { package_name: &bump.package_name, package_path: &bump.package_path, version: OidOf::Tag(bump.new_version.prefixed_tag.clone()), - from: bump - .old_version - .as_ref() - .map(|v| OidOf::Tag(v.prefixed_tag.clone())) - .unwrap_or_else(|| { - let first = self - .repository - .get_first_commit() - .expect("non empty repository"); - OidOf::Other(first) - }), + from: Some( + bump.old_version + .as_ref() + .map(|v| OidOf::Tag(v.prefixed_tag.clone())) + .unwrap_or_else(|| { + let first = self + .repository + .get_first_commit() + .expect("non empty repository"); + OidOf::Other(first) + }), + ), }) } @@ -103,6 +125,7 @@ impl CocoGitto { path, template, ReleaseType::MonoRepo(MonoRepoContext { + package_lock: false, packages: template_context, }), )?; @@ -168,6 +191,116 @@ impl CocoGitto { Ok(()) } + fn create_monorepo_version_manual( + &mut self, + increment: IncrementCommand, + pre_release: Option<&str>, + hooks_config: Option<&str>, + dry_run: bool, + ) -> Result<()> { + self.pre_bump_checks()?; + // Get package bumps + let bumps = self.get_current_packages()?; + + // Get current global tag + let old = self.repository.get_latest_tag(); + let old = tag_or_fallback_to_zero(old)?; + let mut tag = old.bump(increment, &self.repository)?; + ensure_tag_is_greater_than_previous(&old, &tag)?; + + if let Some(pre_release) = pre_release { + tag.version.pre = Prerelease::new(pre_release)?; + } + + let tag = Tag::create(tag.version, None); + + if dry_run { + print!("{}", tag); + return Ok(()); + } + + let mut template_context = vec![]; + for bump in &bumps { + template_context.push(PackageBumpContext { + package_name: &bump.package_name, + package_path: &bump.package_path, + version: OidOf::Tag(bump.version.clone()), + from: None, + }) + } + + let pattern = self.get_revspec_for_tag(&old)?; + let changelog = + self.get_monorepo_global_changelog_with_target_version(pattern, tag.clone())?; + + changelog.pretty_print_bump_summary()?; + + let path = settings::changelog_path(); + let template = SETTINGS.get_monorepo_changelog_template()?; + + changelog.write_to_file( + path, + template, + ReleaseType::MonoRepo(MonoRepoContext { + package_lock: true, + packages: template_context, + }), + )?; + + let current = self.repository.get_latest_tag().map(HookVersion::new).ok(); + let next_version = HookVersion::new(tag.clone()); + + let hook_result = self.run_hooks( + HookType::PreBump, + current.as_ref(), + &next_version, + hooks_config, + None, + None, + ); + + self.repository.add_all()?; + + if let Err(err) = hook_result { + self.stash_failed_version(&tag, err)?; + } + + let sign = self.repository.gpg_sign(); + self.repository.commit( + &format!("chore(version): {}", next_version.prefixed_tag), + sign, + )?; + + self.repository.create_tag(&tag)?; + + // Run global post hooks + self.run_hooks( + HookType::PostBump, + current.as_ref(), + &next_version, + hooks_config, + None, + None, + )?; + + Ok(()) + } + + fn get_current_packages(&self) -> Result> { + let mut packages = vec![]; + for (package_name, package) in SETTINGS.packages.iter() { + let tag = self.repository.get_latest_package_tag(package_name); + let tag = tag_or_fallback_to_zero(tag)?; + packages.push(PackageData { + package_name: package_name.to_string(), + package_path: package.path.to_string_lossy().to_string(), + version: tag, + }) + } + + Ok(packages) + } + // Calculate all package bump fn get_packages_bumps(&self, pre_release: Option<&str>) -> Result> { let mut package_bumps = vec![]; diff --git a/src/conventional/changelog/release.rs b/src/conventional/changelog/release.rs index 8dad2bb9..d6172088 100644 --- a/src/conventional/changelog/release.rs +++ b/src/conventional/changelog/release.rs @@ -253,6 +253,41 @@ mod test { Ok(()) } + #[test] + fn should_render_template_monorepo_for_manual_bump() -> Result<()> { + // Arrange + let release = Release::fixture(); + let renderer = Renderer::try_new(Template { + remote_context: None, + kind: TemplateKind::MonorepoDefault, + })?; + + let mut renderer = monorepo_manual_bump_rendered(renderer)?; + + // Act + let changelog = renderer.render(release)?; + + // Assert + assert_eq!( + changelog, + indoc! { + "## 1.0.0 - 2015-09-05 + ### Packages + - one locked to 0.1.0 + - two locked to 0.2.0 + ### Global changes + #### Bug Fixes + - **(parser)** fix parser implementation - (17f7e23) - *oknozor* + #### Features + - **(parser)** implement the changelog generator - (17f7e23) - *oknozor* + - awesome feature - (17f7e23) - Paul Delafosse + " + } + ); + + Ok(()) + } + #[test] fn should_render_full_hash_template_monorepo() -> Result<()> { // Arrange @@ -288,6 +323,41 @@ mod test { Ok(()) } + #[test] + fn should_render_full_hash_template_manual_monorepo() -> Result<()> { + // Arrange + let release = Release::fixture(); + let renderer = Renderer::try_new(Template { + remote_context: None, + kind: TemplateKind::MonorepoFullHash, + })?; + + let mut renderer = monorepo_manual_bump_rendered(renderer)?; + + // Act + let changelog = renderer.render(release)?; + + // Assert + assert_eq!( + changelog, + indoc! { + "### Packages + - one locked to 0.1.0 + - two locked to 0.2.0 + ### Global changes + #### Bug Fixes + - 17f7e23081db15e9318aeb37529b1d473cf41cbe - **(parser)** fix parser implementation - @oknozor + #### Features + - 17f7e23081db15e9318aeb37529b1d473cf41cbe - **(parser)** implement the changelog generator - @oknozor + - 17f7e23081db15e9318aeb37529b1d473cf41cbe - awesome feature - Paul Delafosse + + " + } + ); + + Ok(()) + } + #[test] fn should_render_remote_template_monorepo() -> Result<()> { // Arrange @@ -424,6 +494,45 @@ mod test { Ok(()) } + #[test] + fn should_render_remote_template_monorepo_for_manual_bump() -> Result<()> { + // Arrange + let release = Release::fixture(); + let renderer = Renderer::try_new(Template { + remote_context: RemoteContext::try_new( + Some("github.com".into()), + Some("cocogitto".into()), + Some("cocogitto".into()), + ), + kind: TemplateKind::MonorepoRemote, + })?; + + let mut renderer = monorepo_manual_bump_rendered(renderer)?; + + // Act + let changelog = renderer.render(release)?; + + // Assert + assert_eq!( + changelog, + indoc! { + "## [1.0.0](https://github.com/cocogitto/cocogitto/compare/0.1.0..1.0.0) - 2015-09-05 + ### Packages + - [0.1.0](crates/one) locked to [0.1.0](https://github.com/cocogitto/cocogitto/tree/0.1.0) + - [0.2.0](crates/two) locked to [0.2.0](https://github.com/cocogitto/cocogitto/tree/0.2.0) + ### Global changes + #### Bug Fixes + - **(parser)** fix parser implementation - ([17f7e23](https://github.com/cocogitto/cocogitto/commit/17f7e23081db15e9318aeb37529b1d473cf41cbe)) - [@oknozor](https://github.com/oknozor) + #### Features + - **(parser)** implement the changelog generator - ([17f7e23](https://github.com/cocogitto/cocogitto/commit/17f7e23081db15e9318aeb37529b1d473cf41cbe)) - [@oknozor](https://github.com/oknozor) + - awesome feature - ([17f7e23](https://github.com/cocogitto/cocogitto/commit/17f7e23081db15e9318aeb37529b1d473cf41cbe)) - Paul Delafosse + " + } + ); + + Ok(()) + } + impl Release<'_> { pub fn fixture() -> Release<'static> { let date = @@ -514,6 +623,7 @@ mod test { fn monorepo_renderer(renderer: Renderer) -> Result { let renderer = renderer.with_monorepo_context(MonoRepoContext { + package_lock: false, packages: vec![ PackageBumpContext { package_name: "one", @@ -522,10 +632,10 @@ mod test { "0.1.0", Some(Oid::from_str("fae3a288a1bc69b14f85a1d5fe57cee1964acd60").unwrap()), )?), - from: OidOf::Tag(Tag::from_str( + from: Some(OidOf::Tag(Tag::from_str( "0.2.0", Some(Oid::from_str("fae3a288a1bc69b14f85a1d5fe57cee1964acd60").unwrap()), - )?), + )?)), }, PackageBumpContext { package_name: "two", @@ -534,10 +644,38 @@ mod test { "0.2.0", Some(Oid::from_str("fae3a288a1bc69b14f85a1d5fe57cee1964acd60").unwrap()), )?), - from: OidOf::Tag(Tag::from_str( + from: Some(OidOf::Tag(Tag::from_str( "0.3.0", Some(Oid::from_str("fae3a288a1bc69b14f85a1d5fe57cee1964acd60").unwrap()), + )?)), + }, + ], + }); + + Ok(renderer) + } + + fn monorepo_manual_bump_rendered(renderer: Renderer) -> Result { + let renderer = renderer.with_monorepo_context(MonoRepoContext { + package_lock: true, + packages: vec![ + PackageBumpContext { + package_name: "one", + package_path: "crates/one", + version: OidOf::Tag(Tag::from_str( + "0.1.0", + Some(Oid::from_str("fae3a288a1bc69b14f85a1d5fe57cee1964acd60").unwrap()), + )?), + from: None, + }, + PackageBumpContext { + package_name: "two", + package_path: "crates/two", + version: OidOf::Tag(Tag::from_str( + "0.2.0", + Some(Oid::from_str("fae3a288a1bc69b14f85a1d5fe57cee1964acd60").unwrap()), )?), + from: None, }, ], }); diff --git a/src/conventional/changelog/template.rs b/src/conventional/changelog/template.rs index e5d987f4..7a96783a 100644 --- a/src/conventional/changelog/template.rs +++ b/src/conventional/changelog/template.rs @@ -130,6 +130,7 @@ pub struct RemoteContext { #[derive(Debug)] pub struct MonoRepoContext<'a> { + pub package_lock: bool, pub packages: Vec>, } @@ -138,7 +139,7 @@ pub struct PackageBumpContext<'a> { pub package_name: &'a str, pub package_path: &'a str, pub version: OidOf, - pub from: OidOf, + pub from: Option, } #[derive(Debug)] @@ -153,6 +154,7 @@ pub(crate) trait ToContext { impl ToContext for MonoRepoContext<'_> { fn to_context(&self) -> Context { let mut context = tera::Context::new(); + context.insert("package_lock", &self.package_lock); context.insert("packages", &self.packages); context } diff --git a/src/conventional/changelog/template/monorepo_full_hash b/src/conventional/changelog/template/monorepo_full_hash index 955ae0bd..5e3e269a 100644 --- a/src/conventional/changelog/template/monorepo_full_hash +++ b/src/conventional/changelog/template/monorepo_full_hash @@ -1,8 +1,14 @@ +{% if package_lock -%} +### Packages +{% for package in packages -%} +- {{ package.package_name }} locked to {{ package.version.tag }} +{% endfor -%} +{% else -%} ### Package updates {% for package in packages -%} - - {{ package.package_name }} bumped to {{ package.version.tag }} +- {{ package.package_name }} bumped to {{ package.version.tag }} {% endfor -%} - +{% endif -%} ### Global changes {% for type, typed_commits in commits | sort(attribute="type")| group_by(attribute="type") -%} diff --git a/src/conventional/changelog/template/monorepo_remote b/src/conventional/changelog/template/monorepo_remote index 45753643..1f915330 100644 --- a/src/conventional/changelog/template/monorepo_remote +++ b/src/conventional/changelog/template/monorepo_remote @@ -12,6 +12,14 @@ ## Unreleased ([{{ from_shorthand ~ ".." ~ to_shorthand }}]({{repository_url ~ "/compare/" ~ from_shorthand ~ ".." ~ to_shorthand}})) {% endif -%} +{% if package_lock -%} +### Packages +{% for package in packages -%} +{% if package.version.tag -%} +- [{{ package.version.tag }}]({{ package.package_path }}) locked to [{{ package.version.tag }}]({{repository_url ~ "/tree/" ~ package.version.tag }}) +{% endif -%} +{% endfor -%} +{% else -%} ### Package updates {% for package in packages -%} {% if package.version.tag and package.from.tag -%} @@ -19,15 +27,9 @@ {% elif package.version.tag and package.from.id -%} - [{{ package.package_name }}]({{ package.package_path }}) bumped to [{{ package.version.tag }}]({{repository_url ~ "/compare/" ~ package.from.id ~ ".." ~ package.version.tag}}) {% else -%} - {% set from = package.from.id -%} - {% set to = package.version.id -%} - - {% set from_shorthand = from.id | truncate(length=7, end="") -%} - {% set to_shorthand = version.id | truncate(length=7, end="") -%} - - ## Unreleased ([{{ from_shorthand ~ ".." ~ to_shorthand }}]({{repository_url ~ "/compare/" ~ from_shorthand ~ ".." ~ to_shorthand}})) {% endif -%} {% endfor -%} +{% endif -%} ### Global changes {% for type, typed_commits in commits | sort(attribute="type")| group_by(attribute="type")-%} diff --git a/src/conventional/changelog/template/monorepo_simple b/src/conventional/changelog/template/monorepo_simple index 78a9809b..2305ac74 100644 --- a/src/conventional/changelog/template/monorepo_simple +++ b/src/conventional/changelog/template/monorepo_simple @@ -8,10 +8,18 @@ ## Unreleased ({{ from_shorthand ~ ".." ~ to_shorthand }}) {% endif -%} +{% if package_lock -%} +### Packages +{% for package in packages -%} +- {{ package.package_name }} locked to {{ package.version.tag }} +{% endfor -%} +{% else -%} ### Package updates {% for package in packages -%} - {{ package.package_name }} bumped to {{ package.version.tag }} {% endfor -%} +{% endif -%} + ### Global changes {% for type, typed_commits in commits | sort(attribute="type")| group_by(attribute="type")-%} diff --git a/tests/lib_tests/bump.rs b/tests/lib_tests/bump.rs index 3cbeca21..8ea55d62 100644 --- a/tests/lib_tests/bump.rs +++ b/tests/lib_tests/bump.rs @@ -39,7 +39,7 @@ fn monorepo_bump_ok() -> Result<()> { let mut cocogitto = CocoGitto::get()?; // Act - let result = cocogitto.create_monorepo_version(None, None, false); + let result = cocogitto.create_monorepo_version(IncrementCommand::Auto, None, None, false); // Assert assert_that!(result).is_ok(); @@ -48,6 +48,29 @@ fn monorepo_bump_ok() -> Result<()> { Ok(()) } +#[sealed_test] +fn monorepo_bump_manual_ok() -> Result<()> { + // Arrange + let mut settings = Settings { + ..Default::default() + }; + + init_monorepo(&mut settings)?; + run_cmd!( + git tag "one-0.1.0"; + )?; + + let mut cocogitto = CocoGitto::get()?; + + // Act + let result = cocogitto.create_monorepo_version(IncrementCommand::Major, None, None, false); + + // Assert + assert_that!(result).is_ok(); + assert_tag_exists("1.0.0")?; + Ok(()) +} + #[sealed_test] fn monorepo_with_tag_prefix_bump_ok() -> Result<()> { // Arrange @@ -61,7 +84,7 @@ fn monorepo_with_tag_prefix_bump_ok() -> Result<()> { let mut cocogitto = CocoGitto::get()?; // Act - let result = cocogitto.create_monorepo_version(None, None, false); + let result = cocogitto.create_monorepo_version(IncrementCommand::Auto, None, None, false); // Assert assert_that!(result).is_ok();