diff --git a/common/Cargo.toml b/common/Cargo.toml index 561b4b6be..599c6ee95 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -21,3 +21,4 @@ config = { workspace = true } envsubst = "0.2.1" rand = { workspace = true } serde_json = { workspace = true } +regex.workspace = true diff --git a/common/src/utils.rs b/common/src/utils.rs index 55982ebb9..b67fe4dda 100644 --- a/common/src/utils.rs +++ b/common/src/utils.rs @@ -1,5 +1,6 @@ use idgenerator::IdInstance; use rand::{distributions::Alphanumeric, thread_rng, Rng}; +use regex::Regex; use serde_json::{json, Value}; pub const ZERO_ID: &str = match std::str::from_utf8(&[b'0'; 40]) { @@ -44,3 +45,127 @@ pub fn generate_rich_text(content: &str) -> String { pub fn mr_ref_name(mr_link: &str) -> String { format!("refs/heads/{}", mr_link) } + +/// Format commit message with GPG signature
+/// There must be a `blank line`(\n) before `message`, or remote unpack failed.
+/// If there is `GPG signature`, +/// `blank line` should be placed between `signature` and `message` +pub fn format_commit_msg(msg: &str, gpg_sig: Option<&str>) -> String { + match gpg_sig { + None => { + format!("\n{}", msg) + } + Some(gpg) => { + format!("{}\n\n{}", gpg, msg) + } + } +} + +/// parse commit message +pub fn parse_commit_msg(msg_gpg: &str) -> (String, Option) { + const GPG_SIG_START: &str = "gpgsig -----BEGIN PGP SIGNATURE-----"; + const GPG_SIG_END: &str = "-----END PGP SIGNATURE-----"; + let gpg_start = msg_gpg.find(GPG_SIG_START); + let gpg_end = msg_gpg.find(GPG_SIG_END).map(|end| end + GPG_SIG_END.len()); + let gpg_sig = match (gpg_start, gpg_end) { + (Some(start), Some(end)) => { + if start < end { + Some(msg_gpg[start..end].to_string()) + } else { + None + } + } + _ => None, + }; + match gpg_sig { + Some(gpg) => { + // Skip the leading '\n\n' (blank lines). + // Some commit messages may use '\n \n\n' or similar patterns. + // To handle such cases, remove all leading blank lines from the message. + let msg = msg_gpg[gpg_end.unwrap()..].trim_start().to_string(); + (msg, Some(gpg)) + } + None => { + assert!(msg_gpg.starts_with('\n'), "commit message format error"); + let msg = msg_gpg[1..].to_string(); // skip the leading '\n' (blank line) + (msg, None) + } + } +} + +// check if the commit message is conventional commit +// ref: https://www.conventionalcommits.org/en/v1.0.0/ +pub fn check_conventional_commits_message(msg: &str) -> bool { + let first_line = msg.lines().next().unwrap_or_default(); + #[allow(unused_variables)] + let body_footer = msg.lines().skip(1).collect::>().join("\n"); + + let unicode_pattern = r"\p{L}\p{N}\p{P}\p{S}\p{Z}"; + // type only support characters&numbers, others fields support all unicode characters + let regex_str = format!( + r"^(?P[\p{{L}}\p{{N}}_-]+)(?:\((?P[{unicode}]+)\))?!?: (?P[{unicode}]+)$", + unicode = unicode_pattern + ); + + let re = Regex::new(®ex_str).unwrap(); + const RECOMMENDED_TYPES: [&str; 8] = [ + "build", "chore", "ci", "docs", "feat", "fix", "perf", "refactor", + ]; + + if let Some(captures) = re.captures(first_line) { + let commit_type = captures.name("type").map(|m| m.as_str().to_string()); + #[allow(unused_variables)] + let scope = captures.name("scope").map(|m| m.as_str().to_string()); + let description = captures.name("description").map(|m| m.as_str().to_string()); + if commit_type.is_none() || description.is_none() { + return false; + } + + let commit_type = commit_type.unwrap(); + if !RECOMMENDED_TYPES.contains(&commit_type.to_lowercase().as_str()) { + println!("`{}` is not a recommended commit type, refer to https://www.conventionalcommits.org/en/v1.0.0/ for more information", commit_type); + } + + // println!("{}({}): {}\n{}", commit_type, scope.unwrap_or("None".to_string()), description.unwrap(), body_footer); + + return true; + } + false +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_check_conventional_commits() { + // successfull cases + let msg = "feat: add new feature"; + assert!(check_conventional_commits_message(msg)); + + let msg = "fix(common crate): bug fix"; + assert!(check_conventional_commits_message(msg)); + + let msg = "chore(范围)!: 依存関係を更新する"; + assert!(check_conventional_commits_message(msg)); + + let msg = "se_lf-ty9pe(scope)!: Description\n\n여기 시체가 있어요\n\nвот нога"; + assert!(check_conventional_commits_message(msg)); + + let msg = "feat(scope)!: Description\n\n\nbody one\n\nbody two\n\nfooter"; + assert!(check_conventional_commits_message(msg)); + + // failed casesmsg + let msg = "feat:add new feature"; // missing ' ' before ':' + assert!(!check_conventional_commits_message(msg)); + + let msg = "fix(common crate)bug fix"; // missing ':' + assert!(!check_conventional_commits_message(msg)); + + let msg = "類@型(common): add new feature"; // unssupported characters in type + assert!(!check_conventional_commits_message(msg)); + + let msg = "()(common): add new feature"; // unssupported characters in type + assert!(!check_conventional_commits_message(msg)); + } +} diff --git a/libra/Cargo.toml b/libra/Cargo.toml index 3b6346a9f..ce6348bf1 100644 --- a/libra/Cargo.toml +++ b/libra/Cargo.toml @@ -17,6 +17,7 @@ chrono = { workspace = true } clap = { workspace = true, features = ["derive"] } color-backtrace = "0.6.1" colored = { workspace = true } +common = { workspace = true } flate2 = { workspace = true } # add features = ["zlib"] if slow futures = { workspace = true } futures-util = { workspace = true } diff --git a/libra/src/command/branch.rs b/libra/src/command/branch.rs index 0314f10d8..2b3d7cabf 100644 --- a/libra/src/command/branch.rs +++ b/libra/src/command/branch.rs @@ -234,6 +234,7 @@ mod tests { let commit_args = CommitArgs { message: "first".to_string(), allow_empty: true, + conventional: false, }; commit::execute(commit_args).await; let first_commit_id = Branch::find_branch("master", None).await.unwrap().commit; @@ -241,6 +242,7 @@ mod tests { let commit_args = CommitArgs { message: "second".to_string(), allow_empty: true, + conventional: false, }; commit::execute(commit_args).await; let second_commit_id = Branch::find_branch("master", None).await.unwrap().commit; @@ -318,6 +320,7 @@ mod tests { let args = CommitArgs { message: "first".to_string(), allow_empty: true, + conventional: false, }; commit::execute(args).await; let hash = Head::current_commit().await.unwrap(); @@ -349,6 +352,7 @@ mod tests { let args = CommitArgs { message: "first".to_string(), allow_empty: true, + conventional: false, }; commit::execute(args).await; diff --git a/libra/src/command/commit.rs b/libra/src/command/commit.rs index b87888c46..7d5f1fdde 100644 --- a/libra/src/command/commit.rs +++ b/libra/src/command/commit.rs @@ -6,14 +6,15 @@ use crate::internal::head::Head; use crate::utils::client_storage::ClientStorage; use crate::utils::path; use crate::utils::util; -use mercury::internal::index::Index; use clap::Parser; +use common::utils::{check_conventional_commits_message, format_commit_msg}; use mercury::hash::SHA1; +use mercury::internal::index::Index; use mercury::internal::object::commit::Commit; use mercury::internal::object::tree::{Tree, TreeItem, TreeItemMode}; use mercury::internal::object::ObjectTrait; -use super::{format_commit_msg, save_object}; +use super::save_object; #[derive(Parser, Debug)] pub struct CommitArgs { @@ -22,6 +23,9 @@ pub struct CommitArgs { #[arg(long)] pub allow_empty: bool, + + #[arg(long, requires("message"))] + pub conventional: bool, } pub async fn execute(args: CommitArgs) { @@ -30,7 +34,12 @@ pub async fn execute(args: CommitArgs) { let storage = ClientStorage::init(path::objects()); let tracked_entries = index.tracked_entries(0); if tracked_entries.is_empty() && !args.allow_empty { - panic!("fatal: no changes added to commit, use --allow-empty to override"); + println!("fatal: no changes added to commit, use --allow-empty to override"); + return; + } + if args.conventional && !check_conventional_commits_message(&args.message) { + println!("fatal: commit message does not follow conventional commits"); + return; } /* Create tree */ @@ -39,7 +48,11 @@ pub async fn execute(args: CommitArgs) { /* Create & save commit objects */ let parents_commit_ids = get_parents_ids().await; // There must be a `blank line`(\n) before `message`, or remote unpack failed - let commit = Commit::from_tree_id(tree.id, parents_commit_ids, &format_commit_msg(&args.message, None)); + let commit = Commit::from_tree_id( + tree.id, + parents_commit_ids, + &format_commit_msg(&args.message, None), + ); // TODO default signature created in `from_tree_id`, wait `git config` to set correct user info @@ -161,6 +174,23 @@ mod test { }; use super::*; + #[test] + fn test_parse_args() { + let args = CommitArgs::try_parse_from(["commit", "-m", "init"]); + assert!(args.is_ok()); + + let args = CommitArgs::try_parse_from(["commit", "-m", "init", "--allow-empty"]); + assert!(args.is_ok()); + + let args = CommitArgs::try_parse_from(["commit", "--conventional", "-m", "init"]); + assert!(args.is_ok()); + + let args = CommitArgs::try_parse_from(["commit", "--conventional"]); + assert!(args.is_err(), "conventional should require message"); + + let args = CommitArgs::try_parse_from(["commit"]); + assert!(args.is_err(), "message is required"); + } #[tokio::test] async fn test_create_tree() { @@ -191,6 +221,7 @@ mod test { let args = CommitArgs { message: "init".to_string(), allow_empty: false, + conventional: false, }; execute(args).await; } @@ -203,6 +234,7 @@ mod test { let args = CommitArgs { message: "init".to_string(), allow_empty: true, + conventional: false, }; execute(args).await; @@ -219,7 +251,7 @@ mod test { let branch = Branch::find_branch(&branch_name, None).await.unwrap(); assert_eq!(branch.commit, commit.id); } - + // create a new commit { // create `a.txt` `bb/b.txt` `bb/c.txt` @@ -239,6 +271,7 @@ mod test { let args = CommitArgs { message: "add some files".to_string(), allow_empty: false, + conventional: false, }; execute(args).await; diff --git a/libra/src/command/log.rs b/libra/src/command/log.rs index 8524555ee..bb1531741 100644 --- a/libra/src/command/log.rs +++ b/libra/src/command/log.rs @@ -16,7 +16,7 @@ use std::str::FromStr; use mercury::hash::SHA1; use mercury::internal::object::commit::Commit; -use super::parse_commit_msg; +use common::utils::parse_commit_msg; #[derive(Parser, Debug)] pub struct LogArgs { /// Limit the number of output diff --git a/libra/src/command/mod.rs b/libra/src/command/mod.rs index a882c5dc7..a09bb9ef8 100644 --- a/libra/src/command/mod.rs +++ b/libra/src/command/mod.rs @@ -86,53 +86,6 @@ pub fn ask_basic_auth() -> BasicAuth { BasicAuth { username, password } } -/// Format commit message with GPG signature
-/// There must be a `blank line`(\n) before `message`, or remote unpack failed.
-/// If there is `GPG signature`, -/// `blank line` should be placed between `signature` and `message` -pub fn format_commit_msg(msg: &str, gpg_sig: Option<&str>) -> String { - match gpg_sig { - None => { - format!("\n{}", msg) - } - Some(gpg) => { - format!("{}\n\n{}", gpg, msg) - } - } -} - -/// parse commit message -pub fn parse_commit_msg(msg_gpg: &str) -> (String, Option) { - const GPG_SIG_START: &str = "gpgsig -----BEGIN PGP SIGNATURE-----"; - const GPG_SIG_END: &str = "-----END PGP SIGNATURE-----"; - let gpg_start = msg_gpg.find(GPG_SIG_START); - let gpg_end = msg_gpg.find(GPG_SIG_END).map(|end| end + GPG_SIG_END.len()); - let gpg_sig = match (gpg_start, gpg_end) { - (Some(start), Some(end)) => { - if start < end { - Some(msg_gpg[start..end].to_string()) - } else { - None - } - } - _ => None, - }; - match gpg_sig { - Some(gpg) => { - // Skip the leading '\n\n' (blank lines). - // Some commit messages may use '\n \n\n' or similar patterns. - // To handle such cases, remove all leading blank lines from the message. - let msg = msg_gpg[gpg_end.unwrap()..].trim_start().to_string(); - (msg, Some(gpg)) - } - None => { - assert!(msg_gpg.starts_with('\n'), "commit message format error"); - let msg = msg_gpg[1..].to_string(); // skip the leading '\n' (blank line) - (msg, None) - } - } -} - /// Calculate the hash of a file blob /// - for `lfs` file: calculate hash of the pointer data pub fn calc_file_blob_hash(path: impl AsRef) -> io::Result { @@ -174,6 +127,7 @@ pub async fn get_target_commit(branch_or_commit: &str) -> Result