4FCEKFETVRRY5ZNAO44GFUCEIXYCM3J2MRTUQ7CUYZ2ZHANWI2MAC
#[macro_use]
extern crate lazy_static;
mod cli;
mod cmd;
mod conventional;
mod git;
use std::io;
use crate::cmd::Command;
use handlebars::{RenderError, TemplateError};
use std::process::exit;
use structopt::StructOpt;
#[derive(Debug)]
enum Error {
Git(git2::Error),
Io(io::Error),
Template(TemplateError),
Render(RenderError),
Check,
}
impl From<git2::Error> for Error {
fn from(e: git2::Error) -> Self {
Self::Git(e)
}
}
impl From<io::Error> for Error {
fn from(e: io::Error) -> Self {
Self::Io(e)
}
}
impl From<TemplateError> for Error {
fn from(e: TemplateError) -> Self {
Self::Template(e)
}
}
impl From<RenderError> for Error {
fn from(e: RenderError) -> Self {
Self::Render(e)
}
}
fn main() -> Result<(), Error> {
let opt: cli::Opt = cli::Opt::from_args();
if let Some(path) = opt.path {
std::env::set_current_dir(path)?;
}
let res = match opt.cmd {
cli::Command::Check(cmd) => cmd.exec(),
cli::Command::Changelog(cmd) => cmd.exec(),
cli::Command::Version(cmd) => cmd.exec(),
};
match res {
Err(e) => {
match e {
Error::Check => (),
_ => eprintln!("{:?}", e),
}
exit(1)
}
_ => exit(0),
}
}
use git2::{Commit, Error, Oid, Repository, Revwalk};
use semver::Version;
use std::{cmp::Ordering, collections::HashMap};
/// git helper for common operations
pub(crate) struct GitHelper {
repo: Repository,
version_map: HashMap<Oid, VersionAndTag>,
}
#[derive(Clone, Debug)]
pub(crate) struct VersionAndTag {
pub(crate) tag: String,
pub(crate) version: Version,
}
impl Eq for VersionAndTag {}
impl PartialEq for VersionAndTag {
fn eq(&self, other: &Self) -> bool {
self.version.eq(&other.version)
}
}
impl PartialOrd for VersionAndTag {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
self.version.partial_cmp(&other.version)
}
}
impl Ord for VersionAndTag {
fn cmp(&self, other: &Self) -> Ordering {
self.version.cmp(&other.version)
}
}
impl GitHelper {
pub(crate) fn new(prefix: &str) -> Result<Self, Error> {
let repo = Repository::open_from_env()?;
let version_map = make_oid_version_map(&repo, prefix);
Ok(Self { repo, version_map })
}
/// Get the last version (can be pre-release) for the given revision.
///
/// Arguments:
///
/// - rev: A single commit rev spec
/// - prefix: The version prefix
pub(crate) fn find_last_version(&self, rev: &str) -> Result<Option<VersionAndTag>, Error> {
let rev = self.repo.revparse_single(rev)?.peel_to_commit()?;
let mut revwalk = self.repo.revwalk()?;
revwalk.push(rev.id())?;
let mut version: Vec<&VersionAndTag> = revwalk
.flatten()
.filter_map(|oid| self.version_map.get(&oid))
.collect();
version.sort_by(|a, b| b.version.cmp(&a.version));
Ok(version.first().cloned().cloned())
}
/// Returnes a sorted vector with the lowest version at index `0`.
pub(crate) fn versions_from(&self, version: &VersionAndTag) -> Vec<&VersionAndTag> {
let mut values: Vec<&VersionAndTag> = self.version_map.values().collect();
values.retain(|v| *v < version && !v.version.is_prerelease());
values.sort();
values
}
pub(crate) fn revwalk(&self) -> Result<Revwalk<'_>, Error> {
self.repo.revwalk()
}
pub(crate) fn find_commit(&self, oid: Oid) -> Result<Commit<'_>, Error> {
self.repo.find_commit(oid)
}
pub(crate) fn ref_to_commit(&self, r#ref: &str) -> Result<Commit<'_>, Error> {
self.repo.revparse_single(r#ref)?.peel_to_commit()
}
pub(crate) fn same_commit(&self, ref_a: &str, ref_b: &str) -> bool {
ref_a == ref_b
|| match (self.ref_to_commit(ref_a), self.ref_to_commit(ref_b)) {
(Ok(a), Ok(b)) => a.id() == b.id(),
_ => false,
}
}
}
/// Build a hashmap that contains Commit `Oid` as key and `Version` as value.
/// Can be used to easily walk a graph and check if it is a version.
fn make_oid_version_map(repo: &Repository, prefix: &str) -> HashMap<Oid, VersionAndTag> {
let tags = repo
.tag_names(Some(format!("{}*.*.*", prefix).as_str()))
.expect("some array");
let mut map = HashMap::new();
for tag in tags.iter().flatten().filter(|tag| tag.starts_with(prefix)) {
if let Ok(oid) = repo.revparse_single(tag).and_then(|obj| {
if let Some(tag) = obj.as_tag() {
Ok(tag.target_id())
} else {
Ok(obj.id())
}
}) {
if let Ok(version) = Version::parse(tag.trim_start_matches(prefix)) {
map.insert(
oid,
VersionAndTag {
tag: tag.to_owned(),
version,
},
);
}
}
}
map
}
//! # Specification [v1.0.0](https://www.conventionalcommits.org/en/v1.0.0/#specification)
//!
//! The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC 2119.
//!
//! 1. Commits MUST be prefixed with a type, which consists of a noun, feat, fix, etc., followed by the OPTIONAL scope, OPTIONAL !, and REQUIRED terminal colon and space.
//! 2. The type feat MUST be used when a commit adds a new feature to your application or library.
//! 3. The type fix MUST be used when a commit represents a bug fix for your application.
//! 4. A scope MAY be provided after a type. A scope MUST consist of a noun describing a section of the codebase surrounded by parenthesis, e.g., fix(parser):
//! 5. A description MUST immediately follow the colon and space after the type/scope prefix. The description is a short summary of the code changes, e.g., fix: array parsing issue when multiple spaces were contained in string.
//! 6. A longer commit body MAY be provided after the short description, providing additional contextual information about the code changes. The body MUST begin one blank line after the description.
//! 7. A commit body is free-form and MAY consist of any number of newline separated paragraphs.
//! 8. One or more footers MAY be provided one blank line after the body. Each footer MUST consist of a word token, followed by either a :<space> or <space># separator, followed by a string value (this is inspired by the git trailer convention).
//! 9. A footer’s token MUST use - in place of whitespace characters, e.g., Acked-by (this helps differentiate the footer section from a multi-paragraph body). An exception is made for BREAKING CHANGE, which MAY also be used as a token.
//! 10. A footer’s value MAY contain spaces and newlines, and parsing MUST terminate when the next valid footer token/separator pair is observed.
//! 11. Breaking changes MUST be indicated in the type/scope prefix of a commit, or as an entry in the footer.
//! 12. If included as a footer, a breaking change MUST consist of the uppercase text BREAKING CHANGE, followed by a colon, space, and description, e.g., BREAKING CHANGE: environment variables now take precedence over config files.
//! 13. If included in the type/scope prefix, breaking changes MUST be indicated by a ! immediately before the :. If ! is used, BREAKING CHANGE: MAY be ommitted from the footer section, and the commit description SHALL be used to describe the breaking change.
//! 14. Types other than feat and fix MAY be used in your commit messages, e.g., docs: updated ref docs.
//! 15. The units of information that make up conventional commits MUST NOT be treated as case sensitive by implementors, with the exception of BREAKING CHANGE which MUST be uppercase.
//! 16. BREAKING-CHANGE MUST be synonymous with BREAKING CHANGE, when used as a token in a footer.
pub(crate) mod changelog;
mod commits;
pub(crate) use commits::{Commit, Type};
use regex::Regex;
use std::{fmt, str::FromStr};
#[derive(Debug, PartialEq)]
pub(crate) enum Type {
Build,
Chore,
Ci,
Docs,
Feat,
Fix,
Perf,
Refactor,
Revert,
Style,
Test,
Custom(String),
}
impl AsRef<str> for Type {
fn as_ref(&self) -> &str {
match self {
Self::Build => "build",
Self::Chore => "chore",
Self::Ci => "ci",
Self::Docs => "docs",
Self::Feat => "feat",
Self::Fix => "fix",
Self::Perf => "perf",
Self::Refactor => "refactor",
Self::Revert => "revert",
Self::Style => "style",
Self::Test => "test",
Self::Custom(c) => c.as_str(),
}
}
}
impl From<&str> for Type {
fn from(s: &str) -> Type {
match s.to_ascii_lowercase().as_str() {
"build" => Self::Build,
"chore" => Self::Chore,
"ci" => Self::Ci,
"docs" => Self::Docs,
"feat" => Self::Feat,
"fix" => Self::Fix,
"perf" => Self::Perf,
"refactor" => Self::Refactor,
"revert" => Self::Revert,
"style" => Self::Style,
"test" => Self::Test,
custom => Self::Custom(custom.to_owned()),
}
}
}
impl fmt::Display for Type {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Build => write!(f, "build"),
Self::Chore => write!(f, "chore"),
Self::Ci => write!(f, "ci"),
Self::Docs => write!(f, "docs"),
Self::Feat => write!(f, "feat"),
Self::Fix => write!(f, "fix"),
Self::Perf => write!(f, "perf"),
Self::Refactor => write!(f, "refactor"),
Self::Revert => write!(f, "revert"),
Self::Style => write!(f, "style"),
Self::Test => write!(f, "test"),
Self::Custom(noun) => write!(f, "{}", noun),
}
}
}
#[derive(Debug, PartialEq)]
pub(crate) struct Footer {
key: String,
value: String,
}
#[derive(Debug, PartialEq)]
pub(crate) struct Commit {
pub(crate) r#type: Type,
pub(crate) scope: Option<String>,
pub(crate) breaking: bool,
pub(crate) description: String,
pub(crate) body: Option<String>,
pub(crate) footers: Vec<Footer>,
}
impl fmt::Display for Commit {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.r#type)?;
Ok(())
}
}
impl FromStr for Commit {
type Err = &'static str;
fn from_str(s: &str) -> Result<Self, Self::Err> {
lazy_static! {
static ref RE_FIRST_LINE: Regex = Regex::new(
r#"(?xms)
^
(?P<type>[a-zA-Z]+)
(?:\((?P<scope>[a-zA-Z]+(?:-[a-zA-Z]+)*)\))?
(?P<breaking>!)?
:\x20(?P<desc>[^\r\n]+)
$"#,
)
.unwrap();
}
let mut lines = s.lines();
if let Some(first) = lines.next() {
if let Some(capts) = RE_FIRST_LINE.captures(first) {
let r#type: Option<Type> = capts.name("type").map(|t| t.as_str().into());
let scope = capts.name("scope").map(|s| s.as_str().to_owned());
let breaking = capts.name("breaking").is_some();
let description = capts.name("desc").map(|d| d.as_str().to_owned());
match (r#type, description) {
(Some(r#type), Some(description)) => {
lazy_static! {
static ref RE_FOOTER : Regex = Regex::new(
r#"(?xm)
^
(?:(?P<key>(?:BREAKING\x20CHANGE|[a-zA-Z]+(?:-[a-zA-Z]+)*)):\x20|
(?P<ref>[a-zA-Z]+(?:-[a-zA-Z]+)*)\x20\#)
(?P<value>.+)
$"#,
).unwrap();
}
let mut body = String::new();
let mut footers: Vec<Footer> = Vec::new();
for line in lines {
if let Some(capts) = RE_FOOTER.captures(line) {
let key = capts.name("key").map(|key| key.as_str());
let ref_key = capts.name("ref").map(|key| key.as_str());
let value = capts.name("value").map(|value| value.as_str());
match (key, ref_key, value) {
(Some(key), None, Some(value)) => {
footers.push(Footer {
key: key.to_owned(),
value: value.to_owned(),
});
}
(None, Some(key), Some(value)) => {
footers.push(Footer {
key: key.to_owned(),
value: value.to_owned(),
});
}
_ => unreachable!(),
}
} else if footers.is_empty() {
body.push_str(line);
body.push('\n');
} else if let Some(v) = footers.last_mut() {
v.value.push_str(line);
v.value.push('\n');
}
}
let body = if body.trim().is_empty() {
None
} else {
Some(body.trim().to_owned())
};
Ok(Commit {
r#type,
scope,
breaking,
description,
body,
footers,
})
}
_ => Err("First line does contain a <type> or <description>"),
}
} else {
Err("First line does not match `<type>[optional scope]: <description>`")
}
} else {
Err("Commit is empty")
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_simple() {
let msg = "docs: correct spelling of CHANGELOG";
let commit: Commit = msg.parse().expect("valid");
assert_eq!(
commit,
Commit {
r#type: Type::Docs,
scope: None,
breaking: false,
description: "correct spelling of CHANGELOG".into(),
body: None,
footers: Vec::new()
}
);
}
#[test]
fn test_with_scope() {
let msg = "feat(lang): add polish language";
let commit: Commit = msg.parse().expect("valid");
assert_eq!(
commit,
Commit {
r#type: Type::Feat,
scope: Some("lang".into()),
breaking: false,
description: "add polish language".into(),
body: None,
footers: Vec::new()
}
);
}
#[test]
fn test_with_breaking() {
let msg = "refactor!: drop support for Node 6";
let commit: Commit = msg.parse().expect("valid");
assert_eq!(
commit,
Commit {
r#type: Type::Refactor,
scope: None,
breaking: true,
description: "drop support for Node 6".into(),
body: None,
footers: Vec::new()
}
);
}
#[test]
fn test_with_breaking_footer() {
let msg = "feat: allow provided config object to extend other configs\n\
\n\
BREAKING CHANGE: `extends` key in config file is now used for extending other config files";
let commit: Commit = msg.parse().expect("valid");
assert_eq!(
commit,
Commit {
r#type: Type::Feat,
scope: None,
breaking: false,
description: "allow provided config object to extend other configs".into(),
body: None,
footers: vec![Footer {
key: "BREAKING CHANGE".to_string(),
value:
"`extends` key in config file is now used for extending other config files"
.to_string()
}]
}
);
}
#[test]
fn test_with_multi_body_and_footer() {
let msg = "fix: correct minor typos in code\n\
\n\
see the issue for details\n\
\n\
on typos fixed.\n\
\n\
Reviewed-by: Z\n\
Refs #133";
let commit: Commit = msg.parse().expect("valid");
assert_eq!(
commit,
Commit {
r#type: Type::Fix,
scope: None,
breaking: false,
description: "correct minor typos in code".into(),
body: Some("see the issue for details\n\non typos fixed.".into()),
footers: vec![
Footer {
key: "Reviewed-by".to_string(),
value: "Z".to_string()
},
Footer {
key: "Refs".to_string(),
value: "133".to_string()
}
]
}
);
}
}
{{~> header~}}
{{#if noteGroups}}
{{#each noteGroups}}
### ⚠ {{title}}
{{#each notes}}
* {{#if this.scope}}**{{this.scope}}:** {{/if}}{{text}}
{{/each}}
{{/each}}
{{/if}}
{{~#each commitGroups~}}
{{#if @root.isPatch}}####{{else}}###{{/if}} {{title}}
{{#each commits~}}
{{> commit root=@root~}}
{{~/each}}
{{/each~}}
{{> footer}}
use crate::Error;
use chrono::NaiveDate;
use handlebars::Handlebars;
use serde::{Deserialize, Serialize};
use std::io;
/// [Conventional Changelog Configuration](https://github.com/conventional-changelog/conventional-changelog-config-spec/blob/master/versions/2.1.0/README.md)
/// Describes the configuration options supported by conventional-config for upstream tooling.
#[derive(Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct Config {
/// A string to be used as the main header section of the CHANGELOG.
#[serde(default = "default_header")]
pub(crate) header: String,
/// An array of `type` objects representing the explicitly supported commit message types, and whether they should show up in generated `CHANGELOG`s.
#[serde(default = "default_types")]
pub(crate) types: Vec<Type>,
/// Boolean indicating whether or not the action being run (generating CHANGELOG, recommendedBump, etc.) is being performed for a pre-major release (<1.0.0).\n This config setting will generally be set by tooling and not a user.
#[serde(default)]
pre_major: bool,
/// A URL representing a specific commit at a hash.
#[serde(default = "default_commit_url_format")]
commit_url_format: String,
/// A URL representing the comparison between two git SHAs.
#[serde(default = "default_compare_url_format")]
compare_url_format: String,
/// A URL representing the issue format (allowing a different URL format to be swapped in for Gitlab, Bitbucket, etc).
#[serde(default = "default_issue_url_format")]
issue_url_format: String,
/// A URL representing the a user's profile URL on GitHub, Gitlab, etc. This URL is used for substituting @bcoe with https://github.com/bcoe in commit messages.
#[serde(default = "default_user_url_format")]
user_url_format: String,
/// A string to be used to format the auto-generated release commit message.
#[serde(default = "default_release_commit_message_format")]
release_commit_message_format: String,
/// An array of prefixes used to detect references to issues
#[serde(default = "default_issue_prefixes")]
issue_prefixes: Vec<String>,
}
impl Default for Config {
fn default() -> Self {
Self {
header: default_header(),
types: default_types(),
pre_major: false,
commit_url_format: default_commit_url_format(),
compare_url_format: default_compare_url_format(),
issue_url_format: default_issue_url_format(),
user_url_format: default_user_url_format(),
release_commit_message_format: default_release_commit_message_format(),
issue_prefixes: default_issue_prefixes(),
}
}
}
fn default_header() -> String {
"# Changelog\n\n".into()
}
fn default_types() -> Vec<Type> {
vec![
Type {
r#type: "fix".into(),
section: "Fixes".into(),
hidden: false,
},
Type {
r#type: "feat".into(),
section: "Features".into(),
hidden: false,
},
]
}
fn default_commit_url_format() -> String {
"{{host}}/{{owner}}/{{repository}}/commit/{{hash}}".into()
}
fn default_compare_url_format() -> String {
"{{host}}/{{owner}}/{{repository}}/compare/{{previousTag}}...{{currentTag}}".into()
}
fn default_issue_url_format() -> String {
"{{host}}/{{owner}}/{{repository}}/issues/{{id}}".into()
}
fn default_user_url_format() -> String {
"{{host}}/{{user}}".into()
}
fn default_release_commit_message_format() -> String {
"chore(release): {{currentTag}}".into()
}
fn default_issue_prefixes() -> Vec<String> {
vec!["#".into()]
}
#[derive(Debug, PartialEq, Serialize, Deserialize)]
pub(crate) struct Type {
pub(crate) r#type: String,
pub(crate) section: String,
pub(crate) hidden: bool,
}
const TEMPLATE: &str = include_str!("template.hbs");
const HEADER: &str = include_str!("header.hbs");
const FOOTER: &str = include_str!("footer.hbs");
const COMMIT: &str = include_str!("commit.hbs");
pub(crate) struct Reference<'a> {
action: &'a str,
owner: &'a str,
repository: &'a str,
issue: &'a str,
raw: &'a str,
}
pub(crate) struct Note<'a> {
title: &'a str,
text: &'a str,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CommitContext {
pub(crate) hash: String,
pub(crate) date: NaiveDate,
pub(crate) subject: String,
pub(crate) short_hash: String,
}
#[derive(Serialize)]
pub(crate) struct CommitGroup<'a> {
pub(crate) title: &'a str,
pub(crate) commits: Vec<CommitContext>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct Context<'a> {
#[serde(flatten)]
pub(crate) context: ContextBase<'a>,
pub(crate) compare_url_format: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ContextBase<'a> {
pub(crate) version: &'a str,
pub(crate) date: Option<NaiveDate>,
pub(crate) is_patch: bool,
pub(crate) commit_groups: Vec<CommitGroup<'a>>,
}
pub(crate) struct ContextBuilder<'a> {
handlebars: Handlebars,
config: &'a Config,
pub(crate) context: ContextBase<'a>,
}
impl<'a> ContextBuilder<'a> {
pub fn new(config: &'a Config) -> Result<ContextBuilder<'a>, Error> {
let mut handlebars = Handlebars::new();
handlebars
.register_template_string("compare_url_format", config.compare_url_format.as_str())?;
Ok(Self {
config,
handlebars,
context: ContextBase {
version: Default::default(),
date: Default::default(),
is_patch: Default::default(),
commit_groups: Default::default(),
},
})
}
pub fn version(mut self, version: &'a str) -> Self {
self.context.version = version;
self
}
pub fn date(mut self, date: NaiveDate) -> Self {
self.context.date = Some(date);
self
}
pub fn is_patch(mut self, is_patch: bool) -> Self {
self.context.is_patch = is_patch;
self
}
pub fn commit_groups(mut self, commit_groups: Vec<CommitGroup<'a>>) -> Self {
self.context.commit_groups = commit_groups;
self
}
pub fn build(self) -> Result<Context<'a>, Error> {
let compare_url_format = self
.handlebars
.render("compare_url_format", &self.context)?;
Ok(Context {
context: self.context,
compare_url_format,
})
}
}
pub(crate) struct ChangelogWriter<W: io::Write> {
pub(crate) writer: W,
}
impl<W: io::Write> ChangelogWriter<W> {
pub(crate) fn write_header(&mut self, header: &str) -> Result<(), Error> {
write!(self.writer, "{}", header)?;
Ok(())
}
pub fn write_template(&mut self, context: &Context<'_>) -> Result<(), Error> {
let mut handlebars = Handlebars::new();
handlebars.register_template_string("template", TEMPLATE)?;
handlebars.register_partial("header", HEADER)?;
handlebars.register_partial("commit", COMMIT)?;
handlebars.register_partial("footer", FOOTER)?;
handlebars.set_strict_mode(true);
let writer = &mut self.writer;
handlebars.render_to_write("template", context, writer)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json;
#[test]
fn test() {
let json = r#"{
"types": [
{"type": "chore", "section":"Others", "hidden": false},
{"type": "revert", "section":"Reverts", "hidden": false},
{"type": "feat", "section": "Features", "hidden": false},
{"type": "fix", "section": "Bug Fixes", "hidden": false},
{"type": "improvement", "section": "Feature Improvements", "hidden": false},
{"type": "docs", "section":"Docs", "hidden": false},
{"type": "style", "section":"Styling", "hidden": false},
{"type": "refactor", "section":"Code Refactoring", "hidden": false},
{"type": "perf", "section":"Performance Improvements", "hidden": false},
{"type": "test", "section":"Tests", "hidden": false},
{"type": "build", "section":"Build System", "hidden": false},
{"type": "ci", "section":"CI", "hidden":false}
]
}"#;
let value: Config = serde_json::from_str(json).unwrap();
assert_eq!(
value,
Config {
header: "# Changelog\n\n".to_string(),
types: vec![
Type {
r#type: "chore".into(),
section: "Others".into(),
hidden: false
},
Type {
r#type: "revert".into(),
section: "Reverts".into(),
hidden: false
},
Type {
r#type: "feat".into(),
section: "Features".into(),
hidden: false
},
Type {
r#type: "fix".into(),
section: "Bug Fixes".into(),
hidden: false
},
Type {
r#type: "improvement".into(),
section: "Feature Improvements".into(),
hidden: false
},
Type {
r#type: "docs".into(),
section: "Docs".into(),
hidden: false
},
Type {
r#type: "style".into(),
section: "Styling".into(),
hidden: false
},
Type {
r#type: "refactor".into(),
section: "Code Refactoring".into(),
hidden: false
},
Type {
r#type: "perf".into(),
section: "Performance Improvements".into(),
hidden: false
},
Type {
r#type: "test".into(),
section: "Tests".into(),
hidden: false
},
Type {
r#type: "build".into(),
section: "Build System".into(),
hidden: false
},
Type {
r#type: "ci".into(),
section: "CI".into(),
hidden: false
}
],
pre_major: false,
commit_url_format: "{{host}}/{{owner}}/{{repository}}/commit/{{hash}}".to_string(),
compare_url_format:
"{{host}}/{{owner}}/{{repository}}/compare/{{previousTag}}...{{currentTag}}"
.to_string(),
issue_url_format: "{{host}}/{{owner}}/{{repository}}/issues/{{id}}".to_string(),
user_url_format: "{{host}}/{{user}}".to_string(),
release_commit_message_format: "chore(release): {{currentTag}}".to_string(),
issue_prefixes: vec!["#".into()]
}
)
}
}
{{~#if isPatch~}}
###
{{~else~}}
##
{{~/if}} {{#if @root.linkCompare~}}
[{{version}}]({{compareUrlFormat}})
{{~else}}
{{~version}}
{{~/if}}
{{~#if title}} "{{title}}"
{{~/if}}
{{~#if date}} ({{date}})
{{~/if}}
*{{#if scope}} **{{scope}}:**
{{~/if}} {{#if subject}}
{{~subject}}
{{~else}}
{{~header}}
{{~/if~}}
{{~#if @root.linkReferences}} ([{{shortHash}}]({{commitUrlFormat}}))
{{~else}} {{shortHash}}
{{~/if~}}
{{~#if references~}}
, closes
{{~#each references}} {{#if @root.linkReferences~}}
[
{{~#if this.owner}}
{{~this.owner}}/
{{~/if}}
{{~this.repository}}{{this.prefix}}{{this.issue}}]({{issueUrlFormat}})
{{~else}}
{{~#if this.owner}}
{{~this.owner}}/
{{~/if}}
{{~this.repository}}{{this.prefix}}{{this.issue}}
{{~/if}}{{/each}}
{{~/if}}
use crate::{
cli::VersionCommand,
cmd::Command,
conventional::{Commit, Type},
git::{GitHelper, VersionAndTag},
Error,
};
use semver::Version;
use std::str::FromStr;
impl VersionCommand {
/// returns the versions under the given rev
fn find_last_version(&self) -> Result<Option<VersionAndTag>, Error> {
let prefix = self.prefix.as_str();
Ok(GitHelper::new(prefix)?.find_last_version(self.rev.as_str())?)
}
fn find_bump_version(
&self,
last_v_tag: &str,
mut last_version: Version,
) -> Result<Version, Error> {
let prefix = self.prefix.as_str();
let git = GitHelper::new(prefix)?;
let mut revwalk = git.revwalk()?;
revwalk.push_range(format!("{}..{}", last_v_tag, self.rev).as_str())?;
let i = revwalk
.flatten()
.filter_map(|oid| git.find_commit(oid).ok())
.filter_map(|commit| commit.message().and_then(|msg| Commit::from_str(msg).ok()));
let mut major = false;
let mut minor = false;
let mut patch = false;
for commit in i {
if commit.breaking {
major = true;
break;
}
match commit.r#type {
Type::Feat => {
minor = true;
}
Type::Fix => patch = true,
_ => (),
}
}
match (major, minor, patch) {
(true, _, _) => last_version.increment_major(),
(false, true, _) => last_version.increment_minor(),
(false, false, true) => last_version.increment_patch(),
// TODO what should be the behaviour? always increment patch? or stay on same version?
_ => (),
}
Ok(last_version)
}
}
impl Command for VersionCommand {
fn exec(&self) -> Result<(), Error> {
if let Some(VersionAndTag { tag, mut version }) = self.find_last_version()? {
let v = if self.bump {
if version.is_prerelease() {
version.pre.clear();
version.build.clear();
version
} else {
self.find_bump_version(tag.as_str(), version)?
}
} else if self.major {
version.increment_major();
version
} else if self.minor {
version.increment_minor();
version
} else if self.patch {
version.increment_patch();
version
} else {
version
};
println!("{}", v);
} else {
println!("0.1.0");
}
Ok(())
}
}
use crate::Error;
mod changelog;
mod check;
mod version;
pub(crate) trait Command {
fn exec(&self) -> Result<(), Error>;
}
use crate::{cli::CheckCommand, cmd::Command, conventional, Error};
use git2::{Commit, Repository};
fn print_check(commit: &Commit<'_>) -> bool {
let msg = std::str::from_utf8(commit.message_bytes()).expect("valid utf-8 message");
let short_id = commit.as_object().short_id().unwrap();
let short_id = short_id.as_str().expect("short id");
let msg_parsed: Result<conventional::Commit, &str> = msg.parse();
match msg_parsed {
Err(e) => {
let first_line = msg.lines().next().unwrap_or("");
let short_msg: String = first_line.chars().take(40).collect();
if first_line.len() > 40 {
println!("FAIL {} {} {}...", short_id, e, short_msg)
} else {
println!("FAIL {} {} {}", short_id, e, short_msg)
}
false
}
_ => true,
}
}
impl Command for CheckCommand {
fn exec(&self) -> Result<(), Error> {
let repo = Repository::open_from_env()?;
let mut revwalk = repo.revwalk()?;
if self.rev.contains("..") {
revwalk.push_range(self.rev.as_str())?;
} else {
revwalk.push_ref(self.rev.as_str())?;
}
let mut total = 0;
let mut fail = 0;
for commit in revwalk
.flatten()
.flat_map(|oid| repo.find_commit(oid).ok())
.filter(|commit| commit.parent_count() <= 1)
{
total += 1;
fail += u32::from(!print_check(&commit));
}
if fail == 0 {
match total {
0 => println!("no commits checked"),
1 => println!("no errors in {} commit", total),
_ => println!("no errors in {} commits", total),
}
Ok(())
} else {
println!("\n{}/{} failed", fail, total);
Err(Error::Check)
}
}
}
use crate::{
cli::ChangelogCommand,
cmd::Command,
conventional::changelog::{
ChangelogWriter, CommitContext, CommitGroup, Config, ContextBuilder,
},
git::{GitHelper, VersionAndTag},
Error,
};
use semver::Version;
use std::{collections::HashMap, io, str::FromStr};
#[derive(Debug)]
struct Rev<'a>(&'a str, Option<&'a Version>);
impl<'a> From<&'a VersionAndTag> for Rev<'a> {
fn from(tav: &'a VersionAndTag) -> Self {
Rev(tav.tag.as_str(), Some(&tav.version))
}
}
impl ChangelogCommand {
fn make_changelog_for(
&self,
config: &Config,
writer: &mut ChangelogWriter<impl io::Write>,
helper: &GitHelper,
from_rev: &Rev<'_>,
to_rev: &Rev<'_>,
) -> Result<(), Error> {
// TODO: this should be a parameter
let group_types =
config
.types
.iter()
.filter(|ty| !ty.hidden)
.fold(HashMap::new(), |mut acc, ty| {
acc.insert(ty.r#type.as_str(), ty.section.as_str());
acc
});
let mut revwalk = helper.revwalk()?;
if to_rev.0 == "" {
let to_commit = helper.ref_to_commit(from_rev.0)?;
revwalk.push(to_commit.id())?;
} else {
// reverse from and to as
revwalk.push_range(format!("{}..{}", to_rev.0, from_rev.0).as_str())?;
}
let mut commits: HashMap<&str, Vec<CommitContext>> = HashMap::new();
let mut version_date = None;
for commit in revwalk
.flatten()
.flat_map(|oid| helper.find_commit(oid).ok())
.filter(|commit| commit.parent_count() <= 1)
{
if let Some(Ok(conv_commit)) =
commit.message().map(crate::conventional::Commit::from_str)
{
let hash = commit.id().to_string();
let date = chrono::NaiveDateTime::from_timestamp(commit.time().seconds(), 0).date();
let subject = conv_commit.description;
let short_hash = hash[..7].into();
let commit_context = CommitContext {
hash,
date,
subject,
short_hash,
};
if let Some(section) = group_types.get(conv_commit.r#type.as_ref()) {
if version_date.is_none() {
version_date = Some(date);
}
commits.entry(section).or_default().push(commit_context)
}
}
}
let version = if from_rev.0 == "HEAD" {
"Unreleased"
} else {
from_rev.0
};
let is_patch = from_rev.1.map(|i| i.patch != 0).unwrap_or(false);
let mut builder = ContextBuilder::new(&config)?
.version(version)
.is_patch(is_patch)
.commit_groups(
commits
.into_iter()
.map(|(title, commits)| CommitGroup { title, commits })
.collect(),
);
if let Some(date) = version_date {
builder = builder.date(date);
}
let context = builder.build()?;
writer.write_template(&context)?;
Ok(())
}
}
fn make_cl_config() -> Config {
Config::default()
}
impl Command for ChangelogCommand {
fn exec(&self) -> Result<(), Error> {
let helper = GitHelper::new(self.prefix.as_str())?;
let rev = self.rev.as_str();
let config = make_cl_config();
let stdout = std::io::stdout();
let stdout = stdout.lock();
let mut writer = ChangelogWriter { writer: stdout };
writer.write_header(config.header.as_str())?;
match helper.find_last_version(rev)? {
Some(v) => {
let mut versions = helper.versions_from(&v);
let semver = Version::from_str(rev.trim_start_matches(&self.prefix));
let from_rev = if let Ok(ref semver) = &semver {
if helper.same_commit(rev, v.tag.as_str()) {
Rev(v.tag.as_str(), Some(semver))
} else {
Rev(rev, Some(semver))
}
} else if helper.same_commit(rev, v.tag.as_str()) {
Rev(v.tag.as_str(), Some(&v.version))
} else {
Rev(rev, None)
};
let to_rev = versions.pop();
match to_rev {
None => self.make_changelog_for(
&config,
&mut writer,
&helper,
&from_rev,
&Rev("", None),
)?,
Some(tav) => {
let mut rev = tav.into();
self.make_changelog_for(&config, &mut writer, &helper, &from_rev, &rev)?;
loop {
let from_rev = rev;
match versions.pop() {
None => {
self.make_changelog_for(
&config,
&mut writer,
&helper,
&from_rev,
&Rev("", None),
)?;
break;
}
Some(tav) => {
rev = tav.into();
self.make_changelog_for(
&config,
&mut writer,
&helper,
&from_rev,
&rev,
)?;
}
}
}
}
}
}
None => self.make_changelog_for(
&config,
&mut writer,
&helper,
&Rev("HEAD", None),
&Rev("", None),
)?,
}
Ok(())
}
}
use std::path::PathBuf;
use structopt::StructOpt;
#[derive(Debug, StructOpt)]
#[structopt(name = "example", about = "An example of StructOpt usage.")]
pub struct Opt {
/// Run as if git was started in <path> instead of the current working directory.
#[structopt(short = "C", global = true)]
pub path: Option<PathBuf>,
#[structopt(subcommand)]
pub cmd: Command,
}
#[derive(Debug, StructOpt)]
pub enum Command {
/// Verifies if all commits are conventional
Check(CheckCommand),
/// Writes out a changelog
Changelog(ChangelogCommand),
/// Show the current version
Version(VersionCommand),
}
#[derive(Debug, StructOpt)]
pub struct VersionCommand {
/// Prefix used in front of the semantic version.
#[structopt(short, long, default_value = "v")]
pub prefix: String,
/// Revision to show the version for.
#[structopt(default_value = "HEAD")]
pub rev: String,
/// Get the next version
#[structopt(short, long)]
pub bump: bool,
/// Bump to a major release version, regardless of the conventional commits.
#[structopt(long)]
pub major: bool,
/// Bump to a minor release version, regardless of the conventional commits.
#[structopt(long)]
pub minor: bool,
/// Bump to a patch release version, regardless of the conventional commits.
#[structopt(long)]
pub patch: bool,
}
#[derive(Debug, StructOpt)]
pub struct CheckCommand {
#[structopt(default_value = "HEAD")]
pub rev: String,
}
#[derive(Debug, StructOpt)]
pub struct ChangelogCommand {
/// Prefix used in front of the semantic version.
#[structopt(short, long, default_value = "v")]
pub prefix: String,
#[structopt(default_value = "HEAD")]
pub rev: String,
}
# Convco
Conventional commit cli
`convco` gives tools to work with [Conventional Commits](https://www.conventionalcommits.org/).
- `convco check`: Checks if a range of commits is following the convention.
- `convco version`: Finds out the current or next version.
- `convco changelog`: Create a changelog file.
## Installation
`cargo install convco`
## Tools
### Check
Check a range of revisions for compliance.
It returns a non zero exit code if some commits are not conventional.
This is useful in a pre-push hook.
```sh
convco check $remote_sha..$local_sha
```
### Version
When no options are given it will return the current version.
When `--bump` is provided, the next version will be printed out.
Conventional commits are used to calculate the next major, minor or patch.
If needed one can provide `--major`, `--minor` or `--patch` to overrule the convention.
```sh
convco version --bump
```
### Changelog
A changelog can be generated using the conventional commits.
For now it just prints out the features and fixes for each version.
```sh
convco changelog > CHANGELOG.md
```
#### TODO
- [ ] automatic notes for breaking changes
- [ ] custom template folder
- [ ] use a `.versionrc` file
- [ ] limit to a range of versions
MIT License
Copyright (c) 2019 Hannes De Valkeneer
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
[package]
name = "convco"
version = "0.1.0"
license = "MIT"
authors = ["Hannes De Valkeneer <hannes@de-valkeneer.be>"]
edition = "2018"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
chrono = { version = "0.4.9", features = ["serde"] }
git2 = "0.10.1"
handlebars = "2.0.2"
lazy_static = "1.4.0"
regex = "1.3.1"
semver = { version = "0.9.0", features = ["serde"] }
serde_json = "1.0.41"
serde = { version = "1.0.102", features = ["derive"] }
structopt = "0.3.4"