use std::{
cmp::max,
collections::{BTreeMap, HashMap},
fmt::Display,
path::Path,
};
use gitcc_convco::{ConvcoMessage, DEFAULT_CONVCO_INCR_MINOR_TYPES, DEFAULT_CONVCO_TYPES};
use gitcc_git::discover_repo;
use semver::Version;
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
pub use gitcc_git::StatusShow;
use crate::{Config, Error};
#[derive(Debug, Serialize, Deserialize)]
pub struct CommitConfig {
pub types: BTreeMap<String, String>,
}
impl Default for CommitConfig {
fn default() -> Self {
Self {
types: DEFAULT_CONVCO_TYPES
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
}
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct VersioningConfig {
pub types_incr_minor: Vec<String>,
}
impl Default for VersioningConfig {
fn default() -> Self {
Self {
types_incr_minor: DEFAULT_CONVCO_INCR_MINOR_TYPES
.map(|s| s.to_string())
.to_vec(),
}
}
}
#[derive(Debug)]
pub struct Commit {
pub id: String,
pub date: OffsetDateTime,
pub author_name: String,
pub author_email: String,
pub committer_name: String,
pub committer_email: String,
pub raw_message: String,
pub conv_message: Option<ConvcoMessage>,
pub tag: Option<gitcc_git::Tag>,
pub version_tag: Option<gitcc_git::Tag>,
}
impl Commit {
pub fn short_id(&self) -> String {
let mut short_id = self.id.clone();
short_id.truncate(7);
short_id
}
pub fn subject(&self) -> String {
if let Some(line) = self.raw_message.lines().next() {
return line.to_string();
}
unreachable!()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum VersionIncr {
None,
Patch,
Minor,
Major,
}
impl Display for VersionIncr {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
VersionIncr::None => write!(f, "na"),
VersionIncr::Patch => write!(f, "patch"),
VersionIncr::Minor => write!(f, "minor"),
VersionIncr::Major => write!(f, "major"),
}
}
}
impl VersionIncr {
fn apply(&self, version: &Option<Version>) -> Version {
if let Some(v) = version {
if v.major == 0 {
match self {
VersionIncr::None => v.clone(),
VersionIncr::Patch => Version::new(0, v.minor + 1, 0),
VersionIncr::Minor => Version::new(0, v.minor + 1, 0),
VersionIncr::Major => Version::new(1, 0, 0),
}
} else {
match self {
VersionIncr::None => v.clone(),
VersionIncr::Patch => Version::new(v.major, v.minor, v.patch + 1),
VersionIncr::Minor => Version::new(v.major, v.minor + 1, 0),
VersionIncr::Major => Version::new(v.major + 1, 0, 0),
}
}
} else {
Version::new(0, 1, 0)
}
}
}
pub trait ConvcoMessageExt {
fn version_incr_kind(&self, cfg: &VersioningConfig) -> VersionIncr;
}
impl ConvcoMessageExt for ConvcoMessage {
fn version_incr_kind(&self, cfg: &VersioningConfig) -> VersionIncr {
if self.is_breaking_change() {
return VersionIncr::Major;
}
if cfg.types_incr_minor.contains(&self.r#type) {
return VersionIncr::Minor;
}
VersionIncr::Patch
}
}
#[derive(Debug)]
pub struct CommitHistory {
pub commits: Vec<Commit>,
pub curr_version: Option<Version>,
pub next_version: Version,
}
impl CommitHistory {
pub fn next_version_str(&self) -> String {
format!("v{}", self.next_version)
}
}
pub fn git_status(
cwd: &Path,
show: StatusShow,
) -> Result<BTreeMap<String, gitcc_git::Status>, Error> {
let repo = discover_repo(cwd)?;
let files = gitcc_git::repo_status(&repo, show)?;
Ok(files)
}
pub fn git_add_all(cwd: &Path) -> Result<(), Error> {
let repo = discover_repo(cwd)?;
gitcc_git::add_all(&repo)?;
Ok(())
}
pub fn commit_history(cwd: &Path, cfg: &Config) -> Result<CommitHistory, Error> {
let repo = gitcc_git::discover_repo(cwd)?;
let git_commits = gitcc_git::commit_log(&repo)?;
let map_commit_to_tag: HashMap<_, _> = gitcc_git::get_tag_refs(&repo)?
.into_iter()
.map(|t| (t.commit_id.clone(), t))
.collect();
let mut commits = Vec::new();
let mut curr_version: Option<Version> = None; let mut latest_version_tag: Option<gitcc_git::Tag> = None;
let mut unreleased_incr_kind = VersionIncr::None; let mut is_commit_released = false;
for c in git_commits {
let conv_message = match c.message.parse::<ConvcoMessage>() {
Ok(m) => {
if !cfg.commit.types.contains_key(&m.r#type) {
log::debug!("commit {} has an invalid type: {}", c.id, m.r#type);
}
Some(m)
}
Err(err) => {
log::debug!(
"commit {} does not follow the conventional commit format: {}",
c.id,
err
);
None
}
};
let tag = map_commit_to_tag.get(&c.id).cloned();
let mut has_annotated_tag = false;
if let Some(tag) = &tag {
if tag.is_annotated() {
has_annotated_tag = true
}
}
if has_annotated_tag {
let tag = tag.clone().unwrap();
let tag_name = tag.name.trim();
let tag_version = tag_name.strip_prefix('v').unwrap_or(tag_name);
match tag_version.parse::<Version>() {
Ok(v) => {
latest_version_tag = Some(tag);
if curr_version.is_none() {
curr_version = Some(v);
}
is_commit_released = true;
}
Err(err) => {
log::debug!(
"commit {} has tag {} which is not a semver version: {}",
c.id,
tag.name,
err
);
}
}
}
if !is_commit_released {
if let Some(m) = &conv_message {
let commit_incr_kind = m.version_incr_kind(&cfg.version);
unreleased_incr_kind = max(unreleased_incr_kind, commit_incr_kind);
} else {
unreleased_incr_kind = max(unreleased_incr_kind, VersionIncr::Patch);
}
}
commits.push(Commit {
id: c.id,
date: c.date,
author_name: c.author_name,
author_email: c.author_email,
committer_name: c.committer_name,
committer_email: c.committer_email,
raw_message: c.message,
conv_message,
tag,
version_tag: latest_version_tag.clone(),
});
}
let next_version = unreleased_incr_kind.apply(&curr_version);
Ok(CommitHistory {
commits,
curr_version,
next_version,
})
}
pub fn commit_changes(cwd: &Path, message: &str) -> Result<gitcc_git::Commit, Error> {
let repo = gitcc_git::discover_repo(cwd)?;
Ok(gitcc_git::commit_to_head(&repo, message)?)
}
#[cfg(test)]
mod tests {
use time::macros::format_description;
use super::*;
#[test]
fn test_history() {
let cwd = std::env::current_dir().unwrap();
let cfg = Config::load_from_fs(&cwd).unwrap().unwrap_or_default();
let history = commit_history(&cwd, &cfg).unwrap();
for c in &history.commits {
eprintln!(
"{}: {} | {} | {} {}",
c.date
.format(format_description!("[year]-[month]-[day]"))
.unwrap(),
c.conv_message
.as_ref()
.map(|m| m.r#type.clone())
.unwrap_or("--".to_string()),
if c.version_tag.is_none() {
c.conv_message
.as_ref()
.map(|m| m.version_incr_kind(&cfg.version).to_string())
.unwrap_or("--".to_string())
} else {
"--".to_string()
},
c.version_tag
.as_ref()
.map(|t| t.name.to_string())
.unwrap_or("unreleased".to_string()),
if let Some(tag) = &c.tag {
format!("<- {}", &tag.name)
} else {
"".to_string()
}
);
}
eprintln!();
eprintln!(
"current version: {}",
history
.curr_version
.map(|v| v.to_string())
.unwrap_or("unreleased".to_string())
);
eprintln!("next version: {}", history.next_version);
eprintln!();
}
}